@qvac/tts-ggml 0.14.1 → 0.15.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/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@qvac/tts-ggml",
3
- "version": "0.14.1",
3
+ "version": "0.15.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
- "ggml-speech": "2026-10-27"
6
+ "ggml-speech": "2026-10-27#1"
7
7
  },
8
8
  "engines": {
9
9
  "bare": ">=1.19.0"
@@ -65,7 +65,7 @@
65
65
  "example:moss-sfx": "bare examples/moss-sound-effect.js \"Heavy rain falling on a tin roof with distant thunder.\" 5",
66
66
  "example:moss-speech": "bare examples/moss-speech.js test/reference-audio/jfk.wav",
67
67
  "lint": "npm run lint:js && npm run lint:ts && npm run test:types",
68
- "lint:js": "prettier --check \"test/**/*.js\" \"scripts/run-cpp-fuzz.js\" \"scripts/__tests__/package-contract.test.js\" \"scripts/__tests__/run-cpp-fuzz.test.js\" \"binding.js\" \"addon-unavailable.js\" && lunte \"test/**/*.js\" \"scripts/run-cpp-fuzz.js\" \"scripts/__tests__/package-contract.test.js\" \"scripts/__tests__/run-cpp-fuzz.test.js\" \"binding.js\" \"addon-unavailable.js\"",
68
+ "lint:js": "prettier --check \"test/**/*.js\" \"scripts/run-cpp-fuzz.js\" \"scripts/__tests__/package-contract.test.js\" \"scripts/__tests__/backend-distribution.test.js\" \"scripts/__tests__/run-cpp-fuzz.test.js\" \"binding.js\" \"addon-unavailable.js\" && lunte \"test/**/*.js\" \"scripts/run-cpp-fuzz.js\" \"scripts/__tests__/package-contract.test.js\" \"scripts/__tests__/backend-distribution.test.js\" \"scripts/__tests__/run-cpp-fuzz.test.js\" \"binding.js\" \"addon-unavailable.js\"",
69
69
  "lint:ts": "eslint \"src/**/*.ts\" --max-warnings=0",
70
70
  "lint:ts:fix": "eslint \"src/**/*.ts\" --fix",
71
71
  "format": "prettier --write \"test/**/*.js\" \"scripts/__tests__/package-contract.test.js\" \"binding.js\" \"addon-unavailable.js\"",
@@ -106,7 +106,8 @@
106
106
  "download-models:registry": "node scripts/download-tts-ggml-models.js",
107
107
  "download-models:smoke": "node scripts/download-tts-ggml-models.js --group supertonic",
108
108
  "example:pocket": "bare examples/pocket-tts.js",
109
- "test:pocket": "npm run build:ts && brittle-bare test/integration/pocket.integration.test.js"
109
+ "test:pocket": "npm run build:ts && brittle-bare test/integration/pocket.integration.test.js",
110
+ "example:fabric-controls": "bare examples/fabric-controls.js"
110
111
  },
111
112
  "files": [
112
113
  "addonLogging.js",
@@ -218,16 +219,16 @@
218
219
  "bare-url": "^2.4.3"
219
220
  },
220
221
  "optionalDependencies": {
221
- "@qvac/tts-ggml-linux-x64": "0.14.1",
222
- "@qvac/tts-ggml-linux-arm64": "0.14.1",
223
- "@qvac/tts-ggml-darwin-arm64": "0.14.1",
224
- "@qvac/tts-ggml-darwin-x64": "0.14.1",
225
- "@qvac/tts-ggml-win32-x64": "0.14.1"
222
+ "@qvac/tts-ggml-linux-x64": "0.15.0",
223
+ "@qvac/tts-ggml-linux-arm64": "0.15.0",
224
+ "@qvac/tts-ggml-darwin-arm64": "0.15.0",
225
+ "@qvac/tts-ggml-darwin-x64": "0.15.0",
226
+ "@qvac/tts-ggml-win32-x64": "0.15.0"
226
227
  },
