Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4f64b19
docs(speak): design for sandbox-only streaming TTS command
alexkroman-assembly Jun 10, 2026
47eabe5
docs(speak): implementation plan for the speak command
alexkroman-assembly Jun 10, 2026
4ac6e2a
feat(speak): add streaming_tts_host to Environment
alexkroman-assembly Jun 10, 2026
847ef10
feat(speak): tts session availability + url building
alexkroman-assembly Jun 10, 2026
ea9a73e
feat(speak): tts websocket synthesize protocol
alexkroman-assembly Jun 10, 2026
31c872a
feat(speak): tts wav writing + pcm playback
alexkroman-assembly Jun 10, 2026
133c97f
feat(speak): add sandbox-only speak command
alexkroman-assembly Jun 10, 2026
ba37f08
test(speak): refresh CLI help snapshots
alexkroman-assembly Jun 10, 2026
cb68bd0
docs(speak): document the tts subsystem and speak command
alexkroman-assembly Jun 10, 2026
58981ea
test(speak): cover default audio/ws factories + help order
alexkroman-assembly Jun 10, 2026
b419755
test(speak): kill surviving mutants on changed lines
alexkroman-assembly Jun 10, 2026
a03b0c5
fix(speak): default voice/language so the bare command works
alexkroman-assembly Jun 10, 2026
4c26cc6
test(speak): satisfy strict mypy/pyright on tts tests
alexkroman-assembly Jun 10, 2026
d1920b0
fix(speak): match the real streaming-TTS protocol
alexkroman-assembly Jun 10, 2026
85cc31e
fix(speak): cancel playback immediately on Ctrl-C
alexkroman-assembly Jun 10, 2026
4c37cbe
docs(speak): design for speaker-aware multi-voice playback
alexkroman-assembly Jun 10, 2026
b60526f
docs(speak): implementation plan for speaker-aware multi-voice playback
alexkroman-assembly Jun 10, 2026
81988a8
feat(speak): parse speaker-labeled transcript text into segments
alexkroman-assembly Jun 10, 2026
3dc1848
test(speak): cover label-only continuation; drop no-op strip
alexkroman-assembly Jun 10, 2026
222bac2
feat(speak): resolve speaker voices via rotation + overrides
alexkroman-assembly Jun 10, 2026
dcdf9f6
test(speak): make voice-rotation wrap test self-sufficient; parametri…
alexkroman-assembly Jun 10, 2026
2907b60
feat(speak): add silence() PCM helper for inter-turn gaps
alexkroman-assembly Jun 10, 2026
4a746ce
feat(speak): synthesize_dialogue concatenates per-voice segments
alexkroman-assembly Jun 10, 2026
ee023a2
test(speak): pin dialogue duration, server rate, and empty-segments edge
alexkroman-assembly Jun 10, 2026
c4730c3
feat(speak): auto-detect diarized input and voice each speaker
alexkroman-assembly Jun 10, 2026
31b448b
test(speak): cover empty-labels path; pin voice fallback, speaker cou…
alexkroman-assembly Jun 11, 2026
b34be10
test(speak): kill mutation survivors (Segment frozen, multi --out pat…
alexkroman-assembly Jun 11, 2026
cb1b475
docs(speak): clarify _read_text stdin-fallback is intentional (omitte…
alexkroman-assembly Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ A Typer CLI. `aai_cli/main.py` builds the `app`, registers each command sub-app,

### Command layer

Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, `transcripts`, `agent`, `llm`, `login` (login/logout/whoami), `doctor`, `init`, `dev`, `share`, `deploy`, `setup`, `onboard`, `account` (balance/usage/limits), `keys`, `sessions`, `audit`). Command bodies run through `context.run_command(ctx, fn, json=...)`, which maps any `CLIError` to clean stderr output + the error's exit code. Commands never print tracebacks for expected failures.
Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, `agent`, `speak`, `llm`, `transcripts`, `login` (login/logout/whoami), `doctor`, `init`, `dev`, `share`, `deploy`, `setup`, `onboard`, `account` (balance/usage/limits), `keys`, `sessions`, `audit`). Command bodies run through `context.run_command(ctx, fn, json=...)`, which maps any `CLIError` to clean stderr output + the error's exit code. Commands never print tracebacks for expected failures.

### Cross-cutting state (resolution order matters)

