diff --git a/README.md b/README.md index ec2a7fa..4ba3f41 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ curl -fsSL raw.githubusercontent.com/jatinkrmalik/vocalinux/main/install.sh -o / The installer will: - **Auto-detect your hardware** (GPU, RAM, Vulkan support) - **Recommend the best engine** for your system -- **Download the appropriate model** (~74MB for whisper.cpp tiny) +- **Download the appropriate model** (~74MB for the default whisper.cpp tiny model) - **Install neural VAD support** when ONNX Runtime is available - **Install in ~1-2 minutes** (vs 5-10 min with old Whisper) @@ -243,6 +243,8 @@ vocalinux --engine whisper_cpp # Use whisper.cpp engine (default) vocalinux --engine whisper # Use OpenAI Whisper engine vocalinux --engine vosk # Use VOSK engine vocalinux --model medium # Use medium-sized model +vocalinux --model medium.en-q5_0 # Use exact whisper.cpp model variant +vocalinux --model large-v3-turbo # Use large-v3 Turbo with whisper.cpp vocalinux --wayland # Force Wayland mode vocalinux --start-minimized # Start without first-run modal prompts ``` @@ -283,7 +285,10 @@ Configuration is stored in `~/.config/vocalinux/config.json`: } ``` -You can also configure settings through the graphical Settings dialog (right-click the tray icon). +For whisper.cpp, `model_size` may be a size such as `tiny` or an exact ggml model ID +such as `medium.en-q5_0` or `large-v3-turbo`. You can also configure this through +the graphical Settings dialog, where whisper.cpp models are split into **Model Size** +and **Specialization** controls. ### Neural Voice Activity Detection diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 1c2e7d4..7eccf8f 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -16,7 +16,7 @@ That's it! The installer handles everything automatically: - ✅ Installs whisper.cpp (~1-2 minutes, no heavy dependencies!) - ✅ Auto-detects your GPU (AMD, Intel, NVIDIA all supported) - ✅ Installs neural VAD support when ONNX Runtime is available -- ✅ Downloads the tiny model (~74MB) +- ✅ Downloads the default whisper.cpp tiny model (~74MB) - ✅ Configures everything automatically > ⏱️ **Installation Time**: ~1-2 minutes (vs 5-10 minutes with old Whisper AI) @@ -205,7 +205,7 @@ Examples: - **VOSK**: Lightweight engine for older systems 5. **Installs neural VAD support** when ONNX Runtime is available, with a safe amplitude-VAD fallback otherwise 6. **Downloads speech recognition models**: - - whisper.cpp: ~74MB tiny model (or larger if selected) + - whisper.cpp: default ~74MB tiny model; selectable variants range from smaller quantized models to large models - Whisper: ~75MB tiny model + PyTorch dependencies (~2.3GB with CUDA) - VOSK: ~40MB small model 7. **Installs desktop integration** (icons, .desktop file) @@ -215,7 +215,7 @@ Examples: | Engine | Download Size | Install Time | GPU Support | |--------|---------------|--------------|-------------| -| **whisper.cpp** (default) | ~74-500MB | ~1-2 min | AMD, Intel, NVIDIA (Vulkan) | +| **whisper.cpp** (default) | ~74MB default; variants from ~15MB-3GB | ~1-2 min | AMD, Intel, NVIDIA (Vulkan) | | Whisper (OpenAI) | ~2.3GB+ | ~5-10 min | NVIDIA only (CUDA) | | VOSK | ~40MB | ~30 sec | CPU only | @@ -247,6 +247,8 @@ vocalinux --model tiny # Use tiny model (default, fastest) vocalinux --model small # Use small model vocalinux --model medium # Use medium model vocalinux --model large # Use large model +vocalinux --model medium.en-q5_0 # Use exact whisper.cpp model variant +vocalinux --model large-v3-turbo # Use large-v3 Turbo with whisper.cpp vocalinux --wayland # Force Wayland compatibility mode vocalinux --start-minimized # Start without first-run modal prompts ``` @@ -449,13 +451,25 @@ gtk-update-icon-cache -f -t ~/.local/share/icons/hicolor - **No special drivers** - Just needs standard Vulkan support **Models:** -whisper.cpp uses the same models as OpenAI Whisper, converted to `ggml` format: +whisper.cpp uses OpenAI Whisper models converted to `ggml` format. Vocalinux lets you +pick a top-level size and, for whisper.cpp, a specialization: + - **tiny** (~74MB) - Fastest, good for real-time dictation - **base** (~141MB) - Good balance of speed and accuracy - **small** (~465MB) - Better accuracy, still fast - **medium** (~1.5GB) - High accuracy - **large** (~3.0GB) - Best accuracy, slower +Available whisper.cpp specializations include: +- **Standard multilingual** - Best default for auto-detect or non-English dictation +- **English-only** - Choose when you dictate only in English +- **Quantized** - Lower memory and smaller downloads with a possible accuracy tradeoff +- **Turbo** - Faster large-v3 option with strong accuracy +- **Legacy large** - Use only if you specifically need an older large model version + +Examples of exact whisper.cpp model IDs are `tiny.en`, `small.en-q5_1`, +`medium-q5_0`, `medium.en-q5_0`, `large-v3-turbo`, and `large-v3-turbo-q8_0`. + ### Checking GPU Support To verify your GPU is detected: @@ -494,12 +508,13 @@ Change the `engine` field: { "speech_recognition": { "engine": "whisper_cpp", // Options: whisper_cpp, whisper, vosk - "model_size": "tiny" + "model_size": "tiny" // Or an exact whisper.cpp ID like medium.en-q5_0 } } ``` -Or use the GUI: Right-click tray icon → Settings → Speech Engine +Or use the GUI: Right-click tray icon → Settings → Speech Engine. For whisper.cpp, +the GUI splits this into **Model Size** and **Specialization**. ## Troubleshooting diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 19b0646..9387a77 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -69,7 +69,9 @@ Vocalinux supports several commands that you can speak to control formatting: 6. **Use GPU acceleration**: If you have a GPU (AMD, Intel, or NVIDIA), whisper.cpp will automatically use it for faster transcription 7. **Choose the right model**: - For real-time dictation: Use `tiny` or `base` (fastest) - - For better accuracy: Use `vocalinux --model medium` or `--model large` + - For better accuracy: Use `small`, `medium`, or `large` + - For English-only dictation: Choose an `.en` specialization + - For lower-memory systems: Choose a quantized specialization such as `q5_0` or `q5_1` 8. **Check debug logs**: Run `vocalinux --debug` to see which backend is being used (Vulkan, CUDA, or CPU) ## Customization @@ -121,6 +123,14 @@ Vocalinux now offers **three speech recognition engines**: - **small** (~465MB) - Better accuracy - **medium** (~1.5GB) - High accuracy - **large** (~3.0GB) - Best accuracy, slower +5. For whisper.cpp, select a **Specialization**: + - **Standard multilingual** - Best default for auto-detect or non-English dictation + - **English-only** - Choose when you dictate only in English + - **Quantized** - Lower memory and smaller downloads with a possible accuracy tradeoff + - **Turbo** - Faster large-v3 option with strong accuracy + - **Legacy large** - Use only if you specifically need an older large model version + +English-only whisper.cpp specializations limit the language selector to English. ### When to Use Each Model @@ -130,6 +140,10 @@ Vocalinux now offers **three speech recognition engines**: **For maximum accuracy:** Use **large** - best results but requires more RAM and GPU power. +**For lower-memory systems:** Use a quantized whisper.cpp specialization such as **Q5** or **Q8**. + +**For English-only dictation:** Use an **English-only** specialization and keep the language set to English. + ### GPU Acceleration **whisper.cpp** automatically uses GPU acceleration when available: diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index f691e78..3164cdd 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -28,8 +28,10 @@ def parse_arguments(): parser.add_argument( "--model", type=str, - choices=["small", "medium", "large"], - help="Speech recognition model size (small, medium, large)", + help=( + "Speech recognition model ID. Examples: small, medium, large, " + "medium.en-q5_0, large-v3-turbo" + ), ) parser.add_argument( "--language", diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index e03c876..9ec00cd 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -18,7 +18,7 @@ import logging import os import threading import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional import gi @@ -30,12 +30,16 @@ from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402 from ..common_types import RecognitionState # noqa: E402 from ..speech_recognition.silero_vad import is_silero_available # noqa: E402 from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO # noqa: E402 +from ..utils.whispercpp_model_info import MODEL_SIZES as WHISPERCPP_MODEL_SIZES from ..utils.whispercpp_model_info import ( WHISPERCPP_MODEL_INFO, detect_compute_backend, get_backend_display_name, ) +from ..utils.whispercpp_model_info import get_model_size as get_whispercpp_model_size +from ..utils.whispercpp_model_info import get_model_variants as get_whispercpp_model_variants from ..utils.whispercpp_model_info import get_recommended_model as get_recommended_whispercpp_model +from ..utils.whispercpp_model_info import is_english_only_model as is_english_only_whispercpp_model from ..utils.whispercpp_model_info import is_model_downloaded as is_whispercpp_model_downloaded from .config_manager import DEFAULT_CONFIG # noqa: E402 from .keyboard_backends import ( # noqa: E402 @@ -67,12 +71,8 @@ ENGINE_MODELS = { "large", ], # Add more whisper sizes if needed "whisper_cpp": [ - "tiny", - "base", - "small", - "medium", - "large", - ], # whisper.cpp models (ggml format) + *WHISPERCPP_MODEL_SIZES, + ], # whisper.cpp top-level size buckets; variants are selected separately "remote_api": [], # Remote API does not need local models } @@ -98,6 +98,165 @@ def _engine_from_display(display_name: str) -> str: return display_name.lower() +def _model_display_name(model_name: str) -> str: + """Get a user-friendly model display name.""" + if model_name == "large": + return "Large v3" + + display_parts = [] + for part in model_name.split("-"): + if part.endswith(".en"): + display_parts.append(part[:-3].capitalize()) + display_parts.append("EN") + elif part.startswith("q"): + display_parts.append(part.upper()) + elif part == "turbo": + display_parts.append("Turbo") + elif part.startswith("v") and part[1:].isdigit(): + display_parts.append(part) + else: + display_parts.append(part.capitalize()) + + return " ".join(display_parts) + + +def _model_specialization_display_name(model_name: str) -> str: + """Get a concise label for a whisper.cpp model variant.""" + if model_name == "large": + return "Standard v3" + + quantization = None + for part in model_name.split("-"): + if part.startswith("q"): + quantization = part.upper() + break + + if model_name.startswith("large-v") and "turbo" not in model_name: + version = next( + (part for part in model_name.split("-") if part.startswith("v")), + "large", + ) + if quantization: + return f"{version} {quantization}" + return version + + if is_english_only_whispercpp_model(model_name): + return f"English-only {quantization}" if quantization else "English-only" + + if "turbo" in model_name: + if quantization: + return f"Turbo {quantization}" + return "Turbo" + + if quantization: + return f"Quantized {quantization}" + + return "Standard multilingual" + + +def _language_is_english(language_id: str) -> bool: + """Return whether a language ID maps to English for Whisper.""" + return SUPPORTED_LANGUAGES.get(language_id, {}).get("whisper") == "en" + + +def _recommended_whispercpp_variant_for_language( + recommended_model: str, + reason: str, + language_id: str, +) -> tuple[str, str]: + """Adjust a hardware recommendation to the selected language.""" + recommended_size = get_whispercpp_model_size(recommended_model) + english_variant = f"{recommended_size}.en" + + if _language_is_english(language_id) and english_variant in WHISPERCPP_MODEL_INFO: + return english_variant, f"{reason}; English language selected" + + return recommended_model, reason + + +def _default_whispercpp_variant_for_size(model_size: str, language_id: str) -> Optional[str]: + """Return the default specialization for a user-selected size and language.""" + variants = get_whispercpp_model_variants(model_size) + if not variants: + return None + + english_variant = f"{model_size}.en" + if _language_is_english(language_id) and english_variant in variants: + return english_variant + + standard_variant = "large" if model_size == "large" else model_size + if standard_variant in variants: + return standard_variant + + return variants[0] + + +MODEL_SIZE_TOOLTIP = ( + "Choose the largest model your computer can run comfortably. Tiny/Base are fastest, " + "Small is balanced, and Medium/Large can be more accurate but need more memory." +) +MODEL_SPECIALIZATION_TOOLTIP = ( + "Choose Standard multilingual unless you specifically need English-only accuracy, " + "lower-memory quantized models, Turbo speed, or a legacy large model." +) +LANGUAGE_TOOLTIP = ( + "Choose the language you dictate in. English-only model specializations limit this " + "list to English." +) + + +def _model_specialization_tooltip(model_name: str) -> str: + """Return hover guidance for a whisper.cpp specialization.""" + is_english_only = is_english_only_whispercpp_model(model_name) + is_quantized = any(part.startswith("q") for part in model_name.split("-")) + + if "turbo" in model_name: + if is_quantized: + return ( + "Choose this for a faster large-v3 Turbo model with lower disk and memory use; " + "expect a small accuracy tradeoff from quantization." + ) + return ( + "Choose Turbo when you want high accuracy from a large model with less memory use " + "and faster inference than full large v3." + ) + + if model_name.startswith("large-v") and model_name != "large": + return ( + "Choose this only if you specifically want that legacy large model version; " + "Standard v3 or Turbo is the better default for most users." + ) + + if is_english_only and is_quantized: + return ( + "Choose this for English-only dictation on lower-memory systems; choose " + "multilingual if you use auto-detect or any non-English language." + ) + + if is_english_only: + return ( + "Choose this when you dictate only in English. It can be better for English, " + "but it will not work for other languages." + ) + + if is_quantized: + return ( + "Choose this on lower-memory systems or when download size matters; it uses " + "less disk and RAM with a possible accuracy tradeoff." + ) + + if model_name == "large": + return ( + "Choose Standard v3 when you want the highest default accuracy and have enough " + "memory for a large model." + ) + + return ( + "Choose Standard multilingual for most users, auto-detect, or any supported " + "non-English language." + ) + + # Whisper model metadata for display WHISPER_MODEL_INFO = { "tiny": {"size_mb": 75, "desc": "Fastest, lowest accuracy", "params": "39M"}, @@ -635,7 +794,7 @@ class ModelDownloadDialog(Gtk.Dialog): language: str = "en-us", ): super().__init__( - title=f"Downloading {model_name.capitalize()} Model", + title=f"Downloading {_model_display_name(model_name)} Model", transient_for=parent, flags=Gtk.DialogFlags.MODAL, ) @@ -657,7 +816,10 @@ class ModelDownloadDialog(Gtk.Dialog): # Info label self.info_label = Gtk.Label( - label=f"Downloading {engine_display} {model_name} model (~{_format_size(model_size_mb)})...", + label=( + f"Downloading {engine_display} {_model_display_name(model_name)} model " + f"(~{_format_size(model_size_mb)})..." + ), wrap=True, justify=Gtk.Justification.CENTER, ) @@ -811,7 +973,7 @@ class SettingsDialog(Gtk.Dialog): else: screen_height = 1080 # Default fallback screen_width = 1920 - dialog_height = int(screen_height * 0.4) + dialog_height = min(600, int(screen_height * 0.4)) dialog_width = min(700, int(screen_width * 0.8)) self.set_default_size(dialog_width, dialog_height) self.get_style_context().add_class("settings-dialog") @@ -1120,24 +1282,40 @@ class SettingsDialog(Gtk.Dialog): # Model size selection self.model_combo = Gtk.ComboBoxText() self.model_combo.set_size_request(180, -1) + self.model_combo.set_tooltip_text(MODEL_SIZE_TOOLTIP) _prevent_scroll_on_hover(self.model_combo) self.model_row = PreferenceRow( title="Model Size", subtitle="Larger models are more accurate but slower", widget=self.model_combo, ) + self.model_row.set_tooltip_text(MODEL_SIZE_TOOLTIP) group.add_row(self.model_row) + # whisper.cpp specialization selection + self.model_variant_combo = Gtk.ComboBoxText() + self.model_variant_combo.set_size_request(220, -1) + self.model_variant_combo.set_tooltip_text(MODEL_SPECIALIZATION_TOOLTIP) + _prevent_scroll_on_hover(self.model_variant_combo) + self.model_variant_row = PreferenceRow( + title="Specialization", + subtitle="Variant for language, speed, or memory use", + widget=self.model_variant_combo, + ) + self.model_variant_row.set_tooltip_text(MODEL_SPECIALIZATION_TOOLTIP) + group.add_row(self.model_variant_row) + # Language selection self.language_combo = Gtk.ComboBoxText() self.language_combo.set_size_request(180, -1) - self.language_combo.set_tooltip_text("Primary language for speech recognition") + self.language_combo.set_tooltip_text(LANGUAGE_TOOLTIP) _prevent_scroll_on_hover(self.language_combo) self.language_row = PreferenceRow( title="Language", subtitle="Primary language for recognition", widget=self.language_combo, ) + self.language_row.set_tooltip_text(LANGUAGE_TOOLTIP) group.add_row(self.language_row) self.content_box.pack_start(group, False, False, 0) @@ -1188,6 +1366,7 @@ class SettingsDialog(Gtk.Dialog): # Connect signals self.engine_combo.connect("changed", self._on_engine_changed) self.model_combo.connect("changed", self._on_model_changed) + self.model_variant_combo.connect("changed", self._on_model_variant_changed) self.language_combo.connect("changed", self._on_language_changed) def _on_remote_api_settings_changed(self, widget): @@ -2063,16 +2242,9 @@ class SettingsDialog(Gtk.Dialog): if self.engine_combo.get_model(): self.engine_combo.set_active(0) - # Populate model options for the selected engine + # Populate model and language options for the selected engine self._populate_model_options() - - # Populate language options - self._populate_language_options() - if self.language: - if not self.language_combo.set_active_id(self.language): - logger.warning(f"Language '{self.language}' not found in options, using auto") - self.language_combo.set_active_id("auto") - self.language = "auto" + self._sync_language_options_for_selected_model(self.language) # Set spin button values self.vad_spin.set_value(self.current_vad) @@ -2136,11 +2308,110 @@ class SettingsDialog(Gtk.Dialog): "silence_timeout": silence_timeout, } + def _get_selected_engine(self) -> str: + """Return the currently selected engine ID.""" + engine_text = self.engine_combo.get_active_text() + return _engine_from_display(engine_text) if engine_text else "vosk" + + def _get_selected_whispercpp_model(self) -> str: + """Return the selected whisper.cpp model variant.""" + variant_id = self.model_variant_combo.get_active_id() + if variant_id: + return variant_id + + size_id = self.model_combo.get_active_id() + model_size = size_id.lower() if size_id else "small" + variants = get_whispercpp_model_variants(model_size) + return variants[0] if variants else "small" + + def _get_recommended_whispercpp_model_for_language(self) -> tuple[str, str]: + """Return the recommended whisper.cpp variant for the selected language.""" + recommended_model, reason = get_recommended_whispercpp_model() + language_id = self.language_combo.get_active_id() or self.language + return _recommended_whispercpp_variant_for_language( + recommended_model, + reason, + language_id, + ) + + def _get_default_whispercpp_variant_for_size(self, model_size: str) -> Optional[str]: + """Return the default specialization for a user-selected size.""" + language_id = self.language_combo.get_active_id() or self.language + return _default_whispercpp_variant_for_size(model_size, language_id) + + def _is_selected_whispercpp_model_english_only(self) -> bool: + """Return whether the selected model is a whisper.cpp English-only variant.""" + if self._get_selected_engine() != "whisper_cpp": + return False + return is_english_only_whispercpp_model(self._get_selected_whispercpp_model()) + + def _set_combo_active_id_or_first(self, combo, active_id: Optional[str]) -> bool: + """Set a combo to an ID, falling back to the first row.""" + if active_id and combo.set_active_id(active_id): + return True + + model = combo.get_model() + if model: + combo.set_active(0) + return True + return False + + def _default_language_for_engine(self, engine: str) -> str: + """Return a safe default language for the selected engine and model.""" + if engine == "vosk" or self._is_selected_whispercpp_model_english_only(): + return "en-us" + return "auto" + + def _update_model_picker_tooltips(self): + """Refresh model picker hover guidance for the current selection.""" + self.model_combo.set_tooltip_text(MODEL_SIZE_TOOLTIP) + self.model_row.set_tooltip_text(MODEL_SIZE_TOOLTIP) + self.language_combo.set_tooltip_text(LANGUAGE_TOOLTIP) + self.language_row.set_tooltip_text(LANGUAGE_TOOLTIP) + + if self._get_selected_engine() == "whisper_cpp": + specialization_tooltip = _model_specialization_tooltip( + self._get_selected_whispercpp_model() + ) + else: + specialization_tooltip = MODEL_SPECIALIZATION_TOOLTIP + + self.model_variant_combo.set_tooltip_text(specialization_tooltip) + self.model_variant_row.set_tooltip_text(specialization_tooltip) + + def _sync_language_options_for_selected_model(self, preferred_language: Optional[str] = None): + """Refresh language options and keep the current model/language pair valid.""" + engine = self._get_selected_engine() + language_to_keep = ( + preferred_language or self.language_combo.get_active_id() or self.language + ) + + self._processing_language_change = True + try: + self._populate_language_options() + if not self._set_combo_active_id_or_first(self.language_combo, language_to_keep): + return + + active_language = self.language_combo.get_active_id() + if active_language != language_to_keep: + fallback_language = self._default_language_for_engine(engine) + self._set_combo_active_id_or_first(self.language_combo, fallback_language) + + self.language = ( + self.language_combo.get_active_id() or self._default_language_for_engine(engine) + ) + finally: + self._processing_language_change = False + + self._update_language_warning() + self._update_model_picker_tooltips() + def _populate_model_options(self): """Populate model options based on the current engine selection.""" self._populating_models = True try: self.model_combo.remove_all() + self.model_variant_combo.remove_all() engine_text = self.engine_combo.get_active_text() if not engine_text: @@ -2158,12 +2429,14 @@ class SettingsDialog(Gtk.Dialog): saved_model_for_engine = self.config_manager.get_model_size_for_engine(engine) logger.info(f"Saved model for {engine}: {saved_model_for_engine}") + if engine == "whisper_cpp": + self._populate_whispercpp_model_options(saved_model_for_engine) + return + downloaded_models = [] smallest_model = None if engine == "whisper": recommended_model, _ = _get_recommended_whisper_model() - elif engine == "whisper_cpp": - recommended_model, _ = get_recommended_whispercpp_model() else: recommended_model, _ = _get_recommended_vosk_model() @@ -2172,9 +2445,6 @@ 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 == "whisper_cpp" and size in WHISPERCPP_MODEL_INFO: - info = WHISPERCPP_MODEL_INFO[size] - is_downloaded = is_whispercpp_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) @@ -2182,9 +2452,13 @@ class SettingsDialog(Gtk.Dialog): is_downloaded = False info = {"size_mb": 0} + model_display_name = _model_display_name(size) status = "✓" if is_downloaded else "↓" star = " ★" if size == recommended_model else "" - display_text = f"{size.capitalize()} ({_format_size(info.get('size_mb', 0))}) {status}{star}" + display_text = ( + f"{model_display_name} ({_format_size(info.get('size_mb', 0))}) " + f"{status}{star}" + ) if is_downloaded: downloaded_models.append(size) @@ -2221,6 +2495,62 @@ class SettingsDialog(Gtk.Dialog): finally: self._populating_models = False + def _populate_whispercpp_model_options(self, saved_model_for_engine: str): + """Populate whisper.cpp size and specialization selectors.""" + recommended_model, _ = self._get_recommended_whispercpp_model_for_language() + recommended_size = get_whispercpp_model_size(recommended_model) + + saved_model = saved_model_for_engine.lower() + if saved_model not in WHISPERCPP_MODEL_INFO: + saved_model = ( + recommended_model if recommended_model in WHISPERCPP_MODEL_INFO else "tiny" + ) + + saved_size = get_whispercpp_model_size(saved_model) + + for model_size in ENGINE_MODELS["whisper_cpp"]: + display_text = _model_display_name(model_size) + if model_size == recommended_size: + display_text += " ★" + self.model_combo.append(model_size, display_text) + + self._set_combo_active_id_or_first(self.model_combo, saved_size) + active_size = self.model_combo.get_active_id() or saved_size + self._populate_whispercpp_variant_options(active_size, saved_model) + + def _populate_whispercpp_variant_options( + self, + model_size: str, + selected_model: Optional[str] = None, + ): + """Populate the whisper.cpp specialization selector for a size.""" + self.model_variant_combo.remove_all() + + variants = get_whispercpp_model_variants(model_size.lower()) + recommended_model, _ = self._get_recommended_whispercpp_model_for_language() + + for model_name in variants: + info = WHISPERCPP_MODEL_INFO[model_name] + is_downloaded = is_whispercpp_model_downloaded(model_name) + status = "✓" if is_downloaded else "↓" + star = " ★" if model_name == recommended_model else "" + display_text = ( + f"{_model_specialization_display_name(model_name)} " + f"({_format_size(info['size_mb'])}) {status}{star}" + ) + self.model_variant_combo.append(model_name, display_text) + + model_to_set = selected_model if selected_model in variants else None + if not model_to_set and recommended_model in variants: + model_to_set = recommended_model + if not model_to_set: + model_to_set = self._get_default_whispercpp_variant_for_size(model_size.lower()) + if not model_to_set and variants: + model_to_set = variants[0] + + self._set_combo_active_id_or_first(self.model_variant_combo, model_to_set) + self._update_model_picker_tooltips() + def _on_engine_changed(self, widget): """Handle changes in the selected engine.""" engine_text = self.engine_combo.get_active_text() @@ -2239,8 +2569,7 @@ class SettingsDialog(Gtk.Dialog): self.language = "auto" self._populate_model_options() - self._populate_language_options() - self.language_combo.set_active_id(self.language) + self._sync_language_options_for_selected_model(self.language) self._update_engine_specific_ui() self._update_model_info() self._update_voice_commands_for_engine() @@ -2261,6 +2590,25 @@ class SettingsDialog(Gtk.Dialog): if self._populating_models: return + if self._get_selected_engine() == "whisper_cpp": + model_size = self.model_combo.get_active_id() + if model_size: + self._populating_models = True + try: + self._populate_whispercpp_variant_options(model_size) + finally: + self._populating_models = False + self._sync_language_options_for_selected_model() + + self._update_model_info() + self._auto_apply_settings() + + def _on_model_variant_changed(self, widget): + """Handle changes in the selected whisper.cpp specialization.""" + if self._populating_models: + return + + self._sync_language_options_for_selected_model() self._update_model_info() self._auto_apply_settings() @@ -2297,6 +2645,7 @@ class SettingsDialog(Gtk.Dialog): return engine = _engine_from_display(engine) + english_only_whispercpp = self._is_selected_whispercpp_model_english_only() for lang_code, lang_info in SUPPORTED_LANGUAGES.items(): display_text = lang_info["name"] @@ -2308,6 +2657,8 @@ class SettingsDialog(Gtk.Dialog): is_downloaded = _is_vosk_model_downloaded("small", lang_code) display_text += " ✓" if is_downloaded else " ↓" elif engine in ["whisper", "whisper_cpp", "remote_api"]: + if english_only_whispercpp and lang_info.get("whisper") != "en": + continue # Both Whisper and whisper.cpp support auto-detect if lang_code == "auto": display_text += " ⚠" @@ -2316,6 +2667,26 @@ class SettingsDialog(Gtk.Dialog): self.language_combo.append(lang_code, display_text) + def _update_language_warning(self): + """Update language help text for the selected engine/model/language.""" + lang_code = self.language_combo.get_active_id() + lang_info = SUPPORTED_LANGUAGES.get(lang_code, {}) + + if self._is_selected_whispercpp_model_english_only(): + self.language_warning.set_markup( + "⚠ English-only model selected. " + "Language choices are limited to English." + ) + self.language_warning.show() + elif lang_info.get("warning"): + self.language_warning.set_markup( + f"⚠ {lang_info['warning']}" + ) + self.language_warning.show() + else: + self.language_warning.set_markup("") + self.language_warning.hide() + def _on_language_changed(self, widget): """Handle language selection change.""" if self._processing_language_change: @@ -2331,20 +2702,9 @@ class SettingsDialog(Gtk.Dialog): self._processing_language_change = True try: - engine = _engine_from_display(engine) - lang_info = SUPPORTED_LANGUAGES.get(lang_code, {}) - - if lang_info.get("warning"): - self.language_warning.set_markup( - f"⚠ {lang_info['warning']}" - ) - self.language_warning.show() - else: - self.language_warning.set_markup("") - self.language_warning.hide() - self.language = lang_code self._populate_model_options() + self._update_language_warning() self._auto_apply_settings() finally: self._processing_language_change = False @@ -2357,17 +2717,24 @@ class SettingsDialog(Gtk.Dialog): if is_remote: self.model_row.hide() + self.model_variant_row.hide() self.model_info_card.hide() self.legend_box.hide() self.remote_server_group.show_all() self.remote_status_label.show() else: self.model_row.show_all() + if engine == "whisper_cpp": + self.model_variant_row.show_all() + else: + self.model_variant_row.hide() self.legend_box.show_all() self.remote_server_group.hide() self.remote_status_label.hide() self._update_model_info() + self._update_language_warning() + self._update_model_picker_tooltips() self._update_advanced_tab_sensitivity() def _update_advanced_tab_sensitivity(self): @@ -2405,12 +2772,14 @@ class SettingsDialog(Gtk.Dialog): engine = _engine_from_display(engine_text) - model_id = self.model_combo.get_active_id() - if not model_id: - self.model_info_card.hide() - return - - model_name = model_id.lower() + if engine == "whisper_cpp": + model_name = self._get_selected_whispercpp_model() + else: + model_id = self.model_combo.get_active_id() + if not model_id: + self.model_info_card.hide() + return + model_name = model_id.lower() if engine == "whisper": if model_name not in WHISPER_MODEL_INFO: @@ -2426,7 +2795,7 @@ class SettingsDialog(Gtk.Dialog): return info = WHISPERCPP_MODEL_INFO[model_name] is_downloaded = is_whispercpp_model_downloaded(model_name) - recommended, reason = get_recommended_whispercpp_model() + recommended, reason = self._get_recommended_whispercpp_model_for_language() backend, backend_info = detect_compute_backend() extra_info = ( f"Parameters: {info['params']} • Backend: {get_backend_display_name(backend)}" @@ -2443,8 +2812,11 @@ class SettingsDialog(Gtk.Dialog): self.model_info_card.hide() return + model_display_name = _model_display_name(model_name) + recommended_display_name = _model_display_name(recommended) + # Update title - self.model_info_title.set_markup(f"{model_name.capitalize()}: {info['desc']}") + self.model_info_title.set_markup(f"{model_display_name}: {info['desc']}") # Update subtitle with status if is_downloaded: @@ -2460,9 +2832,10 @@ class SettingsDialog(Gtk.Dialog): ) else: self.model_recommendation.set_markup( - f"Tip: {recommended.capitalize()} is recommended for your system ({reason})" + f"Tip: {recommended_display_name} is recommended for your system ({reason})" ) + self._update_model_picker_tooltips() self.model_info_card.show_all() def _auto_apply_settings(self): @@ -2585,8 +2958,11 @@ class SettingsDialog(Gtk.Dialog): language_id = self.language_combo.get_active_id() engine = _engine_from_display(engine_text) if engine_text else "vosk" - model_size = model_id.lower() if model_id else "small" - language = language_id if language_id else "auto" + if engine == "whisper_cpp": + model_size = self._get_selected_whispercpp_model() + else: + model_size = model_id.lower() if model_id else "small" + language = language_id if language_id else self._default_language_for_engine(engine) vad = int(self.vad_spin.get_value()) silence = self.silence_spin.get_value() @@ -2770,6 +3146,9 @@ For now, the engine has been reverted to VOSK.""" if engine == "whisper" and not _is_whisper_model_downloaded(model_name): needs_download = True model_info = WHISPER_MODEL_INFO.get(model_name, {"size_mb": 500}) + elif engine == "whisper_cpp" and not is_whispercpp_model_downloaded(model_name): + needs_download = True + model_info = WHISPERCPP_MODEL_INFO.get(model_name, {"size_mb": 39}) elif engine == "vosk" and not _is_vosk_model_downloaded(model_name, self.language): needs_download = True model_info = VOSK_MODEL_INFO.get(model_name, {"size_mb": 50}) diff --git a/src/vocalinux/utils/whispercpp_model_info.py b/src/vocalinux/utils/whispercpp_model_info.py index dcfe027..6617353 100644 --- a/src/vocalinux/utils/whispercpp_model_info.py +++ b/src/vocalinux/utils/whispercpp_model_info.py @@ -15,42 +15,104 @@ logger = logging.getLogger(__name__) # Whisper.cpp model information # Models are downloaded from Hugging Face (ggml format) +_WHISPERCPP_REPO_URL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main" + + +def _model_url(model_name: str) -> str: + """Build the Hugging Face URL for a ggml whisper.cpp model.""" + file_model_name = "large-v3" if model_name == "large" else model_name + return f"{_WHISPERCPP_REPO_URL}/ggml-{file_model_name}.bin" + + +_WHISPERCPP_MODEL_SPECS = [ + ("tiny", 74, "39M", "Fastest, lowest accuracy"), + ("tiny.en", 74, "39M", "English-only tiny model"), + ("tiny-q5_1", 15, "39M", "Quantized tiny model, lowest memory"), + ("tiny.en-q5_1", 15, "39M", "Quantized English-only tiny model"), + ("tiny-q8_0", 32, "39M", "Q8 quantized tiny model"), + ("base", 141, "74M", "Fast, good for basic use"), + ("base.en", 141, "74M", "English-only base model"), + ("base-q5_1", 60, "74M", "Quantized base model, lower memory"), + ("base.en-q5_1", 60, "74M", "Quantized English-only base model"), + ("base-q8_0", 82, "74M", "Q8 quantized base model"), + ("small", 465, "244M", "Balanced speed/accuracy"), + ("small.en", 465, "244M", "English-only small model"), + ("small-q5_1", 163, "244M", "Quantized small model, lower memory"), + ("small.en-q5_1", 163, "244M", "Quantized English-only small model"), + ("small-q8_0", 190, "244M", "Q8 quantized small model"), + ("medium", 1463, "769M", "High accuracy, slower"), + ("medium.en", 1463, "769M", "English-only medium model"), + ("medium-q5_0", 568, "769M", "Quantized medium model, lower memory"), + ("medium.en-q5_0", 568, "769M", "Quantized English-only medium model"), + ("medium-q8_0", 823, "769M", "Q8 quantized medium model"), + ("large-v1", 2952, "1550M", "Legacy large v1 model"), + ("large-v2", 2952, "1550M", "Legacy large v2 model"), + ("large-v2-q5_0", 1170, "1550M", "Quantized large v2 model, lower memory"), + ("large-v2-q8_0", 1660, "1550M", "Q8 quantized large v2 model"), + ("large", 2952, "1550M", "Highest accuracy, maps to large v3"), + ("large-v3-q5_0", 1170, "1550M", "Quantized large v3 model, lower memory"), + ("large-v3-turbo", 1620, "809M", "High accuracy, lower memory than large"), + ("large-v3-turbo-q5_0", 574, "809M", "Quantized large v3 Turbo model"), + ("large-v3-turbo-q8_0", 874, "809M", "Q8 quantized large v3 Turbo model"), +] + WHISPERCPP_MODEL_INFO = { - "tiny": { - "size_mb": 74, - "params": "39M", - "desc": "Fastest, lowest accuracy", - "url": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin", - }, - "base": { - "size_mb": 141, - "params": "74M", - "desc": "Fast, good for basic use", - "url": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin", - }, - "small": { - "size_mb": 465, - "params": "244M", - "desc": "Balanced speed/accuracy", - "url": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin", - }, - "medium": { - "size_mb": 1463, - "params": "769M", - "desc": "High accuracy, slower", - "url": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin", - }, - "large": { - "size_mb": 2952, - "params": "1550M", - "desc": "Highest accuracy, slowest", - "url": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin", - }, + spec[0]: { + "size_mb": spec[1], + "params": spec[2], + "desc": spec[3], + "url": _model_url(spec[0]), + } + for spec in _WHISPERCPP_MODEL_SPECS } # Available models list AVAILABLE_MODELS = list(WHISPERCPP_MODEL_INFO.keys()) +MODEL_SIZES = ["tiny", "base", "small", "medium", "large"] + +MODEL_VARIANTS_BY_SIZE = { + "tiny": ["tiny", "tiny.en", "tiny-q5_1", "tiny.en-q5_1", "tiny-q8_0"], + "base": ["base", "base.en", "base-q5_1", "base.en-q5_1", "base-q8_0"], + "small": [ + "small", + "small.en", + "small-q5_1", + "small.en-q5_1", + "small-q8_0", + ], + "medium": ["medium", "medium.en", "medium-q5_0", "medium.en-q5_0", "medium-q8_0"], + "large": [ + "large", + "large-v3-q5_0", + "large-v3-turbo", + "large-v3-turbo-q5_0", + "large-v3-turbo-q8_0", + "large-v2", + "large-v2-q5_0", + "large-v2-q8_0", + "large-v1", + ], +} + + +def get_model_size(model_name: str) -> str: + """Return the top-level whisper.cpp size bucket for a model variant.""" + model_name = model_name.lower() + if model_name.startswith("large"): + return "large" + return model_name.split(".", 1)[0].split("-", 1)[0] + + +def get_model_variants(model_size: str) -> list[str]: + """Return available whisper.cpp variants for a size bucket.""" + return list(MODEL_VARIANTS_BY_SIZE.get(model_size.lower(), [])) + + +def is_english_only_model(model_name: str) -> bool: + """Return whether a whisper.cpp model variant is English-only.""" + return ".en" in model_name.lower() + # Compute backend types class ComputeBackend: @@ -244,7 +306,7 @@ def get_model_path(model_name: str) -> str: Get the path where a model should be stored. Args: - model_name: Name of the model (tiny, base, small, medium, large) + model_name: Name of the model (for example tiny, base, small, medium, large) Returns: Path to the model file @@ -252,11 +314,11 @@ def get_model_path(model_name: str) -> str: models_dir = os.path.expanduser("~/.local/share/vocalinux/models/whispercpp") os.makedirs(models_dir, exist_ok=True) - if model_name == "large": - # Large model uses v3 variant - return os.path.join(models_dir, "ggml-large-v3.bin") - else: - return os.path.join(models_dir, f"ggml-{model_name}.bin") + model_info = WHISPERCPP_MODEL_INFO.get(model_name) + if model_info and model_info.get("url"): + return os.path.join(models_dir, os.path.basename(model_info["url"])) + + return os.path.join(models_dir, f"ggml-{model_name}.bin") def is_model_downloaded(model_name: str) -> bool: diff --git a/tests/test_main.py b/tests/test_main.py index 50587d0..53e9d95 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -57,8 +57,8 @@ class TestMainModule(unittest.TestCase): self.assertTrue(args.wayland) self.assertTrue(args.start_minimized) - def test_parse_arguments_model_choices(self): - """Test that model only accepts valid choices.""" + def test_parse_arguments_model_values(self): + """Test model parsing for base and exact whisper.cpp model IDs.""" with patch("sys.argv", ["vocalinux", "--model", "small"]): args = parse_arguments() self.assertEqual(args.model, "small") @@ -71,6 +71,14 @@ class TestMainModule(unittest.TestCase): args = parse_arguments() self.assertEqual(args.model, "large") + with patch("sys.argv", ["vocalinux", "--model", "medium.en-q5_0"]): + args = parse_arguments() + self.assertEqual(args.model, "medium.en-q5_0") + + with patch("sys.argv", ["vocalinux", "--model", "large-v3-turbo"]): + args = parse_arguments() + self.assertEqual(args.model, "large-v3-turbo") + def test_parse_arguments_engine_choices(self): """Test that engine only accepts valid choices.""" with patch("sys.argv", ["vocalinux", "--engine", "vosk"]): diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 17e7e1d..3f81933 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -586,6 +586,110 @@ class TestSettingsDialogHelperFunctions(unittest.TestCase): self.assertTrue(callable(_get_recommended_vosk_model)) + def test_whispercpp_settings_use_size_buckets(self): + """Test that whisper.cpp settings split size from specialization.""" + from vocalinux.ui.settings_dialog import ENGINE_MODELS, WHISPERCPP_MODEL_INFO + + self.assertEqual( + ENGINE_MODELS["whisper_cpp"], + ["tiny", "base", "small", "medium", "large"], + ) + self.assertIn("large-v3-turbo", WHISPERCPP_MODEL_INFO) + self.assertIn("large-v3-turbo-q5_0", WHISPERCPP_MODEL_INFO) + self.assertNotIn("small.en-tdrz", WHISPERCPP_MODEL_INFO) + + def test_model_display_name_large_v3_turbo(self): + """Test display labels for whisper.cpp model variants.""" + from vocalinux.ui.settings_dialog import _model_display_name + + self.assertEqual(_model_display_name("large"), "Large v3") + self.assertEqual(_model_display_name("large-v3-turbo"), "Large v3 Turbo") + self.assertEqual(_model_display_name("large-v3-turbo-q5_0"), "Large v3 Turbo Q5_0") + self.assertEqual(_model_display_name("tiny.en-q5_1"), "Tiny EN Q5_1") + + def test_model_specialization_display_name(self): + """Test concise specialization labels for the second whisper.cpp dropdown.""" + from vocalinux.ui.settings_dialog import _model_specialization_display_name + + self.assertEqual(_model_specialization_display_name("medium"), "Standard multilingual") + self.assertEqual(_model_specialization_display_name("medium.en"), "English-only") + self.assertEqual( + _model_specialization_display_name("medium.en-q5_0"), + "English-only Q5_0", + ) + self.assertEqual(_model_specialization_display_name("large"), "Standard v3") + self.assertEqual(_model_specialization_display_name("large-v3-turbo"), "Turbo") + self.assertEqual(_model_specialization_display_name("large-v2-q5_0"), "v2 Q5_0") + self.assertEqual(_model_specialization_display_name("large-v3-q5_0"), "v3 Q5_0") + + def test_model_picker_tooltips_explain_when_to_choose_variants(self): + """Test hover guidance for model picker choices.""" + from vocalinux.ui.settings_dialog import ( + LANGUAGE_TOOLTIP, + MODEL_SIZE_TOOLTIP, + MODEL_SPECIALIZATION_TOOLTIP, + _model_specialization_tooltip, + ) + + self.assertIn("largest model", MODEL_SIZE_TOOLTIP) + self.assertIn("Standard multilingual", MODEL_SPECIALIZATION_TOOLTIP) + self.assertIn("English-only", LANGUAGE_TOOLTIP) + self.assertIn("only in English", _model_specialization_tooltip("medium.en")) + self.assertIn("lower-memory systems", _model_specialization_tooltip("medium-q5_0")) + self.assertIn("Turbo", _model_specialization_tooltip("large-v3-turbo")) + self.assertIn("legacy large model", _model_specialization_tooltip("large-v2")) + self.assertIn("most users", _model_specialization_tooltip("small")) + + def test_model_picker_rows_have_hover_tooltips(self): + """Test that model picker rows expose the guidance as hover text.""" + import os + + source_path = os.path.join( + os.path.dirname(__file__), + "..", + "src", + "vocalinux", + "ui", + "settings_dialog.py", + ) + with open(source_path, "r") as f: + source_code = f.read() + + self.assertIn("self.model_combo.set_tooltip_text(MODEL_SIZE_TOOLTIP)", source_code) + self.assertIn("self.model_row.set_tooltip_text(MODEL_SIZE_TOOLTIP)", source_code) + self.assertIn( + "self.model_variant_row.set_tooltip_text(specialization_tooltip)", + source_code, + ) + self.assertIn("self.language_row.set_tooltip_text(LANGUAGE_TOOLTIP)", source_code) + + def test_whispercpp_recommendation_uses_language_for_specialization(self): + """Test that English language nudges recommendations to .en variants.""" + from vocalinux.ui.settings_dialog import ( + _default_whispercpp_variant_for_size, + _recommended_whispercpp_variant_for_language, + ) + + self.assertEqual( + _recommended_whispercpp_variant_for_language( + "medium", + "mock hardware reason", + "en-us", + ), + ("medium.en", "mock hardware reason; English language selected"), + ) + self.assertEqual(_default_whispercpp_variant_for_size("medium", "en-us"), "medium.en") + + self.assertEqual( + _recommended_whispercpp_variant_for_language( + "medium", + "mock hardware reason", + "auto", + ), + ("medium", "mock hardware reason"), + ) + self.assertEqual(_default_whispercpp_variant_for_size("medium", "auto"), "medium") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_whispercpp_model_info_ext.py b/tests/test_whispercpp_model_info_ext.py index 307e8e7..fd9406e 100644 --- a/tests/test_whispercpp_model_info_ext.py +++ b/tests/test_whispercpp_model_info_ext.py @@ -74,6 +74,12 @@ class TestModelInfo(unittest.TestCase): self.assertIn("large", WHISPERCPP_MODEL_INFO) + def test_whispercpp_model_info_contains_large_v3_turbo(self): + """Test that WHISPERCPP_MODEL_INFO contains large-v3-turbo model.""" + from vocalinux.utils.whispercpp_model_info import WHISPERCPP_MODEL_INFO + + self.assertIn("large-v3-turbo", WHISPERCPP_MODEL_INFO) + def test_model_info_has_size_mb(self): """Test that each model has size_mb field.""" from vocalinux.utils.whispercpp_model_info import WHISPERCPP_MODEL_INFO @@ -124,6 +130,80 @@ class TestModelInfo(unittest.TestCase): self.assertEqual(set(AVAILABLE_MODELS), set(WHISPERCPP_MODEL_INFO.keys())) + def test_available_models_tracks_upstream_dictation_models(self): + """Test that AVAILABLE_MODELS covers upstream dictation models.""" + from vocalinux.utils.whispercpp_model_info import AVAILABLE_MODELS + + expected_models = [ + "tiny", + "tiny.en", + "tiny-q5_1", + "tiny.en-q5_1", + "tiny-q8_0", + "base", + "base.en", + "base-q5_1", + "base.en-q5_1", + "base-q8_0", + "small", + "small.en", + "small-q5_1", + "small.en-q5_1", + "small-q8_0", + "medium", + "medium.en", + "medium-q5_0", + "medium.en-q5_0", + "medium-q8_0", + "large-v1", + "large-v2", + "large-v2-q5_0", + "large-v2-q8_0", + "large", # Vocalinux alias for upstream large-v3 + "large-v3-q5_0", + "large-v3-turbo", + "large-v3-turbo-q5_0", + "large-v3-turbo-q8_0", + ] + + self.assertEqual(AVAILABLE_MODELS, expected_models) + + def test_model_sizes_group_whispercpp_variants_for_ui(self): + """Test that whisper.cpp models are grouped into size buckets.""" + from vocalinux.utils.whispercpp_model_info import ( + MODEL_SIZES, + MODEL_VARIANTS_BY_SIZE, + WHISPERCPP_MODEL_INFO, + ) + + self.assertEqual(MODEL_SIZES, ["tiny", "base", "small", "medium", "large"]) + self.assertEqual(MODEL_VARIANTS_BY_SIZE["medium"][0], "medium") + self.assertIn("medium.en-q5_0", MODEL_VARIANTS_BY_SIZE["medium"]) + self.assertIn("large-v3-turbo-q8_0", MODEL_VARIANTS_BY_SIZE["large"]) + self.assertNotIn("small.en-tdrz", MODEL_VARIANTS_BY_SIZE["small"]) + + grouped_models = { + model_name for variants in MODEL_VARIANTS_BY_SIZE.values() for model_name in variants + } + self.assertEqual(grouped_models, set(WHISPERCPP_MODEL_INFO.keys())) + self.assertFalse( + any(model_name.endswith("-tdrz") for model_name in grouped_models), + "TinyDiarize needs diarization-specific runtime support, not the base dictation picker", + ) + + def test_model_variant_helpers_identify_size_and_english_only_variants(self): + """Test helpers that drive the split whisper.cpp model selectors.""" + from vocalinux.utils.whispercpp_model_info import ( + get_model_size, + get_model_variants, + is_english_only_model, + ) + + self.assertEqual(get_model_size("medium.en-q5_0"), "medium") + self.assertEqual(get_model_size("large-v3-turbo-q8_0"), "large") + self.assertTrue(is_english_only_model("tiny.en-q5_1")) + self.assertFalse(is_english_only_model("large-v3-turbo")) + class TestDetectVulkanSupport(unittest.TestCase): """Test cases for detect_vulkan_support function.""" @@ -559,6 +639,26 @@ class TestGetModelPath(unittest.TestCase): path = get_model_path("large") self.assertIn("ggml-large-v3.bin", path) + def test_large_model_url_uses_v3(self): + """Test large model metadata points to upstream large-v3.""" + from vocalinux.utils.whispercpp_model_info import WHISPERCPP_MODEL_INFO + + self.assertIn("ggml-large-v3.bin", WHISPERCPP_MODEL_INFO["large"]["url"]) + + def test_get_model_path_large_v3_turbo(self): + """Test get_model_path for large-v3-turbo model.""" + from vocalinux.utils.whispercpp_model_info import get_model_path + + path = get_model_path("large-v3-turbo") + self.assertIn("ggml-large-v3-turbo.bin", path) + + def test_get_model_path_quantized_model(self): + """Test get_model_path for quantized model.""" + from vocalinux.utils.whispercpp_model_info import get_model_path + + path = get_model_path("large-v3-turbo-q5_0") + self.assertIn("ggml-large-v3-turbo-q5_0.bin", path) + class TestIsModelDownloaded(unittest.TestCase): """Test cases for is_model_downloaded function."""