"""Local transcription via faster-whisper. Reads a local media file and returns the same shape the highlight generator expects: {duration, segments[start, end, text]}. """ import os import re from pathlib import Path from typing import Dict, Optional from ..config import LOCAL_OUTPUT_DIR, LOCAL_WHISPER_DEVICE, LOCAL_WHISPER_MODEL def _transcript_cache_path(media_path: str) -> Path: """Return the .srt cache path for a media file.""" cache_dir = Path(LOCAL_OUTPUT_DIR) cache_dir.mkdir(parents=True, exist_ok=True) return cache_dir / (Path(media_path).stem + ".srt") def _format_srt_timestamp(seconds: float) -> str: total_ms = max(0, int(round(seconds * 1000))) ms = total_ms % 1000 total_s = total_ms // 1000 s = total_s % 60 total_m = total_s // 60 m = total_m % 60 h = total_m // 60 return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" def _parse_srt_timestamp(value: str) -> float: match = re.fullmatch(r"(\d{2}):(\d{2}):(\d{2}),(\d{3})", value.strip()) if not match: raise ValueError(f"Invalid SRT timestamp: {value!r}") hours, minutes, seconds, millis = map(int, match.groups()) return hours * 3600 + minutes * 60 + seconds + (millis / 1000.0) def _write_srt_cache(media_path: str, transcript: Dict) -> Path: cache_path = _transcript_cache_path(media_path) lines = [] for idx, segment in enumerate(transcript.get("segments", []), start=1): start = _format_srt_timestamp(float(segment["start"])) end = _format_srt_timestamp(float(segment["end"])) text = str(segment.get("text", "")).strip().replace("\r", "").replace("\n", " ") lines.append(str(idx)) lines.append(f"{start} --> {end}") lines.append(text) lines.append("") cache_path.write_text("\n".join(lines), encoding="utf-8") return cache_path def _load_srt_cache(cache_path: Path) -> Dict: content = cache_path.read_text(encoding="utf-8-sig").strip() if not content: return {"duration": 0.0, "segments": []} segments = [] for block in re.split(r"\n\s*\n", content): lines = [line.strip("\ufeff") for line in block.splitlines() if line.strip()] if not lines: continue if "-->" not in lines[0] and len(lines) > 1 and "-->" in lines[1]: lines = lines[1:] if not lines or "-->" not in lines[0]: continue start_raw, end_raw = [part.strip() for part in lines[0].split("-->", 1)] text = "\n".join(lines[1:]).strip() segments.append( { "start": _parse_srt_timestamp(start_raw), "end": _parse_srt_timestamp(end_raw), "text": text, } ) duration = segments[-1]["end"] if segments else 0.0 return {"duration": duration, "segments": segments} def _resolve_device() -> str: if LOCAL_WHISPER_DEVICE != "auto": return LOCAL_WHISPER_DEVICE try: import torch # type: ignore if torch.cuda.is_available(): # Test that CUDA actually works (catches missing cuBLAS/cuDNN libs) torch.zeros(1, device="cuda") return "cuda" except (ImportError, OSError, RuntimeError): pass return "cpu" def transcribe_local(media_path: str, language: Optional[str] = None) -> Dict: """Run faster-whisper on a local file path, caching the result as .srt.""" cache_path = _transcript_cache_path(media_path) if cache_path.exists(): source_mtime = os.path.getmtime(media_path) cache_mtime = cache_path.stat().st_mtime if cache_mtime >= source_mtime: print(f"[transcribe/local] reusing cached transcript: {cache_path}", flush=True) cached = _load_srt_cache(cache_path) # Treat empty cache as invalid (likely from a failed/partial run) — delete and re-transcribe if not cached["segments"] or cached["duration"] <= 0.0: print(f"[transcribe/local] cache is empty/invalid, deleting: {cache_path}", flush=True) cache_path.unlink(missing_ok=True) else: print( f"[transcribe/local] {len(cached['segments'])} cached segments, " f"{cached['duration']:.0f}s of audio", flush=True, ) return cached try: from faster_whisper import WhisperModel # type: ignore except ImportError as e: raise RuntimeError( "faster-whisper is required for --mode local. Install it with:\n" " pip install -r requirements-local.txt" ) from e device = _resolve_device() compute_type = "float16" if device == "cuda" else "int8" print(f"[transcribe/local] faster-whisper model={LOCAL_WHISPER_MODEL} device={device}", flush=True) from ..config import LOCAL_WHISPER_VAD_FILTER, LOCAL_WHISPER_VAD_PARAMETERS model = WhisperModel(LOCAL_WHISPER_MODEL, device=device, compute_type=compute_type) transcribe_kwargs = { "audio": media_path, "language": language, "beam_size": 5, "condition_on_previous_text": False, } if LOCAL_WHISPER_VAD_FILTER: transcribe_kwargs["vad_filter"] = True transcribe_kwargs["vad_parameters"] = LOCAL_WHISPER_VAD_PARAMETERS else: transcribe_kwargs["vad_filter"] = False segments_iter, info = model.transcribe(**transcribe_kwargs) segments = [] for s in segments_iter: segments.append({ "start": float(s.start), "end": float(s.end), "text": (s.text or "").strip(), }) duration = float(getattr(info, "duration", 0.0)) or (segments[-1]["end"] if segments else 0.0) print(f"[transcribe/local] {len(segments)} segments, {duration:.0f}s of audio", flush=True) transcript = {"duration": duration, "segments": segments} cache_path = _write_srt_cache(media_path, transcript) print(f"[transcribe/local] wrote cache: {cache_path}", flush=True) return transcript