Expand All @@ -167,6 +167,7 @@ Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, `tr

- **`streaming/`** + `client.stream_audio` — v3 realtime API. Event callbacks run on the SDK reader thread and guard against `BrokenPipeError` (`stdio.silence_stdout()`) so a closed pipe never dumps a thread traceback.
- **`agent/`** — full-duplex voice agent (mic in, TTS out via `voices.py`).
- **`tts/`** + `commands/speak.py` — `aai 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`).
- **`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`).
- **`auth/`** — browser-assisted `aai login` via AMS + **Stytch B2B OAuth discovery** (`discovery.py`, `flow.py`, `loopback.py`, `ams.py`). Not Stytch Connected Apps.
- **`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`.
Expand Down
252 changes: 252 additions & 0 deletions aai_cli/commands/speak.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
from __future__ import annotations

import sys
from pathlib import Path

import typer

from aai_cli import config, help_panels, options, output
from aai_cli.context import AppState, run_command
from aai_cli.errors import CLIError, UsageError
from aai_cli.help_text import examples_epilog
from aai_cli.tts import audio, dialogue, session

app = typer.Typer()

# The streaming-TTS reference client defaults to the PocketTTS "jane" voice and
# English, so the CLI sends the same and a bare `aai speak` works out of the box.
# Override either with --voice/--language.
DEFAULT_VOICE = "jane"
DEFAULT_LANGUAGE = "English"


def _read_text(text: str | None) -> str:
"""The text to speak: the non-blank argument, or piped stdin when the argument
is omitted entirely. A *blank* argument (e.g. "") is a usage error, never a
silent fall-through to stdin — so `aai speak "$MSG"` with an empty MSG fails
fast instead of consuming whatever happens to be on the pipe."""
if text is not None and text.strip():
return text
# `text is None` (argument omitted), not merely blank: see the docstring rationale.
if text is None and not sys.stdin.isatty():
Comment thread
alexkroman marked this conversation as resolved.
piped = sys.stdin.read().strip()
if piped:
return piped
raise UsageError(
"No text to speak.",
suggestion='Pass text as an argument: aai speak "Hello" — or pipe it via stdin.',
)


def _output_audio(result: session.SpeakResult, out: Path | None) -> None:
"""Write a WAV when --out is given, else play through the speakers."""
if out is not None:
audio.write_wav(out, result.pcm, result.sample_rate)
else:
audio.play_pcm(result.pcm, result.sample_rate)


def _disposition(out: Path | None) -> str:
return f"saved to {out}" if out is not None else "played"


def _emit_single(
result: session.SpeakResult,
cfg: session.SpeakConfig,
out: Path | None,
*,
json_mode: bool,
) -> None:
"""Single-voice result: a JSON object on stdout, or a human note on stderr."""
duration = round(result.audio_duration_seconds, 3)
if json_mode:
output.emit_ndjson(
{
"voice": cfg.voice,
"language": cfg.language,
"sample_rate": result.sample_rate,
"audio_duration_seconds": duration,
"bytes": len(result.pcm),
"out": str(out) if out is not None else None,
}
)
return
output.error_console.print(
f"[aai.muted]Spoke {duration}s of audio ({_disposition(out)}).[/aai.muted]"
)


def _emit_multi(
result: session.SpeakResult,
speakers: dict[str, str],
segment_count: int,
out: Path | None,
*,
json_mode: bool,
) -> None:
"""Multi-voice result: a JSON object on stdout, or a human note on stderr."""
duration = round(result.audio_duration_seconds, 3)
if json_mode:
output.emit_ndjson(
{
"mode": "multi",
"speakers": speakers,
"segments": segment_count,
"sample_rate": result.sample_rate,
"audio_duration_seconds": duration,
"bytes": len(result.pcm),
"out": str(out) if out is not None else None,
}
)
return
voices = ", ".join(f"{spk}={voice}" for spk, voice in speakers.items())
output.error_console.print(
f"[aai.muted]Spoke {duration}s across {len(speakers)} voices "
f"({voices}) ({_disposition(out)}).[/aai.muted]"
)


def _speak_single(
api_key: str,
text: str,
voice: str,
language: str,
sample_rate: int | None,
out: Path | None,
*,
json_mode: bool,
quiet: bool,
) -> None:
cfg = session.SpeakConfig(text=text, voice=voice, language=language, sample_rate=sample_rate)
with output.status("Synthesizing speech…", json_mode=json_mode, quiet=quiet):
result = session.synthesize(
api_key, cfg, on_warning=lambda m: output.emit_warning(m, json_mode=json_mode)
)
_output_audio(result, out)
_emit_single(result, cfg, out, json_mode=json_mode)


def _speak_dialogue(
api_key: str,
text: str,
bare_voice: str | None,
overrides: dict[str, str],
language: str,
sample_rate: int | None,
out: Path | None,
*,
json_mode: bool,
quiet: bool,
) -> None:
segments = dialogue.parse_segments(text)
if not segments:
raise UsageError(
"No text to speak.",
suggestion="The input had speaker labels but no spoken text.",
)
if bare_voice is not None:
output.emit_warning(
"Ignoring bare --voice in multi-speaker mode; "
"set a voice per speaker with --voice A=NAME.",
json_mode=json_mode,
)
resolved, speakers = dialogue.assign_voices(
segments, dialogue.DEFAULT_VOICE_ROTATION, overrides
)
with output.status("Synthesizing speech…", json_mode=json_mode, quiet=quiet):
result = session.synthesize_dialogue(
api_key,
resolved,
language=language,
sample_rate=sample_rate,
on_warning=lambda m: output.emit_warning(m, json_mode=json_mode),
)
_output_audio(result, out)
_emit_multi(result, speakers, len(resolved), out, json_mode=json_mode)


@app.command(
rich_help_panel=help_panels.TRANSCRIPTION,
epilog=examples_epilog(
[
("Speak text aloud (sandbox only)", 'aai speak "Hello there, friend." --sandbox'),
(
"Pick a voice and language",
'aai speak "Bonjour" --voice jane --language French --sandbox',
),
(
"Speak a diarized transcript, one voice per speaker",
"aai transcribe meeting.mp3 --speaker-labels | aai speak --sandbox",
),
(
"Override a speaker's voice",
"… | aai speak --voice A=vera --voice B=paul --sandbox",
),
(
"Save to a WAV instead of playing",
'aai speak "Hello" --out /tmp/hello.wav --sandbox',
),
]
),
)
def speak(
ctx: typer.Context,
text: str | None = typer.Argument(None, help="Text to speak. Omit to read from stdin."),
voice: list[str] = typer.Option(
[],
"--voice",
help="Voice id, or SPEAKER=VOICE for diarized input (repeatable, e.g. --voice A=jane).",
),
language: str = typer.Option(DEFAULT_LANGUAGE, "--language", help="Language of the text."),
sample_rate: int | None = typer.Option(
None, "--sample-rate", help="Output sample rate in Hz. Server default if omitted."
),
out: Path | None = typer.Option(
None, "--out", help="Write a WAV file instead of playing through the speakers."
),
json_out: bool = options.json_option("Emit JSON metadata about the synthesized audio."),
) -> None:
"""Synthesize speech from text with AssemblyAI streaming TTS (sandbox only).

Plays the audio through your speakers by default, or writes a WAV with --out.
Speaker-labeled input (from 'aai transcribe --speaker-labels') is detected
automatically: the labels are stripped and each speaker gets a different
voice. This feature only exists in the sandbox today — run it with --sandbox.
"""

def body(state: AppState, json_mode: bool) -> None:
if not session.is_available():
raise CLIError(
"aai speak is only available in the sandbox.",
error_type="unsupported_environment",
exit_code=2,
suggestion="Re-run with --sandbox (or --env sandbox000).",
)
spoken = _read_text(text)
api_key = config.resolve_api_key(profile=state.profile)
bare_voice, overrides = dialogue.parse_voice_overrides(voice)
if dialogue.looks_like_speaker_labeled(spoken):
_speak_dialogue(
api_key,
spoken,
bare_voice,
overrides,
language,
sample_rate,
out,
json_mode=json_mode,
quiet=state.quiet,
)
else:
_speak_single(
api_key,
spoken,
bare_voice or DEFAULT_VOICE,
language,
sample_rate,
out,
json_mode=json_mode,
quiet=state.quiet,
)

run_command(ctx, body, json=json_out)
3 changes: 3 additions & 0 deletions aai_cli/environments.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class Environment:
name: str
api_base: str # SDK base_url for /v2/upload + /v2/transcript
streaming_host: str # StreamingClientOptions.api_host (SDK builds wss://host/v3/ws)
streaming_tts_host: str # streaming TTS host; empty when TTS isn't available (prod)
agents_host: str # Voice Agent host; the agent client builds wss://host/v1/ws
llm_gateway_base: str # OpenAI base_url for the LLM Gateway (…/v1)
ams_base: str # Accounts Management Service
Expand All @@ -37,6 +38,7 @@ class Environment:
name="production",
api_base="https://api.assemblyai.com",
streaming_host="streaming.assemblyai.com",
streaming_tts_host="",
agents_host="agents.assemblyai.com",
llm_gateway_base="https://llm-gateway.assemblyai.com/v1",
ams_base="https://ams.internal.assemblyai-labs.com",
Expand All @@ -48,6 +50,7 @@ class Environment:
name="sandbox000",
api_base="https://api.sandbox000.assemblyai-labs.com",
streaming_host="streaming.sandbox000.assemblyai-labs.com",
streaming_tts_host="streaming-tts.sandbox000.assemblyai-labs.com",
agents_host="agents.sandbox000.assemblyai-labs.com",
llm_gateway_base="https://llm-gateway.sandbox000.assemblyai-labs.com/v1",
ams_base="https://ams.sandbox000.assemblyai-labs.com",
Expand Down
3 changes: 3 additions & 0 deletions aai_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
sessions,
setup,
share,
speak,
stream,
transcribe,
transcripts,
Expand All @@ -60,6 +61,7 @@
"transcribe",
"stream",
"agent",
"speak",
"llm",
# Setup & Tools — get set up & maintain
"doctor",
Expand Down Expand Up @@ -313,6 +315,7 @@ def main(
app.add_typer(sessions.app, name="sessions", rich_help_panel=help_panels.HISTORY)
app.add_typer(audit.app) # audit
app.add_typer(agent.app)
app.add_typer(speak.app)
app.add_typer(llm.app)
app.add_typer(account.app) # balance, usage, limits
app.add_typer(login.app) # login, logout, whoami
Expand Down
Empty file added aai_cli/tts/__init__.py
Empty file.
Loading
Loading