UNPKG

node-red-contrib-tts-ultimate

Version:

Transforms the text in speech and hear it using Sonos, Google Cast (Chromecast / Nest) or DLNA/UPnP players, or generate an audio file to be used with third parties nodes. Works with voices from Google (without credentials as well), Google TTS, ElevenLabs

1,048 lines (967 loc) 71.5 kB
const { Readable } = require('stream'); module.exports = function (RED) { 'use strict'; var fs = require('fs'); var path = require('path'); const sonos = require('sonos'); const crypto = require("crypto"); const VOICEAI_API_BASE_URL = "https://dev.voice.ai/api/v1/tts"; function slug(_text) { var sRet = _text; sRet = sRet.toString().replace(/\./g, "_stop_"); sRet = sRet.toString().replace(/\?/g, "_qm_"); sRet = sRet.toString().replace(/\!/g, "_em_"); sRet = sRet.toString().replace(/\,/g, "_pause_"); sRet = sRet.toString().replace(/\:/g, "_colon_"); sRet = sRet.toString().replace(/\;/g, "_semicolon_"); sRet = sRet.toString().replace(/\</g, "_less_"); sRet = sRet.toString().replace(/\>/g, "_greater_"); sRet = sRet.toString().replace(/\//g, "_sl_"); sRet = sRet.toString().replace(/\'/g, "_ap_"); sRet = sRet.toString().replace(/\=/g, "_ug_"); sRet = sRet.toString().replace(/\\/g, "_bs_"); sRet = sRet.toString().replace(/\(/g, "_pa_"); sRet = sRet.toString().replace(/\)/g, "_pc_"); sRet = sRet.toString().replace(/\*/g, "_as_"); sRet = sRet.toString().replace(/\[/g, "_qa_"); sRet = sRet.toString().replace(/\]/g, "_qc_"); sRet = sRet.toString().replace(/\^/g, "_fu_"); sRet = sRet.toString().replace(/\|/g, "_pi_"); sRet = sRet.toString().replace(/\"/g, "_dc_"); sRet = sRet.toString().replace(/\#/g, "_"); // slug.charmap['.'] = '_stop_'; // slug.charmap['?'] = '_qm_'; // slug.charmap['!'] = '_em_'; // slug.charmap[','] = '_pause_'; // slug.charmap[':'] = '_colon_'; // slug.charmap[';'] = '_semicolon_'; // slug.charmap['<'] = '_less_'; // slug.charmap['>'] = '_greater_'; return sRet; } // Node Register function PollyNode(config) { RED.nodes.createNode(this, config); var node = this; node.server = RED.nodes.getNode(config.config); if (!node.server) { RED.log.error('Missing Polly config'); return; } node.ssml = config.ssml; node.oTimerSonosConnectionCheck = null; node.sSonosIPAddress = ""; node.sonosCoordinatorGroupName = ""; node.sonoshailing = "0"; // Hailing file node.sSonosIPAddress = config.sonosipaddress.trim(); node.voiceId = config.voice || 0; node.sSonosVolume = config.sonosvolume; node.sonoshailing = config.sonoshailing; node.msg = {}; // 08/05/2019 Node message node.msg.completed = true; node.msg.connectionerror = true; node.userDir = node.server.TTSRootFolderPath === undefined ? path.join(RED.settings.userDir, "sonospollyttsstorage") : node.server.TTSRootFolderPath; node.ttsCacheFolder = node.server.ttsCacheFolder === undefined ? path.join(node.userDir, "ttsfiles") : node.server.ttsCacheFolder; node.legacyTtsCacheFolder = path.join(node.userDir, "ttsfiles"); node.oAdditionalSonosPlayers = []; // 20/03/2020 Contains other players to be grouped node.rules = config.rules || [{}]; node.sNoderedURL = ""; node.oTimerCacheFlowMSG = null; // 05/12/2020 node.tempMSGStorage = []; // 04/12/2020 Temporary stores the flow messages node.bBusyPlayingQueue = false; // 04/12/2020 is busy during playing of the queue node.currentMSGbeingSpoken = {}; // Stores the current message being spoken node.sonosCoordinatorPreviousVolumeSetByApp = 0; // 05/07/2021 stores the main payer volume set by the sonos app node.playertype = config.playertype === undefined ? "sonos" : config.playertype; // 20/09/2021 Player type node.playeripaddress = (config.playeripaddress || "").trim(); // 06/2026 Chromecast/Nest IP or DLNA/UPnP renderer description XML URL node.playervolume = config.playervolume === undefined ? "50" : config.playervolume; // 06/2026 Volume (0-100) for Google Cast / DLNA players node.speakingpitch = config.speakingpitch === undefined ? "0" : config.speakingpitch; // 21/09/2021 AudioConfig speakingpitch node.speakingrate = config.speakingrate === undefined ? "1" : config.speakingrate; // 21/09/2021 AudioConfig speakingrate node.unmuteIfMuted = config.unmuteIfMuted === undefined ? false : config.unmuteIfMuted; // 21/10/2021 Unmute if previiously muted. node.sonosCoordinatorIsPreviouslyMuted = false; node.passThroughMessage = {}; node.bTimeOutPlay = false; node.doNotResumeMusic = config.doNotResumeMusic === undefined ? false : config.doNotResumeMusic; // 06/2025 Do not resume previous music after TTS, if playing. if (typeof node.server !== "undefined" && node.server !== null) { node.sNoderedURL = node.server.sNoderedURL || ""; } // 20/11/2019 Used to call the status update node.setNodeStatus = ({ fill, shape, text }) => { try { var dDate = new Date(); node.status({ fill: fill, shape: shape, text: text + " (" + dDate.getDate() + ", " + dDate.toLocaleTimeString() + ")" }); } catch (error) { } } //#region ASYNC DECLARATIONS // 30/12/2020 we are at the end of this crazy 2020 function getMusicQueue(_oPlayer = node.SonosClient) { return new Promise(function (resolve, reject) { var oRet = null; _oPlayer.currentTrack().then(track => { oRet = track;// .queuePosition || 1; // Get the current track in the queue. _oPlayer.getCurrentState().then(state => { // A music queue is playing and no TTS is speaking? oRet.state = state; resolve(oRet); }).catch(err => { //console.log('ttsultimate: getCurrentState: Error occurred %j', err); reject(err); }) }).catch(err => { reject(err); //console.log('ttsultimate: Error currentTrackoccurred %j', err); }); }); }; let iWaitAfterSync = 100; // 24/08/2021 Sync wrapper function PLAYSync(_toPlay, _oPlayer = node.SonosClient) { return new Promise((resolve, reject) => { _oPlayer.play(_toPlay).then(result => { if (iWaitAfterSync > 2000) console.log("PLAYSYNC") let t = setTimeout(() => { resolve(true); }, iWaitAfterSync); }).catch(err => { RED.log.error("ttsultimate: Error PLAYSync: " + err.message); reject(err); }); }); } // 24/08/2021 Sync wrapper function SEEKSync(_Position, _oPlayer = node.SonosClient) { return new Promise((resolve, reject) => { _oPlayer.seek(_Position).then(result => { if (iWaitAfterSync > 2000) console.log("SEEKSync", _Position) let t = setTimeout(() => { resolve(true); }, iWaitAfterSync); }).catch(err => { //RED.log.error("ttsultimate: Error SEEKSync: " + err.message); reject(err); }); }); } // 24/08/2021 Sync wrapper function SELECTQUEUESync(_oPlayer = node.SonosClient) { return new Promise((resolve, reject) => { _oPlayer.selectQueue().then(result => { if (iWaitAfterSync > 2000) console.log("SELECTQUEUESync") try { STOPSync(); // The SetQueue automatically starts playing, so i need to stop it now! } catch (error) { } let t = setTimeout(() => { resolve(true); }, iWaitAfterSync); }).catch(err => { RED.log.error("ttsultimate: Error SELECTQUEUESync: " + err.message); reject(err); }); }); } // 24/08/2021 Sync wrapper function SELECTTRACKSync(_queuePosition, _oPlayer = node.SonosClient) { return new Promise((resolve, reject) => { _oPlayer.selectTrack(_queuePosition).then(result => { if (iWaitAfterSync > 2000) console.log("SELECTTRACKSync", _queuePosition) let t = setTimeout(() => { resolve(true); }, iWaitAfterSync); }).catch(err => { RED.log.error("ttsultimate: Error SELECTTRACKSync: " + err.message); reject(err); }); }); } // 24/08/2021 Sync wrapper function STOPSync(_oPlayer = node.SonosClient) { return new Promise((resolve, reject) => { _oPlayer.stop().then(result => { if (iWaitAfterSync > 2000) console.log("STOPSync") let t = setTimeout(() => { resolve(true); }, iWaitAfterSync); }).catch(err => { //RED.log.error("ttsultimate: Error STOPSync: " + err.message); reject(err); }); }); } // 24/08/2021 Sync wrapper function GETVOLUMESync(_oPlayer = node.SonosClient) { return new Promise((resolve, reject) => { _oPlayer.getVolume().then(volume => { if (iWaitAfterSync > 2000) console.log("GETVOLUMESync", volume) resolve(volume); }).catch(err => { RED.log.error("ttsultimate: Error GETVOLUMESync: " + err.message); reject(err); }); }); } // 24/08/2021 Sync wrapper function SETVOLUMESync(_volume, _oPlayer = node.SonosClient) { return new Promise((resolve, reject) => { _oPlayer.setVolume(_volume).then(result => { if (iWaitAfterSync > 2000) console.log("SETVOLUMESync", _volume) resolve(true); }).catch(err => { RED.log.error("ttsultimate: Error SETVOLUMESync: " + err.message); reject(err); }); }); } // 24/08/2021 Sync wrapper function setAVTransportURISync(_Uri, _oPlayer = node.SonosClient) { return new Promise((resolve, reject) => { _oPlayer.setAVTransportURI(_Uri).then(volume => { if (iWaitAfterSync > 2000) console.log("setAVTransportURISync", _Uri) resolve(true); }).catch(err => { RED.log.error("ttsultimate: Error setAVTransportURISync: " + err.message); reject(err); }); }); } // 24/08/2021 Sync wrapper function getCurrentStateSync(_oPlayer = node.SonosClient) { return new Promise((resolve, reject) => { _oPlayer.getCurrentState().then(state => { resolve(state); }).catch(err => { RED.log.error("ttsultimate: Error getCurrentStateSync: " + err.message); reject(err); }); }); } // 21/10/2021 Sync wrapper function GETMutedSync(_oPlayer = node.SonosClient) { return new Promise((resolve, reject) => { _oPlayer.getMuted().then(state => { resolve(state); }).catch(err => { RED.log.error("ttsultimate: Error GETMutedSync: " + err.message); reject(err); }); }); } // 21/10/2021 Sync wrapper function SETMutedSync(_muted, _oPlayer = node.SonosClient) { return new Promise((resolve, reject) => { _oPlayer.setMuted(_muted).then(state => { resolve(state); }).catch(err => { RED.log.error("ttsultimate: Error SETMutedSync: " + err.message); reject(err); }); }); } // 20/03/2020 Join Coordinator queue // ###################################################### async function groupSpeakersSync() { // 05/07/2021 Get the main coordinator player previous volume set by app try { node.sonosCoordinatorPreviousVolumeSetByApp = await GETVOLUMESync(); node.sonosCoordinatorIsPreviouslyMuted = await GETMutedSync(); } catch (error) { node.sonosCoordinatorPreviousVolumeSetByApp = node.sSonosVolume; node.sonosCoordinatorIsPreviouslyMuted = false; } // 30/03/2020 in the middle of coronavirus emergency. Group Speakers for (let index = 0; index < node.oAdditionalSonosPlayers.length; index++) { let element = node.oAdditionalSonosPlayers[index].oPlayer; // 02/07/2021 Get the additional's player's volume set by app and the current track, to be set again in ungroupspealers try { element.additionalPlayerPreviousVolumeSetByApp = await element.getVolume(); element.additionalPlayerCurrentTrack = await getMusicQueue(element); } catch (error) { RED.log.warn("ttsultimate: Error setting volume of joined device " + error.message); } // 21/10/2021 set the previous mute/unmute state try { element.isPreviouslyMuted = await element.getMuted(); } catch (error) { RED.log.warn("ttsultimate: Error getMuted of joined device " + error.message); } try { await element.joinGroup(node.sonosCoordinatorGroupName); } catch (error) { RED.log.warn("ttsultimate: Error joining device " + error.message); } }; } // 20/03/2020 Ungroup Coordinator queue async function ungroupSpeakersSync() { // 05/07/2021, set the previous app volume for main coordinator player try { await SETVOLUMESync(node.sonosCoordinatorPreviousVolumeSetByApp); } catch (error) { RED.log.warn("ttsultimate: Error set preious volume on main coordinator in ungroupSpeakers " + error.message); } // 21/10/2021 Unmute? if (node.unmuteIfMuted && node.sonosCoordinatorIsPreviouslyMuted) { try { await SETMutedSync(true); } catch (error) { RED.log.warn("ttsultimate: Error set preivous mute state on main coordinator in ungroupSpeakers " + error.message); } } for (let index = 0; index < node.oAdditionalSonosPlayers.length; index++) { let element = node.oAdditionalSonosPlayers[index].oPlayer; try { await element.leaveGroup(); } catch (error) { // Dont care RED.log.warn("ttsultimate: Error leaving group device " + error.message); } if (element.additionalPlayerPreviousVolumeSetByApp !== undefined) { try { await element.setVolume(element.additionalPlayerPreviousVolumeSetByApp); } catch (error) { RED.log.warn("ttsultimate: Error set previous volume on group device " + error.message); } } // 21/10/2021 Unmute? if (node.unmuteIfMuted && element.isPreviouslyMuted) { try { await element.setMuted(true); } catch (error) { RED.log.warn("ttsultimate: Error set previous mute state on group device " + error.message); } } } } // ###################################################### async function delay(ms) { return new Promise(function (resolve, reject) { try { node.timerWait = setTimeout(resolve, ms); } catch (error) { reject(); } }); } //#endregion // 27/11/2019 Check Sonos connection health node.CheckSonosConnection = () => { if (node.playertype === "sonos") { node.SonosClient.getCurrentState().then(state => { // 11/12/202020 The connection with Sonos is OK. if (node.msg.connectionerror == true) { node.flushQueue(); node.setNodeStatus({ fill: "green", shape: "dot", text: "Sonos is connected." }); node.msg.connectionerror = false; node.send([null, { payload: node.msg.connectionerror }]); } node.oTimerSonosConnectionCheck = setTimeout(function () { node.CheckSonosConnection(); }, 5000); }).catch(err => { node.setNodeStatus({ fill: "red", shape: "dot", text: "Sonos connection is DOWN: " + err.message }); node.flushQueue(); // 11/12/2020 Set node output to signal connectio error if (node.msg.connectionerror == false) { node.msg.connectionerror = true; node.send([null, { payload: node.msg.connectionerror }]); } node.oTimerSonosConnectionCheck = setTimeout(function () { node.CheckSonosConnection(); }, 10000); }); } else { node.setNodeStatus({ fill: "green", shape: "dot", text: "Ready." }); node.msg.connectionerror = false; node.send([null, { payload: node.msg.connectionerror }]); } } // 20/09/2021 If Sonos, do init if (node.playertype === "sonos") { // Create sonos client & groups node.SonosClient = new sonos.Sonos(node.sSonosIPAddress); // 20/03/2020 Set the coorinator's zone name node.SonosClient.getName().then(info => { node.sonosCoordinatorGroupName = info; RED.log.info("ttsultimate: ZONE COORDINATOR " + JSON.stringify(info)); }).catch(err => { }); // Fill the node.oAdditionalSonosPlayers with all sonos object in the rules for (let index = 0; index < node.rules.length; index++) { let element = node.rules[index]; // Rule row is {host:"192.168.1.12,hostVolumeAdjust:0} // 12/04/2022 Create an object containing the addidtional player and the adapted volume node.oAdditionalSonosPlayers.push({ oPlayer: new sonos.Sonos(element.host), hostVolumeAdjust: Number(element.hostVolumeAdjust) }); RED.log.info("ttsultimate: FOUND ADDITIONAL PLAYER " + element.host + " Adjusted volume: " + element.hostVolumeAdjust); } // 27/11/2019 Start the connection healty check node.oTimerSonosConnectionCheck = setTimeout(function () { node.CheckSonosConnection(); }, 5000); } else { // 06/2026 noplayer, googlecast and dlna do not use the Sonos connection healthcheck. node.msg.connectionerror = false; } node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Initialized.' }); // 22/09/2020 Flush Queue and set to stopped node.flushQueue = () => { // 10/04/2018 Remove the TTS message from the queue node.tempMSGStorage = []; // Exit whatever cycle node.bTimeOutPlay = true; node.bBusyPlayingQueue = false; node.currentMSGbeingSpoken = {}; if (node.server.whoIsUsingTheServer === node.id) node.server.whoIsUsingTheServer = ""; } // 30/12/2020 Supergiovane resume queue for radio, queue music, TV in , line in etc. async function resumeMusicQueue(_oTrack, _oPlayer = node.SonosClient) { if (_oTrack !== null) { // Do some checks on the track. if (_oTrack.hasOwnProperty("duration") && _oTrack.duration === 0 || (_oTrack.uri.startsWith("x-sonosprog-http") || _oTrack.uri.startsWith("x-sonosapi-hls-static") || _oTrack.uri.startsWith("x-sonos-spotify"))) { // Stream _oTrack.trackType = "stream"; } else if (_oTrack.hasOwnProperty("duration") && isNaN(_oTrack.duration)) { // Line input _oTrack.trackType = "lineinput"; } else { // Music queue _oTrack.trackType = "musicqueue"; } } else { // Track is null, nothing to resume. return false; } // It's a radio station or a generic stream, not a queue. if (_oTrack.trackType === "stream") { if (_oTrack.state === "playing") { // 03/09/2021 Play if it was playing try { await PLAYSync(_oTrack.uri, _oPlayer); } catch (error) { return error; } try { await delay(1000); await SEEKSync(_oTrack.position, _oPlayer); } catch (error) { // Don't care } } } else { if (_oTrack.trackType === "musicqueue") { // This indicates that is an audio file or stream station try { await SELECTQUEUESync(_oPlayer); } catch (error) { return error; } try { await delay(1000); await SELECTTRACKSync(_oTrack.queuePosition, _oPlayer); } catch (error) { return error; } try { await delay(1000); await SEEKSync(_oTrack.position, _oPlayer); } catch (error) { // Don't care } if (_oTrack.state === "playing") { // 24/08/2021 Play if it was playing try { await PLAYSync(_oPlayer); } catch (error) { return error; } } else { /// 03/09/2021 try { await STOPSync(_oPlayer); } catch (error) { return error; } } } else if (_oTrack.trackType === "lineinput") { // Line in, TV in, etc... if (_oTrack.state === "playing") { try { await setAVTransportURISync(_oTrack.uri, _oPlayer); } catch (error) { return error; } } } } let t = setTimeout(() => { return true; }, 5000); // Wait some seconds }; // Handle the queue async function HandleQueue() { node.bBusyPlayingQueue = true; node.server.whoIsUsingTheServer = node.id; // Signal to other ttsultimate node, that i'm using the Sonos device try { if (node.playertype === "sonos") { // Get the current music queue, if one var oCurTrack = null; try { oCurTrack = await getMusicQueue(); // 19/04/2022 The current track of additional players is read in the groupSpeakerySync function } catch (error) { oCurTrack = null; } // 05/12/2020 Set "completed" to false and send it node.msg.completed = false; try { await groupSpeakersSync(); // 20/03/2020 Group Speakers toghether and reads each current track } catch (error) { // Don't care. node.setNodeStatus({ fill: "red", shape: "ring", text: "Error grouping speakers: " + error.message }); RED.log.error("ttsultimate: Error grouping speakers: " + error.message); } node.send([{ passThroughMessage: node.passThroughMessage, payload: node.msg.completed }, null]); // 24/08/2021 If something was playing, stop the player https://github.com/Supergiovane/node-red-contrib-tts-ultimate/issues/32 try { //await node.SonosClient.stop(); //.then(result => { await STOPSync(); } catch (error) { //RED.log.error("ttsultimate: Error stopping in HandleSend: " + error.message); } } else { node.msg.completed = false; node.send([{ passThroughMessage: node.passThroughMessage, payload: node.msg.completed }, null]); } while (node.tempMSGStorage.length > 0) { node.currentMSGbeingSpoken = node.tempMSGStorage.shift()//node.tempMSGStorage[0];// Advise the whole node of the currently spoken MSG const msg = node.currentMSGbeingSpoken.payload.toString(); // Get the text to be spoken //node.tempMSGStorage.splice(0, 1); // Remove the first item in the array node.sFileToBePlayed = ""; let audioSource = "unknown"; // internet|cache|local|http|unknown const resolveCachePath = (cacheFilename) => { const primaryPath = path.join(node.ttsCacheFolder, cacheFilename); const legacyPath = path.join(node.legacyTtsCacheFolder, cacheFilename); if (fs.existsSync(primaryPath)) return { isCached: true, resolvedPath: primaryPath }; if (fs.existsSync(legacyPath)) return { isCached: true, resolvedPath: legacyPath }; return { isCached: false, resolvedPath: primaryPath }; }; const formatAudioSourceTag = (source) => { switch (source) { case "internet": return "[CLOUD] "; case "cache": return "[CACHE] "; case "local": return "[LOCAL] "; case "http": return "[HTTP] "; default: return ""; } }; node.setNodeStatus({ fill: "gray", shape: "ring", text: "Read " + msg }); // 04/12/2020 check what really is the file to be played if (msg.toLowerCase().startsWith("http://") || msg.toLowerCase().startsWith("https://")) { RED.log.info('ttsultimate: HTTP filename: ' + msg); audioSource = "http"; node.sFileToBePlayed = msg; } else if (msg.indexOf("OwnFile_") !== -1) { RED.log.info('ttsultimate: OwnFile .MP3, skip tts, filename: ' + msg); audioSource = "local"; node.sFileToBePlayed = path.join(node.userDir, "ttspermanentfiles", msg); } else if (msg.indexOf("Hailing_") !== -1) { RED.log.info('ttsultimate: Hailing .MP3, skip tts, filename: ' + msg); audioSource = "local"; node.sFileToBePlayed = path.join(node.userDir, "hailingpermanentfiles", msg); } else { try { // No file in cache. Download from tts service var data = undefined; if (node.server.ttsservice === "polly") { var params = { OutputFormat: "mp3", SampleRate: '22050', Text: msg, TextType: node.ssml ? 'ssml' : 'text' }; // 02/03/2022 check wether standard or neural engine is POLLY is selected if (node.voiceId.includes("#engineType:")) { params.VoiceId = node.voiceId.split("#engineType:")[0]; params.Engine = node.voiceId.split("#engineType:")[1]; } else { params.VoiceId = node.voiceId; } // Download or read from cache const cacheFilename = getFilename(msg, params); const { isCached, resolvedPath } = resolveCachePath(cacheFilename); node.sFileToBePlayed = resolvedPath; audioSource = isCached ? "cache" : "internet"; if (!isCached) { node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice }); data = await synthesizeSpeechPolly([node.server.polly, params]); } else { node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Reading offline from cache' }); } } else if (node.server.ttsservice === "googletts") { // VoiceId is: name + "#" + languageCode + "#" + ssmlGender // speakingRate tra 0.25 e 4.0 // pitch tra -20.0 e 20.0 const params = { voice: { name: node.voiceId.split("#")[0], languageCode: node.voiceId.split("#")[1], ssmlGender: node.voiceId.split("#")[2] }, audioConfig: { audioEncoding: "MP3", speakingRate: parseFloat(node.speakingrate), pitch: parseFloat(node.speakingpitch), }, }; params.input = node.ssml === false ? { text: msg } : { ssml: msg }; // Download or read from cache const cacheFilename = getFilename(msg, params); const { isCached, resolvedPath } = resolveCachePath(cacheFilename); node.sFileToBePlayed = resolvedPath; audioSource = isCached ? "cache" : "internet"; if (!isCached) { node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice }); data = await synthesizeSpeechGoogleTTS([node.server.googleTTS, params]); } else { node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Reading offline from cache' }); } } else if (node.server.ttsservice === "googletranslate") { // VoiceId is: code. SSML is not supported by google translate if (node.voiceId === "cmn-Hant-TW") node.voiceId = "zh-CN"; // 06/08/2022 fix for a wrong voiceid sent by google translate as voice code const params = { text: msg, voice: node.voiceId, slow: false // optional }; // Download or read from cache const cacheFilename = getFilename(msg, params); const { isCached, resolvedPath } = resolveCachePath(cacheFilename); node.sFileToBePlayed = resolvedPath; audioSource = isCached ? "cache" : "internet"; if (!isCached) { node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice }); data = await synthesizeSpeechGoogleTranslate(node.server.googleTranslateTTS, params); } else { node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Reading offline from cache' }); } } else if (node.server.ttsservice === "voiceai") { const apiKey = node.server.credentials.voiceaiKey; if (!apiKey || apiKey.trim() === "") throw new Error("Voice.ai API key missing"); const params = { text: msg, audio_format: "mp3" }; if (node.voiceId && node.voiceId !== "" && node.voiceId !== "voiceai_default") { params.voice_id = node.voiceId; } const cacheFilename = getFilename(msg, params); const { isCached, resolvedPath } = resolveCachePath(cacheFilename); node.sFileToBePlayed = resolvedPath; audioSource = isCached ? "cache" : "internet"; if (!isCached) { node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice }); data = await synthesizeSpeechVoiceAi(apiKey, params); } else { node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Reading offline from cache' }); } } else if (node.server.ttsservice === "microsoftazuretts") { // VoiceId is: code const params = { text: msg, voice: node.voiceId }; // Download or read from cache const cacheFilename = getFilename(msg, params); const { isCached, resolvedPath } = resolveCachePath(cacheFilename); node.sFileToBePlayed = resolvedPath; audioSource = isCached ? "cache" : "internet"; if (!isCached) { node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice }); data = await synthesizeSpeechMicrosoftAzureTTS(node.server.microsoftAzureTTS, params); } else { node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Reading offline from cache' }); } } else if (node.server.ttsservice === "elevenlabs") { // VoiceId is: code const stability = config.elevenlabsStability !== undefined && config.elevenlabsStability !== "" ? Number(config.elevenlabsStability) : undefined; const similarity = config.elevenlabsSimilarity_boost !== undefined && config.elevenlabsSimilarity_boost !== "" ? Number(config.elevenlabsSimilarity_boost) : undefined; const resolvedModel = config.elevenlabsModel && config.elevenlabsModel !== "" ? config.elevenlabsModel : "eleven_monolingual_v1"; const params = { text: msg, voice: node.voiceId, model_id: resolvedModel, voice_settings: {} }; if (stability !== undefined && !Number.isNaN(stability)) params.voice_settings.stability = stability; if (similarity !== undefined && !Number.isNaN(similarity)) params.voice_settings.similarity_boost = similarity; if (Object.keys(params.voice_settings).length === 0) delete params.voice_settings; // Download or read from cache const cacheFilename = getFilename(msg, params); const { isCached, resolvedPath } = resolveCachePath(cacheFilename); node.sFileToBePlayed = resolvedPath; audioSource = isCached ? "cache" : "internet"; if (!isCached) { node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice }); data = await synthesizeSpeechElevenLabs(node.server.elevenlabsTTS, params); } else { node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Reading offline from cache' }); } } else if (node.server.ttsservice === "elevenlabsv2") { // VoiceId is: code const stability = config.elevenlabsStability !== undefined && config.elevenlabsStability !== "" ? Number(config.elevenlabsStability) : undefined; const similarity = config.elevenlabsSimilarity_boost !== undefined && config.elevenlabsSimilarity_boost !== "" ? Number(config.elevenlabsSimilarity_boost) : undefined; const style = config.elevenlabsStyle !== undefined && config.elevenlabsStyle !== "" ? Number(config.elevenlabsStyle) : undefined; const speed = config.elevenlabsSpeed !== undefined && config.elevenlabsSpeed !== "" ? Number(config.elevenlabsSpeed) : undefined; const resolvedModel = config.elevenlabsModel && config.elevenlabsModel !== "" ? config.elevenlabsModel : "eleven_multilingual_v2"; const latencyPreset = config.elevenlabsOptimizeLatency && config.elevenlabsOptimizeLatency !== "" ? config.elevenlabsOptimizeLatency : undefined; const outputFormat = config.elevenlabsOutputFormat && config.elevenlabsOutputFormat !== "" ? config.elevenlabsOutputFormat : undefined; const seed = config.elevenlabsSeed && config.elevenlabsSeed !== "" ? Number(config.elevenlabsSeed) : undefined; const useSpeakerBoost = config.elevenlabsUse_speaker_boost === undefined ? true : config.elevenlabsUse_speaker_boost; const params = { stream: false, text: msg, voice: node.voiceId, model_id: resolvedModel, voice_settings: {} }; if (stability !== undefined && !Number.isNaN(stability)) params.voice_settings.stability = stability; if (similarity !== undefined && !Number.isNaN(similarity)) params.voice_settings.similarity_boost = similarity; if (style !== undefined && !Number.isNaN(style)) params.voice_settings.style = style; if (speed !== undefined && !Number.isNaN(speed)) params.voice_settings.speed = speed; params.voice_settings.use_speaker_boost = useSpeakerBoost; if (Object.keys(params.voice_settings).length === 0) delete params.voice_settings; if (latencyPreset !== undefined) params.optimize_streaming_latency = latencyPreset; if (outputFormat !== undefined) params.output_format = outputFormat; if (seed !== undefined && !Number.isNaN(seed)) params.seed = seed; // Download or read from cache const cacheFilename = getFilename(msg, params); const { isCached, resolvedPath } = resolveCachePath(cacheFilename); node.sFileToBePlayed = resolvedPath; audioSource = isCached ? "cache" : "internet"; if (!isCached) { node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice }); data = await synthesizeSpeechElevenLabsV2(node.server.elevenlabsTTS, params); } else { node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Reading offline from cache' }); } } // Save the downloaded file into the cache if (data !== undefined) { try { //console.log("Salvelox " + node.sFileToBePlayed) fs.writeFileSync(node.sFileToBePlayed, data); } catch (error) { RED.log.error("ttsultimate: node id: " + node.id + " Unable to save the file " + error.message); node.setNodeStatus({ fill: "red", shape: "ring", text: "Unable to save the file " + node.sFileToBePlayed + " " + error.message }); throw (error); } } } catch (error) { RED.log.error("ttsultimate: node id: " + node.id + " Error Downloading TTS: " + error.message + ". THE TTS SERVICE MAY BE DOWN."); node.setNodeStatus({ fill: 'red', shape: 'ring', text: 'Error Downloading TTS:' + error.message }); node.sFileToBePlayed = ""; } } // Ready to play if (node.sFileToBePlayed !== "") { //#region Now i am ready to play the file if (node.playertype === "sonos") { // Play with Sonos node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Play ' + formatAudioSourceTag(audioSource) + msg }); // Play directly files starting with http:// if (!node.sFileToBePlayed.toLowerCase().startsWith("http://") && !node.sFileToBePlayed.toLowerCase().startsWith("https://")) { node.sFileToBePlayed = node.sNoderedURL + "/tts/tts.mp3?f=" + encodeURIComponent(node.sFileToBePlayed); } // Set Volume try { let volTemp = 0; if (node.currentMSGbeingSpoken.hasOwnProperty("volume")) { volTemp = Number(node.currentMSGbeingSpoken.volume); } else { volTemp = Number(node.sSonosVolume); } await SETVOLUMESync(volTemp); if (node.unmuteIfMuted) await SETMutedSync(false); // 21/10/2021 Unmute if (node.oAdditionalSonosPlayers.length > 0) { // 05/07/2021 set the volume of additional coordinators for (let index = 0; index < node.oAdditionalSonosPlayers.length; index++) { let element = node.oAdditionalSonosPlayers[index].oPlayer; //node.oAdditionalSonosPlayers.push({ oPlayer: new sonos.Sonos(element.host), hostVolumeAdjust: element.hostVolumeAdjust }); try { // 12/04/20222 Set the adjusted volume, based on the main player volume + the adjusted volume in % let iAdjustedVol = Number(volTemp) + Number((node.oAdditionalSonosPlayers[index].hostVolumeAdjust || 0)); if (iAdjustedVol < 0) iAdjustedVol = 0; if (iAdjustedVol > 100) iAdjustedVol = 100; await element.setVolume(iAdjustedVol); if (node.unmuteIfMuted) await element.setMuted(false); // 21/10/2021 Unmute } catch (error) { RED.log.error("ttsultimate: Handlequeue: Unable to set the volume on additional player " + error.message); } }; }; } catch (error) { RED.log.error("ttsultimate: Unable to set the volume for " + node.sFileToBePlayed); } try { await setAVTransportURISync(node.sFileToBePlayed); // Wait for start playing var state = ""; if (node.timerbTimeOutPlay !== null) clearTimeout(node.timerbTimeOutPlay); node.bTimeOutPlay = false; node.timerbTimeOutPlay = setTimeout(() => { node.bTimeOutPlay = true; }, 10000); while (state !== "playing" && !node.bTimeOutPlay) { try { //state = await node.SonosClient.getCurrentState(); state = await getCurrentStateSync(); } catch (error) { node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: 'Error getCurrentState of playing ' + msg }); RED.log.error("ttsultimate: Error getCurrentState of playing " + error.message); throw new MessageEvent("Error getCurrentState of playing " + error.message); } } if (node.timerbTimeOutPlay !== null) clearTimeout(node.timerbTimeOutPlay); switch (node.bTimeOutPlay) { case false: node.setNodeStatus({ fill: 'green', shape: 'dot', text: 'Playing ' + formatAudioSourceTag(audioSource) + msg }); break; default: node.setNodeStatus({ fill: 'grey', shape: 'dot', text: 'Timeout waiting start play state: ' + msg }); break; } // Wait for end if (node.timerbTimeOutPlay !== null) clearTimeout(node.timerbTimeOutPlay); node.bTimeOutPlay = false; state = ""; node.timerbTimeOutPlay = setTimeout(() => { node.bTimeOutPlay = true; }, 60000 * 10); // 10 minutes timeout while (state !== "stopped" && !node.bTimeOutPlay) { try { state = await getCurrentStateSync(); } catch (error) { node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: 'Error getCurrentState of stopped ' + msg }); RED.log.error("ttsultimate: Error getCurrentState of stopped " + error.message); throw new MessageEvent("Error getCurrentState of stopped " + error.message); } } if (node.timerbTimeOutPlay !== null) clearTimeout(node.timerbTimeOutPlay); switch (node.bTimeOutPlay) { case false: node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'End playing ' + msg }); break; default: node.setNodeStatus({ fill: 'grey', shape: 'dot', text: 'Timeout waiting end play state: ' + msg }); break; } } catch (error) { if (node.timerbTimeOutPlay !== null) clearTimeout(node.timerbTimeOutPlay); // Clear the player timeout RED.log.error("ttsultimate: Error HandleQueue for " + node.sFileToBePlayed + " " + error.message); node.setNodeStatus({ fill: 'red', shape: 'dot', text: 'Error ' + msg + " " + error.message }); } } else if (node.playertype === "googlecast" || node.playertype === "dlna") { // 06/2026 Google Cast (Chromecast/Nest) and generic DLNA/UPnP renderers. // Both just need the public HTTP URL of the file (same conversion as Sonos). if (!node.sFileToBePlayed.toLowerCase().startsWith("http://") && !node.sFileToBePlayed.toLowerCase().startsWith("https://")) { node.sFileToBePlayed = node.sNoderedURL + "/tts/tts.mp3?f=" + encodeURIComponent(node.sFileToBePlayed); } // Volume (0-100): a per-message volume overrides the configured one let volTemp = node.currentMSGbeingSpoken.hasOwnProperty("volume") ? Number(node.currentMSGbeingSpoken.volume) : Number(node.playervolume); if (isNaN(volTemp)) volTemp = 50; if (volTemp < 0) volTemp = 0; if (volTemp > 100) volTemp = 100; node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Play ' + formatAudioSourceTag(audioSource) + msg }); try { // Play on the main player + all additional players (reusing the Sonos additional // players list), in parallel for multi-room playback. Works for Cast and DLNA. const playFn = node.playertype === "googlecast" ? playGoogleCastSync : playDLNASync; const targets = [{ host: node.playeripaddress, vol: volTemp }]; for (let ci = 0; ci < node.rules.length; ci++) { const r = node.rules[ci]; if (r && r.host) { let v = volTemp + Number(r.hostVolumeAdjust || 0); if (isNaN(v)) v = volTemp; if (v < 0) v = 0; if (v > 100) v = 100; targets.push({ host: r.host, vol: v }); } } const results = await Promise.allSettled(targets.map((t) => playFn(t.host, node.sFileToBePlayed, t.vol))); const failures = results.filter((rr) => rr.status === "rejected"); if (failures.length === results.length) { throw new Error("all " + node.playertype + " targets failed: " + failures.map((f) => f.reason && f.reason.message).join("; ")); } else if (failures.length > 0) { RED.log.warn("ttsultimate: Some " + node.playertype + " targets failed: " + failures.map((f) => f.reason && f.reason.message).join("; ")); } node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'End playing ' + msg }); } catch (error) { RED.log.error("ttsultimate: Error playing on " + node.playertype + " for " + node.sFileToBePlayed + " " + error.message); node.setNodeStatus({ fill: 'red', shape: 'dot', text: 'Error ' + msg + " " + error.message }); } } else if (node.playertype === "noplayer") { // Output only the filename if (noPlayerFileArray === undefined || noPlayerFileArray === null) var noPlayerFileArray = []; noPlayerFileArray.push({ file: node.sFileToBePlayed }); } } //#endregion }; // End Loop // End task if (node.playertype === "sonos") { // Ends the tasks of Sonos // Ungroup speaker try { await ungroupSpeakersSync(); // Ungroup speakers } catch (error) { // Don't care. node.setNodeStatus({ fill: "red", shape: "ring", text: "Error ungrouping speakers: " + error.message }); } await delay(2000); // Resume music if (!node.doNotResumeMusic) { try { if (oCurTrack !== null && (!oCurTrack.hasOwnProperty("title") || oCurTrack.title.indexOf(".mp3") === -1)) { node.setNodeStatus({ fill: 'grey', shape: 'ring', text: "Resuming original queue..." }); await resumeMusicQueue(oCurTrack); node.setNodeStatus({ fill: 'green', shape: 'ring', text: "Done resuming queue." }); } else { // 28/08/2021 There was no queue playing. Delete the TTS from the queue node.setNodeStatus({ fill: 'green', shape: 'ring', text: "No queue to resume." }); } } catch (error) { node.setNodeStatus({ fill: 'red', shape: 'ring', text: "Error resuming queue: " + error.message }); } } // 19/04/2022 Resume music queue of additional players if (!node.doNotResumeMusic) { for (let index = 0; index < node.oAdditionalSonosPlayers.length; index++) { let addPlayer = node.oAdditionalSonosPlayers[index].oPlayer; let trackAddPlayer = addPlayer.additionalPlayerCurrentTrack; if (trackAddPlayer !== null) { try { await resumeMusicQueue(trackAddPlayer, addPlayer); node.setNodeStatus({ fill: 'green', shape: 'ring', text: "Done resuming queue additional player " + addPlayer.host || "" }); } catch (error) { // Dont care RED.log.warn("ttsultimate: Error resuming music queue of additional player " + error.message + " " + addPlayer.host || ""); } } else { node.setNodeStatus({ fill: 'green', shape: 'ring', text: "No queue to resume for " + addPlayer.host || "" }); } } } // Signal end playing let t = setTimeout(() => { node.msg.completed = true; node.currentMSGbeingSpoken = {}; node.send([{ passThroughMessage: node.passThroughMessage, payload: node.msg.completed }, null]); node.bBusyPlayingQueue = false node.server.whoIsUsingTheServer = ""; // Signal to other ttsultimate node, that i'm not using the Sonos device anymore }, 500) } else { // End task for noplayer, googlecast and dlna. // For "noplayer" also output the array of generated files (undefined for the other types). // Signal end playing node.msg.completed = true; node.currentMSGbeingSpoken = {}; node.send([{ passThroughMessage: node.passThroughMessage, payload: node.msg.completed, filesArray: noPlayerFileArray }, null]); node.bBusyPlayingQueue = false node.server.whoIsUsingTheServer = ""; // Signal to other ttsultimate node, that i'm not using the Sonos device anymore } } catch (error) { // Should'nt be there RED.log.error("ttsultimate: BIG Error HandleQueue MAIN " + error.message); node.setNodeStatus({ fill: "grey", shape: "ring", text: "Error Handlequeue " + error.message }); node.flushQueue(); } } node.on('input', function (msg) { // if (msg.hasOwnProperty("banana")) { // node.Sono