227
228
  "types": "index.d.ts",
228
229
  "peerDependencies": {
229
- "@qvac/tts-ggml-android-arm64": "0.14.1",
230
- "@qvac/tts-ggml-ios": "0.14.1"
230
+ "@qvac/tts-ggml-android-arm64": "0.15.0",
231
+ "@qvac/tts-ggml-ios": "0.15.0"
231
232
  },
232
233
  "peerDependenciesMeta": {
233
234
  "@qvac/tts-ggml-android-arm64": {
@@ -0,0 +1,124 @@
1
+ 'use strict'
2
+
3
+ const path = require('bare-path')
4
+ const proc = require('bare-process')
5
+ const test = require('brittle')
6
+ const TTSGgml = require('../../index')
7
+ const { ensureCosyvoiceModel, ensureSupertonicModel } = require('../utils/downloadModel')
8
+ const { getBaseDir } = require('../utils/runTTS')
9
+ const { NO_GPU, GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
10
+ const { TTS_TEST_THREADS } = require('../utils/testThreads')
11
+
12
+ async function collect(response) {
13
+ let samples = 0
14
+ let terminalCount = 0
15
+ let tokenIds
16
+ for await (const chunk of response.iterate()) {
17
+ samples += chunk.outputArray.length
18
+ if (chunk.isLast) terminalCount++
19
+ if (chunk.speechTokenIds) tokenIds = chunk.speechTokenIds
20
+ }
21
+ return { samples, terminalCount, tokenIds }
22
+ }
23
+
24
+ test(
25
+ 'CosyVoice returns owned speech tokens and replays them through native streaming',
26
+ { skip: NO_GPU },
27
+ async (t) => {
28
+ const download = await ensureCosyvoiceModel({
29
+ targetDir: path.join(getBaseDir(), 'models', 'cosyvoice3')
30
+ })
31
+ t.ok(download.success, 'CosyVoice bundle is available')
32
+ if (!download.success) return
33
+ const model = new TTSGgml({
34
+ engine: 'cosyvoice3',
35
+ files: { cosyvoiceModelDir: download.modelDir },
36
+ config: { useGPU: GPU_ONLY_USE_GPU },
37
+ threads: TTS_TEST_THREADS,
38
+ seed: 42,
39
+ greedy: true,
40
+ opts: { stats: true }
41
+ })
42
+ try {
43
+ await model.load()
44
+ const first = await collect(await model.run({ input: 'Hello from the speech addon.' }))
45
+ t.ok(first.samples > 0, 'batch synthesis emits audio')
46
+ t.ok(first.tokenIds.length > 0, 'batch synthesis returns its speech token trajectory')
47
+ const retained = first.tokenIds.slice()
48
+ await model.reload({ forceSpeechTokens: first.tokenIds, streamChunkTokens: 25 })
49
+ const replay = await model.run({ input: 'Hello from the speech addon.' })
50
+ const streamed = await collect(replay)
51
+ t.ok(streamed.samples > 0, 'forced trajectory emits streamed audio')
52
+ t.is(streamed.terminalCount, 1, 'streaming emits exactly one terminal chunk')
53
+ t.alike(streamed.tokenIds, retained, 'returned IDs match the forced trajectory')
54
+ t.alike(first.tokenIds, retained, 'a subsequent synthesis preserves the first result')
55
+ t.is(replay.stats.speechTokens, retained.length, 'numeric speechTokens remains a count')
56
+ t.is(replay.stats.totalSamples, streamed.samples, 'stream and stats account for the same PCM')
57
+ await t.exception(
58
+ model.reload({ forceSpeechTokens: [2147483647] }),
59
+ /vocabulary/,
60
+ 'an out-of-vocabulary forced ID rejects without aborting the process'
61
+ )
62
+ } finally {
63
+ await model.destroy()
64
+ }
65
+ }
66
+ )
67
+
68
+ test('Supertonic accepts precision controls and remains usable after warmUp', async (t) => {
69
+ const download = await ensureSupertonicModel({ targetDir: path.join(getBaseDir(), 'models') })
70
+ t.ok(download.success, 'Supertonic model is available')
71
+ if (!download.success) return
72
+ const model = new TTSGgml({
73
+ engine: 'supertonic',
74
+ files: { supertonicModel: download.path },
75
+ config: { backend: 'cpu' },
76
+ threads: TTS_TEST_THREADS,
77
+ voice: 'F1',
78
+ precision: 'f32',
79
+ f16Weights: 0,
80
+ f16Attn: 0,
81
+ kvAttnType: 0
82
+ })
83
+ try {
84
+ await model.load()
85
+ t.is(
86
+ await model.warmUp('Speech is ready.'),
87
+ undefined,
88
+ 'warmUp completes without an audio response'
89
+ )
90
+ const result = await collect(await model.run({ input: 'Speech is ready.' }))
91
+ t.ok(result.samples > 0, 'normal synthesis works after warmUp')
92
+ } finally {
93
+ await model.destroy()
94
+ }
95
+ })
96
+
97
+ test(
98
+ 'Pocket preflight applies an explicit memory budget without loading a model',
99
+ { skip: !proc.env.QVAC_POCKET_MODEL_DIR },
100
+ async (t) => {
101
+ const bundle = proc.env.QVAC_POCKET_MODEL_DIR
102
+ const request = {
103
+ engineType: 'pocket',
104
+ pocketFlowModelPath: path.join(bundle, 'flow-lm.gguf'),
105
+ pocketMimiModelPath: path.join(bundle, 'mimi.gguf'),
106
+ pocketFrontendPath: path.join(bundle, 'frontend.json'),
107
+ pocketVoicePath: path.join(bundle, 'voice.gguf'),
108
+ backend: 'cpu',
109
+ threads: TTS_TEST_THREADS,
110
+ text: 'Project this sentence.'
111
+ }
112
+ const result = await TTSGgml.assessFit(request)
113
+ t.is(result.status, 'fits', 'native metadata-only projection fits')
114
+ t.is(result.devices.length, 1, 'a CPU projection has one host pool')
115
+ t.ok(result.devices[0].usedBytes > 0, 'metadata accounts for the load')
116
+ const constrained = await TTSGgml.assessFit({ ...request, memoryBudgetBytes: 1 })
117
+ t.is(constrained.status, 'does-not-fit', 'application memory budget affects the projection')
118
+ t.is(
119
+ constrained.devices[0].usedBytes,
120
+ result.devices[0].usedBytes,
121
+ 'the same metadata is used for both budgets'
122
+ )
123
+ }
124
+ )
@@ -27,16 +27,20 @@ async function supertonicPath(t) {
27
27
  /**
28
28
  * The supertonic fitter covers the fused graph path: a validated GPU, or a CPU
29
29
  * without the Accelerate pointwise kernels. A runner outside that answers
30
- * `compute-path-not-supported`. That is a refusal, and it carries no figures.
30
+ * `unknown`, and it carries no rows.
31
31
  */
32
32
  function projected(t, fit) {
33
- if (fit.reason === 'compute-path-not-supported') {
34
- t.pass('this runner’s compute path is not modelled')
33
+ if (fit.status === 'unknown') {
34
+ t.is(fit.devices.length, 0, 'an unknown carries no rows')
35
35
  return false
36
36
  }
37
37
  return true
38
38
  }
39
39
 
40
+ function hostRow(fit) {
41
+ return fit.devices[fit.devices.length - 1]
42
+ }
43
+
40
44
  function tempDir(t) {
41
45
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tts-fit-'))
42
46
  t.teardown(() => fs.rmSync(dir, { recursive: true, force: true }))
@@ -70,14 +74,19 @@ test('a projection is internally consistent', { timeout: TEST_TIMEOUT_MS }, asyn
70
74
  useGPU: true
71
75
  })
72
76
 
73
- t.comment(`status=${fit.status} reason=${fit.reason} device=${fit.deviceName}`)
77
+ t.comment(JSON.stringify(fit))
74
78
  if (!projected(t, fit)) return
75
- t.ok(fit.status === 'fits' || fit.status === 'does-not-fit', 'a verdict, not an error')
76
- t.ok(fit.deviceTotalBytes > 0, 'the device reported its capacity')
77
- t.ok(fit.deviceFreeBytes <= fit.deviceTotalBytes, 'free never exceeds total')
78
- t.ok(fit.weightsBytes > 0, 'the weights were measured')
79
- t.ok(fit.deviceBytes >= fit.weightsBytes, 'weights are part of the device demand')
80
- t.is(fit.lavasrFileBytes, 0, 'no LavaSR stage was named')
79
+ t.ok(fit.status === 'fits' || fit.status === 'does-not-fit', 'a verdict')
80
+ t.ok(fit.devices.length >= 1, 'at least the device the load resolves to')
81
+ for (const row of fit.devices) {
82
+ t.ok(row.id.length > 0 && row.name.length > 0, `${row.name} is named`)
83
+ t.ok(row.totalBytes > 0, `${row.name} reported its capacity`)
84
+ t.ok(row.freeBytes <= row.totalBytes, `${row.name} free never exceeds total`)
85
+ }
86
+ t.ok(fit.devices[0].usedBytes > 0, 'the device demand was measured')
87
+ t.is(hostRow(fit).name, 'CPU', 'the host row is last')
88
+ t.ok(hostRow(fit).unified, 'the host row is unified')
89
+ t.alike(fit.stats, {})
81
90
  })
82
91
 
83
92
  test('a weightless copy projects the same', { timeout: TEST_TIMEOUT_MS }, async (t) => {
@@ -97,9 +106,11 @@ test('a weightless copy projects the same', { timeout: TEST_TIMEOUT_MS }, async
97
106
  })
98
107
 
99
108
  t.is(stub.status, full.status, 'the same verdict')
100
- t.is(stub.weightsBytes, full.weightsBytes, 'the same weight footprint')
101
- t.is(stub.deviceBytes, full.deviceBytes, 'the same device demand')
102
- t.is(stub.hostBytes, full.hostBytes, 'the same host demand')
109
+ t.alike(
110
+ stub.devices.map((row) => [row.id, row.usedBytes]),
111
+ full.devices.map((row) => [row.id, row.usedBytes]),
112
+ 'the same demand on the same devices'
113
+ )
103
114
  })
104
115
 
105
116
  test('the load’s offload keys reach the projection', { timeout: TEST_TIMEOUT_MS }, async (t) => {
@@ -117,28 +128,14 @@ test('the load’s offload keys reach the projection', { timeout: TEST_TIMEOUT_M
117
128
  nGpuLayers: 0
118
129
  })
119
130
 
120
- t.ok(cpu.deviceIsCpu, 'useGPU false projects on the CPU device')
121
- t.is(pinned.deviceIsCpu, cpu.deviceIsCpu, 'nGpuLayers 0 agrees with useGPU false')
122
- })
123
-
124
- test('a LavaSR stage is sized beside the projection', { timeout: TEST_TIMEOUT_MS }, async (t) => {
125
- const model = await supertonicPath(t)
126
- if (model === null) return
127
-
128
- const dir = tempDir(t)
129
- const enhancer = path.join(dir, 'enhancer.gguf')
130
- fs.writeFileSync(enhancer, Buffer.alloc(4096, 1))
131
-
132
- const fit = await TTSGgml.assessFit({
133
- engineType: 'supertonic',
134
- supertonicModelPath: model,
135
- useGPU: true,
136
- lavasrEnhancerPath: enhancer
137
- })
138
-
139
- t.is(fit.lavasrFileBytes, 4096, 'the enhancer is reported at its size on disk')
140
- if (!projected(t, fit)) return
141
- t.ok(fit.lavasrFileBytes < fit.deviceBytes, 'reported beside the projection')
131
+ t.is(pinned.status, cpu.status, 'nGpuLayers 0 agrees with useGPU false')
132
+ if (!projected(t, cpu)) return
133
+ t.is(cpu.devices.length, 1, 'useGPU false projects on the CPU alone')
134
+ t.ok(cpu.devices[0].unified, 'the CPU row is the host')
135
+ // Free memory is read again by each call, so only the placement and the
136
+ // demand are compared.
137
+ const placement = (fit) => fit.devices.map(({ freeBytes, ...row }) => row)
138
+ t.alike(placement(pinned), placement(cpu), 'the same row')
142
139
  })
143
140
 
144
141
  test('fitting keeps the event loop responsive', { timeout: TEST_TIMEOUT_MS }, async (t) => {
@@ -166,9 +163,7 @@ test('fitting keeps the event loop responsive', { timeout: TEST_TIMEOUT_MS }, as
166
163
 
167
164
  t.comment(`${turns} loop turns before the fits settled`)
168
165
  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
- }
166
+ t.is(new Set(fits.map((fit) => fit.status)).size, 1, 'every queued projection agrees')
172
167
  })
173
168
 
174
169
  test(
@@ -178,28 +173,16 @@ test(
178
173
  const model = await supertonicPath(t)
179
174
  if (model === null) return
180
175
 
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
- }
176
+ const request = { engineType: 'supertonic', supertonicModelPath: model, useGPU: true }
191
177
  const first = TTSGgml.assessFit(request)
192
- request.lavasrEnhancerPath = ''
178
+ request.supertonicModelPath = '/nonexistent/model.gguf'
193
179
  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])
180
+ const third = TTSGgml.assessFit({ ...request, supertonicModelPath: model })
181
+ const [a, b, c] = await Promise.allSettled([first, second, third])
199
182
 
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')
183
+ t.is(a.status, 'fulfilled', 'the first call owns its original request')
184
+ t.is(b.status, 'rejected', 'the second call owns its changed request')
185
+ t.is(c.status, 'fulfilled', 'a rejection does not strand queued callers')
203
186
  }
