@qvac/tts-ggml 0.9.1 → 0.10.0

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.
Files changed (42) hide show
  1. package/README.md +342 -40
  2. package/docs/pocket-tts.md +201 -0
  3. package/examples/moss-dialogue-tts.js +113 -0
  4. package/examples/moss-tts.js +154 -0
  5. package/examples/pocket-tts.js +51 -0
  6. package/examples/supertonic-chunk-stream-tts.js +156 -0
  7. package/examples/supertonic-sentence-stream-tts.js +4 -4
  8. package/index.d.ts +352 -29
  9. package/index.js +698 -52
  10. package/lib/fit.d.ts +127 -0
  11. package/lib/fit.js +29 -0
  12. package/lib/pocketConfig.d.ts +10 -0
  13. package/lib/pocketConfig.js +145 -0
  14. package/package.json +14 -11
  15. package/test/integration/addon.test.js +5 -0
  16. package/test/integration/audio8.test.js +7 -0
  17. package/test/integration/chatterbox-mtl.test.js +6 -0
  18. package/test/integration/chatterbox-speed.test.js +13 -2
  19. package/test/integration/cosyvoice3-clone.test.js +16 -1
  20. package/test/integration/cosyvoice3-lavasr.test.js +6 -0
  21. package/test/integration/cosyvoice3.test.js +19 -0
  22. package/test/integration/fit.test.js +174 -0
  23. package/test/integration/gpu-smoke.test.js +13 -0
  24. package/test/integration/lavasr-enhancer.test.js +104 -3
  25. package/test/integration/moss.test.js +282 -0
  26. package/test/integration/multiple-runs.test.js +18 -2
  27. package/test/integration/output-sample-rate.test.js +6 -0
  28. package/test/integration/parler-wer.test.js +7 -1
  29. package/test/integration/parler.test.js +27 -3
  30. package/test/integration/pocket.integration.test.js +156 -0
  31. package/test/integration/supertonic-mtl.test.js +2 -0
  32. package/test/integration/supertonic.test.js +70 -0
  33. package/test/integration/supertonic3-quant.test.js +2 -0
  34. package/test/integration/voice-controls.test.js +56 -0
  35. package/test/mobile/integration.auto.cjs +16 -1
  36. package/test/mobile/pocket-worklet-host.cpp +92 -0
  37. package/test/mobile/pocket-worklet.cjs +47 -0
  38. package/test/mobile/test-groups.json +8 -4
  39. package/test/utils/runCosyvoiceTTS.js +1 -0
  40. package/test/utils/runTTS.js +6 -11
  41. package/tts.d.ts +13 -2
  42. package/tts.js +3 -2
@@ -12,6 +12,7 @@ const test = require('brittle')
12
12
 
13
13
  const { loadCosyvoiceTTS, runCosyvoiceTTS } = require('../utils/runCosyvoiceTTS')
14
14
  const { ensureCosyvoiceModel } = require('../utils/downloadModel')
15
+ const { TTS_TEST_THREADS } = require('../utils/testThreads')
15
16
 
16
17
  const platform = os.platform()
17
18
  const isMobile = platform === 'ios' || platform === 'android'
