Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
139 changes: 139 additions & 0 deletions src/vocalinux/dictionary_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""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 dictionary prompting 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."
return f"{len(self.get_words())} term(s) available from the live file."
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

@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
20 changes: 20 additions & 0 deletions src/vocalinux/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"),
Expand Down
48 changes: 46 additions & 2 deletions src/vocalinux/speech_recognition/recognition_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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'")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/vocalinux/ui/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading