aboutsummaryrefslogtreecommitdiff
path: root/app/converter/clients/transcribe.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/converter/clients/transcribe.py')
-rw-r--r--app/converter/clients/transcribe.py147
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)