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:
Jatin K Malik
2026-09-07 14:32:13 -07:00
committed by GitHub
co-authored by Alex Thunder
parent 946e19ee41
commit 8f4aeca2f1
16 changed files with 1444 additions and 39 deletions
+1
View File
@@ -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
View File
@@ -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)"
+3
View File
@@ -57,6 +57,9 @@ whisper = [
"torch>=2.0.0",
"torchaudio>=2.0.0",
]
parakeet = [
"sherpa-onnx>=1.12.0",
]
vad = [
"onnxruntime>=1.16.0",
]
+50 -2
View File
@@ -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
View File
@@ -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:
+1
View File
@@ -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
+191 -15
View File
@@ -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():
+12 -1
View File
@@ -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
+140
View File
@@ -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"
)
+102
View File
@@ -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")
+16
View File
@@ -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)
+189 -16
View File
@@ -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
+399
View File
@@ -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"
Generated
+49 -1
View File
@@ -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 = [