@qvac/tts-ggml 0.9.2 → 0.10.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.
Files changed (42) hide show
  1. package/README.md +317 -33
  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 +14 -9
  15. package/test/integration/addon.test.js +5 -0
  16. package/test/integration/audio8.test.js +7 -0
  17. package/test/integration/chatterbox-mtl.test.js +6 -0
  18. package/test/integration/chatterbox-speed.test.js +13 -2
  19. package/test/integration/cosyvoice3-clone.test.js +16 -1
  20. package/test/integration/cosyvoice3-lavasr.test.js +6 -0
  21. package/test/integration/cosyvoice3.test.js +19 -0
  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 +104 -3
  25. package/test/integration/moss.test.js +282 -0
  26. package/test/integration/multiple-runs.test.js +18 -2
  27. package/test/integration/output-sample-rate.test.js +6 -0
  28. package/test/integration/parler-wer.test.js +7 -1
  29. package/test/integration/parler.test.js +27 -3
  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.0",
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",
@@ -104,7 +106,9 @@
104
106
  "setup:venv": "bash scripts/setup-venv.sh",
105
107
  "convert-models": "bash scripts/convert-models.sh",
106
108
  "setup-models": "bash scripts/setup-venv.sh && bash scripts/convert-models.sh",
107
- "download-models:registry": "node scripts/download-tts-ggml-models.js"
109
+ "download-models:registry": "node scripts/download-tts-ggml-models.js",
110
+ "example:pocket": "bare examples/pocket-tts.js",
111
+ "test:pocket": "npm run build:ts && brittle-bare test/integration/pocket.integration.test.js"
108
112
  },
109
113
  "files": [
110
114
  "addonLogging.js",
@@ -134,7 +138,8 @@
134
138
  "test/utils/textMetrics.js",
135
139
  "test/data/sentences-medium.js",
136
140
  "test/data/sentences-long.js",
137
- "LICENSE"
141
+ "LICENSE",
142
+ "docs"
138
143
  ],
139
144
  "exports": {
140
145
  "./package": "./package.json",
@@ -209,10 +214,10 @@
209
214
  },
210
215
  "types": "index.d.ts",
211
216
  "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"
217
+ "@qvac/tts-ggml-linux-x64": "0.10.0",
218
+ "@qvac/tts-ggml-linux-arm64": "0.10.0",
219
+ "@qvac/tts-ggml-darwin-arm64": "0.10.0",
220
+ "@qvac/tts-ggml-darwin-x64": "0.10.0",
221
+ "@qvac/tts-ggml-win32-x64": "0.10.0"
217
222
  }
218
223
  }
@@ -15,6 +15,7 @@ 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')
18
19
 
19
20
  const platform = os.platform()
20
21
  const isMobile = platform === 'ios' || platform === 'android'
@@ -83,6 +84,7 @@ 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
89
  language: 'en'
88
90
  })
