node-red-contrib-tts-ultimate 3.1.1 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +152 -68
- package/img/audio-file.svg +8 -0
- package/img/logo-v2.svg +39 -0
- package/package.json +7 -7
- package/scripts/discover-dlna.js +44 -0
- package/scripts/verify-googletranslate-split.js +1 -1
- package/ttsultimate/lib/dlna-discovery.js +98 -0
- package/ttsultimate/lib/dlna-player.js +163 -0
- package/ttsultimate/lib/googlecast-discovery.js +69 -0
- package/ttsultimate/lib/googletranslate.js +140 -0
- package/ttsultimate/ttsultimate-config copy.js +1 -1
- package/ttsultimate/ttsultimate-config.html +3 -3
- package/ttsultimate/ttsultimate-config.js +98 -28
- package/ttsultimate/ttsultimate.html +139 -19
- package/ttsultimate/ttsultimate.js +153 -11
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Minimal native DLNA / UPnP MediaRenderer control client.
|
|
2
|
+
// Unlike upnp-mediarenderer-client, it locates the AVTransport / RenderingControl
|
|
3
|
+
// services anywhere in the device tree (root device OR embedded <deviceList> devices),
|
|
4
|
+
// so it also works with renderers that nest a MediaRenderer sub-device (e.g. Sonos).
|
|
5
|
+
//
|
|
6
|
+
// It uses only the built-in http module (no XML parser dependency): the UPnP
|
|
7
|
+
// description and SOAP responses are simple enough to read with focused regexes.
|
|
8
|
+
const http = require("http");
|
|
9
|
+
const { URL } = require("url");
|
|
10
|
+
|
|
11
|
+
function httpRequest(options, body) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const req = http.request(options, (res) => {
|
|
14
|
+
let data = "";
|
|
15
|
+
res.on("data", (c) => (data += c));
|
|
16
|
+
res.on("end", () => resolve({ statusCode: res.statusCode, body: data }));
|
|
17
|
+
});
|
|
18
|
+
req.on("error", reject);
|
|
19
|
+
req.on("timeout", () => { req.destroy(new Error("request timeout")); });
|
|
20
|
+
if (body) req.write(body);
|
|
21
|
+
req.end();
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getText(xml, tag) {
|
|
26
|
+
const m = xml.match(new RegExp("<" + tag + "[^>]*>([\\s\\S]*?)</" + tag + ">", "i"));
|
|
27
|
+
return m ? m[1].trim() : "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function escapeXml(s) {
|
|
31
|
+
return String(s)
|
|
32
|
+
.replace(/&/g, "&")
|
|
33
|
+
.replace(/</g, "<")
|
|
34
|
+
.replace(/>/g, ">")
|
|
35
|
+
.replace(/"/g, """)
|
|
36
|
+
.replace(/'/g, "'");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Fetch the device description and resolve the (absolute) control URLs for
|
|
40
|
+
// AVTransport and RenderingControl, scanning every <service> in the document.
|
|
41
|
+
async function resolveServices(descriptionUrl) {
|
|
42
|
+
let u;
|
|
43
|
+
try {
|
|
44
|
+
u = new URL(descriptionUrl);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
throw new Error('invalid DLNA renderer URL "' + descriptionUrl + '": use the full device description XML URL (e.g. http://192.168.1.50:49153/nmrDescription.xml), not just the IP address');
|
|
47
|
+
}
|
|
48
|
+
if (!/^https?:$/.test(u.protocol)) {
|
|
49
|
+
throw new Error('DLNA renderer URL must start with http:// (got "' + descriptionUrl + '")');
|
|
50
|
+
}
|
|
51
|
+
const res = await httpRequest({
|
|
52
|
+
hostname: u.hostname,
|
|
53
|
+
port: u.port || 80,
|
|
54
|
+
path: u.pathname + u.search,
|
|
55
|
+
method: "GET",
|
|
56
|
+
timeout: 5000
|
|
57
|
+
});
|
|
58
|
+
if (!res.body) throw new Error("empty device description");
|
|
59
|
+
const xml = res.body;
|
|
60
|
+
const urlBase = getText(xml, "URLBase");
|
|
61
|
+
const base = urlBase || descriptionUrl;
|
|
62
|
+
|
|
63
|
+
const services = {};
|
|
64
|
+
const svcRe = /<service>([\s\S]*?)<\/service>/g;
|
|
65
|
+
let m;
|
|
66
|
+
while ((m = svcRe.exec(xml)) !== null) {
|
|
67
|
+
const block = m[1];
|
|
68
|
+
const type = getText(block, "serviceType");
|
|
69
|
+
const ctrl = getText(block, "controlURL");
|
|
70
|
+
if (!type || !ctrl) continue;
|
|
71
|
+
const controlURL = new URL(ctrl, base).href;
|
|
72
|
+
// Match exact service ids; ":service:RenderingControl:" will not match "GroupRenderingControl".
|
|
73
|
+
if (/:service:AVTransport:\d/i.test(type) && !services.AVTransport) {
|
|
74
|
+
services.AVTransport = { type, controlURL };
|
|
75
|
+
} else if (/:service:RenderingControl:\d/i.test(type) && !services.RenderingControl) {
|
|
76
|
+
services.RenderingControl = { type, controlURL };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!services.AVTransport) throw new Error("AVTransport service not found in device description");
|
|
80
|
+
return services;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function soapAction(controlURL, serviceType, action, argsXml) {
|
|
84
|
+
const u = new URL(controlURL);
|
|
85
|
+
const body =
|
|
86
|
+
'<?xml version="1.0" encoding="utf-8"?>' +
|
|
87
|
+
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' +
|
|
88
|
+
"<s:Body>" +
|
|
89
|
+
'<u:' + action + ' xmlns:u="' + serviceType + '">' + argsXml + "</u:" + action + ">" +
|
|
90
|
+
"</s:Body></s:Envelope>";
|
|
91
|
+
return httpRequest({
|
|
92
|
+
hostname: u.hostname,
|
|
93
|
+
port: u.port || 80,
|
|
94
|
+
path: u.pathname + u.search,
|
|
95
|
+
method: "POST",
|
|
96
|
+
timeout: 8000,
|
|
97
|
+
headers: {
|
|
98
|
+
"Content-Type": 'text/xml; charset="utf-8"',
|
|
99
|
+
"SOAPACTION": '"' + serviceType + "#" + action + '"',
|
|
100
|
+
"Content-Length": Buffer.byteLength(body)
|
|
101
|
+
}
|
|
102
|
+
}, body).then((res) => {
|
|
103
|
+
if (res.statusCode >= 400) {
|
|
104
|
+
const errCode = getText(res.body, "errorCode");
|
|
105
|
+
throw new Error("SOAP " + action + " failed: HTTP " + res.statusCode + (errCode ? " (UPnP error " + errCode + ")" : ""));
|
|
106
|
+
}
|
|
107
|
+
return res.body;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildDidl(url, contentType) {
|
|
112
|
+
const protocolInfo = "http-get:*:" + (contentType || "audio/mpeg") + ":*";
|
|
113
|
+
return (
|
|
114
|
+
'<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" ' +
|
|
115
|
+
'xmlns:dc="http://purl.org/dc/elements/1.1/" ' +
|
|
116
|
+
'xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">' +
|
|
117
|
+
'<item id="0" parentID="-1" restricted="1">' +
|
|
118
|
+
"<dc:title>TTS</dc:title>" +
|
|
119
|
+
"<upnp:class>object.item.audioItem.musicTrack</upnp:class>" +
|
|
120
|
+
'<res protocolInfo="' + protocolInfo + '">' + escapeXml(url) + "</res>" +
|
|
121
|
+
"</item></DIDL-Lite>"
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// createPlayer(descriptionUrl) -> { setVolume, setAVTransportURI, play, getTransportState }
|
|
126
|
+
function createPlayer(descriptionUrl) {
|
|
127
|
+
let servicesPromise = null;
|
|
128
|
+
const getServices = () => {
|
|
129
|
+
if (!servicesPromise) servicesPromise = resolveServices(descriptionUrl);
|
|
130
|
+
return servicesPromise;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
async setVolume(volume0to100) {
|
|
135
|
+
const svc = await getServices();
|
|
136
|
+
if (!svc.RenderingControl) return; // not all renderers expose volume control
|
|
137
|
+
const args =
|
|
138
|
+
"<InstanceID>0</InstanceID><Channel>Master</Channel>" +
|
|
139
|
+
"<DesiredVolume>" + Math.max(0, Math.min(100, Math.round(volume0to100))) + "</DesiredVolume>";
|
|
140
|
+
return soapAction(svc.RenderingControl.controlURL, svc.RenderingControl.type, "SetVolume", args);
|
|
141
|
+
},
|
|
142
|
+
async setAVTransportURI(url, contentType) {
|
|
143
|
+
const svc = await getServices();
|
|
144
|
+
const didl = buildDidl(url, contentType);
|
|
145
|
+
const args =
|
|
146
|
+
"<InstanceID>0</InstanceID>" +
|
|
147
|
+
"<CurrentURI>" + escapeXml(url) + "</CurrentURI>" +
|
|
148
|
+
"<CurrentURIMetaData>" + escapeXml(didl) + "</CurrentURIMetaData>";
|
|
149
|
+
return soapAction(svc.AVTransport.controlURL, svc.AVTransport.type, "SetAVTransportURI", args);
|
|
150
|
+
},
|
|
151
|
+
async play() {
|
|
152
|
+
const svc = await getServices();
|
|
153
|
+
return soapAction(svc.AVTransport.controlURL, svc.AVTransport.type, "Play", "<InstanceID>0</InstanceID><Speed>1</Speed>");
|
|
154
|
+
},
|
|
155
|
+
async getTransportState() {
|
|
156
|
+
const svc = await getServices();
|
|
157
|
+
const bodyXml = await soapAction(svc.AVTransport.controlURL, svc.AVTransport.type, "GetTransportInfo", "<InstanceID>0</InstanceID>");
|
|
158
|
+
return getText(bodyXml, "CurrentTransportState");
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { createPlayer, resolveServices };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Discover Google Cast devices (Chromecast / Google Nest) on the local network
|
|
2
|
+
// via mDNS / DNS-SD (service "_googlecast._tcp.local").
|
|
3
|
+
const mdns = require("multicast-dns");
|
|
4
|
+
|
|
5
|
+
const SERVICE = "_googlecast._tcp.local";
|
|
6
|
+
|
|
7
|
+
// discoverCastDevices({ timeoutMs }) -> Promise<[{ name, host }]>
|
|
8
|
+
function discoverCastDevices(options) {
|
|
9
|
+
const opts = options || {};
|
|
10
|
+
const timeoutMs = Number(opts.timeoutMs) || 4000;
|
|
11
|
+
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let socket;
|
|
14
|
+
try {
|
|
15
|
+
socket = mdns();
|
|
16
|
+
} catch (error) {
|
|
17
|
+
resolve([]);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const srvByName = new Map(); // service instance name -> target hostname
|
|
22
|
+
const fnByName = new Map(); // service instance name -> friendly name (TXT "fn=")
|
|
23
|
+
const ipByHost = new Map(); // hostname -> IPv4 address
|
|
24
|
+
|
|
25
|
+
const handleRecords = (records) => {
|
|
26
|
+
(records || []).forEach((r) => {
|
|
27
|
+
if (!r || !r.name) return;
|
|
28
|
+
if (r.type === "SRV" && /_googlecast\._tcp/i.test(r.name) && r.data) {
|
|
29
|
+
srvByName.set(r.name, r.data.target);
|
|
30
|
+
} else if (r.type === "TXT" && /_googlecast\._tcp/i.test(r.name)) {
|
|
31
|
+
const arr = Array.isArray(r.data) ? r.data : [r.data];
|
|
32
|
+
arr.forEach((entry) => {
|
|
33
|
+
const s = (entry || "").toString();
|
|
34
|
+
if (/^fn=/i.test(s)) fnByName.set(r.name, s.slice(3));
|
|
35
|
+
});
|
|
36
|
+
} else if (r.type === "A" && r.data) {
|
|
37
|
+
ipByHost.set(r.name, r.data);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
socket.on("response", (response) => {
|
|
43
|
+
handleRecords(response.answers);
|
|
44
|
+
handleRecords(response.additionals);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const query = () => {
|
|
48
|
+
try { socket.query({ questions: [{ name: SERVICE, type: "PTR" }] }); } catch (e) { }
|
|
49
|
+
};
|
|
50
|
+
query();
|
|
51
|
+
setTimeout(query, 800);
|
|
52
|
+
|
|
53
|
+
setTimeout(() => {
|
|
54
|
+
try { socket.destroy(); } catch (e) { }
|
|
55
|
+
const devices = [];
|
|
56
|
+
const seenIp = new Set();
|
|
57
|
+
srvByName.forEach((target, name) => {
|
|
58
|
+
const ip = ipByHost.get(target);
|
|
59
|
+
if (!ip || seenIp.has(ip)) return;
|
|
60
|
+
seenIp.add(ip);
|
|
61
|
+
const fn = fnByName.get(name) || name.split(".")[0];
|
|
62
|
+
devices.push({ name: fn, host: ip });
|
|
63
|
+
});
|
|
64
|
+
resolve(devices);
|
|
65
|
+
}, timeoutMs);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { discoverCastDevices };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Native Google Translate TTS implementation.
|
|
2
|
+
// Replaces the external "google-translate-tts" dependency.
|
|
3
|
+
// Exposes the same public API: { synthesize, voices }.
|
|
4
|
+
//
|
|
5
|
+
// It performs an unauthenticated POST to the Google Translate web endpoint
|
|
6
|
+
// (the same one the translate.google.com page uses for the "listen" button)
|
|
7
|
+
// and decodes the base64 MP3 audio from the response.
|
|
8
|
+
const https = require("https");
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Voices supported by Google Translate (free, unauthenticated) TTS.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
const voices = (() => {
|
|
14
|
+
// prettier-ignore
|
|
15
|
+
const list = [
|
|
16
|
+
{ code: "af-ZA", name: "Afrikaans" },
|
|
17
|
+
{ code: "sq", name: "Albanian" },
|
|
18
|
+
{ code: "ar-AE", name: "Arabic" },
|
|
19
|
+
{ code: "hy", name: "Armenian" },
|
|
20
|
+
{ code: "bn-BD", name: "Bengali (Bangladesh)" },
|
|
21
|
+
{ code: "bn-IN", name: "Bengali (India)" },
|
|
22
|
+
{ code: "bs", name: "Bosnian" },
|
|
23
|
+
{ code: "my", name: "Burmese (Myanmar)" },
|
|
24
|
+
{ code: "ca-ES", name: "Catalan" },
|
|
25
|
+
{ code: "cmn-Hant-TW", name: "Chinese" },
|
|
26
|
+
{ code: "hr-HR", name: "Croatian" },
|
|
27
|
+
{ code: "cs-CZ", name: "Czech" },
|
|
28
|
+
{ code: "da-DK", name: "Danish" },
|
|
29
|
+
{ code: "nl-NL", name: "Dutch" },
|
|
30
|
+
{ code: "en-AU", name: "English (Australia)" },
|
|
31
|
+
{ code: "en-GB", name: "English (United Kingdom)" },
|
|
32
|
+
{ code: "en-US", name: "English (United States)" },
|
|
33
|
+
{ code: "eo", name: "Esperanto" },
|
|
34
|
+
{ code: "et", name: "Estonian" },
|
|
35
|
+
{ code: "fil-PH", name: "Filipino" },
|
|
36
|
+
{ code: "fi-FI", name: "Finnish" },
|
|
37
|
+
{ code: "fr-FR", name: "French" },
|
|
38
|
+
{ code: "fr-CA", name: "French (Canada)" },
|
|
39
|
+
{ code: "de-DE", name: "German" },
|
|
40
|
+
{ code: "el-GR", name: "Greek" },
|
|
41
|
+
{ code: "gu", name: "Gujarati" },
|
|
42
|
+
{ code: "hi-IN", name: "Hindi" },
|
|
43
|
+
{ code: "hu-HU", name: "Hungarian" },
|
|
44
|
+
{ code: "is-IS", name: "Icelandic" },
|
|
45
|
+
{ code: "id-ID", name: "Indonesian" },
|
|
46
|
+
{ code: "it-IT", name: "Italian" },
|
|
47
|
+
{ code: "ja-JP", name: "Japanese (Japan)" },
|
|
48
|
+
{ code: "kn", name: "Kannada" },
|
|
49
|
+
{ code: "km", name: "Khmer" },
|
|
50
|
+
{ code: "ko-KR", name: "Korean" },
|
|
51
|
+
{ code: "la", name: "Latin" },
|
|
52
|
+
{ code: "lv", name: "Latvian" },
|
|
53
|
+
{ code: "mk", name: "Macedonian" },
|
|
54
|
+
{ code: "ml", name: "Malayalam" },
|
|
55
|
+
{ code: "mr", name: "Marathi" },
|
|
56
|
+
{ code: "ne", name: "Nepali" },
|
|
57
|
+
{ code: "nb-NO", name: "Norwegian" },
|
|
58
|
+
{ code: "pl-PL", name: "Polish" },
|
|
59
|
+
{ code: "pt-BR", name: "Portuguese" },
|
|
60
|
+
{ code: "ro-RO", name: "Romanian" },
|
|
61
|
+
{ code: "ru-RU", name: "Russian" },
|
|
62
|
+
{ code: "sr-RS", name: "Serbian" },
|
|
63
|
+
{ code: "si", name: "Sinhala" },
|
|
64
|
+
{ code: "sk-SK", name: "Slovak" },
|
|
65
|
+
{ code: "es-MX", name: "Spanish (Mexico)" },
|
|
66
|
+
{ code: "es-ES", name: "Spanish (Spain)" },
|
|
67
|
+
{ code: "sw", name: "Swahili" },
|
|
68
|
+
{ code: "sv-SE", name: "Swedish" },
|
|
69
|
+
{ code: "ta", name: "Tamil" },
|
|
70
|
+
{ code: "te", name: "Telugu" },
|
|
71
|
+
{ code: "th-TH", name: "Thai" },
|
|
72
|
+
{ code: "tr-TR", name: "Turkish" },
|
|
73
|
+
{ code: "uk-UA", name: "Ukrainian" },
|
|
74
|
+
{ code: "ur", name: "Urdu" },
|
|
75
|
+
{ code: "vi-VN", name: "Vietnamese" },
|
|
76
|
+
{ code: "cy", name: "Welsh" }
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
list.findByCode = (code) => list.find((l) => l.code === code);
|
|
80
|
+
list.findByName = (name) => list.find((l) => l.name === name);
|
|
81
|
+
|
|
82
|
+
return list;
|
|
83
|
+
})();
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// synthesize({ text, voice, slow }) -> Promise<Buffer> (MP3 audio)
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
const requestOptions = {
|
|
89
|
+
headers: {
|
|
90
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
91
|
+
},
|
|
92
|
+
hostname: "translate.google.com",
|
|
93
|
+
method: "POST",
|
|
94
|
+
path: "/_/TranslateWebserverUi/data/batchexecute",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Builds the urlencoded "f.req" body expected by the batchexecute endpoint.
|
|
98
|
+
const buildBody = ({ slow = false, text, voice }) => {
|
|
99
|
+
const values = JSON.stringify([text, voice, slow ? true : null, "null"]);
|
|
100
|
+
const data = JSON.stringify([[["jQ1olc", values, null, "generic"]]]);
|
|
101
|
+
const params = new URLSearchParams({ "f.req": data });
|
|
102
|
+
return params.toString();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const httpRequest = (opts) =>
|
|
106
|
+
new Promise((resolve, reject) => {
|
|
107
|
+
const req = https.request(requestOptions, (res) => {
|
|
108
|
+
let data = "";
|
|
109
|
+
res.on("data", (chunk) => (data += chunk));
|
|
110
|
+
res.on("end", () => resolve(data));
|
|
111
|
+
});
|
|
112
|
+
req.on("error", reject);
|
|
113
|
+
req.write(buildBody(opts));
|
|
114
|
+
req.end();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/* Response looks like:
|
|
118
|
+
*
|
|
119
|
+
* )]}'
|
|
120
|
+
*
|
|
121
|
+
* [["wrb.fr","jQ1olc","[\"<base 64 data>\"]"]]
|
|
122
|
+
* ,["di",52]
|
|
123
|
+
* ,["af.httprm",51,"8692744518077823928",2]
|
|
124
|
+
* ]
|
|
125
|
+
*/
|
|
126
|
+
const toBuffer = (response) => {
|
|
127
|
+
const slice = response.split("\n").slice(1).join("");
|
|
128
|
+
const json = JSON.parse(slice);
|
|
129
|
+
const dataString = json[0][2];
|
|
130
|
+
const dataArray = JSON.parse(dataString);
|
|
131
|
+
|
|
132
|
+
if (dataArray === null)
|
|
133
|
+
throw new Error("Unable to parse audio data. Check your request params.");
|
|
134
|
+
|
|
135
|
+
return Buffer.from(dataArray[0], "base64");
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const synthesize = (opts) => httpRequest(opts).then(toBuffer);
|
|
139
|
+
|
|
140
|
+
module.exports = { synthesize, voices };
|
|
@@ -13,7 +13,7 @@ module.exports = function (RED) {
|
|
|
13
13
|
// Setting up the engines
|
|
14
14
|
const AWS = require('aws-sdk');
|
|
15
15
|
const GoogleTTS = require('@google-cloud/text-to-speech');
|
|
16
|
-
const GoogleTranslate = require('
|
|
16
|
+
const GoogleTranslate = require('./lib/googletranslate'); // Native TTS without credentials, limited to 200 chars per row.
|
|
17
17
|
const microsoftAzureTTS = require("microsoft-cognitiveservices-speech-sdk"); // 12/10/2021
|
|
18
18
|
const elevenlabsTTS = require("elevenlabs-node"); // 03/08/2023
|
|
19
19
|
const ElevenLabsClient = require("elevenlabs").ElevenLabsClient;
|
|
@@ -218,8 +218,8 @@
|
|
|
218
218
|
# TTS Service node
|
|
219
219
|
Here you can set all parameters you need. All nodes will refer to this config node, so you need to set it only once.<br/>
|
|
220
220
|
IF YOU RUN NODE-RED BEHIND DOCKER OR SOMETHING ELSE, BE AWARE: <br/>
|
|
221
|
-
PORT USED BY THE NODE ARE 1980 (DEFAULT) AND 1400 (FOR SONOS DISCOVER). <br/>
|
|
222
|
-
PLEASE ALLOW MDNS AND UDP AS WELL
|
|
221
|
+
PORT USED BY THE NODE ARE 1980 (DEFAULT, HTTP FILE SERVER) AND 1400 (FOR SONOS DISCOVER). <br/>
|
|
222
|
+
PLEASE ALLOW MDNS AND UDP AS WELL (USED TO DISCOVER SONOS, GOOGLE CAST AND DLNA/UPNP DEVICES)
|
|
223
223
|
|
|
224
224
|
**TTS Service**<br/>
|
|
225
225
|
You can choose between Google (without credentials), Google TTS (require credentials and registration to google), ElevenLabs or Voice.ai TTS engines.<br/>
|
|
@@ -262,7 +262,7 @@ PLEASE ALLOW MDNS AND UDP AS WELL
|
|
|
262
262
|
set IP of your node-red machine. Write **AUTODISCOVER** to allow the node to auto discover your IP.
|
|
263
263
|
|
|
264
264
|
**Host Port**<br/>
|
|
265
|
-
Sonos will connect to this port
|
|
265
|
+
The players (Sonos, Google Cast, DLNA/UPnP renderers) will connect to this port to fetch the TTS audio. Default 1980. Choose a free port. Do not use 1880 or any other port already in use on your computer. The port must be reachable from the players on your network.
|
|
266
266
|
|
|
267
267
|
**TTS Cache**
|
|
268
268
|
<br/>
|
|
@@ -11,7 +11,7 @@ module.exports = function (RED) {
|
|
|
11
11
|
|
|
12
12
|
// Setting up the engines
|
|
13
13
|
const GoogleTTS = require('@google-cloud/text-to-speech');
|
|
14
|
-
const GoogleTranslate = require('
|
|
14
|
+
const GoogleTranslate = require('./lib/googletranslate'); // Native TTS without credentials, limited to 200 chars per row.
|
|
15
15
|
const elevenlabsTTS = require("elevenlabs-node"); // 03/08/2023
|
|
16
16
|
const ElevenLabsClient = require("elevenlabs").ElevenLabsClient;
|
|
17
17
|
|
|
@@ -20,6 +20,8 @@ module.exports = function (RED) {
|
|
|
20
20
|
var formidable = require('formidable');
|
|
21
21
|
const oOS = require('os');
|
|
22
22
|
const sonos = require('sonos');
|
|
23
|
+
const dlnaDiscovery = require('./lib/dlna-discovery'); // 06/2026 SSDP discovery of DLNA/UPnP renderers
|
|
24
|
+
const castDiscovery = require('./lib/googlecast-discovery'); // 06/2026 mDNS discovery of Google Cast devices
|
|
23
25
|
|
|
24
26
|
const VOICEAI_API_BASE_URL = "https://dev.voice.ai/api/v1/tts";
|
|
25
27
|
|
|
@@ -326,6 +328,38 @@ module.exports = function (RED) {
|
|
|
326
328
|
});
|
|
327
329
|
|
|
328
330
|
|
|
331
|
+
// 06/2026 Discover DLNA/UPnP MediaRenderers on the network (for the "dlna" player type)
|
|
332
|
+
RED.httpAdmin.get("/ttsultimateDiscoverDLNA", RED.auth.needsPermission('TTSConfigNode.read'), function (req, res) {
|
|
333
|
+
dlnaDiscovery.discoverRenderers({ timeoutMs: 4000 }).then((devices) => {
|
|
334
|
+
// Same shape as sonosgetAllGroups: { name, host }. For DLNA the "host" is the description URL.
|
|
335
|
+
const list = devices.map((d) => ({
|
|
336
|
+
name: d.name || "Renderer",
|
|
337
|
+
host: d.location
|
|
338
|
+
}));
|
|
339
|
+
res.json(list);
|
|
340
|
+
}).catch((error) => {
|
|
341
|
+
RED.log.warn('ttsultimate-config ' + node.id + ': Error discovering DLNA renderers ' + error.message);
|
|
342
|
+
res.json("ERRORDISCOVERY");
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
// 06/2026 Discover Google Cast devices (Chromecast / Nest) on the network (for the "googlecast" player type)
|
|
348
|
+
RED.httpAdmin.get("/ttsultimateDiscoverCast", RED.auth.needsPermission('TTSConfigNode.read'), function (req, res) {
|
|
349
|
+
castDiscovery.discoverCastDevices({ timeoutMs: 4000 }).then((devices) => {
|
|
350
|
+
// Same shape as sonosgetAllGroups: { name, host }
|
|
351
|
+
const list = devices.map((d) => ({
|
|
352
|
+
name: d.name,
|
|
353
|
+
host: d.host
|
|
354
|
+
}));
|
|
355
|
+
res.json(list);
|
|
356
|
+
}).catch((error) => {
|
|
357
|
+
RED.log.warn('ttsultimate-config ' + node.id + ': Error discovering Google Cast devices ' + error.message);
|
|
358
|
+
res.json("ERRORDISCOVERY");
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
|
|
329
363
|
// 09/03/2020 Get list of filenames in hailing folder
|
|
330
364
|
RED.httpAdmin.get("/getHailingFilesList", RED.auth.needsPermission('TTSConfigNode.read'), function (req, res) {
|
|
331
365
|
var jListOwnFiles = [];
|
|
@@ -680,45 +714,81 @@ module.exports = function (RED) {
|
|
|
680
714
|
var url_parts = url.parse(req.url, true);
|
|
681
715
|
var query = url_parts.query;
|
|
682
716
|
|
|
683
|
-
res.setHeader('Content-Disposition', 'attachment; filename=tts.mp3')
|
|
684
717
|
if (!query || query.f === undefined || query.f === null) {
|
|
685
|
-
res.
|
|
686
|
-
res.end();
|
|
718
|
+
res.statusCode = 400;
|
|
719
|
+
res.end("File not specified");
|
|
687
720
|
return;
|
|
688
721
|
}
|
|
689
722
|
|
|
690
723
|
const requestedPath = query.f.toString();
|
|
691
|
-
if (fs.existsSync(requestedPath)) {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
if (path.extname(resolvedRequested) === ".mp3" && isInsideRoot) {
|
|
699
|
-
var readStream = fs.createReadStream(resolvedRequested);
|
|
700
|
-
readStream.on("error", function (error) {
|
|
701
|
-
RED.log.error("ttsultimate-config " + node.id + ": Playsonos error opening stream : " + resolvedRequested + ' : ' + error);
|
|
702
|
-
res.end();
|
|
703
|
-
return;
|
|
704
|
-
});
|
|
705
|
-
readStream.pipe(res);
|
|
706
|
-
} else {
|
|
707
|
-
res.write("NOT ALLOWED");
|
|
708
|
-
res.end();
|
|
709
|
-
}
|
|
724
|
+
if (!fs.existsSync(requestedPath)) {
|
|
725
|
+
RED.log.error("ttsultimate-config " + node.id + ": Playsonos RED.httpAdmin file not found: " + query.f);
|
|
726
|
+
res.statusCode = 404;
|
|
727
|
+
res.end("File not found");
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
710
730
|
|
|
711
|
-
|
|
731
|
+
// Security check: allow only mp3 files under the configured storage folder.
|
|
732
|
+
// http://localhost:1980/tts?f=/etc/passwd
|
|
733
|
+
const resolvedRequested = path.resolve(requestedPath);
|
|
734
|
+
const resolvedAllowedRoot = path.resolve(node.TTSRootFolderPath);
|
|
735
|
+
const isInsideRoot =
|
|
736
|
+
resolvedRequested === resolvedAllowedRoot || resolvedRequested.startsWith(resolvedAllowedRoot + path.sep);
|
|
737
|
+
if (path.extname(resolvedRequested) !== ".mp3" || !isInsideRoot) {
|
|
738
|
+
res.statusCode = 403;
|
|
739
|
+
res.end("NOT ALLOWED");
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
712
742
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
743
|
+
// Serve as a proper streaming media response. Strict DLNA renderers (e.g. some TVs)
|
|
744
|
+
// require Content-Type, Content-Length, byte-range support and DLNA headers to start playing.
|
|
745
|
+
const total = fs.statSync(resolvedRequested).size;
|
|
746
|
+
res.setHeader('Content-Type', 'audio/mpeg');
|
|
747
|
+
res.setHeader('Content-Disposition', 'inline; filename="tts.mp3"');
|
|
748
|
+
res.setHeader('Accept-Ranges', 'bytes');
|
|
749
|
+
res.setHeader('transferMode.dlna.org', 'Streaming');
|
|
750
|
+
res.setHeader('contentFeatures.dlna.org', 'DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000');
|
|
751
|
+
|
|
752
|
+
// HEAD: headers only (many DLNA renderers probe with HEAD before playing).
|
|
753
|
+
if (req.method === 'HEAD') {
|
|
754
|
+
res.setHeader('Content-Length', total);
|
|
755
|
+
res.statusCode = 200;
|
|
716
756
|
res.end();
|
|
757
|
+
return;
|
|
717
758
|
}
|
|
718
759
|
|
|
760
|
+
let readStream;
|
|
761
|
+
const range = req.headers.range;
|
|
762
|
+
if (range) {
|
|
763
|
+
const m = /bytes=(\d*)-(\d*)/.exec(range);
|
|
764
|
+
let start = m && m[1] ? parseInt(m[1], 10) : 0;
|
|
765
|
+
let end = m && m[2] ? parseInt(m[2], 10) : total - 1;
|
|
766
|
+
if (isNaN(start)) start = 0;
|
|
767
|
+
if (isNaN(end) || end >= total) end = total - 1;
|
|
768
|
+
if (start > end || start >= total) {
|
|
769
|
+
res.statusCode = 416;
|
|
770
|
+
res.setHeader('Content-Range', 'bytes */' + total);
|
|
771
|
+
res.end();
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
res.statusCode = 206;
|
|
775
|
+
res.setHeader('Content-Range', 'bytes ' + start + '-' + end + '/' + total);
|
|
776
|
+
res.setHeader('Content-Length', (end - start) + 1);
|
|
777
|
+
readStream = fs.createReadStream(resolvedRequested, { start: start, end: end });
|
|
778
|
+
} else {
|
|
779
|
+
res.statusCode = 200;
|
|
780
|
+
res.setHeader('Content-Length', total);
|
|
781
|
+
readStream = fs.createReadStream(resolvedRequested);
|
|
782
|
+
}
|
|
783
|
+
readStream.on("error", function (error) {
|
|
784
|
+
RED.log.error("ttsultimate-config " + node.id + ": Playsonos error opening stream : " + resolvedRequested + ' : ' + error);
|
|
785
|
+
try { res.end(); } catch (e) { }
|
|
786
|
+
});
|
|
787
|
+
readStream.pipe(res);
|
|
788
|
+
|
|
719
789
|
} catch (error) {
|
|
720
790
|
RED.log.error("ttsultimate-config " + node.id + ": Playsonos RED.httpAdmin error: " + error);
|
|
721
|
-
res.end();
|
|
791
|
+
try { res.end(); } catch (e) { }
|
|
722
792
|
}
|
|
723
793
|
|
|
724
794
|
}
|