Files
voculinux/scripts/generate-model-checksums.py
T
8f4aeca2f1 feat(asr): add Parakeet TDT 0.6B engine via sherpa-onnx (#802)
* 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>
2026-09-07 14:32:13 -07:00

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())