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 +22 -1
- package/README.md +11 -25
- package/package.json +7 -5
- package/scripts/verify-googletranslate-split.js +115 -0
- package/ttsultimate/ttsultimate-config.html +89 -125
- package/ttsultimate/ttsultimate-config.js +117 -20
- package/ttsultimate/ttsultimate.html +46 -32
- package/ttsultimate/ttsultimate.js +159 -24
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,27 @@
|
|
|
3
3
|
[](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
|
-

|
|
657
|
+

|
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.
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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>  <b><span style="color:red"><i class="fa fa-question-circle"></i> <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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* **
|
|
247
|
+
* **TTS Service using ElevenLabs**
|
|
283
248
|
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
254
|
+
* **TTS Service using Voice.ai**
|
|
290
255
|
|
|
291
|
-
|
|
292
|
-
|
|
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 ' +
|
|
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(
|
|
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.
|
|
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.
|
|
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 (
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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 : " +
|
|
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
|
|
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
|
-
|
|
630
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1162
|
-
|
|
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));
|