diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 33ab6c75..433a1399 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -208,3 +208,33 @@ vocalinux --debug ``` Check the logs for error messages and possible solutions. +## Custom dictionary + +VocaLinux can bias OpenAI Whisper and whisper.cpp toward terms in a UTF-8 file. +Create `~/.config/vocalinux/dictionary.txt` with one term or phrase per line; blank +lines and lines beginning with `#` are ignored. In **Settings → Dictionary**, enable +the feature and choose a different file if needed. The file is re-read before every +transcription, so edits take effect without restarting the app. + +For a one-session override, start VocaLinux with: + +```bash +vocalinux --dictionary-file /path/to/dictionary.txt +``` + +The CLI override does not change `config.json`. VOSK does not support custom dictionaries +and will warn that the enabled dictionary is ignored. For whisper.cpp, the +Advanced initial prompt remains first and dictionary terms are appended after it. + +To roll back a saved dictionary, turn off **Enable custom dictionary** in +**Settings → Dictionary**. To roll back a `--dictionary-file` override, restart without +that option; Settings controls are disabled while the override is active. A missing, +unreadable, invalid, or unresolvable `~user` path is shown as unavailable and contributes +no prompt terms. Dictation continues normally; correct the path or file permissions and +the next transcription reloads it. + +Human test: add an uncommon proper noun to the file, enable Dictionary, select +whisper.cpp or Whisper, dictate the term, then edit the file and dictate again without +restarting. Try a nonexistent or unreadable file and verify the Settings status reports +it without disrupting dictation. Repeat with VOSK to confirm the warning and no prompt +effect. diff --git a/src/vocalinux/dictionary_manager.py b/src/vocalinux/dictionary_manager.py new file mode 100644 index 00000000..60c24449 --- /dev/null +++ b/src/vocalinux/dictionary_manager.py @@ -0,0 +1,142 @@ +"""Custom dictionary support for Whisper-family recognition engines.""" + +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from .ui.config_manager import ConfigManager + +logger = logging.getLogger(__name__) + +DEFAULT_DICTIONARY_FILE = "~/.config/vocalinux/dictionary.txt" +DEFAULT_MAX_WORDS = 200 + + +class DictionaryManager: + """Read the user dictionary and build a prompt for each transcription. + + The file format is UTF-8, one term per line. Empty lines and lines beginning + with ``#`` are ignored. The file is read for every prompt build so external + edits take effect on the next transcription without restarting Vocalinux. + """ + + def __init__( + self, config_manager: "ConfigManager", transient_path: Optional[str] = None + ) -> None: + self.config = config_manager + self._transient_path = transient_path + + def is_enabled(self) -> bool: + """Return whether custom dictionary support is enabled.""" + return self._transient_path is not None or bool( + self.config.get("dictionary", "enabled", False) + ) + + @property + def is_transient(self) -> bool: + """Return whether this manager was created by the CLI session override.""" + return self._transient_path is not None + + def set_enabled(self, enabled: bool) -> None: + """Persist the enabled state.""" + if self._transient_path is None: + self.config.set("dictionary", "enabled", bool(enabled)) + self.config.save_config() + + def get_path(self) -> Optional[Path]: + """Return a safely expanded dictionary path, or ``None`` when invalid.""" + configured = self._transient_path + if configured is None: + configured = self.config.get("dictionary", "file_path", DEFAULT_DICTIONARY_FILE) + if not isinstance(configured, str) or not configured.strip(): + configured = DEFAULT_DICTIONARY_FILE + try: + return Path(configured).expanduser() + except RuntimeError as error: + logger.warning("Could not expand custom dictionary path %r: %s", configured, error) + return None + + def set_path(self, path: str) -> bool: + """Persist a non-empty dictionary path, returning whether it was accepted.""" + if self._transient_path is not None: + logger.info("Ignoring dictionary path change while a session-only override is active") + return False + if not isinstance(path, str) or not path.strip(): + logger.warning("Ignoring empty custom dictionary path") + return False + try: + candidate = Path(path.strip()).expanduser() + if candidate.exists() and (not candidate.is_file() or not self._is_readable(candidate)): + logger.warning("Ignoring unusable custom dictionary path %s", candidate) + return False + except (OSError, RuntimeError) as error: + logger.warning("Ignoring invalid custom dictionary path %r: %s", path, error) + return False + self.config.set("dictionary", "file_path", path.strip()) + self.config.save_config() + return True + + def get_words(self) -> list[str]: + """Read and return the current terms from the configured dictionary file.""" + path = self.get_path() + if path is None: + return [] + try: + contents = path.read_text(encoding="utf-8-sig") + except FileNotFoundError: + return [] + except OSError as error: + logger.warning("Could not read custom dictionary %s: %s", path, error) + return [] + + words: list[str] = [] + seen: set[str] = set() + for line in contents.splitlines(): + term = line.strip() + if not term or term.startswith("#") or term in seen: + continue + seen.add(term) + words.append(term) + return words + + def get_status(self) -> str: + """Return a safe, user-facing description of the dictionary file state.""" + path = self.get_path() + if path is None: + return "Dictionary path is invalid or cannot be expanded." + try: + if not path.exists(): + return "Dictionary file does not exist yet; it will be used when created." + if not path.is_file() or not self._is_readable(path): + return "Dictionary path is not a readable file." + except OSError: + return "Dictionary path cannot be inspected." + try: + return f"{len(self.get_words())} term(s) available from the live file." + except UnicodeDecodeError: + return "Dictionary file is not valid UTF-8." + + @staticmethod + def _is_readable(path: Path) -> bool: + """Return whether *path* can be opened for reading without leaking errors.""" + try: + with path.open("r", encoding="utf-8"): + return True + except OSError: + return False + + def build_initial_prompt(self) -> Optional[str]: + """Build a live dictionary prompt, or return ``None`` when unavailable.""" + if not self.is_enabled(): + return None + max_words = self.config.get("dictionary", "max_words", DEFAULT_MAX_WORDS) + try: + max_words = max(0, int(max_words)) + except (TypeError, ValueError): + logger.warning( + "Invalid dictionary max_words value %r; using %d", max_words, DEFAULT_MAX_WORDS + ) + max_words = DEFAULT_MAX_WORDS + words = self.get_words()[:max_words] + return " ".join(words) if words else None diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index f842d040..647fe97d 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -91,6 +91,14 @@ def parse_arguments(): action="store_true", help="Start minimized to system tray", ) + parser.add_argument( + "--dictionary-file", + type=str, + help=( + "Use this custom dictionary file for this session (one term per line); " + "enables custom dictionary support without changing config.json" + ), + ) return parser.parse_args() @@ -322,6 +330,7 @@ def main(): # Now it's safe to import GTK-dependent modules from .common_types import RecognitionState + from .dictionary_manager import DictionaryManager from .speech_recognition import recognition_manager from .text_injection import text_injector from .ui import tray_indicator @@ -413,6 +422,16 @@ def main(): advanced_settings = config_manager.get_settings().get("advanced", {}) + dictionary_file = getattr(args, "dictionary_file", None) + transient_dictionary_path = ( + dictionary_file.strip() + if isinstance(dictionary_file, str) and dictionary_file.strip() + else None + ) + if transient_dictionary_path is not None: + logger.info("Using session-only dictionary file override: %s", transient_dictionary_path) + dictionary_manager = DictionaryManager(config_manager, transient_path=transient_dictionary_path) + logger.info(f"Final settings: engine={engine}, language={language}, model={model_size}") if audio_device_index is not None: logger.info( @@ -445,6 +464,7 @@ def main(): whispercpp_no_speech_thold=advanced_settings.get("whispercpp_no_speech_thold", 0.6), whispercpp_n_threads=advanced_settings.get("whispercpp_n_threads", 0), whispercpp_gpu_device=advanced_settings.get("whispercpp_gpu_device", None), + dictionary_manager=dictionary_manager, remote_api_url=saved_settings.get("remote_api_url", ""), remote_api_key=saved_settings.get("remote_api_key", ""), remote_api_endpoint=saved_settings.get("remote_api_endpoint", "/inference"), diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 6950192c..1cea0928 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -17,7 +17,7 @@ import threading import time from pathlib import Path -from typing import Callable, Optional +from typing import TYPE_CHECKING, Callable, Optional from ..common_types import RecognitionState from ..ui.audio_feedback import play_error_sound, play_start_sound, play_stop_sound @@ -40,6 +40,9 @@ from .command_processor import CommandProcessor from .silero_vad import SILERO_CHUNK_SIZE, load_silero_vad +if TYPE_CHECKING: + from ..dictionary_manager import DictionaryManager + def _pywhispercpp_distribution_version() -> Optional[tuple[int, ...]]: """Installed pywhispercpp version, or None if it cannot be read. @@ -984,6 +987,8 @@ def __init__( self.model_size = model_size self.language = language self.stop_sound_guard_ms = kwargs.get("stop_sound_guard_ms", 200) + self.dictionary_manager: Optional["DictionaryManager"] = kwargs.get("dictionary_manager") + self._vosk_dictionary_warned = False self.state = RecognitionState.IDLE self.audio_thread = None self.recognition_thread = None @@ -1097,6 +1102,27 @@ def _resolve_voice_commands_enabled(self) -> bool: return self.engine == "vosk" return bool(self._voice_commands_preference) + def _get_dictionary_prompt(self) -> Optional[str]: + """Return the current dictionary prompt without interrupting dictation on errors.""" + if self.dictionary_manager is None: + return None + try: + return self.dictionary_manager.build_initial_prompt() + except (OSError, TypeError, ValueError) as error: + logger.warning("Could not build custom dictionary prompt: %s", error) + return None + + def _get_whispercpp_prompt(self) -> Optional[str]: + """Compose the explicit advanced prompt before live dictionary terms. + + The Advanced setting remains the user-controlled prefix. Dictionary terms + follow it and are refreshed for every transcription, so neither setting + silently replaces the other. + """ + parts = [self.whispercpp_initial_prompt.strip(), self._get_dictionary_prompt() or ""] + prompt = " ".join(part for part in parts if part) + return prompt or None + def _init_vosk(self): """Initialize the VOSK speech recognition engine.""" # VOSK doesn't support auto-detect, so fall back to en-us for "auto" @@ -1142,6 +1168,16 @@ def _init_vosk(self): self.recognizer = KaldiRecognizer(self.model, 16000) self._model_initialized = True logger.info("VOSK engine initialized successfully.") + if ( + self.dictionary_manager is not None + and self.dictionary_manager.is_enabled() + and not self._vosk_dictionary_warned + ): + self._vosk_dictionary_warned = True + logger.warning( + "Custom dictionary is enabled, but VOSK does not support custom dictionaries; " + "it is ignored." + ) except ImportError: logger.error("Failed to import VOSK. Please install it with 'pip install vosk'") @@ -1271,6 +1307,7 @@ def _transcribe_with_whisper(self, audio_buffer: list[bytes]) -> str: temperature=0.0, # Greedy decoding for consistency no_speech_threshold=0.6, fp16=use_fp16, # Explicitly set to avoid warning on CPU + initial_prompt=self._get_dictionary_prompt(), ) text = result.get("text", "").strip() @@ -1725,7 +1762,14 @@ def _transcribe_with_whispercpp(self, audio_buffer: list[bytes]) -> str: # Transcribe with whisper.cpp # pywhispercpp expects audio as numpy array transcribe_start = time.time() - segments = self.model.transcribe(audio_float, language=lang) + transcribe_kwargs = {"language": lang} + initial_prompt = self._get_whispercpp_prompt() + # pywhispercpp's Model.transcribe() mutates its reusable native + # parameter object (the pinned 1.5.0 API delegates kwargs to + # _set_params). Always send an explicit empty value to clear a + # dictionary prompt from a previous transcription. + transcribe_kwargs["initial_prompt"] = initial_prompt or "" + segments = self.model.transcribe(audio_float, **transcribe_kwargs) transcribe_duration = time.time() - transcribe_start # Extract text from segments, filtering non-speech tokens diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index a29b368d..d3470691 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -127,6 +127,11 @@ def normalize_sound_effect_tone(tone: Any) -> str: # Ctrl+Shift+V when a nested terminal panel is not detected. "paste_shortcut": "auto", }, + "dictionary": { + "enabled": False, + "file_path": "~/.config/vocalinux/dictionary.txt", + "max_words": 200, + }, "advanced": { "power_user_mode": False, "debug_logging": False, diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index f8986880..3427b3f0 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -1662,6 +1662,7 @@ def __init__( self.config_manager = config_manager self.speech_engine = speech_engine + self.dictionary_manager = getattr(speech_engine, "dictionary_manager", None) self.shortcut_update_callback = shortcut_update_callback self.update_status_callback = update_status_callback self._test_active = False @@ -1715,6 +1716,7 @@ def __init__( SettingsPage("performance", "Performance", "power-profile-performance-symbolic"), SettingsPage("application", "Application", "preferences-system-symbolic"), SettingsPage("advanced", "Advanced", "applications-engineering-symbolic"), + SettingsPage("dictionary", "Dictionary", "accessories-dictionary-symbolic"), SettingsPage("about", "About", "help-about-symbolic"), ] pages_by_name = {page.name: page for page in self._pages} @@ -1728,6 +1730,7 @@ def __init__( self.power_tab = pages_by_name["performance"].box self.general_tab = pages_by_name["application"].box self.advanced_tab = pages_by_name["advanced"].box + self.dictionary_tab = pages_by_name["dictionary"].box self.about_tab = pages_by_name["about"].box # Each page is wrapped in a vertical ScrolledWindow: without one, the @@ -1790,6 +1793,7 @@ def _scrollable(tab): self._build_gpu_section() self._build_general_section() self._build_advanced_section() + self._build_dictionary_section() self._build_about_section() self._build_sidebar_footer(sidebar_box) @@ -3568,6 +3572,98 @@ def _on_close_clicked(self, button): """Close the dialog through the normal response path (same as title-bar X).""" self.response(Gtk.ResponseType.CLOSE) + def _build_dictionary_section(self) -> None: + """Build controls for the live custom dictionary file.""" + group = PreferencesGroup(title="Custom Dictionary") + self.dictionary_enabled_switch = Gtk.Switch() + group.add_row( + PreferenceRow( + title="Enable custom dictionary", + subtitle="Bias Whisper and whisper.cpp toward terms in a text file", + widget=self.dictionary_enabled_switch, + ) + ) + + path_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self.dictionary_path_entry = Gtk.Entry() + self.dictionary_path_entry.set_hexpand(True) + self.dictionary_path_entry.set_placeholder_text("~/.config/vocalinux/dictionary.txt") + path_box.pack_start(self.dictionary_path_entry, True, True, 0) + self.dictionary_file_button = Gtk.FileChooserButton(title="Choose Dictionary File") + path_box.pack_start(self.dictionary_file_button, False, False, 0) + group.add_row( + PreferenceRow( + title="Dictionary file", + subtitle="UTF-8 text, one term per line; # starts a comment", + widget=path_box, + ) + ) + + self.dictionary_status_label = Gtk.Label(xalign=0) + self.dictionary_status_label.set_line_wrap(True) + self.dictionary_status_label.get_style_context().add_class("tip-label") + group.add_row( + PreferenceRow( + title="Status", + subtitle="The file is re-read before every transcription.", + widget=self.dictionary_status_label, + ) + ) + self.dictionary_tab.pack_start(group, False, False, 0) + + self.dictionary_enabled_switch.connect("state-set", self._on_dictionary_enabled) + self.dictionary_path_entry.connect("activate", self._on_dictionary_path_changed) + self.dictionary_path_entry.connect("focus-out-event", self._on_dictionary_path_changed) + self.dictionary_file_button.connect("file-set", self._on_dictionary_file_chosen) + + def _on_dictionary_enabled(self, widget: Any, state: bool) -> bool: + """Persist dictionary enablement immediately.""" + if not self._initializing and not self._applying_settings and self.dictionary_manager: + self.dictionary_manager.set_enabled(bool(state)) + self._refresh_dictionary_ui() + return False + + def _on_dictionary_path_changed(self, widget: Any, *args: Any) -> bool: + """Persist a manually entered dictionary path.""" + if not self._initializing and not self._applying_settings and self.dictionary_manager: + self.dictionary_manager.set_path(self.dictionary_path_entry.get_text()) + self._refresh_dictionary_ui() + return False + + def _on_dictionary_file_chosen(self, widget: Any) -> None: + """Persist a path selected through the GTK file chooser.""" + path = widget.get_filename() + if path: + self.dictionary_path_entry.set_text(path) + self._on_dictionary_path_changed(self.dictionary_path_entry) + + def _refresh_dictionary_ui(self) -> None: + """Refresh dictionary controls and engine-specific status text.""" + if self.dictionary_manager is None: + self.dictionary_enabled_switch.set_sensitive(False) + self.dictionary_path_entry.set_sensitive(False) + self.dictionary_file_button.set_sensitive(False) + self.dictionary_status_label.set_text("Dictionary support is unavailable.") + return + enabled = self.dictionary_manager.is_enabled() + self.dictionary_enabled_switch.set_active(enabled) + transient = self.dictionary_manager.is_transient + self.dictionary_enabled_switch.set_sensitive(not transient) + path = self.dictionary_manager.get_path() + self.dictionary_path_entry.set_text(str(path) if path is not None else "") + self.dictionary_path_entry.set_sensitive(enabled and not transient) + self.dictionary_file_button.set_sensitive(enabled and not transient) + if self._get_selected_engine() == "vosk": + self.dictionary_status_label.set_text( + "VOSK does not support custom dictionaries; the file is ignored." + ) + elif transient: + self.dictionary_status_label.set_text( + "Session-only --dictionary-file override is active; Settings changes are disabled." + ) + else: + self.dictionary_status_label.set_text(self.dictionary_manager.get_status()) + def _build_advanced_section(self): """Build the Advanced section with whisper.cpp parameters.""" @@ -4607,6 +4703,7 @@ def _load_and_apply_settings(self): self.advanced_no_speech_thold_spin.set_value( advanced_settings.get("whispercpp_no_speech_thold", 0.6) ) + self._refresh_dictionary_ui() def _get_current_settings(self): """Get current settings from config manager.""" @@ -5299,6 +5396,7 @@ def _update_engine_specific_ui(self): self._update_language_warning() self._update_model_picker_tooltips() self._update_advanced_tab_sensitivity() + self._refresh_dictionary_ui() def _update_advanced_tab_sensitivity(self): """Enable or disable advanced settings based on selected engine.""" diff --git a/tests/test_dictionary_manager.py b/tests/test_dictionary_manager.py new file mode 100644 index 00000000..f1c789a0 --- /dev/null +++ b/tests/test_dictionary_manager.py @@ -0,0 +1,221 @@ +"""Tests for live custom dictionary support.""" + +import threading +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +from vocalinux.dictionary_manager import DEFAULT_DICTIONARY_FILE, DictionaryManager +from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager + + +class MemoryConfig: + """Small ConfigManager stand-in for dictionary tests.""" + + def __init__(self, path: str, enabled: bool = True) -> None: + self.values = {"dictionary": {"enabled": enabled, "file_path": path, "max_words": 200}} + self.saved = False + + def get(self, section: str, key: str, default: Any = None) -> Any: + return self.values.get(section, {}).get(key, default) + + def set(self, section: str, key: str, value: Any) -> bool: + self.values.setdefault(section, {})[key] = value + return True + + def save_config(self) -> None: + self.saved = True + + +def _manager( + engine: str, dictionary: DictionaryManager, advanced_prompt: str = "" +) -> SpeechRecognitionManager: + """Create a recognition manager without loading a speech model.""" + with patch.object(SpeechRecognitionManager, "_init_vosk"): + with patch.object(SpeechRecognitionManager, "_init_whisper"): + with patch.object(SpeechRecognitionManager, "_init_whispercpp"): + return SpeechRecognitionManager( + engine=engine, + dictionary_manager=dictionary, + whispercpp_initial_prompt=advanced_prompt, + ) + + +def _mock_numpy() -> MagicMock: + numpy = MagicMock() + numpy.frombuffer.return_value = MagicMock(__len__=lambda _: 16000) + numpy.frombuffer.return_value.astype.return_value = numpy.frombuffer.return_value + numpy.int16 = "int16" + numpy.float32 = "float32" + return numpy + + +def test_dictionary_uses_default_contract_for_empty_path() -> None: + dictionary = DictionaryManager(MemoryConfig("")) + assert str(dictionary.get_path()).endswith(DEFAULT_DICTIONARY_FILE.removeprefix("~/")) + assert not dictionary.set_path(" ") + + +def test_transient_dictionary_never_changes_saved_settings(tmp_path: Path) -> None: + """A CLI manager must not persist state even if the Settings page calls it.""" + config = MemoryConfig("/saved/dictionary.txt", enabled=False) + dictionary = DictionaryManager(config, transient_path=str(tmp_path / "session.txt")) + + dictionary.set_enabled(False) + assert not dictionary.set_path("/another/path.txt") + config.save_config() # Simulate a later Settings save in the same process. + + assert config.values["dictionary"] == { + "enabled": False, + "file_path": "/saved/dictionary.txt", + "max_words": 200, + } + assert config.saved + + +def test_unresolvable_or_unreadable_paths_are_safe_and_not_persisted( + tmp_path: Path, +) -> None: + unreadable = tmp_path / "unreadable.txt" + unreadable.write_text("term\n", encoding="utf-8") + config = MemoryConfig(str(unreadable)) + dictionary = DictionaryManager(config) + + with patch("vocalinux.dictionary_manager.Path.expanduser", side_effect=RuntimeError("no user")): + assert dictionary.get_path() is None + assert dictionary.build_initial_prompt() is None + assert "invalid" in dictionary.get_status().lower() + assert not dictionary.set_path("~missing-user/dictionary.txt") + + with patch.object(DictionaryManager, "_is_readable", return_value=False): + assert not dictionary.set_path(str(unreadable)) + assert "readable" in dictionary.get_status().lower() + + assert config.values["dictionary"]["file_path"] == str(unreadable) + assert not config.saved + + +def test_dictionary_ignores_missing_file_and_reloads_live_file(tmp_path: Path) -> None: + path = tmp_path / "dictionary.txt" + dictionary = DictionaryManager(MemoryConfig(str(path))) + assert dictionary.build_initial_prompt() is None + + path.write_text("# comment\nVocaLinux\nVocaLinux\npywhispercpp\n", encoding="utf-8") + assert dictionary.build_initial_prompt() == "VocaLinux pywhispercpp" + path.write_text("VocaHQ\n", encoding="utf-8") + assert dictionary.build_initial_prompt() == "VocaHQ" + + +def test_invalid_utf8_has_safe_status_and_is_ignored(tmp_path: Path) -> None: + path = tmp_path / "dictionary.txt" + path.write_bytes(b"\xff\xfe\xfa") + dictionary = DictionaryManager(MemoryConfig(str(path))) + manager = _manager("whisper", dictionary) + + assert dictionary.get_status() == "Dictionary file is not valid UTF-8." + assert manager._get_dictionary_prompt() is None + + +def test_dictionary_disabled_returns_no_prompt(tmp_path: Path) -> None: + path = tmp_path / "dictionary.txt" + path.write_text("VocaLinux\n", encoding="utf-8") + assert DictionaryManager(MemoryConfig(str(path), enabled=False)).build_initial_prompt() is None + + +def test_dictionary_max_words_parses_and_caps_terms(tmp_path: Path) -> None: + path = tmp_path / "dictionary.txt" + path.write_text("one\ntwo\nthree\n", encoding="utf-8") + config = MemoryConfig(str(path)) + dictionary = DictionaryManager(config) + + config.values["dictionary"]["max_words"] = "2" + assert dictionary.build_initial_prompt() == "one two" + config.values["dictionary"]["max_words"] = "invalid" + assert dictionary.build_initial_prompt() == "one two three" + config.values["dictionary"]["max_words"] = -1 + assert dictionary.build_initial_prompt() is None + + +def test_whisper_receives_live_dictionary_prompt(tmp_path: Path) -> None: + path = tmp_path / "dictionary.txt" + path.write_text("VocaLinux\n", encoding="utf-8") + manager = _manager("whisper", DictionaryManager(MemoryConfig(str(path)))) + manager.model = MagicMock() + manager.model.transcribe.return_value = {"text": "ok"} + manager.model.device = MagicMock() + + with patch.dict("sys.modules", {"numpy": _mock_numpy(), "torch": MagicMock()}): + assert manager._transcribe_with_whisper([b"\x00\x00"]) == "ok" + assert manager.model.transcribe.call_args.kwargs["initial_prompt"] == "VocaLinux" + + +def test_whispercpp_composes_advanced_prompt_and_live_dictionary(tmp_path: Path) -> None: + path = tmp_path / "dictionary.txt" + path.write_text("pywhispercpp\n", encoding="utf-8") + manager = _manager( + "whisper_cpp", + DictionaryManager(MemoryConfig(str(path))), + advanced_prompt="Explicit context", + ) + manager._model_lock = threading.Lock() + segment = MagicMock(text="ok") + manager.model = MagicMock() + manager.model.transcribe.return_value = [segment] + + with patch.dict("sys.modules", {"numpy": _mock_numpy()}): + assert manager._transcribe_with_whispercpp([b"\x00\x00"]) == "ok" + assert ( + manager.model.transcribe.call_args.kwargs["initial_prompt"] + == "Explicit context pywhispercpp" + ) + + +def test_whispercpp_clears_reused_prompt_after_dictionary_changes(tmp_path: Path) -> None: + path = tmp_path / "dictionary.txt" + path.write_text("VocaLinux\n", encoding="utf-8") + config = MemoryConfig(str(path)) + manager = _manager("whisper_cpp", DictionaryManager(config)) + manager._model_lock = threading.Lock() + manager.model = MagicMock() + manager.model.transcribe.return_value = [MagicMock(text="ok")] + + with patch.dict("sys.modules", {"numpy": _mock_numpy()}): + manager._transcribe_with_whispercpp([b"\x00\x00"]) + config.values["dictionary"]["enabled"] = False + manager._transcribe_with_whispercpp([b"\x00\x00"]) + config.values["dictionary"]["enabled"] = True + path.write_text("", encoding="utf-8") + manager._transcribe_with_whispercpp([b"\x00\x00"]) + + prompts = [call.kwargs["initial_prompt"] for call in manager.model.transcribe.call_args_list] + assert prompts == ["VocaLinux", "", ""] + + +def test_vosk_dictionary_prompt_is_a_noop(tmp_path: Path) -> None: + path = tmp_path / "dictionary.txt" + path.write_text("VocaLinux\n", encoding="utf-8") + manager = _manager("vosk", DictionaryManager(MemoryConfig(str(path)))) + assert manager._get_dictionary_prompt() == "VocaLinux" + + +def test_vosk_logs_dictionary_noop_warning_once(tmp_path: Path) -> None: + path = tmp_path / "dictionary.txt" + path.write_text("VocaLinux\n", encoding="utf-8") + manager = _manager("vosk", DictionaryManager(MemoryConfig(str(path)))) + manager._get_vosk_model_path = MagicMock(return_value="/models/vosk") + vosk = MagicMock() + + with patch.dict("sys.modules", {"vosk": vosk}): + with patch( + "vocalinux.speech_recognition.recognition_manager.os.path.exists", return_value=True + ): + with patch( + "vocalinux.speech_recognition.recognition_manager.logger.warning" + ) as warning: + manager._init_vosk() + manager._init_vosk() + + warning.assert_called_once_with( + "Custom dictionary is enabled, but VOSK does not support custom dictionaries; " + "it is ignored." + ) diff --git a/tests/test_main.py b/tests/test_main.py index cb2e2116..17b9d39e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -5,7 +5,7 @@ import argparse import sys import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch # Mock GTK modules before importing vocalinux.main sys.modules["gi"] = MagicMock() @@ -30,6 +30,7 @@ def test_parse_arguments_defaults(self): self.assertIsNone(args.language) self.assertFalse(args.wayland) self.assertFalse(args.start_minimized) + self.assertIsNone(args.dictionary_file) def test_parse_arguments_custom(self): """Test argument parsing with custom values.""" @@ -57,6 +58,11 @@ def test_parse_arguments_custom(self): self.assertTrue(args.wayland) self.assertTrue(args.start_minimized) + def test_parse_arguments_dictionary_file(self): + """The dictionary file CLI override is accepted without persisting config.""" + with patch("sys.argv", ["vocalinux", "--dictionary-file", "/tmp/terms.txt"]): + assert parse_arguments().dictionary_file == "/tmp/terms.txt" + 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"]): @@ -234,6 +240,7 @@ def test_main_initializes_components( whispercpp_no_speech_thold=0.6, whispercpp_n_threads=0, whispercpp_gpu_device=None, + dictionary_manager=ANY, remote_api_url="", remote_api_key="", remote_api_endpoint="/inference", diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index f385d2ad..84a272a7 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -955,9 +955,18 @@ def test_topic_pages_exist(self): ("performance", "Performance"), ("application", "Application"), ("advanced", "Advanced"), + ("dictionary", "Dictionary"), ]: self.assertIn(f'SettingsPage("{name}", "{title}"', self.source_code) + def test_dictionary_page_wires_live_and_session_only_state(self): + """Dictionary controls must reflect the live manager and CLI override state.""" + self.assertIn("self._build_dictionary_section()", self.source_code) + self.assertIn("self.dictionary_manager = getattr(speech_engine", self.source_code) + self.assertIn("self.dictionary_enabled_switch = Gtk.Switch()", self.source_code) + self.assertIn("transient = self.dictionary_manager.is_transient", self.source_code) + self.assertIn("self.dictionary_manager.get_status()", self.source_code) + def test_application_page_has_tray_warning_toggle(self): self.assertIn('PreferencesGroup(title="General")', self.source_code) self.assertIn("self.missing_tray_warning_switch = Gtk.Switch()", self.source_code)