diff --git a/README.md b/README.md index 2a02893..f98f728 100644 --- a/README.md +++ b/README.md @@ -303,10 +303,14 @@ Or launch it from your application menu! ### Voice Commands +English phrases always work. With a non-English recognition language, matching +punctuation / line-break phrases in that language are also recognized +(Italian *virgola* / *punto*, French *virgule* / *point*, etc.). + | Command | Action | |---------|--------| | "new line" | Inserts a line break | -| "period" / "full stop" | Types a period (.) | +| "period" / "full stop" / "dot" | Types a period (.) | | "comma" | Types a comma (,) | | "question mark" | Types a question mark (?) | | "exclamation mark" | Types an exclamation mark (!) | diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 6183855..b6c2fc7 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -56,12 +56,16 @@ Nested terminal panels inside an IDE are often invisible to window-class detecti ## Voice Commands -Vocalinux supports several commands that you can speak to control formatting: +Vocalinux supports several commands that you can speak to control formatting. +English phrases always work. When recognition language is set (e.g. Italian, +French, German, Spanish), matching punctuation and line-break phrases in that +language are also recognized (for example Italian *virgola* / *punto*, French +*virgule* / *point*). | Command | Action | |---------|--------| | "new line" or "new paragraph" | Inserts a line break | -| "period" or "full stop" | Types a period (.) | +| "period", "full stop", or "dot" | Types a period (.) | | "comma" | Types a comma (,) | | "question mark" | Types a question mark (?) | | "exclamation point" or "exclamation mark" | Types an exclamation point (!) | @@ -71,6 +75,9 @@ Vocalinux supports several commands that you can speak to control formatting: | "capitalize" or "uppercase" | Capitalizes the next word | | "all caps" | Makes the next word ALL CAPS | +Editing and formatting action phrases (`delete that`, `undo`, `capitalize`, …) +are currently English-only. + ## Tips for Better Recognition 1. **Use a good microphone**: A quality microphone significantly improves recognition accuracy diff --git a/src/vocalinux/speech_recognition/command_phrases.py b/src/vocalinux/speech_recognition/command_phrases.py new file mode 100644 index 0000000..458b84e --- /dev/null +++ b/src/vocalinux/speech_recognition/command_phrases.py @@ -0,0 +1,212 @@ +"""Localized spoken phrase aliases for voice commands. + +English phrases live in ``CommandProcessor``; this module supplies extra +punctuation / line-break aliases keyed by Whisper ISO language codes +(``it``, ``fr``, ``de``, …). English aliases are always merged on top. +""" + +from __future__ import annotations + +from typing import Optional + +from vocalinux.utils.vosk_model_info import SUPPORTED_LANGUAGES + +# ASR may emit ASCII, typographic (U+2019), or backtick apostrophes in elisions. +_APOSTROPHE_CHARS = ("'", "\u2019", "`") + +# Phrase → replacement. Longer phrases must be listed before shorter ones that +# share a prefix (e.g. "punto interrogativo" before "punto") so callers can +# rely on insertion order when sorting is skipped. +_TEXT_COMMAND_ALIASES: dict[str, dict[str, str]] = { + "it": { + "punto interrogativo": "?", + "punto esclamativo": "!", + "punto e virgola": ";", + "nuovo paragrafo": "\n\n", + "nuova riga": "\n", + "a capo": "\n", + "due punti": ":", + "trattino basso": "_", + "apri parentesi": "(", + "chiudi parentesi": ")", + "virgola": ",", + "punto": ".", + "trattino": "-", + }, + "fr": { + "point d'interrogation": "?", + "point d\u2019interrogation": "?", + "point d interrogation": "?", + "point d'exclamation": "!", + "point d\u2019exclamation": "!", + "point d exclamation": "!", + "nouveau paragraphe": "\n\n", + "nouvelle ligne": "\n", + "a la ligne": "\n", + "à la ligne": "\n", + "point virgule": ";", + "point-virgule": ";", + "deux points": ":", + "parenthese ouvrante": "(", + "parenthèse ouvrante": "(", + "parenthese fermante": ")", + "parenthèse fermante": ")", + "virgule": ",", + "point": ".", + "tiret": "-", + "underscore": "_", + }, + "de": { + "neuer absatz": "\n\n", + "neue zeile": "\n", + "fragezeichen": "?", + "ausrufezeichen": "!", + "strichpunkt": ";", + "doppelpunkt": ":", + "klammer auf": "(", + "klammer zu": ")", + "bindestrich": "-", + "unterstrich": "_", + "komma": ",", + "punkt": ".", + "absatz": "\n\n", + }, + "es": { + "signo de interrogacion": "?", + "signo de interrogación": "?", + "signo de exclamacion": "!", + "signo de exclamación": "!", + "punto y coma": ";", + "nuevo parrafo": "\n\n", + "nuevo párrafo": "\n\n", + "nueva linea": "\n", + "nueva línea": "\n", + "dos puntos": ":", + "abrir parentesis": "(", + "abrir paréntesis": "(", + "cerrar parentesis": ")", + "cerrar paréntesis": ")", + "guion bajo": "_", + "guión bajo": "_", + "coma": ",", + "punto": ".", + "guion": "-", + "guión": "-", + }, + "pt": { + "ponto de interrogacao": "?", + "ponto de interrogação": "?", + "ponto de exclamacao": "!", + "ponto de exclamação": "!", + "ponto e virgula": ";", + "ponto e vírgula": ";", + "novo paragrafo": "\n\n", + "nova linha": "\n", + "dois pontos": ":", + "abrir parenteses": "(", + "abrir parênteses": "(", + "fechar parenteses": ")", + "fechar parênteses": ")", + "traco baixo": "_", + "traço baixo": "_", + "virgula": ",", + "vírgula": ",", + "ponto": ".", + "traco": "-", + "traço": "-", + }, + "nl": { + "vraagteken": "?", + "uitroepteken": "!", + "puntkomma": ";", + "dubbele punt": ":", + "nieuwe regel": "\n", + "nieuwe alinea": "\n\n", + "komma": ",", + "punt": ".", + "streepje": "-", + "underscore": "_", + }, + "pl": { + "znak zapytania": "?", + "wykrzyknik": "!", + "srednik": ";", + "średnik": ";", + "dwukropek": ":", + "nowa linia": "\n", + "nowy akapit": "\n\n", + "przecinek": ",", + "kropka": ".", + "myslnik": "-", + "myślnik": "-", + "podkreslnik": "_", + "podkreślnik": "_", + }, + "ru": { + "вопросительный знак": "?", + "восклицательный знак": "!", + "новая строка": "\n", + "новый абзац": "\n\n", + "точка с запятой": ";", + "двоеточие": ":", + "открыть скобку": "(", + "закрыть скобку": ")", + "запятая": ",", + "точка": ".", + "тире": "-", + "подчёркивание": "_", + "подчеркивание": "_", + }, +} + +# Extra English aliases always available (beyond the base CommandProcessor map). +_ENGLISH_EXTRA: dict[str, str] = { + "dot": ".", +} + + +def _swap_apostrophes(phrase: str, apostrophe: str) -> str: + """Replace every apostrophe-like character in ``phrase`` with ``apostrophe``.""" + for src in _APOSTROPHE_CHARS: + phrase = phrase.replace(src, apostrophe) + return phrase + + +def _expand_apostrophe_aliases(aliases: dict[str, str]) -> dict[str, str]: + """Duplicate apostrophe-containing phrases with ASCII, U+2019, and backtick.""" + expanded = dict(aliases) + for phrase, replacement in aliases.items(): + if not any(ch in phrase for ch in _APOSTROPHE_CHARS): + continue + for apostrophe in _APOSTROPHE_CHARS: + expanded.setdefault(_swap_apostrophes(phrase, apostrophe), replacement) + return expanded + + +def normalize_command_language(language: Optional[str]) -> str: + """Map a catalog / Whisper language id to a phrase-alias key. + + ``auto`` and unknown values fall back to English (``en``). Catalog keys + such as ``en-us`` resolve via ``SUPPORTED_LANGUAGES`` whisper codes. + """ + if not language or language == "auto": + return "en" + info = SUPPORTED_LANGUAGES.get(language) + if info is not None and info.get("whisper"): + return info["whisper"] + if language.startswith("en"): + return "en" + return language + + +def text_command_aliases_for(language: Optional[str]) -> dict[str, str]: + """Return localized text-command aliases for ``language``. + + English extras are always included. Localized aliases are added when the + recognition language is not English. + """ + aliases = dict(_ENGLISH_EXTRA) + code = normalize_command_language(language) + if code != "en": + aliases.update(_TEXT_COMMAND_ALIASES.get(code, {})) + return _expand_apostrophe_aliases(aliases) diff --git a/src/vocalinux/speech_recognition/command_processor.py b/src/vocalinux/speech_recognition/command_processor.py index dc7597b..5cbe4e4 100644 --- a/src/vocalinux/speech_recognition/command_processor.py +++ b/src/vocalinux/speech_recognition/command_processor.py @@ -5,48 +5,73 @@ This module processes text commands from speech recognition, such as "new line", "period", etc. """ +from __future__ import annotations + import logging import re +from typing import Optional + +from .command_phrases import text_command_aliases_for logger = logging.getLogger(__name__) +# Replacements that should attach to the preceding word (no leading space). +_ATTACH_LEFT_REPLACEMENTS = {".", ",", "?", "!", ";", ":"} + +# Eat spaces/tabs around attach-left punctuation, but never a newline that a +# line-break command just inserted (``\s*`` would collapse "new line" + "period"). +_ATTACH_LEFT_WS = r"[^\S\n]*" + +# Base English text commands (always present). +_BASE_TEXT_COMMANDS = { + # Line commands + "new line": "\n", + "new paragraph": "\n\n", + # Punctuation + "period": ".", + "full stop": ".", + "comma": ",", + "question mark": "?", + "exclamation mark": "!", + "exclamation point": "!", + "semicolon": ";", + "colon": ":", + "dash": "-", + "hyphen": "-", + "underscore": "_", + "quote": '"', + "single quote": "'", + "open parenthesis": "(", + "close parenthesis": ")", + "open bracket": "[", + "close bracket": "]", + "open brace": "{", + "close brace": "}", +} + class CommandProcessor: """ Processes text commands in speech recognition results. This class handles special commands like "new line", "period", - "delete that", etc. + "delete that", etc. Localized punctuation aliases follow the + recognition language (see ``command_phrases``). """ - def __init__(self): - """Initialize the command processor.""" - # Map of command phrases to their actions - self.text_commands = { - # Line commands - "new line": "\n", - "new paragraph": "\n\n", - # Punctuation - "period": ".", - "full stop": ".", - "comma": ",", - "question mark": "?", - "exclamation mark": "!", - "exclamation point": "!", - "semicolon": ";", - "colon": ":", - "dash": "-", - "hyphen": "-", - "underscore": "_", - "quote": '"', - "single quote": "'", - "open parenthesis": "(", - "close parenthesis": ")", - "open bracket": "[", - "close bracket": "]", - "open brace": "{", - "close brace": "}", - } + def __init__(self, language: Optional[str] = "en-us"): + """Initialize the command processor. + + Args: + language: Recognition language catalog id (e.g. ``it``, ``en-us``, + ``auto``). Localized punctuation phrases are merged for + non-English languages; English phrases are always available. + """ + self.language = language or "en-us" + + # Map of command phrases to their actions (English base + locale aliases) + self.text_commands = dict(_BASE_TEXT_COMMANDS) + self.text_commands.update(text_command_aliases_for(self.language)) # Special action commands that don't directly map to text self.action_commands = { @@ -78,12 +103,18 @@ class CommandProcessor: # Compile regex patterns for faster matching self._compile_patterns() - def _compile_patterns(self): + def set_language(self, language: Optional[str]) -> None: + """Update recognition language and rebuild text-command aliases.""" + self.language = language or "en-us" + self.text_commands = dict(_BASE_TEXT_COMMANDS) + self.text_commands.update(text_command_aliases_for(self.language)) + self._compile_patterns() + + def _compile_patterns(self) -> None: """Compile regex patterns for command matching.""" - # Create regex pattern for text commands - text_cmd_pattern = ( - r"\b(" + "|".join(re.escape(cmd) for cmd in self.text_commands.keys()) + r")\b" - ) + # Longer phrases first so alternation prefers "punto interrogativo" over "punto" + text_keys = sorted(self.text_commands.keys(), key=len, reverse=True) + text_cmd_pattern = r"\b(" + "|".join(re.escape(cmd) for cmd in text_keys) + r")\b" self.text_cmd_regex = re.compile(text_cmd_pattern, re.IGNORECASE) # Create regex pattern for action commands @@ -245,23 +276,16 @@ class CommandProcessor: else: processed_text = "" - # Handle text commands - for cmd, replacement in self.text_commands.items(): + # Handle text commands (longest phrase first to avoid partial matches) + for cmd, replacement in sorted( + self.text_commands.items(), key=lambda item: len(item[0]), reverse=True + ): cmd_pattern = r"\b" + re.escape(cmd) + r"\b" if re.search(cmd_pattern, processed_text, re.IGNORECASE): - if cmd in [ - "period", - "full stop", - "comma", - "question mark", - "exclamation mark", - "exclamation point", - "semicolon", - "colon", - ]: - # For punctuation, replace the command and remove the space before it + if replacement in _ATTACH_LEFT_REPLACEMENTS: + # Punctuation: drop horizontal space before the spoken phrase processed_text = re.sub( - r"\s*" + cmd_pattern + r"\s*", + _ATTACH_LEFT_WS + cmd_pattern + _ATTACH_LEFT_WS, replacement, processed_text, flags=re.IGNORECASE, diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 0c48cc1..946f8d2 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -1002,7 +1002,7 @@ class SpeechRecognitionManager: self.recognition_thread = None self.model = None self.recognizer = None # Added for VOSK - self.command_processor = CommandProcessor() + self.command_processor = CommandProcessor(language=self.language) # Voice commands: None=auto (VOSK=yes, Whisper=no), True=always on, False=always off self._voice_commands_preference = kwargs.get("voice_commands_enabled") @@ -3437,8 +3437,10 @@ class SpeechRecognitionManager: # Language change requires restart for both engines # Whisper needs to know the language for transcription # VOSK needs to load a different model for the new language + language_changed = False if language is not None and language != self.language: self.language = language + language_changed = True restart_needed = True # Parakeet never consumes catalog language. Apply after engine/language @@ -3446,8 +3448,13 @@ class SpeechRecognitionManager: normalized_language = normalize_language_for_engine(self.engine, self.language) if normalized_language != self.language: self.language = normalized_language + language_changed = True restart_needed = True + # Command aliases follow the stored language (auto after Parakeet). + if language_changed: + self.command_processor.set_language(self.language) + # Update VOSK specific params if provided if vad_sensitivity is not None: self.vad_sensitivity = max(1, min(5, int(vad_sensitivity))) diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 6bf7a7a..74bb737 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -3392,11 +3392,13 @@ class SettingsDialog(Gtk.Dialog): self.voice_commands_switch = Gtk.Switch() self.voice_commands_switch.set_tooltip_text( "Enable voice commands like 'new line', 'period', 'undo', etc.\n" + "Punctuation phrases also match the recognition language " + "(e.g. Italian 'virgola', French 'virgule').\n" "Useful for VOSK engine. Whisper engines handle punctuation automatically." ) voice_commands_row = PreferenceRow( title="Voice Commands", - subtitle="Say 'new line', 'period', 'undo' while dictating", + subtitle="Say 'new line', 'period', 'undo' (localized punctuation too)", widget=self.voice_commands_switch, keywords=("punctuation", "editing"), ) diff --git a/tests/test_command_processor.py b/tests/test_command_processor.py index 234c1ef..8d01e8d 100644 --- a/tests/test_command_processor.py +++ b/tests/test_command_processor.py @@ -343,3 +343,172 @@ class TestCommandProcessorFallback(unittest.TestCase): """Test paste action through generic path.""" result, actions = self.processor.process_text("paste content") self.assertIn("paste", actions) + + +class TestLocalizedTextCommands(unittest.TestCase): + """Localized punctuation / line-break voice commands (issue #640).""" + + def test_english_dot_alias(self): + """English 'dot' maps to a period.""" + processor = CommandProcessor(language="en-us") + result, actions = processor.process_text("end of sentence dot") + self.assertEqual(result, "end of sentence.") + self.assertEqual(actions, []) + + def test_italian_punctuation(self): + """Italian spoken punctuation replaces with symbols.""" + processor = CommandProcessor(language="it") + cases = [ + ("ciao virgola mondo", "ciao,mondo"), + ("fine punto", "fine."), + ("davvero punto interrogativo", "davvero?"), + ("bravo punto esclamativo", "bravo!"), + ("lista punto e virgola", "lista;"), + ("nota due punti", "nota:"), + ("prima nuova riga dopo", "prima \n dopo"), + ] + for input_text, expected in cases: + result, actions = processor.process_text(input_text) + self.assertEqual(result, expected, msg=input_text) + self.assertEqual(actions, []) + + def test_italian_long_phrase_before_punto(self): + """'punto interrogativo' must not collapse to '.' + leftover words.""" + processor = CommandProcessor(language="it") + result, _ = processor.process_text("sei sicuro punto interrogativo") + self.assertEqual(result, "sei sicuro?") + self.assertNotIn("interrogativo", result) + + def test_french_and_german(self): + """French and German punctuation aliases work.""" + fr = CommandProcessor(language="fr") + result, _ = fr.process_text("bonjour virgule monde point") + self.assertEqual(result, "bonjour,monde.") + + de = CommandProcessor(language="de") + result, _ = de.process_text("hallo komma welt punkt") + self.assertEqual(result, "hallo,welt.") + + def test_spanish_portuguese(self): + """Spanish and Portuguese punctuation aliases work.""" + es = CommandProcessor(language="es") + result, _ = es.process_text("hola coma mundo punto") + self.assertEqual(result, "hola,mundo.") + + pt = CommandProcessor(language="pt") + result, _ = pt.process_text("ola virgula mundo ponto") + self.assertEqual(result, "ola,mundo.") + + def test_english_phrases_still_work_in_italian(self): + """English command phrases remain available when language is Italian.""" + processor = CommandProcessor(language="it") + result, _ = processor.process_text("hello comma world period") + self.assertEqual(result, "hello,world.") + + def test_auto_language_keeps_english_only_aliases(self): + """auto-detect does not load non-English phrase aliases.""" + processor = CommandProcessor(language="auto") + self.assertNotIn("virgola", processor.text_commands) + self.assertIn("dot", processor.text_commands) + result, _ = processor.process_text("ciao virgola") + self.assertEqual(result, "ciao virgola") + + def test_set_language_rebuilds_aliases(self): + """set_language swaps localized aliases without recreating the processor.""" + processor = CommandProcessor(language="en-us") + self.assertNotIn("virgola", processor.text_commands) + processor.set_language("it") + self.assertIn("virgola", processor.text_commands) + result, _ = processor.process_text("testo virgola") + self.assertEqual(result, "testo,") + processor.set_language("auto") + self.assertNotIn("virgola", processor.text_commands) + + def test_localized_newline_then_punctuation_keeps_break(self): + """Attach-left punctuation must not eat a preceding command-inserted newline.""" + fr = CommandProcessor(language="fr") + result, _ = fr.process_text("bonjour nouvelle ligne point") + self.assertEqual(result, "bonjour \n.") + result, _ = fr.process_text("bonjour nouvelle ligne virgule") + self.assertEqual(result, "bonjour \n,") + + it = CommandProcessor(language="it") + result, _ = it.process_text("ciao nuova riga punto") + self.assertEqual(result, "ciao \n.") + + def test_localized_punctuation_then_newline_keeps_break(self): + """Attach-left trailing whitespace must not eat a following newline.""" + fr = CommandProcessor(language="fr") + result, _ = fr.process_text("bonjour point nouvelle ligne") + self.assertEqual(result, "bonjour.\n") + + def test_french_apostrophe_forms_map_to_question_mark(self): + """ASCII and typographic apostrophes in 'point d'interrogation' both yield '?'.""" + fr = CommandProcessor(language="fr") + ascii_result, _ = fr.process_text("vrai point d'interrogation") + self.assertEqual(ascii_result, "vrai?") + self.assertNotIn("interrogation", ascii_result) + + typographic_result, _ = fr.process_text("vrai point d\u2019interrogation") + self.assertEqual(typographic_result, "vrai?") + self.assertNotIn("interrogation", typographic_result) + self.assertNotIn(".", typographic_result) + + def test_french_apostrophe_forms_map_to_exclamation(self): + """ASCII and typographic apostrophes in 'point d'exclamation' both yield '!'.""" + fr = CommandProcessor(language="fr") + ascii_result, _ = fr.process_text("bravo point d'exclamation") + self.assertEqual(ascii_result, "bravo!") + + typographic_result, _ = fr.process_text("bravo point d\u2019exclamation") + self.assertEqual(typographic_result, "bravo!") + self.assertNotIn("exclamation", typographic_result) + + def test_locale_phrases_are_not_partially_replaced(self): + """Longer aliases must win over embedded shorter ones in every locale.""" + from vocalinux.speech_recognition.command_phrases import ( + _TEXT_COMMAND_ALIASES, + text_command_aliases_for, + ) + + for lang, phrases in _TEXT_COMMAND_ALIASES.items(): + processor = CommandProcessor(language=lang) + for phrase, replacement in phrases.items(): + result, _ = processor.process_text(phrase) + self.assertEqual( + result, + replacement, + msg=f"{lang}: {phrase!r} -> {result!r}, expected {replacement!r}", + ) + + aliases = text_command_aliases_for(lang) + for apostrophe in ("'", "\u2019", "`"): + question = f"point d{apostrophe}interrogation" + if question in aliases: + result, _ = processor.process_text(f"vrai {question}") + self.assertEqual(result, "vrai?", msg=f"{lang}: {question!r}") + + +class TestCommandPhrases(unittest.TestCase): + """Catalog id mapping for localized command aliases.""" + + def test_normalize_command_language(self): + """Catalog keys and auto-detect resolve to phrase-table codes.""" + from vocalinux.speech_recognition.command_phrases import normalize_command_language + + self.assertEqual(normalize_command_language("en-us"), "en") + self.assertEqual(normalize_command_language("en-in"), "en") + self.assertEqual(normalize_command_language("it"), "it") + self.assertEqual(normalize_command_language("auto"), "en") + self.assertEqual(normalize_command_language(None), "en") + self.assertEqual(normalize_command_language("fr"), "fr") + + def test_french_aliases_include_apostrophe_variants(self): + """French question/exclamation phrases exist for ASCII, U+2019, and backtick.""" + from vocalinux.speech_recognition.command_phrases import text_command_aliases_for + + aliases = text_command_aliases_for("fr") + for apostrophe in ("'", "\u2019", "`"): + self.assertEqual(aliases[f"point d{apostrophe}interrogation"], "?") + self.assertEqual(aliases[f"point d{apostrophe}exclamation"], "!") + self.assertEqual(aliases["point"], ".") diff --git a/tests/test_settings_model_state.py b/tests/test_settings_model_state.py index b5f12f0..5eb99cd 100644 --- a/tests/test_settings_model_state.py +++ b/tests/test_settings_model_state.py @@ -1106,6 +1106,8 @@ def test_speech_manager_init_normalizes_parakeet_language(): defer_download=True, ) assert manager.language == "auto" + assert manager.command_processor.language == "auto" + assert "virgule" not in manager.command_processor.text_commands def test_speech_manager_reconfigure_to_parakeet_normalizes_language(): @@ -1128,6 +1130,8 @@ def test_speech_manager_reconfigure_to_parakeet_normalizes_language(): manager.reconfigure(engine="parakeet", model_size="v3-european", language="fr") assert manager.engine == "parakeet" assert manager.language == "auto" + assert manager.command_processor.language == "auto" + assert "virgule" not in manager.command_processor.text_commands def test_main_startup_normalizes_parakeet_language(): @@ -1155,9 +1159,12 @@ def test_speech_manager_reconfigure_to_parakeet_clears_leftover_without_language defer_download=True, ) assert manager.language == "de" + assert "komma" in manager.command_processor.text_commands manager.reconfigure(engine="parakeet", model_size="v3-european", force_download=False) assert manager.engine == "parakeet" assert manager.language == "auto" + assert manager.command_processor.language == "auto" + assert "komma" not in manager.command_processor.text_commands def test_speech_manager_reconfigure_parakeet_language_arg_stays_auto(): @@ -1175,3 +1182,25 @@ def test_speech_manager_reconfigure_parakeet_language_arg_stays_auto(): ) manager.reconfigure(language="fr", force_download=False) assert manager.language == "auto" + assert manager.command_processor.language == "auto" + assert "virgule" not in manager.command_processor.text_commands + + +def test_speech_manager_command_processor_follows_language(): + """Localized command aliases track the recognition language.""" + from unittest.mock import patch + + from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager + + with patch.object(SpeechRecognitionManager, "_init_whispercpp"): + manager = SpeechRecognitionManager( + engine="whisper_cpp", + model_size="small", + language="it", + defer_download=True, + ) + assert manager.command_processor.language == "it" + assert "virgola" in manager.command_processor.text_commands + manager.reconfigure(language="en-us", force_download=False) + assert manager.command_processor.language == "en-us" + assert "virgola" not in manager.command_processor.text_commands diff --git a/web/PRODUCT.md b/web/PRODUCT.md index 7f75d5d..88b221b 100644 --- a/web/PRODUCT.md +++ b/web/PRODUCT.md @@ -31,7 +31,7 @@ Offline-first Linux voice typing with real desktop integration (system tray, X11 - Display servers: X11 and Wayland - Shortcut modes: push-to-talk default (hold Right Alt / Option); toggle available; left/right modifier distinction; configurable modifier+key combos - Searchable language combobox; delete unused downloaded speech models from Settings -- Optional voice commands (English-only); Silero neural VAD with amplitude fallback +- Optional voice commands with localized punctuation phrases for common languages; Silero neural VAD with amplitude fallback - Continuous dictation polish: capitalize after sentence punctuation; trailing space after each completed utterance - Optional auto-pause while configured apps run; optional idle model keep-alive unload - In-app update checker (stable/nightly) with tray notification when a newer GitHub release is available