Skip to content

Stream live audio input from stdin#118

Open
patrickvonplaten wants to merge 4 commits into
0xShug0:mainfrom
patrickvonplaten:pvp/streaming-stdin-input
Open

Stream live audio input from stdin#118
patrickvonplaten wants to merge 4 commits into
0xShug0:mainfrom
patrickvonplaten:pvp/streaming-stdin-input

Conversation

@patrickvonplaten

@patrickvonplaten patrickvonplaten commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

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.

 ffmpeg -f avfoundation -i ":0" -ar 16000 -ac 1 -f s16le - 2>/dev/null \
  | ./build/macos-metal-release/bin/audiocpp_cli \
      --task asr --family voxtral_realtime \
      --model /Users/patrickprivate/models/Voxtral-Mini-4B-Realtime-2602-GGUF/voxtral-mini-4b-realtime-2602-q8_0.gguf \
      --backend metal --mode streaming --audio -

Why

run_streaming_task required a fully materialized request.audio_input and sliced it into chunks. On main, process_audio_chunk has exactly one call site — inside feed_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.py paces a multipart upload across several socket writes, but computes Content-Length from 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 a Content-Length.
  • webui/realtime_pipeline.py does transcribe a live mic, but via Python silero-VAD segmentation with each completed utterance sent as an ordinary /v1/audio/transcriptions request. Nothing surfaces until the speaker pauses (min_silence_ms=450), and _run_turn runs 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:

using AudioChunkReader = std::function<bool(int64_t max_samples, std::vector<float> & samples)>;

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-argument run_streaming_task stays as a wrapper, so app/server/runtime.cpp is untouched.

--input-format (s16le/f32le), --input-rate and --input-channels supply the format a headerless stream cannot carry. They double as the audio contract prepare() requires — VoxtralRealtimeSession::prepare throws without one — carried as an audio_input with rate and channels but no samples. Decoding is explicitly little-endian and reuses the WAV reader's /32768 scaling, 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.cpp changes 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 on main today.

Verification

  • 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, growing one token at a time.
  • --audio - without --mode streaming fails with --audio - reads live PCM and requires --mode streaming; --input-format s24le fails with unsupported 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.
  • ctest 29/32, matching main. gguf_tensor_source_test and scaled_dot_product_attention_test fail on main too; asr_standalone_gguf_test does not compile there (test_asr_standalone_gguf.cpp calls load_hviske_assets, the header declares load_hviske_asr_assets), as do the three tests/moss_tts_local/codec_*_parity.cpp targets. audio_dsp_test and supertonic_vector_convnext_exp_test are flaky rather than broken — test_audio_dsp.cpp:38 seeds its RNG from the wall clock. None of these are touched by this branch. CI is green on all four platforms.

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>
Comment thread app/cli/main.cpp
std::cout << "vad_chunks_out=" << path.string() << "\n";
}

bool stream_audio_from_stdin(int argc, char ** argv) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok great, the PR is ready for review then I think

@patrickvonplaten patrickvonplaten changed the title Stream live audio input from stdin [WIP] Stream live audio input from stdin Jul 26, 2026
@patrickvonplaten
patrickvonplaten marked this pull request as draft July 26, 2026 23:21
patrickvonplaten and others added 2 commits July 26, 2026 18:24
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>
@patrickvonplaten patrickvonplaten changed the title [WIP] Stream live audio input from stdin Stream live audio input from stdin Jul 27, 2026
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
patrickvonplaten marked this pull request as ready for review July 27, 2026 04:13
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.

2 participants