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

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,27 @@
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.6</b> March 2026<br/>
8
+ - Fix: Voice.ai voice list parsing (now shows all available voices).<br/>
9
+ - Change: removed legacy/removed engines (Polly/Azure) from the config UI and docs.<br/>
10
+ </p>
11
+
12
+ <p>
13
+ <b>Version 3.0.5</b> March 2026<br/>
14
+ - NEW: Voice.ai TTS engine.<br/>
15
+ </p>
16
+
17
+ <p>
18
+ <b>Version 3.0.4</b> January 2026<br/>
19
+ - Fix: cache purge at restart/deploy is now isolated per `ttsultimate-config` node (no more deleting other config caches).<br/>
20
+ </p>
21
+
22
+ <p>
23
+ <b>Version 3.0.3</b> January 2026<br/>
24
+ - Google (without credentials): automatically split long texts into 200-char chunks and merge audio output.<br/>
25
+ </p>
26
+
6
27
  <p>
7
28
  <b>Version 3.0.1</b> October 2025<br/>
8
29
  - Elevenlabs Engine: added more option to personalize the voice.<br/>
@@ -633,4 +654,4 @@
633
654
  <br/>
634
655
  </p>
635
656
 
636
- ![Logo](https://raw.githubusercontent.com/Supergiovane/node-red-contrib-tts-ultimate/master/img/madeinitaly.png)
657
+ ![Logo](https://raw.githubusercontent.com/Supergiovane/node-red-contrib-tts-ultimate/master/img/madeinitaly.png)
package/README.md CHANGED
@@ -73,24 +73,15 @@ PORT USED BY THE NODE ARE 1980 (DEFAULT) AND 1400 (FOR SONOS DISCOVER). <br/>
73
73
  PLEASE ALLOW MDNS AND UDP AS WELL
74
74
 
75
75
  **TTS Service**<br/>
76
- You can choose between Elevenlabs.io, Google (without credentials), Google TTS (require credentials and registration to google).<br/>
76
+ You can choose between Voice.ai, Elevenlabs.io, Google (without credentials), Google TTS (require credentials and registration to google).<br/>
77
77
  For Google TTS Engine, you can choose pitch and speed rate of the voice.
78
78
  <br/>
79
79
  <br/>
80
80
 
81
- * **TTS Service using Amazon AWS (Polly)**<br/>
82
-
83
- (REMOVED IN v3.0.0 AND NOT USED ANYMORE)
84
-
85
- !IF YOU NEED THIS SERVICE, INSTALL ANY VERSION < 3.0.0 (ANY 2.x.x IS FINE)!
86
- > ``` npm install node-red-contrib-tts-ultimate@2.0.10 ```
87
-
88
- [Navigate here go here to view the old version](https://www.npmjs.com/package/node-red-contrib-tts-ultimate/v/2.0.10)
89
-
90
- <br/>
91
-
92
81
  * **TTS Service using Google (without credentials)**<br/>
93
82
  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.
83
+ Note: long texts are automatically split into 200-character chunks (Google Translate TTS limit) and merged into a single audio output.
84
+ Manual verify: `npm run verify:googletranslate-split -- --voice it-IT --text "..." --out ./out.mp3`
94
85
 
95
86
  <br/>
96
87
 
@@ -103,18 +94,6 @@ For Google TTS Engine, you can choose pitch and speed rate of the voice.
103
94
  > [Enable the Google Cloud Text-to-Speech API](https://console.cloud.google.com/flows/enableapi?apiid=texttospeech.googleapis.com)<br/>
104
95
 
105
96
 
106
- <br/>
107
-
108
- * **TTS Service using Microsot Azure TTS**
109
-
110
- (REMOVED IN v3.0.0 AND NOT USED ANYMORE)
111
-
112
- !IF YOU NEED THIS SERVICE, INSTALL ANY VERSION < 3.0.0 (ANY 2.x.x IS FINE)!
113
- > ``` npm install node-red-contrib-tts-ultimate@2.0.10 ```
114
-
115
- [Navigate here go here to view the old version](https://www.npmjs.com/package/node-red-contrib-tts-ultimate/v/2.0.10)
116
-
117
-
118
97
  <br/>
119
98
 
120
99
  * **TTS Service using ElevenLabs**<br/>
@@ -123,12 +102,19 @@ For Google TTS Engine, you can choose pitch and speed rate of the voice.
123
102
  After registration at elevenlabs.io, you can add any language to your personal list. The personal list will be then show in the node voice's list.<br/>
124
103
  <br/>
125
104
 
105
+ * **TTS Service using Voice.ai**<br/>
106
+ Add your Voice.ai API key in the config node, deploy and restart Node-RED. The node will load your available voices and show them in the Voice dropdown.
107
+ Note: SSML is not supported by this engine.
108
+ <br/>
109
+
126
110
  **Node-Red IP**<br/>
127
111
  set IP of your node-red machine. Write **AUTODISCOVER** to allow the node to auto discover your IP.
128
112
 
129
113
  **Host Port**<br/>
130
114
  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
115
 
116
+ 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.
117
+
132
118
  **TTS Cache**
133
119
  <br/>
134
120
  ***Purge and delete the TTS cache folder at deploy or restart***<br/>
@@ -307,4 +293,4 @@ Please refer to *msg.priority* msg input property of TTS-Ultimate for info on ho
307
293
  [npm-downloads-month-image]: https://img.shields.io/npm/dm/node-red-contrib-tts-ultimate.svg
308
294
  [npm-downloads-total-image]: https://img.shields.io/npm/dt/node-red-contrib-tts-ultimate.svg
309
295
  [facebook-image]: https://img.shields.io/badge/Visit%20me-Facebook-blue
310
- [facebook-url]: https://www.facebook.com/supergiovaneDev
296
+ [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",
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.",
3
+ "version": "3.0.6",
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 Google (without credentials as well), Google TTS, ElevenLabs.io TTS, Voice.ai 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",
@@ -24,7 +25,8 @@
24
25
  "ttsultimate",
25
26
  "sonospollytts",
26
27
  "neural",
27
- "elevenlabs"
28
+ "elevenlabs",
29
+ "voiceai"
28
30
  ],
29
31
  "node-red": {
30
32
  "nodes": {
@@ -55,4 +57,4 @@
55
57
  "engines": {
56
58
  "node": ">=22.0.0"
57
59
  }
58
- }
60
+ }
@@ -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
+
@@ -1,9 +1,9 @@
1
1
  <script type="text/javascript">
2
2
 
3
- RED.nodes.registerType("ttsultimate-config", {
4
- category: 'config',
5
- defaults:
6
- {
3
+ RED.nodes.registerType("ttsultimate-config", {
4
+ category: 'config',
5
+ defaults:
6
+ {
7
7
  name: { value: "TTS Service" },
8
8
  noderedipaddress:
9
9
  {
@@ -21,18 +21,15 @@
21
21
  ttsservice: { value: "googletranslate", required: false },
22
22
  TTSRootFolderPath: { value: "", required: false }
23
23
 
24
- },
25
- credentials: {
26
- accessKey: { type: "text" },
27
- secretKey: { type: "password" },
28
- mssubscriptionKey: { type: "text" },
29
- mslocation: { type: "text" },
30
- elevenlabsKey: { type: "text" }
31
- },
32
- label: function () {
33
- return this.name || "";
34
- },
35
- oneditprepare: function () {
24
+ },
25
+ credentials: {
26
+ elevenlabsKey: { type: "text" },
27
+ voiceaiKey: { type: "text" }
28
+ },
29
+ label: function () {
30
+ return this.name || "";
31
+ },
32
+ oneditprepare: function () {
36
33
  var node = this;
37
34
 
38
35
  // 21/03/2020 Check if the node is the absolute first in the flow. In this case, it has no http server instatiaced
@@ -60,37 +57,29 @@
60
57
  $("#node-config-input-purgediratrestart").val("leave");
61
58
  }
62
59
 
63
- // 22/12/2020 Hide parts of the ui
64
- // ##########################################################
65
- $("#node-config-input-ttsservice").change(function (e) {
66
- if ($("#node-config-input-ttsservice").val() === "polly") {
67
- $("#GoogleForm").hide();
68
- $("#microsoftAzureForm").hide();
69
- $("#elevenlabsForm").hide();
70
- $("#pollyForm").show();
71
- } else if ($("#node-config-input-ttsservice").val() === "googletts") {
72
- $("#microsoftAzureForm").hide();
73
- $("#pollyForm").hide();
74
- $("#elevenlabsForm").hide();
75
- $("#GoogleForm").show();
76
- } else if ($("#node-config-input-ttsservice").val() === "googletranslate") {
77
- $("#pollyForm").hide();
78
- $("#GoogleForm").hide();
79
- $("#microsoftAzureForm").hide();
80
- $("#elevenlabsForm").hide();
81
- } else if ($("#node-config-input-ttsservice").val() === "microsoftazuretts") {
82
- $("#pollyForm").hide();
83
- $("#GoogleForm").hide();
84
- $("#elevenlabsForm").hide();
85
- $("#microsoftAzureForm").show();
86
- } else if ($("#node-config-input-ttsservice").val().includes("elevenlabs")) {
87
- $("#pollyForm").hide();
88
- $("#GoogleForm").hide();
89
- $("#microsoftAzureForm").hide();
90
- $("#elevenlabsForm").show();
91
- }
92
- });
93
- // ##########################################################
60
+ // 22/12/2020 Hide parts of the ui
61
+ // ##########################################################
62
+ $("#node-config-input-ttsservice").change(function (e) {
63
+ if ($("#node-config-input-ttsservice").val() === "googletts") {
64
+ $("#elevenlabsForm").hide();
65
+ $("#voiceaiForm").hide();
66
+ $("#GoogleForm").show();
67
+ } else if ($("#node-config-input-ttsservice").val() === "googletranslate") {
68
+ $("#GoogleForm").hide();
69
+ $("#elevenlabsForm").hide();
70
+ $("#voiceaiForm").hide();
71
+ } else if ($("#node-config-input-ttsservice").val().includes("elevenlabs")) {
72
+ $("#GoogleForm").hide();
73
+ $("#voiceaiForm").hide();
74
+ $("#elevenlabsForm").show();
75
+ } else if ($("#node-config-input-ttsservice").val() === "voiceai") {
76
+ $("#GoogleForm").hide();
77
+ $("#elevenlabsForm").hide();
78
+ $("#voiceaiForm").show();
79
+ }
80
+ });
81
+ $("#node-config-input-ttsservice").trigger("change");
82
+ // ##########################################################
94
83
 
95
84
  // 022/12/2020 Upload file or files
96
85
  // ##########################################################
@@ -177,56 +166,41 @@
177
166
  </div>
178
167
  <br/>
179
168
  <p><b>SPEECH CONFIGURATION</b></p>
180
- <div class="form-row">
181
- <label for="node-config-input-ttsservice"><i class="fa fa-comments"></i> TTS Service</label>
182
- <select id="node-config-input-ttsservice">
183
- <option value="polly">(REMOVED. SEE THE GITHUB PROJECT PAGE)Amazon Polly</option>
184
- <option value="googletts">Google TTS</option>
185
- <option value="googletranslate">Google free TTS</option>
186
- <option value="microsoftazuretts">(REMOVED. SEE THE GITHUB PROJECT PAGE)Microsoft Azure TTS</option>
187
- <option value="elevenlabs">ElevenLabs TTS V1 (deprecated)</option>
188
- <option value="elevenlabsv2">ElevenLabs TTS V2 Multilingual</option>
189
- </select>&nbsp&nbsp<b><span style="color:red"><i class="fa fa-question-circle"></i>&nbsp<a target="_blank" href="https://github.com/Supergiovane/node-red-contrib-tts-ultimate"><u>Help configure</u></a></span>
190
- </div>
191
- <div id="pollyForm">
192
- <div class="form-row">
193
- <label for="node-config-input-accessKey"><i class="fa fa-user"></i> AWS Access key</label>
194
- <input type="text" id="node-config-input-accessKey">
195
- </div>
196
- <div class="form-row">
197
- <label for="node-config-input-secretKey"><i class="fa fa-user"></i> AWS Secret key</label>
198
- <input type="password" id="node-config-input-secretKey">
199
- </div>
200
- </div>
201
- <div id="GoogleForm">
202
- <div class="form-row">
203
- <label><i class="fa fa-upload"></i> Google credentials file path</label>
204
- <input style="width:180px" id="googleCredentialsPath" type="file">
205
- </div>
206
- </div>
207
- <div id="microsoftAzureForm">
208
- <div class="form-row">
209
- <label style="width:35%" for="node-config-input-mssubscriptionKey"><i class="fa fa-user"></i> Azure subscription key</label>
210
- <input style="width:58%" type="text" id="node-config-input-mssubscriptionKey">
211
- </div>
212
- <div class="form-row">
213
- <label style="width:35%" for="node-config-input-mslocation"><i class="fa fa-user"></i> Azure location (ex:westeurope)</label>
214
- <input style="width:58%" type="text" id="node-config-input-mslocation">
215
- </div>
216
- </div>
217
- <div id="elevenlabsForm">
218
- <div class="form-row">
219
- <label style="width:35%" for="node-config-input-elevenlabsKey"><i class="fa fa-user"></i> <a href="https://elevenlabs.io" target="_new">Elevenlabs</a>API key</label>
220
- <input style="width:58%" type="text" id="node-config-input-elevenlabsKey">
221
- </div>
222
- </div>
223
- <div class="form-row">
224
- <label for="node-config-input-purgediratrestart"><i class="fa fa-folder-o"></i> TTS Cache</label>
225
- <select id="node-config-input-purgediratrestart">
226
- <option value="purge">Purge and delete the TTS cache folder at deploy or restart(default)</option>
227
- <option value="leave">Leave the TTS cache folder untouched (not suggested if you have less disk space)</option>
228
- </select>
229
- </div>
169
+ <div class="form-row">
170
+ <label for="node-config-input-ttsservice"><i class="fa fa-comments"></i> TTS Service</label>
171
+ <select id="node-config-input-ttsservice">
172
+ <option value="googletts">Google TTS</option>
173
+ <option value="googletranslate">Google free TTS</option>
174
+ <option value="elevenlabs">ElevenLabs TTS V1 (deprecated)</option>
175
+ <option value="elevenlabsv2">ElevenLabs TTS V2 Multilingual</option>
176
+ <option value="voiceai">Voice.ai TTS</option>
177
+ </select>&nbsp&nbsp<b><span style="color:red"><i class="fa fa-question-circle"></i>&nbsp<a target="_blank" href="https://github.com/Supergiovane/node-red-contrib-tts-ultimate"><u>Help configure</u></a></span>
178
+ </div>
179
+ <div id="GoogleForm">
180
+ <div class="form-row">
181
+ <label><i class="fa fa-upload"></i> Google credentials file path</label>
182
+ <input style="width:180px" id="googleCredentialsPath" type="file">
183
+ </div>
184
+ </div>
185
+ <div id="elevenlabsForm">
186
+ <div class="form-row">
187
+ <label style="width:35%" for="node-config-input-elevenlabsKey"><i class="fa fa-user"></i> <a href="https://elevenlabs.io" target="_new">Elevenlabs</a>API key</label>
188
+ <input style="width:58%" type="text" id="node-config-input-elevenlabsKey">
189
+ </div>
190
+ </div>
191
+ <div id="voiceaiForm">
192
+ <div class="form-row">
193
+ <label style="width:35%" for="node-config-input-voiceaiKey"><i class="fa fa-user"></i> <a href="https://voice.ai" target="_new">Voice.ai</a> API key</label>
194
+ <input style="width:58%" type="text" id="node-config-input-voiceaiKey">
195
+ </div>
196
+ </div>
197
+ <div class="form-row">
198
+ <label for="node-config-input-purgediratrestart"><i class="fa fa-folder-o"></i> TTS Cache</label>
199
+ <select id="node-config-input-purgediratrestart">
200
+ <option value="purge">Purge and delete this config node's TTS cache at deploy or restart(default)</option>
201
+ <option value="leave">Leave the TTS cache folder untouched (not suggested if you have less disk space)</option>
202
+ </select>
203
+ </div>
230
204
 
231
205
  <div class="form-row">
232
206
  <label for="node-config-input-TTSRootFolderPath"><i class="fa fa-folder-o"></i> Cache root folder</label>
@@ -247,24 +221,15 @@ IF YOU RUN NODE-RED BEHIND DOCKER OR SOMETHING ELSE, BE AWARE: <br/>
247
221
  PORT USED BY THE NODE ARE 1980 (DEFAULT) AND 1400 (FOR SONOS DISCOVER). <br/>
248
222
  PLEASE ALLOW MDNS AND UDP AS WELL
249
223
 
250
- **TTS Service**<br/>
251
- You can choose between Google (without credentials), Amazon AWS (Polly), Google TTS (require credentials and registration to google) or Microsoft Azure TTS engines.<br/>
252
- For Google TTS Engine, you can choose pitch and speed rate of the voice.
253
- <br/>
254
- <br/>
255
-
256
- * **(REMOVED AND NOT USED ANYMORE) TTS Service using Amazon AWS (Polly)**
224
+ **TTS Service**<br/>
225
+ You can choose between Google (without credentials), Google TTS (require credentials and registration to google), ElevenLabs or Voice.ai TTS engines.<br/>
226
+ For Google TTS Engine, you can choose pitch and speed rate of the voice.
227
+ <br/>
228
+ <br/>
257
229
 
258
- !IF YOU NEED THIS SERVICE, INSTALL ANY VERSION < 3.0.0 (ANY 2.x.x IS FINE)!
259
- > ``` npm install node-red-contrib-tts-ultimate@2.0.10 ```
260
-
261
- > HOW-TO in Deutsch: for german users, there is a very helpful how-to, where you can learn how to use the node and how to register to Amazon AWS Polly as well: here: https://technikkram.net/blog/2020/09/26/sonos-sprachausgabe-mit-raspberry-pi-node-red-und-amazon-polly-fuer-homematic-oder-knx-systeme
262
-
263
- <br/>
230
+ * **TTS Service using Google (without credentials)**
264
231
 
265
- * **TTS Service using Google (without credentials)**
266
-
267
- 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.
232
+ 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.
268
233
 
269
234
  <br/>
270
235
 
@@ -275,22 +240,21 @@ For Google TTS Engine, you can choose pitch and speed rate of the voice.
275
240
  Here you must select your credential file, previously downloaded from Google, [with these steps](https://www.npmjs.com/package/@google-cloud/text-to-speech):
276
241
  > [Select or create a Cloud Platform project](https://console.cloud.google.com/project)<br/>
277
242
  > [Enable billing for your project](https://support.google.com/cloud/answer/6293499#enable-billing)<br/>
278
- > [Enable the Google Cloud Text-to-Speech API](https://console.cloud.google.com/flows/enableapi?apiid=texttospeech.googleapis.com)<br/>
279
-
280
- <br/>
243
+ > [Enable the Google Cloud Text-to-Speech API](https://console.cloud.google.com/flows/enableapi?apiid=texttospeech.googleapis.com)<br/>
244
+
245
+ <br/>
281
246
 
282
- * **(REMOVED AND NOT USED ANYMORE) TTS Service using Microsot Azure TTS**
247
+ * **TTS Service using ElevenLabs**
283
248
 
284
- !IF YOU NEED THIS SERVICE, INSTALL ANY VERSION < 3.0.0 (ANY 2.x.x IS FINE)!
285
- > ``` npm install node-red-contrib-tts-ultimate@2.0.10 ```
286
-
249
+ Please use the V2 engine, as the V1 is deprecated and will not longer be supported. The V2 has multilingual voices and is more powerful.
250
+ You have two choiches: To register to eventlabs, or not to register. If you don't register to elevenlabs.io, you will either have access on a limited amount of voices, or no access at all.
251
+ After registration at elevenlabs.io, you can add any language to your personal list. The personal list will be then show in the node voice's list.<br/>
287
252
  <br/>
288
253
 
289
- * **TTS Service using ElevenLabs**
254
+ * **TTS Service using Voice.ai**
290
255
 
291
- Please use the V2 engine, as the V1 is deprecated and will not longer be supported. The V2 has multilingual voices and is more powerful.
292
- You have two choiches: To register to eventlabs, or not to register. If you don't register to elevenlabs.io, you will either have access on a limited amount of voices, or no access at all.
293
- After registration at elevenlabs.io, you can add any language to your personal list. The personal list will be then show in the node voice's list.<br/>
256
+ Add your Voice.ai API key in the config node, deploy and restart Node-RED. The node will load your available voices and show them in the Voice dropdown.
257
+ Note: SSML is not supported by this engine.
294
258
  <br/>
295
259
 
296
260
 
@@ -327,4 +291,4 @@ Leave this field blank for the default.<br/>
327
291
  Configuration help is also here <a href="https://github.com/Supergiovane/node-red-contrib-tts-ultimate/blob/master/README.md">README</a><br/>
328
292
  </p>
329
293
 
330
- </script>
294
+ </script>
@@ -21,6 +21,8 @@ module.exports = function (RED) {
21
21
  const oOS = require('os');
22
22
  const sonos = require('sonos');
23
23
 
24
+ const VOICEAI_API_BASE_URL = "https://dev.voice.ai/api/v1/tts";
25
+
24
26
  // Configuration Node Register
25
27
  function TTSConfigNode(config) {
26
28
  RED.nodes.createNode(this, config);
@@ -43,7 +45,7 @@ module.exports = function (RED) {
43
45
  if (!fs.existsSync(_aPath)) {
44
46
  // Create the path
45
47
  try {
46
- fs.mkdirSync(_aPath);
48
+ fs.mkdirSync(_aPath, { recursive: true });
47
49
  return true;
48
50
  } catch (error) { return false; }
49
51
  } else {
@@ -54,9 +56,15 @@ module.exports = function (RED) {
54
56
  RED.log.error('ttsultimate-config ' + node.id + ': Unable to set up MAIN directory: ' + node.TTSRootFolderPath);
55
57
  }
56
58
  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"));
59
+ RED.log.error('ttsultimate-config ' + node.id + ': Unable to set up cache root directory: ' + path.join(node.TTSRootFolderPath, "ttsfiles"));
60
+ } else {
61
+ RED.log.info('ttsultimate-config ' + node.id + ': TTS cache root set to ' + path.join(node.TTSRootFolderPath, "ttsfiles"));
62
+ }
63
+ node.ttsCacheFolder = path.join(node.TTSRootFolderPath, "ttsfiles", node.id);
64
+ if (!setupDirectory(node.ttsCacheFolder)) {
65
+ RED.log.error('ttsultimate-config ' + node.id + ': Unable to set up cache directory: ' + node.ttsCacheFolder);
58
66
  } else {
59
- RED.log.info('ttsultimate-config ' + node.id + ': TTS cache set to ' + path.join(node.TTSRootFolderPath, "ttsfiles"));
67
+ RED.log.info('ttsultimate-config ' + node.id + ': TTS cache set to ' + node.ttsCacheFolder);
60
68
  }
61
69
  if (!setupDirectory(path.join(node.TTSRootFolderPath, "ttsultimategooglecredentials"))) {
62
70
  RED.log.error('ttsultimate-config ' + node.id + ': Unable to set google creds directory: ' + path.join(node.TTSRootFolderPath, "ttsultimategooglecredentials"));
@@ -203,6 +211,52 @@ module.exports = function (RED) {
203
211
  //RED.log.info("ttsultimate-config " + node.id + ": Google Translate free service not used.");
204
212
  }
205
213
 
214
+ // Voice.ai TTS
215
+ if (node.ttsservice === "voiceai") {
216
+ node.voiceAiVoiceList = [];
217
+
218
+ const loadVoiceAiVoices = async () => {
219
+ try {
220
+ const apiKey = node.credentials.voiceaiKey;
221
+ if (!apiKey || apiKey.trim() === "") {
222
+ node.voiceAiVoiceList = [{ name: "Voice.ai API key missing. Please configure, deploy and restart node-red.", id: "voiceai_default" }];
223
+ return;
224
+ }
225
+
226
+ const res = await fetch(`${VOICEAI_API_BASE_URL}/voices`, {
227
+ method: "GET",
228
+ headers: { Authorization: `Bearer ${apiKey}` }
229
+ });
230
+
231
+ if (!res.ok) {
232
+ const body = await res.text().catch(() => "");
233
+ throw new Error(`HTTP ${res.status} ${res.statusText}${body ? " - " + body : ""}`);
234
+ }
235
+
236
+ const payload = await res.json();
237
+ const voices = Array.isArray(payload) ? payload : [];
238
+
239
+ const list = [{ name: "Default (built-in)", id: "voiceai_default" }];
240
+ const voiceEntries = [];
241
+ voices.forEach(v => {
242
+ if (v && v.voice_id) {
243
+ const extra = [v.voice_visibility, v.status].filter(Boolean).join(", ");
244
+ voiceEntries.push({ name: `${v.name || v.voice_id}${extra ? " (" + extra + ")" : ""}`, id: v.voice_id });
245
+ }
246
+ });
247
+ voiceEntries.sort((a, b) => (a.name || "").toLowerCase().localeCompare((b.name || "").toLowerCase()));
248
+ node.voiceAiVoiceList = list.concat(voiceEntries);
249
+ RED.log.info("ttsultimate-config " + node.id + ": Voice.ai TTS enabled.");
250
+ } catch (error) {
251
+ RED.log.warn("ttsultimate-config " + node.id + ": Voice.ai TTS disabled. " + error.message);
252
+ node.voiceAiVoiceList = [{ name: "Error getting Voice.ai voices: " + error.message + " Check credentials, deploy and restart node-red.", id: "voiceai_default" }];
253
+ }
254
+ };
255
+
256
+ // Fire and forget; list will be available shortly after restart/deploy
257
+ loadVoiceAiVoices();
258
+ }
259
+
206
260
 
207
261
  //#endregion
208
262
 
@@ -410,6 +464,43 @@ module.exports = function (RED) {
410
464
 
411
465
  } else if (ttsservice.includes("elevenlabs")) {
412
466
  res.json(node.elevenlabsTTSVoiceList);
467
+ } else if (ttsservice === "voiceai") {
468
+ const ensureList = async () => {
469
+ try {
470
+ if (Array.isArray(node.voiceAiVoiceList) && node.voiceAiVoiceList.length > 0) {
471
+ return node.voiceAiVoiceList;
472
+ }
473
+ const apiKey = node.credentials.voiceaiKey;
474
+ if (!apiKey || apiKey.trim() === "") {
475
+ return [{ name: "Voice.ai API key missing. Please configure, deploy and restart node-red.", id: "voiceai_default" }];
476
+ }
477
+
478
+ const resVoices = await fetch(`${VOICEAI_API_BASE_URL}/voices`, {
479
+ method: "GET",
480
+ headers: { Authorization: `Bearer ${apiKey}` }
481
+ });
482
+ if (!resVoices.ok) {
483
+ const body = await resVoices.text().catch(() => "");
484
+ throw new Error(`HTTP ${resVoices.status} ${resVoices.statusText}${body ? " - " + body : ""}`);
485
+ }
486
+ const payload = await resVoices.json();
487
+ const voices = Array.isArray(payload) ? payload : [];
488
+ const list = [{ name: "Default (built-in)", id: "voiceai_default" }];
489
+ const voiceEntries = [];
490
+ voices.forEach(v => {
491
+ if (v && v.voice_id) {
492
+ const extra = [v.voice_visibility, v.status].filter(Boolean).join(", ");
493
+ voiceEntries.push({ name: `${v.name || v.voice_id}${extra ? " (" + extra + ")" : ""}`, id: v.voice_id });
494
+ }
495
+ });
496
+ voiceEntries.sort((a, b) => (a.name || "").toLowerCase().localeCompare((b.name || "").toLowerCase()));
497
+ node.voiceAiVoiceList = list.concat(voiceEntries);
498
+ return node.voiceAiVoiceList;
499
+ } catch (error) {
500
+ return [{ name: "Error getting Voice.ai voices: " + error.message + " Check credentials, deploy and restart node-red.", id: "voiceai_default" }];
501
+ }
502
+ };
503
+ ensureList().then(list => res.json(list));
413
504
  }
414
505
 
415
506
  });
@@ -517,15 +608,14 @@ module.exports = function (RED) {
517
608
 
518
609
  // 26/02/2020
519
610
  if (node.purgediratrestart === "purge") {
520
- // Delete all files, that are'nt OwnFiles_
521
611
  try {
522
- let files = fs.readdirSync(path.join(node.TTSRootFolderPath, "ttsfiles"));
612
+ let files = fs.readdirSync(node.ttsCacheFolder);
523
613
  try {
524
614
  if (files.length > 0) {
525
615
  files.forEach(function (file) {
526
- RED.log.info("ttsultimate-config " + node.id + ": Deleted TTS file " + path.join(node.TTSRootFolderPath, "ttsfiles", file));
616
+ RED.log.info("ttsultimate-config " + node.id + ": Deleted TTS file " + path.join(node.ttsCacheFolder, file));
527
617
  try {
528
- fs.unlinkSync(path.join(node.TTSRootFolderPath, "ttsfiles", file));
618
+ fs.unlinkSync(path.join(node.ttsCacheFolder, file));
529
619
  } catch (error) {
530
620
  }
531
621
  });
@@ -551,13 +641,24 @@ module.exports = function (RED) {
551
641
  var query = url_parts.query;
552
642
 
553
643
  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());
644
+ if (!query || query.f === undefined || query.f === null) {
645
+ res.write("File not specified");
646
+ res.end();
647
+ return;
648
+ }
649
+
650
+ 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);
559
660
  readStream.on("error", function (error) {
560
- RED.log.error("ttsultimate-config " + node.id + ": Playsonos error opening stream : " + query.f.toString() + ' : ' + error);
661
+ RED.log.error("ttsultimate-config " + node.id + ": Playsonos error opening stream : " + resolvedRequested + ' : ' + error);
561
662
  res.end();
562
663
  return;
563
664
  });
@@ -576,7 +677,7 @@ module.exports = function (RED) {
576
677
  }
577
678
 
578
679
  } catch (error) {
579
- RED.log.error("ttsultimate-config " + node.id + ": Playsonos RED.httpAdmin error: " + error + " on: " + query.f);
680
+ RED.log.error("ttsultimate-config " + node.id + ": Playsonos RED.httpAdmin error: " + error);
580
681
  res.end();
581
682
  }
582
683
 
@@ -626,13 +727,9 @@ module.exports = function (RED) {
626
727
  }
627
728
  RED.nodes.registerType("ttsultimate-config", TTSConfigNode, {
628
729
  credentials: {
629
- accessKey: { type: "text" },
630
- secretKey: { type: "password" },
631
- mssubscriptionKey: { type: "text" },
632
- mslocation: { type: "text" },
633
- elevenlabsKey: { type: "text" }
730
+ elevenlabsKey: { type: "text" },
731
+ voiceaiKey: { type: "text" }
634
732
  }
635
733
  });
636
734
 
637
735
  }
638
-
@@ -226,35 +226,48 @@
226
226
  label: function () {
227
227
  return this.name || "TTS-ultimate";
228
228
  },
229
- oneditprepare: function () {
230
- var node = this;
231
- var oNodeServer = RED.nodes.node($("#node-input-config").val()); // Store the config-node
232
- if (node.elevenlabsModel !== undefined) $("#node-input-elevenlabsModel").val(node.elevenlabsModel);
233
- if (node.elevenlabsOptimizeLatency !== undefined) $("#node-input-elevenlabsOptimizeLatency").val(node.elevenlabsOptimizeLatency);
234
- if (node.elevenlabsOutputFormat !== undefined) $("#node-input-elevenlabsOutputFormat").val(node.elevenlabsOutputFormat);
235
- if (node.elevenlabsSeed !== undefined) $("#node-input-elevenlabsSeed").val(node.elevenlabsSeed);
229
+ oneditprepare: function () {
230
+ var node = this;
231
+ var oNodeServer = RED.nodes.node($("#node-input-config").val()); // Store the config-node
232
+ if (node.elevenlabsModel !== undefined) $("#node-input-elevenlabsModel").val(node.elevenlabsModel);
233
+ if (node.elevenlabsOptimizeLatency !== undefined) $("#node-input-elevenlabsOptimizeLatency").val(node.elevenlabsOptimizeLatency);
234
+ if (node.elevenlabsOutputFormat !== undefined) $("#node-input-elevenlabsOutputFormat").val(node.elevenlabsOutputFormat);
235
+ if (node.elevenlabsSeed !== undefined) $("#node-input-elevenlabsSeed").val(node.elevenlabsSeed);
236
+
237
+ function updateTtsOptionsVisibility() {
238
+ try {
239
+ if (!oNodeServer || !oNodeServer.ttsservice) return;
240
+ if (oNodeServer.ttsservice === "googletts") {
241
+ $("#divGoogleTTSAudioConfig").show();
242
+ $("#divElevenLabsOptions").hide();
243
+ $("#divSSML").show();
244
+ } else if (oNodeServer.ttsservice.includes("elevenlabs")) {
245
+ $("#divGoogleTTSAudioConfig").hide();
246
+ $("#divElevenLabsOptions").show();
247
+ $("#divSSML").hide();
248
+ } else if (oNodeServer.ttsservice === "voiceai") {
249
+ $("#divGoogleTTSAudioConfig").hide();
250
+ $("#divElevenLabsOptions").hide();
251
+ $("#divSSML").hide();
252
+ } else {
253
+ $("#divGoogleTTSAudioConfig").hide();
254
+ $("#divElevenLabsOptions").hide();
255
+ $("#divSSML").show();
256
+ }
257
+ } catch (error) {
258
+ }
259
+ }
236
260
 
237
261
  // 19/02/2020 Used to alert the user if the CSV file has not been loaded and to get the server sooner als deploy
238
262
  // ###########################
239
- $("#node-input-config").change(function () {
240
- try {
241
- oNodeServer = RED.nodes.node($(this).val());
242
- getVoices();
243
- if (oNodeServer.ttsservice === "googletts") {
244
- $("#divGoogleTTSAudioConfig").show();
245
- $("#divElevenLabsOptions").hide();
246
- } else if (oNodeServer.ttsservice.includes("elevenlabs")) {
247
- $("#divGoogleTTSAudioConfig").hide();
248
- $("#divElevenLabsOptions").show();
249
- $("#divSSML").hide();
250
- } else {
251
- $("#divGoogleTTSAudioConfig").hide();
252
- $("#divElevenLabsOptions").hide();
253
- $("#divSSML").show();
254
- }
255
- } catch (error) {
256
- }
257
- });
263
+ $("#node-input-config").change(function () {
264
+ try {
265
+ oNodeServer = RED.nodes.node($(this).val());
266
+ getVoices();
267
+ updateTtsOptionsVisibility();
268
+ } catch (error) {
269
+ }
270
+ });
258
271
  // ###########################
259
272
 
260
273
 
@@ -347,9 +360,10 @@
347
360
  }
348
361
  return comparison;
349
362
  }
350
- }
351
- getVoices();
352
- // #####################################
363
+ }
364
+ updateTtsOptionsVisibility();
365
+ getVoices();
366
+ // #####################################
353
367
 
354
368
  // Refresh the combo
355
369
  // #####################################
@@ -601,7 +615,7 @@
601
615
  |Property|Description|
602
616
  |--|--|
603
617
  | TTS Service | Select the TTS SERVICE ENGINE NODE. |
604
- | Voice | Select your preferred voice. If you use Amazon, Polly voices will be displayed (standard and neural). If you use Google, google voices will be displayed. Google service without authentication, has a limited set of voices. |
618
+ | Voice | Select your preferred voice. The list depends on the selected TTS engine (Google / ElevenLabs / Voice.ai). Google service without authentication has a limited set of voices. |
605
619
  | Hailing | Before the first TTS message of the message queues, the node will play an "hailing" sound. You can select the hailing, upload your own, or totally disable it. |
606
620
  | Upload hail | It allows you to upload your own hailing file. |
607
621
  | Player | Select the player. If you select not to use a player, the node will output a msg with an array of files, ready to be played by third party nodes. In case you select No player, only output file name, you'll get a message with an additional property filesArray, containing an array of all mp3 files ready to be played with third party nodes. Please see below the OUTPUT MESSAGES FROM THE NODE section. |
@@ -614,7 +628,7 @@
614
628
  **Google TTS Engines specific options**
615
629
  |Property|Description|
616
630
  |--|--|
617
- | Enable SSML | Enable the SSML XML notation. Please be aware, not all the TTS engines supports that.|
631
+ | Enable SSML | Enable the SSML XML notation. Please be aware, not all the TTS engines supports that (for example Voice.ai).|
618
632
  | Rate | Only avaiable if you choose Google TTS Engine (with credentials). Specifies the speech speed (Between 0.25 and 4.0, default 1). |
619
633
  | Pitch | Only avaiable if you choose Google TTS Engine (with credentials). Specifies the speech pitch (Between -20.0 and 20.0, default 0). |
620
634
 
@@ -637,7 +651,7 @@
637
651
  : sonoshailing (string) : Overrides the selected hailing and plays the filename you passed in. Please double check the spelling of the filename (must be the same as you can see in the dropdown list of your hailing files, in the ttsultimate config window) and do not include the .mp3 extenson. For example node.sonoshailing="ComputerCall".
638
652
  : priority (boolean) : If set to true, the inbound flow message will cancel the current TTS queue, will stop the current phrase being spoken and the node will play this priority message. If there are other priority messages in the queue, they will be retained and the inbound priority flow message is added to the queue. If the inbound priority flow message is the first in the priority queue, the hailing is played first (if the hailing has been enabled or if the hailing has been overridden by node.sonoshailing).
639
653
  : stop (boolean) : If set to true, stops whatever is playing and clears the TTS queue.
640
- : voiceId (number) : Play a message with custom voice ID.
654
+ : voiceId (string) : Play a message with custom voice ID (engine-specific).
641
655
  : setConfig (json) : This is the property where you can set all the things. It must be a JSON Object with the below specified properties. The setting is retained until the node receives another msg.setConfig or until node-red is restarted.
642
656
  : setConfig {setMainPlayerIP} (string) : Sets the main player IP. This will also be the coordinator if you have a group of players.
643
657
  : setConfig {setPlayerGroupArray} (array) : Sets the array of players beloging to the group, if any. You can also specify the volume variation from the main volume player, to adapt the additional player's perceived volume to the main sonos player volume. For example, if you have a speaker mounted in celiling, having less perceived volume, you can "push" the volume up, to match the whole perceived volume. Just add # after the IP and a number from -100 to 100 to subtract or add volume compared to the main sonos volume. For example, if the sonos main player volume is 40, you can push this celing speaker's volume to further 10, so it'll have the real volume of 50. See below, the example. Even if you have only one additional player, you need to put it into an array.
@@ -7,6 +7,7 @@ module.exports = function (RED) {
7
7
  var path = require('path');
8
8
  const sonos = require('sonos');
9
9
  const crypto = require("crypto");
10
+ const VOICEAI_API_BASE_URL = "https://dev.voice.ai/api/v1/tts";
10
11
 
11
12
  function slug(_text) {
12
13
  var sRet = _text;
@@ -65,6 +66,8 @@ module.exports = function (RED) {
65
66
  node.msg.completed = true;
66
67
  node.msg.connectionerror = true;
67
68
  node.userDir = node.server.TTSRootFolderPath === undefined ? path.join(RED.settings.userDir, "sonospollyttsstorage") : node.server.TTSRootFolderPath;
69
+ node.ttsCacheFolder = node.server.ttsCacheFolder === undefined ? path.join(node.userDir, "ttsfiles") : node.server.ttsCacheFolder;
70
+ node.legacyTtsCacheFolder = path.join(node.userDir, "ttsfiles");
68
71
  node.oAdditionalSonosPlayers = []; // 20/03/2020 Contains other players to be grouped
69
72
  node.rules = config.rules || [{}];
70
73
  node.sNoderedURL = "";
@@ -566,17 +569,42 @@ module.exports = function (RED) {
566
569
  const msg = node.currentMSGbeingSpoken.payload.toString(); // Get the text to be spoken
567
570
  //node.tempMSGStorage.splice(0, 1); // Remove the first item in the array
568
571
  node.sFileToBePlayed = "";
572
+ let audioSource = "unknown"; // internet|cache|local|http|unknown
573
+ const resolveCachePath = (cacheFilename) => {
574
+ const primaryPath = path.join(node.ttsCacheFolder, cacheFilename);
575
+ const legacyPath = path.join(node.legacyTtsCacheFolder, cacheFilename);
576
+ if (fs.existsSync(primaryPath)) return { isCached: true, resolvedPath: primaryPath };
577
+ if (fs.existsSync(legacyPath)) return { isCached: true, resolvedPath: legacyPath };
578
+ return { isCached: false, resolvedPath: primaryPath };
579
+ };
580
+ const formatAudioSourceTag = (source) => {
581
+ switch (source) {
582
+ case "internet":
583
+ return "[CLOUD] ";
584
+ case "cache":
585
+ return "[CACHE] ";
586
+ case "local":
587
+ return "[LOCAL] ";
588
+ case "http":
589
+ return "[HTTP] ";
590
+ default:
591
+ return "";
592
+ }
593
+ };
569
594
  node.setNodeStatus({ fill: "gray", shape: "ring", text: "Read " + msg });
570
595
 
571
596
  // 04/12/2020 check what really is the file to be played
572
597
  if (msg.toLowerCase().startsWith("http://") || msg.toLowerCase().startsWith("https://")) {
573
598
  RED.log.info('ttsultimate: HTTP filename: ' + msg);
599
+ audioSource = "http";
574
600
  node.sFileToBePlayed = msg;
575
601
  } else if (msg.indexOf("OwnFile_") !== -1) {
576
602
  RED.log.info('ttsultimate: OwnFile .MP3, skip tts, filename: ' + msg);
603
+ audioSource = "local";
577
604
  node.sFileToBePlayed = path.join(node.userDir, "ttspermanentfiles", msg);
578
605
  } else if (msg.indexOf("Hailing_") !== -1) {
579
606
  RED.log.info('ttsultimate: Hailing .MP3, skip tts, filename: ' + msg);
607
+ audioSource = "local";
580
608
  node.sFileToBePlayed = path.join(node.userDir, "hailingpermanentfiles", msg);
581
609
  } else {
582
610
  try {
@@ -597,9 +625,11 @@ module.exports = function (RED) {
597
625
  params.VoiceId = node.voiceId;
598
626
  }
599
627
  // 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)) {
628
+ const cacheFilename = getFilename(msg, params);
629
+ const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
630
+ node.sFileToBePlayed = resolvedPath;
631
+ audioSource = isCached ? "cache" : "internet";
632
+ if (!isCached) {
603
633
  node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
604
634
  data = await synthesizeSpeechPolly([node.server.polly, params]);
605
635
  } else {
@@ -616,9 +646,11 @@ module.exports = function (RED) {
616
646
  params.input = node.ssml === false ? { text: msg } : { ssml: msg };
617
647
 
618
648
  // 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)) {
649
+ const cacheFilename = getFilename(msg, params);
650
+ const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
651
+ node.sFileToBePlayed = resolvedPath;
652
+ audioSource = isCached ? "cache" : "internet";
653
+ if (!isCached) {
622
654
  node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
623
655
  data = await synthesizeSpeechGoogleTTS([node.server.googleTTS, params]);
624
656
  } else {
@@ -634,14 +666,38 @@ module.exports = function (RED) {
634
666
  };
635
667
 
636
668
  // 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)) {
669
+ const cacheFilename = getFilename(msg, params);
670
+ const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
671
+ node.sFileToBePlayed = resolvedPath;
672
+ audioSource = isCached ? "cache" : "internet";
673
+ if (!isCached) {
640
674
  node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
641
675
  data = await synthesizeSpeechGoogleTranslate(node.server.googleTranslateTTS, params);
642
676
  } else {
643
677
  node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Reading offline from cache' });
644
678
  }
679
+ } else if (node.server.ttsservice === "voiceai") {
680
+ const apiKey = node.server.credentials.voiceaiKey;
681
+ if (!apiKey || apiKey.trim() === "") throw new Error("Voice.ai API key missing");
682
+
683
+ const params = {
684
+ text: msg,
685
+ audio_format: "mp3"
686
+ };
687
+ if (node.voiceId && node.voiceId !== "" && node.voiceId !== "voiceai_default") {
688
+ params.voice_id = node.voiceId;
689
+ }
690
+
691
+ const cacheFilename = getFilename(msg, params);
692
+ const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
693
+ node.sFileToBePlayed = resolvedPath;
694
+ audioSource = isCached ? "cache" : "internet";
695
+ if (!isCached) {
696
+ node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
697
+ data = await synthesizeSpeechVoiceAi(apiKey, params);
698
+ } else {
699
+ node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Reading offline from cache' });
700
+ }
645
701
  } else if (node.server.ttsservice === "microsoftazuretts") {
646
702
  // VoiceId is: code
647
703
  const params = {
@@ -650,9 +706,11 @@ module.exports = function (RED) {
650
706
  };
651
707
 
652
708
  // 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)) {
709
+ const cacheFilename = getFilename(msg, params);
710
+ const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
711
+ node.sFileToBePlayed = resolvedPath;
712
+ audioSource = isCached ? "cache" : "internet";
713
+ if (!isCached) {
656
714
  node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
657
715
  data = await synthesizeSpeechMicrosoftAzureTTS(node.server.microsoftAzureTTS, params);
658
716
  } else {
@@ -673,9 +731,11 @@ module.exports = function (RED) {
673
731
  if (similarity !== undefined && !Number.isNaN(similarity)) params.voice_settings.similarity_boost = similarity;
674
732
  if (Object.keys(params.voice_settings).length === 0) delete params.voice_settings;
675
733
  // 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)) {
734
+ const cacheFilename = getFilename(msg, params);
735
+ const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
736
+ node.sFileToBePlayed = resolvedPath;
737
+ audioSource = isCached ? "cache" : "internet";
738
+ if (!isCached) {
679
739
  node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
680
740
  data = await synthesizeSpeechElevenLabs(node.server.elevenlabsTTS, params);
681
741
  } else {
@@ -707,9 +767,11 @@ module.exports = function (RED) {
707
767
  if (outputFormat !== undefined) params.output_format = outputFormat;
708
768
  if (seed !== undefined && !Number.isNaN(seed)) params.seed = seed;
709
769
  // 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)) {
770
+ const cacheFilename = getFilename(msg, params);
771
+ const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
772
+ node.sFileToBePlayed = resolvedPath;
773
+ audioSource = isCached ? "cache" : "internet";
774
+ if (!isCached) {
713
775
  node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
714
776
  data = await synthesizeSpeechElevenLabsV2(node.server.elevenlabsTTS, params);
715
777
  } else {
@@ -743,7 +805,7 @@ module.exports = function (RED) {
743
805
  if (node.playertype === "sonos") {
744
806
 
745
807
  // Play with Sonos
746
- node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Play ' + msg });
808
+ node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Play ' + formatAudioSourceTag(audioSource) + msg });
747
809
 
748
810
  // Play directly files starting with http://
749
811
  if (!node.sFileToBePlayed.toLowerCase().startsWith("http://") && !node.sFileToBePlayed.toLowerCase().startsWith("https://")) {
@@ -806,7 +868,7 @@ module.exports = function (RED) {
806
868
  if (node.timerbTimeOutPlay !== null) clearTimeout(node.timerbTimeOutPlay);
807
869
  switch (node.bTimeOutPlay) {
808
870
  case false:
809
- node.setNodeStatus({ fill: 'green', shape: 'dot', text: 'Playing ' + msg });
871
+ node.setNodeStatus({ fill: 'green', shape: 'dot', text: 'Playing ' + formatAudioSourceTag(audioSource) + msg });
810
872
  break;
811
873
  default:
812
874
  node.setNodeStatus({ fill: 'grey', shape: 'dot', text: 'Timeout waiting start play state: ' + msg });
@@ -1154,12 +1216,62 @@ module.exports = function (RED) {
1154
1216
  // 26/12/2020 Google TTS Service
1155
1217
  async function synthesizeSpeechGoogleTranslate(ttsService, params) {
1156
1218
  try {
1157
- // 30/01/2021 changed how google handles voices
1219
+ const GOOGLE_TRANSLATE_MAX_CHARS = 200;
1220
+
1221
+ const stripId3v2 = (buffer) => {
1222
+ if (!Buffer.isBuffer(buffer) || buffer.length < 10) return buffer;
1223
+ if (buffer[0] !== 0x49 || buffer[1] !== 0x44 || buffer[2] !== 0x33) return buffer; // "ID3"
1224
+ const size =
1225
+ ((buffer[6] & 0x7f) << 21) |
1226
+ ((buffer[7] & 0x7f) << 14) |
1227
+ ((buffer[8] & 0x7f) << 7) |
1228
+ (buffer[9] & 0x7f);
1229
+ const tagEnd = 10 + size;
1230
+ if (tagEnd <= 10 || tagEnd >= buffer.length) return buffer;
1231
+ return buffer.subarray(tagEnd);
1232
+ };
1233
+
1234
+ const splitText = (text, maxLen) => {
1235
+ const chunks = [];
1236
+ let remaining = (text ?? "").toString().trim();
1237
+ if (remaining === "") return chunks;
1238
+
1239
+ const breakChars = ["\n", ".", "!", "?", ";", ":", ",", " "];
1240
+ while (remaining.length > maxLen) {
1241
+ const window = remaining.slice(0, maxLen + 1);
1242
+ let breakAt = -1;
1243
+ for (const ch of breakChars) {
1244
+ const idx = window.lastIndexOf(ch);
1245
+ if (idx > breakAt) breakAt = idx;
1246
+ }
1247
+ if (breakAt <= 0) breakAt = maxLen;
1248
+ const cutAt = breakAt === maxLen ? maxLen : breakAt + 1;
1249
+ const chunk = remaining.slice(0, cutAt).trim();
1250
+ if (chunk !== "") chunks.push(chunk);
1251
+ remaining = remaining.slice(cutAt).trimStart();
1252
+ }
1253
+ if (remaining !== "") chunks.push(remaining);
1254
+ return chunks;
1255
+ };
1256
+
1257
+ // 30/01/2021 changed how google handles voices
1158
1258
  // https://github.com/ncpierson/google-translate-tts/issues/5#issuecomment-770209715
1159
- if (params.voice.includes("-")) params.voice = params.voice.split("-")[0];
1259
+ const resolvedVoice =
1260
+ typeof params.voice === "string" && params.voice.includes("-") ? params.voice.split("-")[0] : params.voice;
1261
+
1262
+ const textChunks = splitText(params.text, GOOGLE_TRANSLATE_MAX_CHARS);
1263
+ if (textChunks.length === 0) return Buffer.from([]);
1264
+
1265
+ if (textChunks.length === 1) {
1266
+ return await ttsService.synthesize({ ...params, voice: resolvedVoice, text: textChunks[0] });
1267
+ }
1160
1268
 
1161
- const buffer = await ttsService.synthesize(params);
1162
- return (buffer);
1269
+ const buffers = [];
1270
+ for (let i = 0; i < textChunks.length; i += 1) {
1271
+ const chunkBuffer = await ttsService.synthesize({ ...params, voice: resolvedVoice, text: textChunks[i] });
1272
+ buffers.push(i === 0 ? chunkBuffer : stripId3v2(chunkBuffer));
1273
+ }
1274
+ return Buffer.concat(buffers);
1163
1275
  } catch (error) {
1164
1276
  throw (error);
1165
1277
  }
@@ -1239,6 +1351,29 @@ module.exports = function (RED) {
1239
1351
  }
1240
1352
  }
1241
1353
 
1354
+ async function synthesizeSpeechVoiceAi(apiKey, params) {
1355
+ try {
1356
+ const res = await fetch(`${VOICEAI_API_BASE_URL}/speech`, {
1357
+ method: "POST",
1358
+ headers: {
1359
+ Authorization: `Bearer ${apiKey}`,
1360
+ "Content-Type": "application/json"
1361
+ },
1362
+ body: JSON.stringify(params)
1363
+ });
1364
+
1365
+ if (!res.ok) {
1366
+ const body = await res.text().catch(() => "");
1367
+ throw new Error(`HTTP ${res.status} ${res.statusText}${body ? " - " + body : ""}`);
1368
+ }
1369
+
1370
+ const ab = await res.arrayBuffer();
1371
+ return Buffer.from(ab);
1372
+ } catch (error) {
1373
+ throw (error);
1374
+ }
1375
+ }
1376
+
1242
1377
  // 04/01/2021 hashing filename to avoid issues with long filenames.
1243
1378
  function getFilename(_text, _params) {
1244
1379
  let sTextToBeHashed = _text.concat(JSON.stringify(_params));