"""Optional local Whisper transcription of reference audio.""" import logging import threading from typing import Dict, Optional, Tuple logger = logging.getLogger(__name__) # One loaded Whisper model per (backend, model size), reused across calls. # The audio.cpp setup transcribes every reference wav in one run; loading # the model once instead of per file saves seconds per voice. The lock # keeps a TUI lane and a concurrent conversion from racing the load. _MODEL_LOCK = threading.Lock() _MODELS: Dict[Tuple[str, str], object] = {} def _cached_model(key: Tuple[str, str], loader): """The model for KEY, loaded by LOADER() on first use and cached.""" with _MODEL_LOCK: model = _MODELS.get(key) if model is None: model = loader() _MODELS[key] = model return model def _import_failure_reason(backend: str, exc: ImportError) -> str: """Why importing BACKEND failed: missing package or broken install. A backend whose own compiled dependency fails to load raises the same ImportError as a missing package (e.g. faster-whisper whose av build cannot load its shared libraries); telling the two apart turns 'was it even installed?' into the actual fix. A ModuleNotFoundError naming the backend itself is a plain missing package; anything else is checked against the installed distributions. """ if isinstance(exc, ModuleNotFoundError) \ and getattr(exc, "name", "") == backend: return f"{backend} is not installed" try: import importlib.util present = importlib.util.find_spec(backend) is not None except (ImportError, ValueError): present = True if not present: return f"{backend} is not installed" return f"{backend} is installed but failed to import: {exc}" def _transcribe_with(backend: str, audio_path: str, model_name: str) -> Tuple[Optional[str], str]: """One transcription attempt with BACKEND; returns (text, reason). TEXT is the transcript, or None on any failure; REASON then explains why in one human-readable line (missing package, broken import, model or transcribe error, or an empty result), so callers can surface the cause instead of a bare 'no transcript'. """ try: if backend == "faster_whisper": def load(): from faster_whisper import WhisperModel return WhisperModel(model_name, device="cpu", compute_type="int8") model = _cached_model(("faster_whisper", model_name), load) segments, _ = model.transcribe(audio_path) text = " ".join(seg.text.strip() for seg in segments).strip() else: def load(): import whisper return whisper.load_model(model_name) model = _cached_model(("whisper", model_name), load) result = model.transcribe(audio_path) text = (result.get("text") or "").strip() except ImportError as exc: return None, _import_failure_reason(backend, exc) except Exception as exc: return None, f"{backend} transcription failed: {exc}" if not text: return None, f"{backend} heard no speech in this audio" logger.info("Transcription complete via %s: %s", backend, text) return text, "ok" def transcribe_reference_audio_detailed( audio_path: str, model_name: str = "base") -> Tuple[Optional[str], str]: """Transcribe one reference wav locally; returns (text, reason). The current qwen-tts demo does not expose a transcription endpoint, so transcription is done client-side when a Whisper backend is available. TEXT is the transcript, or None when no backend produced one; REASON is "ok" on success and otherwise explains the failure (tried backends in order), e.g. "faster_whisper is installed but failed to import: ...; whisper is not installed" — what the audio.cpp setup prints so a voice that cannot be transcribed is never a silent blank. """ reasons = [] for backend in ("faster_whisper", "whisper"): text, reason = _transcribe_with(backend, audio_path, model_name) if text: return text, "ok" reasons.append(reason) return None, "; ".join(reasons) def transcribe_reference_audio(audio_path: str, model_name: str = "base") -> Optional[str]: """Transcribe reference audio locally using an optional Whisper backend. Returns None if no backend is installed or transcription failed; transcribe_reference_audio_detailed also explains why. """ text, _ = transcribe_reference_audio_detailed(audio_path, model_name) return text def whisper_backend_available() -> Optional[str]: """Return the name of an importable Whisper backend, or None. Checks faster_whisper first (preferred), then the openai-whisper package, without importing the heavy model code: a bare import probe is enough to tell whether the package is installed in the current environment. Used by the make_audiocpp_server_json tool to warn when neither is present (e.g. the wrong conda environment is active). """ for backend in ("faster_whisper", "whisper"): try: __import__(backend) except ImportError: continue return backend return None def whisper_backend_problem() -> Optional[str]: """None when a Whisper backend is importable, else why none is usable. One line per backend in probe order, distinguishing 'not installed' from 'installed but failed to import: ' — the setup prints this before transcribing so a broken compiled dependency (which surfaces as the same ImportError as a missing package) is visible as such. """ problems = [] for backend in ("faster_whisper", "whisper"): try: __import__(backend) except ImportError as exc: problems.append(_import_failure_reason(backend, exc)) except Exception as exc: problems.append(f"{backend} failed to import: {exc}") else: return None return "; ".join(problems)