@qvac/tts-ggml 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1275,9 +1275,10 @@ the demos still run and write the concatenated wav.
1275
1275
  ## Testing
1276
1276
 
1277
1277
  ```bash
1278
- npm run test:unit # mocked binding; fast
1279
- npm run test:integration # spins up the real engine; needs models
1280
- npm run test # both
1278
+ npm run test:unit # mocked binding; fast
1279
+ npm run test:integration # spins up the real engine; needs models
1280
+ npm run test:integration:smoke # Supertonic suite only; the darwin-x64 CI leg runs this
1281
+ npm run test # both
1281
1282
  ```
1282
1283
 
1283
1284
  Integration tests scan a few candidate `models/` directories for the
@@ -1298,6 +1299,15 @@ files are absent. They cover, across the engines:
1298
1299
  for WER against the synthesized audio (whisper-small via the
1299
1300
  `@qvac/asr-ggml` development dependency).
1300
1301
 
1302
+ On CPU the suite keeps to brief inference: single short syntheses, plus
1303
+ Supertonic's sub-second multi-sentence checks and a few capped CPU-backend
1304
+ checks (Audio8's 16-frame runs, the LavaSR denoiser composition). Tests that
1305
+ synthesize more (several runs or loads, longer text, WER) run on the GPU and
1306
+ are skipped when `NO_GPU=true`. So are the CosyVoice3, Chatterbox MTL and
1307
+ Parler f16/large tests, where one CPU synthesis already takes about a minute;
1308
+ the CPU smokes in `gpu-smoke.test.js` still run everywhere. The multi-run
1309
+ Parler tests need Metal, so off Apple they are skipped too.
1310
+
1301
1311
  The MOSS integration test (`moss.test.js`) does not download its GGUFs:
1302
1312
  set `QVAC_TEST_MOSS_MODEL_DIR` to a directory holding them, otherwise it is
1303
1313
  skipped. The C++ suite runs its MOSS synthesis cases when
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qvac/tts-ggml",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Text to Speech (TTS) addon for qvac (ggml backend, wrapping the chatterbox + supertonic + parler + cosyvoice3 + audio8 + moss + Pocket engines from tts-cpp)",
5
5
  "addon": true,
6
6
  "engines": {
@@ -89,6 +89,7 @@
89
89
  "test:node": "node --test scripts/__tests__/*.test.js",
90
90
  "test": "npm run test:types && npm run test:unit && npm run test:integration",
91
91
  "test:integration": "npm run build:ts && brittle-bare test/integration/**/*.test.js",
92
+ "test:integration:smoke": "npm run build:ts && brittle-bare test/integration/supertonic.test.js",
92
93
  "test:benchmark:rtf": "npm run build:ts && brittle-bare test/benchmark/rtf-benchmark.test.js",
93
94
  "test:benchmark:rtf:matrix": "node scripts/run-rtf-benchmark-matrix.js",
94
95
  "test:benchmark:streaming": "npm run build:ts && brittle-bare test/benchmark/streaming-benchmark.test.js",
@@ -107,6 +108,7 @@
107
108
  "convert-models": "bash scripts/convert-models.sh",
108
109
  "setup-models": "bash scripts/setup-venv.sh && bash scripts/convert-models.sh",
109
110
  "download-models:registry": "node scripts/download-tts-ggml-models.js",
111
+ "download-models:smoke": "node scripts/download-tts-ggml-models.js --group supertonic",
110
112
  "example:pocket": "bare examples/pocket-tts.js",
111
113
  "test:pocket": "npm run build:ts && brittle-bare test/integration/pocket.integration.test.js"
112
114
  },
@@ -214,10 +216,10 @@
214
216
  },
215
217
  "types": "index.d.ts",
216
218
  "optionalDependencies": {
217
- "@qvac/tts-ggml-linux-x64": "0.10.0",
218
- "@qvac/tts-ggml-linux-arm64": "0.10.0",
219
- "@qvac/tts-ggml-darwin-arm64": "0.10.0",
220
- "@qvac/tts-ggml-darwin-x64": "0.10.0",
221
- "@qvac/tts-ggml-win32-x64": "0.10.0"
219
+ "@qvac/tts-ggml-linux-x64": "0.10.2",
220
+ "@qvac/tts-ggml-linux-arm64": "0.10.2",
221
+ "@qvac/tts-ggml-darwin-arm64": "0.10.2",
222
+ "@qvac/tts-ggml-darwin-x64": "0.10.2",
223
+ "@qvac/tts-ggml-win32-x64": "0.10.2"
222
224
  }
