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
846 lines (752 loc) • 44.6 kB
JavaScript
module.exports = function (RED) {
'use strict';
// 31/05/2022 checking nodejs version due to Microsoft Azure SDK bad nodejs compatibility.
let nodejsVersion = process.version.match(/^v(\d+\.\d+)/)[1];
if (nodejsVersion.startsWith("18")) {
//RED.log.error('ttsultimate-config: YOUR NODEJS VERSION IS CURRENTLY INCOMPATIBLE WITH Microsoft Azure SDK. Your NodeJS version: ' + nodejsVersion + ", please install one of these: (^12.22.0, ^14.17.0, or >=16.0.0), with SSL support.");
}
// Setting up the engines
const GoogleTTS = require('@google-cloud/text-to-speech');
const GoogleTranslate = require('./lib/googletranslate'); // Native TTS without credentials, limited to 200 chars per row.
const elevenlabsTTS = require("elevenlabs-node"); // 03/08/2023
const ElevenLabsClient = require("elevenlabs").ElevenLabsClient;
var fs = require('fs');
var path = require("path");
var formidable = require('formidable');
const oOS = require('os');
const sonos = require('sonos');
const dlnaDiscovery = require('./lib/dlna-discovery'); // 06/2026 SSDP discovery of DLNA/UPnP renderers
const castDiscovery = require('./lib/googlecast-discovery'); // 06/2026 mDNS discovery of Google Cast devices
const VOICEAI_API_BASE_URL = "https://dev.voice.ai/api/v1/tts";
// Configuration Node Register
function TTSConfigNode(config) {
RED.nodes.createNode(this, config);
var node = this;
node.noderedipaddress = config.noderedipaddress;
if (node.noderedipaddress === undefined || node.noderedipaddress === "AUTODISCOVER") {
node.noderedipaddress = GetEthAddress();
RED.log.info('ttsultimate-config ' + node.id + ': Autodiscover current IP ' + node.noderedipaddress);
}
node.whoIsUsingTheServer = ""; // Client node.id using the server, because only a ttsultimate node can use the serve at once.
node.ttsservice = config.ttsservice || "googletranslate";
node.TTSRootFolderPath = (config.TTSRootFolderPath === undefined || config.TTSRootFolderPath === "") ? path.join(RED.settings.userDir, "sonospollyttsstorage") : path.join(config.TTSRootFolderPath, "sonospollyttsstorage");
// 03/06/2019 you can select the temp dir
//#region "SETUP PATHS"
// 26/10/2020 Check for path and create it if doens't exists
function setupDirectory(_aPath) {
if (!fs.existsSync(_aPath)) {
// Create the path
try {
fs.mkdirSync(_aPath, { recursive: true });
return true;
} catch (error) { return false; }
} else {
return true;
}
}
if (!setupDirectory(node.TTSRootFolderPath)) {
RED.log.error('ttsultimate-config ' + node.id + ': Unable to set up MAIN directory: ' + node.TTSRootFolderPath);
}
if (!setupDirectory(path.join(node.TTSRootFolderPath, "ttsfiles"))) {
RED.log.error('ttsultimate-config ' + node.id + ': Unable to set up cache root directory: ' + path.join(node.TTSRootFolderPath, "ttsfiles"));
} else {
RED.log.info('ttsultimate-config ' + node.id + ': TTS cache root set to ' + path.join(node.TTSRootFolderPath, "ttsfiles"));
}
node.ttsCacheFolder = path.join(node.TTSRootFolderPath, "ttsfiles", node.id);
if (!setupDirectory(node.ttsCacheFolder)) {
RED.log.error('ttsultimate-config ' + node.id + ': Unable to set up cache directory: ' + node.ttsCacheFolder);
} else {
RED.log.info('ttsultimate-config ' + node.id + ': TTS cache set to ' + node.ttsCacheFolder);
}
if (!setupDirectory(path.join(node.TTSRootFolderPath, "ttsultimategooglecredentials"))) {
RED.log.error('ttsultimate-config ' + node.id + ': Unable to set google creds directory: ' + path.join(node.TTSRootFolderPath, "ttsultimategooglecredentials"));
} else {
RED.log.info('ttsultimate-config ' + node.id + ': google credentials path set to ' + path.join(node.TTSRootFolderPath, "ttsultimategooglecredentials"));
}
if (!setupDirectory(path.join(node.TTSRootFolderPath, "hailingpermanentfiles"))) {
RED.log.error('ttsultimate-config ' + node.id + ': Unable to set up hailing directory: ' + path.join(node.TTSRootFolderPath, "hailingpermanentfiles"));
} else {
RED.log.info('ttsultimate-config ' + node.id + ': hailing path set to ' + path.join(node.TTSRootFolderPath, "hailingpermanentfiles"));
// 09/03/2020 Copy defaults to the userDir
fs.readdirSync(path.join(__dirname, "hailingpermanentfiles")).forEach(file => {
try {
fs.copyFileSync(path.join(__dirname, "hailingpermanentfiles", file), path.join(node.TTSRootFolderPath, "hailingpermanentfiles", file));
} catch (error) { }
});
}
if (!setupDirectory(path.join(node.TTSRootFolderPath, "ttspermanentfiles"))) {
RED.log.error('ttsultimate-config ' + node.id + ': Unable to set up permanent files directory: ' + path.join(node.TTSRootFolderPath, "ttspermanentfiles"));
} else {
RED.log.info('ttsultimate-config ' + node.id + ': permanent files path set to ' + path.join(node.TTSRootFolderPath, "ttspermanentfiles"));
// 09/03/2020 // Copy the samples of permanent files into the userDir
fs.readdirSync(path.join(__dirname, "ttspermanentfiles")).forEach(file => {
try {
fs.copyFileSync(path.join(__dirname, "ttspermanentfiles", file), path.join(node.TTSRootFolderPath, "ttspermanentfiles", file));
} catch (error) { }
});
}
//#endregion
//#region "INSTANTIATE SERVICE ENGINES"
// Google TTS with authentication
if (node.ttsservice === "googletts") {
try {
// 23/12/2020 Set environment path of googleTTS
RED.log.info("ttsultimate-config " + node.id + ": Google credentials are stored in the file " + path.join(node.TTSRootFolderPath, "ttsultimategooglecredentials", "googlecredentials.json"));
process.env.GOOGLE_APPLICATION_CREDENTIALS = path.join(node.TTSRootFolderPath, "ttsultimategooglecredentials", "googlecredentials.json");
try {
node.googleTTS = new GoogleTTS.TextToSpeechClient();
RED.log.info("ttsultimate-config " + node.id + ": Google TTS service enabled. ")
} catch (error) {
RED.log.warn("ttsultimate-config " + node.id + ": Google TTS service disabled. " + error.message)
}
} catch (error) {
RED.log.warn("ttsultimate-config " + node.id + ": Google TTS service error: " + error.message)
}
} else {
// RED.log.info("ttsultimate-config " + node.id + ": Google TTS service not used.");
}
// Google Translate without authentication
if (node.ttsservice === "googletranslate") {
try {
node.googleTranslateTTS = GoogleTranslate;
} catch (error) {
//RED.log.info("ttsultimate-config " + node.id + ": Google Translate free service not used.");
}
} else {
//RED.log.info("ttsultimate-config " + node.id + ": Google Translate free service not used.");
}
// #########################################
// elevenlabsTTS v1 deprecated
if (node.ttsservice === "elevenlabs") {
node.elevenlabsTTSVoiceList = []
try {
node.elevenlabsTTS = elevenlabsTTS;
try {
node.elevenlabsTTS.getVoices(node.credentials.elevenlabsKey).then((res) => {
try {
res.voices.forEach(element => {
node.elevenlabsTTSVoiceList.push({ name: element.labels.accent + " - " + element.name + " (" + element.labels.gender + ")", id: element.voice_id })
});
node.elevenlabsTTSVoiceList.sort(function (a, b) {
let x = a.name.toLowerCase();
let y = b.name.toLowerCase();
if (x < y) { return -1; }
if (x > y) { return 1; }
return 0;
});
} catch (error) {
RED.log.error('ttsultimate-config ' + node.id + ': listVoices: Error getting elevenlabs 2 voices: ' + error.message);
node.elevenlabsTTSVoiceList.push({ name: "Error getting elevenlabs 2 voices: " + error.message + " Check cretentials, deploy and restart node-red.", id: "Ivy" })
}
});
} catch (error) {
RED.log.error('ttsultimate-config ' + node.id + ': listVoices: Error getting elevenlabs voices: ' + error.message);
node.elevenlabsTTSVoiceList.push({ name: "Error getting elevenlabs voices: " + error.message + " Check cretentials, deploy and restart node-red.", id: "Ivy" })
}
RED.log.info("ttsultimate-config " + node.id + ": elevenlabsTTS service enabled.")
} catch (error) {
RED.log.warn("ttsultimate-config " + node.id + ": elevenlabsTTS service disabled. " + error.message)
}
} else {
//RED.log.info("ttsultimate-config " + node.id + ": Google Translate free service not used.");
}
// elevenlabsTTS v2
if (node.ttsservice === "elevenlabsv2") {
const elevenlabsv2 = new ElevenLabsClient({
apiKey: node.credentials.elevenlabsKey
});
node.elevenlabsTTSVoiceList = []
try {
node.elevenlabsTTS = elevenlabsv2;
try {
node.elevenlabsTTS.voices.getAll().then((res) => {
try {
res.voices.forEach(element => {
node.elevenlabsTTSVoiceList.push({ name: element.labels.accent + (element.labels.language !== undefined ? " (" + element.labels.language + ")" : "") + " - " + element.name + " (" + element.labels.gender + ")", id: element.voice_id })
});
node.elevenlabsTTSVoiceList.sort(function (a, b) {
let x = a.name.toLowerCase();
let y = b.name.toLowerCase();
if (x < y) { return -1; }
if (x > y) { return 1; }
return 0;
});
} catch (error) {
RED.log.error('ttsultimate-config ' + node.id + ': listVoices: Error getting elevenlabsv2 voices: ' + error.message);
node.elevenlabsTTSVoiceList.push({ name: "Error getting elevenlabsv2 voices: " + error.message + " Check cretentials, deploy and restart node-red.", id: "Ivy" })
}
});
} catch (error) {
RED.log.error('ttsultimate-config ' + node.id + ': listVoices: Error getting elevenlabsv2 voices: ' + error.message);
node.elevenlabsTTSVoiceList.push({ name: "Error getting elevenlabsv2 voices: " + error.message + " Check cretentials, deploy and restart node-red.", id: "Ivy" })
}
RED.log.info("ttsultimate-config " + node.id + ": elevenlabsTTS servicev2 enabled.")
} catch (error) {
RED.log.warn("ttsultimate-config " + node.id + ": elevenlabsTTS servicev2 disabled. " + error.message)
}
} else {
//RED.log.info("ttsultimate-config " + node.id + ": Google Translate free service not used.");
}
// Voice.ai TTS
if (node.ttsservice === "voiceai") {
node.voiceAiVoiceList = [];
const loadVoiceAiVoices = async () => {
try {
const apiKey = node.credentials.voiceaiKey;
if (!apiKey || apiKey.trim() === "") {
node.voiceAiVoiceList = [{ name: "Voice.ai API key missing. Please configure, deploy and restart node-red.", id: "voiceai_default" }];
return;
}
const res = await fetch(`${VOICEAI_API_BASE_URL}/voices`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` }
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText}${body ? " - " + body : ""}`);
}
const payload = await res.json();
const voices = Array.isArray(payload) ? payload : [];
const list = [{ name: "Default (built-in)", id: "voiceai_default" }];
const voiceEntries = [];
voices.forEach(v => {
if (v && v.voice_id) {
const extra = [v.voice_visibility, v.status].filter(Boolean).join(", ");
voiceEntries.push({ name: `${v.name || v.voice_id}${extra ? " (" + extra + ")" : ""}`, id: v.voice_id });
}
});
voiceEntries.sort((a, b) => (a.name || "").toLowerCase().localeCompare((b.name || "").toLowerCase()));
node.voiceAiVoiceList = list.concat(voiceEntries);
RED.log.info("ttsultimate-config " + node.id + ": Voice.ai TTS enabled.");
} catch (error) {
RED.log.warn("ttsultimate-config " + node.id + ": Voice.ai TTS disabled. " + error.message);
node.voiceAiVoiceList = [{ name: "Error getting Voice.ai voices: " + error.message + " Check credentials, deploy and restart node-red.", id: "voiceai_default" }];
}
};
// Fire and forget; list will be available shortly after restart/deploy
loadVoiceAiVoices();
}
//#endregion
//#region HTTP ENDPOINTS
// ######################################################
// 21/03/2019 Endpoint for retrieving the default IP
RED.httpAdmin.get("/ttsultimateGetEthAddress", RED.auth.needsPermission('TTSConfigNode.read'), function (req, res) {
res.json(GetEthAddress());
});
function GetEthAddress() {
var oiFaces = oOS.networkInterfaces();
var jListInterfaces = [];
try {
Object.keys(oiFaces).forEach(ifname => {
// Interface with single IP
if (Object.keys(oiFaces[ifname]).length === 1) {
if (Object.keys(oiFaces[ifname])[0].internal == false) jListInterfaces.push({ name: ifname, address: Object.keys(oiFaces[ifname])[0].address });
} else {
var sAddresses = "";
oiFaces[ifname].forEach(function (iface) {
if (iface.internal == false && iface.family !== undefined && iface.family.toString().includes("4")) sAddresses = iface.address;
});
if (sAddresses !== "") jListInterfaces.push({ name: ifname, address: sAddresses });
}
})
} catch (error) { }
if (jListInterfaces.length > 0) {
return (jListInterfaces[0].address); // Retunr the first usable IP
} else {
return ("NO ETH INTERFACE FOUND");
}
}
// 20/03/2020 in the middle of coronavirus, get the sonos groups
RED.httpAdmin.get("/sonosgetAllGroups", RED.auth.needsPermission('TTSConfigNode.read'), function (req, res) {
var jListGroups = [];
try {
const discovery = new sonos.AsyncDeviceDiscovery()
discovery.discover().then((device, model) => {
return device.getAllGroups().then((groups) => {
//RED.log.warn('Groups ' + JSON.stringify(groups, null, 2))
for (let index = 0; index < groups.length; index++) {
const element = groups[index];
if (jListGroups.find(x => x.host === element.host) === undefined) jListGroups.push({ name: element.Name, host: element.host })
// 02/03/2023 If there are speakers grouped, read the single speaker
if (element.hasOwnProperty("ZoneGroupMember") && element.ZoneGroupMember.length > 0) {
try {
for (let i = 0; i < element.ZoneGroupMember.length; i++) {
const single = element.ZoneGroupMember[i];
let sHost = single.Location.split("//")[1].split(":")[0]
if (jListGroups.find(x => x.host === sHost) === undefined) jListGroups.push({ name: single.ZoneName, host: sHost }) // Get Name and IP from Location field
}
} catch (error) {
}
}
}
res.json(jListGroups)
//return groups[0].CoordinatorDevice().togglePlayback()
})
}).catch(e => {
RED.log.warn('ttsultimate-config ' + node.id + ': Error in discovery ' + e);
res.json("ERRORDISCOVERY");
})
} catch (error) { }
});
// 06/2026 Discover DLNA/UPnP MediaRenderers on the network (for the "dlna" player type)
RED.httpAdmin.get("/ttsultimateDiscoverDLNA", RED.auth.needsPermission('TTSConfigNode.read'), function (req, res) {
dlnaDiscovery.discoverRenderers({ timeoutMs: 4000 }).then((devices) => {
// Same shape as sonosgetAllGroups: { name, host }. For DLNA the "host" is the description URL.
const list = devices.map((d) => ({
name: d.name || "Renderer",
host: d.location
}));
res.json(list);
}).catch((error) => {
RED.log.warn('ttsultimate-config ' + node.id + ': Error discovering DLNA renderers ' + error.message);
res.json("ERRORDISCOVERY");
});
});
// 06/2026 Discover Google Cast devices (Chromecast / Nest) on the network (for the "googlecast" player type)
RED.httpAdmin.get("/ttsultimateDiscoverCast", RED.auth.needsPermission('TTSConfigNode.read'), function (req, res) {
castDiscovery.discoverCastDevices({ timeoutMs: 4000 }).then((devices) => {
// Same shape as sonosgetAllGroups: { name, host }
const list = devices.map((d) => ({
name: d.name,
host: d.host
}));
res.json(list);
}).catch((error) => {
RED.log.warn('ttsultimate-config ' + node.id + ': Error discovering Google Cast devices ' + error.message);
res.json("ERRORDISCOVERY");
});
});
// 09/03/2020 Get list of filenames in hailing folder
RED.httpAdmin.get("/getHailingFilesList", RED.auth.needsPermission('TTSConfigNode.read'), function (req, res) {
var jListOwnFiles = [];
var sName = "";
try {
fs.readdirSync(path.join(node.TTSRootFolderPath, "hailingpermanentfiles")).forEach(file => {
if (file.indexOf("Hailing_") > -1) {
sName = file.replace("Hailing_", "").replace(".mp3", "");
jListOwnFiles.push({ name: sName, filename: file });
}
});
} catch (error) { }
res.json(jListOwnFiles)
});
// 09/03/2020 Delete Hailing
RED.httpAdmin.get("/deleteHailingFile", RED.auth.needsPermission('TTSConfigNode.read'), function (req, res) {
// Delete the file
try {
var newPath = path.join(node.TTSRootFolderPath, "hailingpermanentfiles", req.query.FileName);
fs.unlinkSync(newPath)
} catch (error) { }
res.json({ status: 220 });
});
// 09/03/2020 Receive new hailing files from html
RED.httpAdmin.post("/ttsultimateHailing", function (req, res) {
var form = new formidable.IncomingForm();
form.parse(req, function (err, fields, files) {
try {
if (files.customHailing.name.indexOf(".mp3") !== -1) {
var newPath = path.join(node.TTSRootFolderPath, "hailingpermanentfiles", "Hailing_" + files.customHailing.name);
// 30/12/2020 To avoid XDEV issue: oldpath and newpath are not on the same mounted filesystem. (Linux permits a filesystem to be mounted at multiple points,
// but rename() does not work across different mount points, even if the same filesystem is mounted on both.)
// Instead of renaming it, i must copy the file and then delete the old one.
try {
fs.unlinkSync(newPath)
} catch (error) {
}
fs.copyFileSync(files.customHailing.path, newPath)
try {
fs.unlinkSync(files.customHailing.path);
} catch (error) {
// Don't care
}
res.json({ status: 220 });
res.end;
}
} catch (error) {
RED.log.error("Upload Hailing " + error.message);
res.json({ status: 404, message: "Unable to write to the filesystem. PLEASE CHECK THAT THE USER RUNNING NODE-RED, HAS PERMISSIONS TO WRITE TO THE FILESYSTEM." });
res.end;
}
});
});
// 22/12/2020 Receive the google credential and on the fly set the environment path
RED.httpAdmin.post("/ttsultimatesavegooglecredentialsfile", function (req, res) {
var form = new formidable.IncomingForm();
form.parse(req, function (err, fields, files) {
if (err) { };
// Allow only json
if (files.googleCreds.name.indexOf(".json") !== -1) {
var newPath = path.join(node.TTSRootFolderPath, "ttsultimategooglecredentials", "googlecredentials.json");
// Set the environment variable
process.env.GOOGLE_APPLICATION_CREDENTIALS = newPath;
// 30/12/2020 To avoid XDEV issue: oldpath and newpath are not on the same mounted filesystem. (Linux permits a filesystem to be mounted at multiple points,
// but rename() does not work across different mount points, even if the same filesystem is mounted on both.)
// Instead of renaming it, i must copy the file and then delete the old one.
try {
fs.unlinkSync(newPath)
} catch (error) {
}
fs.copyFileSync(files.googleCreds.path, newPath)
try {
fs.unlinkSync(files.googleCreds.path);
} catch (error) {
// Don't care
}
}
});
res.json({ status: 220 });
res.end;
});
// 26/10/2020 Supergiovane, get the updated voice list.
RED.httpAdmin.get("/ttsgetvoices" + encodeURIComponent(node.id), RED.auth.needsPermission('TTSConfigNode.read'), function (req, res) {
var ttsservice = req.query.ttsservice;// Retrieve the ttsservice engine
var jListVoices = [];
// 23/12/2020 Based on tts service engine, return appropriate voice list
if (ttsservice === "googletts") {
async function listVoices() {
try {
const [result] = await node.googleTTS.listVoices({});
const voices = result.voices;
voices.forEach(oVoice => {
oVoice.languageCodes.forEach(languageCode => {
jListVoices.push({ name: languageCode + " " + oVoice.name + " - " + oVoice.ssmlGender, id: oVoice.name + "#" + languageCode + "#" + oVoice.ssmlGender })
});
});
res.json(jListVoices)
} catch (error) {
RED.log.error('ttsultimate-config ' + node.id + ': Error getting google TTS voices ' + error.message + " Please deploy and restart node-red.");
jListVoices.push({ name: "Error getting Google TTS voices. " + error.message + " Check credentials, deploy and restart node-red.", id: "Ivy" })
res.json(jListVoices)
}
};
try {
listVoices();
} catch (error) {
RED.log.error('ttsultimate-config ' + node.id + ': listVoices: Error getting google TTS voices ' + error.message + " Please deploy and restart node-red.");
}
} else if (ttsservice === "googletranslate") {
async function listVoices() {
try {
const voices = node.googleTranslateTTS.voices;
voices.forEach(oVoice => {
jListVoices.push({ name: oVoice.name + " - " + oVoice.code, id: oVoice.code })
});
res.json(jListVoices)
} catch (error) {
RED.log.error('ttsultimate-config ' + node.id + ': Error getting google Translate voices ' + error.message);
jListVoices.push({ name: "Error getting Google Translate voices. " + error.message + " Deploy and restart node-red.", id: "Ivy" })
res.json(jListVoices)
}
};
try {
listVoices();
} catch (error) {
RED.log.error('ttsultimate-config ' + node.id + ': listVoices: Error getting google Translate voices ' + error.message + " Please deploy and restart node-red.");
}
} else if (ttsservice.includes("elevenlabs")) {
res.json(node.elevenlabsTTSVoiceList);
} else if (ttsservice === "voiceai") {
const ensureList = async () => {
try {
if (Array.isArray(node.voiceAiVoiceList) && node.voiceAiVoiceList.length > 0) {
return node.voiceAiVoiceList;
}
const apiKey = node.credentials.voiceaiKey;
if (!apiKey || apiKey.trim() === "") {
return [{ name: "Voice.ai API key missing. Please configure, deploy and restart node-red.", id: "voiceai_default" }];
}
const resVoices = await fetch(`${VOICEAI_API_BASE_URL}/voices`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` }
});
if (!resVoices.ok) {
const body = await resVoices.text().catch(() => "");
throw new Error(`HTTP ${resVoices.status} ${resVoices.statusText}${body ? " - " + body : ""}`);
}
const payload = await resVoices.json();
const voices = Array.isArray(payload) ? payload : [];
const list = [{ name: "Default (built-in)", id: "voiceai_default" }];
const voiceEntries = [];
voices.forEach(v => {
if (v && v.voice_id) {
const extra = [v.voice_visibility, v.status].filter(Boolean).join(", ");
voiceEntries.push({ name: `${v.name || v.voice_id}${extra ? " (" + extra + ")" : ""}`, id: v.voice_id });
}
});
voiceEntries.sort((a, b) => (a.name || "").toLowerCase().localeCompare((b.name || "").toLowerCase()));
node.voiceAiVoiceList = list.concat(voiceEntries);
return node.voiceAiVoiceList;
} catch (error) {
return [{ name: "Error getting Voice.ai voices: " + error.message + " Check credentials, deploy and restart node-red.", id: "voiceai_default" }];
}
};
ensureList().then(list => res.json(list));
}
});
// 10/06/2026 Supergiovane, get the available ElevenLabs models with their capabilities.
// Used to dynamically populate the Model dropdown and enable/disable model-specific options.
RED.httpAdmin.get("/ttsgetmodels" + encodeURIComponent(node.id), RED.auth.needsPermission('TTSConfigNode.read'), function (req, res) {
const ttsservice = req.query.ttsservice || node.ttsservice;
if (!ttsservice || !ttsservice.includes("elevenlabs")) {
return res.json([]);
}
const apiKey = node.credentials.elevenlabsKey;
if (!apiKey || apiKey.trim() === "") {
return res.json([{ model_id: "", name: "ElevenLabs API key missing. Please configure, deploy and restart node-red.", error: true }]);
}
(async () => {
try {
const r = await fetch("https://api.elevenlabs.io/v1/models", {
method: "GET",
headers: { "xi-api-key": apiKey }
});
if (!r.ok) {
const body = await r.text().catch(() => "");
throw new Error(`HTTP ${r.status} ${r.statusText}${body ? " - " + body : ""}`);
}
const models = await r.json();
const list = (Array.isArray(models) ? models : [])
.filter(m => m && m.model_id && m.can_do_text_to_speech !== false)
.map(m => ({
model_id: m.model_id,
name: m.name || m.model_id,
can_use_style: m.can_use_style === true,
can_use_speaker_boost: m.can_use_speaker_boost === true,
maximum_text_length_per_request: m.maximum_text_length_per_request,
languages: Array.isArray(m.languages) ? m.languages.map(l => l.name || l.language_id).filter(Boolean) : []
}));
res.json(list);
} catch (error) {
RED.log.error('ttsultimate-config ' + node.id + ': Error getting ElevenLabs models: ' + error.message);
res.json([{ model_id: "", name: "Error getting ElevenLabs models: " + error.message + " Check credentials, deploy and restart node-red.", error: true }]);
}
})();
});
// ########################################################
//#endregion
//#region OWNFILE NODE HTTP ENDPOINTS
// ######################################################
// Receive new files from html
RED.httpAdmin.post("/ttsultimateOwnFile", function (req, res) {
var form = new formidable.IncomingForm();
form.parse(req, function (err, fields, files) {
if (err) { };
// Allow only mp3
try {
if (files.customTTS.name.indexOf(".mp3") !== -1) {
var newPath = path.join(node.TTSRootFolderPath, "ttspermanentfiles", "OwnFile_" + files.customTTS.name);
// 30/12/2020 To avoid XDEV issue: oldpath and newpath are not on the same mounted filesystem. (Linux permits a filesystem to be mounted at multiple points,
// but rename() does not work across different mount points, even if the same filesystem is mounted on both.)
// Instead of renaming it, i must copy the file and then delete the old one.
try {
fs.unlinkSync(newPath)
} catch (error) {
}
fs.copyFileSync(files.customTTS.path, newPath)
try {
fs.unlinkSync(files.customTTS.path);
} catch (error) {
// Don't care
}
res.json({ status: 220 });
res.end;
}
} catch (error) {
RED.log.error("OwnFile " + error.message);
res.json({ status: 404, message: "Unable to write to the filesystem. PLEASE CHECK THAT THE USER RUNNING NODE-RED, HAS PERMISSIONS TO WRITE TO THE FILESYSTEM." });
res.end;
}
});
});
// 27/02/2020 Get list of filenames starting with OwnFile_
RED.httpAdmin.get("/getOwnFilesList", RED.auth.needsPermission('TTSConfigNode.read'), function (req, res) {
var jListOwnFiles = [];
var sName = "";
try {
fs.readdirSync(path.join(node.TTSRootFolderPath, "ttspermanentfiles")).forEach(file => {
if (file.indexOf("OwnFile_") > -1) {
sName = file.replace("OwnFile_", '').replace(".mp3", '');
jListOwnFiles.push({ name: sName, filename: file });
}
});
} catch (error) { }
res.json(jListOwnFiles)
});
// 27/02/2020 Delete OwnFile_
RED.httpAdmin.get("/deleteOwnFile", RED.auth.needsPermission('TTSConfigNode.read'), function (req, res) {
try {
if (req.query.FileName == "DELETEallFiles") {
// Delete all OwnFiles_
try {
fs.readdir(path.join(node.TTSRootFolderPath, "ttspermanentfiles"), (err, files) => {
files.forEach(function (file) {
if (file.indexOf("OwnFile_") !== -1) {
RED.log.warn("ttsultimate-config " + node.id + ": Deleted file " + path.join(node.TTSRootFolderPath, "ttspermanentfiles", file));
try {
fs.unlinkSync(path.join(node.TTSRootFolderPath, "ttspermanentfiles", file));
} catch (error) { }
}
});
});
} catch (error) { }
} else {
// Delete only one file
try {
var newPath = path.join(node.TTSRootFolderPath, "ttspermanentfiles", req.query.FileName);
try {
fs.unlinkSync(newPath)
} catch (error) { }
} catch (error) { }
}
} catch (err) {
}
res.json({ status: 220 });
});
// ########################################################
//#endregion
node.oWebserver; // 11/11/2019 Stores the Webserver
node.purgediratrestart = config.purgediratrestart || "leave"; // 26/02/2020
node.noderedport = typeof config.noderedport === "undefined" ? "1980" : config.noderedport;
// 11/11/2019 NEW in V 1.1.0, changed webserver behaviour. Redirect pre V. 1.1.0 1880 ports to the nde default 1980
if (node.noderedport.trim() == "1880") {
RED.log.warn("ttsultimate-config " + node.id + ": The webserver port ist 1880. Please change it to another port, not to conflict with default node-red 1880 port. I've changed this temporarly for you to 1980");
node.noderedport = "1980";
}
node.sNoderedURL = "http://" + node.noderedipaddress.trim() + ":" + node.noderedport.trim(); // 11/11/2019 New Endpoint to overcome https problem.
RED.log.info('ttsultimate-config ' + node.id + ': Node-Red node.js Endpoint will be created here: ' + node.sNoderedURL + "/tts");
// 26/02/2020
if (node.purgediratrestart === "purge") {
try {
let files = fs.readdirSync(node.ttsCacheFolder);
try {
if (files.length > 0) {
files.forEach(function (file) {
RED.log.info("ttsultimate-config " + node.id + ": Deleted TTS file " + path.join(node.ttsCacheFolder, file));
try {
fs.unlinkSync(path.join(node.ttsCacheFolder, file));
} catch (error) {
}
});
};
} catch (error) { }
} catch (error) { }
};
// 11/11/2019 CREATE THE ENDPOINT
// #################################
const http = require('http')
const sWebport = node.noderedport.trim();
const requestHandler = (req, res) => {
try {
var url = require('url');
var url_parts = url.parse(req.url, true);
var query = url_parts.query;
if (!query || query.f === undefined || query.f === null) {
res.statusCode = 400;
res.end("File not specified");
return;
}
const requestedPath = query.f.toString();
if (!fs.existsSync(requestedPath)) {
RED.log.error("ttsultimate-config " + node.id + ": Playsonos RED.httpAdmin file not found: " + query.f);
res.statusCode = 404;
res.end("File not found");
return;
}
// Security check: allow only mp3 files under the configured storage folder.
// http://localhost:1980/tts?f=/etc/passwd
const resolvedRequested = path.resolve(requestedPath);
const resolvedAllowedRoot = path.resolve(node.TTSRootFolderPath);
const isInsideRoot =
resolvedRequested === resolvedAllowedRoot || resolvedRequested.startsWith(resolvedAllowedRoot + path.sep);
if (path.extname(resolvedRequested) !== ".mp3" || !isInsideRoot) {
res.statusCode = 403;
res.end("NOT ALLOWED");
return;
}
// Serve as a proper streaming media response. Strict DLNA renderers (e.g. some TVs)
// require Content-Type, Content-Length, byte-range support and DLNA headers to start playing.
const total = fs.statSync(resolvedRequested).size;
res.setHeader('Content-Type', 'audio/mpeg');
res.setHeader('Content-Disposition', 'inline; filename="tts.mp3"');
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('transferMode.dlna.org', 'Streaming');
res.setHeader('contentFeatures.dlna.org', 'DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000');
// HEAD: headers only (many DLNA renderers probe with HEAD before playing).
if (req.method === 'HEAD') {
res.setHeader('Content-Length', total);
res.statusCode = 200;
res.end();
return;
}
let readStream;
const range = req.headers.range;
if (range) {
const m = /bytes=(\d*)-(\d*)/.exec(range);
let start = m && m[1] ? parseInt(m[1], 10) : 0;
let end = m && m[2] ? parseInt(m[2], 10) : total - 1;
if (isNaN(start)) start = 0;
if (isNaN(end) || end >= total) end = total - 1;
if (start > end || start >= total) {
res.statusCode = 416;
res.setHeader('Content-Range', 'bytes */' + total);
res.end();
return;
}
res.statusCode = 206;
res.setHeader('Content-Range', 'bytes ' + start + '-' + end + '/' + total);
res.setHeader('Content-Length', (end - start) + 1);
readStream = fs.createReadStream(resolvedRequested, { start: start, end: end });
} else {
res.statusCode = 200;
res.setHeader('Content-Length', total);
readStream = fs.createReadStream(resolvedRequested);
}
readStream.on("error", function (error) {
RED.log.error("ttsultimate-config " + node.id + ": Playsonos error opening stream : " + resolvedRequested + ' : ' + error);
try { res.end(); } catch (e) { }
});
readStream.pipe(res);
} catch (error) {
RED.log.error("ttsultimate-config " + node.id + ": Playsonos RED.httpAdmin error: " + error);
try { res.end(); } catch (e) { }
}
}
try {
node.oWebserver = http.createServer(requestHandler);
node.oWebserver.on('error', function (e) {
RED.log.error("ttsultimate-config " + node.id + ": " + node.ID + " error starting webserver on port " + sWebport + " " + e);
});
} catch (error) {
// Already open. Close it and redo.
RED.log.error("ttsultimate-config " + node.id + ": Webserver creation error: " + error);
}
try {
node.oWebserver.listen(sWebport, (err) => {
if (err) {
RED.log.error("ttsultimate-config " + node.id + ": error listening webserver on port " + sWebport + " " + err);
}
});
} catch (error) {
// In case oWebserver is null
RED.log.error("ttsultimate-config " + node.id + ": error listening webserver on port " + sWebport + " " + error);
}
// #################################
node.on('close', function (done) {
// 11/11/2019 Close the Webserver
try {
node.oWebserver.close(function () { RED.log.info("ttsultimate-config " + node.id + ": Webserver UP. Closing down."); });
} catch (error) {
}
let t = setTimeout(function () {
// Wait some time to allow time to do promises.
done();
}, 500);
});
}
RED.nodes.registerType("ttsultimate-config", TTSConfigNode, {
credentials: {
elevenlabsKey: { type: "text" },
voiceaiKey: { type: "text" }
}
});
}