node-red-contrib-tts-ultimate 3.0.7 → 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,37 @@
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>
22
+ <b>Version 3.1.1</b> June 2026<br/>
23
+
24
+ - NEW: voice option fields (ElevenLabs Stability/Similarity/Style/Speed and Google Rate/Pitch) are now sliders showing the current value live.<br/>
25
+ </p>
26
+
27
+ <p>
28
+ <b>Version 3.1.0</b> June 2026<br/>
29
+
30
+ - NEW: refresh icon next to the "Voice" field to reload the voices list on demand.<br/>
31
+ - NEW: ElevenLabs models are now read dynamically from the API (with a refresh icon), so new models appear automatically. Model-specific options (Style Exaggeration, Speaker boost) are enabled/disabled based on the capabilities reported by ElevenLabs.<br/>
32
+ - NEW: ElevenLabs "Speed" option (0.7 - 1.2, default 1.0) for the v2 engine.<br/>
33
+ </p>
34
+
35
+ <p>
5
36
  <b>Version 3.0.7</b> March 2026<br/>
6
37
 
7
38
  - CHORE: fixed some issues with voice.ai.<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.0.7",
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;