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.
@@ -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 {
@@ -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 if (node.playertype === "noplayer") {
979
- // End task if no player is selected.
980
- // 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).
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
- try {
1057
- STOPSync();
1058
- } catch (error) {
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 !== 'noplayer') {
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));