8000 fix(transcribe): fall back when an engine won't import instead of failing the job by adeelahmad · Pull Request #58 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ docker compose -f docker-compose.prod.yml logs backend | grep "setup code"
* **Backups** of SurrealDB (`surreal export`) and of the `archive-data` volume.
* **Mail** (`MAIL_*`) for password resets.
* **Workers.** Scale with `docker compose up -d --scale worker=3`. The images transcribe with faster-whisper on the
CPU (SenseVoice, the default engine, is used where it is installed). For GPU transcription, build with
CPU (SenseVoice, the default engine, is used where it is installed; elsewhere the job log notes the fallback). To
use SenseVoice in Docker, set `EXTRAS=sensevoice` in `.env` and rebuild (`make dev`); it adds PyTorch. For GPU transcription, build with
`EXTRAS="sensevoice voices"` and give the worker the GPU; or run workers on other machines with `SURREAL_URL`
pointing at the database and `--steps` limited to what they can do.
* **Documents.** The default image reads PDFs and images. To read Word, PowerPoint and spreadsheet files, text,
Expand Down
4 changes: 3 additions & 1 deletion docs/processing.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ How recordings move through Lens: where they come from, what each step does, and
- `scan` finds audio under each namespace's paths and fingerprints it: moved files keep their history, duplicates are skipped,
and so are files whose recording someone deleted or moved to another namespace (at the same path, or a copy of the same
file).
- `transcribe` uses SenseVoice, faster-whisper or mlx-whisper. A file that fails is marked and the batch carries on. For a
- `transcribe` uses SenseVoice, faster-whisper or mlx-whisper. When the configured engine isn't installed on a worker (or
doesn't import there), it uses the next one that is and says so in the job log; with none at all the step fails,
saying how to add one. A file that fails is marked and the batch carries on. For a
document or an image it draws the pages and reads their text instead: a PDF's own text, and OCR for scans and images
([Documents and images](configuration.md#documents-and-images)).
- `diarize` splits genuinely two-channel files by channel, otherwise clusters voice embeddings (or uses pyannote), then
Expand Down
46 changes: 34 additions & 12 deletions fastapi_backend/app/domain/ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ def __init__(self, cfg):
try:
from funasr import AutoModel
except ImportError as e:
raise SystemExit("SenseVoice needs FunASR: uv sync --extra sensevoice") from e
raise EngineMissing("SenseVoice needs FunASR: uv sync --extra sensevoice, or EXTRAS=sensevoice for the Docker images") from e
dev = pick_device(t["device"])
kw = {"disable_update": True, "device": "cpu" if dev == "mps" else dev}
if c.get("hub") == "hf":
Expand Down Expand Up @@ -306,7 +306,8 @@ def __init__(self, cfg, mlx=False):
t["whisper"]["model"], device="cpu" if dev == "mps" else dev, compute_type=t["whisper"]["compute_type"]
)
except ImportError as e:
raise SystemExit(f"uv sync --extra {'mlx' if mlx else 'whisper'}") from e
name = "mlx-whisper" if mlx else "faster-whisper"
raise EngineMissing(f"{name} isn't installed: uv sync --extra {'mlx' if mlx else 'whisper'}") from e

def transcribe(self, audio):
if self.mlx:
Expand Down Expand Up @@ -334,27 +335,48 @@ def transcribe(self, audio):
ENGINE_MODULES = {"sensevoice": "funasr", "mlx-whisper": "mlx_whisper", "whisper": "faster_whisper"} # the order to fall back in


class EngineMissing(ValueError):
"""A speech-to-text engine's packages aren't installed (or don't import) on this worker."""


def installed(engine):
return importlib.util.find_spec(ENGINE_MODULES[engine]) is not None


def get_engine(cfg, log=None):
"""The configured engine; when it isn't installed here, the first one that is (the Docker images and packages
carry faster-whisper, not SenseVoice, the default), so an import is transcribed rather than failing."""
e = cfg["transcribe"]["engine"]
if e in ENGINE_MODULES and not installed(e):
other = next((x for x in ENGINE_MODULES if installed(x)), None)
if other:
if log:
log(f" {e} isn't installed on this worker; transcribing with {other}")
e = other
def _make_engine(cfg, e):
if e == "sensevoice":
return SenseVoice(cfg)
if e in ("whisper", "mlx-whisper"):
return Whisper(cfg, mlx=e == "mlx-whisper")
raise SystemExit(f"unknown transcribe.engine {e!r}")


def get_engine(cfg, log=None):
"""The configured engine; when it isn't installed here (or its packages are there but don't import, e.g. FunASR
without PyTorch), the first one that is (the Docker images and packages carry faster-whisper, not SenseVoice, the
default), so an import is transcribed rather than failing. With none at all, says how to add one."""
e = cfg["transcribe"]["engine"]
if e not in ENGINE_MODULES:
return _make_engine(cfg, e)
why = {}
for x in [e] + [x for x in ENGINE_MODULES if x != e]:
if x != e and not installed(x):
continue
try:
engine = _make_engine(cfg, x)
except EngineMissing as err:
why[x] = str(err)
continue
if x != e and log:
log(f" {e} isn't installed on this worker ({why[e]}); transcribing with {x}")
return engine
raise EngineMissing(
f"no speech-to-text engine is installed on this worker ({why[e]}). In Docker, rebuild the images "
"(make dev, or docker compose up --build --renew-anon-volumes): they carry faster-whisper, and "
"EXTRAS=sensevoice adds SenseVoice. Elsewhere: uv sync --extra whisper (or --extra sensevoice)"
)


def segment_rows(rid, nid, segs):
out = []
for i, s in enumerate(segs):
Expand Down
46 changes: 43 additions & 3 deletions fastapi_backend/tests/domain/test_importing.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,52 @@ def test_paste_and_chunk_stitching(db, cfg):
def test_an_engine_that_isnt_installed_falls_back_to_one_that_is(monkeypatch):
"""The Docker images carry faster-whisper, not SenseVoice (the default engine): imports are transcribed anyway."""
made = []

def sensevoice(cfg):
raise ingest.EngineMissing("SenseVoice needs FunASR")

monkeypatch.setattr(ingest, "installed", lambda e: e == "whisper")
monkeypatch.setattr(ingest, "SenseVoice", sensevoice)
monkeypatch.setattr(ingest, "Whisper", lambda cfg, mlx=False: made.append(mlx) or "whisper engine")
said = []
cfg = {"transcribe": {"engine": "sensevoice", "sensevoice": {}}}
assert ingest.get_engine(cfg, said.append) == "whisper engine" and made == [False]
assert said == [" sensevoice isn't installed on this worker; transcribing with whisper"]
monkeypatch.setattr(ingest, "installed", lambda e: False) # nothing installed: the configured engine says what it needs
with pytest.raises(SystemExit, match="FunASR"):
assert said == [" sensevoice isn't installed on this worker (SenseVoice needs FunASR); transcribing with whisper"]


def test_an_engine_that_is_there_but_wont_import_falls_back_too(monkeypatch):
"""FunASR without PyTorch is found but doesn't import: the job goes on with whisper rather than failing."""
monkeypatch.setattr(ingest, "installed", lambda e: True)

def sensevoice(cfg):
raise ingest.EngineMissing("SenseVoice needs FunASR")

def whisper(cfg, mlx=False):
if mlx:
raise ingest.EngineMissing("mlx-whisper isn't installed")
return "whisper engine"

monkeypatch.setattr(ingest, "SenseVoice", sensevoice)
monkeypatch.setattr(ingest, "Whisper", whisper)
said = []
assert ingest.get_engine({"transcribe": {"engine": "sensevoice"}}, said.append) == "whisper engine"
assert said == [" sensevoice isn't installed on this worker (SenseVoice needs FunASR); transcribing with whisper"]


def test_with_no_engine_at_all_the_error_says_how_to_add_one(monkeypatch):
monkeypatch.setattr(ingest, "installed", lambda e: False)
monkeypatch.setattr(ingest.importlib.util, "find_spec", lambda name: None)
import builtins

real = builtins.__import__

def no_engines(name, *a, **k):
if name.split(".")[0] in ("funasr", "faster_whisper", "mlx_whisper"):
raise ImportError(name)
return real(name, *a, **k)

monkeypatch.setattr(builtins, "__import__", no_engines)
cfg = {"transcribe": {"engine": "sensevoice", "device": "cpu", "sensevoice": {}}}
with pytest.raises(ingest.EngineMissing, match=r"no speech-to-text engine.*FunASR.*make dev") as e:
ingest.get_engine(cfg)
assert not isinstance(e.value, SystemExit) # a job records it as a plain error, not "SystemExit: ..."
Loading
0