@@ -250,6 +252,7 @@ test(
250
252
  // empty/undefined value flows through to the engine cleanly.
251
253
  const TTSGgml = require('@qvac/tts-ggml')
252
254
  const model = new TTSGgml({
255
+ threads: TTS_TEST_THREADS,
253
256
  files: { modelDir: download.targetDir },
254
257
  config: { language: 'en', ...(forceNoGpu ? { useGPU: false } : {}) },
255
258
  opts: { stats: true }
@@ -309,6 +312,7 @@ test(
309
312
  // resolve to (the latter is not readable from native code on iOS).
310
313
  const TTSGgml = require('@qvac/tts-ggml')
311
314
  const model = new TTSGgml({
315
+ threads: TTS_TEST_THREADS,
312
316
  files: { modelDir: download.targetDir },
313
317
  referenceAudio: resolveRefWavPath({}),
314
318
  config: { language: 'en', outputSampleRate: 16000, ...(forceNoGpu ? { useGPU: false } : {}) },
@@ -361,6 +365,7 @@ test(
361
365
  // rationale).
362
366
  const TTSGgml = require('@qvac/tts-ggml')
363
367
  const model = new TTSGgml({
368
+ threads: TTS_TEST_THREADS,
364
369
  files: { modelDir: download.targetDir },
365
370
  referenceAudio: resolveRefWavPath({}),
366
371
  streamChunkTokens: 25,
@@ -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,6 +25,7 @@ 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')
28
29
 
29
30
  const platform = os.platform()
30
31
  const isMobile = platform === 'ios' || platform === 'android'
@@ -61,6 +62,7 @@ async function loadChatterboxMtlTTS(params) {
61
62
  }
62
63
 
63
64
  const model = new TTSGgml({
65
+ threads: TTS_TEST_THREADS,
64
66
  files: {
65
67
  modelDir: params.modelDir,
66
68
  t3Model: params.t3ModelPath,
@@ -145,6 +147,10 @@ test(
145
147
  'backendDevice surfaced in stats'
146
148
  )
147
149
  t.ok(typeof result.data.stats.backendId === 'number', 'backendId surfaced in stats')
150
+ const { t3Tokens, t3Ms, s3genMs, denoiserBackendDevice } = result.data.stats
151
+ t.ok(t3Tokens > 0, 'stats report the speech tokens T3 emitted')
152
+ t.ok(t3Ms > 0 && s3genMs > 0, 'stats report the T3 and S3Gen stage times')
153
+ t.is(denoiserBackendDevice, -1, 'no denoiser loaded -> denoiserBackendDevice=-1')
148
154
  } else {
149
155
  t.fail('expected stats from MTL run')
150
156
  }
@@ -11,6 +11,7 @@ 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')
14
15
 
15
16
  function getBaseDir() {
16
17
  const platform = os.platform()
@@ -47,7 +48,12 @@ test(
47
48
  // across loads, so the only difference between runs is the speed knob.
48
49
  async function sampleCountAt(speed) {
49
50
  const label = speed === undefined ? 'default' : String(speed)
50
- const model = await loadChatterboxTTS({ modelDir, language: 'en', speed })
51
+ const model = await loadChatterboxTTS({
52
+ threads: TTS_TEST_THREADS,
53
+ modelDir,
54
+ language: 'en',
55
+ speed
56
+ })
51
57
  try {
52
58
  const r = await runChatterboxTTS(model, { text: TEXT }, EXPECTATION)
53
59
  t.ok(r.passed, `speed=${label}: passes synthesis expectations`)
@@ -94,7 +100,12 @@ test('Chatterbox TTS (ggml): out-of-range speed is rejected', { timeout: 600000
94
100
  // `speed` is bounded to [0.25, 4.0] by ChatterboxModel::validateConfig,
95
101
  // which runs at load time and surfaces as a rejected load.
96
102
  await t.exception(
97
- loadChatterboxTTS({ modelDir: download.targetDir, language: 'en', speed: 5 }),
103
+ loadChatterboxTTS({
104
+ threads: TTS_TEST_THREADS,
105
+ modelDir: download.targetDir,
106
+ language: 'en',
107
+ speed: 5
108
+ }),
98
109
  'speed=5 (> 4.0) is rejected at load'
99
110
  )
100
111
  })
@@ -8,6 +8,7 @@
8
8
  const fs = require('bare-fs')
9
9
  const os = require('bare-os')
10
10
  const path = require('bare-path')
11
+ const proc = require('bare-process')
11
12
  const test = require('brittle')
12
13
  const TTSGgml = require('@qvac/tts-ggml')
13
14
 
@@ -18,9 +19,11 @@ const {
18
19
  cosyvoiceBaseFileNames
19
20
  } = require('../utils/downloadModel')
20
21
  const { resolveRefWavPath } = require('../utils/runChatterboxTTS')
22
+ const { TTS_TEST_THREADS } = require('../utils/testThreads')
21
23
 
22
24
  const platform = os.platform()
23
25
  const isMobile = platform === 'ios' || platform === 'android'
26
+ const useGPU = !isMobile && proc.env.NO_GPU === 'false'
24
27
 
25
28
  function getBaseDir() {
26
29
  return isMobile && global.testDir ? global.testDir : '.'
@@ -66,7 +69,12 @@ function samplesDiffer(a, b) {
66
69
  }
67
70
 
68
71
  async function synthOnce(t, loadParams, label) {
69
- const model = await loadCosyvoiceTTS({ ...loadParams, seed: CLONE_SEED })
72
+ const model = await loadCosyvoiceTTS({
73
+ threads: TTS_TEST_THREADS,
74
+ ...loadParams,
75
+ seed: CLONE_SEED,
76
+ useGPU
77
+ })
70
78
  try {
71
79
  const result = await runCosyvoiceTTS(
72
80
  model,
@@ -76,6 +84,11 @@ async function synthOnce(t, loadParams, label) {
76
84
  console.log(result.output)
77
85
  t.ok(result.passed, `${label} synth passes expectations`)
78
86
  t.ok(result.data.sampleCount > 0, `${label} produced audio`)
87
+ t.is(
88
+ result.data.stats?.backendDevice,
89
+ useGPU ? 1 : 0,
90
+ `${label} runs on the ${useGPU ? 'GPU' : 'CPU'} selected by the CI lane`
91
+ )
79
92
  return result.data
80
93
  } finally {
81
94
  try {
@@ -127,6 +140,7 @@ test(
127
140
  t.exception(
128
141
  () =>
129
142
  new TTSGgml({
143
+ threads: TTS_TEST_THREADS,
130
144
  engine: TTSGgml.ENGINE_COSYVOICE3,
131
145
  referenceAudio: resolveRefWavPath({}),
132
146
  files: {
@@ -167,6 +181,7 @@ test(
167
181
  }
168
182
 
169
183
  const model = new TTSGgml({
184
+ threads: TTS_TEST_THREADS,
170
185
  engine: TTSGgml.ENGINE_COSYVOICE3,
171
186
  files: { cosyvoiceModelDir: baseOnlyDir },
172
187
  referenceAudio: resolveRefWavPath({})
@@ -24,6 +24,7 @@ const {
24
24
  ensureLavaSRDenoiserGguf,
25
25
  ensureCosyvoiceModel
26
26
  } = require('../utils/downloadModel')
27
+ const { TTS_TEST_THREADS } = require('../utils/testThreads')
27
28
 
28
29
  const platform = os.platform()
29
30
  const isMobile = platform === 'ios' || platform === 'android'
@@ -126,6 +127,7 @@ test(
126
127
  if (!dl) return
127
128
 
128
129
  const model = new TTSGgml({
130
+ threads: TTS_TEST_THREADS,
129
131
  engine: TTSGgml.ENGINE_COSYVOICE3,
130
132
  files: { cosyvoiceModelDir: dl.modelDir, lavasrEnhancer: enh.path },
131
133
  config: { language: 'en', useGPU: false },
@@ -161,6 +163,7 @@ test(
161
163
  if (!dl) return
162
164
 
163
165
  const model = new TTSGgml({
166
+ threads: TTS_TEST_THREADS,
164
167
  engine: TTSGgml.ENGINE_COSYVOICE3,
165
168
  files: { cosyvoiceModelDir: dl.modelDir, lavasrEnhancer: enh.path },
166
169
  streamChunkTokens: 25,
@@ -201,6 +204,7 @@ test(
201
204
  if (!dl) return
202
205
 
203
206
  const model = new TTSGgml({
207
+ threads: TTS_TEST_THREADS,
204
208
  engine: TTSGgml.ENGINE_COSYVOICE3,
205
209
  files: { cosyvoiceModelDir: dl.modelDir, lavasrEnhancer: enh.path },
206
210
  streamChunkTokens: 25,
@@ -240,6 +244,7 @@ test(
240
244
 
241
245
  const synth = async (files) => {
242
246
  const model = new TTSGgml({
247
+ threads: TTS_TEST_THREADS,
243
248
  engine: TTSGgml.ENGINE_COSYVOICE3,
244
249
  files: Object.assign({ cosyvoiceModelDir: dl.modelDir }, files),
245
250
  config: { language: 'en', useGPU: false, seed: SEED },
@@ -292,6 +297,7 @@ test(
292
297
  fs.writeFileSync(badEnhancer, 'not a gguf')
293
298
 
294
299
  const model = new TTSGgml({
300
+ threads: TTS_TEST_THREADS,
295
301
  engine: TTSGgml.ENGINE_COSYVOICE3,
296
302
  files: { cosyvoiceModelDir: dl.modelDir, lavasrEnhancer: badEnhancer },
297
303
  config: { language: 'en', useGPU: false },