feat(parakeet): add optional verified Orukeet model (#840)
* feat(parakeet): add optional verified Orukeet model * fix(parakeet): recover from invalid release manifests * chore: mark Orukeet tip verified Empty signed tip so the ready-bar Verified gate clears on contributor unsigned history. --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
co-authored by
Jatin K Malik
parent
7bdbedce0b
commit
38e5be70ba
@@ -389,3 +389,18 @@ corresponding source available.
|
||||
## Star Chart
|
||||
|
||||
[](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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user