@qvac/tts-ggml 0.9.2 → 0.10.1

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 +330 -36
  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 +16 -9
  15. package/test/integration/addon.test.js +13 -7
  16. package/test/integration/audio8.test.js +7 -0
  17. package/test/integration/chatterbox-mtl.test.js +20 -9
  18. package/test/integration/chatterbox-speed.test.js +16 -3
  19. package/test/integration/cosyvoice3-clone.test.js +17 -2
  20. package/test/integration/cosyvoice3-lavasr.test.js +21 -11
  21. package/test/integration/cosyvoice3.test.js +33 -8
  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 +107 -5
  25. package/test/integration/moss.test.js +287 -0
  26. package/test/integration/multiple-runs.test.js +84 -57
  27. package/test/integration/output-sample-rate.test.js +10 -2
  28. package/test/integration/parler-wer.test.js +14 -9
  29. package/test/integration/parler.test.js +54 -24
  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
@@ -29,13 +29,16 @@ const {
29
29
  } = require('../utils/runChatterboxTTS')
30
30
  const { loadSupertonicTTS, runSupertonicTTS } = require('../utils/runSupertonicTTS')
31
31
  const { ensureChatterboxModels, ensureSupertonicModel } = require('../utils/downloadModel')
32
+ const { TTS_TEST_THREADS } = require('../utils/testThreads')
33
+ const { NO_GPU, GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
32
34
 
33
35
  const platform = os.platform()
34
36
  const isMobile = platform === 'ios' || platform === 'android'
35
37
 
36
- // Lifecycle / sequential-run test, not a GPU policy test: rely on the
37
- // package default (`useGPU: false`) rather than opting into GPU here.
38
- // Tests that *are* about GPU live in gpu-smoke.test.js.
38
+ // Lifecycle / sequential-run tests, not GPU policy tests. The Chatterbox ones
39
+ // run several syntheses, so they run on the GPU only and are skipped on CPU-only
40
+ // runners (NO_GPU=true); the Supertonic reload keeps the package default
41
+ // (`useGPU: false`). Tests that *are* about GPU live in gpu-smoke.test.js.
39
42
 
40
43
  function getBaseDir() {
41
44
  return isMobile && global.testDir ? global.testDir : '.'
@@ -49,7 +52,7 @@ const PHRASES = [
49
52
 
50
53
  test(
51
54
  'Chatterbox: multiple sequential runs reuse the same engine instance',
52
- { timeout: 1800000 },
55
+ { timeout: 1800000, skip: NO_GPU },
53
56
  async (t) => {
54
57
  const baseDir = getBaseDir()
55
58
  const download = await ensureChatterboxModels({ targetDir: path.join(baseDir, 'models') })
@@ -73,9 +76,11 @@ test(
73
76
  }
74
77
 
75
78
  const model = await loadChatterboxTTS({
79
+ threads: TTS_TEST_THREADS,
76
80
  modelDir: download.targetDir,
77
81
  refWavPath,
78
- language: 'en'
82
+ language: 'en',
83
+ useGPU: GPU_ONLY_USE_GPU
79
84
  })
80
85
  try {
81
86
  const timings = []
@@ -109,7 +114,7 @@ test(
109
114
 
110
115
  test(
111
116
  'Chatterbox: fresh instance per run (app-restart simulation)',
112
- { timeout: 1800000 },
117
+ { timeout: 1800000, skip: NO_GPU },
113
118
  async (t) => {
114
119
  const baseDir = getBaseDir()
115
120
  const download = await ensureChatterboxModels({ targetDir: path.join(baseDir, 'models') })
@@ -137,9 +142,11 @@ test(
137
142
  for (let i = 0; i < N; i++) {
138
143
  const t0 = Date.now()
139
144
  const model = await loadChatterboxTTS({
145
+ threads: TTS_TEST_THREADS,
140
146
  modelDir: download.targetDir,
141
147
  refWavPath,
142
- language: 'en'
148
+ language: 'en',
149
+ useGPU: GPU_ONLY_USE_GPU
143
150
  })
144
151
  const loadMs = Date.now() - t0
145
152
  try {
@@ -172,57 +179,63 @@ test(
172
179
  }
173
180
  )
174
181
 
175
- test('Chatterbox: reload() between runs preserves stability', { timeout: 1800000 }, async (t) => {
176
- const baseDir = getBaseDir()
177
- const download = await ensureChatterboxModels({ targetDir: path.join(baseDir, 'models') })
178
- if (!download.success) {
179
- t.fail(
180
- 'Chatterbox GGUFs not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.'
181
- )
182
- return
183
- }
182
+ test(
183
+ 'Chatterbox: reload() between runs preserves stability',
184
+ { timeout: 1800000, skip: NO_GPU },
185
+ async (t) => {
186
+ const baseDir = getBaseDir()
187
+ const download = await ensureChatterboxModels({ targetDir: path.join(baseDir, 'models') })
188
+ if (!download.success) {
189
+ t.fail(
190
+ 'Chatterbox GGUFs not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.'
191
+ )
192
+ return
193
+ }
184
194
 
185
- // Mobile-aware resolution: on iOS / Android the asset is staged into
186
- // `Library/Caches/jfk.wav` via `global.assetPaths`; on desktop falls
187
- // back to the in-tree `test/reference-audio/jfk.wav`. The bundled
188
- // path is not readable from native code on iOS, which would trip
189
- // `ModelFileNotFound` the moment `model.load()` reaches the C++
190
- // `ChatterboxModel::validateConfig` check.
191
- const refWavPath = resolveRefWavPath({})
192
- if (!fs.existsSync(refWavPath)) {
193
- t.pass('Skipped: reference audio missing')
194
- return
195
- }
195
+ // Mobile-aware resolution: on iOS / Android the asset is staged into
196
+ // `Library/Caches/jfk.wav` via `global.assetPaths`; on desktop falls
197
+ // back to the in-tree `test/reference-audio/jfk.wav`. The bundled
198
+ // path is not readable from native code on iOS, which would trip
199
+ // `ModelFileNotFound` the moment `model.load()` reaches the C++
200
+ // `ChatterboxModel::validateConfig` check.
201
+ const refWavPath = resolveRefWavPath({})
202
+ if (!fs.existsSync(refWavPath)) {
203
+ t.pass('Skipped: reference audio missing')
204
+ return
205
+ }
196
206
 
197
- const model = await loadChatterboxTTS({
198
- modelDir: download.targetDir,
199
- refWavPath,
200
- language: 'en'
201
- })
202
- try {
203
- const r1 = await runChatterboxTTS(
204
- model,
205
- { text: 'First run before reload.' },
206
- { minSamples: 5000 }
207
- )
208
- t.ok(r1.passed, 'first run before reload should pass')
207
+ const model = await loadChatterboxTTS({
208
+ threads: TTS_TEST_THREADS,
209
+ modelDir: download.targetDir,
210
+ refWavPath,
211
+ language: 'en',
212
+ useGPU: GPU_ONLY_USE_GPU
213
+ })
214
+ try {
215
+ const r1 = await runChatterboxTTS(
216
+ model,
217
+ { text: 'First run before reload.' },
218
+ { minSamples: 5000 }
219
+ )
220
+ t.ok(r1.passed, 'first run before reload should pass')
209
221
 
210
- await model.reload({ language: 'en' })
211
- t.pass('reload() resolved')
222
+ await model.reload({ language: 'en' })
223
+ t.pass('reload() resolved')
212
224
 
213
- const r2 = await runChatterboxTTS(
214
- model,
215
- { text: 'Second run after reload.' },
216
- { minSamples: 5000 }
217
- )
218
- t.ok(r2.passed, 'second run after reload should pass')
219
- t.ok(r2.data.sampleCount > 0, 'reloaded model produces audio')
220
- } finally {
221
- try {
222
- await model.unload()
223
- } catch (_e) {}
225
+ const r2 = await runChatterboxTTS(
226
+ model,
227
+ { text: 'Second run after reload.' },
228
+ { minSamples: 5000 }
229
+ )
230
+ t.ok(r2.passed, 'second run after reload should pass')
231
+ t.ok(r2.data.sampleCount > 0, 'reloaded model produces audio')
232
+ } finally {
233
+ try {
234
+ await model.unload()
235
+ } catch (_e) {}
236
+ }
224
237
  }
225
- })
238
+ )
226
239
 
227
240
  test('Supertonic: reload() between runs preserves stability', { timeout: 1800000 }, async (t) => {
228
241
  const baseDir = getBaseDir()
@@ -235,6 +248,7 @@ test('Supertonic: reload() between runs preserves stability', { timeout: 1800000
235
248
  }
236
249
 
237
250
  const model = await loadSupertonicTTS({
251
+ threads: TTS_TEST_THREADS,
238
252
  supertonicModelPath: download.path,
239
253
  voice: 'F1',
240
254
  language: 'en',
@@ -267,7 +281,7 @@ test('Supertonic: reload() between runs preserves stability', { timeout: 1800000
267
281
 
268
282
  test(
269
283
  'Engine swap: chatterbox -> supertonic -> chatterbox in separate instances',
270
- { timeout: 1800000 },
284
+ { timeout: 1800000, skip: NO_GPU },
271
285
  async (t) => {
272
286
  const baseDir = getBaseDir()
273
287
  const cb = await ensureChatterboxModels({ targetDir: path.join(baseDir, 'models') })
@@ -291,7 +305,13 @@ test(
291
305
  return
292
306
  }
293
307
 
294
- const c1 = await loadChatterboxTTS({ modelDir: cb.targetDir, refWavPath, language: 'en' })
308
+ const c1 = await loadChatterboxTTS({
309
+ threads: TTS_TEST_THREADS,
310
+ modelDir: cb.targetDir,
311
+ refWavPath,
312
+ language: 'en',
313
+ useGPU: GPU_ONLY_USE_GPU
314
+ })
295
315
  try {
296
316
  const r = await runChatterboxTTS(c1, { text: 'Hello from chatterbox.' }, { minSamples: 5000 })
297
317
  t.ok(r.passed, 'first chatterbox instance OK')
@@ -302,10 +322,11 @@ test(
302
322
  }
303
323
 
304
324
  const s1 = await loadSupertonicTTS({
325
+ threads: TTS_TEST_THREADS,
305
326
  supertonicModelPath: st.path,
306
327
  voice: 'F1',
307
328
  language: 'en',
308
- useGPU: false
329
+ useGPU: GPU_ONLY_USE_GPU
309
330
  })
310
331
  try {
311
332
  const r = await runSupertonicTTS(s1, { text: 'Hello from supertonic.' }, { minSamples: 5000 })
@@ -316,7 +337,13 @@ test(
316
337
  } catch (_e) {}
317
338
  }
318
339
 
319
- const c2 = await loadChatterboxTTS({ modelDir: cb.targetDir, refWavPath, language: 'en' })
340
+ const c2 = await loadChatterboxTTS({
341
+ threads: TTS_TEST_THREADS,
342
+ modelDir: cb.targetDir,
343
+ refWavPath,
344
+ language: 'en',
345
+ useGPU: GPU_ONLY_USE_GPU
346
+ })
320
347
  try {
321
348
  const r = await runChatterboxTTS(
322
349
  c2,
@@ -12,6 +12,8 @@ const test = require('brittle')
12
12
  const TTSGgml = require('@qvac/tts-ggml')
13
13
 
14
14
  const { ensureSupertonicModel, ensureParlerModel } = require('../utils/downloadModel')
15
+ const { TTS_TEST_THREADS } = require('../utils/testThreads')
16
+ const { SKIP_PARLER_GPU_ONLY, PARLER_GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
15
17
 
16
18
  const platform = os.platform()
17
19
  const isMobile = platform === 'ios' || platform === 'android'
@@ -40,6 +42,7 @@ async function collect(model, text) {
40
42
  // JS half, which must forward the combination rather than reject it up front.
41
43
  test('CosyVoice3: enhancer + streaming forwards a non-native outputSampleRate', (t) => {
42
44
  const model = new TTSGgml({
45
+ threads: TTS_TEST_THREADS,
43
46
  engine: TTSGgml.ENGINE_COSYVOICE3,
44
47
  files: {
45
48
  cosyvoiceModelDir: './models/cosyvoice3',
@@ -72,6 +75,7 @@ test(
72
75
  const text = 'Output rate selection resamples the synthesized audio.'
73
76
 
74
77
  const native = new TTSGgml({
78
+ threads: TTS_TEST_THREADS,
75
79
  engine: TTSGgml.ENGINE_SUPERTONIC,
76
80
  files: { supertonicModel: dl.path },
77
81
  voice: 'F1',
@@ -91,6 +95,7 @@ test(
91
95
  }
92
96
 
93
97
  const resampled = new TTSGgml({
98
+ threads: TTS_TEST_THREADS,
94
99
  engine: TTSGgml.ENGINE_SUPERTONIC,
95
100
  files: { supertonicModel: dl.path },
96
101
  voice: 'F1',
@@ -119,7 +124,7 @@ test(
119
124
 
120
125
  test(
121
126
  'Parler: outputSampleRate=16000 resamples and reports 16 kHz',
122
- { timeout: 600000 },
127
+ { timeout: 600000, skip: SKIP_PARLER_GPU_ONLY },
123
128
  async (t) => {
124
129
  const baseDir = getBaseDir()
125
130
  const dl = await ensureParlerModel({ targetDir: path.join(baseDir, 'models') })
@@ -131,10 +136,12 @@ test(
131
136
  const text = 'Output rate selection resamples the synthesized audio.'
132
137
 
133
138
  const native = new TTSGgml({
139
+ threads: TTS_TEST_THREADS,
134
140
  engine: TTSGgml.ENGINE_PARLER,
135
141
  files: { parlerModel: dl.path },
136
142
  voice: 'Laura',
137
143
  seed: 42,
144
+ config: { useGPU: PARLER_GPU_ONLY_USE_GPU },
138
145
  opts: { stats: true }
139
146
  })
140
147
  await native.load()
@@ -150,11 +157,12 @@ test(
150
157
  }
151
158
 
152
159
  const resampled = new TTSGgml({
160
+ threads: TTS_TEST_THREADS,
153
161
  engine: TTSGgml.ENGINE_PARLER,
154
162
  files: { parlerModel: dl.path },
155
163
  voice: 'Laura',
156
164
  seed: 42,
157
- config: { outputSampleRate: 16000 },
165
+ config: { outputSampleRate: 16000, useGPU: PARLER_GPU_ONLY_USE_GPU },
158
166
  opts: { stats: true }
159
167
  })
160
168
  await resampled.load()
@@ -7,9 +7,9 @@
7
7
  // chatterbox WER leg in addon.test.js (whisper ASR + wordErrorRate).
8
8
  //
9
9
  // macOS-desktop only (isDarwin): the whisper WER infra is desktop-only
10
- // across the tts-ggml suite. Runs on both the Metal GPU runner and the
11
- // no_gpu CPU runner (backend chosen like the quant loop). large-v1 is
12
- // RAM-gated; both tiers skip cleanly if their GGUF isn't staged.
10
+ // across the tts-ggml suite. Runs on the Metal GPU runner only and is skipped
11
+ // on CPU-only runners (NO_GPU=true). large-v1 is RAM-gated; both tiers skip
12
+ // cleanly if their GGUF isn't staged.
13
13
 
14
14
  const os = require('bare-os')
15
15
  const path = require('bare-path')
@@ -19,6 +19,7 @@ const test = require('brittle')
19
19
  const { loadParlerTTS, runParlerTTS } = require('../utils/runParlerTTS')
20
20
  const { ensureParlerModel, ensureWhisperModel } = require('../utils/downloadModel')
21
21
  const { loadWhisper, runWhisper } = require('../utils/runWhisper')
22
+ const { TTS_TEST_THREADS } = require('../utils/testThreads')
22
23
 
23
24
  const platform = os.platform()
24
25
  const isDarwin = platform === 'darwin'
@@ -26,10 +27,9 @@ const isApple = platform === 'darwin' || platform === 'ios'
26
27
  const NO_GPU = proc.env && proc.env.NO_GPU === 'true'
27
28
  const LARGE_MIN_RAM_BYTES = 16 * 1024 ** 3
28
29
 
29
- // Calibrated 2026-07-24: the CPU (no_gpu) runner is 0.0% WER at any seed; the
30
- // Metal/GPU runner diverges (FP reorder) — seed 42 is 0% on Apple-Silicon but
31
- // 22.2% on Intel-Mac Metal — so 0.35 tolerates the GPU variance while the CPU
32
- // leg stays effectively strict (greedy would be 50-100%, so this still asserts it).
30
+ // Calibrated 2026-07-24: Metal diverges by FP reorder — seed 42 is 0% on
31
+ // Apple-Silicon but 22.2% on Intel-Mac Metal — so 0.35 tolerates the GPU
32
+ // variance while still asserting the words (greedy would be 50-100%).
33
33
  const WER_THRESHOLD = 0.35
34
34
 
35
35
  // Clear English sentences, all >= 9 words (a single ASR slip stays <= ~0.11,
@@ -50,7 +50,7 @@ function getBaseDir() {
50
50
  for (const { variant } of PARLER_WER_TIERS) {
51
51
  test(
52
52
  `Parler WER (${variant}): English synthesis transcribes within ${(WER_THRESHOLD * 100).toFixed(0)}%`,
53
- { timeout: 1800000, skip: !isDarwin },
53
+ { timeout: 1800000, skip: !isDarwin || NO_GPU },
54
54
  async (t) => {
55
55
  if (variant === 'large' && os.totalmem() < LARGE_MIN_RAM_BYTES) {
56
56
  t.pass(
@@ -77,7 +77,12 @@ for (const { variant } of PARLER_WER_TIERS) {
77
77
  }
78
78
 
79
79
  const useGPU = isApple && !NO_GPU
80
- const model = await loadParlerTTS({ parlerModelPath: download.path, seed: 42, useGPU })
80
+ const model = await loadParlerTTS({
81
+ threads: TTS_TEST_THREADS,
82
+ parlerModelPath: download.path,
83
+ seed: 42,
84
+ useGPU
85
+ })
81
86
  const entries = []
82
87
  try {
83
88
  for (const text of WER_SENTENCES) {
@@ -7,17 +7,17 @@
7
7
 
8
8
  const os = require('bare-os')
9
9
  const path = require('bare-path')
10
- const proc = require('bare-process')
11
10
  const test = require('brittle')
12
11
 
13
12
  const { loadParlerTTS, runParlerTTS } = require('../utils/runParlerTTS')
14
13
  const { ensureParlerModel } = require('../utils/downloadModel')
15
14
  const { recordTtsStats } = require('../utils/perf-helper')
15
+ const { TTS_TEST_THREADS } = require('../utils/testThreads')
16
+ const { NO_GPU, SKIP_PARLER_GPU_ONLY, PARLER_GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
16
17
 
17
18
  const platform = os.platform()
18
19
  const isMobile = platform === 'ios' || platform === 'android'
19
20
  const isApple = platform === 'darwin' || platform === 'ios'
20
- const NO_GPU = proc.env && proc.env.NO_GPU === 'true'
21
21
 
22
22
  function getBaseDir() {
23
23
  return isMobile && global.testDir ? global.testDir : '.'
@@ -39,7 +39,11 @@ test(
39
39
 
40
40
  // Deliberately NO description/voice/emotion: the engine renders the
41
41
  // models' recommended fallback caption — everything works on defaults.
42
- const model = await loadParlerTTS({ parlerModelPath: download.path, seed: 42 })
42
+ const model = await loadParlerTTS({
43
+ threads: TTS_TEST_THREADS,
44
+ parlerModelPath: download.path,
45
+ seed: 42
46
+ })
43
47
  try {
44
48
  const wavPath = isMobile ? undefined : path.join(baseDir, 'test', 'output', 'parler-en.wav')
45
49
  const text = 'The parler engine speaks with a voice controlled by a text description.'
@@ -100,7 +104,11 @@ test(
100
104
  return
101
105
  }
102
106
 
103
- const model = await loadParlerTTS({ parlerModelPath: download.path, voice: 'Laura' })
107
+ const model = await loadParlerTTS({
108
+ threads: TTS_TEST_THREADS,
109
+ parlerModelPath: download.path,
110
+ voice: 'Laura'
111
+ })
104
112
  try {
105
113
  const response = await model.run({
106
114
  type: 'text',
@@ -128,7 +136,7 @@ test(
128
136
 
129
137
  test(
130
138
  'Parler TTS (ggml): description matrix — conflicts throw, per-call wins, defaults work',
131
- { timeout: 600000 },
139
+ { timeout: 600000, skip: SKIP_PARLER_GPU_ONLY },
132
140
  async (t) => {
133
141
  const baseDir = getBaseDir()
134
142
  const download = await ensureParlerModel({ targetDir: path.join(baseDir, 'models') })
@@ -143,6 +151,7 @@ test(
143
151
  t.exception(
144
152
  () =>
145
153
  new TTSGgml({
154
+ threads: TTS_TEST_THREADS,
146
155
  engine: TTSGgml.ENGINE_PARLER,
147
156
  files: { parlerModel: download.path },
148
157
  description: 'A calm female voice.',
@@ -157,6 +166,7 @@ test(
157
166
  t.exception(
158
167
  () =>
159
168
  new TTSGgml({
169
+ threads: TTS_TEST_THREADS,
160
170
  engine: TTSGgml.ENGINE_SUPERTONIC,
161
171
  files: { supertonicModel: download.path },
162
172
  emotion: 'happy'
@@ -167,6 +177,7 @@ test(
167
177
  t.exception(
168
178
  () =>
169
179
  new TTSGgml({
180
+ threads: TTS_TEST_THREADS,
170
181
  engine: TTSGgml.ENGINE_SUPERTONIC,
171
182
  files: { supertonicModel: download.path },
172
183
  pitch: 'high'
@@ -176,9 +187,11 @@ test(
176
187
  )
177
188
 
178
189
  const model = await loadParlerTTS({
190
+ threads: TTS_TEST_THREADS,
179
191
  parlerModelPath: download.path,
180
192
  voice: 'Laura',
181
- seed: 42
193
+ seed: 42,
194
+ useGPU: PARLER_GPU_ONLY_USE_GPU
182
195
  })
183
196
  try {
184
197
  // Per-call description conflict (same level) rejects.
@@ -218,9 +231,11 @@ test(
218
231
 
219
232
  // Constructor-level free-text description + per-call template rejects.
220
233
  const descModel = await loadParlerTTS({
234
+ threads: TTS_TEST_THREADS,
221
235
  parlerModelPath: download.path,
222
236
  description: 'A calm female voice, very clear audio.',
223
- seed: 42
237
+ seed: 42,
238
+ useGPU: PARLER_GPU_ONLY_USE_GPU
224
239
  })
225
240
  try {
226
241
  await t.exception(
@@ -238,7 +253,7 @@ test(
238
253
 
239
254
  test(
240
255
  'Parler TTS (ggml): emotion flag changes the audio; per-call switch needs no reload',
241
- { timeout: 600000 },
256
+ { timeout: 600000, skip: SKIP_PARLER_GPU_ONLY },
242
257
  async (t) => {
243
258
  const baseDir = getBaseDir()
244
259
  const download = await ensureParlerModel({ targetDir: path.join(baseDir, 'models') })
@@ -248,9 +263,11 @@ test(
248
263
  }
249
264
 
250
265
  const model = await loadParlerTTS({
266
+ threads: TTS_TEST_THREADS,
251
267
  parlerModelPath: download.path,
252
268
  voice: 'Laura',
253
- seed: 42
269
+ seed: 42,
270
+ useGPU: PARLER_GPU_ONLY_USE_GPU
254
271
  })
255
272
  try {
256
273
  const text = 'The weather is wonderful today.'
@@ -285,7 +302,7 @@ test(
285
302
 
286
303
  test(
287
304
  'Parler TTS (ggml): fixed seed is deterministic across runs and reload',
288
- { timeout: 600000 },
305
+ { timeout: 600000, skip: SKIP_PARLER_GPU_ONLY },
289
306
  async (t) => {
290
307
  const baseDir = getBaseDir()
291
308
  const download = await ensureParlerModel({ targetDir: path.join(baseDir, 'models') })
@@ -295,10 +312,12 @@ test(
295
312
  }
296
313
 
297
314
  const model = await loadParlerTTS({
315
+ threads: TTS_TEST_THREADS,
298
316
  parlerModelPath: download.path,
299
317
  voice: 'Laura',
300
318
  emotion: 'neutral',
301
- seed: 42
319
+ seed: 42,
320
+ useGPU: PARLER_GPU_ONLY_USE_GPU
302
321
  })
303
322
  try {
304
323
  const text = 'Deterministic synthesis check.'
@@ -329,7 +348,7 @@ test(
329
348
 
330
349
  test(
331
350
  'Parler TTS (ggml): runStream emits per-sentence chunks with one pinned description',
332
- { timeout: 600000 },
351
+ { timeout: 600000, skip: SKIP_PARLER_GPU_ONLY },
333
352
  async (t) => {
334
353
  const baseDir = getBaseDir()
335
354
  const download = await ensureParlerModel({ targetDir: path.join(baseDir, 'models') })
@@ -340,9 +359,11 @@ test(
340
359
 
341
360
  const TTSGgml = require('@qvac/tts-ggml')
342
361
  const model = new TTSGgml({
362
+ threads: TTS_TEST_THREADS,
343
363
  engine: TTSGgml.ENGINE_PARLER,
344
364
  files: { parlerModel: download.path },
345
365
  seed: 42,
366
+ config: { useGPU: PARLER_GPU_ONLY_USE_GPU },
346
367
  opts: { stats: true }
347
368
  })
348
369
  await model.load()
@@ -376,7 +397,7 @@ test(
376
397
 
377
398
  test(
378
399
  'Parler TTS (ggml): native streaming emits monotonic chunks equal to batch',
379
- { timeout: 900000 },
400
+ { timeout: 900000, skip: SKIP_PARLER_GPU_ONLY },
380
401
  async (t) => {
381
402
  const baseDir = getBaseDir()
382
403
  const download = await ensureParlerModel({ targetDir: path.join(baseDir, 'models') })
@@ -392,6 +413,7 @@ test(
392
413
 
393
414
  async function synth(extra) {
394
415
  const model = new TTSGgml({
416
+ threads: TTS_TEST_THREADS,
395
417
  engine: TTSGgml.ENGINE_PARLER,
396
418
  files: { parlerModel: download.path },
397
419
  seed: 42,
@@ -481,6 +503,7 @@ test(
481
503
  }
482
504
 
483
505
  const model = await loadParlerTTS({
506
+ threads: TTS_TEST_THREADS,
484
507
  parlerModelPath: download.path,
485
508
  voice: 'Rohit',
486
509
  emotion: 'happy',
@@ -507,22 +530,24 @@ test(
507
530
 
508
531
  // Desktop quant + backend coverage: q8_0 + f16 for indic/mini, q8_0 for large.
509
532
  // Runs on the platform's GPU-if-available backend (Metal on Apple GPU runners,
510
- // CPU otherwise). large self-gates on RAM; any tier that isn't staged skips
511
- // (t.pass) so a not-yet-registered tier never fails CI. Registered per PR #3372.
512
- // Desktop-only: the mobile Parler surface is covered by the gpu-smoke legs.
533
+ // CPU otherwise). mini f16 and large are the slowest tiers on CPU (roughly
534
+ // 45-80 s per synthesis), so they run on Metal only. large self-gates on RAM; any tier that isn't
535
+ // staged skips (t.pass) so a not-yet-registered tier never fails CI. Registered
536
+ // per PR #3372. Desktop-only: the mobile Parler surface is covered by the
537
+ // gpu-smoke legs.
513
538
  const PARLER_QUANT_MATRIX = [
514
- { variant: 'indic', quant: 'q8_0' },
515
- { variant: 'indic', quant: 'f16' },
516
- { variant: 'mini', quant: 'q8_0' },
517
- { variant: 'mini', quant: 'f16' },
518
- { variant: 'large', quant: 'q8_0' }
539
+ { variant: 'indic', quant: 'q8_0', gpuOnly: false },
540
+ { variant: 'indic', quant: 'f16', gpuOnly: false },
541
+ { variant: 'mini', quant: 'q8_0', gpuOnly: false },
542
+ { variant: 'mini', quant: 'f16', gpuOnly: true },
543
+ { variant: 'large', quant: 'q8_0', gpuOnly: true }
519
544
  ]
520
545
  const LARGE_MIN_RAM_BYTES = 16 * 1024 ** 3
521
546
 
522
- for (const { variant, quant } of PARLER_QUANT_MATRIX) {
547
+ for (const { variant, quant, gpuOnly } of PARLER_QUANT_MATRIX) {
523
548
  test(
524
549
  `Parler TTS (ggml): ${variant} ${quant} synthesizes 44.1 kHz audio on the target backend`,
525
- { timeout: 900000, skip: isMobile },
550
+ { timeout: 900000, skip: isMobile || (gpuOnly && SKIP_PARLER_GPU_ONLY) },
526
551
  async (t) => {
527
552
  if (variant === 'large' && os.totalmem() < LARGE_MIN_RAM_BYTES) {
528
553
  t.pass(
@@ -542,7 +567,12 @@ for (const { variant, quant } of PARLER_QUANT_MATRIX) {
542
567
  }
543
568
 
544
569
  const useGPU = isApple && !NO_GPU
545
- const model = await loadParlerTTS({ parlerModelPath: download.path, seed: 42, useGPU })
570
+ const model = await loadParlerTTS({
571
+ threads: TTS_TEST_THREADS,
572
+ parlerModelPath: download.path,
573
+ seed: 42,
574
+ useGPU
575
+ })
546
576
  try {
547
577
  const text =
548
578
  variant === 'indic'