From 7c0120f57bd3318fa81c6db953b7abc39047253b Mon Sep 17 00:00:00 2001 From: Alok-Ranjan23 Date: Tue, 25 Aug 2026 07:00:21 +0000 Subject: [PATCH 1/2] QVAC-24107 test[tts-ggml]: fold supertonic abort test into basic-synth test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone "aborting the run via signal rejects the response" test loaded Supertonic with default construction (F1/en/CPU) and issued one already-aborted run() to prove the AbortSignal plumbing rejects synchronously. The basic-synth test uses the same construction and completes its own synth first — so the abort call can piggyback on that loaded model without a second load. The abort must be the LAST engine call on the shared instance (the header comment on the original test warned native cancel-teardown wedges macOS process exit; the already-aborted signal path short-circuits before addon.runJob, so this is safe as long as no further synths follow it). Coverage retained: exact same abort assertion runs against the same construction. Only the extra model.load() is gone. Follow-up to PR #4013's cosyvoice3 pilot pattern. --- .../test/integration/supertonic.test.js | 60 ++++--------------- 1 file changed, 12 insertions(+), 48 deletions(-) diff --git a/packages/tts-ggml/test/integration/supertonic.test.js b/packages/tts-ggml/test/integration/supertonic.test.js index 816f7b70fa..13fc5789d9 100644 --- a/packages/tts-ggml/test/integration/supertonic.test.js +++ b/packages/tts-ggml/test/integration/supertonic.test.js @@ -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() From 419015ae8678bd57dabee8a84a306808219baab6 Mon Sep 17 00:00:00 2001 From: Alok-Ranjan23 Date: Tue, 25 Aug 2026 07:02:07 +0000 Subject: [PATCH 2/2] QVAC-24107 test[tts-ggml]: fold chatterbox streaming test into English-WER test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone "streaming input + streaming PCM output (runStreaming + onUpdate)" test loaded Chatterbox with loadChatterboxTTS({modelDir, language:'en'}) and called runChatterboxStreaming on 3 phrases. The English-WER test loads the identical construction and iterates English sentences via runChatterboxTTS — same instance can serve both. The streaming block is inserted BEFORE the WER test's model.unload() (line after the sentence-loop, before whisper is loaded). Its 3-phrase run adds ~15-20s per leg; the target test's timeout is 30 min so ample headroom. Coverage retained: all original streaming assertions (passed, sampleCount > 0, reportedSampleRate === 24000, streamChunkCount === phrases.length, sentenceChunks match) run against the shared loaded model. Follow-up to PR #4013's cosyvoice3 pilot pattern. 5 → 4 tests, one model.load() removed per CPU-heavy leg. --- .../tts-ggml/test/integration/addon.test.js | 118 +++++++----------- 1 file changed, 45 insertions(+), 73 deletions(-) diff --git a/packages/tts-ggml/test/integration/addon.test.js b/packages/tts-ggml/test/integration/addon.test.js index 76930781db..d832e85991 100644 --- a/packages/tts-ggml/test/integration/addon.test.js +++ b/packages/tts-ggml/test/integration/addon.test.js @@ -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') @@ -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') - } -)