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.
@@ -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 & System Clone)',
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: 'System Command (Local/Clone)',
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 locally installed tools (Piper, XTTS, Coqui) via command line.',
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: 'piper --model en_US-lessac-medium --output_file "{output_file}"',
178
- description: 'Command to execute. Use placeholders: "{text}" for input text, "{output_file}" for the temporary audio path.',
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
- srtBuffer = Buffer.from(result.srt, 'utf8');
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:turn.start')) {
324
- }
325
- else if (textData.includes('Path:turn.end')) {
326
- ws.close();
327
- const fullAudio = Buffer.concat(audioChunks);
328
- const srt = buildSRT(wordBoundaries);
329
- resolve({ audio: fullAudio, srt });
330
- }
331
- else if (textData.includes('Path:audio.metadata')) {
332
- try {
333
- const parts = textData.split('\r\n\r\n');
334
- if (parts.length > 1) {
335
- const json = JSON.parse(parts[1]);
336
- if (json.Metadata && Array.isArray(json.Metadata)) {
337
- for (const meta of json.Metadata) {
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
- else if (isBinary || (data.length > 2 && data[0] === 0x00 && data[1] === 0x67)) {
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 & System Clone)',
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: 'System Command (Local/Clone)',
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 locally installed tools (Piper, XTTS, Coqui) via command line.',
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: 'piper --model en_US-lessac-medium --output_file "{output_file}"',
168
- description: 'Command to execute. Use placeholders: "{text}" for input text, "{output_file}" for the temporary audio path.',
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
- srtBuffer = Buffer.from(result.srt, 'utf8');
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:turn.start')) {
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
- try {
369
- const parts = textData.split('\r\n\r\n');
370
- if (parts.length > 1) {
371
- const json = JSON.parse(parts[1]);
372
- if (json.Metadata && Array.isArray(json.Metadata)) {
373
- for (const meta of json.Metadata) {
374
- if (meta.Type === 'Word') {
375
- wordBoundaries.push({
376
- offset: meta.Offset, // 100ns units
377
- duration: meta.Duration,
378
- text: meta.Text,
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
- } else if (isBinary || (data.length > 2 && data[0] === 0x00 && data[1] === 0x67)) {
388
- // Binary Audio Data
389
- // The header is usually 2 bytes + requestID length + timestamp.
390
- // Edge TTS binary format: Header length (2B) + Header + Audio Data
391
- // We need to strip the header to get clean MP3.
392
- // Header format: "X-RequestId:...\r\nPath:audio\r\n..."
393
-
394
- // Simple Strip: Look for "Path:audio\r\n\r\n" in the buffer?
395
- // Binary messages have a specific binary header structure.
396
- // Structure: 2 bytes (header length) + Header Text + Data
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n8n-nodes-tts-bigboss",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "BigBoss TTS node with multi-engine support and automatic SRT generation",
5
5
  "keywords": [
6
6
  "n8n-community-node-package",