@qvac/tts-ggml 0.10.0 → 0.11.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 +251 -23
- package/examples/moss-sfx-fit.js +18 -0
- package/examples/moss-sound-effect.js +125 -0
- package/examples/moss-speech.js +136 -0
- package/examples/pcm-chunk-player.js +35 -10
- package/index.d.ts +116 -6
- package/index.js +391 -2
- package/lib/fit.d.ts +22 -2
- package/package.json +33 -16
- package/test/integration/addon.test.js +8 -7
- package/test/integration/chatterbox-mtl.test.js +14 -9
- package/test/integration/chatterbox-speed.test.js +4 -2
- package/test/integration/cosyvoice3-clone.test.js +3 -3
- package/test/integration/cosyvoice3-lavasr.test.js +15 -11
- package/test/integration/cosyvoice3.test.js +14 -8
- package/test/integration/fit.test.js +7 -3
- package/test/integration/lavasr-enhancer.test.js +3 -2
- package/test/integration/moss-fit.test.js +57 -0
- package/test/integration/moss-sfx-fit.test.js +63 -0
- package/test/integration/moss-sfx.test.js +96 -0
- package/test/integration/moss-speech.test.js +110 -0
- package/test/integration/moss.test.js +21 -16
- package/test/integration/multiple-runs.test.js +69 -58
- package/test/integration/output-sample-rate.test.js +4 -2
- package/test/integration/parler-wer.test.js +7 -8
- package/test/integration/parler.test.js +27 -21
- package/test/mobile/integration.auto.cjs +15 -0
- package/tts.d.ts +20 -0
- /package/test/{mobile → pocket-worklet}/pocket-worklet-host.cpp +0 -0
- /package/test/{mobile → pocket-worklet}/pocket-worklet.cjs +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('brittle')
|
|
4
|
+
const process = require('bare-process')
|
|
5
|
+
const TTSGgml = require('@qvac/tts-ggml')
|
|
6
|
+
|
|
7
|
+
function request(overrides = {}) {
|
|
8
|
+
return {
|
|
9
|
+
engineType: 'moss-sfx',
|
|
10
|
+
mossSoundEffectPath: '/nonexistent/moss-sfx.gguf',
|
|
11
|
+
prompt: 'Rain on a tin roof.',
|
|
12
|
+
seconds: 8,
|
|
13
|
+
useGPU: false,
|
|
14
|
+
...overrides
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('MOSS sound-effect fit rejects missing workload and invalid load controls', (t) => {
|
|
19
|
+
t.exception(() => TTSGgml.assessFit(request({ prompt: undefined })))
|
|
20
|
+
t.exception(() => TTSGgml.assessFit(request({ seconds: undefined })))
|
|
21
|
+
t.exception(() => TTSGgml.assessFit(request({ seconds: NaN })))
|
|
22
|
+
t.exception(() => TTSGgml.assessFit(request({ threads: -1 })))
|
|
23
|
+
t.exception(() => TTSGgml.assessFit(request({ useGPU: true, nGpuLayers: 0 })))
|
|
24
|
+
t.exception(() => TTSGgml.assessFit(request({ marginBytes: -1 })))
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('MOSS sound-effect fit reports unreadable models', (t) => {
|
|
28
|
+
const fit = TTSGgml.assessFit(request())
|
|
29
|
+
t.is(fit.status, 'error')
|
|
30
|
+
t.is(fit.reason, 'model-unreadable')
|
|
31
|
+
t.is(fit.modelVariant, 'moss-sfx')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test(
|
|
35
|
+
'MOSS sound-effect fit projects real-model CPU workloads',
|
|
36
|
+
{
|
|
37
|
+
skip: !process.env.QVAC_TEST_MOSS_SFX_GGUF
|
|
38
|
+
},
|
|
39
|
+
(t) => {
|
|
40
|
+
const model = process.env.QVAC_TEST_MOSS_SFX_GGUF
|
|
41
|
+
const cfg = { mossSoundEffectPath: model }
|
|
42
|
+
const fit = TTSGgml.assessFit(request(cfg))
|
|
43
|
+
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
|
+
)
|
|
54
|
+
const shorter = TTSGgml.assessFit(request({ ...cfg, seconds: 1 }))
|
|
55
|
+
t.is(shorter.weightsBytes, fit.weightsBytes)
|
|
56
|
+
t.is(shorter.lmComputeBytes, fit.lmComputeBytes)
|
|
57
|
+
const noHeadroom = TTSGgml.assessFit(request({ ...cfg, marginBytes: Number.MAX_VALUE }))
|
|
58
|
+
t.is(noHeadroom.status, 'does-not-fit')
|
|
59
|
+
const invalid = TTSGgml.assessFit(request({ ...cfg, seconds: 0 }))
|
|
60
|
+
t.is(invalid.status, 'error')
|
|
61
|
+
t.is(invalid.reason, 'invalid-arguments')
|
|
62
|
+
}
|
|
63
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('bare-path')
|
|
4
|
+
const proc = require('bare-process')
|
|
5
|
+
const test = require('brittle')
|
|
6
|
+
|
|
7
|
+
const TTSGgml = require('@qvac/tts-ggml')
|
|
8
|
+
|
|
9
|
+
const SFX_SAMPLE_RATE = 48000
|
|
10
|
+
const TENTHS_PER_SECOND = 10
|
|
11
|
+
const TEST_TIMEOUT_MS = 1800000
|
|
12
|
+
const PROMPT = 'A glass falls and shatters on a tile floor.'
|
|
13
|
+
const MODEL_DIR_ENV = 'QVAC_TEST_MOSS_SFX_MODEL_DIR'
|
|
14
|
+
const QUICK_STEPS = 2
|
|
15
|
+
const SHORT_SECONDS = 1.5
|
|
16
|
+
const LONGER_SECONDS = 2.5
|
|
17
|
+
const CPU_DEVICE = 0
|
|
18
|
+
const GPU_DEVICE = 1
|
|
19
|
+
|
|
20
|
+
const modelDir = (proc.env && proc.env[MODEL_DIR_ENV]) || ''
|
|
21
|
+
const skipWithoutModels = modelDir === ''
|
|
22
|
+
const noGpu = proc.env && proc.env.NO_GPU === 'true'
|
|
23
|
+
|
|
24
|
+
function createSfxModel(useGPU = false) {
|
|
25
|
+
return new TTSGgml({
|
|
26
|
+
engine: TTSGgml.ENGINE_MOSS_SFX,
|
|
27
|
+
files: { modelDir: path.resolve(modelDir) },
|
|
28
|
+
config: { useGPU },
|
|
29
|
+
seed: 0,
|
|
30
|
+
opts: { stats: true }
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function collectChunk(result, data) {
|
|
35
|
+
if (!data || !data.outputArray) return
|
|
36
|
+
result.samples += data.outputArray.length
|
|
37
|
+
if (data.sampleRate) result.sampleRate = data.sampleRate
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function generate(model, fields) {
|
|
41
|
+
const response = await model.run({ input: PROMPT, type: 'text', steps: QUICK_STEPS, ...fields })
|
|
42
|
+
const result = { samples: 0, sampleRate: null }
|
|
43
|
+
await response.onUpdate((data) => collectChunk(result, data)).await()
|
|
44
|
+
result.stats = response.stats
|
|
45
|
+
return result
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function samplesAtNativeDuration(seconds) {
|
|
49
|
+
return Math.round(seconds * TENTHS_PER_SECOND) * (SFX_SAMPLE_RATE / TENTHS_PER_SECOND)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertClip(t, label, result, seconds) {
|
|
53
|
+
t.is(result.sampleRate, SFX_SAMPLE_RATE, `${label} reports 48 kHz`)
|
|
54
|
+
t.is(
|
|
55
|
+
result.samples,
|
|
56
|
+
samplesAtNativeDuration(seconds),
|
|
57
|
+
`${label} lasts exactly ${seconds} s, rounded to 0.1 s as the engine documents`
|
|
58
|
+
)
|
|
59
|
+
t.ok(result.stats, `${label} returns runtime stats`)
|
|
60
|
+
t.is(result.stats.totalSamples, result.samples, `${label} reports the emitted samples`)
|
|
61
|
+
t.ok(result.stats.totalTime > 0, `${label} reports its generation time`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test(
|
|
65
|
+
'MOSS-SFX integration: a prompt generates the requested duration on CPU',
|
|
66
|
+
{ timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels },
|
|
67
|
+
async (t) => {
|
|
68
|
+
const model = createSfxModel()
|
|
69
|
+
await model.load()
|
|
70
|
+
try {
|
|
71
|
+
const result = await generate(model, { seconds: SHORT_SECONDS })
|
|
72
|
+
assertClip(t, 'cpu clip', result, SHORT_SECONDS)
|
|
73
|
+
t.is(result.stats.backendDevice, CPU_DEVICE, 'the clip ran on the CPU')
|
|
74
|
+
} finally {
|
|
75
|
+
await model.unload()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
test(
|
|
81
|
+
'MOSS-SFX integration: seconds and the negative prompt change per call',
|
|
82
|
+
{ timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels || noGpu },
|
|
83
|
+
async (t) => {
|
|
84
|
+
const model = createSfxModel(true)
|
|
85
|
+
await model.load()
|
|
86
|
+
try {
|
|
87
|
+
const short = await generate(model, { seconds: SHORT_SECONDS })
|
|
88
|
+
const longer = await generate(model, { seconds: LONGER_SECONDS, negativePrompt: 'music' })
|
|
89
|
+
assertClip(t, 'short clip', short, SHORT_SECONDS)
|
|
90
|
+
assertClip(t, 'longer clip', longer, LONGER_SECONDS)
|
|
91
|
+
t.is(short.stats.backendDevice, GPU_DEVICE, 'the clips ran on the GPU')
|
|
92
|
+
} finally {
|
|
93
|
+
await model.unload()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('bare-path')
|
|
4
|
+
const proc = require('bare-process')
|
|
5
|
+
const test = require('brittle')
|
|
6
|
+
|
|
7
|
+
const TTSGgml = require('@qvac/tts-ggml')
|
|
8
|
+
const { resolveRefWavPath } = require('../utils/runChatterboxTTS')
|
|
9
|
+
const { readWavAsFloat32 } = require('../utils/wav-helper')
|
|
10
|
+
const { TTS_TEST_THREADS } = require('../utils/testThreads')
|
|
11
|
+
|
|
12
|
+
const SPEECH_SAMPLE_RATE = 24000
|
|
13
|
+
const TEST_TIMEOUT_MS = 1800000
|
|
14
|
+
const MODEL_DIR_ENV = 'QVAC_TEST_MOSS_SPEECH_MODEL_DIR'
|
|
15
|
+
const MAX_NEW_TOKENS = 120
|
|
16
|
+
const CUT_SECONDS = 2
|
|
17
|
+
const CUT_MARGIN_SECONDS = 0.5
|
|
18
|
+
const CPU_DEVICE = 0
|
|
19
|
+
const GPU_DEVICE = 1
|
|
20
|
+
|
|
21
|
+
const modelDir = (proc.env && proc.env[MODEL_DIR_ENV]) || ''
|
|
22
|
+
const skipWithoutModels = modelDir === ''
|
|
23
|
+
const noGpu = proc.env && proc.env.NO_GPU === 'true'
|
|
24
|
+
|
|
25
|
+
function createSpeechModel(useGPU = false) {
|
|
26
|
+
return new TTSGgml({
|
|
27
|
+
threads: TTS_TEST_THREADS,
|
|
28
|
+
engine: TTSGgml.ENGINE_MOSS_SPEECH,
|
|
29
|
+
files: { modelDir: path.resolve(modelDir) },
|
|
30
|
+
config: { useGPU },
|
|
31
|
+
seed: 0,
|
|
32
|
+
opts: { stats: true }
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function spokenQuestion() {
|
|
37
|
+
const wav = readWavAsFloat32(resolveRefWavPath({}))
|
|
38
|
+
return { audio: wav.samples, sampleRate: wav.sampleRate }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function collectReply(result, data) {
|
|
42
|
+
if (!data || !data.outputArray) return
|
|
43
|
+
result.samples += data.outputArray.length
|
|
44
|
+
if (data.sampleRate) result.sampleRate = data.sampleRate
|
|
45
|
+
if (typeof data.text === 'string') result.text += data.text
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function respond(model, fields) {
|
|
49
|
+
const response = await model.run({
|
|
50
|
+
type: 'text',
|
|
51
|
+
input: '',
|
|
52
|
+
maxNewTokens: MAX_NEW_TOKENS,
|
|
53
|
+
...fields
|
|
54
|
+
})
|
|
55
|
+
const result = { samples: 0, sampleRate: null, text: '' }
|
|
56
|
+
await response.onUpdate((data) => collectReply(result, data)).await()
|
|
57
|
+
result.stats = response.stats
|
|
58
|
+
return result
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function assertSpokenReply(t, label, result) {
|
|
62
|
+
t.is(result.sampleRate, SPEECH_SAMPLE_RATE, `${label} reports 24 kHz`)
|
|
63
|
+
t.ok(result.samples > 0, `${label} carries speech`)
|
|
64
|
+
t.ok(result.stats, `${label} returns runtime stats`)
|
|
65
|
+
t.is(result.stats.totalSamples, result.samples, `${label} reports the emitted samples`)
|
|
66
|
+
t.ok(result.stats.promptTokens > 0, `${label} reports its prompt length`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test(
|
|
70
|
+
'MOSS-Speech integration: a spoken question gets a spoken answer on CPU',
|
|
71
|
+
{ timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels },
|
|
72
|
+
async (t) => {
|
|
73
|
+
const model = createSpeechModel()
|
|
74
|
+
await model.load()
|
|
75
|
+
try {
|
|
76
|
+
const result = await respond(model, spokenQuestion())
|
|
77
|
+
assertSpokenReply(t, 'cpu reply', result)
|
|
78
|
+
t.is(result.stats.backendDevice, CPU_DEVICE, 'the reply ran on the CPU')
|
|
79
|
+
} finally {
|
|
80
|
+
await model.unload()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
test(
|
|
86
|
+
'MOSS-Speech integration: text replies, reply cuts and a text turn on the GPU',
|
|
87
|
+
{ timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels || noGpu },
|
|
88
|
+
async (t) => {
|
|
89
|
+
const model = createSpeechModel(true)
|
|
90
|
+
await model.load()
|
|
91
|
+
try {
|
|
92
|
+
const textReply = await respond(model, { ...spokenQuestion(), textReply: true })
|
|
93
|
+
t.is(textReply.samples, 0, 'a text reply carries no audio')
|
|
94
|
+
t.ok(textReply.text.trim().length > 0, 'a text reply carries the answer')
|
|
95
|
+
|
|
96
|
+
const cut = await respond(model, { ...spokenQuestion(), maxReplySeconds: CUT_SECONDS })
|
|
97
|
+
assertSpokenReply(t, 'cut reply', cut)
|
|
98
|
+
t.ok(
|
|
99
|
+
cut.samples <= (CUT_SECONDS + CUT_MARGIN_SECONDS) * SPEECH_SAMPLE_RATE,
|
|
100
|
+
'maxReplySeconds bounds the spoken reply'
|
|
101
|
+
)
|
|
102
|
+
t.is(cut.stats.backendDevice, GPU_DEVICE, 'the replies ran on the GPU')
|
|
103
|
+
|
|
104
|
+
const typed = await respond(model, { input: 'What is the capital of France?' })
|
|
105
|
+
assertSpokenReply(t, 'reply to a typed question', typed)
|
|
106
|
+
} finally {
|
|
107
|
+
await model.unload()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
)
|
|
@@ -11,6 +11,7 @@ const { recordTtsStats } = require('../utils/perf-helper')
|
|
|
11
11
|
const { resolveRefWavPath } = require('../utils/runChatterboxTTS')
|
|
12
12
|
const { readWavAsFloat32, createWav, resampleLinear } = require('../utils/wav-helper')
|
|
13
13
|
const { TTS_TEST_THREADS } = require('../utils/testThreads')
|
|
14
|
+
const { NO_GPU, GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
|
|
14
15
|
|
|
15
16
|
const MOSS_SAMPLE_RATE = 24000
|
|
16
17
|
const MOSS_STREAM_FRAMES = 10
|
|
@@ -34,7 +35,6 @@ const GPU_DEVICE = 1
|
|
|
34
35
|
|
|
35
36
|
const modelDir = (proc.env && proc.env[MODEL_DIR_ENV]) || ''
|
|
36
37
|
const skipWithoutModels = modelDir === ''
|
|
37
|
-
const noGpu = proc.env && proc.env.NO_GPU === 'true'
|
|
38
38
|
|
|
39
39
|
function createMossModel(extra = {}, useGPU = false) {
|
|
40
40
|
return new TTSGgml({
|
|
@@ -184,7 +184,7 @@ test(
|
|
|
184
184
|
|
|
185
185
|
test(
|
|
186
186
|
'MOSS TTS: useGPU=true synthesizes on a GPU backend',
|
|
187
|
-
{ timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels ||
|
|
187
|
+
{ timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels || NO_GPU },
|
|
188
188
|
async (t) => {
|
|
189
189
|
await withLoadedModel(
|
|
190
190
|
{},
|
|
@@ -231,25 +231,29 @@ test(
|
|
|
231
231
|
|
|
232
232
|
test(
|
|
233
233
|
'MOSS TTS: durationTokens steers the length of the synthesized speech',
|
|
234
|
-
{ timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels },
|
|
234
|
+
{ timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels || NO_GPU },
|
|
235
235
|
async (t) => {
|
|
236
|
-
await withLoadedModel(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
long
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
236
|
+
await withLoadedModel(
|
|
237
|
+
{ durationTokens: SHORT_DURATION_TOKENS, seed: 7 },
|
|
238
|
+
async (model) => {
|
|
239
|
+
const short = await synthesize(model, SYNTHESIS_TEXT)
|
|
240
|
+
assertAudio(t, 'MOSS short target', short)
|
|
241
|
+
await model.reload({ durationTokens: LONG_DURATION_TOKENS })
|
|
242
|
+
const long = await synthesize(model, SYNTHESIS_TEXT)
|
|
243
|
+
assertAudio(t, 'MOSS long target', long)
|
|
244
|
+
t.ok(
|
|
245
|
+
long.samples.length > short.samples.length,
|
|
246
|
+
`a longer target yields longer speech (${short.samples.length} -> ${long.samples.length} samples)`
|
|
247
|
+
)
|
|
248
|
+
},
|
|
249
|
+
GPU_ONLY_USE_GPU
|
|
250
|
+
)
|
|
247
251
|
}
|
|
248
252
|
)
|
|
249
253
|
|
|
250
254
|
test(
|
|
251
255
|
'MOSS TTS: dialogue synthesis clones one reference per speaker',
|
|
252
|
-
{ timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels },
|
|
256
|
+
{ timeout: TEST_TIMEOUT_MS, skip: skipWithoutModels || NO_GPU },
|
|
253
257
|
async (t) => {
|
|
254
258
|
if (!hasDialogueBackbone() || !hasEncoder()) {
|
|
255
259
|
t.comment(`skipping: the TTSD backbone or the codec encoder is not in ${modelDir}`)
|
|
@@ -273,7 +277,8 @@ test(
|
|
|
273
277
|
const result = await synthesize(model, DIALOGUE_TEXT)
|
|
274
278
|
assertAudio(t, 'MOSS dialogue', result)
|
|
275
279
|
recordMoss(t, 'moss dialogue', result, Date.now() - started)
|
|
276
|
-
}
|
|
280
|
+
},
|
|
281
|
+
GPU_ONLY_USE_GPU
|
|
277
282
|
)
|
|
278
283
|
} finally {
|
|
279
284
|
removeFiles(references)
|
|
@@ -30,13 +30,15 @@ const {
|
|
|
30
30
|
const { loadSupertonicTTS, runSupertonicTTS } = require('../utils/runSupertonicTTS')
|
|
31
31
|
const { ensureChatterboxModels, ensureSupertonicModel } = require('../utils/downloadModel')
|
|
32
32
|
const { TTS_TEST_THREADS } = require('../utils/testThreads')
|
|
33
|
+
const { NO_GPU, GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
|
|
33
34
|
|
|
34
35
|
const platform = os.platform()
|
|
35
36
|
const isMobile = platform === 'ios' || platform === 'android'
|
|
36
37
|
|
|
37
|
-
// Lifecycle / sequential-run
|
|
38
|
-
//
|
|
39
|
-
//
|
|
38
|
+
// Lifecycle / sequential-run tests, not GPU policy tests. The Chatterbox ones
|
|
39
|
+
// run several syntheses, so they run on the GPU only and are skipped on CPU-only
|
|
40
|
+
// runners (NO_GPU=true); the Supertonic reload keeps the package default
|
|
41
|
+
// (`useGPU: false`). Tests that *are* about GPU live in gpu-smoke.test.js.
|
|
40
42
|
|
|
41
43
|
function getBaseDir() {
|
|
42
44
|
return isMobile && global.testDir ? global.testDir : '.'
|
|
@@ -50,7 +52,7 @@ const PHRASES = [
|
|
|
50
52
|
|
|
51
53
|
test(
|
|
52
54
|
'Chatterbox: multiple sequential runs reuse the same engine instance',
|
|
53
|
-
{ timeout: 1800000 },
|
|
55
|
+
{ timeout: 1800000, skip: NO_GPU },
|
|
54
56
|
async (t) => {
|
|
55
57
|
const baseDir = getBaseDir()
|
|
56
58
|
const download = await ensureChatterboxModels({ targetDir: path.join(baseDir, 'models') })
|
|
@@ -77,7 +79,8 @@ test(
|
|
|
77
79
|
threads: TTS_TEST_THREADS,
|
|
78
80
|
modelDir: download.targetDir,
|
|
79
81
|
refWavPath,
|
|
80
|
-
language: 'en'
|
|
82
|
+
language: 'en',
|
|
83
|
+
useGPU: GPU_ONLY_USE_GPU
|
|
81
84
|
})
|
|
82
85
|
try {
|
|
83
86
|
const timings = []
|
|
@@ -111,7 +114,7 @@ test(
|
|
|
111
114
|
|
|
112
115
|
test(
|
|
113
116
|
'Chatterbox: fresh instance per run (app-restart simulation)',
|
|
114
|
-
{ timeout: 1800000 },
|
|
117
|
+
{ timeout: 1800000, skip: NO_GPU },
|
|
115
118
|
async (t) => {
|
|
116
119
|
const baseDir = getBaseDir()
|
|
117
120
|
const download = await ensureChatterboxModels({ targetDir: path.join(baseDir, 'models') })
|
|
@@ -142,7 +145,8 @@ test(
|
|
|
142
145
|
threads: TTS_TEST_THREADS,
|
|
143
146
|
modelDir: download.targetDir,
|
|
144
147
|
refWavPath,
|
|
145
|
-
language: 'en'
|
|
148
|
+
language: 'en',
|
|
149
|
+
useGPU: GPU_ONLY_USE_GPU
|
|
146
150
|
})
|
|
147
151
|
const loadMs = Date.now() - t0
|
|
148
152
|
try {
|
|
@@ -175,58 +179,63 @@ test(
|
|
|
175
179
|
}
|
|
176
180
|
)
|
|
177
181
|
|
|
178
|
-
test(
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
|
|
182
|
+
test(
|
|
183
|
+
'Chatterbox: reload() between runs preserves stability',
|
|
184
|
+
{ timeout: 1800000, skip: NO_GPU },
|
|
185
|
+
async (t) => {
|
|
186
|
+
const baseDir = getBaseDir()
|
|
187
|
+
const download = await ensureChatterboxModels({ targetDir: path.join(baseDir, 'models') })
|
|
188
|
+
if (!download.success) {
|
|
189
|
+
t.fail(
|
|
190
|
+
'Chatterbox GGUFs not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.'
|
|
191
|
+
)
|
|
192
|
+
return
|
|
193
|
+
}
|
|
187
194
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
195
|
+
// Mobile-aware resolution: on iOS / Android the asset is staged into
|
|
196
|
+
// `Library/Caches/jfk.wav` via `global.assetPaths`; on desktop falls
|
|
197
|
+
// back to the in-tree `test/reference-audio/jfk.wav`. The bundled
|
|
198
|
+
// path is not readable from native code on iOS, which would trip
|
|
199
|
+
// `ModelFileNotFound` the moment `model.load()` reaches the C++
|
|
200
|
+
// `ChatterboxModel::validateConfig` check.
|
|
201
|
+
const refWavPath = resolveRefWavPath({})
|
|
202
|
+
if (!fs.existsSync(refWavPath)) {
|
|
203
|
+
t.pass('Skipped: reference audio missing')
|
|
204
|
+
return
|
|
205
|
+
}
|
|
199
206
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
207
|
+
const model = await loadChatterboxTTS({
|
|
208
|
+
threads: TTS_TEST_THREADS,
|
|
209
|
+
modelDir: download.targetDir,
|
|
210
|
+
refWavPath,
|
|
211
|
+
language: 'en',
|
|
212
|
+
useGPU: GPU_ONLY_USE_GPU
|
|
213
|
+
})
|
|
214
|
+
try {
|
|
215
|
+
const r1 = await runChatterboxTTS(
|
|
216
|
+
model,
|
|
217
|
+
{ text: 'First run before reload.' },
|
|
218
|
+
{ minSamples: 5000 }
|
|
219
|
+
)
|
|
220
|
+
t.ok(r1.passed, 'first run before reload should pass')
|
|
213
221
|
|
|
214
|
-
|
|
215
|
-
|
|
222
|
+
await model.reload({ language: 'en' })
|
|
223
|
+
t.pass('reload() resolved')
|
|
216
224
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
225
|
+
const r2 = await runChatterboxTTS(
|
|
226
|
+
model,
|
|
227
|
+
{ text: 'Second run after reload.' },
|
|
228
|
+
{ minSamples: 5000 }
|
|
229
|
+
)
|
|
230
|
+
t.ok(r2.passed, 'second run after reload should pass')
|
|
231
|
+
t.ok(r2.data.sampleCount > 0, 'reloaded model produces audio')
|
|
232
|
+
} finally {
|
|
233
|
+
try {
|
|
234
|
+
await model.unload()
|
|
235
|
+
} catch (_e) {}
|
|
236
|
+
}
|
|
228
237
|
}
|
|
229
|
-
|
|
238
|
+
)
|
|
230
239
|
|
|
231
240
|
test('Supertonic: reload() between runs preserves stability', { timeout: 1800000 }, async (t) => {
|
|
232
241
|
const baseDir = getBaseDir()
|
|
@@ -272,7 +281,7 @@ test('Supertonic: reload() between runs preserves stability', { timeout: 1800000
|
|
|
272
281
|
|
|
273
282
|
test(
|
|
274
283
|
'Engine swap: chatterbox -> supertonic -> chatterbox in separate instances',
|
|
275
|
-
{ timeout: 1800000 },
|
|
284
|
+
{ timeout: 1800000, skip: NO_GPU },
|
|
276
285
|
async (t) => {
|
|
277
286
|
const baseDir = getBaseDir()
|
|
278
287
|
const cb = await ensureChatterboxModels({ targetDir: path.join(baseDir, 'models') })
|
|
@@ -300,7 +309,8 @@ test(
|
|
|
300
309
|
threads: TTS_TEST_THREADS,
|
|
301
310
|
modelDir: cb.targetDir,
|
|
302
311
|
refWavPath,
|
|
303
|
-
language: 'en'
|
|
312
|
+
language: 'en',
|
|
313
|
+
useGPU: GPU_ONLY_USE_GPU
|
|
304
314
|
})
|
|
305
315
|
try {
|
|
306
316
|
const r = await runChatterboxTTS(c1, { text: 'Hello from chatterbox.' }, { minSamples: 5000 })
|
|
@@ -316,7 +326,7 @@ test(
|
|
|
316
326
|
supertonicModelPath: st.path,
|
|
317
327
|
voice: 'F1',
|
|
318
328
|
language: 'en',
|
|
319
|
-
useGPU:
|
|
329
|
+
useGPU: GPU_ONLY_USE_GPU
|
|
320
330
|
})
|
|
321
331
|
try {
|
|
322
332
|
const r = await runSupertonicTTS(s1, { text: 'Hello from supertonic.' }, { minSamples: 5000 })
|
|
@@ -331,7 +341,8 @@ test(
|
|
|
331
341
|
threads: TTS_TEST_THREADS,
|
|
332
342
|
modelDir: cb.targetDir,
|
|
333
343
|
refWavPath,
|
|
334
|
-
language: 'en'
|
|
344
|
+
language: 'en',
|
|
345
|
+
useGPU: GPU_ONLY_USE_GPU
|
|
335
346
|
})
|
|
336
347
|
try {
|
|
337
348
|
const r = await runChatterboxTTS(
|
|
@@ -13,6 +13,7 @@ const TTSGgml = require('@qvac/tts-ggml')
|
|
|
13
13
|
|
|
14
14
|
const { ensureSupertonicModel, ensureParlerModel } = require('../utils/downloadModel')
|
|
15
15
|
const { TTS_TEST_THREADS } = require('../utils/testThreads')
|
|
16
|
+
const { SKIP_PARLER_GPU_ONLY, PARLER_GPU_ONLY_USE_GPU } = require('../utils/gpuOnly')
|
|
16
17
|
|
|
17
18
|
const platform = os.platform()
|
|
18
19
|
const isMobile = platform === 'ios' || platform === 'android'
|
|
@@ -123,7 +124,7 @@ test(
|
|
|
123
124
|
|
|
124
125
|
test(
|
|
125
126
|
'Parler: outputSampleRate=16000 resamples and reports 16 kHz',
|
|
126
|
-
{ timeout: 600000 },
|
|
127
|
+
{ timeout: 600000, skip: SKIP_PARLER_GPU_ONLY },
|
|
127
128
|
async (t) => {
|
|
128
129
|
const baseDir = getBaseDir()
|
|
129
130
|
const dl = await ensureParlerModel({ targetDir: path.join(baseDir, 'models') })
|
|
@@ -140,6 +141,7 @@ test(
|
|
|
140
141
|
files: { parlerModel: dl.path },
|
|
141
142
|
voice: 'Laura',
|
|
142
143
|
seed: 42,
|
|
144
|
+
config: { useGPU: PARLER_GPU_ONLY_USE_GPU },
|
|
143
145
|
opts: { stats: true }
|
|
144
146
|
})
|
|
145
147
|
await native.load()
|
|
@@ -160,7 +162,7 @@ test(
|
|
|
160
162
|
files: { parlerModel: dl.path },
|
|
161
163
|
voice: 'Laura',
|
|
162
164
|
seed: 42,
|
|
163
|
-
config: { outputSampleRate: 16000 },
|
|
165
|
+
config: { outputSampleRate: 16000, useGPU: PARLER_GPU_ONLY_USE_GPU },
|
|
164
166
|
opts: { stats: true }
|
|
165
167
|
})
|
|
166
168
|
await resampled.load()
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
// chatterbox WER leg in addon.test.js (whisper ASR + wordErrorRate).
|
|
8
8
|
//
|
|
9
9
|
// macOS-desktop only (isDarwin): the whisper WER infra is desktop-only
|
|
10
|
-
// across the tts-ggml suite. Runs on
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// across the tts-ggml suite. Runs on the Metal GPU runner only and is skipped
|
|
11
|
+
// on CPU-only runners (NO_GPU=true). large-v1 is RAM-gated; both tiers skip
|
|
12
|
+
// cleanly if their GGUF isn't staged.
|
|
13
13
|
|
|
14
14
|
const os = require('bare-os')
|
|
15
15
|
const path = require('bare-path')
|
|
@@ -27,10 +27,9 @@ const isApple = platform === 'darwin' || platform === 'ios'
|
|
|
27
27
|
const NO_GPU = proc.env && proc.env.NO_GPU === 'true'
|
|
28
28
|
const LARGE_MIN_RAM_BYTES = 16 * 1024 ** 3
|
|
29
29
|
|
|
30
|
-
// Calibrated 2026-07-24:
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// leg stays effectively strict (greedy would be 50-100%, so this still asserts it).
|
|
30
|
+
// Calibrated 2026-07-24: Metal diverges by FP reorder — seed 42 is 0% on
|
|
31
|
+
// Apple-Silicon but 22.2% on Intel-Mac Metal — so 0.35 tolerates the GPU
|
|
32
|
+
// variance while still asserting the words (greedy would be 50-100%).
|
|
34
33
|
const WER_THRESHOLD = 0.35
|
|
35
34
|
|
|
36
35
|
// Clear English sentences, all >= 9 words (a single ASR slip stays <= ~0.11,
|
|
@@ -51,7 +50,7 @@ function getBaseDir() {
|
|
|
51
50
|
for (const { variant } of PARLER_WER_TIERS) {
|
|
52
51
|
test(
|
|
53
52
|
`Parler WER (${variant}): English synthesis transcribes within ${(WER_THRESHOLD * 100).toFixed(0)}%`,
|
|
54
|
-
{ timeout: 1800000, skip: !isDarwin },
|
|
53
|
+
{ timeout: 1800000, skip: !isDarwin || NO_GPU },
|
|
55
54
|
async (t) => {
|
|
56
55
|
if (variant === 'large' && os.totalmem() < LARGE_MIN_RAM_BYTES) {
|
|
57
56
|
t.pass(
|