* feat(speech_recognition): add EngineType enum and Engine Protocol Introduce a typed EngineType enum and an Engine Protocol in common_types.py, plus an engines/ registry so new speech recognition backends can be plugged in without scattering string comparisons across the manager. * feat(speech_recognition): add faster-whisper engine backend Add a faster-whisper engine implementation using CTranslate2/INT8 for fast CPU inference. Includes model metadata utility, wiring through the recognition manager, CLI, settings UI, config, optional dependency, and unit tests. Also updates the suspend-handler test fixture for the new engine attribute. * style(ui): format faster-whisper imports for isort 8 * test(speech_recognition): expand faster-whisper coverage for codecov * test(speech_recognition): fix failing faster-whisper coverage tests - Import missing get_compute_type helper - Add tests for English-only detection, cache exceptions, and import-error registry path - Clear _has_torch_cuda lru_cache around exception test to avoid cached True - Replace broad builtins.__import__ patch with a fake engine module for registry test - Restore registry module state after test * test(speech_recognition): add coverage for faster-whisper edge cases Cover previously missing lines in: - faster_whisper_model_info.py: HF cache path/none, hf_hub lookup, fallback traversal, torch CUDA true, psutil exception - faster_whisper_engine.py: model None guard after is_ready() - engine registry import-error path * feat(install): add faster_whisper engine option to install.sh - Add faster_whisper to --engine help text and examples - Add faster-whisper as interactive option 5 with description - Install faster-whisper package and verify import at install time - Add fallback to whisper.cpp if faster-whisper installation fails - Add smoke test for faster-whisper import - Update welcome message engine display name - Note: faster-whisper models download automatically on first use * refactor(faster_whisper): apply ponytail review cleanups - Inline FASTER_WHISPER_MODEL_INFO dict literal; drop unused AVAILABLE_MODELS/MODEL_SIZES - Reuse _has_torch_cuda() in FasterWhisperEngine.device property - Remove redundant model None guard after is_ready() verification - Shrink faster-whisper default config in install.sh to engine + model_size - Update device tests to manage _has_torch_cuda lru_cache - Remove test for deleted dead-code guard * fix(faster_whisper): integrate optional engine with current main Keep whisper_cpp as default and Parakeet as an extra. Put engine selection in the Advanced island, install faster-whisper via the optional extra, and refuse unpinned Hugging Face downloads until model_checksums.txt pins them. * fix(faster_whisper): download verified models and refresh uv.lock Settings already opened a download dialog for uncached faster-whisper, but init only raised ChecksumError. Wire a Parakeet-style downloader that stores bundles under models_dir, verifies each file against model_checksums.txt, and loads WhisperModel from that path. Regenerate uv.lock for the optional faster-whisper extra, pin digests for all eleven Systran models, and add the missing -> None return annotations. * fix(faster_whisper): clear engine on unload and fix auto-pause fixture Auto-pause tests stub __init__, so they never set _faster_whisper_engine. reinitialize_after_resume then AttributeErrors once Test Python runs. Mirror the suspend-handler fixture, and release the faster-whisper engine inside unload_model the same way resume already does. * fix(faster_whisper): restore previous engine after a failed switch reconfigure() selected the new engine and released the live model before init. A failed faster-whisper download then left the manager in ERROR on the new backend while Settings reverted the pickers to the saved engine, so dictation stayed dead until restart. Reload the previous engine from disk (no download) before re-raising so the UI and live manager agree. * fix(speech_recognition): restore all live settings after failed switch Failed reconfigure already put the previous engine back, but VAD, device, remote API, and whisper.cpp knobs stayed on the attempted values while config on disk did not. Snapshot every field reconfigure writes and restore the lot before reloading the old backend. Also bind the real _init_selected_engine onto the MagicMock in the force_reinit test. * fix(speech_recognition): restore every live field after failed reconfigure A failed engine switch already reloaded the previous backend, but Greptile could still miss that VAD, audio device, remote API, and whisper.cpp knobs were part of the snapshot. Keep one explicit attr list for snapshot/restore, including the derived voice-commands flag, so live state matches persisted config after a failed Settings apply. * chore(speech_recognition): sign tip after rollback follow-up
341 lines
13 KiB
Python
341 lines
13 KiB
Python
#!/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, whispercpp_model_info.py, parakeet_model_info.py or
|
|
faster_whisper_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 _parakeet_entries() -> list[Entry]:
|
|
"""Digests for every file of every Parakeet bundle, at its pinned revision.
|
|
|
|
The ONNX files are LFS, so Hugging Face publishes their sha256; tokens.txt
|
|
is a plain blob and is hashed from the bytes this script downloads, the same
|
|
way VOSK archives are.
|
|
"""
|
|
sys.path.insert(0, str(SRC))
|
|
from vocalinux.utils.parakeet_model_info import (
|
|
MODEL_FILES,
|
|
PARAKEET_MODEL_INFO,
|
|
manifest_key,
|
|
)
|
|
|
|
entries = []
|
|
for model_name, info in PARAKEET_MODEL_INFO.items():
|
|
repo, revision = info["repo"], info["revision"]
|
|
payload = _fetch_json(
|
|
f"https://huggingface.co/api/models/{repo}/revision/{revision}?blobs=true"
|
|
)
|
|
published = {}
|
|
for sibling in payload.get("siblings", []):
|
|
lfs = sibling.get("lfs") or {}
|
|
digest = lfs.get("oid") or lfs.get("sha256")
|
|
if digest:
|
|
published[sibling["rfilename"]] = (digest, int(lfs.get("size", 0)))
|
|
|
|
for filename in MODEL_FILES:
|
|
key = manifest_key(model_name, filename)
|
|
if filename in published:
|
|
digest, size = published[filename]
|
|
else:
|
|
url = f"https://huggingface.co/{repo}/resolve/{revision}/{filename}?download=true"
|
|
print(f" {key} (not LFS, hashing bytes)...", flush=True)
|
|
digest, size = _download_and_hash(url, "")
|
|
entries.append(Entry(key, "sha256", digest, size))
|
|
|
|
return entries
|
|
|
|
|
|
def _faster_whisper_entries() -> list[Entry]:
|
|
"""Digests for every file of every faster-whisper bundle, at its pinned revision.
|
|
|
|
``model.bin`` is LFS (Hugging Face publishes sha256 on these repos); config,
|
|
tokenizer and vocabulary files are plain blobs hashed from the bytes this
|
|
script downloads, the same way Parakeet's tokens.txt is.
|
|
"""
|
|
sys.path.insert(0, str(SRC))
|
|
from vocalinux.utils.faster_whisper_model_info import (
|
|
FASTER_WHISPER_MODEL_INFO,
|
|
manifest_key,
|
|
model_files,
|
|
)
|
|
|
|
entries = []
|
|
for model_name, info in FASTER_WHISPER_MODEL_INFO.items():
|
|
repo, revision = info["repo"], info["revision"]
|
|
payload = _fetch_json(
|
|
f"https://huggingface.co/api/models/{repo}/revision/{revision}?blobs=true"
|
|
)
|
|
published = {}
|
|
for sibling in payload.get("siblings", []):
|
|
lfs = sibling.get("lfs") or {}
|
|
digest = lfs.get("sha256") or lfs.get("oid")
|
|
if digest:
|
|
published[sibling["rfilename"]] = (digest, int(lfs.get("size", 0)))
|
|
|
|
for filename in model_files(model_name):
|
|
key = manifest_key(model_name, filename)
|
|
if filename in published:
|
|
digest, size = published[filename]
|
|
else:
|
|
url = f"https://huggingface.co/{repo}/resolve/{revision}/{filename}?download=true"
|
|
print(f" {key} (not LFS, hashing bytes)...", flush=True)
|
|
digest, size = _download_and_hash(url, "")
|
|
entries.append(Entry(key, "sha256", digest, size))
|
|
|
|
return entries
|
|
|
|
|
|
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, whispercpp_model_info.py, parakeet_model_info.py",
|
|
"# or faster_whisper_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.",
|
|
"#",
|
|
"# Parakeet and faster-whisper bundles are pinned per model in their",
|
|
"# model_info modules, so their keys carry the model name: several",
|
|
"# bundles ship the same file names.",
|
|
"",
|
|
]
|
|
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")
|
|
|
|
parakeet_entries = _parakeet_entries()
|
|
print(f" Parakeet: {len(parakeet_entries)} sha256 digests")
|
|
|
|
faster_whisper_entries = _faster_whisper_entries()
|
|
print(f" faster-whisper: {len(faster_whisper_entries)} sha256 digests")
|
|
|
|
MANIFEST.write_text(
|
|
_render(
|
|
whispercpp_entries + vosk_entries + parakeet_entries + faster_whisper_entries,
|
|
revision,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
print(f"Wrote {MANIFEST.relative_to(REPO_ROOT)}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|