@qvac/tts-ggml 0.12.0 → 0.13.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.
package/README.md CHANGED
@@ -29,7 +29,8 @@ Xclipse/Mali → Vulkan). Parler supports Apple/Metal, linux CUDA, and the
29
29
  validated Android paths, including Vulkan on ARM Mali (see
30
30
  [Backends & GPU acceleration](#backends--gpu-acceleration)). Audio8 supports
31
31
  CUDA/Vulkan offload on Linux and Vulkan on Windows. MOSS GPU offload is
32
- validated on Apple/Metal.
32
+ validated on Apple/Metal; desktop CUDA/Vulkan validation requires the native
33
+ and registry prerequisites described under [MOSS](#moss).
33
34
 
34
35
  [qvac-tts-cpp]: https://github.com/tetherto/qvac-fabric-speech.cpp/tree/master/engines/tts
35
36
 
@@ -1060,6 +1061,82 @@ falls back to CPU and sets `response.stats.gpuUnsupported`. `backendsDir`
1060
1061
  reaches MOSS like every other engine, so builds that load ggml backends at
1061
1062
  runtime find them.
1062
1063
 
1064
+ The same controls apply to MOSS-TTSD and MOSS-Speech. `nGpuLayers` is a
1065
+ boolean GPU request for these engines (zero = CPU, non-zero = GPU), not a
1066
+ partial layer count. The addon forwards these controls to the native engine;
1067
+ it does not choose a different backend or change the CPU default.
1068
+ `response.stats.backendId` identifies the language-model backend (2 = CUDA,
1069
+ 3 = Vulkan); it is not a measurement of every codec operation.
1070
+
1071
+ The combined desktop CUDA/Vulkan speech changes in
1072
+ [Vulkan #308](https://github.com/tetherto/qvac-fabric-speech.cpp/pull/308),
1073
+ including [CUDA #321](https://github.com/tetherto/qvac-fabric-speech.cpp/pull/321),
1074
+ merged to native master at `84b02694001cfea863167776dbc185474a816656`.
1075
+ This checkout consumes the merged [registry #423](https://github.com/tetherto/qvac-registry-vcpkg/pull/423)
1076
+ at `e31be272ecdf9f20970302ebf8a2943d47803001`: `speech-cpp` 2026-10-12#1
1077
+ pins that merged revision, and `ggml-speech` 2026-10-07#1 pins
1078
+ `06f1b66806626a635904f31db6914ad2d0e71197`. The earlier native `f4d4c257`
1079
+ passed ten smaller-model CUDA/Vulkan cases; those results do not establish GPU
1080
+ validation of the final native revision or the packaged addon. Rebuild the addon
1081
+ before running its GPU tests. Native desktop results also do not establish
1082
+ Android support or speech-quality signoff.
1083
+
1084
+ The registry `baseline` is the main commit containing #423. The shared exact
1085
+ `speech-cpp` overrides and backend implementation are already on qvac main.
1086
+ This baseline update completes the TTS registry configuration; packaged-addon
1087
+ CUDA/Vulkan inference still requires validation on provisioned GPU hosts before
1088
+ claiming a validated release.
1089
+
1090
+ From `packages/tts-ggml`, resolve the checked-in manifest without building or
1091
+ downloading models (with `VCPKG_ROOT` set):
1092
+
1093
+ ```sh
1094
+ vcpkg install --dry-run --triplet x64-linux --x-feature=cuda --x-feature=vulkan
1095
+ ```
1096
+
1097
+ On a macOS host without a Linux cross-compiler, prefix that dry-run command
1098
+ with `VCPKG_FEATURE_FLAGS=-compilertracking` to check registry resolution only.
1099
+ For a bounded native build on a suitably provisioned Linux GPU host:
1100
+
1101
+ ```sh
1102
+ CMAKE_BUILD_PARALLEL_LEVEL=2 VCPKG_MAX_CONCURRENCY=2 npm run build:cuda
1103
+ ```
1104
+
1105
+ The normal PR workflow requires approval of the `fork-ci` environment for
1106
+ external forks. Obtain that approval through the existing gate; a manual
1107
+ workflow dispatch is not a substitute for fork approval.
1108
+
1109
+ On a desktop with those prerequisites, stage the TTS and TTSD backbones,
1110
+ codec decoder/encoder and the bundled `test/reference-audio/jfk.wav`, plus
1111
+ the Speech model/codec. Set
1112
+ `QVAC_TEST_MOSS_MODEL_DIR` and `QVAC_TEST_MOSS_SPEECH_MODEL_DIR` to their
1113
+ respective directories. Run each available backend in a fresh process:
1114
+
1115
+ ```sh
1116
+ QVAC_TEST_MOSS_FULL_GPU=true TTS_CPP_GPU_BACKEND=cuda npx --no-install brittle-bare test/integration/moss.test.js test/integration/moss-speech.test.js
1117
+ QVAC_TEST_MOSS_FULL_GPU=true TTS_CPP_GPU_BACKEND=vulkan npx --no-install brittle-bare test/integration/moss.test.js test/integration/moss-speech.test.js
1118
+ ```
1119
+
1120
+ The tests check the exact pinned backend, `backendDevice` and
1121
+ `gpuUnsupported` for TTS batch/streaming/cloning, TTSD dialogue, and Speech
1122
+ text/audio replies. CPU control cases still require CPU even with a GPU pin.
1123
+ `QVAC_TEST_MOSS_FULL_GPU=true` fails before model loading unless both model
1124
+ directories, the TTS/TTSD/Speech backbones, all three codec GGUFs, and the
1125
+ reference WAV exist. It also requires an explicit backend pin (case-insensitive),
1126
+ rejects `NO_GPU=true` and mobile CPU-only cases, and checks that every required
1127
+ case completes. Run both files together without test filters: a successful full
1128
+ run has 12 passing tests, including the two completion checks, and no skips.
1129
+ Preflight only checks asset presence; actual model loading and inference must
1130
+ also pass. These commands cover TTS, TTSD and Speech, not MOSS-SoundEffect (SFX).
1131
+ No addon CUDA/Vulkan hardware run has been completed for this test change yet.
1132
+
1133
+ Without strict mode, developer behavior is unchanged: an unpinned run accepts
1134
+ the normal GPU cascade, missing model directories skip tests, and missing
1135
+ encoder/TTSD assets omit cloning/dialogue cases. Such runs are not full GPU
1136
+ validation. `npm run test:node` covers backend selection and strict validation
1137
+ failures without loading models. Do not load these multi-GB models on
1138
+ memory-constrained hosts.
1139
+
1063
1140
  ## MOSS-SoundEffect
1064
1141
 
1065
1142
  MOSS-SoundEffect-v2 generates a sound effect from a text description: a Qwen3
@@ -1348,12 +1425,14 @@ const {
1348
1425
 
1349
1426
  `assessFit` projects a load against the memory free right now. It reads GGUF metadata and never weight data, so the registry's weightless copy of each file answers the same as the file itself and the projection can run before anything is downloaded. It is a module export, not an instance method: nothing is loaded to call it.
1350
1427
 
1428
+ `assessFit` returns a Promise. Fits run one at a time within this addon.
1429
+
1351
1430
  A request is a `createInstance` config plus the workload, so it carries the same keys the load takes and the engine's own config builder reads it.
1352
1431
 
1353
1432
  ```js
1354
1433
  const TTSGgml = require('@qvac/tts-ggml')
1355
1434
 
1356
- const fit = TTSGgml.assessFit({
1435
+ const fit = await TTSGgml.assessFit({
1357
1436
  engineType: 'supertonic',
1358
1437
  supertonicModelPath: '/models/supertonic.gguf',
1359
1438
  useGPU: true,
@@ -1393,7 +1472,7 @@ Everything else comes from the load config: `nGpuLayers` and `useGPU` carry the
1393
1472
 
1394
1473
  `deviceSharesHostMemory` reports that the device pool is system RAM, so host bytes compete with device bytes.
1395
1474
 
1396
- A model the engine cannot read is `status: "error"`, as is a voice with no fitter, which reports `reason: "unsupported-engine"`. MOSS-TTS, MOSS-TTSD and MOSS-SoundEffect are supported. MOSS-Speech has no SDK fit projection. A broken request, or a host with no native binding, throws.
1475
+ A model the engine cannot read resolves to `status: "error"`, as does a voice with no fitter, which reports `reason: "unsupported-engine"`. MOSS-TTS, MOSS-TTSD and MOSS-SoundEffect are supported. MOSS-Speech has no SDK fit projection. A broken request, or a host with no native binding, rejects.
1397
1476
 
1398
1477
  MOSS-TTS and MOSS-TTSD share the `moss` fitter. `promptRows` is the complete
1399
1478
  native prompt length, including special tokens, encoded speaker references and
@@ -1404,7 +1483,7 @@ The fitter does not open reference recordings. Use the TTSD backbone to assess
1404
1483
  dialogue, supplying its complete prompt and total reference workload.
1405
1484
 
1406
1485
  ```js
1407
- const fit = TTSGgml.assessFit({
1486
+ const fit = await TTSGgml.assessFit({
1408
1487
  engineType: 'moss',
1409
1488
  mossBackbonePath: './moss-tts-delay-f16.gguf',
1410
1489
  mossCodecDecoderPath: './moss-codec-decoder-f16.gguf',
@@ -1426,7 +1505,7 @@ The supertonic fitter covers the fused graph path: a validated GPU, or a CPU wit
1426
1505
  ### MOSS-SoundEffect fit
1427
1506
 
1428
1507
  ```js
1429
- const fit = TTSGgml.assessFit({
1508
+ const fit = await TTSGgml.assessFit({
1430
1509
  engineType: 'moss-sfx',
1431
1510
  mossSoundEffectPath: './moss-sfx-v2-q8_0.gguf',
1432
1511
  prompt: 'Rain falling on a tin roof.',
@@ -1581,7 +1660,8 @@ build becomes loadable once `npm run link:platform` (run by
1581
1660
 
1582
1661
  The vcpkg port is hosted in
1583
1662
  [`tetherto/qvac-registry-vcpkg`][registry] and pulls
1584
- [`qvac-tts.cpp`][qvac-tts-cpp] at a pinned REF. See
1663
+ [`qvac-fabric-speech.cpp`](https://github.com/tetherto/qvac-fabric-speech.cpp)
1664
+ (`engines/tts`) at a pinned REF. See
1585
1665
  [`vcpkg-configuration.json`](./vcpkg-configuration.json) for the
1586
1666
  baseline commit.
1587
1667
 
@@ -8,11 +8,18 @@ if (!model || !prompt || seconds === undefined) {
8
8
  throw new Error('Usage: bare examples/moss-sfx-fit.js <model.gguf> <prompt> <seconds>')
9
9
  }
10
10
 
11
- const fit = TTSGgml.assessFit({
12
- engineType: 'moss-sfx',
13
- mossSoundEffectPath: model,
14
- prompt,
15
- seconds: Number(seconds),
16
- useGPU: false
11
+ async function main() {
12
+ const fit = await TTSGgml.assessFit({
13
+ engineType: 'moss-sfx',
14
+ mossSoundEffectPath: model,
15
+ prompt,
16
+ seconds: Number(seconds),
17
+ useGPU: false
18
+ })
19
+ console.log(fit.report || `${fit.status}: ${fit.reason}`)
20
+ }
21
+
22
+ main().catch((err) => {
23
+ console.error(err)
24
+ process.exit(1)
17
25
  })
18
- console.log(fit.report || `${fit.status}: ${fit.reason}`)
package/lib/fit.d.ts CHANGED
@@ -139,9 +139,9 @@ export interface TtsFitResult {
139
139
  *
140
140
  * `engineType` picks the fitter, the same key a load uses, and is required
141
141
  * here: a fit request carries none of the file keys a load is inferred from.
142
- * A model the engine cannot read comes back as `status: "error"`, as does a
142
+ * A model the engine cannot read resolves to `status: "error"`, as does a
143
143
  * voice with no fitter; a broken request, or a host with no native binding,
144
- * throws.
144
+ * rejects. Fits run one at a time within this addon.
145
145
  */
146
- export declare function assessFit(request: TtsFitRequest): TtsFitResult;
146
+ export declare function assessFit(request: TtsFitRequest): Promise<TtsFitResult>;
147
147
  export {};
package/lib/fit.js CHANGED
@@ -12,12 +12,12 @@ exports.assessFit = assessFit;
12
12
  *
13
13
  * `engineType` picks the fitter, the same key a load uses, and is required
14
14
  * here: a fit request carries none of the file keys a load is inferred from.
15
- * A model the engine cannot read comes back as `status: "error"`, as does a
15
+ * A model the engine cannot read resolves to `status: "error"`, as does a
16
16
  * voice with no fitter; a broken request, or a host with no native binding,
17
- * throws.
17
+ * rejects. Fits run one at a time within this addon.
18
18
  */
19
- function assessFit(request) {
19
+ async function assessFit(request) {
20
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- the native binding loads on first use.
21
21
  const binding = require('../binding.js');
22
- return binding.assessFit(request);
22
+ return await binding.assessFit(request);
23
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qvac/tts-ggml",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Text to Speech (TTS) addon for qvac (ggml backend, wrapping the chatterbox + supertonic + parler + cosyvoice3 + audio8 + moss + moss-sfx + moss-speech + Pocket engines from tts-cpp)",
5
5
  "qvac": {
6
6
  "ggml-speech": "2026-10-07#1"
@@ -126,6 +126,11 @@
126
126
  "test/pocket-worklet",
127
127
  "test/reference-audio",
128
128
  "test/utils/runTTS.js",
129
+ "test/utils/mossBackend.js",
130
+ "test/utils/mossGpuValidation.js",
131
+ "test/utils/gpuOnly.js",
132
+ "test/utils/testThreads.js",
133
+ "test/utils/perf-helper.js",
129
134
  "test/utils/runChatterboxTTS.js",
130
135
  "test/utils/runSupertonicTTS.js",
131
136
  "test/utils/runParlerTTS.js",
@@ -200,7 +205,7 @@
200
205
  },
201
206
  "dependencies": {
202
207
  "@qvac/error": "^0.1.0",
203
- "@qvac/infer-base": "^0.6.2",
208
+ "@qvac/infer-base": "^0.8.0",
204
209
  "@qvac/langdetect-text": "^0.1.2",
205
210
  "@qvac/logging": "^0.1.0",
206
211
  "bare-fs": "^4.5.6",
@@ -212,16 +217,16 @@
212
217
  "bare-url": "^2.4.3"
213
218
  },
214
219
  "optionalDependencies": {
215
- "@qvac/tts-ggml-linux-x64": "0.12.0",
216
- "@qvac/tts-ggml-linux-arm64": "0.12.0",
217
- "@qvac/tts-ggml-darwin-arm64": "0.12.0",
218
- "@qvac/tts-ggml-darwin-x64": "0.12.0",
219
- "@qvac/tts-ggml-win32-x64": "0.12.0"
220
+ "@qvac/tts-ggml-linux-x64": "0.13.0",
221
+ "@qvac/tts-ggml-linux-arm64": "0.13.0",
222
+ "@qvac/tts-ggml-darwin-arm64": "0.13.0",
223
+ "@qvac/tts-ggml-darwin-x64": "0.13.0",
224
+ "@qvac/tts-ggml-win32-x64": "0.13.0"
220
225
  },
221
226
  "types": "index.d.ts",
222
227
  "peerDependencies": {
223
- "@qvac/tts-ggml-android-arm64": "0.12.0",
224
- "@qvac/tts-ggml-ios": "0.12.0"
228
+ "@qvac/tts-ggml-android-arm64": "0.13.0",
229
+ "@qvac/tts-ggml-ios": "0.13.0"
225
230
  },
226
231
  "peerDependenciesMeta": {
227
232
  "@qvac/tts-ggml-android-arm64": {
@@ -64,7 +64,7 @@ test('a projection is internally consistent', { timeout: TEST_TIMEOUT_MS }, asyn
64
64
  const model = await supertonicPath(t)
65
65
  if (model === null) return
66
66
 
67
- const fit = TTSGgml.assessFit({
67
+ const fit = await TTSGgml.assessFit({
68
68
  engineType: 'supertonic',
69
69
  supertonicModelPath: model,
70
70
  useGPU: true
@@ -84,13 +84,13 @@ test('a weightless copy projects the same', { timeout: TEST_TIMEOUT_MS }, async
84
84
  const model = await supertonicPath(t)
85
85
  if (model === null) return
86
86
 
87
- const full = TTSGgml.assessFit({
87
+ const full = await TTSGgml.assessFit({
88
88
  engineType: 'supertonic',
89
89
  supertonicModelPath: model,
90
90
  useGPU: true
91
91
  })
92
92
  if (!projected(t, full)) return
93
- const stub = TTSGgml.assessFit({
93
+ const stub = await TTSGgml.assessFit({
94
94
  engineType: 'supertonic',
95
95
  supertonicModelPath: weightlessCopy(tempDir(t), model, 1024 * 1024),
96
96
  useGPU: true
@@ -106,12 +106,12 @@ test('the load’s offload keys reach the projection', { timeout: TEST_TIMEOUT_M
106
106
  const model = await supertonicPath(t)
107
107
  if (model === null) return
108
108
 
109
- const cpu = TTSGgml.assessFit({
109
+ const cpu = await TTSGgml.assessFit({
110
110
  engineType: 'supertonic',
111
111
  supertonicModelPath: model,
112
112
  useGPU: false
113
113
  })
114
- const pinned = TTSGgml.assessFit({
114
+ const pinned = await TTSGgml.assessFit({
115
115
  engineType: 'supertonic',
116
116
  supertonicModelPath: model,
117
117
  nGpuLayers: 0
@@ -129,7 +129,7 @@ test('a LavaSR stage is sized beside the projection', { timeout: TEST_TIMEOUT_MS
129
129
  const enhancer = path.join(dir, 'enhancer.gguf')
130
130
  fs.writeFileSync(enhancer, Buffer.alloc(4096, 1))
131
131
 
132
- const fit = TTSGgml.assessFit({
132
+ const fit = await TTSGgml.assessFit({
133
133
  engineType: 'supertonic',
134
134
  supertonicModelPath: model,
135
135
  useGPU: true,
@@ -141,8 +141,97 @@ test('a LavaSR stage is sized beside the projection', { timeout: TEST_TIMEOUT_MS
141
141
  t.ok(fit.lavasrFileBytes < fit.deviceBytes, 'reported beside the projection')
142
142
  })
143
143
 
144
- test('MOSS routes to its fitter when a model cannot be read', (t) => {
145
- const fit = TTSGgml.assessFit({
144
+ test('fitting keeps the event loop responsive', { timeout: TEST_TIMEOUT_MS }, async (t) => {
145
+ const model = await supertonicPath(t)
146
+ if (model === null) return
147
+
148
+ const request = { engineType: 'supertonic', supertonicModelPath: model, useGPU: true }
149
+
150
+ let pending = true
151
+ let turns = 0
152
+ const spin = () => {
153
+ if (!pending) return
154
+ turns++
155
+ setImmediate(spin)
156
+ }
157
+ setImmediate(spin)
158
+
159
+ const first = TTSGgml.assessFit(request)
160
+ t.ok(first instanceof Promise, 'the call returns before the projection')
161
+ const fits = await Promise.all([
162
+ first,
163
+ ...Array.from({ length: 15 }, () => TTSGgml.assessFit(request))
164
+ ])
165
+ pending = false
166
+
167
+ t.comment(`${turns} loop turns before the fits settled`)
168
+ t.ok(turns > 0, 'the loop turned while the fitter ran')
169
+ for (const fit of fits) {
170
+ t.not(fit.reason, 'model-unreadable', 'every queued projection ran')
171
+ }
172
+ })
173
+
174
+ test(
175
+ 'concurrent fits keep their own requests and all settle',
176
+ { timeout: TEST_TIMEOUT_MS },
177
+ async (t) => {
178
+ const model = await supertonicPath(t)
179
+ if (model === null) return
180
+
181
+ const dir = tempDir(t)
182
+ const enhancer = path.join(dir, 'enhancer.gguf')
183
+ fs.writeFileSync(enhancer, Buffer.alloc(4096, 1))
184
+
185
+ const request = {
186
+ engineType: 'supertonic',
187
+ supertonicModelPath: model,
188
+ useGPU: false,
189
+ lavasrEnhancerPath: enhancer
190
+ }
191
+ const first = TTSGgml.assessFit(request)
192
+ request.lavasrEnhancerPath = ''
193
+ const second = TTSGgml.assessFit(request)
194
+ const missing = TTSGgml.assessFit({
195
+ engineType: 'supertonic',
196
+ supertonicModelPath: '/nonexistent/model.gguf'
197
+ })
198
+ const [a, b, c] = await Promise.all([first, second, missing])
199
+
200
+ t.is(a.lavasrFileBytes, 4096, 'the first call owns its original request')
201
+ t.is(b.lavasrFileBytes, 0, 'the second call owns its changed request')
202
+ t.is(c.status, 'error', 'an error outcome does not strand queued callers')
203
+ }
204
+ )
205
+
206
+ test('a fit settles while a loaded model unloads', { timeout: TEST_TIMEOUT_MS }, async (t) => {
207
+ const model = await supertonicPath(t)
208
+ if (model === null) return
209
+
210
+ const voice = new TTSGgml({
211
+ engine: TTSGgml.ENGINE_SUPERTONIC,
212
+ files: { supertonicModel: model },
213
+ voice: 'F1',
214
+ config: { language: 'en', useGPU: false }
215
+ })
216
+ await voice.load()
217
+ try {
218
+ const fit = TTSGgml.assessFit({
219
+ engineType: 'supertonic',
220
+ supertonicModelPath: model,
221
+ useGPU: false
222
+ })
223
+ await voice.unload()
224
+ const result = await fit
225
+ t.ok(result.deviceTotalBytes > 0, 'the projection measured a device')
226
+ } finally {
227
+ try {
228
+ await voice.unload()
229
+ } catch (_e) {}
230
+ }
231
+ })
232
+
233
+ test('MOSS routes to its fitter when a model cannot be read', async (t) => {
234
+ const fit = await TTSGgml.assessFit({
146
235
  engineType: 'moss',
147
236
  mossBackbonePath: '/models/moss-tts-delay.gguf',
148
237
  mossCodecDecoderPath: '/models/moss-codec-decoder.gguf',
@@ -156,8 +245,8 @@ test('MOSS routes to its fitter when a model cannot be read', (t) => {
156
245
  t.is(fit.modelVariant, 'moss')
157
246
  })
158
247
 
159
- test('a model that cannot be read is an outcome, not a throw', (t) => {
160
- const fit = TTSGgml.assessFit({
248
+ test('a model that cannot be read is an outcome, not a rejection', async (t) => {
249
+ const fit = await TTSGgml.assessFit({
161
250
  engineType: 'supertonic',
162
251
  supertonicModelPath: '/nonexistent/model.gguf'
163
252
  })
@@ -165,14 +254,22 @@ test('a model that cannot be read is an outcome, not a throw', (t) => {
165
254
  t.is(fit.status, 'error')
166
255
  })
167
256
 
168
- test('a count that is not a count is refused', (t) => {
169
- t.exception(
170
- () =>
171
- TTSGgml.assessFit({
172
- engineType: 'supertonic',
173
- supertonicModelPath: '/models/supertonic.gguf',
174
- textTokens: -1
175
- }),
257
+ test('a count that is not a count is refused', async (t) => {
258
+ await t.exception(
259
+ TTSGgml.assessFit({
260
+ engineType: 'supertonic',
261
+ supertonicModelPath: '/models/supertonic.gguf',
262
+ textTokens: -1
263
+ }),
176
264
  /non-negative count/
177
265
  )
178
266
  })
267
+
268
+ test('a broken request rejects', async (t) => {
269
+ let promise
270
+ t.execution(() => {
271
+ promise = TTSGgml.assessFit({})
272
+ }, 'the call itself does not throw')
273
+ t.ok(promise instanceof Promise)
274
+ await t.exception(promise, /engineType is required/)
275
+ })
@@ -5,7 +5,7 @@ const process = require('bare-process')
5
5
  const test = require('brittle')
6
6
  const TTSGgml = require('@qvac/tts-ggml')
7
7
 
8
- test('MOSS fit requires explicit, representable workload counts', (t) => {
8
+ test('MOSS fit requires explicit, representable workload counts', async (t) => {
9
9
  const request = {
10
10
  engineType: 'moss',
11
11
  mossBackbonePath: '/models/moss-tts-delay.gguf',
@@ -15,18 +15,18 @@ test('MOSS fit requires explicit, representable workload counts', (t) => {
15
15
  streaming: false
16
16
  }
17
17
  for (const invalid of [NaN, Infinity, -1, 0.5, 2147483648]) {
18
- t.exception(() => TTSGgml.assessFit({ ...request, promptRows: invalid }))
19
- t.exception(() => TTSGgml.assessFit({ ...request, referenceSamples: invalid }))
18
+ await t.exception(TTSGgml.assessFit({ ...request, promptRows: invalid }))
19
+ await t.exception(TTSGgml.assessFit({ ...request, referenceSamples: invalid }))
20
20
  }
21
- t.exception(() => TTSGgml.assessFit({ ...request, promptRows: undefined }))
22
- t.exception(() => TTSGgml.assessFit({ ...request, streaming: undefined }))
23
- t.exception(() => TTSGgml.assessFit({ ...request, threads: -1 }))
24
- t.exception(() => TTSGgml.assessFit({ ...request, durationTokens: -1 }))
25
- t.exception(() => TTSGgml.assessFit({ ...request, streamChunkTokens: -1 }))
26
- t.exception(() => TTSGgml.assessFit({ ...request, useGPU: true, nGpuLayers: 0 }))
21
+ await t.exception(TTSGgml.assessFit({ ...request, promptRows: undefined }))
22
+ await t.exception(TTSGgml.assessFit({ ...request, streaming: undefined }))
23
+ await t.exception(TTSGgml.assessFit({ ...request, threads: -1 }))
24
+ await t.exception(TTSGgml.assessFit({ ...request, durationTokens: -1 }))
25
+ await t.exception(TTSGgml.assessFit({ ...request, streamChunkTokens: -1 }))
26
+ await t.exception(TTSGgml.assessFit({ ...request, useGPU: true, nGpuLayers: 0 }))
27
27
  })
28
28
 
29
- test('MOSS metadata-only fit covers batch, streaming and cloning', (t) => {
29
+ test('MOSS metadata-only fit covers batch, streaming and cloning', async (t) => {
30
30
  const modelDir = process.env.QVAC_TEST_MOSS_MODEL_DIR
31
31
  if (!modelDir) {
32
32
  t.comment('Set QVAC_TEST_MOSS_MODEL_DIR to exercise real MOSS GGUFs')
@@ -40,14 +40,14 @@ test('MOSS metadata-only fit covers batch, streaming and cloning', (t) => {
40
40
  referenceSamples: 0,
41
41
  streaming: false
42
42
  }
43
- const batch = TTSGgml.assessFit(request)
43
+ const batch = await TTSGgml.assessFit(request)
44
44
  t.not(batch.status, 'error', batch.report)
45
45
  t.ok(batch.weightsBytes > 0)
46
46
  t.ok(batch.lmComputeBytes > 0)
47
47
  t.ok(batch.codecComputeBytes > 0)
48
- const stream = TTSGgml.assessFit({ ...request, streaming: true })
48
+ const stream = await TTSGgml.assessFit({ ...request, streaming: true })
49
49
  t.not(stream.status, 'error', stream.report)
50
- const cloning = TTSGgml.assessFit({
50
+ const cloning = await TTSGgml.assessFit({
51
51
  ...request,
52
52
  mossCodecEncoderPath: path.join(modelDir, 'moss-codec-encoder-f16.gguf'),
53
53
  referenceSamples: 24000
@@ -15,17 +15,17 @@ function request(overrides = {}) {
15
15
  }
16
16
  }
17
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 })))
18
+ test('MOSS sound-effect fit rejects missing workload and invalid load controls', async (t) => {
19
+ await t.exception(TTSGgml.assessFit(request({ prompt: undefined })))
20
+ await t.exception(TTSGgml.assessFit(request({ seconds: undefined })))
21
+ await t.exception(TTSGgml.assessFit(request({ seconds: NaN })))
22
+ await t.exception(TTSGgml.assessFit(request({ threads: -1 })))
23
+ await t.exception(TTSGgml.assessFit(request({ useGPU: true, nGpuLayers: 0 })))
24
+ await t.exception(TTSGgml.assessFit(request({ marginBytes: -1 })))
25
25
  })
26
26
 
27
- test('MOSS sound-effect fit reports unreadable models', (t) => {
28
- const fit = TTSGgml.assessFit(request())
27
+ test('MOSS sound-effect fit reports unreadable models', async (t) => {
28
+ const fit = await TTSGgml.assessFit(request())
29
29
  t.is(fit.status, 'error')
30
30
  t.is(fit.reason, 'model-unreadable')
31
31
  t.is(fit.modelVariant, 'moss-sfx')
@@ -36,10 +36,10 @@ test(
36
36
  {
37
37
  skip: !process.env.QVAC_TEST_MOSS_SFX_GGUF
38
38
  },
39
- (t) => {
39
+ async (t) => {
40
40
  const model = process.env.QVAC_TEST_MOSS_SFX_GGUF
41
41
  const cfg = { mossSoundEffectPath: model }
42
- const fit = TTSGgml.assessFit(request(cfg))
42
+ const fit = await TTSGgml.assessFit(request(cfg))
43
43
  t.ok(fit.status === 'fits' || fit.status === 'does-not-fit')
44
44
  t.is(fit.modelVariant, 'moss-sfx')
45
45
  t.ok(fit.deviceIsCpu)
@@ -51,12 +51,12 @@ test(
51
51
  fit.deviceBytes,
52
52
  fit.weightsBytes + fit.stateBytes + fit.lmComputeBytes + fit.codecComputeBytes
53
53
  )
54
- const shorter = TTSGgml.assessFit(request({ ...cfg, seconds: 1 }))
54
+ const shorter = await TTSGgml.assessFit(request({ ...cfg, seconds: 1 }))
55
55
  t.is(shorter.weightsBytes, fit.weightsBytes)
56
56
  t.is(shorter.lmComputeBytes, fit.lmComputeBytes)
57
- const noHeadroom = TTSGgml.assessFit(request({ ...cfg, marginBytes: Number.MAX_VALUE }))
57
+ const noHeadroom = await TTSGgml.assessFit(request({ ...cfg, marginBytes: Number.MAX_VALUE }))
58
58
  t.is(noHeadroom.status, 'does-not-fit')
59
- const invalid = TTSGgml.assessFit(request({ ...cfg, seconds: 0 }))
59
+ const invalid = await TTSGgml.assessFit(request({ ...cfg, seconds: 0 }))
60
60
  t.is(invalid.status, 'error')
61
61
  t.is(invalid.reason, 'invalid-arguments')
62
62
  }
@@ -8,6 +8,9 @@ const TTSGgml = require('@qvac/tts-ggml')
8
8
  const { resolveRefWavPath } = require('../utils/runChatterboxTTS')
9
9
  const { readWavAsFloat32 } = require('../utils/wav-helper')
10
10
  const { TTS_TEST_THREADS } = require('../utils/testThreads')
11
+ const { assertMossBackend } = require('../utils/mossBackend')
12
+ const { createMossGpuValidation } = require('../utils/mossGpuValidation')
13
+ const { GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
11
14
 
12
15
  const SPEECH_SAMPLE_RATE = 24000
13
16
  const TEST_TIMEOUT_MS = 1800000
@@ -15,12 +18,13 @@ const MODEL_DIR_ENV = 'QVAC_TEST_MOSS_SPEECH_MODEL_DIR'
15
18
  const MAX_NEW_TOKENS = 120
16
19
  const CUT_SECONDS = 2
17
20
  const CUT_MARGIN_SECONDS = 0.5
18
- const CPU_DEVICE = 0
19
- const GPU_DEVICE = 1
21
+ const PINNED_GPU_BACKEND = (proc.env && proc.env.TTS_CPP_GPU_BACKEND) || ''
20
22
 
21
23
  const modelDir = (proc.env && proc.env[MODEL_DIR_ENV]) || ''
22
24
  const skipWithoutModels = modelDir === ''
23
25
  const noGpu = proc.env && proc.env.NO_GPU === 'true'
26
+ const sourceAudio = resolveRefWavPath({})
27
+ const validation = createMossGpuValidation(proc.env || {}, sourceAudio, GPU_ONLY_USE_GPU)
24
28
 
25
29
  function createSpeechModel(useGPU = false) {
26
30
  return new TTSGgml({
@@ -34,7 +38,7 @@ function createSpeechModel(useGPU = false) {
34
38
  }
35
39
 
36
40
  function spokenQuestion() {
37
- const wav = readWavAsFloat32(resolveRefWavPath({}))
41
+ const wav = readWavAsFloat32(sourceAudio)
38
42
  return { audio: wav.samples, sampleRate: wav.sampleRate }
39
43
  }
40
44
 
@@ -75,10 +79,11 @@ test(
75
79
  try {
76
80
  const result = await respond(model, spokenQuestion())
77
81
  assertSpokenReply(t, 'cpu reply', result)
78
- t.is(result.stats.backendDevice, CPU_DEVICE, 'the reply ran on the CPU')
82
+ assertMossBackend(t, 'cpu reply', result.stats, false, PINNED_GPU_BACKEND)
79
83
  } finally {
80
84
  await model.unload()
81
85
  }
86
+ validation.completed('CPU spoken reply')
82
87
  }
83
88
  )
84
89
 
@@ -92,6 +97,7 @@ test(
92
97
  const textReply = await respond(model, { ...spokenQuestion(), textReply: true })
93
98
  t.is(textReply.samples, 0, 'a text reply carries no audio')
94
99
  t.ok(textReply.text.trim().length > 0, 'a text reply carries the answer')
100
+ assertMossBackend(t, 'text reply', textReply.stats, true, PINNED_GPU_BACKEND)
95
101
 
96
102
  const cut = await respond(model, { ...spokenQuestion(), maxReplySeconds: CUT_SECONDS })
97
103
  assertSpokenReply(t, 'cut reply', cut)
@@ -99,12 +105,20 @@ test(
99
105
  cut.samples <= (CUT_SECONDS + CUT_MARGIN_SECONDS) * SPEECH_SAMPLE_RATE,
100
106
  'maxReplySeconds bounds the spoken reply'
101
107
  )
102
- t.is(cut.stats.backendDevice, GPU_DEVICE, 'the replies ran on the GPU')
108
+ assertMossBackend(t, 'cut reply', cut.stats, true, PINNED_GPU_BACKEND)
103
109
 
104
110
  const typed = await respond(model, { input: 'What is the capital of France?' })
105
111
  assertSpokenReply(t, 'reply to a typed question', typed)
112
+ assertMossBackend(t, 'typed reply', typed.stats, true, PINNED_GPU_BACKEND)
106
113
  } finally {
107
114
  await model.unload()
108
115
  }
116
+ validation.completed('GPU text/audio replies and reply cuts')
109
117
  }
110
118
  )
119
+
120
+ if (validation.enabled) {
121
+ test('MOSS-Speech: full GPU validation completed every required case', (t) => {
122
+ validation.assertComplete(t, ['CPU spoken reply', 'GPU text/audio replies and reply cuts'])
123
+ })
124
+ }