diff options
Diffstat (limited to 'app/converter/clients/transcribe.py')
| -rw-r--r-- | app/converter/clients/transcribe.py | 147 |
1 files changed, 122 insertions, 25 deletions
diff --git a/app/converter/clients/transcribe.py b/app/converter/clients/transcribe.py index d2db9f1..28c4907 100644 --- a/app/converter/clients/transcribe.py +++ b/app/converter/clients/transcribe.py @@ -1,39 +1,115 @@ """Optional local Whisper transcription of reference audio.""" import logging -from typing import Optional +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 transcribe_reference_audio(audio_path: str, model_name: str = "base") -> Optional[str]: - """Transcribe reference audio locally using an optional Whisper backend. - The current qwen-tts demo does not expose a transcription endpoint, so - transcription is done client-side when a Whisper package is available. - Returns None if no backend is installed. +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. """ - for backend in ("faster_whisper", "whisper"): - try: - if backend == "faster_whisper": + 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 - model = WhisperModel(model_name, device="cpu", compute_type="int8") - segments, _ = model.transcribe(audio_path) - text = " ".join(seg.text.strip() for seg in segments).strip() - else: + 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 - model = whisper.load_model(model_name) - result = model.transcribe(audio_path) - text = (result.get("text") or "").strip() - if text: - logger.info("Transcription complete via %s: %s", backend, text) - return text - except ImportError: - continue - except Exception as exc: - logger.warning("%s transcription failed: %s", backend, exc) - logger.warning("No Whisper backend available; transcription skipped.") - return None + 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]: @@ -52,3 +128,24 @@ def whisper_backend_available() -> Optional[str]: 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: <error>' — 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) |