@@ -40,6 +41,7 @@ test(
40
41
  // Second model with outputSampleRate wired through the config surface, so the
41
42
  // whole resample path (engine → addon → reported chunk) is exercised end-to-end.
42
43
  const model = await loadCosyvoiceTTS({
44
+ threads: TTS_TEST_THREADS,
43
45
  cosyvoiceModelDir: download.modelDir,
44
46
  outputSampleRate: 16000
45
47
  })
@@ -83,6 +85,7 @@ test(
83
85
  // logits, and cosyvoice reseeds per synthesis, so pinning it is the
84
86
  // difference between measuring the conditioning and measuring the sampler.
85
87
  const model = await loadCosyvoiceTTS({
88
+ threads: TTS_TEST_THREADS,
86
89
  cosyvoiceModelDir: download.modelDir,
87
90
  seed: 42
88
91
  })
@@ -119,6 +122,16 @@ test(
119
122
  'plain cosyvoice reports 24 kHz native sample rate'
120
123
  )
121
124
  t.ok(plain.data.durationMs > 0, 'plain cosyvoice audio duration is > 0 ms')
125
+ // Engine StageTimings: each stage did work, the zero-shot baked voice
126
+ // prompts the LM with its speech tokens, and no denoiser is loaded.
127
+ const stats = plain.data.stats
128
+ t.ok(stats.speechTokens > 0, 'stats report the speech tokens generated')
129
+ t.ok(stats.decodeSteps > 0, 'stats report the LM decode steps')
130
+ t.ok(stats.promptSpeechTokens > 0, 'zero-shot prompts the LM with speech tokens')
131
+ for (const key of ['lmDecodeMs', 'ditEulerMs', 'hiftDecodeMs', 'stageTotalMs']) {
132
+ t.ok(stats[key] > 0, `stats report ${key}`)
133
+ }
134
+ t.is(stats.denoiserBackendDevice, -1, 'no denoiser loaded -> denoiserBackendDevice=-1')
122
135
  const neutral = await runCosyvoiceTTS(
123
136
  model,
124
137
  { text, perCallEmotion: 'neutral' },
@@ -171,6 +184,7 @@ test(
171
184
  }
172
185
 
173
186
  const model = await loadCosyvoiceTTS({
187
+ threads: TTS_TEST_THREADS,
174
188
  cosyvoiceModelDir: download.modelDir,
175
189
  emotion: 'happy'
176
190
  })
@@ -182,6 +196,11 @@ test(
182
196
  t.ok(result.passed, 'cosyvoice instruct synth passes expectations')
183
197
  t.ok(result.data.sampleCount > 0, 'cosyvoice instruct produced audio')
184
198
  t.is(result.data.reportedSampleRate, 24000, 'cosyvoice instruct reports 24 kHz')
199
+ t.is(
200
+ result.data.stats.promptSpeechTokens,
201
+ 0,
202
+ 'instruct mode drops the prompt speech tokens (StageTimings)'
203
+ )
185
204
  } finally {
186
205
  try {
187
206
  await model.unload()
@@ -0,0 +1,174 @@
1
+ 'use strict'
2
+
3
+ const fs = require('bare-fs')
4
+ const os = require('bare-os')
5
+ const path = require('bare-path')
6
+ const test = require('brittle')
7
+
8
+ const TTSGgml = require('@qvac/tts-ggml')
9
+ const { ensureSupertonicModel } = require('../utils/downloadModel')
10
+
11
+ const TEST_TIMEOUT_MS = 900000
12
+
13
+ let modelPath = null
14
+
15
+ async function supertonicPath(t) {
16
+ if (modelPath === null) {
17
+ const resolved = await ensureSupertonicModel()
18
+ modelPath = resolved.success ? resolved.path : ''
19
+ }
20
+ if (modelPath === '') {
21
+ t.pass('Supertonic GGUF unavailable on this runner')
22
+ return null
23
+ }
24
+ return modelPath
25
+ }
26
+
27
+ /**
28
+ * The supertonic fitter covers the fused graph path: a validated GPU, or a CPU
29
+ * without the Accelerate pointwise kernels. A runner outside that answers
30
+ * `compute-path-not-supported`. That is a refusal, and it carries no figures.
31
+ */
32
+ function projected(t, fit) {
33
+ if (fit.reason === 'compute-path-not-supported') {
34
+ t.pass('this runner’s compute path is not modelled')
35
+ return false
36
+ }
37
+ return true
38
+ }
39
+
40
+ function tempDir(t) {
41
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tts-fit-'))
42
+ t.teardown(() => fs.rmSync(dir, { recursive: true, force: true }))
43
+ return dir
44
+ }
45
+
46
+ /**
47
+ * The registry's weightless copy of a GGUF: the header and tensor table as
48
+ * written, and a hole where the tensor data was. The file keeps its length so
49
+ * every offset in the header still resolves.
50
+ */
51
+ function weightlessCopy(dir, sourcePath, headerBytes) {
52
+ const target = path.join(dir, 'weightless.gguf')
53
+ const source = fs.openSync(sourcePath, 'r')
54
+ const header = Buffer.alloc(headerBytes)
55
+ fs.readSync(source, header, 0, headerBytes, 0)
56
+ fs.closeSync(source)
57
+
58
+ fs.writeFileSync(target, header)
59
+ fs.truncateSync(target, fs.statSync(sourcePath).size)
60
+ return target
61
+ }
62
+
63
+ test('a projection is internally consistent', { timeout: TEST_TIMEOUT_MS }, async (t) => {
64
+ const model = await supertonicPath(t)
65
+ if (model === null) return
66
+
67
+ const fit = TTSGgml.assessFit({
68
+ engineType: 'supertonic',
69
+ supertonicModelPath: model,
70
+ useGPU: true
71
+ })
72
+
73
+ t.comment(`status=${fit.status} reason=${fit.reason} device=${fit.deviceName}`)
74
+ if (!projected(t, fit)) return
75
+ t.ok(fit.status === 'fits' || fit.status === 'does-not-fit', 'a verdict, not an error')
76
+ t.ok(fit.deviceTotalBytes > 0, 'the device reported its capacity')
77
+ t.ok(fit.deviceFreeBytes <= fit.deviceTotalBytes, 'free never exceeds total')
78
+ t.ok(fit.weightsBytes > 0, 'the weights were measured')
79
+ t.ok(fit.deviceBytes >= fit.weightsBytes, 'weights are part of the device demand')
80
+ t.is(fit.lavasrFileBytes, 0, 'no LavaSR stage was named')
81
+ })
82
+
83
+ test('a weightless copy projects the same', { timeout: TEST_TIMEOUT_MS }, async (t) => {
84
+ const model = await supertonicPath(t)
85
+ if (model === null) return
86
+
87
+ const full = TTSGgml.assessFit({
88
+ engineType: 'supertonic',
89
+ supertonicModelPath: model,
90
+ useGPU: true
91
+ })
92
+ if (!projected(t, full)) return
93
+ const stub = TTSGgml.assessFit({
94
+ engineType: 'supertonic',
95
+ supertonicModelPath: weightlessCopy(tempDir(t), model, 1024 * 1024),
96
+ useGPU: true
97
+ })
98
+
99
+ t.is(stub.status, full.status, 'the same verdict')
100
+ t.is(stub.weightsBytes, full.weightsBytes, 'the same weight footprint')
101
+ t.is(stub.deviceBytes, full.deviceBytes, 'the same device demand')
102
+ t.is(stub.hostBytes, full.hostBytes, 'the same host demand')
103
+ })
104
+
105
+ test('the load’s offload keys reach the projection', { timeout: TEST_TIMEOUT_MS }, async (t) => {
106
+ const model = await supertonicPath(t)
107
+ if (model === null) return
108
+
109
+ const cpu = TTSGgml.assessFit({
110
+ engineType: 'supertonic',
111
+ supertonicModelPath: model,
112
+ useGPU: false
113
+ })
114
+ const pinned = TTSGgml.assessFit({
115
+ engineType: 'supertonic',
116
+ supertonicModelPath: model,
117
+ nGpuLayers: 0
118
+ })
119
+
120
+ t.ok(cpu.deviceIsCpu, 'useGPU false projects on the CPU device')
121
+ t.is(pinned.deviceIsCpu, cpu.deviceIsCpu, 'nGpuLayers 0 agrees with useGPU false')
122
+ })
123
+
124
+ test('a LavaSR stage is sized beside the projection', { timeout: TEST_TIMEOUT_MS }, async (t) => {
125
+ const model = await supertonicPath(t)
126
+ if (model === null) return
127
+
128
+ const dir = tempDir(t)
129
+ const enhancer = path.join(dir, 'enhancer.gguf')
130
+ fs.writeFileSync(enhancer, Buffer.alloc(4096, 1))
131
+
132
+ const fit = TTSGgml.assessFit({
133
+ engineType: 'supertonic',
134
+ supertonicModelPath: model,
135
+ useGPU: true,
136
+ lavasrEnhancerPath: enhancer
137
+ })
138
+
139
+ t.is(fit.lavasrFileBytes, 4096, 'the enhancer is reported at its size on disk')
140
+ if (!projected(t, fit)) return
141
+ t.ok(fit.lavasrFileBytes < fit.deviceBytes, 'reported beside the projection')
142
+ })
143
+
144
+ test('a voice with no fitter is an outcome, not a throw', (t) => {
145
+ const fit = TTSGgml.assessFit({
146
+ engineType: 'moss',
147
+ mossBackbonePath: '/models/moss-tts-delay.gguf'
148
+ })
149
+
150
+ t.is(fit.status, 'error')
151
+ t.is(fit.reason, 'unsupported-engine')
152
+ t.is(fit.modelVariant, 'moss')
153
+ })
154
+
155
+ test('a model that cannot be read is an outcome, not a throw', (t) => {
156
+ const fit = TTSGgml.assessFit({
157
+ engineType: 'supertonic',
158
+ supertonicModelPath: '/nonexistent/model.gguf'
159
+ })
160
+
161
+ t.is(fit.status, 'error')
162
+ })
163
+
164
+ test('a count that is not a count is refused', (t) => {
165
+ t.exception(
166
+ () =>
167
+ TTSGgml.assessFit({
168
+ engineType: 'supertonic',
169
+ supertonicModelPath: '/models/supertonic.gguf',
170
+ textTokens: -1
171
+ }),
172
+ /non-negative count/
173
+ )
174
+ })
@@ -45,6 +45,7 @@ const {
45
45
  ensureCosyvoiceModel
46
46
  } = require('../utils/downloadModel')
47
47
  const { recordTtsStats } = require('../utils/perf-helper')
48
+ const { TTS_TEST_THREADS } = require('../utils/testThreads')
48
49
 
49
50
  const platform = os.platform()
50
51
  const isMobile = platform === 'ios' || platform === 'android'
@@ -228,6 +229,7 @@ test(
228
229
  }
229
230
 
230
231
  const model = await loadChatterboxTTS({
232
+ threads: TTS_TEST_THREADS,
231
233
  modelDir: download.targetDir,
232
234
  refWavPath,
233
235
  language: 'en',
@@ -287,6 +289,7 @@ test(
287
289
  }
288
290
 
289
291
  const model = await loadChatterboxTTS({
292
+ threads: TTS_TEST_THREADS,
290
293
  modelDir: download.targetDir,
291
294
  t3ModelPath: path.join(download.targetDir, 'chatterbox-t3-mtl.gguf'),
292
295
  s3genModelPath: path.join(download.targetDir, 'chatterbox-s3gen-mtl.gguf'),
@@ -338,6 +341,7 @@ test(
338
341
  const supertonicPath = download.path || path.join(modelsDir, 'supertonic.gguf')
339
342
 
340
343
  const model = await loadSupertonicTTS({
344
+ threads: TTS_TEST_THREADS,
341
345
  supertonicModelPath: supertonicPath,
342
346
  language: 'en',
343
347
  voice: 'F1',
@@ -385,6 +389,7 @@ test(
385
389
  }
386
390
 
387
391
  const model = await loadSupertonicTTS({
392
+ threads: TTS_TEST_THREADS,
388
393
  supertonicModelPath: download.path,
389
394
  language: 'en',
390
395
  voice: 'F1',
@@ -431,6 +436,7 @@ test(
431
436
  }
432
437
 
433
438
  const model = await loadSupertonicTTS({
439
+ threads: TTS_TEST_THREADS,
434
440
  supertonicModelPath: download.path,
435
441
  language: 'en',
436
442
  voice: 'F1',
@@ -488,6 +494,7 @@ test(
488
494
  }
489
495
 
490
496
  const model = await loadChatterboxTTS({
497
+ threads: TTS_TEST_THREADS,
491
498
  modelDir: download.targetDir,
492
499
  refWavPath,
493
500
  language: 'en',
@@ -532,6 +539,7 @@ test(
532
539
  const supertonicPath = download.path || path.join(modelsDir, 'supertonic.gguf')
533
540
 
534
541
  const model = await loadSupertonicTTS({
542
+ threads: TTS_TEST_THREADS,
535
543
  supertonicModelPath: supertonicPath,
536
544
  language: 'en',
537
545
  voice: 'F1',
@@ -583,6 +591,7 @@ for (const v of [
583
591
  return
584
592
  }
585
593
  const model = await loadParlerTTS({
594
+ threads: TTS_TEST_THREADS,
586
595
  parlerModelPath: download.path,
587
596
  seed: 42,
588
597
  useGPU: true
@@ -625,6 +634,7 @@ for (const v of [
625
634
  return
626
635
  }
627
636
  const model = await loadParlerTTS({
637
+ threads: TTS_TEST_THREADS,
628
638
  parlerModelPath: download.path,
629
639
  seed: 42,
630
640
  useGPU: false
@@ -678,6 +688,7 @@ async function probeAndroidGpuVendor(t) {
678
688
  })
679
689
  if (!download || !download.success) return null
680
690
  const model = await loadSupertonicTTS({
691
+ threads: TTS_TEST_THREADS,
681
692
  supertonicModelPath: download.path,
682
693
  language: 'en',
683
694
  voice: 'F1',
@@ -730,6 +741,7 @@ test(
730
741
  }
731
742
  }
732
743
  const model = await loadCosyvoiceTTS({
744
+ threads: TTS_TEST_THREADS,
733
745
  cosyvoiceModelDir: download.modelDir,
734
746
  useGPU: true
735
747
  })
@@ -788,6 +800,7 @@ test(
788
800
  return
789
801
  }
790
802
  const model = await loadCosyvoiceTTS({
803
+ threads: TTS_TEST_THREADS,
791
804
  cosyvoiceModelDir: download.modelDir,
792
805
  useGPU: false
793
806
  })
@@ -32,6 +32,7 @@ const {
32
32
  ensureParlerModel
33
33
  } = require('../utils/downloadModel')
34
34
  const { resolveRefWavPath } = require('../utils/runChatterboxTTS')
35
+ const { TTS_TEST_THREADS } = require('../utils/testThreads')
35
36
 
36
37
  const platform = os.platform()
37
38
  const isMobile = platform === 'ios' || platform === 'android'
@@ -159,6 +160,13 @@ async function runAndCollect(model, text) {
159
160
  return { samples, sampleRate, stats: response.stats || null }
160
161
  }
161
162
 
163
+ // A CPU engine keeps the denoiser on its scalar core, which runtimeStats
164
+ // reports as CPU (denoiserBackendDevice 0, denoiserBackendId 0).
165
+ function assertDenoiserOnCpu(t, stats, label) {
166
+ t.is(stats.denoiserBackendDevice, 0, `${label}: useGPU:false -> denoiser on CPU`)
167
+ t.is(stats.denoiserBackendId, 0, `${label}: denoiser backend reported as CPU`)
168
+ }
169
+
162
170
  // Every chunk that carries audio must be tagged at the enhanced 48 kHz rate
163
171
  // rather than the engine's native rate — the mislabel this feature prevents.
164
172
  function assertStreamedChunksReportEnhancedRate(t, updates) {
@@ -202,6 +210,7 @@ function assertStreamTerminatesOnce(t, updates, terminal) {
202
210
 
203
211
  async function runParlerBatch(files) {
204
212
  const model = new TTSGgml({
213
+ threads: TTS_TEST_THREADS,
205
214
  engine: TTSGgml.ENGINE_PARLER,
206
215
  files,
207
216
  voice: 'Laura',
@@ -225,6 +234,7 @@ test('Chatterbox: enhancer + streamChunkTokens constructs and forwards both', (t
225
234
  // construct and forward both knobs to the addon (which runs the streaming
226
235
  // enhancer per chunk).
227
236
  const model = new TTSGgml({
237
+ threads: TTS_TEST_THREADS,
228
238
  engine: TTSGgml.ENGINE_CHATTERBOX,
229
239
  files: {
230
240
  t3Model: './models/chatterbox-t3-turbo.gguf',
@@ -247,6 +257,7 @@ test('Parler: enhancer + streamChunkTokens constructs and forwards both', (t) =>
247
257
  // Parler used to reject the enhancer outright; it now enhances both the batch
248
258
  // path and native chunk streaming (seam-free via the streaming enhancer).
249
259
  const model = new TTSGgml({
260
+ threads: TTS_TEST_THREADS,
250
261
  engine: TTSGgml.ENGINE_PARLER,
251
262
  files: {
252
263
  parlerModel: './models/parler-mini-v1-q8_0.gguf',
@@ -266,6 +277,7 @@ test('Parler: enhancer + streamChunkTokens constructs and forwards both', (t) =>
266
277
 
267
278
  test('Parler: denoiser forwards on the batch path and is rejected while streaming', (t) => {
268
279
  const model = new TTSGgml({
280
+ threads: TTS_TEST_THREADS,
269
281
  engine: TTSGgml.ENGINE_PARLER,
270
282
  files: {
271
283
  parlerModel: './models/parler-mini-v1-q8_0.gguf',
@@ -281,6 +293,7 @@ test('Parler: denoiser forwards on the batch path and is rejected while streamin
281
293
  t.exception(
282
294
  () =>
283
295
  new TTSGgml({
296
+ threads: TTS_TEST_THREADS,
284
297
  engine: TTSGgml.ENGINE_PARLER,
285
298
  files: {
286
299
  parlerModel: './models/parler-mini-v1-q8_0.gguf',
@@ -299,6 +312,7 @@ test('CosyVoice3: enhancer + streamChunkTokens constructs and forwards both', (t
299
312
  // batch path and on native chunk streaming, so both knobs must reach the
300
313
  // addon together.
301
314
  const model = new TTSGgml({
315
+ threads: TTS_TEST_THREADS,
302
316
  engine: TTSGgml.ENGINE_COSYVOICE3,
303
317
  files: {
304
318
  cosyvoiceModelDir: './models/cosyvoice3',
@@ -318,6 +332,7 @@ test('CosyVoice3: enhancer + streamChunkTokens constructs and forwards both', (t
318
332
 
319
333
  test('CosyVoice3: denoiser is forwarded for batch but rejected with streaming', (t) => {
320
334
  const batch = new TTSGgml({
335
+ threads: TTS_TEST_THREADS,
321
336
  engine: TTSGgml.ENGINE_COSYVOICE3,
322
337
  files: {
323
338
  cosyvoiceModelDir: './models/cosyvoice3',
@@ -334,6 +349,7 @@ test('CosyVoice3: denoiser is forwarded for batch but rejected with streaming',
334
349
  t.exception(
335
350
  () =>
336
351
  new TTSGgml({
352
+ threads: TTS_TEST_THREADS,
337
353
  engine: TTSGgml.ENGINE_COSYVOICE3,
338
354
  files: {
339
355
  cosyvoiceModelDir: './models/cosyvoice3',
@@ -351,6 +367,7 @@ test('enhancer with an unknown type is rejected at construction', (t) => {
351
367
  t.exception(
352
368
  () =>
353
369
  new TTSGgml({
370
+ threads: TTS_TEST_THREADS,
354
371
  engine: TTSGgml.ENGINE_SUPERTONIC,
355
372
  files: {
356
373
  supertonicModel: './models/supertonic.gguf',
@@ -366,6 +383,7 @@ test('enhancer with an unknown type is rejected at construction', (t) => {
366
383
 
367
384
  test('enhancer block with no GGUF path leaves enhancement off (no throw)', (t) => {
368
385
  const model = new TTSGgml({
386
+ threads: TTS_TEST_THREADS,
369
387
  engine: TTSGgml.ENGINE_SUPERTONIC,
370
388
  files: { supertonicModel: './models/supertonic.gguf' },
371
389
  enhancer: { type: 'lavasr' },
@@ -420,7 +438,12 @@ for (const [engineName, engine, files] of [
420
438
  ]
421
439
  ]) {
422
440
  test(`${engineName}: enhancer + useGPU:true forwards useGPU alongside the enhancer path`, (t) => {
423
- const model = new TTSGgml({ engine, files, config: { language: 'en', useGPU: true } })
441
+ const model = new TTSGgml({
442
+ threads: TTS_TEST_THREADS,
443
+ engine,
444
+ files,
445
+ config: { language: 'en', useGPU: true }
446
+ })
424
447
  const params = model._buildTtsParams()
425
448
  t.is(params.useGPU, true, 'useGPU:true forwarded to the addon (drives EnhancerOptions.use_gpu)')
426
449
  t.is(
@@ -431,7 +454,12 @@ for (const [engineName, engine, files] of [
431
454
  })
432
455
 
433
456
  test(`${engineName}: enhancer + useGPU:false keeps the enhancer on CPU`, (t) => {
434
- const model = new TTSGgml({ engine, files, config: { language: 'en', useGPU: false } })
457
+ const model = new TTSGgml({
458
+ threads: TTS_TEST_THREADS,
459
+ engine,
460
+ files,
461
+ config: { language: 'en', useGPU: false }
462
+ })
435
463
  const params = model._buildTtsParams()
436
464
  t.is(
437
465
  params.useGPU,
@@ -446,7 +474,13 @@ for (const [engineName, engine, files] of [
446
474
  })
447
475
 
448
476
  test(`${engineName}: enhancer + nGpuLayers!=0 forwards the GPU layer count`, (t) => {
449
- const model = new TTSGgml({ engine, files, nGpuLayers: 99, config: { language: 'en' } })
477
+ const model = new TTSGgml({
478
+ threads: TTS_TEST_THREADS,
479
+ engine,
480
+ files,
481
+ nGpuLayers: 99,
482
+ config: { language: 'en' }
483
+ })
450
484
  const params = model._buildTtsParams()
451
485
  t.is(params.nGpuLayers, 99, 'nGpuLayers forwarded (non-zero => enhancer requests the GPU)')
452
486
  t.is(
@@ -479,6 +513,7 @@ test(
479
513
  }
480
514
 
481
515
  const model = new TTSGgml({
516
+ threads: TTS_TEST_THREADS,
482
517
  engine: TTSGgml.ENGINE_SUPERTONIC,
483
518
  files: { supertonicModel: dl.path, lavasrEnhancer: enh.path },
484
519
  voice: 'F1',
@@ -525,6 +560,7 @@ test(
525
560
  }
526
561
 
527
562
  const model = new TTSGgml({
563
+ threads: TTS_TEST_THREADS,
528
564
  engine: TTSGgml.ENGINE_SUPERTONIC,
529
565
  files: { supertonicModel: dl.path },
530
566
  voice: 'F1',
@@ -566,6 +602,7 @@ test(
566
602
  const dir = dl.targetDir || modelsDir
567
603
 
568
604
  const model = new TTSGgml({
605
+ threads: TTS_TEST_THREADS,
569
606
  engine: TTSGgml.ENGINE_CHATTERBOX,
570
607
  files: {
571
608
  modelDir: dir,
@@ -615,6 +652,7 @@ test(
615
652
  const dir = dl.targetDir || modelsDir
616
653
 
617
654
  const model = new TTSGgml({
655
+ threads: TTS_TEST_THREADS,
618
656
  engine: TTSGgml.ENGINE_CHATTERBOX,
619
657
  files: {
620
658
  modelDir: dir,
@@ -675,6 +713,7 @@ test(
675
713
  }
676
714
 
677
715
  const model = new TTSGgml({
716
+ threads: TTS_TEST_THREADS,
678
717
  engine: TTSGgml.ENGINE_PARLER,
679
718
  files: { parlerModel: dl.path, lavasrEnhancer: enh.path },
680
719
  voice: 'Laura',
@@ -718,6 +757,7 @@ test(
718
757
  }
719
758
 
720
759
  const model = new TTSGgml({
760
+ threads: TTS_TEST_THREADS,
721
761
  engine: TTSGgml.ENGINE_PARLER,
722
762
  files: { parlerModel: dl.path },
723
763
  voice: 'Laura',
@@ -731,6 +771,8 @@ test(
731
771
  t.ok(r.samples > 0, 'synthesis produced audio')
732
772
  t.ok(r.stats, 'runtimeStats returned (constructed with stats:true)')
733
773
  t.is(r.stats.enhancerBackendDevice, -1, 'no enhancer loaded -> enhancerBackendDevice=-1')
774
+ t.is(r.stats.denoiserBackendDevice, -1, 'no denoiser loaded -> denoiserBackendDevice=-1')
775
+ t.is(r.stats.denoiserBackendId, -1, 'no denoiser loaded -> denoiserBackendId=-1')
734
776
  } finally {
735
777
  try {
736
778
  await model.unload()
@@ -759,6 +801,7 @@ test(
759
801
  }
760
802
 
761
803
  const model = new TTSGgml({
804
+ threads: TTS_TEST_THREADS,
762
805
  engine: TTSGgml.ENGINE_PARLER,
763
806
  files: { parlerModel: dl.path, lavasrEnhancer: enh.path },
764
807
  voice: 'Laura',
@@ -815,6 +858,7 @@ test(
815
858
  }
816
859
 
817
860
  const model = new TTSGgml({
861
+ threads: TTS_TEST_THREADS,
818
862
  engine: TTSGgml.ENGINE_PARLER,
819
863
  files: { parlerModel: dl.path, lavasrEnhancer: enh.path },
820
864
  voice: 'Laura',
@@ -882,6 +926,7 @@ test(
882
926
  -1,
883
927
  'denoiser alone loads no enhancer (enhancerBackendDevice=-1)'
884
928
  )
929
+ assertDenoiserOnCpu(t, denoisedOnly.stats, 'denoiser alone')
885
930
 
886
931
  const enh = await ensureLavaSREnhancerGguf({
887
932
  targetDir: path.join(baseDir, 'models', 'lavasr')
@@ -909,6 +954,7 @@ test(
909
954
  0,
910
955
  'useGPU:false -> enhancer on CPU (enhancerBackendDevice=0)'
911
956
  )
957
+ assertDenoiserOnCpu(t, denoisedAndEnhanced.stats, 'denoiser + enhancer')
912
958
  }
913
959
  )
914
960
 
@@ -941,6 +987,7 @@ test(
941
987
  }
942
988
 
943
989
  const model = new TTSGgml({
990
+ threads: TTS_TEST_THREADS,
944
991
  engine: TTSGgml.ENGINE_SUPERTONIC,
945
992
  files: { supertonicModel: dl.path, lavasrEnhancer: enh.path },
946
993
  voice: 'F1',
@@ -964,6 +1011,59 @@ test(
964
1011
  }
965
1012
  )
966
1013
 
1014
+ test(
1015
+ 'Supertonic + LavaSR denoiser on GPU (useGPU:true) runs the denoiser on the engine device',
1016
+ { timeout: 600000, skip: NO_GPU },
1017
+ async (t) => {
1018
+ const baseDir = getBaseDir()
1019
+ const den = await ensureLavaSRDenoiserGguf({
1020
+ targetDir: path.join(baseDir, 'models', 'lavasr')
1021
+ })
1022
+ if (!den.success) {
1023
+ t.comment('LavaSR denoiser GGUF not staged; skipping.')
1024
+ t.pass('skipped — no denoiser GGUF')
1025
+ return
1026
+ }
1027
+ const dl = await ensureSupertonicModel({ targetDir: path.join(baseDir, 'models') })
1028
+ if (!dl.success) {
1029
+ t.fail('Supertonic GGUF not available — registry fetch failed.')
1030
+ return
1031
+ }
1032
+
1033
+ const model = new TTSGgml({
1034
+ threads: TTS_TEST_THREADS,
1035
+ engine: TTSGgml.ENGINE_SUPERTONIC,
1036
+ files: { supertonicModel: dl.path, lavasrDenoiser: den.path },
1037
+ voice: 'F1',
1038
+ config: { language: 'en', useGPU: true },
1039
+ opts: { stats: true }
1040
+ })
1041
+ await model.load()
1042
+ try {
1043
+ const r = await runAndCollect(model, 'The denoiser follows the engine onto the GPU.')
1044
+ t.is(r.sampleRate, 44100, 'denoising preserves the native 44.1 kHz')
1045
+ t.ok(r.samples > 0, 'denoised synthesis produced audio')
1046
+ const { backendDevice, backendId, denoiserBackendDevice, denoiserBackendId } = r.stats
1047
+ console.log(
1048
+ `[denoiser/GPU] backendDevice=${backendDevice} denoiserBackendDevice=` +
1049
+ `${denoiserBackendDevice} denoiserBackendId=${denoiserBackendId} ` +
1050
+ `(${backendIdToName(denoiserBackendId)})`
1051
+ )
1052
+ t.not(denoiserBackendDevice, -1, 'denoiser was loaded (denoiserBackendDevice != -1)')
1053
+ t.is(denoiserBackendDevice, backendDevice, 'denoiser runs on the engine device')
1054
+ if (backendDevice === 1) {
1055
+ t.is(denoiserBackendId, backendId, 'GPU denoiser uses the engine backend')
1056
+ } else {
1057
+ t.is(denoiserBackendId, 0, 'a CPU-fallback engine keeps the denoiser on CPU')
1058
+ }
1059
+ } finally {
1060
+ try {
1061
+ await model.unload()
1062
+ } catch (_e) {}
1063
+ }
1064
+ }
1065
+ )
1066
+
967
1067
  test(
968
1068
  'Chatterbox + LavaSR enhancer on GPU (useGPU:true, batch) runs the enhancer on the GPU and reports 48 kHz',
969
1069
  { timeout: 900000, skip: NO_GPU },
@@ -986,6 +1086,7 @@ test(
986
1086
  const dir = dl.targetDir || modelsDir
987
1087
 
988
1088
  const model = new TTSGgml({
1089
+ threads: TTS_TEST_THREADS,
989
1090
  engine: TTSGgml.ENGINE_CHATTERBOX,
990
1091
  files: {
991
1092
  modelDir: dir,