Skip to content

fix(speech): fail when the audio stream ends without a terminator - #266

Open
2sumtech wants to merge 1 commit into
MiniMax-AI:mainfrom
2sumtech:fix/audio-stream-truncated
Open

2sumtech wants to merge 1 commit into
MiniMax-AI:mainfrom
2sumtech:fix/audio-stream-truncated

Conversation

@2sumtech

@2sumtech 2sumtech commented Sep 21, 2026

Copy link
Copy Markdown

Summary

mmx speech synthesize --stream treats a dropped connection as a finished
synthesis. It pipes the partial audio to stdout and exits 0, so
mmx speech synthesize --text "…" --stream > out.mp3 silently produces a
truncated audio file that a caller cannot distinguish from a complete one.

This is the audio counterpart of #263 (fix(text): fail when the chat stream ends without a terminator): same missing-terminator shape, a different SSE
consumer.

It is also an internal inconsistency. src/utils/audio-stream.ts already
rejects a stream that completes carrying no audio:

Scenario Today
Stream completes (status: 2), zero audio chunks API stream ended without audio data. (exit 1)
Stream drops after one or more audio chunks success, exit 0, truncated file

The second row is the gap this PR closes.

Root cause

src/utils/audio-stream.tsdecodeAudioStream tracks whether audio arrived,
but never whether the stream finished:

let receivedAudio = false;
for await (const event of parseSSE(response)) {
  if (!event.data || event.data === '[DONE]') break;
  ...
  const hex = parsed.data?.audio;
  if (hex) { receivedAudio = true; yield decodeHexAudio(hex); }

  if (parsed.data?.status === 2) break;   // the finish marker — result discarded
}

if (!receivedAudio) throw missingAudioError();

parseSSE (src/client/stream.ts) returns normally when the body reader
reports done, which is exactly what a server or proxy closing the connection
early produces. The loop therefore exits identically for "server sent the final
status: 2 chunk" and "connection dropped after chunk 2 of 40". Because
receivedAudio is true in both cases, pipeAudioStream returns cleanly and
src/commands/speech/synthesize.ts returns from its --stream branch with no
error.

Merged PR #227 (fix: time out stalled streams, src/client/http.ts) covers
the stalled case (no bytes for N seconds); a stream that closes cleanly but
early never trips that idle timeout.

Fix

One flag in src/utils/audio-stream.ts, set by either terminator the API uses
(data: [DONE] or a chunk with data.status === 2), checked once after the
loop:

function truncatedStreamError(): CLIError {
  return new CLIError(
    'Stream disconnected before audio completed.',
    ExitCode.NETWORK,
  );
}
...
let receivedAudio = false;
let streamCompleted = false;
for await (const event of parseSSE(response)) {
  if (!event.data) break;
  if (event.data === '[DONE]') { streamCompleted = true; break; }
  ...
  if (parsed.data?.status === 2) { streamCompleted = true; break; }
}

if (!receivedAudio) throw missingAudioError();
if (!streamCompleted) throw truncatedStreamError();

Exit code 6 (ExitCode.NETWORK), per the ### Exit Codes table in ERRORS.md
("Network error") and matching #263 — a dropped stream is a transport failure,
not a usage or content error.

The existing missingAudioError() check stays first, so every currently
documented message is produced for exactly the same inputs as before; the new
error only fires on the previously-silent case. Accepting [DONE] as well as
status: 2 keeps every existing audio-stream test green — the repo's
sseResponse helper appends data: [DONE].

ERRORS.md gains the matching row under ## Speech Commands
mmx speech synthesize:

Scenario Error Message
--stream connection drops before the final audio chunk Stream disconnected before audio completed.

Scope: src/utils/audio-stream.ts is consumed only by
src/commands/speech/synthesize.ts (the Music CLI was retired in 1b53953), so
this is one command's behaviour and one concern.

How tested

New regression test in test/utils/audio-stream.test.ts, plus an end-to-end run
of the real CLI against a local mock server — no live API call, no API key.

Reproduction (before the fix) — real CLI, local mock server

Mock server returns two audio chunks and then closes, with no status: 2
chunk and no data: [DONE]:

Bun.serve({
  port: 0,
  fetch() {
    return new Response(
      'data: {"data":{"audio":"414243","status":1}}\n\n'
      + 'data: {"data":{"audio":"444546","status":1}}\n\n',
      { headers: { 'Content-Type': 'text/event-stream' } },
    );
  },
});

