* feat(asr): add Parakeet engine via sherpa-onnx Adds a "parakeet" engine backed by sherpa-onnx, running NVIDIA Parakeet TDT 0.6B int8 models on CPU: v3 (25 European languages, auto-detected) and v2 (English). Output is punctuated and capitalized. On an Intel Core Ultra 7 155H it decodes at about 12x realtime on CPU (RTF 0.08), roughly 8x faster than whisper.cpp large-v3-turbo over the same clips with its Vulkan GPU backend (RTF 0.59). Being a transducer it also has no 30s window to pad, so a short push-to-talk utterance returns in ~0.3s instead of ~2.4s. Follows the existing engine layout: utils/parakeet_model_info.py holds the catalog and the get_model_path / is_model_downloaded / list_downloaded_models / delete_model helpers, while _init_parakeet / _transcribe_with_parakeet / _download_parakeet_model follow the same shape as their whisper.cpp counterparts and reuse _stream_model_download for progress and cancellation. Registered in the engine dispatch, the settings engine/model/language pickers, the model info card, the download prompts, the unused-downloads list and install.sh. Models download to <models_dir>/parakeet/<model>/ via temp files renamed on success; is_model_downloaded requires all four files, so an interrupted download is retried rather than loaded. Requires the new "parakeet" extra (sherpa-onnx). * fix(install): write only the engine key for parakeet The parakeet branch duplicated a full default config.json. ConfigManager deep-merges DEFAULT_CONFIG on load, so every other key was redundant: a config holding only "engine": "parakeet" still resolves parakeet_model_size to v3-european along with the VAD, shortcut and text-injection defaults. * fix(install): keep push-to-talk defaults and switch existing configs Writing only the engine key left config.json without a shortcuts section, which _migrate_shortcuts_config reads as a pre-push-to-talk upgrade and pins back to ctrl+ctrl/toggle - contradicting the installer's own "Hold Right Alt while you speak" message. Seed that section too. Point the config at parakeet with set_configured_engine rather than only on first create, so --engine=parakeet also switches a machine that already has a config.json, and give print_welcome_message a parakeet arm so the summary is not blank. The interactive picker overwrites SELECTED_ENGINE, so document the flag combination that reaches this branch. * fix(asr): pin Parakeet bundles and report one progress bar Pin each bundle to a Hugging Face commit instead of resolve/main, so a file replaced upstream cannot change what an install downloads. This is the same approach whispercpp_model_info takes with its pinned revision. Remap each file's progress into its slice of the bundle. The four files were reported through the single-file downloader independently, so the fraction ran to 1.0 and restarted four times; it now advances once and names the file being fetched. * fix(asr): reject a truncated Parakeet download _stream_model_download only rejects an empty body, so a truncated but nonempty response was renamed into the model directory and then reported as installed, because is_model_downloaded checks existence. Record the byte size of each file at the pinned revision and check it before the rename, the same order whisper.cpp verifies its digest in. A short file is a truncated transfer rather than an upstream change, since the revision is pinned, so it raises and the temp file is removed. * style(asr): annotate the Parakeet model catalog mypy inferred the catalog values as object, so reading file_sizes out of an entry raised attr-defined and no-any-return under `just typecheck`. * fix(asr): pin Parakeet digests in model_checksums.txt AGENTS.md requires every speech model to be verified against a pinned digest before install, failing closed on an unpinned one. Parakeet was checking only the byte size. Teach scripts/generate-model-checksums.py to emit Parakeet entries: the ONNX files are LFS so Hugging Face publishes their sha256, and tokens.txt is hashed from the downloaded bytes the way VOSK archives are. Several bundles ship the same file names, so a key carries the model name. _download_parakeet_model now calls verify_model_file before the rename and catches ChecksumError alongside the other download failures, matching the whisper.cpp downloader. * fix(asr): verify on-disk Parakeet models before load Existing bundle files were trusted by path alone, so a leftover or copied-in tree could reach sherpa-onnx unchecked. Hash each file against its pin on download skip and on init, and cover the Parakeet pins in CI. * fix(ui): hide Parakeet language picker that ignores the setting Parakeet language coverage is the model choice, not a catalog language. Hide the picker and persist auto so Settings does not write a lying value. * fix(asr): normalize Parakeet language to auto at startup CLI --language and stale saved languages still reached SpeechRecognitionManager even though Parakeet ignores catalog language. Force auto on the shared helper used by main and the manager so stored settings stay honest. * fix(ui): restore language after leaving Parakeet Remember the prior Whisper/cpp language across a Parakeet visit so switching back does not leave recognition stuck on auto. Keep Parakeet forced to auto and the picker hidden. Also satisfy black on the startup normalizer path. * fix(ui): keep language memory across Vosk fallback Vosk's unsupported-language en-us coercion was writing into _last_non_parakeet_language, so Whisper→Parakeet→Vosk→Whisper restored en-us instead of the user's Greek (or other) preference. Remember the pre-coercion catalog language and prefer it when leaving Vosk. * fix(ui): keep language memory when entering Parakeet from Vosk Vosk's coerced en-us fallback was still written into _last_non_parakeet_language on Parakeet entry, so Vosk→Parakeet→Whisper restored en-us instead of the pre-coercion preference (Greek or auto). Share one coerced-fallback predicate across entry, leave-Vosk, and language sync, and cover those paths with regression tests. * fix(tests): set initializing flags false on language-change stubs Plain Mock attributes are truthy and were short-circuiting the handler. --------- Co-authored-by: Alex Thunder <AlexThunder@gmail.com>
291 lines
11 KiB
Python
291 lines
11 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 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 _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 _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 or parakeet_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 bundles are pinned per model in parakeet_model_info.py, 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")
|
|
|
|
MANIFEST.write_text(
|
|
_render(whispercpp_entries + vosk_entries + parakeet_entries, revision), encoding="utf-8"
|
|
)
|
|
print(f"Wrote {MANIFEST.relative_to(REPO_ROOT)}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|