* 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:
co-authored by
Sergey Silaev
parent
582d494633
commit
f43f9dcd72
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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
@@ -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")"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user