node-red-contrib-tts-ultimate 3.0.1 → 3.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -2
- package/README.md +5 -1
- package/package.json +4 -3
- package/scripts/verify-googletranslate-split.js +115 -0
- package/ttsultimate/ttsultimate-config.html +2 -2
- package/ttsultimate/ttsultimate-config.js +30 -15
- package/ttsultimate/ttsultimate.html +50 -1
- package/ttsultimate/ttsultimate.js +143 -37
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.paypal.me/techtoday)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
<p>
|
|
7
|
+
<b>Version 3.0.4</b> January 2026<br/>
|
|
8
|
+
- Fix: cache purge at restart/deploy is now isolated per `ttsultimate-config` node (no more deleting other config caches).<br/>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p>
|
|
12
|
+
<b>Version 3.0.3</b> January 2026<br/>
|
|
13
|
+
- Google (without credentials): automatically split long texts into 200-char chunks and merge audio output.<br/>
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
<p>
|
|
17
|
+
<b>Version 3.0.1</b> October 2025<br/>
|
|
18
|
+
- Elevenlabs Engine: added more option to personalize the voice.<br/>
|
|
19
|
+
</p>
|
|
6
20
|
|
|
7
21
|
<p>
|
|
8
22
|
<b>Version 3.0.0</b> June 2025<br/>
|
|
@@ -629,4 +643,4 @@
|
|
|
629
643
|
<br/>
|
|
630
644
|
</p>
|
|
631
645
|
|
|
632
|
-

|
|
646
|
+