223
225
  }
@@ -16,11 +16,11 @@ const { ensureChatterboxModels, ensureWhisperModel } = require('../utils/downloa
16
16
  const { loadWhisper, runWhisper } = require('../utils/runWhisper')
17
17
  const { recordTtsStats } = require('../utils/perf-helper')
18
18
  const { TTS_TEST_THREADS } = require('../utils/testThreads')
19
+ const { NO_GPU, GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
19
20
 
20
21
  const platform = os.platform()
21
22
  const isMobile = platform === 'ios' || platform === 'android'
22
23
  const isDarwin = platform === 'darwin'
23
- const forceNoGpu = os.getEnv('NO_GPU') === 'true'
24
24
 
25
25
  const INPUT_SENTENCES = (isMobile ? 'short' : os.getEnv('INPUT_SENTENCES')) || 'short'
26
26
  const useSplit = INPUT_SENTENCES !== 'short'
@@ -42,7 +42,7 @@ function getEnglishSentences() {
42
42
 
43
43
  test(
44
44
  'Chatterbox TTS (ggml): English synthesis + optional WER verification',
45
- { timeout: 1800000 },
45
+ { timeout: 1800000, skip: NO_GPU },
46
46
  async (t) => {
47
47
  const baseDir = getBaseDir()
48
48
  const modelsDir = path.join(baseDir, 'models')
@@ -86,7 +86,8 @@ test(
86
86
  const model = await loadChatterboxTTS({
87
87
  threads: TTS_TEST_THREADS,
88
88
  modelDir: resolvedModelDir,
89
- language: 'en'
89
+ language: 'en',
90
+ useGPU: GPU_ONLY_USE_GPU
90
91
  })
91
92
  t.ok(model, 'Chatterbox (ggml) model should be loaded')
92
93
 
@@ -254,7 +255,7 @@ test(
254
255
  const model = new TTSGgml({
255
256
  threads: TTS_TEST_THREADS,
256
257
  files: { modelDir: download.targetDir },
257
- config: { language: 'en', ...(forceNoGpu ? { useGPU: false } : {}) },
258
+ config: { language: 'en', ...(NO_GPU ? { useGPU: false } : {}) },
258
259
  opts: { stats: true }
259
260
  })
260
261
 
@@ -315,7 +316,7 @@ test(
315
316
  threads: TTS_TEST_THREADS,
316
317
  files: { modelDir: download.targetDir },
317
318
  referenceAudio: resolveRefWavPath({}),
318
- config: { language: 'en', outputSampleRate: 16000, ...(forceNoGpu ? { useGPU: false } : {}) },
319
+ config: { language: 'en', outputSampleRate: 16000, ...(NO_GPU ? { useGPU: false } : {}) },
319
320
  opts: { stats: true }
320
321
  })
321
322
  await model.load()
@@ -344,7 +345,7 @@ test(
344
345
 
345
346
  test(
346
347
  'Chatterbox TTS (ggml): native C++ chunk streaming via streamChunkTokens',
347
- { timeout: 600000 },
348
+ { timeout: 600000, skip: NO_GPU },
348
349
  async (t) => {
349
350
  const baseDir = getBaseDir()
350
351
  const modelsDir = path.join(baseDir, 'models')
@@ -371,7 +372,7 @@ test(
371
372
  streamChunkTokens: 25,
372
373
  streamFirstChunkTokens: 10,
373
374
  cfmSteps: 1,
374
- config: { language: 'en', ...(forceNoGpu ? { useGPU: false } : {}) },
375
+ config: { language: 'en', useGPU: GPU_ONLY_USE_GPU },
375
376
  opts: { stats: true }
376
377
  })
377
378
  await model.load()
@@ -26,13 +26,15 @@ const {
26
26
  } = require('../utils/downloadModel')
27
27
  const { recordTtsStats } = require('../utils/perf-helper')
28
28
  const { TTS_TEST_THREADS } = require('../utils/testThreads')
29
+ const { NO_GPU, GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
29
30
 
30
31
  const platform = os.platform()
31
32
  const isMobile = platform === 'ios' || platform === 'android'
32
33
 
33
- // Language coverage test, not a GPU policy test: rely on the package
34
- // default (`useGPU: false`) rather than opting into GPU here.
35
- // Tests that *are* about GPU live in gpu-smoke.test.js.
34
+ // Language coverage, not a GPU policy test. A single MTL synthesis already
35
+ // takes close to a minute on the CPU-only runners, so these tests run on the
36
+ // GPU only and are skipped there (NO_GPU=true). Tests that *are* about GPU live
37
+ // in gpu-smoke.test.js.
36
38
 
37
39
  function getBaseDir() {
38
40
  return isMobile && global.testDir ? global.testDir : '.'
@@ -83,7 +85,7 @@ async function loadChatterboxMtlTTS(params) {
83
85
 
84
86
  test(
85
87
  'Chatterbox MTL TTS (ggml): synthesizes across es/fr/de with shared engine',
86
- { timeout: 1800000 },
88
+ { timeout: 1800000, skip: NO_GPU },
87
89
  async (t) => {
88
90
  const baseDir = getBaseDir()
89
91
  const download = await ensureChatterboxMtlModels({ targetDir: path.join(baseDir, 'models') })
@@ -98,7 +100,8 @@ test(
98
100
  modelDir: download.targetDir,
99
101
  t3ModelPath: path.join(download.targetDir, 'chatterbox-t3-mtl.gguf'),
100
102
  s3genModelPath: path.join(download.targetDir, 'chatterbox-s3gen-mtl.gguf'),
101
- language: MTL_SENTENCES[0].lang
103
+ language: MTL_SENTENCES[0].lang,
104
+ useGPU: GPU_ONLY_USE_GPU
102
105
  })
103
106
  try {
104
107
  for (let i = 0; i < MTL_SENTENCES.length; i++) {
@@ -166,7 +169,7 @@ test(
166
169
 
167
170
  test(
168
171
  'Chatterbox MTL TTS (ggml): synthesizes Japanese with MeCab dictionary',
169
- { timeout: 1800000 },
172
+ { timeout: 1800000, skip: NO_GPU },
170
173
  async (t) => {
171
174
  const baseDir = getBaseDir()
172
175
  const download = await ensureChatterboxMtlModels({ targetDir: path.join(baseDir, 'models') })
@@ -188,7 +191,8 @@ test(
188
191
  t3ModelPath: path.join(download.targetDir, 'chatterbox-t3-mtl.gguf'),
189
192
  s3genModelPath: path.join(download.targetDir, 'chatterbox-s3gen-mtl.gguf'),
190
193
  mecabDictDir: mecab.dir,
191
- language: 'ja'
194
+ language: 'ja',
195
+ useGPU: GPU_ONLY_USE_GPU
192
196
  })
193
197
  try {
194
198
  const t0 = Date.now()
@@ -233,7 +237,7 @@ test(
233
237
 
234
238
  test(
235
239
  'Chatterbox MTL TTS (ggml): synthesizes Chinese with Cangjie table',
236
- { timeout: 1800000 },
240
+ { timeout: 1800000, skip: NO_GPU },
237
241
  async (t) => {
238
242
  const baseDir = getBaseDir()
239
243
  const download = await ensureChatterboxMtlModels({ targetDir: path.join(baseDir, 'models') })
@@ -255,7 +259,8 @@ test(
255
259
  t3ModelPath: path.join(download.targetDir, 'chatterbox-t3-mtl.gguf'),
256
260
  s3genModelPath: path.join(download.targetDir, 'chatterbox-s3gen-mtl.gguf'),
257
261
  cangjieTsvPath: cangjie.path,
258
- language: 'zh'
262
+ language: 'zh',
263
+ useGPU: GPU_ONLY_USE_GPU
259
264
  })
260
265
  try {
261
266
  const t0 = Date.now()
@@ -12,6 +12,7 @@ const path = require('bare-path')
12
12
  const { loadChatterboxTTS, runChatterboxTTS } = require('../utils/runChatterboxTTS')
13
13
  const { ensureChatterboxModels } = require('../utils/downloadModel')
14
14
  const { TTS_TEST_THREADS } = require('../utils/testThreads')
15
+ const { NO_GPU, GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
15
16
 
16
17
  function getBaseDir() {
17
18
  const platform = os.platform()
@@ -30,7 +31,7 @@ const EXPECTATION = {
30
31
 
31
32
  test(
32
33
  'Chatterbox TTS (ggml): speed config adjusts duration, preserves backward compat',
33
- { timeout: 1800000 },
34
+ { timeout: 1800000, skip: NO_GPU },
34
35
  async (t) => {
35
36
  const modelsDir = path.join(getBaseDir(), 'models')
36
37
 
@@ -52,7 +53,8 @@ test(
52
53
  threads: TTS_TEST_THREADS,
53
54
  modelDir,
54
55
  language: 'en',
55
- speed
56
+ speed,
57
+ useGPU: GPU_ONLY_USE_GPU
56
58
  })
57
59
  try {
58
60
  const r = await runChatterboxTTS(model, { text: TEXT }, EXPECTATION)
@@ -8,7 +8,6 @@
8
8
  const fs = require('bare-fs')
9
9
  const os = require('bare-os')
10
10
  const path = require('bare-path')
11
- const proc = require('bare-process')
12
11
  const test = require('brittle')
13
12
  const TTSGgml = require('@qvac/tts-ggml')
14
13
 
@@ -20,10 +19,11 @@ const {
20
19
  } = require('../utils/downloadModel')
21
20
  const { resolveRefWavPath } = require('../utils/runChatterboxTTS')
22
21
  const { TTS_TEST_THREADS } = require('../utils/testThreads')
22
+ const { NO_GPU, GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
23
23
 
24
24
  const platform = os.platform()
25
25
  const isMobile = platform === 'ios' || platform === 'android'
26
- const useGPU = !isMobile && proc.env.NO_GPU === 'false'
26
+ const useGPU = GPU_ONLY_USE_GPU
27
27
 
28
28
  function getBaseDir() {
29
29
  return isMobile && global.testDir ? global.testDir : '.'
@@ -99,7 +99,7 @@ async function synthOnce(t, loadParams, label) {
99
99
 
100
100
  test(
101
101
  'CosyVoice3 cloning (ggml): baked, zero-shot and cross-lingual all differ at one seed',
102
- { timeout: 1800000 },
102
+ { timeout: 1800000, skip: NO_GPU },
103
103
  async (t) => {
104
104
  const modelDir = await ensureCloneReadyModelDir(t)
105
105
  if (!modelDir) return
@@ -7,7 +7,9 @@
7
7
  // CosyVoice3's ~2.4 GB model dir, while this one is listed on the dedicated
8
8
  // 'cosyvoice' row that pre-stages it (see test/mobile/test-groups.json).
9
9
  //
10
- // Text is kept short to bound CPU LM-decode time in CI, as in cosyvoice3.test.js.
10
+ // As in cosyvoice3.test.js, the model-backed tests run on the GPU only and are
11
+ // skipped on CPU-only runners (NO_GPU=true): even one short CosyVoice3 CPU
12
+ // synthesis costs minutes there.
11
13
  //
12
14
  // Stage the enhancer GGUF via scripts/convert-lavasr-enhancer-to-gguf.py (from
13
15
  // the public LavaSRcpp ONNX release) into models/lavasr/lavasr-enhancer.gguf,
@@ -25,6 +27,7 @@ const {
25
27
  ensureCosyvoiceModel
26
28
  } = require('../utils/downloadModel')
27
29
  const { TTS_TEST_THREADS } = require('../utils/testThreads')
30
+ const { NO_GPU, GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
28
31
 
29
32
  const platform = os.platform()
30
33
  const isMobile = platform === 'ios' || platform === 'android'
@@ -118,7 +121,7 @@ async function stageCosyvoice(t, baseDir) {
118
121
 
119
122
  test(
120
123
  'CosyVoice3 + LavaSR enhancer (batch) reports 48 kHz enhanced output',
121
- { timeout: 900000, skip: SKIP_COSYVOICE },
124
+ { timeout: 900000, skip: SKIP_COSYVOICE || NO_GPU },
122
125
  async (t) => {
123
126
  const baseDir = getBaseDir()
124
127
  const enh = await stageEnhancer(t, baseDir)
@@ -130,7 +133,7 @@ test(
130
133
  threads: TTS_TEST_THREADS,
131
134
  engine: TTSGgml.ENGINE_COSYVOICE3,
132
135
  files: { cosyvoiceModelDir: dl.modelDir, lavasrEnhancer: enh.path },
133
- config: { language: 'en', useGPU: false },
136
+ config: { language: 'en', useGPU: GPU_ONLY_USE_GPU },
134
137
  opts: { stats: true }
135
138
  })
136
139
  await model.load()
@@ -141,8 +144,8 @@ test(
141
144
  t.ok(r.stats, 'runtimeStats returned (constructed with stats:true)')
142
145
  t.is(
143
146
  r.stats.enhancerBackendDevice,
144
- 0,
145
- 'enhancer loaded and ran on CPU (enhancerBackendDevice=0, not -1 = never loaded)'
147
+ r.stats.backendDevice,
148
+ 'enhancer loaded and ran on the engine device (not -1 = never loaded)'
146
149
  )
147
150
  } finally {
148
151
  try {
@@ -154,7 +157,7 @@ test(
154
157
 
155
158
  test(
156
159
  'CosyVoice3 + LavaSR enhancer + native chunk streaming emits 48 kHz chunks',
157
- { timeout: 900000, skip: SKIP_COSYVOICE },
160
+ { timeout: 900000, skip: SKIP_COSYVOICE || NO_GPU },
158
161
  async (t) => {
159
162
  const baseDir = getBaseDir()
160
163
  const enh = await stageEnhancer(t, baseDir)
@@ -167,7 +170,7 @@ test(
167
170
  engine: TTSGgml.ENGINE_COSYVOICE3,
168
171
  files: { cosyvoiceModelDir: dl.modelDir, lavasrEnhancer: enh.path },
169
172
  streamChunkTokens: 25,
170
- config: { language: 'en', useGPU: false },
173
+ config: { language: 'en', useGPU: GPU_ONLY_USE_GPU },
171
174
  opts: { stats: true }
172
175
  })
173
176
  await model.load()
@@ -191,7 +194,7 @@ test(
191
194
 
192
195
  test(
193
196
  'CosyVoice3 + enhancer + streaming resamples every chunk to outputSampleRate',
194
- { timeout: 900000, skip: SKIP_COSYVOICE },
197
+ { timeout: 900000, skip: SKIP_COSYVOICE || NO_GPU },
195
198
  async (t) => {
196
199
  // A non-native rate while streaming is only legal because the enhancer's
197
200
  // overlap-reprocess window folds the resample in seam-free. Without this the
@@ -208,7 +211,7 @@ test(
208
211
  engine: TTSGgml.ENGINE_COSYVOICE3,
209
212
  files: { cosyvoiceModelDir: dl.modelDir, lavasrEnhancer: enh.path },
210
213
  streamChunkTokens: 25,
211
- config: { language: 'en', useGPU: false, outputSampleRate: 16000 },
214
+ config: { language: 'en', useGPU: GPU_ONLY_USE_GPU, outputSampleRate: 16000 },
212
215
  opts: { stats: true }
213
216
  })
214
217
  await model.load()
@@ -228,7 +231,7 @@ test(
228
231
 
229
232
  test(
230
233
  'CosyVoice3 + LavaSR denoiser changes the emitted PCM',
231
- { timeout: 900000, skip: SKIP_COSYVOICE },
234
+ { timeout: 900000, skip: SKIP_COSYVOICE || NO_GPU },
232
235
  async (t) => {
233
236
  const baseDir = getBaseDir()
234
237
  const dl = await stageCosyvoice(t, baseDir)
@@ -247,7 +250,7 @@ test(
247
250
  threads: TTS_TEST_THREADS,
248
251
  engine: TTSGgml.ENGINE_COSYVOICE3,
249
252
  files: Object.assign({ cosyvoiceModelDir: dl.modelDir }, files),
250
- config: { language: 'en', useGPU: false, seed: SEED },
253
+ config: { language: 'en', useGPU: GPU_ONLY_USE_GPU, seed: SEED },
251
254
  opts: { stats: true }
252
255
  })
253
256
  await model.load()
@@ -294,6 +297,7 @@ test(
294
297
  if (!dl) return
295
298
 
296
299
  const badEnhancer = path.join(baseDir, 'models', 'lavasr', 'invalid-enhancer.gguf')
300
+ fs.mkdirSync(path.dirname(badEnhancer), { recursive: true })
297
301
  fs.writeFileSync(badEnhancer, 'not a gguf')
298
302
 
299
303
  const model = new TTSGgml({
@@ -3,8 +3,10 @@
3
3
  // CosyVoice3 engine integration smoke: real synthesis in the desktop
4
4
  // `run-integration-tests` lane. Mirrors parler.test.js (download model → build
5
5
  // TTSGgml → run → assert) but for the directory-consuming CosyVoice3 engine
6
- // (Qwen2 speech LM + DiT flow + CausalHiFT vocoder; native 24 kHz, CPU-only).
7
- // Text is kept SHORT to bound CPU LM-decode time in CI.
6
+ // (Qwen2 speech LM + DiT flow + CausalHiFT vocoder; native 24 kHz).
7
+ // Even one short CPU synthesis costs minutes on the CPU-only runners, so these
8
+ // tests run on the GPU only and are skipped there (NO_GPU=true); the CPU crash
9
+ // check is the CosyVoice3 CPU smoke in gpu-smoke.test.js.
8
10
 
9
11
  const os = require('bare-os')
10
12
  const path = require('bare-path')
@@ -13,6 +15,7 @@ const test = require('brittle')
13
15
  const { loadCosyvoiceTTS, runCosyvoiceTTS } = require('../utils/runCosyvoiceTTS')
14
16
  const { ensureCosyvoiceModel } = require('../utils/downloadModel')
15
17
  const { TTS_TEST_THREADS } = require('../utils/testThreads')
18
+ const { NO_GPU, GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
16
19
 
17
20
  const platform = os.platform()
18
21
  const isMobile = platform === 'ios' || platform === 'android'
@@ -27,7 +30,7 @@ const MODEL_MISSING =
27
30
 
28
31
  test(
29
32
  'CosyVoice3 TTS (ggml): outputSampleRate=16000 resamples and reports 16 kHz',
30
- { timeout: 600000 },
33
+ { timeout: 600000, skip: NO_GPU },
31
34
  async (t) => {
32
35
  const baseDir = getBaseDir()
33
36
  const download = await ensureCosyvoiceModel({
@@ -43,7 +46,8 @@ test(
43
46
  const model = await loadCosyvoiceTTS({
44
47
  threads: TTS_TEST_THREADS,
45
48
  cosyvoiceModelDir: download.modelDir,
46
- outputSampleRate: 16000
49
+ outputSampleRate: 16000,
50
+ useGPU: GPU_ONLY_USE_GPU
47
51
  })
48
52
  try {
49
53
  const text = 'Hello from CosyVoice.'
@@ -70,7 +74,7 @@ function samplesEqual(a, b) {
70
74
 
71
75
  test(
72
76
  'CosyVoice3 TTS (ggml): emotion changes the audio; per-call switch needs no reload',
73
- { timeout: 900000 },
77
+ { timeout: 900000, skip: NO_GPU },
74
78
  async (t) => {
75
79
  const baseDir = getBaseDir()
76
80
  const download = await ensureCosyvoiceModel({
@@ -87,7 +91,8 @@ test(
87
91
  const model = await loadCosyvoiceTTS({
88
92
  threads: TTS_TEST_THREADS,
89
93
  cosyvoiceModelDir: download.modelDir,
90
- seed: 42
94
+ seed: 42,
95
+ useGPU: GPU_ONLY_USE_GPU
91
96
  })
92
97
  try {
93
98
  const text = 'Hello from CosyVoice.'
@@ -172,7 +177,7 @@ test(
172
177
 
173
178
  test(
174
179
  'CosyVoice3 TTS (ggml): instruct conditioning produces audio',
175
- { timeout: 600000 },
180
+ { timeout: 600000, skip: NO_GPU },
176
181
  async (t) => {
177
182
  const baseDir = getBaseDir()
178
183
  const download = await ensureCosyvoiceModel({
@@ -186,7 +191,8 @@ test(
186
191
  const model = await loadCosyvoiceTTS({
187
192
  threads: TTS_TEST_THREADS,
188
193
  cosyvoiceModelDir: download.modelDir,
189
- emotion: 'happy'
194
+ emotion: 'happy',
195
+ useGPU: GPU_ONLY_USE_GPU
190
196
  })
191
197
  try {
192
198
  const text = 'Hello from CosyVoice.'
@@ -33,6 +33,7 @@ const {
33
33
  } = require('../utils/downloadModel')
34
34
  const { resolveRefWavPath } = require('../utils/runChatterboxTTS')
35
35
  const { TTS_TEST_THREADS } = require('../utils/testThreads')
36
+ const { GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
36
37
 
37
38
  const platform = os.platform()
38
39
  const isMobile = platform === 'ios' || platform === 'android'
@@ -632,7 +633,7 @@ test(
632
633
 
633
634
  test(
634
635
  'Chatterbox + LavaSR enhancer + native chunk streaming emits 48 kHz chunks',
635
- { timeout: 900000 },
636
+ { timeout: 900000, skip: NO_GPU },
636
637
  async (t) => {
637
638
  const baseDir = getBaseDir()
638
639
  const enh = await ensureLavaSREnhancerGguf({
@@ -662,7 +663,7 @@ test(
662
663
  },
663
664
  referenceAudio: resolveRefWavPath({}),
664
665
  streamChunkTokens: 25, // native chunk streaming + enhancer (the path)
665
- config: { language: 'en', useGPU: false },
666
+ config: { language: 'en', useGPU: GPU_ONLY_USE_GPU },
666
667
  opts: { stats: true }
667
668
  })
668
669
  await model.load()
@@ -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(
@@ -7,18 +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')
16
15
  const { TTS_TEST_THREADS } = require('../utils/testThreads')
16
+ const { NO_GPU, SKIP_PARLER_GPU_ONLY, PARLER_GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
17
17
 
18
18
  const platform = os.platform()
19
19
  const isMobile = platform === 'ios' || platform === 'android'
20
20
  const isApple = platform === 'darwin' || platform === 'ios'
21
- const NO_GPU = proc.env && proc.env.NO_GPU === 'true'
22
21
 
23
22
  function getBaseDir() {
24
23
  return isMobile && global.testDir ? global.testDir : '.'
@@ -137,7 +136,7 @@ test(
137
136
 
138
137
  test(
139
138
  'Parler TTS (ggml): description matrix — conflicts throw, per-call wins, defaults work',
140
- { timeout: 600000 },
139
+ { timeout: 600000, skip: SKIP_PARLER_GPU_ONLY },
141
140
  async (t) => {
142
141
  const baseDir = getBaseDir()
143
142
  const download = await ensureParlerModel({ targetDir: path.join(baseDir, 'models') })
@@ -191,7 +190,8 @@ test(
191
190
  threads: TTS_TEST_THREADS,
192
191
  parlerModelPath: download.path,
193
192
  voice: 'Laura',
194
- seed: 42
193
+ seed: 42,
194
+ useGPU: PARLER_GPU_ONLY_USE_GPU
195
195
  })
196
196
  try {
197
197
  // Per-call description conflict (same level) rejects.
@@ -234,7 +234,8 @@ test(
234
234
  threads: TTS_TEST_THREADS,
235
235
  parlerModelPath: download.path,
236
236
  description: 'A calm female voice, very clear audio.',
237
- seed: 42
237
+ seed: 42,
238
+ useGPU: PARLER_GPU_ONLY_USE_GPU
238
239
  })
239
240
  try {
240
241
  await t.exception(
@@ -252,7 +253,7 @@ test(
252
253
 
253
254
  test(
254
255
  'Parler TTS (ggml): emotion flag changes the audio; per-call switch needs no reload',
255
- { timeout: 600000 },
256
+ { timeout: 600000, skip: SKIP_PARLER_GPU_ONLY },
256
257
  async (t) => {
257
258
  const baseDir = getBaseDir()
258
259
  const download = await ensureParlerModel({ targetDir: path.join(baseDir, 'models') })
@@ -265,7 +266,8 @@ test(
265
266
  threads: TTS_TEST_THREADS,
266
267
  parlerModelPath: download.path,
267
268
  voice: 'Laura',
268
- seed: 42
269
+ seed: 42,
270
+ useGPU: PARLER_GPU_ONLY_USE_GPU
269
271
  })
270
272
  try {
271
273
  const text = 'The weather is wonderful today.'
@@ -300,7 +302,7 @@ test(
300
302
 
301
303
  test(
302
304
  'Parler TTS (ggml): fixed seed is deterministic across runs and reload',
303
- { timeout: 600000 },
305
+ { timeout: 600000, skip: SKIP_PARLER_GPU_ONLY },
304
306
  async (t) => {
305
307
  const baseDir = getBaseDir()
306
308
  const download = await ensureParlerModel({ targetDir: path.join(baseDir, 'models') })
@@ -314,7 +316,8 @@ test(
314
316
  parlerModelPath: download.path,
315
317
  voice: 'Laura',
316
318
  emotion: 'neutral',
317
- seed: 42
319
+ seed: 42,
320
+ useGPU: PARLER_GPU_ONLY_USE_GPU
318
321
  })
319
322
  try {
320
323
  const text = 'Deterministic synthesis check.'
@@ -345,7 +348,7 @@ test(
345
348
 
346
349
  test(
347
350
  'Parler TTS (ggml): runStream emits per-sentence chunks with one pinned description',
348
- { timeout: 600000 },
351
+ { timeout: 600000, skip: SKIP_PARLER_GPU_ONLY },
349
352
  async (t) => {
350
353
  const baseDir = getBaseDir()
351
354
  const download = await ensureParlerModel({ targetDir: path.join(baseDir, 'models') })
@@ -360,6 +363,7 @@ test(
360
363
  engine: TTSGgml.ENGINE_PARLER,
361
364
  files: { parlerModel: download.path },
362
365
  seed: 42,
366
+ config: { useGPU: PARLER_GPU_ONLY_USE_GPU },
363
367
  opts: { stats: true }
364
368
  })
365
369
  await model.load()
@@ -393,7 +397,7 @@ test(
393
397
 
394
398
  test(
395
399
  'Parler TTS (ggml): native streaming emits monotonic chunks equal to batch',
396
- { timeout: 900000 },
400
+ { timeout: 900000, skip: SKIP_PARLER_GPU_ONLY },
397
401
  async (t) => {
398
402
  const baseDir = getBaseDir()
399
403
  const download = await ensureParlerModel({ targetDir: path.join(baseDir, 'models') })
@@ -526,22 +530,24 @@ test(
526
530
 
527
531
  // Desktop quant + backend coverage: q8_0 + f16 for indic/mini, q8_0 for large.
528
532
  // Runs on the platform's GPU-if-available backend (Metal on Apple GPU runners,
529
- // CPU otherwise). large self-gates on RAM; any tier that isn't staged skips
530
- // (t.pass) so a not-yet-registered tier never fails CI. Registered per PR #3372.
531
- // 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.
532
538
  const PARLER_QUANT_MATRIX = [
533
- { variant: 'indic', quant: 'q8_0' },
534
- { variant: 'indic', quant: 'f16' },
535
- { variant: 'mini', quant: 'q8_0' },
536
- { variant: 'mini', quant: 'f16' },
537
- { 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 }
538
544
  ]
539
545
  const LARGE_MIN_RAM_BYTES = 16 * 1024 ** 3
540
546
 
541
- for (const { variant, quant } of PARLER_QUANT_MATRIX) {
547
+ for (const { variant, quant, gpuOnly } of PARLER_QUANT_MATRIX) {
542
548
  test(
543
549
  `Parler TTS (ggml): ${variant} ${quant} synthesizes 44.1 kHz audio on the target backend`,
544
- { timeout: 900000, skip: isMobile },
550
+ { timeout: 900000, skip: isMobile || (gpuOnly && SKIP_PARLER_GPU_ONLY) },
545
551
  async (t) => {
546
552
  if (variant === 'large' && os.totalmem() < LARGE_MIN_RAM_BYTES) {
547
553
  t.pass(