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
|
@@ -77,6 +77,8 @@ module.exports = function (RED) {
|
|
|
77
77
|
node.currentMSGbeingSpoken = {}; // Stores the current message being spoken
|
|
78
78
|
node.sonosCoordinatorPreviousVolumeSetByApp = 0; // 05/07/2021 stores the main payer volume set by the sonos app
|
|
79
79
|
node.playertype = config.playertype === undefined ? "sonos" : config.playertype; // 20/09/2021 Player type
|
|
80
|
+
node.playeripaddress = (config.playeripaddress || "").trim(); // 06/2026 Chromecast/Nest IP or DLNA/UPnP renderer description XML URL
|
|
81
|
+
node.playervolume = config.playervolume === undefined ? "50" : config.playervolume; // 06/2026 Volume (0-100) for Google Cast / DLNA players
|
|
80
82
|
node.speakingpitch = config.speakingpitch === undefined ? "0" : config.speakingpitch; // 21/09/2021 AudioConfig speakingpitch
|
|
81
83
|
node.speakingrate = config.speakingrate === undefined ? "1" : config.speakingrate; // 21/09/2021 AudioConfig speakingrate
|
|
82
84
|
node.unmuteIfMuted = config.unmuteIfMuted === undefined ? false : config.unmuteIfMuted; // 21/10/2021 Unmute if previiously muted.
|
|
@@ -422,7 +424,8 @@ module.exports = function (RED) {
|
|
|
422
424
|
}
|
|
423
425
|
// 27/11/2019 Start the connection healty check
|
|
424
426
|
node.oTimerSonosConnectionCheck = setTimeout(function () { node.CheckSonosConnection(); }, 5000);
|
|
425
|
-
} else
|
|
427
|
+
} else {
|
|
428
|
+
// 06/2026 noplayer, googlecast and dlna do not use the Sonos connection healthcheck.
|
|
426
429
|
node.msg.connectionerror = false;
|
|
427
430
|
}
|
|
428
431
|
|
|
@@ -530,7 +533,7 @@ module.exports = function (RED) {
|
|
|
530
533
|
node.server.whoIsUsingTheServer = node.id; // Signal to other ttsultimate node, that i'm using the Sonos device
|
|
531
534
|
try {
|
|
532
535
|
|
|
533
|
-
if (node.playertype
|
|
536
|
+
if (node.playertype === "sonos") {
|
|
534
537
|
// Get the current music queue, if one
|
|
535
538
|
var oCurTrack = null;
|
|
536
539
|
try {
|
|
@@ -746,6 +749,7 @@ module.exports = function (RED) {
|
|
|
746
749
|
const stability = config.elevenlabsStability !== undefined && config.elevenlabsStability !== "" ? Number(config.elevenlabsStability) : undefined;
|
|
747
750
|
const similarity = config.elevenlabsSimilarity_boost !== undefined && config.elevenlabsSimilarity_boost !== "" ? Number(config.elevenlabsSimilarity_boost) : undefined;
|
|
748
751
|
const style = config.elevenlabsStyle !== undefined && config.elevenlabsStyle !== "" ? Number(config.elevenlabsStyle) : undefined;
|
|
752
|
+
const speed = config.elevenlabsSpeed !== undefined && config.elevenlabsSpeed !== "" ? Number(config.elevenlabsSpeed) : undefined;
|
|
749
753
|
const resolvedModel = config.elevenlabsModel && config.elevenlabsModel !== "" ? config.elevenlabsModel : "eleven_multilingual_v2";
|
|
750
754
|
const latencyPreset = config.elevenlabsOptimizeLatency && config.elevenlabsOptimizeLatency !== "" ? config.elevenlabsOptimizeLatency : undefined;
|
|
751
755
|
const outputFormat = config.elevenlabsOutputFormat && config.elevenlabsOutputFormat !== "" ? config.elevenlabsOutputFormat : undefined;
|
|
@@ -761,6 +765,7 @@ module.exports = function (RED) {
|
|
|
761
765
|
if (stability !== undefined && !Number.isNaN(stability)) params.voice_settings.stability = stability;
|
|
762
766
|
if (similarity !== undefined && !Number.isNaN(similarity)) params.voice_settings.similarity_boost = similarity;
|
|
763
767
|
if (style !== undefined && !Number.isNaN(style)) params.voice_settings.style = style;
|
|
768
|
+
if (speed !== undefined && !Number.isNaN(speed)) params.voice_settings.speed = speed;
|
|
764
769
|
params.voice_settings.use_speaker_boost = useSpeakerBoost;
|
|
765
770
|
if (Object.keys(params.voice_settings).length === 0) delete params.voice_settings;
|
|
766
771
|
if (latencyPreset !== undefined) params.optimize_streaming_latency = latencyPreset;
|
|
@@ -907,6 +912,49 @@ module.exports = function (RED) {
|
|
|
907
912
|
node.setNodeStatus({ fill: 'red', shape: 'dot', text: 'Error ' + msg + " " + error.message });
|
|
908
913
|
}
|
|
909
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
|
+
|
|
910
958
|
} else if (node.playertype === "noplayer") {
|
|
911
959
|
// Output only the filename
|
|
912
960
|
if (noPlayerFileArray === undefined || noPlayerFileArray === null) var noPlayerFileArray = [];
|
|
@@ -975,17 +1023,15 @@ module.exports = function (RED) {
|
|
|
975
1023
|
node.server.whoIsUsingTheServer = ""; // Signal to other ttsultimate node, that i'm not using the Sonos device anymore
|
|
976
1024
|
}, 500)
|
|
977
1025
|
|
|
978
|
-
} else
|
|
979
|
-
// End task
|
|
980
|
-
//
|
|
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).
|
|
981
1029
|
// Signal end playing
|
|
982
|
-
//let t = setTimeout(() => {
|
|
983
1030
|
node.msg.completed = true;
|
|
984
1031
|
node.currentMSGbeingSpoken = {};
|
|
985
1032
|
node.send([{ passThroughMessage: node.passThroughMessage, payload: node.msg.completed, filesArray: noPlayerFileArray }, null]);
|
|
986
1033
|
node.bBusyPlayingQueue = false
|
|
987
1034
|
node.server.whoIsUsingTheServer = ""; // Signal to other ttsultimate node, that i'm not using the Sonos device anymore
|
|
988
|
-
//}, 1000)
|
|
989
1035
|
}
|
|
990
1036
|
|
|
991
1037
|
} catch (error) {
|
|
@@ -1053,9 +1099,11 @@ module.exports = function (RED) {
|
|
|
1053
1099
|
// 27/01/2021 Stop whatever in play.
|
|
1054
1100
|
if (msg.hasOwnProperty("stop") && msg.stop === true) {
|
|
1055
1101
|
node.flushQueue();
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1102
|
+
if (node.playertype === "sonos") {
|
|
1103
|
+
try {
|
|
1104
|
+
STOPSync().catch(() => { });
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
}
|
|
1059
1107
|
}
|
|
1060
1108
|
node.setNodeStatus({ fill: 'red', shape: 'ring', text: "Forced stop." });
|
|
1061
1109
|
return;
|
|
@@ -1131,7 +1179,7 @@ module.exports = function (RED) {
|
|
|
1131
1179
|
// There is already a priority message being spoken, do nothing
|
|
1132
1180
|
node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'There is already a priority message being spoken...queuing' });
|
|
1133
1181
|
} else {
|
|
1134
|
-
if (node.playertype
|
|
1182
|
+
if (node.playertype === 'sonos') {
|
|
1135
1183
|
node.SonosClient.stop().then(result => {
|
|
1136
1184
|
node.bTimeOutPlay = true;
|
|
1137
1185
|
node.currentMSGbeingSpoken = msg; // Set immediately, otherwise if comes new flow messages, currentMSGbeingSpoken is too old.
|
|
@@ -1374,6 +1422,102 @@ module.exports = function (RED) {
|
|
|
1374
1422
|
}
|
|
1375
1423
|
}
|
|
1376
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
|
+
|
|
1377
1521
|
// 04/01/2021 hashing filename to avoid issues with long filenames.
|
|
1378
1522
|
function getFilename(_text, _params) {
|
|
1379
1523
|
let sTextToBeHashed = _text.concat(JSON.stringify(_params));
|