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:
@@ -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]
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[](https://github.com/VocaHQ/vocalinux#features)
|
||||
[](CONTRIBUTING.md)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://discord.gg/t6muquAJbm)
|
||||
[](https://vocahq.com)
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
[](https://x.com/vocahq)
|
||||
|
||||
<!-- Distros (widest; base plate above the hero) -->
|
||||
[](docs/DISTRO_COMPATIBILITY.md)
|
||||
[](docs/DISTRO_COMPATIBILITY.md)
|
||||
[](docs/DISTRO_COMPATIBILITY.md)
|
||||
[](docs/DISTRO_COMPATIBILITY.md)
|
||||
[](docs/DISTRO_COMPATIBILITY.md)
|
||||
[](docs/DISTRO_COMPATIBILITY.md)
|
||||
[](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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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__)"
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+12
-257
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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]):
|
||||
"""
|
||||
|
||||
@@ -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)]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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()):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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).",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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)",
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>.
|
||||
</>
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user