Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 45 additions & 73 deletions packages/tts-ggml/test/integration/addon.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,51 @@ test(
})
}

// Streaming input + streaming PCM output on the SAME loaded model
// (previously a standalone test that repeated the ensureChatterboxModels +
// loadChatterboxTTS construction). Both use loadChatterboxTTS({modelDir,
// language:'en'}), so the streaming assertions can piggyback here. Must run
// BEFORE model.unload() below — do not move.
const streamingPhrases = [
'First phrase arrives from the upstream text stream.',
'A short pause could sit between chunks.',
'Each yield is one discrete synthesis job.'
]
const streamingExpectation = {
minSamples: 15000,
maxSamples: 5000000,
minDurationMs: 400,
maxDurationMs: 300000
}
const streamingWavPath = !isMobile
? path.join(baseDir, 'test', 'output', 'chatterbox-streaming.wav')
: undefined
console.log(
`\n=== Running Chatterbox IO stream synthesis (runStreaming, ${streamingPhrases.length} phrases) ===`
)
const streamingResult = await runChatterboxStreaming(
model,
{ phrases: streamingPhrases, saveWav: !isMobile, wavOutputPath: streamingWavPath },
streamingExpectation
)
console.log(streamingResult.output)
t.ok(streamingResult.passed, 'Streaming synthesis should pass expectations')
t.ok(streamingResult.data.sampleCount > 0, 'Streaming should produce audio samples')
t.is(streamingResult.data.reportedSampleRate, 24000, 'Streaming sample rate is native 24 kHz')
t.is(
streamingResult.data.streamChunkCount,
streamingPhrases.length,
'runStreaming should emit one chunk per yielded phrase'
)
t.is(streamingResult.data.sentenceChunks.length, streamingPhrases.length)
for (let i = 0; i < streamingPhrases.length; i++) {
t.is(
streamingResult.data.sentenceChunks[i],
streamingPhrases[i],
`chunk ${i} sentenceChunk should match the streamed-in phrase`
)
}

await model.unload()
t.pass('Chatterbox model unloaded')

Expand Down Expand Up @@ -363,76 +408,3 @@ test(
t.pass('Model unloaded after native streaming')
}
)

test(
'Chatterbox TTS (ggml): streaming input + streaming PCM output (runStreaming + onUpdate)',
{ timeout: 1800000 },
async (t) => {
const baseDir = getBaseDir()
const modelsDir = path.join(baseDir, 'models')

console.log('\n=== Ensuring Chatterbox GGUFs (streaming) ===')
const download = await ensureChatterboxModels({ targetDir: modelsDir })
if (!download.success) {
t.fail(
'Chatterbox GGUFs not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.'
)
return
}
t.ok(download.success, 'Chatterbox GGUFs should be available')

const model = await loadChatterboxTTS({
modelDir: download.targetDir,
language: 'en'
})
t.ok(model, 'Chatterbox (ggml) model should be loaded')

const phrases = [
'First phrase arrives from the upstream text stream.',
'A short pause could sit between chunks.',
'Each yield is one discrete synthesis job.'
]

const expectation = {
minSamples: 15000,
maxSamples: 5000000,
minDurationMs: 400,
maxDurationMs: 300000
}

const saveWav = !isMobile
const wavOutputPath = saveWav
? path.join(baseDir, 'test', 'output', 'chatterbox-streaming.wav')
: undefined

console.log(
`\n=== Running Chatterbox IO stream synthesis (runStreaming, ${phrases.length} phrases) ===`
)
const result = await runChatterboxStreaming(
model,
{ phrases, saveWav, wavOutputPath },
expectation
)
console.log(result.output)

t.ok(result.passed, 'Streaming synthesis should pass expectations')
t.ok(result.data.sampleCount > 0, 'Streaming should produce audio samples')
t.is(result.data.reportedSampleRate, 24000, 'Streaming sample rate is native 24 kHz')
t.is(
result.data.streamChunkCount,
phrases.length,
'runStreaming should emit one chunk per yielded phrase'
)
t.is(result.data.sentenceChunks.length, phrases.length)
for (let i = 0; i < phrases.length; i++) {
t.is(
result.data.sentenceChunks[i],
phrases[i],
`chunk ${i} sentenceChunk should match the streamed-in phrase`
)
}

await model.unload()
t.pass('Chatterbox model unloaded')
}
)
60 changes: 12 additions & 48 deletions packages/tts-ggml/test/integration/supertonic.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,65 +94,29 @@ test(
)
t.ok(typeof result.data.stats.backendId === 'number', 'supertonic stats include backendId')
}
} finally {
try {
await model.unload()
} catch (_e) {}
}
}
)

// Coverage note: this exercises the AbortSignal plumbing, not the native
// mid-flight cancel path. An already-aborted signal makes `run()` short-circuit
// before `addon.runJob` (the model loads but never synthesizes), so
// `response.cancel()` interrupting an in-flight native Supertonic job is
// intentionally NOT covered here. That native teardown leak (cancelling
// mid-synthesis wedges macOS process exit) is the underlying bug and is tracked
// as a separate follow-up (see the PR description); restore native cancel
// coverage once it is fixed.
test(
'Supertonic TTS (ggml): aborting the run via signal rejects the response',
{ timeout: 600000 },
async (t) => {
const baseDir = getBaseDir()
const download = await ensureSupertonicModel({ targetDir: path.join(baseDir, 'models') })
if (!download.success) {
t.fail(
'Supertonic GGUF not available - registry fetch failed. Run `npm run download-models:registry` or stage models locally.'
)
return
}

const model = await loadSupertonicTTS({
supertonicModelPath: download.path,
voice: 'F1',
language: 'en',
useGPU: false
})
try {
// Cancel via an AbortSignal rather than a fixed-delay `response.cancel()`.
// The old timer raced synthesis: on fast runners (e.g. the M4 Max GPU
// runner) the short clip finished before the 50 ms cancel fired, so the
// response resolved and the assertion failed; on slower runners the cancel
// landed mid-flight and the native interrupt wedged macOS process teardown.
// An already-aborted signal makes QvacResponse reject synchronously
// AbortSignal plumbing (previously a standalone test with its own model
// load). An already-aborted signal makes QvacResponse reject synchronously
// (_markAbortPending) with no engine dispatch and no native interrupt, so
// this is deterministic regardless of hardware speed.
// this is deterministic regardless of hardware speed. Runs AFTER the
// successful synth above and MUST be the last engine call — the native
// mid-flight cancel-teardown path (that wedges macOS process exit) is
// intentionally NOT covered here; restore native cancel coverage once that
// bug is fixed.
const signal = makeAbortedSignal(new Error('cancelled by test'))
const response = await model.run({
const abortResponse = await model.run({
type: 'text',
input: 'Cancel this synthesis call before it completes.',
signal
})

let failed = false
let abortFailed = false
try {
await response.await()
await abortResponse.await()
} catch (e) {
failed = true
abortFailed = true
console.log(' cancel rejected with: ' + e.message)
}
t.ok(failed, 'aborted supertonic response should reject')
t.ok(abortFailed, 'aborted supertonic response should reject')
} finally {
try {
await model.unload()
Expand Down
Loading