204
187
  )
205
188
 
@@ -218,11 +201,11 @@ test('a fit settles while a loaded model unloads', { timeout: TEST_TIMEOUT_MS },
218
201
  const fit = TTSGgml.assessFit({
219
202
  engineType: 'supertonic',
220
203
  supertonicModelPath: model,
221
- useGPU: false
204
+ useGPU: true
222
205
  })
223
206
  await voice.unload()
224
207
  const result = await fit
225
- t.ok(result.deviceTotalBytes > 0, 'the projection measured a device')
208
+ t.ok(['fits', 'does-not-fit', 'unknown'].includes(result.status), 'the fit settled')
226
209
  } finally {
227
210
  try {
228
211
  await voice.unload()
@@ -231,27 +214,34 @@ test('a fit settles while a loaded model unloads', { timeout: TEST_TIMEOUT_MS },
231
214
  })
232
215
 
233
216
  test('MOSS routes to its fitter when a model cannot be read', async (t) => {
234
- const fit = await TTSGgml.assessFit({
235
- engineType: 'moss',
236
- mossBackbonePath: '/models/moss-tts-delay.gguf',
237
- mossCodecDecoderPath: '/models/moss-codec-decoder.gguf',
238
- promptRows: 128,
239
- referenceSamples: 0,
240
- streaming: false
241
- })
242
-
243
- t.is(fit.status, 'error')
244
- t.is(fit.reason, 'model-unreadable')
245
- t.is(fit.modelVariant, 'moss')
217
+ await t.exception(
218
+ TTSGgml.assessFit({
219
+ engineType: 'moss',
220
+ mossBackbonePath: '/models/moss-tts-delay.gguf',
221
+ mossCodecDecoderPath: '/models/moss-codec-decoder.gguf',
222
+ promptRows: 128,
223
+ referenceSamples: 0,
224
+ streaming: false
225
+ }),
226
+ /model-unreadable/
227
+ )
246
228
  })
247
229
 
248
- test('a model that cannot be read is an outcome, not a rejection', async (t) => {
249
- const fit = await TTSGgml.assessFit({
250
- engineType: 'supertonic',
251
- supertonicModelPath: '/nonexistent/model.gguf'
252
- })
230
+ test('a model that cannot be read rejects', async (t) => {
231
+ await t.exception(
232
+ TTSGgml.assessFit({
233
+ engineType: 'supertonic',
234
+ supertonicModelPath: '/nonexistent/model.gguf'
235
+ }),
236
+ /model-unreadable/
237
+ )
238
+ })
253
239
 
254
- t.is(fit.status, 'error')
240
+ test('a voice with no fitter rejects', async (t) => {
241
+ await t.exception(
242
+ TTSGgml.assessFit({ engineType: 'moss-speech', mossSpeechModelPath: '/models/speech.gguf' }),
243
+ /has no fitter/
244
+ )
255
245
  })
256
246
 
257
247
  test('a count that is not a count is refused', async (t) => {
@@ -40,18 +40,18 @@ test('MOSS metadata-only fit covers batch, streaming and cloning', async (t) =>
40
40
  referenceSamples: 0,
41
41
  streaming: false
42
42
  }
43
+ const used = (fit) => fit.devices.reduce((total, row) => total + row.usedBytes, 0)
44
+
43
45
  const batch = await TTSGgml.assessFit(request)
44
- t.not(batch.status, 'error', batch.report)
45
- t.ok(batch.weightsBytes > 0)
46
- t.ok(batch.lmComputeBytes > 0)
47
- t.ok(batch.codecComputeBytes > 0)
46
+ t.ok(batch.status === 'fits' || batch.status === 'does-not-fit', JSON.stringify(batch))
47
+ t.ok(batch.devices[0].usedBytes > 0)
48
48
  const stream = await TTSGgml.assessFit({ ...request, streaming: true })
49
- t.not(stream.status, 'error', stream.report)
49
+ t.ok(stream.status === 'fits' || stream.status === 'does-not-fit', JSON.stringify(stream))
50
50
  const cloning = await TTSGgml.assessFit({
51
51
  ...request,
52
52
  mossCodecEncoderPath: path.join(modelDir, 'moss-codec-encoder-f16.gguf'),
53
53
  referenceSamples: 24000
54
54
  })
55
- t.not(cloning.status, 'error', cloning.report)
56
- t.ok(cloning.weightsBytes > batch.weightsBytes)
55
+ t.ok(cloning.status === 'fits' || cloning.status === 'does-not-fit', JSON.stringify(cloning))
56
+ t.ok(used(cloning) > used(batch), 'the encoder adds to the demand')
57
57
  })
@@ -24,11 +24,8 @@ test('MOSS sound-effect fit rejects missing workload and invalid load controls',
24
24
  await t.exception(TTSGgml.assessFit(request({ marginBytes: -1 })))
25
25
  })
26
26
 
27
- test('MOSS sound-effect fit reports unreadable models', async (t) => {
28
- const fit = await TTSGgml.assessFit(request())
29
- t.is(fit.status, 'error')
30
- t.is(fit.reason, 'model-unreadable')
31
- t.is(fit.modelVariant, 'moss-sfx')
27
+ test('MOSS sound-effect fit rejects unreadable models', async (t) => {
28
+ await t.exception(TTSGgml.assessFit(request()), /model-unreadable/)
32
29
  })
33
30
 
34
31
  test(
@@ -41,23 +38,13 @@ test(
41
38
  const cfg = { mossSoundEffectPath: model }
42
39
  const fit = await TTSGgml.assessFit(request(cfg))
43
40
  t.ok(fit.status === 'fits' || fit.status === 'does-not-fit')
44
- t.is(fit.modelVariant, 'moss-sfx')
45
- t.ok(fit.deviceIsCpu)
46
- t.ok(fit.deviceSharesHostMemory)
47
- t.ok(fit.weightsBytes > 0)
48
- t.ok(fit.lmComputeBytes > 0)
49
- t.ok(fit.hostBytes > 0)
50
- t.is(
51
- fit.deviceBytes,
52
- fit.weightsBytes + fit.stateBytes + fit.lmComputeBytes + fit.codecComputeBytes
53
- )
41
+ t.is(fit.devices.length, 1, 'a CPU load is one row')
42
+ t.ok(fit.devices[0].unified, 'the CPU row is the host')
43
+ t.ok(fit.devices[0].usedBytes > 0)
54
44
  const shorter = await TTSGgml.assessFit(request({ ...cfg, seconds: 1 }))
55
- t.is(shorter.weightsBytes, fit.weightsBytes)
56
- t.is(shorter.lmComputeBytes, fit.lmComputeBytes)
45
+ t.ok(shorter.devices[0].usedBytes <= fit.devices[0].usedBytes, 'a shorter clip needs no more')
57
46
  const noHeadroom = await TTSGgml.assessFit(request({ ...cfg, marginBytes: Number.MAX_VALUE }))
58
47
  t.is(noHeadroom.status, 'does-not-fit')
59
- const invalid = await TTSGgml.assessFit(request({ ...cfg, seconds: 0 }))
60
- t.is(invalid.status, 'error')
61
- t.is(invalid.reason, 'invalid-arguments')
48
+ await t.exception(TTSGgml.assessFit(request({ ...cfg, seconds: 0 })), /invalid-arguments/)
62
49
  }
63
50
  )
@@ -0,0 +1,41 @@
1
+ 'use strict'
2
+
3
+ const test = require('brittle')
4
+ const binding = require('../../binding')
5
+ const TTSGgml = require('../../index')
6
+
7
+ const POCKET_FIT = {
8
+ engineType: 'pocket',
9
+ pocketFlowModelPath: '/missing/flow-lm.gguf',
10
+ pocketMimiModelPath: '/missing/mimi.gguf',
11
+ pocketFrontendPath: '/missing/frontend.json',
12
+ pocketVoicePath: '/missing/voice.gguf',
13
+ text: 'Project this sentence.'
14
+ }
15
+
16
+ test('Pocket preflight reaches its native fitter and validates explicit byte budgets', async (t) => {
17
+ const result = await TTSGgml.assessFit(POCKET_FIT)
18
+ t.is(result.status, 'unknown', 'an unmeasurable native projection stays unknown')
19
+ t.alike(result.devices, [])
20
+ t.alike(result.stats, {})
21
+ for (const memoryBudgetBytes of [-1, 1.5, Infinity]) {
22
+ await t.exception(
23
+ TTSGgml.assessFit({ ...POCKET_FIT, memoryBudgetBytes }),
24
+ /count|integer|finite/
25
+ )
26
+ }
27
+ })
28
+
29
+ test('native option parsing rejects malformed vectors and environment maps before loading weights', (t) => {
30
+ const cases = [
31
+ ['cosyvoice3', { forceSpeechTokens: [1.5] }, /integer/],
32
+ ['cosyvoice3', { forceSpeechTokens: [NaN] }, /integer/],
33
+ ['supertonic', { voiceStyleTtl: new Float32Array([Infinity]) }, /finite/],
34
+ ['supertonic', { voiceStyleDp: ['invalid'] }, /numbers/],
35
+ ['supertonic', { vulkanEnvOverrides: { GGML_VK_TEST: false } }, /string/],
36
+ ['moss', { textTemperature: Infinity }, /finite/]
37
+ ]
38
+ for (const [engineType, fields, error] of cases) {
39
+ t.exception(() => binding.createInstance({}, { engineType, ...fields }, () => {}), error)
40
+ }
41
+ })
package/tts.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export interface TTSConfigurationParams {
2
- [key: string]: string | number | boolean | string[] | undefined;
2
+ [key: string]: string | number | boolean | string[] | number[] | Float32Array | Record<string, string> | undefined;
3
3
  }
4
4
  export interface TTSJobData {
5
5
  streamOutput?: boolean;