Stream live audio input from stdin#118
Open
patrickvonplaten wants to merge 4 commits into
Open
Conversation
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. Those flags also become the audio contract that prepare() requires, carried as an audio_input with no samples yet. 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. partial_text lines are now flushed as they are produced. Without that a piped consumer sees nothing until the stdio buffer fills, which defeats the point of a live source. Verified on an M3 Air (Metal, q8_0): file input, s16le stdin and f32le stdin all transcribe assets/resources/sample_16k.wav to byte-identical stdout apart from the audio_input=stdin banner, 34 partials each. --audio - without --mode streaming and an unsupported --input-format both fail with their intended messages. streaming_audio_input_test covers PCM decoding for both sample formats, chunk sizing and drain, the partial trailing frame, the empty stream, and format parsing. 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). Carved out of 0xShug0#112, which keeps the in-place terminal rendering of partials -- that change is orthogonal and improves file streaming on main today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| std::cout << "vad_chunks_out=" << path.string() << "\n"; | ||
| } | ||
|
|
||
| bool stream_audio_from_stdin(int argc, char ** argv) { |
Contributor
Author
There was a problem hiding this comment.
Curious what thoughts you have on this. If such changes to main.cpp are too intrusive it could also make sense to "only" allow true live realtime transcription from a script for now?
Owner
There was a problem hiding this comment.
It's a good extension. The current streaming in the cli is naive and is mostly for testing the correctness of streaming path implementations. In streaming tests I only used the server and the python streaming client.
Contributor
Author
There was a problem hiding this comment.
Ok great, the PR is ready for review then I think
patrickvonplaten
marked this pull request as draft
July 26, 2026 23:21
Two defects found reviewing the commit before it. The buffer overload of run_streaming_task decided what to do on audio_input.has_value(). A live source puts its rate and channels there with no samples -- prepare() requires that contract -- so routing such a request through the buffer overload built a reader over an empty buffer, fed the session nothing, and surfaced as "VoxTral realtime finalize() requires streamed audio", which names the symptom and not the cause. Nothing reaches it today, since the CLI branches to the stream overload and the server never builds such a request, but this commit is what introduces the convention that makes it possible. A sample-less audio_input is now treated as no buffer at all, so it lands on the null-stream check, whose message now names both ways to supply audio. Sessions that do not consume audio chunks are unaffected, because that check only runs for StreamingInputKind::AudioChunks. feed_audio_chunks used to validate the whole buffer against the channel count before feeding any of it. Moving to a pull reader turned that into a per-chunk check, so a buffer whose length is not a whole number of frames was delivered chunk by chunk until the final short one failed, leaving the session mutated by an input that was rejected. A buffer's length is known up front, unlike a live source's, so buffer_audio_stream now rejects it there -- before start_stream, earlier than the original. The per-chunk check stays as the backstop for live sources, which cannot be validated ahead of time. Verified: both paths now fail with 0 chunks delivered, against 0 and 2 before; file and stdin transcripts are unchanged and still byte-identical to each other, 34 partials either way; the file path's output is identical to the previous commit's. Two regression tests cover the contract and the malformed buffer. ctest 29/32, matching main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moving the stdin plumbing into make_stdin_pcm_stream left two declarations in pcm_source.h with no caller outside the translation unit that defines them. set_stdin_binary_mode is now reached only through make_stdin_pcm_stream, and pcm_sample_format_bytes only through make_pcm_chunk_stream, so both move into the anonymous namespace. The header drops to the four names the CLI and the tests actually call. The two assertions that covered pcm_sample_format_bytes go with it. They checked that s16le is 2 bytes and f32le is 4, which test_sample_format_decoding already establishes end to end by decoding four s16 samples and two f32 samples from eight bytes each, so no coverage is lost. Verified: file input, s16le stdin and f32le stdin still produce byte-identical stdout apart from the audio_input=stdin banner, 34 partials each, and the file path's output is unchanged from the previous commit. ctest 29/32, matching main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every case built its 16 kHz format by hand and spelled out the four-line make_pcm_chunk_stream call, seven times between them, which buried the one line each test was actually about. Two helpers, audio_format(channels) and pcm_stream(input, channels, format), carry the shared 16 kHz default. In the PCM reader, the sample format was re-tested inside the decode loop although it cannot change between iterations, and each branch repeated the offset expression. Resolving the decoder once before the loop leaves a single call in the body and drops the per-sample branch. No behaviour change: file input, s16le stdin and f32le stdin still produce byte-identical stdout apart from the audio_input=stdin banner, 34 partials each, and the file path's output is unchanged. ctest 29/32, matching main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
patrickvonplaten
marked this pull request as ready for review
July 27, 2026 04:13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
--audio -so the streaming CLI can transcribe audio as it is produced, instead of only replaying something already in memory. Carved out of #112.Why
run_streaming_taskrequired a fully materializedrequest.audio_inputand sliced it into chunks. Onmain,process_audio_chunkhas exactly one call site — insidefeed_audio_chunks— and it cannot be reached without the complete audio in hand, so no C++ path accepted audio incrementally.That also applies to the two existing streaming paths, which is worth stating since both look like they already cover this:
tools/streaming/measure_asr_streaming_client.pypaces a multipart upload across several socket writes, but computesContent-Lengthfrom a complete file and, as its own manifest records,"current server reads full HTTP body before invoking ASR streaming handler". It measures response latency; a microphone cannot produce aContent-Length.webui/realtime_pipeline.pydoes transcribe a live mic, but via Python silero-VAD segmentation with each completed utterance sent as an ordinary/v1/audio/transcriptionsrequest. Nothing surfaces until the speaker pauses (min_silence_ms=450), and_run_turnruns STT → LLM → TTS with no early exit — it is a speech-to-speech assistant, not a transcriber.Approach
AudioChunkStream— a format declared up front plus a pull-based reader:Both input routes go through it, and the file path is expressed as a reader over the buffer (
buffer_audio_stream), so live and buffered input run the same code and cannot drift apart — that is what makes the byte-identical result below a property of the design rather than a coincidence. The three-argumentrun_streaming_taskstays as a wrapper, soapp/server/runtime.cppis untouched.--input-format(s16le/f32le),--input-rateand--input-channelssupply the format a headerless stream cannot carry. They double as the audio contractprepare()requires —VoxtralRealtimeSession::preparethrows without one — carried as anaudio_inputwith rate and channels but no samples. Decoding is explicitly little-endian and reuses the WAV reader's/32768scaling, so raw PCM and WAV of the same audio decode identically.partial_text=lines are flushed as produced, otherwise a piped consumer sees nothing until the stdio buffer fills.app/cli/main.cppchanges by +50/−6, with no preprocessor work; the in-place terminal rendering that made the original diff larger stays in #112, as it is orthogonal and improves file streaming onmaintoday.Verification
s16lestdin andf32lestdin all transcribeassets/resources/sample_16k.wavto byte-identical stdout apart from theaudio_input=stdinbanner — 34 partials each, growing one token at a time.--audio -without--mode streamingfails with--audio - reads live PCM and requires --mode streaming;--input-format s24lefails withunsupported raw PCM sample format 's24le' (expected s16le or f32le).streaming_audio_input_test(new) covers stdin/buffer equivalence, both sample formats, chunk sizing and drain, a trailing partial frame, the empty stream, format parsing, and the two cases from the second commit.ctest29/32, matchingmain.gguf_tensor_source_testandscaled_dot_product_attention_testfail onmaintoo;asr_standalone_gguf_testdoes not compile there (test_asr_standalone_gguf.cppcallsload_hviske_assets, the header declaresload_hviske_asr_assets), as do the threetests/moss_tts_local/codec_*_parity.cpptargets.audio_dsp_testandsupertonic_vector_convnext_exp_testare flaky rather than broken —test_audio_dsp.cpp:38seeds its RNG from the wall clock. None of these are touched by this branch. CI is green on all four platforms.