Skip to content

Commit 5d9191a

Browse files
alexkromanclaude
andauthored
Add batch streaming mode: assembly stream --from-stdin (#181)
Implements batch streaming for the `assembly stream` command, allowing users to pipe a list of audio file paths or URLs (one per line) to stdin and stream each as its own realtime session in turn. ## Summary This adds the `--from-stdin` flag to `assembly stream`, which reads a newline-delimited list of audio sources from stdin and streams each sequentially with its own transcript and LLM chain state. This complements the existing `-` (raw PCM) stdin mode and enables workflows like `ls *.wav | assembly stream --from-stdin`. ## Key Changes **Core batch streaming logic:** - Added `stream_batch_sources()` function in `aai_cli/streaming/session.py` that orchestrates sequential streaming of multiple sources with per-source error handling - Batch continues on `CLIError` (file not found, decode failure) but aborts on `NotAuthenticated` (rejected API key applies to all sources) - Ctrl-C and broken pipe stop the batch cleanly (exit 0); other failures raise at the end so scripts can trust the exit code **Command integration:** - Added `--from-stdin` flag to `assembly stream` command - Implemented `_run_batch()` and `_collect_batch_sources()` in `aai_cli/commands/stream/_exec.py` to validate flag combinations and collect sources - Rejects incompatible flags: positional source, `--sample`, `--system-audio`, `--device`, `--sample-rate`, `--show-code` - Deduplicates sources while preserving order (mirrors `transcribe --from-stdin` behavior) **Streaming session updates:** - Extended `StreamSession.run()` and `_guarded()` with `handle_interrupt` parameter so batch driver owns Ctrl-C/pipe signals across the whole sequence (one Ctrl-C stops the batch, not just the current source) - Added `StreamRenderer.source()` method to announce each source in the batch with position (e.g., `[2/3] file.wav`) **Event and output handling:** - Added `Source` event type to `aai_cli/streaming/events.py` for JSON mode segmentation - JSON mode emits `{"type": "source", "source": "...", "index": 1, "total": 2}` before each source's events - Text mode writes source headers to stderr (stdout stays pure transcript lines for piping) - Human mode prints muted source headers above turns **Validation and error handling:** - Empty stdin raises `UsageError` with helpful suggestion - Per-source failures logged as warnings; batch summary at end if any failed - `NotAuthenticated` re-raises immediately to abort (one rejected key fails every source) ## Testing Added comprehensive test suite (`tests/test_stream_batch.py`) covering: - Sequential streaming in stdin order - Per-source resolution with `sample=False` (never coerced to hosted sample) - Failed source handling (batch continues, exit 1 at end) - Authentication failure (aborts immediately) - Ctrl-C and broken pipe lifecycle - Empty stdin validation Updated existing tests to include `from_stdin=False` in defaults and added validation tests for flag conflicts. ## Notable Implementation Details - Batch sources are deduplicated using `dict.fromkeys()` to preserve order while removing duplicates - Each source gets a fresh `StreamSession` from `make_session()` so transcripts and LLM state don't bleed between sources - The `open_source` callback resolves sources with `sample=False` to ensure real files/URLs are used, never the hosted sample - Renderer is shared across all sources so JSON output is a continuous NDJSON stream with `source` events marking boundaries https://claude.ai/code/session_01AA7v87iZNvfeGKhs2czBqq Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0bdec6c commit 5d9191a

11 files changed

Lines changed: 491 additions & 16 deletions

File tree

REFERENCE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ each carrying a `"type"` field to dispatch on:
7474

7575
| Command | Event types |
7676
| ------- | ----------- |
77-
| `assembly stream --json` | `begin`, `turn`, `termination` |
77+
| `assembly stream --json` | `begin`, `turn`, `termination` (with `--from-stdin`, a `source` event precedes each file's events) |
7878
| `assembly agent --json` | `session.ready`, `transcript.user.delta`, `transcript.user`, `reply.started`, `transcript.agent`, `reply.done` |
7979
| `assembly agent-cascade --json` | `session.ready`, `transcript.user.delta`, `transcript.user`, `reply.started`, `transcript.agent`, `reply.done` |
8080
| `assembly dictate --json` | `utterance` |

aai_cli/commands/stream/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
[
3131
("Stream from your microphone", "assembly stream"),
3232
("Stream a file or URL in real time", "assembly stream recording.wav"),
33+
("Stream a list of files in turn", "ls *.wav | assembly stream --from-stdin"),
3334
("Stream the hosted sample", "assembly stream --sample"),
3435
("Label speakers in the live transcript", "assembly stream --speaker-labels"),
3536
(
@@ -52,6 +53,11 @@ def stream(
5253
"PCM16/mono/16k on stdin. Omit to use the microphone.",
5354
),
5455
sample: bool = typer.Option(False, "--sample", help="Stream the hosted wildfires.mp3 sample"),
56+
from_stdin: bool = typer.Option(
57+
False,
58+
"--from-stdin",
59+
help="Read a list of audio files/URLs on stdin (one per line) and stream each in turn",
60+
),
5561
# audio capture
5662
sample_rate: int | None = typer.Option(
5763
None,
@@ -302,13 +308,17 @@ def stream(
302308
Pass - as the source to read raw PCM16/mono/16k audio on stdin, e.g.
303309
ffmpeg -i input.mp4 -f s16le -ar 16000 -ac 1 - | assembly stream -.
304310
311+
--from-stdin instead reads a list of file paths/URLs on stdin (one per line)
312+
and streams each as its own realtime session, in turn.
313+
305314
--prompt biases the speech model. --llm runs a prompt over the live transcript
306315
in-process, refreshing the answer on every finalized turn; for a separate step
307316
instead, pipe the text out with -o text | assembly llm -f "…".
308317
"""
309318
opts = stream_exec.StreamOptions(
310319
source=source,
311320
sample=sample,
321+
from_stdin=from_stdin,
312322
sample_rate=sample_rate,
313323
device=device,
314324
system_audio=system_audio,

aai_cli/commands/stream/_exec.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from __future__ import annotations
1111

1212
import tempfile
13+
from collections.abc import Iterable
1314
from dataclasses import dataclass
1415
from pathlib import Path
1516

@@ -18,8 +19,8 @@
1819

1920
from aai_cli import code_gen
2021
from aai_cli.app.context import AppState
21-
from aai_cli.core import choices, client, config_builder, youtube
22-
from aai_cli.core.errors import UsageError
22+
from aai_cli.core import choices, client, config_builder, stdio, youtube
23+
from aai_cli.core.errors import UsageError, mutually_exclusive
2324
from aai_cli.core.microphone import MicrophoneSource
2425
from aai_cli.streaming import turn_presets
2526
from aai_cli.streaming.macos import MacSystemAudioSource
@@ -28,6 +29,7 @@
2829
SourceOptions,
2930
StreamSession,
3031
resolve_output_modes,
32+
stream_batch_sources,
3133
validate_sources,
3234
)
3335
from aai_cli.streaming.sources import TARGET_RATE, FileSource, StdinSource
@@ -46,6 +48,7 @@ class StreamOptions:
4648

4749
source: str | None
4850
sample: bool
51+
from_stdin: bool
4952
sample_rate: int | None
5053
device: int | None
5154
system_audio: bool
@@ -214,9 +217,90 @@ def _dispatch(session: StreamSession, opts: SourceOptions) -> None:
214217
session.run(mic, mic.sample_rate)
215218

216219

220+
def _collect_batch_sources(opts: StreamOptions, *, text_mode: bool) -> list[str]:
221+
"""The newline-delimited source list for ``--from-stdin``, with the flag combos it
222+
can't honor rejected first.
223+
224+
``--from-stdin`` reinterprets stdin as a list of file paths/URLs (one per line),
225+
each streamed as its own realtime session — distinct from ``-`` (raw PCM bytes). It
226+
therefore can't also take a positional source, ``--sample``, the mic/system-audio
227+
inputs, the mic-only capture flags, or ``--show-code`` (which renders one source).
228+
"""
229+
mutually_exclusive(
230+
("--from-stdin", True),
231+
("a source argument", opts.source is not None),
232+
("--sample", opts.sample),
233+
suggestion="--from-stdin reads the source list from stdin; don't also pass one.",
234+
)
235+
mutually_exclusive(
236+
("--from-stdin", True),
237+
("--system-audio", opts.system_audio),
238+
("--system-audio-only", opts.system_audio_only),
239+
suggestion="--from-stdin streams files/URLs, not live capture.",
240+
)
241+
if opts.device is not None or opts.sample_rate is not None:
242+
raise UsageError("--device and --sample-rate apply only to microphone input.")
243+
mutually_exclusive(
244+
("--from-stdin", True),
245+
("--show-code", opts.show_code),
246+
suggestion="--show-code renders one source; pass a single file or URL.",
247+
)
248+
mutually_exclusive(
249+
("--llm", bool(opts.llm_prompt)),
250+
("-o text", text_mode),
251+
suggestion="--llm renders a live panel (or NDJSON when piped).",
252+
)
253+
sources = list(dict.fromkeys(stdio.iter_piped_stdin_lines())) # dedupe, keep order
254+
if not sources:
255+
raise UsageError(
256+
"No sources received on stdin.",
257+
suggestion="Pipe one path or URL per line, e.g. "
258+
"ls *.wav | assembly stream --from-stdin.",
259+
)
260+
return sources
261+
262+
263+
def _run_batch(opts: StreamOptions, state: AppState, *, json_mode: bool, text_mode: bool) -> None:
264+
"""Stream a ``--from-stdin`` list of sources, one realtime session each, in turn."""
265+
sources = _collect_batch_sources(opts, text_mode=text_mode)
266+
api_key = state.resolve_api_key()
267+
base_flags = opts.base_flags()
268+
llm_prompts = list(opts.llm_prompt or [])
269+
renderer = StreamRenderer(json_mode=json_mode, text_mode=text_mode)
270+
271+
def make_session() -> StreamSession:
272+
return StreamSession(
273+
api_key=api_key,
274+
base_flags=base_flags,
275+
overrides=opts.config_kv,
276+
config_file=opts.config_file,
277+
renderer=renderer,
278+
follow=FollowRenderer(json_mode=json_mode) if llm_prompts else None,
279+
llm_prompts=llm_prompts,
280+
model=opts.model,
281+
max_tokens=opts.max_tokens,
282+
llm_interval=opts.llm_interval,
283+
)
284+
285+
def open_source(source: str) -> tuple[Iterable[bytes], int]:
286+
file_audio = FileSource(client.resolve_audio_source(source, sample=False))
287+
return file_audio, file_audio.sample_rate
288+
289+
stream_batch_sources(
290+
sources,
291+
make_session=make_session,
292+
open_source=open_source,
293+
renderer=renderer,
294+
json_mode=json_mode,
295+
)
296+
297+
217298
def run_stream(opts: StreamOptions, state: AppState, *, json_mode: bool) -> None:
218299
"""Execute one `assembly stream` invocation from already-parsed flags."""
219300
text_mode, json_mode = resolve_output_modes(opts.output_field, json_mode=json_mode)
301+
if opts.from_stdin:
302+
_run_batch(opts, state, json_mode=json_mode, text_mode=text_mode)
303+
return
220304
sources = opts.source_options()
221305
base_flags = opts.base_flags()
222306

aai_cli/streaming/events.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,17 @@ class Termination(_StreamEvent):
6666
source: str | None = None
6767

6868

69-
Event = Begin | Turn | Termination
69+
class Source(_StreamEvent):
70+
"""A ``--from-stdin`` batch advanced to its next audio source.
71+
72+
Emitted once before each source's own ``begin``/``turn``/``termination`` events,
73+
so a consumer can segment the NDJSON stream by source. ``index`` is 1-based.
74+
"""
75+
76+
type: Literal["source"] = "source"
77+
source: str
78+
index: int
79+
total: int
80+
81+
82+
Event = Begin | Turn | Termination | Source

aai_cli/streaming/render.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,21 @@ def listening(self) -> None:
104104
elif not self.json_mode:
105105
self._line(Text("Listening… (Ctrl-C to stop)", style="aai.muted"))
106106

107+
def source(self, source: str, *, index: int, total: int) -> None:
108+
"""Announce the next source in a ``--from-stdin`` batch stream.
109+
110+
JSON mode emits a ``source`` event so consumers can segment the stream; text
111+
mode writes a header to stderr (stdout stays pure transcript lines); human
112+
mode prints a muted header above the upcoming turns.
113+
"""
114+
with self._lock:
115+
if self.json_mode:
116+
self._emit(events.Source(source=source, index=index, total=total).wire())
117+
elif self.text_mode:
118+
self._status(f"[{index}/{total}] {source}")
119+
else:
120+
self._line(Text(f"[{index}/{total}] {source}", style="aai.muted"))
121+
107122
def turn(self, event: object, *, source: str | None = None) -> None:
108123
text = getattr(event, "transcript", "") or ""
109124
end = bool(getattr(event, "end_of_turn", False))

aai_cli/streaming/session.py

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@
1010
import typer
1111

1212
from aai_cli.core import choices, client, config_builder, llm
13-
from aai_cli.core.errors import APIError, CLIError, UsageError, mutually_exclusive
13+
from aai_cli.core.errors import (
14+
APIError,
15+
CLIError,
16+
NotAuthenticated,
17+
UsageError,
18+
mutually_exclusive,
19+
)
1420
from aai_cli.streaming.render import StreamRenderer, speaker_prefix
1521
from aai_cli.ui import output
1622
from aai_cli.ui.follow import FollowRenderer
@@ -265,10 +271,15 @@ def stream_one(
265271
),
266272
)
267273

268-
def _guarded(self, work: Callable[[], None]) -> None:
274+
def _guarded(self, work: Callable[[], None], *, handle_interrupt: bool = True) -> None:
269275
"""Run a streaming body with the shared lifecycle handling: enter the
270276
FollowRenderer's live panel if present, treat Ctrl-C as a clean stop, exit 0 on
271-
a closed downstream pipe, and always close the renderer."""
277+
a closed downstream pipe, and always close the renderer.
278+
279+
``handle_interrupt=False`` lets a Ctrl-C or a closed pipe propagate instead of
280+
being swallowed here — the batch driver owns those signals across the whole
281+
``--from-stdin`` sequence, so one Ctrl-C stops the batch rather than just
282+
advancing to the next source."""
272283
try:
273284
if self.follow is not None:
274285
with self.follow:
@@ -281,19 +292,33 @@ def _guarded(self, work: Callable[[], None]) -> None:
281292
else:
282293
work()
283294
except KeyboardInterrupt:
295+
if not handle_interrupt:
296+
raise
284297
# Ctrl-C is a normal "user stopped" signal -> exit 0.
285298
if self.follow is None:
286299
self.renderer.close()
287300
self.renderer.stopped()
288301
except BrokenPipeError:
302+
if not handle_interrupt:
303+
raise
289304
# Downstream consumer (e.g. `| head`) closed the pipe; stop quietly.
290305
raise typer.Exit(code=0) from None
291306
finally:
292307
if self.follow is None:
293308
self.renderer.close()
294309

295-
def run(self, audio: Iterable[bytes], rate: int, *, source_label: str | None = None) -> None:
296-
self._guarded(lambda: self.stream_one(audio, rate, source_label=source_label))
310+
def run(
311+
self,
312+
audio: Iterable[bytes],
313+
rate: int,
314+
*,
315+
source_label: str | None = None,
316+
handle_interrupt: bool = True,
317+
) -> None:
318+
self._guarded(
319+
lambda: self.stream_one(audio, rate, source_label=source_label),
320+
handle_interrupt=handle_interrupt,
321+
)
297322

298323
def run_parallel(self, streams: _ParallelStreams) -> None:
299324
self._guarded(lambda: self._drive(streams))
@@ -331,3 +356,59 @@ def worker(source_label: str, audio: Iterable[bytes], rate: int) -> None:
331356
raise errors.get()
332357
if not errors.empty():
333358
raise errors.get()
359+
360+
361+
# A batch source string resolved to its real-time audio chunks and declared rate.
362+
_OpenedSource = tuple[Iterable[bytes], int]
363+
364+
365+
def stream_batch_sources(
366+
sources: list[str],
367+
*,
368+
make_session: Callable[[], StreamSession],
369+
open_source: Callable[[str], _OpenedSource],
370+
renderer: StreamRenderer,
371+
json_mode: bool,
372+
) -> None:
373+
"""Stream each source in ``sources`` in turn — the ``assembly stream --from-stdin``
374+
batch mode.
375+
376+
The realtime API is one session at a time, so a list of files/URLs streams
377+
sequentially: each source gets a fresh ``StreamSession`` from ``make_session`` (its
378+
own transcript and ``--llm`` chain state) and is announced via ``renderer.source``
379+
before its turns. ``open_source`` resolves a source string to ``(audio, rate)`` and
380+
may raise ``CLIError`` (bad path, missing ffmpeg, decode failure), which is recorded
381+
as a per-source failure so the batch carries on — except ``NotAuthenticated``, which
382+
re-raises to abort the whole batch (one rejected key fails every source identically).
383+
384+
A Ctrl-C or a closed downstream pipe stops the batch cleanly (exit 0). When any
385+
source failed, raises a ``CLIError`` at the end so a script can trust the exit code.
386+
"""
387+
total = len(sources)
388+
failures: list[str] = []
389+
try:
390+
for index, source in enumerate(sources, start=1):
391+
renderer.source(source, index=index, total=total)
392+
try:
393+
audio, rate = open_source(source)
394+
make_session().run(audio, rate, handle_interrupt=False)
395+
except NotAuthenticated:
396+
raise
397+
except CLIError as exc:
398+
failures.append(source)
399+
output.emit_warning(f"{source}: {exc.message}", json_mode=json_mode)
400+
except KeyboardInterrupt:
401+
# One Ctrl-C stops the whole batch, not just the current source -> exit 0.
402+
renderer.stopped()
403+
return
404+
except BrokenPipeError:
405+
# Downstream consumer (e.g. `| head`) closed the pipe; stop quietly.
406+
raise typer.Exit(code=0) from None
407+
finally:
408+
renderer.close()
409+
if failures:
410+
raise CLIError(
411+
f"{len(failures)} of {total} sources failed.",
412+
error_type="batch_failed",
413+
suggestion="Check each failed path or URL, then re-run.",
414+
)

tests/__snapshots__/test_snapshots_help_run.ambr

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,9 @@
669669
Pass - as the source to read raw PCM16/mono/16k audio on stdin, e.g.
670670
ffmpeg -i input.mp4 -f s16le -ar 16000 -ac 1 - | assembly stream -.
671671

672+
--from-stdin instead reads a list of file paths/URLs on stdin (one per line)
673+
and streams each as its own realtime session, in turn.
674+
672675
--prompt biases the speech model. --llm runs a prompt over the live transcript
673676
in-process, refreshing the answer on every finalized turn; for a separate step
674677
instead, pipe the text out with -o text | assembly llm -f "…".
@@ -679,13 +682,15 @@
679682
│ to use the microphone. │
680683
╰──────────────────────────────────────────────────────────────────────────────╯
681684
╭─ Options ────────────────────────────────────────────────────────────────────╮
682-
│ --sample Stream the hosted wildfires.mp3 sample │
683-
│ --json -j Emit newline-delimited JSON events │
684-
│ --output -o [text|json] Output mode: text (finalized turns as │
685-
│ plain lines, pipe-friendly) or json │
686-
│ --show-code Print the equivalent Python SDK code and │
687-
│ exit (does not stream) │
688-
│ --help Show this message and exit. │
685+
│ --sample Stream the hosted wildfires.mp3 sample │
686+
│ --from-stdin Read a list of audio files/URLs on stdin │
687+
│ (one per line) and stream each in turn │
688+
│ --json -j Emit newline-delimited JSON events │
689+
│ --output -o [text|json] Output mode: text (finalized turns as │
690+
│ plain lines, pipe-friendly) or json │
691+
│ --show-code Print the equivalent Python SDK code and │
692+
│ exit (does not stream) │
693+
│ --help Show this message and exit. │
689694
╰──────────────────────────────────────────────────────────────────────────────╯
690695
╭─ Audio Capture ──────────────────────────────────────────────────────────────╮
691696
│ --sample-rate INTEGER RANGE [x>=1] Audio rate in Hz │
@@ -797,6 +802,8 @@
797802
$ assembly stream
798803
Stream a file or URL in real time
799804
$ assembly stream recording.wav
805+
Stream a list of files in turn
806+
$ ls *.wav | assembly stream --from-stdin
800807
Stream the hosted sample
801808
$ assembly stream --sample
802809
Label speakers in the live transcript

0 commit comments

Comments
 (0)