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 CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  [![Donate via PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg?style=flat-square)](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>SONOS</b> speakers, but you can also simply create an audio file, without using SONOS at all.<br/>
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
- - **Output audio file**: the node can just create the TTS file to be used by other nodes. In this case, you doesn't need to use Sonos as player.
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 group all players you want to play your announcements.
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 in order to play TTS. Default 1980. Choose a free port. Do not use 1880 or any other port already in use on your computer.
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.1.1",
4
- "description": "Transforms the text in speech and hear it using Sonos player 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.",
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
- "google-translate-tts": ">=0.3.0",
43
+ "castv2-client": "^1.2.0",
44
+ "elevenlabs": "0.18.0",
47
45
  "elevenlabs-node": "1.1.3",
48
- "elevenlabs": "0.18.0"
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
+ });
@@ -1,6 +1,6 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
- const GoogleTranslate = require("google-translate-tts");
3
+ const GoogleTranslate = require("../ttsultimate/lib/googletranslate");
4
4
 
5
5
  const GOOGLE_TRANSLATE_MAX_CHARS = 200;
6
6
 
@@ -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, "&amp;")
33
+ .replace(/</g, "&lt;")
34
+ .replace(/>/g, "&gt;")
35
+ .replace(/"/g, "&quot;")
36
+ .replace(/'/g, "&apos;");
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('google-translate-tts'); // TTS without credentials, limited to 200 chars per row.
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 in order to play TTS. Default 1980. Choose a free port. Do not use 1880 or any other port already in use on your computer.
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('google-translate-tts'); // TTS without credentials, limited to 200 chars per row.
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.write("File not specified");
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
- // Security check: allow only mp3 files under the configured storage folder.
693
- const resolvedRequested = path.resolve(requestedPath);
694
- const resolvedAllowedRoot = path.resolve(node.TTSRootFolderPath);
695
- const isInsideRoot =
696
- resolvedRequested === resolvedAllowedRoot || resolvedRequested.startsWith(resolvedAllowedRoot + path.sep);
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
- // http://localhost:1980/tts?f=/etc/passwd
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
- } else {
714
- RED.log.error("ttsultimate-config " + node.id + ": Playsonos RED.httpAdmin file not found: " + query.f);
715
- res.write("File not found");
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>&nbsp; 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
- if (node.playertype === "sonos") {
306
- $("#divSonos").show();
307
- } else {
308
- $("#divSonos").hide();
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
- $("#node-input-playertype").change(function () {
312
- if ($("#node-input-playertype").val() === "sonos") {
313
- $("#divSonos").show();
314
- } else {
315
- $("#divSonos").hide();
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 Sonos Player";
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('sonosgetAllGroups', (data) => {
652
- if (typeof data === "string" && data == "ERRORDISCOVERY") { // 10/04/2020 if error in discovery, fallback to manual IP input
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 SONOS speakers, or save it to a file.</p>
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. If you select not to use a player, the node will output a msg with an array of files, ready to be played by third party nodes. In case you select No player, only output file name, you'll get a message with an additional property filesArray, containing an array of all mp3 files ready to be played with third party nodes. Please see below the OUTPUT MESSAGES FROM THE NODE section. |
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
- | Additional Players | Here you can add all additional players that will be grouped toghether to the Main Sonos Player coordinator group. You can add a player using the "ADD" button, below the list. For each additional player, you can adjust their volume, based on the Main Sonos Player volume -+100. |
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 if (node.playertype === "noplayer") {
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 !== "noplayer") {
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 if (node.playertype === "noplayer") {
981
- // End task if no player is selected.
982
- // Output the array of files
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
- try {
1059
- STOPSync();
1060
- } catch (error) {
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 !== 'noplayer') {
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));