fix: leftover engine repair, just --no-sync, and ggml verify from #713 (#732)

* fix: leftover engine repair, just --no-sync, and ggml verify from #713

#713 only rewrote a broken config.json toward whisper_cpp, so a reinstall
that kept the old engine still died when vosk was the extra that installed.
verify_configured_engine now prefers SELECTED_ENGINE, then any working extra,
and the vosk fallback rewrites an existing file.

just recipes use uv run --no-sync so they do not prune extras from
just deps-all. CONTRIBUTING lists libgirepository-2.0-dev for Ubuntu 24.04.
An unverified ggml file is not loaded if unlink fails, and an unpinned name
is refused rather than deleted.

* fix(appimage): retry tooling downloads on curl reset

* fix(build): bootstrap .venv for --no-sync just recipes and cover the contributor path in CI

---------

Co-authored-by: Sergey Silaev <hello@8am.dev>
This commit is contained in:
Jatin K Malik
2026-08-24 10:42:52 -07:00
committed by GitHub
co-authored by Sergey Silaev
parent 582d494633
commit f43f9dcd72
12 changed files with 359 additions and 112 deletions
+42
View File
@@ -164,6 +164,48 @@ jobs:
files: coverage.xml
fail_ci_if_error: false
just-contributor-path:
name: 🧰 Contributor Path (just)
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [changes, python-lint]
if: needs.changes.outputs.python == 'true'
steps:
- uses: actions/checkout@v5
- name: Set up uv
uses: astral-sh/setup-uv@v6
with:
# Caches uv's download cache, not .venv/, so the checkout below stays
# the fresh clone this job exists to exercise.
enable-cache: true
- name: Install just
run: |
uv tool install rust-just
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libgirepository-2.0-dev \
libcairo2-dev \
pkg-config \
python3-dev \
portaudio19-dev \
gir1.2-gtk-3.0 \
gir1.2-appindicator3-0.1 \
xdotool
# The rest of the pipeline drives uv directly, so nothing here checks that
# the workflow CONTRIBUTING documents still works. `just deps` is skipped on
# purpose: running it first would populate .venv/ and hide exactly the
# breakage this job is for, where `uv run --no-sync` finds no interpreter to
# run the tooling with.
- name: just test, from a clean checkout
run: just test
appimage-smoke-test:
name: 📦 Build AppImage (smoke test, ${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
+2 -1
View File
@@ -37,7 +37,7 @@ Rules:
Two virtualenvs, on purpose:
- **`.venv/`** — uv's, for dev tooling. Every `just` recipe runs through `uv run`, so no activation is needed.
- **`.venv/`** — uv's, for dev tooling. `just deps` syncs it; every other Python `just` recipe uses `uv run --no-sync` so it does not prune extras that `just deps-all` installed. No activation needed.
- **`venv/`** — what `install.sh` builds for the app itself, from the *system* Python so distro `gi` is importable.
Never run `install.sh` from an activated `.venv`: it drops an inherited `VIRTUAL_ENV` from `PATH` and picks `$SYSTEM_PYTHON` (default `/usr/bin/python3`) precisely because a venv built from uv's interpreter cannot see distro PyGObject.
@@ -74,6 +74,7 @@ just typecheck # mypy src/
just test # pytest -v
just test-cov # pytest --cov=src --cov-report=html
just deps # sync .venv with dev+vad extras and the lint group
just deps-all # also whisper/vosk/docs; later recipes use --no-sync so they keep it
just lock # regenerate uv.lock + requirements/*.txt
just lock-check # fail if uv.lock is stale vs pyproject.toml
just model-checksums # refresh pinned model digests after adding a model
+17 -12
View File
@@ -70,13 +70,15 @@ This will:
2. **Install system dependencies:**
```bash
# Ubuntu
# Ubuntu 24.04+ (`just deps` pip-builds PyGObject 3.56 from the lock)
sudo apt update
sudo apt install -y python3-pip python3-gi python3-gi-cairo \
gir1.2-gtk-3.0 libgirepository1.0-dev \
gir1.2-gtk-3.0 libgirepository-2.0-dev libgirepository1.0-dev \
python3-dev portaudio19-dev python3-venv xdotool
# Debian 12+
# Debian 12 cannot pip-build PyGObject 3.56 (glib 2.74). Use Option 1
# (`./install.sh --dev`) for tests and running from source, then
# `venv/bin/pytest` / `venv/bin/python -m vocalinux.main --debug`.
sudo apt install -y python3-pip python3-gi python3-gi-cairo \
gir1.2-gtk-3.0 libgirepository1.0-dev libcairo2-dev \
python3-dev portaudio19-dev python3-venv xdotool
@@ -98,6 +100,9 @@ This will:
# Requires uv (https://docs.astral.sh/uv/); creates .venv/ with dev + vad extras
just deps
```
`just deps` compiles PyGObject from the lock into `.venv`. That needs
`libgirepository-2.0-dev` (Ubuntu 24.04+). Debian 12 cannot build it; use
Option 1 and the installer `venv/` instead.
4. **Run the application:**
```bash
@@ -106,7 +111,7 @@ This will:
5. **(Optional) Install pre-commit hooks:**
```bash
uv run --extra dev --extra vad --group lint pre-commit install
uv run --no-sync pre-commit install
```
> **Note:** Pre-commit hooks are optional. The CI pipeline runs the same checks, so you can skip this if you prefer faster local commits.
@@ -119,14 +124,14 @@ The repository uses two virtual environments on purpose — don't merge them:
| `.venv/` | `just deps` (uv) | pinned in `.python-version` | dev tooling: pytest, black, mypy, `just` recipes |
| `venv/` | `./install.sh` | the system Python | running the installed app, which needs distro PyGObject |
`gi` (PyGObject) is the reason. `uv sync` builds it from source into `.venv/`,
which works wherever glib 2.80+ and the `girepository` headers are present (Arch,
Fedora, recent CI runners) but fails on Ubuntu 24.04 and Debian — there the distro
package is the only option, and it is importable only from the system interpreter.
`install.sh` therefore never reuses `.venv/`: it builds `venv/` from the system
Python with `--system-site-packages`, and rebuilds it if another interpreter
created it. Set `SYSTEM_PYTHON=/usr/bin/python3.12 ./install.sh` on systems that
ship several system interpreters.
`gi` (PyGObject) is the reason. `just deps` / `uv sync` build it from source into
`.venv/`, which works wherever glib 2.80+ and `libgirepository-2.0-dev` are
present (Arch, Fedora, Ubuntu 24.04 with that package, CI). Debian 12 cannot
build PyGObject 3.56 at all; use `./install.sh --dev` and `venv/bin/pytest`.
`install.sh` never reuses `.venv/`: it builds `venv/` from the system Python with
`--system-site-packages`, and rebuilds it if another interpreter created it. Set
`SYSTEM_PYTHON=/usr/bin/python3.12 ./install.sh` on systems that ship several
system interpreters.
## Making Changes
+69 -52
View File
@@ -2897,6 +2897,48 @@ should_rebuild_whispercpp() {
return 1
}
# Module each engine needs, and the pip name that provides it. Defined here so
# the whisper.cpp -> vosk fallback can rewrite an existing config.json; the
# writers above used to skip a file that already existed.
engine_import_module() {
case "$1" in
vosk) echo "vosk" ;;
whisper) echo "whisper" ;;
whisper_cpp) echo "pywhispercpp.model" ;;
*) echo "" ;;
esac
}
engine_pip_name() {
case "$1" in
vosk) echo "vosk" ;;
whisper) echo "openai-whisper" ;;
whisper_cpp) echo "pywhispercpp" ;;
*) echo "" ;;
esac
}
venv_can_import() {
[ -x "$VENV_DIR/bin/python" ] || return 1
"$VENV_DIR/bin/python" -c "import $1" >/dev/null 2>&1
}
# Point config.json at engine $2. Non-zero if it could not be rewritten.
set_configured_engine() {
"$VENV_DIR/bin/python" - "$1" "$2" <<'PY' 2>/dev/null
import json
import sys
path, engine = sys.argv[1], sys.argv[2]
with open(path) as handle:
config = json.load(handle)
config.setdefault("speech_recognition", {})["engine"] = engine
with open(path, "w") as handle:
json.dump(config, handle, indent=2)
handle.write("\n")
PY
}
install_whispercpp_with_gpu_support() {
local PIP_LOG_FILE="$1"
@@ -3065,7 +3107,13 @@ install_whispercpp_with_gpu_support() {
fi
local FALLBACK_VOSK_CONFIG="$CONFIG_DIR/config.json"
if [ ! -f "$FALLBACK_VOSK_CONFIG" ]; then
if [ -f "$FALLBACK_VOSK_CONFIG" ]; then
if set_configured_engine "$FALLBACK_VOSK_CONFIG" "vosk"; then
print_success "Switched $FALLBACK_VOSK_CONFIG to the vosk engine."
else
print_warning "Could not rewrite $FALLBACK_VOSK_CONFIG to vosk."
fi
else
mkdir -p "$CONFIG_DIR"
cat > "$FALLBACK_VOSK_CONFIG" << 'FALLBACK_VOSK_CONFIG'
{
@@ -4286,51 +4334,10 @@ else
print_info "Models will be downloaded automatically on first application run"
fi
# config.json survives reinstalls — every writer above guards on "file does not
# exist" — so an engine picked by an earlier attempt can outlive the venv that
# supported it. Repair it here instead of letting the app die at startup with
# ModuleNotFoundError.
# Module each engine needs, and the pip name that provides it. whisper_cpp is
# the default engine and the fallback, so it is what a broken config is moved to.
engine_import_module() {
case "$1" in
vosk) echo "vosk" ;;
whisper) echo "whisper" ;;
whisper_cpp) echo "pywhispercpp.model" ;;
*) echo "" ;;
esac
}
engine_pip_name() {
case "$1" in
vosk) echo "vosk" ;;
whisper) echo "openai-whisper" ;;
whisper_cpp) echo "pywhispercpp" ;;
*) echo "" ;;
esac
}
venv_can_import() {
[ -x "$VENV_DIR/bin/python" ] || return 1
"$VENV_DIR/bin/python" -c "import $1" >/dev/null 2>&1
}
# Point config.json at engine $2. Non-zero if it could not be rewritten.
set_configured_engine() {
"$VENV_DIR/bin/python" - "$1" "$2" <<'PY' 2>/dev/null
import json
import sys
path, engine = sys.argv[1], sys.argv[2]
with open(path) as handle:
config = json.load(handle)
config.setdefault("speech_recognition", {})["engine"] = engine
with open(path, "w") as handle:
json.dump(config, handle, indent=2)
handle.write("\n")
PY
}
# config.json survives reinstalls, so an engine picked by an earlier attempt
# can outlive the venv that supported it. Repair it here instead of letting the
# app die at startup with ModuleNotFoundError. Prefer SELECTED_ENGINE when that
# extra is importable (the whisper.cpp -> vosk fallback), then any working extra.
verify_configured_engine() {
local CONFIG_FILE="$CONFIG_DIR/config.json"
[ -f "$CONFIG_FILE" ] || return 0
@@ -4356,15 +4363,25 @@ PY
print_warning "$CONFIG_FILE selects the $CONFIGURED_ENGINE engine, but it is not importable in $VENV_DIR."
# Leaving it means the app dies at startup with ModuleNotFoundError, so move
# the config to the default engine when that one works.
if [ "$CONFIGURED_ENGINE" != "whisper_cpp" ] && venv_can_import "pywhispercpp.model"; then
if set_configured_engine "$CONFIG_FILE" "whisper_cpp"; then
print_success "Switched $CONFIG_FILE to the whisper_cpp engine."
# Leaving it means the app dies at startup with ModuleNotFoundError. Prefer
# SELECTED_ENGINE (set by the whisper.cpp -> vosk fallback), then any extra
# the venv can actually import.
local CANDIDATE MODULE_FOR_CANDIDATE TRIED=""
for CANDIDATE in ${SELECTED_ENGINE:+$SELECTED_ENGINE} whisper_cpp vosk whisper; do
[ "$CANDIDATE" != "$CONFIGURED_ENGINE" ] || continue
case " $TRIED " in
*" $CANDIDATE "*) continue ;;
esac
TRIED="$TRIED $CANDIDATE"
MODULE_FOR_CANDIDATE=$(engine_import_module "$CANDIDATE")
[ -n "$MODULE_FOR_CANDIDATE" ] || continue
venv_can_import "$MODULE_FOR_CANDIDATE" || continue
if set_configured_engine "$CONFIG_FILE" "$CANDIDATE"; then
print_success "Switched $CONFIG_FILE to the $CANDIDATE engine."
print_info "Reinstall $(engine_pip_name "$CONFIGURED_ENGINE") and switch back in Settings if you want it."
return 0
fi
fi
done
print_error "Vocalinux cannot start with this configuration and no working engine is available."
print_error "Install the engine: $VENV_DIR/bin/pip install $(engine_pip_name "$CONFIGURED_ENGINE")"
+43 -31
View File
@@ -4,26 +4,38 @@
# .venv/ — run `just deps` after cloning (requires uv).
#
# Two environments, deliberately separate:
# .venv/ dev tooling, built by uv from .python-version (3.13).
# venv/ what `just install` creates, always from the system Python — on
# distros where PyGObject cannot be pip-built (Ubuntu 24.04, Debian)
# the distro package is importable only from that interpreter.
# .venv/ dev tooling, built by uv from .python-version (3.13). `just deps`
# pip-builds PyGObject from the lock and needs libgirepository-2.0-dev
# (Ubuntu 24.04+). Debian 12 cannot build 3.56; use `just install-dev`.
# venv/ what `just install` creates, always from the system Python so the
# distro PyGObject package is importable.
# install.sh ignores an activated .venv and rebuilds
# venv/ if another interpreter created it, so `just install` is safe to
# run from any shell. Override with SYSTEM_PYTHON=/usr/bin/python3.12.
# Extras and groups installed by `just deps` and requested by every recipe below.
# `uv sync` prunes whatever the flags do not name — omitting `--group lint` really
# does uninstall the linters — and `uv run` has pruned in past versions, so every
# recipe asks for the same set rather than depending on which uv is installed.
# CI lints with `--only-group lint` instead: that skips the project, whose
# pyaudio/PyGObject need system headers a lint runner has no reason to install.
# Extras and groups installed by `just deps`. `uv sync` prunes whatever the flags
# do not name — omitting `--group lint` really does uninstall the linters.
# Recipes that run tools use `uv run --no-sync` so they do not undo `just deps-all`
# (whisper/vosk/docs). They depend on `_tooling` for the same reason: with nothing
# left to create .venv/, `uv run --no-sync` on a fresh clone leaves an empty one
# behind and fails with `Failed to spawn: pytest`. CI lints with `--only-group lint`
# instead: that skips the project, whose pyaudio/PyGObject need system headers a
# lint runner has no reason to install.
DEV_EXTRAS := "--extra dev --extra vad --group lint"
# List available recipes
default:
@just --list
# Create .venv/ and keep it matching the lock. --inexact is what makes this safe
# to run ahead of every recipe: a plain `uv sync` removes extraneous packages, so
# it would uninstall whatever `just deps-all` installed — the very thing the
# --no-sync flags below exist to prevent. `version` skips this; it reads one
# string out of version.py and needs a bare interpreter, not a sync.
[private]
_tooling:
uv sync --inexact {{DEV_EXTRAS}}
# Install Vocalinux
install:
./install.sh
@@ -41,36 +53,36 @@ deps-all:
uv sync --all-extras --group lint
# Run test suite
test:
test: _tooling
@echo "Running tests..."
uv run {{DEV_EXTRAS}} pytest -v
uv run --no-sync pytest -v
# Run tests with coverage
test-cov:
test-cov: _tooling
@echo "Running tests with coverage..."
uv run {{DEV_EXTRAS}} pytest --cov=src --cov-report=html --cov-report=term
uv run --no-sync pytest --cov=src --cov-report=html --cov-report=term
@echo "Coverage report generated in htmlcov/"
# Run linters (flake8, black, isort)
lint:
lint: _tooling
@echo "Running flake8..."
uv run {{DEV_EXTRAS}} flake8 src/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
uv run --no-sync flake8 src/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
@echo "Checking black formatting..."
uv run {{DEV_EXTRAS}} black --check --diff src/ tests/
uv run --no-sync black --check --diff src/ tests/
@echo "Checking isort..."
uv run {{DEV_EXTRAS}} isort --check-only --diff --profile black src/ tests/
uv run --no-sync isort --check-only --diff --profile black src/ tests/
# Auto-format code (black + isort)
format:
format: _tooling
@echo "Formatting with black..."
uv run {{DEV_EXTRAS}} black src/ tests/
uv run --no-sync black src/ tests/
@echo "Sorting imports with isort..."
uv run {{DEV_EXTRAS}} isort --profile black src/ tests/
uv run --no-sync isort --profile black src/ tests/
# Run type checking (mypy)
typecheck:
typecheck: _tooling
@echo "Running mypy..."
uv run {{DEV_EXTRAS}} mypy src/
uv run --no-sync mypy src/
# Build distribution packages
build:
@@ -103,8 +115,8 @@ lock-check:
# every zip not already in the manifest is downloaded. A first run, or any run
# with --refresh, pulls ~21.9GB and takes ~30 minutes.
# Run after adding a model to vosk_model_info.py or whispercpp_model_info.py.
model-checksums:
uv run {{DEV_EXTRAS}} python scripts/generate-model-checksums.py
model-checksums: _tooling
uv run --no-sync python scripts/generate-model-checksums.py
# Remove build artifacts
clean:
@@ -131,16 +143,16 @@ run-debug:
vocalinux --debug
# Run from source
run-source:
uv run {{DEV_EXTRAS}} python -m vocalinux.main
run-source: _tooling
uv run --no-sync python -m vocalinux.main
# Run from source with debug logging
run-source-debug:
uv run {{DEV_EXTRAS}} python -m vocalinux.main --debug
run-source-debug: _tooling
uv run --no-sync python -m vocalinux.main --debug
# Run pre-commit hooks on all files
pre-commit:
uv run {{DEV_EXTRAS}} pre-commit run --all-files
pre-commit: _tooling
uv run --no-sync pre-commit run --all-files
# Print the current version
version:
+7 -3
View File
@@ -83,11 +83,15 @@ mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/lib" "$TOOLDIR" "$OUTDIR"
export APPIMAGE_EXTRACT_AND_RUN="${APPIMAGE_EXTRACT_AND_RUN:-1}"
echo "== Fetching AppImage tooling ($ARCH) =="
curl -fsSL -o "$TOOLDIR/linuxdeploy" \
# Default --retry skips TLS RST (curl 35). GitHub continuous AppImages flake that way.
curl_retry() {
curl --retry 5 --retry-all-errors --retry-delay 2 --fail --silent --show-error --location "$@"
}
curl_retry -o "$TOOLDIR/linuxdeploy" \
"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage"
curl -fsSL -o "$TOOLDIR/linuxdeploy-plugin-gtk.sh" \
curl_retry -o "$TOOLDIR/linuxdeploy-plugin-gtk.sh" \
https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh
curl -fsSL -o "$TOOLDIR/appimagetool" \
curl_retry -o "$TOOLDIR/appimagetool" \
"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage"
chmod +x "$TOOLDIR/linuxdeploy" "$TOOLDIR/linuxdeploy-plugin-gtk.sh" "$TOOLDIR/appimagetool"
@@ -23,6 +23,7 @@ from ..common_types import RecognitionState
from ..ui.audio_feedback import play_error_sound, play_start_sound, play_stop_sound
from ..utils.model_checksums import (
ChecksumError,
expected_for,
verify_model_file,
write_verification_stamp,
)
@@ -1276,9 +1277,15 @@ class SpeechRecognitionManager:
# re-hashes only ggml-tiny.bin. whisper.cpp maps this straight through
# ctypes, so hash it here; a failure demotes it to "not downloaded".
if os.path.exists(model_path) and not self._whispercpp_model_is_verified(model_path):
if self._defer_download:
if os.path.exists(model_path):
logger.error(
"Refusing to load unverified whisper.cpp model at %s",
model_path,
)
self._model_initialized = False
return
if self._defer_download:
return
raise RuntimeError(f"whisper.cpp model at {model_path} failed verification")
if not os.path.exists(model_path):
if self._defer_download:
@@ -1307,19 +1314,18 @@ class SpeechRecognitionManager:
@staticmethod
def _whispercpp_model_is_verified(model_path: str) -> bool:
"""Hash the model against its pin, or delete it and report False.
"""Hash the model against its pin. Delete only a digest/size mismatch.
Cheap enough to do every time: sha256 runs at GB/s and whisper.cpp is
about to read the whole file anyway, so this is CPU over pages that are
being fetched regardless. Deleting rather than raising keeps the caller
simple, since an unverifiable model is indistinguishable from a missing
one as far as what happens next.
An unpinned name is refused, not deleted. If remove fails, return False
so the caller does not hand the file to ctypes.
"""
try:
verify_model_file(model_path)
return True
except ChecksumError as error:
logger.error("whisper.cpp model at %s is not trustworthy: %s", model_path, error)
if expected_for(model_path) is None:
return False
try:
os.remove(model_path)
except OSError as remove_error:
+65
View File
@@ -72,7 +72,72 @@ def test_documented_uv_run_examples_do_not_prune_the_linters():
assert not offenders, "these examples uninstall the linters:\n" + "\n".join(offenders)
def test_justfile_uv_run_recipes_do_not_sync():
"""`uv run` without --no-sync prunes whisper/vosk after `just deps-all`."""
offenders = []
for number, line in enumerate(JUSTFILE.read_text(encoding="utf-8").splitlines(), 1):
stripped = line.lstrip()
if stripped.startswith("#") or not stripped.startswith("uv run"):
continue
if "--no-sync" not in stripped:
offenders.append(f"{number}: {stripped}")
assert not offenders, "uv run without --no-sync undoes just deps-all:\n" + "\n".join(offenders)
def test_the_dev_extra_reaches_the_dev_export():
dev = _requirement_names(_pyproject()["project"]["optional-dependencies"]["dev"])
missing = sorted(set(dev) - _exported_names(DEV_EXPORT))
assert not missing, f"missing from requirements/dev.txt; re-run `just lock`: {missing}"
#: Recipes that may call `uv run --no-sync` without depending on `_tooling`.
#: `version` reads one string out of version.py with a bare interpreter, so
#: syncing the whole dev environment ahead of it would buy nothing.
NO_TOOLING_NEEDED = {"version"}
def _justfile_recipes() -> dict:
"""Map every recipe name to its dependency list and its body lines."""
recipes = {}
current = None
for line in JUSTFILE.read_text(encoding="utf-8").splitlines():
header = re.match(r"^([a-z_][A-Za-z0-9_-]*):(.*)$", line)
if header:
current = header.group(1)
recipes[current] = (header.group(2).split(), [])
elif current and line.startswith((" ", "\t")):
recipes[current][1].append(line.strip())
elif line.strip():
current = None
return recipes
def test_no_sync_recipes_bootstrap_the_venv():
"""Something has to create .venv before `uv run --no-sync` can use it.
Nothing does, once every recipe stops syncing: a fresh clone gets an empty
.venv and `error: Failed to spawn: pytest`. No CI job would catch it either,
because the pipeline drives uv directly and never runs `just`.
"""
offenders = []
for name, (dependencies, body) in _justfile_recipes().items():
if name in NO_TOOLING_NEEDED:
continue
if not any("uv run --no-sync" in body_line for body_line in body):
continue
if "_tooling" not in dependencies:
offenders.append(name)
assert not offenders, "these run tooling out of .venv but never create it:\n" + "\n".join(
offenders
)
def test_default_is_the_first_recipe():
"""`just` with no arguments runs the first recipe, whatever it is named.
A recipe added above `default` silently becomes what bare `just` does, and
`[private]` does not exempt it — which is how `_tooling` first landed here,
turning `just` into a sync instead of the recipe listing.
"""
first = next(iter(_justfile_recipes()))
assert first == "default", f"bare `just` would run `{first}` instead of listing recipes"
+36 -1
View File
@@ -60,10 +60,13 @@ def _config(config_dir: Path, engine: str) -> Path:
return path
def _check(config_dir: Path, venv_dir: Path) -> subprocess.CompletedProcess:
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
@@ -144,6 +147,38 @@ def test_silent_without_a_config_file(tmp_path):
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())
+9 -4
View File
@@ -480,10 +480,15 @@ class TestInitWhispercpp(unittest.TestCase):
"pywhispercpp.model": mock_pywhispercpp,
},
):
try:
mgr._init_whispercpp()
except Exception:
pass
with patch.object(
SpeechRecognitionManager,
"_whispercpp_model_is_verified",
return_value=True,
):
try:
mgr._init_whispercpp()
except Exception:
pass
def test_cpu_fallback_filters_unsupported_whispercpp_params(self):
mgr = _make_manager(engine="whisper_cpp")
@@ -580,6 +580,56 @@ class TestWhispercppRejectsAnUnverifiedModelOnDisk:
load.assert_not_called()
assert manager._model_initialized is False
def test_a_model_that_cannot_be_deleted_is_still_not_loaded(self, tmp_path):
model = tmp_path / "ggml-tiny.bin"
model.write_bytes(b"not the model that is pinned")
manager = _make_manager(engine="whisper_cpp")
manager._defer_download = False
mock_pywhispercpp = MagicMock()
with patch.dict(
"sys.modules",
{"pywhispercpp": mock_pywhispercpp, "pywhispercpp.model": mock_pywhispercpp},
):
with patch(
"vocalinux.speech_recognition.recognition_manager.get_model_path",
return_value=str(model),
):
with patch(
"vocalinux.speech_recognition.recognition_manager.os.remove",
side_effect=OSError("read-only"),
):
with patch.object(manager, "_load_whispercpp_model") as load:
with pytest.raises(RuntimeError, match="failed verification"):
manager._init_whispercpp()
assert model.exists()
load.assert_not_called()
def test_an_unpinned_model_is_refused_not_deleted(self, tmp_path):
model = tmp_path / "ggml-not-in-manifest.bin"
model.write_bytes(b"bytes")
manager = _make_manager(engine="whisper_cpp")
manager._defer_download = True
mock_pywhispercpp = MagicMock()
with patch.dict(
"sys.modules",
{"pywhispercpp": mock_pywhispercpp, "pywhispercpp.model": mock_pywhispercpp},
):
with patch(
"vocalinux.speech_recognition.recognition_manager.get_model_path",
return_value=str(model),
):
with patch.object(manager, "_load_whispercpp_model") as load:
manager._init_whispercpp()
assert model.exists(), "a missing pin is not a reason to delete the file"
load.assert_not_called()
assert manager._model_initialized is False
class TestAudioReconnection:
"""Test audio reconnection logic."""
+5
View File
@@ -153,6 +153,11 @@ class TestWhisperSupport:
patch(
"vocalinux.speech_recognition.recognition_manager._show_notification"
) as notify_mock,
patch.object(
rm.SpeechRecognitionManager,
"_whispercpp_model_is_verified",
return_value=True,
),
patch.dict(
os.environ,
{"GGML_VULKAN": "1", "GGML_CUDA": "1"},