@qvac/tts-ggml 0.14.0 → 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/README.md +260 -40
- package/docs/pocket-tts.md +32 -4
- package/examples/audio8-streaming.js +74 -0
- package/examples/fabric-controls.js +71 -0
- package/examples/lavasr-coreml.js +76 -0
- package/examples/moss-sfx-fit.js +1 -1
- package/index.d.ts +6 -0
- package/index.js +27 -2
- package/lib/engineParams.js +14 -0
- package/lib/engineSetup.d.ts +8 -1
- package/lib/engines/audio8.js +1 -1
- package/lib/engines/moss.d.ts +1 -1
- package/lib/engines/moss.js +8 -7
- package/lib/engines/parler.d.ts +13 -0
- package/lib/engines/parler.js +54 -1
- package/lib/fabricOptions.d.ts +29 -0
- package/lib/fabricOptions.js +133 -0
- package/lib/fit.d.ts +46 -36
- package/lib/fit.js +5 -3
- package/lib/jobFields.js +1 -0
- package/lib/nativeParams.js +12 -0
- package/lib/pocketConfig.js +27 -4
- package/lib/pocketLifecycle.js +9 -1
- package/lib/reload.js +19 -0
- package/lib/sentenceStream.d.ts +15 -3
- package/lib/sentenceStream.js +49 -5
- package/lib/setup.js +38 -2
- package/lib/streamingSupport.js +2 -2
- package/lib/types.d.ts +34 -13
- package/package.json +12 -11
- package/test/integration/audio8-streaming.test.js +66 -0
- package/test/integration/fabric-controls.test.js +124 -0
- package/test/integration/fit.test.js +67 -77
- package/test/integration/moss-fit.test.js +7 -7
- package/test/integration/moss-sfx-fit.test.js +7 -20
- package/test/integration/native-options.test.js +41 -0
- package/tts.d.ts +2 -1
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qvac/tts-ggml",
|
|
3
|
-
"version": "0.
|
|
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-
|
|
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.
|
|
222
|
-
"@qvac/tts-ggml-linux-arm64": "0.
|
|
223
|
-
"@qvac/tts-ggml-darwin-arm64": "0.
|
|
224
|
-
"@qvac/tts-ggml-darwin-x64": "0.
|
|
225
|
-
"@qvac/tts-ggml-win32-x64": "0.
|
|
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.
|
|
230
|
-
"@qvac/tts-ggml-ios": "0.
|
|
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,66 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('brittle')
|
|
4
|
+
const process = require('bare-process')
|
|
5
|
+
const TTSGgml = require('../..')
|
|
6
|
+
|
|
7
|
+
const LM = process.env.QVAC_TEST_AUDIO8_LM_GGUF
|
|
8
|
+
const DECODER = process.env.QVAC_TEST_AUDIO8_DECODER_GGUF
|
|
9
|
+
const FRAME_LIMIT = 48
|
|
10
|
+
const TEXT = 'The listener should hear the first words before the complete response is generated.'
|
|
11
|
+
const TIMEOUT_MS = 180000
|
|
12
|
+
|
|
13
|
+
async function modelFiles() {
|
|
14
|
+
if (LM && DECODER) return { audio8Lm: LM, audio8CodecDecoder: DECODER }
|
|
15
|
+
const { ensureAudio8Models } = require('../utils/downloadModel')
|
|
16
|
+
const models = await ensureAudio8Models({ includeEncoder: false })
|
|
17
|
+
if (!models.success) throw new Error('Audio8 streaming tests require LM and decoder GGUFs')
|
|
18
|
+
return { audio8Lm: models.lmPath, audio8CodecDecoder: models.decoderPath }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function* source() {
|
|
22
|
+
yield TEXT
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function run(model, method) {
|
|
26
|
+
if (method === 'runStream') return model.runStream(TEXT)
|
|
27
|
+
if (method === 'runStreaming') {
|
|
28
|
+
return model.runStreaming(source(), { accumulateSentences: false })
|
|
29
|
+
}
|
|
30
|
+
return model.run({ input: TEXT, streamOutput: true })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const method of ['runStream', 'runStreaming', 'streamOutput']) {
|
|
34
|
+
test(
|
|
35
|
+
`Audio8 ${method} delivers native PCM without replay`,
|
|
36
|
+
{ timeout: TIMEOUT_MS },
|
|
37
|
+
async (t) => {
|
|
38
|
+
const model = new TTSGgml({
|
|
39
|
+
engine: TTSGgml.ENGINE_AUDIO8,
|
|
40
|
+
files: await modelFiles(),
|
|
41
|
+
greedy: true,
|
|
42
|
+
threads: 4,
|
|
43
|
+
maxFrames: FRAME_LIMIT,
|
|
44
|
+
opts: { stats: true }
|
|
45
|
+
})
|
|
46
|
+
try {
|
|
47
|
+
await model.load()
|
|
48
|
+
let chunks = 0
|
|
49
|
+
let samples = 0
|
|
50
|
+
const response = await run(model, method)
|
|
51
|
+
response.onUpdate((chunk) => {
|
|
52
|
+
if (!chunk.outputArray?.length) return
|
|
53
|
+
t.is(chunk.sampleRate, 44100)
|
|
54
|
+
chunks++
|
|
55
|
+
samples += chunk.outputArray.length
|
|
56
|
+
})
|
|
57
|
+
await response.await()
|
|
58
|
+
t.ok(chunks > 1, 'multiple codec windows reached JavaScript')
|
|
59
|
+
t.is(samples, response.stats.totalSamples, 'PCM was delivered exactly once')
|
|
60
|
+
t.is(response.stats.generatedFrames, FRAME_LIMIT)
|
|
61
|
+
} finally {
|
|
62
|
+
await model.unload()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -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
|
-
* `
|
|
30
|
+
* `unknown`, and it carries no rows.
|
|
31
31
|
*/
|
|
32
32
|
function projected(t, fit) {
|
|
33
|
-
if (fit.
|
|
34
|
-
t.
|
|
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(
|
|
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
|
|
76
|
-
t.ok(fit.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
101
|
-
|
|
102
|
-
|
|
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.
|
|
121
|
-
t
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
178
|
+
request.supertonicModelPath = '/nonexistent/model.gguf'
|
|
193
179
|
const second = TTSGgml.assessFit(request)
|
|
194
|
-
const
|
|
195
|
-
|
|
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.
|
|
201
|
-
t.is(b.
|
|
202
|
-
t.is(c.status, '
|
|
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:
|
|
204
|
+
useGPU: true
|
|
222
205
|
})
|
|
223
206
|
await voice.unload()
|
|
224
207
|
const result = await fit
|
|
225
|
-
t.ok(result.
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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.
|
|
45
|
-
t.ok(batch.
|
|
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.
|
|
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.
|
|
56
|
-
t.ok(cloning
|
|
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
|
|
28
|
-
|
|
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.
|
|
45
|
-
t.ok(fit.
|
|
46
|
-
t.ok(fit.
|
|
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.
|
|
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
|
-
|
|
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,7 +1,8 @@
|
|
|
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
|
+
streamOutput?: boolean;
|
|
5
6
|
type: string;
|
|
6
7
|
input: string;
|
|
7
8
|
description?: string;
|