UNPKG

iobroker.sayit

Version:

Text to speech interface for ioBroker.

675 lines 28.7 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; var _a; Object.defineProperty(exports, "__esModule", { value: true }); const node_os_1 = require("node:os"); const node_path_1 = require("node:path"); const node_child_process_1 = require("node:child_process"); const node_fs_1 = require("node:fs"); const jsftp_1 = __importDefault(require("jsftp")); const axios_1 = __importDefault(require("axios")); // @ts-expect-error no types const castv2_client_1 = require("castv2-client"); class Speech2Device { adapter; options; MP3FILE; vis1exist = null; vis2exist = null; config; constructor(adapter, options) { this.adapter = adapter; this.options = options; this.MP3FILE = options.MP3FILE; this.config = adapter.config; } async #getFileInStates(fileName) { if (fileName.match(/^\/?[-_\w]+\.\d+\//)) { if (fileName.startsWith('/')) { fileName = fileName.substring(1); } // maybe it is "sayit.0/tts.userfiles/gong.mp3" const parts = fileName.split('/'); const id = parts[0]; parts.splice(0, 1); const file = parts.join('/'); let data; try { data = await this.adapter.readFileAsync(id, file); return data?.file; } catch (e) { this.adapter.log.warn(`Cannot read length of file ${fileName}: ${e}`); } } return null; } async #exec(cmd, args, cwd) { return new Promise((resolve, reject) => { try { const _cmd = `${cmd}${args?.length ? ` ${args.join(' ')}` : ''}`; this.adapter.log.debug(`Execute ${cmd} ${args?.length ? args.join(' ') : ''}`); const ls = (0, node_child_process_1.exec)(_cmd, { cwd }, code => { if (!code) { resolve?.(); } else { reject?.(new Error(`Exit code: ${code.code}`)); } reject = null; resolve = null; }); ls.on('error', e => this.adapter.log.error(`sayIt.play: there was an error while playing the file: ${e.toString()}`)); ls.stdout?.on('data', data => this.adapter.log.debug(`stdout: ${data}`)); ls.stderr?.on('data', data => this.adapter.log.error(`stderr: ${data}`)); } catch (e) { reject?.(e); reject = null; resolve = null; } }); } async #spawn(cmd, args, ignoreErrorCode) { return new Promise((resolve, reject) => { try { this.adapter.log.debug(`Execute ${cmd} ${args.join(' ')}`); const ls = (0, node_child_process_1.spawn)(cmd, args); ls.on('error', e => this.adapter.log.error(`sayIt.play: there was an error while playing the file: ${e.toString()}`)); ls.stdout.on('data', data => this.adapter.log.debug(`stdout: ${data}`)); ls.stderr.on('data', data => this.adapter.log.error(`stderr: ${data}`)); ls.on('close', code => { if (!code || ignoreErrorCode) { resolve?.(); } else { reject?.(new Error(`Exit code: ${code}`)); } reject = null; resolve = null; }); } catch (e) { reject?.(e); reject = null; resolve = null; } }); } // Function to fill the .tts.mp3 state async #uploadToStates(text, saveToDisk) { let fileData; let fileName; const data = await this.#getFileInStates(text); if (data) { if (saveToDisk) { try { (0, node_fs_1.writeFileSync)(this.MP3FILE, data); } catch (e) { throw new Error(`Cannot save file "${this.MP3FILE}" to disk: ${e.toString()}`); } } return { fileInDB: `${text.startsWith('/') ? text : `/${text}`}?ts=${Date.now()}`, fileOnDisk: saveToDisk ? this.MP3FILE : null, }; } else if (_a.isPlayFile(text)) { fileName = (0, node_path_1.normalize)(text); } else { fileName = this.MP3FILE; } try { fileData = (0, node_fs_1.readFileSync)(fileName); } catch (e) { throw new Error(`Cannot upload file "${fileName}" to state: ${e.toString()}`); } const file = `tts.${this.options.outFileExt}`; await this.adapter.writeFileAsync(this.adapter.namespace, `tts.${this.options.outFileExt}`, fileData); return { fileInDB: `/${this.adapter.namespace}/${file}?ts=${Date.now()}`, fileOnDisk: fileName, }; } static isPlayFile(text) { if (text.length > 4) { const ext = text.substring(text.length - 4).toLowerCase(); if (ext === '.mp3' || ext === '.wav' || ext === '.ogg') { return true; } } return false; } async #sayItBrowser(props) { const result = await this.#uploadToStates(props.text); const browserInstance = props.testOptions?.browserInstance || this.config.browserInstance; const browserVis = props.testOptions?.browserVis === undefined ? this.config.browserVis : props.testOptions.browserVis; // check if vis.0.control.command exists if (!browserVis || browserVis === '1') { try { if (this.vis1exist === null) { this.vis1exist = !!(await this.adapter.getForeignObjectAsync('vis.0.control.command')); if (!this.vis1exist) { this.adapter.log.error('Cannot control browser via vis1, because vis.0.* objects found'); } } if (this.vis1exist) { await this.adapter.setForeignStateAsync('vis.0.control.instance', browserInstance || '*'); await this.adapter.setForeignStateAsync('vis.0.control.data', result.fileInDB); await this.adapter.setForeignStateAsync('vis.0.control.command', 'playSound'); } } catch (e) { this.adapter.log.error(`Cannot control vis(1): ${e}`); this.vis1exist = false; } } if (!browserVis || browserVis === '2') { try { if (this.vis2exist === null) { this.vis2exist = !!(await this.adapter.getForeignObjectAsync('vis-2.0.control.command')); if (!this.vis2exist) { this.adapter.log.error('Cannot control browser via vis1, because vis-2.0.* objects found'); return false; } } if (this.vis2exist) { await this.adapter.setForeignStateAsync('vis-2.0.control.command', JSON.stringify({ command: 'playSound', data: result.fileInDB, instance: browserInstance || '*', })); } } catch (e) { this.adapter.log.error(`Cannot control vis(1): ${e}`); this.vis2exist = false; } } return !(!this.vis1exist && !this.vis2exist); } async #getWebLink(testOptions) { if (testOptions) { const webServer = testOptions?.webServer || this.config.webServer; const webInstance = testOptions?.webInstance || this.config.webInstance; if (!this.options.webLink || webServer !== this.config.webServer || webInstance !== this.config.webInstance) { const obj = await this.adapter.getForeignObjectAsync(`system.adapter.${testOptions.webInstance}`); return this.options.getWebLink(obj, webServer, webInstance); } } return this.options.webLink; } async #sayItSonos(props) { props.volume ||= this.options.sayLastVolume; const result = await this.#uploadToStates(props.text); const webLink = await this.#getWebLink(props.testOptions); const fileName = `${props.volume ? `${props.volume};` : ''}${webLink}${result.fileInDB}`; const sonosDevice = props.testOptions?.sonosDevice || this.config.sonosDevice; if (sonosDevice && webLink) { this.adapter.log.info(`Set "${sonosDevice}.tts: ${fileName}`); await this.adapter.setForeignStateAsync(`${sonosDevice}.tts`, fileName); } else if (webLink) { this.adapter.log.info(`Send to sonos ${fileName}`); this.adapter.sendTo('sonos', 'send', fileName); } else { this.adapter.log.warn('Web server is unavailable!'); return false; } return true; } async #sayItHeos(props) { props.volume ||= this.options.sayLastVolume; const result = await this.#uploadToStates(props.text); const webLink = await this.#getWebLink(props.testOptions); const fileName = `${props.volume ? `${props.volume};` : ''}${webLink}${result.fileInDB}`; const heosDevice = props.testOptions?.heosDevice || this.config.heosDevice; if (heosDevice && webLink) { this.adapter.log.info(`Set "${heosDevice}.tts: ${fileName}`); await this.adapter.setForeignStateAsync(`${heosDevice}.tts`, fileName); } else if (webLink) { this.adapter.log.info(`Send to heos ${fileName}`); this.adapter.sendTo('heos', 'send', fileName); } else { this.adapter.log.warn('Web server is unavailable!'); return false; } return true; } async #sayItMpd(props) { props.volume ||= this.options.sayLastVolume; const result = await this.#uploadToStates(props.text); const webLink = await this.#getWebLink(props.testOptions); const mpdInstance = props.testOptions?.mpdInstance || this.config.mpdInstance; const fileName = `${props.volume ? `${props.volume};` : ''}${webLink}${result.fileInDB}`; if (mpdInstance && webLink) { this.adapter.log.info(`Set "${mpdInstance}.say: ${fileName}`); await this.adapter.setForeignStateAsync(`${mpdInstance}.say`, fileName); } else if (webLink) { this.adapter.log.info(`Send to MPD ${fileName}`); this.adapter.sendTo('mpd', 'say', fileName); } else { this.adapter.log.warn('Web server is unavailable!'); return false; } return true; } async #sayItChromecast(props) { props.volume ||= this.options.sayLastVolume; const result = await this.#uploadToStates(props.text); const webLink = await this.#getWebLink(props.testOptions); const chromecastDevice = props.testOptions?.chromecastDevice || this.config.chromecastDevice; // Create announcement JSON const announcement = { url: `${webLink}${result.fileInDB}`, }; if (props.volume) { announcement.volume = props.volume; } const announcementJSON = JSON.stringify(announcement); if (chromecastDevice && webLink) { const chromecastAnnouncementDev = `${chromecastDevice}.player.announcement`; this.adapter.log.info(`Set "${chromecastAnnouncementDev} to ${announcementJSON}`); await this.adapter.setForeignStateAsync(chromecastAnnouncementDev, announcementJSON); // Check every 500 ms if the announcement has finished playing return await new Promise((resolve, reject) => { let count = 0; let intervalHandler = setInterval(async () => { count++; // We are checking every 500 ms, expected length of playback is defined in variable duration in seconds // Thus, we have to count to duration*2 to be able to wait long enough for the expected playback duration. // We add two additional seconds, to cover delays before the playback starts. if (count > (props.duration + 2) * 2) { if (intervalHandler) { clearInterval(intervalHandler); intervalHandler = null; } this.adapter.log.error(`Error while checking if ${chromecastAnnouncementDev} finished playing announcement: ${announcementJSON}: TIMEOUT`); reject(new Error('Timeout by checking of announcement finished playing')); return; } try { const state = await this.adapter.getForeignStateAsync(chromecastAnnouncementDev); if (state && state.ack) { this.adapter.log.debug(`${chromecastAnnouncementDev} finished playing announcement: ${announcementJSON}`); if (intervalHandler) { clearInterval(intervalHandler); intervalHandler = null; } resolve(true); } } catch (err) { this.adapter.log.error(`Error while checking if ${chromecastAnnouncementDev} finished playing announcement: ${announcementJSON}: ${err}`); reject(new Error(`Error by checking of announcement finished playing: ${err}`)); } }, 500); }); } this.adapter.log.warn('Web server is unavailable!'); return false; } #launchGoogleHome(client, url) { return new Promise((resolve, reject) => client.launch(castv2_client_1.DefaultMediaReceiver, (err, player) => { if (!player) { return reject(new Error('Player not available.')); } const media = { contentId: url, contentType: 'audio/mp3', streamType: 'BUFFERED', // or LIVE }; player.load(media, { autoplay: true }, (err) => { // , status client.close(); if (err) { reject(new Error(err.toString())); } else { resolve(); } }); })); } async #sendToGoogleHome(host, url, volume) { const client = new castv2_client_1.Client(); return new Promise((resolve, reject) => { client.connect(host, async () => { if (volume !== undefined) { try { await new Promise((_resolve, _reject) => client.setVolume({ level: volume / 100 }, (err) => err ? _reject(new Error(err.toString())) : _resolve())); } catch (err) { this.adapter.log.error(`there was an error setting the volume: ${err}`); } } await this.#launchGoogleHome(client, url); try { client.close(); } catch (err) { this.adapter.log.error(`there was an error closing the client: ${err}`); } resolve(); }); client.on('error', (err) => { client.close(); reject(new Error(err.toString())); }); }); } async #sayItGoogleHome(props) { props.volume ||= this.options.sayLastVolume; const result = await this.#uploadToStates(props.text); const webLink = await this.#getWebLink(props.testOptions); const googleHomeServer = props.testOptions?.googleHomeServer || this.config.googleHomeServer; if (googleHomeServer && webLink) { const url = `${webLink}${result.fileInDB}`; this.adapter.log.debug(`Send to google home "${googleHomeServer}": ${url}`); await this.#sendToGoogleHome(googleHomeServer, url, props.volume); } else { throw new Error('Web server is unavailable!'); } return true; } async #sayItMP24(props) { const mp24Server = props.testOptions?.mp24Server || this.config.mp24Server; if (mp24Server) { if (!_a.isPlayFile(props.text)) { // say this.adapter.log.debug(`Request MediaPlayer24 "http://${mp24Server}:50000/tts=${encodeURI(props.text)}"`); try { const response = await axios_1.default.get(`http://${mp24Server}:50000/tts=${encodeURI(props.text)}`); this.adapter.log.debug(`Response from MediaPlayer24 "${mp24Server}": ${response.data}`); } catch (e) { if (e.message === 'Parse Error') { this.adapter.log.debug('Played successfully'); } else { this.adapter.log.error(`Cannot say text on MediaPlayer24 "${mp24Server}": ${e.message}`); throw e; } } } else { // play local file try { const response = await axios_1.default.get(`http://${mp24Server}:50000/track=${props.text}`); this.adapter.log.debug(`Response from MediaPlayer24 "${mp24Server}": ${response.data}`); } catch (e) { if (e.message === 'Parse Error') { this.adapter.log.debug('Played successfully'); } else { this.adapter.log.error(`Cannot say text on MediaPlayer24 "${mp24Server}": ${e.message}`); throw e; } } } } props.duration += 2; return true; } async #sayItMP24ftp(props) { const result = await this.#uploadToStates(props.text, true); const mp24Server = props.testOptions?.mp24Server || this.config.mp24Server; const ftpPort = props.testOptions?.ftpPort || this.config.ftpPort; const ftpUser = props.testOptions?.ftpUser || this.config.ftpUser; const ftpPassword = props.testOptions?.ftpPassword || this.config.ftpPassword; // Copy mp3 file to an android device to play it later with MediaPlayer if (ftpPort && mp24Server && result.fileOnDisk) { const ftp = new jsftp_1.default({ host: mp24Server, port: parseInt(ftpPort, 10), // defaults to 21 user: ftpUser || 'anonymous', // defaults to 'anonymous' pass: ftpPassword || 'anonymous', // defaults to 'anonymous' }); await new Promise((resolve, reject) => { try { // Copy file to FTP server const fileNameOnFTP = `${this.adapter.namespace}.say.${this.options.outFileExt}`; ftp.put(result.fileOnDisk, fileNameOnFTP, hadError => { // send quit command ftp.raw('quit', async (err) => { // , data if (err) { this.adapter.log.error(err.toString()); } ftp.destroy(); if (!hadError) { try { const response = await axios_1.default.get(`http://${mp24Server}:50000/track=${fileNameOnFTP}`); this.adapter.log.debug(`Response from MediaPlayer24 "${mp24Server}": ${response.data}`); resolve(); } catch (e) { if (e.message === 'Parse Error') { this.adapter.log.debug('Played successfully'); resolve(); } else { this.adapter.log.error(`Cannot say text on MediaPlayer24 "${mp24Server}": ${e.message}`); reject(new Error(e.toString())); } } } else { reject(new Error(`FTP error: ${hadError}`)); } }); }); } catch { throw new Error(`Cannot upload file to ${mp24Server}:${ftpPort}`); } }); } props.duration += 2; return true; } async #sayItSystem(props) { const result = await this.#uploadToStates(props.text, true); const systemCommand = props.testOptions?.systemCommand || this.config.systemCommand; const systemPlayer = props.testOptions?.systemPlayer || this.config.systemPlayer; const p = (0, node_os_1.platform)(); let cmd; if (props.volume !== undefined) { await this.sayItSystemVolume(props.volume, props.testOptions); } if (result.fileOnDisk) { if (systemCommand) { // custom command if (systemCommand.includes('%s')) { cmd = systemCommand.replace('%s', result.fileOnDisk); } else { if (p.match(/^win/)) { cmd = `${systemCommand} "${result.fileOnDisk}"`; } else { cmd = `${systemCommand} ${result.fileOnDisk}`; } } try { await this.#exec(cmd); } catch (e) { this.adapter.log.error(`Cannot play: ${e}`); return false; } } else if (p === 'linux') { // linux if (systemPlayer === 'omxplayer') { cmd = `omxplayer -o local ${result.fileOnDisk}`; } else if (systemPlayer === 'mpg321') { cmd = `mpg321 -g ${this.options.sayLastVolume} ${result.fileOnDisk}`; } else { cmd = `mplayer ${result.fileOnDisk} -volume ${this.options.sayLastVolume}`; } try { await this.#exec(cmd); } catch (e) { this.adapter.log.error(`Cannot play: ${e}`); return false; } } else if (p.match(/^win/)) { // windows try { await this.#exec('cmdmp3.exe', [`"${result.fileOnDisk}"`], (0, node_path_1.normalize)(`${__dirname}/../cmdmp3/`)); } catch (e) { this.adapter.log.error(`Cannot play:${e}`); return false; } } else if (p === 'darwin') { // mac osx try { await this.#exec('/usr/bin/afplay', [result.fileOnDisk]); } catch (e) { this.adapter.log.error(`Cannot play:${e}`); return false; } } } if (props.text !== this.config.announce) { props.duration += 2; } return true; } async #sayItWindows(props) { // If mp3 file if (_a.isPlayFile(props.text)) { return this.#sayItSystem(props); } // Call windows own text 2 speech const p = (0, node_os_1.platform)(); if (props.volume || props.volume === 0) { await this.sayItSystemVolume(props.volume, props.testOptions); } if (p.match(/^win/)) { // windows try { await this.#exec((0, node_path_1.normalize)(`${__dirname}/../say/SayStatic.exe`), [`"${props.text}"`]); } catch (error) { this.adapter.log.error(`sayItWindows: ${error}`); return false; } } else { this.adapter.log.error('sayItWindows: only windows OS is supported for Windows default mode'); return false; } props.duration += 2; return true; } async sayItSystemVolume(level, testOptions) { if (!level && level !== 0) { return; } level = parseInt(level, 10); if (level < 0) { level = 0; } if (level > 100) { level = 100; } if (level === this.options.sayLastVolume) { return; } await this.adapter.setStateAsync('tts.volume', level, true); this.options.sayLastVolume = level; const p = (0, node_os_1.platform)(); const systemPlayer = testOptions?.systemPlayer || this.config.systemPlayer; if (p === 'linux' && systemPlayer !== 'mpg321') { // linux try { await this.#spawn('amixer', ['cset', 'name="Master Playback Volume"', '--', `${level}%`]); } catch { this.adapter.log.error('amixer is not available, so you may hear no audio. Install manually!'); } } else if (p.match(/^win/)) { // windows // windows volume is from 0 to 65535 level = Math.round((65535 * level) / 100); // because this level is from 0 to 100 try { await this.#spawn((0, node_path_1.normalize)(`${__dirname}/../nircmd/nircmdc.exe`), ['setsysvolume', level.toString()], true); } catch { this.adapter.log.error('nircmd is not available, so you may hear no audio.'); } } else if (p === 'darwin') { // mac osx try { await this.#spawn('sudo', ['osascript', '-e', `"set Volume ${Math.round(level / 10)}"`]); } catch { this.adapter.log.error('osascript is not available, so you may hear no audio.'); } } } async playFile(props) { const type = props.testOptions?.type || props.type; if (type === 'browser') { return await this.#sayItBrowser(props); } if (type === 'mp24ftp') { return await this.#sayItMP24ftp(props); } if (type === 'mp24') { return await this.#sayItMP24(props); } if (type === 'system') { return await this.#sayItSystem(props); } if (type === 'windows') { return await this.#sayItWindows(props); } if (type === 'sonos') { return await this.#sayItSonos(props); } if (type === 'heos') { return await this.#sayItHeos(props); } if (type === 'chromecast') { return await this.#sayItChromecast(props); } if (type === 'mpd') { return await this.#sayItMpd(props); } if (type === 'googleHome') { return await this.#sayItGoogleHome(props); } this.adapter.log.error(`Unknown play type: ${type}`); return false; } static sayItIsPlayFile = _a.isPlayFile; } _a = Speech2Device; exports.default = Speech2Device; //# sourceMappingURL=speech2device.js.map