n8n-nodes-tts-bigboss 1.0.1 → 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.
@@ -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 & System Clone)',
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: 'System Command (Local/Clone)',
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 locally installed tools (Piper, XTTS, Coqui) via command line.',
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: '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.',
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
- srtBuffer = Buffer.from(result.srt, 'utf8');
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: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
- });
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
- else if (isBinary || (data.length > 2 && data[0] === 0x00 && data[1] === 0x67)) {
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 & System Clone)',
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: 'System Command (Local/Clone)',
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 locally installed tools (Piper, XTTS, Coqui) via command line.',
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: '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.',
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
- srtBuffer = Buffer.from(result.srt, 'utf8');
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: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')) {
441
+ if (textData.includes('Path:audio.metadata')) {
367
442
  // 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
- });
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
- } 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
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.1",
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",