feat(breeze_tts): sub-chunk streaming via resumable AR stepper - #473
feat(breeze_tts): sub-chunk streaming via resumable AR stepper#473Th-Underscore wants to merge 2 commits into
Conversation
Refactor Impl::generate into begin/step/end_stream (offline path delegates, bit-identical output). Session emits multiple SSE deltas per text chunk via prefix decode with lookahead margin. Opt-in via stream_subchunk/stream_frames_per_event/stream_lookahead_margin; default path unchanged. 40/40 ctest green.
Growing prefix decodes in sub-chunk streaming change chunk_frames every event. Building the replacement first peaks at old+new VRAM and fragments the allocator until cudaMalloc fails mid-stream even at ~90% usage. Reset first; strictly reduces peak with no behavior change.
2beda85 to
1000c2a
Compare
|
@Th-Underscore For the streaming design, I'd suggest following the PocketTTS pattern more closely: keep the codec decoder stateful and decode only the new streaming step/window, instead of repeatedly decoding the accumulated prefix and slicing the tail. Also, please provide an A/B performance regression test for the offline path if it is changed. |
|
@0xShug0 As mentioned, a full stateful decoder upgrade ("incremental per-frame decoder state") would be a much larger PR (~1500 lines on top of the current changes). I can work on it in the background, but I'd suggest having the subchunk streaming foundation merged first. Of course, it's ultimately your choice, so say the word and I'll probably get it done before the end of this week. Anyway, the offline path is almost completely untouched (only the StreamState memory management optimization and
-> byte-identical output, perf within noise, +53 MiB (StreamState). |
Let me check. I’m happy to take this on, and you can follow up with improvements afterward. |
Summary
BreezeTTS streaming currently emits at most one audio event per text chunk, so first audio only becomes available once the chunk's full autoregressive run plus decode has finished. This adds a sub-chunk streaming path: a resumable autoregressive stepper advances the backbone by a configurable number of frames per event (reusing the existing streaming primitives) and emits the decoded prefix audio every event, withholding a configurable lookahead margin.
This directly addresses the streaming time-to-first-audio concern raised in issue #467, where chunk-granular streaming buffers an entire text chunk before emitting any audio.
breeze_tts.stream_subchunk(bool, defaultfalse— existing chunk-granular streaming is unchanged),breeze_tts.stream_frames_per_event(default 32),breeze_tts.stream_lookahead_margin(default 12; must stay below frames-per-event or the emit watermark stalls into the 256-iteration guard).generate()delegates to the same stepper as a single event; output is bit-identical to the pre-stepper path (verified).Known limitation: per-event prefix re-decode is O(N²)
Each event decodes the entire accumulated code prefix (the Mimi decoder has no incremental state), so per-event cost is
c_AR·E + c_decode·prefixand total decode work grows quadratically in frames within a chunk. Measured on a 988-codepoint single-run corpus (below), per-event cost climbs monotonically through the run. Two facts bound the practical impact:text_chunk_sizealready bounds both cost and drift.StreamingConv1d.previous/StreamingConvTranspose1d.partialring buffers) across ~30 decoder layers, with a mandatory byte-identical parity gate. That is a separate, large change and is out of scope here; it is the planned follow-up.Spec note
For a released GGUF the server prefers the GGUF-embedded contract spec, so the new options are unknown to already-published artifacts and are rejected (
unknown BreezeTTS request option: stream_lookahead_margin) until the server is launched with--model-spec-overridepointing at a spec directory containing the updatedmodel_specs/breeze_tts.json. This is a stale-artifact issue, not a code bug.Validation
Tested on 2×V100-16GB, server on GPU 2, released
breeze-tts-2-q8_0GGUF +--model-spec-override, 24/24 unit-gate green on the current base (upstream moved the extended tests out of the unit gate after this PR's base PR #423 merged; 24/24 is the full gate).The 16/8 arm costs +25.3% wall on the long corpus (vs ~+5% on short clips) — the O(N²) penalty growing as a fraction of wall with length, exactly as the model above predicts. 16/8 is a clean win for short clips (lower TTFT, ~2× event cadence); for long-form within one chunk 32/12 is the cheaper trade until the incremental-decode follow-up lands.
Test artifacts (gists)
python3 stream_client.py --port 5025 --server http://127.0.0.1:5023 --voice-dir <dir-with-clone-wavs>fpe_measure.py <frames> <margin>(short-clip A/B) andfpe_long.py <frames> <margin>(long-corpus A/B; forces one AR run viatext_chunk_size=2048)/tmp/long_test.txtbefore runningfpe_long.pyVoice reference for streaming tests
Smoke tests used a VCTK speaker p233 clone: 15.75s at 48 kHz, a deterministic same-text concat of utterances 016+025+030 with 0.3s silence gaps, reproducibly rebuildable from the source dataset (https://huggingface.co/datasets/badayvedat/VCTK — the source copy is declared apache-2.0; note VCTK is Cambridge corpus data). Transcripts sidecar: https://gist.github.com/Th-Underscore/bbfd2a7d65953e44025a739cc9cdfd26. The WAV itself cannot be published (gists do not accept binary files); a maintainer can rebuild it from the utterance list above if desired.
Limitations
Complete and validated as described; the incremental per-frame decoder state (Mimi
previous/partial) is the planned follow-up.