@qvac/tts-ggml 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +330 -36
  2. package/docs/pocket-tts.md +201 -0
  3. package/examples/moss-dialogue-tts.js +113 -0
  4. package/examples/moss-tts.js +154 -0
  5. package/examples/pocket-tts.js +51 -0
  6. package/examples/supertonic-chunk-stream-tts.js +156 -0
  7. package/examples/supertonic-sentence-stream-tts.js +4 -4
  8. package/index.d.ts +352 -29
  9. package/index.js +698 -52
  10. package/lib/fit.d.ts +127 -0
  11. package/lib/fit.js +29 -0
  12. package/lib/pocketConfig.d.ts +10 -0
  13. package/lib/pocketConfig.js +145 -0
  14. package/package.json +16 -9
  15. package/test/integration/addon.test.js +13 -7
  16. package/test/integration/audio8.test.js +7 -0
  17. package/test/integration/chatterbox-mtl.test.js +20 -9
  18. package/test/integration/chatterbox-speed.test.js +16 -3
  19. package/test/integration/cosyvoice3-clone.test.js +17 -2
  20. package/test/integration/cosyvoice3-lavasr.test.js +21 -11
  21. package/test/integration/cosyvoice3.test.js +33 -8
  22. package/test/integration/fit.test.js +174 -0
  23. package/test/integration/gpu-smoke.test.js +13 -0
  24. package/test/integration/lavasr-enhancer.test.js +107 -5
  25. package/test/integration/moss.test.js +287 -0
  26. package/test/integration/multiple-runs.test.js +84 -57
  27. package/test/integration/output-sample-rate.test.js +10 -2
  28. package/test/integration/parler-wer.test.js +14 -9
  29. package/test/integration/parler.test.js +54 -24
  30. package/test/integration/pocket.integration.test.js +156 -0
  31. package/test/integration/supertonic-mtl.test.js +2 -0
  32. package/test/integration/supertonic.test.js +70 -0
  33. package/test/integration/supertonic3-quant.test.js +2 -0
  34. package/test/integration/voice-controls.test.js +56 -0
  35. package/test/mobile/integration.auto.cjs +16 -1
  36. package/test/mobile/pocket-worklet-host.cpp +92 -0
  37. package/test/mobile/pocket-worklet.cjs +47 -0
  38. package/test/mobile/test-groups.json +8 -4
  39. package/test/utils/runCosyvoiceTTS.js +1 -0
  40. package/test/utils/runTTS.js +6 -11
  41. package/tts.d.ts +13 -2
  42. package/tts.js +3 -2
