n8n-nodes-tts-bigboss 1.0.2 → 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 +92 -25
- package/nodes/TTSBigBoss/TTSBigBoss.node.ts +120 -33
- package/package.json +1 -1
package/dist/TTSBigBoss.node.js
CHANGED
|
@@ -47,6 +47,30 @@ const https = __importStar(require("https"));
|
|
|
47
47
|
const stream = __importStar(require("stream"));
|
|
48
48
|
const util_1 = require("util");
|
|
49
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
|
+
];
|
|
50
74
|
const EDGE_URL = 'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4';
|
|
51
75
|
const EDGE_VOICES = [
|
|
52
76
|
{ name: 'Arabic (Egypt) - Salma', value: 'ar-EG-SalmaNeural' },
|
|
@@ -217,17 +241,35 @@ class TTSBigBoss {
|
|
|
217
241
|
description: 'Binary property name containing the reference audio for cloning. Use placeholder "{reference_audio}" in command.',
|
|
218
242
|
},
|
|
219
243
|
{
|
|
220
|
-
displayName: 'Piper Voice Model
|
|
244
|
+
displayName: 'Piper Voice Model',
|
|
221
245
|
name: 'piperModel',
|
|
222
|
-
type: '
|
|
246
|
+
type: 'options',
|
|
247
|
+
options: [
|
|
248
|
+
...PIPER_MODELS,
|
|
249
|
+
{ name: 'Custom (Enter URL)', value: 'custom' },
|
|
250
|
+
],
|
|
223
251
|
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
252
|
displayOptions: {
|
|
226
253
|
show: {
|
|
227
254
|
engine: ['piper_local'],
|
|
228
255
|
},
|
|
229
256
|
},
|
|
230
|
-
|
|
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
|
+
},
|
|
231
273
|
],
|
|
232
274
|
};
|
|
233
275
|
}
|
|
@@ -264,7 +306,10 @@ class TTSBigBoss {
|
|
|
264
306
|
}
|
|
265
307
|
}
|
|
266
308
|
else if (engine === 'piper_local') {
|
|
267
|
-
|
|
309
|
+
let piperModel = this.getNodeParameter('piperModel', i);
|
|
310
|
+
if (piperModel === 'custom') {
|
|
311
|
+
piperModel = this.getNodeParameter('piperModelCustom', i);
|
|
312
|
+
}
|
|
268
313
|
const piperBinPath = await ensurePiperBinary(binDir);
|
|
269
314
|
const { modelPath, configPath } = await ensurePiperModel(binDir, piperModel);
|
|
270
315
|
const outFile = path.join(tempDir, `piper_out_${(0, uuid_1.v4)()}.wav`);
|
|
@@ -281,8 +326,12 @@ class TTSBigBoss {
|
|
|
281
326
|
piperProc.on('close', (code) => {
|
|
282
327
|
if (code === 0)
|
|
283
328
|
resolve();
|
|
284
|
-
|
|
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 {
|
|
285
333
|
reject(new Error(`Piper failed (exit ${code}): ${errData}`));
|
|
334
|
+
}
|
|
286
335
|
});
|
|
287
336
|
piperProc.on('error', (err) => reject(err));
|
|
288
337
|
});
|
|
@@ -522,30 +571,48 @@ async function ensurePiperBinary(binDir) {
|
|
|
522
571
|
}
|
|
523
572
|
}
|
|
524
573
|
async function ensurePiperModel(binDir, modelNameOrUrl) {
|
|
574
|
+
let modelUrl = '';
|
|
575
|
+
let modelFilename = '';
|
|
525
576
|
if (modelNameOrUrl.startsWith('http')) {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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`;
|
|
530
589
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
if (!fs.existsSync(configPath)) {
|
|
534
|
-
await downloadFile(configUrl, configPath);
|
|
590
|
+
else {
|
|
591
|
+
throw new Error(`Invalid model name format: ${modelNameOrUrl}. Use format lang_REGION-voice-quality`);
|
|
535
592
|
}
|
|
536
|
-
return { modelPath, configPath };
|
|
537
593
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
+
}
|
|
547
614
|
}
|
|
548
|
-
return { modelPath
|
|
615
|
+
return { modelPath, configPath };
|
|
549
616
|
}
|
|
550
617
|
async function downloadFile(url, dest) {
|
|
551
618
|
return new Promise((resolve, reject) => {
|
|
@@ -17,6 +17,43 @@ import * as zlib from 'zlib'; // For extracting .tar.gz if needed, typically usa
|
|
|
17
17
|
|
|
18
18
|
const pipeline = promisify(stream.pipeline);
|
|
19
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
|
+
];
|
|
56
|
+
|
|
20
57
|
// Edge TTS Constants
|
|
21
58
|
const EDGE_URL = 'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4';
|
|
22
59
|
const EDGE_VOICES = [
|
|
@@ -212,17 +249,35 @@ export class TTSBigBoss implements INodeType {
|
|
|
212
249
|
// Local Piper Settings
|
|
213
250
|
// ----------------------------------
|
|
214
251
|
{
|
|
215
|
-
displayName: 'Piper Voice Model
|
|
252
|
+
displayName: 'Piper Voice Model',
|
|
216
253
|
name: 'piperModel',
|
|
217
|
-
type: '
|
|
254
|
+
type: 'options',
|
|
255
|
+
options: [
|
|
256
|
+
...PIPER_MODELS,
|
|
257
|
+
{ name: 'Custom (Enter URL)', value: 'custom' },
|
|
258
|
+
],
|
|
218
259
|
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
260
|
displayOptions: {
|
|
221
261
|
show: {
|
|
222
262
|
engine: ['piper_local'],
|
|
223
263
|
},
|
|
224
264
|
},
|
|
225
|
-
|
|
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
|
+
},
|
|
226
281
|
],
|
|
227
282
|
};
|
|
228
283
|
|
|
@@ -271,7 +326,10 @@ export class TTSBigBoss implements INodeType {
|
|
|
271
326
|
// ----------------------------------
|
|
272
327
|
// PIPER LOCAL AUTOMATION
|
|
273
328
|
// ----------------------------------
|
|
274
|
-
|
|
329
|
+
let piperModel = this.getNodeParameter('piperModel', i) as string;
|
|
330
|
+
if (piperModel === 'custom') {
|
|
331
|
+
piperModel = this.getNodeParameter('piperModelCustom', i) as string;
|
|
332
|
+
}
|
|
275
333
|
|
|
276
334
|
// 1. Ensure Piper Binary
|
|
277
335
|
const piperBinPath = await ensurePiperBinary(binDir);
|
|
@@ -299,7 +357,12 @@ export class TTSBigBoss implements INodeType {
|
|
|
299
357
|
|
|
300
358
|
piperProc.on('close', (code) => {
|
|
301
359
|
if (code === 0) resolve();
|
|
302
|
-
|
|
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
|
+
}
|
|
303
366
|
});
|
|
304
367
|
|
|
305
368
|
piperProc.on('error', (err) => reject(err));
|
|
@@ -641,42 +704,66 @@ async function ensurePiperBinary(binDir: string): Promise<string> {
|
|
|
641
704
|
}
|
|
642
705
|
|
|
643
706
|
async function ensurePiperModel(binDir: string, modelNameOrUrl: string): Promise<{ modelPath: string, configPath: string }> {
|
|
644
|
-
//
|
|
645
|
-
// https://huggingface.co/rhasspy/piper-voices/resolve/main/
|
|
646
|
-
//
|
|
647
|
-
// We'll rely on a direct URL or a simplied map for BigBoss defaults.
|
|
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
|
|
648
710
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
const fileName = path.basename(modelNameOrUrl);
|
|
652
|
-
const modelPath = path.join(binDir, fileName);
|
|
653
|
-
if (!fs.existsSync(modelPath)) {
|
|
654
|
-
await downloadFile(modelNameOrUrl, modelPath);
|
|
655
|
-
}
|
|
711
|
+
let modelUrl = '';
|
|
712
|
+
let modelFilename = '';
|
|
656
713
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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`);
|
|
661
737
|
}
|
|
662
|
-
return { modelPath, configPath };
|
|
663
738
|
}
|
|
664
739
|
|
|
665
|
-
|
|
666
|
-
|
|
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');
|
|
740
|
+
const modelPath = path.join(binDir, modelFilename);
|
|
741
|
+
const configPath = modelPath + '.json';
|
|
671
742
|
|
|
672
|
-
|
|
673
|
-
|
|
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
|
+
}
|
|
674
748
|
|
|
675
|
-
|
|
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
|
+
}
|
|
676
764
|
}
|
|
677
765
|
|
|
678
|
-
|
|
679
|
-
return { modelPath: modelNameOrUrl, configPath: modelNameOrUrl + '.json' };
|
|
766
|
+
return { modelPath, configPath };
|
|
680
767
|
}
|
|
681
768
|
|
|
682
769
|
async function downloadFile(url: string, dest: string): Promise<void> {
|