Skip to content

Commit c01c828

Browse files
alexkromanclaude
andauthored
Add voice mode to assembly code command (#234)
Adds a new voice-first interface to the `assembly code` command that lets users speak their requests and hear replies read back aloud (in sandbox environments with streaming TTS). ## Summary The `assembly code` command now defaults to voice mode in interactive terminals. Users can speak their coding requests via microphone (streaming STT) and receive replies read back aloud via streaming TTS (sandbox only). The implementation gracefully degrades: if no microphone is available, it falls back to typed input; if streaming TTS isn't available (production), replies display as text. ## Key Changes - **New `aai_cli/code_agent/voice.py` module**: Implements `VoiceSession` with injectable dependencies for microphone input, streaming STT, streaming TTS, and audio playback. Protocols define the streaming interfaces so the loop is fully unit-testable with fakes (no actual mic/speaker/socket needed). - **Voice mode in `_exec.py`**: - Added `--voice/--no-voice` flag to `CodeOptions` (defaults to `True`) - New `_run_voice()` function wires the voice session, event sink, and read-line handler - `_voice_sink()` renders all events and reads assistant text aloud via TTS - `_voice_read_line()` captures spoken turns, with graceful fallback to typed input if the microphone fails (latched after first failure so the mic isn't retried) - `_announce_voice()` prints a one-time notice explaining whether readback is available - Updated `run_code()` dispatch logic: voice mode runs first (if enabled and interactive), then TUI, then REPL - **Comprehensive test coverage** (`tests/test_code_voice.py` + updates to `tests/test_code_command.py`): - Voice session tests with fake mic, stream function, synth function, and player - Tests for listen/speak behavior, turn finalization, gating, and readback availability - Integration tests for voice mode dispatch, fallback to typed input on mic errors, and ask-handler wiring ## Implementation Details - **Streaming STT** uses the `u3-rt-pro` model (same as `assembly stream` and `assembly agent-cascade`) with `format_turns=True` for punctuated, cased output - **Streaming TTS** synthesizes at 24 kHz (the player's native rate) - **Microphone gating**: The mic stream is shut down the instant a turn finalizes, ensuring exactly one utterance per `listen()` call - **Error handling**: Audio device errors (`mic_missing`, `mic_error`, `audio_input_error`) trigger a one-time fallback to `input()`; other errors re-raise - **Readback availability** is determined by `tts_session.is_available()` at session build time - All voice I/O is dependency-injected so tests drive the loop with lightweight fakes https://claude.ai/code/session_013tckfky3TVuNtHgKENWpoS Co-authored-by: Claude <noreply@anthropic.com>
1 parent 91e3057 commit c01c828

8 files changed

Lines changed: 505 additions & 10 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ That's it. Run `assembly onboard` for a guided tour, or see [Installation](#-ins
5151
| `assembly agent-cascade` | Same live conversation, but wired client-side from Streaming STT + the LLM Gateway + streaming TTS, like the `agent-cascade` starter (sandbox-only) |
5252
| `assembly speak` | Synthesize text to speech over the streaming-TTS WebSocket (sandbox-only) |
5353
| `assembly llm` | Prompt the LLM Gateway over a transcript, files, stdin, or a live stream |
54-
| `assembly code` | Terminal coding agent (deepagents SDK) backed only by the LLM Gateway — reads/writes/edits files, runs shell, searches the docs MCP, and can invoke the `assembly` CLI itself; mutating actions ask for approval |
54+
| `assembly code` | Terminal coding agent (deepagents SDK) backed only by the LLM Gateway — reads/writes/edits files, runs shell, searches the docs MCP, and can invoke the `assembly` CLI itself; mutating actions ask for approval. Defaults to voice in a terminal (speak your request, replies read back via streaming TTS in the sandbox); pass `--no-voice` for the keyboard TUI |
5555
| `assembly clip` | Cut audio/video with ffmpeg by diarized speaker, text match, LLM pick, or time range (`--video` keeps the picture for URL sources) — clip boundaries snap into nearby silence |
5656
| `assembly dub` | Re-voice an audio/video file or URL in another language: transcription, LLM translation, per-speaker TTS, ffmpeg track-swap (sandbox-only) |
5757
| `assembly caption` | Burn always-visible captions into a video: transcribe (or reuse a transcript), fetch SRT, ffmpeg burns it in — audio untouched |

aai_cli/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ heavily-reworked commands with long bodies; small commands keep the inline
153153
- **`agent/`** — full-duplex voice agent (mic in, TTS out via `voices.py`).
154154
- **`agent_cascade/`** + `commands/agent_cascade/``assembly agent-cascade`: the same live terminal conversation as `assembly agent`, but **client-orchestrated**`engine.run_cascade` wires Streaming STT → the LLM Gateway → streaming TTS itself instead of talking to the Voice Agent endpoint, mirroring what the `agent-cascade` `assembly init` template does server-side. **Sandbox-only** (streaming TTS has no prod host; guarded via `tts.session.require_available`). Reuses the agent slice's `DuplexAudio`/`AgentRenderer` and `core.client.stream_audio`/`core.llm.complete`/`tts.session.synthesize`; the three network legs are injected through `engine.CascadeDeps` (the `tts/session.py` seam) so the cascade — greeting, per-sentence TTS, barge-in, history window — is unit-tested against fakes with no sockets/mic/speaker.
155155
- **`tts/`** + `commands/speak.py` — `assembly speak` synthesizes text to speech over the sandbox streaming-TTS WebSocket (`streaming-tts.sandbox000.…`). **Sandbox-only:** `session.is_available()` is false in production (empty `Environment.streaming_tts_host`), so the command exits 2 with a `--sandbox` hint. `session.synthesize` drives a Begin→Generate→Flush→Audio→Terminate protocol with an injectable `connect` for hermetic tests (mirrors `agent/session.py`); `audio.py` plays the PCM (default) or writes a WAV (`--out`). The single-voice default-playback path **streams**: `synthesize`'s `on_audio(chunk, sample_rate)` callback is wired to `audio.PcmPlayer.feed`, so speech starts on the first Audio frame (it opens the device lazily, since the rate is only known at Begin) instead of after the whole text — the win for a long `--url` page. `--out` (needs the full buffer) and the multi-voice dialogue path (`synthesize_dialogue` → `_output_audio` → buffered `play_pcm`) stay buffered; `synthesize` still returns the complete PCM for the summary regardless.
156-
- **`code_agent/`** + `commands/code/` — `assembly code`: a terminal coding agent (a bespoke port of langchain-ai/deepagents' `code` agent) that talks **only** to the LLM Gateway. `model.py` pins the model to `ChatOpenAI` against `llm_gateway_base`; `agent.py` builds the deepagents graph over a cwd-scoped `LocalShellBackend` (filesystem + shell tools), plus extra tools: the custom `assembly` CLI tool (`cli_tool.py`, runs `python -m aai_cli` with the key via child env, never argv), a URL `fetch_url` tool (`fetch_tool.py`), Tavily web search when `TAVILY_API_KEY` is set (`web_search.py`), an `ask_user` tool routed through an `AskBridge` to the front-end (`ask_tool.py`), and best-effort docs MCP tools (`docs_mcp.py`). Middleware adds installed skills (`skills.py`) and long-term memory (`memory.py`), each over its own dedicated backend. Sessions persist via a SQLite checkpointer (`store.py`) keyed by `--session`, so conversations resume. Approval gates the mutating tools (write/edit/execute/`assembly`/`fetch_url`); the general-purpose `task` subagent comes from deepagents by default. `session.py` drives the graph turn-by-turn (interrupt/resume = human approval), emitting framework-agnostic `events.py` to either the Textual TUI (`tui.py`, modeled on deepagents-code: transcript + input + approval/ask modals + clipboard copy) or the Rich fallback (`render.py`). The whole orchestration is tested by driving the **real** graph with a fake `BaseChatModel` (`tests/test_code_agent.py`), so no network/TTY is needed.
156+
- **`code_agent/`** + `commands/code/` — `assembly code`: a terminal coding agent (a bespoke port of langchain-ai/deepagents' `code` agent) that talks **only** to the LLM Gateway. `model.py` pins the model to `ChatOpenAI` against `llm_gateway_base`; `agent.py` builds the deepagents graph over a cwd-scoped `LocalShellBackend` (filesystem + shell tools), plus extra tools: the custom `assembly` CLI tool (`cli_tool.py`, runs `python -m aai_cli` with the key via child env, never argv), a URL `fetch_url` tool (`fetch_tool.py`), Tavily web search when `TAVILY_API_KEY` is set (`web_search.py`), an `ask_user` tool routed through an `AskBridge` to the front-end (`ask_tool.py`), and best-effort docs MCP tools (`docs_mcp.py`). Middleware adds installed skills (`skills.py`) and long-term memory (`memory.py`), each over its own dedicated backend. Sessions persist via a SQLite checkpointer (`store.py`) keyed by `--session`, so conversations resume. Approval gates the mutating tools (write/edit/execute/`assembly`/`fetch_url`); the general-purpose `task` subagent comes from deepagents by default. `session.py` drives the graph turn-by-turn (interrupt/resume = human approval), emitting framework-agnostic `events.py` to either the Textual TUI (`tui.py`, modeled on deepagents-code: transcript + input + approval/ask modals + clipboard copy) or the Rich fallback (`render.py`). The whole orchestration is tested by driving the **real** graph with a fake `BaseChatModel` (`tests/test_code_agent.py`), so no network/TTY is needed. **Voice is the default front-end in an interactive TTY** (`voice.py` + `_exec._run_voice`): `VoiceSession.listen` captures one spoken turn over Streaming STT (gating the mic shut the instant a turn finalizes) and `VoiceSession.speak` reads each assistant reply back over streaming TTS. It runs the **Rich REPL** loop (not the keyboard TUI) with a voice `read_line` + a reply-speaking sink. Readback needs streaming TTS, so it's **sandbox-only** (`tts.session.is_available`); in production the mic input still works and replies stay on screen. A mic-less box degrades to typed input on the first `AUDIO_ERROR_TYPES` `CLIError`; `--no-voice` selects the TUI, and a non-TTY (pipe/CI) the headless loop. Both legs (STT/TTS) are injected like the cascade's, so `tests/test_code_voice.py` drives it with fakes — no mic/speaker/socket.
157157
- **`code_gen/`** — backs `--show-code` on `transcribe`/`stream`/`agent`: builds a ready-to-run Python SDK script from exactly the flags passed (no API key needed; generated code reads `ASSEMBLYAI_API_KEY`).
158158
- **`auth/`** — browser-assisted `assembly login` via AMS + **Stytch B2B OAuth discovery** (`discovery.py`, `flow.py`, `loopback.py`, `ams.py`). Not Stytch Connected Apps.
159159
- **`init/`** — scaffolds a self-contained FastAPI + HTML starter (`audio-transcription`/`live-captions`/`voice-agent` templates), optionally installs deps and opens the browser; writes the key to a git-ignored `.env`.

aai_cli/code_agent/voice.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Voice I/O for `assembly code`: speak your request, hear the reply.
2+
3+
The coding agent's default interactive mode (a TTY) captures one spoken turn via
4+
streaming STT and reads each assistant reply back via streaming TTS. Both legs are
5+
injected so the loop is unit-tested with fakes — no microphone, speaker, or socket.
6+
7+
Readback needs streaming TTS, which only the sandbox environment exposes
8+
(`tts.session.is_available`); in production, voice *input* still works and replies
9+
stay on screen as text. Microphone (STT) input works in every environment.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import threading
15+
from collections.abc import Callable, Iterable, Iterator
16+
from dataclasses import dataclass
17+
from typing import TYPE_CHECKING, Protocol
18+
19+
from aai_cli.core import client, config_builder
20+
from aai_cli.core.microphone import MicrophoneSource
21+
from aai_cli.tts import session as tts_session
22+
from aai_cli.tts.audio import PcmPlayer
23+
from aai_cli.tts.session import SpeakConfig
24+
25+
if TYPE_CHECKING:
26+
from assemblyai.streaming.v3 import StreamingParameters
27+
28+
# The audio-device CLIError types listen() raises when no usable microphone is present;
29+
# the command degrades to typed input on these (see _exec._voice_read_line). They mirror
30+
# the error_type values core.microphone attaches to its mic-open failures.
31+
AUDIO_ERROR_TYPES = frozenset({"mic_missing", "mic_error", "audio_input_error"})
32+
33+
# Streaming TTS synthesizes at 24 kHz, the rate the readback player is opened at.
34+
_TTS_SAMPLE_RATE = 24000
35+
36+
# The streaming STT model used to transcribe a spoken turn — the same realtime default
37+
# `assembly stream` and `assembly agent-cascade` use.
38+
_SPEECH_MODEL = "u3-rt-pro"
39+
40+
41+
class Microphone(Protocol):
42+
"""The microphone slice the listen loop drives: an iterable of PCM at a known rate."""
43+
44+
sample_rate: int
45+
46+
def __iter__(self) -> Iterator[bytes]:
47+
"""Yield captured PCM16 chunks until the stream ends."""
48+
49+
50+
class StreamFn(Protocol):
51+
"""The streaming-STT call: ``client.stream_audio`` satisfies it structurally."""
52+
53+
def __call__(
54+
self,
55+
api_key: str,
56+
source: Iterable[bytes],
57+
*,
58+
params: StreamingParameters,
59+
on_turn: Callable[[object], None],
60+
) -> None:
61+
"""Stream ``source`` and forward each Turn event to ``on_turn``."""
62+
63+
64+
class SynthFn(Protocol):
65+
"""The streaming-TTS call: ``tts.session.synthesize`` satisfies it structurally.
66+
67+
The return is typed ``object`` because the readback path discards it (it plays each
68+
chunk through ``on_audio`` as it arrives), which also lets a test inject a fake that
69+
returns nothing meaningful.
70+
"""
71+
72+
def __call__(
73+
self,
74+
api_key: str,
75+
config: SpeakConfig,
76+
*,
77+
on_audio: Callable[[bytes, int], None],
78+
) -> object:
79+
"""Synthesize ``config.text``, handing each PCM chunk to ``on_audio``."""
80+
81+
82+
class Player(Protocol):
83+
"""The readback player: a context manager that ``feed``s PCM chunks (PcmPlayer)."""
84+
85+
def __enter__(self) -> Player:
86+
"""Enter the playback context (opens the device lazily on first feed)."""
87+
88+
def __exit__(self, exc_type: object, *exc: object) -> object:
89+
"""Drain on a clean exit, abort otherwise; never suppress."""
90+
91+
def feed(self, pcm: bytes, sample_rate: int) -> None:
92+
"""Play one PCM chunk, opening the output device on the first call."""
93+
94+
95+
def _stt_params(sample_rate: int) -> StreamingParameters:
96+
"""StreamingParameters for capturing one spoken turn at ``sample_rate``.
97+
98+
``format_turns`` is on so the finalized turn reads like a typed prompt (punctuated
99+
and cased) rather than raw lowercase tokens.
100+
"""
101+
merged = config_builder.merge_streaming_params(
102+
flags={"speech_model": _SPEECH_MODEL, "format_turns": True, "sample_rate": sample_rate}
103+
)
104+
return config_builder.construct_streaming_params(merged)
105+
106+
107+
@dataclass
108+
class VoiceSession:
109+
"""Speak-to-it / read-it-back I/O for one coding session, with injectable legs."""
110+
111+
api_key: str
112+
readback: bool
113+
mic_factory: Callable[[], Microphone] = MicrophoneSource
114+
stream_fn: StreamFn = client.stream_audio
115+
synth_fn: SynthFn = tts_session.synthesize
116+
player_factory: Callable[[], Player] = PcmPlayer
117+
118+
def listen(self) -> str | None:
119+
"""Capture one spoken turn and return its finalized transcript.
120+
121+
Returns the text of the first end-of-turn the server finalizes, or ``None`` when
122+
the microphone stream ends without one (EOF — e.g. a finite source in tests). The
123+
microphone is gated shut the moment a turn finalizes, so exactly one utterance is
124+
captured per call; a real mic blocks until you speak (Ctrl-C to quit).
125+
"""
126+
mic = self.mic_factory()
127+
done = threading.Event()
128+
captured: list[str] = []
129+
130+
def on_turn(event: object) -> None:
131+
text = (getattr(event, "transcript", "") or "").strip()
132+
if text and getattr(event, "end_of_turn", False):
133+
captured.append(text)
134+
done.set()
135+
136+
def gated() -> Iterator[bytes]:
137+
for chunk in mic:
138+
if done.is_set():
139+
return
140+
yield chunk
141+
142+
self.stream_fn(self.api_key, gated(), params=_stt_params(mic.sample_rate), on_turn=on_turn)
143+
return " ".join(captured).strip() or None
144+
145+
def speak(self, text: str) -> None:
146+
"""Read ``text`` back via streaming TTS, when readback is available.
147+
148+
A no-op when readback is off (production, where streaming TTS has no host) or the
149+
text is blank — so the caller can route every assistant reply here unconditionally.
150+
"""
151+
text = text.strip()
152+
if not self.readback or not text:
153+
return
154+
config = SpeakConfig(text=text, sample_rate=_TTS_SAMPLE_RATE)
155+
with self.player_factory() as player:
156+
self.synth_fn(self.api_key, config, on_audio=player.feed)
157+
158+
159+
def build_voice_session(api_key: str) -> VoiceSession:
160+
"""A voice session for the active environment.
161+
162+
Readback is enabled only where streaming TTS is available (the sandbox); microphone
163+
input is wired regardless.
164+
"""
165+
return VoiceSession(api_key=api_key, readback=tts_session.is_available())

aai_cli/commands/code/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ def code(
7171
tui: bool = typer.Option(
7272
True, "--tui/--no-tui", help="Use the full-screen TUI (off: a plain read-eval loop)"
7373
),
74+
voice: bool = typer.Option(
75+
True,
76+
"--voice/--no-voice",
77+
help="Speak to the agent and hear replies read back (readback needs the sandbox)",
78+
),
7479
) -> None:
7580
"""Run a terminal coding agent backed by the AssemblyAI LLM Gateway
7681
@@ -79,6 +84,10 @@ def code(
7984
invoke the 'assembly' CLI itself — all in the working directory. It talks
8085
only to the AssemblyAI LLM Gateway. Mutating actions ask for approval unless
8186
you pass --auto.
87+
88+
In an interactive terminal it defaults to voice: speak your request (mic ->
89+
streaming STT) and the agent's replies are read back aloud (sandbox only).
90+
Pass --no-voice for the keyboard TUI, or pipe input for the headless loop.
8291
"""
8392
opts = code_exec.CodeOptions(
8493
prompt=prompt,
@@ -92,5 +101,6 @@ def code(
92101
session=session,
93102
persist=persist,
94103
tui=tui,
104+
voice=voice,
95105
)
96106
run_with_options(ctx, code_exec.run_code, opts, json=False)

0 commit comments

Comments
 (0)