From 2ace3270324750f0d70cba77855f40057d8afd92 Mon Sep 17 00:00:00 2001 From: 2sumtech <2sumtech@gmail.com> Date: Mon, 21 Sep 2026 10:45:44 -0700 Subject: [PATCH] fix(speech): fail when the audio stream ends without a terminator `mmx speech synthesize --stream` ended its SSE loop on the first `done` read from the response body, whether or not the server had sent a `data.status: 2` chunk or `data: [DONE]`. A connection dropped mid-synthesis therefore piped the partial audio to stdout and exited 0, so `mmx speech synthesize --stream > out.mp3` produced a silently truncated file that the caller could not distinguish from a complete one. `decodeAudioStream` already rejects a stream that completes with no audio at all (`API stream ended without audio data.`); a stream that delivers one chunk and then drops was the gap. Track the terminator and raise `Stream disconnected before audio completed.` with the network exit code (6) when the stream ends without one, mirroring the text-chat fix, and document the scenario in ERRORS.md. Co-Authored-By: Claude Fable 5.1 --- ERRORS.md | 1 + src/utils/audio-stream.ts | 20 ++++++++++++++++++-- test/utils/audio-stream.test.ts | 14 ++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/ERRORS.md b/ERRORS.md index 88f8a4af..7e6d3b9e 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -118,6 +118,7 @@ This document lists all error scenarios and the messages users will see. | `--text-file` unreadable | `Cannot read file: ${e.message}` | | `--out` path no write permission | `Permission denied: cannot write to "${outPath}".` | | Disk full | `Disk full — cannot write audio file.` | +| `--stream` connection drops before the final audio chunk | `Stream disconnected before audio completed.` | ### `mmx speech transcribe` diff --git a/src/utils/audio-stream.ts b/src/utils/audio-stream.ts index a6bc2d7d..032ff270 100644 --- a/src/utils/audio-stream.ts +++ b/src/utils/audio-stream.ts @@ -37,6 +37,13 @@ function missingAudioError(): CLIError { ); } +function truncatedStreamError(): CLIError { + return new CLIError( + 'Stream disconnected before audio completed.', + ExitCode.NETWORK, + ); +} + export async function* decodeAudioStream( response: Response, ): AsyncGenerator> { @@ -61,8 +68,13 @@ export async function* decodeAudioStream( } let receivedAudio = false; + let streamCompleted = false; for await (const event of parseSSE(response)) { - if (!event.data || event.data === '[DONE]') break; + if (!event.data) break; + if (event.data === '[DONE]') { + streamCompleted = true; + break; + } let parsed: AudioPayload; try { @@ -82,10 +94,14 @@ export async function* decodeAudioStream( yield decodeHexAudio(hex); } - if (parsed.data?.status === 2) break; + if (parsed.data?.status === 2) { + streamCompleted = true; + break; + } } if (!receivedAudio) throw missingAudioError(); + if (!streamCompleted) throw truncatedStreamError(); } export async function pipeAudioStream(response: Response): Promise { diff --git a/test/utils/audio-stream.test.ts b/test/utils/audio-stream.test.ts index 23bdefd4..03731c2d 100644 --- a/test/utils/audio-stream.test.ts +++ b/test/utils/audio-stream.test.ts @@ -53,4 +53,18 @@ describe('decodeAudioStream', () => { 'API stream ended without audio data', ); }); + + it('rejects a stream that closes after audio chunks but without a terminator', async () => { + // A dropped connection: chunks arrive, then the body ends with no + // `status: 2` chunk and no `data: [DONE]` terminator. + const response = new Response( + 'data: {"data":{"audio":"414243","status":1}}\n\n' + + 'data: {"data":{"audio":"444546","status":1}}\n\n', + { headers: { 'Content-Type': 'text/event-stream' } }, + ); + + await expect(collectAudio(response)).rejects.toThrow( + 'Stream disconnected before audio completed.', + ); + }); });