Before the fix:

$ MINIMAX_BASE_URL=http://localhost:61086 \
    bun run src/main.ts speech synthesize \
      --api-key mock-key --region global --text "Hello" --stream > out.mp3
$ echo "exit=$?"
exit=0
$ ls -l out.mp3
-rw-r--r--  1 …  6 …  out.mp3
$ cat out.mp3
ABCDEF

Exit 0, no diagnostic, 6 bytes of a file the user will treat as finished audio.

After the fix:

$ MINIMAX_BASE_URL=http://localhost:61086 \
    bun run src/main.ts speech synthesize \
      --api-key mock-key --region global --text "Hello" --stream > out.mp3
{
  "error": {
    "code": 6,
    "message": "Stream disconnected before audio completed."
  }
}
$ echo "exit=$?"
exit=6
Test evidence — fails before, passes after

Without the source change (test only):

$ bun test test/utils/audio-stream.test.ts
bun test v1.3.13 (bf2e2cec)

test/utils/audio-stream.test.ts:
66 |     await expect(collectAudio(response)).rejects.toThrow(
                                                      ^
error:

Expected promise that rejects
Received promise that resolved: Promise { <resolved> }

(fail) decodeAudioStream > rejects a stream that closes after audio chunks but without a terminator [3.31ms]

 5 pass
 1 fail
 6 expect() calls
Ran 6 tests across 1 file. [145.00ms]

With the fix:

$ bun test test/utils/audio-stream.test.ts
bun test v1.3.13 (bf2e2cec)

 6 pass
 0 fail
 6 expect() calls
Ran 6 tests across 1 file. [19.00ms]

Full suite / typecheck / lint:

$ bun test
 575 pass
 1 fail
 1266 expect() calls
Ran 576 tests across 67 files. [10.94s]

# The single failure is pre-existing and unrelated:
#   (fail) agent installer > times out and terminates the installer process tree
# It reproduces identically on an unmodified `main` checkout
# (574 pass / 1 fail, same test), and on its own:
#   $ bun test test/agent/installer.test.ts   -> 13 pass / 1 fail

$ bun run typecheck
$ tsc --noEmit
(no output, exit 0)

$ bun run lint
$ eslint src/ test/

/…/test/sdk/speech.test.ts
  117:39  warning  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any

✖ 1 problem (0 errors, 1 warning)      # pre-existing, unrelated file
(exit 0)

Environment: bun 1.3.13, macOS.

Duplicate check

Run via gh api search/issues (all states, including merged) and
gh api .../pulls/<n>/files:

Query Result
repo:MiniMax-AI/cli decodeAudioStream 0
repo:MiniMax-AI/cli "ended without audio" 0
repo:MiniMax-AI/cli pipeAudioStream 1 — #130 (merged, adds the hex/SSE decoding itself; no terminator check)
repo:MiniMax-AI/cli audio stream truncated 3 — #263 (the text-chat counterpart), #262, #79; none about audio termination
repo:MiniMax-AI/cli audio-stream 15 — #55/#60/#130 add SSE/hex decoding and EPIPE handling, #196/#197 are Music-era (Music CLI since retired in 1b53953), #127/#128 are format validation, #223 is async/WebSocket TTS; none checks for a missing terminator
repo:MiniMax-AI/cli in:title speech 15 — closest are #60 and #63 (merged; decoding and default file extension), neither about stream completion
repo:MiniMax-AI/cli in:title stream 10 — closest is #227 fix: time out stalled streams (merged, src/client/http.ts): an idle timeout, which a cleanly-closed early stream never trips
repo:MiniMax-AI/cli speech synthesize stream 9 — #54 (closed issue: raw SSE printed instead of audio, fixed by #55/#60); different failure

All 15 open PRs checked file-by-file (#264, #263, #261, #230, #228, #226, #225,
#223, #222, #221, #219, #209, #207, #204, #186). None touches
src/utils/audio-stream.ts, test/utils/audio-stream.test.ts or ERRORS.md.
#223 (async/WebSocket TTS) adds new speech commands and a separate
src/utils/tts-websocket.ts; it does not modify the SSE audio decoder.

git log on src/utils/audio-stream.ts since it was created: dd00b38,
facbb95, 952d6f1 — all Music-era decoding work, none adding a terminator
check.

Disclosure: prepared with AI assistance (Claude Code); I reviewed the change and take responsibility for it.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

`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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant