Skip to content

fix(server): stream one container per response instead of one per chunk - #899

Open
arvindvenkataramani wants to merge 1 commit into
Blaizzy:mainfrom
arvindvenkataramani:streaming-single-container
Open

fix(server): stream one container per response instead of one per chunk#899
arvindvenkataramani wants to merge 1 commit into
Blaizzy:mainfrom
arvindvenkataramani:streaming-single-container

Conversation

@arvindvenkataramani

Copy link
Copy Markdown

This PR was written by Claude (Anthropic's Claude Code). Human review was limited to listening to the output and confirming the seams are gone; the implementation itself has not been line-by-line reviewed by a person. Please read it accordingly.

Apologies for arriving as a PR rather than a discussion. It appears to work and seemed more useful to share than to sit on — treat it as a starting point rather than something to merge as-is.


Context

Closes #898.

Streaming /v1/audio/speech encodes each chunk as a complete standalone container file — its own header, its own encoder delay, its own end padding — and concatenates them on the wire. A response therefore carries one container header per chunk where a well-formed stream has exactly one, and the accumulated per-segment delay and padding are audible as silence at every seam.

Measured on mlx-community/pocket-tts at streaming_interval: 4: streamed mp3 ran roughly 700ms longer than a non-streamed render of the same text, across ten seams. raw/pcm was unaffected, having no container.

Description

_emit_audio calls audio_write once per streamed chunk, and _encode_ffmpeg spawns an ffmpeg process that exits after each call. A process that exits finalises its container, so each chunk necessarily becomes a complete file.

This adds StreamingEncoder, which holds one libav container and encoder open for the duration of a response and returns only the bytes each chunk produces. _emit_audio keeps one per in-flight request, keyed by request id.

Every path that ends a stream finalises the encoder — normal completion, cancellation, client disconnect, the continuous-batch session's done, error and fail branches — and run_serial discards on any exception, so an open container cannot leak.

Changes in the codebase

  • mlx_audio/streaming_encoder.py (new) — incremental encoder. Its codec and container tables mirror _encode_ffmpeg exactly, including FLAC-in-Ogg for ogg/vorbis, so streamed and non-streamed output agree on codec choice.
  • mlx_audio/server.py — per-request encoder held in TTSExecutionAdapter, guarded by a lock since worker threads reach it concurrently. Non-streaming and raw/pcm paths are untouched, and the import is guarded so a missing PyAV falls back to current behaviour rather than raising.
  • mlx_audio/tests/test_streaming_encoder.py (new) — 17 tests covering single-container output, duration parity against the input, chunk-size independence, and float scaling.
  • pyproject.toml — adds av to the server extra.
  • CONTRIBUTIONS.md — the incremental-encoding approach follows Kokoro-FastAPI's StreamingAudioWriter (Apache-2.0), including its ordering fix for muxers that write their trailing bytes during close.

Changes outside the codebase

None.

Additional information

PyAV is a new dependency, scoped to the server extra. A long-lived subprocess.Popen ffmpeg would fix the same bug without it, at the cost of a reader thread and process lifecycle handling on every request-termination path. The in-process encoder avoids that, but takes the dependency; the subprocess version is a reasonable alternative if the dependency is unwelcome.

Verified across mp3, webm, opus, ogg, vorbis, flac and wav: one container header per response, and decoded duration matching the input to within one mp3 frame.

Checklist

  • Tests added/updated
  • Documentation updated
  • Issue referenced (e.g., "Closes #...")

`_emit_audio` called `audio_write` per streamed chunk, and `_encode_ffmpeg`
spawns an ffmpeg that exits after each call. A process that exits finalizes
its container, so every chunk went out as a complete standalone file with its
own header, encoder delay and end padding, concatenated on the wire.

Two consequences: the response carries one container header per chunk where a
well-formed stream has exactly one, and the accumulated per-segment delay and
padding are audible as silence at every seam. Measured on pocket-tts at
`streaming_interval: 4`, streamed mp3 ran ~700ms longer than a non-streamed
render of the same text across 10 seams. Raw/pcm was unaffected, having no
container.

Adds `StreamingEncoder`, which holds one libav container and encoder open for
the duration of a response and returns only the bytes each chunk produced.
`_emit_audio` keeps one per in-flight request, keyed by request id; every path
that ends a stream finalizes it, and `run_serial` discards on any exception so
an open container cannot leak.

The codec and container tables mirror `_encode_ffmpeg` exactly, including
FLAC-in-Ogg for ogg/vorbis, so streamed and non-streamed output agree on codec
choice. Non-streaming and raw/pcm paths are untouched.

Verified across mp3, webm, opus, ogg, vorbis, flac and wav: one container
header per response, and decoded duration matching the input to within one
mp3 frame.

Closes Blaizzy#898
@lucasnewman

Copy link
Copy Markdown
Collaborator

@arvindvenkataramani Thanks, I took a quick look and see some issues:

  1. It's sending unsupported sample rates directory to libopus which will crash with e.g. 22050Hz
  2. The PyAV fallback doesn't actually work

I think the idea is sound but the implementation needs some work. Let me know if you'd rather me handle it or you want to do it yourself.

@arvindvenkataramani

Copy link
Copy Markdown
Author

Please feel free to handle it yourself, you know your codebase better. I honestly even felt bad submitting a Claude Code generated PR. I will note that this patch has been working okay for me so far. I can have Claude look into the PyAV fallback again.

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.

Streaming /v1/audio/speech emits one complete container file per chunk, inserting audible silence at every seam (all container formats)

3 participants