diff --git a/install.sh b/install.sh index 35c7aeb..872aeb7 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, parakeet, remote_api" + echo " --engine=NAME Speech engine: whisper_cpp (default), whisper, vosk, parakeet, faster_whisper, 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" @@ -381,6 +381,7 @@ while [[ $# -gt 0 ]]; do echo " $0 # Interactive mode (recommended)" echo " $0 --auto # Auto-install with whisper.cpp" echo " $0 --auto --engine=vosk # Auto-install VOSK only" + echo " $0 --auto --engine=faster_whisper # Auto-install faster-whisper" echo " $0 --dev --test # Dev mode with tests" echo "" echo "During installation a full transcript is saved to" @@ -1251,7 +1252,15 @@ EOF echo " └─────────────────────────────────────────────────────────────┘" echo "" echo " ┌───────────────────────────────────────────────────────────────┐" - echo " │ 4. REMOTE API (ADVANCED) │" + echo " │ 4. FASTER-WHISPER │" + echo " │ • Optional CTranslate2 Whisper backend │" + echo " │ • Fast on CPU with INT8 quantization │" + echo " │ • Best performance on NVIDIA GPUs (CUDA) │" + echo " │ • Hugging Face models stay checksum-gated (not auto-dl) │" + echo " └───────────────────────────────────────────────────────────────┘" + echo "" + echo " ┌───────────────────────────────────────────────────────────────┐" + echo " │ 5. REMOTE API (ADVANCED) │" echo " │ • Offload processing to a GPU server on your network │" echo " │ • Ideal for laptops without GPU │" echo " │ • Supports whisper.cpp server & OpenAI-compatible APIs │" @@ -1277,7 +1286,7 @@ EOF esac echo "" - read -p "Choose engine [1-4] (default: $DEFAULT_CHOICE): " ENGINE_CHOICE + read -p "Choose engine [1-5] (default: $DEFAULT_CHOICE): " ENGINE_CHOICE ENGINE_CHOICE=${ENGINE_CHOICE:-$DEFAULT_CHOICE} case "$ENGINE_CHOICE" in @@ -1294,6 +1303,10 @@ EOF ENGINE_DISPLAY="VOSK (Lightweight)" ;; 4) + SELECTED_ENGINE="faster_whisper" + ENGINE_DISPLAY="Faster-Whisper" + ;; + 5) SELECTED_ENGINE="remote_api" ENGINE_DISPLAY="Remote API" ;; @@ -1547,13 +1560,12 @@ fi # Set default engine for auto/non-interactive mode if [[ "$NON_INTERACTIVE" == "yes" ]] && [[ -z "$SELECTED_ENGINE" ]]; then - # Default to whisper.cpp for best performance SELECTED_ENGINE="whisper_cpp" print_info "Automatic mode: Installing with whisper.cpp (default engine)" - print_info "For other engines, use: --engine=whisper or --engine=vosk or --engine=remote_api" - echo "" + print_info "For other engines, use: --engine=whisper, --engine=vosk, --engine=parakeet, --engine=faster_whisper, or --engine=remote_api" fi + # Function to check if a command exists command_exists() { command -v "$1" >/dev/null 2>&1 @@ -2914,6 +2926,7 @@ engine_import_module() { whisper) echo "whisper" ;; whisper_cpp) echo "pywhispercpp.model" ;; parakeet) echo "sherpa_onnx" ;; + faster_whisper) echo "faster_whisper" ;; *) echo "" ;; esac } @@ -2924,6 +2937,7 @@ engine_pip_name() { whisper) echo "openai-whisper" ;; whisper_cpp) echo "pywhispercpp" ;; parakeet) echo "sherpa-onnx" ;; + faster_whisper) echo "faster-whisper" ;; *) echo "" ;; esac } @@ -3611,6 +3625,35 @@ REMOTE_CONFIG print_warning "No server URL configured. You can set it later in Settings." fi ;; + + faster_whisper) + print_info "Installing Faster-Whisper engine..." + print_info "This engine uses CTranslate2 for fast CPU inference." + + if pip_install_extras_skip_pygobject "$PIP_LOG_FILE" faster_whisper; then + mkdir -p "$CONFIG_DIR" + if [ ! -f "$CONFIG_DIR/config.json" ]; then + cat > "$CONFIG_DIR/config.json" << 'FASTER_WHISPER_CONFIG' +{ + "shortcuts": { + "toggle_recognition": "right_alt+right_alt", + "mode": "push_to_talk" + } +} +FASTER_WHISPER_CONFIG + fi + set_configured_engine "$CONFIG_DIR/config.json" faster_whisper || + print_warning "Could not point $CONFIG_DIR/config.json at the faster_whisper engine." + else + print_error "Failed to install the faster-whisper engine" + print_error "Falling back to whisper.cpp (recommended engine)" + install_cpu_pywhispercpp "$PIP_LOG_FILE" || { + print_error "Failed to install whisper.cpp fallback" + return 1 + } + SELECTED_ENGINE="whisper_cpp" + fi + ;; esac fi @@ -4573,6 +4616,20 @@ verify_installation() { fi fi + if [[ "$selected_engine" == "faster_whisper" ]]; then + if ! "$VENV_DIR/bin/python" -c "import faster_whisper" 2>/dev/null; then + print_error "faster-whisper package installed but cannot be imported at runtime." + print_error "" + print_error "Diagnostic steps:" + print_error " 1. Check pip installation: $VENV_DIR/bin/pip show faster-whisper" + print_error " 2. Re-run the installer with: --engine=faster_whisper" + print_error " 3. Or switch to whisper.cpp: --engine=whisper_cpp" + ISSUES=$((ISSUES + 1)) + else + print_success "faster-whisper import verified successfully." + fi + fi + # Return the number of issues found return $ISSUES } @@ -4627,6 +4684,10 @@ EOF ENGINE_DISPLAY_NAME="Parakeet" BACKEND_INFO="CPU" ;; + faster_whisper) + ENGINE_DISPLAY_NAME="Faster-Whisper" + BACKEND_INFO="PyTorch/CTranslate2" + ;; remote_api) ENGINE_DISPLAY_NAME="Remote API" BACKEND_INFO="Network (offloaded to remote server)" diff --git a/justfile b/justfile index 923d361..d5ca285 100644 --- a/justfile +++ b/justfile @@ -159,7 +159,8 @@ lock-check: # `lfs` metadata and cost no bandwidth; VOSK is pinned by the bytes we fetch, so # every zip not already in the manifest is downloaded. A first run, or any run # with --refresh, pulls ~21.9GB and takes ~30 minutes. -# Run after adding a model to vosk_model_info.py or whispercpp_model_info.py. +# Run after adding a model to vosk_model_info.py, whispercpp_model_info.py, +# parakeet_model_info.py or faster_whisper_model_info.py. model-checksums: _tooling uv run --no-sync python scripts/generate-model-checksums.py diff --git a/pyproject.toml b/pyproject.toml index aae5160..4cf123c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,9 @@ whisper = [ parakeet = [ "sherpa-onnx>=1.12.0", ] +faster_whisper = [ + "faster-whisper>=1.0.0", +] vad = [ "onnxruntime>=1.16.0", ] @@ -114,6 +117,7 @@ packages = [ "vocalinux.resources.sounds", "vocalinux.speech_recognition", "vocalinux.speech_recognition.data", + "vocalinux.speech_recognition.engines", "vocalinux.text_injection", "vocalinux.ui", "vocalinux.ui.keyboard_backends", diff --git a/scripts/generate-model-checksums.py b/scripts/generate-model-checksums.py index d0389bd..7c26f37 100644 --- a/scripts/generate-model-checksums.py +++ b/scripts/generate-model-checksums.py @@ -26,8 +26,9 @@ costs: pulls every VOSK zip: ~21.9GB, roughly half an hour on a fast connection. The model names come from the package itself, so a model added to -vosk_model_info.py or whispercpp_model_info.py is picked up on the next run and -a model that upstream never published fails the generation loudly. +vosk_model_info.py, whispercpp_model_info.py, parakeet_model_info.py or +faster_whisper_model_info.py is picked up on the next run and a model that +upstream never published fails the generation loudly. Usage: just model-checksums (or: python scripts/generate-model-checksums.py) """ @@ -162,6 +163,46 @@ def _parakeet_entries() -> list[Entry]: return entries +def _faster_whisper_entries() -> list[Entry]: + """Digests for every file of every faster-whisper bundle, at its pinned revision. + + ``model.bin`` is LFS (Hugging Face publishes sha256 on these repos); config, + tokenizer and vocabulary files are plain blobs hashed from the bytes this + script downloads, the same way Parakeet's tokens.txt is. + """ + sys.path.insert(0, str(SRC)) + from vocalinux.utils.faster_whisper_model_info import ( + FASTER_WHISPER_MODEL_INFO, + manifest_key, + model_files, + ) + + entries = [] + for model_name, info in FASTER_WHISPER_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("sha256") or lfs.get("oid") + if digest: + published[sibling["rfilename"]] = (digest, int(lfs.get("size", 0))) + + for filename in model_files(model_name): + 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") @@ -232,7 +273,8 @@ 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, whispercpp_model_info.py or parakeet_model_info.py.", + "# vosk_model_info.py, whispercpp_model_info.py, parakeet_model_info.py", + "# or faster_whisper_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.", @@ -247,8 +289,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.", + "# Parakeet and faster-whisper bundles are pinned per model in their", + "# model_info modules, so their keys carry the model name: several", + "# bundles ship the same file names.", "", ] for entry in entries: @@ -279,8 +322,15 @@ def main() -> int: parakeet_entries = _parakeet_entries() print(f" Parakeet: {len(parakeet_entries)} sha256 digests") + faster_whisper_entries = _faster_whisper_entries() + print(f" faster-whisper: {len(faster_whisper_entries)} sha256 digests") + MANIFEST.write_text( - _render(whispercpp_entries + vosk_entries + parakeet_entries, revision), encoding="utf-8" + _render( + whispercpp_entries + vosk_entries + parakeet_entries + faster_whisper_entries, + revision, + ), + encoding="utf-8", ) print(f"Wrote {MANIFEST.relative_to(REPO_ROOT)}") return 0 diff --git a/src/vocalinux/common_types.py b/src/vocalinux/common_types.py index c684190..db4d1f1 100644 --- a/src/vocalinux/common_types.py +++ b/src/vocalinux/common_types.py @@ -16,6 +16,41 @@ class RecognitionState(Enum): ERROR = auto() +class EngineType(Enum): + """Supported speech recognition engine identifiers.""" + + VOSK = "vosk" + WHISPER = "whisper" + WHISPER_CPP = "whisper_cpp" + PARAKEET = "parakeet" + FASTER_WHISPER = "faster_whisper" + REMOTE_API = "remote_api" + + +class Engine(Protocol): + """Protocol for a speech recognition engine backend. + + New engines should implement this minimal contract so they can be registered + in ``speech_recognition.engines`` and used by the manager. + """ + + def init(self) -> None: + """Initialize the engine and load any required models.""" + ... + + def transcribe(self, audio_buffer: list[bytes]) -> str: + """Transcribe the captured audio and return the recognized text.""" + ... + + def is_ready(self) -> bool: + """Return True if the engine is initialized and ready to transcribe.""" + ... + + def cleanup(self) -> None: + """Release any engine resources.""" + ... + + class SpeechRecognitionManagerProtocol(Protocol): """Protocol defining the interface for SpeechRecognitionManager.""" diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index c2e40bf..ef44508 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", "parakeet", "remote_api"], + choices=["vosk", "whisper", "whisper_cpp", "parakeet", "faster_whisper", "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") diff --git a/src/vocalinux/speech_recognition/engines/__init__.py b/src/vocalinux/speech_recognition/engines/__init__.py new file mode 100644 index 0000000..b34fb7a --- /dev/null +++ b/src/vocalinux/speech_recognition/engines/__init__.py @@ -0,0 +1,21 @@ +"""Speech recognition engine registry for Vocalinux. + +New engine implementations are registered here so the recognition manager can +discover them without hard-coding every backend. +""" + +from typing import Type + +from ...common_types import Engine, EngineType + +ENGINES: dict[EngineType, Type[Engine]] = {} + +# Register faster-whisper when available. +try: + from .faster_whisper_engine import FasterWhisperEngine + + ENGINES[EngineType.FASTER_WHISPER] = FasterWhisperEngine +except Exception: + pass + +__all__ = ["ENGINES"] diff --git a/src/vocalinux/speech_recognition/engines/faster_whisper_engine.py b/src/vocalinux/speech_recognition/engines/faster_whisper_engine.py new file mode 100644 index 0000000..e6e7f40 --- /dev/null +++ b/src/vocalinux/speech_recognition/engines/faster_whisper_engine.py @@ -0,0 +1,174 @@ +"""Faster-Whisper speech recognition engine for Vocalinux. + +This module wraps `faster_whisper.WhisperModel` behind the Engine Protocol. It +reuses the same audio preprocessing used by the OpenAI Whisper backend and is +designed to be significantly faster on CPU thanks to CTranslate2 and INT8 +quantization. +""" + +import logging +from typing import Iterable, Optional, Protocol + +import numpy as np + +from ...utils.faster_whisper_model_info import ( + FASTER_WHISPER_MODEL_INFO, + _has_torch_cuda, + get_compute_type, + get_model_path, + get_recommended_model, +) + +logger = logging.getLogger(__name__) + + +class _Segment(Protocol): + """Minimal Protocol for a faster-whisper transcription segment.""" + + text: str + + +class _WhisperModel(Protocol): + """Minimal Protocol for faster-whisper.WhisperModel API surface used here.""" + + def transcribe( + self, + audio: np.ndarray, + language: Optional[str], + task: str, + beam_size: int, + best_of: int, + condition_on_previous_text: bool, + ) -> tuple[Iterable[_Segment], object]: + """Transcribe audio and return segments with metadata.""" + ... + + +class FasterWhisperEngine: + """faster-whisper backend implementing the Engine Protocol.""" + + def __init__( + self, + model_size: Optional[str] = None, + device: Optional[str] = None, + language: str = "auto", + ) -> None: + """Create a faster-whisper engine instance. + + Args: + model_size: faster-whisper model name (e.g. "tiny"). Defaults to the + recommended model for the current system. + device: "cpu" or "cuda". Auto-detected when None. + language: Language code ("auto" for auto-detect). + """ + self.model_size = model_size or get_recommended_model()[0] + self.language = language + self._device = device + self._model: Optional[_WhisperModel] = None + self._model_initialized = False + + @property + def device(self) -> str: + """Return the compute device, detecting CUDA if not explicitly set.""" + if self._device is not None: + return self._device + return "cuda" if _has_torch_cuda() else "cpu" + + def init(self) -> None: + """Load the faster-whisper model.""" + if self._model_initialized: + return + + try: + from faster_whisper import WhisperModel + except ImportError as e: + logger.error(f"Failed to import faster-whisper: {e}") + raise + + if self.model_size not in FASTER_WHISPER_MODEL_INFO: + logger.warning( + f"Model size '{self.model_size}' not valid for faster-whisper. " + f"Valid options: {list(FASTER_WHISPER_MODEL_INFO.keys())}. Using 'tiny'." + ) + self.model_size = "tiny" + + device = self.device + compute_type = get_compute_type(device) + + logger.info( + f"Loading faster-whisper '{self.model_size}' model on {device} " + f"with compute_type={compute_type}" + ) + + # Load the checksum-gated snapshot from models_dir. local_files_only + # keeps faster-whisper from fetching an unpinned Hugging Face revision. + model_dir = get_model_path(self.model_size) + self._model = WhisperModel( + model_dir, + device=device, + compute_type=compute_type, + local_files_only=True, + ) + self._model_initialized = True + logger.info("faster-whisper engine initialized successfully.") + + def is_ready(self) -> bool: + """Return True if the model has been loaded successfully.""" + return self._model_initialized and self._model is not None + + def cleanup(self) -> None: + """Release the loaded model.""" + self._model = None + self._model_initialized = False + + def _normalize_language(self) -> Optional[str]: + """Map Vocalinux language codes to faster-whisper language codes.""" + if self.language == "auto": + return None + if self.language == "en-us" or self.language == "en-in": + return "en" + return self.language + + def transcribe(self, audio_buffer: list[bytes]) -> str: + """Transcribe the provided audio buffer. + + Args: + audio_buffer: List of audio data chunks (16-bit PCM at 16kHz). + + Returns: + Recognized text. + """ + if not self.is_ready(): + logger.warning("faster-whisper engine is not initialized") + return "" + + model = self._model + if model is None or not audio_buffer: + return "" + + try: + audio_data = np.frombuffer(b"".join(audio_buffer), dtype=np.int16) + audio_float = audio_data.astype(np.float32) / 32768.0 + + segments, _info = model.transcribe( + audio_float, + language=self._normalize_language(), + task="transcribe", + beam_size=5, + best_of=5, + condition_on_previous_text=False, + ) + + text_parts = [segment.text.strip() for segment in segments if segment.text] + text = " ".join(text_parts).strip() + + if text: + logger.info(f"faster-whisper transcribed: '{text}'") + else: + logger.debug("faster-whisper returned empty transcription") + + return text + + except (RuntimeError, OSError, ValueError) as e: + logger.error(f"Error in faster-whisper transcription: {e}", exc_info=True) + return "" diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 946f8d2..48b8934 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 faster_whisper_model_info as faster_whisper from ..utils import parakeet_model_info as parakeet from ..utils.host_process import host_env from ..utils.model_checksums import ( @@ -1062,6 +1063,8 @@ class SpeechRecognitionManager: self.remote_api_model = kwargs.get("remote_api_model", "whisper-1") self._http_session = None + self._faster_whisper_engine = None + # Audio diagnostics tracking self._last_audio_level = 0.0 self._audio_level_callbacks: list[Callable[[float], None]] = [] @@ -1101,6 +1104,8 @@ class SpeechRecognitionManager: self._init_whispercpp() elif engine == "parakeet": self._init_parakeet() + elif engine == "faster_whisper": + self._init_faster_whisper() elif engine == "remote_api": self._init_remote_api() else: @@ -1112,6 +1117,62 @@ class SpeechRecognitionManager: return self.engine == "vosk" return bool(self._voice_commands_preference) + def _init_selected_engine(self) -> None: + """Initialize whichever engine ``self.engine`` currently names.""" + if self.engine == "vosk": + self._init_vosk() + elif self.engine == "whisper": + self._init_whisper() + elif self.engine == "whisper_cpp": + self._init_whispercpp() + elif self.engine == "parakeet": + self._init_parakeet() + elif self.engine == "faster_whisper": + self._init_faster_whisper() + elif self.engine == "remote_api": + self._init_remote_api() + else: + raise ValueError(f"Unsupported speech recognition engine: {self.engine}") + + # Every live field ``reconfigure()`` can write. A failed engine switch + # restores this set so dictation matches the persisted config, not the + # attempted-but-unsaved Settings values. + _RECONFIGURE_STATE_ATTRS: tuple[str, ...] = ( + "engine", + "model_size", + "language", + "vad_sensitivity", + "silence_timeout", + "audio_device_index", + "audio_device_name", + "_voice_commands_preference", + "_voice_commands_enabled", + "stop_sound_guard_ms", + "whispercpp_no_timestamps", + "whispercpp_no_context", + "whispercpp_initial_prompt", + "whispercpp_temperature", + "whispercpp_temperature_inc", + "whispercpp_entropy_thold", + "whispercpp_logprob_thold", + "whispercpp_no_speech_thold", + "whispercpp_n_threads", + "whispercpp_gpu_device", + "remote_api_url", + "remote_api_key", + "remote_api_endpoint", + "remote_api_model", + ) + + def _snapshot_reconfigure_state(self) -> dict[str, object]: + """Copy every live setting ``reconfigure()`` is about to mutate.""" + return {name: getattr(self, name) for name in self._RECONFIGURE_STATE_ATTRS} + + def _restore_reconfigure_state(self, previous: dict[str, object]) -> None: + """Write a ``_snapshot_reconfigure_state`` result back onto the manager.""" + for name, value in previous.items(): + setattr(self, name, value) + def _init_vosk(self): """Initialize the VOSK speech recognition engine.""" # VOSK doesn't support auto-detect, so fall back to en-us for "auto" @@ -1301,6 +1362,212 @@ class SpeechRecognitionManager: logger.error(f"Error in Whisper transcription: {e}", exc_info=True) return "" + def _download_faster_whisper_model(self) -> None: + """Download the faster-whisper model files with progress tracking.""" + import requests + + self._download_cancelled = False + + model_dir = faster_whisper.get_model_path(self.model_size) + os.makedirs(model_dir, exist_ok=True) + logger.info(f"Downloading faster-whisper '{self.model_size}' model to {model_dir}") + + bundle_files = faster_whisper.model_files(self.model_size) + # _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 file. + outer_callback = self._download_progress_callback + total_files = len(bundle_files) + + def file_progress(index: int, name: str) -> Callable[[float, float, str], None]: + def report(fraction: float, speed: float, status: str) -> None: + 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(bundle_files): + dest_path = os.path.join(model_dir, filename) + key = faster_whisper.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 faster-whisper 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 = faster_whisper.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: CTranslate2 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 faster-whisper 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 faster-whisper model: {e}") from e + except (ChecksumError, OSError, RuntimeError, ValueError) as e: + logger.error(f"An error occurred during faster-whisper 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("faster-whisper model downloaded successfully") + if self._download_progress_callback: + self._download_progress_callback(1.0, 0, "Complete!") + + @staticmethod + def _faster_whisper_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 CTranslate2. + """ + verified = True + for filename in faster_whisper.model_files(model_size): + path = os.path.join(model_dir, filename) + key = faster_whisper.manifest_key(model_size, filename) + try: + verify_model_file(path, key) + except ChecksumError as error: + logger.error("faster-whisper 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_faster_whisper(self) -> None: + """Initialize the faster-whisper speech recognition engine.""" + try: + from .engines.faster_whisper_engine import FasterWhisperEngine + except ImportError as e: + logger.error(f"Failed to import faster-whisper engine: {e}") + logger.error("Please install with: pip install faster-whisper") + self.state = RecognitionState.ERROR + raise + + try: + valid_models = list(faster_whisper.FASTER_WHISPER_MODEL_INFO.keys()) + if self.model_size not in valid_models: + logger.warning( + f"Model size '{self.model_size}' not valid for faster-whisper. " + f"Valid options: {valid_models}. Using 'tiny' instead." + ) + self.model_size = "tiny" + + model_dir = faster_whisper.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. CTranslate2 loads these through native code, + # so hash them here; a failure demotes the bundle to "not downloaded". + if faster_whisper.is_model_downloaded( + self.model_size + ) and not self._faster_whisper_model_is_verified(self.model_size, model_dir): + if faster_whisper.is_model_downloaded(self.model_size): + logger.error( + "Refusing to load unverified faster-whisper model at %s", + model_dir, + ) + self._model_initialized = False + if self._defer_download: + return + raise RuntimeError(f"faster-whisper model at {model_dir} failed verification") + + if not faster_whisper.is_model_downloaded(self.model_size): + if self._defer_download: + logger.info( + f"faster-whisper 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 faster-whisper '{self.model_size}' model...") + self._download_faster_whisper_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._faster_whisper_engine = FasterWhisperEngine( + model_size=self.model_size, + language=self.language, + ) + self._faster_whisper_engine.init() + self.model = self._faster_whisper_engine._model + self._model_initialized = True + logger.info("faster-whisper engine initialized successfully.") + except (ImportError, RuntimeError, OSError, ValueError, ChecksumError) as e: + logger.error(f"Failed to initialize faster-whisper engine: {e}", exc_info=True) + self.state = RecognitionState.ERROR + raise + + def _transcribe_with_faster_whisper(self, audio_buffer: list[bytes]) -> str: + """ + Transcribe audio buffer using faster-whisper. + + Args: + audio_buffer: List of audio data chunks (16-bit PCM at 16kHz) + + Returns: + Transcribed text + """ + try: + if not self._faster_whisper_engine or not self._faster_whisper_engine.is_ready(): + logger.warning("faster-whisper engine not ready during transcription") + return "" + + return self._faster_whisper_engine.transcribe(audio_buffer) + except (RuntimeError, OSError, ValueError) as e: + logger.error(f"Error in faster-whisper transcription: {e}", exc_info=True) + return "" + def _init_whispercpp(self): """Initialize the whisper.cpp speech recognition engine.""" try: @@ -3255,6 +3522,9 @@ class SpeechRecognitionManager: elif self.engine == "parakeet": text = self._transcribe_with_parakeet(audio_buffer) + elif self.engine == "faster_whisper": + text = self._transcribe_with_faster_whisper(audio_buffer) + elif self.engine == "remote_api": # Snapshot the HTTP session under lock to prevent race with # reconfigure() / reinitialize_after_resume() which close/recreate @@ -3424,6 +3694,11 @@ class SpeechRecognitionManager: f"audio_device={audio_device_index}, audio_device_name={audio_device_name}" ) + whispercpp_attrs = tuple( + name for name in self._RECONFIGURE_STATE_ATTRS if name.startswith("whispercpp_") + ) + previous = self._snapshot_reconfigure_state() + restart_needed = force_reinit old_engine = self.engine if engine is not None and engine != self.engine: @@ -3477,18 +3752,7 @@ class SpeechRecognitionManager: if "stop_sound_guard_ms" in kwargs: self.stop_sound_guard_ms = kwargs.get("stop_sound_guard_ms", self.stop_sound_guard_ms) - for param_name in ( - "whispercpp_no_timestamps", - "whispercpp_no_context", - "whispercpp_initial_prompt", - "whispercpp_temperature", - "whispercpp_temperature_inc", - "whispercpp_entropy_thold", - "whispercpp_logprob_thold", - "whispercpp_no_speech_thold", - "whispercpp_n_threads", - "whispercpp_gpu_device", - ): + for param_name in whispercpp_attrs: if param_name in kwargs: setattr(self, param_name, kwargs[param_name]) restart_needed = True @@ -3530,28 +3794,39 @@ class SpeechRecognitionManager: # Release old resources explicitly if necessary (Python's GC might handle it) self.model = None self.recognizer = None + if self._faster_whisper_engine is not None: + self._faster_whisper_engine.cleanup() + self._faster_whisper_engine = None if old_engine == "remote_api" and self.engine != "remote_api": if self._http_session is not None: self._http_session.close() self._http_session = None try: - if self.engine == "vosk": - self._init_vosk() - elif self.engine == "whisper": - 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: - raise ValueError(f"Unsupported engine during reconfigure: {self.engine}") + self._init_selected_engine() logger.info("Speech engine re-initialized successfully.") except Exception as e: logger.error(f"Failed to re-initialize speech engine: {e}", exc_info=True) - self._update_state(RecognitionState.ERROR) - # Re-raise or handle appropriately + # Settings reverts the pickers to the saved engine on failure. + # Restore every mutated live field, then reload that engine + # so dictation is not left on the failed backend in ERROR + # with unsaved VAD/device/API knobs while the UI shows the + # previous configuration. + self._restore_reconfigure_state(previous) + self._defer_download = True + try: + self._init_selected_engine() + if self.state == RecognitionState.ERROR: + self._update_state(RecognitionState.IDLE) + logger.info( + "Restored previous speech engine %s after failed reconfigure", + self.engine, + ) + except Exception: + logger.error( + "Failed to restore previous speech engine after reconfigure", + exc_info=True, + ) + self._update_state(RecognitionState.ERROR) raise finally: self._defer_download = old_defer @@ -3682,6 +3957,9 @@ class SpeechRecognitionManager: with self._model_lock: self.model = None self.recognizer = None + if self._faster_whisper_engine is not None: + self._faster_whisper_engine.cleanup() + self._faster_whisper_engine = None if self._http_session is not None: try: self._http_session.close() @@ -3758,6 +4036,9 @@ class SpeechRecognitionManager: with self._model_lock: self.model = None self.recognizer = None + if self._faster_whisper_engine is not None: + self._faster_whisper_engine.cleanup() + self._faster_whisper_engine = None if self._http_session is not None: self._http_session.close() self._http_session = None @@ -3772,6 +4053,8 @@ class SpeechRecognitionManager: self._init_whispercpp() elif self.engine == "parakeet": self._init_parakeet() + elif self.engine == "faster_whisper": + self._init_faster_whisper() 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 87cfc69..5bc3495 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -67,7 +67,7 @@ def normalize_sound_effect_tone(tone: Any) -> str: # Default configuration DEFAULT_CONFIG = { "speech_recognition": { # Changed section name - "engine": "whisper_cpp", # "vosk", "whisper", or "whisper_cpp" - whisper_cpp is default for best performance + "engine": "whisper_cpp", # whisper_cpp is default; vosk/whisper/parakeet/faster_whisper/remote_api are optional "language": "auto", # Auto-detect language (Whisper/whisper.cpp only) "model_size": "tiny", # Current model size (for backward compatibility) "vosk_model_size": "small", # Default model for VOSK engine @@ -83,6 +83,7 @@ DEFAULT_CONFIG = { # Whether the Advanced island under the simple questions is left open. "show_advanced": False, "parakeet_model_size": "v3-european", # Parakeet TDT 0.6B v3 (25 European languages) + "faster_whisper_model_size": "tiny", # Default model for faster-whisper engine "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 74bb737..0cf3d68 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -35,6 +35,19 @@ 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.faster_whisper_model_info import ( + FASTER_WHISPER_MODEL_INFO, +) +from ..utils.faster_whisper_model_info import delete_model as delete_faster_whisper_model +from ..utils.faster_whisper_model_info import ( # noqa: E402 + get_recommended_model as get_recommended_faster_whisper_model, +) +from ..utils.faster_whisper_model_info import ( + is_model_downloaded as is_faster_whisper_model_downloaded, +) +from ..utils.faster_whisper_model_info import ( + list_downloaded_models as list_downloaded_faster_whisper_models, +) from ..utils.model_choice import ( BALANCED, PRIORITIES, @@ -160,6 +173,13 @@ ENGINE_MODELS = { "parakeet": [ *parakeet.MODEL_SIZES, ], # Parakeet TDT 0.6B int8 bundles + "faster_whisper": [ + "tiny", + "base", + "small", + "medium", + "large-v3", + ], # faster-whisper models mirror OpenAI Whisper sizes "remote_api": [], # Remote API does not need local models } @@ -178,6 +198,7 @@ ENGINE_DISPLAY_NAMES = { "whisper": "Whisper", "whisper_cpp": "whisper.cpp", "parakeet": "Parakeet", + "faster_whisper": "Faster Whisper", "remote_api": "Remote API", } @@ -676,6 +697,7 @@ def get_available_engines(): "whisper": False, "whisper_cpp": False, "parakeet": False, + "faster_whisper": False, "remote_api": False, } @@ -711,6 +733,13 @@ def get_available_engines(): except ImportError: pass + try: + from faster_whisper import WhisperModel # noqa: F401 + + engines["faster_whisper"] = True + except ImportError: + pass + # Remote API is always available (only requires requests package) try: import requests # noqa: F401 @@ -1521,6 +1550,9 @@ def recommended_model_for_engine( model_id = parakeet.RECOMMENDED_MODEL reason = parakeet.RECOMMENDED_REASON size_mb = parakeet.PARAKEET_MODEL_INFO.get(model_id, {}).get("size_mb", 0) + elif engine == "faster_whisper": + model_id, reason = get_recommended_faster_whisper_model() + size_mb = FASTER_WHISPER_MODEL_INFO.get(model_id, {}).get("size_mb", 0) else: # Remote API transcribes server-side; there is nothing to download. return None @@ -5308,6 +5340,8 @@ class SettingsDialog(Gtk.Dialog): recommended_model, _ = _get_recommended_whisper_model() elif engine == "parakeet": recommended_model = parakeet.RECOMMENDED_MODEL + elif engine == "faster_whisper": + recommended_model, _ = get_recommended_faster_whisper_model() else: recommended_model, _ = _get_recommended_vosk_model() @@ -5316,6 +5350,9 @@ class SettingsDialog(Gtk.Dialog): if engine == "whisper" and size in WHISPER_MODEL_INFO: info = WHISPER_MODEL_INFO[size] is_downloaded = _is_whisper_model_downloaded(size) + elif engine == "faster_whisper" and size in FASTER_WHISPER_MODEL_INFO: + info = FASTER_WHISPER_MODEL_INFO[size] + is_downloaded = is_faster_whisper_model_downloaded(size) elif engine == "vosk" and size in VOSK_MODEL_INFO: info = VOSK_MODEL_INFO[size] is_downloaded = _is_vosk_model_downloaded(size, self.language) @@ -5472,6 +5509,9 @@ class SettingsDialog(Gtk.Dialog): if engine == "parakeet": model_id = self.model_combo.get_active_id() return model_id.lower() if model_id else None + if engine == "faster_whisper": + 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]]: @@ -5538,6 +5578,16 @@ class SettingsDialog(Gtk.Dialog): name == active_id, ) ) + elif engine == "faster_whisper": + for name in list_downloaded_faster_whisper_models(): + items.append( + ( + name, + _model_display_name(name), + _format_size(FASTER_WHISPER_MODEL_INFO[name]["size_mb"]), + name == active_id, + ) + ) return items @@ -5624,6 +5674,8 @@ class SettingsDialog(Gtk.Dialog): delete_vosk_model(model_id) elif engine == "parakeet": parakeet.delete_model(model_id) + elif engine == "faster_whisper": + delete_faster_whisper_model(model_id) else: raise ValueError(f"No local models to delete for engine {engine}") @@ -5842,7 +5894,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", "parakeet", "remote_api"]: + elif engine in ["whisper", "whisper_cpp", "parakeet", "faster_whisper", "remote_api"]: if english_only_whispercpp and lang_info.get("whisper") != "en": continue # Both Whisper and whisper.cpp support auto-detect @@ -6284,6 +6336,14 @@ class SettingsDialog(Gtk.Dialog): 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'])}" + elif engine == "faster_whisper": + if model_name not in FASTER_WHISPER_MODEL_INFO: + self.model_info_card.hide() + return + info = FASTER_WHISPER_MODEL_INFO[model_name] + is_downloaded = is_faster_whisper_model_downloaded(model_name) + recommended, reason = get_recommended_faster_whisper_model() + extra_info = f"Parameters: {info['params']}" else: self.model_info_card.hide() return @@ -6372,6 +6432,9 @@ class SettingsDialog(Gtk.Dialog): 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}) + elif engine == "faster_whisper" and not is_faster_whisper_model_downloaded(model_name): + needs_download = True + model_info = FASTER_WHISPER_MODEL_INFO.get(model_name, {"size_mb": 39}) if needs_download: if not self.speech_engine.try_begin_download(): @@ -6831,6 +6894,9 @@ For now, the engine has been reverted to VOSK.""" 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}) + elif engine == "faster_whisper" and not is_faster_whisper_model_downloaded(model_name): + needs_download = True + model_info = FASTER_WHISPER_MODEL_INFO.get(model_name, {"size_mb": 39}) if needs_download: if not self.speech_engine.try_begin_download(): diff --git a/src/vocalinux/utils/faster_whisper_model_info.py b/src/vocalinux/utils/faster_whisper_model_info.py new file mode 100644 index 0000000..4ebbeba --- /dev/null +++ b/src/vocalinux/utils/faster_whisper_model_info.py @@ -0,0 +1,275 @@ +"""Faster-Whisper model information for Vocalinux. + +Metadata and checksum-gated download URLs for the optional faster-whisper +backend (CTranslate2). Bundles are stored under ``models_dir()/faster_whisper/`` +and each file is verified against ``model_checksums.txt`` before the engine +loads it, the same way Parakeet and whisper.cpp models are. +""" + +import logging +import os +import shutil +from functools import lru_cache +from typing import Any, Dict, Optional + +from .paths import is_within_directory, models_dir + +logger = logging.getLogger(__name__) + +_HF_BASE_URL = "https://huggingface.co" + +# Each bundle is pinned to a Hugging Face commit rather than tracking main, so +# a file replaced upstream cannot change what an install downloads. +FASTER_WHISPER_MODEL_INFO: Dict[str, Dict[str, Any]] = { + "tiny": { + "repo": "Systran/faster-whisper-tiny", + "revision": "d90ca5fe260221311c53c58e660288d3deb8d356", + "size_mb": 39, + "params": "39M", + "desc": "Fastest, lowest accuracy", + }, + "tiny.en": { + "repo": "Systran/faster-whisper-tiny.en", + "revision": "0d3d19a32d3338f10357c0889762bd8d64bbdeba", + "size_mb": 39, + "params": "39M", + "desc": "English-only tiny model", + }, + "base": { + "repo": "Systran/faster-whisper-base", + "revision": "ebe41f70d5b6dfa9166e2c581c45c9c0cfc57b66", + "size_mb": 74, + "params": "74M", + "desc": "Fast, good for basic use", + }, + "base.en": { + "repo": "Systran/faster-whisper-base.en", + "revision": "3d3d5dee26484f91867d81cb899cfcf72b96be6c", + "size_mb": 74, + "params": "74M", + "desc": "English-only base model", + }, + "small": { + "repo": "Systran/faster-whisper-small", + "revision": "536b0662742c02347bc0e980a01041f333bce120", + "size_mb": 244, + "params": "244M", + "desc": "Balanced speed/accuracy", + }, + "small.en": { + "repo": "Systran/faster-whisper-small.en", + "revision": "d1d751a5f8271d482d14ca55d9e2deeebbae577f", + "size_mb": 244, + "params": "244M", + "desc": "English-only small model", + }, + "medium": { + "repo": "Systran/faster-whisper-medium", + "revision": "08e178d48790749d25932bbc082711ddcfdfbc4f", + "size_mb": 769, + "params": "769M", + "desc": "High accuracy, slower", + }, + "medium.en": { + "repo": "Systran/faster-whisper-medium.en", + "revision": "a29b04bd15381511a9af671baec01072039215e3", + "size_mb": 769, + "params": "769M", + "desc": "English-only medium model", + }, + "large-v1": { + "repo": "Systran/faster-whisper-large-v1", + "revision": "b07c8d4be0be90092aa01a29c975077acb8d15c9", + "size_mb": 1550, + "params": "1550M", + "desc": "Legacy large v1 model", + }, + "large-v2": { + "repo": "Systran/faster-whisper-large-v2", + "revision": "f0fe81560cb8b68660e564f55dd99207059c092e", + "size_mb": 1550, + "params": "1550M", + "desc": "Legacy large v2 model", + }, + "large-v3": { + "repo": "Systran/faster-whisper-large-v3", + "revision": "edaa852ec7e145841d8ffdb056a99866b5f0a478", + "size_mb": 1550, + "params": "1550M", + "desc": "Highest accuracy, slower", + }, +} + +_DEFAULT_MODEL_FILES = ["config.json", "model.bin", "tokenizer.json", "vocabulary.txt"] +_LARGE_V3_MODEL_FILES = [ + "config.json", + "model.bin", + "preprocessor_config.json", + "tokenizer.json", + "vocabulary.json", +] + + +def model_files(model_name: str) -> list[str]: + """Return the files that make up a faster-whisper CTranslate2 bundle.""" + if model_name not in FASTER_WHISPER_MODEL_INFO: + raise ValueError(f"Unknown faster-whisper model: {model_name}") + if model_name == "large-v3": + return list(_LARGE_V3_MODEL_FILES) + return list(_DEFAULT_MODEL_FILES) + + +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 tiny) + + Returns: + Path to the model directory + """ + faster_whisper_dir = os.path.join(models_dir(), "faster_whisper") + os.makedirs(faster_whisper_dir, exist_ok=True) + + return os.path.join(faster_whisper_dir, model_name) + + +def is_model_downloaded(model_name: str) -> bool: + """ + Check if a faster-whisper model is downloaded. + + Args: + model_name: Name of the model + + Returns: + True if every bundle file exists under MODELS_DIR, False otherwise + """ + if model_name not in FASTER_WHISPER_MODEL_INFO: + return False + + model_path = get_model_path(model_name) + return all( + os.path.exists(os.path.join(model_path, filename)) for filename in model_files(model_name) + ) + + +def list_downloaded_models() -> list[str]: + """Return faster-whisper models occupying disk, complete or partially downloaded. + + Mirrors the Parakeet 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 FASTER_WHISPER_MODEL_INFO if os.path.isdir(get_model_path(name))] + + +def delete_model(model_name: str) -> str: + """Delete a downloaded faster-whisper 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 FASTER_WHISPER_MODEL_INFO or os.path.basename(model_name) != model_name: + raise ValueError(f"Unknown faster-whisper 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 faster-whisper 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"faster-whisper-{model_name}-{filename}" + + +def get_model_file_url(model_name: str, filename: str) -> str: + """ + Get the download URL for one file of a faster-whisper model. + + Args: + model_name: Name of the model + filename: File within the model bundle + + Returns: + Hugging Face download URL + """ + model_info = FASTER_WHISPER_MODEL_INFO.get(model_name) + if not model_info: + raise ValueError(f"Unknown faster-whisper model: {model_name}") + + return ( + f"{_HF_BASE_URL}/{model_info['repo']}/resolve/{model_info['revision']}" + f"/{filename}?download=true" + ) + + +@lru_cache(maxsize=1) +def _has_torch_cuda() -> bool: + """Return True if PyTorch reports CUDA available.""" + try: + import torch + + return bool(torch.cuda.is_available()) + except Exception: + return False + + +def get_recommended_model() -> tuple[str, str]: + """Get the recommended faster-whisper model based on system configuration. + + Returns: + Tuple of (model_name, reason) + """ + try: + import psutil + + ram_gb = int(psutil.virtual_memory().total) // (1024**3) + except Exception: + ram_gb = 4 + + has_cuda = _has_torch_cuda() + + if has_cuda: + if ram_gb >= 8: + return "small", f"CUDA GPU with {ram_gb}GB RAM" + return "base", f"CUDA GPU with {ram_gb}GB RAM" + + if ram_gb >= 16: + return "base", f"{ram_gb}GB RAM - CPU inference" + if ram_gb >= 8: + return "tiny", f"{ram_gb}GB RAM - optimized for speed" + return "tiny", f"Limited RAM ({ram_gb}GB) - fastest model" + + +def get_compute_type(device: str) -> str: + """Return the recommended compute type for the given device. + + Args: + device: "cpu" or "cuda" + + Returns: + A faster-whisper compute_type value. + """ + if device == "cpu": + return "int8" + return "float16" + + +def is_english_only_model(model_name: str) -> bool: + """Return whether a faster-whisper model variant is English-only.""" + return ".en" in model_name.lower() diff --git a/src/vocalinux/utils/model_checksums.txt b/src/vocalinux/utils/model_checksums.txt index 27e5f74..215e83f 100644 --- a/src/vocalinux/utils/model_checksums.txt +++ b/src/vocalinux/utils/model_checksums.txt @@ -2,7 +2,8 @@ # # 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, whispercpp_model_info.py or parakeet_model_info.py. +# vosk_model_info.py, whispercpp_model_info.py, parakeet_model_info.py +# or faster_whisper_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. @@ -17,81 +18,127 @@ # 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. +# Parakeet and faster-whisper bundles are pinned per model in their +# model_info modules, 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 -ggml-base.bin sha256 60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe 147951465 -ggml-base.en-q5_1.bin sha256 4baf70dd0d7c4247ba2b81fafd9c01005ac77c2f9ef064e00dcf195d0e2fdd2f 59721011 -ggml-base.en.bin sha256 a03779c86df3323075f5e796cb2ce5029f00ec8869eee3fdfb897afe36c6d002 147964211 -ggml-large-v1.bin sha256 7d99f41a10525d0206bddadd86760181fa920438b6b33237e3118ff6c83bb53d 3094623691 -ggml-large-v2-q5_0.bin sha256 3a214837221e4530dbc1fe8d734f302af393eb30bd0ed046042ebf4baf70f6f2 1080732091 -ggml-large-v2-q8_0.bin sha256 fef54e6d898246a65c8285bfa83bd1807e27fadf54d5d4e81754c47634737e8c 1656129691 -ggml-large-v2.bin sha256 9a423fe4d40c82774b6af34115b8b935f34152246eb19e80e376071d3f999487 3094623691 -ggml-large-v3-q5_0.bin sha256 d75795ecff3f83b5faa89d1900604ad8c780abd5739fae406de19f23ecd98ad1 1081140203 -ggml-large-v3-turbo-q5_0.bin sha256 394221709cd5ad1f40c46e6031ca61bce88931e6e088c188294c6d5a55ffa7e2 574041195 -ggml-large-v3-turbo-q8_0.bin sha256 317eb69c11673c9de1e1f0d459b253999804ec71ac4c23c17ecf5fbe24e259a1 874188075 -ggml-large-v3-turbo.bin sha256 1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69 1624555275 -ggml-large-v3.bin sha256 64d182b440b98d5203c4f9bd541544d84c605196c4f7b845dfa11fb23594d1e2 3095033483 -ggml-medium-q5_0.bin sha256 19fea4b380c3a618ec4723c3eef2eb785ffba0d0538cf43f8f235e7b3b34220f 539212467 -ggml-medium-q8_0.bin sha256 42a1ffcbe4167d224232443396968db4d02d4e8e87e213d3ee2e03095dea6502 823369779 -ggml-medium.bin sha256 6c14d5adee5f86394037b4e4e8b59f1673b6cee10e3cf0b11bbdbee79c156208 1533763059 -ggml-medium.en-q5_0.bin sha256 76733e26ad8fe1c7a5bf7531a9d41917b2adc0f20f2e4f5531688a8c6cd88eb0 539225533 -ggml-medium.en.bin sha256 cc37e93478338ec7700281a7ac30a10128929eb8f427dda2e865faa8f6da4356 1533774781 -ggml-small-q5_1.bin sha256 ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb 190085487 -ggml-small-q8_0.bin sha256 49c8fb02b65e6049d5fa6c04f81f53b867b5ec9540406812c643f177317f779f 264464607 -ggml-small.bin sha256 1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b 487601967 -ggml-small.en-q5_1.bin sha256 bfdff4894dcb76bbf647d56263ea2a96645423f1669176f4844a1bf8e478ad30 190098681 -ggml-small.en.bin sha256 c6138d6d58ecc8322097e0f987c32f1be8bb0a18532a3f88f734d1bbf9c41e5d 487614201 -ggml-tiny-q5_1.bin sha256 818710568da3ca15689e31a743197b520007872ff9576237bda97bd1b469c3d7 32152673 -ggml-tiny-q8_0.bin sha256 c2085835d3f50733e2ff6e4b41ae8a2b8d8110461e18821b09a15c40c42d1cca 43537433 -ggml-tiny.bin sha256 be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21 77691713 -ggml-tiny.en-q5_1.bin sha256 c77c5766f1cef09b6b7d47f21b546cbddd4157886b3b5d6d4f709e91e66c7c2b 32166155 -ggml-tiny.en.bin sha256 921e4cf8686fdd993dcd081a5da5b6c365bfde1162e72b08d75ac75289920b1f 77704715 -vosk-model-ar-0.22-linto-1.1.0.zip sha256 6fc103417517203a5503e176963e3a7e20d32da4c37f4ff21181e9d64e1f02bf 1377395170 -vosk-model-cn-0.22.zip sha256 7f5580bf7ca3d9e8ce8be68337378950ed78ef974df7a7d4ead8dbe32bec2fa1 1358736686 -vosk-model-de-0.21.zip sha256 245060756f8d8394fc5b13639cf220b7620205795f30f37b6823878d4f603b2a 2031717803 -vosk-model-en-in-0.5.zip sha256 0365c52f79308d81c2de832cf934ce42da6bf7e04dd3acd4203cacf50ab70f5c 971186187 -vosk-model-en-us-0.22.zip sha256 47f9a81ebb039dbb0bd319175c36ac393c0893b796c2b6303e64cf58c27b69f6 1913365522 -vosk-model-es-0.42.zip sha256 bbbfaf9239bed5dcb0d140886b5834fcc13c13c7fb2f2799ae790f03ff20bfce 1484681703 -vosk-model-fa-0.42.zip sha256 5f03187838a187cd3f8766e3d329da37bf5ddd81a861081132743b76d8a8d14a 1667089770 -vosk-model-fr-0.22.zip sha256 b26c34e5ea1b9f9dee6cef5e910a1d93e9adb60f1a0a3f85c069976aec481e46 1523026348 -vosk-model-hi-0.22.zip sha256 eaf9bdbd703c79e957f7ea893502e12b336f98d7cd420138bcb224b36ccf86ac 1561909274 -vosk-model-it-0.22.zip sha256 5cdc9f0f596e3db270b92fc4c1fd0f1a3e3d5d19ae0d30b69fc008639257de09 1306765292 -vosk-model-ja-0.22.zip sha256 af620db815ef716f90355f911092ba0c7454d91d793ecdd8dee2df6342e0e9cc 1045975323 -vosk-model-nl-spraakherkenning-0.6.zip sha256 088d40014c43684d65f3cc15f4b06ad764b4407b47854812e8364427702c8b56 1004206965 -vosk-model-pt-fb-v0.1.1-pruned.zip sha256 f197db8d66a53e1ae4332dc599b4c035271a0ffe0c6ac0636f341184cea8d830 1223922169 -vosk-model-ru-0.22.zip sha256 84d9e2cf6324480e4e768da7d75122fd1250df5449b89d8f9d9417ec7a27fb23 1559419132 -vosk-model-small-ar-0.3.zip sha256 8696c91f39ec67a08c513845cfac26dbd4e81937be4fba25233e6c5435c18ca9 104351896 -vosk-model-small-ca-0.4.zip sha256 99f90bfff5c2b187c705ddbc20aaa2600eddc359466f404404f1db6029fba5d4 43405881 -vosk-model-small-cn-0.22.zip sha256 3af8b0e7e0f835ae9d414ce5df580237a3cfb08d586c9fbbb0f7ff29ad5b14ba 43898754 -vosk-model-small-cs-0.4-rhasspy.zip sha256 287c3bbefc8ad67b4ab9636eecef3d62acc3719990777d03e226db5a7f19fbda 46088666 -vosk-model-small-de-0.15.zip sha256 b7e53c90b1f0a38456f4cd62b366ecd58803cd97cd42b06438e2c131713d5e43 46499967 -vosk-model-small-en-in-0.4.zip sha256 20663dcac4d5cb783a579c54d98339344a688e4ec6e1b4a4b059fd1235454cc7 37573330 -vosk-model-small-en-us-0.15.zip sha256 30f26242c4eb449f948e42cb302dd7a686cb29a3423a8367f99ff41780942498 41205931 -vosk-model-small-es-0.42.zip sha256 09b239888f633ef2f0b4e09736e3d9936acfd810bc65d53fad45261762c6511f 39817833 -vosk-model-small-fa-0.42.zip sha256 977cb5faa538f3a835ccfd35f5f6d8284b5c450b89c700b9bd4736b66536ad46 53431220 -vosk-model-small-fr-0.22.zip sha256 cabf6180e177eb9b3a9a9d43a437bd5e549f3a7d09525e5d69a3fed787be12ad 42233323 -vosk-model-small-hi-0.22.zip sha256 7c50a10866889f0ac21d912c20537a055a597ed09fc1d3e5bcd798f9f0017e48 44458845 -vosk-model-small-it-0.22.zip sha256 9ec65e75861d1c6c2e457cccd932705340dcdf233f5b239f00733b4de0bf3267 49665141 -vosk-model-small-ja-0.22.zip sha256 efa092d280153a77615e9e0c7d7283e93e600de3d19d3bec686c57ef19d52eac 49704573 -vosk-model-small-ko-0.22.zip sha256 eea36124087fed26c59996a4761519458e3bd185e8ea9d9865ad8760c4a1d989 86914329 -vosk-model-small-nl-0.22.zip sha256 039811c3b829de64e4f123a9f684a53784005b212a346ac0b899dc7efce2ed0a 40441176 -vosk-model-small-pl-0.22.zip sha256 c4cd16498ea544f446f9e9a55cbd602b71cfe5a2b6f2b0834d81e1b6fce15f0d 52979372 -vosk-model-small-pt-0.3.zip sha256 6e1ce909032e1afa7a88e68a3d628ecafff302bdf195befab308826c395e93b7 32453112 -vosk-model-small-ru-0.22.zip sha256 961d5ff98a17f4aa6de69864d0aa71fa5bac682301d2b5d17a3f24c5c99a46d4 46236750 -vosk-model-small-sv-rhasspy-0.15.zip sha256 5b54f2ca8cacf9766588d05f6c8ca7e8921f054f050726e29d3516e78aefe054 303504931 -vosk-model-small-tr-0.3.zip sha256 8c8d07cec1bce31add14967c16891c84152cdf76d391e33c08278119b9ea96e5 36855784 -vosk-model-small-uk-v3-small.zip sha256 ee05b6a8c796c53c61cd1c821d38e897e5bd67b4d691d8797ec8f9e5100d4247 143914407 -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 +ggml-base-q5_1.bin sha256 422f1ae452ade6f30a004d7e5c6a43195e4433bc370bf23fac9cc591f01a8898 59707625 +ggml-base-q8_0.bin sha256 c577b9a86e7e048a0b7eada054f4dd79a56bbfa911fbdacf900ac5b567cbb7d9 81768585 +ggml-base.bin sha256 60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe 147951465 +ggml-base.en-q5_1.bin sha256 4baf70dd0d7c4247ba2b81fafd9c01005ac77c2f9ef064e00dcf195d0e2fdd2f 59721011 +ggml-base.en.bin sha256 a03779c86df3323075f5e796cb2ce5029f00ec8869eee3fdfb897afe36c6d002 147964211 +ggml-large-v1.bin sha256 7d99f41a10525d0206bddadd86760181fa920438b6b33237e3118ff6c83bb53d 3094623691 +ggml-large-v2-q5_0.bin sha256 3a214837221e4530dbc1fe8d734f302af393eb30bd0ed046042ebf4baf70f6f2 1080732091 +ggml-large-v2-q8_0.bin sha256 fef54e6d898246a65c8285bfa83bd1807e27fadf54d5d4e81754c47634737e8c 1656129691 +ggml-large-v2.bin sha256 9a423fe4d40c82774b6af34115b8b935f34152246eb19e80e376071d3f999487 3094623691 +ggml-large-v3-q5_0.bin sha256 d75795ecff3f83b5faa89d1900604ad8c780abd5739fae406de19f23ecd98ad1 1081140203 +ggml-large-v3-turbo-q5_0.bin sha256 394221709cd5ad1f40c46e6031ca61bce88931e6e088c188294c6d5a55ffa7e2 574041195 +ggml-large-v3-turbo-q8_0.bin sha256 317eb69c11673c9de1e1f0d459b253999804ec71ac4c23c17ecf5fbe24e259a1 874188075 +ggml-large-v3-turbo.bin sha256 1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69 1624555275 +ggml-large-v3.bin sha256 64d182b440b98d5203c4f9bd541544d84c605196c4f7b845dfa11fb23594d1e2 3095033483 +ggml-medium-q5_0.bin sha256 19fea4b380c3a618ec4723c3eef2eb785ffba0d0538cf43f8f235e7b3b34220f 539212467 +ggml-medium-q8_0.bin sha256 42a1ffcbe4167d224232443396968db4d02d4e8e87e213d3ee2e03095dea6502 823369779 +ggml-medium.bin sha256 6c14d5adee5f86394037b4e4e8b59f1673b6cee10e3cf0b11bbdbee79c156208 1533763059 +ggml-medium.en-q5_0.bin sha256 76733e26ad8fe1c7a5bf7531a9d41917b2adc0f20f2e4f5531688a8c6cd88eb0 539225533 +ggml-medium.en.bin sha256 cc37e93478338ec7700281a7ac30a10128929eb8f427dda2e865faa8f6da4356 1533774781 +ggml-small-q5_1.bin sha256 ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb 190085487 +ggml-small-q8_0.bin sha256 49c8fb02b65e6049d5fa6c04f81f53b867b5ec9540406812c643f177317f779f 264464607 +ggml-small.bin sha256 1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b 487601967 +ggml-small.en-q5_1.bin sha256 bfdff4894dcb76bbf647d56263ea2a96645423f1669176f4844a1bf8e478ad30 190098681 +ggml-small.en.bin sha256 c6138d6d58ecc8322097e0f987c32f1be8bb0a18532a3f88f734d1bbf9c41e5d 487614201 +ggml-tiny-q5_1.bin sha256 818710568da3ca15689e31a743197b520007872ff9576237bda97bd1b469c3d7 32152673 +ggml-tiny-q8_0.bin sha256 c2085835d3f50733e2ff6e4b41ae8a2b8d8110461e18821b09a15c40c42d1cca 43537433 +ggml-tiny.bin sha256 be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21 77691713 +ggml-tiny.en-q5_1.bin sha256 c77c5766f1cef09b6b7d47f21b546cbddd4157886b3b5d6d4f709e91e66c7c2b 32166155 +ggml-tiny.en.bin sha256 921e4cf8686fdd993dcd081a5da5b6c365bfde1162e72b08d75ac75289920b1f 77704715 +vosk-model-ar-0.22-linto-1.1.0.zip sha256 6fc103417517203a5503e176963e3a7e20d32da4c37f4ff21181e9d64e1f02bf 1377395170 +vosk-model-cn-0.22.zip sha256 7f5580bf7ca3d9e8ce8be68337378950ed78ef974df7a7d4ead8dbe32bec2fa1 1358736686 +vosk-model-de-0.21.zip sha256 245060756f8d8394fc5b13639cf220b7620205795f30f37b6823878d4f603b2a 2031717803 +vosk-model-en-in-0.5.zip sha256 0365c52f79308d81c2de832cf934ce42da6bf7e04dd3acd4203cacf50ab70f5c 971186187 +vosk-model-en-us-0.22.zip sha256 47f9a81ebb039dbb0bd319175c36ac393c0893b796c2b6303e64cf58c27b69f6 1913365522 +vosk-model-es-0.42.zip sha256 bbbfaf9239bed5dcb0d140886b5834fcc13c13c7fb2f2799ae790f03ff20bfce 1484681703 +vosk-model-fa-0.42.zip sha256 5f03187838a187cd3f8766e3d329da37bf5ddd81a861081132743b76d8a8d14a 1667089770 +vosk-model-fr-0.22.zip sha256 b26c34e5ea1b9f9dee6cef5e910a1d93e9adb60f1a0a3f85c069976aec481e46 1523026348 +vosk-model-hi-0.22.zip sha256 eaf9bdbd703c79e957f7ea893502e12b336f98d7cd420138bcb224b36ccf86ac 1561909274 +vosk-model-it-0.22.zip sha256 5cdc9f0f596e3db270b92fc4c1fd0f1a3e3d5d19ae0d30b69fc008639257de09 1306765292 +vosk-model-ja-0.22.zip sha256 af620db815ef716f90355f911092ba0c7454d91d793ecdd8dee2df6342e0e9cc 1045975323 +vosk-model-nl-spraakherkenning-0.6.zip sha256 088d40014c43684d65f3cc15f4b06ad764b4407b47854812e8364427702c8b56 1004206965 +vosk-model-pt-fb-v0.1.1-pruned.zip sha256 f197db8d66a53e1ae4332dc599b4c035271a0ffe0c6ac0636f341184cea8d830 1223922169 +vosk-model-ru-0.22.zip sha256 84d9e2cf6324480e4e768da7d75122fd1250df5449b89d8f9d9417ec7a27fb23 1559419132 +vosk-model-small-ar-0.3.zip sha256 8696c91f39ec67a08c513845cfac26dbd4e81937be4fba25233e6c5435c18ca9 104351896 +vosk-model-small-ca-0.4.zip sha256 99f90bfff5c2b187c705ddbc20aaa2600eddc359466f404404f1db6029fba5d4 43405881 +vosk-model-small-cn-0.22.zip sha256 3af8b0e7e0f835ae9d414ce5df580237a3cfb08d586c9fbbb0f7ff29ad5b14ba 43898754 +vosk-model-small-cs-0.4-rhasspy.zip sha256 287c3bbefc8ad67b4ab9636eecef3d62acc3719990777d03e226db5a7f19fbda 46088666 +vosk-model-small-de-0.15.zip sha256 b7e53c90b1f0a38456f4cd62b366ecd58803cd97cd42b06438e2c131713d5e43 46499967 +vosk-model-small-en-in-0.4.zip sha256 20663dcac4d5cb783a579c54d98339344a688e4ec6e1b4a4b059fd1235454cc7 37573330 +vosk-model-small-en-us-0.15.zip sha256 30f26242c4eb449f948e42cb302dd7a686cb29a3423a8367f99ff41780942498 41205931 +vosk-model-small-es-0.42.zip sha256 09b239888f633ef2f0b4e09736e3d9936acfd810bc65d53fad45261762c6511f 39817833 +vosk-model-small-fa-0.42.zip sha256 977cb5faa538f3a835ccfd35f5f6d8284b5c450b89c700b9bd4736b66536ad46 53431220 +vosk-model-small-fr-0.22.zip sha256 cabf6180e177eb9b3a9a9d43a437bd5e549f3a7d09525e5d69a3fed787be12ad 42233323 +vosk-model-small-hi-0.22.zip sha256 7c50a10866889f0ac21d912c20537a055a597ed09fc1d3e5bcd798f9f0017e48 44458845 +vosk-model-small-it-0.22.zip sha256 9ec65e75861d1c6c2e457cccd932705340dcdf233f5b239f00733b4de0bf3267 49665141 +vosk-model-small-ja-0.22.zip sha256 efa092d280153a77615e9e0c7d7283e93e600de3d19d3bec686c57ef19d52eac 49704573 +vosk-model-small-ko-0.22.zip sha256 eea36124087fed26c59996a4761519458e3bd185e8ea9d9865ad8760c4a1d989 86914329 +vosk-model-small-nl-0.22.zip sha256 039811c3b829de64e4f123a9f684a53784005b212a346ac0b899dc7efce2ed0a 40441176 +vosk-model-small-pl-0.22.zip sha256 c4cd16498ea544f446f9e9a55cbd602b71cfe5a2b6f2b0834d81e1b6fce15f0d 52979372 +vosk-model-small-pt-0.3.zip sha256 6e1ce909032e1afa7a88e68a3d628ecafff302bdf195befab308826c395e93b7 32453112 +vosk-model-small-ru-0.22.zip sha256 961d5ff98a17f4aa6de69864d0aa71fa5bac682301d2b5d17a3f24c5c99a46d4 46236750 +vosk-model-small-sv-rhasspy-0.15.zip sha256 5b54f2ca8cacf9766588d05f6c8ca7e8921f054f050726e29d3516e78aefe054 303504931 +vosk-model-small-tr-0.3.zip sha256 8c8d07cec1bce31add14967c16891c84152cdf76d391e33c08278119b9ea96e5 36855784 +vosk-model-small-uk-v3-small.zip sha256 ee05b6a8c796c53c61cd1c821d38e897e5bd67b4d691d8797ec8f9e5100d4247 143914407 +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 +faster-whisper-tiny-config.json sha256 a73a28cdfe1c43ccc7202fa333d1f89c202477271407ae9a7f19afa52039cac8 2249 +faster-whisper-tiny-model.bin sha256 dcb76c6586fc06cbdac6dd21f14cfd129cc4cdd9dce19bf4ffa62e59cbe6e6d1 75538270 +faster-whisper-tiny-tokenizer.json sha256 fb7b63191e9bb045082c79fd742a3106a12c99513ab30df4a0d47fa6cb6fd0ab 2203239 +faster-whisper-tiny-vocabulary.txt sha256 34ce3fe1c5041027b3f8d42912270993f986dbc4bb34cf27f951e34a1e453913 459861 +faster-whisper-tiny.en-config.json sha256 14b1b421a90349bc551b881461426b561a874049cb9e4c4864f2ca384f6a7cc5 2317 +faster-whisper-tiny.en-model.bin sha256 1a5afae06a4db91c975c9a9d78be5cc110ee4ea022ad57d55492e4550e936b2a 75537502 +faster-whisper-tiny.en-tokenizer.json sha256 929c5252409436dce1b38a75d1abbcb5e132d170d8e324e4e04ed915fa2d22df 2128466 +faster-whisper-tiny.en-vocabulary.txt sha256 ff77588746d3a2595d32ab5b69ffd7b95ce2441ac57533cb66fc3eb575a115cf 422309 +faster-whisper-base-config.json sha256 56a6d8110d311f19c8f0471e562832c7527f146b567275bfca59fcf7c184da9a 2309 +faster-whisper-base-model.bin sha256 d01c3014881c9c6f3133c182f3d2887eb6ca1c789a7538c5c007196857a0a6a9 145217532 +faster-whisper-base-tokenizer.json sha256 fb7b63191e9bb045082c79fd742a3106a12c99513ab30df4a0d47fa6cb6fd0ab 2203239 +faster-whisper-base-vocabulary.txt sha256 34ce3fe1c5041027b3f8d42912270993f986dbc4bb34cf27f951e34a1e453913 459861 +faster-whisper-base.en-config.json sha256 f3bc3821e9fc76a27bae538e11ae5b677dcdd352b4600429ce7951d398569aeb 2227 +faster-whisper-base.en-model.bin sha256 2a166925539a16005f14ff328359f9b9adb9dc4fb631bb3b227526862e93e2ef 145216508 +faster-whisper-base.en-tokenizer.json sha256 929c5252409436dce1b38a75d1abbcb5e132d170d8e324e4e04ed915fa2d22df 2128466 +faster-whisper-base.en-vocabulary.txt sha256 ff77588746d3a2595d32ab5b69ffd7b95ce2441ac57533cb66fc3eb575a115cf 422309 +faster-whisper-small-config.json sha256 b55496ac7940a7ae47d2c01eab40edfd8701feec1229d9cce3b40014383fb828 2370 +faster-whisper-small-model.bin sha256 3e305921506d8872816023e4c273e75d2419fb89b24da97b4fe7bce14170d671 483546902 +faster-whisper-small-tokenizer.json sha256 fb7b63191e9bb045082c79fd742a3106a12c99513ab30df4a0d47fa6cb6fd0ab 2203239 +faster-whisper-small-vocabulary.txt sha256 34ce3fe1c5041027b3f8d42912270993f986dbc4bb34cf27f951e34a1e453913 459861 +faster-whisper-small.en-config.json sha256 666a9605530ac1f61fa8177f3702b4dacec9966749e42610839fcc32661d5fae 2657 +faster-whisper-small.en-model.bin sha256 62b2a45b05ee59acb4a5341b33ee35e041395d378d418a18acfe4c9e768ee37a 483545366 +faster-whisper-small.en-tokenizer.json sha256 929c5252409436dce1b38a75d1abbcb5e132d170d8e324e4e04ed915fa2d22df 2128466 +faster-whisper-small.en-vocabulary.txt sha256 ff77588746d3a2595d32ab5b69ffd7b95ce2441ac57533cb66fc3eb575a115cf 422309 +faster-whisper-medium-config.json sha256 3622a2ddc41ec0e0fd4e68c13c6830f03b90c38d89aaad184de02c8c642cf807 2257 +faster-whisper-medium-model.bin sha256 9b45e1009dcc4ab601eff815b61d80e60ce3fd8c74c1a14f4a282258286b51ae 1527906378 +faster-whisper-medium-tokenizer.json sha256 fb7b63191e9bb045082c79fd742a3106a12c99513ab30df4a0d47fa6cb6fd0ab 2203239 +faster-whisper-medium-vocabulary.txt sha256 34ce3fe1c5041027b3f8d42912270993f986dbc4bb34cf27f951e34a1e453913 459861 +faster-whisper-medium.en-config.json sha256 4a1848ebabe7938d9797c15a2e8e4ce1d36e6fd4a43d096ae5955257c67c7962 2643 +faster-whisper-medium.en-model.bin sha256 11b220779aea4c6f3ce9d2549c8a95ea869ed84066864b999531ef53e594fe5b 1527904330 +faster-whisper-medium.en-tokenizer.json sha256 929c5252409436dce1b38a75d1abbcb5e132d170d8e324e4e04ed915fa2d22df 2128466 +faster-whisper-medium.en-vocabulary.txt sha256 ff77588746d3a2595d32ab5b69ffd7b95ce2441ac57533cb66fc3eb575a115cf 422309 +faster-whisper-large-v1-config.json sha256 98eb866f6d98c38224aa2f13fc9854f75fa01280714e945f7e803aa8b16e2b4b 2352 +faster-whisper-large-v1-model.bin sha256 a3cce8081a5414206ab09a80aa410ebf9965feef52adafeead13f4a83398b1d1 3086912962 +faster-whisper-large-v1-tokenizer.json sha256 fb7b63191e9bb045082c79fd742a3106a12c99513ab30df4a0d47fa6cb6fd0ab 2203239 +faster-whisper-large-v1-vocabulary.txt sha256 34ce3fe1c5041027b3f8d42912270993f986dbc4bb34cf27f951e34a1e453913 459861 +faster-whisper-large-v2-config.json sha256 d86b7a7664a12559d644aa210a32ce9a7e03913e794b7ea4fb7182de69e273a7 2796 +faster-whisper-large-v2-model.bin sha256 bf2a9746382e1aa7ffff6b3a0d137ed9edbd9670c3b87e5d35f5e85e70d0333a 3086912962 +faster-whisper-large-v2-tokenizer.json sha256 fb7b63191e9bb045082c79fd742a3106a12c99513ab30df4a0d47fa6cb6fd0ab 2203239 +faster-whisper-large-v2-vocabulary.txt sha256 34ce3fe1c5041027b3f8d42912270993f986dbc4bb34cf27f951e34a1e453913 459861 +faster-whisper-large-v3-config.json sha256 a9306624f5ec14270a014b647e5c316b6e03a662c369758d1b90697a7b0655b9 2394 +faster-whisper-large-v3-model.bin sha256 69f74147e3334731bc3a76048724833325d2ec74642fb52620eda87352e3d4f1 3087284237 +faster-whisper-large-v3-preprocessor_config.json sha256 7ccc62c6f2765af1f3b46c00c9b5894426835a05021c8b9c01eecb6dfb542711 340 +faster-whisper-large-v3-tokenizer.json sha256 6d8cbd7cd0d8d5815e478dac67b85a26bbe77c1f5e0c6d76d1ce2abc0e5f21ca 2480617 +faster-whisper-large-v3-vocabulary.json sha256 c69260f2ab26d659b7c398f9a2b2b48ed0df16c3b47d7326782fd9cba71690c1 1068114 diff --git a/tests/test_auto_pause_monitor.py b/tests/test_auto_pause_monitor.py index 971c3ff..70a5968 100644 --- a/tests/test_auto_pause_monitor.py +++ b/tests/test_auto_pause_monitor.py @@ -458,11 +458,14 @@ class TestUnloadModelAndAutoPauseBlock(unittest.TestCase): mgr._model_lock.__enter__ = MagicMock(return_value=None) mgr._model_lock.__exit__ = MagicMock(return_value=False) mgr._http_session = None + mgr._faster_whisper_engine = None mgr._reconnection_attempts = 0 mgr._update_state = MagicMock() mgr._init_vosk = MagicMock() mgr._init_whisper = MagicMock() mgr._init_whispercpp = MagicMock() + mgr._init_parakeet = MagicMock() + mgr._init_faster_whisper = MagicMock() mgr._init_remote_api = MagicMock() mgr.stop_recognition = MagicMock() return mgr diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 92bfd59..39d5933 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -187,7 +187,10 @@ class TestConfigManager(unittest.TestCase): # Also test the get method works - it should return a valid engine value config_manager = ConfigManager() value = config_manager.get("speech_recognition", "engine") - self.assertIn(value, ["vosk", "whisper", "whisper_cpp"]) # Should be one of valid engines + self.assertIn( + value, + ["vosk", "whisper", "whisper_cpp", "parakeet", "faster_whisper", "remote_api"], + ) self.assertEqual( config_manager.get("speech_recognition", "remote_api_model"), "whisper-1", diff --git a/tests/test_faster_whisper_engine.py b/tests/test_faster_whisper_engine.py new file mode 100644 index 0000000..7bf0989 --- /dev/null +++ b/tests/test_faster_whisper_engine.py @@ -0,0 +1,555 @@ +"""Tests for the faster-whisper engine backend.""" + +import os +import sys +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +sys.modules["gi"] = MagicMock() +sys.modules["gi.repository"] = MagicMock() + +from vocalinux.common_types import Engine, EngineType +from vocalinux.speech_recognition.engines.faster_whisper_engine import FasterWhisperEngine +from vocalinux.utils.faster_whisper_model_info import ( + FASTER_WHISPER_MODEL_INFO, + get_compute_type, + get_model_file_url, + get_model_path, + get_recommended_model, + is_english_only_model, + is_model_downloaded, + manifest_key, + model_files, +) + + +class TestEngineType: + """Tests for the EngineType enum.""" + + def test_engine_type_values(self): + """Test that all expected engine types exist with correct string values.""" + assert EngineType.VOSK.value == "vosk" + assert EngineType.WHISPER.value == "whisper" + assert EngineType.WHISPER_CPP.value == "whisper_cpp" + assert EngineType.PARAKEET.value == "parakeet" + assert EngineType.FASTER_WHISPER.value == "faster_whisper" + assert EngineType.REMOTE_API.value == "remote_api" + + +class TestEngineProtocol: + """Tests for the Engine Protocol.""" + + def test_engine_protocol_methods(self): + """Test that the Engine Protocol defines the expected methods.""" + mock_engine = MagicMock(spec=Engine) + mock_engine.init() + mock_engine.is_ready() + mock_engine.transcribe([]) + mock_engine.cleanup() + + mock_engine.init.assert_called_once() + mock_engine.is_ready.assert_called_once() + mock_engine.transcribe.assert_called_once_with([]) + mock_engine.cleanup.assert_called_once() + + +class TestFasterWhisperModelInfo: + """Tests for faster-whisper model metadata utilities.""" + + def test_model_info_keys(self): + """Test that expected model sizes are defined.""" + for size in ["tiny", "base", "small", "medium", "large-v3"]: + assert size in FASTER_WHISPER_MODEL_INFO + + def test_get_compute_type(self): + """Test compute type recommendations.""" + assert get_compute_type("cpu") == "int8" + assert get_compute_type("cuda") == "float16" + + def test_is_english_only_model(self): + """Test English-only model detection.""" + assert is_english_only_model("tiny.en") is True + assert is_english_only_model("tiny") is False + + def test_is_model_downloaded_unknown_model(self): + """Test that unknown models are reported as not downloaded.""" + assert is_model_downloaded("not-a-model") is False + + def test_model_files_default_and_large_v3(self): + """Most bundles use vocabulary.txt; large-v3 uses vocabulary.json.""" + assert model_files("tiny") == [ + "config.json", + "model.bin", + "tokenizer.json", + "vocabulary.txt", + ] + assert model_files("large-v3") == [ + "config.json", + "model.bin", + "preprocessor_config.json", + "tokenizer.json", + "vocabulary.json", + ] + + def test_model_files_unknown_model(self): + with pytest.raises(ValueError, match="Unknown faster-whisper model"): + model_files("not-a-model") + + def test_manifest_key_includes_model_name(self): + assert manifest_key("tiny", "model.bin") == "faster-whisper-tiny-model.bin" + assert ( + manifest_key("large-v3", "vocabulary.json") == "faster-whisper-large-v3-vocabulary.json" + ) + + def test_get_model_file_url_uses_pinned_revision(self): + url = get_model_file_url("tiny", "model.bin") + revision = FASTER_WHISPER_MODEL_INFO["tiny"]["revision"] + assert f"/resolve/{revision}/model.bin?download=true" in url + assert "Systran/faster-whisper-tiny" in url + + def test_get_model_file_url_unknown_model(self): + with pytest.raises(ValueError, match="Unknown faster-whisper model"): + get_model_file_url("not-a-model", "model.bin") + + def test_is_model_downloaded_missing_files(self, tmp_path): + """A partial bundle is not treated as downloaded.""" + with patch( + "vocalinux.utils.faster_whisper_model_info.models_dir", + return_value=str(tmp_path), + ): + model_dir = get_model_path("tiny") + os.makedirs(model_dir, exist_ok=True) + with open(os.path.join(model_dir, "model.bin"), "wb") as handle: + handle.write(b"partial") + assert is_model_downloaded("tiny") is False + + def test_is_model_downloaded_complete_bundle(self, tmp_path): + """All bundle files under MODELS_DIR count as downloaded.""" + with patch( + "vocalinux.utils.faster_whisper_model_info.models_dir", + return_value=str(tmp_path), + ): + model_dir = get_model_path("tiny") + os.makedirs(model_dir, exist_ok=True) + for filename in model_files("tiny"): + with open(os.path.join(model_dir, filename), "wb") as handle: + handle.write(b"ok") + assert is_model_downloaded("tiny") is True + + def test_get_recommended_model_returns_tuple(self): + """Test that get_recommended_model returns a model name and reason.""" + model, reason = get_recommended_model() + assert isinstance(model, str) + assert isinstance(reason, str) + assert model in FASTER_WHISPER_MODEL_INFO + + def test_get_recommended_model_cuda_high_ram(self): + """Test CUDA recommendation with high RAM.""" + with patch( + "vocalinux.utils.faster_whisper_model_info._has_torch_cuda", + return_value=True, + ): + with patch("psutil.virtual_memory") as mock_mem: + mock_mem.return_value.total = 16 * 1024**3 + model, _reason = get_recommended_model() + assert model == "small" + + def test_get_recommended_model_cpu_low_ram(self): + """Test CPU recommendation with low RAM.""" + with patch( + "vocalinux.utils.faster_whisper_model_info._has_torch_cuda", + return_value=False, + ): + with patch("psutil.virtual_memory") as mock_mem: + mock_mem.return_value.total = 4 * 1024**3 + model, _reason = get_recommended_model() + assert model == "tiny" + + def test_get_recommended_model_cpu_medium_ram(self): + """Test CPU recommendation with medium RAM.""" + with patch( + "vocalinux.utils.faster_whisper_model_info._has_torch_cuda", + return_value=False, + ): + with patch("psutil.virtual_memory") as mock_mem: + mock_mem.return_value.total = 8 * 1024**3 + model, _reason = get_recommended_model() + assert model == "tiny" + + def test_get_recommended_model_cpu_high_ram(self): + """Test CPU recommendation with high RAM.""" + with patch( + "vocalinux.utils.faster_whisper_model_info._has_torch_cuda", + return_value=False, + ): + with patch("psutil.virtual_memory") as mock_mem: + mock_mem.return_value.total = 32 * 1024**3 + model, _reason = get_recommended_model() + assert model == "base" + + def test_get_recommended_model_cuda_low_ram(self): + """Test CUDA recommendation with low VRAM-system RAM.""" + with patch( + "vocalinux.utils.faster_whisper_model_info._has_torch_cuda", + return_value=True, + ): + with patch("psutil.virtual_memory") as mock_mem: + mock_mem.return_value.total = 4 * 1024**3 + model, _reason = get_recommended_model() + assert model == "base" + + def test_has_torch_cuda_available(self): + """Test that torch detection reports CUDA availability.""" + from vocalinux.utils.faster_whisper_model_info import _has_torch_cuda + + torch_mock = MagicMock() + torch_mock.cuda.is_available.return_value = True + _has_torch_cuda.cache_clear() + try: + with patch.dict(sys.modules, {"torch": torch_mock}): + assert _has_torch_cuda() is True + finally: + _has_torch_cuda.cache_clear() + _has_torch_cuda() + + def test_has_torch_cuda_exception(self): + """Test that torch detection falls back to False on errors.""" + from vocalinux.utils.faster_whisper_model_info import _has_torch_cuda + + _has_torch_cuda.cache_clear() + try: + with patch.dict(sys.modules, {"torch": None}): + with patch("builtins.__import__", side_effect=ImportError("not found")): + assert _has_torch_cuda() is False + finally: + _has_torch_cuda.cache_clear() + _has_torch_cuda() + + def test_get_recommended_model_psutil_exception(self): + """Test that psutil failures default to a safe model choice.""" + with patch( + "vocalinux.utils.faster_whisper_model_info._has_torch_cuda", + return_value=False, + ): + with patch("psutil.virtual_memory", side_effect=RuntimeError("boom")): + model, _reason = get_recommended_model() + assert model == "tiny" + + +class TestFasterWhisperEngine: + """Tests for the FasterWhisperEngine class.""" + + @pytest.fixture(autouse=True) + def _stub_model_path(self, tmp_path): + with patch( + "vocalinux.speech_recognition.engines.faster_whisper_engine.get_model_path", + side_effect=lambda name: str(tmp_path / "faster_whisper" / name), + ): + self._models_root = tmp_path + yield + + def _mock_whisper_model(self, segments): + """Return a mock WhisperModel that yields the given segments.""" + whisper_mock = MagicMock() + whisper_mock.WhisperModel.return_value.transcribe.return_value = ( + segments, + MagicMock(), + ) + return whisper_mock + + def test_engine_implements_protocol(self): + """Test that FasterWhisperEngine satisfies the Engine Protocol.""" + engine = FasterWhisperEngine(model_size="tiny") + assert hasattr(engine, "init") + assert hasattr(engine, "transcribe") + assert hasattr(engine, "is_ready") + assert hasattr(engine, "cleanup") + + def test_default_model_size(self): + """Test that the engine defaults to a valid model size.""" + engine = FasterWhisperEngine() + assert engine.model_size in FASTER_WHISPER_MODEL_INFO + + def test_init_loads_model(self): + """Test that init() creates a WhisperModel instance.""" + whisper_mock = self._mock_whisper_model([]) + with patch.dict(sys.modules, {"faster_whisper": whisper_mock}): + engine = FasterWhisperEngine(model_size="tiny", device="cpu") + engine.init() + assert engine.is_ready() + whisper_mock.WhisperModel.assert_called_once_with( + str(self._models_root / "faster_whisper" / "tiny"), + device="cpu", + compute_type="int8", + local_files_only=True, + ) + + def test_init_invalid_model_defaults_to_tiny(self): + """Test that an invalid model size is corrected to tiny.""" + whisper_mock = self._mock_whisper_model([]) + with patch.dict(sys.modules, {"faster_whisper": whisper_mock}): + engine = FasterWhisperEngine(model_size="invalid", device="cpu") + engine.init() + assert engine.model_size == "tiny" + + def test_transcribe_returns_text(self): + """Test that transcription concatenates segment text.""" + segments = [ + MagicMock(text="Hello,"), + MagicMock(text=" world."), + ] + whisper_mock = self._mock_whisper_model(segments) + with patch.dict(sys.modules, {"faster_whisper": whisper_mock}): + engine = FasterWhisperEngine(model_size="tiny", device="cpu") + engine.init() + + audio = np.array([0, 1000, -1000, 0], dtype=np.int16) + audio_bytes = [audio.tobytes()] + text = engine.transcribe(audio_bytes) + + assert text == "Hello, world." + + def test_transcribe_empty_audio(self): + """Test that empty audio returns empty text.""" + whisper_mock = self._mock_whisper_model([]) + with patch.dict(sys.modules, {"faster_whisper": whisper_mock}): + engine = FasterWhisperEngine(model_size="tiny", device="cpu") + engine.init() + assert engine.transcribe([]) == "" + + def test_transcribe_not_ready(self): + """Test that transcription before init returns empty text.""" + engine = FasterWhisperEngine(model_size="tiny", device="cpu") + assert engine.transcribe([b""]) == "" + + def test_cleanup_releases_model(self): + """Test that cleanup() resets the engine state.""" + whisper_mock = self._mock_whisper_model([]) + with patch.dict(sys.modules, {"faster_whisper": whisper_mock}): + engine = FasterWhisperEngine(model_size="tiny", device="cpu") + engine.init() + assert engine.is_ready() + engine.cleanup() + assert not engine.is_ready() + + def test_device_detects_cuda(self): + """Test that CUDA is detected when torch reports it available.""" + from vocalinux.utils.faster_whisper_model_info import _has_torch_cuda + + torch_mock = MagicMock() + torch_mock.cuda.is_available.return_value = True + _has_torch_cuda.cache_clear() + try: + with patch.dict(sys.modules, {"torch": torch_mock}): + engine = FasterWhisperEngine(device=None) + assert engine.device == "cuda" + finally: + _has_torch_cuda.cache_clear() + _has_torch_cuda() + + def test_device_falls_back_to_cpu(self): + """Test CPU fallback when torch is unavailable.""" + from vocalinux.utils.faster_whisper_model_info import _has_torch_cuda + + _has_torch_cuda.cache_clear() + try: + with patch.dict(sys.modules, {"torch": None}): + engine = FasterWhisperEngine(device=None) + assert engine.device == "cpu" + finally: + _has_torch_cuda.cache_clear() + _has_torch_cuda() + + def test_init_import_error(self): + """Test that import failure propagates correctly.""" + with patch.dict(sys.modules, {"faster_whisper": None}): + engine = FasterWhisperEngine(model_size="tiny", device="cpu") + with patch("builtins.__import__", side_effect=ImportError("not found")): + with pytest.raises(ImportError): + engine.init() + + def test_init_already_initialized(self): + """Test that init() is idempotent.""" + whisper_mock = self._mock_whisper_model([]) + with patch.dict(sys.modules, {"faster_whisper": whisper_mock}): + engine = FasterWhisperEngine(model_size="tiny", device="cpu") + engine.init() + engine.init() + whisper_mock.WhisperModel.assert_called_once() + + def test_transcribe_handles_error(self): + """Test that transcription errors return empty text.""" + whisper_mock = MagicMock() + whisper_mock.WhisperModel.return_value.transcribe.side_effect = RuntimeError("boom") + with patch.dict(sys.modules, {"faster_whisper": whisper_mock}): + engine = FasterWhisperEngine(model_size="tiny", device="cpu") + engine.init() + audio = np.array([0, 1000], dtype=np.int16) + text = engine.transcribe([audio.tobytes()]) + assert text == "" + + def test_transcribe_empty_result(self): + """Test that empty segment results return empty text and log debug.""" + whisper_mock = self._mock_whisper_model([MagicMock(text="")]) + with patch.dict(sys.modules, {"faster_whisper": whisper_mock}): + engine = FasterWhisperEngine(model_size="tiny", device="cpu") + engine.init() + audio = np.array([0, 1000], dtype=np.int16) + text = engine.transcribe([audio.tobytes()]) + assert text == "" + + def test_language_mapping(self): + """Test that Vocalinux language codes are mapped correctly.""" + engine = FasterWhisperEngine(language="en-us") + assert engine._normalize_language() == "en" + + engine = FasterWhisperEngine(language="auto") + assert engine._normalize_language() is None + + engine = FasterWhisperEngine(language="fr") + assert engine._normalize_language() == "fr" + + +class TestEngineRegistry: + """Tests for the speech recognition engine registry.""" + + def test_registry_contains_faster_whisper(self): + """Test that the faster-whisper engine is registered.""" + from vocalinux.common_types import EngineType + from vocalinux.speech_recognition.engines import ENGINES + + assert EngineType.FASTER_WHISPER in ENGINES + + def test_registry_ignores_import_errors(self): + """Test that the registry stays empty if the engine module fails to import.""" + import importlib + import types + + import vocalinux.speech_recognition.engines as registry_module + + engine_module_name = "vocalinux.speech_recognition.engines.faster_whisper_engine" + real_engine_module = sys.modules.get(engine_module_name) + fake_module = types.ModuleType(engine_module_name) + fake_module.__getattr__ = lambda _name: (_ for _ in ()).throw(ImportError("not found")) + sys.modules[engine_module_name] = fake_module + + try: + importlib.reload(registry_module) + assert EngineType.FASTER_WHISPER not in registry_module.ENGINES + finally: + if real_engine_module is not None: + sys.modules[engine_module_name] = real_engine_module + else: + sys.modules.pop(engine_module_name, None) + importlib.reload(registry_module) + + +class TestRecognitionManagerIntegration: + """Tests that the recognition manager exposes faster-whisper methods.""" + + def test_faster_whisper_methods_exist(self): + """Test that the manager has faster-whisper init and transcribe methods.""" + whisper_mock = MagicMock() + with patch.dict(sys.modules, {"faster_whisper": whisper_mock}): + from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager + + assert hasattr(SpeechRecognitionManager, "_init_faster_whisper") + assert hasattr(SpeechRecognitionManager, "_transcribe_with_faster_whisper") + + def test_faster_whisper_supported_in_config(self): + """Test that the config manager recognizes faster-whisper model size.""" + from vocalinux.ui.config_manager import ConfigManager + + manager = ConfigManager() + size = manager.get_model_size_for_engine("faster_whisper") + assert size in FASTER_WHISPER_MODEL_INFO + + def test_manager_init_faster_whisper(self): + """Test that the manager can initialize with faster-whisper engine.""" + from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager + + whisper_mock = MagicMock() + with patch.dict(sys.modules, {"faster_whisper": whisper_mock}): + with ( + patch.object(SpeechRecognitionManager, "_init_vosk"), + patch.object(SpeechRecognitionManager, "_init_whisper"), + patch.object(SpeechRecognitionManager, "_init_whispercpp"), + patch.object(SpeechRecognitionManager, "_init_parakeet"), + ): + manager = SpeechRecognitionManager( + engine="faster_whisper", + model_size="tiny", + language="en-us", + defer_download=True, + ) + assert manager.engine == "faster_whisper" + + def test_init_faster_whisper_defers_missing_model(self): + """Missing models stay unloaded when download is deferred.""" + from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager + + with ( + patch.object(SpeechRecognitionManager, "_init_vosk"), + patch.object(SpeechRecognitionManager, "_init_whisper"), + patch.object(SpeechRecognitionManager, "_init_whispercpp"), + patch.object(SpeechRecognitionManager, "_init_parakeet"), + patch( + "vocalinux.speech_recognition.recognition_manager.faster_whisper.is_model_downloaded", + return_value=False, + ), + patch.object(SpeechRecognitionManager, "_download_faster_whisper_model") as download, + ): + manager = SpeechRecognitionManager( + engine="faster_whisper", + model_size="tiny", + language="en-us", + defer_download=True, + ) + assert manager.engine == "faster_whisper" + assert manager._model_initialized is False + assert manager._faster_whisper_engine is None + download.assert_not_called() + + def test_init_faster_whisper_downloads_then_loads(self): + """force_download path acquires the bundle, then loads the local snapshot.""" + from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager + + engine_instance = MagicMock() + engine_instance._model = MagicMock() + engine_cls = MagicMock(return_value=engine_instance) + + with ( + patch.object(SpeechRecognitionManager, "_init_vosk"), + patch.object(SpeechRecognitionManager, "_init_whisper"), + patch.object(SpeechRecognitionManager, "_init_whispercpp"), + patch.object(SpeechRecognitionManager, "_init_parakeet"), + patch( + "vocalinux.speech_recognition.recognition_manager.faster_whisper.is_model_downloaded", + return_value=False, + ), + patch( + "vocalinux.speech_recognition.engines.faster_whisper_engine.FasterWhisperEngine", + engine_cls, + ), + ): + manager = SpeechRecognitionManager( + engine="faster_whisper", + model_size="tiny", + language="en-us", + defer_download=True, + ) + manager._defer_download = False + with patch.object(manager, "_download_faster_whisper_model") as download: + manager._init_faster_whisper() + + download.assert_called_once() + engine_cls.assert_called_once() + engine_instance.init.assert_called_once() + assert manager._model_initialized is True + assert manager._faster_whisper_engine is engine_instance + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_installer_engine_config.py b/tests/test_installer_engine_config.py index 4fda516..5c9fe0c 100644 --- a/tests/test_installer_engine_config.py +++ b/tests/test_installer_engine_config.py @@ -33,7 +33,7 @@ def _venv_python(venv_dir: Path, importable: "set[str] | None" = None) -> Path: ``importable`` names the engine modules that succeed; every other engine module fails. Anything else (the json probes) runs on the real interpreter. """ - modules = {"vosk", "whisper", "pywhispercpp.model"} + modules = {"vosk", "whisper", "pywhispercpp.model", "sherpa_onnx", "faster_whisper"} ok = importable if importable is not None else set() python = venv_dir / "bin" / "python" python.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_model_checksums.py b/tests/test_model_checksums.py index 9ca4544..4cd7bd2 100644 --- a/tests/test_model_checksums.py +++ b/tests/test_model_checksums.py @@ -17,6 +17,11 @@ from tempfile import TemporaryDirectory from unittest.mock import patch from vocalinux.utils import model_checksums +from vocalinux.utils.faster_whisper_model_info import ( + FASTER_WHISPER_MODEL_INFO, +) +from vocalinux.utils.faster_whisper_model_info import manifest_key as faster_whisper_manifest_key +from vocalinux.utils.faster_whisper_model_info import model_files as faster_whisper_model_files from vocalinux.utils.model_checksums import ( VERIFICATION_STAMP_NAME, ChecksumError, @@ -89,6 +94,21 @@ class TestManifestCoverage(unittest.TestCase): "run `just model-checksums` to refresh the manifest", ) + def test_every_faster_whisper_model_is_pinned(self): + pinned = pinned_filenames() + missing = [ + faster_whisper_manifest_key(name, filename) + for name in FASTER_WHISPER_MODEL_INFO + for filename in faster_whisper_model_files(name) + if faster_whisper_manifest_key(name, filename) not in pinned + ] + self.assertEqual( + missing, + [], + "faster-whisper 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_model_deletion.py b/tests/test_model_deletion.py index 6b70984..90bf1b9 100644 --- a/tests/test_model_deletion.py +++ b/tests/test_model_deletion.py @@ -85,6 +85,47 @@ def test_vosk_delete_rejects_unknown_and_missing(tmp_path): delete_vosk_model("vosk-model-small-en-us-0.15") +def test_faster_whisper_list_and_delete(tmp_path): + with patch("vocalinux.utils.faster_whisper_model_info.models_dir", return_value=str(tmp_path)): + from vocalinux.utils.faster_whisper_model_info import ( + delete_model, + get_model_path, + list_downloaded_models, + model_files, + ) + + assert list_downloaded_models() == [] + + tiny = get_model_path("tiny") + os.makedirs(tiny, exist_ok=True) + for filename in model_files("tiny"): + with open(os.path.join(tiny, filename), "wb") as handle: + handle.write(b"tiny") + base = get_model_path("base") + os.makedirs(base, exist_ok=True) + with open(os.path.join(base, "model.bin"), "wb") as handle: + handle.write(b"partial") + + downloaded = list_downloaded_models() + assert downloaded == ["tiny", "base"] + + deleted = delete_model("tiny") + assert deleted == tiny + assert not os.path.exists(tiny) + assert os.path.exists(base) + assert list_downloaded_models() == ["base"] + + +def test_faster_whisper_delete_unknown_and_missing(tmp_path): + with patch("vocalinux.utils.faster_whisper_model_info.models_dir", return_value=str(tmp_path)): + from vocalinux.utils.faster_whisper_model_info import delete_model + + with pytest.raises(ValueError, match="Unknown faster-whisper model"): + delete_model("not-a-real-model") + with pytest.raises(FileNotFoundError): + delete_model("tiny") + + def test_whisper_list_and_delete(tmp_path): cache = tmp_path / "whisper" cache.mkdir() diff --git a/tests/test_model_missing_offer.py b/tests/test_model_missing_offer.py index 316a7d4..67718a3 100644 --- a/tests/test_model_missing_offer.py +++ b/tests/test_model_missing_offer.py @@ -373,12 +373,20 @@ def test_engine_without_a_handler_keeps_its_own_notification(): def _reconfigure_manager(): from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager + manager_class = SpeechRecognitionManager manager = MagicMock() manager.engine = "whisper_cpp" manager.model_size = "tiny" manager.language = "en-us" manager._defer_download = True - return SpeechRecognitionManager, manager + # reconfigure() dispatches through _init_selected_engine(); bind the real + # method so force_reinit still reaches _init_whispercpp on this mock. + manager._init_selected_engine = lambda: manager_class._init_selected_engine(manager) + manager._snapshot_reconfigure_state = lambda: manager_class._snapshot_reconfigure_state(manager) + manager._restore_reconfigure_state = lambda previous: manager_class._restore_reconfigure_state( + manager, previous + ) + return manager_class, manager def test_force_reinit_reinitializes_an_engine_that_did_not_change(): diff --git a/tests/test_recognition_manager_advanced.py b/tests/test_recognition_manager_advanced.py index 7b03e81..41e5b2d 100644 --- a/tests/test_recognition_manager_advanced.py +++ b/tests/test_recognition_manager_advanced.py @@ -51,12 +51,16 @@ for _k, _v in _ORIG_MODULES.items(): def _make_manager(engine="whisper_cpp", **kw): """Create a manager with deferred init to avoid actually loading models.""" - 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 - ) + with ( + patch.object(SpeechRecognitionManager, "_init_vosk"), + patch.object(SpeechRecognitionManager, "_init_whisper"), + patch.object(SpeechRecognitionManager, "_init_whispercpp"), + patch.object(SpeechRecognitionManager, "_init_parakeet"), + patch.object(SpeechRecognitionManager, "_init_faster_whisper"), + ): + mgr = SpeechRecognitionManager( + engine=engine, model_size="small", language="en-us", defer_download=True, **kw + ) return mgr diff --git a/tests/test_recognition_manager_downloads.py b/tests/test_recognition_manager_downloads.py index e8e21eb..155ab67 100644 --- a/tests/test_recognition_manager_downloads.py +++ b/tests/test_recognition_manager_downloads.py @@ -23,12 +23,11 @@ if "gi" not in sys.modules: if "gi.repository" not in sys.modules: sys.modules["gi.repository"] = MagicMock() +from vocalinux.common_types import RecognitionState from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager -from vocalinux.utils.model_checksums import ( - VERIFICATION_STAMP_NAME, - ChecksumError, - expected_for, -) +from vocalinux.utils.faster_whisper_model_info import manifest_key as faster_whisper_manifest_key +from vocalinux.utils.faster_whisper_model_info import model_files as faster_whisper_model_files +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 @@ -40,13 +39,14 @@ def _make_manager(engine="whisper_cpp", **kw): with patch.object(SpeechRecognitionManager, "_init_whisper"): with patch.object(SpeechRecognitionManager, "_init_whispercpp"): with patch.object(SpeechRecognitionManager, "_init_parakeet"): - mgr = SpeechRecognitionManager( - engine=engine, - model_size="small", - language="en-us", - defer_download=True, - **kw, - ) + with patch.object(SpeechRecognitionManager, "_init_faster_whisper"): + 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 @@ -804,6 +804,279 @@ class TestParakeetDownloadVerifiesExistingFiles: assert encoder.read_bytes() == b"good-enough" +class TestFasterWhisperRejectsAnUnverifiedModelOnDisk: + """Wiring test: the hash has to happen where the model is picked up.""" + + @staticmethod + def _write_bundle(tmp_path, payload=b"not the model that is pinned"): + model_dir = tmp_path / "tiny" + model_dir.mkdir() + for name in faster_whisper_model_files("tiny"): + (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="faster_whisper") + manager.model_size = "tiny" + manager._defer_download = True + + with patch( + "vocalinux.speech_recognition.recognition_manager.faster_whisper.get_model_path", + return_value=str(model_dir), + ): + with patch( + "vocalinux.speech_recognition.engines.faster_whisper_engine.FasterWhisperEngine" + ) as engine_cls: + manager._init_faster_whisper() + + for name in faster_whisper_model_files("tiny"): + assert not (model_dir / name).exists(), "an unverifiable model must not stay on disk" + engine_cls.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="faster_whisper") + manager.model_size = "tiny" + manager._defer_download = False + + with patch( + "vocalinux.speech_recognition.recognition_manager.faster_whisper.get_model_path", + return_value=str(model_dir), + ): + with patch( + "vocalinux.speech_recognition.recognition_manager.os.remove", + side_effect=OSError("read-only"), + ): + with patch( + "vocalinux.speech_recognition.engines.faster_whisper_engine.FasterWhisperEngine" + ) as engine_cls: + with pytest.raises(RuntimeError, match="failed verification"): + manager._init_faster_whisper() + + for name in faster_whisper_model_files("tiny"): + assert (model_dir / name).exists() + engine_cls.assert_not_called() + + def test_an_unpinned_model_is_refused_not_deleted(self, tmp_path): + """faster-whisper pins are constructed keys; simulate a missing pin for one file.""" + model_dir = self._write_bundle(tmp_path, payload=b"bytes") + unpinned_name = faster_whisper_model_files("tiny")[0] + unpinned_key = faster_whisper_manifest_key("tiny", 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="faster_whisper") + manager.model_size = "tiny" + manager._defer_download = True + + with patch( + "vocalinux.speech_recognition.recognition_manager.faster_whisper.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, + ): + with patch( + "vocalinux.speech_recognition.engines.faster_whisper_engine.FasterWhisperEngine" + ) as engine_cls: + manager._init_faster_whisper() + + assert ( + model_dir / unpinned_name + ).exists(), "a missing pin is not a reason to delete the file" + engine_cls.assert_not_called() + assert manager._model_initialized is False + + +class TestFasterWhisperDownloadVerifiesExistingFiles: + """Existence is not a pin: a leftover dest must still match its digest.""" + + def test_an_existing_file_that_fails_its_pin_is_redownloaded(self, tmp_path): + manager = _make_manager(engine="faster_whisper") + manager.model_size = "tiny" + model_dir = tmp_path / "tiny" + model_dir.mkdir() + + first_name = faster_whisper_model_files("tiny")[0] + first_file = model_dir / first_name + first_file.write_bytes(b"not the file that is pinned") + first_key = faster_whisper_manifest_key("tiny", first_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)) + 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.faster_whisper.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_faster_whisper_model() + + assert any( + path == str(first_file) and filename == first_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) == first_name + ".tmp" for path in streamed + ), "an existing bad file must be removed and re-downloaded" + assert first_file.read_bytes() == b"good-enough" + + +class TestFailedReconfigureRestoresPreviousEngine: + """A failed faster-whisper switch must not discard the live engine. + + Settings reverts the pickers to the saved config on error. If reconfigure + keeps faster-whisper in ERROR after releasing whisper.cpp, dictation is + dead until restart even though the UI shows the old engine. + """ + + def test_failed_faster_whisper_download_reloads_previous_engine(self): + manager = _make_manager(engine="whisper_cpp") + manager.engine = "whisper_cpp" + manager.model_size = "tiny" + manager._model_initialized = True + manager.state = RecognitionState.IDLE + + with patch.object( + manager, "_init_faster_whisper", side_effect=RuntimeError("download failed") + ): + with patch.object(manager, "_init_whispercpp") as restore_init: + with pytest.raises(RuntimeError, match="download failed"): + manager.reconfigure( + engine="faster_whisper", + model_size="tiny", + force_download=True, + ) + restore_init.assert_called_once() + + assert manager.engine == "whisper_cpp" + assert manager.model_size == "tiny" + assert manager.state != RecognitionState.ERROR + + def test_restore_failure_stays_in_error(self): + manager = _make_manager(engine="whisper_cpp") + manager.engine = "whisper_cpp" + manager.model_size = "tiny" + manager.state = RecognitionState.IDLE + + with patch.object( + manager, "_init_faster_whisper", side_effect=RuntimeError("download failed") + ): + with patch.object( + manager, "_init_whispercpp", side_effect=RuntimeError("restore failed") + ): + with pytest.raises(RuntimeError, match="download failed"): + manager.reconfigure( + engine="faster_whisper", + model_size="tiny", + force_download=True, + ) + + assert manager.engine == "whisper_cpp" + assert manager.state == RecognitionState.ERROR + + def test_failed_switch_restores_live_settings_not_just_engine(self): + """Unsaved VAD/device/API fields must roll back with the previous engine.""" + manager = _make_manager(engine="whisper_cpp") + manager.engine = "whisper_cpp" + manager.model_size = "tiny" + manager.language = "en-us" + manager.vad_sensitivity = 2 + manager.silence_timeout = 1.5 + manager.audio_device_index = 1 + manager.audio_device_name = "Built-in Mic" + manager._voice_commands_preference = False + manager.stop_sound_guard_ms = 200 + manager.whispercpp_n_threads = 4 + manager.whispercpp_gpu_device = 0 + manager.whispercpp_no_timestamps = True + manager.remote_api_url = "http://old" + manager.remote_api_key = "old-key" + manager.remote_api_endpoint = "/inference" + manager.remote_api_model = "whisper-1" + manager._model_initialized = True + manager.state = RecognitionState.IDLE + + with patch.object( + manager, "_init_faster_whisper", side_effect=RuntimeError("download failed") + ): + with patch.object(manager, "_init_whispercpp") as restore_init: + with pytest.raises(RuntimeError, match="download failed"): + manager.reconfigure( + engine="faster_whisper", + model_size="tiny", + vad_sensitivity=5, + silence_timeout=4.0, + audio_device_index=9, + audio_device_name="USB Mic", + voice_commands_enabled=True, + stop_sound_guard_ms=50, + whispercpp_n_threads=1, + whispercpp_gpu_device=2, + whispercpp_no_timestamps=False, + remote_api_url="http://new", + remote_api_key="new-key", + remote_api_endpoint="/v1/audio", + remote_api_model="sensevoice", + force_download=True, + ) + restore_init.assert_called_once() + + assert manager.engine == "whisper_cpp" + assert manager.model_size == "tiny" + assert manager.language == "en-us" + assert manager.vad_sensitivity == 2 + assert manager.silence_timeout == 1.5 + assert manager.audio_device_index == 1 + assert manager.audio_device_name == "Built-in Mic" + assert manager._voice_commands_preference is False + assert manager._voice_commands_enabled is False + assert manager.stop_sound_guard_ms == 200 + assert manager.whispercpp_n_threads == 4 + assert manager.whispercpp_gpu_device == 0 + assert manager.whispercpp_no_timestamps is True + assert manager.remote_api_url == "http://old" + assert manager.remote_api_key == "old-key" + assert manager.remote_api_endpoint == "/inference" + assert manager.remote_api_model == "whisper-1" + assert manager.state != RecognitionState.ERROR + + class TestAudioReconnection: """Test audio reconnection logic.""" diff --git a/tests/test_recognition_manager_init.py b/tests/test_recognition_manager_init.py index 58e5a7c..44dace3 100644 --- a/tests/test_recognition_manager_init.py +++ b/tests/test_recognition_manager_init.py @@ -28,12 +28,20 @@ def _make_manager(engine="whisper_cpp", **kw): """Helper to create SpeechRecognitionManager with init methods patched.""" from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager - with patch.object(SpeechRecognitionManager, "_init_vosk"): - with patch.object(SpeechRecognitionManager, "_init_whisper"): - with patch.object(SpeechRecognitionManager, "_init_whispercpp"): - return SpeechRecognitionManager( - engine=engine, model_size="small", language="en-us", defer_download=True, **kw - ) + with ( + patch.object(SpeechRecognitionManager, "_init_vosk"), + patch.object(SpeechRecognitionManager, "_init_whisper"), + patch.object(SpeechRecognitionManager, "_init_whispercpp"), + patch.object(SpeechRecognitionManager, "_init_parakeet"), + patch.object(SpeechRecognitionManager, "_init_faster_whisper"), + ): + return SpeechRecognitionManager( + engine=engine, + model_size="small", + language="en-us", + defer_download=True, + **kw, + ) class TestSpeechRecognitionManagerInit: @@ -56,6 +64,11 @@ class TestSpeechRecognitionManagerInit: manager = _make_manager(engine="whisper") assert manager.engine == "whisper" + def test_manager_init_faster_whisper_engine(self): + """Test manager initialization with faster_whisper engine.""" + manager = _make_manager(engine="faster_whisper") + assert manager.engine == "faster_whisper" + def test_manager_init_with_audio_device_index(self): """Test manager initialization with audio device index.""" manager = _make_manager(audio_device_index=2) diff --git a/tests/test_suspend_handler.py b/tests/test_suspend_handler.py index 5eb7cf5..428d528 100644 --- a/tests/test_suspend_handler.py +++ b/tests/test_suspend_handler.py @@ -215,11 +215,14 @@ class TestReinitializeAfterResume(unittest.TestCase): mgr._model_lock.__enter__ = MagicMock(return_value=None) mgr._model_lock.__exit__ = MagicMock(return_value=False) mgr._http_session = None + mgr._faster_whisper_engine = None mgr._reconnection_attempts = 3 mgr._update_state = MagicMock() mgr._init_vosk = MagicMock() mgr._init_whisper = MagicMock() mgr._init_whispercpp = MagicMock() + mgr._init_parakeet = MagicMock() + mgr._init_faster_whisper = MagicMock() mgr.stop_recognition = MagicMock() return mgr @@ -247,6 +250,22 @@ class TestReinitializeAfterResume(unittest.TestCase): mgr._init_whispercpp.assert_called_once() + def test_reinitializes_parakeet_engine(self): + mgr = self._make_manager() + mgr.engine = "parakeet" + + mgr.reinitialize_after_resume() + + mgr._init_parakeet.assert_called_once() + + def test_reinitializes_faster_whisper_engine(self): + mgr = self._make_manager() + mgr.engine = "faster_whisper" + + mgr.reinitialize_after_resume() + + mgr._init_faster_whisper.assert_called_once() + def test_stops_active_recognition_first(self): mgr = self._make_manager() mgr.state = RecognitionState.LISTENING diff --git a/uv.lock b/uv.lock index a2d1078..68cb4a1 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + [[package]] name = "ast-serialize" version = "0.8.0" @@ -80,6 +93,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, ] +[[package]] +name = "av" +version = "18.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/f4/f22114d30d3435e38c6af2b4870f37b864403dca6ae7af747a289ce0a18e/av-18.1.0.tar.gz", hash = "sha256:47bfc286e1bc9de7ab4681fc2b575cd2460a66919d31ffe1bd5aa54fae531a28", size = 4451061, upload-time = "2026-08-12T22:28:18.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/d4/d7cdc8bff143c17a6d35924375ae28dd692cacde38700a7d419fde54f44a/av-18.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ae75d8bb6467895ed1f8572ededf7ffa49eac07f6e483222f5d7d62a41d12f04", size = 22546147, upload-time = "2026-08-12T22:27:11.851Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c9/37a619297492256b77d5ed906e7d8166c10a26ed251dccf1ae03ab19bff6/av-18.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:b30a4e8d934558e19602b68998a4d9ac9f250fa0dacef216f7e8e40153b13316", size = 18217603, upload-time = "2026-08-12T22:27:14.713Z" }, + { url = "https://files.pythonhosted.org/packages/d9/84/2464ffb64c08c5ce8b522c8e74594714414e3b0575267652c5c51c0574b9/av-18.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6fc837cc51adf80331ac850779cd53b5d4c4460b0ebe9057a02a921c6736f19d", size = 33640142, upload-time = "2026-08-12T22:27:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/204dbfc3e08eb4cdc6e6ff57be02150bc44523ebdb50182d10025792ebd9/av-18.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:8a032e8d8ebc73dec079364b9b4a6837638a2d106e8472314e685ffbf163e700", size = 35786210, upload-time = "2026-08-12T22:27:20.984Z" }, + { url = "https://files.pythonhosted.org/packages/e1/99/b0d04ec553ff9a7e00455458dfa3a39c8a8f627b273056b4e5fe57d590de/av-18.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:3c8b1f8b46f99d52e2d8b0ed5d0cdadf172d24794d46e2077b16e44ed08e26ff", size = 39379798, upload-time = "2026-08-12T22:27:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/56/b1/e00d4feae59160149df6126585e726fdc6300798fd40c5dd324879e81f68/av-18.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ab5ac081bc9eaf54109120d4e56284674fecfbe520d9aa1707c7fa911ec5f4d2", size = 34690321, upload-time = "2026-08-12T22:27:27.769Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/836fa987e3084d11a21489f11357fb24843ef3aa8faf74ddddfc603d5062/av-18.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:191224788d87af06c31784a395bb73f14b72f33d7f4871ace0157de2abdc6276", size = 36859932, upload-time = "2026-08-12T22:27:31.403Z" }, + { url = "https://files.pythonhosted.org/packages/33/b4/76ba21e46704f632004276b85289a1582e95f5eff760436d6149875a1881/av-18.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:ea1480b7a8d5405cb5f382b344731bf125fd2c1c6fae3964f6c48595628387ff", size = 27595679, upload-time = "2026-08-12T22:27:35.177Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ad/a3135884c5753b09773176b97201ae602f67ad14206c395ff838d66bf9b0/av-18.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:5509ec12aaa19fd6601de13cfa6f4cdad450da07982118510592875d970454d6", size = 20257584, upload-time = "2026-08-12T22:27:38.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5b/4a756265d7fb164336c8d377bca21c39cfa2c178be23cedee840a69b59c5/av-18.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b36b0bae9e4c62f9487c99481ec15e4e3870fcc868522cd6d18fc2d6bfa04f01", size = 22795654, upload-time = "2026-08-12T22:27:42.016Z" }, + { url = "https://files.pythonhosted.org/packages/d5/cc/1bc841462114a1adf4f7d87456ab78a6972e23271e71865fcd2bbd0e7360/av-18.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:025f84494cb23278498f03b0d8117d3e47a1cbc9c44b97eb31875cf02251e46b", size = 18435735, upload-time = "2026-08-12T22:27:45.787Z" }, + { url = "https://files.pythonhosted.org/packages/b8/20/005500ed17a2e62a5e4bb94aa3786942560ec2f55ec1895ebf174c87abef/av-18.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:08a9ae288299cfcbf739dba4ad0c53b9b71f45184303dd45947920d022fed695", size = 37090807, upload-time = "2026-08-12T22:27:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f7/11e7f6d848d3690c31ca4f8578167393e619177f1493ccc93b9400852d4e/av-18.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cf8a17466bef07765dbdecc9e66ed9b25d20b4e14f654fbf35345a58ac45fa0c", size = 38976836, upload-time = "2026-08-12T22:27:54.565Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/b271473b24e806062d31191e40c6d65545e9cf59f80f044eba56dcbba0f4/av-18.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d49a5c542dfdc00f43c6cdb6cc41dac1781ee206fe180b56aa7433dfa816dfae", size = 40896630, upload-time = "2026-08-12T22:27:59.118Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9f/2ab7fa292a947ad3466ed8e655eefa3b82f535d7ea598c297b4471a937c4/av-18.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5548b79e2bf1f59b3e9aedc918a72d9dc45b9adaac10ff9470d5dbdda0002e47", size = 37895673, upload-time = "2026-08-12T22:28:03.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/04507c57249b399c3e4f23f01d221532f357338b5316fd2858fbd343127d/av-18.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7ea063f6690193ea335a1d592d6e0274350d45e2ed6af83ee107cb90cbfd84f", size = 39992431, upload-time = "2026-08-12T22:28:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d6/bc4b95bea9c2353a7e4d62a3fcfad9adcf0f881741c6ce01ee179d539ce3/av-18.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e4d48b9f12cad009cc72fe4f4099107de5e819c95f82767f4fd01a01481c0661", size = 28497798, upload-time = "2026-08-12T22:28:13.003Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d2/0c277a46f12647c1833f40496e132fb6001e0d19e6144b5ea30896461feb/av-18.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5cd9085028902c9880622bd37a12fd4b33060f06a52311f6f4867ca9f29a2c3b", size = 21421979, upload-time = "2026-08-12T22:28:16.48Z" }, +] + [[package]] name = "babel" version = "2.18.0" @@ -529,6 +568,43 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "ctranslate2" +version = "4.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyyaml" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/17/f22fbbb0723891704af803e1e7af5541bb46013adb347f870cbd8c4834d3/ctranslate2-4.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb2a5b4c206b3bd1f7f4b44d07e86e42050af6753053cdc86444f8d0ead9d6d8", size = 1271641, upload-time = "2026-08-31T19:37:07.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/af/cfe767012a0809c15155ecae3125ca66a16aeee4669bae146220cc631a76/ctranslate2-4.8.2-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:8221875b09ef982e3579a5165f8c9fcfb3d5de0f710af228eca2f73d69635ace", size = 11929414, upload-time = "2026-08-31T19:37:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/10/2e/2d08b1219303af7256aeb78b98ee847ffb229c5fe4815e6d3a1b6483846a/ctranslate2-4.8.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3823c9883c2c410a76b2f19feda9da628a3c112cedfd912db815e0055c8235e2", size = 16720002, upload-time = "2026-08-31T19:37:11.957Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3d/75c029ffb484957f1d2be0f9c3b9b1474955bb16c22df85bcba50c48df11/ctranslate2-4.8.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a24e0a95151b970941867fec94c983df978630f1996242a587c072d455607d28", size = 39378274, upload-time = "2026-08-31T19:37:14.898Z" }, + { url = "https://files.pythonhosted.org/packages/7e/86/21926da682103a20d4f4833b389131b801c71e6b8d6047be5f0a28fe673a/ctranslate2-4.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:995938fcd24a1174a7abf9765e7fa216b5b91a1d8e8c4c8f383c7a186e8bab2e", size = 19220522, upload-time = "2026-08-31T19:37:17.845Z" }, + { url = "https://files.pythonhosted.org/packages/12/97/9c63a51a8c8e13e95ec2aec639460fb0f271b0421e1a365b8aad440385c4/ctranslate2-4.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fedda669421a57f8164568ee86ea265727016988d310fcc2e24ca30e9ec457cd", size = 1271613, upload-time = "2026-08-31T19:37:19.958Z" }, + { url = "https://files.pythonhosted.org/packages/ca/27/e419389fb6040a8170bb30c3dcd50c29b70638852a264acc5bf804bf8800/ctranslate2-4.8.2-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:5385f15493e7b6f41377da83d0d5afeb8e0f36188260722f3800c3c7121455af", size = 11931717, upload-time = "2026-08-31T19:37:21.897Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/bfa42114fd583b0ef30b67113486ab72d3ff466e60a824739257bcc533bd/ctranslate2-4.8.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:781f835da7fdc4adcc38f9d674dbe9b37eaf3c9aee3b2bc17d684342ff59d549", size = 16894628, upload-time = "2026-08-31T19:37:24.328Z" }, + { url = "https://files.pythonhosted.org/packages/01/23/d72e70cac2b7c5c832a629c380235b53b20fac8c0921856bcf625b08ba44/ctranslate2-4.8.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:554a0b2abf098a11c66ef7930c0a05b3de683a455e8ea3f2e6ab6966aaabc136", size = 39555908, upload-time = "2026-08-31T19:37:27.948Z" }, + { url = "https://files.pythonhosted.org/packages/4e/23/e3b5322ff7368fcbed181ea4c209149416e7940b5b04971d5ee4084afe1a/ctranslate2-4.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:d94421d565d0de61c032998f737a18942b0f2bef40c0424b1846ec6f67300105", size = 19222069, upload-time = "2026-08-31T19:37:31.531Z" }, + { url = "https://files.pythonhosted.org/packages/2c/00/3f5d12d94daa22ddf51de0252313476c0f8fca8b1c7776592953764a8b85/ctranslate2-4.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a40b190248389e1adb38ff90407937131c6bb9d7f9771f29ca1dca01946ff252", size = 1271572, upload-time = "2026-08-31T19:37:33.476Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b5/fde838502472462f2c8fd9e8fe17e734854bbe1047452c28d4fbe3eab7b2/ctranslate2-4.8.2-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:2778eaf89340062feb0d2d3dd35bedb14644673ccbce8934782dc8d093b2cab9", size = 11931591, upload-time = "2026-08-31T19:37:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/cf22407330c90c1f3ef642ece11ff6bfe686509e995b95545951edf29117/ctranslate2-4.8.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:249b35f575adf8dd11e6ba43b212d5cbfc845d1140abc610c2872eef1ffb3481", size = 16895415, upload-time = "2026-08-31T19:37:37.342Z" }, + { url = "https://files.pythonhosted.org/packages/89/8b/051962470b8e9df1a43f0c9c7a03ce1c8a279db76be4e6a6fcb5171d1d29/ctranslate2-4.8.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:633f19d1ff8d053a8b179ca742b242002ac7882a2ab44e319c6efcde27d586a4", size = 39555517, upload-time = "2026-08-31T19:37:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/c2/fc/a9e9e0ce1c0a29bc4c17bf56ccb4274293c0bbb2b8aae561727d82fcb0ca/ctranslate2-4.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:399c20a7336b6358f69ce3c615e090eabc08f29735a079fd1ac1e464119e0861", size = 19222061, upload-time = "2026-08-31T19:37:43.754Z" }, + { url = "https://files.pythonhosted.org/packages/72/b4/28111e86927b2490388d8e0b79ec83db38f2a81bf452318f24563b42e205/ctranslate2-4.8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e32ae5e625a8999749745de43c2de5c12ceebb6d0d9cc9a3a956e8912bd68edb", size = 1272406, upload-time = "2026-08-31T19:37:45.671Z" }, + { url = "https://files.pythonhosted.org/packages/03/07/444fccd37ba9e3d84dcb985fc174c82636e7a8c69b47bb0c1bf566045d37/ctranslate2-4.8.2-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:980f13e1d41987908af945861081158fbcb2983f4f9399514adc8c87378b5587", size = 11932080, upload-time = "2026-08-31T19:37:47.24Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/c8d91fa1848f9bc09e943337b9c7ebd20d77f1344f5d595d10979f5e59d4/ctranslate2-4.8.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e866ccf5f5668f7302c39b535d19d7e5811f3f00d6246faf6ed6fa070fa1af3", size = 16884555, upload-time = "2026-08-31T19:37:50.337Z" }, + { url = "https://files.pythonhosted.org/packages/14/0c/de0305052c2bb35979c4a6c668c7aa6f139c10bc0939833450b462cef829/ctranslate2-4.8.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:573dcf5d96034dd9bcf7ee0db1657af490a52292ffc77dfb4b104da1e93322ad", size = 39521307, upload-time = "2026-08-31T19:37:53.623Z" }, + { url = "https://files.pythonhosted.org/packages/f2/86/03386a60b634a34c820e00f82bec808d38841cc2c007ec6b797892a9a5b5/ctranslate2-4.8.2-cp314-cp314-win_amd64.whl", hash = "sha256:00bc9f44172d05bd2becefe757d2397ab3292fad1695169f9ebb9c16eb497ce2", size = 19478465, upload-time = "2026-08-31T19:37:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/33/46/f25a66e905d3ce13aba221e6e7d63e24acd2539e48549675e3e0527f9ffe/ctranslate2-4.8.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:58b1a78d050a7f281907b8acfe6f0cdbbf2feaf7464f38eb05b846f83fbaa297", size = 1295017, upload-time = "2026-08-31T19:37:58.379Z" }, + { url = "https://files.pythonhosted.org/packages/28/c2/0fc88102f448aee8fa704fec13e568f716df11aa701e901b2e401aa5d8ab/ctranslate2-4.8.2-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:70e2388d31dfb59968e6e011d01a6fc6bf802c0e1ad920a8d2a413f1c9da02ae", size = 11953132, upload-time = "2026-08-31T19:38:00.199Z" }, + { url = "https://files.pythonhosted.org/packages/62/23/f859ee8af1c6366795d59979a7e941a397823cc38585f5731c34a66f1048/ctranslate2-4.8.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32b9d0e984aa17a0da9b37bb7e4dcc638e898a2c32de08d5a4db53f15f640faa", size = 16872477, upload-time = "2026-08-31T19:38:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/b964f427fcdfad983977859386007e1367eb340f747f63b6c0001cf78905/ctranslate2-4.8.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd9ab8b521230e8e962a58362b1ccde582806e781af8c1b9d7b244f3024468b1", size = 39491894, upload-time = "2026-08-31T19:38:06.365Z" }, + { url = "https://files.pythonhosted.org/packages/6a/39/9d316f00f184cea15e807a977df5bc76fc8f27f81246015f12083f42cd1c/ctranslate2-4.8.2-cp314-cp314t-win_amd64.whl", hash = "sha256:f6f0b576c247984d3fc299a372ccc9319b668d6d25b0539f3c861beba15d0504", size = 19500896, upload-time = "2026-08-31T19:38:09.111Z" }, +] + [[package]] name = "cuda-bindings" version = "13.3.1" @@ -630,6 +706,22 @@ version = "1.9.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a5/f5/397b61091120a9ca5001041dd7bf76c385b3bfd67a0e5bcb74b852bd22a4/evdev-1.9.3.tar.gz", hash = "sha256:2c140e01ac8437758fa23fe5c871397412461f42d421aa20241dc8fe8cfccbc9", size = 32723, upload-time = "2026-02-05T21:54:24.987Z" } +[[package]] +name = "faster-whisper" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "av" }, + { name = "ctranslate2" }, + { name = "huggingface-hub" }, + { name = "onnxruntime" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", hash = "sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7", size = 1118909, upload-time = "2025-10-31T11:35:47.794Z" }, +] + [[package]] name = "filelock" version = "3.32.3" @@ -670,6 +762,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/62/3c062f593bd92ef4e77a0ef39541e3d82a0a1d3947c8a777a02a13a27828/hf_xet-1.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d", size = 4074584, upload-time = "2026-08-03T22:32:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1e/c0ad437dd267a8e435bef594acf781bbc3874ff0b6435b4962d03ecf7cc4/hf_xet-1.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675", size = 3867381, upload-time = "2026-08-03T22:32:49.049Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ee/7c0d7b6ab336167531b1c30af2af003f054af4c749becbd7209ae33a77c3/hf_xet-1.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b", size = 4453982, upload-time = "2026-08-03T22:32:50.568Z" }, + { url = "https://files.pythonhosted.org/packages/63/06/ad8eab1c9525246650cbaa821caa3cdbaca734ab1a5b8c91bea09cbd8d69/hf_xet-1.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522", size = 4249445, upload-time = "2026-08-03T22:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/d8/26/1eee8aedb0dafc1ab9717dc9ac602cde33361b232dc06803f1f6ed18b58c/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e", size = 4451099, upload-time = "2026-08-03T22:32:54.114Z" }, + { url = "https://files.pythonhosted.org/packages/67/57/0b88af1f194ab6c9c650547d9cc06bfeaab836ae4dcdb331676bfb8be95a/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9", size = 4664712, upload-time = "2026-08-03T22:32:55.547Z" }, + { url = "https://files.pythonhosted.org/packages/53/a0/26b717a9d1840e8abf48dcec64b5ed8fbe472671d38ad28d30e147132b33/hf_xet-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338", size = 4025906, upload-time = "2026-08-03T22:32:57.391Z" }, + { url = "https://files.pythonhosted.org/packages/49/f6/4a9966633c6fef83af997e2cff68ec1963676d412bdfd096df2a93b8e185/hf_xet-1.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765", size = 3849221, upload-time = "2026-08-03T22:32:59.123Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload-time = "2026-08-03T22:33:00.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload-time = "2026-08-03T22:33:03.802Z" }, + { url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload-time = "2026-08-03T22:33:05.287Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload-time = "2026-08-03T22:33:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload-time = "2026-08-03T22:33:08.616Z" }, + { url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload-time = "2026-08-03T22:33:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/97/2eb4abaa5b969ed385066a0496a3823b3ff467fc1082e2202955f1867d60/huggingface_hub-1.30.0.tar.gz", hash = "sha256:e6a6120bc8c8e2723d03648434ee247088cceb55ba7067e7d34d692cad5fdb57", size = 964291, upload-time = "2026-09-03T10:05:14.053Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/0e/3e45bbe0dd48f4e56b1d46649d342de853cd1c7e815323472ab62687f153/huggingface_hub-1.30.0-py3-none-any.whl", hash = "sha256:96ae0a8e99a234374a6fe43e989ebd21c04640b91ab2927e7e5773ba1131ca59", size = 796795, upload-time = "2026-09-03T10:05:12.21Z" }, +] + [[package]] name = "identify" version = "2.6.19" @@ -2120,6 +2293,10 @@ wheels = [ { 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/28/45/33e7fa218836035bf7ceda9035a3aaf1b34c5e31241015a72d970eea512e/sherpa_onnx-1.13.7-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:2053956c4575b6ce4621f54ff0af6fae595d7df10691032c79d271b089988ff5", size = 2355187, upload-time = "2026-09-04T14:54:04.717Z" }, + { url = "https://files.pythonhosted.org/packages/9f/23/dfc6a40410c7db7861da7f5a46cbd88054260c8d65e706b12a168e08f689/sherpa_onnx-1.13.7-cp314-cp314-android_24_armeabi_v7a.whl", hash = "sha256:70eecfee42f9104911be6af6573fa8eb9e5e9f658ca04454b5dc24ede1c99f2a", size = 2424902, upload-time = "2026-09-05T22:26:27.069Z" }, + { url = "https://files.pythonhosted.org/packages/ce/32/f2fb82f360e7d53ef5bdb92a1d245e387cf91695ca903cae994c5b364d9d/sherpa_onnx-1.13.7-cp314-cp314-android_24_x86.whl", hash = "sha256:81fea366ccc88074e15b9b7710723f0266cdd13f53b8bff1e5e8b5f95582c00a", size = 2516683, upload-time = "2026-09-05T22:32:46.817Z" }, + { url = "https://files.pythonhosted.org/packages/74/17/b99ccbb0fdb4cb1e4dedef03ec4b8ecbbd909b5d687f7407105dd28aabf7/sherpa_onnx-1.13.7-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26293b1db9a2772f69bd286cfe37551d0bd0437c1f1382e98cfe7d0c4435d331", size = 2577480, upload-time = "2026-09-05T16:43:39.794Z" }, { 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" }, @@ -2373,6 +2550,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/5e/9b01afd037bfa22a0033963fa091e0f75b6fb15cd85bffb42ff86e697323/tiktoken-0.14.0-cp315-cp315t-win_amd64.whl", hash = "sha256:561e7580f84a79859af1ef6f676968e9030fcc3fe195700b15235bca64f009c9", size = 987929, upload-time = "2026-08-17T19:49:38.947Z" }, ] +[[package]] +name = "tokenizers" +version = "0.23.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/1e/bc6587c5ab643b2e17776cace9070a2ae73549c86bffac9934a600bf3c31/tokenizers-0.23.2.tar.gz", hash = "sha256:7f0f085686b9de0d0079e6f874ae053600db64c5d13049e0bbc0119926d25aac", size = 385745, upload-time = "2026-09-03T08:55:42.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/ed/8a443528baa6fac8dfe8c3b75b038c63ac92bb539bcabe311e227c718173/tokenizers-0.23.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:85a9a357a3764aecc904ee76bdaf8cf1ad8e5a67a1b929a487c4a39b49ed0e90", size = 3148852, upload-time = "2026-09-03T08:55:30.874Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/22da045a91732384d3a3771816bf188dc5a1f702c32e635afa7c679c0bef/tokenizers-0.23.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:986670e43691469dcee610ea0f846f91a8f84e91fc6f7a48d4c064414c0ec2bf", size = 3101593, upload-time = "2026-09-03T08:55:28.587Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4d/8f569ed49372a3ed8e57099bd515055fd48d7c95912c4307cda6973c2168/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a37039b5dfc4af84eb3ef0a92f4307e28936c8f9adccba2629d36f652e9bf7a2", size = 3516830, upload-time = "2026-09-03T08:55:14.741Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/e2f14c8919d5bf51874051d00d6c7b7e0e8bde6c6a2dbeddda7f642896ff/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b7e37ba198f24150f523e1242e83c4970de4a525480586be5dcc24d9add32c5", size = 3407975, upload-time = "2026-09-03T08:55:16.842Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bd/93c69152d02ef06ce47aed8b2bf4952dcf733c935a62791873932b2934d9/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43e4f2071e3cc8d5d86421c874aebc82659bb51a68bcdef5a0da75ee89511ccb", size = 3748165, upload-time = "2026-09-03T08:55:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b7/56b84b80bc96942bba8eb23751a9e8a1fce4faaf4390425e7083f721c98c/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:325fee2e0418a9dc6c9ecf736a5f5f0db7875183ace9549ae339da76f7a1fbb7", size = 4024165, upload-time = "2026-09-03T08:55:18.806Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8a/0175e216f005c2fe08238292663aa41e4c802b216e71047a69a0e9fc6fa3/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:950d7c9426fa72406a0ffeacdbc0bb9985f5db20eb8b263f29c79aaf83105703", size = 3591899, upload-time = "2026-09-03T08:55:22.752Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ca/ca6b93c7820df123b2662a9469e8facc826ccc94e98fdd0d615f6431e73a/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41c2f84d172449b4dadb9cdc508e3e364076613c35b16e76ecfe47a60d1e3305", size = 3386843, upload-time = "2026-09-03T08:55:26.584Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a4/4f9106d317b14a80aefea9f0e3a8d07ef25f856a7607eb7f5ab894281fcb/tokenizers-0.23.2-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:12f0835dc2ee694746a76adf7b1567d4346a4a502ebe93fb1f5f80ea49799b78", size = 3577314, upload-time = "2026-09-03T08:55:20.825Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6a/1552b70fb0d9ab074fd3fc961435d01364e79c9058481822c3af6e8d402c/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:eb2f9c8a24da020ea8c11a01a19c1c2547912d92121ae4a01cfbca46125dee40", size = 9967367, upload-time = "2026-09-03T08:55:33.188Z" }, + { url = "https://files.pythonhosted.org/packages/06/01/3ccb3a956c7528b2507b8a9714155c4baf86af593039db6ea375dd0c96c3/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f486f402f6f9abee5bb032553736813af0c710a86b2e0ca592634c55cea1f835", size = 9811886, upload-time = "2026-09-03T08:55:35.642Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/7038e612d48bda1599457f712f6bd3854eae1a9dc9c13aa47f835349db48/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:bef235815a067b2648caf6dcc7a71091b0b0fff9ee8057f6451eb9335fae52ef", size = 10146224, upload-time = "2026-09-03T08:55:38.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d8/8e9e4e0b287a338d8f88976729628c9d22e8a54cfaf9777018a7f7cb58a0/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5c56bda1511921587789163e524d196ed8284174ac23abd7685d5ea8da6c4718", size = 10256304, upload-time = "2026-09-03T08:55:40.977Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1f/c79a01f671a49728ebb0b61f7ff9ea45663b66cab40bc0858e9859b25c16/tokenizers-0.23.2-cp310-abi3-win32.whl", hash = "sha256:debf978920d93ba9c219bd67cc4bbfaf912c9039e41e7a28b91ec15e3728c95a", size = 2592809, upload-time = "2026-09-03T08:55:48.02Z" }, + { url = "https://files.pythonhosted.org/packages/db/f7/0a69ac6b82dbccf3f71add938a161c497952749294b8dd6dfe03a819dc40/tokenizers-0.23.2-cp310-abi3-win_amd64.whl", hash = "sha256:2e96f5699d5249c9c64aa8412e044f727aae3a4098cf830f9901ec1afc361cde", size = 2863236, upload-time = "2026-09-03T08:55:46.193Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b0/dee84cb44175be1b4c35bd2f770727494e78f0bb38e571a623ade94dbebb/tokenizers-0.23.2-cp310-abi3-win_arm64.whl", hash = "sha256:e49c394456dd9985787fec76132438ba3fb8911f857b1bf3d40119f9292d41aa", size = 2729352, upload-time = "2026-09-03T08:55:44.345Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -2592,6 +2796,9 @@ docs = [ { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-rtd-theme" }, ] +faster-whisper = [ + { name = "faster-whisper" }, +] parakeet = [ { name = "sherpa-onnx" }, ] @@ -2617,6 +2824,7 @@ lint = [ [package.metadata] requires-dist = [ { name = "evdev", marker = "sys_platform == 'linux'", specifier = ">=1.6.0,<2" }, + { name = "faster-whisper", marker = "extra == 'faster-whisper'", specifier = ">=1.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "numpy", specifier = ">=1.22.0,<3" }, { name = "onnxruntime", marker = "extra == 'vad'", specifier = ">=1.16.0" }, @@ -2639,7 +2847,7 @@ requires-dist = [ { name = "torchaudio", marker = "extra == 'whisper'", specifier = ">=2.0.0" }, { name = "vosk", marker = "extra == 'vosk'", specifier = ">=0.3.45" }, ] -provides-extras = ["vosk", "whisper", "parakeet", "vad", "dev", "docs"] +provides-extras = ["vosk", "whisper", "parakeet", "faster-whisper", "vad", "dev", "docs"] [package.metadata.requires-dev] lint = [