@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 +86 -6
- package/examples/moss-sfx-fit.js +14 -7
- package/lib/fit.d.ts +3 -3
- package/lib/fit.js +4 -4
- package/package.json +14 -9
- package/test/integration/fit.test.js +115 -18
- package/test/integration/moss-fit.test.js +13 -13
- package/test/integration/moss-sfx-fit.test.js +14 -14
- package/test/integration/moss-speech.test.js +19 -5
- package/test/integration/moss.test.js +92 -55
- package/test/mobile/integration.auto.cjs +10 -0
- package/test/mobile/test-groups.json +6 -2
- package/test/utils/gpuOnly.js +15 -0
- package/test/utils/mossBackend.js +34 -0
- package/test/utils/mossGpuValidation.js +70 -0
- package/test/utils/perf-helper.js +275 -0
- package/test/utils/testThreads.js +5 -0
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
|
|
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-
|
|
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
|
|
package/examples/moss-sfx-fit.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
216
|
-
"@qvac/tts-ggml-linux-arm64": "0.
|
|
217
|
-
"@qvac/tts-ggml-darwin-arm64": "0.
|
|
218
|
-
"@qvac/tts-ggml-darwin-x64": "0.
|
|
219
|
-
"@qvac/tts-ggml-win32-x64": "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.
|
|
224
|
-
"@qvac/tts-ggml-ios": "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('
|
|
145
|
-
const
|
|
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
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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(
|
|
19
|
-
t.exception(
|
|
18
|
+
await t.exception(TTSGgml.assessFit({ ...request, promptRows: invalid }))
|
|
19
|
+
await t.exception(TTSGgml.assessFit({ ...request, referenceSamples: invalid }))
|
|
20
20
|
}
|
|
21
|
-
t.exception(
|
|
22
|
-
t.exception(
|
|
23
|
-
t.exception(
|
|
24
|
-
t.exception(
|
|
25
|
-
t.exception(
|
|
26
|
-
t.exception(
|
|
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(
|
|
20
|
-
t.exception(
|
|
21
|
-
t.exception(
|
|
22
|
-
t.exception(
|
|
23
|
-
t.exception(
|
|
24
|
-
t.exception(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
+
}
|