feat(asr): add Parakeet TDT 0.6B engine via sherpa-onnx (#802)
* feat(asr): add Parakeet engine via sherpa-onnx Adds a "parakeet" engine backed by sherpa-onnx, running NVIDIA Parakeet TDT 0.6B int8 models on CPU: v3 (25 European languages, auto-detected) and v2 (English). Output is punctuated and capitalized. On an Intel Core Ultra 7 155H it decodes at about 12x realtime on CPU (RTF 0.08), roughly 8x faster than whisper.cpp large-v3-turbo over the same clips with its Vulkan GPU backend (RTF 0.59). Being a transducer it also has no 30s window to pad, so a short push-to-talk utterance returns in ~0.3s instead of ~2.4s. Follows the existing engine layout: utils/parakeet_model_info.py holds the catalog and the get_model_path / is_model_downloaded / list_downloaded_models / delete_model helpers, while _init_parakeet / _transcribe_with_parakeet / _download_parakeet_model follow the same shape as their whisper.cpp counterparts and reuse _stream_model_download for progress and cancellation. Registered in the engine dispatch, the settings engine/model/language pickers, the model info card, the download prompts, the unused-downloads list and install.sh. Models download to <models_dir>/parakeet/<model>/ via temp files renamed on success; is_model_downloaded requires all four files, so an interrupted download is retried rather than loaded. Requires the new "parakeet" extra (sherpa-onnx). * fix(install): write only the engine key for parakeet The parakeet branch duplicated a full default config.json. ConfigManager deep-merges DEFAULT_CONFIG on load, so every other key was redundant: a config holding only "engine": "parakeet" still resolves parakeet_model_size to v3-european along with the VAD, shortcut and text-injection defaults. * fix(install): keep push-to-talk defaults and switch existing configs Writing only the engine key left config.json without a shortcuts section, which _migrate_shortcuts_config reads as a pre-push-to-talk upgrade and pins back to ctrl+ctrl/toggle - contradicting the installer's own "Hold Right Alt while you speak" message. Seed that section too. Point the config at parakeet with set_configured_engine rather than only on first create, so --engine=parakeet also switches a machine that already has a config.json, and give print_welcome_message a parakeet arm so the summary is not blank. The interactive picker overwrites SELECTED_ENGINE, so document the flag combination that reaches this branch. * fix(asr): pin Parakeet bundles and report one progress bar Pin each bundle to a Hugging Face commit instead of resolve/main, so a file replaced upstream cannot change what an install downloads. This is the same approach whispercpp_model_info takes with its pinned revision. Remap each file's progress into its slice of the bundle. The four files were reported through the single-file downloader independently, so the fraction ran to 1.0 and restarted four times; it now advances once and names the file being fetched. * fix(asr): reject a truncated Parakeet download _stream_model_download only rejects an empty body, so a truncated but nonempty response was renamed into the model directory and then reported as installed, because is_model_downloaded checks existence. Record the byte size of each file at the pinned revision and check it before the rename, the same order whisper.cpp verifies its digest in. A short file is a truncated transfer rather than an upstream change, since the revision is pinned, so it raises and the temp file is removed. * style(asr): annotate the Parakeet model catalog mypy inferred the catalog values as object, so reading file_sizes out of an entry raised attr-defined and no-any-return under `just typecheck`. * fix(asr): pin Parakeet digests in model_checksums.txt AGENTS.md requires every speech model to be verified against a pinned digest before install, failing closed on an unpinned one. Parakeet was checking only the byte size. Teach scripts/generate-model-checksums.py to emit Parakeet entries: the ONNX files are LFS so Hugging Face publishes their sha256, and tokens.txt is hashed from the downloaded bytes the way VOSK archives are. Several bundles ship the same file names, so a key carries the model name. _download_parakeet_model now calls verify_model_file before the rename and catches ChecksumError alongside the other download failures, matching the whisper.cpp downloader. * fix(asr): verify on-disk Parakeet models before load Existing bundle files were trusted by path alone, so a leftover or copied-in tree could reach sherpa-onnx unchecked. Hash each file against its pin on download skip and on init, and cover the Parakeet pins in CI. * fix(ui): hide Parakeet language picker that ignores the setting Parakeet language coverage is the model choice, not a catalog language. Hide the picker and persist auto so Settings does not write a lying value. * fix(asr): normalize Parakeet language to auto at startup CLI --language and stale saved languages still reached SpeechRecognitionManager even though Parakeet ignores catalog language. Force auto on the shared helper used by main and the manager so stored settings stay honest. * fix(ui): restore language after leaving Parakeet Remember the prior Whisper/cpp language across a Parakeet visit so switching back does not leave recognition stuck on auto. Keep Parakeet forced to auto and the picker hidden. Also satisfy black on the startup normalizer path. * fix(ui): keep language memory across Vosk fallback Vosk's unsupported-language en-us coercion was writing into _last_non_parakeet_language, so Whisper→Parakeet→Vosk→Whisper restored en-us instead of the user's Greek (or other) preference. Remember the pre-coercion catalog language and prefer it when leaving Vosk. * fix(ui): keep language memory when entering Parakeet from Vosk Vosk's coerced en-us fallback was still written into _last_non_parakeet_language on Parakeet entry, so Vosk→Parakeet→Whisper restored en-us instead of the pre-coercion preference (Greek or auto). Share one coerced-fallback predicate across entry, leave-Vosk, and language sync, and cover those paths with regression tests. * fix(tests): set initializing flags false on language-change stubs Plain Mock attributes are truthy and were short-circuiting the handler. --------- Co-authored-by: Alex Thunder <AlexThunder@gmail.com>
This commit is contained in:
co-authored by
Alex Thunder
parent
946e19ee41
commit
8f4aeca2f1
@@ -239,6 +239,7 @@ cd vocalinux
|
||||
# Or pick the engine up front
|
||||
./install.sh --engine=whisper_cpp # whisper.cpp (default, GPU-accelerated)
|
||||
./install.sh --engine=vosk # lightweight VOSK
|
||||
./install.sh --engine=parakeet --auto # Parakeet (25 European languages, CPU)
|
||||
```
|
||||
|
||||
The installer handles everything: system dependencies, Python environment, speech models, and desktop integration.
|
||||
|
||||
+35
-1
@@ -365,7 +365,7 @@ while [[ $# -gt 0 ]]; do
|
||||
echo "Options:"
|
||||
echo " --interactive, -i Force interactive mode (default)"
|
||||
echo " --auto Non-interactive automatic installation"
|
||||
echo " --engine=NAME Speech engine: whisper_cpp (default), whisper, vosk, remote_api"
|
||||
echo " --engine=NAME Speech engine: whisper_cpp (default), whisper, vosk, parakeet, remote_api"
|
||||
echo " --dev Install in development mode with all dev dependencies"
|
||||
echo " --test Run tests after installation"
|
||||
echo " --venv-dir=PATH Specify custom virtual environment directory"
|
||||
@@ -2913,6 +2913,7 @@ engine_import_module() {
|
||||
vosk) echo "vosk" ;;
|
||||
whisper) echo "whisper" ;;
|
||||
whisper_cpp) echo "pywhispercpp.model" ;;
|
||||
parakeet) echo "sherpa_onnx" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
@@ -2922,6 +2923,7 @@ engine_pip_name() {
|
||||
vosk) echo "vosk" ;;
|
||||
whisper) echo "openai-whisper" ;;
|
||||
whisper_cpp) echo "pywhispercpp" ;;
|
||||
parakeet) echo "sherpa-onnx" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
@@ -3512,6 +3514,34 @@ VOSK_CONFIG
|
||||
fi
|
||||
;;
|
||||
|
||||
parakeet)
|
||||
print_info "Installing Parakeet (sherpa-onnx)..."
|
||||
print_info "Parakeet runs NVIDIA NeMo ASR models on CPU."
|
||||
|
||||
# sherpa-onnx is an optional extra; install it alongside the base package
|
||||
pip_install_extras_skip_pygobject "$PIP_LOG_FILE" parakeet || {
|
||||
print_error "Failed to install the parakeet engine"
|
||||
return 1
|
||||
}
|
||||
|
||||
# config_manager merges the remaining defaults, but a file with no
|
||||
# shortcuts section reads as a pre-push-to-talk config and gets
|
||||
# pinned back to ctrl+ctrl/toggle, so seed that section too.
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
if [ ! -f "$CONFIG_DIR/config.json" ]; then
|
||||
cat > "$CONFIG_DIR/config.json" << 'PARAKEET_CONFIG'
|
||||
{
|
||||
"shortcuts": {
|
||||
"toggle_recognition": "right_alt+right_alt",
|
||||
"mode": "push_to_talk"
|
||||
}
|
||||
}
|
||||
PARAKEET_CONFIG
|
||||
fi
|
||||
set_configured_engine "$CONFIG_DIR/config.json" parakeet ||
|
||||
print_warning "Could not point $CONFIG_DIR/config.json at the parakeet engine."
|
||||
;;
|
||||
|
||||
remote_api)
|
||||
print_info "Setting up Remote API engine..."
|
||||
print_info ""
|
||||
@@ -4593,6 +4623,10 @@ EOF
|
||||
ENGINE_DISPLAY_NAME="VOSK"
|
||||
BACKEND_INFO="Lightweight"
|
||||
;;
|
||||
parakeet)
|
||||
ENGINE_DISPLAY_NAME="Parakeet"
|
||||
BACKEND_INFO="CPU"
|
||||
;;
|
||||
remote_api)
|
||||
ENGINE_DISPLAY_NAME="Remote API"
|
||||
BACKEND_INFO="Network (offloaded to remote server)"
|
||||
|
||||
@@ -57,6 +57,9 @@ whisper = [
|
||||
"torch>=2.0.0",
|
||||
"torchaudio>=2.0.0",
|
||||
]
|
||||
parakeet = [
|
||||
"sherpa-onnx>=1.12.0",
|
||||
]
|
||||
vad = [
|
||||
"onnxruntime>=1.16.0",
|
||||
]
|
||||
|
||||
@@ -122,6 +122,46 @@ def _model_names() -> tuple[list[str], list[str]]:
|
||||
return sorted(whispercpp), 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 (
|
||||
MODEL_FILES,
|
||||
PARAKEET_MODEL_INFO,
|
||||
manifest_key,
|
||||
)
|
||||
|
||||
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:
|
||||
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 _whispercpp_entries(wanted: Iterable[str]) -> tuple[list[Entry], str]:
|
||||
payload = _fetch_json(HF_API)
|
||||
revision = payload.get("sha")
|
||||
@@ -192,7 +232,7 @@ def _render(entries: list[Entry], revision: str) -> str:
|
||||
"#",
|
||||
"# 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 or whispercpp_model_info.py.",
|
||||
"# vosk_model_info.py, whispercpp_model_info.py or parakeet_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.",
|
||||
@@ -206,6 +246,9 @@ def _render(entries: list[Entry], revision: str) -> str:
|
||||
"#",
|
||||
"# 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 bundles are pinned per model in parakeet_model_info.py, so their",
|
||||
"# keys carry the model name: several bundles ship the same file names.",
|
||||
"",
|
||||
]
|
||||
for entry in entries:
|
||||
@@ -233,7 +276,12 @@ def main() -> int:
|
||||
vosk_entries = _vosk_entries(vosk_names, args.refresh)
|
||||
print(f" VOSK: {len(vosk_entries)} sha256 digests")
|
||||
|
||||
MANIFEST.write_text(_render(whispercpp_entries + vosk_entries, revision), encoding="utf-8")
|
||||
parakeet_entries = _parakeet_entries()
|
||||
print(f" Parakeet: {len(parakeet_entries)} sha256 digests")
|
||||
|
||||
MANIFEST.write_text(
|
||||
_render(whispercpp_entries + vosk_entries + parakeet_entries, revision), encoding="utf-8"
|
||||
)
|
||||
print(f"Wrote {MANIFEST.relative_to(REPO_ROOT)}")
|
||||
return 0
|
||||
|
||||
|
||||
+11
-1
@@ -82,7 +82,7 @@ def parse_arguments():
|
||||
parser.add_argument(
|
||||
"--engine",
|
||||
type=str,
|
||||
choices=["vosk", "whisper", "whisper_cpp", "remote_api"],
|
||||
choices=["vosk", "whisper", "whisper_cpp", "parakeet", "remote_api"],
|
||||
help="Speech recognition engine to use (whisper_cpp recommended for best performance)",
|
||||
)
|
||||
parser.add_argument("--wayland", action="store_true", help="Force Wayland compatibility mode")
|
||||
@@ -394,6 +394,16 @@ def main():
|
||||
language = saved_settings.get("language", args.language)
|
||||
logger.info(f"Using language={language} (from saved config)")
|
||||
|
||||
# Parakeet coverage is the model, not a catalog language. Normalize after
|
||||
# CLI vs saved resolution so --language / stale config cannot leave an
|
||||
# unused value on SpeechRecognitionManager.
|
||||
resolved_language = language
|
||||
language = recognition_manager.normalize_language_for_engine(engine, language)
|
||||
if language != resolved_language:
|
||||
logger.info(
|
||||
"Parakeet ignores catalog language; using language=auto " f"(was {resolved_language})"
|
||||
)
|
||||
|
||||
if cli_model_set:
|
||||
model_size = args.model
|
||||
logger.info(f"Using model={model_size} (from command line)")
|
||||
|
||||
@@ -21,6 +21,7 @@ from typing import Callable, Optional
|
||||
|
||||
from ..common_types import RecognitionState
|
||||
from ..ui.audio_feedback import play_error_sound, play_start_sound, play_stop_sound
|
||||
from ..utils import parakeet_model_info as parakeet
|
||||
from ..utils.host_process import host_env
|
||||
from ..utils.model_checksums import (
|
||||
ChecksumError,
|
||||
@@ -84,6 +85,18 @@ def resolve_whisper_language(language: str) -> Optional[str]:
|
||||
return language
|
||||
|
||||
|
||||
def normalize_language_for_engine(engine: str, language: str) -> str:
|
||||
"""Return the language the given engine actually consumes.
|
||||
|
||||
Parakeet coverage is the model (v2-english vs v3-european), not a catalog
|
||||
language. Any leftover CLI or saved code is dropped so callers cannot store
|
||||
an unconsumed value.
|
||||
"""
|
||||
if engine == "parakeet":
|
||||
return "auto"
|
||||
return language
|
||||
|
||||
|
||||
# ALSA error handler to suppress warnings during PyAudio initialization
|
||||
def _setup_alsa_error_handler():
|
||||
"""Set up an error handler to suppress ALSA warnings."""
|
||||
@@ -982,7 +995,7 @@ class SpeechRecognitionManager:
|
||||
"""
|
||||
self.engine = engine
|
||||
self.model_size = model_size
|
||||
self.language = language
|
||||
self.language = normalize_language_for_engine(engine, language)
|
||||
self.stop_sound_guard_ms = kwargs.get("stop_sound_guard_ms", 200)
|
||||
self.state = RecognitionState.IDLE
|
||||
self.audio_thread = None
|
||||
@@ -1076,7 +1089,7 @@ class SpeechRecognitionManager:
|
||||
os.makedirs(MODELS_DIR, exist_ok=True)
|
||||
|
||||
logger.info(
|
||||
f"Initializing speech recognition with {engine} engine, {language} language and {model_size} model"
|
||||
f"Initializing speech recognition with {engine} engine, {self.language} language and {model_size} model"
|
||||
)
|
||||
|
||||
# Initialize the selected speech recognition engine
|
||||
@@ -1086,6 +1099,8 @@ class SpeechRecognitionManager:
|
||||
self._init_whisper()
|
||||
elif engine == "whisper_cpp":
|
||||
self._init_whispercpp()
|
||||
elif engine == "parakeet":
|
||||
self._init_parakeet()
|
||||
elif engine == "remote_api":
|
||||
self._init_remote_api()
|
||||
else:
|
||||
@@ -1763,6 +1778,216 @@ class SpeechRecognitionManager:
|
||||
logger.error(f"Error in whisper.cpp transcription: {e} ({audio_info})", exc_info=True)
|
||||
return ""
|
||||
|
||||
def _download_parakeet_model(self):
|
||||
"""Download the Parakeet model files with progress tracking."""
|
||||
import requests
|
||||
|
||||
self._download_cancelled = False
|
||||
|
||||
model_dir = parakeet.get_model_path(self.model_size)
|
||||
os.makedirs(model_dir, exist_ok=True)
|
||||
logger.info(f"Downloading Parakeet '{self.model_size}' model to {model_dir}")
|
||||
|
||||
# _stream_model_download reports 0..1 per file, so remap each file into
|
||||
# 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)
|
||||
|
||||
def file_progress(index, name):
|
||||
def report(fraction, speed, status):
|
||||
if outer_callback:
|
||||
outer_callback(
|
||||
(index + fraction) / total_files,
|
||||
speed,
|
||||
f"{name} ({index + 1}/{total_files}) - {status}",
|
||||
)
|
||||
|
||||
return report
|
||||
|
||||
temp_file = None
|
||||
try:
|
||||
for index, filename in enumerate(parakeet.MODEL_FILES):
|
||||
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
|
||||
# still match its pin. Verify, and only skip the download when
|
||||
# the digest is good.
|
||||
if os.path.exists(dest_path):
|
||||
try:
|
||||
verify_model_file(dest_path, key)
|
||||
continue
|
||||
except ChecksumError as error:
|
||||
logger.error(
|
||||
"Existing Parakeet model file at %s is not trustworthy: %s",
|
||||
dest_path,
|
||||
error,
|
||||
)
|
||||
if expected_for(key) is None:
|
||||
raise
|
||||
try:
|
||||
os.remove(dest_path)
|
||||
except OSError as remove_error:
|
||||
logger.error("Could not remove %s: %s", dest_path, remove_error)
|
||||
raise
|
||||
logger.info(
|
||||
"Removed the unverified model file; it will be downloaded again"
|
||||
)
|
||||
temp_file = dest_path + ".tmp"
|
||||
url = parakeet.get_model_file_url(self.model_size, filename)
|
||||
self._download_progress_callback = file_progress(index, filename)
|
||||
self._stream_model_download(url, temp_file)
|
||||
|
||||
# Verify before the rename, as the whisper.cpp downloader does:
|
||||
# sherpa-onnx loads these through native code, so an unverified
|
||||
# file must never reach a path is_model_downloaded() trusts.
|
||||
verify_model_file(temp_file, key)
|
||||
|
||||
os.rename(temp_file, dest_path)
|
||||
temp_file = None
|
||||
|
||||
# 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:
|
||||
logger.error(f"Failed to download the Parakeet model: {e}")
|
||||
if temp_file and os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
msg = str(e).lower()
|
||||
if "timeout" in msg or type(e).__name__ == "Timeout":
|
||||
raise RuntimeError(
|
||||
"Model download timed out (Hugging Face may be slow or unavailable). "
|
||||
"Check your network and try again."
|
||||
) from e
|
||||
raise RuntimeError(f"Failed to download Parakeet model: {e}") from e
|
||||
except (ChecksumError, OSError, RuntimeError, ValueError) as e:
|
||||
logger.error(f"An error occurred during Parakeet model download: {e}")
|
||||
if temp_file and os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
raise
|
||||
finally:
|
||||
self._download_progress_callback = outer_callback
|
||||
|
||||
logger.info("Parakeet model downloaded successfully")
|
||||
if self._download_progress_callback:
|
||||
self._download_progress_callback(1.0, 0, "Complete!")
|
||||
|
||||
@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.
|
||||
|
||||
An unpinned name is refused, not deleted. If remove fails, return False
|
||||
so the caller does not hand the files to sherpa-onnx.
|
||||
"""
|
||||
verified = True
|
||||
for filename in parakeet.MODEL_FILES:
|
||||
path = os.path.join(model_dir, filename)
|
||||
key = parakeet.manifest_key(model_size, filename)
|
||||
try:
|
||||
verify_model_file(path, key)
|
||||
except ChecksumError as error:
|
||||
logger.error("Parakeet model file at %s is not trustworthy: %s", path, error)
|
||||
verified = False
|
||||
if expected_for(key) is None:
|
||||
continue
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError as remove_error:
|
||||
logger.error("Could not remove %s: %s", path, remove_error)
|
||||
return False
|
||||
logger.info("Removed the unverified model file; it will be downloaded again")
|
||||
return verified
|
||||
|
||||
def _init_parakeet(self):
|
||||
"""Initialize the Parakeet speech recognition engine."""
|
||||
try:
|
||||
import sherpa_onnx
|
||||
|
||||
# Validate model size for Parakeet
|
||||
valid_models = list(parakeet.PARAKEET_MODEL_INFO.keys())
|
||||
if self.model_size not in valid_models:
|
||||
logger.warning(
|
||||
f"Model size '{self.model_size}' not valid for Parakeet. "
|
||||
f"Valid options: {valid_models}. Using '{parakeet.RECOMMENDED_MODEL}' instead."
|
||||
)
|
||||
self.model_size = parakeet.RECOMMENDED_MODEL
|
||||
|
||||
model_dir = parakeet.get_model_path(self.model_size)
|
||||
|
||||
# A bundle that is merely present did not necessarily come through
|
||||
# the download path: files could be copied in, or left over from a
|
||||
# cancelled download. sherpa-onnx loads these through native code,
|
||||
# so hash them here; a failure demotes the bundle to "not downloaded".
|
||||
if parakeet.is_model_downloaded(
|
||||
self.model_size
|
||||
) and not self._parakeet_model_is_verified(self.model_size, model_dir):
|
||||
if parakeet.is_model_downloaded(self.model_size):
|
||||
logger.error(
|
||||
"Refusing to load unverified Parakeet model at %s",
|
||||
model_dir,
|
||||
)
|
||||
self._model_initialized = False
|
||||
if self._defer_download:
|
||||
return
|
||||
raise RuntimeError(f"Parakeet model at {model_dir} failed verification")
|
||||
|
||||
if not parakeet.is_model_downloaded(self.model_size):
|
||||
if self._defer_download:
|
||||
logger.info(
|
||||
f"Parakeet model '{self.model_size}' not found at {model_dir}. "
|
||||
"Will download when needed."
|
||||
)
|
||||
self._model_initialized = False
|
||||
return # Don't block startup
|
||||
else:
|
||||
logger.info(f"Downloading Parakeet '{self.model_size}' model...")
|
||||
self._download_parakeet_model()
|
||||
|
||||
# NOTE: do not take _model_lock here. It is a non-reentrant Lock and
|
||||
# reconfigure()/reinitialize_after_resume() already hold it when they
|
||||
# call this, so acquiring it would deadlock the caller.
|
||||
self.model = None
|
||||
self.model = sherpa_onnx.OfflineRecognizer.from_transducer(
|
||||
encoder=os.path.join(model_dir, "encoder.int8.onnx"),
|
||||
decoder=os.path.join(model_dir, "decoder.int8.onnx"),
|
||||
joiner=os.path.join(model_dir, "joiner.int8.onnx"),
|
||||
tokens=os.path.join(model_dir, "tokens.txt"),
|
||||
model_type="nemo_transducer",
|
||||
feature_dim=128,
|
||||
num_threads=4,
|
||||
)
|
||||
self._model_initialized = True
|
||||
logger.info(f"Loaded Parakeet model from {model_dir}")
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import sherpa-onnx: {e}")
|
||||
logger.error("Please install it with 'pip install sherpa-onnx'")
|
||||
self.state = RecognitionState.ERROR
|
||||
raise
|
||||
except (FileNotFoundError, RuntimeError, OSError) as e:
|
||||
logger.error(f"Failed to initialize Parakeet engine: {e}", exc_info=True)
|
||||
self.state = RecognitionState.ERROR
|
||||
raise
|
||||
|
||||
def _transcribe_with_parakeet(self, audio_buffer: list[bytes]) -> str:
|
||||
"""Transcribe 16-bit PCM chunks (16kHz) with Parakeet."""
|
||||
import numpy as np
|
||||
|
||||
if not audio_buffer:
|
||||
return ""
|
||||
|
||||
audio_data = np.frombuffer(b"".join(audio_buffer), dtype=np.int16)
|
||||
audio_float = audio_data.astype(np.float32) / 32768.0
|
||||
|
||||
# Lock model access to prevent a race with reconfigure() setting self.model to None
|
||||
with self._model_lock:
|
||||
if self.model is None:
|
||||
logger.warning("Model is None during transcription, returning empty result")
|
||||
return ""
|
||||
stream = self.model.create_stream()
|
||||
stream.accept_waveform(16000, audio_float)
|
||||
self.model.decode_stream(stream)
|
||||
return stream.result.text.strip()
|
||||
|
||||
def _init_remote_api(self):
|
||||
"""Initialize remote API speech recognition engine.
|
||||
|
||||
@@ -3017,6 +3242,9 @@ class SpeechRecognitionManager:
|
||||
elif self.engine == "whisper_cpp":
|
||||
text = self._transcribe_with_whispercpp(audio_buffer)
|
||||
|
||||
elif self.engine == "parakeet":
|
||||
text = self._transcribe_with_parakeet(audio_buffer)
|
||||
|
||||
elif self.engine == "remote_api":
|
||||
# Snapshot the HTTP session under lock to prevent race with
|
||||
# reconfigure() / reinitialize_after_resume() which close/recreate
|
||||
@@ -3203,6 +3431,13 @@ class SpeechRecognitionManager:
|
||||
self.language = language
|
||||
restart_needed = True
|
||||
|
||||
# Parakeet never consumes catalog language. Apply after engine/language
|
||||
# updates so switching TO parakeet also clears a leftover code.
|
||||
normalized_language = normalize_language_for_engine(self.engine, self.language)
|
||||
if normalized_language != self.language:
|
||||
self.language = normalized_language
|
||||
restart_needed = True
|
||||
|
||||
# Update VOSK specific params if provided
|
||||
if vad_sensitivity is not None:
|
||||
self.vad_sensitivity = max(1, min(5, int(vad_sensitivity)))
|
||||
@@ -3289,6 +3524,8 @@ class SpeechRecognitionManager:
|
||||
self._init_whisper()
|
||||
elif self.engine == "whisper_cpp":
|
||||
self._init_whispercpp()
|
||||
elif self.engine == "parakeet":
|
||||
self._init_parakeet()
|
||||
elif self.engine == "remote_api":
|
||||
self._init_remote_api()
|
||||
else:
|
||||
@@ -3516,6 +3753,8 @@ class SpeechRecognitionManager:
|
||||
self._init_whisper()
|
||||
elif self.engine == "whisper_cpp":
|
||||
self._init_whispercpp()
|
||||
elif self.engine == "parakeet":
|
||||
self._init_parakeet()
|
||||
elif self.engine == "remote_api":
|
||||
self._init_remote_api()
|
||||
else:
|
||||
|
||||
@@ -77,6 +77,7 @@ DEFAULT_CONFIG = {
|
||||
# the variant is then derived from the selected language, because a bare size
|
||||
# name ("medium") is indistinguishable from the multilingual variant id.
|
||||
"whisper_cpp_model_variant": "",
|
||||
"parakeet_model_size": "v3-european", # Parakeet TDT 0.6B v3 (25 European languages)
|
||||
"vad_sensitivity": 3, # Voice Activity Detection sensitivity (1-5)
|
||||
"silence_timeout": 2.0, # Seconds of silence before stopping
|
||||
"stop_sound_guard_ms": 200, # Small tail trim to avoid the stop sound without clipping speech
|
||||
|
||||
@@ -34,6 +34,7 @@ from gi.repository import Gdk, GLib, GObject, Gtk, Pango # noqa: E402
|
||||
|
||||
from ..common_types import RecognitionState # noqa: E402
|
||||
from ..speech_recognition.silero_vad import is_silero_available # noqa: E402
|
||||
from ..utils import parakeet_model_info as parakeet # noqa: E402
|
||||
from ..utils.paths import models_dir # noqa: E402
|
||||
from ..utils.update_checker import ( # noqa: E402
|
||||
DEFAULT_UPDATE_CHANNEL,
|
||||
@@ -149,6 +150,9 @@ ENGINE_MODELS = {
|
||||
"whisper_cpp": [
|
||||
*WHISPERCPP_MODEL_SIZES,
|
||||
], # whisper.cpp top-level size buckets; variants are selected separately
|
||||
"parakeet": [
|
||||
*parakeet.MODEL_SIZES,
|
||||
], # Parakeet TDT 0.6B int8 bundles
|
||||
"remote_api": [], # Remote API does not need local models
|
||||
}
|
||||
|
||||
@@ -166,6 +170,7 @@ ENGINE_DISPLAY_NAMES = {
|
||||
"vosk": "Vosk",
|
||||
"whisper": "Whisper",
|
||||
"whisper_cpp": "whisper.cpp",
|
||||
"parakeet": "Parakeet",
|
||||
"remote_api": "Remote API",
|
||||
}
|
||||
|
||||
@@ -244,6 +249,25 @@ def _language_is_english(language_id: str) -> bool:
|
||||
return SUPPORTED_LANGUAGES.get(language_id, {}).get("whisper") == "en"
|
||||
|
||||
|
||||
def _vosk_display_is_coerced_fallback(
|
||||
previous_engine: Optional[str],
|
||||
displayed: Optional[str],
|
||||
remembered: Optional[str],
|
||||
) -> bool:
|
||||
"""Return whether ``displayed`` is Vosk's en-us stand-in for ``remembered``.
|
||||
|
||||
Vosk has no auto-detect and no model for some catalog languages (e.g. Greek).
|
||||
Those selections are shown as en-us; that fallback is not a user preference
|
||||
and must not replace ``remembered`` (including auto).
|
||||
"""
|
||||
return (
|
||||
previous_engine == "vosk"
|
||||
and displayed == "en-us"
|
||||
and bool(remembered)
|
||||
and remembered != "en-us"
|
||||
)
|
||||
|
||||
|
||||
def _recommended_whispercpp_variant_for_language(
|
||||
recommended_model: str,
|
||||
reason: str,
|
||||
@@ -621,7 +645,13 @@ def get_available_engines():
|
||||
Detect which speech recognition engines are available/installed.
|
||||
Returns a dictionary of engine_name -> availability (bool).
|
||||
"""
|
||||
engines = {"vosk": False, "whisper": False, "whisper_cpp": False, "remote_api": False}
|
||||
engines = {
|
||||
"vosk": False,
|
||||
"whisper": False,
|
||||
"whisper_cpp": False,
|
||||
"parakeet": False,
|
||||
"remote_api": False,
|
||||
}
|
||||
|
||||
# Check VOSK
|
||||
try:
|
||||
@@ -647,6 +677,14 @@ def get_available_engines():
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Check Parakeet (sherpa-onnx)
|
||||
try:
|
||||
import sherpa_onnx # noqa: F401
|
||||
|
||||
engines["parakeet"] = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Remote API is always available (only requires requests package)
|
||||
try:
|
||||
import requests # noqa: F401
|
||||
@@ -1453,6 +1491,10 @@ def recommended_model_for_engine(
|
||||
elif engine == "vosk":
|
||||
model_id, reason = _get_recommended_vosk_model()
|
||||
size_mb = VOSK_MODEL_INFO.get(model_id, {}).get("size_mb", 0)
|
||||
elif engine == "parakeet":
|
||||
model_id = parakeet.RECOMMENDED_MODEL
|
||||
reason = parakeet.RECOMMENDED_REASON
|
||||
size_mb = parakeet.PARAKEET_MODEL_INFO.get(model_id, {}).get("size_mb", 0)
|
||||
else:
|
||||
# Remote API transcribes server-side; there is nothing to download.
|
||||
return None
|
||||
@@ -1892,6 +1934,12 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self._processing_language_change = (
|
||||
False # Flag to prevent recursive language change handling
|
||||
)
|
||||
# Last user catalog-language preference across Parakeet visits and across
|
||||
# engine-coerced fallbacks (e.g. Vosk forcing en-us for Greek). Parakeet
|
||||
# forces language=auto for honesty; this restores the preference when the
|
||||
# user returns to Whisper/cpp. Do not store engine-coerced fallbacks here.
|
||||
self._last_non_parakeet_language = None
|
||||
self._engine_for_language_memory = None
|
||||
self._applying_settings = False # Flag to prevent recursive settings application
|
||||
self._advanced_prompt_dirty = False
|
||||
self._about_release_url = ""
|
||||
@@ -4708,6 +4756,14 @@ class SettingsDialog(Gtk.Dialog):
|
||||
settings = self._get_current_settings()
|
||||
self.current_engine = settings["engine"]
|
||||
self.language = settings["language"]
|
||||
if self.current_engine == "parakeet":
|
||||
# Config may already be auto; keep any non-auto leftover as restore seed.
|
||||
self._last_non_parakeet_language = (
|
||||
self.language if self.language and self.language != "auto" else None
|
||||
)
|
||||
else:
|
||||
self._last_non_parakeet_language = self.language
|
||||
self._engine_for_language_memory = self.current_engine
|
||||
self.current_model_size = settings["model_size"]
|
||||
self.current_vad = settings.get("vad_sensitivity", 3)
|
||||
self.current_silence = settings.get("silence_timeout", 2.0)
|
||||
@@ -5003,9 +5059,18 @@ class SettingsDialog(Gtk.Dialog):
|
||||
def _sync_language_options_for_selected_model(self, preferred_language: Optional[str] = None):
|
||||
"""Refresh language options and keep the current model/language pair valid."""
|
||||
engine = self._get_selected_engine()
|
||||
language_to_keep = (
|
||||
preferred_language or self.language_combo.get_active_id() or self.language
|
||||
)
|
||||
if engine == "parakeet":
|
||||
# Coverage is the model (v2-english vs v3-european), not this picker.
|
||||
# Remember a non-auto preferred leftover, but never clobber memory with
|
||||
# the forced auto that follows an engine switch into Parakeet.
|
||||
if preferred_language and preferred_language != "auto":
|
||||
self._last_non_parakeet_language = preferred_language
|
||||
self.language = "auto"
|
||||
language_to_keep = "auto"
|
||||
else:
|
||||
language_to_keep = (
|
||||
preferred_language or self.language_combo.get_active_id() or self.language
|
||||
)
|
||||
|
||||
self._processing_language_change = True
|
||||
try:
|
||||
@@ -5018,9 +5083,16 @@ class SettingsDialog(Gtk.Dialog):
|
||||
fallback_language = self._default_language_for_engine(engine)
|
||||
self._set_combo_active_id_or_first(self.language_combo, fallback_language)
|
||||
|
||||
self.language = (
|
||||
self.language_combo.get_active_id() or self._default_language_for_engine(engine)
|
||||
)
|
||||
if engine == "parakeet":
|
||||
self.language = "auto"
|
||||
else:
|
||||
self.language = (
|
||||
self.language_combo.get_active_id() or self._default_language_for_engine(engine)
|
||||
)
|
||||
if self.language and not _vosk_display_is_coerced_fallback(
|
||||
engine, self.language, self._last_non_parakeet_language
|
||||
):
|
||||
self._last_non_parakeet_language = self.language
|
||||
finally:
|
||||
self._processing_language_change = False
|
||||
|
||||
@@ -5058,6 +5130,8 @@ class SettingsDialog(Gtk.Dialog):
|
||||
smallest_model = None
|
||||
if engine == "whisper":
|
||||
recommended_model, _ = _get_recommended_whisper_model()
|
||||
elif engine == "parakeet":
|
||||
recommended_model = parakeet.RECOMMENDED_MODEL
|
||||
else:
|
||||
recommended_model, _ = _get_recommended_vosk_model()
|
||||
|
||||
@@ -5069,6 +5143,9 @@ class SettingsDialog(Gtk.Dialog):
|
||||
elif engine == "vosk" and size in VOSK_MODEL_INFO:
|
||||
info = VOSK_MODEL_INFO[size]
|
||||
is_downloaded = _is_vosk_model_downloaded(size, self.language)
|
||||
elif engine == "parakeet":
|
||||
info = parakeet.PARAKEET_MODEL_INFO[size]
|
||||
is_downloaded = parakeet.is_model_downloaded(size)
|
||||
else:
|
||||
is_downloaded = False
|
||||
info = {"size_mb": 0}
|
||||
@@ -5216,6 +5293,9 @@ class SettingsDialog(Gtk.Dialog):
|
||||
size = (self.model_combo.get_active_id() or "").lower()
|
||||
language = self.language_combo.get_active_id() or self.language
|
||||
return vosk_model_dirname(size, language)
|
||||
if engine == "parakeet":
|
||||
model_id = self.model_combo.get_active_id()
|
||||
return model_id.lower() if model_id else None
|
||||
return None
|
||||
|
||||
def _list_unused_downloads(self) -> list[tuple[str, str, str]]:
|
||||
@@ -5272,6 +5352,16 @@ class SettingsDialog(Gtk.Dialog):
|
||||
model.dirname == active_id,
|
||||
)
|
||||
)
|
||||
elif engine == "parakeet":
|
||||
for name in parakeet.list_downloaded_models():
|
||||
items.append(
|
||||
(
|
||||
name,
|
||||
_model_display_name(name),
|
||||
_format_size(parakeet.PARAKEET_MODEL_INFO[name]["size_mb"]),
|
||||
name == active_id,
|
||||
)
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
@@ -5356,6 +5446,8 @@ class SettingsDialog(Gtk.Dialog):
|
||||
_delete_whisper_model(model_id)
|
||||
elif engine == "vosk":
|
||||
delete_vosk_model(model_id)
|
||||
elif engine == "parakeet":
|
||||
parakeet.delete_model(model_id)
|
||||
else:
|
||||
raise ValueError(f"No local models to delete for engine {engine}")
|
||||
|
||||
@@ -5404,16 +5496,71 @@ class SettingsDialog(Gtk.Dialog):
|
||||
programmatic = self._initializing or self._applying_settings
|
||||
|
||||
current_lang = None if self._applying_settings else self.language_combo.get_active_id()
|
||||
if current_lang:
|
||||
if engine == "vosk" and (
|
||||
current_lang == "auto" or not SUPPORTED_LANGUAGES.get(current_lang, {}).get("vosk")
|
||||
previous_engine = self._engine_for_language_memory
|
||||
# When Vosk coerces an unsupported language to en-us, keep the user
|
||||
# preference in memory and re-apply it after sync (which would otherwise
|
||||
# write the coerced active value back into memory).
|
||||
preserve_language_memory = None
|
||||
vosk_coerced = False
|
||||
if engine == "parakeet":
|
||||
# Force auto for honesty, but remember the prior catalog preference so
|
||||
# leaving Parakeet can restore Whisper/cpp language instead of auto.
|
||||
# Only capture on entry: a re-fired changed signal while already on
|
||||
# Parakeet would otherwise overwrite memory with the forced auto.
|
||||
if previous_engine != "parakeet":
|
||||
remembered = current_lang or self.language
|
||||
# Vosk may still display coerced en-us for an unsupported
|
||||
# catalog language. Entering Parakeet must not record that
|
||||
# fallback over the pre-coercion preference (e.g. Greek or auto).
|
||||
if remembered and not _vosk_display_is_coerced_fallback(
|
||||
previous_engine, remembered, self._last_non_parakeet_language
|
||||
):
|
||||
self._last_non_parakeet_language = remembered
|
||||
self.language = "auto"
|
||||
elif current_lang is None and self._applying_settings:
|
||||
# Programmatic resync must not rewrite the language the user picked.
|
||||
pass
|
||||
else:
|
||||
# Prefer remembered preference over a prior engine's coerced active
|
||||
# value (Vosk en-us for Greek). Trust the combo for other engines.
|
||||
if (
|
||||
current_lang
|
||||
and current_lang != "auto"
|
||||
and not _vosk_display_is_coerced_fallback(
|
||||
previous_engine, current_lang, self._last_non_parakeet_language
|
||||
)
|
||||
):
|
||||
self.language = "en-us"
|
||||
elif engine in ["whisper", "whisper_cpp", "remote_api"] and not current_lang:
|
||||
self.language = "auto"
|
||||
chosen = current_lang
|
||||
elif self._last_non_parakeet_language:
|
||||
chosen = self._last_non_parakeet_language
|
||||
else:
|
||||
chosen = current_lang or self.language or self._default_language_for_engine(engine)
|
||||
|
||||
if engine == "vosk" and (
|
||||
chosen == "auto" or not SUPPORTED_LANGUAGES.get(chosen, {}).get("vosk")
|
||||
):
|
||||
# Keep the pre-coercion preference (including auto) so sync cannot
|
||||
# persist Vosk's en-us fallback as the remembered Whisper language.
|
||||
preserve_language_memory = (
|
||||
chosen
|
||||
if chosen and chosen != "auto"
|
||||
else (self._last_non_parakeet_language or "auto")
|
||||
)
|
||||
chosen = "en-us"
|
||||
vosk_coerced = True
|
||||
|
||||
self.language = chosen
|
||||
# Never store the coerced en-us fallback as the user preference.
|
||||
if vosk_coerced:
|
||||
self._last_non_parakeet_language = preserve_language_memory
|
||||
elif chosen:
|
||||
self._last_non_parakeet_language = chosen
|
||||
|
||||
self._engine_for_language_memory = engine
|
||||
self._populate_model_options()
|
||||
self._sync_language_options_for_selected_model(self.language)
|
||||
if vosk_coerced and preserve_language_memory is not None:
|
||||
self._last_non_parakeet_language = preserve_language_memory
|
||||
self._update_engine_specific_ui()
|
||||
self._update_model_info()
|
||||
self._update_voice_commands_for_engine()
|
||||
@@ -5519,7 +5666,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
continue
|
||||
is_downloaded = _is_vosk_model_downloaded("small", lang_code)
|
||||
display_text += " ✓" if is_downloaded else " ↓"
|
||||
elif engine in ["whisper", "whisper_cpp", "remote_api"]:
|
||||
elif engine in ["whisper", "whisper_cpp", "parakeet", "remote_api"]:
|
||||
if english_only_whispercpp and lang_info.get("whisper") != "en":
|
||||
continue
|
||||
# Both Whisper and whisper.cpp support auto-detect
|
||||
@@ -5532,6 +5679,11 @@ class SettingsDialog(Gtk.Dialog):
|
||||
|
||||
def _update_language_warning(self):
|
||||
"""Update language help text for the selected engine/model/language."""
|
||||
if self._get_selected_engine() == "parakeet":
|
||||
self.language_warning.set_markup("")
|
||||
self.language_warning.hide()
|
||||
return
|
||||
|
||||
lang_code = self.language_combo.get_active_id()
|
||||
lang_info = SUPPORTED_LANGUAGES.get(lang_code, {})
|
||||
|
||||
@@ -5574,6 +5726,8 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self._processing_language_change = True
|
||||
try:
|
||||
self.language = lang_code
|
||||
if _engine_from_display(engine) != "parakeet":
|
||||
self._last_non_parakeet_language = lang_code
|
||||
self._populate_model_options()
|
||||
self._update_language_warning()
|
||||
self._update_model_info()
|
||||
@@ -5636,6 +5790,11 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self._update_model_info()
|
||||
self._refresh_unused_downloads()
|
||||
self._update_language_warning()
|
||||
if engine == "parakeet":
|
||||
self.language_row.hide()
|
||||
self.language_warning.hide()
|
||||
else:
|
||||
self.language_row.show_all()
|
||||
self._update_model_picker_tooltips()
|
||||
self._update_advanced_tab_sensitivity()
|
||||
|
||||
@@ -5709,6 +5868,14 @@ class SettingsDialog(Gtk.Dialog):
|
||||
is_downloaded = _is_vosk_model_downloaded(model_name, self.language)
|
||||
recommended, reason = _get_recommended_vosk_model()
|
||||
extra_info = f"Size: {_format_size(info['size_mb'])}"
|
||||
elif engine == "parakeet":
|
||||
if model_name not in parakeet.PARAKEET_MODEL_INFO:
|
||||
self.model_info_card.hide()
|
||||
return
|
||||
info = parakeet.PARAKEET_MODEL_INFO[model_name]
|
||||
is_downloaded = parakeet.is_model_downloaded(model_name)
|
||||
recommended, reason = parakeet.RECOMMENDED_MODEL, parakeet.RECOMMENDED_REASON
|
||||
extra_info = f"Size: {_format_size(info['size_mb'])}"
|
||||
else:
|
||||
self.model_info_card.hide()
|
||||
return
|
||||
@@ -5789,6 +5956,9 @@ class SettingsDialog(Gtk.Dialog):
|
||||
elif engine == "vosk" and not _is_vosk_model_downloaded(model_name, self.language):
|
||||
needs_download = True
|
||||
model_info = VOSK_MODEL_INFO.get(model_name, {"size_mb": 50})
|
||||
elif engine == "parakeet" and not parakeet.is_model_downloaded(model_name):
|
||||
needs_download = True
|
||||
model_info = parakeet.PARAKEET_MODEL_INFO.get(model_name, {"size_mb": 639})
|
||||
|
||||
if needs_download:
|
||||
if not self.speech_engine.try_begin_download():
|
||||
@@ -6003,7 +6173,10 @@ class SettingsDialog(Gtk.Dialog):
|
||||
model_variant = ""
|
||||
else:
|
||||
model_size = model_id.lower() if model_id else "small"
|
||||
language = language_id if language_id else self._default_language_for_engine(engine)
|
||||
if engine == "parakeet":
|
||||
language = "auto"
|
||||
else:
|
||||
language = language_id if language_id else self._default_language_for_engine(engine)
|
||||
|
||||
vad = int(self.vad_spin.get_value())
|
||||
silence = self.silence_spin.get_value()
|
||||
@@ -6242,6 +6415,9 @@ For now, the engine has been reverted to VOSK."""
|
||||
elif engine == "vosk" and not _is_vosk_model_downloaded(model_name, self.language):
|
||||
needs_download = True
|
||||
model_info = VOSK_MODEL_INFO.get(model_name, {"size_mb": 50})
|
||||
elif engine == "parakeet" and not parakeet.is_model_downloaded(model_name):
|
||||
needs_download = True
|
||||
model_info = parakeet.PARAKEET_MODEL_INFO.get(model_name, {"size_mb": 639})
|
||||
|
||||
if needs_download:
|
||||
if not self.speech_engine.try_begin_download():
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#
|
||||
# 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 or whispercpp_model_info.py.
|
||||
# vosk_model_info.py, whispercpp_model_info.py or parakeet_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.
|
||||
@@ -16,6 +16,9 @@
|
||||
#
|
||||
# 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 bundles are pinned per model in parakeet_model_info.py, so their
|
||||
# keys carry the model name: several bundles ship the same file names.
|
||||
|
||||
ggml-base-q5_1.bin sha256 422f1ae452ade6f30a004d7e5c6a43195e4433bc370bf23fac9cc591f01a8898 59707625
|
||||
ggml-base-q8_0.bin sha256 c577b9a86e7e048a0b7eada054f4dd79a56bbfa911fbdacf900ac5b567cbb7d9 81768585
|
||||
@@ -84,3 +87,11 @@ vosk-model-small-uk-v3-small.zip sha256 ee05b6a8c796c53c61cd1c821d38e897
|
||||
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-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
|
||||
parakeet-v3-european-tokens.txt sha256 d58544679ea4bc6ac563d1f545eb7d474bd6cfa467f0a6e2c1dc1c7d37e3c35d 93939
|
||||
parakeet-v2-english-encoder.int8.onnx sha256 a32b12d17bbbc309d0686fbbcc2987b5e9b8333a7da83fa6b089f0a2acd651ab 652184296
|
||||
parakeet-v2-english-decoder.int8.onnx sha256 b6bb64963457237b900e496ee9994b59294526439fbcc1fecf705b31a15c6b4e 7257753
|
||||
parakeet-v2-english-joiner.int8.onnx sha256 7946164367946e7f9f29a122407c3252b680dbae9a51343eb2488d057c3c43d2 1739080
|
||||
parakeet-v2-english-tokens.txt sha256 ec182b70dd42113aff6c5372c75cac58c952443eb22322f57bbd7f53977d497d 9384
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Parakeet model information for Vocalinux.
|
||||
|
||||
This module provides model metadata and download URLs for the Parakeet
|
||||
engine, which runs NVIDIA NeMo ASR models through sherpa-onnx.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Any, Dict
|
||||
|
||||
from .paths import is_within_directory, models_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Parakeet model information
|
||||
# Models are downloaded from Hugging Face as sherpa-onnx ONNX bundles.
|
||||
#
|
||||
# Each bundle is pinned to a Hugging Face commit rather than tracking main, so
|
||||
# a file replaced upstream cannot change what an install downloads.
|
||||
_HF_BASE_URL = "https://huggingface.co"
|
||||
|
||||
PARAKEET_MODEL_INFO: Dict[str, Dict[str, Any]] = {
|
||||
"v3-european": {
|
||||
"repo": "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8",
|
||||
"revision": "2bda32ec70b097a55adaa07d9a7173915b43cc78",
|
||||
"size_mb": 639,
|
||||
"desc": "Parakeet TDT 0.6B v3 (int8), 25 European languages",
|
||||
},
|
||||
"v2-english": {
|
||||
"repo": "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8",
|
||||
"revision": "1ab9323565ddb038682214b292f588070a538ce2",
|
||||
"size_mb": 630,
|
||||
"desc": "Parakeet TDT 0.6B v2 (int8), English",
|
||||
},
|
||||
}
|
||||
|
||||
MODEL_SIZES = ["v3-european", "v2-english"]
|
||||
|
||||
# Model offered when the engine has nothing downloaded yet.
|
||||
RECOMMENDED_MODEL = "v3-european"
|
||||
RECOMMENDED_REASON = "25 European languages"
|
||||
|
||||
# Files that make up a sherpa-onnx transducer bundle.
|
||||
MODEL_FILES = ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"]
|
||||
|
||||
|
||||
def get_model_path(model_name: str) -> str:
|
||||
"""
|
||||
Get the path where a model should be stored.
|
||||
|
||||
Args:
|
||||
model_name: Name of the model (for example v3-european)
|
||||
|
||||
Returns:
|
||||
Path to the model directory
|
||||
"""
|
||||
parakeet_dir = os.path.join(models_dir(), "parakeet")
|
||||
os.makedirs(parakeet_dir, exist_ok=True)
|
||||
|
||||
return os.path.join(parakeet_dir, model_name)
|
||||
|
||||
|
||||
def is_model_downloaded(model_name: str) -> bool:
|
||||
"""
|
||||
Check if a Parakeet model is downloaded.
|
||||
|
||||
Args:
|
||||
model_name: Name of the model
|
||||
|
||||
Returns:
|
||||
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)
|
||||
|
||||
|
||||
def list_downloaded_models() -> list[str]:
|
||||
"""Return Parakeet models occupying disk, complete or partially downloaded.
|
||||
|
||||
Mirrors the VOSK helper in listing anything present so Settings can offer
|
||||
deletion; a cancelled download leaves files behind that must be reclaimable.
|
||||
"""
|
||||
return [name for name in MODEL_SIZES if os.path.isdir(get_model_path(name))]
|
||||
|
||||
|
||||
def delete_model(model_name: str) -> str:
|
||||
"""Delete a downloaded Parakeet model directory.
|
||||
|
||||
Returns:
|
||||
The removed filesystem path.
|
||||
|
||||
Raises:
|
||||
ValueError: Unknown model name, or path outside the models directory.
|
||||
FileNotFoundError: The model directory is not present.
|
||||
OSError: The directory could not be removed.
|
||||
"""
|
||||
if model_name not in PARAKEET_MODEL_INFO or os.path.basename(model_name) != model_name:
|
||||
raise ValueError(f"Unknown Parakeet model: {model_name}")
|
||||
|
||||
path = get_model_path(model_name)
|
||||
if not is_within_directory(path, models_dir()):
|
||||
raise ValueError("Refusing to delete a path outside the models directory")
|
||||
if not os.path.isdir(path):
|
||||
raise FileNotFoundError(path)
|
||||
|
||||
shutil.rmtree(path)
|
||||
logger.info("Deleted Parakeet model %s (%s)", model_name, path)
|
||||
return path
|
||||
|
||||
|
||||
def manifest_key(model_name: str, filename: str) -> str:
|
||||
"""Key this bundle file is pinned under in model_checksums.txt.
|
||||
|
||||
Several bundles ship the same file names, so the model name is part of the
|
||||
key rather than the bare filename other engines use.
|
||||
"""
|
||||
return f"parakeet-{model_name}-{filename}"
|
||||
|
||||
|
||||
def get_model_file_url(model_name: str, filename: str) -> str:
|
||||
"""
|
||||
Get the download URL for one file of a Parakeet model.
|
||||
|
||||
Args:
|
||||
model_name: Name of the model
|
||||
filename: File within the model bundle
|
||||
|
||||
Returns:
|
||||
Hugging Face download URL
|
||||
"""
|
||||
model_info = PARAKEET_MODEL_INFO.get(model_name)
|
||||
if not model_info:
|
||||
raise ValueError(f"Unknown Parakeet model: {model_name}")
|
||||
|
||||
return (
|
||||
f"{_HF_BASE_URL}/{model_info['repo']}/resolve/{model_info['revision']}"
|
||||
f"/{filename}?download=true"
|
||||
)
|
||||
@@ -943,6 +943,108 @@ class TestMainConfigPrecedence(unittest.TestCase):
|
||||
self.assertEqual(call_kwargs["language"], "de")
|
||||
self.assertEqual(call_kwargs["audio_device_index"], 2)
|
||||
|
||||
@patch("vocalinux.main.check_dependencies")
|
||||
@patch("vocalinux.ui.action_handler.ActionHandler")
|
||||
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
|
||||
@patch("vocalinux.text_injection.text_injector.TextInjector")
|
||||
@patch("vocalinux.ui.tray_indicator.TrayIndicator")
|
||||
@patch("vocalinux.ui.config_manager.get_shared_config_manager")
|
||||
@patch("vocalinux.ui.logging_manager.initialize_logging")
|
||||
def test_cli_language_normalized_for_parakeet(
|
||||
self,
|
||||
mock_init_logging,
|
||||
mock_config_manager,
|
||||
mock_tray,
|
||||
mock_text,
|
||||
mock_speech,
|
||||
mock_action_handler,
|
||||
mock_check_deps,
|
||||
):
|
||||
"""CLI --language must not be stored when --engine is parakeet."""
|
||||
mock_check_deps.return_value = True
|
||||
|
||||
mock_config_instance = MagicMock()
|
||||
mock_config_instance.get_settings.return_value = {
|
||||
"speech_recognition": {
|
||||
"engine": "whisper_cpp",
|
||||
"model_size": "small",
|
||||
"language": "en-us",
|
||||
},
|
||||
"general": {"first_run": False},
|
||||
}
|
||||
mock_config_manager.return_value = mock_config_instance
|
||||
|
||||
mock_speech.return_value = MagicMock()
|
||||
mock_text.return_value = MagicMock()
|
||||
mock_tray.return_value = MagicMock()
|
||||
mock_action_handler.return_value = MagicMock()
|
||||
|
||||
with patch(
|
||||
"sys.argv",
|
||||
[
|
||||
"vocalinux",
|
||||
"--engine",
|
||||
"parakeet",
|
||||
"--model",
|
||||
"v3-european",
|
||||
"--language",
|
||||
"fr",
|
||||
],
|
||||
):
|
||||
with patch("vocalinux.main.logger"):
|
||||
main()
|
||||
|
||||
mock_speech.assert_called_once()
|
||||
call_kwargs = mock_speech.call_args[1]
|
||||
self.assertEqual(call_kwargs["engine"], "parakeet")
|
||||
self.assertEqual(call_kwargs["language"], "auto")
|
||||
|
||||
@patch("vocalinux.main.check_dependencies")
|
||||
@patch("vocalinux.ui.action_handler.ActionHandler")
|
||||
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
|
||||
@patch("vocalinux.text_injection.text_injector.TextInjector")
|
||||
@patch("vocalinux.ui.tray_indicator.TrayIndicator")
|
||||
@patch("vocalinux.ui.config_manager.get_shared_config_manager")
|
||||
@patch("vocalinux.ui.logging_manager.initialize_logging")
|
||||
def test_saved_language_normalized_for_parakeet(
|
||||
self,
|
||||
mock_init_logging,
|
||||
mock_config_manager,
|
||||
mock_tray,
|
||||
mock_text,
|
||||
mock_speech,
|
||||
mock_action_handler,
|
||||
mock_check_deps,
|
||||
):
|
||||
"""A leftover saved language must not be stored when the engine is parakeet."""
|
||||
mock_check_deps.return_value = True
|
||||
|
||||
mock_config_instance = MagicMock()
|
||||
mock_config_instance.get_settings.return_value = {
|
||||
"speech_recognition": {
|
||||
"engine": "parakeet",
|
||||
"model_size": "v3-european",
|
||||
"language": "de",
|
||||
},
|
||||
"general": {"first_run": False},
|
||||
}
|
||||
mock_config_instance.get_model_size_for_engine.return_value = "v3-european"
|
||||
mock_config_manager.return_value = mock_config_instance
|
||||
|
||||
mock_speech.return_value = MagicMock()
|
||||
mock_text.return_value = MagicMock()
|
||||
mock_tray.return_value = MagicMock()
|
||||
mock_action_handler.return_value = MagicMock()
|
||||
|
||||
with patch("sys.argv", ["vocalinux"]):
|
||||
with patch("vocalinux.main.logger"):
|
||||
main()
|
||||
|
||||
mock_speech.assert_called_once()
|
||||
call_kwargs = mock_speech.call_args[1]
|
||||
self.assertEqual(call_kwargs["engine"], "parakeet")
|
||||
self.assertEqual(call_kwargs["language"], "auto")
|
||||
|
||||
@patch("vocalinux.main.check_dependencies")
|
||||
@patch("vocalinux.ui.action_handler.ActionHandler")
|
||||
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
|
||||
|
||||
@@ -29,6 +29,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.vosk_model_info import VOSK_MODEL_INFO
|
||||
from vocalinux.utils.whispercpp_model_info import (
|
||||
WHISPERCPP_MODEL_INFO,
|
||||
@@ -73,6 +74,21 @@ class TestManifestCoverage(unittest.TestCase):
|
||||
"run `just model-checksums` to refresh the manifest",
|
||||
)
|
||||
|
||||
def test_every_parakeet_model_is_pinned(self):
|
||||
pinned = pinned_filenames()
|
||||
missing = [
|
||||
manifest_key(name, filename)
|
||||
for name in PARAKEET_MODEL_INFO
|
||||
for filename in MODEL_FILES
|
||||
if manifest_key(name, filename) not in pinned
|
||||
]
|
||||
self.assertEqual(
|
||||
missing,
|
||||
[],
|
||||
"Parakeet models without a pinned checksum; "
|
||||
"run `just model-checksums` to refresh the manifest",
|
||||
)
|
||||
|
||||
def test_manifest_entries_are_well_formed(self):
|
||||
for filename in pinned_filenames():
|
||||
expected = expected_for(filename)
|
||||
|
||||
@@ -11,7 +11,6 @@ import base64
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -25,7 +24,14 @@ if "gi.repository" not in sys.modules:
|
||||
sys.modules["gi.repository"] = MagicMock()
|
||||
|
||||
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
||||
from vocalinux.utils.model_checksums import VERIFICATION_STAMP_NAME, expected_for
|
||||
from vocalinux.utils.model_checksums import (
|
||||
VERIFICATION_STAMP_NAME,
|
||||
ChecksumError,
|
||||
expected_for,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def _make_manager(engine="whisper_cpp", **kw):
|
||||
@@ -33,20 +39,25 @@ def _make_manager(engine="whisper_cpp", **kw):
|
||||
with patch.object(SpeechRecognitionManager, "_init_vosk"):
|
||||
with patch.object(SpeechRecognitionManager, "_init_whisper"):
|
||||
with patch.object(SpeechRecognitionManager, "_init_whispercpp"):
|
||||
mgr = SpeechRecognitionManager(
|
||||
engine=engine, model_size="small", language="en-us", defer_download=True, **kw
|
||||
)
|
||||
# Ensure vosk_model_map is set (normally done in _init_vosk)
|
||||
if not hasattr(mgr, "vosk_model_map"):
|
||||
# The names _init_vosk() would pick for en-us. They have to be
|
||||
# real: the download path looks each one up in the checksum
|
||||
# manifest to stamp the tree it extracts.
|
||||
mgr.vosk_model_map = {
|
||||
"small": "vosk-model-small-en-us-0.15",
|
||||
"medium": "vosk-model-en-us-0.22",
|
||||
"large": "vosk-model-en-us-0.22",
|
||||
}
|
||||
return mgr
|
||||
with patch.object(SpeechRecognitionManager, "_init_parakeet"):
|
||||
mgr = SpeechRecognitionManager(
|
||||
engine=engine,
|
||||
model_size="small",
|
||||
language="en-us",
|
||||
defer_download=True,
|
||||
**kw,
|
||||
)
|
||||
# Ensure vosk_model_map is set (normally done in _init_vosk)
|
||||
if not hasattr(mgr, "vosk_model_map"):
|
||||
# The names _init_vosk() would pick for en-us. They have to be
|
||||
# real: the download path looks each one up in the checksum
|
||||
# manifest to stamp the tree it extracts.
|
||||
mgr.vosk_model_map = {
|
||||
"small": "vosk-model-small-en-us-0.15",
|
||||
"medium": "vosk-model-en-us-0.22",
|
||||
"large": "vosk-model-en-us-0.22",
|
||||
}
|
||||
return mgr
|
||||
|
||||
|
||||
# A real zip, embedded rather than built here: by the time this module is
|
||||
@@ -631,6 +642,168 @@ class TestWhispercppRejectsAnUnverifiedModelOnDisk:
|
||||
assert manager._model_initialized is False
|
||||
|
||||
|
||||
class TestParakeetRejectsAnUnverifiedModelOnDisk:
|
||||
"""Wiring test: the hash has to happen where the model is picked up.
|
||||
|
||||
_download_parakeet_model verifies before its rename, so the exposure is a
|
||||
bundle that never came through it: files copied in, or left over from a
|
||||
cancelled download. sherpa-onnx loads these through native code. Without
|
||||
this the files go to OfflineRecognizer.from_transducer as-is.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _write_bundle(tmp_path, payload=b"not the model that is pinned"):
|
||||
model_dir = tmp_path / "v3-european"
|
||||
model_dir.mkdir()
|
||||
for name in PARAKEET_MODEL_FILES:
|
||||
(model_dir / name).write_bytes(payload)
|
||||
return model_dir
|
||||
|
||||
def test_a_model_that_fails_its_pin_is_removed_and_not_loaded(self, tmp_path):
|
||||
model_dir = self._write_bundle(tmp_path)
|
||||
|
||||
manager = _make_manager(engine="parakeet")
|
||||
manager.model_size = "v3-european"
|
||||
manager._defer_download = True
|
||||
|
||||
mock_sherpa = MagicMock()
|
||||
with patch.dict("sys.modules", {"sherpa_onnx": mock_sherpa}):
|
||||
with patch(
|
||||
"vocalinux.speech_recognition.recognition_manager.parakeet.get_model_path",
|
||||
return_value=str(model_dir),
|
||||
):
|
||||
manager._init_parakeet()
|
||||
|
||||
for name in PARAKEET_MODEL_FILES:
|
||||
assert not (model_dir / name).exists(), "an unverifiable model must not stay on disk"
|
||||
mock_sherpa.OfflineRecognizer.from_transducer.assert_not_called()
|
||||
assert manager._model_initialized is False
|
||||
|
||||
def test_a_model_that_cannot_be_deleted_is_still_not_loaded(self, tmp_path):
|
||||
model_dir = self._write_bundle(tmp_path)
|
||||
|
||||
manager = _make_manager(engine="parakeet")
|
||||
manager.model_size = "v3-european"
|
||||
manager._defer_download = False
|
||||
|
||||
mock_sherpa = MagicMock()
|
||||
with patch.dict("sys.modules", {"sherpa_onnx": mock_sherpa}):
|
||||
with patch(
|
||||
"vocalinux.speech_recognition.recognition_manager.parakeet.get_model_path",
|
||||
return_value=str(model_dir),
|
||||
):
|
||||
with patch(
|
||||
"vocalinux.speech_recognition.recognition_manager.os.remove",
|
||||
side_effect=OSError("read-only"),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="failed verification"):
|
||||
manager._init_parakeet()
|
||||
|
||||
for name in PARAKEET_MODEL_FILES:
|
||||
assert (model_dir / name).exists()
|
||||
mock_sherpa.OfflineRecognizer.from_transducer.assert_not_called()
|
||||
|
||||
def test_an_unpinned_model_is_refused_not_deleted(self, tmp_path):
|
||||
"""Parakeet pins are constructed keys; simulate a missing pin for one file."""
|
||||
model_dir = self._write_bundle(tmp_path, payload=b"bytes")
|
||||
unpinned_name = PARAKEET_MODEL_FILES[0]
|
||||
unpinned_key = parakeet_manifest_key("v3-european", unpinned_name)
|
||||
|
||||
def fake_verify(path, filename=None):
|
||||
key = filename or os.path.basename(path)
|
||||
if key == unpinned_key:
|
||||
raise ChecksumError(f"No checksum is pinned for {key}")
|
||||
verify_model_file_real(path, filename)
|
||||
|
||||
def fake_expected(filename):
|
||||
if os.path.basename(filename) == unpinned_key:
|
||||
return None
|
||||
return expected_for(filename)
|
||||
|
||||
manager = _make_manager(engine="parakeet")
|
||||
manager.model_size = "v3-european"
|
||||
manager._defer_download = True
|
||||
|
||||
mock_sherpa = MagicMock()
|
||||
with patch.dict("sys.modules", {"sherpa_onnx": mock_sherpa}):
|
||||
with patch(
|
||||
"vocalinux.speech_recognition.recognition_manager.parakeet.get_model_path",
|
||||
return_value=str(model_dir),
|
||||
):
|
||||
with patch(
|
||||
"vocalinux.speech_recognition.recognition_manager.verify_model_file",
|
||||
side_effect=fake_verify,
|
||||
):
|
||||
with patch(
|
||||
"vocalinux.speech_recognition.recognition_manager.expected_for",
|
||||
side_effect=fake_expected,
|
||||
):
|
||||
manager._init_parakeet()
|
||||
|
||||
assert (
|
||||
model_dir / unpinned_name
|
||||
).exists(), "a missing pin is not a reason to delete the file"
|
||||
mock_sherpa.OfflineRecognizer.from_transducer.assert_not_called()
|
||||
assert manager._model_initialized is False
|
||||
|
||||
|
||||
class TestParakeetDownloadVerifiesExistingFiles:
|
||||
"""Existence is not a pin: a leftover dest must still match its digest.
|
||||
|
||||
A previous continue-on-exists skipped verification and left a copied-in or
|
||||
truncated file for sherpa-onnx to load. OfflineRecognizer is not involved
|
||||
here; this is the download loop only.
|
||||
"""
|
||||
|
||||
def test_an_existing_file_that_fails_its_pin_is_redownloaded(self, tmp_path):
|
||||
manager = _make_manager(engine="parakeet")
|
||||
manager.model_size = "v3-european"
|
||||
model_dir = tmp_path / "v3-european"
|
||||
model_dir.mkdir()
|
||||
|
||||
encoder_name = PARAKEET_MODEL_FILES[0]
|
||||
encoder = model_dir / encoder_name
|
||||
encoder.write_bytes(b"not the encoder that is pinned")
|
||||
encoder_key = parakeet_manifest_key("v3-european", encoder_name)
|
||||
|
||||
streamed = []
|
||||
verify_calls = []
|
||||
|
||||
def fake_stream(url, dest_path):
|
||||
streamed.append(dest_path)
|
||||
with open(dest_path, "wb") as handle:
|
||||
handle.write(b"good-enough")
|
||||
|
||||
def fake_verify(path, filename=None):
|
||||
verify_calls.append((path, filename))
|
||||
# Existing dests fail; freshly streamed temps pass.
|
||||
if not str(path).endswith(".tmp"):
|
||||
raise ChecksumError("digest mismatch")
|
||||
|
||||
mock_requests = MagicMock()
|
||||
mock_requests.exceptions.RequestException = Exception
|
||||
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
with patch(
|
||||
"vocalinux.speech_recognition.recognition_manager.parakeet.get_model_path",
|
||||
return_value=str(model_dir),
|
||||
):
|
||||
with patch.object(manager, "_stream_model_download", side_effect=fake_stream):
|
||||
with patch(
|
||||
"vocalinux.speech_recognition.recognition_manager.verify_model_file",
|
||||
side_effect=fake_verify,
|
||||
):
|
||||
manager._download_parakeet_model()
|
||||
|
||||
assert any(
|
||||
path == str(encoder) and filename == encoder_key for path, filename in verify_calls
|
||||
), "an existing dest must be hashed, not skipped because it is already on disk"
|
||||
assert any(
|
||||
os.path.basename(path) == encoder_name + ".tmp" for path in streamed
|
||||
), "an existing bad file must be removed and re-downloaded"
|
||||
assert encoder.read_bytes() == b"good-enough"
|
||||
|
||||
|
||||
class TestAudioReconnection:
|
||||
"""Test audio reconnection logic."""
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ def _dialog_stub(language: str = "en-us") -> Mock:
|
||||
dialog.language_combo.get_active_id.return_value = language
|
||||
dialog._populating_models = False
|
||||
dialog._processing_language_change = False
|
||||
# Plain Mock attrs are truthy; these guards must be real False or the
|
||||
# language-changed handler returns before updating dialog.language.
|
||||
dialog._initializing = False
|
||||
dialog._applying_settings = False
|
||||
return dialog
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
@@ -61,6 +62,8 @@ def _dialog_stub() -> Mock:
|
||||
dialog._populating_models = False
|
||||
dialog._processing_language_change = False
|
||||
dialog.language = "en-us"
|
||||
dialog._last_non_parakeet_language = None
|
||||
dialog._engine_for_language_memory = None
|
||||
# The real attribute is an enum member; a bare "idle" string would compare
|
||||
# unequal and send every test down the stop_recognition + sleep(0.5) branch.
|
||||
dialog.speech_engine.state = RecognitionState.IDLE
|
||||
@@ -773,3 +776,399 @@ def test_settings_releases_the_engine_once_the_download_is_over(
|
||||
|
||||
dialog.speech_engine.try_begin_download.assert_called_once_with()
|
||||
dialog.speech_engine.end_download.assert_called_once_with()
|
||||
|
||||
|
||||
def _dialog_for_engine_ui(engine_text: str):
|
||||
"""Stub widgets that `_update_engine_specific_ui` show/hides."""
|
||||
dialog = _dialog_stub()
|
||||
dialog.engine_combo.get_active_text.return_value = engine_text
|
||||
return dialog
|
||||
|
||||
|
||||
def _dialog_for_selected_settings(engine_text: str, language_id: str, model_id: str = "small"):
|
||||
"""Stub combos and spins so `get_selected_settings` can run unbound."""
|
||||
dialog = _dialog_stub()
|
||||
dialog.engine_combo.get_active_text.return_value = engine_text
|
||||
dialog.model_combo.get_active_id.return_value = model_id
|
||||
dialog.language_combo.get_active_id.return_value = language_id
|
||||
dialog.vad_spin.get_value.return_value = 3
|
||||
dialog.silence_spin.get_value.return_value = 2.0
|
||||
dialog.advanced_no_timestamps_switch.get_active.return_value = False
|
||||
dialog.advanced_no_context_switch.get_active.return_value = False
|
||||
prompt = dialog.advanced_initial_prompt_buffer
|
||||
prompt.get_text.return_value = ""
|
||||
prompt.get_start_iter.return_value = Mock()
|
||||
prompt.get_end_iter.return_value = Mock()
|
||||
dialog.advanced_temperature_spin.get_value.return_value = 0.0
|
||||
dialog.advanced_temperature_inc_spin.get_value.return_value = 0.2
|
||||
dialog.advanced_entropy_thold_spin.get_value.return_value = 2.4
|
||||
dialog.advanced_logprob_thold_spin.get_value.return_value = -1.0
|
||||
dialog.advanced_no_speech_thold_spin.get_value.return_value = 0.6
|
||||
dialog.gpu_device_combo.get_active_id.return_value = None
|
||||
return dialog
|
||||
|
||||
|
||||
def test_parakeet_hides_the_language_picker(dialog_class):
|
||||
"""Parakeet language coverage is the model, so the picker must not stay shown."""
|
||||
dialog = _dialog_for_engine_ui("Parakeet")
|
||||
|
||||
dialog_class._update_engine_specific_ui(dialog)
|
||||
|
||||
dialog.language_row.hide.assert_called()
|
||||
dialog.language_row.show_all.assert_not_called()
|
||||
dialog.language_warning.hide.assert_called()
|
||||
|
||||
|
||||
def test_non_parakeet_shows_the_language_picker(dialog_class):
|
||||
"""Switching away from Parakeet must bring the language row back."""
|
||||
dialog = _dialog_for_engine_ui("whisper.cpp")
|
||||
|
||||
dialog_class._update_engine_specific_ui(dialog)
|
||||
|
||||
dialog.language_row.show_all.assert_called()
|
||||
dialog.language_row.hide.assert_not_called()
|
||||
|
||||
|
||||
def test_parakeet_selected_settings_force_language_auto(dialog_class):
|
||||
"""A leftover combo language must not be written as if Parakeet used it."""
|
||||
dialog = _dialog_for_selected_settings("Parakeet", "fr", model_id="v3-european")
|
||||
|
||||
settings = dialog_class.get_selected_settings(dialog)
|
||||
|
||||
assert settings["engine"] == "parakeet"
|
||||
assert settings["language"] == "auto"
|
||||
|
||||
|
||||
def test_whisper_selected_settings_keep_combo_language(dialog_class):
|
||||
"""Non-Parakeet engines still persist the language the combo reports."""
|
||||
dialog = _dialog_for_selected_settings("Whisper", "fr", model_id="small")
|
||||
|
||||
settings = dialog_class.get_selected_settings(dialog)
|
||||
|
||||
assert settings["engine"] == "whisper"
|
||||
assert settings["language"] == "fr"
|
||||
|
||||
|
||||
def test_parakeet_engine_change_forces_language_auto(dialog_class):
|
||||
"""A leftover catalog language from another engine must not stick in memory."""
|
||||
dialog = _dialog_stub()
|
||||
dialog.language = "en-us"
|
||||
dialog._engine_for_language_memory = "whisper"
|
||||
dialog.engine_combo.get_active_text.return_value = "Parakeet"
|
||||
dialog.language_combo.get_active_id.return_value = "fr"
|
||||
|
||||
dialog_class._on_engine_changed(dialog, None)
|
||||
|
||||
assert dialog.language == "auto"
|
||||
assert dialog._last_non_parakeet_language == "fr"
|
||||
assert dialog._engine_for_language_memory == "parakeet"
|
||||
dialog._sync_language_options_for_selected_model.assert_called_once_with("auto")
|
||||
|
||||
|
||||
def test_parakeet_reentry_does_not_clobber_remembered_language(dialog_class):
|
||||
"""A second Parakeet changed signal must not replace memory with forced auto."""
|
||||
dialog = _dialog_stub()
|
||||
dialog.language = "auto"
|
||||
dialog._last_non_parakeet_language = "fr"
|
||||
dialog._engine_for_language_memory = "parakeet"
|
||||
dialog.engine_combo.get_active_text.return_value = "Parakeet"
|
||||
dialog.language_combo.get_active_id.return_value = "auto"
|
||||
|
||||
dialog_class._on_engine_changed(dialog, None)
|
||||
|
||||
assert dialog.language == "auto"
|
||||
assert dialog._last_non_parakeet_language == "fr"
|
||||
|
||||
|
||||
def test_leaving_parakeet_restores_remembered_language(dialog_class):
|
||||
"""Whisper/cpp language must survive a round-trip through Parakeet."""
|
||||
dialog = _dialog_stub()
|
||||
dialog.language = "auto"
|
||||
dialog._last_non_parakeet_language = "fr"
|
||||
dialog._engine_for_language_memory = "parakeet"
|
||||
dialog.engine_combo.get_active_text.return_value = "Whisper"
|
||||
dialog.language_combo.get_active_id.return_value = "auto"
|
||||
|
||||
dialog_class._on_engine_changed(dialog, None)
|
||||
|
||||
assert dialog.language == "fr"
|
||||
assert dialog._last_non_parakeet_language == "fr"
|
||||
dialog._sync_language_options_for_selected_model.assert_called_once_with("fr")
|
||||
|
||||
|
||||
def test_leaving_parakeet_to_vosk_maps_unsupported_remembered_language(dialog_class):
|
||||
"""Restored auto/non-Vosk languages must fall back to en-us on Vosk."""
|
||||
dialog = _dialog_stub()
|
||||
dialog.language = "auto"
|
||||
dialog._last_non_parakeet_language = "auto"
|
||||
dialog._engine_for_language_memory = "parakeet"
|
||||
dialog.engine_combo.get_active_text.return_value = "Vosk"
|
||||
dialog.language_combo.get_active_id.return_value = "auto"
|
||||
|
||||
dialog_class._on_engine_changed(dialog, None)
|
||||
|
||||
assert dialog.language == "en-us"
|
||||
# Coercion must not invent a user preference of en-us over remembered auto.
|
||||
assert dialog._last_non_parakeet_language == "auto"
|
||||
dialog._sync_language_options_for_selected_model.assert_called_once_with("en-us")
|
||||
|
||||
|
||||
def test_whisper_parakeet_vosk_whisper_preserves_unsupported_language(dialog_class):
|
||||
"""Vosk en-us fallback must not erase Whisper Greek across the round-trip."""
|
||||
dialog = _dialog_stub()
|
||||
dialog.language = "el"
|
||||
dialog._last_non_parakeet_language = "el"
|
||||
dialog._engine_for_language_memory = "whisper"
|
||||
dialog.engine_combo.get_active_text.return_value = "Parakeet"
|
||||
dialog.language_combo.get_active_id.return_value = "el"
|
||||
|
||||
dialog_class._on_engine_changed(dialog, None)
|
||||
|
||||
assert dialog.language == "auto"
|
||||
assert dialog._last_non_parakeet_language == "el"
|
||||
assert dialog._engine_for_language_memory == "parakeet"
|
||||
|
||||
dialog.engine_combo.get_active_text.return_value = "Vosk"
|
||||
dialog.language_combo.get_active_id.return_value = "auto"
|
||||
dialog_class._on_engine_changed(dialog, None)
|
||||
|
||||
assert dialog.language == "en-us"
|
||||
assert dialog._last_non_parakeet_language == "el"
|
||||
assert dialog._engine_for_language_memory == "vosk"
|
||||
|
||||
dialog.engine_combo.get_active_text.return_value = "Whisper"
|
||||
# Combo still shows Vosk's coerced en-us; memory must win for Whisper.
|
||||
dialog.language_combo.get_active_id.return_value = "en-us"
|
||||
dialog_class._on_engine_changed(dialog, None)
|
||||
|
||||
assert dialog.language == "el"
|
||||
assert dialog._last_non_parakeet_language == "el"
|
||||
dialog._sync_language_options_for_selected_model.assert_called_with("el")
|
||||
|
||||
|
||||
def test_vosk_parakeet_whisper_preserves_unsupported_language(dialog_class):
|
||||
"""Entering Parakeet from coerced Vosk must not record en-us as memory."""
|
||||
dialog = _dialog_stub()
|
||||
dialog.language = "el"
|
||||
dialog._last_non_parakeet_language = "el"
|
||||
dialog._engine_for_language_memory = "whisper"
|
||||
dialog.engine_combo.get_active_text.return_value = "Vosk"
|
||||
dialog.language_combo.get_active_id.return_value = "el"
|
||||
|
||||
dialog_class._on_engine_changed(dialog, None)
|
||||
|
||||
assert dialog.language == "en-us"
|
||||
assert dialog._last_non_parakeet_language == "el"
|
||||
assert dialog._engine_for_language_memory == "vosk"
|
||||
|
||||
# Combo still shows Vosk's en-us fallback; Parakeet entry must keep Greek.
|
||||
dialog.engine_combo.get_active_text.return_value = "Parakeet"
|
||||
dialog.language_combo.get_active_id.return_value = "en-us"
|
||||
dialog_class._on_engine_changed(dialog, None)
|
||||
|
||||
assert dialog.language == "auto"
|
||||
assert dialog._last_non_parakeet_language == "el"
|
||||
assert dialog._engine_for_language_memory == "parakeet"
|
||||
|
||||
dialog.engine_combo.get_active_text.return_value = "Whisper"
|
||||
dialog.language_combo.get_active_id.return_value = "auto"
|
||||
dialog_class._on_engine_changed(dialog, None)
|
||||
|
||||
assert dialog.language == "el"
|
||||
assert dialog._last_non_parakeet_language == "el"
|
||||
dialog._sync_language_options_for_selected_model.assert_called_with("el")
|
||||
|
||||
|
||||
def test_vosk_parakeet_whisper_preserves_auto_language(dialog_class):
|
||||
"""Entering Parakeet from coerced Vosk must not record en-us over auto."""
|
||||
dialog = _dialog_stub()
|
||||
dialog.language = "auto"
|
||||
dialog._last_non_parakeet_language = "auto"
|
||||
dialog._engine_for_language_memory = "whisper"
|
||||
dialog.engine_combo.get_active_text.return_value = "Vosk"
|
||||
dialog.language_combo.get_active_id.return_value = "auto"
|
||||
|
||||
dialog_class._on_engine_changed(dialog, None)
|
||||
|
||||
assert dialog.language == "en-us"
|
||||
assert dialog._last_non_parakeet_language == "auto"
|
||||
assert dialog._engine_for_language_memory == "vosk"
|
||||
|
||||
dialog.engine_combo.get_active_text.return_value = "Parakeet"
|
||||
dialog.language_combo.get_active_id.return_value = "en-us"
|
||||
dialog_class._on_engine_changed(dialog, None)
|
||||
|
||||
assert dialog.language == "auto"
|
||||
assert dialog._last_non_parakeet_language == "auto"
|
||||
assert dialog._engine_for_language_memory == "parakeet"
|
||||
|
||||
dialog.engine_combo.get_active_text.return_value = "Whisper"
|
||||
dialog.language_combo.get_active_id.return_value = "auto"
|
||||
dialog_class._on_engine_changed(dialog, None)
|
||||
|
||||
assert dialog.language == "auto"
|
||||
assert dialog._last_non_parakeet_language == "auto"
|
||||
dialog._sync_language_options_for_selected_model.assert_called_with("auto")
|
||||
|
||||
|
||||
def test_vosk_whisper_preserves_auto_language(dialog_class):
|
||||
"""Leaving Vosk for Whisper must restore auto, not the coerced en-us combo."""
|
||||
dialog = _dialog_stub()
|
||||
dialog.language = "auto"
|
||||
dialog._last_non_parakeet_language = "auto"
|
||||
dialog._engine_for_language_memory = "whisper"
|
||||
dialog.engine_combo.get_active_text.return_value = "Vosk"
|
||||
dialog.language_combo.get_active_id.return_value = "auto"
|
||||
|
||||
dialog_class._on_engine_changed(dialog, None)
|
||||
|
||||
assert dialog.language == "en-us"
|
||||
assert dialog._last_non_parakeet_language == "auto"
|
||||
|
||||
dialog.engine_combo.get_active_text.return_value = "Whisper"
|
||||
dialog.language_combo.get_active_id.return_value = "en-us"
|
||||
dialog_class._on_engine_changed(dialog, None)
|
||||
|
||||
assert dialog.language == "auto"
|
||||
assert dialog._last_non_parakeet_language == "auto"
|
||||
dialog._sync_language_options_for_selected_model.assert_called_with("auto")
|
||||
|
||||
|
||||
def test_vosk_language_sync_does_not_clobber_unsupported_memory(dialog_class):
|
||||
"""Vosk combo fallback to en-us must not overwrite a remembered catalog language."""
|
||||
dialog = _dialog_stub()
|
||||
dialog.language = "en-us"
|
||||
dialog._last_non_parakeet_language = "el"
|
||||
dialog._get_selected_engine.return_value = "vosk"
|
||||
dialog.language_combo.get_active_id.return_value = "en-us"
|
||||
dialog._set_combo_active_id_or_first.return_value = True
|
||||
dialog._default_language_for_engine.return_value = "en-us"
|
||||
|
||||
dialog_class._sync_language_options_for_selected_model(dialog, "en-us")
|
||||
|
||||
assert dialog.language == "en-us"
|
||||
assert dialog._last_non_parakeet_language == "el"
|
||||
|
||||
|
||||
def test_parakeet_language_sync_forces_auto(dialog_class):
|
||||
"""Sync must not keep a preferred or combo language for Parakeet."""
|
||||
dialog = _dialog_stub()
|
||||
dialog.language = "en-us"
|
||||
dialog._get_selected_engine.return_value = "parakeet"
|
||||
dialog.language_combo.get_active_id.return_value = "fr"
|
||||
dialog._set_combo_active_id_or_first.return_value = True
|
||||
dialog._default_language_for_engine.return_value = "auto"
|
||||
|
||||
dialog_class._sync_language_options_for_selected_model(dialog, "de")
|
||||
|
||||
assert dialog.language == "auto"
|
||||
assert dialog._last_non_parakeet_language == "de"
|
||||
dialog._set_combo_active_id_or_first.assert_any_call(dialog.language_combo, "auto")
|
||||
|
||||
|
||||
def test_parakeet_recognition_does_not_consume_language():
|
||||
"""Parakeet models do not take a Whisper-style language argument."""
|
||||
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
||||
|
||||
init_src = inspect.getsource(SpeechRecognitionManager._init_parakeet)
|
||||
transcribe_src = inspect.getsource(SpeechRecognitionManager._transcribe_with_parakeet)
|
||||
assert "self.language" not in init_src
|
||||
assert "self.language" not in transcribe_src
|
||||
|
||||
|
||||
def test_normalize_language_for_engine_forces_auto_for_parakeet():
|
||||
"""CLI/saved non-auto languages must not survive for Parakeet."""
|
||||
from vocalinux.speech_recognition.recognition_manager import (
|
||||
normalize_language_for_engine,
|
||||
)
|
||||
|
||||
assert normalize_language_for_engine("parakeet", "fr") == "auto"
|
||||
assert normalize_language_for_engine("parakeet", "en-us") == "auto"
|
||||
assert normalize_language_for_engine("parakeet", "auto") == "auto"
|
||||
assert normalize_language_for_engine("whisper", "fr") == "fr"
|
||||
assert normalize_language_for_engine("vosk", "en-us") == "en-us"
|
||||
|
||||
|
||||
def test_speech_manager_init_normalizes_parakeet_language():
|
||||
"""SpeechRecognitionManager must store auto even when constructed with a code."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
||||
|
||||
with patch.object(SpeechRecognitionManager, "_init_parakeet"):
|
||||
manager = SpeechRecognitionManager(
|
||||
engine="parakeet",
|
||||
model_size="v3-european",
|
||||
language="fr",
|
||||
defer_download=True,
|
||||
)
|
||||
assert manager.language == "auto"
|
||||
|
||||
|
||||
def test_speech_manager_reconfigure_to_parakeet_normalizes_language():
|
||||
"""Switching to Parakeet must clear a leftover catalog language."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
||||
|
||||
with (
|
||||
patch.object(SpeechRecognitionManager, "_init_vosk"),
|
||||
patch.object(SpeechRecognitionManager, "_init_parakeet"),
|
||||
):
|
||||
manager = SpeechRecognitionManager(
|
||||
engine="vosk",
|
||||
model_size="small",
|
||||
language="en-us",
|
||||
defer_download=True,
|
||||
)
|
||||
assert manager.language == "en-us"
|
||||
manager.reconfigure(engine="parakeet", model_size="v3-european", language="fr")
|
||||
assert manager.engine == "parakeet"
|
||||
assert manager.language == "auto"
|
||||
|
||||
|
||||
def test_main_startup_normalizes_parakeet_language():
|
||||
"""CLI/startup path must call the shared Parakeet language normalizer."""
|
||||
from vocalinux import main as main_mod
|
||||
|
||||
src = inspect.getsource(main_mod.main)
|
||||
assert "normalize_language_for_engine" in src
|
||||
|
||||
|
||||
def test_speech_manager_reconfigure_to_parakeet_clears_leftover_without_language():
|
||||
"""Switching TO Parakeet without a language arg must still drop leftover language."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
||||
|
||||
with (
|
||||
patch.object(SpeechRecognitionManager, "_init_whispercpp"),
|
||||
patch.object(SpeechRecognitionManager, "_init_parakeet"),
|
||||
):
|
||||
manager = SpeechRecognitionManager(
|
||||
engine="whisper_cpp",
|
||||
model_size="small",
|
||||
language="de",
|
||||
defer_download=True,
|
||||
)
|
||||
assert manager.language == "de"
|
||||
manager.reconfigure(engine="parakeet", model_size="v3-european", force_download=False)
|
||||
assert manager.engine == "parakeet"
|
||||
assert manager.language == "auto"
|
||||
|
||||
|
||||
def test_speech_manager_reconfigure_parakeet_language_arg_stays_auto():
|
||||
"""A catalog language passed while already on Parakeet must not stick."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
||||
|
||||
with patch.object(SpeechRecognitionManager, "_init_parakeet"):
|
||||
manager = SpeechRecognitionManager(
|
||||
engine="parakeet",
|
||||
model_size="v3-european",
|
||||
language="auto",
|
||||
defer_download=True,
|
||||
)
|
||||
manager.reconfigure(language="fr", force_download=False)
|
||||
assert manager.language == "auto"
|
||||
|
||||
@@ -2087,6 +2087,50 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sherpa-onnx"
|
||||
version = "1.13.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/05/580c097b8c62a290238fdf1d3db337cd1e897d6b0c5018645552959e461f/sherpa_onnx-1.13.7.tar.gz", hash = "sha256:58ec30d2702ebe86889fc24c077f5ecf6cbdd57d260eca00f179513a0d8bb6ee", size = 1045298, upload-time = "2026-09-01T06:15:56.806Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/76/3b7f7eb7e2676c0dd445b267f1e1bfacf9d18ebc1dd9a402d20db1cc3148/sherpa_onnx-1.13.7-cp311-cp311-linux_armv7l.whl", hash = "sha256:234a4ef4ce48d98a7e12e278c0e9b69eeca6b571ca0057f2553781ded14ba099", size = 12001692, upload-time = "2026-09-01T07:28:47.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/42/730818dcd43361b7aac892a232dd0eac6a184d44a930d58e397824d12c69/sherpa_onnx-1.13.7-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:9a88cfd3b6296b1e9056a6ab843f9f7c4e362f6f27c26e33aafbf917f536d20b", size = 4419440, upload-time = "2026-09-01T06:22:02.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/d6/211eccfaf2fc903a962e4f60a0222133f5cf7154de1d4012eff06e5556d5/sherpa_onnx-1.13.7-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:803013701e0acb95c704143adc2ac9c4dc587dfeba7a9bc5cfafd5d70da921b1", size = 2324512, upload-time = "2026-09-01T06:32:58.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/51/4b3c17afe095cec4132fd456589d9f24a676c1bd35e86b13389fb62cd89a/sherpa_onnx-1.13.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16292efe5fa07ca4532f57c2b5e67f2ef8cad83c418a60593a2dc60438048836", size = 2133569, upload-time = "2026-09-01T06:31:30.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/22/8b696781f9f026ad72999a66e44a6605b5b9806a7ea25e79275c901178fb/sherpa_onnx-1.13.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05e4d9b208ea08b48cd5aec0a07bc3134c6ca5091d7a3df75fe70dd326eff745", size = 4169461, upload-time = "2026-09-01T05:36:15.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/8c/537e68b796c6085fd41f524df81b9875167c4ab622121dc67176c22f0659/sherpa_onnx-1.13.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01c4d001390f282bef653d229dce5d9da5009a804999449e33f890139f1f2da0", size = 4393122, upload-time = "2026-09-01T06:19:07.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/81/dbed4befa8d6e32f5efffe59c6cf499fd79a8af691b3db1296baff1ed1cd/sherpa_onnx-1.13.7-cp311-cp311-win32.whl", hash = "sha256:511edfcedf9b90c4b3b44a14725e9397da95e9b9322efd6772c8e7d7e520d0de", size = 1961573, upload-time = "2026-09-01T06:56:29.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/d1/31b1cc0d53c6b074723b78a75fc13cfebe9328590cea6b67a3f49511080d/sherpa_onnx-1.13.7-cp311-cp311-win_amd64.whl", hash = "sha256:7b3bef2a558f57c403bb7afb994e081cd242e5df3384295b6738b198cb2f8382", size = 2277232, upload-time = "2026-09-01T06:16:07.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/74/9d2fe7f81062a4d961ef7a186608ea34f5d52e6c33002863bf8709c1c659/sherpa_onnx-1.13.7-cp311-cp311-win_arm64.whl", hash = "sha256:6dc8cd03e88a9d1e451bf83d3956c72cd78c5eb4d5a1af602243de75f77ff976", size = 2239366, upload-time = "2026-09-01T05:53:25.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/bb/5ef6c95527b13c8a1734aa5201331f85ea13652cc021eb2c0a302ba86612/sherpa_onnx-1.13.7-cp312-cp312-linux_armv7l.whl", hash = "sha256:a641badd087c9a82a90fe4127ff0b29921348e6a86c21e94d59e3b5cc8321428", size = 12004668, upload-time = "2026-09-01T07:22:32.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/97/a46f3f36bf42ef0f94e56a9d10c1329bf92d74632f937a29d4e6c8ceeb3b/sherpa_onnx-1.13.7-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:fce7cfab223d6bee0a58fa940b5057e2f3cf63613928b88e09ac99b477e40be0", size = 4443852, upload-time = "2026-09-01T07:00:29.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/2d/0cc3d4f91ee56ca8dfd82b9922a859613e77fe831379ea2b7eb6db6af0f1/sherpa_onnx-1.13.7-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7fbdc37a3b2a1f45369a918b47960035fd15b0d365d42157d5cdc5335271ab86", size = 2342763, upload-time = "2026-09-01T05:09:34.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ad/996399e34344faadbb4bb34a45b7300f5ee317bd2ae3c80564ae32764613/sherpa_onnx-1.13.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fed61cc23dba8f212eeb2723d2e90108d6de95734c5ae427aeafdb10f09ec184", size = 2140038, upload-time = "2026-09-01T06:30:32.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/41/1445fa6e18239a3b919acf15c0440c9c230fef91af11ebb51bf299ab46f1/sherpa_onnx-1.13.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9d26e4514a04ddd344970b82c7b32e4e1de332312af5cd2f0e8dc385021d4c5e", size = 4170919, upload-time = "2026-09-01T05:27:58.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/73/ff8144118eeaafb1e26d5c6303a2d539c3eaa7bff124d84d20d03b6f28fa/sherpa_onnx-1.13.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b4cce2e44f989f6c830103c8449e4df84833f66fa4f625fd092270b54918a55", size = 4393531, upload-time = "2026-09-01T06:28:46.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/01/a938f5bff5238536e41bc55935252b2ca420bdef5e5ffebc531328f971ff/sherpa_onnx-1.13.7-cp312-cp312-win32.whl", hash = "sha256:c5d99c89a1c83bed2e930798f614deed05c41136e0e3cc046977320132f56473", size = 1962873, upload-time = "2026-09-01T07:01:01.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/3d/7b5cdf6a1c01080b6c579134fba082c4fc35b13bc6e35d85285ab8fc9380/sherpa_onnx-1.13.7-cp312-cp312-win_amd64.whl", hash = "sha256:d6b8adb339e0e89cf9914de610c85d2d5e4715b09f566eaf796a92eb72c014d1", size = 2282452, upload-time = "2026-09-01T06:14:48.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/41/0bd297439f143f573b176ab6a59354e51c9c59886174580efab1308ff2bf/sherpa_onnx-1.13.7-cp312-cp312-win_arm64.whl", hash = "sha256:0e9445d5d3f65a87b7a158edad4c630f21fcb1692aa006d19d70cbbccd38d71d", size = 2239075, upload-time = "2026-09-01T05:44:24.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/37/80a67d0fd6e12b8e12af52ed86d309ac3a36376ff6a2b5c2f8527a076a6b/sherpa_onnx-1.13.7-cp313-cp313-linux_armv7l.whl", hash = "sha256:5094fac8ece316383f43c9fed287e5852057d21c1fb99645a75741386bde660b", size = 12004242, upload-time = "2026-09-01T06:12:56.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/db/bee7a969f111b1f3b6edf4af99fcf538e21c98fc79d3ac033b9d262243b5/sherpa_onnx-1.13.7-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:ed40aee149f9e2e47d0b5cf1be21b2d662e5816bebdabdcdbbdb98b8a9928cbe", size = 4443848, upload-time = "2026-09-01T06:07:46.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/d5/c33b981797afbbcb0a878789caa4ea77af1ead94076a25352a504e806ef3/sherpa_onnx-1.13.7-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:d7d00b9cec5011d518ae3a99c646fc3d058a8261b58044fac33f25cda0a9147c", size = 2342933, upload-time = "2026-09-01T06:17:04.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/6c/45ea109efd3cc6a3841c3bb33af365bc7abf2692335c39056c3998917610/sherpa_onnx-1.13.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0508bcf514b0ee914f57d2517e3783e383f68e5984a815f303284331097ac3de", size = 2140116, upload-time = "2026-09-01T05:33:54.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/ce/59838be7d94cdaaf3d20c13f65030ec5a98112cdd5ff69e911f3a0959086/sherpa_onnx-1.13.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3bc1fe8dd85a6db146b21db6a1900e1fea0fb4a0dbc27af4401d35407effa401", size = 4169664, upload-time = "2026-09-01T05:39:05.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/73/b1de9c13e8bc2d9bda56e8b39ef95a57d8d1be58db9f4e5eac7fa95a4521/sherpa_onnx-1.13.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71a32c04e34c7c4cfc9300c2c4c3ede9cd8b0d1b00ccf0bfa24f202402368855", size = 4393356, upload-time = "2026-09-01T06:10:36.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/67/dbcb4928a9d3269f32582ad590b25edc80ff862f52a0854a07688e2c9cf4/sherpa_onnx-1.13.7-cp313-cp313-win32.whl", hash = "sha256:68183b11eda2a866231b9e9aaa35b77222918edef5848a4ff6add6d89f523f6d", size = 1963507, upload-time = "2026-09-01T06:32:41.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/cf/29c88ff25004b8ab6e6f93cd3a27cc259ab7dcb82b6a842e166b5ca85311/sherpa_onnx-1.13.7-cp313-cp313-win_amd64.whl", hash = "sha256:9fb46a9b0844b2636b099dfa5ef26513fa0b91df28cd710a97d1cda2c9344fec", size = 2282759, upload-time = "2026-09-01T06:43:39.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/e8/20ced5f876271bbaf2e3bbe2a884265a4e31e382433ec6a3975e8ef0d082/sherpa_onnx-1.13.7-cp313-cp313-win_arm64.whl", hash = "sha256:c2e9c56d453bc804592d65834504e376a4fa90488adba042becf5213cf226328", size = 2241664, upload-time = "2026-09-01T05:56:22.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/03/c9b41e31bb4ccb64b31618eca1ab857331a9a6285a3ae31697b348556cd3/sherpa_onnx-1.13.7-cp314-cp314-linux_armv7l.whl", hash = "sha256:026917d88fde36d5d86661e4a61c968dd741f43a96773ad742da13bffd030471", size = 12002502, upload-time = "2026-09-01T07:18:23.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/22/e98d505f561c4cf354e5d411d78f6037f23a311fd782836b741f3061a3a4/sherpa_onnx-1.13.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:aa04b2ecf7563f86e8a34e3e2666fc0b480618ccca11c6d629dcb31323c1d041", size = 4446936, upload-time = "2026-09-01T07:03:40.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/8a/f7596357d11117c31b2c044a65669e67225d4b0436ebe8df05f8e6835386/sherpa_onnx-1.13.7-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3f58d2c3ece36b908cfb070ebe1b23c7bfaadd78a0ce205b412349c53375a4dd", size = 2343524, upload-time = "2026-09-01T06:20:31.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/97/fb50b5a88e1e16ffdf240c7912d1742744f3514b4f92747e96d112c2d735/sherpa_onnx-1.13.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1333b929a71830ecd2f945e3c06f9ed2207e4aa9642a3a84ad1cbd2c9ffa230b", size = 2143031, upload-time = "2026-09-01T05:44:42.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/40/856512a73abd175029d306f53a9cf93032a4e4b7aebbc8a91a11e1bdd925/sherpa_onnx-1.13.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:470f2e2251d13c632b6ba37878bb355cd4672b6feedc57e9d9823bb04ed5df84", size = 4175477, upload-time = "2026-09-01T06:07:51.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/35/307770b20118300068964f79ce6ea7bed02dd0fc0390534b05f2b362bed3/sherpa_onnx-1.13.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08cdb0b0971c27d7f8d13c7c6b090dd284469c46fb2469f23f9d21905db4c6d2", size = 4394157, upload-time = "2026-09-01T05:56:07.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/ad/93ec56bc3470841755be3e29f824428f3a7244f728d2335cfcc666647823/sherpa_onnx-1.13.7-cp314-cp314-win32.whl", hash = "sha256:4a149bb0e5d8ff382d9d0ec906411d5ba220b8732a7155c0e0cbee27b5a5ff19", size = 2002910, upload-time = "2026-09-01T05:42:50.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/39/67a7024adafa3b4e3593b4ad7e138ee754eb4972cd53ef7749fc7d7b6a93/sherpa_onnx-1.13.7-cp314-cp314-win_amd64.whl", hash = "sha256:d22753738497c162f381c545c35ee245d160031faa3993b5e3d3424ceb80c3a3", size = 2348192, upload-time = "2026-09-01T06:21:17.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/07/58fb06acf48e3040ee9aba95a7330f0804bf5ec8868ae49e90872b4bf979/sherpa_onnx-1.13.7-cp314-cp314-win_arm64.whl", hash = "sha256:52ca4392620bb4a0cb30373f245861107516c8831bface1c82c17a9e34f3f5c9", size = 2305339, upload-time = "2026-09-01T06:05:03.446Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
@@ -2548,6 +2592,9 @@ docs = [
|
||||
{ name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||
{ name = "sphinx-rtd-theme" },
|
||||
]
|
||||
parakeet = [
|
||||
{ name = "sherpa-onnx" },
|
||||
]
|
||||
vad = [
|
||||
{ name = "onnxruntime" },
|
||||
]
|
||||
@@ -2585,13 +2632,14 @@ requires-dist = [
|
||||
{ name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.1.0" },
|
||||
{ name = "pywhispercpp", specifier = ">=1.5.0,<2" },
|
||||
{ name = "requests", specifier = ">=2.28.0" },
|
||||
{ name = "sherpa-onnx", marker = "extra == 'parakeet'", specifier = ">=1.12.0" },
|
||||
{ name = "sphinx", marker = "extra == 'docs'", specifier = ">=6.0.0" },
|
||||
{ name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.2.0" },
|
||||
{ name = "torch", marker = "extra == 'whisper'", specifier = ">=2.0.0" },
|
||||
{ name = "torchaudio", marker = "extra == 'whisper'", specifier = ">=2.0.0" },
|
||||
{ name = "vosk", marker = "extra == 'vosk'", specifier = ">=0.3.45" },
|
||||
]
|
||||
provides-extras = ["vosk", "whisper", "vad", "dev", "docs"]
|
||||
provides-extras = ["vosk", "whisper", "parakeet", "vad", "dev", "docs"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
lint = [
|
||||
|
||||
Reference in New Issue
Block a user