Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Your key is written to a git-ignored `.env` (never sent to the browser). Use `--
| --- | --- |
| `aai login` / `logout` / `whoami` | Manage the stored API key. |
| `aai doctor` | Check your environment (API key, network, ffmpeg, microphone, agent tooling). |
| `aai transcribe <file\|url>` | Transcribe a file, URL, or YouTube URL (`--sample`, `--llm`, `--show-code`). |
| `aai transcribe <file\|url>` | Transcribe a file, URL, or YouTube/podcast page URL (`--sample`, `--llm`, `--show-code`). |
| `aai transcripts list` / `get <id>` | Browse and fetch past transcripts. |
| `aai stream [file]` | Real-time transcription from a file or the microphone. |
| `aai agent` | *Run* a live two-way voice conversation (to **build** a voice agent app, use `aai init voice-agent`). |
Expand Down
24 changes: 12 additions & 12 deletions aai_cli/code_gen/transcribe.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,28 +40,28 @@ def render(
"""
if output is not None:
llm_gateway = None # `-o` returns before the chain runs in the real command
is_youtube = youtube.is_youtube_url(source)
needs_download = youtube.is_downloadable_url(source)
parts = (
_header_block(llm_gateway, output, is_youtube=is_youtube)
+ _transcribe_block(merged, source, is_youtube=is_youtube)
_header_block(llm_gateway, output, needs_download=needs_download)
+ _transcribe_block(merged, source, needs_download=needs_download)
+ _result_block(merged, llm_gateway, output)
)
parts.append("")
return "\n".join(parts)


def _header_block(
llm_gateway: dict[str, object] | None, output: str | None, *, is_youtube: bool
llm_gateway: dict[str, object] | None, output: str | None, *, needs_download: bool
) -> list[str]:
"""Imports plus the api-key (and non-default environment) settings lines."""
stdlib_imports = ["import os"]
if is_youtube:
# The YouTube path downloads audio to a temp dir before uploading.
if needs_download:
# The download path fetches audio to a temp dir before uploading.
stdlib_imports += ["import tempfile"]
if output == "json":
stdlib_imports.insert(0, "import json")
imports = ["import assemblyai as aai"]
if is_youtube:
if needs_download:
imports.append("import yt_dlp")
if llm_gateway:
imports.append("from openai import OpenAI")
Expand All @@ -81,20 +81,20 @@ def _header_block(
return parts


def _transcribe_block(merged: dict[str, object], source: str, *, is_youtube: bool) -> list[str]:
def _transcribe_block(merged: dict[str, object], source: str, *, needs_download: bool) -> list[str]:
"""The transcriber setup, optional config, the transcribe call, and error check."""
parts = ["", "transcriber = aai.Transcriber()"]
config_arg = ""
if merged:
kwargs = "\n".join(serialize.config_kwarg_lines(merged, indent=4))
parts += ["", f"config = aai.TranscriptionConfig(\n{kwargs}\n)"]
config_arg = ", config=config"
if is_youtube:
# AssemblyAI can't read a YouTube watch URL itself, so download the audio
# with yt-dlp into a temp dir and upload the local file — what the CLI does.
if needs_download:
# AssemblyAI can't read a YouTube/podcast page URL itself, so download the
# audio with yt-dlp into a temp dir and upload the local file — what the CLI does.
parts += [
"",
"# AssemblyAI can't fetch a YouTube URL itself; download the audio first.",
"# AssemblyAI can't fetch this page URL itself; download the audio first.",
"with tempfile.TemporaryDirectory() as _tmp:",
" with yt_dlp.YoutubeDL(",
' {"format": "bestaudio/best", "outtmpl": f"{_tmp}/%(id)s.%(ext)s"}',
Expand Down
10 changes: 5 additions & 5 deletions aai_cli/commands/stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def _dispatch(session: StreamSession, opts: SourceOptions) -> None:
# Raw PCM16 mono piped on stdin (e.g. `ffmpeg … -f s16le - | aai stream -`).
stdin_src = StdinSource(sample_rate=opts.sample_rate or TARGET_RATE)
session.run(stdin_src, stdin_src.sample_rate)
elif opts.source and youtube.is_youtube_url(opts.source):
elif opts.source and youtube.is_downloadable_url(opts.source):
# Fetch the audio first, then stream the local file in real time.
with tempfile.TemporaryDirectory(prefix="aai-yt-") as td:
local = youtube.download_audio(opts.source, Path(td))
Expand Down Expand Up @@ -101,8 +101,8 @@ def stream(
ctx: typer.Context,
source: str | None = typer.Argument(
None,
help="Audio file path, URL, or YouTube URL to stream. Use - for raw PCM16/mono/16k "
"on stdin. Omit to use the microphone.",
help="Audio file path, URL, or YouTube/podcast page URL to stream. Use - for raw "
"PCM16/mono/16k on stdin. Omit to use the microphone.",
),
sample: bool = typer.Option(False, "--sample", help="Stream the hosted wildfires.mp3 sample."),
# audio capture
Expand Down Expand Up @@ -395,9 +395,9 @@ def body(state: AppState, json_mode: bool) -> None:
validate_sources(opts, has_llm=bool(llm_prompt), text_mode=text_mode)
if opts.from_system_audio:
raise UsageError("--show-code does not support macOS system audio capture yet.")
if opts.source and youtube.is_youtube_url(opts.source):
if opts.source and youtube.is_downloadable_url(opts.source):
raise UsageError(
"--show-code does not support YouTube sources yet.",
"--show-code does not support downloaded sources (YouTube, podcast pages) yet.",
suggestion="Download the audio first (e.g. yt-dlp) and pass the local file.",
)
code_source: str | None = None
Expand Down
9 changes: 5 additions & 4 deletions aai_cli/commands/transcribe.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def _validate_speakers_expected(merged: dict[str, object]) -> None:
("Transcribe a local file", "aai transcribe call.mp3"),
("Try it with the hosted sample", "aai transcribe --sample"),
("Transcribe a YouTube video", "aai transcribe https://youtu.be/dtp6b76pMak"),
("Transcribe a podcast episode page", 'aai transcribe "https://podcasts.apple.com/…"'),
("Label who said what", "aai transcribe call.mp3 --speaker-labels"),
("Redact PII for compliance", "aai transcribe call.mp3 --redact-pii"),
("Summarize a recording", "aai transcribe call.mp3 --summarization"),
Expand All @@ -75,7 +76,7 @@ def _validate_speakers_expected(merged: dict[str, object]) -> None:
)
def transcribe(
ctx: typer.Context,
source: str | None = typer.Argument(None, help="Audio file path, public URL, or YouTube URL."),
source: str | None = typer.Argument(None, help="Audio file, URL, or YouTube/podcast URL."),
sample: bool = typer.Option(False, "--sample", help="Use the hosted wildfires.mp3 sample."),
# model & language
speech_model: aai.SpeechModel | None = typer.Option(
Expand Down Expand Up @@ -357,12 +358,12 @@ def transcribe(
help="Print the equivalent Python SDK code and exit (does not transcribe).",
),
) -> None:
"""Transcribe an audio file, URL, or YouTube link.
"""Transcribe an audio file, URL, or YouTube/podcast link.

Quickest start: aai transcribe call.mp3 (or --sample for the hosted demo).

Save with --out FILE, or pipe one field with -o text. A YouTube URL is downloaded
first, then transcribed.
Save with --out FILE, or pipe one field with -o text. YouTube and podcast-page
URLs (any page yt-dlp can extract) are downloaded first, then transcribed.

Curated flags cover common features; --config KEY=VALUE and --config-file reach
every other field. Analysis (summary, chapters, ...) renders in human mode.
Expand Down
2 changes: 1 addition & 1 deletion aai_cli/onboard/sections.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def first_request(prompter: Prompter, ctx: WizardContext) -> SectionResult:
prompter.section("Your first transcription")
api_key = config.resolve_api_key(profile=ctx.profile)
source = prompter.text(
"Audio file path or YouTube URL (or press Enter to transcribe a sample clip)",
"Audio file path or YouTube/podcast URL (or press Enter to transcribe a sample clip)",
default="",
).strip()
label = source or "the sample clip"
Expand Down
6 changes: 3 additions & 3 deletions aai_cli/skills/aai-cli/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: aai-cli
description: Use the AssemblyAI CLI (`aai`) from the command line — transcribe audio/video files, URLs, and YouTube links; stream live real-time transcription from a mic/file/system audio; run full-duplex voice agents; query the LLM Gateway over transcripts; browse transcript and streaming-session history; sign in and manage account balance, usage, rate limits, API keys, and audit logs; scaffold a starter app (init); diagnose setup (doctor); and set up your coding agent's AssemblyAI docs MCP + skills (setup). Use whenever an agent is invoking the `aai` command.
description: Use the AssemblyAI CLI (`aai`) from the command line — transcribe audio/video files, URLs, and YouTube/podcast links; stream live real-time transcription from a mic/file/system audio; run full-duplex voice agents; query the LLM Gateway over transcripts; browse transcript and streaming-session history; sign in and manage account balance, usage, rate limits, API keys, and audit logs; scaffold a starter app (init); diagnose setup (doctor); and set up your coding agent's AssemblyAI docs MCP + skills (setup). Use whenever an agent is invoking the `aai` command.
---

# AssemblyAI CLI (`aai`)
Expand Down Expand Up @@ -72,8 +72,8 @@ agent," reach for `aai init voice-agent`, not `aai agent`.

- **Build/scaffold an app (transcription, live captions, or a voice agent app)**
→ `aai init` — see `references/setup.md`
- **Transcribe a file/URL/YouTube, stream live audio, run a live voice agent, or
query the LLM Gateway** → `references/transcription.md`
- **Transcribe a file/URL/YouTube/podcast page, stream live audio, run a live
voice agent, or query the LLM Gateway** → `references/transcription.md`
- **Browse past transcripts or streaming sessions** → `references/history.md`
- **Sign in/out, identity, balance, usage, rate limits, API keys, audit log** →
`references/account.md`
Expand Down
7 changes: 4 additions & 3 deletions aai_cli/skills/aai-cli/references/transcription.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ Four commands. All accept `--json` (auto-enabled when piped) and `-o/--output`
to print a single field. `transcribe`, `stream`, and `agent` accept
`--show-code` to print equivalent Python SDK code without calling the API.

## `aai transcribe [SOURCE]` — file / URL / YouTube
## `aai transcribe [SOURCE]` — file / URL / YouTube / podcast page

`SOURCE` is a local file path, public URL, or YouTube URL (downloaded first).
`SOURCE` is a local file path, public URL, or a media-page URL yt-dlp can extract
(YouTube, Apple Podcasts, Spreaker, SoundCloud, …) — those are downloaded first.
Use `--sample` for the hosted `wildfires.mp3`. Analysis results (summary,
chapters, sentiment, …) render automatically in human mode.

Expand Down Expand Up @@ -38,7 +39,7 @@ aai transcribe call.mp3 --show-code

## `aai stream [SOURCE]` — live real-time transcription

Omit `SOURCE` to use the microphone; pass a file/URL/YouTube to stream that, or
Omit `SOURCE` to use the microphone; pass a file/URL/media page to stream that, or
`--sample`. macOS can capture system audio with `--system-audio` (mic + system)
or `--system-audio-only`.

Expand Down
4 changes: 2 additions & 2 deletions aai_cli/transcribe_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ def run_transcription(
return client.transcribe(api_key, str(local), config=transcription_config)

audio = client.resolve_audio_source(source, sample=sample)
if youtube.is_youtube_url(audio):
# Fetch first; AssemblyAI can't read a YouTube watch URL itself.
if youtube.is_downloadable_url(audio):
# Fetch first; AssemblyAI can't read a YouTube/podcast page URL itself.
with tempfile.TemporaryDirectory(prefix="aai-yt-") as td:
local = youtube.download_audio(audio, Path(td))
return client.transcribe(api_key, str(local), config=transcription_config)
Expand Down
34 changes: 34 additions & 0 deletions aai_cli/youtube.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
"""Downloading audio from media-page URLs (YouTube, podcast pages, …) via yt-dlp.

The AssemblyAI API fetches direct audio URLs itself; this module handles the URLs it
can't — HTML pages whose audio yt-dlp knows how to extract.
"""

from __future__ import annotations

import logging
Expand Down Expand Up @@ -27,6 +33,34 @@ def is_youtube_url(source: str | None) -> bool:
return bool(_YOUTUBE_RE.match(source.strip()))


def is_downloadable_url(source: str | None) -> bool:
"""True if `source` is a media-page URL whose audio must be downloaded first.

YouTube is matched by shape alone — no yt-dlp import needed, so a missing yt-dlp
still routes to ``download_audio``'s install hint. Other http(s) URLs match when
a dedicated yt-dlp extractor claims them (Apple Podcasts, Spreaker, SoundCloud,
…). Direct audio URLs and unknown pages match only yt-dlp's catch-all ``Generic``
extractor, which is excluded: those pass through untouched for the API to fetch.
"""
if is_youtube_url(source):
return True
url = (source or "").strip()
if not url.startswith(("http://", "https://")):
# Local paths (and other non-URL sources) never need a download; skipping the
# extractor sweep also avoids importing yt-dlp on the common local-file path.
return False
return _ytdlp_extractor_claims(url)


def _ytdlp_extractor_claims(url: str) -> bool:
"""True if a dedicated (non-``Generic``) yt-dlp extractor matches `url`."""
try:
from yt_dlp.extractor import gen_extractor_classes
except ImportError:
return False
return any(ie.suitable(url) and ie.ie_key() != "Generic" for ie in gen_extractor_classes())


def download_audio(url: str, dest_dir: Path) -> Path:
"""Download the best audio track of `url` into `dest_dir` and return its path.

Expand Down
17 changes: 9 additions & 8 deletions tests/__snapshots__/test_cli_output_snapshots.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -704,9 +704,9 @@
instead, pipe the text out with -o text | aai llm -f "…".

╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ source [SOURCE] Audio file path, URL, or YouTube URL to stream. Use
│ - for raw PCM16/mono/16k on stdin. Omit to use the
microphone.
│ source [SOURCE] Audio file path, URL, or YouTube/podcast page URL to │
stream. Use - for raw PCM16/mono/16k on stdin. Omit │
to use the microphone.
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --sample Stream the hosted wildfires.mp3 sample. │
Expand Down Expand Up @@ -837,20 +837,19 @@

Usage: aai transcribe [OPTIONS] [SOURCE]

Transcribe an audio file, URL, or YouTube link.
Transcribe an audio file, URL, or YouTube/podcast link.

Quickest start: aai transcribe call.mp3 (or --sample for the hosted demo).

Save with --out FILE, or pipe one field with -o text. A YouTube URL is
downloaded
first, then transcribed.
Save with --out FILE, or pipe one field with -o text. YouTube and podcast-page
URLs (any page yt-dlp can extract) are downloaded first, then transcribed.

Curated flags cover common features; --config KEY=VALUE and --config-file
reach
every other field. Analysis (summary, chapters, ...) renders in human mode.

╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ source [SOURCE] Audio file path, public URL, or YouTube URL. │
│ source [SOURCE] Audio file, URL, or YouTube/podcast URL.
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --sample Use the hosted │
Expand Down Expand Up @@ -969,6 +968,8 @@
$ aai transcribe --sample
Transcribe a YouTube video
$ aai transcribe https://youtu.be/dtp6b76pMak
Transcribe a podcast episode page
$ aai transcribe "https://podcasts.apple.com/…"
Label who said what
$ aai transcribe call.mp3 --speaker-labels
Redact PII for compliance
Expand Down
12 changes: 12 additions & 0 deletions tests/test_code_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,18 @@ def test_transcribe_render_youtube_downloads_before_upload():
assert 'transcribe("https://www.youtube.com' not in code


def test_transcribe_render_podcast_page_downloads_before_upload():
# Podcast episode pages (extractor-matched, like YouTube) generate the same
# download-first script; the page URL never reaches transcribe().
url = "https://podcasts.apple.com/us/podcast/some-show/id1535809341?i=1000123456789"
code = code_gen.transcribe({}, source=url)
ast.parse(code)
assert "import yt_dlp" in code
assert f"extract_info({url!r}, download=True)" in code
assert "transcriber.transcribe(_audio)" in code
assert "transcribe('https://podcasts.apple.com" not in code


def test_transcribe_render_youtube_passes_config_to_local_upload():
# With a config object the download still wraps the upload, and config flows through.
code = code_gen.transcribe({"speaker_labels": True}, source="https://youtu.be/abc123")
Expand Down
7 changes: 6 additions & 1 deletion tests/test_onboard_sections.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def __init__(self, *, select: str = "skip", confirm: bool = True, text: str = "k
self._confirm = confirm
self._text = text
self.confirm_defaults: list[bool] = []
self.text_titles: list[str] = []

def section(self, title: str) -> None:
pass
Expand All @@ -51,6 +52,7 @@ def select(
return self._select

def text(self, title: str, *, default: str | None = None) -> str:
self.text_titles.append(title)
return self._text


Expand Down Expand Up @@ -118,10 +120,13 @@ def _fake(
monkeypatch.setattr(transcribe_exec, "run_transcription", _fake)
monkeypatch.setattr(transcribe_render, "render_transcript_result", lambda *a, **k: None)
status_messages = _capture_status(monkeypatch)
assert sections.first_request(_ScriptedPrompter(text="meeting.mp3"), ctx) is SectionResult.DONE
prompter = _ScriptedPrompter(text="meeting.mp3")
assert sections.first_request(prompter, ctx) is SectionResult.DONE
assert seen["source"] == "meeting.mp3"
assert seen["sample"] is False
assert status_messages == ["Transcribing meeting.mp3…"]
# The prompt advertises every accepted source kind, including podcast pages.
assert any("YouTube/podcast URL" in t for t in prompter.text_titles)


def test_first_request_handles_failure(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None:
Expand Down
Loading
Loading