"""Forced-alignment fallback for word timings. When a TTS provider returns no word timings, we try faster-whisper (if installed) to recover them from the rendered audio. faster-whisper is an OPTIONAL dependency — when missing or failing, return None and the render stage degrades to static (per-scene) captions instead of karaoke. """ from __future__ import annotations from typing import Optional from core.contracts import WordTiming from core.utils import log _model = None _model_failed = False def align_words(audio_path: str, text: str) -> Optional[list[WordTiming]]: """Best-effort word timings for ``audio_path``. None on any failure.""" global _model, _model_failed if _model_failed: return None try: if _model is None: from faster_whisper import WhisperModel # type: ignore _model = WhisperModel("tiny", device="cpu", compute_type="int8") segments, _info = _model.transcribe(audio_path, word_timestamps=True) timings: list[WordTiming] = [] for seg in segments: for w in seg.words or []: timings.append(WordTiming(word=w.word.strip(), start=w.start, end=w.end)) return timings or None except ImportError: log.info("faster-whisper not installed — captions will be static (pip install faster-whisper to enable karaoke alignment)") _model_failed = True return None except Exception as e: log.warning("Forced alignment failed (%s) — falling back to static captions", e) return None