- shared/ = portable Android-origin sources vendored from deferred/desktop-server (app/build.gradle.kts srcDir repointed; PlaybackController.kt excluded as Android-only) - backend/ = bundled-lite engine (SQLite + inline queue); .venv symlinked from the old checkout, PYTHONPATH pins THIS backend's code over any editable install - repoRoot() resolves this project dir (env SHONAR_REPO still wins); desktop-dev.sh watches shared/ + backend/ - Verified: :app:compileKotlin + :app:test green (23 tests); engine boots on :8010, self-migrates, /healthz ok
108 lines
3.7 KiB
Python
108 lines
3.7 KiB
Python
"""Local transcription via faster-whisper (optional dependency).
|
|
|
|
Runs fully on this machine: audio never leaves the server for this stage.
|
|
The import is lazy so the base install (and every test run) works without
|
|
the heavyweight dependency.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import tempfile
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
from shonar.services.ai import ProviderConfigError, Segment, TranscriptResult
|
|
from shonar.services.ai.model_registry import (
|
|
download_instructions,
|
|
is_model_downloaded,
|
|
validate_model_name,
|
|
)
|
|
|
|
# One loaded model per name, shared across jobs in this worker process.
|
|
# ctranslate2 inference is thread-safe; a lock serializes first-load only.
|
|
_MODEL_CACHE: dict[str, object] = {}
|
|
_MODEL_CACHE_LOCK = threading.Lock()
|
|
|
|
|
|
def _load_model(name: str) -> object:
|
|
from faster_whisper import WhisperModel
|
|
|
|
with _MODEL_CACHE_LOCK:
|
|
model = _MODEL_CACHE.get(name)
|
|
if model is None:
|
|
model = WhisperModel(name, device="auto")
|
|
_MODEL_CACHE[name] = model
|
|
return model
|
|
|
|
|
|
def ensure_model_available(name: str) -> None:
|
|
"""Fail fast with download instructions instead of triggering a surprise
|
|
multi-GB download inside a transcription job."""
|
|
if not is_model_downloaded(name):
|
|
raise ProviderConfigError(download_instructions(name))
|
|
|
|
|
|
class FasterWhisperProvider:
|
|
name = "faster_whisper"
|
|
|
|
def __init__(self, model: str = "base") -> None:
|
|
try:
|
|
import faster_whisper # noqa: F401
|
|
except ImportError as e:
|
|
raise ProviderConfigError(
|
|
"faster-whisper is not installed (pip install shonar-backend[faster-whisper])."
|
|
) from e
|
|
self.model = validate_model_name(model or "base")
|
|
|
|
async def transcribe(
|
|
self,
|
|
audio: bytes,
|
|
mime: str,
|
|
*,
|
|
language_hint: str | None = None,
|
|
on_progress=None, # Callable[[int], None] | None — 0..99 percent
|
|
) -> TranscriptResult:
|
|
# faster-whisper is blocking CPU work: keep it off the event loop.
|
|
return await asyncio.to_thread(self._run, audio, language_hint, on_progress)
|
|
|
|
def _run(
|
|
self,
|
|
audio: bytes,
|
|
language_hint: str | None,
|
|
on_progress=None,
|
|
) -> TranscriptResult:
|
|
path: Path | None = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(suffix=".m4a", delete=False) as f:
|
|
f.write(audio)
|
|
path = Path(f.name)
|
|
ensure_model_available(self.model)
|
|
model = _load_model(self.model)
|
|
segments_iter, info = model.transcribe( # type: ignore[union-attr]
|
|
str(path),
|
|
beam_size=5,
|
|
language=language_hint,
|
|
)
|
|
duration = float(getattr(info, "duration", 0.0) or 0.0)
|
|
segments = []
|
|
last_pct = -1
|
|
for s in segments_iter:
|
|
segments.append(Segment(start=s.start, end=s.end, text=s.text.strip()))
|
|
if on_progress is not None and duration > 0:
|
|
# Throttle: report only on whole-percent gains. Capped
|
|
# at 99 — the caller commits 100 when the row finishes.
|
|
pct = min(99, int(s.end / duration * 100))
|
|
if pct > last_pct:
|
|
last_pct = pct
|
|
on_progress(pct)
|
|
text = " ".join(s.text for s in segments).strip()
|
|
return TranscriptResult(
|
|
text=text,
|
|
language=getattr(info, "language", None),
|
|
segments=segments,
|
|
model=self.model,
|
|
)
|
|
finally:
|
|
if path is not None:
|
|
path.unlink(missing_ok=True)
|