n8n-nodes-tts-bigboss 1.0.1 → 1.0.3
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/dist/TTSBigBoss.node.js +242 -30
- package/nodes/TTSBigBoss/TTSBigBoss.node.ts +323 -40
- package/package.json +1 -1
package/dist/TTSBigBoss.node.js
CHANGED
|
@@ -43,6 +43,34 @@ const path = __importStar(require("path"));
|
|
|
43
43
|
const os = __importStar(require("os"));
|
|
44
44
|
const child_process = __importStar(require("child_process"));
|
|
45
45
|
const ws_1 = __importDefault(require("ws"));
|
|
46
|
+
const https = __importStar(require("https"));
|
|
47
|
+
const stream = __importStar(require("stream"));
|
|
48
|
+
const util_1 = require("util");
|
|
49
|
+
const pipeline = (0, util_1.promisify)(stream.pipeline);
|
|
50
|
+
const PIPER_MODELS = [
|
|
51
|
+
{ name: 'Arabic (ar_JO) - Kareem (Low)', value: 'ar_JO-kareem-low' },
|
|
52
|
+
{ name: 'Arabic (ar_JO) - Kareem (Medium)', value: 'ar_JO-kareem-medium' },
|
|
53
|
+
{ name: 'English (US) - Lessac (Low)', value: 'en_US-lessac-low' },
|
|
54
|
+
{ name: 'English (US) - Lessac (Medium)', value: 'en_US-lessac-medium' },
|
|
55
|
+
{ name: 'English (US) - Lessac (High)', value: 'en_US-lessac-high' },
|
|
56
|
+
{ name: 'English (US) - Ryan (Low)', value: 'en_US-ryan-low' },
|
|
57
|
+
{ name: 'English (US) - Ryan (Medium)', value: 'en_US-ryan-medium' },
|
|
58
|
+
{ name: 'English (US) - Ryan (High)', value: 'en_US-ryan-high' },
|
|
59
|
+
{ name: 'English (US) - Amy (Low)', value: 'en_US-amy-low' },
|
|
60
|
+
{ name: 'English (US) - Amy (Medium)', value: 'en_US-amy-medium' },
|
|
61
|
+
{ name: 'English (US) - Kathleen (Low)', value: 'en_US-kathleen-low' },
|
|
62
|
+
{ name: 'English (UK) - Alan (Low)', value: 'en_GB-alan-low' },
|
|
63
|
+
{ name: 'English (UK) - Alan (Medium)', value: 'en_GB-alan-medium' },
|
|
64
|
+
{ name: 'English (UK) - Southern English Female (Low)', value: 'en_GB-southern_english_female-low' },
|
|
65
|
+
{ name: 'French (fr_FR) - Siwis (Low)', value: 'fr_FR-siwis-low' },
|
|
66
|
+
{ name: 'French (fr_FR) - Siwis (Medium)', value: 'fr_FR-siwis-medium' },
|
|
67
|
+
{ name: 'Spanish (es_ES) - Sharvard (Medium)', value: 'es_ES-sharvard-medium' },
|
|
68
|
+
{ name: 'Spanish (es_MX) - Aldone (Medium)', value: 'es_MX-aldona-medium' },
|
|
69
|
+
{ name: 'German (de_DE) - Eva (High)', value: 'de_DE-eva_k-x_low' },
|
|
70
|
+
{ name: 'German (de_DE) - Thorsten (High)', value: 'de_DE-thorsten-high' },
|
|
71
|
+
{ name: 'German (de_DE) - Thorsten (Medium)', value: 'de_DE-thorsten-medium' },
|
|
72
|
+
{ name: 'German (de_DE) - Thorsten (Low)', value: 'de_DE-thorsten-low' },
|
|
73
|
+
];
|
|
46
74
|
const EDGE_URL = 'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4';
|
|
47
75
|
const EDGE_VOICES = [
|
|
48
76
|
{ name: 'Arabic (Egypt) - Salma', value: 'ar-EG-SalmaNeural' },
|
|
@@ -71,7 +99,7 @@ class TTSBigBoss {
|
|
|
71
99
|
icon: 'fa:comment-dots',
|
|
72
100
|
group: ['transform'],
|
|
73
101
|
version: 1,
|
|
74
|
-
description: 'Advanced Text-to-Speech (Edge-TTS &
|
|
102
|
+
description: 'Advanced Text-to-Speech (Edge-TTS & Local Standalone)',
|
|
75
103
|
defaults: {
|
|
76
104
|
name: 'TTS BigBoss',
|
|
77
105
|
},
|
|
@@ -89,9 +117,14 @@ class TTSBigBoss {
|
|
|
89
117
|
description: 'High quality, multilingual, supports Arabic & perfect subtitles. Requires internet.',
|
|
90
118
|
},
|
|
91
119
|
{
|
|
92
|
-
name: '
|
|
120
|
+
name: 'Local Piper (Auto-Download)',
|
|
121
|
+
value: 'piper_local',
|
|
122
|
+
description: 'Downloads and runs Piper locally (Offline). Good quality, fast.',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'System Command (Custom)',
|
|
93
126
|
value: 'system',
|
|
94
|
-
description: 'Use
|
|
127
|
+
description: 'Use custom command for installed tools (XTTS, etc).',
|
|
95
128
|
},
|
|
96
129
|
],
|
|
97
130
|
default: 'edge',
|
|
@@ -174,8 +207,8 @@ class TTSBigBoss {
|
|
|
174
207
|
displayName: 'Command',
|
|
175
208
|
name: 'systemCommand',
|
|
176
209
|
type: 'string',
|
|
177
|
-
default: '
|
|
178
|
-
description: 'Command to execute. Use placeholders: "{text}"
|
|
210
|
+
default: 'echo "Custom command here" > "{output_file}"',
|
|
211
|
+
description: 'Command to execute. Use placeholders: "{text}", "{output_file}".',
|
|
179
212
|
displayOptions: {
|
|
180
213
|
show: {
|
|
181
214
|
engine: ['system'],
|
|
@@ -207,6 +240,36 @@ class TTSBigBoss {
|
|
|
207
240
|
},
|
|
208
241
|
description: 'Binary property name containing the reference audio for cloning. Use placeholder "{reference_audio}" in command.',
|
|
209
242
|
},
|
|
243
|
+
{
|
|
244
|
+
displayName: 'Piper Voice Model',
|
|
245
|
+
name: 'piperModel',
|
|
246
|
+
type: 'options',
|
|
247
|
+
options: [
|
|
248
|
+
...PIPER_MODELS,
|
|
249
|
+
{ name: 'Custom (Enter URL)', value: 'custom' },
|
|
250
|
+
],
|
|
251
|
+
default: 'en_US-lessac-medium',
|
|
252
|
+
displayOptions: {
|
|
253
|
+
show: {
|
|
254
|
+
engine: ['piper_local'],
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
description: 'Select a voice model. It will be downloaded automatically on first use.',
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
displayName: 'Custom Model Name/URL',
|
|
261
|
+
name: 'piperModelCustom',
|
|
262
|
+
type: 'string',
|
|
263
|
+
default: '',
|
|
264
|
+
placeholder: 'e.g. en_US-bryce-medium',
|
|
265
|
+
displayOptions: {
|
|
266
|
+
show: {
|
|
267
|
+
engine: ['piper_local'],
|
|
268
|
+
piperModel: ['custom'],
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
description: 'Name from Hugging Face (e.g. en_US-bryce-medium) or full URL to .onnx file.',
|
|
272
|
+
},
|
|
210
273
|
],
|
|
211
274
|
};
|
|
212
275
|
}
|
|
@@ -214,6 +277,10 @@ class TTSBigBoss {
|
|
|
214
277
|
const items = this.getInputData();
|
|
215
278
|
const returnData = [];
|
|
216
279
|
const tempDir = os.tmpdir();
|
|
280
|
+
const binDir = path.join(path.dirname(__dirname), 'bin');
|
|
281
|
+
if (!fs.existsSync(binDir)) {
|
|
282
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
283
|
+
}
|
|
217
284
|
for (let i = 0; i < items.length; i++) {
|
|
218
285
|
try {
|
|
219
286
|
const engine = this.getNodeParameter('engine', i);
|
|
@@ -231,7 +298,48 @@ class TTSBigBoss {
|
|
|
231
298
|
const pitch = this.getNodeParameter('edgePitch', i);
|
|
232
299
|
const result = await runEdgeTTS(text, voice, rate, pitch);
|
|
233
300
|
audioBuffer = result.audio;
|
|
234
|
-
|
|
301
|
+
if (!result.srt || result.srt.trim().length === 0) {
|
|
302
|
+
srtBuffer = Buffer.from(generateHeuristicSRT(text, result.audio.length), 'utf8');
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
srtBuffer = Buffer.from(result.srt, 'utf8');
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
else if (engine === 'piper_local') {
|
|
309
|
+
let piperModel = this.getNodeParameter('piperModel', i);
|
|
310
|
+
if (piperModel === 'custom') {
|
|
311
|
+
piperModel = this.getNodeParameter('piperModelCustom', i);
|
|
312
|
+
}
|
|
313
|
+
const piperBinPath = await ensurePiperBinary(binDir);
|
|
314
|
+
const { modelPath, configPath } = await ensurePiperModel(binDir, piperModel);
|
|
315
|
+
const outFile = path.join(tempDir, `piper_out_${(0, uuid_1.v4)()}.wav`);
|
|
316
|
+
await new Promise((resolve, reject) => {
|
|
317
|
+
const piperProc = child_process.spawn(piperBinPath, [
|
|
318
|
+
'--model', modelPath,
|
|
319
|
+
'--config', configPath,
|
|
320
|
+
'--output_file', outFile
|
|
321
|
+
]);
|
|
322
|
+
piperProc.stdin.write(text);
|
|
323
|
+
piperProc.stdin.end();
|
|
324
|
+
let errData = '';
|
|
325
|
+
piperProc.stderr.on('data', (d) => errData += d.toString());
|
|
326
|
+
piperProc.on('close', (code) => {
|
|
327
|
+
if (code === 0)
|
|
328
|
+
resolve();
|
|
329
|
+
if (errData.includes('json.exception.parse_error')) {
|
|
330
|
+
reject(new Error(`Piper Config Error: The downloaded JSON configuration for model '${piperModel}' seems corrupted (HTML instead of JSON?). Try deleting the file at ${configPath} and running again.`));
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
reject(new Error(`Piper failed (exit ${code}): ${errData}`));
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
piperProc.on('error', (err) => reject(err));
|
|
337
|
+
});
|
|
338
|
+
if (!fs.existsSync(outFile))
|
|
339
|
+
throw new Error('Piper did not produce output file');
|
|
340
|
+
audioBuffer = fs.readFileSync(outFile);
|
|
341
|
+
srtBuffer = Buffer.from(generateHeuristicSRT(text, audioBuffer.length), 'utf8');
|
|
342
|
+
fs.unlinkSync(outFile);
|
|
235
343
|
}
|
|
236
344
|
else {
|
|
237
345
|
const commandTpl = this.getNodeParameter('systemCommand', i);
|
|
@@ -320,36 +428,35 @@ async function runEdgeTTS(text, voice, rate, pitch) {
|
|
|
320
428
|
});
|
|
321
429
|
ws.on('message', (data, isBinary) => {
|
|
322
430
|
const textData = data.toString();
|
|
323
|
-
if (textData.includes('Path:
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (meta.Type === 'Word') {
|
|
339
|
-
wordBoundaries.push({
|
|
340
|
-
offset: meta.Offset,
|
|
341
|
-
duration: meta.Duration,
|
|
342
|
-
text: meta.Text,
|
|
343
|
-
});
|
|
431
|
+
if (textData.includes('Path:audio.metadata')) {
|
|
432
|
+
const parts = textData.split(/\r\n\r\n/);
|
|
433
|
+
for (const part of parts) {
|
|
434
|
+
try {
|
|
435
|
+
if (part.trim().startsWith('{')) {
|
|
436
|
+
const json = JSON.parse(part);
|
|
437
|
+
if (json.Metadata && Array.isArray(json.Metadata)) {
|
|
438
|
+
for (const meta of json.Metadata) {
|
|
439
|
+
if (meta.Type === 'Word') {
|
|
440
|
+
wordBoundaries.push({
|
|
441
|
+
offset: meta.Offset,
|
|
442
|
+
duration: meta.Duration,
|
|
443
|
+
text: meta.Text,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
344
446
|
}
|
|
345
447
|
}
|
|
346
448
|
}
|
|
347
449
|
}
|
|
348
|
-
|
|
349
|
-
catch (e) {
|
|
450
|
+
catch (e) { }
|
|
350
451
|
}
|
|
351
452
|
}
|
|
352
|
-
|
|
453
|
+
if (textData.includes('Path:turn.end')) {
|
|
454
|
+
ws.close();
|
|
455
|
+
const fullAudio = Buffer.concat(audioChunks);
|
|
456
|
+
const srt = buildSRT(wordBoundaries);
|
|
457
|
+
resolve({ audio: fullAudio, srt });
|
|
458
|
+
}
|
|
459
|
+
if (isBinary || (data.length > 2 && data[0] === 0x00 && data[1] === 0x67)) {
|
|
353
460
|
const headerLen = data.readUInt16BE(0);
|
|
354
461
|
if (data.length > headerLen + 2) {
|
|
355
462
|
const audioData = data.slice(headerLen + 2);
|
|
@@ -363,6 +470,8 @@ async function runEdgeTTS(text, voice, rate, pitch) {
|
|
|
363
470
|
});
|
|
364
471
|
}
|
|
365
472
|
function buildSRT(words) {
|
|
473
|
+
if (!words || words.length === 0)
|
|
474
|
+
return '';
|
|
366
475
|
let srt = '';
|
|
367
476
|
let counter = 1;
|
|
368
477
|
let currentPhrase = [];
|
|
@@ -421,3 +530,106 @@ function generateHeuristicSRT(text, byteLength) {
|
|
|
421
530
|
}
|
|
422
531
|
return srt;
|
|
423
532
|
}
|
|
533
|
+
async function ensurePiperBinary(binDir) {
|
|
534
|
+
const platform = os.platform();
|
|
535
|
+
const arch = os.arch();
|
|
536
|
+
let binaryName = 'piper';
|
|
537
|
+
if (platform === 'win32')
|
|
538
|
+
binaryName = 'piper.exe';
|
|
539
|
+
const finalPath = path.join(binDir, binaryName);
|
|
540
|
+
if (fs.existsSync(finalPath))
|
|
541
|
+
return finalPath;
|
|
542
|
+
let url = '';
|
|
543
|
+
let archiveName = '';
|
|
544
|
+
if (platform === 'linux' && arch === 'x64') {
|
|
545
|
+
url = 'https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz';
|
|
546
|
+
archiveName = 'piper.tar.gz';
|
|
547
|
+
}
|
|
548
|
+
else if (platform === 'win32' && arch === 'x64') {
|
|
549
|
+
url = 'https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_windows_amd64.zip';
|
|
550
|
+
archiveName = 'piper.zip';
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
throw new Error(`Auto-download for Piper not supported on ${platform} ${arch}. Please install manually.`);
|
|
554
|
+
}
|
|
555
|
+
const archivePath = path.join(binDir, archiveName);
|
|
556
|
+
console.log(`Downloading Piper from ${url}...`);
|
|
557
|
+
await downloadFile(url, archivePath);
|
|
558
|
+
try {
|
|
559
|
+
if (platform === 'win32') {
|
|
560
|
+
child_process.execSync(`powershell -command "Expand-Archive -Path '${archivePath}' -DestinationPath '${binDir}' -Force"`);
|
|
561
|
+
const extractedFolder = path.join(binDir, 'piper');
|
|
562
|
+
return path.join(binDir, 'piper', 'piper.exe');
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
child_process.execSync(`tar -xzf "${archivePath}" -C "${binDir}"`);
|
|
566
|
+
return path.join(binDir, 'piper', 'piper');
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
catch (e) {
|
|
570
|
+
throw new Error(`Failed to extract Piper: ${e.message}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
async function ensurePiperModel(binDir, modelNameOrUrl) {
|
|
574
|
+
let modelUrl = '';
|
|
575
|
+
let modelFilename = '';
|
|
576
|
+
if (modelNameOrUrl.startsWith('http')) {
|
|
577
|
+
modelUrl = modelNameOrUrl;
|
|
578
|
+
modelFilename = path.basename(modelNameOrUrl);
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
const parts = modelNameOrUrl.split('-');
|
|
582
|
+
if (parts.length >= 3) {
|
|
583
|
+
const langRegion = parts[0] + '_' + parts[1];
|
|
584
|
+
const voice = parts[2];
|
|
585
|
+
const quality = parts[3] || 'medium';
|
|
586
|
+
const lang = parts[0];
|
|
587
|
+
modelFilename = modelNameOrUrl + '.onnx';
|
|
588
|
+
modelUrl = `https://huggingface.co/rhasspy/piper-voices/resolve/main/${lang}/${langRegion}/${voice}/${quality}/${modelFilename}?download=true`;
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
throw new Error(`Invalid model name format: ${modelNameOrUrl}. Use format lang_REGION-voice-quality`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const modelPath = path.join(binDir, modelFilename);
|
|
595
|
+
const configPath = modelPath + '.json';
|
|
596
|
+
if (!fs.existsSync(modelPath) || fs.statSync(modelPath).size < 1000) {
|
|
597
|
+
console.log(`Downloading Piper Model: ${modelUrl}`);
|
|
598
|
+
await downloadFile(modelUrl, modelPath);
|
|
599
|
+
}
|
|
600
|
+
if (!fs.existsSync(configPath) || fs.statSync(configPath).size < 10) {
|
|
601
|
+
const configUrl = modelUrl + '.json';
|
|
602
|
+
console.log(`Downloading Piper Config: ${configUrl}`);
|
|
603
|
+
await downloadFile(configUrl, configPath);
|
|
604
|
+
try {
|
|
605
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
606
|
+
JSON.parse(content);
|
|
607
|
+
}
|
|
608
|
+
catch (e) {
|
|
609
|
+
fs.unlinkSync(configPath);
|
|
610
|
+
if (fs.existsSync(modelPath))
|
|
611
|
+
fs.unlinkSync(modelPath);
|
|
612
|
+
throw new Error(`Downloaded config for ${modelNameOrUrl} was not valid JSON. URL might be wrong: ${configUrl}. Content start: ${fs.readFileSync(configPath, 'utf8').substring(0, 50)}...`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return { modelPath, configPath };
|
|
616
|
+
}
|
|
617
|
+
async function downloadFile(url, dest) {
|
|
618
|
+
return new Promise((resolve, reject) => {
|
|
619
|
+
const file = fs.createWriteStream(dest);
|
|
620
|
+
https.get(url, (response) => {
|
|
621
|
+
if (response.statusCode === 302 || response.statusCode === 301) {
|
|
622
|
+
downloadFile(response.headers.location, dest).then(resolve).catch(reject);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
response.pipe(file);
|
|
626
|
+
file.on('finish', () => {
|
|
627
|
+
file.close();
|
|
628
|
+
resolve();
|
|
629
|
+
});
|
|
630
|
+
}).on('error', (err) => {
|
|
631
|
+
fs.unlink(dest, () => { });
|
|
632
|
+
reject(err);
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
}
|
|
@@ -10,6 +10,49 @@ import * as path from 'path';
|
|
|
10
10
|
import * as os from 'os';
|
|
11
11
|
import * as child_process from 'child_process';
|
|
12
12
|
import WebSocket from 'ws';
|
|
13
|
+
import * as https from 'https';
|
|
14
|
+
import * as stream from 'stream';
|
|
15
|
+
import { promisify } from 'util';
|
|
16
|
+
import * as zlib from 'zlib'; // For extracting .tar.gz if needed, typically usage of tar command is easier on linux
|
|
17
|
+
|
|
18
|
+
const pipeline = promisify(stream.pipeline);
|
|
19
|
+
|
|
20
|
+
// Piper Models List (Curated High Quality)
|
|
21
|
+
const PIPER_MODELS = [
|
|
22
|
+
// Arabic
|
|
23
|
+
{ name: 'Arabic (ar_JO) - Kareem (Low)', value: 'ar_JO-kareem-low' },
|
|
24
|
+
{ name: 'Arabic (ar_JO) - Kareem (Medium)', value: 'ar_JO-kareem-medium' },
|
|
25
|
+
|
|
26
|
+
// English (US)
|
|
27
|
+
{ name: 'English (US) - Lessac (Low)', value: 'en_US-lessac-low' },
|
|
28
|
+
{ name: 'English (US) - Lessac (Medium)', value: 'en_US-lessac-medium' },
|
|
29
|
+
{ name: 'English (US) - Lessac (High)', value: 'en_US-lessac-high' },
|
|
30
|
+
{ name: 'English (US) - Ryan (Low)', value: 'en_US-ryan-low' },
|
|
31
|
+
{ name: 'English (US) - Ryan (Medium)', value: 'en_US-ryan-medium' },
|
|
32
|
+
{ name: 'English (US) - Ryan (High)', value: 'en_US-ryan-high' },
|
|
33
|
+
{ name: 'English (US) - Amy (Low)', value: 'en_US-amy-low' },
|
|
34
|
+
{ name: 'English (US) - Amy (Medium)', value: 'en_US-amy-medium' },
|
|
35
|
+
{ name: 'English (US) - Kathleen (Low)', value: 'en_US-kathleen-low' },
|
|
36
|
+
|
|
37
|
+
// English (UK)
|
|
38
|
+
{ name: 'English (UK) - Alan (Low)', value: 'en_GB-alan-low' },
|
|
39
|
+
{ name: 'English (UK) - Alan (Medium)', value: 'en_GB-alan-medium' },
|
|
40
|
+
{ name: 'English (UK) - Southern English Female (Low)', value: 'en_GB-southern_english_female-low' },
|
|
41
|
+
|
|
42
|
+
// French
|
|
43
|
+
{ name: 'French (fr_FR) - Siwis (Low)', value: 'fr_FR-siwis-low' },
|
|
44
|
+
{ name: 'French (fr_FR) - Siwis (Medium)', value: 'fr_FR-siwis-medium' },
|
|
45
|
+
|
|
46
|
+
// Spanish
|
|
47
|
+
{ name: 'Spanish (es_ES) - Sharvard (Medium)', value: 'es_ES-sharvard-medium' },
|
|
48
|
+
{ name: 'Spanish (es_MX) - Aldone (Medium)', value: 'es_MX-aldona-medium' },
|
|
49
|
+
|
|
50
|
+
// German
|
|
51
|
+
{ name: 'German (de_DE) - Eva (High)', value: 'de_DE-eva_k-x_low' }, // mapped to available
|
|
52
|
+
{ name: 'German (de_DE) - Thorsten (High)', value: 'de_DE-thorsten-high' },
|
|
53
|
+
{ name: 'German (de_DE) - Thorsten (Medium)', value: 'de_DE-thorsten-medium' },
|
|
54
|
+
{ name: 'German (de_DE) - Thorsten (Low)', value: 'de_DE-thorsten-low' },
|
|
55
|
+
];
|
|
13
56
|
|
|
14
57
|
// Edge TTS Constants
|
|
15
58
|
const EDGE_URL = 'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4';
|
|
@@ -52,7 +95,7 @@ export class TTSBigBoss implements INodeType {
|
|
|
52
95
|
icon: 'fa:comment-dots',
|
|
53
96
|
group: ['transform'],
|
|
54
97
|
version: 1,
|
|
55
|
-
description: 'Advanced Text-to-Speech (Edge-TTS &
|
|
98
|
+
description: 'Advanced Text-to-Speech (Edge-TTS & Local Standalone)',
|
|
56
99
|
defaults: {
|
|
57
100
|
name: 'TTS BigBoss',
|
|
58
101
|
},
|
|
@@ -70,9 +113,14 @@ export class TTSBigBoss implements INodeType {
|
|
|
70
113
|
description: 'High quality, multilingual, supports Arabic & perfect subtitles. Requires internet.',
|
|
71
114
|
},
|
|
72
115
|
{
|
|
73
|
-
name: '
|
|
116
|
+
name: 'Local Piper (Auto-Download)',
|
|
117
|
+
value: 'piper_local',
|
|
118
|
+
description: 'Downloads and runs Piper locally (Offline). Good quality, fast.',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: 'System Command (Custom)',
|
|
74
122
|
value: 'system',
|
|
75
|
-
description: 'Use
|
|
123
|
+
description: 'Use custom command for installed tools (XTTS, etc).',
|
|
76
124
|
},
|
|
77
125
|
],
|
|
78
126
|
default: 'edge',
|
|
@@ -164,8 +212,8 @@ export class TTSBigBoss implements INodeType {
|
|
|
164
212
|
displayName: 'Command',
|
|
165
213
|
name: 'systemCommand',
|
|
166
214
|
type: 'string',
|
|
167
|
-
default: '
|
|
168
|
-
description: 'Command to execute. Use placeholders: "{text}"
|
|
215
|
+
default: 'echo "Custom command here" > "{output_file}"',
|
|
216
|
+
description: 'Command to execute. Use placeholders: "{text}", "{output_file}".',
|
|
169
217
|
displayOptions: {
|
|
170
218
|
show: {
|
|
171
219
|
engine: ['system'],
|
|
@@ -197,6 +245,39 @@ export class TTSBigBoss implements INodeType {
|
|
|
197
245
|
},
|
|
198
246
|
description: 'Binary property name containing the reference audio for cloning. Use placeholder "{reference_audio}" in command.',
|
|
199
247
|
},
|
|
248
|
+
// ----------------------------------
|
|
249
|
+
// Local Piper Settings
|
|
250
|
+
// ----------------------------------
|
|
251
|
+
{
|
|
252
|
+
displayName: 'Piper Voice Model',
|
|
253
|
+
name: 'piperModel',
|
|
254
|
+
type: 'options',
|
|
255
|
+
options: [
|
|
256
|
+
...PIPER_MODELS,
|
|
257
|
+
{ name: 'Custom (Enter URL)', value: 'custom' },
|
|
258
|
+
],
|
|
259
|
+
default: 'en_US-lessac-medium',
|
|
260
|
+
displayOptions: {
|
|
261
|
+
show: {
|
|
262
|
+
engine: ['piper_local'],
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
description: 'Select a voice model. It will be downloaded automatically on first use.',
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
displayName: 'Custom Model Name/URL',
|
|
269
|
+
name: 'piperModelCustom',
|
|
270
|
+
type: 'string',
|
|
271
|
+
default: '',
|
|
272
|
+
placeholder: 'e.g. en_US-bryce-medium',
|
|
273
|
+
displayOptions: {
|
|
274
|
+
show: {
|
|
275
|
+
engine: ['piper_local'],
|
|
276
|
+
piperModel: ['custom'],
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
description: 'Name from Hugging Face (e.g. en_US-bryce-medium) or full URL to .onnx file.',
|
|
280
|
+
},
|
|
200
281
|
],
|
|
201
282
|
};
|
|
202
283
|
|
|
@@ -205,6 +286,12 @@ export class TTSBigBoss implements INodeType {
|
|
|
205
286
|
const returnData: INodeExecutionData[] = [];
|
|
206
287
|
const tempDir = os.tmpdir();
|
|
207
288
|
|
|
289
|
+
// Ensure bin directory exists for local tools
|
|
290
|
+
const binDir = path.join(path.dirname(__dirname), 'bin'); // ../bin from nodes/TTSBigBoss/
|
|
291
|
+
if (!fs.existsSync(binDir)) {
|
|
292
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
293
|
+
}
|
|
294
|
+
|
|
208
295
|
for (let i = 0; i < items.length; i++) {
|
|
209
296
|
try {
|
|
210
297
|
const engine = this.getNodeParameter('engine', i) as string;
|
|
@@ -228,7 +315,65 @@ export class TTSBigBoss implements INodeType {
|
|
|
228
315
|
|
|
229
316
|
const result = await runEdgeTTS(text, voice, rate, pitch);
|
|
230
317
|
audioBuffer = result.audio;
|
|
231
|
-
|
|
318
|
+
// Fallback if SRT empty
|
|
319
|
+
if (!result.srt || result.srt.trim().length === 0) {
|
|
320
|
+
srtBuffer = Buffer.from(generateHeuristicSRT(text, result.audio.length), 'utf8');
|
|
321
|
+
} else {
|
|
322
|
+
srtBuffer = Buffer.from(result.srt, 'utf8');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
} else if (engine === 'piper_local') {
|
|
326
|
+
// ----------------------------------
|
|
327
|
+
// PIPER LOCAL AUTOMATION
|
|
328
|
+
// ----------------------------------
|
|
329
|
+
let piperModel = this.getNodeParameter('piperModel', i) as string;
|
|
330
|
+
if (piperModel === 'custom') {
|
|
331
|
+
piperModel = this.getNodeParameter('piperModelCustom', i) as string;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 1. Ensure Piper Binary
|
|
335
|
+
const piperBinPath = await ensurePiperBinary(binDir);
|
|
336
|
+
|
|
337
|
+
// 2. Ensure Voice Model
|
|
338
|
+
const { modelPath, configPath } = await ensurePiperModel(binDir, piperModel);
|
|
339
|
+
|
|
340
|
+
// 3. Execute
|
|
341
|
+
const outFile = path.join(tempDir, `piper_out_${uuidv4()}.wav`);
|
|
342
|
+
// Piper command: echo "text" | piper --model model.onnx --output_file out.wav
|
|
343
|
+
// We use child_process.spawn to pipe text safely
|
|
344
|
+
|
|
345
|
+
await new Promise<void>((resolve, reject) => {
|
|
346
|
+
const piperProc = child_process.spawn(piperBinPath, [
|
|
347
|
+
'--model', modelPath,
|
|
348
|
+
'--config', configPath,
|
|
349
|
+
'--output_file', outFile
|
|
350
|
+
]);
|
|
351
|
+
|
|
352
|
+
piperProc.stdin.write(text);
|
|
353
|
+
piperProc.stdin.end();
|
|
354
|
+
|
|
355
|
+
let errData = '';
|
|
356
|
+
piperProc.stderr.on('data', (d) => errData += d.toString());
|
|
357
|
+
|
|
358
|
+
piperProc.on('close', (code) => {
|
|
359
|
+
if (code === 0) resolve();
|
|
360
|
+
// Check for the specific JSON error in stderr
|
|
361
|
+
if (errData.includes('json.exception.parse_error')) {
|
|
362
|
+
reject(new Error(`Piper Config Error: The downloaded JSON configuration for model '${piperModel}' seems corrupted (HTML instead of JSON?). Try deleting the file at ${configPath} and running again.`));
|
|
363
|
+
} else {
|
|
364
|
+
reject(new Error(`Piper failed (exit ${code}): ${errData}`));
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
piperProc.on('error', (err) => reject(err));
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
if (!fs.existsSync(outFile)) throw new Error('Piper did not produce output file');
|
|
372
|
+
|
|
373
|
+
audioBuffer = fs.readFileSync(outFile);
|
|
374
|
+
srtBuffer = Buffer.from(generateHeuristicSRT(text, audioBuffer.length), 'utf8');
|
|
375
|
+
|
|
376
|
+
fs.unlinkSync(outFile);
|
|
232
377
|
|
|
233
378
|
} else {
|
|
234
379
|
// ----------------------------------
|
|
@@ -353,47 +498,45 @@ async function runEdgeTTS(text: string, voice: string, rate: string, pitch: stri
|
|
|
353
498
|
});
|
|
354
499
|
|
|
355
500
|
ws.on('message', (data: Buffer, isBinary: boolean) => {
|
|
501
|
+
// Try to interpret as text first
|
|
356
502
|
const textData = data.toString();
|
|
357
503
|
|
|
358
|
-
if (textData.includes('Path:
|
|
359
|
-
// Start of turn
|
|
360
|
-
} else if (textData.includes('Path:turn.end')) {
|
|
361
|
-
// End of turn - Finish
|
|
362
|
-
ws.close();
|
|
363
|
-
const fullAudio = Buffer.concat(audioChunks);
|
|
364
|
-
const srt = buildSRT(wordBoundaries);
|
|
365
|
-
resolve({ audio: fullAudio, srt });
|
|
366
|
-
} else if (textData.includes('Path:audio.metadata')) {
|
|
504
|
+
if (textData.includes('Path:audio.metadata')) {
|
|
367
505
|
// Parse Metadata (Word Boundaries)
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
506
|
+
// Markers might be interleaved with header, need robust split
|
|
507
|
+
const parts = textData.split(/\r\n\r\n/);
|
|
508
|
+
// iterate to find JSON
|
|
509
|
+
for (const part of parts) {
|
|
510
|
+
try {
|
|
511
|
+
// Look for JSON object start
|
|
512
|
+
if (part.trim().startsWith('{')) {
|
|
513
|
+
const json = JSON.parse(part);
|
|
514
|
+
if (json.Metadata && Array.isArray(json.Metadata)) {
|
|
515
|
+
for (const meta of json.Metadata) {
|
|
516
|
+
if (meta.Type === 'Word') {
|
|
517
|
+
wordBoundaries.push({
|
|
518
|
+
offset: meta.Offset, // 100ns units
|
|
519
|
+
duration: meta.Duration,
|
|
520
|
+
text: meta.Text,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
380
523
|
}
|
|
381
524
|
}
|
|
382
525
|
}
|
|
383
|
-
}
|
|
384
|
-
} catch (e) {
|
|
385
|
-
// Ignore parse errors
|
|
526
|
+
} catch (e) { /* ignore non-json parts */ }
|
|
386
527
|
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (textData.includes('Path:turn.end')) {
|
|
531
|
+
// End of turn - Finish
|
|
532
|
+
ws.close();
|
|
533
|
+
const fullAudio = Buffer.concat(audioChunks);
|
|
534
|
+
const srt = buildSRT(wordBoundaries);
|
|
535
|
+
resolve({ audio: fullAudio, srt });
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (isBinary || (data.length > 2 && data[0] === 0x00 && data[1] === 0x67)) {
|
|
539
|
+
// Binary Audio Data (header 2 bytes length + content)
|
|
397
540
|
const headerLen = data.readUInt16BE(0);
|
|
398
541
|
if (data.length > headerLen + 2) {
|
|
399
542
|
const audioData = data.slice(headerLen + 2);
|
|
@@ -409,6 +552,7 @@ async function runEdgeTTS(text: string, voice: string, rate: string, pitch: stri
|
|
|
409
552
|
}
|
|
410
553
|
|
|
411
554
|
function buildSRT(words: WordBoundary[]): string {
|
|
555
|
+
if (!words || words.length === 0) return '';
|
|
412
556
|
let srt = '';
|
|
413
557
|
let counter = 1;
|
|
414
558
|
// Group words into reasonable chunks if they are close?
|
|
@@ -503,3 +647,142 @@ function generateHeuristicSRT(text: string, byteLength: number): string {
|
|
|
503
647
|
|
|
504
648
|
return srt;
|
|
505
649
|
}
|
|
650
|
+
|
|
651
|
+
// --------------------------------------------------------------------------
|
|
652
|
+
// PIPER DOWNLOADER HELPERS
|
|
653
|
+
// --------------------------------------------------------------------------
|
|
654
|
+
async function ensurePiperBinary(binDir: string): Promise<string> {
|
|
655
|
+
// 1. Check Platform
|
|
656
|
+
const platform = os.platform(); // linux, darwin, win32
|
|
657
|
+
const arch = os.arch(); // x64, arm64
|
|
658
|
+
|
|
659
|
+
let binaryName = 'piper';
|
|
660
|
+
if (platform === 'win32') binaryName = 'piper.exe';
|
|
661
|
+
|
|
662
|
+
const finalPath = path.join(binDir, binaryName);
|
|
663
|
+
if (fs.existsSync(finalPath)) return finalPath;
|
|
664
|
+
|
|
665
|
+
// 2. Determine Download URL (Latest release 2023.11.14-2)
|
|
666
|
+
// https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz
|
|
667
|
+
// https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_windows_amd64.zip
|
|
668
|
+
|
|
669
|
+
let url = '';
|
|
670
|
+
let archiveName = '';
|
|
671
|
+
|
|
672
|
+
if (platform === 'linux' && arch === 'x64') {
|
|
673
|
+
url = 'https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz';
|
|
674
|
+
archiveName = 'piper.tar.gz';
|
|
675
|
+
} else if (platform === 'win32' && arch === 'x64') {
|
|
676
|
+
url = 'https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_windows_amd64.zip';
|
|
677
|
+
archiveName = 'piper.zip';
|
|
678
|
+
} else {
|
|
679
|
+
throw new Error(`Auto-download for Piper not supported on ${platform} ${arch}. Please install manually.`);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const archivePath = path.join(binDir, archiveName);
|
|
683
|
+
|
|
684
|
+
// Download
|
|
685
|
+
console.log(`Downloading Piper from ${url}...`);
|
|
686
|
+
await downloadFile(url, archivePath);
|
|
687
|
+
|
|
688
|
+
// Extract (Simple approach: use system tar/unzip if available)
|
|
689
|
+
// Since user environment is likely Linux (n8n docker) or Windows.
|
|
690
|
+
try {
|
|
691
|
+
if (platform === 'win32') {
|
|
692
|
+
child_process.execSync(`powershell -command "Expand-Archive -Path '${archivePath}' -DestinationPath '${binDir}' -Force"`);
|
|
693
|
+
const extractedFolder = path.join(binDir, 'piper'); // Zip contains 'piper' folder
|
|
694
|
+
// Move piper.exe to binDir root or return piper/piper.exe
|
|
695
|
+
// The zip contains a 'piper' folder.
|
|
696
|
+
return path.join(binDir, 'piper', 'piper.exe');
|
|
697
|
+
} else {
|
|
698
|
+
child_process.execSync(`tar -xzf "${archivePath}" -C "${binDir}"`);
|
|
699
|
+
return path.join(binDir, 'piper', 'piper'); // Tar contains 'piper' folder
|
|
700
|
+
}
|
|
701
|
+
} catch (e) {
|
|
702
|
+
throw new Error(`Failed to extract Piper: ${e.message}`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function ensurePiperModel(binDir: string, modelNameOrUrl: string): Promise<{ modelPath: string, configPath: string }> {
|
|
707
|
+
// Heuristic for HF URL construction
|
|
708
|
+
// https://huggingface.co/rhasspy/piper-voices/resolve/main/[lang_code]/[region]/[voice]/[quality]/[filename]
|
|
709
|
+
// Example: en_US-lessac-medium -> en/en_US/lessac/medium/en_US-lessac-medium.onnx
|
|
710
|
+
|
|
711
|
+
let modelUrl = '';
|
|
712
|
+
let modelFilename = '';
|
|
713
|
+
|
|
714
|
+
if (modelNameOrUrl.startsWith('http')) {
|
|
715
|
+
modelUrl = modelNameOrUrl;
|
|
716
|
+
modelFilename = path.basename(modelNameOrUrl);
|
|
717
|
+
} else {
|
|
718
|
+
// Construct URL from name
|
|
719
|
+
const parts = modelNameOrUrl.split('-');
|
|
720
|
+
if (parts.length >= 3) {
|
|
721
|
+
const langRegion = parts[0] + '_' + parts[1]; // en_US
|
|
722
|
+
const voice = parts[2];
|
|
723
|
+
const quality = parts[3] || 'medium';
|
|
724
|
+
const lang = parts[0]; // en
|
|
725
|
+
|
|
726
|
+
// e.g. en_US-lessac-medium
|
|
727
|
+
// lang=en, region=en_US, voice=lessac, quality=medium
|
|
728
|
+
// url path: en/en_US/lessac/medium/en_US-lessac-medium.onnx
|
|
729
|
+
|
|
730
|
+
// Handle special case: ar_JO (no lang folder? check repo)
|
|
731
|
+
// Generally structure is: lang_short/lang_long/voice/quality/filename
|
|
732
|
+
|
|
733
|
+
modelFilename = modelNameOrUrl + '.onnx';
|
|
734
|
+
modelUrl = `https://huggingface.co/rhasspy/piper-voices/resolve/main/${lang}/${langRegion}/${voice}/${quality}/${modelFilename}?download=true`; // Add download=true to force direct link
|
|
735
|
+
} else {
|
|
736
|
+
throw new Error(`Invalid model name format: ${modelNameOrUrl}. Use format lang_REGION-voice-quality`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const modelPath = path.join(binDir, modelFilename);
|
|
741
|
+
const configPath = modelPath + '.json';
|
|
742
|
+
|
|
743
|
+
// Download if missing or seems incomplete (size < 1KB)
|
|
744
|
+
if (!fs.existsSync(modelPath) || fs.statSync(modelPath).size < 1000) {
|
|
745
|
+
console.log(`Downloading Piper Model: ${modelUrl}`);
|
|
746
|
+
await downloadFile(modelUrl, modelPath);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Download config if missing or seems incomplete (size < 10 bytes)
|
|
750
|
+
if (!fs.existsSync(configPath) || fs.statSync(configPath).size < 10) {
|
|
751
|
+
const configUrl = modelUrl + '.json';
|
|
752
|
+
console.log(`Downloading Piper Config: ${configUrl}`);
|
|
753
|
+
await downloadFile(configUrl, configPath);
|
|
754
|
+
|
|
755
|
+
// Validate JSON
|
|
756
|
+
try {
|
|
757
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
758
|
+
JSON.parse(content);
|
|
759
|
+
} catch (e) {
|
|
760
|
+
fs.unlinkSync(configPath); // Delete bad file
|
|
761
|
+
if (fs.existsSync(modelPath)) fs.unlinkSync(modelPath); // Delete model too as it might be bad
|
|
762
|
+
throw new Error(`Downloaded config for ${modelNameOrUrl} was not valid JSON. URL might be wrong: ${configUrl}. Content start: ${fs.readFileSync(configPath, 'utf8').substring(0, 50)}...`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return { modelPath, configPath };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function downloadFile(url: string, dest: string): Promise<void> {
|
|
770
|
+
return new Promise((resolve, reject) => {
|
|
771
|
+
const file = fs.createWriteStream(dest);
|
|
772
|
+
https.get(url, (response) => {
|
|
773
|
+
if (response.statusCode === 302 || response.statusCode === 301) {
|
|
774
|
+
// Follow redirect
|
|
775
|
+
downloadFile(response.headers.location!, dest).then(resolve).catch(reject);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
response.pipe(file);
|
|
779
|
+
file.on('finish', () => {
|
|
780
|
+
file.close();
|
|
781
|
+
resolve();
|
|
782
|
+
});
|
|
783
|
+
}).on('error', (err) => {
|
|
784
|
+
fs.unlink(dest, () => { });
|
|
785
|
+
reject(err);
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
}
|