|
package/README.md
CHANGED
|
@@ -91,6 +91,8 @@ For Google TTS Engine, you can choose pitch and speed rate of the voice.
|
|
|
91
91
|
|
|
92
92
|
* **TTS Service using Google (without credentials)**<br/>
|
|
93
93
|
This is the simplest way. Just select the voice and you're done. You don't need any credential and you don't even need to be registered to any google service. The voice list is more limited than other services, but it works without hassles.
|
|
94
|
+
Note: long texts are automatically split into 200-character chunks (Google Translate TTS limit) and merged into a single audio output.
|
|
95
|
+
Manual verify: `npm run verify:googletranslate-split -- --voice it-IT --text "..." --out ./out.mp3`
|
|
94
96
|
|
|
95
97
|
<br/>
|
|
96
98
|
|
|
@@ -129,6 +131,8 @@ set IP of your node-red machine. Write **AUTODISCOVER** to allow the node to aut
|
|
|
129
131
|
**Host Port**<br/>
|
|
130
132
|
Sonos will connect to this port in order to play TTS. Default 1980. Choose a free port. Do not use 1880 or any other port already in use on your computer.
|
|
131
133
|
|
|
134
|
+
Note: if you use multiple `ttsultimate-config` nodes, each one now keeps its own TTS cache folder; the “purge on restart/deploy” option only affects that config node’s cache.
|
|
135
|
+
|
|
132
136
|
**TTS Cache**
|
|
133
137
|
<br/>
|
|
134
138
|
***Purge and delete the TTS cache folder at deploy or restart***<br/>
|
|
@@ -307,4 +311,4 @@ Please refer to *msg.priority* msg input property of TTS-Ultimate for info on ho
|
|
|
307
311
|
[npm-downloads-month-image]: https://img.shields.io/npm/dm/node-red-contrib-tts-ultimate.svg
|
|
308
312
|
[npm-downloads-total-image]: https://img.shields.io/npm/dt/node-red-contrib-tts-ultimate.svg
|
|
309
313
|
[facebook-image]: https://img.shields.io/badge/Visit%20me-Facebook-blue
|
|
310
|
-
[facebook-url]: https://www.facebook.com/supergiovaneDev
|
|
314
|
+
[facebook-url]: https://www.facebook.com/supergiovaneDev
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-tts-ultimate",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.4",
|
|
4
4
|
"description": "Transforms the text in speech and hear it using Sonos player or generate an audio file to be used with third parties nodes. Works with voices from Amazon, Google (without credentials as well), Microsoft TTS Azure, ElevenLabs.io TTS or your own voice. You can also only create a TTS file to be read by third party nodes. Update of the popular SonosPollyTTS node.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "test"
|
|
7
|
+
"test": "test",
|
|
8
|
+
"verify:googletranslate-split": "node scripts/verify-googletranslate-split.js"
|
|
8
9
|
},
|
|
9
10
|
"repository": {
|
|
10
11
|
"type": "git",
|
|
@@ -55,4 +56,4 @@
|
|
|
55
56
|
"engines": {
|
|
56
57
|
"node": ">=22.0.0"
|
|
57
58
|
}
|
|
58
|
-
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const GoogleTranslate = require("google-translate-tts");
|
|
4
|
+
|
|
5
|
+
const GOOGLE_TRANSLATE_MAX_CHARS = 200;
|
|
6
|
+
|
|
7
|
+
const argValue = (name, fallback) => {
|
|
8
|
+
const idx = process.argv.indexOf(name);
|
|
9
|
+
if (idx === -1) return fallback;
|
|
10
|
+
const value = process.argv[idx + 1];
|
|
11
|
+
if (!value || value.startsWith("--")) return fallback;
|
|
12
|
+
return value;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const flagEnabled = (name) => process.argv.includes(name);
|
|
16
|
+
|
|
17
|
+
const help = () => {
|
|
18
|
+
// eslint-disable-next-line no-console
|
|
19
|
+
console.log(`Usage:
|
|
20
|
+
node scripts/verify-googletranslate-split.js --voice it-IT --text "..." --out ./out.mp3
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
--voice Voice code (default: it-IT)
|
|
24
|
+
--text Text to synthesize (default: long sample text)
|
|
25
|
+
--out Output file path (default: ./googletranslate-split.mp3)
|
|
26
|
+
--slow Enable slow speaking
|
|
27
|
+
--help Show help
|
|
28
|
+
`);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const stripId3v2 = (buffer) => {
|
|
32
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 10) return buffer;
|
|
33
|
+
if (buffer[0] !== 0x49 || buffer[1] !== 0x44 || buffer[2] !== 0x33) return buffer; // "ID3"
|
|
34
|
+
const size =
|
|
35
|
+
((buffer[6] & 0x7f) << 21) |
|
|
36
|
+
((buffer[7] & 0x7f) << 14) |
|
|
37
|
+
((buffer[8] & 0x7f) << 7) |
|
|
38
|
+
(buffer[9] & 0x7f);
|
|
39
|
+
const tagEnd = 10 + size;
|
|
40
|
+
if (tagEnd <= 10 || tagEnd >= buffer.length) return buffer;
|
|
41
|
+
return buffer.subarray(tagEnd);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const splitText = (text, maxLen) => {
|
|
45
|
+
const chunks = [];
|
|
46
|
+
let remaining = (text ?? "").toString().trim();
|
|
47
|
+
if (remaining === "") return chunks;
|
|
48
|
+
|
|
49
|
+
const breakChars = ["\n", ".", "!", "?", ";", ":", ",", " "];
|
|
50
|
+
while (remaining.length > maxLen) {
|
|
51
|
+
const window = remaining.slice(0, maxLen + 1);
|
|
52
|
+
let breakAt = -1;
|
|
53
|
+
for (const ch of breakChars) {
|
|
54
|
+
const idx = window.lastIndexOf(ch);
|
|
55
|
+
if (idx > breakAt) breakAt = idx;
|
|
56
|
+
}
|
|
57
|
+
if (breakAt <= 0) breakAt = maxLen;
|
|
58
|
+
const cutAt = breakAt === maxLen ? maxLen : breakAt + 1;
|
|
59
|
+
const chunk = remaining.slice(0, cutAt).trim();
|
|
60
|
+
if (chunk !== "") chunks.push(chunk);
|
|
61
|
+
remaining = remaining.slice(cutAt).trimStart();
|
|
62
|
+
}
|
|
63
|
+
if (remaining !== "") chunks.push(remaining);
|
|
64
|
+
return chunks;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const isLikelyMp3 = (buffer) => {
|
|
68
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 3) return false;
|
|
69
|
+
if (buffer[0] === 0x49 && buffer[1] === 0x44 && buffer[2] === 0x33) return true; // "ID3"
|
|
70
|
+
if (buffer.length >= 2 && buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) return true; // frame sync
|
|
71
|
+
return false;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const main = async () => {
|
|
75
|
+
if (flagEnabled("--help")) {
|
|
76
|
+
help();
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const voice = argValue("--voice", "it-IT");
|
|
81
|
+
const slow = flagEnabled("--slow");
|
|
82
|
+
const outFile = argValue("--out", path.resolve(process.cwd(), "googletranslate-split.mp3"));
|
|
83
|
+
const text = argValue(
|
|
84
|
+
"--text",
|
|
85
|
+
"Questo è un testo di esempio volutamente molto lungo per verificare lo split automatico in blocchi da 200 caratteri. " +
|
|
86
|
+
"Il file MP3 risultante deve essere unico e riproducibile, anche se generato da più richieste consecutive al servizio di Google Translate TTS."
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const resolvedVoice = typeof voice === "string" && voice.includes("-") ? voice.split("-")[0] : voice;
|
|
90
|
+
const chunks = splitText(text, GOOGLE_TRANSLATE_MAX_CHARS);
|
|
91
|
+
// eslint-disable-next-line no-console
|
|
92
|
+
console.log(`Text length: ${text.length} chars, chunks: ${chunks.length}, voice: ${resolvedVoice}, slow: ${slow}`);
|
|
93
|
+
|
|
94
|
+
const buffers = [];
|
|
95
|
+
for (let i = 0; i < chunks.length; i += 1) {
|
|
96
|
+
const chunkBuffer = await GoogleTranslate.synthesize({ text: chunks[i], voice: resolvedVoice, slow });
|
|
97
|
+
buffers.push(i === 0 ? chunkBuffer : stripId3v2(chunkBuffer));
|
|
98
|
+
}
|
|
99
|
+
const mp3 = Buffer.concat(buffers);
|
|
100
|
+
|
|
101
|
+
if (!isLikelyMp3(mp3)) {
|
|
102
|
+
throw new Error("Output does not look like an MP3 (missing ID3 header or frame sync).");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fs.writeFileSync(outFile, mp3);
|
|
106
|
+
// eslint-disable-next-line no-console
|
|
107
|
+
console.log(`Wrote: ${outFile} (${mp3.length} bytes)`);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
main().catch((err) => {
|
|
111
|
+
// eslint-disable-next-line no-console
|
|
112
|
+
console.error(err);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
@@ -223,7 +223,7 @@
|
|
|
223
223
|
<div class="form-row">
|
|
224
224
|
<label for="node-config-input-purgediratrestart"><i class="fa fa-folder-o"></i> TTS Cache</label>
|
|
225
225
|
<select id="node-config-input-purgediratrestart">
|
|
226
|
-
<option value="purge">Purge and delete
|
|
226
|
+
<option value="purge">Purge and delete this config node's TTS cache at deploy or restart(default)</option>
|
|
227
227
|
<option value="leave">Leave the TTS cache folder untouched (not suggested if you have less disk space)</option>
|
|
228
228
|
</select>
|
|
229
229
|
</div>
|
|
@@ -327,4 +327,4 @@ Leave this field blank for the default.<br/>
|
|
|
327
327
|
Configuration help is also here <a href="https://github.com/Supergiovane/node-red-contrib-tts-ultimate/blob/master/README.md">README</a><br/>
|
|
328
328
|
</p>
|
|
329
329
|
|
|
330
|
-
</script>
|
|
330
|
+
</script>
|
|
@@ -43,7 +43,7 @@ module.exports = function (RED) {
|
|
|
43
43
|
if (!fs.existsSync(_aPath)) {
|
|
44
44
|
// Create the path
|
|
45
45
|
try {
|
|
46
|
-
fs.mkdirSync(_aPath);
|
|
46
|
+
fs.mkdirSync(_aPath, { recursive: true });
|
|
47
47
|
return true;
|
|
48
48
|
} catch (error) { return false; }
|
|
49
49
|
} else {
|
|
@@ -54,9 +54,15 @@ module.exports = function (RED) {
|
|
|
54
54
|
RED.log.error('ttsultimate-config ' + node.id + ': Unable to set up MAIN directory: ' + node.TTSRootFolderPath);
|
|
55
55
|
}
|
|
56
56
|
if (!setupDirectory(path.join(node.TTSRootFolderPath, "ttsfiles"))) {
|
|
57
|
-
RED.log.error('ttsultimate-config ' + node.id + ': Unable to set up cache directory: ' + path.join(node.TTSRootFolderPath, "ttsfiles"));
|
|
57
|
+
RED.log.error('ttsultimate-config ' + node.id + ': Unable to set up cache root directory: ' + path.join(node.TTSRootFolderPath, "ttsfiles"));
|
|
58
58
|
} else {
|
|
59
|
-
RED.log.info('ttsultimate-config ' + node.id + ': TTS cache set to ' + path.join(node.TTSRootFolderPath, "ttsfiles"));
|
|
59
|
+
RED.log.info('ttsultimate-config ' + node.id + ': TTS cache root set to ' + path.join(node.TTSRootFolderPath, "ttsfiles"));
|
|
60
|
+
}
|
|
61
|
+
node.ttsCacheFolder = path.join(node.TTSRootFolderPath, "ttsfiles", node.id);
|
|
62
|
+
if (!setupDirectory(node.ttsCacheFolder)) {
|
|
63
|
+
RED.log.error('ttsultimate-config ' + node.id + ': Unable to set up cache directory: ' + node.ttsCacheFolder);
|
|
64
|
+
} else {
|
|
65
|
+
RED.log.info('ttsultimate-config ' + node.id + ': TTS cache set to ' + node.ttsCacheFolder);
|
|
60
66
|
}
|
|
61
67
|
if (!setupDirectory(path.join(node.TTSRootFolderPath, "ttsultimategooglecredentials"))) {
|
|
62
68
|
RED.log.error('ttsultimate-config ' + node.id + ': Unable to set google creds directory: ' + path.join(node.TTSRootFolderPath, "ttsultimategooglecredentials"));
|
|
@@ -517,15 +523,14 @@ module.exports = function (RED) {
|
|
|
517
523
|
|
|
518
524
|
// 26/02/2020
|
|
519
525
|
if (node.purgediratrestart === "purge") {
|
|
520
|
-
// Delete all files, that are'nt OwnFiles_
|
|
521
526
|
try {
|
|
522
|
-
let files = fs.readdirSync(
|
|
527
|
+
let files = fs.readdirSync(node.ttsCacheFolder);
|
|
523
528
|
try {
|
|
524
529
|
if (files.length > 0) {
|
|
525
530
|
files.forEach(function (file) {
|
|
526
|
-
RED.log.info("ttsultimate-config " + node.id + ": Deleted TTS file " + path.join(node.
|
|
531
|
+
RED.log.info("ttsultimate-config " + node.id + ": Deleted TTS file " + path.join(node.ttsCacheFolder, file));
|
|
527
532
|
try {
|
|
528
|
-
fs.unlinkSync(path.join(node.
|
|
533
|
+
fs.unlinkSync(path.join(node.ttsCacheFolder, file));
|
|
529
534
|
} catch (error) {
|
|
530
535
|
}
|
|
531
536
|
});
|
|
@@ -551,13 +556,24 @@ module.exports = function (RED) {
|
|
|
551
556
|
var query = url_parts.query;
|
|
552
557
|
|
|
553
558
|
res.setHeader('Content-Disposition', 'attachment; filename=tts.mp3')
|
|
554
|
-
if (
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
+
if (!query || query.f === undefined || query.f === null) {
|
|
560
|
+
res.write("File not specified");
|
|
561
|
+
res.end();
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const requestedPath = query.f.toString();
|
|
566
|
+
if (fs.existsSync(requestedPath)) {
|
|
567
|
+
// Security check: allow only mp3 files under the configured storage folder.
|
|
568
|
+
const resolvedRequested = path.resolve(requestedPath);
|
|
569
|
+
const resolvedAllowedRoot = path.resolve(node.TTSRootFolderPath);
|
|
570
|
+
const isInsideRoot =
|
|
571
|
+
resolvedRequested === resolvedAllowedRoot || resolvedRequested.startsWith(resolvedAllowedRoot + path.sep);
|
|
572
|
+
|
|
573
|
+
if (path.extname(resolvedRequested) === ".mp3" && isInsideRoot) {
|
|
574
|
+
var readStream = fs.createReadStream(resolvedRequested);
|
|
559
575
|
readStream.on("error", function (error) {
|
|
560
|
-
RED.log.error("ttsultimate-config " + node.id + ": Playsonos error opening stream : " +
|
|
576
|
+
RED.log.error("ttsultimate-config " + node.id + ": Playsonos error opening stream : " + resolvedRequested + ' : ' + error);
|
|
561
577
|
res.end();
|
|
562
578
|
return;
|
|
563
579
|
});
|
|
@@ -576,7 +592,7 @@ module.exports = function (RED) {
|
|
|
576
592
|
}
|
|
577
593
|
|
|
578
594
|
} catch (error) {
|
|
579
|
-
RED.log.error("ttsultimate-config " + node.id + ": Playsonos RED.httpAdmin error: " + error
|
|
595
|
+
RED.log.error("ttsultimate-config " + node.id + ": Playsonos RED.httpAdmin error: " + error);
|
|
580
596
|
res.end();
|
|
581
597
|
}
|
|
582
598
|
|
|
@@ -635,4 +651,3 @@ module.exports = function (RED) {
|
|
|
635
651
|
});
|
|
636
652
|
|
|
637
653
|
}
|
|
638
|
-
|
|
@@ -49,6 +49,47 @@
|
|
|
49
49
|
<label></label>
|
|
50
50
|
<input type="checkbox" id="node-input-elevenlabsUse_speaker_boost" style="margin-left: 0px; vertical-align: top; width: auto !important;"> <label style="width:auto !important;"> Speaker boost</label>
|
|
51
51
|
</div>
|
|
52
|
+
<div class="form-row">
|
|
53
|
+
<label for="node-input-elevenlabsModel"><i class="fa fa-cubes"></i> Model</label>
|
|
54
|
+
<select id="node-input-elevenlabsModel" style="width:60%">
|
|
55
|
+
<option value="">Automatic</option>
|
|
56
|
+
<option value="eleven_monolingual_v1">Eleven Monolingual v1</option>
|
|
57
|
+
<option value="eleven_multilingual_v1">Eleven Multilingual v1</option>
|
|
58
|
+
<option value="eleven_multilingual_v2">Eleven Multilingual v2</option>
|
|
59
|
+
<option value="eleven_turbo_v2">Eleven Turbo v2</option>
|
|
60
|
+
<option value="eleven_turbo_v2_5">Eleven Turbo v2.5</option>
|
|
61
|
+
</select>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="form-row">
|
|
64
|
+
<label for="node-input-elevenlabsOptimizeLatency"><i class="fa fa-tachometer"></i> Latency preset</label>
|
|
65
|
+
<select id="node-input-elevenlabsOptimizeLatency" style="width:60%">
|
|
66
|
+
<option value="">Default</option>
|
|
67
|
+
<option value="0">0 - Best quality</option>
|
|
68
|
+
<option value="1">1</option>
|
|
69
|
+
<option value="2">2</option>
|
|
70
|
+
<option value="3">3</option>
|
|
71
|
+
<option value="4">4 - Lowest latency</option>
|
|
72
|
+
</select>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="form-row">
|
|
75
|
+
<label for="node-input-elevenlabsOutputFormat"><i class="fa fa-file-audio-o"></i> Output format</label>
|
|
76
|
+
<select id="node-input-elevenlabsOutputFormat" style="width:60%">
|
|
77
|
+
<option value="">Default (mp3_44100_128)</option>
|
|
78
|
+
<option value="mp3_44100_192">mp3_44100_192</option>
|
|
79
|
+
<option value="mp3_44100_128">mp3_44100_128</option>
|
|
80
|
+
<option value="mp3_44100_64">mp3_44100_64</option>
|
|
81
|
+
<option value="mp3_44100_32">mp3_44100_32</option>
|
|
82
|
+
<option value="mp3_22050_128">mp3_22050_128</option>
|
|
83
|
+
<option value="mp3_22050_64">mp3_22050_64</option>
|
|
84
|
+
<option value="pcm_16000">pcm_16000</option>
|
|
85
|
+
<option value="pcm_22050">pcm_22050</option>
|
|
86
|
+
<option value="ulaw_8000">ulaw_8000</option>
|
|
87
|
+
</select>
|
|
88
|
+
</div>
|
|
89
|
+
<div class="form-row">
|
|
90
|
+
<label for="node-input-elevenlabsSeed"><i class="fa fa-random"></i> Seed</label>
|
|
91
|
+
<input type="text" id="node-input-elevenlabsSeed" style="width:120px" placeholder="Leave blank for random">
|
|
92
|
+
</div>
|
|
52
93
|
</div>
|
|
53
94
|
<div class="form-row" id="divSSML">
|
|
54
95
|
<label></label>
|
|
@@ -161,6 +202,10 @@
|
|
|
161
202
|
elevenlabsSimilarity_boost: { value: "0.5", required: false },
|
|
162
203
|
elevenlabsStyle: { value: "0.0", required: false },
|
|
163
204
|
elevenlabsUse_speaker_boost: { value: true, required: false },
|
|
205
|
+
elevenlabsModel: { value: "", required: false },
|
|
206
|
+
elevenlabsOptimizeLatency: { value: "", required: false },
|
|
207
|
+
elevenlabsOutputFormat: { value: "", required: false },
|
|
208
|
+
elevenlabsSeed: { value: "", required: false },
|
|
164
209
|
},
|
|
165
210
|
inputs: 1,
|
|
166
211
|
outputs: 2,
|
|
@@ -184,6 +229,10 @@
|
|
|
184
229
|
oneditprepare: function () {
|
|
185
230
|
var node = this;
|
|
186
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);
|
|
187
236
|
|
|
188
237
|
// 19/02/2020 Used to alert the user if the CSV file has not been loaded and to get the server sooner als deploy
|
|
189
238
|
// ###########################
|
|
@@ -612,4 +661,4 @@ The node has two output pins. The first pin is to signal play status, the second
|
|
|
612
661
|
[Find it useful?](https://www.paypal.me/techtoday)
|
|
613
662
|
|
|
614
663
|
<br/>
|
|
615
|
-
</script>
|
|
664
|
+
</script>
|
|
@@ -65,6 +65,8 @@ module.exports = function (RED) {
|
|
|
65
65
|
node.msg.completed = true;
|
|
66
66
|
node.msg.connectionerror = true;
|
|
67
67
|
node.userDir = node.server.TTSRootFolderPath === undefined ? path.join(RED.settings.userDir, "sonospollyttsstorage") : node.server.TTSRootFolderPath;
|
|
68
|
+
node.ttsCacheFolder = node.server.ttsCacheFolder === undefined ? path.join(node.userDir, "ttsfiles") : node.server.ttsCacheFolder;
|
|
69
|
+
node.legacyTtsCacheFolder = path.join(node.userDir, "ttsfiles");
|
|
68
70
|
node.oAdditionalSonosPlayers = []; // 20/03/2020 Contains other players to be grouped
|
|
69
71
|
node.rules = config.rules || [{}];
|
|
70
72
|
node.sNoderedURL = "";
|
|
@@ -566,17 +568,42 @@ module.exports = function (RED) {
|
|
|
566
568
|
const msg = node.currentMSGbeingSpoken.payload.toString(); // Get the text to be spoken
|
|
567
569
|
//node.tempMSGStorage.splice(0, 1); // Remove the first item in the array
|
|
568
570
|
node.sFileToBePlayed = "";
|
|
571
|
+
let audioSource = "unknown"; // internet|cache|local|http|unknown
|
|
572
|
+
const resolveCachePath = (cacheFilename) => {
|
|
573
|
+
const primaryPath = path.join(node.ttsCacheFolder, cacheFilename);
|
|
574
|
+
const legacyPath = path.join(node.legacyTtsCacheFolder, cacheFilename);
|
|
575
|
+
if (fs.existsSync(primaryPath)) return { isCached: true, resolvedPath: primaryPath };
|
|
576
|
+
if (fs.existsSync(legacyPath)) return { isCached: true, resolvedPath: legacyPath };
|
|
577
|
+
return { isCached: false, resolvedPath: primaryPath };
|
|
578
|
+
};
|
|
579
|
+
const formatAudioSourceTag = (source) => {
|
|
580
|
+
switch (source) {
|
|
581
|
+
case "internet":
|
|
582
|
+
return "[CLOUD] ";
|
|
583
|
+
case "cache":
|
|
584
|
+
return "[CACHE] ";
|
|
585
|
+
case "local":
|
|
586
|
+
return "[LOCAL] ";
|
|
587
|
+
case "http":
|
|
588
|
+
return "[HTTP] ";
|
|
589
|
+
default:
|
|
590
|
+
return "";
|
|
591
|
+
}
|
|
592
|
+
};
|
|
569
593
|
node.setNodeStatus({ fill: "gray", shape: "ring", text: "Read " + msg });
|
|
570
594
|
|
|
571
595
|
// 04/12/2020 check what really is the file to be played
|
|
572
596
|
if (msg.toLowerCase().startsWith("http://") || msg.toLowerCase().startsWith("https://")) {
|
|
573
597
|
RED.log.info('ttsultimate: HTTP filename: ' + msg);
|
|
598
|
+
audioSource = "http";
|
|
574
599
|
node.sFileToBePlayed = msg;
|
|
575
600
|
} else if (msg.indexOf("OwnFile_") !== -1) {
|
|
576
601
|
RED.log.info('ttsultimate: OwnFile .MP3, skip tts, filename: ' + msg);
|
|
602
|
+
audioSource = "local";
|
|
577
603
|
node.sFileToBePlayed = path.join(node.userDir, "ttspermanentfiles", msg);
|
|
578
604
|
} else if (msg.indexOf("Hailing_") !== -1) {
|
|
579
605
|
RED.log.info('ttsultimate: Hailing .MP3, skip tts, filename: ' + msg);
|
|
606
|
+
audioSource = "local";
|
|
580
607
|
node.sFileToBePlayed = path.join(node.userDir, "hailingpermanentfiles", msg);
|
|
581
608
|
} else {
|
|
582
609
|
try {
|
|
@@ -597,9 +624,11 @@ module.exports = function (RED) {
|
|
|
597
624
|
params.VoiceId = node.voiceId;
|
|
598
625
|
}
|
|
599
626
|
// Download or read from cache
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
627
|
+
const cacheFilename = getFilename(msg, params);
|
|
628
|
+
const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
|
|
629
|
+
node.sFileToBePlayed = resolvedPath;
|
|
630
|
+
audioSource = isCached ? "cache" : "internet";
|
|
631
|
+
if (!isCached) {
|
|
603
632
|
node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
|
|
604
633
|
data = await synthesizeSpeechPolly([node.server.polly, params]);
|
|
605
634
|
} else {
|
|
@@ -616,9 +645,11 @@ module.exports = function (RED) {
|
|
|
616
645
|
params.input = node.ssml === false ? { text: msg } : { ssml: msg };
|
|
617
646
|
|
|
618
647
|
// Download or read from cache
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
648
|
+
const cacheFilename = getFilename(msg, params);
|
|
649
|
+
const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
|
|
650
|
+
node.sFileToBePlayed = resolvedPath;
|
|
651
|
+
audioSource = isCached ? "cache" : "internet";
|
|
652
|
+
if (!isCached) {
|
|
622
653
|
node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
|
|
623
654
|
data = await synthesizeSpeechGoogleTTS([node.server.googleTTS, params]);
|
|
624
655
|
} else {
|
|
@@ -634,9 +665,11 @@ module.exports = function (RED) {
|
|
|
634
665
|
};
|
|
635
666
|
|
|
636
667
|
// Download or read from cache
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
668
|
+
const cacheFilename = getFilename(msg, params);
|
|
669
|
+
const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
|
|
670
|
+
node.sFileToBePlayed = resolvedPath;
|
|
671
|
+
audioSource = isCached ? "cache" : "internet";
|
|
672
|
+
if (!isCached) {
|
|
640
673
|
node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
|
|
641
674
|
data = await synthesizeSpeechGoogleTranslate(node.server.googleTranslateTTS, params);
|
|
642
675
|
} else {
|
|
@@ -650,9 +683,11 @@ module.exports = function (RED) {
|
|
|
650
683
|
};
|
|
651
684
|
|
|
652
685
|
// Download or read from cache
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
686
|
+
const cacheFilename = getFilename(msg, params);
|
|
687
|
+
const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
|
|
688
|
+
node.sFileToBePlayed = resolvedPath;
|
|
689
|
+
audioSource = isCached ? "cache" : "internet";
|
|
690
|
+
if (!isCached) {
|
|
656
691
|
node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
|
|
657
692
|
data = await synthesizeSpeechMicrosoftAzureTTS(node.server.microsoftAzureTTS, params);
|
|
658
693
|
} else {
|
|
@@ -660,19 +695,24 @@ module.exports = function (RED) {
|
|
|
660
695
|
}
|
|
661
696
|
} else if (node.server.ttsservice === "elevenlabs") {
|
|
662
697
|
// VoiceId is: code
|
|
698
|
+
const stability = config.elevenlabsStability !== undefined && config.elevenlabsStability !== "" ? Number(config.elevenlabsStability) : undefined;
|
|
699
|
+
const similarity = config.elevenlabsSimilarity_boost !== undefined && config.elevenlabsSimilarity_boost !== "" ? Number(config.elevenlabsSimilarity_boost) : undefined;
|
|
700
|
+
const resolvedModel = config.elevenlabsModel && config.elevenlabsModel !== "" ? config.elevenlabsModel : "eleven_monolingual_v1";
|
|
663
701
|
const params = {
|
|
664
702
|
text: msg,
|
|
665
703
|
voice: node.voiceId,
|
|
666
|
-
model_id:
|
|
667
|
-
voice_settings: {
|
|
668
|
-
stability: config.elevenlabsStability,
|
|
669
|
-
similarity_boost: config.elevenlabsSimilarity_boost
|
|
670
|
-
}
|
|
704
|
+
model_id: resolvedModel,
|
|
705
|
+
voice_settings: {}
|
|
671
706
|
};
|
|
707
|
+
if (stability !== undefined && !Number.isNaN(stability)) params.voice_settings.stability = stability;
|
|
708
|
+
if (similarity !== undefined && !Number.isNaN(similarity)) params.voice_settings.similarity_boost = similarity;
|
|
709
|
+
if (Object.keys(params.voice_settings).length === 0) delete params.voice_settings;
|
|
672
710
|
// Download or read from cache
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
711
|
+
const cacheFilename = getFilename(msg, params);
|
|
712
|
+
const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
|
|
713
|
+
node.sFileToBePlayed = resolvedPath;
|
|
714
|
+
audioSource = isCached ? "cache" : "internet";
|
|
715
|
+
if (!isCached) {
|
|
676
716
|
node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
|
|
677
717
|
data = await synthesizeSpeechElevenLabs(node.server.elevenlabsTTS, params);
|
|
678
718
|
} else {
|
|
@@ -680,22 +720,35 @@ module.exports = function (RED) {
|
|
|
680
720
|
}
|
|
681
721
|
} else if (node.server.ttsservice === "elevenlabsv2") {
|
|
682
722
|
// VoiceId is: code
|
|
723
|
+
const stability = config.elevenlabsStability !== undefined && config.elevenlabsStability !== "" ? Number(config.elevenlabsStability) : undefined;
|
|
724
|
+
const similarity = config.elevenlabsSimilarity_boost !== undefined && config.elevenlabsSimilarity_boost !== "" ? Number(config.elevenlabsSimilarity_boost) : undefined;
|
|
725
|
+
const style = config.elevenlabsStyle !== undefined && config.elevenlabsStyle !== "" ? Number(config.elevenlabsStyle) : undefined;
|
|
726
|
+
const resolvedModel = config.elevenlabsModel && config.elevenlabsModel !== "" ? config.elevenlabsModel : "eleven_multilingual_v2";
|
|
727
|
+
const latencyPreset = config.elevenlabsOptimizeLatency && config.elevenlabsOptimizeLatency !== "" ? config.elevenlabsOptimizeLatency : undefined;
|
|
728
|
+
const outputFormat = config.elevenlabsOutputFormat && config.elevenlabsOutputFormat !== "" ? config.elevenlabsOutputFormat : undefined;
|
|
729
|
+
const seed = config.elevenlabsSeed && config.elevenlabsSeed !== "" ? Number(config.elevenlabsSeed) : undefined;
|
|
730
|
+
const useSpeakerBoost = config.elevenlabsUse_speaker_boost === undefined ? true : config.elevenlabsUse_speaker_boost;
|
|
683
731
|
const params = {
|
|
684
732
|
stream: false,
|
|
685
733
|
text: msg,
|
|
686
734
|
voice: node.voiceId,
|
|
687
|
-
model_id:
|
|
688
|
-
voice_settings: {
|
|
689
|
-
stability: config.elevenlabsStability,
|
|
690
|
-
similarity_boost: config.elevenlabsSimilarity_boost,
|
|
691
|
-
style: config.elevenlabsStyle || 0,
|
|
692
|
-
use_speaker_boost: config.elevenlabsUse_speaker_boost === undefined ? true : config.elevenlabsUse_speaker_boost
|
|
693
|
-
}
|
|
735
|
+
model_id: resolvedModel,
|
|
736
|
+
voice_settings: {}
|
|
694
737
|
};
|
|
738
|
+
if (stability !== undefined && !Number.isNaN(stability)) params.voice_settings.stability = stability;
|
|
739
|
+
if (similarity !== undefined && !Number.isNaN(similarity)) params.voice_settings.similarity_boost = similarity;
|
|
740
|
+
if (style !== undefined && !Number.isNaN(style)) params.voice_settings.style = style;
|
|
741
|
+
params.voice_settings.use_speaker_boost = useSpeakerBoost;
|
|
742
|
+
if (Object.keys(params.voice_settings).length === 0) delete params.voice_settings;
|
|
743
|
+
if (latencyPreset !== undefined) params.optimize_streaming_latency = latencyPreset;
|
|
744
|
+
if (outputFormat !== undefined) params.output_format = outputFormat;
|
|
745
|
+
if (seed !== undefined && !Number.isNaN(seed)) params.seed = seed;
|
|
695
746
|
// Download or read from cache
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
747
|
+
const cacheFilename = getFilename(msg, params);
|
|
748
|
+
const { isCached, resolvedPath } = resolveCachePath(cacheFilename);
|
|
749
|
+
node.sFileToBePlayed = resolvedPath;
|
|
750
|
+
audioSource = isCached ? "cache" : "internet";
|
|
751
|
+
if (!isCached) {
|
|
699
752
|
node.setNodeStatus({ fill: 'blue', shape: 'ring', text: 'Download using ' + node.server.ttsservice });
|
|
700
753
|
data = await synthesizeSpeechElevenLabsV2(node.server.elevenlabsTTS, params);
|
|
701
754
|
} else {
|
|
@@ -729,7 +782,7 @@ module.exports = function (RED) {
|
|
|
729
782
|
if (node.playertype === "sonos") {
|
|
730
783
|
|
|
731
784
|
// Play with Sonos
|
|
732
|
-
node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Play ' + msg });
|
|
785
|
+
node.setNodeStatus({ fill: 'green', shape: 'ring', text: 'Play ' + formatAudioSourceTag(audioSource) + msg });
|
|
733
786
|
|
|
734
787
|
// Play directly files starting with http://
|
|
735
788
|
if (!node.sFileToBePlayed.toLowerCase().startsWith("http://") && !node.sFileToBePlayed.toLowerCase().startsWith("https://")) {
|
|
@@ -792,7 +845,7 @@ module.exports = function (RED) {
|
|
|
792
845
|
if (node.timerbTimeOutPlay !== null) clearTimeout(node.timerbTimeOutPlay);
|
|
793
846
|
switch (node.bTimeOutPlay) {
|
|
794
847
|
case false:
|
|
795
|
-
node.setNodeStatus({ fill: 'green', shape: 'dot', text: 'Playing ' + msg });
|
|
848
|
+
node.setNodeStatus({ fill: 'green', shape: 'dot', text: 'Playing ' + formatAudioSourceTag(audioSource) + msg });
|
|
796
849
|
break;
|
|
797
850
|
default:
|
|
798
851
|
node.setNodeStatus({ fill: 'grey', shape: 'dot', text: 'Timeout waiting start play state: ' + msg });
|
|
@@ -1140,12 +1193,62 @@ module.exports = function (RED) {
|
|
|
1140
1193
|
// 26/12/2020 Google TTS Service
|
|
1141
1194
|
async function synthesizeSpeechGoogleTranslate(ttsService, params) {
|
|
1142
1195
|
try {
|
|
1143
|
-
|
|
1196
|
+
const GOOGLE_TRANSLATE_MAX_CHARS = 200;
|
|
1197
|
+
|
|
1198
|
+
const stripId3v2 = (buffer) => {
|
|
1199
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 10) return buffer;
|
|
1200
|
+
if (buffer[0] !== 0x49 || buffer[1] !== 0x44 || buffer[2] !== 0x33) return buffer; // "ID3"
|
|
1201
|
+
const size =
|
|
1202
|
+
((buffer[6] & 0x7f) << 21) |
|
|
1203
|
+
((buffer[7] & 0x7f) << 14) |
|
|
1204
|
+
((buffer[8] & 0x7f) << 7) |
|
|
1205
|
+
(buffer[9] & 0x7f);
|
|
1206
|
+
const tagEnd = 10 + size;
|
|
1207
|
+
if (tagEnd <= 10 || tagEnd >= buffer.length) return buffer;
|
|
1208
|
+
return buffer.subarray(tagEnd);
|
|
1209
|
+
};
|
|
1210
|
+
|
|
1211
|
+
const splitText = (text, maxLen) => {
|
|
1212
|
+
const chunks = [];
|
|
1213
|
+
let remaining = (text ?? "").toString().trim();
|
|
1214
|
+
if (remaining === "") return chunks;
|
|
1215
|
+
|
|
1216
|
+
const breakChars = ["\n", ".", "!", "?", ";", ":", ",", " "];
|
|
1217
|
+
while (remaining.length > maxLen) {
|
|
1218
|
+
const window = remaining.slice(0, maxLen + 1);
|
|
1219
|
+
let breakAt = -1;
|
|
1220
|
+
for (const ch of breakChars) {
|
|
1221
|
+
const idx = window.lastIndexOf(ch);
|
|
1222
|
+
if (idx > breakAt) breakAt = idx;
|
|
1223
|
+
}
|
|
1224
|
+
if (breakAt <= 0) breakAt = maxLen;
|
|
1225
|
+
const cutAt = breakAt === maxLen ? maxLen : breakAt + 1;
|
|
1226
|
+
const chunk = remaining.slice(0, cutAt).trim();
|
|
1227
|
+
if (chunk !== "") chunks.push(chunk);
|
|
1228
|
+
remaining = remaining.slice(cutAt).trimStart();
|
|
1229
|
+
}
|
|
1230
|
+
if (remaining !== "") chunks.push(remaining);
|
|
1231
|
+
return chunks;
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
// 30/01/2021 changed how google handles voices
|
|
1144
1235
|
// https://github.com/ncpierson/google-translate-tts/issues/5#issuecomment-770209715
|
|
1145
|
-
|
|
1236
|
+
const resolvedVoice =
|
|
1237
|
+
typeof params.voice === "string" && params.voice.includes("-") ? params.voice.split("-")[0] : params.voice;
|
|
1238
|
+
|
|
1239
|
+
const textChunks = splitText(params.text, GOOGLE_TRANSLATE_MAX_CHARS);
|
|
1240
|
+
if (textChunks.length === 0) return Buffer.from([]);
|
|
1146
1241
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1242
|
+
if (textChunks.length === 1) {
|
|
1243
|
+
return await ttsService.synthesize({ ...params, voice: resolvedVoice, text: textChunks[0] });
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
const buffers = [];
|
|
1247
|
+
for (let i = 0; i < textChunks.length; i += 1) {
|
|
1248
|
+
const chunkBuffer = await ttsService.synthesize({ ...params, voice: resolvedVoice, text: textChunks[i] });
|
|
1249
|
+
buffers.push(i === 0 ? chunkBuffer : stripId3v2(chunkBuffer));
|
|
1250
|
+
}
|
|
1251
|
+
return Buffer.concat(buffers);
|
|
1149
1252
|
} catch (error) {
|
|
1150
1253
|
throw (error);
|
|
1151
1254
|
}
|
|
@@ -1194,7 +1297,10 @@ module.exports = function (RED) {
|
|
|
1194
1297
|
}
|
|
1195
1298
|
return new Promise((resolve, reject) => {
|
|
1196
1299
|
// "model_id": "eleven_multilingual_v1",
|
|
1197
|
-
|
|
1300
|
+
const stability = params.voice_settings && params.voice_settings.stability !== undefined ? params.voice_settings.stability : null;
|
|
1301
|
+
const similarity = params.voice_settings && params.voice_settings.similarity_boost !== undefined ? params.voice_settings.similarity_boost : null;
|
|
1302
|
+
const model = params.model_id !== undefined && params.model_id !== "" ? params.model_id : "eleven_monolingual_v1";
|
|
1303
|
+
ttsService.textToSpeechStream(node.server.credentials.elevenlabsKey, params.voice, params.text, stability, similarity, model).then((res) => {
|
|
1198
1304
|
try {
|
|
1199
1305
|
if (res !== undefined) {
|
|
1200
1306
|
resolve(stream2buffer(res));
|