Standalone Shonar Desktop: vendor portable sources + local engine; decouple from ~/Projects/Shonar
- 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
This commit is contained in:
commit
76c867fca4
136 changed files with 21099 additions and 0 deletions
108
backend/shonar/services/ai/faster_whisper.py
Normal file
108
backend/shonar/services/ai/faster_whisper.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue