Files
voculinux/scripts/generate-model-checksums.py
T
8ebc4ce2ba feat(diarization): per-speaker transcripts via TinyDiarize + --transcribe-file (#884)
* feat(transcribe): add TinyDiarize file transcription surface

Catalog small.en-tdrz (hosted at akashmjn/tinydiarize-whisper.cpp,
pinned commit) while keeping it out of every dictation-model selection
path: its [SPEAKER_TURN] markup would type noise into the focused
window. The checksum generator resolves side-repo models at their own
pinned revision, parakeet-style.

Add speech_recognition.diarization (strict-WAV fast path with ffmpeg
fallback, tdrz transcription, speaker-turn parsing, timestamped
formatting) and surface it through a tray Transcribe Audio File flow
with on-demand model download plus a headless --transcribe-file CLI.
TranscriptDialog offers copy and save of the diarized transcript.

Closes #756

* docs(user-guide): document --transcribe-file

* fix(transcribe): pass tdrz_enable as a pywhispercpp kwarg

pywhispercpp's Model takes decode params as keyword arguments; a
params= dict crashes on the C-level whisper_full_params object. Also
let --transcribe-file report unexpected failures cleanly instead of
dumping a traceback.

* fix(tray): dismiss the tdrz download dialog on response

* test(diarization): pin real wave/numpy/tempfile around sibling mocks

Several test modules replace sys.modules entries with MagicMocks at
import time and never restore them, so diarization's wave/numpy/
tempfile bindings resolved to mocks when this file ran after them in
CI. An autouse fixture reimports the real stdlib modules per test, the
same remedy test_audio_feedback already applies for wave.

* fix(transcribe): verify tdrz model, preload libs, gate formats on ffmpeg

- Hash the on-disk TinyDiarize model against the pinned digest before
  native code maps it, matching the dictation load path
- Extract the bundled-lib preloader to utils/pywhispercpp_loader.py and
  preload before the lazy pywhispercpp import so the headless
  --transcribe-file path works on source-built installs without RPATH
- Offer only WAV in the file picker when ffmpeg is absent; name the
  dependency in the filter label
- Type the new tray/diarization callbacks; document --transcribe-file
  and the new modules in AGENTS.md

* style(audio): finish type annotations on capture helpers and download path

_setup_alsa_error_handler now declares Optional[Any] and its ctypes
callback gets concrete parameter types; the remaining capture helpers
take audio: Any (PyAudio has no importable type) and test_audio_input's
device_index becomes Optional[int], which also clears the preexisting
implicit-Optional error. _download_whispercpp_model gains -> None and
its HF url is narrowed to str so the concatenation and download call
stop adding new mypy findings.

* style(tests): wrap long monkeypatch line in preload test

* refactor(main): narrow transcribe-file errors and add file context

* style(ui): annotate TranscriptDialog constructor

* fix(diarize): log worker tracebacks, isort test helpers, document --transcribe-file

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 00:44:55 -07:00

378 lines
15 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
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())