diff --git a/README.md b/README.md index a97f9dc..fd9e6cb 100644 --- a/README.md +++ b/README.md @@ -389,3 +389,18 @@ corresponding source available. ## Star Chart [![Star History Chart](https://api.star-history.com/chart?repos=VocaHQ/vocalinux&type=date&legend=top-left&sealed_token=ZWyQQLhSORoR4mKf6UXMGFSCBXRxM_yEZgc8MFCH_ysBjaFUm_OCH-bI3TD7OivczEzm-ADRIpF9xCWFOMHvBPW95eQBxzfRMpNksChz7rN_eiqL7AIMDw)](https://www.star-history.com/?type=date&repos=VocaHQ%2Fvocalinux) + +### Optional Orukeet model + +With the Parakeet engine installed, Settings → Speech Model → Parakeet also +offers `orukeet-v0.1.0`. It uses the existing local sherpa-onnx recognizer for +25 European languages; the recommended Parakeet model and default whisper.cpp +engine stay unchanged. + +The download comes from a pinned revision of +[oruk/orukeet on Hugging Face](https://huggingface.co/oruk/orukeet). Vocalinux +checks every file against its bundled SHA-256 pins and checks the publisher's +release manifest against those same pins. The manifest download participates +in Hugging Face's normal model-download accounting. Cached recognition works +offline and does not send audio or usage events. The model occupies about +641 MiB; its CC BY-SA 4.0 weights license and NVIDIA attribution are included. diff --git a/scripts/generate-model-checksums.py b/scripts/generate-model-checksums.py index 7c26f37..ea365ac 100644 --- a/scripts/generate-model-checksums.py +++ b/scripts/generate-model-checksums.py @@ -132,9 +132,10 @@ def _parakeet_entries() -> list[Entry]: """ sys.path.insert(0, str(SRC)) from vocalinux.utils.parakeet_model_info import ( - MODEL_FILES, PARAKEET_MODEL_INFO, + get_model_file_url, manifest_key, + model_files, ) entries = [] @@ -150,12 +151,13 @@ def _parakeet_entries() -> list[Entry]: if digest: published[sibling["rfilename"]] = (digest, int(lfs.get("size", 0))) - for filename in MODEL_FILES: + for filename in model_files(model_name): key = manifest_key(model_name, filename) - if filename in published: - digest, size = published[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 = f"https://huggingface.co/{repo}/resolve/{revision}/{filename}?download=true" + 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)) diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index dc48a9c..82e96c7 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -2338,7 +2338,7 @@ class SpeechRecognitionManager: # its own slice of the bundle: the bar advances once across the whole # download instead of restarting for each of the four files. outer_callback = self._download_progress_callback - total_files = len(parakeet.MODEL_FILES) + total_files = len(parakeet.model_files(self.model_size)) def file_progress(index: int, name: str) -> Callable[[float, float, str], None]: def report(fraction: float, speed: float, status: str) -> None: @@ -2353,7 +2353,7 @@ class SpeechRecognitionManager: temp_file = None try: - for index, filename in enumerate(parakeet.MODEL_FILES): + for index, filename in enumerate(parakeet.model_files(self.model_size)): dest_path = os.path.join(model_dir, filename) key = parakeet.manifest_key(self.model_size, filename) # Existence is not enough: a leftover or copied-in file must @@ -2392,6 +2392,8 @@ class SpeechRecognitionManager: os.rename(temp_file, dest_path) temp_file = None + self._validate_parakeet_release_manifest(self.model_size, model_dir) + # RequestException=Exception under the test mocks; do not catch # Timeout separately (it is not a real exception type there). except requests.exceptions.RequestException as e: @@ -2417,6 +2419,17 @@ class SpeechRecognitionManager: if self._download_progress_callback: self._download_progress_callback(1.0, 0, "Complete!") + @staticmethod + def _validate_parakeet_release_manifest(model_size: str, model_dir: str) -> None: + """Discard failed publisher metadata so retries can reuse verified weights.""" + try: + parakeet.validate_release_manifest(model_size, model_dir) + except ChecksumError: + filename = parakeet.PARAKEET_MODEL_INFO[model_size].get("manifest") + if filename: + os.remove(os.path.join(model_dir, filename)) + raise + @staticmethod def _parakeet_model_is_verified(model_size: str, model_dir: str) -> bool: """Hash each bundle file against its pin. Delete only a digest/size mismatch. @@ -2425,7 +2438,7 @@ class SpeechRecognitionManager: so the caller does not hand the files to sherpa-onnx. """ verified = True - for filename in parakeet.MODEL_FILES: + for filename in parakeet.model_files(model_size): path = os.path.join(model_dir, filename) key = parakeet.manifest_key(model_size, filename) try: @@ -2441,6 +2454,12 @@ class SpeechRecognitionManager: logger.error("Could not remove %s: %s", path, remove_error) return False logger.info("Removed the unverified model file; it will be downloaded again") + if verified: + try: + SpeechRecognitionManager._validate_parakeet_release_manifest(model_size, model_dir) + except (ChecksumError, OSError, ValueError) as error: + logger.error("Parakeet release manifest verification failed: %s", error) + return False return verified def _init_parakeet(self) -> None: @@ -2509,7 +2528,7 @@ class SpeechRecognitionManager: logger.error("Please install it with 'pip install sherpa-onnx'") self.state = RecognitionState.ERROR raise - except (FileNotFoundError, RuntimeError, OSError) as e: + except (ChecksumError, FileNotFoundError, RuntimeError, OSError) as e: logger.error(f"Failed to initialize Parakeet engine: {e}", exc_info=True) self.state = RecognitionState.ERROR raise diff --git a/src/vocalinux/utils/model_checksums.txt b/src/vocalinux/utils/model_checksums.txt index 215e83f..1049d44 100644 --- a/src/vocalinux/utils/model_checksums.txt +++ b/src/vocalinux/utils/model_checksums.txt @@ -89,6 +89,14 @@ vosk-model-small-uk-v3-small.zip sha256 ee05b6a8c796c53c61cd1c vosk-model-small-vn-0.4.zip sha256 efe5c8494212110471a79befc48c79da679e5b1fc52a4ffb500222ff86d622e5 33656337 vosk-model-uk-v3.zip sha256 b1a0dbb9a19bfb6cdbad5bb4d43b71281ab57ad4a8c2a372656c83b994b72b1f 371048965 vosk-model-vn-0.4.zip sha256 a8ce6ee52e369b8f23f56e87e037db10322e6d94779b08d5e1ab21cb4b737380 74423562 +parakeet-orukeet-v0.1.0-manifest.json sha256 7e80f93f0e9b923c392424b0f85d28a717feee0a4d2a6aa9bfa723693868e727 1867 +parakeet-orukeet-v0.1.0-encoder.int8.onnx sha256 7b55f2a504a20a8e462899f5befd45f4a1784948d76ed0127902d9cf39405487 653182378 +parakeet-orukeet-v0.1.0-decoder.int8.onnx sha256 c185c2afb4c77c94bb1314807ecb3dc1623057a3dc540b83e10301af9bf4cfca 11845332 +parakeet-orukeet-v0.1.0-joiner.int8.onnx sha256 1a7e90abf7172d926dd7e2edac2a5d5035c24dfb641a15e131d57b6a5f63cdd3 6355335 +parakeet-orukeet-v0.1.0-tokens.txt sha256 d58544679ea4bc6ac563d1f545eb7d474bd6cfa467f0a6e2c1dc1c7d37e3c35d 93939 +parakeet-orukeet-v0.1.0-bpe.vocab sha256 41d5e71b3591642eff088151efd7acd4e750124cc0054c8ba9fa3245187a4804 117408 +parakeet-orukeet-v0.1.0-LICENSE-WEIGHTS sha256 23ee78c8bae49cf08ea2f0c84945c66b987ebe4520881fb51b3dad4fb43d07c2 20137 +parakeet-orukeet-v0.1.0-NOTICE.md sha256 440361d963edd9621e744f251332b47f2c4de2e2594ecfe42b215e3f6223fa44 5271 parakeet-v3-european-encoder.int8.onnx sha256 acfc2b4456377e15d04f0243af540b7fe7c992f8d898d751cf134c3a55fd2247 652184281 parakeet-v3-european-decoder.int8.onnx sha256 179e50c43d1a9de79c8a24149a2f9bac6eb5981823f2a2ed88d655b24248db4e 11845275 parakeet-v3-european-joiner.int8.onnx sha256 3164c13fc2821009440d20fcb5fdc78bff28b4db2f8d0f0b329101719c0948b3 6355277 diff --git a/src/vocalinux/utils/parakeet_model_info.py b/src/vocalinux/utils/parakeet_model_info.py index 2d15d79..617bedd 100644 --- a/src/vocalinux/utils/parakeet_model_info.py +++ b/src/vocalinux/utils/parakeet_model_info.py @@ -5,6 +5,7 @@ This module provides model metadata and download URLs for the Parakeet engine, which runs NVIDIA NeMo ASR models through sherpa-onnx. """ +import json import logging import os import shutil @@ -22,6 +23,24 @@ logger = logging.getLogger(__name__) _HF_BASE_URL = "https://huggingface.co" PARAKEET_MODEL_INFO: Dict[str, Dict[str, Any]] = { + "orukeet-v0.1.0": { + "repo": "oruk/orukeet", + "revision": "eac739d754bb171287930e6e63386f5b88f8179e", + "subdir": "onnx/sherpa-v0.1.0-int8", + "manifest": "manifest.json", + "files": [ + "manifest.json", + "encoder.int8.onnx", + "decoder.int8.onnx", + "joiner.int8.onnx", + "tokens.txt", + "bpe.vocab", + "LICENSE-WEIGHTS", + "NOTICE.md", + ], + "size_mb": 641, + "desc": "Orukeet v0.1.0 (int8), 25 European languages; CC BY-SA 4.0", + }, "v3-european": { "repo": "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", "revision": "2bda32ec70b097a55adaa07d9a7173915b43cc78", @@ -36,7 +55,7 @@ PARAKEET_MODEL_INFO: Dict[str, Dict[str, Any]] = { }, } -MODEL_SIZES = ["v3-european", "v2-english"] +MODEL_SIZES = ["v3-european", "v2-english", "orukeet-v0.1.0"] # Model offered when the engine has nothing downloaded yet. RECOMMENDED_MODEL = "v3-european" @@ -73,7 +92,7 @@ def is_model_downloaded(model_name: str) -> bool: True if model exists, False otherwise """ model_path = get_model_path(model_name) - return all(os.path.exists(os.path.join(model_path, f)) for f in MODEL_FILES) + return all(os.path.exists(os.path.join(model_path, f)) for f in model_files(model_name)) def list_downloaded_models() -> list[str]: @@ -134,7 +153,41 @@ def get_model_file_url(model_name: str, filename: str) -> str: if not model_info: raise ValueError(f"Unknown Parakeet model: {model_name}") + relative = "/".join(part for part in (model_info.get("subdir", ""), filename) if part) return ( f"{_HF_BASE_URL}/{model_info['repo']}/resolve/{model_info['revision']}" - f"/{filename}?download=true" + f"/{relative}?download=true" ) + + +def model_files(model_name: str) -> list[str]: + """Return this model's runtime files, release manifest, and license notices.""" + return list(PARAKEET_MODEL_INFO[model_name].get("files", MODEL_FILES)) + + +def validate_release_manifest(model_name: str, model_dir: str) -> None: + """Check the downloaded publisher manifest against the app's pinned files.""" + from .model_checksums import ChecksumError, expected_for + + filename = PARAKEET_MODEL_INFO[model_name].get("manifest") + if not filename: + return + try: + with open(os.path.join(model_dir, filename), encoding="utf-8") as source: + manifest = json.load(source) + published = {record["path"]: record for record in manifest["files"]} + for name in model_files(model_name): + if name == filename: + continue + expected = expected_for(manifest_key(model_name, name)) + record = published.get(name) + if ( + expected is None + or record is None + or expected.algo != "sha256" + or expected.digest != record["sha256"] + or expected.size != record["bytes"] + ): + raise ChecksumError(f"Release manifest disagrees with pinned Parakeet file: {name}") + except (KeyError, TypeError, ValueError) as error: + raise ChecksumError(f"Malformed Parakeet release manifest: {filename}") from error diff --git a/tests/test_model_checksums.py b/tests/test_model_checksums.py index 4cd7bd2..0139e53 100644 --- a/tests/test_model_checksums.py +++ b/tests/test_model_checksums.py @@ -34,7 +34,7 @@ from vocalinux.utils.model_checksums import ( whispercpp_revision, write_verification_stamp, ) -from vocalinux.utils.parakeet_model_info import MODEL_FILES, PARAKEET_MODEL_INFO, manifest_key +from vocalinux.utils.parakeet_model_info import PARAKEET_MODEL_INFO, manifest_key, model_files from vocalinux.utils.vosk_model_info import VOSK_MODEL_INFO from vocalinux.utils.whispercpp_model_info import ( WHISPERCPP_MODEL_INFO, @@ -84,7 +84,7 @@ class TestManifestCoverage(unittest.TestCase): missing = [ manifest_key(name, filename) for name in PARAKEET_MODEL_INFO - for filename in MODEL_FILES + for filename in model_files(name) if manifest_key(name, filename) not in pinned ] self.assertEqual( diff --git a/tests/test_orukeet.py b/tests/test_orukeet.py new file mode 100644 index 0000000..df5dfed --- /dev/null +++ b/tests/test_orukeet.py @@ -0,0 +1,63 @@ +"""Optional Orukeet catalog and publisher-manifest verification.""" + +import json +from pathlib import Path + +import pytest + +from vocalinux.utils import parakeet_model_info as models +from vocalinux.utils.model_checksums import ChecksumError, expected_for + +MODEL = "orukeet-v0.1.0" + + +def test_orukeet_is_optional_and_keeps_license_files() -> None: + assert models.RECOMMENDED_MODEL == "v3-european" + assert models.MODEL_SIZES[:2] == ["v3-european", "v2-english"] + assert models.model_files("v3-european") == models.MODEL_FILES + assert models.model_files(MODEL)[0] == "manifest.json" + assert {"LICENSE-WEIGHTS", "NOTICE.md"} <= set(models.model_files(MODEL)) + assert models.get_model_file_url(MODEL, "encoder.int8.onnx") == ( + "https://huggingface.co/oruk/orukeet/resolve/" + "eac739d754bb171287930e6e63386f5b88f8179e/onnx/sherpa-v0.1.0-int8/" + "encoder.int8.onnx?download=true" + ) + + +def test_release_manifest_must_agree_with_every_pinned_file(tmp_path) -> None: + files = [] + for name in models.model_files(MODEL): + if name == "manifest.json": + continue + expected = expected_for(models.manifest_key(MODEL, name)) + assert expected is not None + files.append({"path": name, "sha256": expected.digest, "bytes": expected.size}) + manifest_path = tmp_path / "manifest.json" + manifest_path.write_text(json.dumps({"files": files}), encoding="utf-8") + models.validate_release_manifest(MODEL, str(tmp_path)) + files[0]["sha256"] = "0" * 64 + manifest_path.write_text(json.dumps({"files": files}), encoding="utf-8") + with pytest.raises(ChecksumError, match="disagrees"): + models.validate_release_manifest(MODEL, str(tmp_path)) + + +def test_missing_manifest_or_license_keeps_model_unavailable(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(models, "get_model_path", lambda _: str(tmp_path)) + for name in models.model_files(MODEL): + (tmp_path / name).touch() + assert models.is_model_downloaded(MODEL) + (tmp_path / "NOTICE.md").unlink() + assert not models.is_model_downloaded(MODEL) + (tmp_path / "NOTICE.md").touch() + (tmp_path / "manifest.json").unlink() + assert not models.is_model_downloaded(MODEL) + + +@pytest.mark.parametrize( + "content", + ["{", "null", "{}", '{"files": null}', '{"files": [{}]}', '{"files": [1]}'], +) +def test_malformed_release_manifest_is_a_verification_failure(tmp_path: Path, content: str) -> None: + (tmp_path / "manifest.json").write_text(content, encoding="utf-8") + with pytest.raises(ChecksumError, match="Malformed"): + models.validate_release_manifest(MODEL, str(tmp_path)) diff --git a/tests/test_recognition_manager_downloads.py b/tests/test_recognition_manager_downloads.py index 155ab67..f10cf6c 100644 --- a/tests/test_recognition_manager_downloads.py +++ b/tests/test_recognition_manager_downloads.py @@ -8,9 +8,11 @@ Key focus areas: """ import base64 +import json import os import sys import time +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -31,6 +33,7 @@ from vocalinux.utils.model_checksums import VERIFICATION_STAMP_NAME, ChecksumErr from vocalinux.utils.model_checksums import verify_model_file as verify_model_file_real from vocalinux.utils.parakeet_model_info import MODEL_FILES as PARAKEET_MODEL_FILES from vocalinux.utils.parakeet_model_info import manifest_key as parakeet_manifest_key +from vocalinux.utils.parakeet_model_info import model_files as parakeet_model_files def _make_manager(engine="whisper_cpp", **kw): @@ -804,6 +807,85 @@ class TestParakeetDownloadVerifiesExistingFiles: assert encoder.read_bytes() == b"good-enough" +class TestParakeetReleaseManifestRecovery: + """Invalid publisher metadata must leave a retryable, unloaded model.""" + + @pytest.mark.parametrize("cached", [False, True]) + @pytest.mark.parametrize("invalid_manifest", ['{"files": []}', '{"files": [{}]}']) + def test_invalid_manifest_is_removed_and_retry_reuses_weights( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + skip_checksum: MagicMock, + cached: bool, + invalid_manifest: str, + ) -> None: + from vocalinux.utils import parakeet_model_info as parakeet + + model_name = "orukeet-v0.1.0" + model_dir = tmp_path / model_name + model_dir.mkdir() + names = parakeet_model_files(model_name) + manifest_path = model_dir / "manifest.json" + records = [] + for name in names[1:]: + expected = expected_for(parakeet_manifest_key(model_name, name)) + assert expected is not None + records.append({"path": name, "sha256": expected.digest, "bytes": expected.size}) + if cached: + (model_dir / name).write_bytes(b"verified model file") + if cached: + manifest_path.write_text(invalid_manifest, encoding="utf-8") + + manager = _make_manager(engine="parakeet") + manager.model_size = model_name + manager._defer_download = cached + progress = MagicMock() + manager._download_progress_callback = progress + streamed = [] + content = invalid_manifest + + def stream(url: str, destination: str) -> None: + streamed.append(os.path.basename(destination)) + with open(destination, "wb") as target: + target.write( + content.encode() + if destination.endswith("manifest.json.tmp") + else b"verified model file" + ) + + mock_sherpa = MagicMock() + mock_requests = MagicMock() + mock_requests.exceptions.RequestException = FakeRequestError + monkeypatch.setitem(sys.modules, "sherpa_onnx", mock_sherpa) + monkeypatch.setitem(sys.modules, "requests", mock_requests) + monkeypatch.setattr(parakeet, "get_model_path", lambda _: str(model_dir)) + monkeypatch.setattr(manager, "_stream_model_download", stream) + + if cached: + manager._init_parakeet() + else: + with pytest.raises(ChecksumError): + manager._init_parakeet() + assert manager.state == RecognitionState.ERROR + assert not manager._model_initialized + mock_sherpa.OfflineRecognizer.from_transducer.assert_not_called() + assert not manifest_path.exists() + assert not parakeet.is_model_downloaded(model_name) + assert manager._download_progress_callback is progress + assert all(call.args[2] != "Complete!" for call in progress.call_args_list) + assert all((model_dir / name).read_bytes() == b"verified model file" for name in names[1:]) + + content = json.dumps({"files": records}) + manager._defer_download = False + streamed.clear() + manager._init_parakeet() + assert manager._model_initialized + assert parakeet.is_model_downloaded(model_name) + assert streamed == ["manifest.json.tmp"] + assert progress.call_args.args == (1.0, 0, "Complete!") + + class TestFasterWhisperRejectsAnUnverifiedModelOnDisk: """Wiring test: the hash has to happen where the model is picked up."""