Files
voculinux/tests/test_installer_engine_config.py
T
Jatin K MalikandGitHub cdfe541c25 feat(speech_recognition): add faster-whisper engine backend (#543)
* feat(speech_recognition): add EngineType enum and Engine Protocol

Introduce a typed EngineType enum and an Engine Protocol in common_types.py,
plus an engines/ registry so new speech recognition backends can be plugged in
without scattering string comparisons across the manager.

* feat(speech_recognition): add faster-whisper engine backend

Add a faster-whisper engine implementation using CTranslate2/INT8 for fast
CPU inference. Includes model metadata utility, wiring through the recognition
manager, CLI, settings UI, config, optional dependency, and unit tests. Also
updates the suspend-handler test fixture for the new engine attribute.

* style(ui): format faster-whisper imports for isort 8

* test(speech_recognition): expand faster-whisper coverage for codecov

* test(speech_recognition): fix failing faster-whisper coverage tests

- Import missing get_compute_type helper

- Add tests for English-only detection, cache exceptions, and import-error registry path

- Clear _has_torch_cuda lru_cache around exception test to avoid cached True

- Replace broad builtins.__import__ patch with a fake engine module for registry test

- Restore registry module state after test

* test(speech_recognition): add coverage for faster-whisper edge cases

Cover previously missing lines in:

- faster_whisper_model_info.py: HF cache path/none, hf_hub lookup, fallback traversal, torch CUDA true, psutil exception

- faster_whisper_engine.py: model None guard after is_ready()

- engine registry import-error path

* feat(install): add faster_whisper engine option to install.sh

- Add faster_whisper to --engine help text and examples

- Add faster-whisper as interactive option 5 with description

- Install faster-whisper package and verify import at install time

- Add fallback to whisper.cpp if faster-whisper installation fails

- Add smoke test for faster-whisper import

- Update welcome message engine display name

- Note: faster-whisper models download automatically on first use

* refactor(faster_whisper): apply ponytail review cleanups

- Inline FASTER_WHISPER_MODEL_INFO dict literal; drop unused AVAILABLE_MODELS/MODEL_SIZES

- Reuse _has_torch_cuda() in FasterWhisperEngine.device property

- Remove redundant model None guard after is_ready() verification

- Shrink faster-whisper default config in install.sh to engine + model_size

- Update device tests to manage _has_torch_cuda lru_cache

- Remove test for deleted dead-code guard

* fix(faster_whisper): integrate optional engine with current main

Keep whisper_cpp as default and Parakeet as an extra. Put engine selection
in the Advanced island, install faster-whisper via the optional extra, and
refuse unpinned Hugging Face downloads until model_checksums.txt pins them.

* fix(faster_whisper): download verified models and refresh uv.lock

Settings already opened a download dialog for uncached faster-whisper, but
init only raised ChecksumError. Wire a Parakeet-style downloader that stores
bundles under models_dir, verifies each file against model_checksums.txt, and
loads WhisperModel from that path. Regenerate uv.lock for the optional
faster-whisper extra, pin digests for all eleven Systran models, and add the
missing -> None return annotations.

* fix(faster_whisper): clear engine on unload and fix auto-pause fixture

Auto-pause tests stub __init__, so they never set _faster_whisper_engine.
reinitialize_after_resume then AttributeErrors once Test Python runs.
Mirror the suspend-handler fixture, and release the faster-whisper engine
inside unload_model the same way resume already does.

* fix(faster_whisper): restore previous engine after a failed switch

reconfigure() selected the new engine and released the live model before
init. A failed faster-whisper download then left the manager in ERROR on
the new backend while Settings reverted the pickers to the saved engine,
so dictation stayed dead until restart. Reload the previous engine from
disk (no download) before re-raising so the UI and live manager agree.

* fix(speech_recognition): restore all live settings after failed switch

Failed reconfigure already put the previous engine back, but VAD, device,
remote API, and whisper.cpp knobs stayed on the attempted values while
config on disk did not. Snapshot every field reconfigure writes and restore
the lot before reloading the old backend. Also bind the real
_init_selected_engine onto the MagicMock in the force_reinit test.

* fix(speech_recognition): restore every live field after failed reconfigure

A failed engine switch already reloaded the previous backend, but Greptile
could still miss that VAD, audio device, remote API, and whisper.cpp knobs
were part of the snapshot. Keep one explicit attr list for snapshot/restore,
including the derived voice-commands flag, so live state matches persisted
config after a failed Settings apply.

* chore(speech_recognition): sign tip after rollback follow-up
2026-09-07 20:47:56 -07:00

192 lines
6.1 KiB
Python

"""Behavioral tests for the installer's speech-engine sanity check.
config.json survives reinstalls, so it can name an engine the current venv
cannot import — vosk is an optional extra since #705. Vocalinux then dies at
startup with "No module named 'vosk'" instead of the installer saying so.
"""
import json
import subprocess
from pathlib import Path
INSTALL_SH = Path(__file__).resolve().parents[1] / "install.sh"
PRELUDE = """
set -uo pipefail
print_info() { echo "INFO: $*"; }
print_warning() { echo "WARNING: $*"; }
print_error() { echo "ERROR: $*"; }
print_success() { echo "SUCCESS: $*"; }
"""
def _run(script: str) -> subprocess.CompletedProcess:
return subprocess.run(
["bash", "-c", PRELUDE + script], capture_output=True, text=True, timeout=30
)
def _venv_python(venv_dir: Path, importable: "set[str] | None" = None) -> Path:
"""A stub venv interpreter: real python3, with chosen imports forced.
``importable`` names the engine modules that succeed; every other engine
module fails. Anything else (the json probes) runs on the real interpreter.
"""
modules = {"vosk", "whisper", "pywhispercpp.model", "sherpa_onnx", "faster_whisper"}
ok = importable if importable is not None else set()
python = venv_dir / "bin" / "python"
python.parent.mkdir(parents=True, exist_ok=True)
branches = "".join(
'if [[ "$*" == *"import {module}"* ]]; then\n'
" exit {code}\n"
"fi\n".format(module=module, code=0 if module in ok else 1)
for module in sorted(modules, key=len, reverse=True)
)
python.write_text("#!/bin/bash\n" + branches + 'exec python3 "$@"\n')
python.chmod(0o755)
return python
def _engine_of(config_file: Path) -> str:
return json.loads(config_file.read_text())["speech_recognition"]["engine"]
def _config(config_dir: Path, engine: str) -> Path:
config_dir.mkdir(parents=True, exist_ok=True)
path = config_dir / "config.json"
path.write_text(json.dumps({"speech_recognition": {"engine": engine}}))
return path
def _check(
config_dir: Path, venv_dir: Path, selected_engine: str = ""
) -> subprocess.CompletedProcess:
script = f"""
CONFIG_DIR="{config_dir}"
VENV_DIR="{venv_dir}"
SELECTED_ENGINE="{selected_engine}"
for fn in engine_import_module engine_pip_name venv_can_import set_configured_engine verify_configured_engine; do
source <(sed -n "/^$fn() {{$/,/^}}$/p" "{INSTALL_SH}")
done
verify_configured_engine
echo "EXIT=$?"
"""
return _run(script)
def test_repairs_a_config_naming_an_uninstallable_engine(tmp_path):
"""Don't leave a config the app cannot start with."""
venv = tmp_path / "venv"
_venv_python(venv, importable={"pywhispercpp.model"})
config = _config(tmp_path / "config", "vosk")
result = _check(tmp_path / "config", venv)
assert "EXIT=0" in result.stdout
assert _engine_of(config) == "whisper_cpp"
assert "Switched" in result.stdout
def test_repairs_a_leftover_whisper_engine_too(tmp_path):
"""The check was vosk-only; whisper is an optional extra as well."""
venv = tmp_path / "venv"
_venv_python(venv, importable={"pywhispercpp.model"})
config = _config(tmp_path / "config", "whisper")
result = _check(tmp_path / "config", venv)
assert "EXIT=0" in result.stdout
assert _engine_of(config) == "whisper_cpp"
def test_fails_when_no_engine_works(tmp_path):
"""Nothing to fall back to: the install must not report success."""
venv = tmp_path / "venv"
_venv_python(venv, importable=set())
config = _config(tmp_path / "config", "vosk")
result = _check(tmp_path / "config", venv)
assert "EXIT=1" in result.stdout
assert "cannot start" in result.stdout
# The config is left alone so the user can see what it asked for.
assert _engine_of(config) == "vosk"
def test_leaves_a_working_engine_alone(tmp_path):
venv = tmp_path / "venv"
_venv_python(venv, importable={"vosk"})
config = _config(tmp_path / "config", "vosk")
result = _check(tmp_path / "config", venv)
assert "EXIT=0" in result.stdout
assert _engine_of(config) == "vosk"
assert "WARNING" not in result.stdout
def test_ignores_engines_that_need_no_extra(tmp_path):
venv = tmp_path / "venv"
_venv_python(venv, importable=set())
config = _config(tmp_path / "config", "remote_api")
result = _check(tmp_path / "config", venv)
assert "EXIT=0" in result.stdout
assert _engine_of(config) == "remote_api"
def test_silent_without_a_config_file(tmp_path):
venv = tmp_path / "venv"
_venv_python(venv, importable=set())
result = _check(tmp_path / "config", venv)
assert "EXIT=0" in result.stdout
def test_repairs_whisper_cpp_to_vosk_when_that_is_what_works(tmp_path):
"""Reinstall: config still says whisper_cpp, pywhispercpp is gone, vosk is in."""
venv = tmp_path / "venv"
_venv_python(venv, importable={"vosk"})
config = _config(tmp_path / "config", "whisper_cpp")
result = _check(tmp_path / "config", venv)
assert "EXIT=0" in result.stdout
assert _engine_of(config) == "vosk"
assert "Switched" in result.stdout
def test_honors_selected_engine_ahead_of_the_default_fallback(tmp_path):
"""SELECTED_ENGINE is tried before the default vosk/whisper extras."""
venv = tmp_path / "venv"
_venv_python(venv, importable={"vosk", "whisper"})
config = _config(tmp_path / "config", "whisper_cpp")
result = _check(tmp_path / "config", venv, selected_engine="whisper")
assert "EXIT=0" in result.stdout
assert _engine_of(config) == "whisper"
def test_vosk_fallback_rewrites_an_existing_config_file():
"""The fallback used to write vosk only when config.json was missing."""
text = INSTALL_SH.read_text(encoding="utf-8")
assert 'set_configured_engine "$FALLBACK_VOSK_CONFIG" "vosk"' in text
assert '[ ! -f "$FALLBACK_VOSK_CONFIG" ]' not in text
def test_unreadable_config_is_not_treated_as_an_engine(tmp_path):
venv = tmp_path / "venv"
_venv_python(venv, importable=set())
config_dir = tmp_path / "config"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text("{not json")
result = _check(config_dir, venv)
assert "EXIT=0" in result.stdout