package/lib/fit.d.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Settings every voice takes, spelled as `createInstance` takes them. A fit
3
+ * request is a load config plus the workload below, so the projection reads
4
+ * the same keys and applies the same defaults as the load it describes.
5
+ */
6
+ interface TtsFitCommon {
7
+ /** Layers to offload. Unset defers to `useGPU`. */
8
+ nGpuLayers?: number;
9
+ /** All layers or none, when `nGpuLayers` is unset. */
10
+ useGPU?: boolean;
11
+ vulkanDevice?: number;
12
+ backendsDir?: string;
13
+ /** LavaSR enhancer; a non-empty path turns enhancement on. */
14
+ lavasrEnhancerPath?: string;
15
+ /** LavaSR denoiser, applied before the enhancer. */
16
+ lavasrDenoiserPath?: string;
17
+ /** Free memory that must remain for the projection to count as fitting. Defaults to 256 MiB. */
18
+ marginBytes?: number;
19
+ }
20
+ export interface SupertonicFitRequest extends TtsFitCommon {
21
+ engineType: 'supertonic';
22
+ supertonicModelPath: string;
23
+ /** `auto` | `f32` | `f16` | `q8_0`; changes the weight footprint. */
24
+ precision?: 'auto' | 'f32' | 'f16' | 'q8_0';
25
+ /** -1 auto, 0 off, 1 on. */
26
+ f16Weights?: number;
27
+ /** 0 takes the GGUF's own default. */
28
+ steps?: number;
29
+ textTokens?: number;
30
+ audioSeconds?: number;
31
+ }
32
+ export interface ParlerFitRequest extends TtsFitCommon {
33
+ engineType: 'parler';
34
+ parlerModelPath: string;
35
+ descriptionTokens?: number;
36
+ promptTokens?: number;
37
+ /** 0 takes the checkpoint's default generation length. */
38
+ maxFrames?: number;
39
+ }
40
+ export interface ChatterboxFitRequest extends TtsFitCommon {
41
+ engineType: 'chatterbox';
42
+ t3ModelPath: string;
43
+ s3genModelPath: string;
44
+ nCtx?: number;
45
+ kvCacheType?: 'f32' | 'f16' | 'q8_0';
46
+ textTokens?: number;
47
+ predictTokens?: number;
48
+ }
49
+ export interface Audio8FitRequest extends TtsFitCommon {
50
+ engineType: 'audio8';
51
+ audio8LmPath: string;
52
+ audio8CodecDecoderPath: string;
53
+ /** Supplying it projects voice cloning, which the decoder alone cannot do. */
54
+ audio8CodecEncoderPath?: string;
55
+ promptTokens?: number;
56
+ maxFrames?: number;
57
+ referenceSeconds?: number;
58
+ }
59
+ export interface CosyvoiceFitRequest extends TtsFitCommon {
60
+ engineType: 'cosyvoice3';
61
+ cosyvoiceLlmModelPath: string;
62
+ cosyvoiceFlowModelPath: string;
63
+ cosyvoiceHiftModelPath: string;
64
+ cosyvoiceVoiceModelPath: string;
65
+ textTokens?: number;
66
+ /** 0 derives the runtime cap from the text length. */
67
+ speechTokens?: number;
68
+ }
69
+ export interface MossFitRequest extends TtsFitCommon {
70
+ engineType: 'moss';
71
+ mossBackbonePath: string;
72
+ mossCodecDecoderPath?: string;
73
+ mossCodecEncoderPath?: string;
74
+ }
75
+ export type TtsFitRequest = SupertonicFitRequest | ParlerFitRequest | ChatterboxFitRequest | Audio8FitRequest | CosyvoiceFitRequest | MossFitRequest;
76
+ /** The voice engines a fit can be asked for, read off the request union. */
77
+ export type TtsFitEngine = TtsFitRequest['engineType'];
78
+ export type TtsFitStatus = 'fits' | 'does-not-fit' | 'error';
79
+ export interface TtsFitResult {
80
+ status: TtsFitStatus;
81
+ /**
82
+ * The engine's own wording, e.g. `model-unreadable`, `workload-too-large`.
83
+ * `unsupported-engine` names a voice the addon loads and the fitter does
84
+ * not cover.
85
+ */
86
+ reason: string;
87
+ /** Which pipeline was projected, e.g. `chatterbox-t3-turbo`. */
88
+ modelVariant: string;
89
+ deviceName: string;
90
+ deviceIsCpu: boolean;
91
+ /** The device pool is system RAM, so host bytes compete with device bytes. */
92
+ deviceSharesHostMemory: boolean;
93
+ deviceFreeBytes: number;
94
+ deviceTotalBytes: number;
95
+ deviceBytes: number;
96
+ weightsBytes: number;
97
+ stateBytes: number;
98
+ /** 0 for a pipeline with no language-model stage, such as supertonic. */
99
+ lmComputeBytes: number;
100
+ /** 0 for a pipeline with no separate codec stage. */
101
+ codecComputeBytes: number;
102
+ hostBytes: number;
103
+ /**
104
+ * The LavaSR stages' size on disk, 0 when none was named. They have no
105
+ * fitter, so this is a size on disk. It is counted in neither `deviceBytes`
106
+ * nor `hostBytes`.
107
+ */
108
+ lavasrFileBytes: number;
109
+ report: string;
110
+ }
111
+ /**
112
+ * Projects one voice against the memory free right now, reading GGUF metadata
113
+ * and never weight data. The registry's weightless copy of each file answers
114
+ * the same as the file itself, so this can run before anything is downloaded.
115
+ *
116
+ * The request is a `createInstance` config plus the workload, and the engine's
117
+ * own config builder reads it, so a load that runs and a fit that describes it
118
+ * resolve their settings the same way.
119
+ *
120
+ * `engineType` picks the fitter, the same key a load uses, and is required
121
+ * here: a fit request carries none of the file keys a load is inferred from.
122
+ * A model the engine cannot read comes back as `status: "error"`, as does a
123
+ * voice with no fitter; a broken request, or a host with no native binding,
124
+ * throws.
125
+ */
126
+ export declare function assessFit(request: TtsFitRequest): TtsFitResult;
127
+ export {};
package/lib/fit.js ADDED
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assessFit = assessFit;
4
+ const backends_1 = require("./backends");
5
+ /**
6
+ * Projects one voice against the memory free right now, reading GGUF metadata
7
+ * and never weight data. The registry's weightless copy of each file answers
8
+ * the same as the file itself, so this can run before anything is downloaded.
9
+ *
10
+ * The request is a `createInstance` config plus the workload, and the engine's
11
+ * own config builder reads it, so a load that runs and a fit that describes it
12
+ * resolve their settings the same way.
13
+ *
14
+ * `engineType` picks the fitter, the same key a load uses, and is required
15
+ * here: a fit request carries none of the file keys a load is inferred from.
16
+ * A model the engine cannot read comes back as `status: "error"`, as does a
17
+ * voice with no fitter; a broken request, or a host with no native binding,
18
+ * throws.
19
+ */
20
+ function assessFit(request) {
21
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- native binding is resolved lazily from package prebuilds.
22
+ const binding = require('../binding.js');
23
+ return binding.assessFit({
24
+ ...request,
25
+ backendsDir: typeof request.backendsDir === 'string' && request.backendsDir.length > 0
26
+ ? request.backendsDir
27
+ : (0, backends_1.resolveBackendsDir)()
28
+ });
29
+ }
@@ -0,0 +1,10 @@
1
+ import type { TTSConfigurationParams } from '../tts';
2
+ interface PocketFiles {
3
+ modelDir?: string;
4
+ pocketFlowModel?: string;
5
+ pocketMimiModel?: string;
6
+ pocketFrontend?: string;
7
+ pocketVoice?: string;
8
+ }
9
+ export declare function buildPocketParams(files: PocketFiles, optionInput: object, configInput: object): TTSConfigurationParams;
10
+ export {};
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.buildPocketParams = buildPocketParams;
7
+ const bare_path_1 = __importDefault(require("bare-path"));
8
+ const DEFAULT_SAMPLING_STEPS = 1;
9
+ const UNSUPPORTED_OPTIONS = [
10
+ 'voice',
11
+ 'voiceName',
12
+ 'voiceDir',
13
+ 'speed',
14
+ 'noiseNpyPath',
15
+ 'kvCacheType',
16
+ 'streamChunkTokens',
17
+ 'streamFirstChunkTokens',
18
+ 'cfmSteps',
19
+ 'cfgRate',
20
+ 'streamLeftContextTokens',
21
+ 'promptText',
22
+ 'referenceText',
23
+ 'instruct',
24
+ 'description',
25
+ 'voiceDescription',
26
+ 'emotion',
27
+ 'pace',
28
+ 'pitch',
29
+ 'expressivity',
30
+ 'noise',
31
+ 'reverb',
32
+ 'quality',
33
+ 'greedy',
34
+ 'topK',
35
+ 'topP',
36
+ 'maxFrames',
37
+ 'minNewTokens',
38
+ 'normalizeNumbers',
39
+ 'enhancer',
40
+ 'denoiser'
41
+ ];
42
+ const NUMERIC_OPTIONS = {
43
+ threads: [1, 1024],
44
+ nCtx: [1, 8192],
45
+ maxTokens: [1, 1024],
46
+ steps: [1, 64],
47
+ framesAfterEos: [-1, 100],
48
+ seed: [0, 4294967295],
49
+ temperature: [0, 10, false],
50
+ noiseClamp: [0, 3.402823466e38, false],
51
+ eosThreshold: [-3.402823466e38, 3.402823466e38, false],
52
+ outputSampleRate: [8000, 192000]
53
+ };
54
+ function resolveAsset(root, value, name) {
55
+ return value || (root ? bare_path_1.default.join(root, name) : '');
56
+ }
57
+ function resolveModelFiles(files, options) {
58
+ return {
59
+ engineType: 'pocket',
60
+ pocketFlowModelPath: resolveAsset(files.modelDir, files.pocketFlowModel, 'flow-lm.gguf'),
61
+ pocketMimiModelPath: resolveAsset(files.modelDir, files.pocketMimiModel, 'mimi.gguf'),
62
+ pocketFrontendPath: resolveAsset(files.modelDir, files.pocketFrontend, 'frontend.json'),
63
+ pocketVoicePath: files.pocketVoice ||
64
+ (options.referenceAudio ? '' : resolveAsset(files.modelDir, undefined, 'voice.gguf')),
65
+ referenceAudio: typeof options.referenceAudio === 'string' ? options.referenceAudio : '',
66
+ language: 'en',
67
+ useGPU: false,
68
+ steps: DEFAULT_SAMPLING_STEPS
69
+ };
70
+ }
71
+ function validateEngineOptions(options, config) {
72
+ if (options.referenceAudio !== undefined && typeof options.referenceAudio !== 'string')
73
+ throw new Error('Pocket referenceAudio must be a path string');
74
+ if (config.language !== undefined && config.language !== 'en')
75
+ throw new Error('Pocket currently supports English (en) only');
76
+ if (config.useGPU !== undefined && config.useGPU !== false)
77
+ throw new Error('Pocket currently supports CPU only (useGPU: false)');
78
+ if (options.nGpuLayers !== undefined && options.nGpuLayers !== 0)
79
+ throw new Error('Pocket requires nGpuLayers: 0');
80
+ if (options.steps !== undefined &&
81
+ options.numInferenceSteps !== undefined &&
82
+ options.steps !== options.numInferenceSteps)
83
+ throw new Error('Pocket steps conflicts with numInferenceSteps');
84
+ }
85
+ function rejectUnsupportedOptions(options) {
86
+ for (const key of UNSUPPORTED_OPTIONS) {
87
+ if (options[key] !== undefined)
88
+ throw new Error('Pocket does not support ' + key);
89
+ }
90
+ }
91
+ function numericOptionValue(key, options, config) {
92
+ if (key === 'outputSampleRate')
93
+ return config.outputSampleRate;
94
+ if (key === 'steps')
95
+ return options.steps === undefined ? options.numInferenceSteps : options.steps;
96
+ return options[key];
97
+ }
98
+ function validateNumericOption(key, value, min, max, integer) {
99
+ if (typeof value !== 'number' ||
100
+ !Number.isFinite(value) ||
101
+ value < min ||
102
+ value > max ||
103
+ (integer && !Number.isInteger(value))) {
104
+ throw new Error('Invalid Pocket ' +
105
+ key +
106
+ ': expected ' +
107
+ (integer ? 'an integer' : 'a number') +
108
+ ' between ' +
109
+ min +
110
+ ' and ' +
111
+ max);
112
+ }
113
+ return value;
114
+ }
115
+ function applyNumericOptions(params, options, config) {
116
+ for (const [key, [min, max, integer = true]] of Object.entries(NUMERIC_OPTIONS)) {
117
+ const value = numericOptionValue(key, options, config);
118
+ if (value !== undefined)
119
+ params[key] = validateNumericOption(key, value, min, max, integer);
120
+ }
121
+ }
122
+ function validateRequiredFiles(params) {
123
+ for (const key of ['pocketFlowModelPath', 'pocketMimiModelPath', 'pocketFrontendPath']) {
124
+ if (typeof params[key] !== 'string' || !params[key])
125
+ throw new Error('Pocket requires ' + key);
126
+ }
127
+ }
128
+ function validateVoiceConditioning(params) {
129
+ if (typeof params.pocketVoicePath !== 'string' ||
130
+ typeof params.referenceAudio !== 'string' ||
131
+ !!params.pocketVoicePath === !!params.referenceAudio) {
132
+ throw new Error('Pocket requires exactly one prepared voice file or referenceAudio WAV');
133
+ }
134
+ }
135
+ function buildPocketParams(files, optionInput, configInput) {
136
+ const options = optionInput;
137
+ const config = configInput;
138
+ validateEngineOptions(options, config);
139
+ rejectUnsupportedOptions(options);
140
+ const params = resolveModelFiles(files, options);
141
+ applyNumericOptions(params, options, config);
142
+ validateRequiredFiles(params);
143
+ validateVoiceConditioning(params);
144
+ return params;
145
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@qvac/tts-ggml",
3
- "version": "0.9.2",
4
- "description": "Text to Speech (TTS) addon for qvac (ggml backend, wrapping the chatterbox + supertonic + parler + cosyvoice3 + audio8 engines from tts-cpp)",
3
+ "version": "0.10.1",
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": {
7
7
  "bare": ">=1.19.0"
@@ -73,11 +73,13 @@
73
73
  "example:supertonic-mtl": "bare examples/supertonic-mtl-tts.js \"Hello from the multilingual Supertonic engine.\"",
74
74
  "example:supertonic-mtl-sweep": "bare examples/supertonic-mtl-sweep-tts.js",
75
75
  "example:supertonic-sentence-stream": "bare examples/supertonic-sentence-stream-tts.js",
76
+ "example:supertonic-chunk-stream": "bare examples/supertonic-chunk-stream-tts.js",
76
77
  "example:cosyvoice": "bare examples/cosyvoice-tts.js \"Hello from CosyVoice three.\"",
77
78
  "example:cosyvoice-enhanced": "bare examples/cosyvoice-enhanced.js \"Hello from CosyVoice three.\"",
78
79
  "example:parler": "bare examples/parler-tts.js \"Hey, how are you doing today?\"",
79
80
  "example:parler-enhanced": "bare examples/parler-enhanced.js \"Hello from enhanced Parler.\"",
80
81
  "example:audio8": "bare examples/audio8-tts.js \"Audio eight speaks on-device.\"",
82
+ "example:moss": "bare examples/moss-tts.js \"MOSS speaks on-device.\"",
81
83
  "lint": "npm run lint:js && npm run lint:ts && npm run test:types",
82
84
  "lint:js": "prettier --check \"test/**/*.js\" \"scripts/__tests__/package-contract.test.js\" \"scripts/__tests__/binding-loader.test.js\" \"binding.js\" \"addon-unavailable.js\" && lunte \"test/**/*.js\" \"scripts/__tests__/package-contract.test.js\" \"scripts/__tests__/binding-loader.test.js\" \"binding.js\" \"addon-unavailable.js\"",
83
85
  "lint:ts": "eslint \"src/**/*.ts\" --max-warnings=0",
@@ -87,6 +89,7 @@
87
89
  "test:node": "node --test scripts/__tests__/*.test.js",
88
90
  "test": "npm run test:types && npm run test:unit && npm run test:integration",
89
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",
90
93
  "test:benchmark:rtf": "npm run build:ts && brittle-bare test/benchmark/rtf-benchmark.test.js",
91
94
  "test:benchmark:rtf:matrix": "node scripts/run-rtf-benchmark-matrix.js",
92
95
  "test:benchmark:streaming": "npm run build:ts && brittle-bare test/benchmark/streaming-benchmark.test.js",
@@ -104,7 +107,10 @@
104
107
  "setup:venv": "bash scripts/setup-venv.sh",
105
108
  "convert-models": "bash scripts/convert-models.sh",
106
109
  "setup-models": "bash scripts/setup-venv.sh && bash scripts/convert-models.sh",
107
- "download-models:registry": "node scripts/download-tts-ggml-models.js"
110
+ "download-models:registry": "node scripts/download-tts-ggml-models.js",
111
+ "download-models:smoke": "node scripts/download-tts-ggml-models.js --group supertonic",
112
+ "example:pocket": "bare examples/pocket-tts.js",
113
+ "test:pocket": "npm run build:ts && brittle-bare test/integration/pocket.integration.test.js"
108
114
  },
