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 +31 -0
- package/README.md +8 -6
- package/package.json +8 -8
- package/scripts/discover-dlna.js +44 -0
- package/scripts/verify-googletranslate-split.js +1 -1
- package/ttsultimate/lib/dlna-discovery.js +98 -0
- package/ttsultimate/lib/dlna-player.js +163 -0
- package/ttsultimate/lib/googlecast-discovery.js +69 -0
- package/ttsultimate/lib/googletranslate.js +140 -0
- package/ttsultimate/ttsultimate-config copy.js +1 -1
- package/ttsultimate/ttsultimate-config.html +3 -3
- package/ttsultimate/ttsultimate-config.js +138 -28
- package/ttsultimate/ttsultimate.html +260 -28
- package/ttsultimate/ttsultimate.js +155 -11
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.paypal.me/techtoday)
|
|
4
4
|
|
|
5
|
+
<b>Version 3.2.0</b> June 2026<br/>
|
|
6
|
+
|
|
7
|
+
- NEW: **Google Cast** player (Chromecast / Google Nest). Devices are discovered via mDNS (press "Discover" in the node), with multi-room playback on the main device + additional players.<br/>
|
|
8
|
+
- NEW: **DLNA / UPnP renderer** player (smart TVs, AV receivers, etc.). Devices are discovered via SSDP. A native UPnP client locates the AVTransport/RenderingControl services anywhere in the device tree, so it also works with renderers that nest a MediaRenderer sub-device (e.g. Sonos).<br/>
|
|
9
|
+
- NEW: the "Additional players" list now works for Google Cast and DLNA too (not only Sonos), to play the announcement on several devices at once.<br/>
|
|
10
|
+
- FIX: the built-in HTTP file server now serves the TTS audio with proper streaming headers (Content-Type, Content-Length, byte-range support and DLNA headers), so strict DLNA renderers (some TVs) start playing correctly.<br/>
|
|
11
|
+
- CHORE: removed the "upnp-mediarenderer-client" dependency (replaced by a native UPnP client); added "castv2-client" and "multicast-dns".<br/>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p>
|
|
15
|
+
<b>Version 3.1.2</b> June 2026<br/>
|
|
16
|
+
|
|
17
|
+
- CHORE: removed the external "google-translate-tts" dependency, replaced by a native built-in implementation (same voices and behaviour).<br/>
|
|
18
|
+
- CHORE: removed the redundant "path" dependency (Node.js built-in module is used instead).<br/>
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
<p>
|
|
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>
|
|
164
|
+
This node transforms a text into a speech audio that you can hear natively via <b>Sonos</b> speakers, <b>Google Cast</b> devices (Chromecast / Google Nest) and generic <b>DLNA/UPnP</b> renderers (smart TVs, AV receivers, etc.), but you can also simply create an audio file, without using any player at all.<br/>
|
|
165
165
|
You can also generate an audio file for bluetooth speakers, web pages, etc.<br/>
|
|
166
166
|
You can also use it with **your own audio file** as well and it can be used **totally offline** even without the use of TTS, without internet connection.<br/>
|
|
167
167
|
The node can also create a **_TTS file (without the use of any Sonos device)_**, to be read by third parties nodes.<br/>
|
|
@@ -193,10 +193,12 @@ This is a major **_upgrade from the previously popular node SonosPollyTTS_** (So
|
|
|
193
193
|
## FEATURES
|
|
194
194
|
|
|
195
195
|
- **Native Sonos support**: hear the TTS audio directly via Sonos. You can also group speakers, set an hailing sound, choose the volume of each speaker etc.
|
|
196
|
-
- **
|
|
196
|
+
- **Google Cast support** (Chromecast / Google Nest): play the TTS directly on your Cast devices, with multi-room playback on several devices at once.
|
|
197
|
+
- **DLNA / UPnP renderer support**: play the TTS on generic UPnP renderers (smart TVs, AV receivers, etc.). It also works with renderers that nest a MediaRenderer sub-device (e.g. Sonos).
|
|
198
|
+
- **Output audio file**: the node can just create the TTS file to be used by other nodes. In this case, you don't need to use any player.
|
|
197
199
|
- **Google Translate Voices, Google TTS Voices, ElevenLabs voices and Voice.ai voices** are supported.
|
|
198
|
-
- **Automatic grouping** is supported. You can
|
|
199
|
-
- **Automatic discovery** of your players.
|
|
200
|
+
- **Automatic grouping / multi-room** is supported on Sonos, Google Cast and DLNA. You can add additional players to play your announcements on all of them at once.
|
|
201
|
+
- **Automatic discovery** of your players: Sonos and DLNA/UPnP renderers via SSDP, Google Cast devices via mDNS. A "Discover" button in the node lists the devices found on your network.
|
|
200
202
|
- **Automatic resume of music** queue (including radio stations, but here, some users reports problem resuming **_radio stations_** and, because of lack of Sonos API documentation, the issue cannot currently be fixed), at exact track, at exact time. **Be aware that this could not work with all music queues**.
|
|
201
203
|
- **TTS caching**. ElevenLabs and Google paid service charge you for a high rate of text-to-speech requests. TTS-Ultimate caches the TTS files: it downloads each generated audio only once, and then reads it from cache. The cache is resilient (survives reboots and updates).
|
|
202
204
|
- **Can work offline**. You can use your own audio files (with OwnFile node) to make the node works offline.
|
|
@@ -224,7 +226,7 @@ This is a major **_upgrade from the previously popular node SonosPollyTTS_** (So
|
|
|
224
226
|
|
|
225
227
|
Here you can set all parameters you need. All nodes will refer to this config node, so you need to set it only once.<br/>
|
|
226
228
|
IF YOU RUN NODE-RED BEHIND DOCKER OR SOMETHING ELSE, BE AWARE: <br/>
|
|
227
|
-
PORT USED BY THE NODE ARE 1980 (DEFAULT) AND 1400 (FOR SONOS DISCOVER). <br/>
|
|
229
|
+
PORT USED BY THE NODE ARE 1980 (DEFAULT, HTTP FILE SERVER) AND 1400 (FOR SONOS DISCOVER). <br/>
|
|
228
230
|
PLEASE ALLOW MDNS AND UDP AS WELL
|
|
229
231
|
|
|
230
232
|
**TTS Service**<br/>
|
|
@@ -265,7 +267,7 @@ For Google TTS Engine, you can choose pitch and speed rate of the voice.
|
|
|
265
267
|
set IP of your node-red machine. Write **AUTODISCOVER** to allow the node to auto discover your IP.
|
|
266
268
|
|
|
267
269
|
**Host Port**<br/>
|
|
268
|
-
Sonos will connect to this port
|
|
270
|
+
The players (Sonos, Google Cast, DLNA/UPnP renderers) will connect to this port to fetch the TTS audio. Default 1980. Choose a free port. Do not use 1880 or any other port already in use on your computer. The port must be reachable from the players on your network.
|
|
269
271
|
|
|
270
272
|
Note: if you use multiple `ttsultimate-config` nodes, each one now keeps its own TTS cache folder; the “purge on restart/deploy” option only affects that config node’s cache.
|
|
271
273
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-tts-ultimate",
|
|
3
|
-
"version": "3.0
|
|
4
|
-
"description": "Transforms the text in speech and hear it using Sonos
|
|
3
|
+
"version": "3.2.0",
|
|
4
|
+
"description": "Transforms the text in speech and hear it using Sonos, Google Cast (Chromecast / Nest) or DLNA/UPnP players, or generate an audio file to be used with third parties nodes. Works with voices from Google (without credentials as well), Google TTS, ElevenLabs.io TTS, Voice.ai TTS or your own voice. You can also only create a TTS file to be read by third party nodes. Update of the popular SonosPollyTTS node.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "test",
|
|
@@ -39,13 +39,13 @@
|
|
|
39
39
|
},
|
|
40
40
|
"homepage": "https://github.com/Supergiovane/node-red-contrib-tts-ultimate",
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"sonos": "1.14.1",
|
|
43
|
-
"formidable": "1.2.2",
|
|
44
|
-
"path": ">=0.12.7",
|
|
45
42
|
"@google-cloud/text-to-speech": "4.2.2",
|
|
46
|
-
"
|
|
43
|
+
"castv2-client": "^1.2.0",
|
|
44
|
+
"elevenlabs": "0.18.0",
|
|
47
45
|
"elevenlabs-node": "1.1.3",
|
|
48
|
-
"
|
|
46
|
+
"formidable": "1.2.2",
|
|
47
|
+
"multicast-dns": "^7.2.5",
|
|
48
|
+
"sonos": "1.14.1"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"eslint": ">=4.18.2",
|
|
@@ -54,4 +54,4 @@
|
|
|
54
54
|
"engines": {
|
|
55
55
|
"node": ">=22.0.0"
|
|
56
56
|
}
|
|
57
|
-
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Discover DLNA / UPnP MediaRenderer devices on the local network.
|
|
2
|
+
// Prints the device description XML URL (LOCATION) to paste into the
|
|
3
|
+
// tts-ultimate "DLNA / UPnP renderer" player configuration.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// node scripts/discover-dlna.js (search ~5s)
|
|
7
|
+
// node scripts/discover-dlna.js --timeout 8000
|
|
8
|
+
const { discoverRenderers } = require("../ttsultimate/lib/dlna-discovery");
|
|
9
|
+
|
|
10
|
+
const argValue = (name, fallback) => {
|
|
11
|
+
const idx = process.argv.indexOf(name);
|
|
12
|
+
if (idx === -1) return fallback;
|
|
13
|
+
const value = process.argv[idx + 1];
|
|
14
|
+
return value && !value.startsWith("--") ? value : fallback;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const timeoutMs = Number(argValue("--timeout", 5000)) || 5000;
|
|
18
|
+
|
|
19
|
+
(async () => {
|
|
20
|
+
// eslint-disable-next-line no-console
|
|
21
|
+
console.log(`Searching for DLNA/UPnP MediaRenderers for ${timeoutMs} ms...\n`);
|
|
22
|
+
const devices = await discoverRenderers({ timeoutMs });
|
|
23
|
+
if (devices.length === 0) {
|
|
24
|
+
// eslint-disable-next-line no-console
|
|
25
|
+
console.log("No MediaRenderer devices found. Make sure a renderer is on and on the same subnet.");
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
// eslint-disable-next-line no-console
|
|
29
|
+
console.log(`Found ${devices.length} renderer(s):\n`);
|
|
30
|
+
for (const dev of devices) {
|
|
31
|
+
// eslint-disable-next-line no-console
|
|
32
|
+
console.log(" " + (dev.name || "(unknown name)"));
|
|
33
|
+
// eslint-disable-next-line no-console
|
|
34
|
+
console.log(" Description URL (paste this in the node): " + dev.location);
|
|
35
|
+
if (dev.server) console.log(" Server: " + dev.server);
|
|
36
|
+
// eslint-disable-next-line no-console
|
|
37
|
+
console.log("");
|
|
38
|
+
}
|
|
39
|
+
process.exit(0);
|
|
40
|
+
})().catch((err) => {
|
|
41
|
+
// eslint-disable-next-line no-console
|
|
42
|
+
console.error(err);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Discover DLNA / UPnP MediaRenderer devices on the local network via SSDP.
|
|
2
|
+
// No external dependencies: built-in dgram (UDP multicast) + http only.
|
|
3
|
+
const dgram = require("dgram");
|
|
4
|
+
const http = require("http");
|
|
5
|
+
const { URL } = require("url");
|
|
6
|
+
|
|
7
|
+
const SSDP_ADDR = "239.255.255.250";
|
|
8
|
+
const SSDP_PORT = 1900;
|
|
9
|
+
const SEARCH_TARGET = "urn:schemas-upnp-org:device:MediaRenderer:1";
|
|
10
|
+
|
|
11
|
+
// Reads the <friendlyName> from a device description XML URL (best-effort).
|
|
12
|
+
function fetchFriendlyName(location) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
try {
|
|
15
|
+
const u = new URL(location);
|
|
16
|
+
const req = http.get(
|
|
17
|
+
{ hostname: u.hostname, port: u.port || 80, path: u.pathname + u.search, timeout: 3000 },
|
|
18
|
+
(res) => {
|
|
19
|
+
let body = "";
|
|
20
|
+
res.on("data", (c) => (body += c));
|
|
21
|
+
res.on("end", () => {
|
|
22
|
+
const m = body.match(/<friendlyName>([^<]+)<\/friendlyName>/i);
|
|
23
|
+
resolve(m ? m[1].trim() : "");
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
);
|
|
27
|
+
req.on("error", () => resolve(""));
|
|
28
|
+
req.on("timeout", () => { req.destroy(); resolve(""); });
|
|
29
|
+
} catch (e) {
|
|
30
|
+
resolve("");
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// discoverRenderers({ timeoutMs }) -> Promise<[{ name, location, server }]>
|
|
36
|
+
function discoverRenderers(options) {
|
|
37
|
+
const opts = options || {};
|
|
38
|
+
const timeoutMs = Number(opts.timeoutMs) || 5000;
|
|
39
|
+
const withNames = opts.withNames !== false; // resolve friendlyName by default
|
|
40
|
+
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const found = new Map(); // location -> { location, server }
|
|
43
|
+
const mSearch = Buffer.from(
|
|
44
|
+
"M-SEARCH * HTTP/1.1\r\n" +
|
|
45
|
+
`HOST: ${SSDP_ADDR}:${SSDP_PORT}\r\n` +
|
|
46
|
+
'MAN: "ssdp:discover"\r\n' +
|
|
47
|
+
"MX: 2\r\n" +
|
|
48
|
+
`ST: ${SEARCH_TARGET}\r\n` +
|
|
49
|
+
"\r\n"
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
let socket;
|
|
53
|
+
try {
|
|
54
|
+
socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
reject(error);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
socket.on("message", (msg) => {
|
|
61
|
+
const text = msg.toString();
|
|
62
|
+
// Keep only genuine MediaRenderer responses (some non-compliant devices,
|
|
63
|
+
// e.g. Hue bridges, answer any M-SEARCH; their ST/USN won't mention it).
|
|
64
|
+
if (!/MediaRenderer/i.test(text)) return;
|
|
65
|
+
const locationMatch = text.match(/LOCATION:\s*(.+)\r/i);
|
|
66
|
+
if (!locationMatch) return;
|
|
67
|
+
const location = locationMatch[1].trim();
|
|
68
|
+
if (found.has(location)) return;
|
|
69
|
+
const serverMatch = text.match(/SERVER:\s*(.+)\r/i);
|
|
70
|
+
found.set(location, { location, server: serverMatch ? serverMatch[1].trim() : "" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
socket.on("error", (err) => {
|
|
74
|
+
try { socket.close(); } catch (e) { }
|
|
75
|
+
reject(err);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
socket.bind(() => {
|
|
79
|
+
try { socket.setBroadcast(true); } catch (e) { }
|
|
80
|
+
const send = () => { try { socket.send(mSearch, 0, mSearch.length, SSDP_PORT, SSDP_ADDR); } catch (e) { } };
|
|
81
|
+
send();
|
|
82
|
+
setTimeout(send, 500);
|
|
83
|
+
setTimeout(send, 1500);
|
|
84
|
+
|
|
85
|
+
setTimeout(async () => {
|
|
86
|
+
try { socket.close(); } catch (e) { }
|
|
87
|
+
const devices = Array.from(found.values());
|
|
88
|
+
if (!withNames) { resolve(devices); return; }
|
|
89
|
+
for (const dev of devices) {
|
|
90
|
+
dev.name = (await fetchFriendlyName(dev.location)) || "";
|
|
91
|
+
}
|
|
92
|
+
resolve(devices);
|
|
93
|
+
}, timeoutMs);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { discoverRenderers, fetchFriendlyName };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Minimal native DLNA / UPnP MediaRenderer control client.
|
|
2
|
+
// Unlike upnp-mediarenderer-client, it locates the AVTransport / RenderingControl
|
|
3
|
+
// services anywhere in the device tree (root device OR embedded <deviceList> devices),
|
|
4
|
+
// so it also works with renderers that nest a MediaRenderer sub-device (e.g. Sonos).
|
|
5
|
+
//
|
|
6
|
+
// It uses only the built-in http module (no XML parser dependency): the UPnP
|
|
7
|
+
// description and SOAP responses are simple enough to read with focused regexes.
|
|
8
|
+
const http = require("http");
|
|
9
|
+
const { URL } = require("url");
|
|
10
|
+
|
|
11
|
+
function httpRequest(options, body) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const req = http.request(options, (res) => {
|
|
14
|
+
let data = "";
|
|
15
|
+
res.on("data", (c) => (data += c));
|
|
16
|
+
res.on("end", () => resolve({ statusCode: res.statusCode, body: data }));
|
|
17
|
+
});
|
|
18
|
+
req.on("error", reject);
|
|
19
|
+
req.on("timeout", () => { req.destroy(new Error("request timeout")); });
|
|
20
|
+
if (body) req.write(body);
|
|
21
|
+
req.end();
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getText(xml, tag) {
|
|
26
|
+
const m = xml.match(new RegExp("<" + tag + "[^>]*>([\\s\\S]*?)</" + tag + ">", "i"));
|
|
27
|
+
return m ? m[1].trim() : "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function escapeXml(s) {
|
|
31
|
+
return String(s)
|
|
32
|
+
.replace(/&/g, "&")
|
|
33
|
+
.replace(/</g, "<")
|
|
34
|
+
.replace(/>/g, ">")
|
|
35
|
+
.replace(/"/g, """)
|
|
36
|
+
.replace(/'/g, "'");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Fetch the device description and resolve the (absolute) control URLs for
|
|
40
|
+
// AVTransport and RenderingControl, scanning every <service> in the document.
|
|
41
|
+
async function resolveServices(descriptionUrl) {
|
|
42
|
+
let u;
|
|
43
|
+
try {
|
|
44
|
+
u = new URL(descriptionUrl);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
throw new Error('invalid DLNA renderer URL "' + descriptionUrl + '": use the full device description XML URL (e.g. http://192.168.1.50:49153/nmrDescription.xml), not just the IP address');
|
|
47
|
+
}
|
|
48
|
+
if (!/^https?:$/.test(u.protocol)) {
|
|
49
|
+
throw new Error('DLNA renderer URL must start with http:// (got "' + descriptionUrl + '")');
|
|
50
|
+
}
|
|
51
|
+
const res = await httpRequest({
|
|
52
|
+
hostname: u.hostname,
|
|
53
|
+
port: u.port || 80,
|
|
54
|
+
path: u.pathname + u.search,
|
|
55
|
+
method: "GET",
|
|
56
|
+
timeout: 5000
|
|
57
|
+
});
|
|
58
|
+
if (!res.body) throw new Error("empty device description");
|
|
59
|
+
const xml = res.body;
|
|
60
|
+
const urlBase = getText(xml, "URLBase");
|
|
61
|
+
const base = urlBase || descriptionUrl;
|
|
62
|
+
|
|
63
|
+
const services = {};
|
|
64
|
+
const svcRe = /<service>([\s\S]*?)<\/service>/g;
|
|
65
|
+
let m;
|
|
66
|
+
while ((m = svcRe.exec(xml)) !== null) {
|
|
67
|
+
const block = m[1];
|
|
68
|
+
const type = getText(block, "serviceType");
|
|
69
|
+
const ctrl = getText(block, "controlURL");
|
|
70
|
+
if (!type || !ctrl) continue;
|
|
71
|
+
const controlURL = new URL(ctrl, base).href;
|
|
72
|
+
// Match exact service ids; ":service:RenderingControl:" will not match "GroupRenderingControl".
|
|
73
|
+
if (/:service:AVTransport:\d/i.test(type) && !services.AVTransport) {
|
|
74
|
+
services.AVTransport = { type, controlURL };
|
|
75
|
+
} else if (/:service:RenderingControl:\d/i.test(type) && !services.RenderingControl) {
|
|
76
|
+
services.RenderingControl = { type, controlURL };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!services.AVTransport) throw new Error("AVTransport service not found in device description");
|
|
80
|
+
return services;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function soapAction(controlURL, serviceType, action, argsXml) {
|
|
84
|
+
const u = new URL(controlURL);
|
|
85
|
+
const body =
|
|
86
|
+
'<?xml version="1.0" encoding="utf-8"?>' +
|
|
87
|
+
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' +
|
|
88
|
+
"<s:Body>" +
|
|
89
|
+
'<u:' + action + ' xmlns:u="' + serviceType + '">' + argsXml + "</u:" + action + ">" +
|
|
90
|
+
"</s:Body></s:Envelope>";
|
|
91
|
+
return httpRequest({
|
|
92
|
+
hostname: u.hostname,
|
|
93
|
+
port: u.port || 80,
|
|
94
|
+
path: u.pathname + u.search,
|
|
95
|
+
method: "POST",
|
|
96
|
+
timeout: 8000,
|
|
97
|
+
headers: {
|
|
98
|
+
"Content-Type": 'text/xml; charset="utf-8"',
|
|
99
|
+
"SOAPACTION": '"' + serviceType + "#" + action + '"',
|
|
100
|
+
"Content-Length": Buffer.byteLength(body)
|
|
101
|
+
}
|
|
102
|
+
}, body).then((res) => {
|
|
103
|
+
if (res.statusCode >= 400) {
|
|
104
|
+
const errCode = getText(res.body, "errorCode");
|
|
105
|
+
throw new Error("SOAP " + action + " failed: HTTP " + res.statusCode + (errCode ? " (UPnP error " + errCode + ")" : ""));
|
|
106
|
+
}
|
|
107
|
+
return res.body;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildDidl(url, contentType) {
|
|
112
|
+
const protocolInfo = "http-get:*:" + (contentType || "audio/mpeg") + ":*";
|
|
113
|
+
return (
|
|
114
|
+
'<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" ' +
|
|
115
|
+
'xmlns:dc="http://purl.org/dc/elements/1.1/" ' +
|
|
116
|
+
'xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">' +
|
|
117
|
+
'<item id="0" parentID="-1" restricted="1">' +
|
|
118
|
+
"<dc:title>TTS</dc:title>" +
|
|
119
|
+
"<upnp:class>object.item.audioItem.musicTrack</upnp:class>" +
|
|
120
|
+
'<res protocolInfo="' + protocolInfo + '">' + escapeXml(url) + "</res>" +
|
|
121
|
+
"</item></DIDL-Lite>"
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// createPlayer(descriptionUrl) -> { setVolume, setAVTransportURI, play, getTransportState }
|
|
126
|
+
function createPlayer(descriptionUrl) {
|
|
127
|
+
let servicesPromise = null;
|
|
128
|
+
const getServices = () => {
|
|
129
|
+
if (!servicesPromise) servicesPromise = resolveServices(descriptionUrl);
|
|
130
|
+
return servicesPromise;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
async setVolume(volume0to100) {
|
|
135
|
+
const svc = await getServices();
|
|
136
|
+
if (!svc.RenderingControl) return; // not all renderers expose volume control
|
|
137
|
+
const args =
|
|
138
|
+
"<InstanceID>0</InstanceID><Channel>Master</Channel>" +
|
|
139
|
+
"<DesiredVolume>" + Math.max(0, Math.min(100, Math.round(volume0to100))) + "</DesiredVolume>";
|
|
140
|
+
return soapAction(svc.RenderingControl.controlURL, svc.RenderingControl.type, "SetVolume", args);
|
|
141
|
+
},
|
|
142
|
+
async setAVTransportURI(url, contentType) {
|
|
143
|
+
const svc = await getServices();
|
|
144
|
+
const didl = buildDidl(url, contentType);
|
|
145
|
+
const args =
|
|
146
|
+
"<InstanceID>0</InstanceID>" +
|
|
147
|
+
"<CurrentURI>" + escapeXml(url) + "</CurrentURI>" +
|
|
148
|
+
"<CurrentURIMetaData>" + escapeXml(didl) + "</CurrentURIMetaData>";
|
|
149
|
+
return soapAction(svc.AVTransport.controlURL, svc.AVTransport.type, "SetAVTransportURI", args);
|
|
150
|
+
},
|
|
151
|
+
async play() {
|
|
152
|
+
const svc = await getServices();
|
|
153
|
+
return soapAction(svc.AVTransport.controlURL, svc.AVTransport.type, "Play", "<InstanceID>0</InstanceID><Speed>1</Speed>");
|
|
154
|
+
},
|
|
155
|
+
async getTransportState() {
|
|
156
|
+
const svc = await getServices();
|
|
157
|
+
const bodyXml = await soapAction(svc.AVTransport.controlURL, svc.AVTransport.type, "GetTransportInfo", "<InstanceID>0</InstanceID>");
|
|
158
|
+
return getText(bodyXml, "CurrentTransportState");
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { createPlayer, resolveServices };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Discover Google Cast devices (Chromecast / Google Nest) on the local network
|
|
2
|
+
// via mDNS / DNS-SD (service "_googlecast._tcp.local").
|
|
3
|
+
const mdns = require("multicast-dns");
|
|
4
|
+
|
|
5
|
+
const SERVICE = "_googlecast._tcp.local";
|
|
6
|
+
|
|
7
|
+
// discoverCastDevices({ timeoutMs }) -> Promise<[{ name, host }]>
|
|
8
|
+
function discoverCastDevices(options) {
|
|
9
|
+
const opts = options || {};
|
|
10
|
+
const timeoutMs = Number(opts.timeoutMs) || 4000;
|
|
11
|
+
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let socket;
|
|
14
|
+
try {
|
|
15
|
+
socket = mdns();
|
|
16
|
+
} catch (error) {
|
|
17
|
+
resolve([]);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const srvByName = new Map(); // service instance name -> target hostname
|
|
22
|
+
const fnByName = new Map(); // service instance name -> friendly name (TXT "fn=")
|
|
23
|
+
const ipByHost = new Map(); // hostname -> IPv4 address
|
|
24
|
+
|
|
25
|
+
const handleRecords = (records) => {
|
|
26
|
+
(records || []).forEach((r) => {
|
|
27
|
+
if (!r || !r.name) return;
|
|
28
|
+
if (r.type === "SRV" && /_googlecast\._tcp/i.test(r.name) && r.data) {
|
|
29
|
+
srvByName.set(r.name, r.data.target);
|
|
30
|
+
} else if (r.type === "TXT" && /_googlecast\._tcp/i.test(r.name)) {
|
|
31
|
+
const arr = Array.isArray(r.data) ? r.data : [r.data];
|
|
32
|
+
arr.forEach((entry) => {
|
|
33
|
+
const s = (entry || "").toString();
|
|
34
|
+
if (/^fn=/i.test(s)) fnByName.set(r.name, s.slice(3));
|
|
35
|
+
});
|
|
36
|
+
} else if (r.type === "A" && r.data) {
|
|
37
|
+
ipByHost.set(r.name, r.data);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
socket.on("response", (response) => {
|
|
43
|
+
handleRecords(response.answers);
|
|
44
|
+
handleRecords(response.additionals);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const query = () => {
|
|
48
|
+
try { socket.query({ questions: [{ name: SERVICE, type: "PTR" }] }); } catch (e) { }
|
|
49
|
+
};
|
|
50
|
+
query();
|
|
51
|
+
setTimeout(query, 800);
|
|
52
|
+
|
|
53
|
+
setTimeout(() => {
|
|
54
|
+
try { socket.destroy(); } catch (e) { }
|
|
55
|
+
const devices = [];
|
|
56
|
+
const seenIp = new Set();
|
|
57
|
+
srvByName.forEach((target, name) => {
|
|
58
|
+
const ip = ipByHost.get(target);
|
|
59
|
+
if (!ip || seenIp.has(ip)) return;
|
|
60
|
+
seenIp.add(ip);
|
|
61
|
+
const fn = fnByName.get(name) || name.split(".")[0];
|
|
62
|
+
devices.push({ name: fn, host: ip });
|
|
63
|
+
});
|
|
64
|
+
resolve(devices);
|
|
65
|
+
}, timeoutMs);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { discoverCastDevices };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Native Google Translate TTS implementation.
|
|
2
|
+
// Replaces the external "google-translate-tts" dependency.
|
|
3
|
+
// Exposes the same public API: { synthesize, voices }.
|
|
4
|
+
//
|
|
5
|
+
// It performs an unauthenticated POST to the Google Translate web endpoint
|
|
6
|
+
// (the same one the translate.google.com page uses for the "listen" button)
|
|
7
|
+
// and decodes the base64 MP3 audio from the response.
|
|
8
|
+
const https = require("https");
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Voices supported by Google Translate (free, unauthenticated) TTS.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
const voices = (() => {
|
|
14
|
+
// prettier-ignore
|
|
15
|
+
const list = [
|
|
16
|
+
{ code: "af-ZA", name: "Afrikaans" },
|
|
17
|
+
{ code: "sq", name: "Albanian" },
|
|
18
|
+
{ code: "ar-AE", name: "Arabic" },
|
|
19
|
+
{ code: "hy", name: "Armenian" },
|
|
20
|
+
{ code: "bn-BD", name: "Bengali (Bangladesh)" },
|
|
21
|
+
{ code: "bn-IN", name: "Bengali (India)" },
|
|
22
|
+
{ code: "bs", name: "Bosnian" },
|
|
23
|
+
{ code: "my", name: "Burmese (Myanmar)" },
|
|
24
|
+
{ code: "ca-ES", name: "Catalan" },
|
|
25
|
+
{ code: "cmn-Hant-TW", name: "Chinese" },
|
|
26
|
+
{ code: "hr-HR", name: "Croatian" },
|
|
27
|
+
{ code: "cs-CZ", name: "Czech" },
|
|
28
|
+
{ code: "da-DK", name: "Danish" },
|
|
29
|
+
{ code: "nl-NL", name: "Dutch" },
|
|
30
|
+
{ code: "en-AU", name: "English (Australia)" },
|
|
31
|
+
{ code: "en-GB", name: "English (United Kingdom)" },
|
|
32
|
+
{ code: "en-US", name: "English (United States)" },
|
|
33
|
+
{ code: "eo", name: "Esperanto" },
|
|
34
|
+
{ code: "et", name: "Estonian" },
|
|
35
|
+
{ code: "fil-PH", name: "Filipino" },
|
|
36
|
+
{ code: "fi-FI", name: "Finnish" },
|
|
37
|
+
{ code: "fr-FR", name: "French" },
|
|
38
|
+
{ code: "fr-CA", name: "French (Canada)" },
|
|
39
|
+
{ code: "de-DE", name: "German" },
|
|
40
|
+
{ code: "el-GR", name: "Greek" },
|
|
41
|
+
{ code: "gu", name: "Gujarati" },
|
|
42
|
+
{ code: "hi-IN", name: "Hindi" },
|
|
43
|
+
{ code: "hu-HU", name: "Hungarian" },
|
|
44
|
+
{ code: "is-IS", name: "Icelandic" },
|
|
45
|
+
{ code: "id-ID", name: "Indonesian" },
|
|
46
|
+
{ code: "it-IT", name: "Italian" },
|
|
47
|
+
{ code: "ja-JP", name: "Japanese (Japan)" },
|
|
48
|
+
{ code: "kn", name: "Kannada" },
|
|
49
|
+
{ code: "km", name: "Khmer" },
|
|
50
|
+
{ code: "ko-KR", name: "Korean" },
|
|
51
|
+
{ code: "la", name: "Latin" },
|
|
52
|
+
{ code: "lv", name: "Latvian" },
|
|
53
|
+
{ code: "mk", name: "Macedonian" },
|
|
54
|
+
{ code: "ml", name: "Malayalam" },
|
|
55
|
+
{ code: "mr", name: "Marathi" },
|
|
56
|
+
{ code: "ne", name: "Nepali" },
|
|
57
|
+
{ code: "nb-NO", name: "Norwegian" },
|
|
58
|
+
{ code: "pl-PL", name: "Polish" },
|
|
59
|
+
{ code: "pt-BR", name: "Portuguese" },
|
|
60
|
+
{ code: "ro-RO", name: "Romanian" },
|
|
61
|
+
{ code: "ru-RU", name: "Russian" },
|
|
62
|
+
{ code: "sr-RS", name: "Serbian" },
|
|
63
|
+
{ code: "si", name: "Sinhala" },
|
|
64
|
+
{ code: "sk-SK", name: "Slovak" },
|
|
65
|
+
{ code: "es-MX", name: "Spanish (Mexico)" },
|
|
66
|
+
{ code: "es-ES", name: "Spanish (Spain)" },
|
|
67
|
+
{ code: "sw", name: "Swahili" },
|
|
68
|
+
{ code: "sv-SE", name: "Swedish" },
|
|
69
|
+
{ code: "ta", name: "Tamil" },
|
|
70
|
+
{ code: "te", name: "Telugu" },
|
|
71
|
+
{ code: "th-TH", name: "Thai" },
|
|
72
|
+
{ code: "tr-TR", name: "Turkish" },
|
|
73
|
+
{ code: "uk-UA", name: "Ukrainian" },
|
|
74
|
+
{ code: "ur", name: "Urdu" },
|
|
75
|
+
{ code: "vi-VN", name: "Vietnamese" },
|
|
76
|
+
{ code: "cy", name: "Welsh" }
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
list.findByCode = (code) => list.find((l) => l.code === code);
|
|
80
|
+
list.findByName = (name) => list.find((l) => l.name === name);
|
|
81
|
+
|
|
82
|
+
return list;
|
|
83
|
+
})();
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// synthesize({ text, voice, slow }) -> Promise<Buffer> (MP3 audio)
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
const requestOptions = {
|
|
89
|
+
headers: {
|
|
90
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
91
|
+
},
|
|
92
|
+
hostname: "translate.google.com",
|
|
93
|
+
method: "POST",
|
|
94
|
+
path: "/_/TranslateWebserverUi/data/batchexecute",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Builds the urlencoded "f.req" body expected by the batchexecute endpoint.
|
|
98
|
+
const buildBody = ({ slow = false, text, voice }) => {
|
|
99
|
+
const values = JSON.stringify([text, voice, slow ? true : null, "null"]);
|
|
100
|
+
const data = JSON.stringify([[["jQ1olc", values, null, "generic"]]]);
|
|
101
|
+
const params = new URLSearchParams({ "f.req": data });
|
|
102
|
+
return params.toString();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const httpRequest = (opts) =>
|
|
106
|
+
new Promise((resolve, reject) => {
|
|
107
|
+
const req = https.request(requestOptions, (res) => {
|
|
108
|
+
let data = "";
|
|
109
|
+
res.on("data", (chunk) => (data += chunk));
|
|
110
|
+
res.on("end", () => resolve(data));
|
|
111
|
+
});
|
|
112
|
+
req.on("error", reject);
|
|
113
|
+
req.write(buildBody(opts));
|
|
114
|
+
req.end();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/* Response looks like:
|
|
118
|
+
*
|
|
119
|
+
* )]}'
|
|
120
|
+
*
|
|
121
|
+
* [["wrb.fr","jQ1olc","[\"<base 64 data>\"]"]]
|
|
122
|
+
* ,["di",52]
|
|
123
|
+
* ,["af.httprm",51,"8692744518077823928",2]
|
|
124
|
+
* ]
|
|
125
|
+
*/
|
|
126
|
+
const toBuffer = (response) => {
|
|
127
|
+
const slice = response.split("\n").slice(1).join("");
|
|
128
|
+
const json = JSON.parse(slice);
|
|
129
|
+
const dataString = json[0][2];
|
|
130
|
+
const dataArray = JSON.parse(dataString);
|
|
131
|
+
|
|
132
|
+
if (dataArray === null)
|
|
133
|
+
throw new Error("Unable to parse audio data. Check your request params.");
|
|
134
|
+
|
|
135
|
+
return Buffer.from(dataArray[0], "base64");
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const synthesize = (opts) => httpRequest(opts).then(toBuffer);
|
|
139
|
+
|
|
140
|
+
module.exports = { synthesize, voices };
|
|
@@ -13,7 +13,7 @@ module.exports = function (RED) {
|
|
|
13
13
|
// Setting up the engines
|
|
14
14
|
const AWS = require('aws-sdk');
|
|
15
15
|
const GoogleTTS = require('@google-cloud/text-to-speech');
|
|
16
|
-
const GoogleTranslate = require('
|
|
16
|
+
const GoogleTranslate = require('./lib/googletranslate'); // Native TTS without credentials, limited to 200 chars per row.
|
|
17
17
|
const microsoftAzureTTS = require("microsoft-cognitiveservices-speech-sdk"); // 12/10/2021
|
|
18
18
|
const elevenlabsTTS = require("elevenlabs-node"); // 03/08/2023
|
|
19
19
|
const ElevenLabsClient = require("elevenlabs").ElevenLabsClient;
|