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

911 lines (836 loc) 47.7 kB
<script type="text/html" data-template-name="ttsultimate"> <style> .ttsSliderValue { display: inline-block; min-width: 34px; margin-left: 8px; text-align: right; font-weight: bold; vertical-align: middle; } </style> <div class="form-row"> <b>TTS Ultimate configuration</b>&nbsp&nbsp&nbsp&nbsp<span style="color:red"><i class="fa fa-question-circle"></i>&nbsp<a target="_blank" href="https://github.com/Supergiovane/node-red-contrib-tts-ultimate"><u>Help online</u></a></span> <br/> <br/> </div> <div class="form-row"> <label for="node-input-config"><i class="icon-tag"></i> TTS service</label> <input type="text" id="node-input-config"> </div> <div class="form-row"> <label for="node-input-name"><i class="fa fa-tag"></i> Name</label> <input type="text" id="node-input-name" placeholder="Node Name"> </div> <div id="allGUI"> <div class="form-row"> <label for="node-input-voice"><i class="icon-tag"></i> Voice</label> <select id="node-input-voice" style="width:60%"> <option value='Joanna'>Joanna (en-US)</option> </select> <a id="refreshVoices" class="red-ui-button" title="Refresh voices list" style="margin-left:5px"><i class="fa fa-refresh"></i></a> </div> <div id = "divGoogleTTSAudioConfig"> <div class="form-row"> <label for="node-input-speakingrate"><i class="fa fa-volume-up"></i> Rate</label> <input type="range" id="node-input-speakingrate" min="0.25" max="4.0" step="0.05" style="width:50%; vertical-align:middle;"> <span class="ttsSliderValue" id="val-speakingrate">1</span> <small>(0.25 - 4.0, default 1)</small> </div> <div class="form-row"> <label for="node-input-speakingpitch"><i class="fa fa-volume-up"></i> Pitch</label> <input type="range" id="node-input-speakingpitch" min="-20" max="20" step="0.5" style="width:50%; vertical-align:middle;"> <span class="ttsSliderValue" id="val-speakingpitch">0</span> <small>(-20.0 - 20.0, default 0)</small> </div> </div> <div id="divElevenLabsOptions" hidden> <div class="form-row"> <label for="node-input-elevenlabsStability"><i class="fa fa-volume-up"></i> Stability</label> <input type="range" id="node-input-elevenlabsStability" min="0" max="1" step="0.05" style="width:50%; vertical-align:middle;"> <span class="ttsSliderValue" id="val-elevenlabsStability">0.5</span> <small>(default 0.5)</small> </div> <div class="form-row"> <label for="node-input-elevenlabsSimilarity_boost"><i class="fa fa-volume-up"></i> Similarity boost</label> <input type="range" id="node-input-elevenlabsSimilarity_boost" min="0" max="1" step="0.05" style="width:50%; vertical-align:middle;"> <span class="ttsSliderValue" id="val-elevenlabsSimilarity_boost">0.5</span> <small>(default 0.5)</small> </div> <div class="form-row" id="divElevenLabsStyle"> <label for="node-input-elevenlabsStyle"><i class="fa fa-volume-up"></i> Style Exaggeration </label> <input type="range" id="node-input-elevenlabsStyle" min="0" max="1" step="0.05" style="width:50%; vertical-align:middle;"> <span class="ttsSliderValue" id="val-elevenlabsStyle">0.0</span> <small>(default 0.0)</small> </div> <div class="form-row"> <label for="node-input-elevenlabsSpeed"><i class="fa fa-tachometer"></i> Speed</label> <input type="range" id="node-input-elevenlabsSpeed" min="0.7" max="1.2" step="0.05" style="width:50%; vertical-align:middle;"> <span class="ttsSliderValue" id="val-elevenlabsSpeed">1.0</span> <small>(0.7 - 1.2, default 1.0)</small> </div> <div class="form-row" id="divElevenLabsSpeakerBoost"> <label></label> <input type="checkbox" id="node-input-elevenlabsUse_speaker_boost" style="margin-left: 0px; vertical-align: top; width: auto !important;"> <label style="width:auto !important;"> Speaker boost</label> </div> <div class="form-row"> <label for="node-input-elevenlabsModel"><i class="fa fa-cubes"></i> Model</label> <select id="node-input-elevenlabsModel" style="width:55%"> <option value="">Automatic (recommended)</option> <option value="eleven_monolingual_v1">Eleven Monolingual v1</option> <option value="eleven_multilingual_v1">Eleven Multilingual v1</option> <option value="eleven_multilingual_v2">Eleven Multilingual v2</option> <option value="eleven_turbo_v2">Eleven Turbo v2</option> <option value="eleven_turbo_v2_5">Eleven Turbo v2.5</option> </select> <a id="refreshElevenLabsModels" class="red-ui-button" title="Refresh models list" style="margin-left:5px"><i class="fa fa-refresh"></i></a> </div> <div class="form-row"> <label for="node-input-elevenlabsOptimizeLatency"><i class="fa fa-tachometer"></i> Latency preset</label> <select id="node-input-elevenlabsOptimizeLatency" style="width:60%"> <option value="">Default</option> <option value="0">0 - Best quality</option> <option value="1">1</option> <option value="2">2</option> <option value="3">3</option> <option value="4">4 - Lowest latency</option> </select> </div> <div class="form-row"> <label for="node-input-elevenlabsOutputFormat"><i class="fa fa-file-audio-o"></i> Output format</label> <select id="node-input-elevenlabsOutputFormat" style="width:60%"> <option value="">Default (mp3_44100_128)</option> <option value="mp3_44100_192">mp3_44100_192</option> <option value="mp3_44100_128">mp3_44100_128</option> <option value="mp3_44100_64">mp3_44100_64</option> <option value="mp3_44100_32">mp3_44100_32</option> <option value="mp3_22050_128">mp3_22050_128</option> <option value="mp3_22050_64">mp3_22050_64</option> <option value="pcm_16000">pcm_16000</option> <option value="pcm_22050">pcm_22050</option> <option value="ulaw_8000">ulaw_8000</option> </select> </div> <div class="form-row"> <label for="node-input-elevenlabsSeed"><i class="fa fa-random"></i> Seed</label> <input type="text" id="node-input-elevenlabsSeed" style="width:120px" placeholder="Leave blank for random"> </div> </div> <div class="form-row" id="divSSML"> <label></label> <input type="checkbox" id="node-input-ssml" style="margin-left: 0px; vertical-align: top; width: auto !important;"> <label style="width:auto !important;"> Enable SSML (unsupported by Google free TTS)</label> </div> <div class="form-row"> <label for="node-input-sonoshailing"><i class="fa fa-bell"></i> Hailing</label> <select id="node-input-sonoshailing"></select> <input id="deleteSelectedFile" type="button" value="DELETE" style="width:100px"> </div> <div class="form-row"> <label><i class="fa fa-upload"></i> Upload hail</label> <input id="ownFileUpload" type="file" multiple="multiple"> </div> <div class="form-row"> <label for="node-input-playertype"><i class="fa fa-play"></i> Player</label> <select id="node-input-playertype"> <option value="sonos">Sonos</option> <option value="googlecast">Google Cast (Chromecast / Nest)</option> <option value="dlna">DLNA / UPnP renderer</option> <option value="noplayer">No player, only output file name.</option> </select> </div> <div id="divCastDlna"> <div class="form-row"> <label for="node-input-playervolume"><i class="fa fa-volume-up"></i> Volume</label> <input type="text" id="node-input-playervolume" style="width:150px" placeholder="0-100"> </div> <div class="form-row" id="playerDiscoverRow"> <label for="playerDiscoverList"><i class="fa fa-search"></i> Discover</label> <select id="playerDiscoverList" style="width:250px"><option value="">-- press Discover --</option></select> <a id="playerDiscoverBtn" class="red-ui-button" style="width:auto;"><i class="fa fa-refresh"></i> Discover</a> </div> <div class="form-row"> <label for="node-input-playeripaddress"><i class="fa fa-globe"></i> <span id="playeraddresslabel">Device</span></label> <input type="text" id="node-input-playeripaddress" style="width:250px"> </div> <div class="form-row"> <p id="playeraddresshint" style="margin-left:105px;color:#888;"></p> </div> </div> <div id="divSonos"> <div class="form-row"> <label for="node-input-sonosvolume"><i class="fa fa-volume-up"></i> Volume</label> <input type="text" id="node-input-sonosvolume" style="width:150px"> </div> <div class="form-row"> <label for="node-input-unmuteIfMuted"><i class="fa fa-bell-slash-o"></i> Unmute</label> <input type="checkbox" id="node-input-unmuteIfMuted" style="margin-left: 0px; vertical-align: top; width: auto !important;"> <label style="width:auto !important;"> Unmute, then restore previous state after play.</label> </div> <div class="form-row"> <label for="node-input-doNotResumeMusic"><i class="fa fa-bell-slash-o"></i> Resume</label> <input type="checkbox" id="node-input-doNotResumeMusic" style="margin-left: 0px; vertical-align: top; width: auto !important;"> <label style="width:auto !important;"> Do not resume previous music.</label> </div> <div class="form-row"> <label for="node-input-sonosipaddress"><i class="fa fa-globe"></i> Main Sonos Player</label> <label style="width:200px;" id="node-input-sonosipaddress">Discovering.... wait...</label> </div> </div> <div id="divAdditionalPlayers"> <dt><i class="fa fa-code-fork"></i>&nbsp; Additional players</dt> <div class="form-row node-input-rule-container-row"> <ol id="node-input-rule-container"></ol> </div> <div class="form-row"> <p>Press ADD, to add a player in the list.</p> </div> </div> </div> <div id="pleaseDeploy"> <p align="center"> <b>PLEASE COMPLETE THE CONFIGURATION<br/> OF THE ABOVE CONFIG-NODE<br/>THEN SAVE, DEPLOY AND RE-OPEN THIS WINDOW</b><br/> REMEMBER: IF YOU'RE RUNNING NODE-RED<br/> IN DOCKER, HOMEMATIC OR SIMILAR<br/> BE SURE TO FORWARD PORTS 1980 AND 1400 </p> </div> </script> <script type="text/javascript"> RED.nodes.registerType('ttsultimate', { category: 'TTSUltimate', color: '#ffe6ff', // #ffe6ff defaults: { name: { value: "TTS-Ultimate" }, voice: { value: "Ivy", required: false }, ssml: { value: false, required: false }, sonosipaddress: { value: "", required: false }, sonosvolume: { value: "20", required: false, type: "text" }, playeripaddress: { value: "", required: false }, playervolume: { value: "50", required: false, type: "text" }, sonoshailing: { value: "Hailing_Hailing.mp3", required: false, }, doNotResumeMusic: { value: true, required: false, }, config: { type: "ttsultimate-config", required: false }, property: { value: "payload", required: false, validate: RED.validators.typedInput("propertyType") }, propertyType: { value: "msg" }, rules: { value: [] }, playertype: { value: "sonos", required: false }, speakingrate: { value: "1", required: false }, speakingpitch: { value: "0", required: false }, unmuteIfMuted: { value: true }, elevenlabsStability: { value: "0.5", required: false }, elevenlabsSimilarity_boost: { value: "0.5", required: false }, elevenlabsStyle: { value: "0.0", required: false }, elevenlabsSpeed: { value: "1.0", required: false }, elevenlabsUse_speaker_boost: { value: true, required: false }, elevenlabsModel: { value: "", required: false }, elevenlabsOptimizeLatency: { value: "", required: false }, elevenlabsOutputFormat: { value: "", required: false }, elevenlabsSeed: { value: "", required: false }, }, inputs: 1, outputs: 2, outputLabels: function (i) { var ret = ""; switch (i) { case 0: return "Play Status"; break; case 1: return "Error"; break; default: break; } }, icon: "tts-icon.png", label: function () { return this.name || "TTS-ultimate"; }, oneditprepare: function () { var node = this; var oNodeServer = RED.nodes.node($("#node-input-config").val()); // Store the config-node if (node.elevenlabsModel !== undefined) $("#node-input-elevenlabsModel").val(node.elevenlabsModel); if (node.elevenlabsSpeed !== undefined) $("#node-input-elevenlabsSpeed").val(node.elevenlabsSpeed); if (node.elevenlabsOptimizeLatency !== undefined) $("#node-input-elevenlabsOptimizeLatency").val(node.elevenlabsOptimizeLatency); if (node.elevenlabsOutputFormat !== undefined) $("#node-input-elevenlabsOutputFormat").val(node.elevenlabsOutputFormat); if (node.elevenlabsSeed !== undefined) $("#node-input-elevenlabsSeed").val(node.elevenlabsSeed); function updateTtsOptionsVisibility() { try { if (!oNodeServer || !oNodeServer.ttsservice) return; if (oNodeServer.ttsservice === "googletts") { $("#divGoogleTTSAudioConfig").show(); $("#divElevenLabsOptions").hide(); $("#divSSML").show(); } else if (oNodeServer.ttsservice.includes("elevenlabs")) { $("#divGoogleTTSAudioConfig").hide(); $("#divElevenLabsOptions").show(); $("#divSSML").hide(); } else if (oNodeServer.ttsservice === "voiceai") { $("#divGoogleTTSAudioConfig").hide(); $("#divElevenLabsOptions").hide(); $("#divSSML").hide(); } else { $("#divGoogleTTSAudioConfig").hide(); $("#divElevenLabsOptions").hide(); $("#divSSML").show(); } } catch (error) { } } // 19/02/2020 Used to alert the user if the CSV file has not been loaded and to get the server sooner als deploy // ########################### $("#node-input-config").change(function () { try { oNodeServer = RED.nodes.node($(this).val()); getVoices(); getElevenLabsModels(); updateTtsOptionsVisibility(); } catch (error) { } }); // ########################### // 20/09/2021 Player type // ########################### if (node.playertype === undefined) { node.playertype = "sonos"; $("#node-input-playertype").val("sonos"); } var playerDiscovered = false; // discover only once per shown section, unless refreshed manually // Discovery endpoint used by the per-row "Additional players" list (Sonos / Cast / DLNA). function currentPlayerDiscoveryUrl() { var pt = $("#node-input-playertype").val(); if (pt === "googlecast") return 'ttsultimateDiscoverCast'; if (pt === "dlna") return 'ttsultimateDiscoverDLNA'; return 'sonosgetAllGroups'; } // Discovery for the MAIN player address field (Google Cast IP or DLNA description URL). // All endpoints share the { name, host } shape, so a single code path works. function runPlayerDiscovery() { var pt = $("#node-input-playertype").val(); var noun = pt === "googlecast" ? "Cast devices" : "renderers"; $("#playerDiscoverList").html('<option value="">Discovering... please wait ~4s</option>'); $.getJSON(currentPlayerDiscoveryUrl(), function (data) { $("#playerDiscoverList").empty(); if (typeof data === "string" || !data || data.length === 0) { $("#playerDiscoverList").append($("<option></option>").attr("value", "").text("No " + noun + " found - type manually below")); return; } $("#playerDiscoverList").append($("<option></option>").attr("value", "").text("-- select a discovered device --")); data.forEach(function (dev) { $("#playerDiscoverList").append($("<option></option>").attr("value", dev.host).text(dev.name + " (" + dev.host + ")")); }); }).fail(function () { $("#playerDiscoverList").html('<option value="">Discovery failed - type manually below</option>'); }); } // Picking a discovered device fills the main address field. $("#playerDiscoverList").change(function () { var v = $(this).val(); if (v) $("#node-input-playeripaddress").val(v); }); $("#playerDiscoverBtn").click(function (e) { e.preventDefault(); runPlayerDiscovery(); }); // Re-render the additional players rows so each row re-discovers using the // current player type (Sonos groups vs Cast devices), preserving entered hosts. function rebuildAdditionalPlayers() { try { var items = $("#node-input-rule-container").editableList('items'); var saved = []; items.each(function () { var r = $(this); saved.push({ host: r.find(".rowRulePlayerHost").val(), hostVolumeAdjust: r.find(".rowRulePlayerHostAdjustVolume").val() }); }); $("#node-input-rule-container").editableList('empty'); saved.forEach(function (rule, idx) { $("#node-input-rule-container").editableList('addItem', { r: rule, i: idx }); }); } catch (e) { } } function refreshPlayerSections() { var pt = $("#node-input-playertype").val(); $("#divSonos").toggle(pt === "sonos"); $("#divCastDlna").toggle(pt === "googlecast" || pt === "dlna"); $("#playerDiscoverRow").toggle(pt === "googlecast" || pt === "dlna"); // Additional players are supported for grouped Sonos and multi-room Google Cast / DLNA. $("#divAdditionalPlayers").toggle(pt === "sonos" || pt === "googlecast" || pt === "dlna"); if (pt === "googlecast") { $("#playeraddresslabel").text("Main Chromecast / Nest IP"); $("#playeraddresshint").text("IP address of the main Google Cast device, e.g. 192.168.1.50"); } else if (pt === "dlna") { $("#playeraddresslabel").text("Renderer description URL"); $("#playeraddresshint").text("UPnP device description XML URL, e.g. http://192.168.1.50:1400/desc.xml"); } // Auto-discover the first time a discoverable section is shown. if ((pt === "googlecast" || pt === "dlna") && !playerDiscovered) { playerDiscovered = true; runPlayerDiscovery(); } } refreshPlayerSections(); $("#node-input-playertype").change(function () { playerDiscovered = false; // force re-discovery for the newly selected type $("#playerDiscoverList").html('<option value="">-- press Discover --</option>'); rebuildAdditionalPlayers(); refreshPlayerSections(); }); // ########################### // 24/12/2020 Check if the node is the absolute first in the flow. In this case, it has no http server instatiaced // !oNodeServer.hasOwnProperty("noderedipaddress") is when the config node exists, but not deployed // oNodeServer.noderedipaddress === "" is when the config node has been deployed, but not configured if (oNodeServer === null || oNodeServer === undefined || !oNodeServer.hasOwnProperty("noderedipaddress")) { $("#pleaseDeploy").show(); $("#allGUI").hide(); } else if (oNodeServer.hasOwnProperty("noderedipaddress") && oNodeServer.noderedipaddress === "") { // Config Node deployed but not configured $("#pleaseDeploy").html("<p align=\"center\"><b>GOOD, YOU'RE ALMOST DONE</b><br/>PLEASE FINISH CONFIGURING<br/>THE CONFIG NODE ABOVE,<br/><b>THEN SAVE AND FULL DEPLOY</b>.</p>"); $("#pleaseDeploy").show(); $("#allGUI").hide(); } else { $("#pleaseDeploy").hide(); $("#allGUI").show(); }; // 26/10/2020 Retrieve all avaiables voices // ##################################### function getVoices() { $('#node-input-voice') .find('option') .remove() .end(); $.getJSON("ttsgetvoices" + encodeURIComponent(oNodeServer.id) + "?ttsservice=" + oNodeServer.ttsservice, new Date().getTime(), (data) => { var aVoices = data.sort(compareVoices); for (let index = 0; index < aVoices.length; index++) { const oVoice = aVoices[index]; $("#node-input-voice").append($("<option></option>") .attr("value", oVoice.id) .text(`${oVoice.name} (ID: ${oVoice.id})`) ) if (oVoice.name.indexOf("ttsultimategooglecredentials") > -1) { // Cred file not present or invalid // The only way is to wait some time, then refresh let myNotification = RED.notify("Either you haven't uploaded the credential file, or the file is invalid. " + oVoice.name, { modal: true, fixed: true, type: 'error', buttons: [ { "text": "OK", "class": "primary", "click": function (e) { myNotification.close(); } }] }); break; }; }; $("#node-input-voice").val(node.voice); }); function compareVoices(a, b) { // Use toUpperCase() to ignore character casing const bandA = a.name.toUpperCase(); const bandB = b.name.toUpperCase(); let comparison = 0; if (bandA > bandB) { comparison = 1; } else if (bandA < bandB) { comparison = -1; } return comparison; } } updateTtsOptionsVisibility(); getVoices(); // Refresh voices list on demand $("#refreshVoices").click(function (e) { e.preventDefault(); if (oNodeServer === null || oNodeServer === undefined || !oNodeServer.ttsservice) { RED.notify("Please select a valid TTS service first.", { type: 'warning', timeout: 3000 }); return; } getVoices(); }); // ##################################### // ElevenLabs: read models and their capabilities from the API, then propose them to the user. // Options not supported by the selected model are automatically disabled. // ##################################### function applyElevenLabsModelCapabilities() { var $opt = $("#node-input-elevenlabsModel option:selected"); var modelVal = $("#node-input-elevenlabsModel").val(); // "Automatic" (empty) or models loaded without capability data => keep everything enabled. var styleSupported = (modelVal === "" || modelVal === null) ? true : ($opt.data("style") !== false); var boostSupported = (modelVal === "" || modelVal === null) ? true : ($opt.data("boost") !== false); toggleElevenLabsRow("#divElevenLabsStyle", "#node-input-elevenlabsStyle", styleSupported); toggleElevenLabsRow("#divElevenLabsSpeakerBoost", "#node-input-elevenlabsUse_speaker_boost", boostSupported); } function toggleElevenLabsRow(rowSelector, inputSelector, supported) { if (supported) { $(rowSelector).css("opacity", "1"); $(inputSelector).prop("disabled", false); } else { $(rowSelector).css("opacity", "0.4"); $(inputSelector).prop("disabled", true); } } function getElevenLabsModels() { if (oNodeServer === null || oNodeServer === undefined || !oNodeServer.ttsservice || oNodeServer.ttsservice.indexOf("elevenlabs") === -1) return; $.getJSON("ttsgetmodels" + encodeURIComponent(oNodeServer.id) + "?ttsservice=" + oNodeServer.ttsservice, new Date().getTime(), (data) => { if (!Array.isArray(data)) return; // Backend signalled an error/missing key: keep the static fallback list and warn the user. if (data.length === 1 && data[0].error) { RED.notify(data[0].name, { type: 'warning', timeout: 6000 }); return; } if (data.length === 0) return; var $sel = $("#node-input-elevenlabsModel"); $sel.find('option').remove().end(); $sel.append($("<option></option>").attr("value", "").text("Automatic (recommended)")); data.forEach(m => { var $o = $("<option></option>").attr("value", m.model_id).text(m.name); $o.attr("data-style", m.can_use_style === true); $o.attr("data-boost", m.can_use_speaker_boost === true); if (m.languages && m.languages.length) $o.attr("title", "Languages: " + m.languages.join(", ")); $sel.append($o); }); $sel.val(node.elevenlabsModel !== undefined ? node.elevenlabsModel : ""); if ($sel.val() === null) $sel.val(""); // Saved model no longer offered by the API applyElevenLabsModelCapabilities(); }); } getElevenLabsModels(); $("#node-input-elevenlabsModel").change(applyElevenLabsModelCapabilities); // Refresh ElevenLabs models list on demand $("#refreshElevenLabsModels").click(function (e) { e.preventDefault(); if (oNodeServer === null || oNodeServer === undefined || !oNodeServer.ttsservice || oNodeServer.ttsservice.indexOf("elevenlabs") === -1) { RED.notify("Please select an ElevenLabs TTS service first.", { type: 'warning', timeout: 3000 }); return; } getElevenLabsModels(); }); // ##################################### // Voice options sliders: show the current value live next to each slider. // ##################################### function setupVoiceOptionSlider(id) { var $input = $("#node-input-" + id); var $out = $("#val-" + id); if ($input.length === 0 || $out.length === 0) return; var update = function () { $out.text($input.val()); }; $input.on("input change", update); update(); } ["speakingrate", "speakingpitch", "elevenlabsStability", "elevenlabsSimilarity_boost", "elevenlabsStyle", "elevenlabsSpeed"].forEach(setupVoiceOptionSlider); // ##################################### // Refresh the combo // ##################################### node.refreshHailingList = () => { return new Promise((resolve, reject) => { $('#node-input-sonoshailing') .find('option') .remove() .end(); try { $.getJSON("getHailingFilesList", new Date().getTime(), (data) => { $("#node-input-sonoshailing").append($("<option></option>") .attr("value", "0") .text("Disable") ) data.sort().forEach(oFile => { $("#node-input-sonoshailing").append($("<option></option>") .attr("value", oFile.filename) .text(oFile.name) ) }); $("#node-input-sonoshailing").val(typeof node.sonoshailing === "undefined" ? "Hailing_Hailing.mp3" : node.sonoshailing); resolve(true); }); } catch (error) { return reject(error); } }); } // ##################################### // 09/03/2020 Upload file or files // ########################################################## $("#ownFileUpload").change(function (e) { var oFiles; oFiles = this.files; $.each(oFiles, function (i, file) { var fdata = new FormData(); fdata.append("customHailing", file); $.ajax({ url: "ttsultimateHailing", type: "POST", data: fdata, //add the FormData object to the data parameter processData: false, //tell jquery not to process data contentType: false, success: function (response, status, jqxhr) { if (typeof response === "object" && response.status === 404) { let myNotification = RED.notify(response.message, { modal: true, fixed: true, type: 'error', buttons: [ { text: "Understood", click: function (e) { myNotification.close(); } }] }); return; } // Refresh the combo // The only way is to wait some time, then refresh let t = setTimeout(function () { node.refreshHailingList().then((success, error) => { $("#ownFileUpload").val("");// Otherwise will not re-upload a file with the same name $("#node-input-sonoshailing").val("Hailing_" + file.name); }); }, 500); }, error: function (jqxhr, status, errorMessage) { let myNotification = RED.notify(errorMessage, { modal: true, fixed: true, type: 'error', buttons: [ { text: "Understood", click: function (e) { myNotification.close(); } }] }); } }); }); }); // ########################################################## // Delete selected file // ########################################################## $("#deleteSelectedFile").click(function () { $.getJSON('deleteHailingFile?FileName=' + $("#node-input-sonoshailing").val(), (data) => { node.refreshHailingList(); }); }); // ########################################################## // 20/03/2020 Coronavirus issue in Itay. Getting all Sonos Groups // ########################################################## $.getJSON('sonosgetAllGroups', (data) => { if (typeof data === "string" && data == "ERRORDISCOVERY") { // 10/04/2020 if error in discovery, fallback to manual IP input // Transform the dropdown to a simple input $("#node-input-sonosipaddress").replaceWith('<input type="text" id="node-input-sonosipaddress" style="width:150px">'); } else { $("#node-input-sonosipaddress").replaceWith('<select id="node-input-sonosipaddress"></select>'); data.sort().forEach(oGroup => { $("#node-input-sonosipaddress").append($("<option></option>") .attr("value", oGroup.host) .text(oGroup.name + " (" + oGroup.host + ")") ) }); } if (typeof node.sonosipaddress === "undefined" || node.sonosipaddress == "") { $("#node-input-sonosipaddress").val($("#node-input-sonosipaddress option:first").val()); } else { $("#node-input-sonosipaddress").val(node.sonosipaddress) } }); // ########################################################## //#region ADDITIONAL PLAYERS // 20/03/2020 ADDITIONAL PLAYERS // ########################################################## //var previousValueType = { value: "prev", label: this._("switch.previous"), hasValue: false }; // Add Selectbox with the volume for the additional players function addAdditionalPlayerVolumeUI(row, _currentVolume = 0) { let oAdjustVolume = $('<select/>', { class: "rowRulePlayerHostAdjustVolume", type: "text", style: "width:200px; margin-left: 5px; text-align: left;" }).appendTo(row); for (let index = -100; index < 100; index += 5) { let sTesto = ""; if (index === 0) sTesto = "Same volume as Main Player"; if (index < 0) sTesto = "Decrease volume by " + Math.abs(index); if (index > 0) sTesto = "Increase volume by " + index; oAdjustVolume.append($("<option></option>") .attr("value", index) .text(sTesto) ) } oAdjustVolume.val(_currentVolume); } function resizeRule(rule) { } $("#node-input-rule-container").css('min-height', '150px').css('min-width', '450px').editableList({ addItem: function (container, i, opt) { // row, index, data if (!opt.hasOwnProperty('r')) { opt.r = {}; } let rule = opt.r; if (!opt.hasOwnProperty('i')) { opt._i = Math.floor((0x99999 - 0x10000) * Math.random()).toString(); } container.css({ overflow: 'hidden', whiteSpace: 'nowrap' }); var row = $('<div class="form-row"/>').appendTo(container); var oPlayer = $('<label>Discovering.... wait...</label>', { class: "rowRulePlayerHost", type: "text", style: "width:200px; margin-left: 5px; text-align: left;" }).appendTo(row); oPlayer.on("change", function () { resizeRule(container); }); $.getJSON(currentPlayerDiscoveryUrl(), (data) => { if ((typeof data === "string" && data == "ERRORDISCOVERY") || !data || data.length === 0) { // 10/04/2020 if error/empty discovery, fallback to manual IP input // Transform the dropdown to a simple input oPlayer.remove(); oPlayer = $('<input/>', { class: "rowRulePlayerHost", type: "text", style: "width:200px; margin-left: 5px; text-align: left;" }).appendTo(row); addAdditionalPlayerVolumeUI(row, rule.hostVolumeAdjust); } else { oPlayer.remove(); oPlayer = $('<select/>', { class: "rowRulePlayerHost", type: "text", style: "width:200px; margin-left: 5px; text-align: left;" }).appendTo(row); data.sort().forEach(oGroup => { oPlayer.append($("<option></option>") .attr("value", oGroup.host) .text(oGroup.name + " (" + oGroup.host + ")") ) }); addAdditionalPlayerVolumeUI(row, rule.hostVolumeAdjust); } oPlayer.val(rule.host); }); //oPlayer.change(); }, removeItem: function (opt) { }, resizeItem: resizeRule, sortItems: function (rules) { }, sortable: true, removable: true }); // 20/03/2020 For each rule, create a row for (var i = 0; i < this.rules.length; i++) { let rule = this.rules[i]; $("#node-input-rule-container").editableList('addItem', { r: rule, i: i }); } // ########################################################## //#endregion node.refreshHailingList(); }, oneditsave: function () { let node = this; let rules = $("#node-input-rule-container").editableList('items'); node.rules = []; rules.each(function (i) { let rule = $(this); let rowRulePlayerHost = rule.find(".rowRulePlayerHost").val(); let rowRulePlayerHostAdjustVolume = rule.find(".rowRulePlayerHostAdjustVolume").val(); node.rules.push({ host: rowRulePlayerHost, hostVolumeAdjust: rowRulePlayerHostAdjustVolume }); }); this.propertyType = $("#node-input-property").typedInput('type'); }, oneditresize: function (size) { var node = this; var rows = $("#dialog-form>div:not(.node-input-rule-container-row)"); var height = size.height; for (var i = 0; i < rows.length; i++) { height -= $(rows[i]).outerHeight(true); } var editorRow = $("#dialog-form>div.node-input-rule-container-row"); height -= (parseInt(editorRow.css("marginTop")) + parseInt(editorRow.css("marginBottom"))); height += 16; $("#node-input-rule-container").editableList('height', height); } }); </script> <script type="text/markdown" data-help-name="ttsultimate"> <p>This node transforms a text into a speech audio that you can hear natively via Sonos speakers, Google Cast devices (Chromecast / Google Nest), generic DLNA/UPnP renderers, or save it to a file.</p> **General** |Property|Description| |--|--| | TTS Service | Select the TTS SERVICE ENGINE NODE. | | Voice | Select your preferred voice. The list depends on the selected TTS engine (Google / ElevenLabs / Voice.ai). Google service without authentication has a limited set of voices. | | Hailing | Before the first TTS message of the message queues, the node will play an "hailing" sound. You can select the hailing, upload your own, or totally disable it. | | Upload hail | It allows you to upload your own hailing file. | | Player | Select the player: **Sonos**, **Google Cast** (Chromecast / Google Nest), **DLNA / UPnP renderer**, or **No player**. If you select No player, only output file name, the node will output a msg with an additional property filesArray, containing an array of all mp3 files ready to be played by third party nodes. Please see below the OUTPUT MESSAGES FROM THE NODE section. | | Volume | Set the preferred TTS volume, from "0" to "100" (can be overridden by passing msg.volume = "40"; to the node). | | Unmute | (Sonos only) Unmute the main and the addotional players, then restore the previous mute state once finished. (Can be overridden by passing msg.unmute = true; to the node). | | Resume | (Sonos only) If music was playing prior to TTS messages, the node will try to resume it, but can fail in some cases. Enabla this option to avoid resuming music after TTS message. | | Main Sonos Player | Select your Sonos primary player. (It's strongly suggested to set a fixed IP for this player; you can reserve an IP using the DHCP Reservation function of your router/firewall's DHCP Server). It's possibile to group players, so your announcement can be played on all selected players. For this to happen, you need to select your primary coordinator player. All other players will be then controlled by this coordinator. | | Main Chromecast / Nest IP | (Google Cast) IP address of the main Google Cast device (e.g. 192.168.1.50). Press **Discover** to auto-detect the Cast devices on your network and pick one from the list. | | Renderer description URL | (DLNA) The full UPnP device description XML URL of the renderer (e.g. http://192.168.1.50:49153/nmrDescription.xml), **not** just the IP. Press **Discover** to auto-detect the DLNA renderers on your network and pick one from the list. | | Additional Players | (Sonos / Google Cast / DLNA) Add additional players so your announcement is played on all of them at once. For Sonos they are grouped to the Main Player coordinator; for Google Cast and DLNA the TTS is played on every device in parallel. Use the "ADD" button below the list; for each additional player you can adjust its volume relative to the main volume (-100/+100). | **Google TTS Engines specific options** |Property|Description| |--|--| | Enable SSML | Enable the SSML XML notation. Please be aware, not all the TTS engines supports that (for example Voice.ai).| | Rate | Only avaiable if you choose Google TTS Engine (with credentials). Specifies the speech speed (Between 0.25 and 4.0, default 1). | | Pitch | Only avaiable if you choose Google TTS Engine (with credentials). Specifies the speech pitch (Between -20.0 and 20.0, default 0). | **Elevenlabs TTS Engines specific options** |Property|Description| |--|--| | Stability | Only avaiable with Elevenlabs. Please refer to Elevenlabs.io description. Values from 0 to 1 (for example 0.2, 0.7, etc.) | | Similarity | Only avaiable with Elevenlabs. Please refer to Elevenlabs.io description. Values from 0 to 1 (for example 0.2, 0.7, etc.) | | Style Exaggeration | Only avaiable with Elevenlabs. Please refer to Elevenlabs.io description. Values from 0 to 1 (for example 0.2, 0.7, etc.)| | Speed | Only avaiable with Elevenlabs v2. Controls the speaking speed. Values between 0.7 (slower) and 1.2 (faster), default 1.0. | | Speaker boost | Only avaiable with Elevenlabs. Please refer to Elevenlabs.io description. Values from 0 to 1 (for example 0.2, 0.7, etc.) | | Model | The model list is read directly from ElevenLabs (press the refresh icon to reload it), so new models appear automatically. Options not supported by the selected model (such as Style Exaggeration or Speaker boost) are automatically disabled, based on the model capabilities reported by ElevenLabs. Leave on "Automatic" to let the node pick a sensible default. | <br/> **Players** | Player | Notes | |--|--| | Sonos | Native control: grouping, volume, mute/unmute and music resume after the announcement. | | Google Cast | Chromecast and Google Nest devices, discovered via mDNS. Set the main device IP and, optionally, additional devices for synchronized multi-room playback. | | DLNA / UPnP renderer | Generic UPnP MediaRenderers (smart TVs, AV receivers, etc.), discovered via SSDP. Works with renderers that nest a MediaRenderer sub-device too (e.g. Sonos). Requires the full device description XML URL. | | No player | Only generates the mp3 file(s) and outputs their paths in filesArray, to be played by third party nodes. | For Google Cast and DLNA the device fetches the generated mp3 over HTTP from Node-RED, so the Node-RED host/port (default 1980) must be reachable from the player and on the same subnet (mDNS/SSDP discovery does not cross subnets/VLANs). ### Inputs : volume (string) : Set the volume (values between "0" and "100"). : unmute (boolean) : *true* Unmute all players then mutes it again once finished playing. *false* Leaves the player as they are. : nohailing (string) : Temporary stop playing the Hailing sound prior to the message (values "true" or "1" with quotes). : payload (string | file) : The text to be spoken (for example msg.payload = "Hello World!"). You can also play an mp3 stored on an http server, by passing the URL to the payload ( msg.payload = "www.myserver.com/alarm.mp3"). : sonoshailing (string) : Overrides the selected hailing and plays the filename you passed in. Please double check the spelling of the filename (must be the same as you can see in the dropdown list of your hailing files, in the ttsultimate config window) and do not include the .mp3 extenson. For example node.sonoshailing="ComputerCall". : priority (boolean) : If set to true, the inbound flow message will cancel the current TTS queue, will stop the current phrase being spoken and the node will play this priority message. If there are other priority messages in the queue, they will be retained and the inbound priority flow message is added to the queue. If the inbound priority flow message is the first in the priority queue, the hailing is played first (if the hailing has been enabled or if the hailing has been overridden by node.sonoshailing). : stop (boolean) : If set to true, stops whatever is playing and clears the TTS queue. : voiceId (string) : Play a message with custom voice ID (engine-specific). : setConfig (json) : This is the property where you can set all the things. It must be a JSON Object with the below specified properties. The setting is retained until the node receives another msg.setConfig or until node-red is restarted. : setConfig {setMainPlayerIP} (string) : Sets the main player IP. This will also be the coordinator if you have a group of players. : setConfig {setPlayerGroupArray} (array) : Sets the array of players beloging to the group, if any. You can also specify the volume variation from the main volume player, to adapt the additional player's perceived volume to the main sonos player volume. For example, if you have a speaker mounted in celiling, having less perceived volume, you can "push" the volume up, to match the whole perceived volume. Just add # after the IP and a number from -100 to 100 to subtract or add volume compared to the main sonos volume. For example, if the sonos main player volume is 40, you can push this celing speaker's volume to further 10, so it'll have the real volume of 50. See below, the example. Even if you have only one additional player, you need to put it into an array. ### Output The node has two output pins. The first pin is to signal play status, the second one, is to signal an error 1. Standard output : payload (boolean) : Payload is true when the node has finished playing, false if the node is playing. In case you selected No player, only output file name. in the Player property, you'll get a message with an additional property filesArray, containing an array of all mp3 files ready to be played with third party nodes. The property passThroughMessage contains the input msg passed through the output. : passThroughMessage (json) : Original message object. : filesArray (array of string) : Arrays of generated files name, including paths. 2. Error : payload (boolean) : Payload is true when error occurs (for example, lost connection with Sonos Player), otherwise false. ### Samples [You'll find useful samples here](https://github.com/Supergiovane/node-red-contrib-tts-ultimate) [Find it useful?](https://www.paypal.me/techtoday) <br/> </script>