chore(build): uv tooling, a Python 3.11 floor, and verified model downloads (epic #701, phases 2.5 & 5) (#713)

* chore(build): migrate justfile tooling to uv

All recipes now run through 'uv run' against .venv, with a shared
DEV_EXTRAS variable (dev + vad) because uv syncs the environment
exactly and would otherwise uninstall the dev tools between
recipes. 'deps' installs dev+vad; new 'deps-all' covers every
extra. 'build' switches to 'uv build'; 'version' uses --no-sync.

Bump [tool.uv] required-version to >=0.12,<0.13 for uv 0.12.x, and
raise the mypy python_version target to 3.12: mypy 1.19 in the lock
cannot target 3.9 and numpy 2.x stubs need a 3.12 target to parse
(mypy is not run in CI or pre-commit; this only affects 'just
typecheck').

* fix(tests): isolate test runs from real XDG state; fix Arch model-path crash

Local test runs failed en masse (185 failures) while CI stayed
green: tests picked up the developer's real ~/.local/share/vocalinux
models via the default XDG paths and switched IBus/keyboard code
paths through the desktop session env (XDG_CURRENT_DESKTOP/
XDG_SESSION_TYPE on GNOME/Wayland, issue #478 branches). conftest
now points XDG base dirs at a throwaway session-wide directory and
clears session variables at import time, mirroring a fresh CI
runner (paths like MODELS_DIR freeze at first import during
collection, so fixture-time isolation is too late).

Also guard the Arch branch of _get_system_model_paths(): it removed
/usr/local/share/vocalinux/models unconditionally and crashed with
ValueError on Arch hosts when a custom XDG_DATA_DIRS never added
that path. Add regression tests, and make the lock-file test assert
the actual $XDG_DATA_HOME/vocalinux contract instead of the
hardcoded ~/.local/share default.

* fix(tests): keep the IBus engine double a plain class

Python 3.14's unittest.mock re-instantiates a user subclass when it builds
child mocks (klass(name=...)). VocalinuxEngine inherits from whatever
IBus.Engine resolves to, and the test module bound that to the MagicMock
class itself, so every engine instantiation raised

    TypeError: VocalinuxEngine.__init__() got an unexpected keyword argument 'name'

on 3.14 while passing on 3.13. Replace it with a plain stand-in class that
still hands out per-instance MagicMocks for the IBus API surface the engine
touches.

The same pattern remains in test_ibus_engine.py, test_ibus_engine_utils.py,
test_ibus_active_engine_guard.py and test_ibus_xkb_wayland.py; those never
instantiate the engine, so they are unaffected for now.

* fix(install): build the venv from the system Python

install.sh looked up a bare `python3` from PATH. Started from an activated
virtualenv — a shell left in uv's .venv after `just deps` — it built venv/
from that interpreter, where distro PyGObject is invisible even with
--system-site-packages, and the run aborted with

    Distro PyGObject (python3-gi / python3-gobject) is not importable in the venv.

A mismatched venv/ then survived every retry, because the installer reuses
whatever venv it finds.

- drop an inherited VIRTUAL_ENV (and its bin from PATH) before any
  interpreter lookup
- select the interpreter explicitly: $SYSTEM_PYTHON (default
  /usr/bin/python3) or PATH python3, preferring one that can import gi,
  and create the venv with it instead of `python3`
- recreate venv/ when it was built by a different interpreter
- name the interpreter in the PyGObject guard's error

Documents the two environments (uv's .venv for tooling, venv/ for the
installed app) in CONTRIBUTING, the justfile header and AGENTS.md, and adds
the mismatch symptom to the INSTALL troubleshooting section.

* chore(python): raise the supported floor to 3.11

requires-python promised 3.9 while the CI matrix ran 3.9/3.10/3.11/3.13,
the classifiers advertised up to 3.14, mypy targeted 3.12 and install.sh
enforced 3.9 — five different answers to "which Python does this support".

Settle on 3.11 everywhere: pyproject (requires-python, classifiers, black
targets), install.sh's MIN_VERSION, check-system-deps.sh, the distro package
map, the CI matrix (now 3.11-3.14, matching the classifiers), the release and
nightly workflows, and the docs.

mypy now targets the floor instead of 3.12, so type checking covers what we
promise; numpy 2.x ships stubs in 3.12 syntax that a 3.11 target cannot
parse, so its imports are skipped. (mypy has 71 pre-existing errors either
way; no CI job gates on it.)

uv.lock and the requirements/* exports are regenerated — dropping 3.9/3.10
removes most of the version-conditional resolution branches.

tests/test_python_version_policy.py pins the invariant: install.sh's floor,
requires-python, the classifiers and the CI matrix must agree.

* ci: run lint and tests from uv.lock

The lock file and the hash-pinned exports were not enforced anywhere: CI
still built a venv by hand and installed unpinned `PyGObject` plus
`-e ".[dev,vad]"`, so the tool versions it checked against had nothing to do
with the ones `just lint` uses locally.

Both Python jobs now use astral-sh/setup-uv and run through `uv sync
--locked` / `uv run --locked`, which also fails the build when uv.lock drifts
from pyproject.toml.

Partially covers phase 5 of #701 (tests install from the lock, lock freshness
gate); trusted publishing, artifact checksums and action SHA pinning are
still open there.

* ci: lint from a dedicated dependency group

The lint job failed on this branch: `uv run --extra dev` syncs the whole
project, so it tried to build pyaudio from source on a runner that installs
no system dependencies.

    src/pyaudio/device_api.c:9:10: fatal error: portaudio.h: No such file or directory

Move black/isort/flake8 out of the `dev` extra into a `lint` dependency
group. CI runs `uv run --locked --only-group lint`, which installs the three
pure-Python tools and skips the project entirely — no portaudio or
girepository headers needed. `just deps` and DEV_EXTRAS request the group
alongside the dev extras, so the local environment still carries test and
lint tooling at once and `just lint` matches CI version for version.

pre-commit is unaffected: its hooks pin their own tool versions upstream.

* fix(install): never configure an engine the venv cannot import

vosk became an optional extra in #705, but the installer still writes
`"engine": "vosk"` into config.json on the whisper.cpp fallback path even
when installing the extra only produced a warning. config.json also survives
reinstalls — every writer guards on "file does not exist" — so an engine
picked by an earlier attempt outlives the venv that supported it. Either way
Vocalinux dies at startup:

    ModuleNotFoundError: No module named 'vosk'

- the fallback now writes the vosk config only when `import vosk` actually
  succeeds, and otherwise leaves the engine configuration alone
- a new verify_configured_engine() runs near the end of the install: if
  config.json selects vosk and the venv cannot import it, it says so and
  prints both fixes instead of leaving a broken install behind

* feat(models): verify every model download against a pinned digest

Pins a sha256/md5 per downloadable speech model in
src/vocalinux/utils/model_checksums.txt and verifies against it in both
install.sh and the runtime downloaders, before the file is moved into place.
Verification fails closed. Covers phase 2.5 of #701; see the PR for details.

* docs(agents): rebase AGENTS.md onto the #727 rewrite

* fix(install): hash models that are already on disk instead of trusting them

* refactor(models): one download path per engine, and fix Whisper's

* docs(install): drop distros that cannot meet the 3.11 floor

* docs: retire the distros and Python versions the 3.11 floor rules out

* fix(install): abort instead of warning when Python is below the floor

* fix(install): fall back to the system Python, not the one on PATH

* fix(install): match an existing venv by installation, not by X.Y

* fix(install): repair a config naming an engine the venv cannot import

* fix(models): pin VOSK by the sha256 of bytes we fetched, not upstream's md5

* fix(justfile): request the dev set in every recipe that syncs

* test(version): scan the docs and website trees for a stale Python floor

* docs: correct the stale model-checksum and distro claims

* fix(models): stamp the VOSK tree the app extracts, as the installer does

* fix(install): replace an unverified VOSK model only once its successor is ready

* docs(install): align the Fedora floor with the rest of the tree

* test(install): drop an f-string with no placeholders

* test(models): stub the downloader probe the VOSK installer test relies on

* docs: drop the long dashes from two model-checksum comments

* fix(install): gate on the interpreter, not on the distro release label

* fix(justfile): export the lint group into requirements/dev.txt

* fix(models): unpack the VOSK tree into staging and stamp it before the swap

* fix(whisper): adopt a checkpoint an earlier release stored under the catalog name

* docs(settings): state what a stray Whisper copy really means for deletion

* fix(models): hash a whisper.cpp model found on disk before it reaches ctypes

* docs(contributing): stop telling readers to uninstall their linters

* fix(install): stop warning off distros the project documents as supported

* docs(distros): file the supported derivatives under supported
This commit is contained in:
Sergey Silaev
2026-08-24 00:25:06 -07:00
committed by GitHub
parent ba9cca844b
commit 582d494633
49 changed files with 3691 additions and 4122 deletions
+2 -2
View File
@@ -25,8 +25,8 @@ What actually happened.
If applicable, add screenshots to help explain your problem.
## 💻 Environment
- **OS**: [e.g., Ubuntu 22.04]
- **Python Version**: [e.g., 3.10]
- **OS**: [e.g., Ubuntu 24.04]
- **Python Version**: [e.g., 3.12]
- **Vocalinux Version**: [e.g., 0.2.0-alpha]
- **Display Server**: [X11 / Wayland]
- **Speech Engine**: [VOSK / Whisper]
+2 -2
View File
@@ -28,8 +28,8 @@ jobs:
fail-fast: false
matrix:
container:
- ubuntu:22.04
- ubuntu:24.04
- ubuntu:26.04
- debian:12
- fedora:39
container:
@@ -268,8 +268,8 @@ jobs:
echo "Check individual job logs for details."
echo ""
echo "Expected behavior:"
echo " ✓ ubuntu:22.04 - Full support expected"
echo " ✓ ubuntu:24.04 - Full support expected"
echo " ✓ ubuntu:26.04 - Full support expected"
echo " ✓ debian:12 - Full support expected"
echo " ⚠ fedora:39 - Good support, minor differences expected"
echo ""
+3 -3
View File
@@ -26,7 +26,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
python-version: '3.11'
cache: 'pip'
- name: Install system dependencies
@@ -213,7 +213,7 @@ jobs:
| **Version** | ${{ steps.version.outputs.version }} |
| **Base Version** | ${{ steps.version.outputs.base_version }} |
| **Build Date** | ${{ steps.version.outputs.date }} |
| **Python** | 3.9+ |
| **Python** | 3.11+ |
| **OS** | Linux (X11 & Wayland) |
---
@@ -284,7 +284,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
python-version: '3.11'
cache: 'pip'
- name: Install system dependencies
+2 -1
View File
@@ -11,7 +11,8 @@ permissions:
id-token: write # Required for PyPI trusted publishing
env:
PYTHON_VERSION: '3.10'
# Build against the supported floor (requires-python in pyproject.toml).
PYTHON_VERSION: '3.11'
jobs:
build-and-release:
+24 -30
View File
@@ -70,28 +70,26 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
- name: Set up uv
uses: astral-sh/setup-uv@v6
with:
python-version: '3.13'
cache: 'pip'
- name: Install linting tools
run: |
python -m pip install --upgrade pip
pip install flake8 "black>=26,<27" isort
enable-cache: true
# --only-group lint installs the linters without the project itself, so
# this job needs no portaudio/girepository headers to build pyaudio and
# PyGObject. --locked fails if uv.lock drifts from pyproject.toml, so the
# tool versions are the ones `just lint` uses locally.
- name: Lint with flake8 (critical errors)
run: |
flake8 src/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
uv run --locked --only-group lint flake8 src/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
- name: Check formatting with black
run: |
black --check --diff src/ tests/
uv run --locked --only-group lint black --check --diff src/ tests/
- name: Check import sorting with isort
run: |
isort --check-only --diff --profile black src/ tests/
uv run --locked --only-group lint isort --check-only --diff --profile black src/ tests/
python-test:
name: 🧪 Test Python ${{ matrix.python-version }}
@@ -102,16 +100,18 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ['3.9', '3.10', '3.11', '3.13']
# Every version requires-python promises; keep in sync with the
# classifiers in pyproject.toml (tests/test_python_version_policy.py).
python-version: ['3.11', '3.12', '3.13', '3.14']
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
- name: Set up uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install system dependencies
run: |
@@ -136,24 +136,18 @@ jobs:
# Alternatively, for older Ubuntu versions (like 22.04), you might need:
# sudo apt-get install -y libgirepository1.0-dev pkg-config
- name: Set up virtual environment
run: |
python -m venv venv
source venv/bin/activate
python -m pip install --upgrade pip setuptools wheel
# Install PyGObject from pip (with build deps installed above)
pip install PyGObject
pip install -e ".[dev,vad]"
echo "VIRTUAL_ENV=$VIRTUAL_ENV" >> $GITHUB_ENV
echo "PATH=$VIRTUAL_ENV/bin:$PATH" >> $GITHUB_ENV
# PyGObject is built from the lock here: the runner has glib 2.80+ and the
# -dev headers installed above. End users get the distro package instead
# (install.sh builds venv/ with --system-site-packages).
- name: Install dependencies from uv.lock
run: uv sync --locked --extra dev --extra vad
- name: Run tests with coverage
run: |
source venv/bin/activate
# Set GI_TYPELIB_PATH so pip-installed PyGObject can find system typelibs (AppIndicator3, etc.)
# Use dynamic detection for cross-distro compatibility, fallback to common Ubuntu paths
export GI_TYPELIB_PATH=$(pkg-config --variable=typelibdir gobject-introspection-1.0 2>/dev/null || echo "/usr/lib/x86_64-linux-gnu/girepository-1.0:/usr/lib/girepository-1.0")
PYTHONPATH=$PWD pytest --junitxml=junit-${{ matrix.python-version }}.xml --cov=src --cov-report=xml tests/
PYTHONPATH=$PWD uv run --locked --extra dev --extra vad pytest --junitxml=junit-${{ matrix.python-version }}.xml --cov=src --cov-report=xml tests/
- name: Upload test results
if: always()
@@ -163,7 +157,7 @@ jobs:
path: junit-${{ matrix.python-version }}.xml
- name: Upload coverage to Codecov
if: matrix.python-version == '3.10'
if: matrix.python-version == '3.13'
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -189,7 +183,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
python-version: '3.11'
cache: 'pip'
- name: Install system dependencies
+13 -5
View File
@@ -28,14 +28,19 @@ Rules:
| Piece | Current |
|---|---|
| Python | `>=3.9` (`requires-python` in `pyproject.toml`; CI: 3.9, 3.10, 3.11, 3.13) |
| Python | `>=3.11` (`requires-python` in `pyproject.toml`; CI: 3.11–3.14). Floor, classifiers, CI matrix and `install.sh` must agree — `tests/test_python_version_policy.py` |
| GTK | GTK 3 via distro `python3-gi` (PyGObject). Never pip-install it |
| uv | `>=0.11,<0.12` (`[tool.uv]` in `pyproject.toml`). `uv.lock` is the source of truth |
| uv | `>=0.12,<0.13` (`[tool.uv]` in `pyproject.toml`). `uv.lock` is the source of truth; CI runs `uv sync --locked` / `uv run --locked`, which fails on drift |
| just | https://just.systems or distro package `just` |
| Format / lint / types | Black + isort (line length 100), flake8 (`E9,F63,F7,F82` only), mypy `src/` |
| Format / lint / types | Black + isort (line length 100), flake8 (`E9,F63,F7,F82` only), mypy `src/` (targets 3.11). The three linters live in the `lint` dependency group, not the `dev` extra, so CI can install them without building the project |
| Website | Next.js / TypeScript in `web/` — see `web/AGENTS.md` |
Activate the venv before Python tooling: `source venv/bin/activate` (or prefix `./venv/bin/`).
Two virtualenvs, on purpose:
- **`.venv/`** — uv's, for dev tooling. Every `just` recipe runs through `uv run`, so no activation is 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.
## Setup
@@ -68,8 +73,10 @@ just format # black + isort
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 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
just pre-commit # pre-commit run --all-files
just run-debug # vocalinux --debug
just run-source-debug
@@ -88,7 +95,7 @@ Website: `web/AGENTS.md`, `web/PRODUCT.md`, `web/DESIGN.md`. Do not duplicate si
## Dependencies (uv)
`uv.lock` is authoritative. `just lock` regenerates it and the hash-pinned `requirements/*.txt` exports. **Do not edit `requirements/*.txt` by hand.** Change `pyproject.toml` (or `requirements/whisper.in` for the Whisper engine), run `just lock`, and commit the lock plus the exports with the manifest change.
`uv.lock` is authoritative. `just lock` regenerates it and the hash-pinned `requirements/*.txt` exports. Those exports exist for later packaging work (`install.sh`, AppImage, CI — phases 2, 3 and 5 of #701) and are unused until those phases land. **Do not edit `requirements/*.txt` by hand.** Change `pyproject.toml` (or `requirements/whisper.in` for the Whisper engine), run `just lock`, and commit the lock plus the exports with the manifest change.
| Constraint | Rule |
|---|---|
@@ -97,6 +104,7 @@ Website: `web/AGENTS.md`, `web/PRODUCT.md`, `web/DESIGN.md`. Do not duplicate si
| Whisper CPU torch | `requirements/whisper.txt` is compiled from `requirements/whisper.in`. Pin `torch`/`torchaudio` together to `+cpu` local versions — PyPI CUDA wheels win resolution regardless of index order, and torchaudio lags torch on the CPU index |
| pywhispercpp | Pinned in `install.sh` as `PYWHISPERCPP_VERSION` (keep in sync with `uv.lock`) |
| `[vad]` extra | `onnxruntime` for Silero VAD |
| Speech models | Verified against digests pinned in `src/vocalinux/utils/model_checksums.txt` before install, by both `install.sh` and the runtime downloaders. **Fails closed** — an unpinned model is refused. Regenerate with `just model-checksums` (never by hand); `tests/test_model_checksums.py` fails if it falls behind. whisper.cpp URLs use a pinned Hugging Face commit, never `main` |
Optional extras: `vosk`, `whisper`, `vad`, `dev`.
+41 -26
View File
@@ -50,9 +50,16 @@ This will:
1. Install all system dependencies
2. Create a Python virtual environment
3. Install the package in editable mode (`-e`)
4. Install all dev dependencies (pytest, black, isort, flake8)
4. Install the dev dependencies (pytest and friends; the linters live in the
`lint` dependency group, which `just deps` installs)
5. Run the test suite automatically
> **Note:** `install.sh` always builds `venv/` from the system Python
> (`/usr/bin/python3`, or `$SYSTEM_PYTHON`), because distro PyGObject is only
> importable from that interpreter. It ignores an activated virtualenv, so you can
> run it from a shell that still has uv's `.venv` active. See
> [The two environments](#the-two-environments).
### Option 2: Manual Setup
1. **Fork and clone:**
@@ -69,7 +76,7 @@ This will:
gir1.2-gtk-3.0 libgirepository1.0-dev \
python3-dev portaudio19-dev python3-venv xdotool
# Debian 11/12
# Debian 12+
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
@@ -82,30 +89,45 @@ This will:
# For appindicator (system tray icon):
# On older Ubuntu:
sudo apt install -y gir1.2-appindicator3-0.1
# On Debian 11+ or newer Ubuntu:
# On Debian 12+ or newer Ubuntu:
sudo apt install -y gir1.2-ayatanaappindicator3-0.1
```
3. **Set up Python environment:**
3. **Set up the Python environment:**
```bash
python3 -m venv venv --system-site-packages
source venv/bin/activate
pip install --upgrade pip setuptools wheel
pip install -e ".[dev]"
# Requires uv (https://docs.astral.sh/uv/); creates .venv/ with dev + vad extras
just deps
```
4. **Run the application:**
```bash
source venv/bin/activate
vocalinux --debug
just run-source-debug
```
5. **(Optional) Install pre-commit hooks:**
```bash
pre-commit install
uv run --extra dev --extra vad --group lint 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.
### The two environments
The repository uses two virtual environments on purpose — don't merge them:
| Directory | Created by | Python | Used for |
|-----------|-----------|--------|----------|
| `.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.
## Making Changes
### Branching Strategy
@@ -136,12 +158,11 @@ We use automated tools to ensure consistent code style:
- **flake8** - Linting
```bash
# Format your code
black src/ tests/
isort src/ tests/
# Format your code (black + isort)
just format
# Check for issues
flake8 src/ tests/
# Check for issues (flake8 + black + isort, as CI runs them)
just lint
```
Pre-commit hooks will run these automatically before each commit.
@@ -187,20 +208,14 @@ vocalinux/
### Running Tests
```bash
# Activate virtual environment
source venv/bin/activate
# Run all tests
pytest
just test
# Run with coverage
pytest --cov=src --cov-report=html
just test-cov
# Run specific test file
pytest tests/test_command_processor.py
# Run with verbose output
pytest -v
uv run --extra dev --extra vad --group lint pytest tests/test_command_processor.py
```
### Writing Tests
@@ -257,7 +272,7 @@ The test server supports both API formats:
### Before Submitting
- [ ] Code follows the style guidelines
- [ ] Tests pass locally (`pytest`)
- [ ] Tests pass locally (`just test`)
- [ ] Pre-commit hooks pass
- [ ] Documentation is updated (if needed)
- [ ] Commit messages are clear and descriptive
+5 -5
View File
@@ -13,7 +13,7 @@
[![X11 & Wayland](https://img.shields.io/badge/display-X11%20%7C%20Wayland-lightgrey)](https://github.com/VocaHQ/vocalinux#features)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![Discord](https://img.shields.io/discord/1538633755877580810?logo=discord&logoColor=white&label=Discord)](https://discord.gg/t6muquAJbm)
[![VocaHQ](https://img.shields.io/badge/VocaHQ-vocahq.com-1a7f4e)](https://vocahq.com)
@@ -26,8 +26,8 @@
[![Follow on X](https://img.shields.io/badge/Follow%20%40vocahq-000000?style=flat&logo=x&logoColor=white)](https://x.com/vocahq)
<!-- Distros (widest; base plate above the hero) -->
[![Ubuntu](https://img.shields.io/badge/Ubuntu-22.04+-E95420?logo=ubuntu&logoColor=white)](docs/DISTRO_COMPATIBILITY.md)
[![Debian](https://img.shields.io/badge/Debian-11+-A81D33?logo=debian&logoColor=white)](docs/DISTRO_COMPATIBILITY.md)
[![Ubuntu](https://img.shields.io/badge/Ubuntu-24.04+-E95420?logo=ubuntu&logoColor=white)](docs/DISTRO_COMPATIBILITY.md)
[![Debian](https://img.shields.io/badge/Debian-12+-A81D33?logo=debian&logoColor=white)](docs/DISTRO_COMPATIBILITY.md)
[![Fedora](https://img.shields.io/badge/Fedora-39+-51A2DA?logo=fedora&logoColor=white)](docs/DISTRO_COMPATIBILITY.md)
[![Arch](https://img.shields.io/badge/Arch-rolling-1793D1?logo=archlinux&logoColor=white)](docs/DISTRO_COMPATIBILITY.md)
[![openSUSE](https://img.shields.io/badge/openSUSE-Tumbleweed-73BA25?logo=opensuse&logoColor=white)](docs/DISTRO_COMPATIBILITY.md)
@@ -265,8 +265,8 @@ Or launch it from your application menu!
## 📋 Requirements
- **OS**: Linux (tested on Ubuntu 22.04+, Debian 11+, Fedora 39+, Arch Linux, openSUSE Tumbleweed)
- **Python**: 3.9 or newer
- **OS**: Linux (tested on Ubuntu 24.04+, Debian 12+, Fedora 39+, Arch Linux, openSUSE Tumbleweed)
- **Python**: 3.11 or newer
- **Display**: X11 or Wayland
- **Hardware**: Microphone for voice input
+14 -11
View File
@@ -44,15 +44,24 @@ The `.desktop` entry is automatically configured with the detected `GI_TYPELIB_P
## Officially Supported Distributions
These distributions are tested and known to work well with Vocalinux:
These distributions are tested and known to work well with Vocalinux. A
derivative is judged by the Python it ships rather than by its own version
number, which rarely tracks the base release, and `install.sh` checks the
interpreter rather than the release label.
| Distribution | Version | Status | Notes |
|--------------|---------|--------|-------|
| Ubuntu | 22.04+ | ✅ Full Support | Primary target, most thoroughly tested |
| Debian | 11+, Testing | ✅ Full Support | Well tested, use Ayatana AppIndicator on Debian 11+ |
| Ubuntu | 24.04+ | ✅ Full Support | 22.04 ships Python 3.10, below the 3.11 floor |
| Debian | 12+, Testing | ✅ Full Support | Primary target; use Ayatana AppIndicator |
| Fedora | 39+ | ✅ Good Support | Uses lib64 paths, fully supported |
| Arch Linux | Rolling | ✅ Good Support | Community tested, rolling release compatible |
| openSUSE | Tumbleweed | ✅ Good Support | Good support with zypper package manager |
| Linux Mint | On Ubuntu 24.04+ | ✅ Good Support | Mint 22 qualifies; Mint 21 is on Ubuntu 22.04, below the floor |
| Pop!_OS | 24.04+ | ✅ Good Support | Uses Ubuntu's own numbering |
| elementary OS | On Ubuntu 24.04+ | ✅ Good Support | elementary 8 qualifies; 7 is on Ubuntu 22.04 |
| Zorin OS | On Ubuntu 24.04+ | ✅ Good Support | Zorin 17 is on Ubuntu 22.04, below the floor |
| Manjaro | Rolling | ✅ Good Support | Arch base, inherits Arch compatibility |
| EndeavourOS | Rolling | ✅ Good Support | Arch base, inherits Arch compatibility |
## Experimental Support
@@ -65,12 +74,6 @@ These distributions may work but have known limitations or are less tested:
| Void Linux | ⚠️ Experimental | Untested, manual install required. Uses xbps package manager. |
| Solus | ⚠️ Experimental | Untested, manual install required. Uses eopkg package manager. |
| Mageia | ⚠️ Experimental | Untested, uses dnf/urpmi package managers. |
| Linux Mint | ✅ Good Support | Based on Ubuntu, inherits Ubuntu compatibility |
| Pop!_OS | ✅ Good Support | Based on Ubuntu, inherits Ubuntu compatibility |
| elementary OS | ✅ Good Support | Based on Ubuntu, inherits Ubuntu compatibility |
| Zorin OS | ✅ Good Support | Based on Ubuntu, inherits Ubuntu compatibility |
| Manjaro | ✅ Good Support | Based on Arch, inherits Arch compatibility |
| EndeavourOS | ✅ Good Support | Based on Arch, inherits Arch compatibility |
## Not Supported
@@ -88,7 +91,7 @@ sudo apt install -y python3-gi gir1.2-gtk-3.0 gir1.2-gdkpixbuf-2.0 \
portaudio19-dev python3-dev python3-venv pkg-config
```
**Note for Debian 11+:** Use `gir1.2-ayatanaappindicator3-0.1` instead of `gir1.2-appindicator3-0.1`
**Note for Debian 12+:** Use `gir1.2-ayatanaappindicator3-0.1` instead of `gir1.2-appindicator3-0.1`
### Debian-specific notes
@@ -174,7 +177,7 @@ native dependencies; it does **not** mean beta or unstable packages. If
### Gentoo
```bash
sudo emerge dev-python/pygobject:3 x11-libs/gtk+:3 dev-libs/libayatana-appindicator \
media-libs/portaudio dev-lang/python:3.9
media-libs/portaudio dev-lang/python:3.11
```
**Note:** Gentoo compiles packages from source, which will take longer.
+42 -7
View File
@@ -136,13 +136,24 @@ On Ubuntu 24.04+ or Pop!_OS, install `libgirepository-2.0-dev` if
| Requirement | Details |
|-------------|---------|
| **Operating System** | Ubuntu 22.04+ (recommended), Debian 11+, Fedora 38+, Arch Linux |
| **Python** | 3.9 or newer |
| **Operating System** | Debian 12+, Ubuntu 24.04+, Fedora 39+, Arch Linux |
| **Python** | 3.11 or newer |
| **Display Server** | X11 or Wayland |
| **Hardware** | Microphone for speech input |
| **Disk Space** | ~200MB (including whisper.cpp model) |
| **RAM** | 4GB minimum, works great with 8GB |
The distribution has to ship Python 3.11 or newer, because Vocalinux uses the
distro's own PyGObject (`python3-gi`), which is built for that interpreter and
no other. That rules out Ubuntu 22.04 (Python 3.10) and Debian 11 (3.9): a 3.11
virtualenv on them cannot import the distro `gi`, and their `python3` is below
the floor.
Derivatives are judged by the interpreter they ship, not by their own version
number, which rarely tracks the base release. Linux Mint 22 and elementary OS 8
are built on Ubuntu 24.04 and qualify; Linux Mint 21 and Zorin OS 17 sit on
Ubuntu 22.04 and do not.
### GPU Support (Optional)
**whisper.cpp** supports GPU acceleration via **Vulkan**, which works with:
@@ -332,7 +343,7 @@ sudo apt install -y xdotool
sudo apt install -y wtype wl-clipboard xclip xsel
```
**Debian 11/12:**
**Debian 12+:**
```bash
sudo apt update
sudo apt install -y \
@@ -412,9 +423,14 @@ is unavailable on your snapshot, try `${PYVER}-venv`.
### 2. Create Virtual Environment
Use the **system** Python: PyGObject (`gi`) comes from your distro package and is
compiled for that interpreter only, so a venv created from pyenv/conda/uv Python
cannot import it even with `--system-site-packages`. Deactivate any active
virtualenv first.
```bash
cd vocalinux
python3 -m venv venv --system-site-packages
/usr/bin/python3 -m venv venv --system-site-packages
source venv/bin/activate
pip install --upgrade pip setuptools wheel
```
@@ -586,15 +602,34 @@ sudo apt install python3-gi python3-gi-cairo
# For appindicator (system tray icon) - try one of these:
sudo apt install gir1.2-appindicator3-0.1 # For older Debian/Ubuntu
# OR
sudo apt install gir1.2-ayatanaappindicator3-0.1 # For Debian 11+ or newer Ubuntu
sudo apt install gir1.2-ayatanaappindicator3-0.1 # For Debian 12+ or newer Ubuntu
# Recreate venv with system packages
# Recreate venv with system packages, from the system Python
deactivate 2>/dev/null || true
rm -rf venv
python3 -m venv venv --system-site-packages
/usr/bin/python3 -m venv venv --system-site-packages
source venv/bin/activate
pip install -e .
```
**Symptom:** the installer stops with "Distro PyGObject (python3-gi /
python3-gobject) is not importable in the venv"
The distro package is installed, but `venv/` was built by a different Python than
the one it targets (a pyenv/conda/uv interpreter, or a virtualenv that was active
when you started the installer). Current versions of `install.sh` detect and fix
this on their own — ignoring an activated virtualenv and rebuilding a mismatched
`venv/`. Otherwise:
```bash
deactivate 2>/dev/null || true
rm -rf venv
./install.sh
```
On systems shipping several system interpreters, point the installer at the one
your distro built `gi` for: `SYSTEM_PYTHON=/usr/bin/python3.12 ./install.sh`.
### Text Injection Not Working
**Symptom:** Recognized text doesn't appear in applications
+541 -139
View File
@@ -38,6 +38,36 @@ command_exists() {
command -v "$1" >/dev/null 2>&1
}
# The installer must build its venv from the *system* Python: distro PyGObject
# (python3-gi / python3-gobject) is compiled for that interpreter only. When the
# script starts inside an activated virtualenv — a shell left in uv's .venv
# after `just deps`, for instance — a bare `python3` resolves to that venv's
# interpreter instead, and a venv created from it cannot import gi even with
# --system-site-packages. Undo the activation for the installer's own process
# before anything looks up an interpreter.
deactivate_inherited_virtualenv() {
local venv_bin cleaned entry
[ -z "${VIRTUAL_ENV:-}" ] && return 0
print_warning "Running inside an activated virtualenv ($VIRTUAL_ENV)."
print_info "Ignoring it so the installation uses the system Python."
venv_bin="${VIRTUAL_ENV%/}/bin"
cleaned=""
while IFS= read -r entry; do
[ "$entry" = "$venv_bin" ] && continue
cleaned="${cleaned:+$cleaned:}$entry"
done < <(printf '%s\n' "${PATH//:/$'\n'}")
PATH="$cleaned"
export PATH
unset VIRTUAL_ENV
unset PYTHONHOME
}
deactivate_inherited_virtualenv
# HTTP-level connectivity check. ICMP ping is blocked on many networks
# (corporate firewalls, public Wi-Fi, CI runners), so probe the actual
# endpoints the installer depends on. Returns 0 if any probe succeeds.
@@ -684,20 +714,6 @@ detect_distro() {
fi
}
# Check minimum required version for Ubuntu-based systems
check_ubuntu_version() {
local MIN_VERSION="18.04"
if [[ "$DISTRO_FAMILY" == "ubuntu" ]]; then
if [[ $(echo -e "$DISTRO_VERSION\n$MIN_VERSION" | sort -V | head -n1) == "$MIN_VERSION" || "$DISTRO_VERSION" == "$MIN_VERSION" ]]; then
return 0
else
print_error "This application requires Ubuntu $MIN_VERSION or newer. Detected: $DISTRO_VERSION"
return 1
fi
fi
return 0
}
# Detect NVIDIA GPU presence
detect_nvidia_gpu() {
# Check if nvidia-smi command exists and can successfully query GPU
@@ -1454,24 +1470,25 @@ EOF
# Detect distribution
detect_distro
# Check compatibility
if [[ "$DISTRO_FAMILY" == "debian" ]]; then
print_info "Detected Debian — fully supported. Continuing with Debian-specific configuration."
elif [[ "$DISTRO_FAMILY" != "ubuntu" ]]; then
print_warning "This installer is primarily designed for Ubuntu/Debian-based systems. Your system: $DISTRO_NAME"
print_warning "The application may still work, but you might need to install dependencies manually."
if [[ "$NON_INTERACTIVE" == "yes" ]]; then
print_info "Non-interactive mode: continuing anyway..."
else
read -p "Do you want to continue anyway? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit "$EXIT_USER_ABORT"
fi
fi
else
# Check version for Ubuntu-based systems
if ! check_ubuntu_version; then
# Check compatibility. These tiers mirror docs/DISTRO_COMPATIBILITY.md, and the
# distro matrix builds on ubuntu, debian and fedora. Nothing here decides whether
# the install can proceed: what Vocalinux needs is an interpreter at the floor
# and that interpreter's distro PyGObject, checked by check_python_version()
# (fatal) and require_distro_gi() further down. This block only says how much
# help the package step is likely to be, so it must not turn away a distro the
# project documents as supported, and it must not key off a release label:
# derivatives carry their own numbering, so Linux Mint 22 and elementary OS 8
# report "22" and "8" while being built on Ubuntu 24.04 with Python 3.12.
case "$DISTRO_FAMILY" in
debian)
print_info "Detected Debian — fully supported. Continuing with Debian-specific configuration."
;;
ubuntu|fedora|arch|suse)
print_info "Detected $DISTRO_NAME ($DISTRO_FAMILY family) — supported."
;;
*)
print_warning "This installer has not been tested on $DISTRO_NAME; you may need to install dependencies manually."
print_warning "Vocalinux itself does not care which distribution it runs on, only that Python and PyGObject are new enough."
if [[ "$NON_INTERACTIVE" == "yes" ]]; then
print_info "Non-interactive mode: continuing anyway..."
else
@@ -1481,8 +1498,8 @@ else
exit "$EXIT_USER_ABORT"
fi
fi
fi
fi
;;
esac
# Handle installation mode selection
if [[ "$INTERACTIVE_MODE" == "ask" ]]; then
@@ -1727,7 +1744,7 @@ install_system_dependencies() {
local PACMAN_PACKAGES="python-pip python-gobject gtk3 ibus gobject-introspection python-cairo portaudio python-virtualenv pkg-config cmake wget curl unzip base-devel vulkan-tools vulkan-headers glslang xclip xsel wl-clipboard"
local ZYPPER_PACKAGES="gtk3 ibus-devel gobject-introspection-devel portaudio-devel pkg-config cmake wget curl unzip xclip xsel wl-clipboard typelib-1_0-Notify-0_7 libnotify4"
# Gentoo uses Portage and different package naming convention
local EMERGE_PACKAGES="dev-python/pygobject:3 x11-libs/gtk+:3 dev-libs/libayatana-appindicator media-libs/portaudio dev-lang/python:3.9 pkgconf cmake dev-util/glslang x11-misc/xclip x11-misc/xsel gui-apps/wl-clipboard"
local EMERGE_PACKAGES="dev-python/pygobject:3 x11-libs/gtk+:3 dev-libs/libayatana-appindicator media-libs/portaudio dev-lang/python:3.11 pkgconf cmake dev-util/glslang x11-misc/xclip x11-misc/xsel gui-apps/wl-clipboard"
# Alpine Linux uses apk and has musl libc
local APK_PACKAGES="py3-gobject3 py3-pip gtk+3.0 py3-cairo portaudio-dev py3-virtualenv pkgconf cmake wget curl unzip glslang vulkan-tools xclip xsel wl-clipboard"
# Void Linux uses xbps
@@ -2093,7 +2110,7 @@ install_system_dependencies() {
print_info " ./install.sh --skip-system-deps"
print_info ""
print_info "4. Or install from source in a virtual environment:"
print_info " python3 -m venv venv"
print_info " /usr/bin/python3 -m venv --system-site-packages venv"
print_info " source venv/bin/activate"
print_info " pip install -e .[whisper,vad]"
print_info ""
@@ -2411,34 +2428,130 @@ mkdir -p "$DATA_DIR/models"
mkdir -p "$DESKTOP_DIR"
mkdir -p "$ICON_DIR"
# Interpreter the venv is built from. Resolved by select_python_interpreter();
# nothing below may fall back to a bare `python3` for venv creation.
PYTHON_CMD="python3"
# The distro interpreter its GTK/PyGObject packages are built for. Overridable
# (SYSTEM_PYTHON=/usr/bin/python3.12 ./install.sh) for systems that ship several.
SYSTEM_PYTHON="${SYSTEM_PYTHON:-/usr/bin/python3}"
python_version_of() {
"$1" -c "import sys; print('%d.%d' % sys.version_info[:2])" 2>/dev/null
}
python_version_at_least() {
local version
version=$(python_version_of "$1") || return 1
[ -n "$version" ] || return 1
[[ $(printf '%s\n%s\n' "$version" "$2" | sort -V | head -n1) == "$2" ]]
}
python_has_gi() {
"$1" -c "import gi" >/dev/null 2>&1
}
# Pick the interpreter to build the venv from. Distro PyGObject is compiled for
# exactly one Python, so prefer a candidate that can already import gi:
# $SYSTEM_PYTHON is probed explicitly because PATH may expose a different
# interpreter (pyenv, uv, /usr/local) that the distro packages were never built
# for. Falls back to the newest-enough candidate when none of them has gi yet —
# require_distro_gi() reports that case with a proper message later on.
select_python_interpreter() {
local min_version="$1"
local candidates=() candidate seen="" first="" ok_version="" ok_system=""
if command_exists python3; then
candidates+=("$(command -v python3)")
fi
if [ -x "$SYSTEM_PYTHON" ]; then
candidates+=("$SYSTEM_PYTHON")
fi
for candidate in ${candidates[@]+"${candidates[@]}"}; do
case ":$seen:" in
*":$candidate:"*) continue ;;
esac
seen="${seen:+$seen:}$candidate"
[ -n "$first" ] || first="$candidate"
python_version_at_least "$candidate" "$min_version" || continue
[ -n "$ok_version" ] || ok_version="$candidate"
if [ "$candidate" = "$SYSTEM_PYTHON" ]; then
ok_system="$candidate"
fi
if python_has_gi "$candidate"; then
PYTHON_CMD="$candidate"
return 0
fi
done
# No candidate has gi yet; it may be installed later in this run. Prefer the
# distro interpreter, because its PyGObject is the one that will show up.
PYTHON_CMD="${ok_system:-${ok_version:-$first}}"
[ -n "$PYTHON_CMD" ]
}
# Check Python version
check_python_version() {
local MIN_VERSION="3.9"
local PYTHON_CMD="python3"
# Keep in sync with requires-python in pyproject.toml.
local MIN_VERSION="3.11"
# Check if python3 command exists
if ! command_exists python3; then
if ! select_python_interpreter "$MIN_VERSION"; then
print_error "Python 3 is not installed or not in PATH"
return 1
fi
# Get Python version
local PY_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
print_info "Detected Python version: $PY_VERSION"
local PY_VERSION
PY_VERSION=$(python_version_of "$PYTHON_CMD" || true)
print_info "Detected Python version: ${PY_VERSION:-unknown} ($PYTHON_CMD)"
# Compare versions
if [[ $(echo -e "$PY_VERSION\n$MIN_VERSION" | sort -V | head -n1) == "$MIN_VERSION" || "$PY_VERSION" == "$MIN_VERSION" ]]; then
if python_version_at_least "$PYTHON_CMD" "$MIN_VERSION"; then
return 0
else
print_error "This application requires Python $MIN_VERSION or newer. Detected: $PY_VERSION"
return 1
fi
print_error "This application requires Python $MIN_VERSION or newer. Detected: ${PY_VERSION:-unknown}"
return 1
}
# An existing venv built by a different interpreter than the selected one is the
# classic cause of "distro PyGObject is not importable": PyGObject lives in the
# system Python's site-packages and --system-site-packages only exposes it to a
# venv of the *same* version. Such a venv otherwise survives every re-run,
# because the installer reuses whatever it finds.
# Where an interpreter's installation lives. For a venv this is the interpreter
# it was built from, which is what decides whether distro gi is visible.
python_base_prefix() {
"$1" -c "import sys; print(sys.base_prefix)" 2>/dev/null
}
venv_matches_selected_python() {
local venv_python="$VENV_DIR/bin/python"
local venv_base selected_base
[ -x "$venv_python" ] || return 1
# Compare installations, not the X.Y string: a distro 3.12 and a pyenv/uv
# 3.12 are not interchangeable, because distro PyGObject is importable only
# from the one it was built for.
venv_base=$(python_base_prefix "$venv_python") || return 1
selected_base=$(python_base_prefix "$PYTHON_CMD") || return 1
[ -n "$venv_base" ] && [ "$venv_base" = "$selected_base" ]
}
# Set up virtual environment with error handling
setup_virtual_environment() {
print_info "Setting up Python virtual environment in $VENV_DIR..."
# Discard a venv left behind by another interpreter before the reuse logic
# below can adopt it.
if [ -d "$VENV_DIR" ] && [ -f "$VENV_DIR/bin/activate" ] && ! venv_matches_selected_python; then
print_warning "Existing virtual environment in $VENV_DIR was built by a different Python than $PYTHON_CMD."
print_warning "Recreating it — distro PyGObject would stay invisible inside it otherwise."
rm -rf "$VENV_DIR"
fi
# Check if virtual environment already exists
if [ -d "$VENV_DIR" ] && [ -f "$VENV_DIR/bin/activate" ]; then
print_warning "Virtual environment already exists in $VENV_DIR"
@@ -2464,9 +2577,9 @@ setup_virtual_environment() {
# Create virtual environment
# Use --system-site-packages to access pre-compiled system packages like PyGObject
# This avoids build failures with Python 3.13+ where PyGObject may not build from source
python3 -m venv --system-site-packages "$VENV_DIR" || {
print_warning "python3 -m venv failed, trying python3 -m virtualenv..."
python3 -m virtualenv --system-site-packages "$VENV_DIR" || {
"$PYTHON_CMD" -m venv --system-site-packages "$VENV_DIR" || {
print_warning "$PYTHON_CMD -m venv failed, trying $PYTHON_CMD -m virtualenv..."
"$PYTHON_CMD" -m virtualenv --system-site-packages "$VENV_DIR" || {
print_error "Failed to create virtual environment. Please check your Python installation."
exit "$EXIT_MISSING_DEPS"
}
@@ -2482,9 +2595,13 @@ setup_virtual_environment() {
print_info "Virtual environment activated successfully."
}
# Check Python version
# Check Python version. Not advisory: distro PyGObject is built for the system
# interpreter, so a venv below the floor cannot import gi, and the install would
# fail later somewhere less obvious.
if ! check_python_version; then
print_warning "Continuing with unsupported Python version. Some features may not work correctly."
print_error "Point SYSTEM_PYTHON at a newer interpreter if one is installed:"
print_error " SYSTEM_PYTHON=/usr/bin/python3.12 ./install.sh"
exit "$EXIT_MISSING_DEPS"
fi
# Set up virtual environment
@@ -2938,6 +3055,15 @@ install_whispercpp_with_gpu_support() {
print_warning "Could not install vosk; install it manually or pick another engine in Settings."
fi
# Writing engine=vosk when the import still fails only moves the failure
# to startup, where it surfaces as ModuleNotFoundError: No module named
# 'vosk' and the app never comes up.
if ! "$VENV_DIR/bin/python" -c "import vosk" 2>/dev/null; then
print_warning "vosk is still not importable; leaving the engine configuration untouched."
SELECTED_ENGINE=""
return 0
fi
local FALLBACK_VOSK_CONFIG="$CONFIG_DIR/config.json"
if [ ! -f "$FALLBACK_VOSK_CONFIG" ]; then
mkdir -p "$CONFIG_DIR"
@@ -3023,6 +3149,7 @@ PY
require_distro_gi() {
if ! "$VENV_DIR/bin/python" -c "import gi" 2>/dev/null; then
print_error "Distro PyGObject (python3-gi / python3-gobject) is not importable in the venv."
print_error "The venv was built from $PYTHON_CMD (Python $(python_version_of "$PYTHON_CMD" || echo unknown))."
print_error "Install it with your package manager. Pip cannot build PyGObject here."
exit "$EXIT_MISSING_DEPS"
fi
@@ -3523,6 +3650,150 @@ if ! install_python_package; then
exit "$EXIT_NETWORK"
fi
# ---------------------------------------------------------------------------
# Model integrity verification
#
# Models are 40MB-2GB downloads that end up being loaded by native code, so no
# model is installed without matching a digest pinned in this repository.
# src/vocalinux/utils/model_checksums.txt is the same manifest the application
# uses at runtime; it is regenerated by scripts/generate-model-checksums.py.
#
# A model that cannot be verified is deleted and the function returns 1, which
# callers already treat as "leave it for first run" rather than aborting the
# install. The app re-downloads and re-verifies it later.
# ---------------------------------------------------------------------------
MODEL_CHECKSUMS_FILE="$INSTALL_DIR/src/vocalinux/utils/model_checksums.txt"
# Print the digest of $1 using algorithm $2, trying the tools most likely present.
compute_file_digest() {
local file="$1" algo="$2"
case "$algo" in
sha256)
if command_exists sha256sum; then sha256sum "$file" | cut -d' ' -f1
elif command_exists shasum; then shasum -a 256 "$file" | cut -d' ' -f1
elif command_exists openssl; then openssl dgst -sha256 "$file" | awk '{print $NF}'
else return 1; fi
;;
*)
return 1
;;
esac
}
# Compare $1 against algorithm $2 and digest $3. $4 labels the file in messages.
verify_digest() {
local file="$1" algo="$2" expected="$3" label="$4" actual
if ! actual=$(compute_file_digest "$file" "$algo") || [ -z "$actual" ]; then
print_error "Cannot compute the $algo digest of $label: no sha256sum, shasum or openssl found."
return 1
fi
if [ "$actual" != "$expected" ]; then
print_error "$label failed $algo verification."
print_error " expected: $expected"
print_error " actual: $actual"
print_error "The file does not match the digest pinned in this release."
return 1
fi
print_success "$label verified ($algo)"
return 0
}
# Verify $1 against the manifest entry named $2 (defaults to $1's basename).
verify_model_checksum() {
local file="$1"
local key="${2:-$(basename "$file")}"
local algo expected size actual_size
if [ ! -f "$MODEL_CHECKSUMS_FILE" ]; then
print_error "Checksum manifest not found at $MODEL_CHECKSUMS_FILE."
print_error "Cannot verify $key; refusing to install an unverified model."
return 1
fi
# Manifest columns: filename algorithm digest size-in-bytes
algo=$(awk -v k="$key" '$1==k {print $2; exit}' "$MODEL_CHECKSUMS_FILE")
expected=$(awk -v k="$key" '$1==k {print $3; exit}' "$MODEL_CHECKSUMS_FILE")
size=$(awk -v k="$key" '$1==k {print $4; exit}' "$MODEL_CHECKSUMS_FILE")
if [ -z "$algo" ] || [ -z "$expected" ]; then
print_error "No checksum is pinned for $key in $MODEL_CHECKSUMS_FILE."
print_error "Regenerate it with scripts/generate-model-checksums.py."
return 1
fi
# Size first: it is free, and it reports a truncated download as truncation
# rather than as a digest mismatch that reads like tampering.
if [ -n "$size" ] && [ "$size" != "0" ]; then
actual_size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo "")
if [ -n "$actual_size" ] && [ "$actual_size" != "$size" ]; then
print_error "$key is $actual_size bytes, expected $size (truncated download?)."
return 1
fi
fi
verify_digest "$file" "$algo" "$expected" "$key"
}
# Verify an OpenAI Whisper checkpoint against the sha256 embedded in its own URL
# (they publish each file under a path segment that is its digest).
verify_openai_model_checksum() {
local file="$1" url="$2" label="$3" expected
expected=$(printf '%s\n' "$url" | grep -oE '/[0-9a-f]{64}/' | tr -d '/' | head -n1)
if [ -z "$expected" ]; then
print_error "$url carries no sha256 path segment; refusing to install an unverified model."
return 1
fi
verify_digest "$file" "sha256" "$expected" "$label"
}
# Download $1 to $2, preferring wget and falling back to curl. $3 labels the
# model in error messages. Leaves no partial file behind on failure.
download_model_file() {
local url="$1" dest="$2" label="$3"
if command_exists wget; then
if ! wget --progress=bar:force:noscroll --tries=3 --timeout=60 -O "$dest" "$url" 2>&1; then
print_error "Failed to download $label with wget"
rm -f "$dest"
return 1
fi
elif command_exists curl; then
# -f: fail on HTTP errors instead of saving the error page as the model
if ! curl -fL --progress-bar --retry 3 --retry-delay 2 -o "$dest" "$url"; then
print_error "Failed to download $label with curl"
rm -f "$dest"
return 1
fi
else
print_error "Neither wget nor curl is available to download $label"
return 1
fi
if [ ! -s "$dest" ]; then
print_error "Downloaded $label is empty or missing"
rm -f "$dest"
return 1
fi
}
# Print the digest pinned for manifest entry $1, or nothing when unpinned.
pinned_digest_for() {
[ -f "$MODEL_CHECKSUMS_FILE" ] || return 1
awk -v k="$1" '$1==k {print $3; exit}' "$MODEL_CHECKSUMS_FILE"
}
# The Hugging Face commit the whisper.cpp digests were taken at.
whispercpp_pinned_revision() {
[ -f "$MODEL_CHECKSUMS_FILE" ] || return 1
awk '/^#[[:space:]]*whispercpp-revision:/ {print $3; exit}' "$MODEL_CHECKSUMS_FILE"
}
# Function to download and install Whisper tiny model
install_whisper_model() {
print_info "Installing Whisper tiny model (~75MB)..."
@@ -3535,10 +3806,16 @@ install_whisper_model() {
local TINY_MODEL_URL="https://openaipublic.azureedge.net/main/whisper/models/65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/tiny.pt"
local TINY_MODEL_PATH="$WHISPER_DIR/tiny.pt"
# Check if model already exists
# An existing file is not a verified file: it may predate checksum
# verification, or have been replaced since. Hash it before trusting it,
# and re-download rather than keep something that does not match.
if [ -f "$TINY_MODEL_PATH" ]; then
print_info "Whisper tiny model already exists at $TINY_MODEL_PATH"
return 0
if verify_openai_model_checksum "$TINY_MODEL_PATH" "$TINY_MODEL_URL" "Whisper tiny model"; then
print_info "Whisper tiny model already exists at $TINY_MODEL_PATH"
return 0
fi
print_warning "The existing Whisper tiny model does not match its pinned digest; replacing it."
rm -f "$TINY_MODEL_PATH"
fi
# Check internet connectivity
@@ -3560,26 +3837,15 @@ install_whisper_model() {
local TEMP_FILE="$VOCALINUX_TMP_DIR/tiny.pt"
# Download the model
if command -v wget >/dev/null 2>&1; then
if ! wget --progress=bar:force:noscroll --tries=3 --timeout=60 -O "$TEMP_FILE" "$TINY_MODEL_URL" 2>&1; then
print_error "Failed to download Whisper model with wget"
rm -f "$TEMP_FILE"
return 1
fi
elif command -v curl >/dev/null 2>&1; then
# -f: fail on HTTP errors instead of saving the error page as the model
if ! curl -fL --progress-bar --retry 3 --retry-delay 2 -o "$TEMP_FILE" "$TINY_MODEL_URL"; then
print_error "Failed to download Whisper model with curl"
rm -f "$TEMP_FILE"
return 1
fi
if ! download_model_file "$TINY_MODEL_URL" "$TEMP_FILE" "Whisper model"; then
return 1
fi
# Verify download
if [ ! -f "$TEMP_FILE" ] || [ ! -s "$TEMP_FILE" ]; then
print_error "Downloaded model file is empty or missing"
# Verify before the file reaches its final location, so a failed download is
# never installed. (The "already exists" path above hashes what it finds.)
if ! verify_openai_model_checksum "$TEMP_FILE" "$TINY_MODEL_URL" "Whisper tiny model"; then
rm -f "$TEMP_FILE"
print_warning "Whisper model will be downloaded and verified on first application run."
return 1
fi
@@ -3614,10 +3880,26 @@ install_vosk_models() {
local SMALL_MODEL_NAME="vosk-model-small-en-us-0.15"
local SMALL_MODEL_PATH="$MODELS_DIR/$SMALL_MODEL_NAME"
# Check if small model already exists
local SMALL_MODEL_ARCHIVE="$SMALL_MODEL_NAME.zip"
# The extracted tree has no digest of its own — the pin covers the zip, which
# is deleted after unpacking. So whoever unpacks it records the verified zip
# digest in a stamp (here, and in the app's own downloader), and the directory
# is trusted only while that stamp matches what we pin today.
local SMALL_MODEL_STAMP="$SMALL_MODEL_PATH/.vocalinux_verified"
local EXPECTED_ZIP_DIGEST
EXPECTED_ZIP_DIGEST=$(pinned_digest_for "$SMALL_MODEL_ARCHIVE" || true)
if [ -d "$SMALL_MODEL_PATH" ]; then
print_info "Small VOSK model already exists at $SMALL_MODEL_PATH"
return 0
if [ -n "$EXPECTED_ZIP_DIGEST" ] && [ -f "$SMALL_MODEL_STAMP" ] &&
[ "$(cat "$SMALL_MODEL_STAMP" 2>/dev/null)" = "$EXPECTED_ZIP_DIGEST" ]; then
print_info "Small VOSK model already exists at $SMALL_MODEL_PATH (verified)"
return 0
fi
# Unverified is not the same as known-bad, and this runs on every install:
# a tree unpacked before stamps existed, or by the app itself, simply has
# no stamp. Deleting it here would trade a working model for no model
# whenever the download, unzip or network that follows fails. Fetch and
# verify the replacement first; the swap below is what removes this tree.
print_warning "The existing VOSK model carries no matching verification stamp; re-downloading it."
fi
# Check internet connectivity
@@ -3639,62 +3921,88 @@ install_vosk_models() {
local TEMP_ZIP="$VOCALINUX_TMP_DIR/$(basename $SMALL_MODEL_URL)"
# Download the model
if command -v wget >/dev/null 2>&1; then
if ! wget --progress=bar:force:noscroll --tries=3 --timeout=60 -O "$TEMP_ZIP" "$SMALL_MODEL_URL" 2>&1; then
print_error "Failed to download VOSK model with wget"
rm -f "$TEMP_ZIP"
return 1
fi
elif command -v curl >/dev/null 2>&1; then
# -f: fail on HTTP errors instead of saving the error page as the model
if ! curl -fL --progress-bar --retry 3 --retry-delay 2 -o "$TEMP_ZIP" "$SMALL_MODEL_URL"; then
print_error "Failed to download VOSK model with curl"
rm -f "$TEMP_ZIP"
return 1
fi
if ! download_model_file "$SMALL_MODEL_URL" "$TEMP_ZIP" "VOSK model"; then
return 1
fi
# Verify download
if [ ! -f "$TEMP_ZIP" ] || [ ! -s "$TEMP_ZIP" ]; then
print_error "Downloaded model file is empty or missing"
# Verify before extracting: a zip that fails its pinned digest must not get
# as far as writing files into the models directory.
if ! verify_model_checksum "$TEMP_ZIP"; then
rm -f "$TEMP_ZIP"
print_warning "VOSK model will be downloaded and verified on first application run."
return 1
fi
print_info "Extracting VOSK model..."
# Extract the model
if command -v unzip >/dev/null 2>&1; then
if ! unzip -q "$TEMP_ZIP" -d "$MODELS_DIR"; then
print_error "Failed to extract VOSK model"
rm -f "$TEMP_ZIP"
return 1
fi
else
if ! command -v unzip >/dev/null 2>&1; then
print_error "unzip command not found. Cannot extract VOSK model."
rm -f "$TEMP_ZIP"
return 1
fi
# Clean up zip file
rm -f "$TEMP_ZIP"
# Verify extraction
if [ -d "$SMALL_MODEL_PATH" ]; then
print_success "VOSK small model installed successfully at $SMALL_MODEL_PATH"
# Set proper permissions
chmod -R 755 "$SMALL_MODEL_PATH"
# Create a marker file to indicate this model was pre-installed
echo "$(date)" > "$SMALL_MODEL_PATH/.vocalinux_preinstalled"
return 0
else
print_error "VOSK model extraction failed - directory not found"
# Unpack beside the model rather than over it: any model already installed
# stays usable until a complete, verified replacement exists, and staging in
# MODELS_DIR keeps the swap a same-filesystem rename.
local STAGING_DIR="$MODELS_DIR/.vosk-staging.$$"
# A run killed mid-swap leaves its scratch directories behind; they are named
# so they can be recognised and are of no use to a later run.
find "$MODELS_DIR" -maxdepth 1 -type d \
\( -name '.vosk-staging.*' -o -name '.*.replaced.*' \) \
-exec rm -rf {} + 2>/dev/null || true
if ! mkdir -p "$STAGING_DIR"; then
print_error "Failed to create a staging directory under $MODELS_DIR"
rm -f "$TEMP_ZIP"
return 1
fi
if ! unzip -q "$TEMP_ZIP" -d "$STAGING_DIR"; then
print_error "Failed to extract VOSK model"
rm -f "$TEMP_ZIP"
rm -rf "$STAGING_DIR"
return 1
fi
rm -f "$TEMP_ZIP"
if [ ! -d "$STAGING_DIR/$SMALL_MODEL_NAME" ]; then
print_error "VOSK model extraction failed - $SMALL_MODEL_NAME not found in the archive"
rm -rf "$STAGING_DIR"
return 1
fi
chmod -R 755 "$STAGING_DIR/$SMALL_MODEL_NAME"
echo "$(date)" > "$STAGING_DIR/$SMALL_MODEL_NAME/.vocalinux_preinstalled"
# Record the digest this tree was extracted from, so a later run can tell a
# verified model from one that merely exists. Written before the swap: an
# unstamped tree is one this function would download all over again.
if [ -z "$EXPECTED_ZIP_DIGEST" ] ||
! printf '%s\n' "$EXPECTED_ZIP_DIGEST" > "$STAGING_DIR/$SMALL_MODEL_NAME/.vocalinux_verified"; then
print_error "Could not record the verified digest for $SMALL_MODEL_NAME"
rm -rf "$STAGING_DIR"
return 1
fi
# Swap. The old tree is moved aside rather than deleted, so a failed rename
# can put it back.
local REPLACED_DIR="$MODELS_DIR/.$SMALL_MODEL_NAME.replaced.$$"
if [ -d "$SMALL_MODEL_PATH" ] && ! mv "$SMALL_MODEL_PATH" "$REPLACED_DIR"; then
print_error "Could not move the existing VOSK model aside; keeping it"
rm -rf "$STAGING_DIR"
return 1
fi
if ! mv "$STAGING_DIR/$SMALL_MODEL_NAME" "$SMALL_MODEL_PATH"; then
print_error "Failed to install the verified VOSK model"
if [ -d "$REPLACED_DIR" ]; then
mv "$REPLACED_DIR" "$SMALL_MODEL_PATH"
fi
rm -rf "$STAGING_DIR"
return 1
fi
rm -rf "$REPLACED_DIR" "$STAGING_DIR"
print_success "VOSK small model installed successfully at $SMALL_MODEL_PATH"
return 0
}
# Function to download and install whisper.cpp tiny model
@@ -3705,14 +4013,29 @@ install_whispercpp_model() {
local WHISPERCPP_DIR="$DATA_DIR/models/whispercpp"
mkdir -p "$WHISPERCPP_DIR"
# whisper.cpp tiny model URL and path
local TINY_MODEL_URL="https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin"
# whisper.cpp tiny model URL and path. The Hugging Face revision is pinned to
# the one the digests in model_checksums.txt were taken at, so upstream
# replacing ggml-tiny.bin cannot turn every install into a checksum failure.
local WHISPERCPP_REVISION
WHISPERCPP_REVISION=$(whispercpp_pinned_revision)
if [ -z "$WHISPERCPP_REVISION" ]; then
print_error "No whisper.cpp revision is pinned in $MODEL_CHECKSUMS_FILE."
print_warning "whisper.cpp model will be downloaded and verified on first application run."
return 1
fi
local TINY_MODEL_URL="https://huggingface.co/ggerganov/whisper.cpp/resolve/$WHISPERCPP_REVISION/ggml-tiny.bin"
local TINY_MODEL_PATH="$WHISPERCPP_DIR/ggml-tiny.bin"
# Check if model already exists
# An existing file is not a verified file: it may predate checksum
# verification, or have been replaced since. Hash it before trusting it,
# and re-download rather than keep something that does not match.
if [ -f "$TINY_MODEL_PATH" ]; then
print_info "whisper.cpp tiny model already exists at $TINY_MODEL_PATH"
return 0
if verify_model_checksum "$TINY_MODEL_PATH" "ggml-tiny.bin"; then
print_info "whisper.cpp tiny model already exists at $TINY_MODEL_PATH"
return 0
fi
print_warning "The existing whisper.cpp tiny model does not match its pinned digest; replacing it."
rm -f "$TINY_MODEL_PATH"
fi
# Check internet connectivity
@@ -3734,26 +4057,15 @@ install_whispercpp_model() {
local TEMP_FILE="$VOCALINUX_TMP_DIR/ggml-tiny.bin"
# Download the model
if command -v wget >/dev/null 2>&1; then
if ! wget --progress=bar:force:noscroll --tries=3 --timeout=60 -O "$TEMP_FILE" "$TINY_MODEL_URL" 2>&1; then
print_error "Failed to download whisper.cpp model with wget"
rm -f "$TEMP_FILE"
return 1
fi
elif command -v curl >/dev/null 2>&1; then
# -f: fail on HTTP errors instead of saving the error page as the model
if ! curl -fL --progress-bar --retry 3 --retry-delay 2 -o "$TEMP_FILE" "$TINY_MODEL_URL"; then
print_error "Failed to download whisper.cpp model with curl"
rm -f "$TEMP_FILE"
return 1
fi
if ! download_model_file "$TINY_MODEL_URL" "$TEMP_FILE" "whisper.cpp model"; then
return 1
fi
# Verify download
if [ ! -f "$TEMP_FILE" ] || [ ! -s "$TEMP_FILE" ]; then
print_error "Downloaded model file is empty or missing"
# Verify before the file reaches its final location, so a failed download is
# never installed. (The "already exists" path above hashes what it finds.)
if ! verify_model_checksum "$TEMP_FILE" "ggml-tiny.bin"; then
rm -f "$TEMP_FILE"
print_warning "whisper.cpp model will be downloaded and verified on first application run."
return 1
fi
@@ -3974,6 +4286,96 @@ 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
}
verify_configured_engine() {
local CONFIG_FILE="$CONFIG_DIR/config.json"
[ -f "$CONFIG_FILE" ] || return 0
local CONFIGURED_ENGINE
CONFIGURED_ENGINE=$("$VENV_DIR/bin/python" - "$CONFIG_FILE" <<'PY' 2>/dev/null || true
import json
import sys
try:
with open(sys.argv[1]) as handle:
print(json.load(handle).get("speech_recognition", {}).get("engine", ""))
except Exception:
pass
PY
)
local MODULE
MODULE=$(engine_import_module "$CONFIGURED_ENGINE")
# remote_api and anything unrecognised need no extra module.
[ -n "$MODULE" ] || return 0
venv_can_import "$MODULE" && return 0
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."
print_info "Reinstall $(engine_pip_name "$CONFIGURED_ENGINE") and switch back in Settings if you want it."
return 0
fi
fi
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")"
print_error "or edit $CONFIG_FILE and set speech_recognition.engine to an installed engine."
return 1
}
if ! verify_configured_engine; then
exit "$EXIT_MISSING_DEPS"
fi
# Update icon cache
update_icon_cache
+52 -16
View File
@@ -1,7 +1,24 @@
# Vocalinux justfile
# Convenient commands for development
# Run `just` to list all recipes. Python tooling (pytest, black, mypy) lives in
# venv/ — run `source venv/bin/activate` first.
# Run `just` to list all recipes. Python tooling runs through `uv run` against
# .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.
# 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.
DEV_EXTRAS := "--extra dev --extra vad --group lint"
# List available recipes
default:
@@ -15,42 +32,50 @@ install:
install-dev:
./install.sh --dev
# Install development dependencies into .venv/ (dev + vad extras)
deps:
uv sync {{DEV_EXTRAS}}
# Install every optional extra — whisper/vosk engines and docs (CUDA torch, multi-GB)
deps-all:
uv sync --all-extras --group lint
# Run test suite
test:
@echo "Running tests..."
pytest -v
uv run {{DEV_EXTRAS}} pytest -v
# Run tests with coverage
test-cov:
@echo "Running tests with coverage..."
pytest --cov=src --cov-report=html --cov-report=term
uv run {{DEV_EXTRAS}} pytest --cov=src --cov-report=html --cov-report=term
@echo "Coverage report generated in htmlcov/"
# Run linters (flake8, black, isort)
lint:
@echo "Running flake8..."
flake8 src/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
uv run {{DEV_EXTRAS}} flake8 src/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
@echo "Checking black formatting..."
black --check --diff src/ tests/
uv run {{DEV_EXTRAS}} black --check --diff src/ tests/
@echo "Checking isort..."
isort --check-only --diff --profile black src/ tests/
uv run {{DEV_EXTRAS}} isort --check-only --diff --profile black src/ tests/
# Auto-format code (black + isort)
format:
@echo "Formatting with black..."
black src/ tests/
uv run {{DEV_EXTRAS}} black src/ tests/
@echo "Sorting imports with isort..."
isort --profile black src/ tests/
uv run {{DEV_EXTRAS}} isort --profile black src/ tests/
# Run type checking (mypy)
typecheck:
@echo "Running mypy..."
mypy src/
uv run {{DEV_EXTRAS}} mypy src/
# Build distribution packages
build:
@echo "Building distribution packages..."
python -m build
uv build
@echo "Built packages in dist/"
# Regenerate uv.lock and the hash-pinned requirements/* exports.
@@ -60,7 +85,10 @@ lock:
uv lock
uv export --no-dev --no-emit-project --no-emit-package pygobject -o requirements/runtime.txt
uv export --no-dev --extra vad --no-emit-project --no-emit-package pygobject -o requirements/vad.txt
uv export --extra dev --no-emit-project --no-emit-package pygobject -o requirements/dev.txt
# --group lint too: the linters live in a dependency group, not in the dev
# extra, so exporting the extra alone produced a file that reproduced
# neither `just deps` nor what CI lints with.
uv export --extra dev --group lint --no-emit-project --no-emit-package pygobject -o requirements/dev.txt
uv pip compile requirements/whisper.in --generate-hashes --emit-index-url \
--index-url https://pypi.org/simple \
--extra-index-url https://download.pytorch.org/whl/cpu \
@@ -70,6 +98,14 @@ lock:
lock-check:
uv lock --check
# Refresh the pinned model digests. whisper.cpp digests come from Hugging Face
# `lfs` metadata and cost no bandwidth; VOSK is pinned by the bytes we fetch, so
# 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
# Remove build artifacts
clean:
@echo "Cleaning build artifacts..."
@@ -96,16 +132,16 @@ run-debug:
# Run from source
run-source:
python -m vocalinux.main
uv run {{DEV_EXTRAS}} python -m vocalinux.main
# Run from source with debug logging
run-source-debug:
python -m vocalinux.main --debug
uv run {{DEV_EXTRAS}} python -m vocalinux.main --debug
# Run pre-commit hooks on all files
pre-commit:
pre-commit run --all-files
uv run {{DEV_EXTRAS}} pre-commit run --all-files
# Print the current version
version:
@python -c "from src.vocalinux.version import __version__; print(__version__)"
@uv run --no-sync python -c "from src.vocalinux.version import __version__; print(__version__)"
+1 -1
View File
@@ -11,7 +11,7 @@ arch=('any')
url="https://github.com/VocaHQ/vocalinux"
license=('AGPL-3.0-only')
depends=(
'python>=3.9'
'python>=3.11'
'python-gobject'
'gtk3'
'libayatana-appindicator'
+24 -9
View File
@@ -14,7 +14,7 @@ authors = [
maintainers = [
{name = "Jatin K Malik", email = "jatinkrmalik@gmail.com"}
]
requires-python = ">=3.9"
requires-python = ">=3.11"
keywords = [
"voice", "dictation", "speech", "recognition",
"linux", "accessibility", "voice-typing", "speech-to-text"
@@ -26,8 +26,6 @@ classifiers = [
"License :: OSI Approved :: GNU Affero General Public License v3",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
@@ -65,9 +63,6 @@ dev = [
"pytest-cov>=4.0.0",
"pytest-mock>=3.10.0",
"pytest-timeout>=2.1.0",
"black>=23.0.0",
"isort>=5.12.0",
"flake8>=6.0.0",
"pre-commit>=3.0.0",
"mypy>=1.0.0",
]
@@ -76,6 +71,16 @@ docs = [
"sphinx-rtd-theme>=1.2.0",
]
# Formatters and linters, deliberately outside the `dev` extra: `uv sync
# --only-group lint` installs them without the project, so the lint job needs
# no portaudio/girepository headers to build pyaudio and PyGObject.
[dependency-groups]
lint = [
"black>=23.0.0",
"isort>=5.12.0",
"flake8>=6.0.0",
]
[project.scripts]
vocalinux = "vocalinux.main:main"
@@ -92,7 +97,7 @@ Changelog = "https://github.com/VocaHQ/vocalinux/releases"
[tool.uv]
# Lock/exports are generated with uv; keep the minor pinned so lockfile
# upgrades are deliberate (see `just lock`).
required-version = ">=0.11,<0.12"
required-version = ">=0.12,<0.13"
[tool.setuptools]
packages = ["vocalinux", "vocalinux.resources", "vocalinux.speech_recognition", "vocalinux.text_injection", "vocalinux.ui", "vocalinux.ui.keyboard_backends", "vocalinux.utils"]
@@ -105,10 +110,11 @@ version = {attr = "vocalinux.version.__version__"}
"*" = ["*.svg", "*.wav", "*.onnx"]
"vocalinux" = ["resources/**/*"]
"vocalinux.speech_recognition" = ["data/*.onnx", "data/LICENSE"]
"vocalinux.utils" = ["model_checksums.txt"]
[tool.black]
line-length = 100
target-version = ['py39', 'py310', 'py311', 'py312', 'py313', 'py314']
target-version = ['py311', 'py312', 'py313', 'py314']
include = '\.pyi?$'
exclude = '''
/(
@@ -166,8 +172,17 @@ exclude_lines = [
show_missing = true
[tool.mypy]
python_version = "3.9"
# Target the supported floor so typechecking covers what requires-python promises.
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
ignore_missing_imports = true
exclude = ["venv", "build", "dist", "node_modules"]
# numpy 2.x ships stubs written in 3.12 syntax, which a 3.11 target cannot parse.
# We only pass arrays through, so skipping the stubs costs little compared to
# type-checking against the Python version we actually promise to support.
[[tool.mypy.overrides]]
module = ["numpy", "numpy.*"]
follow_imports = "skip"
follow_imports_for_stubs = true
+28 -592
View File
File diff suppressed because it is too large Load Diff
+12 -257
View File
@@ -5,17 +5,13 @@ certifi==2026.7.22 \
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
# via requests
charset-normalizer==3.5.1 \
--hash=sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45 \
--hash=sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d \
--hash=sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5 \
--hash=sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b \
--hash=sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f \
--hash=sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f \
--hash=sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5 \
--hash=sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22 \
--hash=sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5 \
--hash=sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac \
--hash=sha256:13e3afe97712e8887cd516e960c63f0b93122971e5b5e4b2622fe7701771e838 \
--hash=sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90 \
--hash=sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626 \
--hash=sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4 \
@@ -26,9 +22,7 @@ charset-normalizer==3.5.1 \
--hash=sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1 \
--hash=sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102 \
--hash=sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8 \
--hash=sha256:29880d17a8eb0b5cfdfd8944b468322928059aa35f1f5fa8ff22b149ec0b42f8 \
--hash=sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9 \
--hash=sha256:2e9cf9253119d8e5d111f05d71626786fd3d6193817316eab1ca088cdb8593cf \
--hash=sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0 \
--hash=sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031 \
--hash=sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e \
@@ -38,15 +32,11 @@ charset-normalizer==3.5.1 \
--hash=sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c \
--hash=sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950 \
--hash=sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2 \
--hash=sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb \
--hash=sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e \
--hash=sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6 \
--hash=sha256:3e5e1224c0a6a90e05843e07adfec669edebec17801c67072f51e59561d63c0b \
--hash=sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2 \
--hash=sha256:433c5a81eade63b47e522303bad236f59dba55ea6951746f5558355eeed8c75d \
--hash=sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa \
--hash=sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2 \
--hash=sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818 \
--hash=sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032 \
--hash=sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71 \
--hash=sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96 \
@@ -56,17 +46,13 @@ charset-normalizer==3.5.1 \
--hash=sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61 \
--hash=sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9 \
--hash=sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1 \
--hash=sha256:55261ac0d2941c42f196dd576f543d87a8ee03cd6f5e30dfb4d807b2e3b9121a \
--hash=sha256:56490c595a28b1bb27dfc583e816152a9767721ef58b2c03b13f954d2f707420 \
--hash=sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4 \
--hash=sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65 \
--hash=sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663 \
--hash=sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f \
--hash=sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591 \
--hash=sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a \
--hash=sha256:5ca0555312ae2fe82715cada7fac375530c2f3349e1eaa1bcb33d0283ac79a18 \
--hash=sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e \
--hash=sha256:5e2d0e146dcb57034f8b97dc58d2d512cb90aba253960ce449f695fec6a82c6f \
--hash=sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7 \
--hash=sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3 \
--hash=sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c \
@@ -83,14 +69,12 @@ charset-normalizer==3.5.1 \
--hash=sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf \
--hash=sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8 \
--hash=sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e \
--hash=sha256:789b8982559ae28dad2356519f841655756cdcd96616410590ae0b17454ee64f \
--hash=sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885 \
--hash=sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0 \
--hash=sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506 \
--hash=sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2 \
--hash=sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0 \
--hash=sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e \
--hash=sha256:85de3134b5379856e323ba37c19c9256d39425f7b76a63af52b09fb4664c2e8f \
--hash=sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e \
--hash=sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491 \
--hash=sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a \
@@ -101,13 +85,9 @@ charset-normalizer==3.5.1 \
--hash=sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712 \
--hash=sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7 \
--hash=sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a \
--hash=sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20 \
--hash=sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f \
--hash=sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3 \
--hash=sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9 \
--hash=sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e \
--hash=sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5 \
--hash=sha256:994e883d17c559cdfd38c84003c8b27d25424a1077272a17e7cd27bfe0bf57b2 \
--hash=sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36 \
--hash=sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263 \
--hash=sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4 \
@@ -115,11 +95,8 @@ charset-normalizer==3.5.1 \
--hash=sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a \
--hash=sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3 \
--hash=sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375 \
--hash=sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa \
--hash=sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d \
--hash=sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5 \
--hash=sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99 \
--hash=sha256:a951ad59cad9145664a730d3036b40b844e74d2d3683da40111463cd3a83845d \
--hash=sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c \
--hash=sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488 \
--hash=sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6 \
@@ -133,40 +110,31 @@ charset-normalizer==3.5.1 \
--hash=sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962 \
--hash=sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c \
--hash=sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08 \
--hash=sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab \
--hash=sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573 \
--hash=sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90 \
--hash=sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5 \
--hash=sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18 \
--hash=sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d \
--hash=sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af \
--hash=sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea \
--hash=sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c \
--hash=sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b \
--hash=sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6 \
--hash=sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8 \
--hash=sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774 \
--hash=sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004 \
--hash=sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a \
--hash=sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a \
--hash=sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2 \
--hash=sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2 \
--hash=sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa \
--hash=sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe \
--hash=sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3 \
--hash=sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc \
--hash=sha256:e06efa066f7dbadbc84ebc126a97c452a6451dfcf589d89d788484949e1cf795 \
--hash=sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d \
--hash=sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc \
--hash=sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893 \
--hash=sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef \
--hash=sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d \
--hash=sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda \
--hash=sha256:eb12fb2ba69ffa05f8695f61c69e591dc4b4a12ac3757ac8af8adb259bf56d17 \
--hash=sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30 \
--hash=sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7 \
--hash=sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5 \
--hash=sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182 \
--hash=sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f \
--hash=sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9 \
--hash=sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada \
@@ -191,115 +159,7 @@ idna==3.19 \
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
# via requests
numpy==2.0.2 ; python_full_version < '3.10' \
--hash=sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a \
--hash=sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195 \
--hash=sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951 \
--hash=sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1 \
--hash=sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c \
--hash=sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc \
--hash=sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b \
--hash=sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd \
--hash=sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4 \
--hash=sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd \
--hash=sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318 \
--hash=sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448 \
--hash=sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece \
--hash=sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d \
--hash=sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5 \
--hash=sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8 \
--hash=sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57 \
--hash=sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78 \
--hash=sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66 \
--hash=sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a \
--hash=sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e \
--hash=sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c \
--hash=sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa \
--hash=sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d \
--hash=sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c \
--hash=sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729 \
--hash=sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97 \
--hash=sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c \
--hash=sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9 \
--hash=sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669 \
--hash=sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4 \
--hash=sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73 \
--hash=sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385 \
--hash=sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8 \
--hash=sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c \
--hash=sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b \
--hash=sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692 \
--hash=sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15 \
--hash=sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131 \
--hash=sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a \
--hash=sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326 \
--hash=sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b \
--hash=sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded \
--hash=sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04 \
--hash=sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd
# via
# pywhispercpp
# vocalinux
numpy==2.2.6 ; python_full_version == '3.10.*' \
--hash=sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff \
--hash=sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47 \
--hash=sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84 \
--hash=sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d \
--hash=sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6 \
--hash=sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f \
--hash=sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b \
--hash=sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49 \
--hash=sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163 \
--hash=sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571 \
--hash=sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42 \
--hash=sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff \
--hash=sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491 \
--hash=sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4 \
--hash=sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566 \
--hash=sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf \
--hash=sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40 \
--hash=sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd \
--hash=sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06 \
--hash=sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282 \
--hash=sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680 \
--hash=sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db \
--hash=sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3 \
--hash=sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90 \
--hash=sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1 \
--hash=sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289 \
--hash=sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab \
--hash=sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c \
--hash=sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d \
--hash=sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb \
--hash=sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d \
--hash=sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a \
--hash=sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf \
--hash=sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1 \
--hash=sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2 \
--hash=sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a \
--hash=sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543 \
--hash=sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00 \
--hash=sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c \
--hash=sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f \
--hash=sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd \
--hash=sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868 \
--hash=sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303 \
--hash=sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83 \
--hash=sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3 \
--hash=sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d \
--hash=sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87 \
--hash=sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa \
--hash=sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f \
--hash=sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae \
--hash=sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda \
--hash=sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915 \
--hash=sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249 \
--hash=sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de \
--hash=sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8
# via
# pywhispercpp
# vocalinux
numpy==2.4.6 ; python_full_version == '3.11.*' \
numpy==2.4.6 ; python_full_version < '3.12' \
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
--hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \
@@ -445,11 +305,7 @@ numpy==2.5.2 ; python_full_version >= '3.12' \
# via
# pywhispercpp
# vocalinux
platformdirs==4.4.0 ; python_full_version < '3.10' \
--hash=sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85 \
--hash=sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf
# via pywhispercpp
platformdirs==4.11.3 ; python_full_version >= '3.10' \
platformdirs==4.11.3 \
--hash=sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7 \
--hash=sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab
# via pywhispercpp
@@ -477,50 +333,27 @@ psutil==7.2.2 \
--hash=sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8
# via vocalinux
pyaudio==0.2.14 \
--hash=sha256:009f357ee5aa6bc8eb19d69921cd30e98c42cddd34210615d592a71d09c4bd57 \
--hash=sha256:126065b5e82a1c03ba16e7c0404d8f54e17368836e7d2d92427358ad44fefe61 \
--hash=sha256:12f2f1ba04e06ff95d80700a78967897a489c05e093e3bffa05a84ed9c0a7fa3 \
--hash=sha256:2a166fc88d435a2779810dd2678354adc33499e9d4d7f937f28b20cc55893e83 \
--hash=sha256:506b32a595f8693811682ab4b127602d404df7dfc453b499c91a80d0f7bad289 \
--hash=sha256:5fce4bcdd2e0e8c063d835dbe2860dac46437506af509353c7f8114d4bacbd5b \
--hash=sha256:692d8c1446f52ed2662120bcd9ddcb5aa2b71f38bda31e58b19fb4672fffba69 \
--hash=sha256:78dfff3879b4994d1f4fc6485646a57755c6ee3c19647a491f790a0895bd2f87 \
--hash=sha256:95328285b4dab57ea8c52a4a996cb52be6d629353315be5bfda403d15932a497 \
--hash=sha256:bbeb01d36a2f472ae5ee5e1451cacc42112986abe622f735bb870a5db77cf903 \
--hash=sha256:f745109634a7c19fa4d6b8b7d6967c3123d988c9ade0cd35d4295ee1acdb53e9
--hash=sha256:bbeb01d36a2f472ae5ee5e1451cacc42112986abe622f735bb870a5db77cf903
# via vocalinux
pycairo==1.28.0 ; python_full_version < '3.10' and sys_platform == 'linux' \
--hash=sha256:26ec5c6126781eb167089a123919f87baa2740da2cca9098be8b3a6b91cc5fbc
# via pygobject
pycairo==1.29.1 ; python_full_version >= '3.10' and sys_platform == 'linux' \
pycairo==1.29.1 ; sys_platform == 'linux' \
--hash=sha256:4fbd26b4af24c9787d84cf5448e34eb8dca064b732479aaecd03109520eebd5f
# via pygobject
pynput==1.8.2 \
--hash=sha256:8cc38cf13a6ab2749cb375678be8a0fd705d7ce49c8001ff5db4007a723bbef1 \
--hash=sha256:f493c87157cd3861b4468f7f896857051762f44ed26f1b641e7cc5840a457087
# via vocalinux
pyobjc-core==11.1 ; python_full_version < '3.10' and sys_platform == 'darwin' \
--hash=sha256:18986f83998fbd5d3f56d8a8428b2f3e0754fd15cef3ef786ca0d29619024f2c \
--hash=sha256:4a99e6558b48b8e47c092051e7b3be05df1c8d0617b62f6fa6a316c01902d157 \
--hash=sha256:4c7536f3e94de0a3eae6bb382d75f1219280aa867cdf37beef39d9e7d580173c \
--hash=sha256:765b97dea6b87ec4612b3212258024d8496ea23517c95a1c5f0735f96b7fd529 \
--hash=sha256:8849e78cfe6595c4911fbba29683decfb0bf57a350aed8a43316976ba6f659d2 \
--hash=sha256:8cb9ed17a8d84a312a6e8b665dd22393d48336ea1d8277e7ad20c19a38edf731 \
--hash=sha256:b63d4d90c5df7e762f34739b39cc55bc63dbcf9fb2fb3f2671e528488c7a87fe \
--hash=sha256:ec36680b5c14e2f73d432b03ba7c1457dc6ca70fa59fd7daea1073f2b4157d33 \
--hash=sha256:f2455683e807f8541f0d83fbba0f5d9a46128ab0d5cc83ea208f0bec759b7f96
# via
# pyobjc-framework-applicationservices
# pyobjc-framework-cocoa
# pyobjc-framework-coretext
# pyobjc-framework-quartz
pyobjc-core==12.2.2 ; python_full_version >= '3.10' and sys_platform == 'darwin' \
pyobjc-core==12.2.2 ; sys_platform == 'darwin' \
--hash=sha256:122e6ad302a2abf5d4d4adb0156db751600ddf2768441696cba17b31323085e7 \
--hash=sha256:2062e8ad30a310441cd022544a897553408bebeaa7820d5edba3c96fd7fd693b \
--hash=sha256:2c7ef3d2f865b4b3ebb14ec3556f7a3e8abb6d130c67275cd9daa08dbd6e4e4e \
--hash=sha256:3772b406edb3ff78171530a17cda1c4a7817f87b87ded0d8715b3fa664df16db \
--hash=sha256:3906452339cd06a3bb07df103c2511d4cb0f7a22d8771c0b802eba15d9a642b6 \
--hash=sha256:56c6c39f1de059fcbb174ebca5525505fc8feaa89be2a28c329bf09b6b25ee75 \
--hash=sha256:59a77038ebe0ab1240f61c341e7fb67b8674f2b4cd41bc71a6472511a12b50f7 \
--hash=sha256:89acc6bc13aaa6e3f52b0ce652ede7e201edb6bf062741b246b0c5a44582f25f \
--hash=sha256:950bd2d9c74634398c4e3d24ef2f213d4e23d705083697464fa67afedc53c1ad \
@@ -530,18 +363,7 @@ pyobjc-core==12.2.2 ; python_full_version >= '3.10' and sys_platform == 'darwin'
# pyobjc-framework-cocoa
# pyobjc-framework-coretext
# pyobjc-framework-quartz
pyobjc-framework-applicationservices==11.1 ; python_full_version < '3.10' and sys_platform == 'darwin' \
--hash=sha256:03fcd8c0c600db98fa8b85eb7b3bc31491701720c795e3f762b54e865138bbaf \
--hash=sha256:385a89f4d0838c97a331e247519d9e9745aa3f7427169d18570e3c664076a63c \
--hash=sha256:89aa713f16f1de66efd82f3be77c632ad1068e51e0ef0c2b0237ac7c7f580814 \
--hash=sha256:a0ce40a57a9b993793b6f72c4fd93f80618ef54a69d76a1da97b8360a2f3ffc5 \
--hash=sha256:ba671fc6b695de69b2ed5e350b09cc1806f39352e8ad07635c94ef17730f6fe0 \
--hash=sha256:cf45d15eddae36dec2330a9992fc852476b61c8f529874b9ec2805c768a75482 \
--hash=sha256:e8dee91c6a14fd042f98819dc0ac4a182e0e816282565534032f0e544bfab143 \
--hash=sha256:f480fab20f3005e559c9d06c9a3874a1f1c60dde52c6d28a53ab59b45e79d55f \
--hash=sha256:f4a85ccd78bab84f7f05ac65ff9be117839dfc09d48c39edd65c617ed73eb01c
# via pynput
pyobjc-framework-applicationservices==12.2.2 ; python_full_version >= '3.10' and sys_platform == 'darwin' \
pyobjc-framework-applicationservices==12.2.2 ; sys_platform == 'darwin' \
--hash=sha256:0bcc09531d5854598fd74706d999e4ae3b7c503204d318910d02eba30e8eecef \
--hash=sha256:1af01a271b85eae2a327e93cf3ec46de0199a1a933594f78d4c2b9b9c2240e5e \
--hash=sha256:3b5693a377480cf127caa1c8c835b8620896971029c545d99d01aa31a2c38159 \
@@ -550,26 +372,10 @@ pyobjc-framework-applicationservices==12.2.2 ; python_full_version >= '3.10' and
--hash=sha256:5506bd6b5268a97fcb79b5b021f6cbbaaf5cdff3ec87094d8870268d8cf096ed \
--hash=sha256:82275ea56be975ee84fafdb340dcfee6b5289422c3f674c1982d3fcd47ba68c9 \
--hash=sha256:93ba63aad6607f369c1b38bf91306731da6306fdde5eb67afa407b3f8a87dda7 \
--hash=sha256:e525167b28560d1cbadadcaee4bcdd20c999f6af3e489cc63bacde4ed1a6b549 \
--hash=sha256:fc861e14f78e7370e982202b21db8bcb562dc14cd4098e557a17961d3411de45
# via pynput
pyobjc-framework-cocoa==11.1 ; python_full_version < '3.10' and sys_platform == 'darwin' \
--hash=sha256:1b5de4e1757bb65689d6dc1f8d8717de9ec8587eb0c4831c134f13aba29f9b71 \
--hash=sha256:54e93e1d9b0fc41c032582a6f0834befe1d418d73893968f3f450281b11603da \
--hash=sha256:7b9a9b8ba07f5bf84866399e3de2aa311ed1c34d5d2788a995bdbe82cc36cfa0 \
--hash=sha256:806de56f06dfba8f301a244cce289d54877c36b4b19818e3b53150eb7c2424d0 \
--hash=sha256:87df76b9b73e7ca699a828ff112564b59251bb9bbe72e610e670a4dc9940d038 \
--hash=sha256:aede53a1afc5433e1e7d66568cc52acceeb171b0a6005407a42e8e82580b4fc0 \
--hash=sha256:b27a5bdb3ab6cdeb998443ff3fce194ffae5f518c6a079b832dbafc4426937f9 \
--hash=sha256:bbee71eeb93b1b31ffbac8560b59a0524a8a4b90846a260d2c4f2188f3d4c721 \
--hash=sha256:fd5245ee1997d93e78b72703be1289d75d88ff6490af94462b564892e9266350
# via
# pyobjc-framework-applicationservices
# pyobjc-framework-coretext
# pyobjc-framework-quartz
pyobjc-framework-cocoa==12.2.2 ; python_full_version >= '3.10' and sys_platform == 'darwin' \
pyobjc-framework-cocoa==12.2.2 ; sys_platform == 'darwin' \
--hash=sha256:0e8ace0d44a00d281281a723d17fcd05eea7544a38a6a512e1fd018ddb7aece2 \
--hash=sha256:5a751c8033a3b51f7996f0327e0675eb44dcfdfe7920fae01e3d78b662723fff \
--hash=sha256:600b1723184ca094931330e79355274949965460e23de38628d601b5a967baf9 \
--hash=sha256:851dca4c16e70b405e5cd5a8c166cf7c445ae54a4cdd95ce9a523803172f32d1 \
--hash=sha256:875f2aad73963faa81a6b36ae674fd494a4658d6d999e1075e0e2aca3d2391df \
@@ -582,44 +388,18 @@ pyobjc-framework-cocoa==12.2.2 ; python_full_version >= '3.10' and sys_platform
# pyobjc-framework-applicationservices
# pyobjc-framework-coretext
# pyobjc-framework-quartz
pyobjc-framework-coretext==11.1 ; python_full_version < '3.10' and sys_platform == 'darwin' \
--hash=sha256:15650ba99692d00953e91e53118c11636056a22c90d472020f7ba31500577bf5 \
--hash=sha256:1597bf7234270ee1b9963bf112e9061050d5fb8e1384b3f50c11bde2fe2b1570 \
--hash=sha256:37e051e8f12a0f47a81b8efc8c902156eb5bc3d8123c43e5bd4cebd24c222228 \
--hash=sha256:515be6beb48c084ee413c00c4e9fbd6e730c1b8a24270f4c618fc6c7ba0011ce \
--hash=sha256:56a3a02202e0d50be3c43e781c00f9f1859ab9b73a8342ff56260b908e911e37 \
--hash=sha256:7fee99a1ac96e3f70d482731bc39a546da82a58f87fa9f0e2b784a5febaff33d \
--hash=sha256:a29bbd5d85c77f46a8ee81d381b847244c88a3a5a96ac22f509027ceceaffaf6 \
--hash=sha256:b4f4d2d2a6331fa64465247358d7aafce98e4fb654b99301a490627a073d021e \
--hash=sha256:fb27f66a56660c31bb956191d64b85b95bac99cfb833f6e99622ca0ac4b3ba12
# via pyobjc-framework-applicationservices
pyobjc-framework-coretext==12.2.2 ; python_full_version >= '3.10' and sys_platform == 'darwin' \
pyobjc-framework-coretext==12.2.2 ; sys_platform == 'darwin' \
--hash=sha256:1324d91305022b1de03a12cf82de2b42c87dafe9b12cde6237253658a8637139 \
--hash=sha256:1dc50de18346c4d6c89a51df066f03beea81e3557e3090ddc2a7a59395332fd8 \
--hash=sha256:295d1037494630527acc24d116b0301ab5cf5e918952f49f7bf4f76ebae24d0c \
--hash=sha256:64ddc02303217028e32e22c7cc00b5112d84e9d9a67c37d00c2e54f9172284ab \
--hash=sha256:77a379ff47c599cb2bab918eb75680d30d950692ec19d67b734d11ded090b4e1 \
--hash=sha256:8af29c8f5fd3404b2ce3730ac1166c5b66e206830603facb85d0ad3ede5309dc \
--hash=sha256:a69611de569d92776e6391ca25492a5bbb4a05ba744006349e377a4fc5f7c8a5 \
--hash=sha256:b585f0db77280930cb32e71cf8d27f5203d8c0f42a65fba690294530ab6e1aa6 \
--hash=sha256:dbd0f288a859e359ee8e7ccf45bc2c489f38ae1087c21468b61d700433e0c91b \
--hash=sha256:f1ad871d761bad9a0d461cceeeff7375a39f4d181c2a206dada6fc05307fec12
# via pyobjc-framework-applicationservices
pyobjc-framework-quartz==11.1 ; python_full_version < '3.10' and sys_platform == 'darwin' \
--hash=sha256:07cbda78b4a8fcf3a2d96e047a2ff01f44e3e1820f46f0f4b3b6d77ff6ece07c \
--hash=sha256:2d501fe95ef15d8acf587cb7dc4ab4be3c5a84e2252017da8dbb7df1bbe7a72a \
--hash=sha256:39d02a3df4b5e3eee1e0da0fb150259476910d2a9aa638ab94153c24317a9561 \
--hash=sha256:43a1138280571bbf44df27a7eef519184b5c4183a588598ebaaeb887b9e73e76 \
--hash=sha256:9ac806067541917d6119b98d90390a6944e7d9bd737f5c0a79884202327c9204 \
--hash=sha256:9b1f451ddb5243d8d6316af55f240a02b0fffbfe165bff325628bf73f3df7f44 \
--hash=sha256:a57f35ccfc22ad48c87c5932818e583777ff7276605fef6afad0ac0741169f75 \
--hash=sha256:b23d81c30c564adf6336e00b357f355b35aad10075dd7e837cfd52a9912863e5 \
--hash=sha256:b5ef75c416b0209e25b2eb07a27bd7eedf14a8c6b2f968711969d45ceceb0f84
# via
# pynput
# pyobjc-framework-applicationservices
# pyobjc-framework-coretext
pyobjc-framework-quartz==12.2.2 ; python_full_version >= '3.10' and sys_platform == 'darwin' \
pyobjc-framework-quartz==12.2.2 ; sys_platform == 'darwin' \
--hash=sha256:0ec9751904ef975bf0789d760dc4fadcb400edc4ffe4a736eb54971968babe5c \
--hash=sha256:1f7f3d9010e38f03ea1fa266664c10ea349cd7492bd603b403584f49d713dbed \
--hash=sha256:4b01e325b0cdc121e78730dde9756e971b23069bf141cd62efbcaac76d7b6dbb \
@@ -628,7 +408,6 @@ pyobjc-framework-quartz==12.2.2 ; python_full_version >= '3.10' and sys_platform
--hash=sha256:810f97b210cfd93704d240860286dfd6df09f9f1c52525fc5c2166723aea3f9e \
--hash=sha256:8f58c589b5a76ba98f186b1f3b19fb1c8b730e82351f81fb62e6194f64a71622 \
--hash=sha256:cee63b891c2b6b7ccf98f233175411529f3e80286f58438793b3634af79858f1 \
--hash=sha256:d89a5f47c079b5c340d2b1cbb83eb6c4c92d4bb17cd4daf7d8c02c91a49f5399 \
--hash=sha256:ebf8167ca2096cf3a05199decfa517be0df4c56048f49cf132bd6b1a6ab9c086
# via
# pynput
@@ -640,63 +419,43 @@ python-xlib==0.33 ; 'linux' in sys_platform \
# via pynput
pywhispercpp==1.5.0 \
--hash=sha256:03025227d0da211e382db6b6b16d910a267c663b6b2c57092b1200965ec45393 \
--hash=sha256:0804fc3a2f168f78b6869386c221a3622bc76cdb5ba5e25b8347572aac704542 \
--hash=sha256:0c393c01a0a77007a17d17cb706e08cba97f0e35ea0268733af1792949d03ceb \
--hash=sha256:0f895cbbf5ba71a9e3f0e6afb4b6ae49bae5c716dc6a8a4d905eb789d9fe2238 \
--hash=sha256:11a3cd1973238beccd8ca523e7789bee88956968881893770bedd26be1c995d7 \
--hash=sha256:1bc31f684b6a38000e397824155bda3b45b61af6acc9f7e0f3bfa70e20b574e3 \
--hash=sha256:22f9ddf12f6fb0fe3c43465d976b554e8928fcc113c902127e94c792d85f0631 \
--hash=sha256:24602b8d0329a224fe6eb1c1e455853e983a5e04a63d4cb759ae2f275e254f5f \
--hash=sha256:2498d591cca97bf186adb0d746775c21debd8a264b2053358f252cc9dd10167a \
--hash=sha256:24da0c0bd33068f4c9c3f45eae3e6b7fb31d9b5d3487e4e1e1323b4253288d62 \
--hash=sha256:2ea8fa7f5ce602d3e986fb11c4c2973b471f1b14e0fe75e01a66f1a60aecbfbf \
--hash=sha256:358261055c56a96a04f6b0ff419633ffc61ef61027788b2bb2c6e7dca69abdb7 \
--hash=sha256:3f825942067da3b0c48fc5b552b816fcee1761e955b9fdfdc911f0ab2d6fff11 \
--hash=sha256:4363fa893e398d1f18a5775374109aa310022b9f4fc777a89329b12963f3149d \
--hash=sha256:4bb409027566b473100e43f07fac48f9c8bdb6cba1ac0fbff5594a6f5a0e1b7e \
--hash=sha256:521df17d4e1d7ccd192303051bda8ce8b516f66816ed0b3a32281b57a6a0d3c4 \
--hash=sha256:568669bae45bd879bba1e35401cefc89d572428bd17326ce8437107ab80003d0 \
--hash=sha256:57dd5111d4dd9fe3f0e784a03a3337df5abd514260a7d16656733d110316d2f0 \
--hash=sha256:5aa54c73ec96617d97aa519dbbf817e44255eddd9bd52ee1243be61028dbd018 \
--hash=sha256:64415b7687c0b39e66652b38683983ef7f23e174d7b3b05caa539955da8b088e \
--hash=sha256:649af3ae0f9dc0d0dd757d8c2adfc6d929b1b203ac1ba85d5ac453015ddd6e63 \
--hash=sha256:6ad4071772eb9dad59a741611e44e3819e6249c46e295ab8468609d03c6e4232 \
--hash=sha256:6e709172192331a7e3f18fc227279e3e11295894b5e2d3caca1bf837ecb69128 \
--hash=sha256:70e925fad2c48554d3f5baf79e2746540268d2974b5cf40a4880887e4a065c2f \
--hash=sha256:77d4d0ff382b5bc1369e9f77a851b45659edf5974e0903ffb15b981a043d216b \
--hash=sha256:8796bcc410b7f6bd5af430e22116458d178effba628555c4c1d7db9ecce11d55 \
--hash=sha256:9806e084ebf956c7e492c76ea31c708e894849024772b3c3a57f85c342afc341 \
--hash=sha256:9b43ddd44e65ca53d5ab1f62b623bc3edc64019bbe06b9641fff2dfef704c990 \
--hash=sha256:a2fe9312fe3c08f832da392296118cc8cc1fc901753289132253f2066d911357 \
--hash=sha256:a6777b5d40c0679952ded7934f7119d5fbfd606ec348293f869f430795609bae \
--hash=sha256:a839b738936a8cd72cdd55c59a83a462820532bee78e233241bf9a3503e92144 \
--hash=sha256:a96816cf6b8d62c19ff3eda89094679006f86cef81acd60339ca19cc56241b95 \
--hash=sha256:b1f94300f2fa1748f67f63a9240b526e8542b2dad1ceca5ea87ec2e9e15a939a \
--hash=sha256:b592011cd992a5bcd36d0923477fb4cfdd338a0c7ce58997de195a14ab2b6d9a \
--hash=sha256:be1683fe67b5980472779eac8592536f993b987e193af21fda7da1c4c929561b \
--hash=sha256:c35c16f5da31d79be604dfd082b8bb67d5d46ba2f7f59bf5ab13857d5cd6ec6b \
--hash=sha256:c9498eb9f4c38ea121da2435b5275c267486a5e14dddf5e72ec54a480b1fb44e \
--hash=sha256:ce3f1e511335ca6c4f8cd2eabd8f0db5cdaff54047b7aa3f24bd962a9436b077 \
--hash=sha256:d2805f15361247ada04a34b51c556514527b984f9b089a4e32f1b2a4abbc5367 \
--hash=sha256:d92eec44b49595ab1454cff1d10faa83b0b27779db8d8f00592ed363f0c5a262 \
--hash=sha256:dc8dbcb6033db306e9ec450d835c005dd491c457937c79ef2673ea8797cbb93c \
--hash=sha256:e287bc277eef67729c57a4c7abf687e38ad9bd62074cdf451ca3e0fcf1ff4859 \
--hash=sha256:e2e89712d340170771e538e39543b904535195621a0706836ababb69fb159ff1 \
--hash=sha256:ea34ae824a6f934ba7736e6440f93cc467a7ec98b9c8fb2e5a37bb439ffa93b9 \
--hash=sha256:eb36ac24e0c80199cda03a393df53941f0dfaac463773bf770163984fb984c7e \
--hash=sha256:eed1d5f73d8c38f2f18cdf11882860ac7e6e061e611c67627933d7e1b59b6fcb \
--hash=sha256:f5d13b4a7e34b9f4eebf70bff150ec36f4145c580bd5d812b9a3139611e7433c \
--hash=sha256:f9b1cd9dd4c3eb7fb2b777529eb62618e58a6bb05be20f82b7b1afe3d0b12274 \
--hash=sha256:fbdbb1bb236f4481ff6ea475aed9ed091393ddb02271df309645d71de207778d \
--hash=sha256:fd163b551b95112ddd18aac6bddd4cd30636790bff288274ebbb404e3f66a08f
--hash=sha256:fbdbb1bb236f4481ff6ea475aed9ed091393ddb02271df309645d71de207778d
# via vocalinux
requests==2.32.5 ; python_full_version < '3.10' \
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
# via
# pywhispercpp
# vocalinux
requests==2.34.2 ; python_full_version >= '3.10' \
requests==2.34.2 \
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
--hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
# via
@@ -712,11 +471,7 @@ tqdm==4.70.0 \
--hash=sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220 \
--hash=sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953
# via pywhispercpp
urllib3==2.6.3 ; python_full_version < '3.10' \
--hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \
--hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4
# via requests
urllib3==2.7.0 ; python_full_version >= '3.10' \
urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
# via requests
+14 -342
View File
@@ -5,17 +5,13 @@ certifi==2026.7.22 \
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
# via requests
charset-normalizer==3.5.1 \
--hash=sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45 \
--hash=sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d \
--hash=sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5 \
--hash=sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b \
--hash=sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f \
--hash=sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f \
--hash=sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5 \
--hash=sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22 \
--hash=sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5 \
--hash=sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac \
--hash=sha256:13e3afe97712e8887cd516e960c63f0b93122971e5b5e4b2622fe7701771e838 \
--hash=sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90 \
--hash=sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626 \
--hash=sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4 \
@@ -26,9 +22,7 @@ charset-normalizer==3.5.1 \
--hash=sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1 \
--hash=sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102 \
--hash=sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8 \
--hash=sha256:29880d17a8eb0b5cfdfd8944b468322928059aa35f1f5fa8ff22b149ec0b42f8 \
--hash=sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9 \
--hash=sha256:2e9cf9253119d8e5d111f05d71626786fd3d6193817316eab1ca088cdb8593cf \
--hash=sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0 \
--hash=sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031 \
--hash=sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e \
@@ -38,15 +32,11 @@ charset-normalizer==3.5.1 \
--hash=sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c \
--hash=sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950 \
--hash=sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2 \
--hash=sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb \
--hash=sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e \
--hash=sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6 \
--hash=sha256:3e5e1224c0a6a90e05843e07adfec669edebec17801c67072f51e59561d63c0b \
--hash=sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2 \
--hash=sha256:433c5a81eade63b47e522303bad236f59dba55ea6951746f5558355eeed8c75d \
--hash=sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa \
--hash=sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2 \
--hash=sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818 \
--hash=sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032 \
--hash=sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71 \
--hash=sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96 \
@@ -56,17 +46,13 @@ charset-normalizer==3.5.1 \
--hash=sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61 \
--hash=sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9 \
--hash=sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1 \
--hash=sha256:55261ac0d2941c42f196dd576f543d87a8ee03cd6f5e30dfb4d807b2e3b9121a \
--hash=sha256:56490c595a28b1bb27dfc583e816152a9767721ef58b2c03b13f954d2f707420 \
--hash=sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4 \
--hash=sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65 \
--hash=sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663 \
--hash=sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f \
--hash=sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591 \
--hash=sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a \
--hash=sha256:5ca0555312ae2fe82715cada7fac375530c2f3349e1eaa1bcb33d0283ac79a18 \
--hash=sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e \
--hash=sha256:5e2d0e146dcb57034f8b97dc58d2d512cb90aba253960ce449f695fec6a82c6f \
--hash=sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7 \
--hash=sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3 \
--hash=sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c \
@@ -83,14 +69,12 @@ charset-normalizer==3.5.1 \
--hash=sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf \
--hash=sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8 \
--hash=sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e \
--hash=sha256:789b8982559ae28dad2356519f841655756cdcd96616410590ae0b17454ee64f \
--hash=sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885 \
--hash=sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0 \
--hash=sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506 \
--hash=sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2 \
--hash=sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0 \
--hash=sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e \
--hash=sha256:85de3134b5379856e323ba37c19c9256d39425f7b76a63af52b09fb4664c2e8f \
--hash=sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e \
--hash=sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491 \
--hash=sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a \
@@ -101,13 +85,9 @@ charset-normalizer==3.5.1 \
--hash=sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712 \
--hash=sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7 \
--hash=sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a \
--hash=sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20 \
--hash=sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f \
--hash=sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3 \
--hash=sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9 \
--hash=sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e \
--hash=sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5 \
--hash=sha256:994e883d17c559cdfd38c84003c8b27d25424a1077272a17e7cd27bfe0bf57b2 \
--hash=sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36 \
--hash=sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263 \
--hash=sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4 \
@@ -115,11 +95,8 @@ charset-normalizer==3.5.1 \
--hash=sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a \
--hash=sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3 \
--hash=sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375 \
--hash=sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa \
--hash=sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d \
--hash=sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5 \
--hash=sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99 \
--hash=sha256:a951ad59cad9145664a730d3036b40b844e74d2d3683da40111463cd3a83845d \
--hash=sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c \
--hash=sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488 \
--hash=sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6 \
@@ -133,40 +110,31 @@ charset-normalizer==3.5.1 \
--hash=sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962 \
--hash=sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c \
--hash=sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08 \
--hash=sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab \
--hash=sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573 \
--hash=sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90 \
--hash=sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5 \
--hash=sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18 \
--hash=sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d \
--hash=sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af \
--hash=sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea \
--hash=sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c \
--hash=sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b \
--hash=sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6 \
--hash=sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8 \
--hash=sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774 \
--hash=sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004 \
--hash=sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a \
--hash=sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a \
--hash=sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2 \
--hash=sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2 \
--hash=sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa \
--hash=sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe \
--hash=sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3 \
--hash=sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc \
--hash=sha256:e06efa066f7dbadbc84ebc126a97c452a6451dfcf589d89d788484949e1cf795 \
--hash=sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d \
--hash=sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc \
--hash=sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893 \
--hash=sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef \
--hash=sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d \
--hash=sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda \
--hash=sha256:eb12fb2ba69ffa05f8695f61c69e591dc4b4a12ac3757ac8af8adb259bf56d17 \
--hash=sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30 \
--hash=sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7 \
--hash=sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5 \
--hash=sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182 \
--hash=sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f \
--hash=sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9 \
--hash=sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada \
@@ -182,10 +150,6 @@ colorama==0.4.6 ; sys_platform == 'win32' \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
# via tqdm
coloredlogs==15.0.1 ; python_full_version < '3.10' \
--hash=sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934 \
--hash=sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0
# via onnxruntime
evdev==1.9.3 ; 'linux' in sys_platform or sys_platform == 'linux' \
--hash=sha256:2c140e01ac8437758fa23fe5c871397412461f42d421aa20241dc8fe8cfccbc9
# via
@@ -194,129 +158,11 @@ evdev==1.9.3 ; 'linux' in sys_platform or sys_platform == 'linux' \
flatbuffers==25.12.19 \
--hash=sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4
# via onnxruntime
humanfriendly==10.0 ; python_full_version < '3.10' \
--hash=sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477 \
--hash=sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc
# via coloredlogs
idna==3.19 \
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
# via requests
mpmath==1.3.0 ; python_full_version < '3.11' \
--hash=sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f \
--hash=sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c
# via sympy
numpy==2.0.2 ; python_full_version < '3.10' \
--hash=sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a \
--hash=sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195 \
--hash=sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951 \
--hash=sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1 \
--hash=sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c \
--hash=sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc \
--hash=sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b \
--hash=sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd \
--hash=sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4 \
--hash=sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd \
--hash=sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318 \
--hash=sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448 \
--hash=sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece \
--hash=sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d \
--hash=sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5 \
--hash=sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8 \
--hash=sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57 \
--hash=sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78 \
--hash=sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66 \
--hash=sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a \
--hash=sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e \
--hash=sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c \
--hash=sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa \
--hash=sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d \
--hash=sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c \
--hash=sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729 \
--hash=sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97 \
--hash=sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c \
--hash=sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9 \
--hash=sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669 \
--hash=sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4 \
--hash=sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73 \
--hash=sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385 \
--hash=sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8 \
--hash=sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c \
--hash=sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b \
--hash=sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692 \
--hash=sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15 \
--hash=sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131 \
--hash=sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a \
--hash=sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326 \
--hash=sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b \
--hash=sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded \
--hash=sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04 \
--hash=sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd
# via
# onnxruntime
# pywhispercpp
# vocalinux
numpy==2.2.6 ; python_full_version == '3.10.*' \
--hash=sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff \
--hash=sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47 \
--hash=sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84 \
--hash=sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d \
--hash=sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6 \
--hash=sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f \
--hash=sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b \
--hash=sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49 \
--hash=sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163 \
--hash=sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571 \
--hash=sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42 \
--hash=sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff \
--hash=sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491 \
--hash=sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4 \
--hash=sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566 \
--hash=sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf \
--hash=sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40 \
--hash=sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd \
--hash=sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06 \
--hash=sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282 \
--hash=sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680 \
--hash=sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db \
--hash=sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3 \
--hash=sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90 \
--hash=sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1 \
--hash=sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289 \
--hash=sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab \
--hash=sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c \
--hash=sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d \
--hash=sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb \
--hash=sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d \
--hash=sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a \
--hash=sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf \
--hash=sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1 \
--hash=sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2 \
--hash=sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a \
--hash=sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543 \
--hash=sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00 \
--hash=sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c \
--hash=sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f \
--hash=sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd \
--hash=sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868 \
--hash=sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303 \
--hash=sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83 \
--hash=sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3 \
--hash=sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d \
--hash=sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87 \
--hash=sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa \
--hash=sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f \
--hash=sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae \
--hash=sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda \
--hash=sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915 \
--hash=sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249 \
--hash=sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de \
--hash=sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8
# via
# onnxruntime
# pywhispercpp
# vocalinux
numpy==2.4.6 ; python_full_version == '3.11.*' \
numpy==2.4.6 ; python_full_version < '3.12' \
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
--hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \
@@ -464,56 +310,7 @@ numpy==2.5.2 ; python_full_version >= '3.12' \
# onnxruntime
# pywhispercpp
# vocalinux
onnxruntime==1.20.1 ; python_full_version < '3.10' \
--hash=sha256:06bfbf02ca9ab5f28946e0f912a562a5f005301d0c419283dc57b3ed7969bb7b \
--hash=sha256:0df6f2df83d61f46e842dbcde610ede27218947c33e994545a22333491e72a3b \
--hash=sha256:19c2d843eb074f385e8bbb753a40df780511061a63f9def1b216bf53860223fb \
--hash=sha256:22b0655e2bf4f2161d52706e31f517a0e54939dc393e92577df51808a7edc8c9 \
--hash=sha256:4c4b251a725a3b8cf2aab284f7d940c26094ecd9d442f07dd81ab5470e99b83f \
--hash=sha256:5eec64c0269dcdb8d9a9a53dc4d64f87b9e0c19801d9321246a53b7eb5a7d1bc \
--hash=sha256:7b2908b50101a19e99c4d4e97ebb9905561daf61829403061c1adc1b588bc0de \
--hash=sha256:8508887eb1c5f9537a4071768723ec7c30c28eb2518a00d0adcd32c89dea3221 \
--hash=sha256:a19bc6e8c70e2485a1725b3d517a2319603acc14c1f1a017dda0afe6d4665b41 \
--hash=sha256:bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e \
--hash=sha256:bd386cc9ee5f686ee8a75ba74037750aca55183085bf1941da8efcfe12d5b120 \
--hash=sha256:bda6aebdf7917c1d811f21d41633df00c58aff2bef2f598f69289c1f1dabc4b3 \
--hash=sha256:c9158465745423b2b5d97ed25aa7740c7d38d2993ee2e5c3bfacb0c4145c49d8 \
--hash=sha256:cc01437a32d0042b606f462245c8bbae269e5442797f6213e36ce61d5abdd8cc \
--hash=sha256:d30367df7e70f1d9fc5a6a68106f5961686d39b54d3221f760085524e8d38e16 \
--hash=sha256:d3b616bb53a77a9463707bb313637223380fc327f5064c9a782e8ec69c22e6a2 \
--hash=sha256:d82daaec24045a2e87598b8ac2b417b1cce623244e80e663882e9fe1aae86410 \
--hash=sha256:e50ba5ff7fed4f7d9253a6baf801ca2883cc08491f9d32d78a80da57256a5439 \
--hash=sha256:f1f56e898815963d6dc4ee1c35fc6c36506466eff6d16f3cb9848cea4e8c8172 \
--hash=sha256:f6243e34d74423bdd1edf0ae9596dd61023b260f546ee17d701723915f06a9f7 \
--hash=sha256:fb44b08e017a648924dbe91b82d89b0c105b1adcfe31e90d1dc06b8677ad37be
# via vocalinux
onnxruntime==1.24.3 ; python_full_version == '3.10.*' \
--hash=sha256:02aaf6ddfa784523b6873b4176a79d508e599efe12ab0ea1a3a6e7314408b7aa \
--hash=sha256:0a9847b870b6cb462652b547bc98c49e0efb67553410a082fde1918a38707452 \
--hash=sha256:0d244227dc5e00a9ae15a7ac1eba4c4460d7876dfecafe73fb00db9f1d914d91 \
--hash=sha256:1fd2ec7bb0fabe42f55e8337cfc9b1969d0d14622711aac73d69b4bd5abb5ed7 \
--hash=sha256:2d3706719be6ad41d38a2250998b1d87758a20f6ea4546962e21dc79f1f1fd2b \
--hash=sha256:34a0ea5ff191d8420d9c1332355644148b1bf1a0d10c411af890a63a9f662aa7 \
--hash=sha256:3e6456801c66b095c5cd68e690ca25db970ea5202bd0c5b84a2c3ef7731c5a3c \
--hash=sha256:44ea708c34965439170d811267c51281d3897ecfc4aa0087fa25d4a4c3eb2e4a \
--hash=sha256:48d1092b44ca2ba6f9543892e7c422c15a568481403c10440945685faf27a8d8 \
--hash=sha256:4d4e70ce578aa214c74c7a7a9226bc8e229814db4a5b2d097333b81279ecde36 \
--hash=sha256:72f956634bc2e4bd2e8b006bef111849bd42c42dea37bd0a4c728404fdaf4d34 \
--hash=sha256:78d1f25eed4ab9959db70a626ed50ee24cf497e60774f59f1207ac8556399c4d \
--hash=sha256:8b2ebc54c6d8281dccff78d4b06e47d4cf07535937584ab759448390a70f4978 \
--hash=sha256:951e897a275f897a05ffbcaa615d98777882decaeb80c9216c68cdc62f849f53 \
--hash=sha256:a6b4bce87d96f78f0a9bf5cefab3303ae95d558c5bfea53d0bf7f9ea207880a8 \
--hash=sha256:a8f761857ebaf58a85b9e42422d03207f1d39e6bb8fecfdbf613bac5b9710723 \
--hash=sha256:b082f3ba9519f0a1a1e754556bc7e635c7526ef81b98b3f78da4455d25f0437b \
--hash=sha256:b354afce3333f2859c7e8706d84b6c552beac39233bcd3141ce7ab77b4cabb5d \
--hash=sha256:c958222ef9eff54018332beecd32d5d94a3ab079d8821937b333811bf4da0d39 \
--hash=sha256:d48f36c87b25ab3b2b4c88826c96cf1399a5631e3c2c03cc27d6a1e5d6b18eb4 \
--hash=sha256:df8e70e732fe26346faaeec9147fa38bef35d232d2495d27e93dd221a2d473a9 \
--hash=sha256:e104d33a409bf6e3f30f0e8198ec2aaf8d445b8395490a80f6e6ad56da98e400 \
--hash=sha256:e785d73fbd17421c2513b0bb09eb25d88fa22c8c10c3f5d6060589efa5537c5b \
--hash=sha256:fb56575d7794bf0781156955610c9e651c9504c64d42ec880784b6106244882d
# via vocalinux
onnxruntime==1.29.0 ; python_full_version >= '3.11' \
onnxruntime==1.29.0 \
--hash=sha256:07c5907474dec4a2792fd7626b753dc66707808385a6d9eecf993db0066a9d0f \
--hash=sha256:0d4f427afac434b0070fe992b540ddf20a7aff2265f760f314d91331935b6b98 \
--hash=sha256:11264bb58f7b7cf6af835ab10d36838d73680580820fd6f51d90124a1ca8f449 \
@@ -543,27 +340,11 @@ packaging==26.3 \
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
# via onnxruntime
platformdirs==4.4.0 ; python_full_version < '3.10' \
--hash=sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85 \
--hash=sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf
# via pywhispercpp
platformdirs==4.11.3 ; python_full_version >= '3.10' \
platformdirs==4.11.3 \
--hash=sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7 \
--hash=sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab
# via pywhispercpp
protobuf==6.33.6 ; python_full_version < '3.10' \
--hash=sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326 \
--hash=sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901 \
--hash=sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3 \
--hash=sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a \
--hash=sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135 \
--hash=sha256:bd56799fb262994b2c2faa1799693c95cc2e22c62f56fb43af311cae45d26f0e \
--hash=sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3 \
--hash=sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2 \
--hash=sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593 \
--hash=sha256:f443a394af5ed23672bc6c486be138628fbe5c651ccbc536873d7da23d1868cf
# via onnxruntime
protobuf==7.35.1 ; python_full_version >= '3.10' \
protobuf==7.35.1 \
--hash=sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799 \
--hash=sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87 \
--hash=sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6 \
@@ -597,50 +378,27 @@ psutil==7.2.2 \
--hash=sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8
# via vocalinux
pyaudio==0.2.14 \
--hash=sha256:009f357ee5aa6bc8eb19d69921cd30e98c42cddd34210615d592a71d09c4bd57 \
--hash=sha256:126065b5e82a1c03ba16e7c0404d8f54e17368836e7d2d92427358ad44fefe61 \
--hash=sha256:12f2f1ba04e06ff95d80700a78967897a489c05e093e3bffa05a84ed9c0a7fa3 \
--hash=sha256:2a166fc88d435a2779810dd2678354adc33499e9d4d7f937f28b20cc55893e83 \
--hash=sha256:506b32a595f8693811682ab4b127602d404df7dfc453b499c91a80d0f7bad289 \
--hash=sha256:5fce4bcdd2e0e8c063d835dbe2860dac46437506af509353c7f8114d4bacbd5b \
--hash=sha256:692d8c1446f52ed2662120bcd9ddcb5aa2b71f38bda31e58b19fb4672fffba69 \
--hash=sha256:78dfff3879b4994d1f4fc6485646a57755c6ee3c19647a491f790a0895bd2f87 \
--hash=sha256:95328285b4dab57ea8c52a4a996cb52be6d629353315be5bfda403d15932a497 \
--hash=sha256:bbeb01d36a2f472ae5ee5e1451cacc42112986abe622f735bb870a5db77cf903 \
--hash=sha256:f745109634a7c19fa4d6b8b7d6967c3123d988c9ade0cd35d4295ee1acdb53e9
--hash=sha256:bbeb01d36a2f472ae5ee5e1451cacc42112986abe622f735bb870a5db77cf903
# via vocalinux
pycairo==1.28.0 ; python_full_version < '3.10' and sys_platform == 'linux' \
--hash=sha256:26ec5c6126781eb167089a123919f87baa2740da2cca9098be8b3a6b91cc5fbc
# via pygobject
pycairo==1.29.1 ; python_full_version >= '3.10' and sys_platform == 'linux' \
pycairo==1.29.1 ; sys_platform == 'linux' \
--hash=sha256:4fbd26b4af24c9787d84cf5448e34eb8dca064b732479aaecd03109520eebd5f
# via pygobject
pynput==1.8.2 \
--hash=sha256:8cc38cf13a6ab2749cb375678be8a0fd705d7ce49c8001ff5db4007a723bbef1 \
--hash=sha256:f493c87157cd3861b4468f7f896857051762f44ed26f1b641e7cc5840a457087
# via vocalinux
pyobjc-core==11.1 ; python_full_version < '3.10' and sys_platform == 'darwin' \
--hash=sha256:18986f83998fbd5d3f56d8a8428b2f3e0754fd15cef3ef786ca0d29619024f2c \
--hash=sha256:4a99e6558b48b8e47c092051e7b3be05df1c8d0617b62f6fa6a316c01902d157 \
--hash=sha256:4c7536f3e94de0a3eae6bb382d75f1219280aa867cdf37beef39d9e7d580173c \
--hash=sha256:765b97dea6b87ec4612b3212258024d8496ea23517c95a1c5f0735f96b7fd529 \
--hash=sha256:8849e78cfe6595c4911fbba29683decfb0bf57a350aed8a43316976ba6f659d2 \
--hash=sha256:8cb9ed17a8d84a312a6e8b665dd22393d48336ea1d8277e7ad20c19a38edf731 \
--hash=sha256:b63d4d90c5df7e762f34739b39cc55bc63dbcf9fb2fb3f2671e528488c7a87fe \
--hash=sha256:ec36680b5c14e2f73d432b03ba7c1457dc6ca70fa59fd7daea1073f2b4157d33 \
--hash=sha256:f2455683e807f8541f0d83fbba0f5d9a46128ab0d5cc83ea208f0bec759b7f96
# via
# pyobjc-framework-applicationservices
# pyobjc-framework-cocoa
# pyobjc-framework-coretext
# pyobjc-framework-quartz
pyobjc-core==12.2.2 ; python_full_version >= '3.10' and sys_platform == 'darwin' \
pyobjc-core==12.2.2 ; sys_platform == 'darwin' \
--hash=sha256:122e6ad302a2abf5d4d4adb0156db751600ddf2768441696cba17b31323085e7 \
--hash=sha256:2062e8ad30a310441cd022544a897553408bebeaa7820d5edba3c96fd7fd693b \
--hash=sha256:2c7ef3d2f865b4b3ebb14ec3556f7a3e8abb6d130c67275cd9daa08dbd6e4e4e \
--hash=sha256:3772b406edb3ff78171530a17cda1c4a7817f87b87ded0d8715b3fa664df16db \
--hash=sha256:3906452339cd06a3bb07df103c2511d4cb0f7a22d8771c0b802eba15d9a642b6 \
--hash=sha256:56c6c39f1de059fcbb174ebca5525505fc8feaa89be2a28c329bf09b6b25ee75 \
--hash=sha256:59a77038ebe0ab1240f61c341e7fb67b8674f2b4cd41bc71a6472511a12b50f7 \
--hash=sha256:89acc6bc13aaa6e3f52b0ce652ede7e201edb6bf062741b246b0c5a44582f25f \
--hash=sha256:950bd2d9c74634398c4e3d24ef2f213d4e23d705083697464fa67afedc53c1ad \
@@ -650,18 +408,7 @@ pyobjc-core==12.2.2 ; python_full_version >= '3.10' and sys_platform == 'darwin'
# pyobjc-framework-cocoa
# pyobjc-framework-coretext
# pyobjc-framework-quartz
pyobjc-framework-applicationservices==11.1 ; python_full_version < '3.10' and sys_platform == 'darwin' \
--hash=sha256:03fcd8c0c600db98fa8b85eb7b3bc31491701720c795e3f762b54e865138bbaf \
--hash=sha256:385a89f4d0838c97a331e247519d9e9745aa3f7427169d18570e3c664076a63c \
--hash=sha256:89aa713f16f1de66efd82f3be77c632ad1068e51e0ef0c2b0237ac7c7f580814 \
--hash=sha256:a0ce40a57a9b993793b6f72c4fd93f80618ef54a69d76a1da97b8360a2f3ffc5 \
--hash=sha256:ba671fc6b695de69b2ed5e350b09cc1806f39352e8ad07635c94ef17730f6fe0 \
--hash=sha256:cf45d15eddae36dec2330a9992fc852476b61c8f529874b9ec2805c768a75482 \
--hash=sha256:e8dee91c6a14fd042f98819dc0ac4a182e0e816282565534032f0e544bfab143 \
--hash=sha256:f480fab20f3005e559c9d06c9a3874a1f1c60dde52c6d28a53ab59b45e79d55f \
--hash=sha256:f4a85ccd78bab84f7f05ac65ff9be117839dfc09d48c39edd65c617ed73eb01c
# via pynput
pyobjc-framework-applicationservices==12.2.2 ; python_full_version >= '3.10' and sys_platform == 'darwin' \
pyobjc-framework-applicationservices==12.2.2 ; sys_platform == 'darwin' \
--hash=sha256:0bcc09531d5854598fd74706d999e4ae3b7c503204d318910d02eba30e8eecef \
--hash=sha256:1af01a271b85eae2a327e93cf3ec46de0199a1a933594f78d4c2b9b9c2240e5e \
--hash=sha256:3b5693a377480cf127caa1c8c835b8620896971029c545d99d01aa31a2c38159 \
@@ -670,26 +417,10 @@ pyobjc-framework-applicationservices==12.2.2 ; python_full_version >= '3.10' and
--hash=sha256:5506bd6b5268a97fcb79b5b021f6cbbaaf5cdff3ec87094d8870268d8cf096ed \
--hash=sha256:82275ea56be975ee84fafdb340dcfee6b5289422c3f674c1982d3fcd47ba68c9 \
--hash=sha256:93ba63aad6607f369c1b38bf91306731da6306fdde5eb67afa407b3f8a87dda7 \
--hash=sha256:e525167b28560d1cbadadcaee4bcdd20c999f6af3e489cc63bacde4ed1a6b549 \
--hash=sha256:fc861e14f78e7370e982202b21db8bcb562dc14cd4098e557a17961d3411de45
# via pynput
pyobjc-framework-cocoa==11.1 ; python_full_version < '3.10' and sys_platform == 'darwin' \
--hash=sha256:1b5de4e1757bb65689d6dc1f8d8717de9ec8587eb0c4831c134f13aba29f9b71 \
--hash=sha256:54e93e1d9b0fc41c032582a6f0834befe1d418d73893968f3f450281b11603da \
--hash=sha256:7b9a9b8ba07f5bf84866399e3de2aa311ed1c34d5d2788a995bdbe82cc36cfa0 \
--hash=sha256:806de56f06dfba8f301a244cce289d54877c36b4b19818e3b53150eb7c2424d0 \
--hash=sha256:87df76b9b73e7ca699a828ff112564b59251bb9bbe72e610e670a4dc9940d038 \
--hash=sha256:aede53a1afc5433e1e7d66568cc52acceeb171b0a6005407a42e8e82580b4fc0 \
--hash=sha256:b27a5bdb3ab6cdeb998443ff3fce194ffae5f518c6a079b832dbafc4426937f9 \
--hash=sha256:bbee71eeb93b1b31ffbac8560b59a0524a8a4b90846a260d2c4f2188f3d4c721 \
--hash=sha256:fd5245ee1997d93e78b72703be1289d75d88ff6490af94462b564892e9266350
# via
# pyobjc-framework-applicationservices
# pyobjc-framework-coretext
# pyobjc-framework-quartz
pyobjc-framework-cocoa==12.2.2 ; python_full_version >= '3.10' and sys_platform == 'darwin' \
pyobjc-framework-cocoa==12.2.2 ; sys_platform == 'darwin' \
--hash=sha256:0e8ace0d44a00d281281a723d17fcd05eea7544a38a6a512e1fd018ddb7aece2 \
--hash=sha256:5a751c8033a3b51f7996f0327e0675eb44dcfdfe7920fae01e3d78b662723fff \
--hash=sha256:600b1723184ca094931330e79355274949965460e23de38628d601b5a967baf9 \
--hash=sha256:851dca4c16e70b405e5cd5a8c166cf7c445ae54a4cdd95ce9a523803172f32d1 \
--hash=sha256:875f2aad73963faa81a6b36ae674fd494a4658d6d999e1075e0e2aca3d2391df \
@@ -702,44 +433,18 @@ pyobjc-framework-cocoa==12.2.2 ; python_full_version >= '3.10' and sys_platform
# pyobjc-framework-applicationservices
# pyobjc-framework-coretext
# pyobjc-framework-quartz
pyobjc-framework-coretext==11.1 ; python_full_version < '3.10' and sys_platform == 'darwin' \
--hash=sha256:15650ba99692d00953e91e53118c11636056a22c90d472020f7ba31500577bf5 \
--hash=sha256:1597bf7234270ee1b9963bf112e9061050d5fb8e1384b3f50c11bde2fe2b1570 \
--hash=sha256:37e051e8f12a0f47a81b8efc8c902156eb5bc3d8123c43e5bd4cebd24c222228 \
--hash=sha256:515be6beb48c084ee413c00c4e9fbd6e730c1b8a24270f4c618fc6c7ba0011ce \
--hash=sha256:56a3a02202e0d50be3c43e781c00f9f1859ab9b73a8342ff56260b908e911e37 \
--hash=sha256:7fee99a1ac96e3f70d482731bc39a546da82a58f87fa9f0e2b784a5febaff33d \
--hash=sha256:a29bbd5d85c77f46a8ee81d381b847244c88a3a5a96ac22f509027ceceaffaf6 \
--hash=sha256:b4f4d2d2a6331fa64465247358d7aafce98e4fb654b99301a490627a073d021e \
--hash=sha256:fb27f66a56660c31bb956191d64b85b95bac99cfb833f6e99622ca0ac4b3ba12
# via pyobjc-framework-applicationservices
pyobjc-framework-coretext==12.2.2 ; python_full_version >= '3.10' and sys_platform == 'darwin' \
pyobjc-framework-coretext==12.2.2 ; sys_platform == 'darwin' \
--hash=sha256:1324d91305022b1de03a12cf82de2b42c87dafe9b12cde6237253658a8637139 \
--hash=sha256:1dc50de18346c4d6c89a51df066f03beea81e3557e3090ddc2a7a59395332fd8 \
--hash=sha256:295d1037494630527acc24d116b0301ab5cf5e918952f49f7bf4f76ebae24d0c \
--hash=sha256:64ddc02303217028e32e22c7cc00b5112d84e9d9a67c37d00c2e54f9172284ab \
--hash=sha256:77a379ff47c599cb2bab918eb75680d30d950692ec19d67b734d11ded090b4e1 \
--hash=sha256:8af29c8f5fd3404b2ce3730ac1166c5b66e206830603facb85d0ad3ede5309dc \
--hash=sha256:a69611de569d92776e6391ca25492a5bbb4a05ba744006349e377a4fc5f7c8a5 \
--hash=sha256:b585f0db77280930cb32e71cf8d27f5203d8c0f42a65fba690294530ab6e1aa6 \
--hash=sha256:dbd0f288a859e359ee8e7ccf45bc2c489f38ae1087c21468b61d700433e0c91b \
--hash=sha256:f1ad871d761bad9a0d461cceeeff7375a39f4d181c2a206dada6fc05307fec12
# via pyobjc-framework-applicationservices
pyobjc-framework-quartz==11.1 ; python_full_version < '3.10' and sys_platform == 'darwin' \
--hash=sha256:07cbda78b4a8fcf3a2d96e047a2ff01f44e3e1820f46f0f4b3b6d77ff6ece07c \
--hash=sha256:2d501fe95ef15d8acf587cb7dc4ab4be3c5a84e2252017da8dbb7df1bbe7a72a \
--hash=sha256:39d02a3df4b5e3eee1e0da0fb150259476910d2a9aa638ab94153c24317a9561 \
--hash=sha256:43a1138280571bbf44df27a7eef519184b5c4183a588598ebaaeb887b9e73e76 \
--hash=sha256:9ac806067541917d6119b98d90390a6944e7d9bd737f5c0a79884202327c9204 \
--hash=sha256:9b1f451ddb5243d8d6316af55f240a02b0fffbfe165bff325628bf73f3df7f44 \
--hash=sha256:a57f35ccfc22ad48c87c5932818e583777ff7276605fef6afad0ac0741169f75 \
--hash=sha256:b23d81c30c564adf6336e00b357f355b35aad10075dd7e837cfd52a9912863e5 \
--hash=sha256:b5ef75c416b0209e25b2eb07a27bd7eedf14a8c6b2f968711969d45ceceb0f84
# via
# pynput
# pyobjc-framework-applicationservices
# pyobjc-framework-coretext
pyobjc-framework-quartz==12.2.2 ; python_full_version >= '3.10' and sys_platform == 'darwin' \
pyobjc-framework-quartz==12.2.2 ; sys_platform == 'darwin' \
--hash=sha256:0ec9751904ef975bf0789d760dc4fadcb400edc4ffe4a736eb54971968babe5c \
--hash=sha256:1f7f3d9010e38f03ea1fa266664c10ea349cd7492bd603b403584f49d713dbed \
--hash=sha256:4b01e325b0cdc121e78730dde9756e971b23069bf141cd62efbcaac76d7b6dbb \
@@ -748,79 +453,54 @@ pyobjc-framework-quartz==12.2.2 ; python_full_version >= '3.10' and sys_platform
--hash=sha256:810f97b210cfd93704d240860286dfd6df09f9f1c52525fc5c2166723aea3f9e \
--hash=sha256:8f58c589b5a76ba98f186b1f3b19fb1c8b730e82351f81fb62e6194f64a71622 \
--hash=sha256:cee63b891c2b6b7ccf98f233175411529f3e80286f58438793b3634af79858f1 \
--hash=sha256:d89a5f47c079b5c340d2b1cbb83eb6c4c92d4bb17cd4daf7d8c02c91a49f5399 \
--hash=sha256:ebf8167ca2096cf3a05199decfa517be0df4c56048f49cf132bd6b1a6ab9c086
# via
# pynput
# pyobjc-framework-applicationservices
# pyobjc-framework-coretext
pyreadline3==3.5.6 ; python_full_version < '3.10' and sys_platform == 'win32' \
--hash=sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf \
--hash=sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d
# via humanfriendly
python-xlib==0.33 ; 'linux' in sys_platform \
--hash=sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32 \
--hash=sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398
# via pynput
pywhispercpp==1.5.0 \
--hash=sha256:03025227d0da211e382db6b6b16d910a267c663b6b2c57092b1200965ec45393 \
--hash=sha256:0804fc3a2f168f78b6869386c221a3622bc76cdb5ba5e25b8347572aac704542 \
--hash=sha256:0c393c01a0a77007a17d17cb706e08cba97f0e35ea0268733af1792949d03ceb \
--hash=sha256:0f895cbbf5ba71a9e3f0e6afb4b6ae49bae5c716dc6a8a4d905eb789d9fe2238 \
--hash=sha256:11a3cd1973238beccd8ca523e7789bee88956968881893770bedd26be1c995d7 \
--hash=sha256:1bc31f684b6a38000e397824155bda3b45b61af6acc9f7e0f3bfa70e20b574e3 \
--hash=sha256:22f9ddf12f6fb0fe3c43465d976b554e8928fcc113c902127e94c792d85f0631 \
--hash=sha256:24602b8d0329a224fe6eb1c1e455853e983a5e04a63d4cb759ae2f275e254f5f \
--hash=sha256:2498d591cca97bf186adb0d746775c21debd8a264b2053358f252cc9dd10167a \
--hash=sha256:24da0c0bd33068f4c9c3f45eae3e6b7fb31d9b5d3487e4e1e1323b4253288d62 \
--hash=sha256:2ea8fa7f5ce602d3e986fb11c4c2973b471f1b14e0fe75e01a66f1a60aecbfbf \
--hash=sha256:358261055c56a96a04f6b0ff419633ffc61ef61027788b2bb2c6e7dca69abdb7 \
--hash=sha256:3f825942067da3b0c48fc5b552b816fcee1761e955b9fdfdc911f0ab2d6fff11 \
--hash=sha256:4363fa893e398d1f18a5775374109aa310022b9f4fc777a89329b12963f3149d \
--hash=sha256:4bb409027566b473100e43f07fac48f9c8bdb6cba1ac0fbff5594a6f5a0e1b7e \
--hash=sha256:521df17d4e1d7ccd192303051bda8ce8b516f66816ed0b3a32281b57a6a0d3c4 \
--hash=sha256:568669bae45bd879bba1e35401cefc89d572428bd17326ce8437107ab80003d0 \
--hash=sha256:57dd5111d4dd9fe3f0e784a03a3337df5abd514260a7d16656733d110316d2f0 \
--hash=sha256:5aa54c73ec96617d97aa519dbbf817e44255eddd9bd52ee1243be61028dbd018 \
--hash=sha256:64415b7687c0b39e66652b38683983ef7f23e174d7b3b05caa539955da8b088e \
--hash=sha256:649af3ae0f9dc0d0dd757d8c2adfc6d929b1b203ac1ba85d5ac453015ddd6e63 \
--hash=sha256:6ad4071772eb9dad59a741611e44e3819e6249c46e295ab8468609d03c6e4232 \
--hash=sha256:6e709172192331a7e3f18fc227279e3e11295894b5e2d3caca1bf837ecb69128 \
--hash=sha256:70e925fad2c48554d3f5baf79e2746540268d2974b5cf40a4880887e4a065c2f \
--hash=sha256:77d4d0ff382b5bc1369e9f77a851b45659edf5974e0903ffb15b981a043d216b \
--hash=sha256:8796bcc410b7f6bd5af430e22116458d178effba628555c4c1d7db9ecce11d55 \
--hash=sha256:9806e084ebf956c7e492c76ea31c708e894849024772b3c3a57f85c342afc341 \
--hash=sha256:9b43ddd44e65ca53d5ab1f62b623bc3edc64019bbe06b9641fff2dfef704c990 \
--hash=sha256:a2fe9312fe3c08f832da392296118cc8cc1fc901753289132253f2066d911357 \
--hash=sha256:a6777b5d40c0679952ded7934f7119d5fbfd606ec348293f869f430795609bae \
--hash=sha256:a839b738936a8cd72cdd55c59a83a462820532bee78e233241bf9a3503e92144 \
--hash=sha256:a96816cf6b8d62c19ff3eda89094679006f86cef81acd60339ca19cc56241b95 \
--hash=sha256:b1f94300f2fa1748f67f63a9240b526e8542b2dad1ceca5ea87ec2e9e15a939a \
--hash=sha256:b592011cd992a5bcd36d0923477fb4cfdd338a0c7ce58997de195a14ab2b6d9a \
--hash=sha256:be1683fe67b5980472779eac8592536f993b987e193af21fda7da1c4c929561b \
--hash=sha256:c35c16f5da31d79be604dfd082b8bb67d5d46ba2f7f59bf5ab13857d5cd6ec6b \
--hash=sha256:c9498eb9f4c38ea121da2435b5275c267486a5e14dddf5e72ec54a480b1fb44e \
--hash=sha256:ce3f1e511335ca6c4f8cd2eabd8f0db5cdaff54047b7aa3f24bd962a9436b077 \
--hash=sha256:d2805f15361247ada04a34b51c556514527b984f9b089a4e32f1b2a4abbc5367 \
--hash=sha256:d92eec44b49595ab1454cff1d10faa83b0b27779db8d8f00592ed363f0c5a262 \
--hash=sha256:dc8dbcb6033db306e9ec450d835c005dd491c457937c79ef2673ea8797cbb93c \
--hash=sha256:e287bc277eef67729c57a4c7abf687e38ad9bd62074cdf451ca3e0fcf1ff4859 \
--hash=sha256:e2e89712d340170771e538e39543b904535195621a0706836ababb69fb159ff1 \
--hash=sha256:ea34ae824a6f934ba7736e6440f93cc467a7ec98b9c8fb2e5a37bb439ffa93b9 \
--hash=sha256:eb36ac24e0c80199cda03a393df53941f0dfaac463773bf770163984fb984c7e \
--hash=sha256:eed1d5f73d8c38f2f18cdf11882860ac7e6e061e611c67627933d7e1b59b6fcb \
--hash=sha256:f5d13b4a7e34b9f4eebf70bff150ec36f4145c580bd5d812b9a3139611e7433c \
--hash=sha256:f9b1cd9dd4c3eb7fb2b777529eb62618e58a6bb05be20f82b7b1afe3d0b12274 \
--hash=sha256:fbdbb1bb236f4481ff6ea475aed9ed091393ddb02271df309645d71de207778d \
--hash=sha256:fd163b551b95112ddd18aac6bddd4cd30636790bff288274ebbb404e3f66a08f
--hash=sha256:fbdbb1bb236f4481ff6ea475aed9ed091393ddb02271df309645d71de207778d
# via vocalinux
requests==2.32.5 ; python_full_version < '3.10' \
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
# via
# pywhispercpp
# vocalinux
requests==2.34.2 ; python_full_version >= '3.10' \
requests==2.34.2 \
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
--hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
# via
@@ -832,19 +512,11 @@ six==1.17.0 \
# via
# pynput
# python-xlib
sympy==1.14.0 ; python_full_version < '3.11' \
--hash=sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517 \
--hash=sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5
# via onnxruntime
tqdm==4.70.0 \
--hash=sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220 \
--hash=sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953
# via pywhispercpp
urllib3==2.6.3 ; python_full_version < '3.10' \
--hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \
--hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4
# via requests
urllib3==2.7.0 ; python_full_version >= '3.10' \
urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
# via requests
+4 -4
View File
@@ -9,14 +9,14 @@ check_deps() {
echo "Checking Vocalinux system dependencies..."
echo ""
# Check for Python 3.9+
# Check for Python 3.11+ (requires-python in pyproject.toml)
if command -v python3 >/dev/null 2>&1; then
PY_VERSION=$(python3 -c "import sys; print('.'.join(map(str, sys.version_info[:2])))")
echo "✓ Python $PY_VERSION"
if [ "$(echo "$PY_VERSION" | cut -d. -f1)" -lt 3 ] || \
[ "$(echo "$PY_VERSION" | cut -d. -f2)" -lt 9 ]; then
echo " ✗ Python 3.9+ required, found $PY_VERSION"
[ "$(echo "$PY_VERSION" | cut -d. -f2)" -lt 11 ]; then
echo " ✗ Python 3.11+ required, found $PY_VERSION"
MISSING=1
fi
else
@@ -172,7 +172,7 @@ PY
echo " If \"\${PYVER}-virtualenv\" is unavailable, try \"\${PYVER}-venv\"."
elif [[ "$DETECTED_DISTRO" == "gentoo" ]]; then
echo "Gentoo:"
echo " sudo emerge dev-python/pygobject:3 x11-libs/gtk+:3 dev-libs/libayatana-appindicator media-libs/portaudio dev-lang/python:3.9 xdotool wtype"
echo " sudo emerge dev-python/pygobject:3 x11-libs/gtk+:3 dev-libs/libayatana-appindicator media-libs/portaudio dev-lang/python:3.11 xdotool wtype"
elif [[ "$DETECTED_DISTRO" == "alpine" ]]; then
echo "Alpine Linux:"
echo " sudo apk add py3-gobject3 py3-pip gtk+3.0 py3-cairo portaudio-dev py3-virtualenv pkgconf xdotool wtype"
+2 -2
View File
@@ -57,7 +57,7 @@ system_packages:
# Tumbleweed package names follow the active Python minor version, e.g. python313-devel.
# NOTE: "python3XY" is a placeholder — see gtk3.opensuse comment above for details.
opensuse: ["python3XY-devel", "python3XY-virtualenv", "python3XY-venv", "python3XY-pip"]
gentoo: ["dev-lang/python:3.9"]
gentoo: ["dev-lang/python:3.11"]
alpine: ["py3-virtualenv"]
void: ["python3-devel"]
solus: ["python3-virtualenv"]
@@ -217,7 +217,7 @@ distribution_notes:
gentoo:
general: "Gentoo compiles packages from source. Installation will take longer."
python: "Ensure Python 3.9+ is set as the active version"
python: "Ensure Python 3.11+ is set as the active version"
alpine:
general: "Alpine uses musl libc. Some Python packages may not have pre-built wheels."
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Regenerate src/vocalinux/utils/model_checksums.txt from upstream metadata.
Vocalinux downloads 40MB-2GB speech models at install time and on first run,
then hands them to native code (whisper.cpp loads its ggml files through
``ctypes.cdll``). Nothing about that is safe unless we know what we received,
so every downloader checks the file against a hash pinned *in this repository*
rather than one fetched alongside the download.
Where that hash comes from differs by registry, and so does what generating it
costs:
* whisper.cpp ships on Hugging Face, whose API exposes the sha256 of every LFS
blob (``lfs.oid``) plus the commit the listing was taken at, so nothing is
downloaded for it. That commit is pinned too -- the download URLs use it
instead of ``main``, so upstream retagging a file cannot turn every user's
install into a checksum failure.
* VOSK is different: ``model-list.json`` carries an md5 per model, but that is a
metadata field from the same host we are pinning against, so copying it pins
nothing an attacker could not change alongside the file. Those models are
downloaded here and pinned by the sha256 of the bytes we actually received;
the published md5 is checked against them on the way through, so a listing
that disagrees with its own file is caught at generation time. Downloading is
incremental -- a model already pinned by sha256 is not fetched again, so only
new models cost bandwidth. A first run, or any run with --refresh, therefore
pulls every VOSK zip: ~21.9GB, roughly half an hour on a fast connection.
The model names come from the package itself, so a model added to
vosk_model_info.py or whispercpp_model_info.py is picked up on the next run and
a model that upstream never published fails the generation loudly.
Usage: just model-checksums (or: python scripts/generate-model-checksums.py)
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
import urllib.request
from pathlib import Path
from typing import Iterable, NamedTuple
REPO_ROOT = Path(__file__).resolve().parents[1]
SRC = REPO_ROOT / "src"
MANIFEST = SRC / "vocalinux" / "utils" / "model_checksums.txt"
HF_REPO = "ggerganov/whisper.cpp"
HF_API = f"https://huggingface.co/api/models/{HF_REPO}?blobs=true"
VOSK_MODEL_LIST = "https://alphacephei.com/vosk/models/model-list.json"
TIMEOUT = 60
class Entry(NamedTuple):
"""One verifiable artifact: the file name is the manifest's lookup key."""
filename: str
algo: str
digest: str
size: int
def _fetch_json(url: str):
request = urllib.request.Request(url, headers={"User-Agent": "vocalinux-checksum-generator"})
with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310
return json.load(response)
def _existing_sha256_entries() -> dict:
"""sha256 entries already in the manifest, so a rerun re-downloads nothing."""
if not MANIFEST.exists():
return {}
entries = {}
for line in MANIFEST.read_text(encoding="utf-8").splitlines():
if line.startswith("#") or not line.strip():
continue
fields = line.split()
if len(fields) == 4 and fields[1] == "sha256":
entries[fields[0]] = Entry(fields[0], "sha256", fields[2], int(fields[3]))
return entries
def _download_and_hash(url: str, expected_md5: str) -> tuple[str, int]:
"""Stream ``url``, returning the sha256 of the bytes and their length.
The published md5 is verified on the way through: it is not what we pin, but
a mismatch means the listing and the file disagree, which is worth stopping
for rather than pinning either one.
"""
sha256, md5, size = hashlib.sha256(), hashlib.md5(), 0
request = urllib.request.Request(url, headers={"User-Agent": "vocalinux-checksum-generator"})
with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310
for chunk in iter(lambda: response.read(1024 * 1024), b""):
sha256.update(chunk)
md5.update(chunk)
size += len(chunk)
if expected_md5 and md5.hexdigest() != expected_md5:
raise SystemExit(
f"{url}\n"
f" published md5: {expected_md5}\n"
f" actual md5: {md5.hexdigest()}\n"
"The listing does not describe the file it points at. Refusing to pin either."
)
return sha256.hexdigest(), size
def _model_names() -> tuple[list[str], list[str]]:
"""Model names the application can actually download, straight from the package."""
sys.path.insert(0, str(SRC))
from vocalinux.utils.vosk_model_info import VOSK_MODEL_INFO
from vocalinux.utils.whispercpp_model_info import WHISPERCPP_MODEL_INFO, whispercpp_model_file
vosk = {
name for info in VOSK_MODEL_INFO.values() for name in info["languages"].values() if name
}
whispercpp = {whispercpp_model_file(name) for name in WHISPERCPP_MODEL_INFO}
return sorted(whispercpp), sorted(vosk)
def _whispercpp_entries(wanted: Iterable[str]) -> tuple[list[Entry], str]:
payload = _fetch_json(HF_API)
revision = payload.get("sha")
if not revision:
raise SystemExit(f"{HF_API} returned no commit sha; refusing to pin an unknown revision")
by_name = {}
for sibling in payload.get("siblings", []):
lfs = sibling.get("lfs") or {}
digest = lfs.get("oid") or lfs.get("sha256")
if digest:
by_name[sibling["rfilename"]] = (digest, int(lfs.get("size", 0)))
entries, missing = [], []
for filename in wanted:
if filename not in by_name:
missing.append(filename)
continue
digest, size = by_name[filename]
entries.append(Entry(filename, "sha256", digest, size))
if missing:
raise SystemExit(
f"Hugging Face {HF_REPO}@{revision} publishes no sha256 for: {', '.join(missing)}.\n"
"Either the model was renamed upstream or whispercpp_model_info.py lists "
"a file that does not exist."
)
return entries, revision
def _vosk_entries(wanted: Iterable[str], refresh: bool) -> list[Entry]:
payload = _fetch_json(VOSK_MODEL_LIST)
by_name = {item["name"]: item for item in payload}
existing = {} if refresh else _existing_sha256_entries()
missing = [name for name in wanted if name not in by_name]
if missing:
raise SystemExit(
f"{VOSK_MODEL_LIST} does not list: {', '.join(missing)}.\n"
"Either the model was withdrawn upstream or vosk_model_info.py names "
"a model Alphacephei never shipped."
)
todo = [name for name in wanted if f"{name}.zip" not in existing]
if todo:
total = sum(int(by_name[name].get("size", 0)) for name in todo)
print(f" downloading {len(todo)} VOSK model(s), {total / 1e9:.1f} GB, to hash the bytes")
entries = []
for name in wanted:
filename = f"{name}.zip"
if filename in existing:
entries.append(existing[filename])
continue
item = by_name[name]
print(f" {name} ({int(item.get('size', 0)) / 1e6:.0f} MB)...", flush=True)
digest, size = _download_and_hash(item["url"], item.get("md5", ""))
entries.append(Entry(filename, "sha256", digest, size))
return entries
def _render(entries: list[Entry], revision: str) -> str:
width = max(len(entry.filename) for entry in entries)
lines = [
"# Checksums for every speech model Vocalinux downloads.",
"#",
"# Generated by scripts/generate-model-checksums.py -- do not edit by hand.",
"# Regenerate with `just model-checksums` after adding a model to",
"# vosk_model_info.py or whispercpp_model_info.py.",
"#",
"# Read by src/vocalinux/utils/model_checksums.py and by install.sh; keep the",
"# format (filename, algorithm, digest, size in bytes) parseable by awk.",
"#",
"# whisper.cpp models are pinned to this Hugging Face commit so upstream",
"# replacing a file cannot invalidate the digests below.",
f"# whispercpp-revision: {revision}",
"#",
"# OpenAI Whisper (.pt) models are absent on purpose: their download URLs embed",
"# the sha256 as a path segment, so the expected digest travels with the URL.",
"#",
"# VOSK digests are the sha256 of bytes this script downloaded, not the md5",
"# Alphacephei publishes; that md5 is only checked against them at that point.",
"",
]
for entry in entries:
lines.append(
f"{entry.filename.ljust(width)} {entry.algo:<6} {entry.digest} {entry.size}"
)
return "\n".join(lines) + "\n"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--refresh",
action="store_true",
help="re-download every VOSK model instead of reusing pinned sha256 entries",
)
args = parser.parse_args()
whispercpp_files, vosk_names = _model_names()
print(f"Resolving {len(whispercpp_files)} whisper.cpp and {len(vosk_names)} VOSK models...")
whispercpp_entries, revision = _whispercpp_entries(whispercpp_files)
print(f" whisper.cpp: {len(whispercpp_entries)} sha256 digests at {HF_REPO}@{revision[:12]}")
vosk_entries = _vosk_entries(vosk_names, args.refresh)
print(f" VOSK: {len(vosk_entries)} sha256 digests")
MANIFEST.write_text(_render(whispercpp_entries + vosk_entries, revision), encoding="utf-8")
print(f"Wrote {MANIFEST.relative_to(REPO_ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -21,8 +21,18 @@ from typing import Callable, Optional
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,
verify_model_file,
write_verification_stamp,
)
from ..utils.paths import models_dir
from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO
from ..utils.whisper_model_info import (
migrate_legacy_checkpoint_names,
whisper_model_file,
whisper_model_url,
)
from ..utils.whispercpp_model_info import WHISPERCPP_MODEL_INFO, get_model_path, is_model_downloaded
from ..version import __version__
from .command_processor import CommandProcessor
@@ -875,7 +885,9 @@ def _get_system_model_paths() -> list:
# Arch Linux doesn't use /usr/local
if "arch" in os_release:
paths.remove("/usr/local/share/vocalinux/models")
local_share_path = "/usr/local/share/vocalinux/models"
if local_share_path in paths:
paths.remove(local_share_path)
except (IOError, OSError, FileNotFoundError):
pass # File doesn't exist on all systems
@@ -1123,14 +1135,16 @@ class SpeechRecognitionManager:
)
self.model_size = "base"
# Check if model is downloaded
# Only this directory counts: load_model() gets it as download_root
# and looks nowhere else, so a copy in ~/.cache/whisper saves nothing.
whisper_cache_dir = os.path.join(MODELS_DIR, "whisper")
os.makedirs(whisper_cache_dir, exist_ok=True)
model_file = os.path.join(whisper_cache_dir, f"{self.model_size}.pt")
default_cache = os.path.expanduser("~/.cache/whisper")
default_model_file = os.path.join(default_cache, f"{self.model_size}.pt")
model_exists = os.path.exists(model_file) or os.path.exists(default_model_file)
# Before asking whether the model is here: an earlier release saved it
# under the catalog name, which for "large" is not what load_model
# looks for. Without this it refetches 2.9GB and orphans the old file.
migrate_legacy_checkpoint_names(whisper_cache_dir)
model_file = os.path.join(whisper_cache_dir, whisper_model_file(self.model_size))
model_exists = os.path.exists(model_file)
if not model_exists and self._defer_download:
logger.info(
@@ -1139,7 +1153,6 @@ class SpeechRecognitionManager:
self._model_initialized = False
return # Don't block startup
# If model doesn't exist and we're not deferring, download it with progress
if not model_exists:
logger.info(f"Downloading Whisper '{self.model_size}' model...")
self._download_whisper_model(whisper_cache_dir)
@@ -1152,7 +1165,9 @@ class SpeechRecognitionManager:
# Ensure previous model is released if re-initializing
self.model = None
# Load model with device and custom cache directory
# load_model() verifies the checkpoint against the sha256 in its URL,
# for a file already on disk as well as a fresh download, so Vocalinux
# never hashes Whisper checkpoints itself.
self.model = whisper.load_model(
self.model_size, device=device, download_root=whisper_cache_dir
)
@@ -1255,6 +1270,16 @@ class SpeechRecognitionManager:
# Check if model is downloaded
model_path = get_model_path(self.model_size)
# A file that is merely present did not necessarily come through the
# download path: releases before checksum verification fetched ggml
# models from `resolve/main` with no check at all, and install.sh
# 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:
self._model_initialized = False
return
if not os.path.exists(model_path):
if self._defer_download:
logger.info(
@@ -1280,6 +1305,29 @@ class SpeechRecognitionManager:
self.state = RecognitionState.ERROR
raise
@staticmethod
def _whispercpp_model_is_verified(model_path: str) -> bool:
"""Hash the model against its pin, or delete it and report False.
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.
"""
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)
try:
os.remove(model_path)
except OSError as remove_error:
logger.error("Could not remove %s: %s", model_path, remove_error)
return False
logger.info("Removed the unverified model; it will be downloaded again")
return False
def _build_whispercpp_model_kwargs(self, n_threads: int) -> dict:
model_kwargs = {
"n_threads": n_threads,
@@ -1945,7 +1993,7 @@ class SpeechRecognitionManager:
raise RuntimeError(
f"Model URL returned HTML instead of a binary file "
f"(status {response.status_code}, content-type {content_type}). "
f"Hugging Face may be unavailable; try again later."
f"The server may be unavailable; try again later."
)
total_size = int(response.headers.get("content-length", 0))
@@ -2003,9 +2051,40 @@ class SpeechRecognitionManager:
os.remove(dest_path)
raise RuntimeError(
f"Downloaded 0 bytes from {url}. "
"Hugging Face may be down or blocked; try again later."
"The server may be down or blocked; try again later."
)
def _download_whisper_model(self, cache_dir: str) -> None:
"""Fetch an OpenAI Whisper checkpoint into ``cache_dir``.
No digest check here: whisper.load_model() verifies whatever it loads.
This exists so the UI gets progress and a working cancel, which
load_model does not expose.
"""
import requests
self._download_cancelled = False
url = whisper_model_url(self.model_size)
model_file = os.path.join(cache_dir, whisper_model_file(self.model_size))
temp_file = model_file + ".tmp"
os.makedirs(cache_dir, exist_ok=True)
logger.info(f"Downloading Whisper {self.model_size} model to {model_file}")
try:
self._stream_model_download(url, temp_file)
os.rename(temp_file, model_file)
if self._download_progress_callback:
self._download_progress_callback(1.0, 0, "Complete!")
except requests.exceptions.RequestException as e:
logger.error(f"Failed to download Whisper model from {url}: {e}")
if os.path.exists(temp_file):
os.remove(temp_file)
raise RuntimeError(f"Failed to download Whisper model: {e}") from e
except (OSError, RuntimeError, ValueError):
if os.path.exists(temp_file):
os.remove(temp_file)
raise
def _download_whispercpp_model(self):
"""Download a whisper.cpp model with progress tracking."""
import requests
@@ -2029,6 +2108,10 @@ class SpeechRecognitionManager:
try:
self._stream_model_download(url, temp_file)
# Verify before the rename: whisper.cpp loads ggml files through
# ctypes, so an unverified file must never reach its final path
# where is_model_downloaded() would treat it as good.
verify_model_file(temp_file, os.path.basename(model_path))
os.rename(temp_file, model_path)
logger.info("whisper.cpp model downloaded successfully")
@@ -2049,7 +2132,7 @@ class SpeechRecognitionManager:
"Check your network and try again."
) from e
raise RuntimeError(f"Failed to download whisper.cpp model: {e}") from e
except (OSError, RuntimeError, ValueError) as e:
except (ChecksumError, OSError, RuntimeError, ValueError) as e:
logger.error(f"An error occurred during whisper.cpp model download: {e}")
if os.path.exists(temp_file):
os.remove(temp_file)
@@ -2101,245 +2184,111 @@ class SpeechRecognitionManager:
logger.info("Download cancellation requested")
def _download_vosk_model(self):
"""Download the VOSK model if it doesn't exist."""
"""Download and extract the VOSK model if it doesn't exist."""
import zipfile
import requests
self._download_cancelled = False
model_urls = {
"small": f"https://alphacephei.com/vosk/models/{self.vosk_model_map['small']}.zip",
"medium": f"https://alphacephei.com/vosk/models/{self.vosk_model_map['medium']}.zip",
"large": f"https://alphacephei.com/vosk/models/{self.vosk_model_map['large']}.zip",
}
url = model_urls.get(self.model_size)
if not url:
model_name = self.vosk_model_map.get(self.model_size)
if not model_name:
raise ValueError(f"Unknown model size: {self.model_size}")
url = f"https://alphacephei.com/vosk/models/{model_name}.zip"
model_name = os.path.basename(url).replace(".zip", "")
# Always download to user's local directory
model_path = os.path.join(MODELS_DIR, model_name)
zip_path = os.path.join(MODELS_DIR, os.path.basename(url))
# Create models directory if it doesn't exist
zip_path = os.path.join(MODELS_DIR, f"{model_name}.zip")
os.makedirs(MODELS_DIR, exist_ok=True)
logger.info(f"Downloading VOSK {self.model_size} model to user directory: {model_path}")
# Download the model
logger.info(f"Downloading VOSK model from {url}")
logger.info(f"Downloading VOSK {self.model_size} model to {model_path}")
try:
response = requests.get(url, stream=True, timeout=self._MODEL_DOWNLOAD_TIMEOUT)
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
self._stream_model_download(url, zip_path)
total_size = int(response.headers.get("content-length", 0))
downloaded_size = 0
start_time = time.time()
last_update_time = start_time
chunk_size = 8192 # 8KB chunks for smoother progress
# Verify before extracting, so a zip that fails its pinned digest
# never writes files into MODELS_DIR.
if self._download_progress_callback:
self._download_progress_callback(1.0, 0, "Verifying model...")
verify_model_file(zip_path)
with open(zip_path, "wb") as f:
for data in response.iter_content(chunk_size=chunk_size):
if self._download_cancelled:
logger.info("Download cancelled by user")
f.close()
if os.path.exists(zip_path):
os.remove(zip_path)
raise RuntimeError("Download cancelled")
f.write(data)
downloaded_size += len(data)
# Update progress callback
current_time = time.time()
if (
self._download_progress_callback
and (current_time - last_update_time) >= 0.1
):
elapsed = current_time - start_time
if elapsed > 0:
speed_mbps = (downloaded_size / (1024 * 1024)) / elapsed
else:
speed_mbps = 0
if total_size > 0:
progress = downloaded_size / total_size
remaining_mb = (total_size - downloaded_size) / (1024 * 1024)
if speed_mbps > 0:
eta_seconds = remaining_mb / speed_mbps
eta_str = (
f"{int(eta_seconds)}s"
if eta_seconds < 60
else f"{int(eta_seconds / 60)}m {int(eta_seconds % 60)}s"
)
else:
eta_str = "--"
status = f"{downloaded_size / (1024 * 1024):.1f} / {total_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s • ETA: {eta_str}"
else:
progress = 0
status = (
f"{downloaded_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s"
)
self._download_progress_callback(progress, speed_mbps, status)
last_update_time = current_time
# Also log progress periodically
logger.info(f"Download progress: {progress * 100:.1f}% - {status}")
# Update status for extraction phase
if self._download_progress_callback:
self._download_progress_callback(1.0, 0, "Extracting model...")
# Extract the model
logger.info(f"Extracting VOSK model to {model_path}")
with zipfile.ZipFile(zip_path, "r") as zip_ref:
zip_ref.extractall(MODELS_DIR)
self._unpack_vosk_model(zipfile, zip_path, model_name, model_path)
# Remove the zip file
os.remove(zip_path)
logger.info("VOSK model downloaded and extracted successfully")
# Final status
if self._download_progress_callback:
self._download_progress_callback(1.0, 0, "Complete!")
except requests.exceptions.RequestException as e:
logger.error(f"Failed to download VOSK model from {url}: {e}")
# Clean up potentially incomplete download
if os.path.exists(zip_path):
os.remove(zip_path)
self._discard_vosk_scratch(zip_path, model_name)
raise RuntimeError(f"Failed to download VOSK model: {e}") from e
except zipfile.BadZipFile:
logger.error(f"Downloaded file from {url} is not a valid zip file.")
# Clean up corrupted download
if os.path.exists(zip_path):
os.remove(zip_path)
self._discard_vosk_scratch(zip_path, model_name)
raise RuntimeError("Downloaded VOSK model file is corrupted.")
except (OSError, RuntimeError, ValueError) as e:
except (ChecksumError, OSError, RuntimeError, ValueError) as e:
logger.error(f"An error occurred during VOSK model download/extraction: {e}")
# Clean up potentially corrupted extraction
if os.path.exists(zip_path):
os.remove(zip_path)
# Consider removing partially extracted model dir if needed
# if os.path.exists(model_path): shutil.rmtree(model_path)
self._discard_vosk_scratch(zip_path, model_name)
raise
def _download_whisper_model(self, cache_dir: str):
"""Download a Whisper model with progress tracking."""
import requests
@staticmethod
def _vosk_scratch_paths(model_name: str) -> tuple:
"""Where a download unpacks (staging) and parks the tree it replaces."""
return (
os.path.join(MODELS_DIR, f".{model_name}.incoming"),
os.path.join(MODELS_DIR, f".{model_name}.replaced"),
)
self._download_cancelled = False
def _discard_vosk_scratch(self, zip_path: str, model_name: str) -> None:
"""Leave nothing behind that a later run would mistake for a model."""
import shutil
# Whisper model URLs (from openai-whisper package)
model_urls = {
"tiny": "https://openaipublic.azureedge.net/main/whisper/models/"
"65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/"
"tiny.pt",
"base": "https://openaipublic.azureedge.net/main/whisper/models/"
"ed3a0b6b1c0edf879ad9b11b1af5a0e6ab5db9205f891f668f8b0e6c6326e34e/"
"base.pt",
"small": "https://openaipublic.azureedge.net/main/whisper/models/"
"9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794/"
"small.pt",
"medium": "https://openaipublic.azureedge.net/main/whisper/models/"
"345ae4da62f9b3d59415adc60127b97c714f32e89e936602e85993674d08dcb1/"
"medium.pt",
"large": "https://openaipublic.azureedge.net/main/whisper/models/"
"e5b1a55b89c1367dacf97e3e19bfd829a01529dbfdeefa8caeb59b3f1b81dadb/"
"large-v3.pt",
}
if os.path.exists(zip_path):
os.remove(zip_path)
for path in self._vosk_scratch_paths(model_name):
shutil.rmtree(path, ignore_errors=True)
url = model_urls.get(self.model_size)
if not url:
raise ValueError(f"Unknown Whisper model size: {self.model_size}")
def _unpack_vosk_model(self, zipfile, zip_path: str, model_name: str, model_path: str) -> None:
"""Unpack the verified zip beside the model, stamp it, then swap it in.
model_file = os.path.join(cache_dir, f"{self.model_size}.pt")
temp_file = model_file + ".tmp"
The stamp is the only evidence the tree was ever checked, so a tree must
not become visible without one: _init_vosk would treat it as installed
while install.sh, finding no stamp, refetched it on every run. Unpacking
into a staging directory and renaming afterwards makes "extracted" and
"stamped" a single step, the way install_vosk_models already does it, and
keeps whatever was already there until the replacement is complete.
"""
import shutil
os.makedirs(cache_dir, exist_ok=True)
staging, replaced = self._vosk_scratch_paths(model_name)
shutil.rmtree(staging, ignore_errors=True)
shutil.rmtree(replaced, ignore_errors=True)
os.makedirs(staging, exist_ok=True)
logger.info(f"Downloading Whisper {self.model_size} model to {model_file}")
logger.info(f"Downloading from {url}")
with zipfile.ZipFile(zip_path, "r") as zip_ref:
zip_ref.extractall(staging)
extracted = os.path.join(staging, model_name)
if not os.path.isdir(extracted):
raise RuntimeError(f"{model_name}.zip does not contain a {model_name} directory")
write_verification_stamp(extracted, f"{model_name}.zip")
# Same filesystem, so both renames are cheap; the old tree is parked
# rather than deleted, so a failed swap can put it back.
if os.path.isdir(model_path):
os.rename(model_path, replaced)
try:
response = requests.get(url, stream=True)
response.raise_for_status()
total_size = int(response.headers.get("content-length", 0))
downloaded_size = 0
start_time = time.time()
last_update_time = start_time
chunk_size = 8192 # 8KB chunks
with open(temp_file, "wb") as f:
for data in response.iter_content(chunk_size=chunk_size):
if self._download_cancelled:
logger.info("Download cancelled by user")
f.close()
if os.path.exists(temp_file):
os.remove(temp_file)
raise RuntimeError("Download cancelled")
f.write(data)
downloaded_size += len(data)
# Update progress callback
current_time = time.time()
if (
self._download_progress_callback
and (current_time - last_update_time) >= 0.1
):
elapsed = current_time - start_time
if elapsed > 0:
speed_mbps = (downloaded_size / (1024 * 1024)) / elapsed
else:
speed_mbps = 0
if total_size > 0:
progress = downloaded_size / total_size
remaining_mb = (total_size - downloaded_size) / (1024 * 1024)
if speed_mbps > 0:
eta_seconds = remaining_mb / speed_mbps
eta_str = (
f"{int(eta_seconds)}s"
if eta_seconds < 60
else f"{int(eta_seconds / 60)}m {int(eta_seconds % 60)}s"
)
else:
eta_str = "--"
status = f"{downloaded_size / (1024 * 1024):.1f} / {total_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s • ETA: {eta_str}"
else:
progress = 0
status = (
f"{downloaded_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s"
)
self._download_progress_callback(progress, speed_mbps, status)
last_update_time = current_time
logger.info(f"Download progress: {progress * 100:.1f}% - {status}")
# Rename temp file to final
os.rename(temp_file, model_file)
logger.info("Whisper model downloaded successfully")
if self._download_progress_callback:
self._download_progress_callback(1.0, 0, "Complete!")
except requests.exceptions.RequestException as e:
logger.error(f"Failed to download Whisper model from {url}: {e}")
if os.path.exists(temp_file):
os.remove(temp_file)
raise RuntimeError(f"Failed to download Whisper model: {e}") from e
except (OSError, RuntimeError, ValueError) as e:
logger.error(f"An error occurred during Whisper model download: {e}")
if os.path.exists(temp_file):
os.remove(temp_file)
os.rename(extracted, model_path)
except OSError:
if os.path.isdir(replaced):
os.rename(replaced, model_path)
raise
shutil.rmtree(replaced, ignore_errors=True)
shutil.rmtree(staging, ignore_errors=True)
def register_text_callback(self, callback: Callable[[str], None]):
"""
+28 -10
View File
@@ -50,6 +50,10 @@ from ..utils.vosk_model_info import ( # noqa: E402
list_downloaded_vosk_models,
vosk_model_dirname,
)
from ..utils.whisper_model_info import ( # noqa: E402
migrate_legacy_checkpoint_names,
whisper_model_file,
)
from ..utils.whispercpp_model_info import MODEL_SIZES as WHISPERCPP_MODEL_SIZES
from ..utils.whispercpp_model_info import (
WHISPERCPP_MODEL_INFO,
@@ -841,7 +845,7 @@ def _whisper_model_files(model_name: str) -> list[str]:
if model_name not in WHISPER_MODEL_INFO:
return []
filename = f"{model_name}.pt"
filename = whisper_model_file(model_name)
candidates = [
os.path.join(_get_whisper_cache_dir(), filename),
os.path.join(os.path.expanduser("~/.cache/whisper"), filename),
@@ -867,18 +871,32 @@ def _whisper_model_files(model_name: str) -> list[str]:
def _is_whisper_model_downloaded(model_name: str) -> bool:
"""Check if a Whisper model is downloaded."""
cache_dir = _get_whisper_cache_dir()
model_file = os.path.join(cache_dir, f"{model_name}.pt")
if os.path.exists(model_file):
return True
# Also check default whisper cache
default_cache = os.path.expanduser("~/.cache/whisper")
return os.path.exists(os.path.join(default_cache, f"{model_name}.pt"))
"""Check if a Whisper model is downloaded.
Only the directory the engine passes to ``whisper.load_model`` as
``download_root`` counts. A copy in the default ~/.cache/whisper is not used
by the engine, so reporting it as downloaded would promise the user a model
that selecting it then spends up to 2.9GB fetching.
That has a corollary worth stating plainly, because this function is what
``_list_downloaded_whisper_models`` filters on and the dialog can only delete
what it lists: deleting a model here also removes a copy in
~/.cache/whisper, since ``_whisper_model_files`` returns both, but a model
that exists *only* there is not listed and so cannot be reclaimed from this
dialog at all.
"""
model_file = os.path.join(_get_whisper_cache_dir(), whisper_model_file(model_name))
return os.path.exists(model_file)
def _list_downloaded_whisper_models() -> list[str]:
"""Return OpenAI Whisper catalog names that are present on disk."""
"""Return OpenAI Whisper catalog names that are present on disk.
Checkpoints an earlier release stored under the catalog name are renamed
first, so a "large" downloaded back then is listed here (and can therefore be
deleted) instead of sitting on disk unreachable from this dialog.
"""
migrate_legacy_checkpoint_names(_get_whisper_cache_dir())
return [name for name in WHISPER_MODEL_INFO if _is_whisper_model_downloaded(name)]
+224
View File
@@ -0,0 +1,224 @@
"""Integrity verification for downloaded speech models.
Vocalinux pulls 40MB-2GB model files from Hugging Face, Alphacephei and
openaipublic.azureedge.net, then feeds them to native code -- whisper.cpp maps
its ggml files through ``ctypes.cdll``. A tampered or truncated model is
therefore not merely a bad transcription, so every download is checked against a
digest pinned in this repository before it is moved into place, and a model that
is merely found on disk is hashed before it is handed to native code.
One source of truth, consulted without a network lookup: ``model_checksums.txt``
next to this module pins a sha256 per file name, for whisper.cpp (the
``lfs.oid`` at the pinned Hugging Face revision) and for VOSK (the hash of the
bytes the generator downloaded; the md5 Alphacephei publishes beside a model
pins nothing they could not change alongside it). Regenerate it with
``scripts/generate-model-checksums.py``.
OpenAI Whisper ``.pt`` checkpoints are absent from it: ``openai-whisper``
downloads and verifies those itself, against the sha256 it reads out of the URL
path, so Vocalinux neither fetches nor hashes them. ``install.sh`` does preinstall
the tiny checkpoint, and verifies it there with its own bash implementation.
Verification fails closed: a file with no pinned digest is an error, not a
warning. ``tests/test_model_checksums.py`` asserts the manifest covers every
model the application can download, so that strictness is caught in CI rather
than on a user's machine.
"""
from __future__ import annotations
import hashlib
import logging
import os
import re
from functools import lru_cache
from typing import Dict, NamedTuple, Optional
logger = logging.getLogger(__name__)
_MANIFEST_NAME = "model_checksums.txt"
# Written into an extracted model tree to record the digest of the archive it
# came out of. A directory has no hash of its own and the zip is deleted after
# unpacking, so this file is the only evidence the tree was ever verified.
# install.sh reads it under the same name; keep the two in step.
VERIFICATION_STAMP_NAME = ".vocalinux_verified"
# Hash of a 2GB model read in one go would sit in RAM; stream it instead.
_CHUNK_SIZE = 1024 * 1024
_REVISION_HEADER = re.compile(r"^#\s*whispercpp-revision:\s*([0-9a-f]{7,40})\s*$")
class ChecksumError(Exception):
"""A downloaded model did not match its pinned digest, or has no pin at all."""
class Expected(NamedTuple):
"""The pinned identity of one artifact."""
algo: str
digest: str
size: int
def _manifest_text() -> str:
"""Read the manifest from the installed package, falling back to the source tree.
A missing manifest yields an empty one rather than an import-time crash: the
consequence is that every model then lacks a pin and is refused by
:func:`verify_model_file`, which is the safe direction to fail.
"""
try:
from importlib.resources import files
return files(__package__).joinpath(_MANIFEST_NAME).read_text(encoding="utf-8")
except (ImportError, TypeError, ModuleNotFoundError):
pass
except (FileNotFoundError, OSError):
return ""
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), _MANIFEST_NAME)
try:
with open(path, encoding="utf-8") as handle:
return handle.read()
except OSError:
logger.error("%s is missing; no model download can be verified", _MANIFEST_NAME)
return ""
@lru_cache(maxsize=1)
def _parse_manifest() -> tuple[Dict[str, Expected], str]:
entries: Dict[str, Expected] = {}
revision = ""
for line in _manifest_text().splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("#"):
match = _REVISION_HEADER.match(stripped)
if match:
revision = match.group(1)
continue
fields = stripped.split()
if len(fields) != 4:
logger.warning("Ignoring malformed line in %s: %r", _MANIFEST_NAME, line)
continue
filename, algo, digest, size = fields
entries[filename] = Expected(algo=algo, digest=digest.lower(), size=int(size))
return entries, revision
#: Branch to build URLs from when the manifest pins no revision. Only reachable
#: with a missing or hand-damaged manifest, and deliberately not fatal: URL
#: construction runs at import time, whereas refusing an unverifiable file is
#: :func:`verify_model_file`'s job. A download made from this fallback still has
#: no pinned digest, so it is rejected before it is installed.
_UNPINNED_REVISION = "main"
def whispercpp_revision() -> str:
"""The pinned Hugging Face commit the whisper.cpp digests were taken at."""
_, revision = _parse_manifest()
if not revision:
logger.warning(
"%s pins no whisper.cpp revision; falling back to %r. Downloads will "
"fail verification until it is regenerated with "
"scripts/generate-model-checksums.py.",
_MANIFEST_NAME,
_UNPINNED_REVISION,
)
return _UNPINNED_REVISION
return revision
def expected_for(filename: str) -> Optional[Expected]:
"""Return the pinned digest for a model file name, or None when unpinned."""
entries, _ = _parse_manifest()
return entries.get(os.path.basename(filename))
def write_verification_stamp(tree_path: str, archive_name: str) -> None:
"""Record inside ``tree_path`` the pinned digest of the archive it came from.
Only archives get a digest, so an extracted tree is trustworthy later only if
something writes down what was checked. ``install.sh`` re-downloads any VOSK
model whose stamp does not match the digest pinned today, which means a tree
left unstamped here — a first-run or Settings download — is refetched on the
next ``./install.sh``. That makes a failed write an error, not a warning.
"""
expected = expected_for(archive_name)
if expected is None:
raise ChecksumError(f"Cannot stamp {tree_path}: {archive_name} has no pinned digest")
stamp = os.path.join(tree_path, VERIFICATION_STAMP_NAME)
with open(stamp, "w", encoding="utf-8") as handle:
handle.write(f"{expected.digest}\n")
logger.debug("Recorded verified digest for %s in %s", archive_name, stamp)
def pinned_filenames() -> frozenset:
"""Every file name the manifest pins. Used by the coverage test."""
entries, _ = _parse_manifest()
return frozenset(entries)
def file_digest(path: str, algo: str) -> str:
"""Stream ``path`` through ``algo`` and return the lowercase hex digest."""
try:
digest = hashlib.new(algo)
except ValueError as exc:
raise ChecksumError(f"Unsupported checksum algorithm {algo!r}") from exc
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(_CHUNK_SIZE), b""):
digest.update(chunk)
return digest.hexdigest()
def verify_file(path: str, expected: Expected, *, description: str = "") -> None:
"""Raise :class:`ChecksumError` unless ``path`` matches ``expected``.
The size is checked first: it is free, and a truncated download (the common
case) is then reported as a truncated download rather than as a hash
mismatch that reads like tampering.
"""
label = description or os.path.basename(path)
if expected.size:
actual_size = os.path.getsize(path)
if actual_size != expected.size:
raise ChecksumError(
f"{label} is {actual_size} bytes, expected {expected.size}. "
"The download was truncated or the file was replaced upstream."
)
actual = file_digest(path, expected.algo)
if actual != expected.digest:
raise ChecksumError(
f"{label} failed {expected.algo} verification.\n"
f" expected: {expected.digest}\n"
f" actual: {actual}\n"
"The file does not match the digest pinned in this release. Delete it "
"and retry; if it keeps failing, do not use it."
)
logger.info("%s verified (%s)", label, expected.algo)
def verify_model_file(path: str, filename: Optional[str] = None) -> None:
"""Verify a downloaded model against the manifest.
``filename`` overrides the lookup key when ``path`` is still a temporary
name such as ``ggml-tiny.bin.tmp``.
"""
key = filename or os.path.basename(path)
expected = expected_for(key)
if expected is None:
raise ChecksumError(
f"No checksum is pinned for {key}. Regenerate "
f"{_MANIFEST_NAME} with scripts/generate-model-checksums.py."
)
verify_file(path, expected, description=key)
+86
View File
@@ -0,0 +1,86 @@
# Checksums for every speech model Vocalinux downloads.
#
# Generated by scripts/generate-model-checksums.py -- do not edit by hand.
# Regenerate with `just model-checksums` after adding a model to
# vosk_model_info.py or whispercpp_model_info.py.
#
# Read by src/vocalinux/utils/model_checksums.py and by install.sh; keep the
# format (filename, algorithm, digest, size in bytes) parseable by awk.
#
# whisper.cpp models are pinned to this Hugging Face commit so upstream
# replacing a file cannot invalidate the digests below.
# whispercpp-revision: 5359861c739e955e79d9a303bcbc70fb988958b1
#
# OpenAI Whisper (.pt) models are absent on purpose: their download URLs embed
# the sha256 as a path segment, so the expected digest travels with the URL.
#
# VOSK digests are the sha256 of bytes this script downloaded, not the md5
# Alphacephei publishes; that md5 is only checked against them at that point.
ggml-base-q5_1.bin sha256 422f1ae452ade6f30a004d7e5c6a43195e4433bc370bf23fac9cc591f01a8898 59707625
ggml-base-q8_0.bin sha256 c577b9a86e7e048a0b7eada054f4dd79a56bbfa911fbdacf900ac5b567cbb7d9 81768585
ggml-base.bin sha256 60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe 147951465
ggml-base.en-q5_1.bin sha256 4baf70dd0d7c4247ba2b81fafd9c01005ac77c2f9ef064e00dcf195d0e2fdd2f 59721011
ggml-base.en.bin sha256 a03779c86df3323075f5e796cb2ce5029f00ec8869eee3fdfb897afe36c6d002 147964211
ggml-large-v1.bin sha256 7d99f41a10525d0206bddadd86760181fa920438b6b33237e3118ff6c83bb53d 3094623691
ggml-large-v2-q5_0.bin sha256 3a214837221e4530dbc1fe8d734f302af393eb30bd0ed046042ebf4baf70f6f2 1080732091
ggml-large-v2-q8_0.bin sha256 fef54e6d898246a65c8285bfa83bd1807e27fadf54d5d4e81754c47634737e8c 1656129691
ggml-large-v2.bin sha256 9a423fe4d40c82774b6af34115b8b935f34152246eb19e80e376071d3f999487 3094623691
ggml-large-v3-q5_0.bin sha256 d75795ecff3f83b5faa89d1900604ad8c780abd5739fae406de19f23ecd98ad1 1081140203
ggml-large-v3-turbo-q5_0.bin sha256 394221709cd5ad1f40c46e6031ca61bce88931e6e088c188294c6d5a55ffa7e2 574041195
ggml-large-v3-turbo-q8_0.bin sha256 317eb69c11673c9de1e1f0d459b253999804ec71ac4c23c17ecf5fbe24e259a1 874188075
ggml-large-v3-turbo.bin sha256 1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69 1624555275
ggml-large-v3.bin sha256 64d182b440b98d5203c4f9bd541544d84c605196c4f7b845dfa11fb23594d1e2 3095033483
ggml-medium-q5_0.bin sha256 19fea4b380c3a618ec4723c3eef2eb785ffba0d0538cf43f8f235e7b3b34220f 539212467
ggml-medium-q8_0.bin sha256 42a1ffcbe4167d224232443396968db4d02d4e8e87e213d3ee2e03095dea6502 823369779
ggml-medium.bin sha256 6c14d5adee5f86394037b4e4e8b59f1673b6cee10e3cf0b11bbdbee79c156208 1533763059
ggml-medium.en-q5_0.bin sha256 76733e26ad8fe1c7a5bf7531a9d41917b2adc0f20f2e4f5531688a8c6cd88eb0 539225533
ggml-medium.en.bin sha256 cc37e93478338ec7700281a7ac30a10128929eb8f427dda2e865faa8f6da4356 1533774781
ggml-small-q5_1.bin sha256 ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb 190085487
ggml-small-q8_0.bin sha256 49c8fb02b65e6049d5fa6c04f81f53b867b5ec9540406812c643f177317f779f 264464607
ggml-small.bin sha256 1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b 487601967
ggml-small.en-q5_1.bin sha256 bfdff4894dcb76bbf647d56263ea2a96645423f1669176f4844a1bf8e478ad30 190098681
ggml-small.en.bin sha256 c6138d6d58ecc8322097e0f987c32f1be8bb0a18532a3f88f734d1bbf9c41e5d 487614201
ggml-tiny-q5_1.bin sha256 818710568da3ca15689e31a743197b520007872ff9576237bda97bd1b469c3d7 32152673
ggml-tiny-q8_0.bin sha256 c2085835d3f50733e2ff6e4b41ae8a2b8d8110461e18821b09a15c40c42d1cca 43537433
ggml-tiny.bin sha256 be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21 77691713
ggml-tiny.en-q5_1.bin sha256 c77c5766f1cef09b6b7d47f21b546cbddd4157886b3b5d6d4f709e91e66c7c2b 32166155
ggml-tiny.en.bin sha256 921e4cf8686fdd993dcd081a5da5b6c365bfde1162e72b08d75ac75289920b1f 77704715
vosk-model-ar-0.22-linto-1.1.0.zip sha256 6fc103417517203a5503e176963e3a7e20d32da4c37f4ff21181e9d64e1f02bf 1377395170
vosk-model-cn-0.22.zip sha256 7f5580bf7ca3d9e8ce8be68337378950ed78ef974df7a7d4ead8dbe32bec2fa1 1358736686
vosk-model-de-0.21.zip sha256 245060756f8d8394fc5b13639cf220b7620205795f30f37b6823878d4f603b2a 2031717803
vosk-model-en-in-0.5.zip sha256 0365c52f79308d81c2de832cf934ce42da6bf7e04dd3acd4203cacf50ab70f5c 971186187
vosk-model-en-us-0.22.zip sha256 47f9a81ebb039dbb0bd319175c36ac393c0893b796c2b6303e64cf58c27b69f6 1913365522
vosk-model-es-0.42.zip sha256 bbbfaf9239bed5dcb0d140886b5834fcc13c13c7fb2f2799ae790f03ff20bfce 1484681703
vosk-model-fa-0.42.zip sha256 5f03187838a187cd3f8766e3d329da37bf5ddd81a861081132743b76d8a8d14a 1667089770
vosk-model-fr-0.22.zip sha256 b26c34e5ea1b9f9dee6cef5e910a1d93e9adb60f1a0a3f85c069976aec481e46 1523026348
vosk-model-hi-0.22.zip sha256 eaf9bdbd703c79e957f7ea893502e12b336f98d7cd420138bcb224b36ccf86ac 1561909274
vosk-model-it-0.22.zip sha256 5cdc9f0f596e3db270b92fc4c1fd0f1a3e3d5d19ae0d30b69fc008639257de09 1306765292
vosk-model-ja-0.22.zip sha256 af620db815ef716f90355f911092ba0c7454d91d793ecdd8dee2df6342e0e9cc 1045975323
vosk-model-nl-spraakherkenning-0.6.zip sha256 088d40014c43684d65f3cc15f4b06ad764b4407b47854812e8364427702c8b56 1004206965
vosk-model-pt-fb-v0.1.1-pruned.zip sha256 f197db8d66a53e1ae4332dc599b4c035271a0ffe0c6ac0636f341184cea8d830 1223922169
vosk-model-ru-0.22.zip sha256 84d9e2cf6324480e4e768da7d75122fd1250df5449b89d8f9d9417ec7a27fb23 1559419132
vosk-model-small-ar-0.3.zip sha256 8696c91f39ec67a08c513845cfac26dbd4e81937be4fba25233e6c5435c18ca9 104351896
vosk-model-small-ca-0.4.zip sha256 99f90bfff5c2b187c705ddbc20aaa2600eddc359466f404404f1db6029fba5d4 43405881
vosk-model-small-cn-0.22.zip sha256 3af8b0e7e0f835ae9d414ce5df580237a3cfb08d586c9fbbb0f7ff29ad5b14ba 43898754
vosk-model-small-cs-0.4-rhasspy.zip sha256 287c3bbefc8ad67b4ab9636eecef3d62acc3719990777d03e226db5a7f19fbda 46088666
vosk-model-small-de-0.15.zip sha256 b7e53c90b1f0a38456f4cd62b366ecd58803cd97cd42b06438e2c131713d5e43 46499967
vosk-model-small-en-in-0.4.zip sha256 20663dcac4d5cb783a579c54d98339344a688e4ec6e1b4a4b059fd1235454cc7 37573330
vosk-model-small-en-us-0.15.zip sha256 30f26242c4eb449f948e42cb302dd7a686cb29a3423a8367f99ff41780942498 41205931
vosk-model-small-es-0.42.zip sha256 09b239888f633ef2f0b4e09736e3d9936acfd810bc65d53fad45261762c6511f 39817833
vosk-model-small-fa-0.42.zip sha256 977cb5faa538f3a835ccfd35f5f6d8284b5c450b89c700b9bd4736b66536ad46 53431220
vosk-model-small-fr-0.22.zip sha256 cabf6180e177eb9b3a9a9d43a437bd5e549f3a7d09525e5d69a3fed787be12ad 42233323
vosk-model-small-hi-0.22.zip sha256 7c50a10866889f0ac21d912c20537a055a597ed09fc1d3e5bcd798f9f0017e48 44458845
vosk-model-small-it-0.22.zip sha256 9ec65e75861d1c6c2e457cccd932705340dcdf233f5b239f00733b4de0bf3267 49665141
vosk-model-small-ja-0.22.zip sha256 efa092d280153a77615e9e0c7d7283e93e600de3d19d3bec686c57ef19d52eac 49704573
vosk-model-small-ko-0.22.zip sha256 eea36124087fed26c59996a4761519458e3bd185e8ea9d9865ad8760c4a1d989 86914329
vosk-model-small-nl-0.22.zip sha256 039811c3b829de64e4f123a9f684a53784005b212a346ac0b899dc7efce2ed0a 40441176
vosk-model-small-pl-0.22.zip sha256 c4cd16498ea544f446f9e9a55cbd602b71cfe5a2b6f2b0834d81e1b6fce15f0d 52979372
vosk-model-small-pt-0.3.zip sha256 6e1ce909032e1afa7a88e68a3d628ecafff302bdf195befab308826c395e93b7 32453112
vosk-model-small-ru-0.22.zip sha256 961d5ff98a17f4aa6de69864d0aa71fa5bac682301d2b5d17a3f24c5c99a46d4 46236750
vosk-model-small-sv-rhasspy-0.15.zip sha256 5b54f2ca8cacf9766588d05f6c8ca7e8921f054f050726e29d3516e78aefe054 303504931
vosk-model-small-tr-0.3.zip sha256 8c8d07cec1bce31add14967c16891c84152cdf76d391e33c08278119b9ea96e5 36855784
vosk-model-small-uk-v3-small.zip sha256 ee05b6a8c796c53c61cd1c821d38e897e5bd67b4d691d8797ec8f9e5100d4247 143914407
vosk-model-small-vn-0.4.zip sha256 efe5c8494212110471a79befc48c79da679e5b1fc52a4ffb500222ff86d622e5 33656337
vosk-model-uk-v3.zip sha256 b1a0dbb9a19bfb6cdbad5bb4d43b71281ab57ad4a8c2a372656c83b994b72b1f 371048965
vosk-model-vn-0.4.zip sha256 a8ce6ee52e369b8f23f56e87e037db10322e6d94779b08d5e1ab21cb4b737380 74423562
+2 -2
View File
@@ -242,7 +242,7 @@ VOSK_MODEL_INFO = {
"ko": "vosk-model-small-ko-0.22",
"fa": "vosk-model-fa-0.42",
"pl": "vosk-model-small-pl-0.22",
"pt": "vosk-model-pt-0.4",
"pt": "vosk-model-pt-fb-v0.1.1-pruned",
"ru": "vosk-model-ru-0.22",
"es": "vosk-model-es-0.42",
"sv": "vosk-model-small-sv-rhasspy-0.15",
@@ -270,7 +270,7 @@ VOSK_MODEL_INFO = {
"ko": "vosk-model-small-ko-0.22",
"fa": "vosk-model-fa-0.42",
"pl": "vosk-model-small-pl-0.22",
"pt": "vosk-model-pt-0.4",
"pt": "vosk-model-pt-fb-v0.1.1-pruned",
"ru": "vosk-model-ru-0.22",
"es": "vosk-model-es-0.42",
"sv": "vosk-model-small-sv-rhasspy-0.15",
+79
View File
@@ -0,0 +1,79 @@
"""Download URLs and file names for OpenAI Whisper checkpoints.
The file name is derived from the URL rather than from the catalog name, because
that is what `openai-whisper` does. Deriving it as f"{size}.pt" instead is what
made "large" (stored as large-v3.pt) download twice and never show as present.
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger(__name__)
_BASE = "https://openaipublic.azureedge.net/main/whisper/models"
#: Catalog name -> upstream URL. The path segment before the file name is the
#: checkpoint's sha256; openai-whisper verifies against it on load.
WHISPER_MODEL_URLS = {
"tiny": f"{_BASE}/65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/tiny.pt",
"base": f"{_BASE}/ed3a0b6b1c0edf879ad9b11b1af5a0e6ab5db9205f891f668f8b0e6c6326e34e/base.pt",
"small": f"{_BASE}/9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794/small.pt",
"medium": f"{_BASE}/345ae4da62f9b3d59415adc60127b97c714f32e89e936602e85993674d08dcb1/medium.pt",
"large": f"{_BASE}/e5b1a55b89c1367dacf97e3e19bfd829a01529dbfdeefa8caeb59b3f1b81dadb/large-v3.pt",
}
#: Catalog names the UI offers for the OpenAI Whisper engine.
WHISPER_MODEL_SIZES = list(WHISPER_MODEL_URLS)
def whisper_model_url(model_size: str) -> str:
"""Return the upstream URL for ``model_size``."""
try:
return WHISPER_MODEL_URLS[model_size]
except KeyError:
raise ValueError(f"Unknown Whisper model size: {model_size}") from None
def whisper_model_file(model_size: str) -> str:
"""Return the file name ``openai-whisper`` stores for ``model_size``."""
return os.path.basename(whisper_model_url(model_size))
def migrate_legacy_checkpoint_names(cache_dir: str) -> list[str]:
"""Rename checkpoints saved under the old f"{size}.pt" name. Returns the new names.
Vocalinux's own downloader used to name every checkpoint after the catalog
entry, which is wrong wherever upstream publishes a versioned file: "large"
is stored as large-v3.pt. A file left under the old name is invisible three
times over. load_model() looks for the upstream name and refetches 2.9GB,
Settings does not list it, and because it is not listed it cannot be deleted
from there either, so the disk stays occupied with no way to reclaim it from
the UI.
Renaming is safe rather than lucky: the old downloader fetched "large" from
the same large-v3 URL it uses today, so the bytes already are that
checkpoint. And ``openai-whisper`` verifies whatever it loads against the
sha256 in the URL, so even a wrong guess here costs at most the refetch that
would have happened anyway.
"""
renamed = []
for size in WHISPER_MODEL_URLS:
upstream = whisper_model_file(size)
legacy = f"{size}.pt"
if legacy == upstream:
continue
legacy_path = os.path.join(cache_dir, legacy)
upstream_path = os.path.join(cache_dir, upstream)
if not os.path.isfile(legacy_path) or os.path.exists(upstream_path):
continue
try:
os.rename(legacy_path, upstream_path)
except OSError as error:
# Not fatal: the only cost is the refetch this would have avoided.
logger.warning("Could not rename %s to %s: %s", legacy_path, upstream, error)
continue
logger.info("Renamed legacy Whisper checkpoint %s to %s", legacy, upstream)
renamed.append(upstream)
return renamed
+18 -4
View File
@@ -12,19 +12,33 @@ import subprocess
from functools import lru_cache
from typing import Optional
from .model_checksums import whispercpp_revision
from .paths import is_within_directory, models_dir
logger = logging.getLogger(__name__)
# Whisper.cpp model information
# Models are downloaded from Hugging Face (ggml format)
_WHISPERCPP_REPO_URL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main"
# Models are downloaded from Hugging Face (ggml format).
#
# The revision is pinned rather than tracking `main`: model_checksums.txt holds a
# sha256 per file, and a file replaced upstream would turn every download into a
# checksum failure. Both the pin and the digests are refreshed together by
# scripts/generate-model-checksums.py.
_WHISPERCPP_REPO = "https://huggingface.co/ggerganov/whisper.cpp/resolve"
def whispercpp_model_file(model_name: str) -> str:
"""Return the ggml file name upstream publishes for ``model_name``.
"large" is an alias the UI offers; upstream only ships the versioned file.
"""
file_model_name = "large-v3" if model_name == "large" else model_name
return f"ggml-{file_model_name}.bin"
def _model_url(model_name: str) -> str:
"""Build the Hugging Face URL for a ggml whisper.cpp model."""
file_model_name = "large-v3" if model_name == "large" else model_name
return f"{_WHISPERCPP_REPO_URL}/ggml-{file_model_name}.bin"
return f"{_WHISPERCPP_REPO}/{whispercpp_revision()}/{whispercpp_model_file(model_name)}"
_WHISPERCPP_MODEL_SPECS = [
+39
View File
@@ -3,9 +3,12 @@ Configuration file for pytest.
This file makes sure that the 'src' module can be imported in tests.
"""
import atexit
import os
import shutil
import subprocess
import sys
import tempfile
import threading
from unittest.mock import MagicMock, patch
@@ -14,6 +17,33 @@ import pytest
# Set PYTEST_RUNNING early so audio_feedback module can detect it
os.environ["PYTEST_RUNNING"] = "1"
# Isolate the whole test process from the developer's real desktop session
# and Vocalinux state, mirroring a fresh CI runner:
# * XDG base dirs point at a throwaway directory, so locally downloaded
# models (~/.local/share/vocalinux/models) and the real config file
# never leak into tests. This must happen at conftest import time:
# vocalinux modules freeze paths like MODELS_DIR at first import during
# collection, before any fixture could run. The directory is shared for
# the whole session on purpose — some tests rely on directories that
# earlier tests created, exactly as they do on CI with the runner's HOME.
# * Desktop session variables are cleared: a developer's GNOME/Wayland
# session flips IBus/keyboard code paths (issue #478) that CI never sees.
# Tests that need specific values set them via patch.dict/monkeypatch, which
# layer on top of this.
_XDG_ISOLATION_DIR = tempfile.mkdtemp(prefix="vocalinux-pytest-xdg-")
os.environ["XDG_DATA_HOME"] = os.path.join(_XDG_ISOLATION_DIR, "data")
os.environ["XDG_CONFIG_HOME"] = os.path.join(_XDG_ISOLATION_DIR, "config")
os.environ["XDG_CACHE_HOME"] = os.path.join(_XDG_ISOLATION_DIR, "cache")
for _var in (
"XDG_CURRENT_DESKTOP",
"XDG_SESSION_TYPE",
"XDG_SESSION_DESKTOP",
"DESKTOP_SESSION",
"WAYLAND_DISPLAY",
):
os.environ.pop(_var, None)
atexit.register(shutil.rmtree, _XDG_ISOLATION_DIR, ignore_errors=True)
# Several legacy tests replace sys.modules["numpy"] with MagicMock at module
# import time. Preserve the real module once so newer tests can restore it
# without unloading/re-importing NumPy's compiled extensions.
@@ -24,6 +54,15 @@ except ImportError:
else:
sys._vocalinux_real_numpy = _real_numpy
# And the same for zipfile: test_recognition_manager.py and
# test_recognition_manager_download_buffer.py assign a MagicMock to
# sys.modules["zipfile"] at import time and never put it back, so any later test
# that extracts a real archive gets a silent no-op. Keep the genuine module
# reachable for the ones that need it.
import zipfile as _real_zipfile # noqa: E402
sys._vocalinux_real_zipfile = _real_zipfile
# Prevent specific known-blocking daemon threads from starting during tests.
# Source code in ibus_engine.py and evdev_backend.py spawns daemon threads
# (socket server, device monitor) that block on socket.accept() or
+78
View File
@@ -0,0 +1,78 @@
"""Guard the hash-pinned requirements/* exports against drift.
`just lock` regenerates these by hand-written `uv export` lines, so a dependency
that moves between an extra and a group silently falls out of the export. That
happened: the linters moved into the `lint` dependency group and
`requirements/dev.txt` kept exporting only `--extra dev`, so the file no longer
reproduced either `just deps` or what CI lints with.
"""
import re
import tomllib
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
PYPROJECT = REPO_ROOT / "pyproject.toml"
JUSTFILE = REPO_ROOT / "justfile"
DEV_EXPORT = REPO_ROOT / "requirements" / "dev.txt"
def _pyproject() -> dict:
with PYPROJECT.open("rb") as handle:
return tomllib.load(handle)
def _requirement_names(specs: list) -> list:
"""Distribution names out of PEP 508 specifiers, lowercased."""
return [re.split(r"[<>=!~\[; ]", spec, maxsplit=1)[0].strip().lower() for spec in specs]
def _exported_names(path: Path) -> set:
names = set()
for line in path.read_text(encoding="utf-8").splitlines():
match = re.match(r"^([A-Za-z0-9._-]+)==", line)
if match:
names.add(match.group(1).lower())
return names
def test_the_dev_export_requests_every_group_it_needs():
"""The export line has to name the lint group, not just the dev extra."""
line = next(
l
for l in JUSTFILE.read_text(encoding="utf-8").splitlines()
if "-o requirements/dev.txt" in l
)
assert "--extra dev" in line
assert "--group lint" in line, "the linters live in a group; the extra alone misses them"
def test_every_linter_reaches_the_dev_export():
"""What CI lints with must be reproducible from the committed export."""
linters = _requirement_names(_pyproject()["dependency-groups"]["lint"])
assert linters, "no lint group in pyproject.toml"
missing = sorted(set(linters) - _exported_names(DEV_EXPORT))
assert not missing, f"missing from requirements/dev.txt; re-run `just lock`: {missing}"
def test_documented_uv_run_examples_do_not_prune_the_linters():
"""`uv sync`/`uv run` install exactly what the flags name and remove the rest.
That is why every justfile recipe passes the same DEV_EXTRAS. A documented
example that stops at `--extra dev` silently uninstalls black, isort and
flake8 from .venv, so the next `just lint` fails for a reason the reader has
no way to connect to the command they were told to run.
"""
offenders = []
for path in sorted(REPO_ROOT.glob("*.md")) + sorted((REPO_ROOT / "docs").glob("**/*.md")):
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if "uv run --extra dev" in line or "uv sync --extra dev" in line:
if "--group lint" not in line:
offenders.append(f"{path.relative_to(REPO_ROOT)}:{number}: {line.strip()}")
assert not offenders, "these examples uninstall the linters:\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}"
+25 -2
View File
@@ -26,8 +26,31 @@ mock_ibus = MagicMock()
mock_glib = MagicMock()
mock_gobject = MagicMock()
# Set up IBus mock - Engine should be the actual MagicMock class, not an instance
mock_ibus.Engine = MagicMock
class _FakeIBusEngine:
"""Stand-in base class for ``IBus.Engine``.
``VocalinuxEngine`` inherits from whatever ``IBus.Engine`` resolves to, so
this must be a real class: on Python 3.14 ``unittest.mock`` re-instantiates
the user subclass when it builds child mocks (``klass(name=...)``), which
raises ``TypeError`` as soon as a MagicMock is used as a base class.
Unknown IBus API surface still records calls, per instance.
"""
def __init__(self, *args, **kwargs):
pass
def __getattr__(self, name):
mock = MagicMock(name=name)
object.__setattr__(self, name, mock)
return mock
def do_destroy(self):
"""Parent implementation invoked through ``super().do_destroy``."""
# Set up IBus mock - Engine is a plain class so the engine can subclass it
mock_ibus.Engine = _FakeIBusEngine
mock_ibus.Bus = MagicMock
mock_ibus.Factory = MagicMock
mock_ibus.Factory.new = MagicMock(return_value=MagicMock())
+156
View File
@@ -0,0 +1,156 @@
"""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"}
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) -> subprocess.CompletedProcess:
script = f"""
CONFIG_DIR="{config_dir}"
VENV_DIR="{venv_dir}"
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_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
+354
View File
@@ -0,0 +1,354 @@
"""Behavioral tests for the installer's Python interpreter selection.
Regression cover for the "Distro PyGObject is not importable in the venv"
failure: running install.sh from an activated virtualenv (e.g. uv's .venv left
active after `just deps`) made it build venv/ from that interpreter, where the
distro's gi is invisible.
"""
import re
import subprocess
from pathlib import Path
import pytest
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: $*"; }
command_exists() { command -v "$1" >/dev/null 2>&1; }
"""
def _source(*functions: str) -> str:
"""Source the named function definitions out of install.sh."""
return "\n".join(
f"source <(sed -n '/^{name}() {{$/,/^}}$/p' \"{INSTALL_SH}\")" for name in functions
)
def _run(script: str) -> subprocess.CompletedProcess:
return subprocess.run(
["bash", "-c", PRELUDE + script], capture_output=True, text=True, timeout=30
)
def _fake_python(path: Path, version: str, has_gi: bool, base_prefix: str = "") -> Path:
"""Create a stub interpreter answering the probes install.sh runs.
``base_prefix`` defaults to the stub's own directory, so two stubs are
distinct installations unless they are told to share one.
"""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"#!/bin/bash\n"
'if [[ "$*" == *"sys.version_info"* ]]; then\n'
f' echo "{version}"\n'
" exit 0\n"
"fi\n"
'if [[ "$*" == *"sys.base_prefix"* ]]; then\n'
f' echo "{base_prefix or path.parent}"\n'
" exit 0\n"
"fi\n"
'if [[ "$*" == *"import gi"* ]]; then\n'
f" exit {0 if has_gi else 1}\n"
"fi\n"
"exit 0\n"
)
path.chmod(0o755)
return path
class TestDeactivateInheritedVirtualenv:
def test_removes_active_venv_from_path(self, tmp_path):
venv = tmp_path / "venv"
(venv / "bin").mkdir(parents=True)
script = f"""
{_source("deactivate_inherited_virtualenv")}
export VIRTUAL_ENV="{venv}"
export PATH="{venv}/bin:/usr/bin:/bin"
deactivate_inherited_virtualenv
echo "PATH=$PATH"
echo "VIRTUAL_ENV=${{VIRTUAL_ENV:-<unset>}}"
"""
result = _run(script)
assert "PATH=/usr/bin:/bin" in result.stdout
assert "VIRTUAL_ENV=<unset>" in result.stdout
def test_removes_trailing_slash_venv_and_repeats(self, tmp_path):
venv = tmp_path / "venv"
(venv / "bin").mkdir(parents=True)
script = f"""
{_source("deactivate_inherited_virtualenv")}
export VIRTUAL_ENV="{venv}/"
export PATH="{venv}/bin:/usr/bin:{venv}/bin:/bin"
deactivate_inherited_virtualenv
echo "PATH=$PATH"
"""
result = _run(script)
assert "PATH=/usr/bin:/bin" in result.stdout
def test_no_active_venv_leaves_path_alone(self):
script = f"""
{_source("deactivate_inherited_virtualenv")}
unset VIRTUAL_ENV
export PATH="/usr/bin:/bin"
deactivate_inherited_virtualenv
echo "PATH=$PATH"
"""
result = _run(script)
assert "PATH=/usr/bin:/bin" in result.stdout
assert "WARNING" not in result.stdout
class TestSelectPythonInterpreter:
@staticmethod
def _select(path_python: Path, system_python: Path, min_version: str = "3.9") -> str:
script = f"""
PYTHON_CMD="python3"
SYSTEM_PYTHON="{system_python}"
{_source("python_version_of", "python_version_at_least", "python_has_gi", "select_python_interpreter")}
# Keep the system bin dirs: the fake interpreters shadow python3, while
# sort/head that select_python_interpreter relies on stay reachable.
export PATH="{path_python.parent}:/usr/bin:/bin"
select_python_interpreter "{min_version}" && echo "SELECTED=$PYTHON_CMD" || echo "SELECT_FAILED"
"""
return _run(script).stdout
def test_prefers_system_python_when_path_python_lacks_gi(self, tmp_path):
# The reported failure: PATH points at uv's 3.13 venv without gi while
# the distro built PyGObject for the system 3.14.
path_python = _fake_python(tmp_path / "pathbin" / "python3", "3.13", has_gi=False)
system_python = _fake_python(tmp_path / "usrbin" / "python3", "3.14", has_gi=True)
assert f"SELECTED={system_python}" in self._select(path_python, system_python)
def test_keeps_path_python_when_it_has_gi(self, tmp_path):
path_python = _fake_python(tmp_path / "pathbin" / "python3", "3.14", has_gi=True)
system_python = _fake_python(tmp_path / "usrbin" / "python3", "3.14", has_gi=True)
assert f"SELECTED={path_python}" in self._select(path_python, system_python)
def test_falls_back_to_the_system_python_when_no_candidate_has_gi(self, tmp_path):
# gi is usually installed later in the same run, so the fallback has to
# pick the interpreter that PyGObject will land on. Picking the PATH one
# here is exactly the venv-cannot-import-gi bug this file covers.
path_python = _fake_python(tmp_path / "pathbin" / "python3", "3.13", has_gi=False)
system_python = _fake_python(tmp_path / "usrbin" / "python3", "3.14", has_gi=False)
stdout = self._select(path_python, system_python)
assert f"SELECTED={system_python}" in stdout
def test_falls_back_to_path_python_when_the_system_one_is_too_old(self, tmp_path):
"""Preferring the system interpreter must not override the floor."""
path_python = _fake_python(tmp_path / "pathbin" / "python3", "3.12", has_gi=False)
system_python = _fake_python(tmp_path / "usrbin" / "python3", "3.9", has_gi=False)
stdout = self._select(path_python, system_python, min_version="3.11")
assert f"SELECTED={path_python}" in stdout
assert "SELECTED=python3" not in stdout
def test_skips_python_below_minimum_version(self, tmp_path):
too_old = _fake_python(tmp_path / "pathbin" / "python3", "3.6", has_gi=True)
system_python = _fake_python(tmp_path / "usrbin" / "python3", "3.14", has_gi=False)
assert f"SELECTED={system_python}" in self._select(too_old, system_python)
def test_reports_failure_when_no_python_exists(self, tmp_path):
empty_bin = tmp_path / "empty"
empty_bin.mkdir()
script = f"""
PYTHON_CMD="python3"
SYSTEM_PYTHON="{tmp_path}/missing-python3"
{_source("python_version_of", "python_version_at_least", "python_has_gi", "select_python_interpreter")}
export PATH="{empty_bin}"
select_python_interpreter "3.9" && echo "SELECTED=$PYTHON_CMD" || echo "SELECT_FAILED"
"""
assert "SELECT_FAILED" in _run(script).stdout
class TestVenvMatchesSelectedPython:
def test_venv_built_from_the_selected_interpreter(self, tmp_path):
base = str(tmp_path / "usr")
venv_python = _fake_python(
tmp_path / "venv" / "bin" / "python", "3.14", has_gi=True, base_prefix=base
)
selected = _fake_python(tmp_path / "usr" / "python3", "3.14", has_gi=True, base_prefix=base)
script = f"""
VENV_DIR="{venv_python.parents[1]}"
PYTHON_CMD="{selected}"
{_source("python_base_prefix", "venv_matches_selected_python")}
venv_matches_selected_python && echo "MATCH" || echo "MISMATCH"
"""
result = _run(script)
assert "MATCH" in result.stdout
def test_stale_venv_from_other_interpreter(self, tmp_path):
venv_python = _fake_python(tmp_path / "venv" / "bin" / "python", "3.13", has_gi=False)
selected = _fake_python(tmp_path / "usr" / "python3", "3.14", has_gi=True)
script = f"""
VENV_DIR="{venv_python.parents[1]}"
PYTHON_CMD="{selected}"
{_source("python_base_prefix", "venv_matches_selected_python")}
venv_matches_selected_python && echo "MATCH" || echo "MISMATCH"
"""
result = _run(script)
assert "MISMATCH" in result.stdout
def test_same_version_from_a_different_installation_is_stale(self, tmp_path):
"""A distro 3.12 and a pyenv/uv 3.12 are not interchangeable."""
venv_python = _fake_python(
tmp_path / "venv" / "bin" / "python",
"3.12",
has_gi=False,
base_prefix=str(tmp_path / "pyenv"),
)
selected = _fake_python(
tmp_path / "usr" / "python3", "3.12", has_gi=True, base_prefix="/usr"
)
script = f"""
VENV_DIR="{venv_python.parents[1]}"
PYTHON_CMD="{selected}"
{_source("python_base_prefix", "venv_matches_selected_python")}
venv_matches_selected_python && echo "MATCH" || echo "MISMATCH"
"""
result = _run(script)
assert "MISMATCH" in result.stdout
def test_missing_venv_python(self, tmp_path):
selected = _fake_python(tmp_path / "usr" / "python3", "3.14", has_gi=True)
script = f"""
VENV_DIR="{tmp_path}/nonexistent"
PYTHON_CMD="{selected}"
{_source("python_base_prefix", "venv_matches_selected_python")}
venv_matches_selected_python && echo "MATCH" || echo "MISMATCH"
"""
result = _run(script)
assert "MISMATCH" in result.stdout
class TestTheFloorIsTheInterpreterNotTheReleaseLabel:
"""Any distro that ships a new enough Python must be installable.
The installer used to reject the Ubuntu family on `VERSION_ID` alone. That
label is not the requirement and derivatives do not share Ubuntu's numbering:
Linux Mint 22 and elementary OS 8 are built on Ubuntu 24.04 and ship Python
3.12, yet report "22" and "8", which sort below "24.04".
"""
# The two shapes that matter, both handed an interpreter at 3.12: a label
# that sorts below the base release, and one that is not a release number at
# all. Pop!_OS and Kubuntu need no case of their own — they use Ubuntu's
# numbering, so they were never the ones being turned away.
DERIVATIVES = [
("linuxmint", "22"),
("elementary", "8"),
]
#: Family name in install.sh -> how docs/DISTRO_COMPATIBILITY.md spells it.
SUPPORTED_FAMILIES = {
"ubuntu": "Ubuntu",
"debian": "Debian",
"fedora": "Fedora",
"arch": "Arch Linux",
"suse": "openSUSE",
}
def test_a_documented_family_is_never_met_with_untested(self):
"""Fedora, Arch and openSUSE used to get the Ubuntu/Debian-only warning.
That warning ends in a y/n prompt which exits on "n", so a distro the
project documents as supported, and in Fedora's case builds in the distro
matrix, could be talked out of installing.
"""
source = INSTALL_SH.read_text(encoding="utf-8")
block = source[source.index("# Check compatibility") :]
block = block[: block.index("\nesac\n")]
arms = set()
for line in block.splitlines():
match = re.fullmatch(r"\s*([a-z|]+)\)", line)
if match:
arms.update(match.group(1).split("|"))
assert arms == set(self.SUPPORTED_FAMILIES), f"unexpected support tiers: {sorted(arms)}"
def test_the_installer_and_the_compatibility_doc_agree(self):
doc = (INSTALL_SH.parent / "docs" / "DISTRO_COMPATIBILITY.md").read_text(encoding="utf-8")
supported = doc[: doc.index("## Experimental Support")]
missing = [
name for name in self.SUPPORTED_FAMILIES.values() if f"| {name} |" not in supported
]
assert not missing, f"install.sh calls these supported, the doc does not: {missing}"
def test_no_supported_distro_is_filed_under_experimental(self):
"""The two tables have to mean what their headings say.
Linux Mint, Pop!_OS, elementary, Zorin, Manjaro and EndeavourOS sat in
"Experimental Support" while marked "Good Support", which is where the
installer's own idea of who is supported drifted from the document.
"""
doc = (INSTALL_SH.parent / "docs" / "DISTRO_COMPATIBILITY.md").read_text(encoding="utf-8")
experimental = doc[doc.index("## Experimental Support") : doc.index("## Not Supported")]
misfiled = [
line.split("|")[1].strip()
for line in experimental.splitlines()
if line.startswith("|") and "✅" in line
]
assert not misfiled, f"listed as supported but filed as experimental: {misfiled}"
def test_no_release_number_gate_survives_in_the_installer(self):
source = INSTALL_SH.read_text(encoding="utf-8")
assert "check_ubuntu_version" not in source
assert "requires Ubuntu" not in source, "the installer must not gate on a release label"
@staticmethod
def _check(interpreter: Path) -> str:
script = f"""
PYTHON_CMD="python3"
SYSTEM_PYTHON="{interpreter}"
{_source("python_version_of", "python_version_at_least", "python_has_gi",
"select_python_interpreter", "check_python_version")}
export PATH="{interpreter.parent}:/usr/bin:/bin"
check_python_version && echo "ACCEPTED" || echo "REJECTED"
"""
return _run(script).stdout
@pytest.mark.parametrize("distro_id,version_id", DERIVATIVES)
def test_a_derivative_with_a_new_enough_python_is_accepted(
self, tmp_path, distro_id, version_id
):
"""What the distro calls itself must not decide this; its Python must."""
interpreter = _fake_python(
tmp_path / f"{distro_id}-{version_id}" / "python3", "3.12", has_gi=True
)
assert "ACCEPTED" in self._check(interpreter)
def test_an_interpreter_below_the_floor_is_still_rejected(self, tmp_path):
"""Dropping the label gate must not drop the real one."""
interpreter = _fake_python(tmp_path / "jammy" / "python3", "3.10", has_gi=True)
assert "REJECTED" in self._check(interpreter)
def test_the_floor_itself_is_accepted(self, tmp_path):
interpreter = _fake_python(tmp_path / "floor" / "python3", "3.11", has_gi=True)
assert "ACCEPTED" in self._check(interpreter)
def test_installer_never_creates_venv_with_bare_python3():
source = INSTALL_SH.read_text(encoding="utf-8")
for line in source.splitlines():
stripped = line.strip()
if stripped.startswith("python3 -m venv") or stripped.startswith("python3 -m virtualenv"):
raise AssertionError(f"venv must be created via $PYTHON_CMD: {stripped}")
+575
View File
@@ -0,0 +1,575 @@
"""Tests for model download integrity verification.
The manifest-coverage test is what makes verification safe to fail closed: a
model added to the app without regenerating model_checksums.txt is caught here
rather than on a user's machine, where it would surface as an unexplained
refusal to install.
"""
import base64
import hashlib
import os
import shutil
import subprocess
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from vocalinux.utils import model_checksums
from vocalinux.utils.model_checksums import (
VERIFICATION_STAMP_NAME,
ChecksumError,
Expected,
expected_for,
file_digest,
pinned_filenames,
verify_file,
verify_model_file,
whispercpp_revision,
write_verification_stamp,
)
from vocalinux.utils.vosk_model_info import VOSK_MODEL_INFO
from vocalinux.utils.whispercpp_model_info import (
WHISPERCPP_MODEL_INFO,
whispercpp_model_file,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
INSTALL_SH = REPO_ROOT / "install.sh"
class TestManifestCoverage(unittest.TestCase):
"""Every model the application can download must have a pinned digest."""
def test_every_whispercpp_model_is_pinned(self):
pinned = pinned_filenames()
missing = [
whispercpp_model_file(name)
for name in WHISPERCPP_MODEL_INFO
if whispercpp_model_file(name) not in pinned
]
self.assertEqual(
missing,
[],
"whisper.cpp models without a pinned checksum; "
"run `just model-checksums` to refresh the manifest",
)
def test_every_vosk_model_is_pinned(self):
pinned = pinned_filenames()
missing = sorted(
{
f"{name}.zip"
for info in VOSK_MODEL_INFO.values()
for name in info["languages"].values()
if name and f"{name}.zip" not in pinned
}
)
self.assertEqual(
missing,
[],
"VOSK models without a pinned checksum; "
"run `just model-checksums` to refresh the manifest",
)
def test_manifest_entries_are_well_formed(self):
for filename in pinned_filenames():
expected = expected_for(filename)
# sha256 only: the VOSK entries used to copy the md5 Alphacephei
# publishes, which pins nothing they could not change themselves.
self.assertEqual(expected.algo, "sha256", filename)
self.assertEqual(len(expected.digest), 64, filename)
self.assertRegex(expected.digest, r"^[0-9a-f]+$", filename)
self.assertGreater(expected.size, 0, filename)
def test_whispercpp_revision_is_pinned_to_a_commit(self):
revision = whispercpp_revision()
self.assertNotEqual(revision, "main", "whisper.cpp must not track a branch")
self.assertRegex(revision, r"^[0-9a-f]{40}$")
def test_download_urls_use_the_pinned_revision(self):
revision = whispercpp_revision()
for name, info in WHISPERCPP_MODEL_INFO.items():
self.assertIn(f"/resolve/{revision}/", info["url"], name)
class TestVerifyFile(unittest.TestCase):
def setUp(self):
self.tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
self.payload = b"vocalinux model payload"
self.path = self.tmp / "model.bin"
self.path.write_bytes(self.payload)
self.digest = hashlib.sha256(self.payload).hexdigest()
def test_accepts_a_matching_file(self):
verify_file(str(self.path), Expected("sha256", self.digest, len(self.payload)))
def test_rejects_a_wrong_digest(self):
with self.assertRaises(ChecksumError) as ctx:
verify_file(str(self.path), Expected("sha256", "0" * 64, len(self.payload)))
self.assertIn("failed sha256 verification", str(ctx.exception))
def test_reports_truncation_as_a_size_problem(self):
"""A short read is the common failure; it must not read like tampering."""
with self.assertRaises(ChecksumError) as ctx:
verify_file(str(self.path), Expected("sha256", self.digest, len(self.payload) + 10))
self.assertIn("truncated", str(ctx.exception))
def test_size_zero_skips_the_size_check(self):
verify_file(str(self.path), Expected("sha256", self.digest, 0))
def test_md5_is_supported(self):
digest = hashlib.md5(self.payload).hexdigest()
verify_file(str(self.path), Expected("md5", digest, len(self.payload)))
def test_unknown_algorithm_is_rejected(self):
with self.assertRaises(ChecksumError):
file_digest(str(self.path), "not-a-hash")
def test_digest_matches_hashlib_for_a_multi_chunk_file(self):
big = self.tmp / "big.bin"
payload = os.urandom(model_checksums._CHUNK_SIZE * 2 + 7)
big.write_bytes(payload)
self.assertEqual(file_digest(str(big), "sha256"), hashlib.sha256(payload).hexdigest())
class TestVerifyModelFile(unittest.TestCase):
def setUp(self):
self.tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
def test_unpinned_file_is_refused(self):
"""Fail closed: no pin means no install, not a warning."""
path = self.tmp / "ggml-not-a-real-model.bin"
path.write_bytes(b"x")
with self.assertRaises(ChecksumError) as ctx:
verify_model_file(str(path))
self.assertIn("No checksum is pinned", str(ctx.exception))
def test_temp_name_is_looked_up_under_the_real_name(self):
"""Downloads land as *.tmp, so the lookup key must be overridable."""
path = self.tmp / "ggml-tiny.bin.tmp"
path.write_bytes(b"wrong content")
with self.assertRaises(ChecksumError) as ctx:
verify_model_file(str(path), "ggml-tiny.bin")
# Reached the digest comparison, so the manifest lookup succeeded.
self.assertIn("ggml-tiny.bin", str(ctx.exception))
self.assertNotIn("No checksum is pinned", str(ctx.exception))
def test_missing_manifest_refuses_everything(self):
with patch.object(model_checksums, "_manifest_text", return_value=""):
model_checksums._parse_manifest.cache_clear()
try:
path = self.tmp / "ggml-tiny.bin"
path.write_bytes(b"x")
with self.assertRaises(ChecksumError):
verify_model_file(str(path))
finally:
model_checksums._parse_manifest.cache_clear()
class TestInstallerVerification(unittest.TestCase):
"""The bash half must agree with the Python half on the same manifest."""
PRELUDE = """
set -uo pipefail
print_info() { echo "INFO: $*"; }
print_warning() { echo "WARNING: $*"; }
print_error() { echo "ERROR: $*"; }
print_success() { echo "SUCCESS: $*"; }
command_exists() { command -v "$1" >/dev/null 2>&1; }
INSTALL_DIR="%s"
""" % str(REPO_ROOT)
FUNCTIONS = (
"compute_file_digest",
"verify_digest",
"verify_model_checksum",
"verify_openai_model_checksum",
"pinned_digest_for",
"whispercpp_pinned_revision",
)
def _source(self) -> str:
text = INSTALL_SH.read_text()
chunks = []
for name in self.FUNCTIONS:
start = text.index(f"\n{name}() {{")
end = text.index("\n}\n", start) + len("\n}\n")
chunks.append(text[start:end])
manifest_line = (
'MODEL_CHECKSUMS_FILE="$INSTALL_DIR/src/vocalinux/utils/model_checksums.txt"'
)
return self.PRELUDE + manifest_line + "\n" + "\n".join(chunks)
def _run(self, script: str) -> subprocess.CompletedProcess:
return subprocess.run(
["bash", "-c", self._source() + "\n" + script],
capture_output=True,
text=True,
)
def test_reads_the_same_revision_as_python(self):
result = self._run("whispercpp_pinned_revision")
self.assertEqual(result.stdout.strip(), whispercpp_revision())
def test_accepts_a_file_matching_the_manifest(self):
"""Build a file whose digest is the pinned one by asking Python for it."""
tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
# Pick a manifest entry and forge a file that matches it, by rewriting the
# manifest to the digest of content we control.
payload = b"installer payload"
target = tmp / "ggml-tiny.bin"
target.write_bytes(payload)
manifest = tmp / "model_checksums.txt"
manifest.write_text(
"# whispercpp-revision: " + "a" * 40 + "\n"
f"ggml-tiny.bin sha256 {hashlib.sha256(payload).hexdigest()} {len(payload)}\n"
)
result = self._run(f'MODEL_CHECKSUMS_FILE="{manifest}"; verify_model_checksum "{target}"')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("verified (sha256)", result.stdout)
def test_rejects_a_digest_mismatch(self):
tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
target = tmp / "ggml-tiny.bin"
target.write_bytes(b"tampered")
manifest = tmp / "model_checksums.txt"
manifest.write_text(f"ggml-tiny.bin sha256 {'0' * 64} 8\n")
result = self._run(f'MODEL_CHECKSUMS_FILE="{manifest}"; verify_model_checksum "{target}"')
self.assertEqual(result.returncode, 1)
self.assertIn("failed sha256 verification", result.stdout)
def test_rejects_a_truncated_file_as_truncation(self):
tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
payload = b"short"
target = tmp / "ggml-tiny.bin"
target.write_bytes(payload)
manifest = tmp / "model_checksums.txt"
manifest.write_text(
f"ggml-tiny.bin sha256 {hashlib.sha256(payload).hexdigest()} 999999\n"
)
result = self._run(f'MODEL_CHECKSUMS_FILE="{manifest}"; verify_model_checksum "{target}"')
self.assertEqual(result.returncode, 1)
self.assertIn("truncated", result.stdout)
def test_refuses_an_unpinned_file(self):
tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
target = tmp / "ggml-unknown.bin"
target.write_bytes(b"x")
manifest = tmp / "model_checksums.txt"
manifest.write_text("ggml-tiny.bin sha256 " + "0" * 64 + " 1\n")
result = self._run(f'MODEL_CHECKSUMS_FILE="{manifest}"; verify_model_checksum "{target}"')
self.assertEqual(result.returncode, 1)
self.assertIn("No checksum is pinned", result.stdout)
def test_refuses_when_the_manifest_is_absent(self):
tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
target = tmp / "ggml-tiny.bin"
target.write_bytes(b"x")
result = self._run(
f'MODEL_CHECKSUMS_FILE="{tmp}/nope.txt"; verify_model_checksum "{target}"'
)
self.assertEqual(result.returncode, 1)
self.assertIn("Checksum manifest not found", result.stdout)
def test_openai_url_digest_is_honoured(self):
tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
payload = b"pretend checkpoint"
target = tmp / "tiny.pt"
target.write_bytes(payload)
url = (
"https://openaipublic.azureedge.net/main/whisper/models/"
f"{hashlib.sha256(payload).hexdigest()}/tiny.pt"
)
result = self._run(f'verify_openai_model_checksum "{target}" "{url}" "Whisper tiny"')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("verified (sha256)", result.stdout)
def test_openai_url_without_a_digest_is_refused(self):
tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
target = tmp / "tiny.pt"
target.write_bytes(b"x")
result = self._run(
f'verify_openai_model_checksum "{target}" "https://example.com/tiny.pt" "Whisper tiny"'
)
self.assertEqual(result.returncode, 1)
self.assertIn("no sha256 path segment", result.stdout)
def test_bash_and_python_agree_on_a_real_manifest_entry(self):
"""Same file, same manifest, same verdict from both implementations."""
tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
target = tmp / "ggml-tiny.bin"
target.write_bytes(b"definitely not the real model")
result = self._run(f'verify_model_checksum "{target}" "ggml-tiny.bin"')
self.assertEqual(result.returncode, 1)
with self.assertRaises(ChecksumError):
verify_model_file(str(target), "ggml-tiny.bin")
class TestExistingModelsAreVerified(unittest.TestCase):
"""A model that is already on disk must be hashed, not trusted.
Regression cover for the review finding on #713: all three installer
functions returned early when the model file existed, so every re-run — and
every install predating checksum verification — kept an unverified model.
"""
SOURCE = INSTALL_SH.read_text()
def _function_body(self, name: str) -> str:
start = self.SOURCE.index(f"\n{name}() {{")
end = self.SOURCE.index("\n}\n", start)
return self.SOURCE[start:end]
def _exists_guard(self, name: str) -> str:
"""The part of the function before it starts downloading."""
body = self._function_body(name)
marker = body.find("Check internet connectivity")
return body[:marker] if marker != -1 else body
def test_whisper_hashes_an_existing_model(self):
guard = self._exists_guard("install_whisper_model")
self.assertIn("verify_openai_model_checksum", guard)
self.assertIn("rm -f", guard)
def test_whispercpp_hashes_an_existing_model(self):
guard = self._exists_guard("install_whispercpp_model")
self.assertIn("verify_model_checksum", guard)
self.assertIn("rm -f", guard)
def test_vosk_requires_a_matching_verification_stamp(self):
guard = self._exists_guard("install_vosk_models")
self.assertIn(VERIFICATION_STAMP_NAME, guard)
def test_vosk_keeps_an_unstamped_tree_until_it_has_a_replacement(self):
"""Deleting on sight would make every failed install a missing model.
No stamp is the ordinary case — an older install, or a tree the app
downloaded itself — and this runs on every ./install.sh. Removal belongs
after the replacement is downloaded, verified and unpacked, not before.
"""
guard = self._exists_guard("install_vosk_models")
self.assertNotIn("rm -rf", guard)
def test_vosk_unpacks_to_staging_and_swaps_only_what_it_verified(self):
body = self._function_body("install_vosk_models")
order = [
body.index("verify_model_checksum"),
body.index('unzip -q "$TEMP_ZIP" -d "$STAGING_DIR"'),
body.index(f'> "$STAGING_DIR/$SMALL_MODEL_NAME/{VERIFICATION_STAMP_NAME}"'),
body.index('mv "$STAGING_DIR/$SMALL_MODEL_NAME" "$SMALL_MODEL_PATH"'),
]
self.assertEqual(order, sorted(order), "verify, unpack, stamp, then swap")
def test_vosk_restores_the_old_tree_when_the_swap_fails(self):
body = self._function_body("install_vosk_models")
self.assertIn('mv "$REPLACED_DIR" "$SMALL_MODEL_PATH"', body)
def test_no_installer_path_returns_zero_on_an_unverified_file(self):
"""Each guard must verify before its `return 0`, not after."""
for name, checker in (
("install_whisper_model", "verify_openai_model_checksum"),
("install_whispercpp_model", "verify_model_checksum"),
("install_vosk_models", ".vocalinux_verified"),
):
guard = self._exists_guard(name)
self.assertLess(
guard.index(checker),
guard.index("return 0"),
f"{name} returns success before verifying an existing model",
)
class TestVerificationStamp(unittest.TestCase):
"""The stamp is the only thing that makes an extracted tree verifiable.
Two implementations write and read it — this module and install.sh — so the
name and the contents have to be the same on both sides.
"""
def test_the_installer_uses_the_same_stamp_name(self):
self.assertIn(VERIFICATION_STAMP_NAME, INSTALL_SH.read_text())
def test_it_records_the_pinned_digest_of_the_archive(self):
with TemporaryDirectory() as tree:
write_verification_stamp(tree, "vosk-model-small-en-us-0.15.zip")
written = Path(tree, VERIFICATION_STAMP_NAME).read_text()
pinned = expected_for("vosk-model-small-en-us-0.15.zip")
self.assertIsNotNone(pinned)
# install.sh compares with $(cat ...), which strips the trailing newline
# the shell's own writer leaves; match it byte for byte.
self.assertEqual(written, f"{pinned.digest}\n")
def test_it_refuses_to_stamp_an_unpinned_archive(self):
with TemporaryDirectory() as tree:
with self.assertRaises(ChecksumError):
write_verification_stamp(tree, "not-a-model-we-ship.zip")
self.assertFalse(Path(tree, VERIFICATION_STAMP_NAME).exists())
class TestVoskInstallerReplacesRatherThanDeletes(unittest.TestCase):
"""Run install_vosk_models for real, with the network and unzip around it.
The static checks above pin the shape of the code; these pin the behaviour
the review asked for: an install that fails after finding an unstamped tree
must leave that tree where it was, because "no stamp" is the ordinary state
of any model the app downloaded itself or that predates stamping.
"""
# A zip whose top-level directory is the one the real archive unpacks to.
# Embedded rather than built here so no test that mocks zipfile can reach it.
FIXTURE_ZIP = base64.b64decode(
"UEsDBBQAAAAIAM92F12oDuXYFgAAAIgTAAAoAAAAdm9zay1tb2RlbC1zbWFsbC1lbi11cy0wLjE1L2FtL2Zp"
"bmFsLm1kbO3BMQEAAADCoPVPbQo/oAAAAACAtwFQSwMEFAAAAAgAz3YXXVXAPAMTAAAAEQAAACsAAAB2b3Nr"
"LW1vZGVsLXNtYWxsLWVuLXVzLTAuMTUvY29uZi9tb2RlbC5jb25m09XNzczTTUwuySxLtTUyMOACAFBLAQIU"
"AxQAAAAIAM92F12oDuXYFgAAAIgTAAAoAAAAAAAAAAAAAACAAQAAAAB2b3NrLW1vZGVsLXNtYWxsLWVuLXVz"
"LTAuMTUvYW0vZmluYWwubWRsUEsBAhQDFAAAAAgAz3YXXVXAPAMTAAAAEQAAACsAAAAAAAAAAAAAAIABXAAA"
"AHZvc2stbW9kZWwtc21hbGwtZW4tdXMtMC4xNS9jb25mL21vZGVsLmNvbmZQSwUGAAAAAAIAAgCvAAAAuAAA"
"AAAA"
)
MODEL_NAME = "vosk-model-small-en-us-0.15"
PRELUDE = """
set -Eeuo pipefail
print_info() { echo "INFO: $*"; }
print_warning() { echo "WARNING: $*"; }
print_error() { echo "ERROR: $*"; }
print_success() { echo "SUCCESS: $*"; }
command_exists() { command -v "$1" >/dev/null 2>&1; }
DATA_DIR="$ROOT/data"
VOCALINUX_TMP_DIR="$ROOT/tmp"
MODEL_CHECKSUMS_FILE="$ROOT/model_checksums.txt"
mkdir -p "$VOCALINUX_TMP_DIR"
# The installer only probes for these; the download itself is stubbed out.
check_connectivity() { return 0; }
download_model_file() {
echo "call" >> "$ROOT/downloads"
[ -z "${DOWNLOAD_FAILS:-}" ] || return 1
cp "$ROOT/${SERVED_ZIP:-fixture.zip}" "$2"
}
"""
FUNCTIONS = (
"compute_file_digest",
"verify_digest",
"verify_model_checksum",
"pinned_digest_for",
"install_vosk_models",
)
def setUp(self):
if not shutil.which("unzip"):
self.skipTest("unzip is required to exercise the extraction path")
self.root = Path(self.enterContext(TemporaryDirectory()))
(self.root / "fixture.zip").write_bytes(self.FIXTURE_ZIP)
# A manifest of our own, so the fixture is what the pin describes.
digest = hashlib.sha256(self.FIXTURE_ZIP).hexdigest()
self.digest = digest
(self.root / "model_checksums.txt").write_text(
f"{self.MODEL_NAME}.zip sha256 {digest} {len(self.FIXTURE_ZIP)}\n"
)
self.models_dir = self.root / "data" / "models"
self.model_path = self.models_dir / self.MODEL_NAME
self.stamp = self.model_path / VERIFICATION_STAMP_NAME
# install_vosk_models probes for a downloader with `command -v` before it
# downloads anything. download_model_file is stubbed, so the probe is all
# that has to succeed, and a stub on PATH keeps the test from depending on
# the host having wget or curl.
self.fake_bin = self.root / "bin"
self.fake_bin.mkdir()
curl = self.fake_bin / "curl"
curl.write_text("#!/bin/sh\necho 'the stubbed downloader must not run' >&2\nexit 1\n")
curl.chmod(0o755)
def _source(self) -> str:
text = INSTALL_SH.read_text()
chunks = []
for name in self.FUNCTIONS:
start = text.index(f"\n{name}() {{")
end = text.index("\n}\n", start) + len("\n}\n")
chunks.append(text[start:end])
return self.PRELUDE + "\n".join(chunks)
def _install(self, **env) -> subprocess.CompletedProcess:
script = (
self._source() + '\nif install_vosk_models; then echo "RC=0"; else echo "RC=$?"; fi\n'
)
return subprocess.run(
["bash", "-c", script],
capture_output=True,
text=True,
env={
**os.environ,
"PATH": f"{self.fake_bin}{os.pathsep}{os.environ['PATH']}",
"ROOT": str(self.root),
**env,
},
)
@staticmethod
def _rc(result: subprocess.CompletedProcess) -> str:
return result.stdout.strip().splitlines()[-1]
def _downloads(self) -> int:
path = self.root / "downloads"
return len(path.read_text().splitlines()) if path.exists() else 0
def test_fresh_install_extracts_and_stamps(self):
result = self._install()
self.assertEqual(self._rc(result), "RC=0", result.stdout + result.stderr)
self.assertTrue((self.model_path / "am" / "final.mdl").exists())
self.assertEqual(self.stamp.read_text().strip(), self.digest)
def test_a_stamped_tree_is_not_downloaded_again(self):
self.assertEqual(self._rc(self._install()), "RC=0")
self.assertEqual(self._rc(self._install()), "RC=0")
self.assertEqual(self._downloads(), 1, "the second run re-fetched a verified model")
def test_an_unstamped_tree_survives_a_failed_download(self):
"""The regression: the old code deleted here and then failed to download."""
(self.model_path / "am").mkdir(parents=True)
(self.model_path / "am" / "final.mdl").write_bytes(b"an older install")
result = self._install(DOWNLOAD_FAILS="1")
self.assertEqual(self._rc(result), "RC=1")
self.assertEqual((self.model_path / "am" / "final.mdl").read_bytes(), b"an older install")
def test_an_unstamped_tree_survives_a_download_that_fails_verification(self):
(self.root / "tampered.zip").write_bytes(self.FIXTURE_ZIP + b"trailing junk")
(self.model_path / "am").mkdir(parents=True)
(self.model_path / "am" / "final.mdl").write_bytes(b"an older install")
result = self._install(SERVED_ZIP="tampered.zip")
self.assertEqual(self._rc(result), "RC=1")
self.assertEqual((self.model_path / "am" / "final.mdl").read_bytes(), b"an older install")
def test_an_unstamped_tree_is_replaced_once_a_verified_one_is_ready(self):
(self.model_path / "am").mkdir(parents=True)
(self.model_path / "am" / "final.mdl").write_bytes(b"an older install")
(self.model_path / "stale-file").write_text("gone after the swap")
result = self._install()
self.assertEqual(self._rc(result), "RC=0", result.stdout + result.stderr)
self.assertNotEqual(
(self.model_path / "am" / "final.mdl").read_bytes(), b"an older install"
)
self.assertFalse((self.model_path / "stale-file").exists(), "the swap must not merge")
self.assertEqual(self.stamp.read_text().strip(), self.digest)
def test_no_scratch_directories_are_left_behind(self):
self._install()
leftovers = [p.name for p in self.models_dir.iterdir() if p.name != self.MODEL_NAME]
self.assertEqual(leftovers, [])
if __name__ == "__main__":
unittest.main()
+128
View File
@@ -0,0 +1,128 @@
"""Guard against Python-version drift between the places that declare it.
`requires-python`, the classifiers, the CI matrix and install.sh's MIN_VERSION
used to disagree (3.9 floor, 3.9-3.13 matrix, classifiers promising 3.14), which
is how an unsupported interpreter can quietly become the one users install with.
"""
import re
import sys
import tomllib
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
PYPROJECT = REPO_ROOT / "pyproject.toml"
INSTALL_SH = REPO_ROOT / "install.sh"
PIPELINE = REPO_ROOT / ".github" / "workflows" / "unified-pipeline.yml"
PKGBUILD = REPO_ROOT / "packaging" / "aur" / "vocalinux" / "PKGBUILD"
# Everything user-facing that could state the floor. Scanned as trees rather than
# a fixed list: docs and the website drifted to "3.9+" for a full release cycle
# after the floor moved, and a new page must not be able to reintroduce it.
DOC_AND_SITE_GLOBS = [
(REPO_ROOT / "docs", "**/*.md"),
(REPO_ROOT / "web" / "src", "**/*.tsx"),
(REPO_ROOT / "web" / "src", "**/*.ts"),
(REPO_ROOT, "README.md"),
(REPO_ROOT, "CONTRIBUTING.md"),
]
def _doc_and_site_files() -> list:
files = []
for base, pattern in DOC_AND_SITE_GLOBS:
files.extend(p for p in base.glob(pattern) if "node_modules" not in p.parts)
return sorted(set(files))
def _version_tuple(version: str) -> tuple:
return tuple(int(part) for part in version.split("."))
def _pyproject() -> dict:
with PYPROJECT.open("rb") as handle:
return tomllib.load(handle)
def _requires_python_floor() -> str:
requires = _pyproject()["project"]["requires-python"]
match = re.fullmatch(r">=\s*(\d+\.\d+)", requires.strip())
assert match, f"unexpected requires-python format: {requires!r}"
return match.group(1)
def _classifier_versions() -> list:
versions = []
for classifier in _pyproject()["project"]["classifiers"]:
match = re.fullmatch(r"Programming Language :: Python :: (\d+\.\d+)", classifier)
if match:
versions.append(match.group(1))
return versions
def _ci_matrix_versions() -> list:
match = re.search(r"python-version: \[([^\]]+)\]", PIPELINE.read_text(encoding="utf-8"))
assert match, "python-version matrix not found in unified-pipeline.yml"
return re.findall(r"'(\d+\.\d+)'", match.group(1))
def _install_sh_min_version() -> str:
# Anchored on the function: install.sh also carries an Ubuntu MIN_VERSION.
match = re.search(
r'check_python_version\(\) \{.*?local MIN_VERSION="(\d+\.\d+)"',
INSTALL_SH.read_text(encoding="utf-8"),
re.DOTALL,
)
assert match, "check_python_version's MIN_VERSION not found in install.sh"
return match.group(1)
def test_installer_floor_matches_requires_python():
assert _install_sh_min_version() == _requires_python_floor()
def test_classifiers_match_supported_range():
floor = _requires_python_floor()
classifiers = _classifier_versions()
assert classifiers, "no Programming Language :: Python :: X.Y classifiers"
assert min(map(_version_tuple, classifiers)) == _version_tuple(floor)
def test_ci_matrix_covers_every_supported_version():
# The matrix is the only thing that proves a promised version works.
assert sorted(_ci_matrix_versions()) == sorted(_classifier_versions())
def test_running_interpreter_is_supported():
assert sys.version_info[:2] >= _version_tuple(_requires_python_floor())
def _pkgbuild_python_floor() -> str:
match = re.search(r"'python>=(\d+\.\d+)'", PKGBUILD.read_text(encoding="utf-8"))
assert match, "python>= dependency not found in the AUR PKGBUILD"
return match.group(1)
def test_aur_package_matches_requires_python():
assert _pkgbuild_python_floor() == _requires_python_floor()
def test_docs_and_site_do_not_advertise_an_older_floor():
"""No user-facing file may promise a Python older than the real floor."""
floor = _version_tuple(_requires_python_floor())
files = _doc_and_site_files()
assert files, "found nothing to scan; the docs/website layout moved"
offenders = []
for path in files:
text = path.read_text(encoding="utf-8", errors="ignore")
for number, line in enumerate(text.splitlines(), 1):
for claimed in re.findall(r"Python (\d+\.\d+)\+", line):
if _version_tuple(claimed) < floor:
rel = path.relative_to(REPO_ROOT)
offenders.append(f"{rel}:{number} claims Python {claimed}+")
assert not offenders, "user-facing files promise an unsupported Python:\n" + "\n".join(
offenders
)
+5 -4
View File
@@ -65,9 +65,11 @@ class TestSpeechRecognition(unittest.TestCase):
mock_audio_feedback.play_stop_sound.reset_mock()
mock_audio_feedback.play_error_sound.reset_mock()
# Patch os.makedirs to avoid creating directories
self.patcher_makedirs = patch("os.makedirs")
self.mock_makedirs = self.patcher_makedirs.start()
# os.makedirs is already patched above as mockMakeDirs. Patching it a
# second time here and then stopping the two out of order (this one last)
# restored the *first* mock into the os module, leaking a fake makedirs
# into every test that ran after this class.
self.mock_makedirs = self.makeDirsMock
# Patch os.path.exists to return True for any path
self.patcher_exists = patch("os.path.exists", return_value=True)
@@ -105,7 +107,6 @@ class TestSpeechRecognition(unittest.TestCase):
self.mockDownload.stop()
self.mockCmdProcessor.stop()
self.patcher_makedirs.stop()
self.patcher_exists.stop()
self.patcher_unlink.stop()
self.patcher_temp.stop()
@@ -519,26 +519,6 @@ class TestDownloadVoskModel(unittest.TestCase):
pass
class TestDownloadWhisperModel(unittest.TestCase):
def test_download_whisper_model(self):
mgr = _make_manager(engine="whisper")
mgr._download_cancelled = False
# Mock the download by preventing actual network calls
mock_requests = MagicMock()
mock_response = MagicMock()
mock_response.headers.get.return_value = "1000" # content-length
mock_response.iter_content.return_value = [b"test" * 250]
mock_requests.get.return_value = mock_response
with patch.dict("sys.modules", {"requests": mock_requests}):
with patch("builtins.open", create=True) as mock_open:
mock_file = MagicMock()
mock_open.return_value.__enter__.return_value = mock_file
with patch("os.rename"):
mgr._download_whisper_model(cache_dir="/tmp/test")
# Verify file write was called
mock_file.write.assert_called()
class TestBufferManagement(unittest.TestCase):
def test_set_buffer_limit(self):
mgr = _make_manager()
@@ -948,6 +948,17 @@ class TestWhisperInitialization(unittest.TestCase):
class TestWhispercppInitialization(unittest.TestCase):
"""Test whisper.cpp engine initialization."""
def setUp(self):
# These cover device/backend selection, not integrity. _init_whispercpp
# now hashes an existing model before handing it to ctypes, and the model
# here is a mocked path with no bytes behind it. Verification itself is
# covered by tests/test_model_checksums.py.
patcher = patch.object(
SpeechRecognitionManager, "_whispercpp_model_is_verified", return_value=True
)
self.addCleanup(patcher.stop)
patcher.start()
def test_init_whispercpp_invalid_model_size(self):
"""Test whisper.cpp with invalid model size."""
manager = _make_manager(engine="whisper_cpp")
@@ -1077,6 +1088,17 @@ class TestWhispercppInitialization(unittest.TestCase):
class TestWhispercppGpuDeviceSelection(unittest.TestCase):
"""Test whisper.cpp GPU device selection logic."""
def setUp(self):
# These cover device/backend selection, not integrity. _init_whispercpp
# now hashes an existing model before handing it to ctypes, and the model
# here is a mocked path with no bytes behind it. Verification itself is
# covered by tests/test_model_checksums.py.
patcher = patch.object(
SpeechRecognitionManager, "_whispercpp_model_is_verified", return_value=True
)
self.addCleanup(patcher.stop)
patcher.start()
class ContextParamsModel:
calls = []
@@ -74,35 +74,6 @@ class TestDownloadFunctions(unittest.TestCase):
)
return manager
def test_download_whisper_model_success(self):
"""Test successful Whisper model download with progress tracking."""
with patch("builtins.open", mock_open()) as mock_file:
with patch("os.rename") as mock_rename:
with patch("os.path.dirname") as mock_dirname:
with patch("os.makedirs"):
mock_dirname.return_value = "/fake/dir"
mock_response = MagicMock()
mock_response.headers = {"content-length": "1024"}
mock_response.iter_content.return_value = [b"x" * 512, b"y" * 512]
progress_calls = []
def progress_cb(progress, speed, status):
progress_calls.append((progress, speed, status))
with patch("requests.get", return_value=mock_response):
manager = self._create_manager(engine="whisper")
manager._download_progress_callback = progress_cb
# This should call the download function
manager._download_whisper_model("/fake/cache")
# Verify file was written
mock_file.assert_called()
# Verify progress callback was called
assert len(progress_calls) > 0, "Progress callback not called"
def test_download_whisper_model_cancelled(self):
"""Test Whisper download cancellation."""
# Verify the cancelled flag exists and can be set
+269 -28
View File
@@ -7,11 +7,10 @@ Key focus areas:
- IBus engine utility functions
"""
import base64
import os
import sys
import time
import zipfile as REAL_ZIPFILE # Capture real zipfile before any test mocks it
from io import BytesIO
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -26,6 +25,7 @@ if "gi.repository" not in sys.modules:
sys.modules["gi.repository"] = MagicMock()
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
from vocalinux.utils.model_checksums import VERIFICATION_STAMP_NAME, expected_for
def _make_manager(engine="whisper_cpp", **kw):
@@ -38,14 +38,77 @@ def _make_manager(engine="whisper_cpp", **kw):
)
# Ensure vosk_model_map is set (normally done in _init_vosk)
if not hasattr(mgr, "vosk_model_map"):
# The names _init_vosk() would pick for en-us. They have to be
# real: the download path looks each one up in the checksum
# manifest to stamp the tree it extracts.
mgr.vosk_model_map = {
"small": "model-en-us-0.22-lgraph",
"small": "vosk-model-small-en-us-0.15",
"medium": "vosk-model-en-us-0.22",
"large": "vosk-model-en-us-0.22-lgraph",
"large": "vosk-model-en-us-0.22",
}
return mgr
# A real zip, embedded rather than built here: by the time this module is
# imported, other test modules have already replaced zipfile/BytesIO in
# sys.modules with mocks, so building one here yields empty bytes. Its single
# entry sits under the directory VOSK's small en-us archive unpacks to, because
# that is the tree the downloader stamps after extracting.
VOSK_ZIP_BYTES = base64.b64decode(
"UEsDBBQAAAAIAHV2F12oDuXYFgAAAIgTAAAoAAAAdm9zay1tb2RlbC1zbWFsbC1lbi11cy0wLjE1L2FtL2Zp"
"bmFsLm1kbO3BMQEAAADCoPVPbQo/oAAAAACAtwFQSwECFAMUAAAACAB1dhddqA7l2BYAAACIEwAAKAAAAAAA"
"AAAAAAAAgAEAAAAAdm9zay1tb2RlbC1zbWFsbC1lbi11cy0wLjE1L2FtL2ZpbmFsLm1kbFBLBQYAAAAAAQAB"
"AFYAAABcAAAAAAA="
)
MODEL_NAME = "vosk-model-small-en-us-0.15"
# Same shape, but its top-level directory is not the model name: the downloader
# has to notice rather than report a success that installed nothing.
WRONG_LAYOUT_ZIP_BYTES = base64.b64decode(
"UEsDBBQAAAAIAHeGF11E9MkCEQAAANAHAAAcAAAAc29tZS1vdGhlci1uYW1lL2FtL2ZpbmFsLm1kbGNgGAWj"
"YBSMglEwCkbBUAcAUEsBAhQDFAAAAAgAd4YXXUT0yQIRAAAA0AcAABwAAAAAAAAAAAAAAIABAAAAAHNvbWUt"
"b3RoZXItbmFtZS9hbS9maW5hbC5tZGxQSwUGAAAAAAEAAQBKAAAASwAAAAAA"
)
class FakeRequestError(Exception):
"""Stands in for requests.exceptions.RequestException.
A leaked mock makes ``except requests.exceptions.RequestException`` a
TypeError, so the mocked module needs a real class here. It must not be
``Exception`` itself, or that clause swallows every other failure the
downloader is supposed to surface unwrapped.
"""
def _fake_clock(step=0.2):
"""Monotonic stand-in for time.time().
A fixed side_effect list runs out unpredictably: time.time is patched
globally, so logging consumes ticks too.
"""
from itertools import count
counter = count()
return lambda: next(counter) * step
@pytest.fixture
def skip_checksum():
"""Accept the synthetic payloads these tests stream.
Downloads are verified against the digests pinned in model_checksums.txt, so
a few hundred bytes of ``b"x"`` are correctly rejected. These tests cover
download *mechanics* (progress, content-length, URL shaping); integrity
itself is covered by tests/test_model_checksums.py. Yielding the mock lets
each test still assert that verification was reached.
"""
with patch("vocalinux.speech_recognition.recognition_manager.verify_model_file") as mock_verify:
yield mock_verify
@pytest.fixture(autouse=True)
def cleanup_sys_modules():
"""Cleanup sys.modules after each test - full snapshot/restore."""
@@ -66,7 +129,7 @@ def cleanup_sys_modules():
class TestDownloadWhispercppModel:
"""Test _download_whispercpp_model() with runtime import mocking."""
def test_download_whispercpp_success_basic(self, tmp_path):
def test_download_whispercpp_success_basic(self, tmp_path, skip_checksum):
"""Test successful whisper.cpp model download."""
manager = _make_manager(engine="whisper_cpp")
model_file = str(tmp_path / "ggml-small.bin")
@@ -87,8 +150,10 @@ class TestDownloadWhispercppModel:
assert os.path.exists(model_file)
assert os.path.getsize(model_file) == 1000
# The model is only installed after it is verified.
skip_checksum.assert_called_once()
def test_download_whispercpp_progress_callback(self, tmp_path):
def test_download_whispercpp_progress_callback(self, tmp_path, skip_checksum):
"""Test progress callback is invoked during download."""
manager = _make_manager(engine="whisper_cpp")
progress_calls = []
@@ -111,7 +176,7 @@ class TestDownloadWhispercppModel:
"vocalinux.speech_recognition.recognition_manager.get_model_path",
return_value=model_file,
):
with patch("time.time", side_effect=[0, 0.2, 0.4, 0.6]):
with patch("time.time", side_effect=_fake_clock()):
manager._download_whispercpp_model()
mock_requests.get.assert_called_once()
@@ -120,7 +185,7 @@ class TestDownloadWhispercppModel:
assert len(call_args[0]) > 0 or "url" in call_args[1]
assert len(progress_calls) >= 1
def test_download_whispercpp_no_content_length(self, tmp_path):
def test_download_whispercpp_no_content_length(self, tmp_path, skip_checksum):
"""Test download when content-length header is missing."""
manager = _make_manager(engine="whisper_cpp")
model_file = str(tmp_path / "ggml-small.bin")
@@ -159,7 +224,7 @@ class TestDownloadWhispercppModel:
with pytest.raises(RuntimeError, match="Failed to download"):
manager._download_whispercpp_model()
def test_download_whispercpp_appends_download_true(self, tmp_path):
def test_download_whispercpp_appends_download_true(self, tmp_path, skip_checksum):
"""Hugging Face URLs get ?download=true for reliable binary responses."""
manager = _make_manager(engine="whisper_cpp")
model_file = str(tmp_path / "ggml-small.bin")
@@ -285,10 +350,91 @@ class TestDownloadWhispercppModel:
assert any("m " in s or "ETA" in s for s in progress_calls)
class TestDownloadWhisperModel:
"""OpenAI Whisper downloads run through the shared streaming helper.
Vocalinux fetches the checkpoint only to give the UI progress and a working
cancel; whisper.load_model() is what verifies it. Both were briefly lost when
the downloader was removed altogether, so they are pinned here.
"""
@staticmethod
def _fake_stream(url, dest_path):
with open(dest_path, "wb") as handle:
handle.write(b"checkpoint")
def test_writes_the_name_whisper_expects(self, tmp_path):
manager = _make_manager(engine="whisper")
manager.model_size = "large"
with patch.object(manager, "_stream_model_download", side_effect=self._fake_stream):
manager._download_whisper_model(str(tmp_path))
# large is stored as large-v3.pt; "large.pt" here would mean load_model
# downloads the 2.9GB checkpoint a second time.
assert (tmp_path / "large-v3.pt").exists()
assert not (tmp_path / "large.pt").exists()
def test_reports_progress(self, tmp_path):
manager = _make_manager(engine="whisper")
progress_calls = []
manager._download_progress_callback = lambda f, s, st: progress_calls.append(st)
with patch.object(manager, "_stream_model_download", side_effect=self._fake_stream):
manager._download_whisper_model(str(tmp_path))
assert progress_calls, "the download dialog would sit at zero"
def test_cancel_propagates_and_cleans_up(self, tmp_path):
"""Cancel raised by the stream helper must reach the caller."""
manager = _make_manager(engine="whisper")
def cancel(url, dest_path):
with open(dest_path, "wb") as handle:
handle.write(b"partial")
raise RuntimeError("Download cancelled")
# A leaked requests mock from another module makes
# `except requests.exceptions.RequestException` a TypeError, so pin a
# real exception class here as the other download tests do.
mock_requests = MagicMock()
mock_requests.exceptions.RequestException = Exception
with patch.dict("sys.modules", {"requests": mock_requests}):
with patch.object(manager, "_stream_model_download", side_effect=cancel):
with pytest.raises(RuntimeError, match="cancelled"):
manager._download_whisper_model(str(tmp_path))
assert not list(tmp_path.glob("*"))
class TestDownloadVoskModel:
"""Test _download_vosk_model() with runtime import mocking."""
def test_download_vosk_progress_callback(self, tmp_path):
@staticmethod
def _serve_zip(manager, tmp_path, payload=None):
"""Run a full download+extract of an archive into tmp_path."""
payload = VOSK_ZIP_BYTES if payload is None else payload
mock_requests = MagicMock()
mock_response = MagicMock()
mock_response.headers = {"content-length": str(len(payload))}
mock_response.iter_content.return_value = [payload]
mock_requests.get.return_value = mock_response
mock_requests.exceptions.RequestException = FakeRequestError
# Extraction has to really happen here — the stamp lands in the tree it
# creates — and other modules leave a MagicMock in sys.modules["zipfile"].
real_zipfile = getattr(sys, "_vocalinux_real_zipfile", None) or __import__("zipfile")
with patch.dict("sys.modules", {"requests": mock_requests, "zipfile": real_zipfile}):
with patch(
"vocalinux.speech_recognition.recognition_manager.MODELS_DIR", str(tmp_path)
):
with patch("time.time", side_effect=_fake_clock()):
manager._download_vosk_model()
return mock_requests
def test_download_vosk_progress_callback(self, tmp_path, skip_checksum):
"""Test progress callback during Vosk download."""
manager = _make_manager(engine="vosk")
progress_calls = []
@@ -298,30 +444,92 @@ class TestDownloadVoskModel:
manager._download_progress_callback = track_progress
zip_data = BytesIO()
with REAL_ZIPFILE.ZipFile(zip_data, "w") as zf:
zf.writestr("model-en-us-0.22-lgraph/am/model.pkl", "x" * 5000)
zip_bytes = zip_data.getvalue()
mock_requests = MagicMock()
mock_response = MagicMock()
mock_response.headers = {"content-length": str(len(zip_bytes))}
mock_response.iter_content.return_value = [zip_bytes]
mock_requests.get.return_value = mock_response
mock_requests.exceptions.RequestException = Exception
with patch.dict("sys.modules", {"requests": mock_requests}):
with patch(
"vocalinux.speech_recognition.recognition_manager.MODELS_DIR", str(tmp_path)
):
with patch("time.time", side_effect=[0, 0.2, 0.4, 0.6]):
manager._download_vosk_model()
mock_requests = self._serve_zip(manager, tmp_path)
mock_requests.get.assert_called_once()
call_args = mock_requests.get.call_args
assert call_args is not None
assert len(call_args[0]) > 0 or "url" in call_args[1]
assert len(progress_calls) >= 1
# Otherwise the digest check could be deleted and this stay green.
skip_checksum.assert_called_once()
def test_download_vosk_stamps_the_extracted_tree(self, tmp_path, skip_checksum):
"""A directory has no digest; install.sh reads this stamp instead.
Leave it out and every model fetched at first run or from Settings looks
unverified to the next ./install.sh, which downloads it again.
"""
manager = _make_manager(engine="vosk")
self._serve_zip(manager, tmp_path)
stamp = tmp_path / "vosk-model-small-en-us-0.15" / VERIFICATION_STAMP_NAME
pinned = expected_for("vosk-model-small-en-us-0.15.zip")
assert pinned is not None, "the fixture model must be in the manifest"
assert stamp.read_text().strip() == pinned.digest
@staticmethod
def _leftovers(tmp_path):
"""Everything in MODELS_DIR that is not the finished model tree."""
return sorted(p.name for p in tmp_path.iterdir() if p.name != MODEL_NAME)
def test_download_vosk_fails_when_the_stamp_cannot_be_written(self, tmp_path, skip_checksum):
"""An unstampable tree is one we would silently refetch; say so instead."""
manager = _make_manager(engine="vosk")
with patch(
"vocalinux.speech_recognition.recognition_manager.write_verification_stamp",
side_effect=OSError("read-only models directory"),
):
with pytest.raises(OSError):
self._serve_zip(manager, tmp_path)
# And it must not leave the tree behind: _init_vosk would call that
# installed while install.sh, finding no stamp, refetched it every run.
assert not (tmp_path / MODEL_NAME).exists()
assert self._leftovers(tmp_path) == []
def test_a_failed_unpack_keeps_the_model_that_was_already_there(self, tmp_path, skip_checksum):
"""Replacing is a swap: a broken download must not cost the old model."""
old = tmp_path / MODEL_NAME / "am"
old.mkdir(parents=True)
(old / "final.mdl").write_bytes(b"the model that was already installed")
manager = _make_manager(engine="vosk")
with patch(
"vocalinux.speech_recognition.recognition_manager.write_verification_stamp",
side_effect=OSError("read-only models directory"),
):
with pytest.raises(OSError):
self._serve_zip(manager, tmp_path)
assert (old / "final.mdl").read_bytes() == b"the model that was already installed"
assert self._leftovers(tmp_path) == []
def test_an_archive_with_the_wrong_layout_is_refused(self, tmp_path, skip_checksum):
"""A zip that unpacks somewhere else must not look like a success."""
manager = _make_manager(engine="vosk")
with pytest.raises(RuntimeError, match="does not contain"):
self._serve_zip(manager, tmp_path, payload=WRONG_LAYOUT_ZIP_BYTES)
assert not (tmp_path / MODEL_NAME).exists()
assert self._leftovers(tmp_path) == []
def test_a_verified_download_replaces_an_older_tree(self, tmp_path, skip_checksum):
old = tmp_path / MODEL_NAME
(old / "am").mkdir(parents=True)
(old / "am" / "final.mdl").write_bytes(b"stale")
(old / "stale-file").write_text("must not survive the swap")
manager = _make_manager(engine="vosk")
self._serve_zip(manager, tmp_path)
assert (old / "am" / "final.mdl").read_bytes() != b"stale"
assert not (old / "stale-file").exists(), "the swap must replace, not merge"
assert (old / VERIFICATION_STAMP_NAME).exists()
assert self._leftovers(tmp_path) == []
def test_download_vosk_request_error(self, tmp_path):
"""Test Vosk download request error handling."""
@@ -340,6 +548,39 @@ class TestDownloadVoskModel:
manager._download_vosk_model()
class TestWhispercppRejectsAnUnverifiedModelOnDisk:
"""Wiring test: the hash has to happen where the model is picked up.
_download_whispercpp_model verifies before its rename, so the exposure is a
file that never came through it: releases before verification fetched ggml
models from `resolve/main` unchecked, and install.sh re-hashes only
ggml-tiny.bin. Without this the file goes to pywhispercpp/ctypes as-is.
"""
def test_a_model_that_fails_its_pin_is_removed_and_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 = 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 not model.exists(), "an unverifiable model must not stay on disk"
load.assert_not_called()
assert manager._model_initialized is False
class TestAudioReconnection:
"""Test audio reconnection logic."""
+26
View File
@@ -155,6 +155,32 @@ class TestGetSystemModelPaths(unittest.TestCase):
paths = _get_system_model_paths()
self.assertIsInstance(paths, list)
def test_arch_paths(self):
# Arch drops /usr/local/share, but must not crash when a custom
# XDG_DATA_DIRS never added that path in the first place.
mock_os_release = 'NAME="Arch Linux"\nID=arch\n'
with patch.dict(os.environ, {"XDG_DATA_DIRS": "/usr/local/share:/usr/share"}):
with patch("builtins.open", create=True) as mock_open:
mock_open.return_value.__enter__ = lambda s: s
mock_open.return_value.__exit__ = MagicMock(return_value=False)
mock_open.return_value.read.return_value = mock_os_release
paths = _get_system_model_paths()
self.assertIsInstance(paths, list)
self.assertNotIn("/usr/local/share/vocalinux/models", paths)
self.assertIn("/usr/share/vocalinux/models", paths)
def test_arch_paths_custom_xdg_dirs(self):
# Regression test: custom XDG_DATA_DIRS + Arch os-release used to
# raise ValueError from paths.remove() on the missing default path.
mock_os_release = 'NAME="Arch Linux"\nID=arch\n'
with patch.dict(os.environ, {"XDG_DATA_DIRS": "/custom/share"}):
with patch("builtins.open", create=True) as mock_open:
mock_open.return_value.__enter__ = lambda s: s
mock_open.return_value.__exit__ = MagicMock(return_value=False)
mock_open.return_value.read.return_value = mock_os_release
paths = _get_system_model_paths()
self.assertTrue(any("/custom/share" in p for p in paths))
def test_os_release_not_found(self):
with patch.dict(os.environ, {"XDG_DATA_DIRS": "/usr/share"}):
with patch("builtins.open", side_effect=FileNotFoundError()):
+6 -5
View File
@@ -12,6 +12,7 @@ from pathlib import Path
from unittest.mock import MagicMock, Mock, call, mock_open, patch
import vocalinux.single_instance as single_instance_module
from vocalinux.utils.paths import data_dir
class TestAcquireLock(unittest.TestCase):
@@ -181,11 +182,11 @@ class TestGetLockFileFd(unittest.TestCase):
class TestLockFileConstants(unittest.TestCase):
"""Tests for lock file constants."""
def test_lock_file_path_is_in_home_directory(self):
"""Test that lock file is in ~/.local/share/vocalinux/."""
self.assertIn(".local", str(single_instance_module.LOCK_FILE_PATH))
self.assertIn("share", str(single_instance_module.LOCK_FILE_PATH))
self.assertIn("vocalinux", str(single_instance_module.LOCK_FILE_PATH))
def test_lock_file_path_is_in_data_home(self):
"""Test that lock file lives under $XDG_DATA_HOME/vocalinux/."""
expected_dir = Path(data_dir())
self.assertEqual(single_instance_module.LOCK_FILE_DIR, expected_dir)
self.assertEqual(single_instance_module.LOCK_FILE_PATH, expected_dir / "instance.lock")
def test_lock_file_path_is_path_object(self):
"""Test that lock file path is a Path object."""
+161
View File
@@ -0,0 +1,161 @@
"""Tests for OpenAI Whisper checkpoint file naming.
Regression cover for the review finding on #713: the file name was derived as
``f"{model_size}.pt"`` in three places, which is wrong for "large" — upstream
only ships the versioned checkpoint, so `openai-whisper` stores `large-v3.pt`.
The consequences were a 2.9GB model downloaded and stored twice, and a settings
dialog that reported "large" as missing no matter how often it was fetched.
"""
import os
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from vocalinux.utils.whisper_model_info import (
WHISPER_MODEL_SIZES,
migrate_legacy_checkpoint_names,
whisper_model_file,
)
class TestWhisperModelFile(unittest.TestCase):
def test_large_maps_to_the_versioned_checkpoint(self):
self.assertEqual(whisper_model_file("large"), "large-v3.pt")
def test_other_sizes_keep_their_own_name(self):
for size in ("tiny", "base", "small", "medium"):
self.assertEqual(whisper_model_file(size), f"{size}.pt")
def test_every_catalog_size_is_covered(self):
for size in WHISPER_MODEL_SIZES:
self.assertTrue(whisper_model_file(size).endswith(".pt"), size)
def test_names_match_what_openai_whisper_stores(self):
"""openai-whisper names each file after the basename of its URL."""
upstream = {
"tiny": "tiny.pt",
"base": "base.pt",
"small": "small.pt",
"medium": "medium.pt",
# whisper's _MODELS maps "large" to the large-v3 URL
"large": "large-v3.pt",
}
for size, expected in upstream.items():
self.assertEqual(whisper_model_file(size), expected, size)
class TestLegacyCheckpointMigration(unittest.TestCase):
"""A checkpoint fetched by an earlier release must not be stranded.
That release saved "large" as large.pt, from the same large-v3 URL used
today. Under the old name load_model() cannot see it (2.9GB refetched),
Settings does not list it, and what is not listed cannot be deleted there.
"""
def setUp(self):
self.cache = Path(self.enterContext(TemporaryDirectory()))
def test_a_legacy_large_is_renamed(self):
(self.cache / "large.pt").write_bytes(b"checkpoint")
renamed = migrate_legacy_checkpoint_names(str(self.cache))
self.assertEqual(renamed, ["large-v3.pt"])
self.assertEqual((self.cache / "large-v3.pt").read_bytes(), b"checkpoint")
self.assertFalse((self.cache / "large.pt").exists())
def test_names_that_already_match_are_untouched(self):
for size in ("tiny", "base", "small", "medium"):
(self.cache / f"{size}.pt").write_bytes(size.encode())
self.assertEqual(migrate_legacy_checkpoint_names(str(self.cache)), [])
for size in ("tiny", "base", "small", "medium"):
self.assertEqual((self.cache / f"{size}.pt").read_bytes(), size.encode())
def test_an_existing_upstream_file_is_never_clobbered(self):
"""The real checkpoint wins; the stale duplicate is left for the user."""
(self.cache / "large.pt").write_bytes(b"legacy")
(self.cache / "large-v3.pt").write_bytes(b"current")
self.assertEqual(migrate_legacy_checkpoint_names(str(self.cache)), [])
self.assertEqual((self.cache / "large-v3.pt").read_bytes(), b"current")
self.assertEqual((self.cache / "large.pt").read_bytes(), b"legacy")
def test_a_rename_failure_is_not_fatal(self):
"""Worst case is the refetch that would have happened regardless."""
(self.cache / "large.pt").write_bytes(b"checkpoint")
with patch("os.rename", side_effect=OSError("read-only")):
self.assertEqual(migrate_legacy_checkpoint_names(str(self.cache)), [])
def test_an_empty_cache_is_fine(self):
self.assertEqual(migrate_legacy_checkpoint_names(str(self.cache)), [])
class TestSettingsDialogUsesTheSameName(unittest.TestCase):
"""The UI must look for the file that is actually written to disk."""
def test_large_is_detected_once_present(self):
from vocalinux.ui import settings_dialog
with patch.object(settings_dialog, "_get_whisper_cache_dir", return_value="/models"):
def exists(path):
return path == os.path.join("/models", "large-v3.pt")
with patch("os.path.exists", side_effect=exists):
self.assertTrue(settings_dialog._is_whisper_model_downloaded("large"))
def test_a_copy_only_in_the_default_cache_is_not_reported_as_downloaded(self):
"""The engine cannot use ~/.cache/whisper, so the UI must not promise it."""
from vocalinux.ui import settings_dialog
with patch.object(settings_dialog, "_get_whisper_cache_dir", return_value="/models"):
def exists(path):
return path == os.path.expanduser("~/.cache/whisper/base.pt")
with patch("os.path.exists", side_effect=exists):
self.assertFalse(settings_dialog._is_whisper_model_downloaded("base"))
def test_large_is_not_detected_under_the_wrong_name(self):
"""`large.pt` is a name nothing writes; it must not count as downloaded."""
from vocalinux.ui import settings_dialog
with patch.object(settings_dialog, "_get_whisper_cache_dir", return_value="/models"):
def exists(path):
return path == os.path.join("/models", "large.pt")
with patch("os.path.exists", side_effect=exists):
self.assertFalse(settings_dialog._is_whisper_model_downloaded("large"))
class TestTheDialogRescuesALegacyCheckpoint(unittest.TestCase):
"""Wiring test: the rename has to happen where the list is built.
Without it a "large" downloaded by an earlier release stays absent from the
dialog, and absent means the delete button never offers it, so 2.9GB sits
there with no way to reclaim it short of a file manager.
"""
def test_a_legacy_large_becomes_listed_and_therefore_deletable(self):
from vocalinux.ui import settings_dialog
with TemporaryDirectory() as cache:
Path(cache, "large.pt").write_bytes(b"checkpoint")
with patch.object(settings_dialog, "_get_whisper_cache_dir", return_value=cache):
listed = settings_dialog._list_downloaded_whisper_models()
self.assertIn("large", listed)
# Listed is only half of it: deletion resolves the path again.
self.assertEqual(
settings_dialog._whisper_model_files("large"),
[os.path.realpath(os.path.join(cache, "large-v3.pt"))],
)
if __name__ == "__main__":
unittest.main()
+2 -5
View File
@@ -89,13 +89,10 @@ class TestWhisperSupport:
model_mock = MagicMock()
whisper_mock.load_model.return_value = model_mock
# Patch modules and mock the download method to avoid network/file operations
# os.path.exists is patched so the checkpoint looks present; load_model is
# mocked, so nothing touches the network or the filesystem.
with (
patch.dict(sys.modules, {"whisper": whisper_mock, "torch": torch_mock}),
patch(
"vocalinux.speech_recognition.recognition_manager."
"SpeechRecognitionManager._download_whisper_model"
) as _mock_download,
patch("os.path.exists", return_value=True),
):
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
Generated
+170 -2338
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -26,11 +26,11 @@ const faqCategories = [
},
{
q: "Which Linux distributions are supported?",
a: "Vocalinux works on Ubuntu 22.04+, Fedora 39+, Arch Linux, Manjaro, Debian, and most other Linux distributions. See our install guides for distro-specific instructions.",
a: "Vocalinux works on Ubuntu 24.04+, Debian 12+, Fedora 39+, Arch Linux, Manjaro, and most other Linux distributions. See our install guides for distro-specific instructions.",
},
{
q: "What are the system requirements?",
a: "You need Python 3.9+, a working microphone, and about 500MB of disk space. For GPU acceleration with whisper.cpp, you need Vulkan support (AMD, Intel, or NVIDIA).",
a: "You need Python 3.11+, a working microphone, and about 500MB of disk space. For GPU acceleration with whisper.cpp, you need Vulkan support (AMD, Intel, or NVIDIA).",
},
],
},
+6 -6
View File
@@ -33,18 +33,18 @@ const distroConfig: Record<DistroSlug, DistroConfig> = {
name: "Ubuntu",
title: "Install Voice Dictation on Ubuntu",
description:
"Step-by-step Ubuntu voice dictation setup for offline speech-to-text with whisper.cpp and VOSK. Works on Ubuntu 22.04+, GNOME, KDE, X11, and Wayland.",
"Step-by-step Ubuntu voice dictation setup for offline speech-to-text with whisper.cpp and VOSK. Works on Ubuntu 24.04+, GNOME, KDE, X11, and Wayland.",
intro:
"This Ubuntu guide is tuned for practical desktop use: terminals, browsers, IDEs, office suites, and chat apps. Local engines keep audio on your machine. Remote API is optional.",
prerequisites: [
"Ubuntu 22.04+ (or Ubuntu-based distro)",
"Python 3.9+",
"Ubuntu 24.04+ (or Ubuntu-based distro)",
"Python 3.11+",
"Working microphone",
"curl installed (sudo apt update && sudo apt install -y curl)",
],
testedOn: [
"Ubuntu 24.04 LTS (GNOME, Wayland)",
"Ubuntu 22.04 LTS (GNOME, X11)",
"Ubuntu 24.04 LTS (GNOME, X11)",
"Kubuntu and Linux Mint derivatives",
],
installCommand:
@@ -69,7 +69,7 @@ const distroConfig: Record<DistroSlug, DistroConfig> = {
"Fedora is an excellent match for Vocalinux thanks to current Python stacks and up-to-date system libraries. This guide focuses on reliable setup and minimal friction.",
prerequisites: [
"Fedora Workstation 39+ (or Fedora-based distro)",
"Python 3.9+",
"Python 3.11+",
"Working microphone",
"curl installed (sudo dnf install -y curl)",
],
@@ -100,7 +100,7 @@ const distroConfig: Record<DistroSlug, DistroConfig> = {
"Arch users usually care about control and performance. This guide keeps setup lean while preserving compatibility across rolling-release desktop environments.",
prerequisites: [
"Arch Linux / Manjaro / EndeavourOS",
"Python 3.9+",
"Python 3.11+",
"Working microphone",
"curl installed (sudo pacman -Sy --needed curl)",
],
+1 -1
View File
@@ -17,7 +17,7 @@ const distroGuides = [
name: "Ubuntu",
href: "/install/ubuntu/",
description:
"Install offline voice dictation on Ubuntu 22.04+ with GNOME, KDE, X11, or Wayland.",
"Install offline voice dictation on Ubuntu 24.04+ with GNOME, KDE, X11, or Wayland.",
icon: Laptop,
},
{
+1 -1
View File
@@ -815,7 +815,7 @@ export default function HomePage() {
question: "Which Linux distributions are supported?",
answer: (
<>
Ubuntu 22.04+, Debian 11+, Fedora 39+, Arch, openSUSE
Ubuntu 24.04+, Debian 12+, Fedora 39+, Arch, openSUSE
Tumbleweed, and most modern desktops on X11 or Wayland.{" "}
<Link href="/install/">Install guides</Link>.
</>
+2 -2
View File
@@ -23,7 +23,7 @@ const troubleshootingItems = [
solutions: [
"Ensure you ran the installer from the homepage or install guide",
"Check if the virtual environment is activated: source ~/.local/share/vocalinux/venv/bin/activate",
"Verify Python version (3.9+ required): python3 --version",
"Verify Python version (3.11+ required): python3 --version",
"Try reinstalling: ./uninstall.sh && ./install.sh",
],
},
@@ -114,7 +114,7 @@ const troubleshootingItems = [
solutions: [
"Ensure you have internet connectivity for downloading dependencies",
"Run with sufficient permissions (don't use sudo unless specifically needed)",
"Check your distribution is supported (Ubuntu 22.04+, Fedora 39+, Arch)",
"Check your distribution is supported (Ubuntu 24.04+, Debian 12+, Fedora 39+, Arch)",
"Install system dependencies manually: sudo apt install python3-pip python3-gi python3-venv",
"Check disk space: df -h",
],