Skip to content

Commit ce46f51

Browse files
committed
Make assembly code default to voice control with spoken readback
In an interactive terminal, `assembly code` now defaults to a voice loop: speak your request (microphone -> Streaming STT) and hear the agent's replies read back over streaming TTS. This is the new front-end picked ahead of the keyboard TUI and the headless REPL. - New code_agent/voice.py: a VoiceSession with injectable STT/TTS legs. listen() captures one spoken turn (gating the mic shut the instant a turn finalizes) and returns the finalized transcript; speak() reads each assistant reply back, a no-op where readback isn't available. - _exec.run_code now selects voice (default, TTY) -> TUI (--no-voice) -> headless REPL (piped/CI). The voice path runs the Rich REPL with a voice read_line + a reply-speaking sink, and degrades to typed input on the first microphone-open failure. - Readback needs streaming TTS, so it engages only in the sandbox (tts.session.is_available); microphone input works everywhere, and a non-TTY (pipe/CI) keeps the existing headless behavior. - Added the --voice/--no-voice flag (default on) plus help/docs, and regenerated the `run`-group help snapshot. Both network legs are injected, so tests/test_code_voice.py and the new _exec voice-helper tests drive it with fakes — no mic, speaker, or socket. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013tckfky3TVuNtHgKENWpoS
1 parent 5d08c6d commit ce46f51

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)