n8n-nodes-tts-bigboss 1.0.0 → 1.0.2
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 +175 -30
- package/nodes/TTSBigBoss/TTSBigBoss.node.ts +236 -40
- package/package.json +2 -2
package/dist/TTSBigBoss.node.js
CHANGED
|
@@ -43,6 +43,10 @@ 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);
|
|
46
50
|
const EDGE_URL = 'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4';
|
|
47
51
|
const EDGE_VOICES = [
|
|
48
52
|
{ name: 'Arabic (Egypt) - Salma', value: 'ar-EG-SalmaNeural' },
|
|
@@ -71,7 +75,7 @@ class TTSBigBoss {
|
|
|
71
75
|
icon: 'fa:comment-dots',
|
|
72
76
|
group: ['transform'],
|
|
73
77
|
version: 1,
|
|
74
|
-
description: 'Advanced Text-to-Speech (Edge-TTS &
|
|
78
|
+
description: 'Advanced Text-to-Speech (Edge-TTS & Local Standalone)',
|
|
75
79
|
defaults: {
|
|
76
80
|
name: 'TTS BigBoss',
|
|
77
81
|
},
|
|
@@ -89,9 +93,14 @@ class TTSBigBoss {
|
|
|
89
93
|
description: 'High quality, multilingual, supports Arabic & perfect subtitles. Requires internet.',
|
|
90
94
|
},
|
|
91
95
|
{
|
|
92
|
-
name: '
|
|
96
|
+
name: 'Local Piper (Auto-Download)',
|
|
97
|
+
value: 'piper_local',
|
|
98
|
+
description: 'Downloads and runs Piper locally (Offline). Good quality, fast.',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'System Command (Custom)',
|
|
93
102
|
value: 'system',
|
|
94
|
-
description: 'Use
|
|
103
|
+
description: 'Use custom command for installed tools (XTTS, etc).',
|
|
95
104
|
},
|
|
96
105
|
],
|
|
97
106
|
default: 'edge',
|
|
@@ -174,8 +183,8 @@ class TTSBigBoss {
|
|
|
174
183
|
displayName: 'Command',
|
|
175
184
|
name: 'systemCommand',
|
|
176
185
|
type: 'string',
|
|
177
|
-
default: '
|
|
178
|
-
description: 'Command to execute. Use placeholders: "{text}"
|
|
186
|
+
default: 'echo "Custom command here" > "{output_file}"',
|
|
187
|
+
description: 'Command to execute. Use placeholders: "{text}", "{output_file}".',
|
|
179
188
|
displayOptions: {
|
|
180
189
|
show: {
|
|
181
190
|
engine: ['system'],
|
|
@@ -207,6 +216,18 @@ class TTSBigBoss {
|
|
|
207
216
|
},
|
|
208
217
|
description: 'Binary property name containing the reference audio for cloning. Use placeholder "{reference_audio}" in command.',
|
|
209
218
|
},
|
|
219
|
+
{
|
|
220
|
+
displayName: 'Piper Voice Model (HF URL or Path)',
|
|
221
|
+
name: 'piperModel',
|
|
222
|
+
type: 'string',
|
|
223
|
+
default: 'en_US-lessac-medium',
|
|
224
|
+
description: 'Enter a known Piper model name (e.g. "en_US-lessac-medium") which will be auto-downloaded, OR a full URL to the .onnx file.',
|
|
225
|
+
displayOptions: {
|
|
226
|
+
show: {
|
|
227
|
+
engine: ['piper_local'],
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
}
|
|
210
231
|
],
|
|
211
232
|
};
|
|
212
233
|
}
|
|
@@ -214,6 +235,10 @@ class TTSBigBoss {
|
|
|
214
235
|
const items = this.getInputData();
|
|
215
236
|
const returnData = [];
|
|
216
237
|
const tempDir = os.tmpdir();
|
|
238
|
+
const binDir = path.join(path.dirname(__dirname), 'bin');
|
|
239
|
+
if (!fs.existsSync(binDir)) {
|
|
240
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
241
|
+
}
|
|
217
242
|
for (let i = 0; i < items.length; i++) {
|
|
218
243
|
try {
|
|
219
244
|
const engine = this.getNodeParameter('engine', i);
|
|
@@ -231,7 +256,41 @@ class TTSBigBoss {
|
|
|
231
256
|
const pitch = this.getNodeParameter('edgePitch', i);
|
|
232
257
|
const result = await runEdgeTTS(text, voice, rate, pitch);
|
|
233
258
|
audioBuffer = result.audio;
|
|
234
|
-
|
|
259
|
+
if (!result.srt || result.srt.trim().length === 0) {
|
|
260
|
+
srtBuffer = Buffer.from(generateHeuristicSRT(text, result.audio.length), 'utf8');
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
srtBuffer = Buffer.from(result.srt, 'utf8');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else if (engine === 'piper_local') {
|
|
267
|
+
const piperModel = this.getNodeParameter('piperModel', i);
|
|
268
|
+
const piperBinPath = await ensurePiperBinary(binDir);
|
|
269
|
+
const { modelPath, configPath } = await ensurePiperModel(binDir, piperModel);
|
|
270
|
+
const outFile = path.join(tempDir, `piper_out_${(0, uuid_1.v4)()}.wav`);
|
|
271
|
+
await new Promise((resolve, reject) => {
|
|
272
|
+
const piperProc = child_process.spawn(piperBinPath, [
|
|
273
|
+
'--model', modelPath,
|
|
274
|
+
'--config', configPath,
|
|
275
|
+
'--output_file', outFile
|
|
276
|
+
]);
|
|
277
|
+
piperProc.stdin.write(text);
|
|
278
|
+
piperProc.stdin.end();
|
|
279
|
+
let errData = '';
|
|
280
|
+
piperProc.stderr.on('data', (d) => errData += d.toString());
|
|
281
|
+
piperProc.on('close', (code) => {
|
|
282
|
+
if (code === 0)
|
|
283
|
+
resolve();
|
|
284
|
+
else
|
|
285
|
+
reject(new Error(`Piper failed (exit ${code}): ${errData}`));
|
|
286
|
+
});
|
|
287
|
+
piperProc.on('error', (err) => reject(err));
|
|
288
|
+
});
|
|
289
|
+
if (!fs.existsSync(outFile))
|
|
290
|
+
throw new Error('Piper did not produce output file');
|
|
291
|
+
audioBuffer = fs.readFileSync(outFile);
|
|
292
|
+
srtBuffer = Buffer.from(generateHeuristicSRT(text, audioBuffer.length), 'utf8');
|
|
293
|
+
fs.unlinkSync(outFile);
|
|
235
294
|
}
|
|
236
295
|
else {
|
|
237
296
|
const commandTpl = this.getNodeParameter('systemCommand', i);
|
|
@@ -320,36 +379,35 @@ async function runEdgeTTS(text, voice, rate, pitch) {
|
|
|
320
379
|
});
|
|
321
380
|
ws.on('message', (data, isBinary) => {
|
|
322
381
|
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
|
-
});
|
|
382
|
+
if (textData.includes('Path:audio.metadata')) {
|
|
383
|
+
const parts = textData.split(/\r\n\r\n/);
|
|
384
|
+
for (const part of parts) {
|
|
385
|
+
try {
|
|
386
|
+
if (part.trim().startsWith('{')) {
|
|
387
|
+
const json = JSON.parse(part);
|
|
388
|
+
if (json.Metadata && Array.isArray(json.Metadata)) {
|
|
389
|
+
for (const meta of json.Metadata) {
|
|
390
|
+
if (meta.Type === 'Word') {
|
|
391
|
+
wordBoundaries.push({
|
|
392
|
+
offset: meta.Offset,
|
|
393
|
+
duration: meta.Duration,
|
|
394
|
+
text: meta.Text,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
344
397
|
}
|
|
345
398
|
}
|
|
346
399
|
}
|
|
347
400
|
}
|
|
348
|
-
|
|
349
|
-
catch (e) {
|
|
401
|
+
catch (e) { }
|
|
350
402
|
}
|
|
351
403
|
}
|
|
352
|
-
|
|
404
|
+
if (textData.includes('Path:turn.end')) {
|
|
405
|
+
ws.close();
|
|
406
|
+
const fullAudio = Buffer.concat(audioChunks);
|
|
407
|
+
const srt = buildSRT(wordBoundaries);
|
|
408
|
+
resolve({ audio: fullAudio, srt });
|
|
409
|
+
}
|
|
410
|
+
if (isBinary || (data.length > 2 && data[0] === 0x00 && data[1] === 0x67)) {
|
|
353
411
|
const headerLen = data.readUInt16BE(0);
|
|
354
412
|
if (data.length > headerLen + 2) {
|
|
355
413
|
const audioData = data.slice(headerLen + 2);
|
|
@@ -363,6 +421,8 @@ async function runEdgeTTS(text, voice, rate, pitch) {
|
|
|
363
421
|
});
|
|
364
422
|
}
|
|
365
423
|
function buildSRT(words) {
|
|
424
|
+
if (!words || words.length === 0)
|
|
425
|
+
return '';
|
|
366
426
|
let srt = '';
|
|
367
427
|
let counter = 1;
|
|
368
428
|
let currentPhrase = [];
|
|
@@ -421,3 +481,88 @@ function generateHeuristicSRT(text, byteLength) {
|
|
|
421
481
|
}
|
|
422
482
|
return srt;
|
|
423
483
|
}
|
|
484
|
+
async function ensurePiperBinary(binDir) {
|
|
485
|
+
const platform = os.platform();
|
|
486
|
+
const arch = os.arch();
|
|
487
|
+
let binaryName = 'piper';
|
|
488
|
+
if (platform === 'win32')
|
|
489
|
+
binaryName = 'piper.exe';
|
|
490
|
+
const finalPath = path.join(binDir, binaryName);
|
|
491
|
+
if (fs.existsSync(finalPath))
|
|
492
|
+
return finalPath;
|
|
493
|
+
let url = '';
|
|
494
|
+
let archiveName = '';
|
|
495
|
+
if (platform === 'linux' && arch === 'x64') {
|
|
496
|
+
url = 'https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz';
|
|
497
|
+
archiveName = 'piper.tar.gz';
|
|
498
|
+
}
|
|
499
|
+
else if (platform === 'win32' && arch === 'x64') {
|
|
500
|
+
url = 'https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_windows_amd64.zip';
|
|
501
|
+
archiveName = 'piper.zip';
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
throw new Error(`Auto-download for Piper not supported on ${platform} ${arch}. Please install manually.`);
|
|
505
|
+
}
|
|
506
|
+
const archivePath = path.join(binDir, archiveName);
|
|
507
|
+
console.log(`Downloading Piper from ${url}...`);
|
|
508
|
+
await downloadFile(url, archivePath);
|
|
509
|
+
try {
|
|
510
|
+
if (platform === 'win32') {
|
|
511
|
+
child_process.execSync(`powershell -command "Expand-Archive -Path '${archivePath}' -DestinationPath '${binDir}' -Force"`);
|
|
512
|
+
const extractedFolder = path.join(binDir, 'piper');
|
|
513
|
+
return path.join(binDir, 'piper', 'piper.exe');
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
child_process.execSync(`tar -xzf "${archivePath}" -C "${binDir}"`);
|
|
517
|
+
return path.join(binDir, 'piper', 'piper');
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
catch (e) {
|
|
521
|
+
throw new Error(`Failed to extract Piper: ${e.message}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
async function ensurePiperModel(binDir, modelNameOrUrl) {
|
|
525
|
+
if (modelNameOrUrl.startsWith('http')) {
|
|
526
|
+
const fileName = path.basename(modelNameOrUrl);
|
|
527
|
+
const modelPath = path.join(binDir, fileName);
|
|
528
|
+
if (!fs.existsSync(modelPath)) {
|
|
529
|
+
await downloadFile(modelNameOrUrl, modelPath);
|
|
530
|
+
}
|
|
531
|
+
const configUrl = modelNameOrUrl + '.json';
|
|
532
|
+
const configPath = modelPath + '.json';
|
|
533
|
+
if (!fs.existsSync(configPath)) {
|
|
534
|
+
await downloadFile(configUrl, configPath);
|
|
535
|
+
}
|
|
536
|
+
return { modelPath, configPath };
|
|
537
|
+
}
|
|
538
|
+
if (modelNameOrUrl === 'en_US-lessac-medium') {
|
|
539
|
+
const url = 'https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx';
|
|
540
|
+
const modelPath = path.join(binDir, 'en_US-lessac-medium.onnx');
|
|
541
|
+
const configPath = path.join(binDir, 'en_US-lessac-medium.onnx.json');
|
|
542
|
+
if (!fs.existsSync(modelPath))
|
|
543
|
+
await downloadFile(url, modelPath);
|
|
544
|
+
if (!fs.existsSync(configPath))
|
|
545
|
+
await downloadFile(url + '.json', configPath);
|
|
546
|
+
return { modelPath, configPath };
|
|
547
|
+
}
|
|
548
|
+
return { modelPath: modelNameOrUrl, configPath: modelNameOrUrl + '.json' };
|
|
549
|
+
}
|
|
550
|
+
async function downloadFile(url, dest) {
|
|
551
|
+
return new Promise((resolve, reject) => {
|
|
552
|
+
const file = fs.createWriteStream(dest);
|
|
553
|
+
https.get(url, (response) => {
|
|
554
|
+
if (response.statusCode === 302 || response.statusCode === 301) {
|
|
555
|
+
downloadFile(response.headers.location, dest).then(resolve).catch(reject);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
response.pipe(file);
|
|
559
|
+
file.on('finish', () => {
|
|
560
|
+
file.close();
|
|
561
|
+
resolve();
|
|
562
|
+
});
|
|
563
|
+
}).on('error', (err) => {
|
|
564
|
+
fs.unlink(dest, () => { });
|
|
565
|
+
reject(err);
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
}
|
|
@@ -10,6 +10,12 @@ 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);
|
|
13
19
|
|
|
14
20
|
// Edge TTS Constants
|
|
15
21
|
const EDGE_URL = 'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4';
|
|
@@ -52,7 +58,7 @@ export class TTSBigBoss implements INodeType {
|
|
|
52
58
|
icon: 'fa:comment-dots',
|
|
53
59
|
group: ['transform'],
|
|
54
60
|
version: 1,
|
|
55
|
-
description: 'Advanced Text-to-Speech (Edge-TTS &
|
|
61
|
+
description: 'Advanced Text-to-Speech (Edge-TTS & Local Standalone)',
|
|
56
62
|
defaults: {
|
|
57
63
|
name: 'TTS BigBoss',
|
|
58
64
|
},
|
|
@@ -70,9 +76,14 @@ export class TTSBigBoss implements INodeType {
|
|
|
70
76
|
description: 'High quality, multilingual, supports Arabic & perfect subtitles. Requires internet.',
|
|
71
77
|
},
|
|
72
78
|
{
|
|
73
|
-
name: '
|
|
79
|
+
name: 'Local Piper (Auto-Download)',
|
|
80
|
+
value: 'piper_local',
|
|
81
|
+
description: 'Downloads and runs Piper locally (Offline). Good quality, fast.',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'System Command (Custom)',
|
|
74
85
|
value: 'system',
|
|
75
|
-
description: 'Use
|
|
86
|
+
description: 'Use custom command for installed tools (XTTS, etc).',
|
|
76
87
|
},
|
|
77
88
|
],
|
|
78
89
|
default: 'edge',
|
|
@@ -164,8 +175,8 @@ export class TTSBigBoss implements INodeType {
|
|
|
164
175
|
displayName: 'Command',
|
|
165
176
|
name: 'systemCommand',
|
|
166
177
|
type: 'string',
|
|
167
|
-
default: '
|
|
168
|
-
description: 'Command to execute. Use placeholders: "{text}"
|
|
178
|
+
default: 'echo "Custom command here" > "{output_file}"',
|
|
179
|
+
description: 'Command to execute. Use placeholders: "{text}", "{output_file}".',
|
|
169
180
|
displayOptions: {
|
|
170
181
|
show: {
|
|
171
182
|
engine: ['system'],
|
|
@@ -197,6 +208,21 @@ export class TTSBigBoss implements INodeType {
|
|
|
197
208
|
},
|
|
198
209
|
description: 'Binary property name containing the reference audio for cloning. Use placeholder "{reference_audio}" in command.',
|
|
199
210
|
},
|
|
211
|
+
// ----------------------------------
|
|
212
|
+
// Local Piper Settings
|
|
213
|
+
// ----------------------------------
|
|
214
|
+
{
|
|
215
|
+
displayName: 'Piper Voice Model (HF URL or Path)',
|
|
216
|
+
name: 'piperModel',
|
|
217
|
+
type: 'string',
|
|
218
|
+
default: 'en_US-lessac-medium',
|
|
219
|
+
description: 'Enter a known Piper model name (e.g. "en_US-lessac-medium") which will be auto-downloaded, OR a full URL to the .onnx file.',
|
|
220
|
+
displayOptions: {
|
|
221
|
+
show: {
|
|
222
|
+
engine: ['piper_local'],
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
}
|
|
200
226
|
],
|
|
201
227
|
};
|
|
202
228
|
|
|
@@ -205,6 +231,12 @@ export class TTSBigBoss implements INodeType {
|
|
|
205
231
|
const returnData: INodeExecutionData[] = [];
|
|
206
232
|
const tempDir = os.tmpdir();
|
|
207
233
|
|
|
234
|
+
// Ensure bin directory exists for local tools
|
|
235
|
+
const binDir = path.join(path.dirname(__dirname), 'bin'); // ../bin from nodes/TTSBigBoss/
|
|
236
|
+
if (!fs.existsSync(binDir)) {
|
|
237
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
238
|
+
}
|
|
239
|
+
|
|
208
240
|
for (let i = 0; i < items.length; i++) {
|
|
209
241
|
try {
|
|
210
242
|
const engine = this.getNodeParameter('engine', i) as string;
|
|
@@ -228,7 +260,57 @@ export class TTSBigBoss implements INodeType {
|
|
|
228
260
|
|
|
229
261
|
const result = await runEdgeTTS(text, voice, rate, pitch);
|
|
230
262
|
audioBuffer = result.audio;
|
|
231
|
-
|
|
263
|
+
// Fallback if SRT empty
|
|
264
|
+
if (!result.srt || result.srt.trim().length === 0) {
|
|
265
|
+
srtBuffer = Buffer.from(generateHeuristicSRT(text, result.audio.length), 'utf8');
|
|
266
|
+
} else {
|
|
267
|
+
srtBuffer = Buffer.from(result.srt, 'utf8');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
} else if (engine === 'piper_local') {
|
|
271
|
+
// ----------------------------------
|
|
272
|
+
// PIPER LOCAL AUTOMATION
|
|
273
|
+
// ----------------------------------
|
|
274
|
+
const piperModel = this.getNodeParameter('piperModel', i) as string;
|
|
275
|
+
|
|
276
|
+
// 1. Ensure Piper Binary
|
|
277
|
+
const piperBinPath = await ensurePiperBinary(binDir);
|
|
278
|
+
|
|
279
|
+
// 2. Ensure Voice Model
|
|
280
|
+
const { modelPath, configPath } = await ensurePiperModel(binDir, piperModel);
|
|
281
|
+
|
|
282
|
+
// 3. Execute
|
|
283
|
+
const outFile = path.join(tempDir, `piper_out_${uuidv4()}.wav`);
|
|
284
|
+
// Piper command: echo "text" | piper --model model.onnx --output_file out.wav
|
|
285
|
+
// We use child_process.spawn to pipe text safely
|
|
286
|
+
|
|
287
|
+
await new Promise<void>((resolve, reject) => {
|
|
288
|
+
const piperProc = child_process.spawn(piperBinPath, [
|
|
289
|
+
'--model', modelPath,
|
|
290
|
+
'--config', configPath,
|
|
291
|
+
'--output_file', outFile
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
piperProc.stdin.write(text);
|
|
295
|
+
piperProc.stdin.end();
|
|
296
|
+
|
|
297
|
+
let errData = '';
|
|
298
|
+
piperProc.stderr.on('data', (d) => errData += d.toString());
|
|
299
|
+
|
|
300
|
+
piperProc.on('close', (code) => {
|
|
301
|
+
if (code === 0) resolve();
|
|
302
|
+
else reject(new Error(`Piper failed (exit ${code}): ${errData}`));
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
piperProc.on('error', (err) => reject(err));
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
if (!fs.existsSync(outFile)) throw new Error('Piper did not produce output file');
|
|
309
|
+
|
|
310
|
+
audioBuffer = fs.readFileSync(outFile);
|
|
311
|
+
srtBuffer = Buffer.from(generateHeuristicSRT(text, audioBuffer.length), 'utf8');
|
|
312
|
+
|
|
313
|
+
fs.unlinkSync(outFile);
|
|
232
314
|
|
|
233
315
|
} else {
|
|
234
316
|
// ----------------------------------
|
|
@@ -353,47 +435,45 @@ async function runEdgeTTS(text: string, voice: string, rate: string, pitch: stri
|
|
|
353
435
|
});
|
|
354
436
|
|
|
355
437
|
ws.on('message', (data: Buffer, isBinary: boolean) => {
|
|
438
|
+
// Try to interpret as text first
|
|
356
439
|
const textData = data.toString();
|
|
357
440
|
|
|
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')) {
|
|
441
|
+
if (textData.includes('Path:audio.metadata')) {
|
|
367
442
|
// Parse Metadata (Word Boundaries)
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
443
|
+
// Markers might be interleaved with header, need robust split
|
|
444
|
+
const parts = textData.split(/\r\n\r\n/);
|
|
445
|
+
// iterate to find JSON
|
|
446
|
+
for (const part of parts) {
|
|
447
|
+
try {
|
|
448
|
+
// Look for JSON object start
|
|
449
|
+
if (part.trim().startsWith('{')) {
|
|
450
|
+
const json = JSON.parse(part);
|
|
451
|
+
if (json.Metadata && Array.isArray(json.Metadata)) {
|
|
452
|
+
for (const meta of json.Metadata) {
|
|
453
|
+
if (meta.Type === 'Word') {
|
|
454
|
+
wordBoundaries.push({
|
|
455
|
+
offset: meta.Offset, // 100ns units
|
|
456
|
+
duration: meta.Duration,
|
|
457
|
+
text: meta.Text,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
380
460
|
}
|
|
381
461
|
}
|
|
382
462
|
}
|
|
383
|
-
}
|
|
384
|
-
} catch (e) {
|
|
385
|
-
// Ignore parse errors
|
|
463
|
+
} catch (e) { /* ignore non-json parts */ }
|
|
386
464
|
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (textData.includes('Path:turn.end')) {
|
|
468
|
+
// End of turn - Finish
|
|
469
|
+
ws.close();
|
|
470
|
+
const fullAudio = Buffer.concat(audioChunks);
|
|
471
|
+
const srt = buildSRT(wordBoundaries);
|
|
472
|
+
resolve({ audio: fullAudio, srt });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (isBinary || (data.length > 2 && data[0] === 0x00 && data[1] === 0x67)) {
|
|
476
|
+
// Binary Audio Data (header 2 bytes length + content)
|
|
397
477
|
const headerLen = data.readUInt16BE(0);
|
|
398
478
|
if (data.length > headerLen + 2) {
|
|
399
479
|
const audioData = data.slice(headerLen + 2);
|
|
@@ -409,6 +489,7 @@ async function runEdgeTTS(text: string, voice: string, rate: string, pitch: stri
|
|
|
409
489
|
}
|
|
410
490
|
|
|
411
491
|
function buildSRT(words: WordBoundary[]): string {
|
|
492
|
+
if (!words || words.length === 0) return '';
|
|
412
493
|
let srt = '';
|
|
413
494
|
let counter = 1;
|
|
414
495
|
// Group words into reasonable chunks if they are close?
|
|
@@ -503,3 +584,118 @@ function generateHeuristicSRT(text: string, byteLength: number): string {
|
|
|
503
584
|
|
|
504
585
|
return srt;
|
|
505
586
|
}
|
|
587
|
+
|
|
588
|
+
// --------------------------------------------------------------------------
|
|
589
|
+
// PIPER DOWNLOADER HELPERS
|
|
590
|
+
// --------------------------------------------------------------------------
|
|
591
|
+
async function ensurePiperBinary(binDir: string): Promise<string> {
|
|
592
|
+
// 1. Check Platform
|
|
593
|
+
const platform = os.platform(); // linux, darwin, win32
|
|
594
|
+
const arch = os.arch(); // x64, arm64
|
|
595
|
+
|
|
596
|
+
let binaryName = 'piper';
|
|
597
|
+
if (platform === 'win32') binaryName = 'piper.exe';
|
|
598
|
+
|
|
599
|
+
const finalPath = path.join(binDir, binaryName);
|
|
600
|
+
if (fs.existsSync(finalPath)) return finalPath;
|
|
601
|
+
|
|
602
|
+
// 2. Determine Download URL (Latest release 2023.11.14-2)
|
|
603
|
+
// https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz
|
|
604
|
+
// https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_windows_amd64.zip
|
|
605
|
+
|
|
606
|
+
let url = '';
|
|
607
|
+
let archiveName = '';
|
|
608
|
+
|
|
609
|
+
if (platform === 'linux' && arch === 'x64') {
|
|
610
|
+
url = 'https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz';
|
|
611
|
+
archiveName = 'piper.tar.gz';
|
|
612
|
+
} else if (platform === 'win32' && arch === 'x64') {
|
|
613
|
+
url = 'https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_windows_amd64.zip';
|
|
614
|
+
archiveName = 'piper.zip';
|
|
615
|
+
} else {
|
|
616
|
+
throw new Error(`Auto-download for Piper not supported on ${platform} ${arch}. Please install manually.`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const archivePath = path.join(binDir, archiveName);
|
|
620
|
+
|
|
621
|
+
// Download
|
|
622
|
+
console.log(`Downloading Piper from ${url}...`);
|
|
623
|
+
await downloadFile(url, archivePath);
|
|
624
|
+
|
|
625
|
+
// Extract (Simple approach: use system tar/unzip if available)
|
|
626
|
+
// Since user environment is likely Linux (n8n docker) or Windows.
|
|
627
|
+
try {
|
|
628
|
+
if (platform === 'win32') {
|
|
629
|
+
child_process.execSync(`powershell -command "Expand-Archive -Path '${archivePath}' -DestinationPath '${binDir}' -Force"`);
|
|
630
|
+
const extractedFolder = path.join(binDir, 'piper'); // Zip contains 'piper' folder
|
|
631
|
+
// Move piper.exe to binDir root or return piper/piper.exe
|
|
632
|
+
// The zip contains a 'piper' folder.
|
|
633
|
+
return path.join(binDir, 'piper', 'piper.exe');
|
|
634
|
+
} else {
|
|
635
|
+
child_process.execSync(`tar -xzf "${archivePath}" -C "${binDir}"`);
|
|
636
|
+
return path.join(binDir, 'piper', 'piper'); // Tar contains 'piper' folder
|
|
637
|
+
}
|
|
638
|
+
} catch (e) {
|
|
639
|
+
throw new Error(`Failed to extract Piper: ${e.message}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async function ensurePiperModel(binDir: string, modelNameOrUrl: string): Promise<{ modelPath: string, configPath: string }> {
|
|
644
|
+
// If it's a known model name like "en_US-lessac-medium", construct URL
|
|
645
|
+
// https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx
|
|
646
|
+
// This is tricky because directory structure varies.
|
|
647
|
+
// We'll rely on a direct URL or a simplied map for BigBoss defaults.
|
|
648
|
+
|
|
649
|
+
// If full URL provided
|
|
650
|
+
if (modelNameOrUrl.startsWith('http')) {
|
|
651
|
+
const fileName = path.basename(modelNameOrUrl);
|
|
652
|
+
const modelPath = path.join(binDir, fileName);
|
|
653
|
+
if (!fs.existsSync(modelPath)) {
|
|
654
|
+
await downloadFile(modelNameOrUrl, modelPath);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const configUrl = modelNameOrUrl + '.json';
|
|
658
|
+
const configPath = modelPath + '.json';
|
|
659
|
+
if (!fs.existsSync(configPath)) {
|
|
660
|
+
await downloadFile(configUrl, configPath);
|
|
661
|
+
}
|
|
662
|
+
return { modelPath, configPath };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// If short name provided, try a heuristic for common English voice
|
|
666
|
+
// We only support auto-download for "en_US-lessac-medium" easily for now.
|
|
667
|
+
if (modelNameOrUrl === 'en_US-lessac-medium') {
|
|
668
|
+
const url = 'https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx';
|
|
669
|
+
const modelPath = path.join(binDir, 'en_US-lessac-medium.onnx');
|
|
670
|
+
const configPath = path.join(binDir, 'en_US-lessac-medium.onnx.json');
|
|
671
|
+
|
|
672
|
+
if (!fs.existsSync(modelPath)) await downloadFile(url, modelPath);
|
|
673
|
+
if (!fs.existsSync(configPath)) await downloadFile(url + '.json', configPath);
|
|
674
|
+
|
|
675
|
+
return { modelPath, configPath };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Assume local path if not url/special name
|
|
679
|
+
return { modelPath: modelNameOrUrl, configPath: modelNameOrUrl + '.json' };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function downloadFile(url: string, dest: string): Promise<void> {
|
|
683
|
+
return new Promise((resolve, reject) => {
|
|
684
|
+
const file = fs.createWriteStream(dest);
|
|
685
|
+
https.get(url, (response) => {
|
|
686
|
+
if (response.statusCode === 302 || response.statusCode === 301) {
|
|
687
|
+
// Follow redirect
|
|
688
|
+
downloadFile(response.headers.location!, dest).then(resolve).catch(reject);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
response.pipe(file);
|
|
692
|
+
file.on('finish', () => {
|
|
693
|
+
file.close();
|
|
694
|
+
resolve();
|
|
695
|
+
});
|
|
696
|
+
}).on('error', (err) => {
|
|
697
|
+
fs.unlink(dest, () => { });
|
|
698
|
+
reject(err);
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "n8n-nodes-tts-bigboss",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "BigBoss TTS node with multi-engine support and automatic SRT generation",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"n8n-community-node-package",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"n8n": {
|
|
25
25
|
"n8nNodesApiVersion": 1,
|
|
26
26
|
"nodes": [
|
|
27
|
-
"dist/
|
|
27
|
+
"dist/TTSBigBoss.node.js"
|
|
28
28
|
]
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|