node-red-contrib-tts-ultimate
Version:
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
99 lines (88 loc) • 3.75 kB
JavaScript
// Discover DLNA / UPnP MediaRenderer devices on the local network via SSDP.
// No external dependencies: built-in dgram (UDP multicast) + http only.
const dgram = require("dgram");
const http = require("http");
const { URL } = require("url");
const SSDP_ADDR = "239.255.255.250";
const SSDP_PORT = 1900;
const SEARCH_TARGET = "urn:schemas-upnp-org:device:MediaRenderer:1";
// Reads the <friendlyName> from a device description XML URL (best-effort).
function fetchFriendlyName(location) {
return new Promise((resolve) => {
try {
const u = new URL(location);
const req = http.get(
{ hostname: u.hostname, port: u.port || 80, path: u.pathname + u.search, timeout: 3000 },
(res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
const m = body.match(/<friendlyName>([^<]+)<\/friendlyName>/i);
resolve(m ? m[1].trim() : "");
});
}
);
req.on("error", () => resolve(""));
req.on("timeout", () => { req.destroy(); resolve(""); });
} catch (e) {
resolve("");
}
});
}
// discoverRenderers({ timeoutMs }) -> Promise<[{ name, location, server }]>
function discoverRenderers(options) {
const opts = options || {};
const timeoutMs = Number(opts.timeoutMs) || 5000;
const withNames = opts.withNames !== false; // resolve friendlyName by default
return new Promise((resolve, reject) => {
const found = new Map(); // location -> { location, server }
const mSearch = Buffer.from(
"M-SEARCH * HTTP/1.1\r\n" +
`HOST: ${SSDP_ADDR}:${SSDP_PORT}\r\n` +
'MAN: "ssdp:discover"\r\n' +
"MX: 2\r\n" +
`ST: ${SEARCH_TARGET}\r\n` +
"\r\n"
);
let socket;
try {
socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
} catch (error) {
reject(error);
return;
}
socket.on("message", (msg) => {
const text = msg.toString();
// Keep only genuine MediaRenderer responses (some non-compliant devices,
// e.g. Hue bridges, answer any M-SEARCH; their ST/USN won't mention it).
if (!/MediaRenderer/i.test(text)) return;
const locationMatch = text.match(/LOCATION:\s*(.+)\r/i);
if (!locationMatch) return;
const location = locationMatch[1].trim();
if (found.has(location)) return;
const serverMatch = text.match(/SERVER:\s*(.+)\r/i);
found.set(location, { location, server: serverMatch ? serverMatch[1].trim() : "" });
});
socket.on("error", (err) => {
try { socket.close(); } catch (e) { }
reject(err);
});
socket.bind(() => {
try { socket.setBroadcast(true); } catch (e) { }
const send = () => { try { socket.send(mSearch, 0, mSearch.length, SSDP_PORT, SSDP_ADDR); } catch (e) { } };
send();
setTimeout(send, 500);
setTimeout(send, 1500);
setTimeout(async () => {
try { socket.close(); } catch (e) { }
const devices = Array.from(found.values());
if (!withNames) { resolve(devices); return; }
for (const dev of devices) {
dev.name = (await fetchFriendlyName(dev.location)) || "";
}
resolve(devices);
}, timeoutMs);
});
});
}
module.exports = { discoverRenderers, fetchFriendlyName };