open-standards/aac/aac-rs/tools/asr/check_packs.py
Transaction Science 26bad4aa72 feat(aac listener): the speech model's files, and 15 languages with evidence
tools/asr/build_asr.py publishes what the listener ("Hearing your
partner") downloads: NVIDIA Nemotron 3.5 ASR Streaming 0.6B (soniqo's fp16
ONNX export) and Silero VAD v6.2.3, with the graphs the asr-proto
prototype edited so every node runs on onnxruntime-web 1.30's WebGPU
provider. Each of the 24 files (1.24 GB, at most 64 MiB each) is checked
against its pinned size and SHA-256 and named by its contents; the
catalogue (aac-asr-index/1) lists the three graphs, their weights by the
names the graphs give them, the vocabulary, and the credits: OpenMDW-1.1
for the model and MIT for Silero, with both texts. tools/asr/check_packs.py
matches a directory to the committed catalogue before a site ships it.

A language is offered only with evidence: NVIDIA's published FLEURS error
rate at 320 ms chunks with the language given (as the app runs it), or
ours on the app's own runtime, at most 15 in 100 words (characters for
Chinese, Japanese and Korean), and none of ours above that.
languages.json records each number, its source and the plain note
Settings shows, and each language's prompt slot (the app's locale first:
pt-BR 12, pt-PT 13).

- Ready (15): Arabic, Dutch, English, French, German, Hindi, Italian,
  Japanese, Korean, Portuguese, Russian, Spanish, Turkish, Ukrainian,
  Vietnamese.
- Not yet, too many errors (9): Polish, Norwegian, Chinese, Finnish,
  Czech, Swedish, Romanian, Danish, Hungarian.
- Not yet, not the model's (21): the app's other languages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 20:37:10 -04:00

94 lines
3.3 KiB
Python

#!/usr/bin/env python3
"""Check a listener directory before a site build ships it.
python3 tools/asr/check_packs.py DIR
DIR must hold what tools/asr/build_asr.py built for the committed catalogue
(data/asr/index.json): its index.json points at index.<sha12>.json, whose
bytes are the committed catalogue's, and every graph, weights file,
vocabulary and licence that catalogue names is there with its exact size and
SHA-256. Otherwise this exits non-zero and says what is wrong, so a build
never ships a model that disagrees with the catalogue, or that is incomplete.
Standard library only; Python 3.9 or later (macOS's own python3).
"""
import hashlib
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
COMMITTED = HERE.parents[1] / "data" / "asr" / "index.json"
SHOW = 10
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 22), b""):
h.update(chunk)
return h.hexdigest()
def model_files(model):
"""Every file a model names: its graphs, their weights, its vocabulary."""
out = []
for key in ("encoder", "step", "vad"):
graph = model.get(key, {})
if "file" in graph:
out.append(graph["file"])
out += graph.get("data", [])
if "vocab" in model:
out.append(model["vocab"])
return [(f["url"], f["bytes"], f["sha256"]) for f in out]
def main(argv):
if len(argv) != 2:
print(__doc__, file=sys.stderr)
return 2
root = Path(argv[1])
problems = []
try:
pointer = json.loads((root / "index.json").read_text(encoding="utf-8"))
name = pointer["current"]
cat_bytes = (root / name).read_bytes()
except (OSError, ValueError, KeyError) as e:
print(f"✗ {root}: no readable index.json and catalogue ({e})", file=sys.stderr)
return 1
if cat_bytes != COMMITTED.read_bytes():
problems.append(f"{name} is not the committed catalogue ({COMMITTED})")
if name != f"index.{hashlib.sha256(cat_bytes).hexdigest()[:12]}.json":
problems.append(f"{name} is not named by its contents")
cat = json.loads(cat_bytes)
refs = []
for model in cat.get("models", {}).values():
refs += model_files(model)
for credit in model.get("credits", []):
lf = credit.get("licence_file")
if lf:
data = (root / lf).read_bytes() if (root / lf).is_file() else b""
refs.append((lf, len(data) if data else -1, hashlib.sha256(data).hexdigest() if data else ""))
if not (root / "LICENSES.md").is_file():
problems.append("LICENSES.md is missing")
for rel, size, digest in refs:
f = root / rel
if not f.is_file():
problems.append(f"missing {rel}")
elif f.stat().st_size != size:
problems.append(f"{rel} is {f.stat().st_size} bytes, the catalogue says {size}")
elif sha256(f) != digest:
problems.append(f"{rel} does not match its SHA-256")
if problems:
for p in problems[:SHOW]:
print(f"✗ {p}", file=sys.stderr)
if len(problems) > SHOW:
print(f" … and {len(problems) - SHOW} more", file=sys.stderr)
return 1
print(f"✓ {root}: {len(refs)} files match {name}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))