@qvac/tts-ggml 0.10.0 → 0.11.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.
@@ -0,0 +1,63 @@
1
+ 'use strict'
2
+
3
+ const test = require('brittle')
4
+ const process = require('bare-process')
5
+ const TTSGgml = require('@qvac/tts-ggml')
6
+
7
+ function request(overrides = {}) {
8
+ return {
9
+ engineType: 'moss-sfx',
10
+ mossSoundEffectPath: '/nonexistent/moss-sfx.gguf',
11
+ prompt: 'Rain on a tin roof.',
12
+ seconds: 8,
13
+ useGPU: false,
14
+ ...overrides
15
+ }
16
+ }
17
+
18
+ test('MOSS sound-effect fit rejects missing workload and invalid load controls', (t) => {
19
+ t.exception(() => TTSGgml.assessFit(request({ prompt: undefined })))
20
+ t.exception(() => TTSGgml.assessFit(request({ seconds: undefined })))
21
+ t.exception(() => TTSGgml.assessFit(request({ seconds: NaN })))
22
+ t.exception(() => TTSGgml.assessFit(request({ threads: -1 })))
23
+ t.exception(() => TTSGgml.assessFit(request({ useGPU: true, nGpuLayers: 0 })))
24
+ t.exception(() => TTSGgml.assessFit(request({ marginBytes: -1 })))
25
+ })
26
+
27
+ test('MOSS sound-effect fit reports unreadable models', (t) => {
28
+ const fit = TTSGgml.assessFit(request())
29
+ t.is(fit.status, 'error')
30
+ t.is(fit.reason, 'model-unreadable')
31
+ t.is(fit.modelVariant, 'moss-sfx')
32
+ })
33
+
34
+ test(
35
+ 'MOSS sound-effect fit projects real-model CPU workloads',
36
+ {
37
+ skip: !process.env.QVAC_TEST_MOSS_SFX_GGUF
38
+ },
39
+ (t) => {
40
+ const model = process.env.QVAC_TEST_MOSS_SFX_GGUF
41
+ const cfg = { mossSoundEffectPath: model }
42
+ const fit = TTSGgml.assessFit(request(cfg))
43
+ t.ok(fit.status === 'fits' || fit.status === 'does-not-fit')
44
+ t.is(fit.modelVariant, 'moss-sfx')
45
+ t.ok(fit.deviceIsCpu)
46
+ t.ok(fit.deviceSharesHostMemory)
47
+ t.ok(fit.weightsBytes > 0)
48
+ t.ok(fit.lmComputeBytes > 0)
49
+ t.ok(fit.hostBytes > 0)
50
+ t.is(
51
+ fit.deviceBytes,
52
+ fit.weightsBytes + fit.stateBytes + fit.lmComputeBytes + fit.codecComputeBytes
53
+ )
54
+ const shorter = TTSGgml.assessFit(request({ ...cfg, seconds: 1 }))
55
+ t.is(shorter.weightsBytes, fit.weightsBytes)
56
+ t.is(shorter.lmComputeBytes, fit.lmComputeBytes)
57
+ const noHeadroom = TTSGgml.assessFit(request({ ...cfg, marginBytes: Number.MAX_VALUE }))
58
+ t.is(noHeadroom.status, 'does-not-fit')
59
+ const invalid = TTSGgml.assessFit(request({ ...cfg, seconds: 0 }))
60
+ t.is(invalid.status, 'error')
61
+ t.is(invalid.reason, 'invalid-arguments')
62
+ }
63
+ )
@@ -0,0 +1,96 @@
1
+ 'use strict'
2
+
3
+ const path = require('bare-path')
4
+ const proc = require('bare-process')
5
+ const test = require('brittle')
6
+
7
+ const TTSGgml = require('@qvac/tts-ggml')
8
+
9
+ const SFX_SAMPLE_RATE = 48000
10
+ const TENTHS_PER_SECOND = 10
11
+ const TEST_TIMEOUT_MS = 1800000
12
+ const PROMPT = 'A glass falls and shatters on a tile floor.'
13
+ const MODEL_DIR_ENV = 'QVAC_TEST_MOSS_SFX_MODEL_DIR'
14
+ const QUICK_STEPS = 2
15
+ const SHORT_SECONDS = 1.5
16
+ const LONGER_SECONDS = 2.5
17
+ const CPU_DEVICE = 0
18
+ const GPU_DEVICE = 1
19
+
20
+ const modelDir = (proc.env && proc.env[MODEL_DIR_ENV]) || ''
21
+ const skipWithoutModels = modelDir === ''
22
+ const noGpu = proc.env && proc.env.NO_GPU === 'true'
23
+
24
+ function createSfxModel(useGPU = false) {
25
+ return new TTSGgml({
26
+ engine: TTSGgml.ENGINE_MOSS_SFX,
27
+ files: { modelDir: path.resolve(modelDir) },
28
+ config: { useGPU },
29
+ seed: 0,
30
+ opts: { stats: true }
31
+ })
32
+ }
33
+
34
+ function collectChunk(result, data) {
35
+ if (!data || !data.outputArray) return
36
+ result.samples += data.outputArray.length
37
+ if (data.sampleRate) result.sampleRate = data.sampleRate
38
+ }
39
+
40
+ async function generate(model, fields) {
41
+ const response = await model.run({ input: PROMPT, type: 'text', steps: QUICK_STEPS, ...fields })
42
+ const result = { samples: 0, sampleRate: null }
43
+ await response.onUpdate((data) => collectChunk(result, data)).await()
44
+ result.stats = response.stats
45
+ return result
46
+ }
47
+
48
+ function samplesAtNativeDuration(seconds) {
49
+ return Math.round(seconds * TENTHS_PER_SECOND) * (SFX_SAMPLE_RATE / TENTHS_PER_SECOND)
50
+ }
51
+
52
+ function assertClip(t, label, result, seconds) {
53
+ t.is(result.sampleRate, SFX_SAMPLE_RATE, `${label} reports 48 kHz`)
54
+ t.is(
55
+ result.samples,
56
+ samplesAtNativeDuration(seconds),
57
+ `${label} lasts exactly ${seconds} s, rounded to 0.1 s as the engine documents`
58
+ )
59
+ t.ok(result.stats, `${label} returns runtime stats`)
60
+ t.is(result.stats.totalSamples, result.samples, `${label} reports the emitted samples`)
61
+ t.ok(result.stats.totalTime > 0, `${label} reports its generation time`)
62
+ }
63
+
64
+ test(
65
+ 'MOSS-SFX integration: a prompt generates the requested duration on CPU',
66
+ { timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels },
67
+ async (t) => {
68
+ const model = createSfxModel()
69
+ await model.load()
70
+ try {
71
+ const result = await generate(model, { seconds: SHORT_SECONDS })
72
+ assertClip(t, 'cpu clip', result, SHORT_SECONDS)
73
+ t.is(result.stats.backendDevice, CPU_DEVICE, 'the clip ran on the CPU')
74
+ } finally {
75
+ await model.unload()
76
+ }
77
+ }
78
+ )
79
+
80
+ test(
81
+ 'MOSS-SFX integration: seconds and the negative prompt change per call',
82
+ { timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels || noGpu },
83
+ async (t) => {
84
+ const model = createSfxModel(true)
85
+ await model.load()
86
+ try {
87
+ const short = await generate(model, { seconds: SHORT_SECONDS })
88
+ const longer = await generate(model, { seconds: LONGER_SECONDS, negativePrompt: 'music' })
89
+ assertClip(t, 'short clip', short, SHORT_SECONDS)
90
+ assertClip(t, 'longer clip', longer, LONGER_SECONDS)
91
+ t.is(short.stats.backendDevice, GPU_DEVICE, 'the clips ran on the GPU')
92
+ } finally {
93
+ await model.unload()
94
+ }
95
+ }
96
+ )
@@ -0,0 +1,110 @@
1
+ 'use strict'
2
+
3
+ const path = require('bare-path')
4
+ const proc = require('bare-process')
5
+ const test = require('brittle')
6
+
7
+ const TTSGgml = require('@qvac/tts-ggml')
8
+ const { resolveRefWavPath } = require('../utils/runChatterboxTTS')
9
+ const { readWavAsFloat32 } = require('../utils/wav-helper')
10
+ const { TTS_TEST_THREADS } = require('../utils/testThreads')
11
+
12
+ const SPEECH_SAMPLE_RATE = 24000
13
+ const TEST_TIMEOUT_MS = 1800000
14
+ const MODEL_DIR_ENV = 'QVAC_TEST_MOSS_SPEECH_MODEL_DIR'
15
+ const MAX_NEW_TOKENS = 120
16
+ const CUT_SECONDS = 2
17
+ const CUT_MARGIN_SECONDS = 0.5
18
+ const CPU_DEVICE = 0
19
+ const GPU_DEVICE = 1
20
+
21
+ const modelDir = (proc.env && proc.env[MODEL_DIR_ENV]) || ''
22
+ const skipWithoutModels = modelDir === ''
23
+ const noGpu = proc.env && proc.env.NO_GPU === 'true'
24
+
25
+ function createSpeechModel(useGPU = false) {
26
+ return new TTSGgml({
27
+ threads: TTS_TEST_THREADS,
28
+ engine: TTSGgml.ENGINE_MOSS_SPEECH,
29
+ files: { modelDir: path.resolve(modelDir) },
30
+ config: { useGPU },
31
+ seed: 0,
32
+ opts: { stats: true }
33
+ })
34
+ }
35
+
36
+ function spokenQuestion() {
37
+ const wav = readWavAsFloat32(resolveRefWavPath({}))
38
+ return { audio: wav.samples, sampleRate: wav.sampleRate }
39
+ }
40
+
41
+ function collectReply(result, data) {
42
+ if (!data || !data.outputArray) return
43
+ result.samples += data.outputArray.length
44
+ if (data.sampleRate) result.sampleRate = data.sampleRate
45
+ if (typeof data.text === 'string') result.text += data.text
46
+ }
47
+
48
+ async function respond(model, fields) {
49
+ const response = await model.run({
50
+ type: 'text',
51
+ input: '',
52
+ maxNewTokens: MAX_NEW_TOKENS,
53
+ ...fields
54
+ })
55
+ const result = { samples: 0, sampleRate: null, text: '' }
56
+ await response.onUpdate((data) => collectReply(result, data)).await()
57
+ result.stats = response.stats
58
+ return result
59
+ }
60
+
61
+ function assertSpokenReply(t, label, result) {
62
+ t.is(result.sampleRate, SPEECH_SAMPLE_RATE, `${label} reports 24 kHz`)
63
+ t.ok(result.samples > 0, `${label} carries speech`)
64
+ t.ok(result.stats, `${label} returns runtime stats`)
65
+ t.is(result.stats.totalSamples, result.samples, `${label} reports the emitted samples`)
66
+ t.ok(result.stats.promptTokens > 0, `${label} reports its prompt length`)
67
+ }
68
+
69
+ test(
70
+ 'MOSS-Speech integration: a spoken question gets a spoken answer on CPU',
71
+ { timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels },
72
+ async (t) => {
73
+ const model = createSpeechModel()
74
+ await model.load()
75
+ try {
76
+ const result = await respond(model, spokenQuestion())
77
+ assertSpokenReply(t, 'cpu reply', result)
78
+ t.is(result.stats.backendDevice, CPU_DEVICE, 'the reply ran on the CPU')
79
+ } finally {
80
+ await model.unload()
81
+ }
82
+ }
83
+ )
84
+
85
+ test(
86
+ 'MOSS-Speech integration: text replies, reply cuts and a text turn on the GPU',
87
+ { timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels || noGpu },
88
+ async (t) => {
89
+ const model = createSpeechModel(true)
90
+ await model.load()
91
+ try {
92
+ const textReply = await respond(model, { ...spokenQuestion(), textReply: true })
93
+ t.is(textReply.samples, 0, 'a text reply carries no audio')
94
+ t.ok(textReply.text.trim().length > 0, 'a text reply carries the answer')
95
+
96
+ const cut = await respond(model, { ...spokenQuestion(), maxReplySeconds: CUT_SECONDS })
97
+ assertSpokenReply(t, 'cut reply', cut)
98
+ t.ok(
99
+ cut.samples <= (CUT_SECONDS + CUT_MARGIN_SECONDS) * SPEECH_SAMPLE_RATE,
100
+ 'maxReplySeconds bounds the spoken reply'
101
+ )
102
+ t.is(cut.stats.backendDevice, GPU_DEVICE, 'the replies ran on the GPU')
103
+
104
+ const typed = await respond(model, { input: 'What is the capital of France?' })
105
+ assertSpokenReply(t, 'reply to a typed question', typed)
106
+ } finally {
107
+ await model.unload()
108
+ }
109
+ }
110
+ )
@@ -11,6 +11,7 @@ const { recordTtsStats } = require('../utils/perf-helper')
11
11
  const { resolveRefWavPath } = require('../utils/runChatterboxTTS')
