node-red-contrib-tts-ultimate 3.1.1 → 3.2.0
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 +8 -6
- package/package.json +8 -8
- 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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.paypal.me/techtoday)
|
|
4
4
|
|
|
5
|
+
<b>Version 3.2.0</b> June 2026<br/>
|
|
6
|
+
|
|
7
|
+
- NEW: **Google Cast** player (Chromecast / Google Nest). Devices are discovered via mDNS (press "Discover" in the node), with multi-room playback on the main device + additional players.<br/>
|
|
8
|
+
- NEW: **DLNA / UPnP renderer** player (smart TVs, AV receivers, etc.). Devices are discovered via SSDP. A native UPnP client locates the AVTransport/RenderingControl services anywhere in the device tree, so it also works with renderers that nest a MediaRenderer sub-device (e.g. Sonos).<br/>
|
|
9
|
+
- NEW: the "Additional players" list now works for Google Cast and DLNA too (not only Sonos), to play the announcement on several devices at once.<br/>
|
|
10
|
+
- FIX: the built-in HTTP file server now serves the TTS audio with proper streaming headers (Content-Type, Content-Length, byte-range support and DLNA headers), so strict DLNA renderers (some TVs) start playing correctly.<br/>
|
|
11
|
+
- CHORE: removed the "upnp-mediarenderer-client" dependency (replaced by a native UPnP client); added "castv2-client" and "multicast-dns".<br/>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p>
|
|
15
|
+
<b>Version 3.1.2</b> June 2026<br/>
|
|
16
|
+
|
|
17
|
+
- CHORE: removed the external "google-translate-tts" dependency, replaced by a native built-in implementation (same voices and behaviour).<br/>
|
|
18
|
+
- CHORE: removed the redundant "path" dependency (Node.js built-in module is used instead).<br/>
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
<p>
|
|
5
22
|
<b>Version 3.1.1</b> June 2026<br/>
|
|
6
23
|
|
|
7
24
|
- NEW: voice option fields (ElevenLabs Stability/Similarity/Style/Speed and Google Rate/Pitch) are now sliders showing the current value live.<br/>
|
package/README.md
CHANGED
|
@@ -161,7 +161,7 @@
|
|
|
161
161
|
|
|
162
162
|
## DESCRIPTION
|
|
163
163
|
|
|
164
|
-
This node transforms a text into a speech audio that you can hear natively via <b>
|
|
164
|
+
This node transforms a text into a speech audio that you can hear natively via <b>Sonos</b> speakers, <b>Google Cast</b> devices (Chromecast / Google Nest) and generic <b>DLNA/UPnP</b> renderers (smart TVs, AV receivers, etc.), but you can also simply create an audio file, without using any player at all.<br/>
|
|
165
165
|
You can also generate an audio file for bluetooth speakers, web pages, etc.<br/>
|
|
166
166
|
You can also use it with **your own audio file** as well and it can be used **totally offline** even without the use of TTS, without internet connection.<br/>
|
|
167
167
|
The node can also create a **_TTS file (without the use of any Sonos device)_**, to be read by third parties nodes.<br/>
|
|
@@ -193,10 +193,12 @@ This is a major **_upgrade from the previously popular node SonosPollyTTS_** (So
|
|
|
193
193
|
## FEATURES
|
|
194
194
|
|
|
195
195
|
- **Native Sonos support**: hear the TTS audio directly via Sonos. You can also group speakers, set an hailing sound, choose the volume of each speaker etc.
|
|
196
|
-
- **
|
|
196
|
+
- **Google Cast support** (Chromecast / Google Nest): play the TTS directly on your Cast devices, with multi-room playback on several devices at once.
|
|
197
|
+
- **DLNA / UPnP renderer support**: play the TTS on generic UPnP renderers (smart TVs, AV receivers, etc.). It also works with renderers that nest a MediaRenderer sub-device (e.g. Sonos).
|
|
198
|
+
- **Output audio file**: the node can just create the TTS file to be used by other nodes. In this case, you don't need to use any player.
|
|
197
199
|
- **Google Translate Voices, Google TTS Voices, ElevenLabs voices and Voice.ai voices** are supported.
|
|
198
|
-
- **Automatic grouping** is supported. You can
|
|
199
|
-
- **Automatic discovery** of your players.
|
|
200
|
+
- **Automatic grouping / multi-room** is supported on Sonos, Google Cast and DLNA. You can add additional players to play your announcements on all of them at once.
|
|
201
|
+
- **Automatic discovery** of your players: Sonos and DLNA/UPnP renderers via SSDP, Google Cast devices via mDNS. A "Discover" button in the node lists the devices found on your network.
|
|
200
202
|
- **Automatic resume of music** queue (including radio stations, but here, some users reports problem resuming **_radio stations_** and, because of lack of Sonos API documentation, the issue cannot currently be fixed), at exact track, at exact time. **Be aware that this could not work with all music queues**.
|
|
201
203
|
- **TTS caching**. ElevenLabs and Google paid service charge you for a high rate of text-to-speech requests. TTS-Ultimate caches the TTS files: it downloads each generated audio only once, and then reads it from cache. The cache is resilient (survives reboots and updates).
|
|
202
204
|
- **Can work offline**. You can use your own audio files (with OwnFile node) to make the node works offline.
|
|
@@ -224,7 +226,7 @@ This is a major **_upgrade from the previously popular node SonosPollyTTS_** (So
|
|
|
224
226
|
|
|
225
227
|
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/>
|
|
226
228
|
IF YOU RUN NODE-RED BEHIND DOCKER OR SOMETHING ELSE, BE AWARE: <br/>
|
|
227
|
-
PORT USED BY THE NODE ARE 1980 (DEFAULT) AND 1400 (FOR SONOS DISCOVER). <br/>
|
|
229
|
+
PORT USED BY THE NODE ARE 1980 (DEFAULT, HTTP FILE SERVER) AND 1400 (FOR SONOS DISCOVER). <br/>
|
|
228
230
|
PLEASE ALLOW MDNS AND UDP AS WELL
|
|
229
231
|
|
|
230
232
|
**TTS Service**<br/>
|
|
@@ -265,7 +267,7 @@ For Google TTS Engine, you can choose pitch and speed rate of the voice.
|
|
|
265
267
|
set IP of your node-red machine. Write **AUTODISCOVER** to allow the node to auto discover your IP.
|
|
266
268
|
|
|
267
269
|
**Host Port**<br/>
|
|
268
|
-
Sonos will connect to this port
|
|
270
|
+
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.
|
|
269
271
|
|
|
270
272
|
Note: if you use multiple `ttsultimate-config` nodes, each one now keeps its own TTS cache folder; the “purge on restart/deploy” option only affects that config node’s cache.
|
|
271
273
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-tts-ultimate",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "Transforms the text in speech and hear it using Sonos
|
|
3
|
+
"version": "3.2.0",
|
|
4
|
+
"description": "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.io TTS, Voice.ai TTS or your own voice. You can also only create a TTS file to be read by third party nodes. Update of the popular SonosPollyTTS node.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "test",
|
|
@@ -39,13 +39,13 @@
|
|
|
39
39
|
},
|
|
40
40
|
"homepage": "https://github.com/Supergiovane/node-red-contrib-tts-ultimate",
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"sonos": "1.14.1",
|
|
43
|
-
"formidable": "1.2.2",
|
|
44
|
-
"path": ">=0.12.7",
|
|
45
42
|
"@google-cloud/text-to-speech": "4.2.2",
|
|
46
|
-
"
|
|
43
|
+
"castv2-client": "^1.2.0",
|
|
44
|
+
"elevenlabs": "0.18.0",
|
|
47
45
|
"elevenlabs-node": "1.1.3",
|
|
48
|
-
"
|
|
46
|
+
"formidable": "1.2.2",
|
|
47
|
+
"multicast-dns": "^7.2.5",
|
|
48
|
+
"sonos": "1.14.1"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"eslint": ">=4.18.2",
|
|
@@ -54,4 +54,4 @@
|
|
|
54
54
|
"engines": {
|
|
55
55
|
"node": ">=22.0.0"
|
|
56
56
|
}
|
|
57
|
-
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Discover DLNA / UPnP MediaRenderer devices on the local network.
|
|
2
|
+
// Prints the device description XML URL (LOCATION) to paste into the
|
|
3
|
+
// tts-ultimate "DLNA / UPnP renderer" player configuration.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// node scripts/discover-dlna.js (search ~5s)
|
|
7
|
+
// node scripts/discover-dlna.js --timeout 8000
|
|
8
|
+
const { discoverRenderers } = require("../ttsultimate/lib/dlna-discovery");
|
|
9
|
+
|
|
10
|
+
const argValue = (name, fallback) => {
|
|
11
|
+
const idx = process.argv.indexOf(name);
|
|
12
|
+
if (idx === -1) return fallback;
|
|
13
|
+
const value = process.argv[idx + 1];
|
|
14
|
+
return value && !value.startsWith("--") ? value : fallback;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const timeoutMs = Number(argValue("--timeout", 5000)) || 5000;
|
|
18
|
+
|
|
19
|
+
(async () => {
|
|
20
|
+
// eslint-disable-next-line no-console
|
|
21
|
+
console.log(`Searching for DLNA/UPnP MediaRenderers for ${timeoutMs} ms...\n`);
|
|
22
|
+
const devices = await discoverRenderers({ timeoutMs });
|
|
23
|
+
if (devices.length === 0) {
|
|
24
|
+
// eslint-disable-next-line no-console
|
|
25
|
+
console.log("No MediaRenderer devices found. Make sure a renderer is on and on the same subnet.");
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
// eslint-disable-next-line no-console
|
|
29
|
+
console.log(`Found ${devices.length} renderer(s):\n`);
|
|
30
|
+
for (const dev of devices) {
|
|
31
|
+
// eslint-disable-next-line no-console
|
|
32
|
+
console.log(" " + (dev.name || "(unknown name)"));
|
|
33
|
+
// eslint-disable-next-line no-console
|
|
34
|
+
console.log(" Description URL (paste this in the node): " + dev.location);
|
|
35
|
+
if (dev.server) console.log(" Server: " + dev.server);
|
|
36
|
+
// eslint-disable-next-line no-console
|
|
37
|
+
console.log("");
|
|
38
|
+
}
|
|
39
|
+
process.exit(0);
|
|
40
|
+
})().catch((err) => {
|
|
41
|
+
// eslint-disable-next-line no-console
|
|
42
|
+
console.error(err);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Discover DLNA / UPnP MediaRenderer devices on the local network via SSDP.
|
|
2
|
+
// No external dependencies: built-in dgram (UDP multicast) + http only.
|
|
3
|
+
const dgram = require("dgram");
|
|
4
|
+
const http = require("http");
|
|
5
|
+
const { URL } = require("url");
|
|
6
|
+
|
|
7
|
+
const SSDP_ADDR = "239.255.255.250";
|
|
8
|
+
const SSDP_PORT = 1900;
|
|
9
|
+
const SEARCH_TARGET = "urn:schemas-upnp-org:device:MediaRenderer:1";
|
|
10
|
+
|
|
11
|
+
// Reads the <friendlyName> from a device description XML URL (best-effort).
|
|
12
|
+
function fetchFriendlyName(location) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
try {
|
|
15
|
+
const u = new URL(location);
|
|
16
|
+
const req = http.get(
|
|
17
|
+
{ hostname: u.hostname, port: u.port || 80, path: u.pathname + u.search, timeout: 3000 },
|
|
18
|
+
(res) => {
|
|
19
|
+
let body = "";
|
|
20
|
+
res.on("data", (c) => (body += c));
|
|
21
|
+
res.on("end", () => {
|
|
22
|
+
const m = body.match(/<friendlyName>([^<]+)<\/friendlyName>/i);
|
|
23
|
+
resolve(m ? m[1].trim() : "");
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
);
|
|
27
|
+
req.on("error", () => resolve(""));
|
|
28
|
+
req.on("timeout", () => { req.destroy(); resolve(""); });
|
|
29
|
+
} catch (e) {
|
|
30
|
+
resolve("");
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// discoverRenderers({ timeoutMs }) -> Promise<[{ name, location, server }]>
|
|
36
|
+
function discoverRenderers(options) {
|
|
37
|
+
const opts = options || {};
|
|
38
|
+
const timeoutMs = Number(opts.timeoutMs) || 5000;
|
|
39
|
+
const withNames = opts.withNames !== false; // resolve friendlyName by default
|
|
40
|
+
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const found = new Map(); // location -> { location, server }
|
|
43
|
+
const mSearch = Buffer.from(
|
|
44
|
+
"M-SEARCH * HTTP/1.1\r\n" +
|
|
45
|
+
`HOST: ${SSDP_ADDR}:${SSDP_PORT}\r\n` +
|
|
46
|
+
'MAN: "ssdp:discover"\r\n' +
|
|
47
|
+
"MX: 2\r\n" +
|
|
48
|
+
`ST: ${SEARCH_TARGET}\r\n` +
|
|
49
|
+
"\r\n"
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
let socket;
|
|
53
|
+
try {
|
|
54
|
+
socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
reject(error);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
socket.on("message", (msg) => {
|
|
61
|
+
const text = msg.toString();
|
|
62
|
+
// Keep only genuine MediaRenderer responses (some non-compliant devices,
|
|
63
|
+
// e.g. Hue bridges, answer any M-SEARCH; their ST/USN won't mention it).
|
|
64
|
+
if (!/MediaRenderer/i.test(text)) return;
|
|
65
|
+
const locationMatch = text.match(/LOCATION:\s*(.+)\r/i);
|
|
66
|
+
if (!locationMatch) return;
|
|
67
|
+
const location = locationMatch[1].trim();
|
|
68
|
+
if (found.has(location)) return;
|
|
69
|
+
const serverMatch = text.match(/SERVER:\s*(.+)\r/i);
|
|
70
|
+
found.set(location, { location, server: serverMatch ? serverMatch[1].trim() : "" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
socket.on("error", (err) => {
|
|
74
|
+
try { socket.close(); } catch (e) { }
|
|
75
|
+
reject(err);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
socket.bind(() => {
|
|
79
|
+
try { socket.setBroadcast(true); } catch (e) { }
|
|
80
|
+
const send = () => { try { socket.send(mSearch, 0, mSearch.length, SSDP_PORT, SSDP_ADDR); } catch (e) { } };
|
|
81
|
+
send();
|
|
82
|
+
setTimeout(send, 500);
|
|
83
|
+
setTimeout(send, 1500);
|
|
84
|
+
|
|
85
|
+
setTimeout(async () => {
|
|
86
|
+
try { socket.close(); } catch (e) { }
|
|
87
|
+
const devices = Array.from(found.values());
|
|
88
|
+
if (!withNames) { resolve(devices); return; }
|
|
89
|
+
for (const dev of devices) {
|
|
90
|
+
dev.name = (await fetchFriendlyName(dev.location)) || "";
|
|
91
|
+
}
|
|
92
|
+
resolve(devices);
|
|
93
|
+
}, timeoutMs);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { discoverRenderers, fetchFriendlyName };
|
|
@@ -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
|
}
|
|
@@ -129,9 +129,30 @@
|
|
|
129
129
|
<label for="node-input-playertype"><i class="fa fa-play"></i> Player</label>
|
|
130
130
|
<select id="node-input-playertype">
|
|
131
131
|
<option value="sonos">Sonos</option>
|
|
132
|
+
<option value="googlecast">Google Cast (Chromecast / Nest)</option>
|
|
133
|
+
<option value="dlna">DLNA / UPnP renderer</option>
|
|
132
134
|
<option value="noplayer">No player, only output file name.</option>
|
|
133
135
|
</select>
|
|
134
|
-
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div id="divCastDlna">
|
|
139
|
+
<div class="form-row">
|
|
140
|
+
<label for="node-input-playervolume"><i class="fa fa-volume-up"></i> Volume</label>
|
|
141
|
+
<input type="text" id="node-input-playervolume" style="width:150px" placeholder="0-100">
|
|
142
|
+
</div>
|
|
143
|
+
<div class="form-row" id="playerDiscoverRow">
|
|
144
|
+
<label for="playerDiscoverList"><i class="fa fa-search"></i> Discover</label>
|
|
145
|
+
<select id="playerDiscoverList" style="width:250px"><option value="">-- press Discover --</option></select>
|
|
146
|
+
<a id="playerDiscoverBtn" class="red-ui-button" style="width:auto;"><i class="fa fa-refresh"></i> Discover</a>
|
|
147
|
+
</div>
|
|
148
|
+
<div class="form-row">
|
|
149
|
+
<label for="node-input-playeripaddress"><i class="fa fa-globe"></i> <span id="playeraddresslabel">Device</span></label>
|
|
150
|
+
<input type="text" id="node-input-playeripaddress" style="width:250px">
|
|
151
|
+
</div>
|
|
152
|
+
<div class="form-row">
|
|
153
|
+
<p id="playeraddresshint" style="margin-left:105px;color:#888;"></p>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
135
156
|
|
|
136
157
|
<div id="divSonos">
|
|
137
158
|
<div class="form-row">
|
|
@@ -150,7 +171,9 @@
|
|
|
150
171
|
<label for="node-input-sonosipaddress"><i class="fa fa-globe"></i> Main Sonos Player</label>
|
|
151
172
|
<label style="width:200px;" id="node-input-sonosipaddress">Discovering.... wait...</label>
|
|
152
173
|
</div>
|
|
153
|
-
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div id="divAdditionalPlayers">
|
|
154
177
|
<dt><i class="fa fa-code-fork"></i> Additional players</dt>
|
|
155
178
|
<div class="form-row node-input-rule-container-row">
|
|
156
179
|
<ol id="node-input-rule-container"></ol>
|
|
@@ -197,6 +220,17 @@
|
|
|
197
220
|
required: false,
|
|
198
221
|
type: "text"
|
|
199
222
|
},
|
|
223
|
+
playeripaddress:
|
|
224
|
+
{
|
|
225
|
+
value: "",
|
|
226
|
+
required: false
|
|
227
|
+
},
|
|
228
|
+
playervolume:
|
|
229
|
+
{
|
|
230
|
+
value: "50",
|
|
231
|
+
required: false,
|
|
232
|
+
type: "text"
|
|
233
|
+
},
|
|
200
234
|
sonoshailing:
|
|
201
235
|
{
|
|
202
236
|
value: "Hailing_Hailing.mp3",
|
|
@@ -302,18 +336,91 @@
|
|
|
302
336
|
node.playertype = "sonos";
|
|
303
337
|
$("#node-input-playertype").val("sonos");
|
|
304
338
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
339
|
+
var playerDiscovered = false; // discover only once per shown section, unless refreshed manually
|
|
340
|
+
|
|
341
|
+
// Discovery endpoint used by the per-row "Additional players" list (Sonos / Cast / DLNA).
|
|
342
|
+
function currentPlayerDiscoveryUrl() {
|
|
343
|
+
var pt = $("#node-input-playertype").val();
|
|
344
|
+
if (pt === "googlecast") return 'ttsultimateDiscoverCast';
|
|
345
|
+
if (pt === "dlna") return 'ttsultimateDiscoverDLNA';
|
|
346
|
+
return 'sonosgetAllGroups';
|
|
309
347
|
}
|
|
310
348
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
349
|
+
// Discovery for the MAIN player address field (Google Cast IP or DLNA description URL).
|
|
350
|
+
// All endpoints share the { name, host } shape, so a single code path works.
|
|
351
|
+
function runPlayerDiscovery() {
|
|
352
|
+
var pt = $("#node-input-playertype").val();
|
|
353
|
+
var noun = pt === "googlecast" ? "Cast devices" : "renderers";
|
|
354
|
+
$("#playerDiscoverList").html('<option value="">Discovering... please wait ~4s</option>');
|
|
355
|
+
$.getJSON(currentPlayerDiscoveryUrl(), function (data) {
|
|
356
|
+
$("#playerDiscoverList").empty();
|
|
357
|
+
if (typeof data === "string" || !data || data.length === 0) {
|
|
358
|
+
$("#playerDiscoverList").append($("<option></option>").attr("value", "").text("No " + noun + " found - type manually below"));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
$("#playerDiscoverList").append($("<option></option>").attr("value", "").text("-- select a discovered device --"));
|
|
362
|
+
data.forEach(function (dev) {
|
|
363
|
+
$("#playerDiscoverList").append($("<option></option>").attr("value", dev.host).text(dev.name + " (" + dev.host + ")"));
|
|
364
|
+
});
|
|
365
|
+
}).fail(function () {
|
|
366
|
+
$("#playerDiscoverList").html('<option value="">Discovery failed - type manually below</option>');
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Picking a discovered device fills the main address field.
|
|
371
|
+
$("#playerDiscoverList").change(function () {
|
|
372
|
+
var v = $(this).val();
|
|
373
|
+
if (v) $("#node-input-playeripaddress").val(v);
|
|
374
|
+
});
|
|
375
|
+
$("#playerDiscoverBtn").click(function (e) {
|
|
376
|
+
e.preventDefault();
|
|
377
|
+
runPlayerDiscovery();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Re-render the additional players rows so each row re-discovers using the
|
|
381
|
+
// current player type (Sonos groups vs Cast devices), preserving entered hosts.
|
|
382
|
+
function rebuildAdditionalPlayers() {
|
|
383
|
+
try {
|
|
384
|
+
var items = $("#node-input-rule-container").editableList('items');
|
|
385
|
+
var saved = [];
|
|
386
|
+
items.each(function () {
|
|
387
|
+
var r = $(this);
|
|
388
|
+
saved.push({ host: r.find(".rowRulePlayerHost").val(), hostVolumeAdjust: r.find(".rowRulePlayerHostAdjustVolume").val() });
|
|
389
|
+
});
|
|
390
|
+
$("#node-input-rule-container").editableList('empty');
|
|
391
|
+
saved.forEach(function (rule, idx) {
|
|
392
|
+
$("#node-input-rule-container").editableList('addItem', { r: rule, i: idx });
|
|
393
|
+
});
|
|
394
|
+
} catch (e) { }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function refreshPlayerSections() {
|
|
398
|
+
var pt = $("#node-input-playertype").val();
|
|
399
|
+
$("#divSonos").toggle(pt === "sonos");
|
|
400
|
+
$("#divCastDlna").toggle(pt === "googlecast" || pt === "dlna");
|
|
401
|
+
$("#playerDiscoverRow").toggle(pt === "googlecast" || pt === "dlna");
|
|
402
|
+
// Additional players are supported for grouped Sonos and multi-room Google Cast / DLNA.
|
|
403
|
+
$("#divAdditionalPlayers").toggle(pt === "sonos" || pt === "googlecast" || pt === "dlna");
|
|
404
|
+
if (pt === "googlecast") {
|
|
405
|
+
$("#playeraddresslabel").text("Main Chromecast / Nest IP");
|
|
406
|
+
$("#playeraddresshint").text("IP address of the main Google Cast device, e.g. 192.168.1.50");
|
|
407
|
+
} else if (pt === "dlna") {
|
|
408
|
+
$("#playeraddresslabel").text("Renderer description URL");
|
|
409
|
+
$("#playeraddresshint").text("UPnP device description XML URL, e.g. http://192.168.1.50:1400/desc.xml");
|
|
316
410
|
}
|
|
411
|
+
// Auto-discover the first time a discoverable section is shown.
|
|
412
|
+
if ((pt === "googlecast" || pt === "dlna") && !playerDiscovered) {
|
|
413
|
+
playerDiscovered = true;
|
|
414
|
+
runPlayerDiscovery();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
refreshPlayerSections();
|
|
418
|
+
|
|
419
|
+
$("#node-input-playertype").change(function () {
|
|
420
|
+
playerDiscovered = false; // force re-discovery for the newly selected type
|
|
421
|
+
$("#playerDiscoverList").html('<option value="">-- press Discover --</option>');
|
|
422
|
+
rebuildAdditionalPlayers();
|
|
423
|
+
refreshPlayerSections();
|
|
317
424
|
});
|
|
318
425
|
// ###########################
|
|
319
426
|
|
|
@@ -614,7 +721,7 @@
|
|
|
614
721
|
let oAdjustVolume = $('<select/>', { class: "rowRulePlayerHostAdjustVolume", type: "text", style: "width:200px; margin-left: 5px; text-align: left;" }).appendTo(row);
|
|
615
722
|
for (let index = -100; index < 100; index += 5) {
|
|
616
723
|
let sTesto = "";
|
|
617
|
-
if (index === 0) sTesto = "Same volume as Main
|
|
724
|
+
if (index === 0) sTesto = "Same volume as Main Player";
|
|
618
725
|
if (index < 0) sTesto = "Decrease volume by " + Math.abs(index);
|
|
619
726
|
if (index > 0) sTesto = "Increase volume by " + index;
|
|
620
727
|
oAdjustVolume.append($("<option></option>")
|
|
@@ -648,8 +755,8 @@
|
|
|
648
755
|
resizeRule(container);
|
|
649
756
|
});
|
|
650
757
|
|
|
651
|
-
$.getJSON(
|
|
652
|
-
if (typeof data === "string" && data == "ERRORDISCOVERY") { // 10/04/2020 if error
|
|
758
|
+
$.getJSON(currentPlayerDiscoveryUrl(), (data) => {
|
|
759
|
+
if ((typeof data === "string" && data == "ERRORDISCOVERY") || !data || data.length === 0) { // 10/04/2020 if error/empty discovery, fallback to manual IP input
|
|
653
760
|
// Transform the dropdown to a simple input
|
|
654
761
|
oPlayer.remove();
|
|
655
762
|
oPlayer = $('<input/>', { class: "rowRulePlayerHost", type: "text", style: "width:200px; margin-left: 5px; text-align: left;" }).appendTo(row);
|
|
@@ -719,7 +826,7 @@
|
|
|
719
826
|
</script>
|
|
720
827
|
|
|
721
828
|
<script type="text/markdown" data-help-name="ttsultimate">
|
|
722
|
-
<p>This node transforms a text into a speech audio that you can hear natively via
|
|
829
|
+
<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>
|
|
723
830
|
|
|
724
831
|
**General**
|
|
725
832
|
|Property|Description|
|
|
@@ -728,12 +835,14 @@
|
|
|
728
835
|
| 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. |
|
|
729
836
|
| 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. |
|
|
730
837
|
| Upload hail | It allows you to upload your own hailing file. |
|
|
731
|
-
| Player | Select the player
|
|
838
|
+
| 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. |
|
|
732
839
|
| Volume | Set the preferred TTS volume, from "0" to "100" (can be overridden by passing msg.volume = "40"; to the node). |
|
|
733
|
-
| Unmute | 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). |
|
|
734
|
-
| Resume | 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. |
|
|
840
|
+
| 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). |
|
|
841
|
+
| 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. |
|
|
735
842
|
| 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. |
|
|
736
|
-
|
|
|
843
|
+
| 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. |
|
|
844
|
+
| 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. |
|
|
845
|
+
| 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). |
|
|
737
846
|
|
|
738
847
|
**Google TTS Engines specific options**
|
|
739
848
|
|Property|Description|
|
|
@@ -754,6 +863,17 @@
|
|
|
754
863
|
|
|
755
864
|
<br/>
|
|
756
865
|
|
|
866
|
+
**Players**
|
|
867
|
+
|
|
868
|
+
| Player | Notes |
|
|
869
|
+
|--|--|
|
|
870
|
+
| Sonos | Native control: grouping, volume, mute/unmute and music resume after the announcement. |
|
|
871
|
+
| Google Cast | Chromecast and Google Nest devices, discovered via mDNS. Set the main device IP and, optionally, additional devices for synchronized multi-room playback. |
|
|
872
|
+
| 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. |
|
|
873
|
+
| No player | Only generates the mp3 file(s) and outputs their paths in filesArray, to be played by third party nodes. |
|
|
874
|
+
|
|
875
|
+
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).
|
|
876
|
+
|
|
757
877
|
### Inputs
|
|
758
878
|
|
|
759
879
|
: volume (string) : Set the volume (values between "0" and "100").
|
|
@@ -77,6 +77,8 @@ module.exports = function (RED) {
|
|
|
77
77
|
node.currentMSGbeingSpoken = {}; // Stores the current message being spoken
|
|
78
78
|
node.sonosCoordinatorPreviousVolumeSetByApp = 0; // 05/07/2021 stores the main payer volume set by the sonos app
|
|
79
79
|
node.playertype = config.playertype === undefined ? "sonos" : config.playertype; // 20/09/2021 Player type
|
|
80
|
+
node.playeripaddress = (config.playeripaddress || "").trim(); // 06/2026 Chromecast/Nest IP or DLNA/UPnP renderer description XML URL
|
|
81
|
+
node.playervolume = config.playervolume === undefined ? "50" : config.playervolume; // 06/2026 Volume (0-100) for Google Cast / DLNA players
|
|
80
82
|
node.speakingpitch = config.speakingpitch === undefined ? "0" : config.speakingpitch; // 21/09/2021 AudioConfig speakingpitch
|
|
81
83
|
node.speakingrate = config.speakingrate === undefined ? "1" : config.speakingrate; // 21/09/2021 AudioConfig speakingrate
|
|
82
84
|
node.unmuteIfMuted = config.unmuteIfMuted === undefined ? false : config.unmuteIfMuted; // 21/10/2021 Unmute if previiously muted.
|
|
@@ -422,7 +424,8 @@ module.exports = function (RED) {
|
|
|
422
424
|
}
|
|
423
425
|
// 27/11/2019 Start the connection healty check
|
|
424
426
|
node.oTimerSonosConnectionCheck = setTimeout(function () { node.CheckSonosConnection(); }, 5000);
|
|
425
|
-
} else
|
|
427
|
+
} else {
|
|
428
|
+
// 06/2026 noplayer, googlecast and dlna do not use the Sonos connection healthcheck.
|
|
426
429
|
node.msg.connectionerror = false;
|
|
427
430
|
}
|
|
428
431
|
|
|
@@ -530,7 +533,7 @@ module.exports = function (RED) {
|
|
|
530
533
|
node.server.whoIsUsingTheServer = node.id; // Signal to other ttsultimate node, that i'm using the Sonos device
|
|
531
534
|
try {
|
|
532
535
|
|
|
533
|
-
if (node.playertype
|
|
536
|
+
if (node.playertype === "sonos") {
|
|
534
537
|
// Get the current music queue, if one
|
|
535
538
|
var oCurTrack = null;
|
|
536
539
|
try {
|
|
@@ -909,6 +912,49 @@ module.exports = function (RED) {
|
|
|
909
912
|
node.setNodeStatus({ fill: 'red', shape: 'dot', text: 'Error ' + msg + " " + error.message });
|
|
910
913
|
}
|
|
911
914
|
|
|
915
|
+
} else if (node.playertype === "googlecast" || node.playertype === "dlna") {
|
|
916
|
+
|
|
917
|
+
// 06/2026 Google Cast (Chromecast/Nest) and generic DLNA/UPnP renderers.
|
|
918
|
+
// Both just need the public HTTP URL of the file (same conversion as Sonos).
|
|
919
|
+
if (!node.sFileToBePlayed.toLowerCase().startsWith("http://") && !node.sFileToBePlayed.toLowerCase().startsWith("https://")) {
|
|
920
|
+
node.sFileToBePlayed = node.sNoderedURL + "/tts/tts.mp3?f=" + encodeURIComponent(node.sFileToBePlayed);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Volume (0-100): a per-message volume overrides the configured one
|
|
924
|
+
let volTemp = node.currentMSGbeingSpoken.hasOwnProperty("volume") ? Number(node.currentMSGbeingSpoken.volume) : Number(node.playervolume);
|
|
925
|
+
if (isNaN(volTemp)) volTemp = 50;
|
|
926
|
+
if (volTemp < 0) volTemp = 0;
|
|
927
|
+
if (volTemp > 100) volTemp = 100;
|
|
928
|
+
|
|
929
|
+
node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Play ' + formatAudioSourceTag(audioSource) + msg });
|
|
930
|
+
try {
|
|
931
|
+
// Play on the main player + all additional players (reusing the Sonos additional
|
|
932
|
+
// players list), in parallel for multi-room playback. Works for Cast and DLNA.
|
|
933
|
+
const playFn = node.playertype === "googlecast" ? playGoogleCastSync : playDLNASync;
|
|
934
|
+
const targets = [{ host: node.playeripaddress, vol: volTemp }];
|
|
935
|
+
for (let ci = 0; ci < node.rules.length; ci++) {
|
|
936
|
+
const r = node.rules[ci];
|
|
937
|
+
if (r && r.host) {
|
|
938
|
+
let v = volTemp + Number(r.hostVolumeAdjust || 0);
|
|
939
|
+
if (isNaN(v)) v = volTemp;
|
|
940
|
+
if (v < 0) v = 0;
|
|
941
|
+
if (v > 100) v = 100;
|
|
942
|
+
targets.push({ host: r.host, vol: v });
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
const results = await Promise.allSettled(targets.map((t) => playFn(t.host, node.sFileToBePlayed, t.vol)));
|
|
946
|
+
const failures = results.filter((rr) => rr.status === "rejected");
|
|
947
|
+
if (failures.length === results.length) {
|
|
948
|
+
throw new Error("all " + node.playertype + " targets failed: " + failures.map((f) => f.reason && f.reason.message).join("; "));
|
|
949
|
+
} else if (failures.length > 0) {
|
|
950
|
+
RED.log.warn("ttsultimate: Some " + node.playertype + " targets failed: " + failures.map((f) => f.reason && f.reason.message).join("; "));
|
|
951
|
+
}
|
|
952
|
+
node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'End playing ' + msg });
|
|
953
|
+
} catch (error) {
|
|
954
|
+
RED.log.error("ttsultimate: Error playing on " + node.playertype + " for " + node.sFileToBePlayed + " " + error.message);
|
|
955
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: 'Error ' + msg + " " + error.message });
|
|
956
|
+
}
|
|
957
|
+
|
|
912
958
|
} else if (node.playertype === "noplayer") {
|
|
913
959
|
// Output only the filename
|
|
914
960
|
if (noPlayerFileArray === undefined || noPlayerFileArray === null) var noPlayerFileArray = [];
|
|
@@ -977,17 +1023,15 @@ module.exports = function (RED) {
|
|
|
977
1023
|
node.server.whoIsUsingTheServer = ""; // Signal to other ttsultimate node, that i'm not using the Sonos device anymore
|
|
978
1024
|
}, 500)
|
|
979
1025
|
|
|
980
|
-
} else
|
|
981
|
-
// End task
|
|
982
|
-
//
|
|
1026
|
+
} else {
|
|
1027
|
+
// End task for noplayer, googlecast and dlna.
|
|
1028
|
+
// For "noplayer" also output the array of generated files (undefined for the other types).
|
|
983
1029
|
// Signal end playing
|
|
984
|
-
//let t = setTimeout(() => {
|
|
985
1030
|
node.msg.completed = true;
|
|
986
1031
|
node.currentMSGbeingSpoken = {};
|
|
987
1032
|
node.send([{ passThroughMessage: node.passThroughMessage, payload: node.msg.completed, filesArray: noPlayerFileArray }, null]);
|
|
988
1033
|
node.bBusyPlayingQueue = false
|
|
989
1034
|
node.server.whoIsUsingTheServer = ""; // Signal to other ttsultimate node, that i'm not using the Sonos device anymore
|
|
990
|
-
//}, 1000)
|
|
991
1035
|
}
|
|
992
1036
|
|
|
993
1037
|
} catch (error) {
|
|
@@ -1055,9 +1099,11 @@ module.exports = function (RED) {
|
|
|
1055
1099
|
// 27/01/2021 Stop whatever in play.
|
|
1056
1100
|
if (msg.hasOwnProperty("stop") && msg.stop === true) {
|
|
1057
1101
|
node.flushQueue();
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1102
|
+
if (node.playertype === "sonos") {
|
|
1103
|
+
try {
|
|
1104
|
+
STOPSync().catch(() => { });
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
}
|
|
1061
1107
|
}
|
|
1062
1108
|
node.setNodeStatus({ fill: 'red', shape: 'ring', text: "Forced stop." });
|
|
1063
1109
|
return;
|
|
@@ -1133,7 +1179,7 @@ module.exports = function (RED) {
|
|
|
1133
1179
|
// There is already a priority message being spoken, do nothing
|
|
1134
1180
|
node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'There is already a priority message being spoken...queuing' });
|
|
1135
1181
|
} else {
|
|
1136
|
-
if (node.playertype
|
|
1182
|
+
if (node.playertype === 'sonos') {
|
|
1137
1183
|
node.SonosClient.stop().then(result => {
|
|
1138
1184
|
node.bTimeOutPlay = true;
|
|
1139
1185
|
node.currentMSGbeingSpoken = msg; // Set immediately, otherwise if comes new flow messages, currentMSGbeingSpoken is too old.
|
|
@@ -1376,6 +1422,102 @@ module.exports = function (RED) {
|
|
|
1376
1422
|
}
|
|
1377
1423
|
}
|
|
1378
1424
|
|
|
1425
|
+
// 06/2026 Google Cast (Chromecast / Google Nest) playback.
|
|
1426
|
+
// Resolves when playback finishes (IDLE after PLAYING) or rejects on error/timeout.
|
|
1427
|
+
function playGoogleCastSync(host, url, volume0to100) {
|
|
1428
|
+
return new Promise((resolve, reject) => {
|
|
1429
|
+
if (!host) { reject(new Error("Google Cast: no device IP address configured")); return; }
|
|
1430
|
+
let CastClient, DefaultMediaReceiver;
|
|
1431
|
+
try {
|
|
1432
|
+
CastClient = require("castv2-client").Client;
|
|
1433
|
+
DefaultMediaReceiver = require("castv2-client").DefaultMediaReceiver;
|
|
1434
|
+
} catch (error) {
|
|
1435
|
+
reject(new Error("castv2-client module not available: " + error.message));
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
const client = new CastClient();
|
|
1439
|
+
let settled = false;
|
|
1440
|
+
const cleanup = () => { try { client.close(); } catch (e) { } };
|
|
1441
|
+
const clearTimers = () => { clearTimeout(timeout); clearTimeout(startTimer); };
|
|
1442
|
+
const fail = (err) => { if (settled) return; settled = true; clearTimers(); cleanup(); reject(err); };
|
|
1443
|
+
const done = () => { if (settled) return; settled = true; clearTimers(); cleanup(); resolve(); };
|
|
1444
|
+
const timeout = setTimeout(() => { fail(new Error("Google Cast: playback timeout")); }, 60000 * 10); // 10 minutes, like Sonos
|
|
1445
|
+
// Fail fast if playback never starts (e.g. wrong IP on an unreachable/black-holed host).
|
|
1446
|
+
const startTimer = setTimeout(() => { fail(new Error("Google Cast: device did not start playing within 45s (check IP/network)")); }, 45000);
|
|
1447
|
+
|
|
1448
|
+
client.on("error", (err) => fail(err));
|
|
1449
|
+
client.connect(host, () => {
|
|
1450
|
+
// Volume is 0..1 on Cast
|
|
1451
|
+
try { client.setVolume({ level: Math.max(0, Math.min(1, volume0to100 / 100)) }, () => { }); } catch (e) { }
|
|
1452
|
+
client.launch(DefaultMediaReceiver, (err, player) => {
|
|
1453
|
+
if (err) { fail(err); return; }
|
|
1454
|
+
let started = false;
|
|
1455
|
+
player.on("status", (status) => {
|
|
1456
|
+
if (!status) return;
|
|
1457
|
+
if (status.playerState === "PLAYING") { started = true; clearTimeout(startTimer); }
|
|
1458
|
+
// IDLE after playback started means the track has finished.
|
|
1459
|
+
if (started && status.playerState === "IDLE") done();
|
|
1460
|
+
});
|
|
1461
|
+
const media = { contentId: url, contentType: "audio/mpeg", streamType: "BUFFERED" };
|
|
1462
|
+
player.load(media, { autoplay: true }, (err2) => {
|
|
1463
|
+
if (err2) fail(err2);
|
|
1464
|
+
});
|
|
1465
|
+
});
|
|
1466
|
+
});
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// 06/2026 Generic DLNA / UPnP media renderer playback.
|
|
1471
|
+
// descriptionUrl is the renderer device description XML URL (e.g. http://192.168.1.50:1400/xml/device_description.xml).
|
|
1472
|
+
// Uses a native client that finds the AVTransport/RenderingControl services anywhere in the
|
|
1473
|
+
// device tree (so it also works with renderers that nest a MediaRenderer sub-device, e.g. Sonos).
|
|
1474
|
+
// Resolves when playback finishes (STOPPED after PLAYING) or rejects on error/timeout.
|
|
1475
|
+
function playDLNASync(descriptionUrl, url, volume0to100) {
|
|
1476
|
+
return new Promise((resolve, reject) => {
|
|
1477
|
+
if (!descriptionUrl) { reject(new Error("DLNA: no renderer description URL configured")); return; }
|
|
1478
|
+
let dlnaPlayer;
|
|
1479
|
+
try {
|
|
1480
|
+
dlnaPlayer = require("./lib/dlna-player");
|
|
1481
|
+
} catch (error) {
|
|
1482
|
+
reject(new Error("dlna-player module not available: " + error.message));
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
let settled = false;
|
|
1486
|
+
const clearTimers = () => { clearTimeout(timeout); clearTimeout(startTimer); };
|
|
1487
|
+
const fail = (err) => { if (settled) return; settled = true; clearTimers(); reject(err); };
|
|
1488
|
+
const done = () => { if (settled) return; settled = true; clearTimers(); resolve(); };
|
|
1489
|
+
const timeout = setTimeout(() => { fail(new Error("DLNA: playback timeout")); }, 60000 * 10); // 10 minutes, like Sonos
|
|
1490
|
+
// Fail fast if playback never starts (e.g. wrong/unreachable renderer URL).
|
|
1491
|
+
const startTimer = setTimeout(() => { fail(new Error("DLNA: renderer did not start playing within 45s (check description URL/network)")); }, 45000);
|
|
1492
|
+
|
|
1493
|
+
const player = dlnaPlayer.createPlayer(descriptionUrl);
|
|
1494
|
+
|
|
1495
|
+
(async () => {
|
|
1496
|
+
try {
|
|
1497
|
+
// Best-effort volume first (ignored by renderers without RenderingControl).
|
|
1498
|
+
try { await player.setVolume(volume0to100); } catch (e) { }
|
|
1499
|
+
await player.setAVTransportURI(url, "audio/mpeg");
|
|
1500
|
+
await player.play();
|
|
1501
|
+
} catch (error) {
|
|
1502
|
+
fail(error);
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
// Poll the transport state (UPnP eventing is not implemented by all renderers).
|
|
1506
|
+
let started = false;
|
|
1507
|
+
const poll = async () => {
|
|
1508
|
+
if (settled) return;
|
|
1509
|
+
let state = "";
|
|
1510
|
+
try { state = await player.getTransportState(); } catch (e) { /* transient, keep polling */ }
|
|
1511
|
+
if (settled) return;
|
|
1512
|
+
if (state === "PLAYING" || state === "TRANSITIONING") { started = true; clearTimeout(startTimer); }
|
|
1513
|
+
if (started && (state === "STOPPED" || state === "NO_MEDIA_PRESENT" || state === "PAUSED_PLAYBACK")) { done(); return; }
|
|
1514
|
+
setTimeout(poll, 1000);
|
|
1515
|
+
};
|
|
1516
|
+
setTimeout(poll, 1000);
|
|
1517
|
+
})();
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1379
1521
|
// 04/01/2021 hashing filename to avoid issues with long filenames.
|
|
1380
1522
|
function getFilename(_text, _params) {
|
|
1381
1523
|
let sTextToBeHashed = _text.concat(JSON.stringify(_params));
|