#!/usr/bin/env python3 """Transcribe a video via Groq or OpenAI Whisper API. Strategy: extract audio (mono 16kHz mp3, tiny payload), upload to whichever API has a key. Returns segments in the same shape as transcribe.parse_vtt so the rest of the pipeline (filter_range, format_transcript) doesn't care where the transcript came from. Pure stdlib — no `pip install groq` or `pip install openai` needed. """ from __future__ import annotations import io import json import mimetypes import os import shutil import ssl import subprocess import sys import time import urllib.error import uuid from pathlib import Path from urllib.request import Request, urlopen GROQ_ENDPOINT = "https://api.groq.com/openai/v1/audio/transcriptions" GROQ_MODEL = "whisper-large-v3" OPENAI_ENDPOINT = "https://api.openai.com/v1/audio/transcriptions" OPENAI_MODEL = "whisper-1" def load_api_key(preferred: str | None = None) -> tuple[str, str] | tuple[None, None]: """Return (backend, api_key). Prefers Groq, falls back to OpenAI. If `preferred` is "groq" or "openai", only that backend's key is considered. """ def _from_env(name: str) -> str | None: value = os.environ.get(name) return value.strip() if value else None def _from_dotenv(path: Path, name: str) -> str | None: if not path.exists(): return None try: for line in path.read_text().splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") if key.strip() != name: continue value = value.strip() if len(value) >= 2 and value[0] in ('"', "'") and value[-1] == value[0]: value = value[1:-1] return value or None except OSError: return None return None dotenv_paths = [ Path.home() / ".config" / "watch" / ".env", Path.cwd() / ".env", ] candidates = (("GROQ_API_KEY", "groq"), ("OPENAI_API_KEY", "openai")) if preferred is not None: candidates = tuple(c for c in candidates if c[1] == preferred) for key_name, backend in candidates: value = _from_env(key_name) if not value: for candidate in dotenv_paths: value = _from_dotenv(candidate, key_name) if value: break if value: return backend, value return None, None def extract_audio(video_path: str, out_path: Path) -> Path: """Extract mono 16kHz 64kbps mp3 — ~480 kB/min, fits any Whisper limit.""" if shutil.which("ffmpeg") is None: raise SystemExit("ffmpeg is not installed. Install with: brew install ffmpeg") out_path.parent.mkdir(parents=True, exist_ok=True) cmd = [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", video_path, "-vn", "-acodec", "libmp3lame", "-ar", "16000", "-ac", "1", "-b:a", "64k", str(out_path), ] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise SystemExit(f"ffmpeg audio extraction failed: {result.stderr.strip()}") if not out_path.exists() or out_path.stat().st_size == 0: raise SystemExit("ffmpeg produced no audio — video may have no audio track") return out_path def _build_multipart(fields: dict[str, str], file_path: Path) -> tuple[bytes, str]: """Assemble a multipart/form-data body the Whisper APIs accept. Whisper's multipart upload is small and predictable — doing it by hand keeps us on pure stdlib instead of pulling requests/groq/openai SDKs. """ boundary = f"----WatchBoundary{uuid.uuid4().hex}" eol = b"\r\n" buf = io.BytesIO() for name, value in fields.items(): buf.write(f"--{boundary}".encode()); buf.write(eol) buf.write(f'Content-Disposition: form-data; name="{name}"'.encode()); buf.write(eol) buf.write(eol) buf.write(str(value).encode()); buf.write(eol) mimetype = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream" buf.write(f"--{boundary}".encode()); buf.write(eol) buf.write( f'Content-Disposition: form-data; name="file"; filename="{file_path.name}"'.encode() ) buf.write(eol) buf.write(f"Content-Type: {mimetype}".encode()); buf.write(eol) buf.write(eol) buf.write(file_path.read_bytes()) buf.write(eol) buf.write(f"--{boundary}--".encode()); buf.write(eol) return buf.getvalue(), boundary MAX_ATTEMPTS = 4 # initial + 3 retries MAX_429_RETRIES = 2 RETRY_BASE_DELAY = 2.0 def _post_whisper(endpoint: str, api_key: str, model: str, audio_path: Path) -> dict: fields = { "model": model, "response_format": "verbose_json", "temperature": "0", } body, boundary = _build_multipart(fields, audio_path) headers = { "Authorization": f"Bearer {api_key}", "Content-Type": f"multipart/form-data; boundary={boundary}", # Groq sits behind Cloudflare — the default `Python-urllib/3.x` UA # trips WAF rule 1010 (403) before auth even runs. Any non-default # UA clears it; we identify honestly. "User-Agent": "watch-skill/1.0 (+claude-code; python-urllib)", } context = ssl.create_default_context() rate_limit_hits = 0 last_exc: Exception | None = None last_detail = "" for attempt in range(MAX_ATTEMPTS): request = Request(endpoint, data=body, headers=headers, method="POST") try: with urlopen(request, timeout=300, context=context) as response: payload = response.read().decode("utf-8", errors="replace") except urllib.error.HTTPError as exc: detail = _read_error_body(exc) last_exc, last_detail = exc, detail # 4xx other than 429 are client errors — no retry will fix them. if 400 <= exc.code < 500 and exc.code != 429: raise SystemExit(f"Whisper request failed: {exc}{detail}") if exc.code == 429: rate_limit_hits += 1 if rate_limit_hits >= MAX_429_RETRIES: raise SystemExit(f"Whisper request failed: {exc}{detail}") delay = _retry_after(exc) or RETRY_BASE_DELAY * (2 ** attempt) + 1 else: delay = RETRY_BASE_DELAY * (2 ** attempt) if attempt < MAX_ATTEMPTS - 1: print( f"[watch] whisper HTTP {exc.code} — retrying in {delay:.1f}s " f"(attempt {attempt + 2}/{MAX_ATTEMPTS})", file=sys.stderr, ) time.sleep(delay) continue except (urllib.error.URLError, TimeoutError, ConnectionResetError, OSError) as exc: last_exc, last_detail = exc, "" if attempt < MAX_ATTEMPTS - 1: delay = RETRY_BASE_DELAY * (attempt + 1) print( f"[watch] whisper network error ({type(exc).__name__}: {exc}) — " f"retrying in {delay:.1f}s (attempt {attempt + 2}/{MAX_ATTEMPTS})", file=sys.stderr, ) time.sleep(delay) continue try: return json.loads(payload) except json.JSONDecodeError as exc: raise SystemExit(f"Whisper returned non-JSON response: {exc}: {payload[:200]}") raise SystemExit( f"Whisper request failed after {MAX_ATTEMPTS} attempts: {last_exc}{last_detail}" ) def _read_error_body(exc: urllib.error.HTTPError) -> str: try: body = exc.read() except Exception: return "" if not body: return "" try: return f" — {body.decode('utf-8', errors='replace')[:400]}" except Exception: return "" def _retry_after(exc: urllib.error.HTTPError) -> float | None: header = exc.headers.get("Retry-After") if getattr(exc, "headers", None) else None if not header: return None try: return float(header) except ValueError: return None def _segments_from_response(data: dict) -> list[dict]: """Convert Whisper verbose_json into our {start, end, text} segment format.""" out: list[dict] = [] for seg in data.get("segments") or []: text = (seg.get("text") or "").strip() if not text: continue out.append({ "start": round(float(seg.get("start") or 0.0), 2), "end": round(float(seg.get("end") or 0.0), 2), "text": text, }) if not out: full = (data.get("text") or "").strip() if full: out.append({"start": 0.0, "end": 0.0, "text": full}) return out def transcribe_video( video_path: str, audio_out: Path, backend: str | None = None, api_key: str | None = None, ) -> tuple[list[dict], str]: """Run the full flow: extract audio → upload → parse segments. Returns (segments, backend_used). Raises SystemExit on any failure. """ if backend is None or api_key is None: detected_backend, detected_key = load_api_key() backend = backend or detected_backend api_key = api_key or detected_key if not backend or not api_key: setup_py = Path(__file__).resolve().parent / "setup.py" raise SystemExit( "No Whisper API key available. Set GROQ_API_KEY (preferred) or OPENAI_API_KEY " "in the environment or in ~/.config/watch/.env. " f"Run `python3 {setup_py}` to configure." ) print(f"[watch] extracting audio for Whisper ({backend})…", file=sys.stderr) audio_path = extract_audio(video_path, audio_out) size_kb = audio_path.stat().st_size / 1024 print(f"[watch] audio: {size_kb:.0f} kB — uploading to {backend} Whisper…", file=sys.stderr) if backend == "groq": response = _post_whisper(GROQ_ENDPOINT, api_key, GROQ_MODEL, audio_path) elif backend == "openai": response = _post_whisper(OPENAI_ENDPOINT, api_key, OPENAI_MODEL, audio_path) else: raise SystemExit(f"Unknown whisper backend: {backend}") segments = _segments_from_response(response) if not segments: raise SystemExit("Whisper returned no transcript segments") print(f"[watch] transcribed {len(segments)} segments via {backend}", file=sys.stderr) return segments, backend if __name__ == "__main__": if len(sys.argv) < 2: print("usage: whisper.py [] [--backend groq|openai]", file=sys.stderr) raise SystemExit(2) video = sys.argv[1] audio_out = Path(sys.argv[2]) if len(sys.argv) > 2 and not sys.argv[2].startswith("--") else Path("audio.mp3") backend_override = None if "--backend" in sys.argv: backend_override = sys.argv[sys.argv.index("--backend") + 1] segments, backend = transcribe_video(video, audio_out, backend=backend_override) print(json.dumps({"backend": backend, "segments": segments}, indent=2))