109
115
  "files": [
110
116
  "addonLogging.js",
@@ -134,7 +140,8 @@
134
140
  "test/utils/textMetrics.js",
135
141
  "test/data/sentences-medium.js",
136
142
  "test/data/sentences-long.js",
137
- "LICENSE"
143
+ "LICENSE",
144
+ "docs"
138
145
  ],
139
146
  "exports": {
140
147
  "./package": "./package.json",
@@ -209,10 +216,10 @@
209
216
  },
210
217
  "types": "index.d.ts",
211
218
  "optionalDependencies": {
212
- "@qvac/tts-ggml-linux-x64": "0.9.2",
213
- "@qvac/tts-ggml-linux-arm64": "0.9.2",
214
- "@qvac/tts-ggml-darwin-arm64": "0.9.2",
215
- "@qvac/tts-ggml-darwin-x64": "0.9.2",
216
- "@qvac/tts-ggml-win32-x64": "0.9.2"
219
+ "@qvac/tts-ggml-linux-x64": "0.10.1",
220
+ "@qvac/tts-ggml-linux-arm64": "0.10.1",
221
+ "@qvac/tts-ggml-darwin-arm64": "0.10.1",
222
+ "@qvac/tts-ggml-darwin-x64": "0.10.1",
223
+ "@qvac/tts-ggml-win32-x64": "0.10.1"
217
224
  }
