# -*- coding: utf-8 -*- """Whisper audio transcription with Groq → OpenAI fallback. Downloads audio (yt-dlp), compresses + chunks (ffmpeg), and posts to a Whisper-compatible API. Defaults to Groq's free `whisper-large-v3` and falls back to OpenAI's `whisper-1` on HTTP error. Public entry point: transcribe(source, *, provider="auto", out_dir=None, config=None) -> str Designed to be importable from channels (e.g. YouTubeChannel.transcribe). """ from __future__ import annotations import shutil import subprocess import tempfile from pathlib import Path from typing import List, Optional import requests from agent_reach.config import Config # Whisper API limit is 25MB; leave headroom for multipart overhead. SIZE_LIMIT_BYTES = 24 * 1024 * 1024 CHUNK_SECONDS = 600 # 10 min — small enough that boundary cuts rarely lose meaning PROVIDERS = { "groq": { "endpoint": "https://api.groq.com/openai/v1/audio/transcriptions", "model": "whisper-large-v3", "key_field": "groq_api_key", }, "openai": { "endpoint": "https://api.openai.com/v1/audio/transcriptions", "model": "whisper-1", "key_field": "openai_api_key", }, } class TranscribeError(RuntimeError): """Raised when transcription cannot complete.""" class MissingDependency(TranscribeError): """Raised when a required external binary is missing.""" class NoProviderConfigured(TranscribeError): """Raised when no provider has an API key configured.""" def _require(binary: str) -> None: if not shutil.which(binary): raise MissingDependency(f"{binary} not found in PATH") def _run(cmd: List[str], timeout: int = 600) -> None: """Run a subprocess, raising TranscribeError on nonzero exit or timeout. cmd carries user-supplied URLs/paths into yt-dlp/ffmpeg — a stalled network read or a hung probe must not block the CLI forever. """ try: proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) except subprocess.TimeoutExpired: raise TranscribeError(f"{cmd[0]} timed out after {timeout}s") if proc.returncode != 0: raise TranscribeError( f"{cmd[0]} failed (exit {proc.returncode}): {proc.stderr.strip()[:300]}" ) def download_audio(url: str, out_dir: Path) -> Path: """Download audio with yt-dlp into out_dir; return the resulting file path.""" _require("yt-dlp") template = out_dir / "source.%(ext)s" _run( [ "yt-dlp", "-x", "--audio-format", "m4a", "--audio-quality", "0", "-o", str(template), url, ], timeout=1800, # long podcasts over slow networks — generous but bounded ) files = sorted(out_dir.glob("source.*")) if not files: raise TranscribeError("yt-dlp produced no output file") return files[0] def compress_audio(src: Path, out_dir: Path) -> Path: """Re-encode to mono / 16kHz / 32kbps m4a — keeps most content under 25MB.""" _require("ffmpeg") dst = out_dir / "compressed.m4a" _run( [ "ffmpeg", "-loglevel", "error", "-y", "-i", str(src), "-vn", "-ac", "1", "-ar", "16000", "-b:a", "32k", str(dst), ] ) return dst def chunk_audio(src: Path, out_dir: Path, segment_seconds: int = CHUNK_SECONDS) -> List[Path]: """Split src into segments. Re-encodes each segment so cuts align to keyframes.""" _require("ffmpeg") pattern = out_dir / "chunk_%03d.m4a" _run( [ "ffmpeg", "-loglevel", "error", "-y", "-i", str(src), "-f", "segment", "-segment_time", str(segment_seconds), "-ac", "1", "-ar", "16000", "-b:a", "32k", str(pattern), ] ) chunks = sorted(out_dir.glob("chunk_*.m4a")) if not chunks: raise TranscribeError("ffmpeg produced no chunks") return chunks def _provider_key(provider: str, config: Config) -> Optional[str]: field = PROVIDERS[provider]["key_field"] val = config.get(field) return val or None def transcribe_chunk( chunk: Path, provider: str, *, config: Optional[Config] = None, timeout: int = 120, ) -> str: """Transcribe one chunk via the named provider. Raises TranscribeError on failure.""" if provider not in PROVIDERS: raise TranscribeError(f"unknown provider: {provider}") cfg = config or Config() key = _provider_key(provider, cfg) if not key: raise NoProviderConfigured( f"{provider}: missing {PROVIDERS[provider]['key_field']} " f"(configure with `agent-reach configure {provider}-key ...`)" ) info = PROVIDERS[provider] with chunk.open("rb") as fh: try: resp = requests.post( info["endpoint"], headers={"Authorization": f"Bearer {key}"}, files={"file": (chunk.name, fh, "audio/m4a")}, data={"model": info["model"], "response_format": "text"}, timeout=timeout, ) except requests.RequestException as e: raise TranscribeError(f"{provider}: network error: {e}") from e if not resp.ok: raise TranscribeError(f"{provider}: HTTP {resp.status_code}: {resp.text[:300]}") return resp.text def _provider_order(provider: str) -> List[str]: if provider == "auto": return ["groq", "openai"] if provider in PROVIDERS: return [provider] raise TranscribeError(f"unknown provider: {provider} (use groq|openai|auto)") def transcribe( source: str, *, provider: str = "auto", out_dir: Optional[Path] = None, config: Optional[Config] = None, ) -> str: """Transcribe a URL or local file path. Returns the joined transcript text. `provider` is one of `auto` (groq → openai), `groq`, or `openai`. `out_dir` defaults to a fresh temp directory; intermediate files stay there. """ cfg = config or Config() order = _provider_order(provider) # Validate at least one provider is configured before doing expensive work. if not any(_provider_key(p, cfg) for p in order): names = ", ".join(PROVIDERS[p]["key_field"] for p in order) raise NoProviderConfigured(f"no provider key configured (need one of: {names})") work_dir = Path(out_dir) if out_dir else Path(tempfile.mkdtemp(prefix="transcribe-")) work_dir.mkdir(parents=True, exist_ok=True) src_path = Path(source) if src_path.is_file(): audio = src_path else: audio = download_audio(source, work_dir) compressed = compress_audio(audio, work_dir) if compressed.stat().st_size <= SIZE_LIMIT_BYTES: chunks = [compressed] else: chunks = chunk_audio(compressed, work_dir) pieces: List[str] = [] for chunk in chunks: text = _transcribe_with_fallback(chunk, order, cfg) pieces.append(text.strip()) return "\n".join(p for p in pieces if p) def _transcribe_with_fallback(chunk: Path, order: List[str], config: Config) -> str: """Try each provider in order; return first success or raise the last error.""" last_err: Optional[Exception] = None for p in order: if not _provider_key(p, config): # Skip silently — caller already validated at least one is configured. continue try: return transcribe_chunk(chunk, p, config=config) except TranscribeError as e: last_err = e continue raise TranscribeError(f"all providers failed for {chunk.name}: {last_err}")