[WIP] Improve streaming#112
Conversation
ff5b2f1 to
0b49f04
Compare
Status reportStill WIP and draft. Summary of what is in the branch, what was measured, and what was What this changes1. Streaming input is actually streaming. The CLI gains 2. EOS no longer terminates a stream. Worth being precise here: this is a latent bug. I originally diagnosed it as the cause of live 3. One event per chunk. The session invoked its own 4. Terminal output. Partials carry the full transcript so far, so appending each one printed a Throughput: measured, not addressedThis is the main open issue and the reason live microphone capture is not usable yet. Each steady step advances
Two independent bottlenecks:
Caveat on confidence: the ~84 ms encoder figure is derived by subtraction (136.6 total minus The consequence for live capture: the consumer runs at ~0.6x realtime while a capture device Likely paths to realtime, neither attempted here: a q4 GGUF roughly halves the decoder (~52 -> Tried and removedA Testing
End-to-end, on an M3 with Metal:
Not covered by tests: the EOS-boundary and duplicate-emission fixes both live inside |
Commands for benchmarking and verificationEverything below assumes these two, adjusted for your machine and backend BIN=./build/macos-metal-release/bin/audiocpp_cli
MODEL=path/to/voxtral-mini-4b-realtime-2602-q8_0.ggufBuildscripts/build_metal.sh --with-tests --target audiocpp_cliThe unit test needs cmake -S . -B build/macos-metal-release -DENGINE_BUILD_TESTS=ON
cmake --build build/macos-metal-release --target audiocpp_cli streaming_audio_input_testTests# Hermetic, no model required, ~2 s
./build/macos-metal-release/bin/streaming_audio_input_test
# Full suite. asr_standalone_gguf_test does not compile on main and is excluded.
cd build/macos-metal-release && ctest -E "asr_standalone_gguf_test"Expect 25/27. ctest -R "supertonic_vector_convnext_exp_test"The correctness bar: stdin must equal fileThis is the check that matters most for this PR. Streaming from a file and streaming the same audio $BIN --task asr --family voxtral_realtime --model $MODEL --backend metal \
--mode streaming --audio speech.wav 2>/dev/null \
| grep -E '^(partial_text|text_output)=' > /tmp/from_file.txt
python3 -c "
import sys, wave
w = wave.open('speech.wav')
sys.stdout.buffer.write(w.readframes(w.getnframes()))
" | $BIN --task asr --family voxtral_realtime --model $MODEL --backend metal \
--mode streaming --audio - 2>/dev/null \
| grep -E '^(partial_text|text_output)=' > /tmp/from_stdin.txt
diff /tmp/from_file.txt /tmp/from_stdin.txt && echo IDENTICAL
Throughput: is it realtime?The single number that matters. Each steady step advances $BIN --task asr --family voxtral_realtime --model $MODEL --backend metal \
--mode streaming --audio speech.wav --log 2>&1 \
| grep -E "stream\.(steps_processed|wall_ms)"
Where the time goesThe streaming path has no per-step breakdown, so run the same audio offline to attribute cost $BIN --task asr --family voxtral_realtime --model $MODEL --backend metal \
--audio speech.wav --log 2>&1 \
| grep -E "session\.(frontend_ms|audio_encoder_ms)|text_decoder\.(step_total_ms|generated_tokens)|session\.audio_tokens"The decoder figure is directly comparable to the streaming path. The encoder figure is batched, Also worth checking that graph rebuilds are not polluting a measurement — these should appear once ... --log 2>&1 | grep -cE "graph_build_ms|decode_cache_steps"Live behaviourFeed at 1x wall-clock and timestamp each partial. Partials should appear progressively while audio python3 -c "
import sys, time, wave
w = wave.open('speech.wav')
t0 = time.time()
while True:
f = w.readframes(1600) # 100 ms at 16 kHz
if not f: break
sys.stdout.buffer.write(f); sys.stdout.buffer.flush()
time.sleep(0.1)
sys.stderr.write(f'[feeder] finished writing at {time.time()-t0:.1f}s\n')
" | $BIN --task asr --family voxtral_realtime --model $MODEL --backend metal \
--mode streaming --audio - 2>/dev/null \
| python3 -c "
import sys, time
t0 = time.time()
for l in sys.stdin:
if l.startswith(('partial_text=', 'text_output=')):
print(f'[{time.time()-t0:6.2f}s] {l.rstrip()[:78]}')
"The Multi-utterance / EOS boundaryBuilds a file with a pause in the middle, which exercises the EOS-as-utterance-boundary path. python3 -c "
import wave
src = wave.open('speech.wav'); n, sr = src.getnframes(), src.getframerate()
pcm = src.readframes(n)
out = wave.open('/tmp/two_utterances.wav','wb')
out.setnchannels(1); out.setsampwidth(2); out.setframerate(sr)
out.writeframes(pcm + b'\x00\x00' * (sr * 3) + pcm)
out.close()
"
$BIN --task asr --family voxtral_realtime --model $MODEL --backend metal \
--mode streaming --audio /tmp/two_utterances.wav 2>/dev/null | tail -1Step count is a useful cross-check: a 25 s file should report ~312 steps (25 s / 80 ms). A much Real microphoneffmpeg -f avfoundation -i ":0" -t 30 -ar 16000 -ac 1 -f s16le -loglevel warning - 2>ffmpeg.log \
| $BIN --task asr --family voxtral_realtime --model $MODEL --backend metal --mode streaming --audio -Use Terminal renderingPartials rewrite one line in place on a TTY and print one line per update when redirected. python3 -c "
import os
pid, fd = os.forkpty()
if pid == 0:
os.execv('$BIN', ['audiocpp_cli','--task','asr','--family','voxtral_realtime',
'--model','$MODEL','--backend','metal','--mode','streaming','--audio','speech.wav'])
data = b''
while True:
try: d = os.read(fd, 65536)
except OSError: break
if not d: break
data += d
os.waitpid(pid, 0)
print('carriage returns:', data.count(b'\r'), '| partial_text= labels:', data.count(b'partial_text='))
"Expect many carriage returns and zero |
Implementation plan: get streaming to realtime on M3 (hand-off)This comment is a self-contained implementation plan for whoever picks this branch up next. What the investigation foundArchitecture (Kyutai-style delayed streams; one decoder step per 80 ms, always):
Cost model (M3 ≈ 90–100 GB/s shared bandwidth; measured 136.6 ms/step = 52.5 decoder
Phase 0 — correctness: remove the EOS special-casing (match the vLLM reference)The reference implementation gives EOS no special treatment whatsoever; the stream ends
Change in Consequence: with no utterance resets, decoder KV state is continuous for the whole session Phase 1 — instrument the streaming path (settle the ~84 ms attribution)Accumulate per-step frontend / encoder / decoder wall-time in Phase 2 — low-risk throughput wins (est. ~136 → ~75–90 ms/step)
Phase 3 — encoder batching option (amortize dispatch; target ≤60 ms/step)
Phase 4 — only if still short after measuring Phases 2–3
Verification (run per phase)
|
Streaming is now faster than realtime: 138.9 → 39.6 ms/stepAll of this landed in a single commit,
The practical outcome: a paced feeder writes 25 s of audio in 33.9 s (was 47.9 s), and session lag is now flat at the one-time model load (~9 s) instead of growing monotonically. What changed, in
|
|
Command to test: |
run_streaming_task required a fully materialized request.audio_input and sliced it into chunks, so "streaming" only ever replayed a file. Introduce an AudioChunkStream -- a format declared up front plus a pull-based reader -- and drive both routes through it. The file path is expressed as a reader over the buffer, so live and buffered input cannot diverge. The existing three-argument run_streaming_task is kept as a wrapper, leaving the server unchanged. The CLI grows --audio - to read interleaved PCM from stdin, with --input-format/--input-rate/--input-channels supplying the format that a live stream carries no header for. Decoding is explicitly little-endian and reuses the WAV reader's /32768 scaling, so raw PCM and WAV of the same audio decode identically. Any capture tool that can write PCM to a pipe works as a source, and the audio is never buffered up front. Terminal output. Partials carry the full transcript, so appending each one printed a pyramid of growing copies. On a TTY a single line is now rewritten in place; when stdout is redirected each update keeps its own line, flushed as it is produced, so pipes stay parseable. Verified on an M3 Air (Metal, q8_0): stdin and file input transcribe assets/resources/sample_16k.wav to byte-identical output apart from the audio_input=stdin banner, 34 partials either way, and partial delivery matches a CLI built from main exactly. streaming_audio_input_test covers PCM decoding for both sample formats, chunk sizing and drain, the partial trailing frame, the empty stream, and format validation. ctest 29/32, with gguf_tensor_source_test, scaled_dot_product_attention_test and asr_standalone_gguf_test also failing on main (the last does not compile there). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
01ce5c8 to
9e601d5
Compare
|
Rebased onto The performance work that made up the tail of this branch landed separately as #116, so this PR is now a single commit: the live stdin input. What the rebase dropped
What is left11 files, +665/−49, entirely app-layer and docs:
Verification (M3 Air, Metal, q8_0)
Still WIPThroughput against a live source is the open question. With #116's optimizations a step costs ~78 ms cool and ~88 ms sustained against an 80 ms budget, so an open-ended capture still backs up on this machine; 🤖 Generated with Claude Code |
|
Carved the stdin streaming input out into #118, which is reviewable on its own. This PR was doing two unrelated things. #118 takes the first — Keeping it separate also keeps this out of #118: #define isatty _isatty
#define fileno _filenoRedefining libc names as macros at file scope in One piece did move to #118 rather than stay here: flushing the I'll rebase this branch on #118 once that lands, leaving just the rendering change. |
Work in progress to improve streaming.