12
12
  const { readWavAsFloat32, createWav, resampleLinear } = require('../utils/wav-helper')
13
13
  const { TTS_TEST_THREADS } = require('../utils/testThreads')
14
+ const { NO_GPU, GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
14
15
 
15
16
  const MOSS_SAMPLE_RATE = 24000
16
17
  const MOSS_STREAM_FRAMES = 10
@@ -34,7 +35,6 @@ const GPU_DEVICE = 1
34
35
 
35
36
  const modelDir = (proc.env && proc.env[MODEL_DIR_ENV]) || ''
36
37
  const skipWithoutModels = modelDir === ''
37
- const noGpu = proc.env && proc.env.NO_GPU === 'true'
38
38
 
39
39
  function createMossModel(extra = {}, useGPU = false) {
40
40
  return new TTSGgml({
@@ -184,7 +184,7 @@ test(
184
184
 
185
185
  test(
186
186
  'MOSS TTS: useGPU=true synthesizes on a GPU backend',
187
- { timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels || noGpu },
187
+ { timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels || NO_GPU },
188
188
  async (t) => {
189
189
  await withLoadedModel(
190
190
  {},
@@ -231,25 +231,29 @@ test(
231
231
 
232
232
  test(
233
233
  'MOSS TTS: durationTokens steers the length of the synthesized speech',
234
- { timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels },
234
+ { timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels || NO_GPU },
235
235
  async (t) => {
236
- await withLoadedModel({ durationTokens: SHORT_DURATION_TOKENS, seed: 7 }, async (model) => {
237
- const short = await synthesize(model, SYNTHESIS_TEXT)
238
- assertAudio(t, 'MOSS short target', short)
239
- await model.reload({ durationTokens: LONG_DURATION_TOKENS })
240
- const long = await synthesize(model, SYNTHESIS_TEXT)
241
- assertAudio(t, 'MOSS long target', long)
242
- t.ok(
243
- long.samples.length > short.samples.length,
244
- `a longer target yields longer speech (${short.samples.length} -> ${long.samples.length} samples)`
245
- )
246
- })
236
+ await withLoadedModel(
237
+ { durationTokens: SHORT_DURATION_TOKENS, seed: 7 },
238
+ async (model) => {
239
+ const short = await synthesize(model, SYNTHESIS_TEXT)
240
+ assertAudio(t, 'MOSS short target', short)
241
+ await model.reload({ durationTokens: LONG_DURATION_TOKENS })
242
+ const long = await synthesize(model, SYNTHESIS_TEXT)
243
+ assertAudio(t, 'MOSS long target', long)
244
+ t.ok(
245
+ long.samples.length > short.samples.length,
246
+ `a longer target yields longer speech (${short.samples.length} -> ${long.samples.length} samples)`
247
+ )
248
+ },
249
+ GPU_ONLY_USE_GPU
250
+ )
247
251
  }
248
252
  )
249
253
 
250
254
  test(
251
255
  'MOSS TTS: dialogue synthesis clones one reference per speaker',
252
- { timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels },
256
+ { timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels || NO_GPU },
253
257
  async (t) => {
254
258
  if (!hasDialogueBackbone() || !hasEncoder()) {
255
259
  t.comment(`skipping: the TTSD backbone or the codec encoder is not in ${modelDir}`)
@@ -273,7 +277,8 @@ test(
273
277
  const result = await synthesize(model, DIALOGUE_TEXT)
274
278
  assertAudio(t, 'MOSS dialogue', result)
275
279
  recordMoss(t, 'moss dialogue', result, Date.now() - started)
276
- }
280
+ },
281
+ GPU_ONLY_USE_GPU
277
282
  )
278
283
  } finally {
279
284
  removeFiles(references)
@@ -30,13 +30,15 @@ const {
30
30
  const { loadSupertonicTTS, runSupertonicTTS } = require('../utils/runSupertonicTTS')
31
31
  const { ensureChatterboxModels, ensureSupertonicModel } = require('../utils/downloadModel')
32
32
  const { TTS_TEST_THREADS } = require('../utils/testThreads')
33
+ const { NO_GPU, GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
33
34
 
34
35
  const platform = os.platform()
35
36
  const isMobile = platform === 'ios' || platform === 'android'
36
37
 
37
- // Lifecycle / sequential-run test, not a GPU policy test: rely on the
38
- // package default (`useGPU: false`) rather than opting into GPU here.
39
- // 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.
40
42
 
41
43
  function getBaseDir() {
42
44
  return isMobile && global.testDir ? global.testDir : '.'
@@ -50,7 +52,7 @@ const PHRASES = [
50
52
 
51
53
  test(
52
54
  'Chatterbox: multiple sequential runs reuse the same engine instance',
53
- { timeout: 1800000 },
55
+ { timeout: 1800000, skip: NO_GPU },
54
56
  async (t) => {
55
57
  const baseDir = getBaseDir()
56
58
  const download = await ensureChatterboxModels({ targetDir: path.join(baseDir, 'models') })
@@ -77,7 +79,8 @@ test(
77
79
  threads: TTS_TEST_THREADS,
78
80
  modelDir: download.targetDir,
79
81
  refWavPath,
80
- language: 'en'
82
+ language: 'en',
83
+ useGPU: GPU_ONLY_USE_GPU
81
84
  })
82
85
  try {
83
86
  const timings = []
@@ -111,7 +114,7 @@ test(
111
114
 
112
115
  test(
113
116
  'Chatterbox: fresh instance per run (app-restart simulation)',
114
- { timeout: 1800000 },
117
+ { timeout: 1800000, skip: NO_GPU },
115
118
  async (t) => {
116
119
  const baseDir = getBaseDir()
117
120
  const download = await ensureChatterboxModels({ targetDir: path.join(baseDir, 'models') })
@@ -142,7 +145,8 @@ test(
142
145
  threads: TTS_TEST_THREADS,
143
146
  modelDir: download.targetDir,
144
147
  refWavPath,
145
- language: 'en'
148
+ language: 'en',
149
+ useGPU: GPU_ONLY_USE_GPU
146
150
  })
147
151
  const loadMs = Date.now() - t0
148
152
  try {
@@ -175,58 +179,63 @@ test(
175
179
  }
176
180
  )
177
181
 
178
- test('Chatterbox: reload() between runs preserves stability', { timeout: 1800000 }, async (t) => {
179
- const baseDir = getBaseDir()
180
- const download = await ensureChatterboxModels({ targetDir: path.join(baseDir, 'models') })
181
- if (!download.success) {
182
- t.fail(
183
- 'Chatterbox GGUFs not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.'
184
- )
185
- return
186
- }
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
+ }
187
194
 
188
- // Mobile-aware resolution: on iOS / Android the asset is staged into
189
- // `Library/Caches/jfk.wav` via `global.assetPaths`; on desktop falls
190
- // back to the in-tree `test/reference-audio/jfk.wav`. The bundled
191
- // path is not readable from native code on iOS, which would trip
192
- // `ModelFileNotFound` the moment `model.load()` reaches the C++
193
- // `ChatterboxModel::validateConfig` check.
194
- const refWavPath = resolveRefWavPath({})
195
- if (!fs.existsSync(refWavPath)) {
196
- t.pass('Skipped: reference audio missing')
197
- return
198
- }
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
+ }
199
206
 
200
- const model = await loadChatterboxTTS({
201
- threads: TTS_TEST_THREADS,
202
- modelDir: download.targetDir,
203
- refWavPath,
204
- language: 'en'
205
- })
206
- try {
207
- const r1 = await runChatterboxTTS(
208
- model,
209
- { text: 'First run before reload.' },
210
- { minSamples: 5000 }
211
- )
212
- 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')
213
221
 
214
- await model.reload({ language: 'en' })
215
- t.pass('reload() resolved')
222
+ await model.reload({ language: 'en' })
223
+ t.pass('reload() resolved')
216
224
 
217
- const r2 = await runChatterboxTTS(
218
- model,
219
- { text: 'Second run after reload.' },
220
- { minSamples: 5000 }
221
- )
222
- t.ok(r2.passed, 'second run after reload should pass')
223
- t.ok(r2.data.sampleCount > 0, 'reloaded model produces audio')
224
- } finally {
225
- try {
226
- await model.unload()
227
- } 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
+ }
228
237
  }
229
- })
238
+ )
230
239
 
231
240
  test('Supertonic: reload() between runs preserves stability', { timeout: 1800000 }, async (t) => {
232
241
  const baseDir = getBaseDir()
@@ -272,7 +281,7 @@ test('Supertonic: reload() between runs preserves stability', { timeout: 1800000
272
281
 
273
282
  test(
274
283
  'Engine swap: chatterbox -> supertonic -> chatterbox in separate instances',
275
- { timeout: 1800000 },
284
+ { timeout: 1800000, skip: NO_GPU },
276
285
  async (t) => {
277
286
  const baseDir = getBaseDir()
278
287
  const cb = await ensureChatterboxModels({ targetDir: path.join(baseDir, 'models') })
@@ -300,7 +309,8 @@ test(
300
309
  threads: TTS_TEST_THREADS,
301
310
  modelDir: cb.targetDir,
302
311
  refWavPath,
303
- language: 'en'
312
+ language: 'en',
313
+ useGPU: GPU_ONLY_USE_GPU
304
314
  })
305
315
  try {
306
316
  const r = await runChatterboxTTS(c1, { text: 'Hello from chatterbox.' }, { minSamples: 5000 })
@@ -316,7 +326,7 @@ test(
316
326
  supertonicModelPath: st.path,
317
327
  voice: 'F1',
318
328
  language: 'en',
319
- useGPU: false
329
+ useGPU: GPU_ONLY_USE_GPU
320
330
  })
321
331
  try {
322
332
  const r = await runSupertonicTTS(s1, { text: 'Hello from supertonic.' }, { minSamples: 5000 })
@@ -331,7 +341,8 @@ test(
331
341
  threads: TTS_TEST_THREADS,
332
342
  modelDir: cb.targetDir,
333
343
  refWavPath,
334
- language: 'en'
344
+ language: 'en',
345
+ useGPU: GPU_ONLY_USE_GPU
335
346
  })
336
347
  try {
337
348
  const r = await runChatterboxTTS(
@@ -13,6 +13,7 @@ const TTSGgml = require('@qvac/tts-ggml')
13
13
 
14
14
  const { ensureSupertonicModel, ensureParlerModel } = require('../utils/downloadModel')
15
15
  const { TTS_TEST_THREADS } = require('../utils/testThreads')
16
+ const { 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'
@@ -123,7 +124,7 @@ test(
123
124
 
124
125
  test(
125
126
  'Parler: outputSampleRate=16000 resamples and reports 16 kHz',
126
- { timeout: 600000 },
127
+ { timeout: 600000, skip: SKIP_PARLER_GPU_ONLY },
127
128
  async (t) => {
128
129
  const baseDir = getBaseDir()
129
130
  const dl = await ensureParlerModel({ targetDir: path.join(baseDir, 'models') })
@@ -140,6 +141,7 @@ test(
140
141
  files: { parlerModel: dl.path },
141
142
  voice: 'Laura',
142
143
  seed: 42,
144
+ config: { useGPU: PARLER_GPU_ONLY_USE_GPU },
143
145
  opts: { stats: true }
144
146
  })
145
147
  await native.load()
@@ -160,7 +162,7 @@ test(
160
162
  files: { parlerModel: dl.path },
161
163
  voice: 'Laura',
162
164
  seed: 42,
163
- config: { outputSampleRate: 16000 },
165
+ config: { outputSampleRate: 16000, useGPU: PARLER_GPU_ONLY_USE_GPU },
164
166
  opts: { stats: true }
165
167
  })
166
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')
@@ -27,10 +27,9 @@ const isApple = platform === 'darwin' || platform === 'ios'
27
27
  const NO_GPU = proc.env && proc.env.NO_GPU === 'true'
28
28
  const LARGE_MIN_RAM_BYTES = 16 * 1024 ** 3
29
29
 
30
- // Calibrated 2026-07-24: the CPU (no_gpu) runner is 0.0% WER at any seed; the
31
- // Metal/GPU runner diverges (FP reorder) — seed 42 is 0% on Apple-Silicon but
32
- // 22.2% on Intel-Mac Metal — so 0.35 tolerates the GPU variance while the CPU
33
- // 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%).
34
33
  const WER_THRESHOLD = 0.35
35
34
 
36
35
  // Clear English sentences, all >= 9 words (a single ASR slip stays <= ~0.11,
@@ -51,7 +50,7 @@ function getBaseDir() {
51
50
  for (const { variant } of PARLER_WER_TIERS) {
52
51
  test(
53
52
  `Parler WER (${variant}): English synthesis transcribes within ${(WER_THRESHOLD * 100).toFixed(0)}%`,
54
- { timeout: 1800000, skip: !isDarwin },
53
+ { timeout: 1800000, skip: !isDarwin || NO_GPU },
55
54
  async (t) => {
56
55
  if (variant === 'large' && os.totalmem() < LARGE_MIN_RAM_BYTES) {
57
56
  t.pass(