iobroker.sayit
Version:
Text to speech interface for ioBroker.
720 lines (629 loc) • 28.8 kB
JavaScript
;
const path = require('node:path');
const fs = require('node:fs');
let jsftp;
let os;
let cp;
let axios;
class Speech2Device {
constructor(adapter, options) {
this.adapter = adapter;
this.options = options;
this.MP3FILE = options.MP3FILE;
this.vis1exist = null;
this.vis2exist = null;
// Google home
this.GHClient = null;
this.GHDefaultMediaReceiver = null;
}
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) {
cp = cp || require('node:child_process');
return new Promise((resolve, reject) => {
try {
const _cmd = `${cmd}${args && args.length ? ` ${args.join(' ')}` : ''}`;
this.adapter.log.debug(`Execute ${cmd} ${args && args.length ? args.join(' ') : ''}`);
const ls = cp.exec(_cmd, {cwd}, code => {
if (!code) {
resolve && resolve();
} else {
reject && reject(`Exit 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 && reject(e.toString());
reject = null;
resolve = null;
}
});
}
async spawn(cmd, args, ignoreErrorCode) {
cp = cp || require('node:child_process');
return new Promise((resolve, reject) => {
try {
this.adapter.log.debug(`Execute ${cmd} ${args.join(' ')}`);
const ls = cp.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 && resolve();
} else {
reject && reject(`Exit code: ${code}`);
}
reject = null;
resolve = null;
});
} catch (e) {
reject && reject(e.toString());
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 {
fs.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 (Speech2Device.isPlayFile(text)) {
fileName = path.normalize(text);
} else {
fileName = this.MP3FILE;
}
try {
fileData = fs.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(text, language, volume, duration, testOptions) {
const result = await this.uploadToStates(text);
const browserInstance = (testOptions && testOptions.browserInstance) || this.adapter.config.browserInstance;
const browserVis = testOptions?.browserVis === undefined ? this.adapter.config.browserVis : testOptions.browserVis;
// check if vis.0.control.command exists
if (!browserVis || browserVis === 1 || 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 || 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;
}
}
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 duration;
}
async getWebLink(testOptions) {
if (testOptions) {
const webServer = (testOptions && testOptions.webServer) || this.adapter.config.webServer;
const webInstance = (testOptions && testOptions.webInstance) || this.adapter.config.webInstance;
if (!this.options.webLink || webServer !== this.adapter.config.webServer || webInstance !== this.adapter.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(text, language, volume, duration, testOptions) {
volume = volume || this.options.sayLastVolume;
const result = await this.uploadToStates(text);
if (volume === 'null') {
volume = 0;
}
const webLink = await this.getWebLink(testOptions);
const fileName = `${volume ? `${volume};` : ''}${webLink}${result.fileInDB}`;
const sonosDevice = (testOptions && testOptions.sonosDevice) || this.adapter.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 duration;
}
async sayItHeos(text, language, volume, duration, testOptions) {
volume = volume || this.options.sayLastVolume;
const result = await this.uploadToStates(text);
if (volume === 'null') {
volume = 0;
}
const webLink = await this.getWebLink(testOptions);
const fileName = `${volume ? `${volume};` : ''}${webLink}${result.fileInDB}`;
const heosDevice = (testOptions && testOptions.heosDevice) || this.adapter.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 duration;
}
async sayItMpd(text, language, volume, duration, testOptions) {
volume = volume || this.options.sayLastVolume;
const result = await this.uploadToStates(text);
if (volume === 'null' || volume === 'undefined') {
volume = 0;
}
const webLink = await this.getWebLink(testOptions);
const mpdInstance = (testOptions && testOptions.mpdInstance) || this.adapter.config.mpdInstance;
const fileName = `${volume ? `${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 duration;
}
async sayItChromecast(text, language, volume, duration, testOptions) {
volume = volume || this.options.sayLastVolume;
const result = await this.uploadToStates(text);
if (volume === 'null') {
volume = 0;
}
const webLink = await this.getWebLink(testOptions);
const chromecastDevice = (testOptions && testOptions.chromecastDevice) || this.adapter.config.chromecastDevice;
//Create announcement JSON
const announcement = {
url: `${webLink}${result.fileInDB}`,
};
if (volume) {
announcement.volume = 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
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 > (duration+2)*2) {
clearInterval(intervalHandler);
intervalHandler = null;
this.adapter.log.error(`Error while checking if ${chromecastAnnouncementDev} finished playing announcement: ${announcementJSON}: TIMEOUT`);
reject('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}`);
clearInterval(intervalHandler);
intervalHandler = null;
resolve();
}
} catch (err) {
this.adapter.log.error(`Error while checking if ${chromecastAnnouncementDev} finished playing announcement: ${announcementJSON}: ${err}`);
reject(`Error by checking of announcement finished playing: ${err}`);
}
}, 500);
});
} else {
this.adapter.log.warn('Web server is unavailable!');
}
return duration;
}
launchGoogleHome(client, url) {
return new Promise((resolve, reject) =>
client.launch(this.GHDefaultMediaReceiver, (err, player) => {
if (!player) {
return reject('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(err);
} else {
resolve();
}
});
}));
}
async sendToGoogleHome(host, url, volume) {
const Client = this.GHClient;
const client = new 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(err) : _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(err);
});
});
}
async sayItGoogleHome(text, language, volume, duration, testOptions) {
if (!this.GHClient) {
const {Client, DefaultMediaReceiver} = require('castv2-client');
this.GHClient = Client;
this.GHDefaultMediaReceiver = DefaultMediaReceiver;
}
volume = volume || this.options.sayLastVolume;
const result = await this.uploadToStates(text);
if (volume === 'null' || volume === null) {
volume = 0;
}
const webLink = await this.getWebLink(testOptions);
const googleHomeServer = (testOptions && testOptions.googleHomeServer) || this.adapter.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, volume);
} else {
throw new Error('Web server is unavailable!');
}
return duration;
}
async sayItMP24(text, language, volume, duration, testOptions) {
axios = axios || require('axios');
const mp24Server = (testOptions && testOptions.mp24Server) || this.adapter.config.mp24Server;
if (mp24Server) {
if (!Speech2Device.isPlayFile(text)) {
// say
this.adapter.log.debug(`Request MediaPlayer24 "http://${mp24Server}:50000/tts=${encodeURI(text)}"`);
try {
const response = await axios.get(`http://${mp24Server}:50000/tts=${encodeURI(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.get(`http://${mp24Server}:50000/track=${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;
}
}
}
}
return duration + 2;
}
async sayItMP24ftp(text, language, volume, duration, testOptions) {
const result = await this.uploadToStates(text, true);
const mp24Server = (testOptions && testOptions.mp24Server) || this.adapter.config.mp24Server;
const ftpPort = (testOptions && testOptions.ftpPort) || this.adapter.config.ftpPort;
const ftpUser = (testOptions && testOptions.ftpUser) || this.adapter.config.ftpUser;
const ftpPassword = (testOptions && testOptions.ftpPassword) || this.adapter.config.ftpPassword;
// Copy mp3 file to an android device to play it later with MediaPlayer
if (ftpPort && mp24Server) {
jsftp = jsftp || require('jsftp');
axios = axios || require('axios');
const ftp = new jsftp({
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
err && this.adapter.log.error(err);
ftp.destroy();
if (!hadError) {
try {
const response = await axios.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(e);
}
}
} else {
reject(`FTP error:${hadError}`);
}
});
});
} catch (e) {
throw new Error(`Cannot upload file to ${mp24Server}:${ftpPort}`);
}
});
}
return duration + 2;
}
async sayItSystem(text, language, volume, duration, testOptions) {
os = os || require('node:os');
cp = cp || require('node:child_process');
const result = await this.uploadToStates(text, true);
const systemCommand = (testOptions && testOptions.systemCommand) || this.adapter.config.systemCommand;
const systemPlayer = (testOptions && testOptions.systemPlayer) || this.adapter.config.systemPlayer;
const p = os.platform();
let cmd;
if (volume !== undefined) {
await this.sayItSystemVolume(volume, testOptions);
}
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}`);
}
} 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}`);
}
} else if (p.match(/^win/)) {
// windows
try {
await this.exec('cmdmp3.exe', [`"${result.fileOnDisk}"`], path.normalize(`${__dirname}/../cmdmp3/`));
} catch (e) {
this.adapter.log.error(`Cannot play:${e}`);
}
} else if (p === 'darwin') {
// mac osx
try {
await this.exec('/usr/bin/afplay', [result.fileOnDisk]);
} catch (e) {
this.adapter.log.error(`Cannot play:${e}`);
}
}
}
if (text === this.adapter.config.announce) {
return duration;
} else {
return duration + 2;
}
}
async sayItWindows(text, language, volume, duration, testOptions) {
// If mp3 file
if (Speech2Device.isPlayFile(text)) {
return this.sayItSystem(text, language, volume, duration, testOptions);
}
os = os || require('node:os');
// Call windows own text 2 speech
const p = os.platform();
if (volume || volume === 0) {
await this.sayItSystemVolume(volume, testOptions);
}
if (p.match(/^win/)) {
// windows
try {
await this.exec(path.normalize(`${__dirname}/../say/SayStatic.exe`), [`"${text}"`]);
} catch (error) {
this.adapter.log.error(`sayItWindows: ${error}`);
}
} else {
this.adapter.log.error('sayItWindows: only windows OS is supported for Windows default mode');
}
return duration + 2;
}
async sayItSystemVolume(level, testOptions) {
if ((!level && level !== 0) || level === 'null') {
return;
}
level = parseInt(level);
if (level < 0) {
level = 0;
}
if (level > 100) {
level = 100;
}
if (level === this.options.sayLastVolume) {
return;
}
os = os || require('node:os');
cp = cp || require('node:child_process');
await this.adapter.setStateAsync('tts.volume', level, true);
this.options.sayLastVolume = level;
const p = os.platform();
const systemPlayer = (testOptions && testOptions.systemPlayer) || this.adapter.config.systemPlayer;
if (p === 'linux' && systemPlayer !== 'mpg321') {
// linux
try {
await this.spawn('amixer', ['cset', 'name="Master Playback Volume"', '--', `${level}%`]);
} catch (err) {
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(path.normalize(`${__dirname}/../nircmd/nircmdc.exe`), ['setsysvolume', level], true);
} catch (err) {
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 (err) {
this.adapter.log.error('osascript is not available, so you may hear no audio.');
}
}
};
async playFile(type, text, language, volume, duration, testOptions) {
type = testOptions ? testOptions.type || type : type;
if (type === 'browser') {
return await this.sayItBrowser(text, language, volume, duration, testOptions);
}
if (type === 'mp24ftp') {
return await this.sayItMP24ftp(text, language, volume, duration, testOptions);
}
if (type === 'mp24') {
return await this.sayItMP24(text, language, volume, duration, testOptions);
}
if (type === 'system') {
return await this.sayItSystem(text, language, volume, duration, testOptions);
}
if (type === 'windows') {
return await this.sayItWindows(text, language, volume, duration, testOptions);
}
if (type === 'sonos') {
return await this.sayItSonos(text, language, volume, duration, testOptions);
}
if (type === 'heos') {
return await this.sayItHeos(text, language, volume, duration, testOptions);
}
if (type === 'chromecast') {
return await this.sayItChromecast(text, language, volume, duration, testOptions);
}
if (type === 'mpd') {
return await this.sayItMpd(text, language, volume, duration, testOptions);
}
if (type === 'googleHome') {
return await this.sayItGoogleHome(text, language, volume, duration, testOptions);
}
this.adapter.log.error(`Unknown play type: ${type}`);
}
static sayItIsPlayFile = Speech2Device.isPlayFile;
}
module.exports = Speech2Device;