node-red-contrib-tts-ultimate 3.0.2 → 3.0.4

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 CHANGED
@@ -3,6 +3,16 @@
3
3
  [![Donate via PayPal](https://img.shields.io/badge/Donate-PayPal-blue.svg?style=flat-square)](https://www.paypal.me/techtoday)
4
4
 
5
5
 
6
+ <p>
7
+ <b>Version 3.0.4</b> January 2026<br/>
8
+ - Fix: cache purge at restart/deploy is now isolated per `ttsultimate-config` node (no more deleting other config caches).<br/>
9
+ </p>
10
+
11
+ <p>
12
+ <b>Version 3.0.3</b> January 2026<br/>
13
+ - Google (without credentials): automatically split long texts into 200-char chunks and merge audio output.<br/>
14
+ </p>
15
+
6
16
  <p>
7
17
  <b>Version 3.0.1</b> October 2025<br/>
8
18
  - Elevenlabs Engine: added more option to personalize the voice.<br/>
@@ -633,4 +643,4 @@
633
643
  <br/>
634
644
  </p>
635
645
 
636
- ![Logo](https://raw.githubusercontent.com/Supergiovane/node-red-contrib-tts-ultimate/master/img/madeinitaly.png)
646
+ ![Logo](https://raw.githubusercontent.com/Supergiovane/node-red-contrib-tts-ultimate/master/img/madeinitaly.png)
package/README.md CHANGED
@@ -91,6 +91,8 @@ For Google TTS Engine, you can choose pitch and speed rate of the voice.
91
91
 
92
92
  * **TTS Service using Google (without credentials)**<br/>
93
93
  This is the simplest way. Just select the voice and you're done. You don't need any credential and you don't even need to be registered to any google service. The voice list is more limited than other services, but it works without hassles.
94
+ Note: long texts are automatically split into 200-character chunks (Google Translate TTS limit) and merged into a single audio output.
95
+ Manual verify: `npm run verify:googletranslate-split -- --voice it-IT --text "..." --out ./out.mp3`
94
96
 
95
97
  <br/>
96
98
 
@@ -129,6 +131,8 @@ set IP of your node-red machine. Write **AUTODISCOVER** to allow the node to aut
129
131
  **Host Port**<br/>
130
132
  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.
131
133
 
134
+ 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.
135
+
132
136
  **TTS Cache**
133
137
  <br/>
134
138
  ***Purge and delete the TTS cache folder at deploy or restart***<br/>
@@ -307,4 +311,4 @@ Please refer to *msg.priority* msg input property of TTS-Ultimate for info on ho
307
311
  [npm-downloads-month-image]: https://img.shields.io/npm/dm/node-red-contrib-tts-ultimate.svg
308
312
  [npm-downloads-total-image]: https://img.shields.io/npm/dt/node-red-contrib-tts-ultimate.svg
309
313
  [facebook-image]: https://img.shields.io/badge/Visit%20me-Facebook-blue
310
- [facebook-url]: https://www.facebook.com/supergiovaneDev
314
+ [facebook-url]: https://www.facebook.com/supergiovaneDev
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "node-red-contrib-tts-ultimate",
3
- "version": "3.0.2",
3
+ "version": "3.0.4",
4
4
  "description": "Transforms the text in speech and hear it using Sonos player or generate an audio file to be used with third parties nodes. Works with voices from Amazon, Google (without credentials as well), Microsoft TTS Azure, ElevenLabs.io 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
- "test": "test"
7
+ "test": "test",
8
+ "verify:googletranslate-split": "node scripts/verify-googletranslate-split.js"
8
9
  },
9
10
  "repository": {
10
11
  "type": "git",
@@ -55,4 +56,4 @@
55
56
  "engines": {
56
57
  "node": ">=22.0.0"
57
58
  }
58
- }
59
+ }
@@ -0,0 +1,115 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const GoogleTranslate = require("google-translate-tts");
4
+
5
+ const GOOGLE_TRANSLATE_MAX_CHARS = 200;
6
+
7
+ const argValue = (name, fallback) => {
8
+ const idx = process.argv.indexOf(name);
9
+ if (idx === -1) return fallback;
10
+ const value = process.argv[idx + 1];
11
+ if (!value || value.startsWith("--")) return fallback;
12
+ return value;
13
+ };
14
+
15
+ const flagEnabled = (name) => process.argv.includes(name);
16
+
17
+ const help = () => {
18
+ // eslint-disable-next-line no-console
19
+ console.log(`Usage:
20
+ node scripts/verify-googletranslate-split.js --voice it-IT --text "..." --out ./out.mp3
21
+
22
+ Options:
23
+ --voice Voice code (default: it-IT)
24
+ --text Text to synthesize (default: long sample text)
25
+ --out Output file path (default: ./googletranslate-split.mp3)
26
+ --slow Enable slow speaking
27
+ --help Show help
28
+ `);
29
+ };
30
+
31
+ const stripId3v2 = (buffer) => {
32
+ if (!Buffer.isBuffer(buffer) || buffer.length < 10) return buffer;
33
+ if (buffer[0] !== 0x49 || buffer[1] !== 0x44 || buffer[2] !== 0x33) return buffer; // "ID3"
34
+ const size =
35
+ ((buffer[6] & 0x7f) << 21) |
36
+ ((buffer[7] & 0x7f) << 14) |
37
+ ((buffer[8] & 0x7f) << 7) |
38
+ (buffer[9] & 0x7f);
39
+ const tagEnd = 10 + size;
40
+ if (tagEnd <= 10 || tagEnd >= buffer.length) return buffer;
41
+ return buffer.subarray(tagEnd);
42
+ };
43
+
44
+ const splitText = (text, maxLen) => {
45
+ const chunks = [];
46
+ let remaining = (text ?? "").toString().trim();
47
+ if (remaining === "") return chunks;
48
+
49
+ const breakChars = ["\n", ".", "!", "?", ";", ":", ",", " "];
50
+ while (remaining.length > maxLen) {
51
+ const window = remaining.slice(0, maxLen + 1);
52
+ let breakAt = -1;
53
+ for (const ch of breakChars) {
54
+ const idx = window.lastIndexOf(ch);
55
+ if (idx > breakAt) breakAt = idx;
56
+ }
57
+ if (breakAt <= 0) breakAt = maxLen;
58
+ const cutAt = breakAt === maxLen ? maxLen : breakAt + 1;
59
+ const chunk = remaining.slice(0, cutAt).trim();
60
+ if (chunk !== "") chunks.push(chunk);
61
+ remaining = remaining.slice(cutAt).trimStart();
62
+ }
63
+ if (remaining !== "") chunks.push(remaining);
64
+ return chunks;
65
+ };
66
+
67
+ const isLikelyMp3 = (buffer) => {
68
+ if (!Buffer.isBuffer(buffer) || buffer.length < 3) return false;
69
+ if (buffer[0] === 0x49 && buffer[1] === 0x44 && buffer[2] === 0x33) return true; // "ID3"
70
+ if (buffer.length >= 2 && buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) return true; // frame sync
71
+ return false;
72
+ };
73
+
74
+ const main = async () => {
75
+ if (flagEnabled("--help")) {
76
+ help();
77
+ process.exit(0);
78
+ }
79
+
80
+ const voice = argValue("--voice", "it-IT");
81
+ const slow = flagEnabled("--slow");
82
+ const outFile = argValue("--out", path.resolve(process.cwd(), "googletranslate-split.mp3"));
83
+ const text = argValue(
84
+ "--text",
85
+ "Questo è un testo di esempio volutamente molto lungo per verificare lo split automatico in blocchi da 200 caratteri. " +
86
+ "Il file MP3 risultante deve essere unico e riproducibile, anche se generato da più richieste consecutive al servizio di Google Translate TTS."
87
+ );
88
+
89
+ const resolvedVoice = typeof voice === "string" && voice.includes("-") ? voice.split("-")[0] : voice;
90
+ const chunks = splitText(text, GOOGLE_TRANSLATE_MAX_CHARS);
91
+ // eslint-disable-next-line no-console
92
+ console.log(`Text length: ${text.length} chars, chunks: ${chunks.length}, voice: ${resolvedVoice}, slow: ${slow}`);
93
+
94
+ const buffers = [];
95
+ for (let i = 0; i < chunks.length; i += 1) {
96
+ const chunkBuffer = await GoogleTranslate.synthesize({ text: chunks[i], voice: resolvedVoice, slow });
97
+ buffers.push(i === 0 ? chunkBuffer : stripId3v2(chunkBuffer));
98
+ }
99
+ const mp3 = Buffer.concat(buffers);
100
+
101
+ if (!isLikelyMp3(mp3)) {
102
+ throw new Error("Output does not look like an MP3 (missing ID3 header or frame sync).");
103
+ }
104
+
105
+ fs.writeFileSync(outFile, mp3);
106
+ // eslint-disable-next-line no-console
107
+ console.log(`Wrote: ${outFile} (${mp3.length} bytes)`);
108
+ };
109
+
110
+ main().catch((err) => {
111
+ // eslint-disable-next-line no-console
112
+ console.error(err);
113
+ process.exit(1);
114
+ });
115
+
@@ -223,7 +223,7 @@
223
223
  <div class="form-row">
224
224
  <label for="node-config-input-purgediratrestart"><i class="fa fa-folder-o"></i> TTS Cache</label>
225
225
  <select id="node-config-input-purgediratrestart">
226
- <option value="purge">Purge and delete the TTS cache folder at deploy or restart(default)</option>
226
+ <option value="purge">Purge and delete this config node's TTS cache at deploy or restart(default)</option>
227
227
  <option value="leave">Leave the TTS cache folder untouched (not suggested if you have less disk space)</option>
228
228
  </select>
229
229
  </div>
@@ -327,4 +327,4 @@ Leave this field blank for the default.<br/>
327
327
  Configuration help is also here <a href="https://github.com/Supergiovane/node-red-contrib-tts-ultimate/blob/master/README.md">README</a><br/>
328
328
  </p>
329
329
 
330
- </script>
330
+ </script>
@@ -43,7 +43,7 @@ module.exports = function (RED) {
43
43
  if (!fs.existsSync(_aPath)) {
44
44
  // Create the path
45
45
  try {
46
- fs.mkdirSync(_aPath);
46
+ fs.mkdirSync(_aPath, { recursive: true });
47
47
  return true;
48
48
  } catch (error) { return false; }
49
49
  } else {
@@ -54,9 +54,15 @@ module.exports = function (RED) {
54
54
  RED.log.error('ttsultimate-config ' + node.id + ': Unable to set up MAIN directory: ' + node.TTSRootFolderPath);
55
55
  }
56
56
  if (!setupDirectory(path.join(node.TTSRootFolderPath, "ttsfiles"))) {
57
- RED.log.error('ttsultimate-config ' + node.id + ': Unable to set up cache directory: ' + path.join(node.TTSRootFolderPath, "ttsfiles"));
57
+ RED.log.error('ttsultimate-config ' + node.id + ': Unable to set up cache root directory: ' + path.join(node.TTSRootFolderPath, "ttsfiles"));
58
58
  } else {
59
- RED.log.info('ttsultimate-config ' + node.id + ': TTS cache set to ' + path.join(node.TTSRootFolderPath, "ttsfiles"));
59
+ RED.log.info('ttsultimate-config ' + node.id + ': TTS cache root set to ' + path.join(node.TTSRootFolderPath, "ttsfiles"));
60
+ }
61
+ node.ttsCacheFolder = path.join(node.TTSRootFolderPath, "ttsfiles", node.id);
62
+ if (!setupDirectory(node.ttsCacheFolder)) {
63
+ RED.log.error('ttsultimate-config ' + node.id + ': Unable to set up cache directory: ' + node.ttsCacheFolder);
64
+ } else {
65
+ RED.log.info('ttsultimate-config ' + node.id + ': TTS cache set to ' + node.ttsCacheFolder);
60
66
  }
61
67
  if (!setupDirectory(path.join(node.TTSRootFolderPath, "ttsultimategooglecredentials"))) {
62
68
  RED.log.error('ttsultimate-config ' + node.id + ': Unable to set google creds directory: ' + path.join(node.TTSRootFolderPath, "ttsultimategooglecredentials"));
@@ -517,15 +523,14 @@ module.exports = function (RED) {
517
523
 
518
524
  // 26/02/2020
519
525
  if (node.purgediratrestart === "purge") {
520
- // Delete all files, that are'nt OwnFiles_
521
526
  try {
522
- let files = fs.readdirSync(path.join(node.TTSRootFolderPath, "ttsfiles"));
527
+ let files = fs.readdirSync(node.ttsCacheFolder);
523
528
  try {
524
529
  if (files.length > 0) {
525
530
  files.forEach(function (file) {
526
- RED.log.info("ttsultimate-config " + node.id + ": Deleted TTS file " + path.join(node.TTSRootFolderPath, "ttsfiles", file));
531
+ RED.log.info("ttsultimate-config " + node.id + ": Deleted TTS file " + path.join(node.ttsCacheFolder, file));
527
532
  try {
528
- fs.unlinkSync(path.join(node.TTSRootFolderPath, "ttsfiles", file));
533
+ fs.unlinkSync(path.join(node.ttsCacheFolder, file));
529
534
  } catch (error) {
530
535
  }
531
536
  });
@@ -551,13 +556,24 @@ module.exports = function (RED) {
551
556
  var query = url_parts.query;
552
557
 
553
558
  res.setHeader('Content-Disposition', 'attachment; filename=tts.mp3')
554
- if (fs.existsSync(query.f.toString())) {
555
- // 26/01/2021 security check
556
- // File should be something like mydocs/.node-red/sonospollyttsstorage/ttsfiles/Hello_de-DE.mp3
557
- if (path.extname(query.f.toString()) === ".mp3" && path.dirname(path.dirname(query.f.toString())).endsWith("sonospollyttsstorage")) {
558
- var readStream = fs.createReadStream(query.f.toString());
559
+ if (!query || query.f === undefined || query.f === null) {
560
+ res.write("File not specified");
561
+ res.end();
562
+ return;
563
+ }
564
+
565
+ const requestedPath = query.f.toString();
566
+ if (fs.existsSync(requestedPath)) {
567
+ // Security check: allow only mp3 files under the configured storage folder.
568
+ const resolvedRequested = path.resolve(requestedPath);
569
+ const resolvedAllowedRoot = path.resolve(node.TTSRootFolderPath);
570
+ const isInsideRoot =
571
+ resolvedRequested === resolvedAllowedRoot || resolvedRequested.startsWith(resolvedAllowedRoot + path.sep);
572
+
573
+ if (path.extname(resolvedRequested) === ".mp3" && isInsideRoot) {
574
+ var readStream = fs.createReadStream(resolvedRequested);
559
575
  readStream.on("error", function (error) {
560
- RED.log.error("ttsultimate-config " + node.id + ": Playsonos error opening stream : " + query.f.toString() + ' : ' + error);
576
+ RED.log.error("ttsultimate-config " + node.id + ": Playsonos error opening stream : " + resolvedRequested + ' : ' + error);
561
577
  res.end();
562
578
  return;
563
579
  });
@@ -576,7 +592,7 @@ module.exports = function (RED) {
576
592
  }
577
593
 
578
594
  } catch (error) {
579
- RED.log.error("ttsultimate-config " + node.id + ": Playsonos RED.httpAdmin error: " + error + " on: " + query.f);
595
+ RED.log.error("ttsultimate-config " + node.id + ": Playsonos RED.httpAdmin error: " + error);
580
596
  res.end();
581
597
  }
582
598
 
@@ -635,4 +651,3 @@ module.exports = function (RED) {
635
651
  });
636
652
 
637
653
  }
638
-
@@ -65,6 +65,8 @@ module.exports = function (RED) {
65
65
  node.msg.completed = true;
66
66
  node.msg.connectionerror = true;
67
67
  node.userDir = node.server.TTSRootFolderPath === undefined ? path.join(RED.settings.userDir, "sonospollyttsstorage") : node.server.TTSRootFolderPath;
68
+ node.ttsCacheFolder = node.server.ttsCacheFolder === undefined ? path.join(node.userDir, "ttsfiles") : node.server.ttsCacheFolder;
69
+ node.legacyTtsCacheFolder = path.join(node.userDir, "ttsfiles");
68
70
  node.oAdditionalSonosPlayers = []; // 20/03/2020 Contains other players to be grouped
69
71
  node.rules = config.rules || [{}];
70
72
  node.sNoderedURL = "";
@@ -566,17 +568,42 @@ module.exports = function (RED) {
566
568
  const msg = node.currentMSGbeingSpoken.payload.toString(); // Get the text to be spoken
567
569
  //node.tempMSGStorage.splice(0, 1); // Remove the first item in the array
568
570
  node.sFileToBePlayed = "";
571
+ let audioSource = "unknown"; // internet|cache|local|http|unknown
572
+ const resolveCachePath = (cacheFilename) => {
573
+ const primaryPath = path.join(node.ttsCacheFolder, cacheFilename);
574
+ const legacyPath = path.join(node.legacyTtsCacheFolder, cacheFilename);
575
+ if (fs.existsSync(primaryPath)) return { isCached: true, resolvedPath: primaryPath };
576
+ if (fs.existsSync(legacyPath)) return { isCached: true, resolvedPath: legacyPath };
577
+ return { isCached: false, resolvedPath: primaryPath };
578
+ };
579
+ const formatAudioSourceTag = (source) => {
580
+ switch (source) {
581
+ case "internet":
582
+ return "[CLOUD] ";
583
+ case "cache":
584
+ return "[CACHE] ";
585
+ case "local":
586
+ return "[LOCAL] ";
587
+ case "http":
588
+ return "[HTTP] ";
589
+ default:
590
+ return "";
591
+ }
592
+ };
569
593
  node.setNodeStatus({ fill: "gray", shape: "ring", text: "Read " + msg });
570
594
 
571
595
  // 04/12/2020 check what really is the file to be played
572
596
  if (msg.toLowerCase().startsWith("http://") || msg.toLowerCase().startsWith("https://")) {
573
597
  RED.log.info('ttsultimate: HTTP filename: ' + msg);
598
+ audioSource = "http";
574
599
  node.sFileToBePlayed = msg;
575
600
  } else if (msg.indexOf("OwnFile_") !== -1) {
576
601
  RED.log.info('ttsultimate: OwnFile .MP3, skip tts, filename: ' + msg);
602
+ audioSource = "local";
577
603
  node.sFileToBePlayed = path.join(node.userDir, "ttspermanentfiles", msg);
578
604
  } else if (msg.indexOf("Hailing_") !== -1) {
579
605
  RED.log.info('ttsultimate: Hailing .MP3, skip tts, filename: ' + msg);
606
+ audioSource = "local";
580
607
  node.sFileToBePlayed = path.join(node.userDir, "hailingpermanentfiles", msg);
581
608
  } else {
582
609
  try {
@@ -597,9 +624,11 @@ module.exports = function (RED) {
597
624
  params.VoiceId = node.voiceId;
598
625
  }
599
626
  // Download or read from cache
600
- node.sFileToBePlayed = getFilename(msg, params);
601
- node.sFileToBePlayed = path.join(node.userDir, "ttsfiles", node.sFileToBePlayed);
602
- if (!fs.existsSync(node.sFileToBePlayed)) {
627
+ const cacheFilename = getFilename(msg, params);
628
+ const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
629
+ node.sFileToBePlayed = resolvedPath;
630
+ audioSource = isCached ? "cache" : "internet";
631
+ if (!isCached) {
603
632
  node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
604
633
  data = await synthesizeSpeechPolly([node.server.polly, params]);
605
634
  } else {
@@ -616,9 +645,11 @@ module.exports = function (RED) {
616
645
  params.input = node.ssml === false ? { text: msg } : { ssml: msg };
617
646
 
618
647
  // Download or read from cache
619
- node.sFileToBePlayed = getFilename(msg, params);
620
- node.sFileToBePlayed = path.join(node.userDir, "ttsfiles", node.sFileToBePlayed);
621
- if (!fs.existsSync(node.sFileToBePlayed)) {
648
+ const cacheFilename = getFilename(msg, params);
649
+ const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
650
+ node.sFileToBePlayed = resolvedPath;
651
+ audioSource = isCached ? "cache" : "internet";
652
+ if (!isCached) {
622
653
  node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
623
654
  data = await synthesizeSpeechGoogleTTS([node.server.googleTTS, params]);
624
655
  } else {
@@ -634,9 +665,11 @@ module.exports = function (RED) {
634
665
  };
635
666
 
636
667
  // Download or read from cache
637
- node.sFileToBePlayed = getFilename(msg, params);
638
- node.sFileToBePlayed = path.join(node.userDir, "ttsfiles", node.sFileToBePlayed);
639
- if (!fs.existsSync(node.sFileToBePlayed)) {
668
+ const cacheFilename = getFilename(msg, params);
669
+ const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
670
+ node.sFileToBePlayed = resolvedPath;
671
+ audioSource = isCached ? "cache" : "internet";
672
+ if (!isCached) {
640
673
  node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
641
674
  data = await synthesizeSpeechGoogleTranslate(node.server.googleTranslateTTS, params);
642
675
  } else {
@@ -650,9 +683,11 @@ module.exports = function (RED) {
650
683
  };
651
684
 
652
685
  // Download or read from cache
653
- node.sFileToBePlayed = getFilename(msg, params);
654
- node.sFileToBePlayed = path.join(node.userDir, "ttsfiles", node.sFileToBePlayed);
655
- if (!fs.existsSync(node.sFileToBePlayed)) {
686
+ const cacheFilename = getFilename(msg, params);
687
+ const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
688
+ node.sFileToBePlayed = resolvedPath;
689
+ audioSource = isCached ? "cache" : "internet";
690
+ if (!isCached) {
656
691
  node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
657
692
  data = await synthesizeSpeechMicrosoftAzureTTS(node.server.microsoftAzureTTS, params);
658
693
  } else {
@@ -673,9 +708,11 @@ module.exports = function (RED) {
673
708
  if (similarity !== undefined && !Number.isNaN(similarity)) params.voice_settings.similarity_boost = similarity;
674
709
  if (Object.keys(params.voice_settings).length === 0) delete params.voice_settings;
675
710
  // Download or read from cache
676
- node.sFileToBePlayed = getFilename(msg, params);
677
- node.sFileToBePlayed = path.join(node.userDir, "ttsfiles", node.sFileToBePlayed);
678
- if (!fs.existsSync(node.sFileToBePlayed)) {
711
+ const cacheFilename = getFilename(msg, params);
712
+ const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
713
+ node.sFileToBePlayed = resolvedPath;
714
+ audioSource = isCached ? "cache" : "internet";
715
+ if (!isCached) {
679
716
  node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
680
717
  data = await synthesizeSpeechElevenLabs(node.server.elevenlabsTTS, params);
681
718
  } else {
@@ -707,9 +744,11 @@ module.exports = function (RED) {
707
744
  if (outputFormat !== undefined) params.output_format = outputFormat;
708
745
  if (seed !== undefined && !Number.isNaN(seed)) params.seed = seed;
709
746
  // Download or read from cache
710
- node.sFileToBePlayed = getFilename(msg, params);
711
- node.sFileToBePlayed = path.join(node.userDir, "ttsfiles", node.sFileToBePlayed);
712
- if (!fs.existsSync(node.sFileToBePlayed)) {
747
+ const cacheFilename = getFilename(msg, params);
748
+ const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
749
+ node.sFileToBePlayed = resolvedPath;
750
+ audioSource = isCached ? "cache" : "internet";
751
+ if (!isCached) {
713
752
  node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
714
753
  data = await synthesizeSpeechElevenLabsV2(node.server.elevenlabsTTS, params);
715
754
  } else {
@@ -743,7 +782,7 @@ module.exports = function (RED) {
743
782
  if (node.playertype === "sonos") {
744
783
 
745
784
  // Play with Sonos
746
- node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Play ' + msg });
785
+ node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Play ' + formatAudioSourceTag(audioSource) + msg });
747
786
 
748
787
  // Play directly files starting with http://
749
788
  if (!node.sFileToBePlayed.toLowerCase().startsWith("http://") && !node.sFileToBePlayed.toLowerCase().startsWith("https://")) {
@@ -806,7 +845,7 @@ module.exports = function (RED) {
806
845
  if (node.timerbTimeOutPlay !== null) clearTimeout(node.timerbTimeOutPlay);
807
846
  switch (node.bTimeOutPlay) {
808
847
  case false:
809
- node.setNodeStatus({ fill: 'green', shape: 'dot', text: 'Playing ' + msg });
848
+ node.setNodeStatus({ fill: 'green', shape: 'dot', text: 'Playing ' + formatAudioSourceTag(audioSource) + msg });
810
849
  break;
811
850
  default:
812
851
  node.setNodeStatus({ fill: 'grey', shape: 'dot', text: 'Timeout waiting start play state: ' + msg });
@@ -1154,12 +1193,62 @@ module.exports = function (RED) {
1154
1193
  // 26/12/2020 Google TTS Service
1155
1194
  async function synthesizeSpeechGoogleTranslate(ttsService, params) {
1156
1195
  try {
1157
- // 30/01/2021 changed how google handles voices
1196
+ const GOOGLE_TRANSLATE_MAX_CHARS = 200;
1197
+
1198
+ const stripId3v2 = (buffer) => {
1199
+ if (!Buffer.isBuffer(buffer) || buffer.length < 10) return buffer;
1200
+ if (buffer[0] !== 0x49 || buffer[1] !== 0x44 || buffer[2] !== 0x33) return buffer; // "ID3"
1201
+ const size =
1202
+ ((buffer[6] & 0x7f) << 21) |
1203
+ ((buffer[7] & 0x7f) << 14) |
1204
+ ((buffer[8] & 0x7f) << 7) |
1205
+ (buffer[9] & 0x7f);
1206
+ const tagEnd = 10 + size;
1207
+ if (tagEnd <= 10 || tagEnd >= buffer.length) return buffer;
1208
+ return buffer.subarray(tagEnd);
1209
+ };
1210
+
1211
+ const splitText = (text, maxLen) => {
1212
+ const chunks = [];
1213
+ let remaining = (text ?? "").toString().trim();
1214
+ if (remaining === "") return chunks;
1215
+
1216
+ const breakChars = ["\n", ".", "!", "?", ";", ":", ",", " "];
1217
+ while (remaining.length > maxLen) {
1218
+ const window = remaining.slice(0, maxLen + 1);
1219
+ let breakAt = -1;
1220
+ for (const ch of breakChars) {
1221
+ const idx = window.lastIndexOf(ch);
1222
+ if (idx > breakAt) breakAt = idx;
1223
+ }
1224
+ if (breakAt <= 0) breakAt = maxLen;
1225
+ const cutAt = breakAt === maxLen ? maxLen : breakAt + 1;
1226
+ const chunk = remaining.slice(0, cutAt).trim();
1227
+ if (chunk !== "") chunks.push(chunk);
1228
+ remaining = remaining.slice(cutAt).trimStart();
1229
+ }
1230
+ if (remaining !== "") chunks.push(remaining);
1231
+ return chunks;
1232
+ };
1233
+
1234
+ // 30/01/2021 changed how google handles voices
1158
1235
  // https://github.com/ncpierson/google-translate-tts/issues/5#issuecomment-770209715
1159
- if (params.voice.includes("-")) params.voice = params.voice.split("-")[0];
1236
+ const resolvedVoice =
1237
+ typeof params.voice === "string" && params.voice.includes("-") ? params.voice.split("-")[0] : params.voice;
1238
+
1239
+ const textChunks = splitText(params.text, GOOGLE_TRANSLATE_MAX_CHARS);
1240
+ if (textChunks.length === 0) return Buffer.from([]);
1160
1241
 
1161
- const buffer = await ttsService.synthesize(params);
1162
- return (buffer);
1242
+ if (textChunks.length === 1) {
1243
+ return await ttsService.synthesize({ ...params, voice: resolvedVoice, text: textChunks[0] });
1244
+ }
1245
+
1246
+ const buffers = [];
1247
+ for (let i = 0; i < textChunks.length; i += 1) {
1248
+ const chunkBuffer = await ttsService.synthesize({ ...params, voice: resolvedVoice, text: textChunks[i] });
1249
+ buffers.push(i === 0 ? chunkBuffer : stripId3v2(chunkBuffer));
1250
+ }
1251
+ return Buffer.concat(buffers);
1163
1252
  } catch (error) {
1164
1253
  throw (error);
1165
1254
  }