Package the speech recogniser and its model for apt

No speech recogniser is in Debian, so installing bandsaunter from a .deb
left transcription to a manual pip step on every machine. A repository of
one's own is not bound by archive policy, so build-repo.sh now packages
faster-whisper and the base.en model alongside the application:

  bandsaunter                the application (Architecture: all)
  bandsaunter-transcribe     faster-whisper, vendored (amd64)
  bandsaunter-model-base-en  the model, so nothing reaches the network

The wheels land in /usr/lib/bandsaunter/vendor rather than dist-packages,
and transcribe.py appends that directory to sys.path -- appends, so an
apt-managed numpy or PyYAML still wins and the vendor copy only fills the
gap. Duplicates of what Debian already ships are stripped from the tree.
resolve_model() turns a bare "base.en" into the packaged copy when one is
installed, and leaves it alone to be downloaded when none is.

The app package recommends the other two, so "apt install bandsaunter"
brings the lot and --no-install-recommends still gets just the scanner.
Its postinst explains how to add a recogniser only when there genuinely
is not one -- including the case where apt has already unpacked the
recogniser package but not yet configured it.

Verified with the source tree hidden and no home directory: the packaged
CLI runs, and a real recording transcribes offline from the vendored
engine and packaged model while numpy still resolves to the system one.
apt itself resolves the repository over HTTP and plans all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
The Dust Council 2026-08-21 22:12:24 -07:00
parent 16f3128690
commit 8d94a52942
5 changed files with 304 additions and 22 deletions

View file

@ -1,4 +1,5 @@
"""Speech to text: the engine plumbing, and how captures reach it."""
import sys
import time
from datetime import datetime
from pathlib import Path
@ -376,3 +377,50 @@ def test_vosk_accepts_the_plain_language_code():
result = tr.transcribe(say("one two three", 16000), 16000, engine="vosk",
language="en")
assert result is not None and not result.note, result.note
# --- packaged engine and models -------------------------------------------
def test_a_packaged_model_is_used_in_place_of_its_name(tmp_path, monkeypatch):
""""base.en" should mean the copy on this machine when one is installed."""
monkeypatch.setattr(tr, "MODEL_DIR", tmp_path)
(tmp_path / "base.en").mkdir()
assert tr.resolve_model("base.en") == str(tmp_path / "base.en")
def test_an_unpackaged_model_keeps_its_name_to_be_downloaded(tmp_path, monkeypatch):
monkeypatch.setattr(tr, "MODEL_DIR", tmp_path)
assert tr.resolve_model("small.en") == "small.en"
assert tr.resolve_model("") == ""
def test_an_explicit_model_directory_wins_over_the_packaged_one(tmp_path, monkeypatch):
packaged = tmp_path / "packaged"
(packaged / "base.en").mkdir(parents=True)
monkeypatch.setattr(tr, "MODEL_DIR", packaged)
mine = tmp_path / "base.en"
mine.mkdir()
assert tr.resolve_model(str(mine)) == str(mine)
def test_the_vendored_engine_is_searched_after_the_system_one(tmp_path, monkeypatch):
"""Anything apt provides must still win; the vendor copy fills a gap."""
monkeypatch.setattr(sys, "path", list(sys.path))
monkeypatch.setattr(tr, "VENDOR_DIR", tmp_path)
tr._add_vendor_path()
assert sys.path[-1] == str(tmp_path)
def test_the_vendor_path_is_added_once(tmp_path, monkeypatch):
monkeypatch.setattr(sys, "path", list(sys.path))
monkeypatch.setattr(tr, "VENDOR_DIR", tmp_path)
tr._add_vendor_path()
tr._add_vendor_path()
assert sys.path.count(str(tmp_path)) == 1
def test_a_missing_vendor_directory_is_not_added(tmp_path, monkeypatch):
monkeypatch.setattr(sys, "path", list(sys.path))
monkeypatch.setattr(tr, "VENDOR_DIR", tmp_path / "absent")
tr._add_vendor_path()
assert str(tmp_path / "absent") not in sys.path