diff --git a/README.md b/README.md index ff2a6b9..2a02893 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/install.sh b/install.sh index b4c1a34..35c7aeb 100755 --- a/install.sh +++ b/install.sh @@ -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)" diff --git a/pyproject.toml b/pyproject.toml index 145ed58..aae5160 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,9 @@ whisper = [ "torch>=2.0.0", "torchaudio>=2.0.0", ] +parakeet = [ + "sherpa-onnx>=1.12.0", +] vad = [ "onnxruntime>=1.16.0", ] diff --git a/scripts/generate-model-checksums.py b/scripts/generate-model-checksums.py index 3229efe..d0389bd 100644 --- a/scripts/generate-model-checksums.py +++ b/scripts/generate-model-checksums.py @@ -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 diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index f842d04..c2e40bf 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -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)") diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 6950192..33a6f80 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -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: diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 2d03a8c..cbef910 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -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 diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index e639c0e..3fa85c2 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -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(): diff --git a/src/vocalinux/utils/model_checksums.txt b/src/vocalinux/utils/model_checksums.txt index e78e53d..27e5f74 100644 --- a/src/vocalinux/utils/model_checksums.txt +++ b/src/vocalinux/utils/model_checksums.txt @@ -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 diff --git a/src/vocalinux/utils/parakeet_model_info.py b/src/vocalinux/utils/parakeet_model_info.py new file mode 100644 index 0000000..2d15d79 --- /dev/null +++ b/src/vocalinux/utils/parakeet_model_info.py @@ -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" + ) diff --git a/tests/test_main.py b/tests/test_main.py index cb2e211..0247420 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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") diff --git a/tests/test_model_checksums.py b/tests/test_model_checksums.py index bbbb74a..9ca4544 100644 --- a/tests/test_model_checksums.py +++ b/tests/test_model_checksums.py @@ -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) diff --git a/tests/test_recognition_manager_downloads.py b/tests/test_recognition_manager_downloads.py index b824671..e8e21eb 100644 --- a/tests/test_recognition_manager_downloads.py +++ b/tests/test_recognition_manager_downloads.py @@ -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.""" diff --git a/tests/test_recommendation_is_actionable.py b/tests/test_recommendation_is_actionable.py index 118c97e..74cb984 100644 --- a/tests/test_recommendation_is_actionable.py +++ b/tests/test_recommendation_is_actionable.py @@ -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 diff --git a/tests/test_settings_model_state.py b/tests/test_settings_model_state.py index c4a527b..b26e172 100644 --- a/tests/test_settings_model_state.py +++ b/tests/test_settings_model_state.py @@ -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" diff --git a/uv.lock b/uv.lock index 406d658..a2d1078 100644 --- a/uv.lock +++ b/uv.lock @@ -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 = [