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.
@@ -218,8 +218,8 @@
218
218
  # TTS Service node
219
219
  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/>
220
220
  IF YOU RUN NODE-RED BEHIND DOCKER OR SOMETHING ELSE, BE AWARE: <br/>
221
- PORT USED BY THE NODE ARE 1980 (DEFAULT) AND 1400 (FOR SONOS DISCOVER). <br/>
222
- PLEASE ALLOW MDNS AND UDP AS WELL
221
+ PORT USED BY THE NODE ARE 1980 (DEFAULT, HTTP FILE SERVER) AND 1400 (FOR SONOS DISCOVER). <br/>
222
+ PLEASE ALLOW MDNS AND UDP AS WELL (USED TO DISCOVER SONOS, GOOGLE CAST AND DLNA/UPNP DEVICES)
223
223
 
224
224
  **TTS Service**<br/>
225
225
  You can choose between Google (without credentials), Google TTS (require credentials and registration to google), ElevenLabs or Voice.ai TTS engines.<br/>
@@ -262,7 +262,7 @@ PLEASE ALLOW MDNS AND UDP AS WELL
262
262
  set IP of your node-red machine. Write **AUTODISCOVER** to allow the node to auto discover your IP.
263
263
 
264
264
  **Host Port**<br/>
265
- 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.
265
+ 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.
266
266
 
267
267
  **TTS Cache**
268
268
  <br/>