218
225
  }
@@ -15,11 +15,12 @@ const {
15
15
  const { ensureChatterboxModels, ensureWhisperModel } = require('../utils/downloadModel')
16
16
  const { loadWhisper, runWhisper } = require('../utils/runWhisper')
17
17
  const { recordTtsStats } = require('../utils/perf-helper')
18
+ const { TTS_TEST_THREADS } = require('../utils/testThreads')
19
+ const { NO_GPU, GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
18
20
 
19
21
  const platform = os.platform()
20
22
  const isMobile = platform === 'ios' || platform === 'android'
21
23
  const isDarwin = platform === 'darwin'
22
- const forceNoGpu = os.getEnv('NO_GPU') === 'true'
23
24
 
24
25
  const INPUT_SENTENCES = (isMobile ? 'short' : os.getEnv('INPUT_SENTENCES')) || 'short'
25
26
  const useSplit = INPUT_SENTENCES !== 'short'
@@ -41,7 +42,7 @@ function getEnglishSentences() {
41
42
 
42
43
  test(
43
44
  'Chatterbox TTS (ggml): English synthesis + optional WER verification',
44
- { timeout: 1800000 },
45
+ { timeout: 1800000, skip: NO_GPU },
45
46
  async (t) => {
46
47
  const baseDir = getBaseDir()
47
48
  const modelsDir = path.join(baseDir, 'models')
@@ -83,8 +84,10 @@ test(
83
84
  `\n=== English synthesis (${englishSentences.length} sentences, tier: ${INPUT_SENTENCES}, modelDir: ${resolvedModelDir}) ===`
84
85
  )
85
86
  const model = await loadChatterboxTTS({
87
+ threads: TTS_TEST_THREADS,
86
88
  modelDir: resolvedModelDir,
87
- language: 'en'
89
+ language: 'en',
90
+ useGPU: GPU_ONLY_USE_GPU
88
91
  })
89
92
  t.ok(model, 'Chatterbox (ggml) model should be loaded')
90
93
 
@@ -250,8 +253,9 @@ test(
250
253
  // empty/undefined value flows through to the engine cleanly.
251
254
  const TTSGgml = require('@qvac/tts-ggml')
252
255
  const model = new TTSGgml({
256
+ threads: TTS_TEST_THREADS,
253
257
  files: { modelDir: download.targetDir },
254
- config: { language: 'en', ...(forceNoGpu ? { useGPU: false } : {}) },
258
+ config: { language: 'en', ...(NO_GPU ? { useGPU: false } : {}) },
255
259
  opts: { stats: true }
256
260
  })
257
261
 
@@ -309,9 +313,10 @@ test(
309
313
  // resolve to (the latter is not readable from native code on iOS).
310
314
  const TTSGgml = require('@qvac/tts-ggml')
311
315
  const model = new TTSGgml({
316
+ threads: TTS_TEST_THREADS,
312
317
  files: { modelDir: download.targetDir },
313
318
  referenceAudio: resolveRefWavPath({}),
314
- config: { language: 'en', outputSampleRate: 16000, ...(forceNoGpu ? { useGPU: false } : {}) },
319
+ config: { language: 'en', outputSampleRate: 16000, ...(NO_GPU ? { useGPU: false } : {}) },
315
320
  opts: { stats: true }
316
321
  })
317
322
  await model.load()
@@ -340,7 +345,7 @@ test(
340
345
 
341
346
  test(
342
347
  'Chatterbox TTS (ggml): native C++ chunk streaming via streamChunkTokens',
343
- { timeout: 600000 },
348
+ { timeout: 600000, skip: NO_GPU },
344
349
  async (t) => {
345
350
  const baseDir = getBaseDir()
346
351
  const modelsDir = path.join(baseDir, 'models')
@@ -361,12 +366,13 @@ test(
361
366
  // rationale).
362
367
  const TTSGgml = require('@qvac/tts-ggml')
363
368
  const model = new TTSGgml({
369
+ threads: TTS_TEST_THREADS,
364
370
  files: { modelDir: download.targetDir },
365
371
  referenceAudio: resolveRefWavPath({}),
366
372
  streamChunkTokens: 25,
367
373
  streamFirstChunkTokens: 10,
368
374
  cfmSteps: 1,
369
- config: { language: 'en', ...(forceNoGpu ? { useGPU: false } : {}) },
375
+ config: { language: 'en', useGPU: GPU_ONLY_USE_GPU },
370
376
  opts: { stats: true }
371
377
  })
372
378
  await model.load()
@@ -10,6 +10,7 @@ const TTSGgml = require('@qvac/tts-ggml')
10
10
  const { ensureAudio8Models } = require('../utils/downloadModel')
11
11
  const { recordTtsStats } = require('../utils/perf-helper')
12
12
  const { resolveRefWavPath } = require('../utils/runChatterboxTTS')
13
+ const { TTS_TEST_THREADS } = require('../utils/testThreads')
13
14
 
14
15
  const AUDIO8_SAMPLE_RATE = 44100
15
16
  const AUDIO8_MAX_FRAMES = 16
@@ -46,6 +47,7 @@ function resolveAudio8Files(models, useModelDir) {
46
47
 
47
48
  function createAudio8Model(models, useGPU, useModelDir) {
48
49
  return new TTSGgml({
50
+ threads: TTS_TEST_THREADS,
49
51
  engine: TTSGgml.ENGINE_AUDIO8,
50
52
  files: resolveAudio8Files(models, useModelDir),
51
53
  greedy: true,
@@ -84,6 +86,9 @@ function assertAudio(t, label, result) {
84
86
  )
85
87
  t.ok(result.stats.tokensPerSecond > 0, `${label} reports codec frames per second`)
86
88
  t.ok(result.stats.audioDurationMs > 0, `${label} reports audio duration`)
89
+ for (const key of ['prefillMs', 'fastDecodeMs', 'codecSynthMs', 'stageTotalMs']) {
90
+ t.ok(result.stats[key] > 0, `${label} reports the engine's ${key}`)
91
+ }
87
92
  }
88
93
 
89
94
  function sampleBytes(samples) {
@@ -145,6 +150,7 @@ test(
145
150
  assertAudio(t, 'Audio8 text-only', textOnly)
146
151
  t.is(textOnly.stats.backendDevice, CPU_DEVICE, 'text-only CPU run reports a CPU device')
147
152
  t.is(textOnly.stats.backendId, CPU_BACKEND, 'text-only CPU run reports the CPU backend')
153
+ t.is(textOnly.stats.voiceEncodeMs, 0, 'text-only run encodes no reference voice')
148
154
  recordAudio8(t, 'audio8 text-only', textOnly, Date.now() - textStarted)
149
155
 
150
156
  const cloneStarted = Date.now()
@@ -154,6 +160,7 @@ test(
154
160
  })
155
161
  assertAudio(t, 'Audio8 voice clone', cloned)
156
162
  t.is(cloned.stats.backendDevice, CPU_DEVICE, 'voice cloning remains on the requested CPU')
163
+ t.ok(cloned.stats.voiceEncodeMs > 0, 'voice cloning reports the reference encode time')
157
164
  t.ok(
158
165
  samplesDiffer(textOnly.samples, cloned.samples),
159
166
  'per-call reference audio changes the synthesized voice'
@@ -25,13 +25,16 @@ const {
25
25
  ensureCangjieTsv
26
26
  } = require('../utils/downloadModel')
27
27
  const { recordTtsStats } = require('../utils/perf-helper')
28
+ const { TTS_TEST_THREADS } = require('../utils/testThreads')
29
+ const { NO_GPU, GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
28
30
 
29
31
  const platform = os.platform()
30
32
  const isMobile = platform === 'ios' || platform === 'android'
31
33
 
32
- // Language coverage test, not a GPU policy test: rely on the package
33
- // default (`useGPU: false`) rather than opting into GPU here.
34
- // 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.
35
38
 
36
39
  function getBaseDir() {
37
40
  return isMobile && global.testDir ? global.testDir : '.'
@@ -61,6 +64,7 @@ async function loadChatterboxMtlTTS(params) {
61
64
  }
62
65
 
63
66
  const model = new TTSGgml({
67
+ threads: TTS_TEST_THREADS,
64
68
  files: {
65
69
  modelDir: params.modelDir,
66
70
  t3Model: params.t3ModelPath,
@@ -81,7 +85,7 @@ async function loadChatterboxMtlTTS(params) {
81
85
 
82
86
  test(
83
87
  'Chatterbox MTL TTS (ggml): synthesizes across es/fr/de with shared engine',
84
- { timeout: 1800000 },
88
+ { timeout: 1800000, skip: NO_GPU },
85
89
  async (t) => {
86
90
  const baseDir = getBaseDir()
87
91
  const download = await ensureChatterboxMtlModels({ targetDir: path.join(baseDir, 'models') })
@@ -96,7 +100,8 @@ test(
96
100
  modelDir: download.targetDir,
97
101
  t3ModelPath: path.join(download.targetDir, 'chatterbox-t3-mtl.gguf'),
98
102
  s3genModelPath: path.join(download.targetDir, 'chatterbox-s3gen-mtl.gguf'),
99
- language: MTL_SENTENCES[0].lang
103
+ language: MTL_SENTENCES[0].lang,
104
+ useGPU: GPU_ONLY_USE_GPU
100
105
  })
101
106
  try {
102
107
  for (let i = 0; i < MTL_SENTENCES.length; i++) {
@@ -145,6 +150,10 @@ test(
145
150
  'backendDevice surfaced in stats'
146
151
  )
147
152
  t.ok(typeof result.data.stats.backendId === 'number', 'backendId surfaced in stats')
153
+ const { t3Tokens, t3Ms, s3genMs, denoiserBackendDevice } = result.data.stats
154
+ t.ok(t3Tokens > 0, 'stats report the speech tokens T3 emitted')
155
+ t.ok(t3Ms > 0 && s3genMs > 0, 'stats report the T3 and S3Gen stage times')
156
+ t.is(denoiserBackendDevice, -1, 'no denoiser loaded -> denoiserBackendDevice=-1')
148
157
  } else {
149
158
  t.fail('expected stats from MTL run')
150
159
  }
@@ -160,7 +169,7 @@ test(
160
169
 
161
170
  test(
162
171
  'Chatterbox MTL TTS (ggml): synthesizes Japanese with MeCab dictionary',
163
- { timeout: 1800000 },
172
+ { timeout: 1800000, skip: NO_GPU },
164
173
  async (t) => {
165
174
  const baseDir = getBaseDir()
166
175
  const download = await ensureChatterboxMtlModels({ targetDir: path.join(baseDir, 'models') })
@@ -182,7 +191,8 @@ test(
182
191
  t3ModelPath: path.join(download.targetDir, 'chatterbox-t3-mtl.gguf'),
183
192
  s3genModelPath: path.join(download.targetDir, 'chatterbox-s3gen-mtl.gguf'),
184
193
  mecabDictDir: mecab.dir,
185
- language: 'ja'
194
+ language: 'ja',
195
+ useGPU: GPU_ONLY_USE_GPU
186
196
  })
187
197
  try {
188
198
  const t0 = Date.now()
@@ -227,7 +237,7 @@ test(
227
237
 
228
238
  test(
229
239
  'Chatterbox MTL TTS (ggml): synthesizes Chinese with Cangjie table',
230
- { timeout: 1800000 },
240
+ { timeout: 1800000, skip: NO_GPU },
231
241
  async (t) => {
232
242
  const baseDir = getBaseDir()
233
243
  const download = await ensureChatterboxMtlModels({ targetDir: path.join(baseDir, 'models') })
@@ -249,7 +259,8 @@ test(
249
259
  t3ModelPath: path.join(download.targetDir, 'chatterbox-t3-mtl.gguf'),
250
260
  s3genModelPath: path.join(download.targetDir, 'chatterbox-s3gen-mtl.gguf'),
251
261
  cangjieTsvPath: cangjie.path,
252
- language: 'zh'
262
+ language: 'zh',
263
+ useGPU: GPU_ONLY_USE_GPU
253
264
  })
254
265
  try {
255
266
  const t0 = Date.now()
@@ -11,6 +11,8 @@ const path = require('bare-path')
11
11
 
12
12
  const { loadChatterboxTTS, runChatterboxTTS } = require('../utils/runChatterboxTTS')
13
13
  const { ensureChatterboxModels } = require('../utils/downloadModel')
14
+ const { TTS_TEST_THREADS } = require('../utils/testThreads')
15
+ const { NO_GPU, GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
14
16
 
15
17
  function getBaseDir() {
16
18
  const platform = os.platform()
@@ -29,7 +31,7 @@ const EXPECTATION = {
29
31
 
30
32
  test(
31
33
  'Chatterbox TTS (ggml): speed config adjusts duration, preserves backward compat',
32
- { timeout: 1800000 },
34
+ { timeout: 1800000, skip: NO_GPU },
33
35
  async (t) => {
34
36
  const modelsDir = path.join(getBaseDir(), 'models')
35
37
 
@@ -47,7 +49,13 @@ test(
47
49
  // across loads, so the only difference between runs is the speed knob.
48
50
  async function sampleCountAt(speed) {
49
51
  const label = speed === undefined ? 'default' : String(speed)
50
- const model = await loadChatterboxTTS({ modelDir, language: 'en', speed })
52
+ const model = await loadChatterboxTTS({
53
+ threads: TTS_TEST_THREADS,
54
+ modelDir,
55
+ language: 'en',
56
+ speed,
57
+ useGPU: GPU_ONLY_USE_GPU
58
+ })
51
59
  try {
52
60
  const r = await runChatterboxTTS(model, { text: TEXT }, EXPECTATION)
53
61
  t.ok(r.passed, `speed=${label}: passes synthesis expectations`)
@@ -94,7 +102,12 @@ test('Chatterbox TTS (ggml): out-of-range speed is rejected', { timeout: 600000
94
102
  // `speed` is bounded to [0.25, 4.0] by ChatterboxModel::validateConfig,
95
103
  // which runs at load time and surfaces as a rejected load.
96
104
  await t.exception(
97
- loadChatterboxTTS({ modelDir: download.targetDir, language: 'en', speed: 5 }),
105
+ loadChatterboxTTS({
106
+ threads: TTS_TEST_THREADS,
107
+ modelDir: download.targetDir,
108
+ language: 'en',
109
+ speed: 5
110
+ }),
98
111
  'speed=5 (> 4.0) is rejected at load'
99
112
  )
100
113
  })