Import the vocalinux source tree (tray app, Next.js site in web/, packaging, installers, docs and tests) on top of the Gitea template commit. The project README replaces the template README, and the template CI files (.gitea/workflows/ci.yml, .jscpd.json, CI.md) are kept.
378 lines
15 KiB
Python
Executable File
378 lines
15 KiB
Python
Executable File
#!/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
|
|
|
|
vosk = {
|
|
name for info in VOSK_MODEL_INFO.values() for name in info["languages"].values() if name
|
|
}
|
|
return sorted(WHISPERCPP_MODEL_INFO), 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 (
|
|
PARAKEET_MODEL_INFO,
|
|
get_model_file_url,
|
|
manifest_key,
|
|
model_files,
|
|
)
|
|
|
|
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(model_name):
|
|
key = manifest_key(model_name, filename)
|
|
remote_name = "/".join(part for part in (info.get("subdir", ""), filename) if part)
|
|
if remote_name in published:
|
|
digest, size = published[remote_name]
|
|
else:
|
|
url = get_model_file_url(model_name, filename)
|
|
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 _hf_lfs_digests(repo: str, revision: str) -> dict[str, tuple[str, int]]:
|
|
"""sha256/size per LFS file in ``repo`` at ``revision``, from the HF API."""
|
|
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)))
|
|
return published
|
|
|
|
|
|
def _whispercpp_entries(wanted: Iterable[str]) -> tuple[list[Entry], str]:
|
|
sys.path.insert(0, str(SRC))
|
|
from vocalinux.utils.whispercpp_model_info import (
|
|
whispercpp_model_file,
|
|
whispercpp_model_source,
|
|
)
|
|
|
|
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)))
|
|
|
|
# Catalog entries hosted outside the main repository pin their own commit
|
|
# in whispercpp_model_info.py (same contract parakeet bundles follow).
|
|
side_repo_digests: dict[str, dict[str, tuple[str, int]]] = {}
|
|
|
|
entries, missing = [], []
|
|
for model_name in wanted:
|
|
filename = whispercpp_model_file(model_name)
|
|
repo, pinned_revision = whispercpp_model_source(model_name)
|
|
if repo != HF_REPO:
|
|
if repo not in side_repo_digests:
|
|
side_repo_digests[repo] = _hf_lfs_digests(repo, pinned_revision)
|
|
found = side_repo_digests[repo].get(filename)
|
|
if found is None:
|
|
missing.append(f"{filename} ({repo})")
|
|
continue
|
|
digest, size = found
|
|
elif filename not in by_name:
|
|
missing.append(filename)
|
|
continue
|
|
else:
|
|
digest, size = by_name[filename]
|
|
entries.append(Entry(filename, "sha256", digest, size))
|
|
|
|
if missing:
|
|
raise SystemExit(
|
|
f"Hugging Face 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."
|
|
)
|
|
entries.sort(key=lambda entry: entry.filename)
|
|
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())
|