@@ -11,7 +11,7 @@ module.exports = function (RED) {
11
11
 
12
12
  // Setting up the engines
13
13
  const GoogleTTS = require('@google-cloud/text-to-speech');
14
- const GoogleTranslate = require('google-translate-tts'); // TTS without credentials, limited to 200 chars per row.
14
+ const GoogleTranslate = require('./lib/googletranslate'); // Native TTS without credentials, limited to 200 chars per row.
15
15
  const elevenlabsTTS = require("elevenlabs-node"); // 03/08/2023
16
16
  const ElevenLabsClient = require("elevenlabs").ElevenLabsClient;
17
17
 
@@ -20,6 +20,8 @@ module.exports = function (RED) {
20
20
  var formidable = require('formidable');
21
21
  const oOS = require('os');
22
22
  const sonos = require('sonos');
23
+ const dlnaDiscovery = require('./lib/dlna-discovery'); // 06/2026 SSDP discovery of DLNA/UPnP renderers
24
+ const castDiscovery = require('./lib/googlecast-discovery'); // 06/2026 mDNS discovery of Google Cast devices
23
25
 
24
26
  const VOICEAI_API_BASE_URL = "https://dev.voice.ai/api/v1/tts";
25
27
 
@@ -326,6 +328,38 @@ module.exports = function (RED) {
326
328
  });
327
329
 
328
330
 
331
+ // 06/2026 Discover DLNA/UPnP MediaRenderers on the network (for the "dlna" player type)
332
+ RED.httpAdmin.get("/ttsultimateDiscoverDLNA", RED.auth.needsPermission('TTSConfigNode.read'), function (req, res) {
333
+ dlnaDiscovery.discoverRenderers({ timeoutMs: 4000 }).then((devices) => {
334
+ // Same shape as sonosgetAllGroups: { name, host }. For DLNA the "host" is the description URL.
335
+ const list = devices.map((d) => ({
336
+ name: d.name || "Renderer",
337
+ host: d.location
338
+ }));
339
+ res.json(list);
340
+ }).catch((error) => {
341
+ RED.log.warn('ttsultimate-config ' + node.id + ': Error discovering DLNA renderers ' + error.message);
342
+ res.json("ERRORDISCOVERY");
343
+ });
344
+ });
345
+
346
+
347
+ // 06/2026 Discover Google Cast devices (Chromecast / Nest) on the network (for the "googlecast" player type)
348
+ RED.httpAdmin.get("/ttsultimateDiscoverCast", RED.auth.needsPermission('TTSConfigNode.read'), function (req, res) {
349
+ castDiscovery.discoverCastDevices({ timeoutMs: 4000 }).then((devices) => {
350
+ // Same shape as sonosgetAllGroups: { name, host }
351
+ const list = devices.map((d) => ({
352
+ name: d.name,
353
+ host: d.host
354
+ }));
355
+ res.json(list);
356
+ }).catch((error) => {
357
+ RED.log.warn('ttsultimate-config ' + node.id + ': Error discovering Google Cast devices ' + error.message);
358
+ res.json("ERRORDISCOVERY");
359
+ });
360
+ });
361
+
362
+
329
363
  // 09/03/2020 Get list of filenames in hailing folder
330
364
  RED.httpAdmin.get("/getHailingFilesList", RED.auth.needsPermission('TTSConfigNode.read'), function (req, res) {
331
365
  var jListOwnFiles = [];
@@ -505,6 +539,46 @@ module.exports = function (RED) {
505
539
 
506
540
  });
507
541
 
542
+ // 10/06/2026 Supergiovane, get the available ElevenLabs models with their capabilities.
543
+ // Used to dynamically populate the Model dropdown and enable/disable model-specific options.
544
+ RED.httpAdmin.get("/ttsgetmodels" + encodeURIComponent(node.id), RED.auth.needsPermission('TTSConfigNode.read'), function (req, res) {
545
+ const ttsservice = req.query.ttsservice || node.ttsservice;
546
+ if (!ttsservice || !ttsservice.includes("elevenlabs")) {
547
+ return res.json([]);
548
+ }
549
+ const apiKey = node.credentials.elevenlabsKey;
550
+ if (!apiKey || apiKey.trim() === "") {
551
+ return res.json([{ model_id: "", name: "ElevenLabs API key missing. Please configure, deploy and restart node-red.", error: true }]);
552
+ }
553
+ (async () => {
554
+ try {
555
+ const r = await fetch("https://api.elevenlabs.io/v1/models", {
556
+ method: "GET",
557
+ headers: { "xi-api-key": apiKey }
558
+ });
559
+ if (!r.ok) {
560
+ const body = await r.text().catch(() => "");
561
+ throw new Error(`HTTP ${r.status} ${r.statusText}${body ? " - " + body : ""}`);
562
+ }
563
+ const models = await r.json();
564
+ const list = (Array.isArray(models) ? models : [])
565
+ .filter(m => m && m.model_id && m.can_do_text_to_speech !== false)
566
+ .map(m => ({
567
+ model_id: m.model_id,
568
+ name: m.name || m.model_id,
569
+ can_use_style: m.can_use_style === true,
570
+ can_use_speaker_boost: m.can_use_speaker_boost === true,
571
+ maximum_text_length_per_request: m.maximum_text_length_per_request,
572
+ languages: Array.isArray(m.languages) ? m.languages.map(l => l.name || l.language_id).filter(Boolean) : []
573
+ }));
574
+ res.json(list);
575
+ } catch (error) {
576
+ RED.log.error('ttsultimate-config ' + node.id + ': Error getting ElevenLabs models: ' + error.message);
577
+ res.json([{ model_id: "", name: "Error getting ElevenLabs models: " + error.message + " Check credentials, deploy and restart node-red.", error: true }]);
578
+ }
579
+ })();
580
+ });
581
+
508
582
  // ########################################################
509
583
  //#endregion
510
584
 
@@ -640,45 +714,81 @@ module.exports = function (RED) {
640
714
  var url_parts = url.parse(req.url, true);
641
715
  var query = url_parts.query;
642
716
 
643
- res.setHeader('Content-Disposition', 'attachment; filename=tts.mp3')
644
717
  if (!query || query.f === undefined || query.f === null) {
645
- res.write("File not specified");
646
- res.end();
718
+ res.statusCode = 400;
719
+ res.end("File not specified");
647
720
  return;
648
721
  }
649
722
 
650
723
  const requestedPath = query.f.toString();
651
- if (fs.existsSync(requestedPath)) {
652
- // Security check: allow only mp3 files under the configured storage folder.
653
- const resolvedRequested = path.resolve(requestedPath);
654
- const resolvedAllowedRoot = path.resolve(node.TTSRootFolderPath);
655
- const isInsideRoot =
656
- resolvedRequested === resolvedAllowedRoot || resolvedRequested.startsWith(resolvedAllowedRoot + path.sep);
657
-
658
- if (path.extname(resolvedRequested) === ".mp3" && isInsideRoot) {
659
- var readStream = fs.createReadStream(resolvedRequested);
660
- readStream.on("error", function (error) {
661
- RED.log.error("ttsultimate-config " + node.id + ": Playsonos error opening stream : " + resolvedRequested + ' : ' + error);
662
- res.end();
663
- return;
664
- });
665
- readStream.pipe(res);
666
- } else {
667
- res.write("NOT ALLOWED");
668
- res.end();
669
- }
724
+ if (!fs.existsSync(requestedPath)) {
725
+ RED.log.error("ttsultimate-config " + node.id + ": Playsonos RED.httpAdmin file not found: " + query.f);
726
+ res.statusCode = 404;
727
+ res.end("File not found");
728
+ return;
729
+ }
670
730
 
671
- // http://localhost:1980/tts?f=/etc/passwd
731
+ // Security check: allow only mp3 files under the configured storage folder.
732
+ // http://localhost:1980/tts?f=/etc/passwd
733
+ const resolvedRequested = path.resolve(requestedPath);
734
+ const resolvedAllowedRoot = path.resolve(node.TTSRootFolderPath);
735
+ const isInsideRoot =
736
+ resolvedRequested === resolvedAllowedRoot || resolvedRequested.startsWith(resolvedAllowedRoot + path.sep);
737
+ if (path.extname(resolvedRequested) !== ".mp3" || !isInsideRoot) {
738
+ res.statusCode = 403;
739
+ res.end("NOT ALLOWED");
740
+ return;
741
+ }
672
742
 
673
- } else {
674
- RED.log.error("ttsultimate-config " + node.id + ": Playsonos RED.httpAdmin file not found: " + query.f);
675
- res.write("File not found");
743
+ // Serve as a proper streaming media response. Strict DLNA renderers (e.g. some TVs)
744
+ // require Content-Type, Content-Length, byte-range support and DLNA headers to start playing.
745
+ const total = fs.statSync(resolvedRequested).size;
746
+ res.setHeader('Content-Type', 'audio/mpeg');
747
+ res.setHeader('Content-Disposition', 'inline; filename="tts.mp3"');
748
+ res.setHeader('Accept-Ranges', 'bytes');
749
+ res.setHeader('transferMode.dlna.org', 'Streaming');
750
+ res.setHeader('contentFeatures.dlna.org', 'DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000');
751
+
752
+ // HEAD: headers only (many DLNA renderers probe with HEAD before playing).
753
+ if (req.method === 'HEAD') {
754
+ res.setHeader('Content-Length', total);
755
+ res.statusCode = 200;
676
756
  res.end();
757
+ return;
677
758
  }
678
759
 
760
+ let readStream;
761
+ const range = req.headers.range;
762
+ if (range) {
763
+ const m = /bytes=(\d*)-(\d*)/.exec(range);
764
+ let start = m && m[1] ? parseInt(m[1], 10) : 0;
765
+ let end = m && m[2] ? parseInt(m[2], 10) : total - 1;
766
+ if (isNaN(start)) start = 0;
767
+ if (isNaN(end) || end >= total) end = total - 1;
768
+ if (start > end || start >= total) {
769
+ res.statusCode = 416;
770
+ res.setHeader('Content-Range', 'bytes */' + total);
771
+ res.end();
772
+ return;
773
+ }
774
+ res.statusCode = 206;
775
+ res.setHeader('Content-Range', 'bytes ' + start + '-' + end + '/' + total);
776
+ res.setHeader('Content-Length', (end - start) + 1);
777
+ readStream = fs.createReadStream(resolvedRequested, { start: start, end: end });
778
+ } else {
779
+ res.statusCode = 200;
780
+ res.setHeader('Content-Length', total);
781
+ readStream = fs.createReadStream(resolvedRequested);
782
+ }
783
+ readStream.on("error", function (error) {
784
+ RED.log.error("ttsultimate-config " + node.id + ": Playsonos error opening stream : " + resolvedRequested + ' : ' + error);
785
+ try { res.end(); } catch (e) { }
786
+ });
787
+ readStream.pipe(res);
788
+
679
789
  } catch (error) {
680
790
  RED.log.error("ttsultimate-config " + node.id + ": Playsonos RED.httpAdmin error: " + error);
681
- res.end();
791
+ try { res.end(); } catch (e) { }
682
792
  }
683
793
 
684
794
  }