feat(shortcuts): add language switching shortcuts, fix PTT races, and add dictation history (#880)
* feat(shortcuts): per-language dictation shortcuts (#805) Bind extra keys that start dictation in a specific catalog language for one utterance, then restore the configured language. Bindings live in shortcuts.language_shortcuts as [{shortcut, language}], normalized against the language catalog and the backend's shortcut parser. The tray spawns one KeyboardShortcutManager per binding in the active mode; toggle presses route through start_recognition_with_language, and push-to-talk arms press/release the same way. The manager keeps the override one-shot: it applies before the utterance starts (outranking follow-keyboard-layout) and unwinds on the next IDLE/ERROR transition or when a mid-dictation reconfigure picks a new language. Engines that cannot re-language without a reload (VOSK, Parakeet, English-only whisper.cpp) refuse with the error cue and a notification instead of dictating in the wrong language. Settings gains a Language Shortcuts group under Shortcuts: each row is a language picker, an editable key entry, a Record button that retargets the shared key-capture, and a remove button; edits persist immediately and rebuild the live listeners. A non-dict shortcuts section now rebuilds instead of crashing config load. * fix(shortcuts): class-level default for language shortcut listeners TrayIndicator stubs built with __new__ (e.g. test_suspend_handler) skip __init__ but still run _quit(); give _language_shortcut_managers a class-level default so teardown iterates an empty list. * Fix Greptile review findings on per-language shortcuts (#805) - Bind each dictation session's language for transcription workers so the one-shot restore cannot rewrite a queued segment's language - Judge a shortcut's capability against the model the layout refresh will serve (whisper.cpp sibling swap) and refuse requests the engine drops - Add _oneshot_language_restore to the reconfigure snapshot and settle a revived pending restore on failure - Dedupe language bindings by gesture (modifier reordering, enter/return alias, pure-modifier side sets) instead of by string - Stop per-language listeners under external activation and never re-arm - Gate push-to-talk release on the binding that started the session, and stop a held hold before rebuilding listeners - Keep a row's last valid binding during partial edits, report rejected duplicates, cancel a capture aimed at a removed row, disarm the previous Record target, persist a recorded key once, and add the missing type hints * fix(shortcuts): resolve remaining Greptile race findings on PR #880 - Stamp each queued audio segment with the session language it was recorded under, so a worker that outlives stop_recognition's joins transcribes each utterance in its own language rather than whatever a newer session stored in _session_language. - Serialize push-to-talk press/release behind _ptt_lock: a release landing while start_recognition runs now waits for the owner claim instead of being dropped, so a quick key tap cannot leave the microphone running. - Add the missing return type hint on _stop_language_shortcut_managers. * test(shortcuts): stub _segment_started_at on bare manager stubs * fix(shortcuts): carry segment language as attribute, default language on bare managers * fix(shortcuts): keep (segment, language) queue tuples, stamp segment.language too * fix(settings): apply history toggle and limit to the live store --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
co-authored by
Jatin K Malik
parent
ca40f7d8b0
commit
dbe0215aad
@@ -60,6 +60,10 @@ class SpeechRecognitionManagerProtocol(Protocol):
|
||||
"""Start the speech recognition process. Returns True if listening began."""
|
||||
...
|
||||
|
||||
def start_recognition_with_language(self, language: str, mode: str = "toggle") -> bool:
|
||||
"""Start recognition in ``language`` for this utterance only (#805)."""
|
||||
...
|
||||
|
||||
def stop_recognition(self) -> None:
|
||||
"""Stop the speech recognition process."""
|
||||
...
|
||||
|
||||
@@ -121,19 +121,24 @@ class FasterWhisperEngine:
|
||||
self._model = None
|
||||
self._model_initialized = False
|
||||
|
||||
def _normalize_language(self) -> Optional[str]:
|
||||
def _normalize_language(self, language: Optional[str] = None) -> Optional[str]:
|
||||
"""Map Vocalinux language codes to faster-whisper language codes."""
|
||||
if self.language == "auto":
|
||||
language = self.language if language is None else language
|
||||
if language == "auto":
|
||||
return None
|
||||
if self.language == "en-us" or self.language == "en-in":
|
||||
if language == "en-us" or language == "en-in":
|
||||
return "en"
|
||||
return self.language
|
||||
return language
|
||||
|
||||
def transcribe(self, audio_buffer: list[bytes]) -> str:
|
||||
def transcribe(self, audio_buffer: list[bytes], language: Optional[str] = None) -> str:
|
||||
"""Transcribe the provided audio buffer.
|
||||
|
||||
Args:
|
||||
audio_buffer: List of audio data chunks (16-bit PCM at 16kHz).
|
||||
language: Per-call override of the configured engine language
|
||||
(#805): a one-shot dictation binds its session language here
|
||||
so a later restore cannot rewrite it mid-flight. None uses
|
||||
``self.language``.
|
||||
|
||||
Returns:
|
||||
Recognized text.
|
||||
@@ -152,7 +157,7 @@ class FasterWhisperEngine:
|
||||
|
||||
segments, _info = model.transcribe(
|
||||
audio_float,
|
||||
language=self._normalize_language(),
|
||||
language=self._normalize_language(language),
|
||||
task="transcribe",
|
||||
beam_size=5,
|
||||
best_of=5,
|
||||
|
||||
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Callable, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
from ..audio.capture import ( # noqa: F401 (re-exported for compatibility)
|
||||
_STICKY_LOCK_MIN_MEAN_SQUARE,
|
||||
@@ -90,9 +91,18 @@ class _AudioSegment(list):
|
||||
from speech captured after it, however late the decode finishes.
|
||||
"""
|
||||
|
||||
def __init__(self, chunks: list[bytes], started_at: Optional[float]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
chunks: list[bytes],
|
||||
started_at: Optional[float],
|
||||
language: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(chunks)
|
||||
self.started_at = started_at
|
||||
# The language this utterance was dictated under (#805): a worker that
|
||||
# outlives its session must not transcribe a stale segment in a newer
|
||||
# session's language.
|
||||
self.language = language
|
||||
|
||||
|
||||
def _pywhispercpp_distribution_version() -> Optional[tuple[int, ...]]:
|
||||
@@ -380,6 +390,14 @@ class SpeechRecognitionManager:
|
||||
# start of every dictation while the sentinel is in force (#821).
|
||||
self.language_preference = language
|
||||
self.language = normalize_language_for_engine(engine, resolve_language_preference(language))
|
||||
# One-shot language override set by a per-language shortcut (#805):
|
||||
# consumed by the next start attempt, restored when dictation ends.
|
||||
self._pending_language_override: Optional[str] = None
|
||||
self._oneshot_language_restore: Optional[str] = None
|
||||
# The language the in-flight dictation session resolves to. Workers
|
||||
# read this so a one-shot restore during a lingering transcribe cannot
|
||||
# change a queued segment's language (#805).
|
||||
self._session_language: Optional[str] = None
|
||||
self.stop_sound_guard_ms = kwargs.get("stop_sound_guard_ms", 200)
|
||||
self.state = RecognitionState.IDLE
|
||||
self.audio_thread = None
|
||||
@@ -574,9 +592,17 @@ class SpeechRecognitionManager:
|
||||
#: "not following the layout" instead of raising on the dictation path.
|
||||
language_preference: str = "auto"
|
||||
|
||||
#: Same for the one-shot override state (#805): __new__-built stubs in tests
|
||||
#: must not raise on the dictation path either.
|
||||
language: str = "auto"
|
||||
_pending_language_override: Optional[str] = None
|
||||
_oneshot_language_restore: Optional[str] = None
|
||||
_session_language: Optional[str] = None
|
||||
|
||||
#: Logged once rather than per dictation, so an unsupported pairing does not
|
||||
#: spam the log on every hotkey press.
|
||||
_warned_follow_layout_unsupported: bool = False
|
||||
_warned_language_override_unsupported: bool = False
|
||||
|
||||
# Every live field ``reconfigure()`` can write. A failed engine switch
|
||||
# restores this set so dictation matches the persisted config, not the
|
||||
@@ -586,6 +612,7 @@ class SpeechRecognitionManager:
|
||||
"model_size",
|
||||
"language",
|
||||
"language_preference",
|
||||
"_oneshot_language_restore",
|
||||
"vad_sensitivity",
|
||||
"silence_timeout",
|
||||
"audio_device_index",
|
||||
@@ -704,6 +731,107 @@ class SpeechRecognitionManager:
|
||||
"multilingual model in Settings to use this mode."
|
||||
)
|
||||
|
||||
def _dictation_language(self, segment_language: Optional[str] = None) -> str:
|
||||
"""The language a queued audio segment was recorded under (#805).
|
||||
|
||||
A transcription worker can outlive ``stop_recognition``'s timed joins,
|
||||
so ``self.language`` may already be restored to the configured value
|
||||
while a queued segment still needs the one-shot override — and a newer
|
||||
dictation may already have overwritten ``_session_language`` by the
|
||||
time a stale worker reads it. Queued segments therefore carry a
|
||||
``segment_language`` snapshot stamped at enqueue time; calls without
|
||||
one (tests and direct transcribe calls) fall back to the session
|
||||
binding, then the configured language.
|
||||
"""
|
||||
if segment_language is not None:
|
||||
return segment_language
|
||||
return self._session_language or self.language
|
||||
|
||||
def _refuse_language_override(self, language: str) -> None:
|
||||
"""Play the refusal cue for a per-language shortcut the model can't serve."""
|
||||
if not self._warned_language_override_unsupported:
|
||||
self._warned_language_override_unsupported = True
|
||||
logger.warning(
|
||||
f"Cannot dictate in {language!r}: the loaded "
|
||||
f"{self.engine}/{self.model_size!r} model cannot transcribe "
|
||||
"it without a reload. Pick a multilingual model in Settings "
|
||||
"or switch the dictation language instead."
|
||||
)
|
||||
play_error_sound()
|
||||
_show_notification(
|
||||
"Language Shortcut Unavailable",
|
||||
f"Vocalinux cannot dictate in {language} with the loaded model. "
|
||||
"Choose a multilingual model in Settings to use this shortcut.",
|
||||
"dialog-warning",
|
||||
)
|
||||
|
||||
def _serving_model_supports_language_switch(self) -> bool:
|
||||
"""Whether the model that will serve this utterance can switch language.
|
||||
|
||||
``start_recognition`` refreshes the layout before a shortcut's override
|
||||
applies, and on follow-keyboard-layout that refresh can swap an
|
||||
English-only whisper.cpp model for a downloaded multilingual sibling.
|
||||
Judge the model the refresh leaves loaded, not the one in memory now.
|
||||
"""
|
||||
if self._can_relanguage_without_reload():
|
||||
return True
|
||||
if self.engine == "whisper_cpp" and self.language_preference == LANGUAGE_FOLLOWS_LAYOUT:
|
||||
sibling = _multilingual_sibling(self.model_size)
|
||||
if sibling != self.model_size and is_model_downloaded(sibling):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _apply_pending_language_override(self) -> bool:
|
||||
"""Point this utterance at a per-language shortcut's language (#805).
|
||||
|
||||
Consumed once, right after the layout refresh, so the explicit shortcut
|
||||
wins over the follow-mode resolution *and* the capability check runs
|
||||
against the model that will actually transcribe. The previous effective
|
||||
language is kept in ``_oneshot_language_restore`` and put back by
|
||||
``_restore_language_after_oneshot`` once dictation ends.
|
||||
|
||||
Returns False — refusing to start — when the engine drops the request
|
||||
(Parakeet normalizes every catalog language to ``auto``, so a labeled
|
||||
shortcut could never select it) or the loaded model cannot transcribe
|
||||
it without a reload; dictating silently in the wrong language would be
|
||||
worse than playing the refusal cue.
|
||||
"""
|
||||
requested = self._pending_language_override
|
||||
if requested is None:
|
||||
return True
|
||||
target = normalize_language_for_engine(self.engine, requested)
|
||||
if target != requested:
|
||||
self._refuse_language_override(requested)
|
||||
return False
|
||||
if target == self.language:
|
||||
return True
|
||||
if not self._can_relanguage_without_reload():
|
||||
self._refuse_language_override(requested)
|
||||
return False
|
||||
self._oneshot_language_restore = self.language
|
||||
self.language = target
|
||||
self.command_processor.set_language(target)
|
||||
if self._faster_whisper_engine is not None:
|
||||
self._faster_whisper_engine.language = target
|
||||
logger.debug(f"Dictating in {target} via per-language shortcut")
|
||||
return True
|
||||
|
||||
def _restore_language_after_oneshot(self) -> None:
|
||||
"""Undo a per-language shortcut's one-shot override (#805).
|
||||
|
||||
Runs when dictation drops to IDLE or ERROR, so the next plain hotkey
|
||||
dictation resolves the configured language again.
|
||||
"""
|
||||
previous = self._oneshot_language_restore
|
||||
if previous is None:
|
||||
return
|
||||
self._oneshot_language_restore = None
|
||||
self.language = previous
|
||||
self.command_processor.set_language(previous)
|
||||
if self._faster_whisper_engine is not None:
|
||||
self._faster_whisper_engine.language = previous
|
||||
logger.debug(f"Restored dictation language {previous} after one-shot")
|
||||
|
||||
def _init_vosk(self):
|
||||
"""Initialize the VOSK speech recognition engine."""
|
||||
# VOSK doesn't support auto-detect, so fall back to en-us for "auto"
|
||||
@@ -879,12 +1007,16 @@ class SpeechRecognitionManager:
|
||||
self.state = RecognitionState.ERROR
|
||||
raise
|
||||
|
||||
def _transcribe_with_whisper(self, audio_buffer: list[bytes]) -> str:
|
||||
def _transcribe_with_whisper(
|
||||
self, audio_buffer: list[bytes], language: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Transcribe audio buffer using Whisper.
|
||||
|
||||
Args:
|
||||
audio_buffer: List of audio data chunks (16-bit PCM at 16kHz)
|
||||
language: Per-utterance snapshot stamped on the queued segment
|
||||
(#805); None resolves the live session language.
|
||||
|
||||
Returns:
|
||||
Transcribed text
|
||||
@@ -919,7 +1051,7 @@ class SpeechRecognitionManager:
|
||||
import torch
|
||||
use_fp16 = self.model.device != torch.device("cpu")
|
||||
|
||||
lang = resolve_whisper_language(self.language)
|
||||
lang = resolve_whisper_language(self._dictation_language(language))
|
||||
|
||||
# Transcribe with Whisper (handles variable length audio automatically)
|
||||
result = self.model.transcribe(
|
||||
@@ -1131,12 +1263,16 @@ class SpeechRecognitionManager:
|
||||
self.state = RecognitionState.ERROR
|
||||
raise
|
||||
|
||||
def _transcribe_with_faster_whisper(self, audio_buffer: list[bytes]) -> str:
|
||||
def _transcribe_with_faster_whisper(
|
||||
self, audio_buffer: list[bytes], language: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Transcribe audio buffer using faster-whisper.
|
||||
|
||||
Args:
|
||||
audio_buffer: List of audio data chunks (16-bit PCM at 16kHz)
|
||||
language: Per-utterance snapshot stamped on the queued segment
|
||||
(#805); None resolves the live session language.
|
||||
|
||||
Returns:
|
||||
Transcribed text
|
||||
@@ -1146,7 +1282,9 @@ class SpeechRecognitionManager:
|
||||
logger.warning("faster-whisper engine not ready during transcription")
|
||||
return ""
|
||||
|
||||
return self._faster_whisper_engine.transcribe(audio_buffer)
|
||||
return self._faster_whisper_engine.transcribe(
|
||||
audio_buffer, language=self._dictation_language(language)
|
||||
)
|
||||
except (RuntimeError, OSError, ValueError) as e:
|
||||
logger.error(f"Error in faster-whisper transcription: {e}", exc_info=True)
|
||||
return ""
|
||||
@@ -1552,7 +1690,9 @@ class SpeechRecognitionManager:
|
||||
logger.info("Successfully loaded model with CPU backend")
|
||||
return cpu_backend
|
||||
|
||||
def _transcribe_with_whispercpp(self, audio_buffer: list[bytes]) -> str:
|
||||
def _transcribe_with_whispercpp(
|
||||
self, audio_buffer: list[bytes], language: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Transcribe audio buffer using whisper.cpp.
|
||||
|
||||
@@ -1583,7 +1723,7 @@ class SpeechRecognitionManager:
|
||||
)
|
||||
|
||||
# Prepare language parameter
|
||||
lang = resolve_whisper_language(self.language)
|
||||
lang = resolve_whisper_language(self._dictation_language(language))
|
||||
|
||||
logger.debug(f"whisper.cpp using language: {lang or 'auto-detect'}")
|
||||
|
||||
@@ -1953,7 +2093,12 @@ class SpeechRecognitionManager:
|
||||
self._model_initialized = True
|
||||
logger.info("Remote API engine setup complete.")
|
||||
|
||||
def _transcribe_with_remote_api(self, audio_buffer: list[bytes], session) -> str:
|
||||
def _transcribe_with_remote_api(
|
||||
self,
|
||||
audio_buffer: list[bytes],
|
||||
session: Optional["requests.Session"],
|
||||
language: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Transcribe audio via remote API.
|
||||
|
||||
Package audio buffer into WAV format and send to remote server via HTTP POST.
|
||||
@@ -1963,6 +2108,8 @@ class SpeechRecognitionManager:
|
||||
Args:
|
||||
audio_buffer: Audio data chunk list (16-bit PCM at 16kHz)
|
||||
session: A requests.Session snapshot (obtained under _model_lock)
|
||||
language: Per-utterance snapshot stamped on the queued segment
|
||||
(#805); None resolves the live session language.
|
||||
|
||||
Returns:
|
||||
Transcribed text
|
||||
@@ -1998,7 +2145,7 @@ class SpeechRecognitionManager:
|
||||
)
|
||||
|
||||
# Prepare language parameters
|
||||
lang = resolve_whisper_language(self.language)
|
||||
lang = resolve_whisper_language(self._dictation_language(language))
|
||||
|
||||
# Prepare HTTP request headers
|
||||
headers = {}
|
||||
@@ -2749,6 +2896,11 @@ class SpeechRecognitionManager:
|
||||
new_state: The new recognition state
|
||||
"""
|
||||
self.state = new_state
|
||||
if new_state in (RecognitionState.IDLE, RecognitionState.ERROR):
|
||||
# A per-language shortcut's override lasts for exactly one
|
||||
# dictation (#805); restore the configured language here so every
|
||||
# exit path — normal stop, error, quit — unwinds it.
|
||||
self._restore_language_after_oneshot()
|
||||
for callback in self.state_callbacks:
|
||||
callback(new_state)
|
||||
|
||||
@@ -2919,6 +3071,17 @@ class SpeechRecognitionManager:
|
||||
# user is typing in right now rather than the one they had at startup.
|
||||
self._refresh_language_from_layout()
|
||||
|
||||
# A per-language shortcut's override runs after the layout refresh so
|
||||
# the explicit key press still wins over follow-mode resolution, and
|
||||
# so its capability check sees the model the refresh will serve (#805).
|
||||
if not self._apply_pending_language_override():
|
||||
return False
|
||||
|
||||
# Bind this session's language for the transcription workers: they can
|
||||
# outlive stop_recognition's joins, and the one-shot restore must not
|
||||
# rewrite the language of a segment still in the queue (#805).
|
||||
self._session_language = self.language
|
||||
|
||||
logger.info("Starting speech recognition")
|
||||
self._update_state(RecognitionState.LISTENING)
|
||||
|
||||
@@ -3078,6 +3241,43 @@ class SpeechRecognitionManager:
|
||||
if getattr(self, "_buffered_reload_session", False) and self._buffered_capture_failed:
|
||||
self._capture_finished.set()
|
||||
|
||||
def start_recognition_with_language(self, language: str, mode: str = "toggle") -> bool:
|
||||
"""Start dictation in ``language`` for this utterance only (#805).
|
||||
|
||||
The configured preference is untouched: when this dictation ends the
|
||||
engine's language is restored, so the next plain hotkey behaves as
|
||||
before. When the loaded engine/model cannot transcribe ``language``
|
||||
without a reload (VOSK, Parakeet, an English-only whisper.cpp model),
|
||||
starting is refused with the usual error cue rather than dictating in
|
||||
the wrong language.
|
||||
|
||||
Args:
|
||||
language: A catalog id (e.g. "de", "auto") as stored by
|
||||
``shortcuts.language_shortcuts``.
|
||||
mode: "toggle" or "push_to_talk", same as start_recognition.
|
||||
|
||||
Returns:
|
||||
True if recognition actually started, False otherwise.
|
||||
"""
|
||||
target = normalize_language_for_engine(self.engine, language)
|
||||
if target != language:
|
||||
# The engine drops the requested language entirely: Parakeet maps
|
||||
# every catalog id to auto, so a labeled shortcut could never
|
||||
# select it.
|
||||
self._refuse_language_override(language)
|
||||
return False
|
||||
if target != self.language and not self._serving_model_supports_language_switch():
|
||||
self._refuse_language_override(language)
|
||||
return False
|
||||
|
||||
self._pending_language_override = language
|
||||
try:
|
||||
return self.start_recognition(mode=mode)
|
||||
finally:
|
||||
# The override only ever feeds the next start attempt, whether it
|
||||
# ran or was refused by the guards inside start_recognition.
|
||||
self._pending_language_override = None
|
||||
|
||||
def _record_audio(self):
|
||||
"""Record audio from the microphone with reconnection logic."""
|
||||
# Lazy import to avoid circular dependency
|
||||
@@ -3370,10 +3570,20 @@ class SpeechRecognitionManager:
|
||||
|
||||
self._process_audio_buffer(audio_buffer)
|
||||
|
||||
def _process_audio_buffer(self, audio_buffer: list[bytes]):
|
||||
"""Process an immutable audio segment for transcription and commands."""
|
||||
def _process_audio_buffer(
|
||||
self, audio_buffer: list[bytes], language: Optional[str] = None
|
||||
) -> None:
|
||||
"""Process an immutable audio segment for transcription and commands.
|
||||
|
||||
``language`` is the per-utterance snapshot stamped on the segment when
|
||||
it was queued (#805); None falls back to the segment's own stamp and
|
||||
then to the live session language.
|
||||
"""
|
||||
if not audio_buffer:
|
||||
return
|
||||
if language is None:
|
||||
language = getattr(audio_buffer, "language", None)
|
||||
dictation_language = self._dictation_language(language)
|
||||
|
||||
if self.engine == "vosk":
|
||||
# Lock recognizer access to prevent race condition with reconfigure
|
||||
@@ -3389,16 +3599,16 @@ class SpeechRecognitionManager:
|
||||
text = result.get("text", "")
|
||||
|
||||
elif self.engine == "whisper":
|
||||
text = self._transcribe_with_whisper(audio_buffer)
|
||||
text = self._transcribe_with_whisper(audio_buffer, dictation_language)
|
||||
|
||||
elif self.engine == "whisper_cpp":
|
||||
text = self._transcribe_with_whispercpp(audio_buffer)
|
||||
text = self._transcribe_with_whispercpp(audio_buffer, dictation_language)
|
||||
|
||||
elif self.engine == "parakeet":
|
||||
text = self._transcribe_with_parakeet(audio_buffer)
|
||||
|
||||
elif self.engine == "faster_whisper":
|
||||
text = self._transcribe_with_faster_whisper(audio_buffer)
|
||||
text = self._transcribe_with_faster_whisper(audio_buffer, dictation_language)
|
||||
|
||||
elif self.engine == "remote_api":
|
||||
# Snapshot the HTTP session under lock to prevent race with
|
||||
@@ -3412,7 +3622,7 @@ class SpeechRecognitionManager:
|
||||
if session is None:
|
||||
logger.error("Remote API HTTP session not initialized")
|
||||
return
|
||||
text = self._transcribe_with_remote_api(audio_buffer, session)
|
||||
text = self._transcribe_with_remote_api(audio_buffer, session, dictation_language)
|
||||
|
||||
else:
|
||||
logger.error(f"Unknown engine: {self.engine}")
|
||||
@@ -3459,7 +3669,7 @@ class SpeechRecognitionManager:
|
||||
f"Recognition loop - should_record={self.should_record}, queue_empty={self._segment_queue.empty()}"
|
||||
)
|
||||
try:
|
||||
segment = self._segment_queue.get(timeout=0.1)
|
||||
queued = self._segment_queue.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
# Only exit if we're not recording AND queue is empty
|
||||
if not self.should_record and self._segment_queue.empty():
|
||||
@@ -3468,7 +3678,7 @@ class SpeechRecognitionManager:
|
||||
)
|
||||
# Give a brief moment for any final items to be enqueued
|
||||
try:
|
||||
segment = self._segment_queue.get(timeout=0.5)
|
||||
queued = self._segment_queue.get(timeout=0.5)
|
||||
except queue.Empty:
|
||||
logger.debug("Recognition loop - no more items, exiting")
|
||||
break
|
||||
@@ -3476,28 +3686,30 @@ class SpeechRecognitionManager:
|
||||
logger.debug("Recognition loop - queue timeout, continuing")
|
||||
continue
|
||||
|
||||
if segment is None:
|
||||
if queued is None:
|
||||
logger.debug("Recognition loop - got None signal, draining remaining items...")
|
||||
# Drain any remaining items before exiting
|
||||
while not self._segment_queue.empty():
|
||||
try:
|
||||
remaining = self._segment_queue.get_nowait()
|
||||
if remaining is not None:
|
||||
remaining_segment, remaining_language = remaining
|
||||
logger.debug(
|
||||
f"Recognition loop - processing remaining segment with {len(remaining)} chunks"
|
||||
f"Recognition loop - processing remaining segment with {len(remaining_segment)} chunks"
|
||||
)
|
||||
if self.should_record:
|
||||
self._update_state(RecognitionState.PROCESSING)
|
||||
self._process_audio_buffer(remaining)
|
||||
self._process_audio_buffer(remaining_segment, remaining_language)
|
||||
except queue.Empty:
|
||||
break
|
||||
logger.debug("Recognition loop - exiting after None signal")
|
||||
break
|
||||
|
||||
segment, segment_language = queued
|
||||
logger.debug(f"Recognition loop - processing segment with {len(segment)} chunks")
|
||||
if self.should_record:
|
||||
self._update_state(RecognitionState.PROCESSING)
|
||||
self._process_audio_buffer(segment)
|
||||
self._process_audio_buffer(segment, segment_language)
|
||||
if self.should_record:
|
||||
self._update_state(RecognitionState.LISTENING)
|
||||
finally:
|
||||
@@ -3517,14 +3729,20 @@ class SpeechRecognitionManager:
|
||||
|
||||
logger.debug(f"_enqueue_audio_segment called with {len(segment)} chunks")
|
||||
|
||||
# Stamp this utterance's language on the segment: a worker that
|
||||
# outlives its dictation (or drains another session's queue) must
|
||||
# transcribe each segment in the language it was recorded under, not
|
||||
# whatever a newer session stored in _session_language (#805).
|
||||
segment.language = self._dictation_language()
|
||||
stamped = (segment, segment.language)
|
||||
try:
|
||||
self._segment_queue.put_nowait(segment)
|
||||
self._segment_queue.put_nowait(stamped)
|
||||
logger.debug("Enqueued segment successfully")
|
||||
except queue.Full:
|
||||
logger.warning("Transcription queue is full, dropping oldest pending segment")
|
||||
try:
|
||||
self._segment_queue.get_nowait()
|
||||
self._segment_queue.put_nowait(segment)
|
||||
self._segment_queue.put_nowait(stamped)
|
||||
except queue.Empty:
|
||||
logger.warning("Could not recover queue space for transcription segment")
|
||||
|
||||
@@ -3596,6 +3814,10 @@ class SpeechRecognitionManager:
|
||||
# VOSK needs to load a different model for the new language
|
||||
language_changed = False
|
||||
if language is not None:
|
||||
# A new configured language replaces any pending one-shot restore:
|
||||
# if this reconfigure stops an in-flight per-language dictation,
|
||||
# the restore must not undo what was just set here (#805).
|
||||
self._oneshot_language_restore = None
|
||||
# Settings hands over the preference, which may be the sentinel. Keep
|
||||
# it so the follow mode survives, and resolve what the engine gets.
|
||||
self.language_preference = language
|
||||
@@ -3708,6 +3930,11 @@ class SpeechRecognitionManager:
|
||||
# with unsaved VAD/device/API knobs while the UI shows the
|
||||
# previous configuration.
|
||||
self._restore_reconfigure_state(previous)
|
||||
# The snapshot can revive a one-shot language restore
|
||||
# whose dictation was already stopped above; settle it
|
||||
# now so the shortcut language does not leak into the
|
||||
# next ordinary dictation (#805).
|
||||
self._restore_language_after_oneshot()
|
||||
self._defer_download = True
|
||||
try:
|
||||
self._init_selected_engine()
|
||||
|
||||
@@ -157,6 +157,9 @@ DEFAULT_CONFIG = {
|
||||
# left_/right_ variants) — double-tap (toggle) or hold (push_to_talk).
|
||||
# Modifier+key combos are also supported, e.g. "alt+r", "ctrl+alt+r",
|
||||
# "super+space" — press (toggle) or hold (push_to_talk).
|
||||
# Per-language shortcuts (#805): each {"shortcut", "language"} entry
|
||||
# starts dictation in that catalog language for one utterance.
|
||||
"language_shortcuts": [],
|
||||
},
|
||||
"ui": {
|
||||
"start_minimized": False,
|
||||
@@ -230,6 +233,52 @@ DEFAULT_CONFIG = {
|
||||
}
|
||||
|
||||
|
||||
def _is_valid_language_shortcut(shortcut: Any) -> bool:
|
||||
"""Return whether ``shortcut`` parses as a bindable shortcut string.
|
||||
|
||||
Imported lazily: config_manager loads before the GTK stack in several
|
||||
entry points, and the keyboard package must not become a hard dependency
|
||||
of configuration access.
|
||||
"""
|
||||
from .keyboard_backends import is_valid_shortcut
|
||||
|
||||
return isinstance(shortcut, str) and is_valid_shortcut(shortcut)
|
||||
|
||||
|
||||
def normalize_language_shortcuts(raw: Any) -> list[dict[str, str]]:
|
||||
"""Normalize a ``shortcuts.language_shortcuts`` value (#805).
|
||||
|
||||
Returns ``[{"shortcut": ..., "language": ...}]`` in stored order. Entries
|
||||
that are not objects, lack a valid shortcut, or name a language outside the
|
||||
catalog are dropped; the first binding wins for a duplicated shortcut.
|
||||
``"auto"`` stays valid (dictate with per-utterance detection); ``"layout"``
|
||||
is not a catalog id and cannot be bound.
|
||||
"""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
|
||||
entries: list[dict[str, str]] = []
|
||||
seen_shortcuts: set[str] = set()
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
shortcut = item.get("shortcut")
|
||||
language = item.get("language")
|
||||
if not isinstance(shortcut, str) or not isinstance(language, str):
|
||||
continue
|
||||
shortcut = shortcut.strip().lower()
|
||||
language = language.strip()
|
||||
if language not in SUPPORTED_LANGUAGES:
|
||||
continue
|
||||
if not _is_valid_language_shortcut(shortcut):
|
||||
continue
|
||||
if shortcut in seen_shortcuts:
|
||||
continue
|
||||
seen_shortcuts.add(shortcut)
|
||||
entries.append({"shortcut": shortcut, "language": language})
|
||||
return entries
|
||||
|
||||
|
||||
def _multilingual_sibling(model_name: str) -> str:
|
||||
"""Drop the ``.en`` infix so medium.en / medium.en-q5_0 become multilingual."""
|
||||
if ".en" not in model_name:
|
||||
@@ -454,7 +503,12 @@ class ConfigManager:
|
||||
|
||||
def _migrate_shortcuts_config(self, user_config: Optional[dict] = None):
|
||||
"""Migrate deprecated shortcuts and preserve legacy defaults when omitted."""
|
||||
shortcuts_config = self.config.get("shortcuts", {})
|
||||
shortcuts_config = self.config.get("shortcuts")
|
||||
if not isinstance(shortcuts_config, dict):
|
||||
# A hand-edited config can hold a scalar here; rebuild the section
|
||||
# so the migrations below and later readers find a dict.
|
||||
shortcuts_config = {}
|
||||
self.config["shortcuts"] = shortcuts_config
|
||||
shortcut = shortcuts_config.get("toggle_recognition")
|
||||
changed = False
|
||||
|
||||
@@ -802,6 +856,23 @@ class ConfigManager:
|
||||
self.config["text_injection"] = {}
|
||||
self.config["text_injection"]["paste_shortcut"] = normalize_paste_shortcut(shortcut)
|
||||
|
||||
def get_language_shortcuts(self) -> list[dict[str, str]]:
|
||||
"""Return validated ``[{"shortcut", "language"}]`` bindings (#805).
|
||||
|
||||
Malformed or stale entries (unknown language, unparsable shortcut)
|
||||
never reach the listeners: they are filtered out here.
|
||||
"""
|
||||
shortcuts = self.config.get("shortcuts")
|
||||
if not isinstance(shortcuts, dict):
|
||||
return []
|
||||
return normalize_language_shortcuts(shortcuts.get("language_shortcuts"))
|
||||
|
||||
def set_language_shortcuts(self, entries: Any) -> None:
|
||||
"""Store per-language shortcut bindings after normalization (#805)."""
|
||||
if not isinstance(self.config.get("shortcuts"), dict):
|
||||
self.config["shortcuts"] = {}
|
||||
self.config["shortcuts"]["language_shortcuts"] = normalize_language_shortcuts(entries)
|
||||
|
||||
def is_overlay_enabled(self) -> bool:
|
||||
"""Check if the floating dictation overlay is enabled (default True)."""
|
||||
return self.get_bool("ui", "show_overlay", True)
|
||||
|
||||
@@ -24,6 +24,8 @@ from .base import (
|
||||
is_valid_shortcut,
|
||||
parse_shortcut,
|
||||
parse_shortcut_spec,
|
||||
shortcut_gesture,
|
||||
shortcut_gestures_collide,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -126,6 +126,29 @@ MODIFIER_NAMES = {
|
||||
"right_shift",
|
||||
}
|
||||
|
||||
# The physical modifier keys each token can satisfy. The side-agnostic family
|
||||
# name covers both hands ("ctrl" fires on either Ctrl key), while left_/right_
|
||||
# tokens cover one side only. Two gestures are the same to a backend whenever
|
||||
# the key sets they can fire on overlap (#880).
|
||||
MODIFIER_KEY_SIDES: dict[str, frozenset] = {
|
||||
"ctrl": frozenset({"left_ctrl", "right_ctrl"}),
|
||||
"alt": frozenset({"left_alt", "right_alt"}),
|
||||
"shift": frozenset({"left_shift", "right_shift"}),
|
||||
"super": frozenset({"left_super", "right_super"}),
|
||||
"left_ctrl": frozenset({"left_ctrl"}),
|
||||
"right_ctrl": frozenset({"right_ctrl"}),
|
||||
"left_alt": frozenset({"left_alt"}),
|
||||
"right_alt": frozenset({"right_alt"}),
|
||||
"left_shift": frozenset({"left_shift"}),
|
||||
"right_shift": frozenset({"right_shift"}),
|
||||
}
|
||||
|
||||
# Shortcut tokens that resolve to the same physical main key.
|
||||
_MAIN_KEY_ALIASES = {
|
||||
"return": "enter",
|
||||
"escape": "esc",
|
||||
}
|
||||
|
||||
# Named (non-alphanumeric) main keys accepted in a combo. Single letters/digits
|
||||
# and function keys (f1-f24) are accepted by rule and need not be listed here.
|
||||
_NAMED_MAIN_KEYS = {
|
||||
@@ -315,6 +338,45 @@ def is_valid_shortcut(shortcut_string: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def shortcut_gesture(shortcut_string: str) -> Optional[Tuple[str, frozenset]]:
|
||||
"""The physical gesture a shortcut string describes, as backends match it.
|
||||
|
||||
A pure-modifier hold normalizes to ``("hold", sides)``: the set of
|
||||
physical keys whose press fires it, so "ctrl+ctrl" and
|
||||
"left_ctrl+left_ctrl" overlap on the left Ctrl key. A combo normalizes to
|
||||
``("combo", {main key})`` because backends fire on the main key while
|
||||
every required modifier is held — extra held keys do not block a combo,
|
||||
so any two combos on the same key can both fire. Tokens that alias to the
|
||||
same physical key ("return"/"enter", "escape"/"esc") normalize together.
|
||||
|
||||
Returns None when the string does not parse.
|
||||
"""
|
||||
try:
|
||||
spec = parse_shortcut_spec(shortcut_string)
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
if spec.key is None:
|
||||
return ("hold", MODIFIER_KEY_SIDES[spec.modifiers[0]])
|
||||
return ("combo", frozenset({_MAIN_KEY_ALIASES.get(spec.key, spec.key)}))
|
||||
|
||||
|
||||
def shortcut_gestures_collide(first: str, second: str) -> bool:
|
||||
"""Whether two shortcut strings can be triggered by the same gesture (#805).
|
||||
|
||||
String comparison alone misses gestures the backends treat as identical,
|
||||
like "ctrl+alt+r" vs "alt+ctrl+r" or "ctrl+ctrl" vs
|
||||
"left_ctrl+left_ctrl".
|
||||
"""
|
||||
first_gesture = shortcut_gesture(first)
|
||||
second_gesture = shortcut_gesture(second)
|
||||
if first_gesture is None or second_gesture is None:
|
||||
return False
|
||||
if first_gesture[0] != second_gesture[0]:
|
||||
# A modifier press and a main-key press are different trigger events.
|
||||
return False
|
||||
return bool(first_gesture[1] & second_gesture[1])
|
||||
|
||||
|
||||
def _main_key_label(token: str) -> str:
|
||||
"""Human-readable label for a main-key token (e.g. 'r' -> 'R')."""
|
||||
if token in _NAMED_KEY_LABELS:
|
||||
|
||||
@@ -24,7 +24,8 @@ import re
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterator, Literal, NamedTuple, Optional
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterator, Literal, NamedTuple, Optional, cast
|
||||
|
||||
import gi
|
||||
|
||||
@@ -2568,6 +2569,8 @@ class SettingsDialog(Gtk.Dialog):
|
||||
update_status_callback: callable = None,
|
||||
overlay_enabled_callback: Optional[Callable[[bool], None]] = None,
|
||||
hotkey_listener_update_callback: Optional[Callable[[], None]] = None,
|
||||
language_shortcuts_update_callback: Optional[Callable[[], None]] = None,
|
||||
history_update_callback: Optional[Callable[[], None]] = None,
|
||||
):
|
||||
super().__init__(title="Vocalinux Settings", transient_for=parent, flags=0)
|
||||
# Force window decorations (title-bar close) on all WMs. An in-window
|
||||
@@ -2582,6 +2585,13 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self.update_status_callback = update_status_callback
|
||||
self.overlay_enabled_callback = overlay_enabled_callback
|
||||
self.hotkey_listener_update_callback = hotkey_listener_update_callback
|
||||
self.language_shortcuts_update_callback = language_shortcuts_update_callback
|
||||
self.history_update_callback = history_update_callback
|
||||
# Per-language shortcut rows (#805): a dict of row widgets per binding,
|
||||
# populated by _build_language_shortcuts_section.
|
||||
self._language_shortcut_rows: list[dict] = []
|
||||
self._language_shortcut_add_row: Optional[PreferenceRow] = None
|
||||
self._recording_shortcut_target = None
|
||||
self._test_active = False
|
||||
self._test_result = ""
|
||||
self._initializing = True # Flag to prevent auto-apply during initialization
|
||||
@@ -2738,6 +2748,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
|
||||
# Build UI sections into their topic pages
|
||||
self._build_shortcuts_section()
|
||||
self._build_language_shortcuts_section()
|
||||
self._build_recognition_section()
|
||||
self._build_simple_model_section()
|
||||
self._build_engine_section()
|
||||
@@ -3639,6 +3650,8 @@ class SettingsDialog(Gtk.Dialog):
|
||||
logger.info(f"Transcription history toggled: {enabled}")
|
||||
self.config_manager.set("history", "enabled", enabled)
|
||||
self.config_manager.save_settings()
|
||||
if self.history_update_callback:
|
||||
self.history_update_callback()
|
||||
return False
|
||||
|
||||
def _on_history_max_items_changed(self, widget: Gtk.SpinButton) -> None:
|
||||
@@ -3650,6 +3663,8 @@ class SettingsDialog(Gtk.Dialog):
|
||||
logger.info(f"Transcription history max items: {max_items}")
|
||||
self.config_manager.set("history", "max_items", max_items)
|
||||
self.config_manager.save_settings()
|
||||
if self.history_update_callback:
|
||||
self.history_update_callback()
|
||||
|
||||
def _on_autostart_toggled(self, widget, state):
|
||||
"""Handle toggle of the autostart switch."""
|
||||
@@ -4457,8 +4472,11 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self.custom_shortcut_row.set_no_show_all(True)
|
||||
group.add_row(self.custom_shortcut_row)
|
||||
|
||||
# Key-capture state for the Record button.
|
||||
# Key-capture state for the Record button. ``_recording_shortcut_target``
|
||||
# names (entry, apply_fn, button, hint_label) so one recorder serves the
|
||||
# main shortcut and every per-language row (#805).
|
||||
self._recording_shortcut = False
|
||||
self._recording_shortcut_target = None
|
||||
self._evdev_shortcut_recorder = None
|
||||
self.connect("key-press-event", self._on_shortcut_key_press)
|
||||
self.connect("destroy", self._on_shortcut_recorder_destroy)
|
||||
@@ -4532,6 +4550,254 @@ class SettingsDialog(Gtk.Dialog):
|
||||
|
||||
return False
|
||||
|
||||
def _build_language_shortcuts_section(self) -> None:
|
||||
"""Build the Language Shortcuts section (#805).
|
||||
|
||||
One row per shortcut → language binding, an Add row at the bottom, and
|
||||
an info label the shared key-recorder writes its hints into.
|
||||
"""
|
||||
group = PreferencesGroup(
|
||||
title="Language Shortcuts",
|
||||
description=(
|
||||
"Start dictation in a specific language with its own key. The "
|
||||
"binding lasts for that one dictation; your main language is "
|
||||
"restored after. Needs a multilingual model."
|
||||
),
|
||||
keywords=("language", "shortcut", "multilingual"),
|
||||
)
|
||||
self.language_shortcuts_group = group
|
||||
|
||||
for entry in self.config_manager.get_language_shortcuts():
|
||||
self._add_language_shortcut_row(entry["language"], entry["shortcut"])
|
||||
|
||||
add_button = Gtk.Button(label="Add Language Shortcut")
|
||||
add_button.set_tooltip_text("Bind another language to its own key")
|
||||
add_button.connect("clicked", self._on_add_language_shortcut_clicked)
|
||||
self._language_shortcut_add_row = PreferenceRow(
|
||||
title="Add a binding",
|
||||
subtitle="Pick a language, then record a key for it",
|
||||
widget=add_button,
|
||||
keywords=("add", "language", "shortcut"),
|
||||
)
|
||||
group.add_row(self._language_shortcut_add_row)
|
||||
|
||||
self.shortcuts_tab.pack_start(group, False, False, 0)
|
||||
|
||||
info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
info_box.get_style_context().add_class("info-box")
|
||||
info_box.set_margin_start(4)
|
||||
info_box.set_margin_end(4)
|
||||
info_box.set_margin_top(4)
|
||||
|
||||
info_icon = Gtk.Image.new_from_icon_name("dialog-information-symbolic", Gtk.IconSize.MENU)
|
||||
info_box.pack_start(info_icon, False, False, 0)
|
||||
|
||||
self.language_shortcuts_info_label = Gtk.Label(
|
||||
label="Language shortcuts take effect immediately and follow the "
|
||||
"shortcut mode above.",
|
||||
xalign=0,
|
||||
wrap=True,
|
||||
)
|
||||
self.language_shortcuts_info_label.get_style_context().add_class("tip-label")
|
||||
info_box.pack_start(self.language_shortcuts_info_label, True, True, 0)
|
||||
|
||||
self.shortcuts_tab.pack_start(info_box, False, False, 0)
|
||||
|
||||
def _add_language_shortcut_row(self, language: str, shortcut: str) -> None:
|
||||
"""Append one language → shortcut binding row (#805)."""
|
||||
hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
|
||||
language_picker = SearchablePicker()
|
||||
_style_combo(language_picker, _CONTROL_WIDTH - 40)
|
||||
_prevent_scroll_on_hover(language_picker)
|
||||
for lang_code, lang_info in SUPPORTED_LANGUAGES.items():
|
||||
language_picker.append(lang_code, str(cast(dict, lang_info)["name"]))
|
||||
hbox.pack_start(language_picker, False, False, 0)
|
||||
|
||||
shortcut_entry = Gtk.Entry()
|
||||
shortcut_entry.set_placeholder_text("e.g. ctrl+alt+d")
|
||||
shortcut_entry.set_width_chars(14)
|
||||
shortcut_entry.set_tooltip_text(
|
||||
"A modifier plus a key (ctrl+alt+d) or a function key (f10)"
|
||||
)
|
||||
shortcut_entry.set_text(shortcut)
|
||||
shortcut_entry.connect("changed", self._on_language_shortcut_row_changed)
|
||||
hbox.pack_start(shortcut_entry, True, True, 0)
|
||||
|
||||
record_button = Gtk.Button(label="Record")
|
||||
record_button.set_tooltip_text("Click, then press your desired key combo")
|
||||
hbox.pack_start(record_button, False, False, 0)
|
||||
|
||||
remove_button = Gtk.Button.new_from_icon_name("list-remove-symbolic", Gtk.IconSize.BUTTON)
|
||||
remove_button.set_tooltip_text("Remove this binding")
|
||||
hbox.pack_start(remove_button, False, False, 0)
|
||||
|
||||
# ``refs["row"]`` fills in below: handlers are wired before the row
|
||||
# exists so the picker's initial set_active_id cannot emit stale titles.
|
||||
refs = {
|
||||
"row": None,
|
||||
"language_picker": language_picker,
|
||||
"shortcut_entry": shortcut_entry,
|
||||
"record_button": record_button,
|
||||
# The last binding this row actually persisted; a mid-edit entry
|
||||
# (empty or half-typed) falls back to it rather than erasing the
|
||||
# binding.
|
||||
"last_valid_shortcut": shortcut.strip().lower(),
|
||||
}
|
||||
language_picker.connect("changed", partial(self._on_language_shortcut_picker_changed, refs))
|
||||
record_button.connect("clicked", partial(self._on_record_language_shortcut_clicked, refs))
|
||||
remove_button.connect("clicked", partial(self._on_remove_language_shortcut_clicked, refs))
|
||||
if not language_picker.set_active_id(language):
|
||||
language_picker.set_active_id("auto")
|
||||
|
||||
lang_info = cast(Optional[dict], SUPPORTED_LANGUAGES.get(language))
|
||||
lang_name = str(lang_info["name"]) if lang_info else language
|
||||
row = PreferenceRow(
|
||||
title=lang_name,
|
||||
subtitle="",
|
||||
widget=hbox,
|
||||
keywords=("language", "shortcut", lang_name),
|
||||
)
|
||||
refs["row"] = row
|
||||
|
||||
# Newest binding above the Add row so the group reads top-down.
|
||||
if self._language_shortcut_add_row is not None:
|
||||
index = self.language_shortcuts_group.rows.index(self._language_shortcut_add_row)
|
||||
self.language_shortcuts_group.listbox.insert(row, index)
|
||||
self.language_shortcuts_group.rows.insert(index, row)
|
||||
else:
|
||||
self.language_shortcuts_group.add_row(row)
|
||||
self._language_shortcut_rows.append(refs)
|
||||
|
||||
def _on_language_shortcut_picker_changed(
|
||||
self, refs: dict[str, Any], picker: SearchablePicker
|
||||
) -> None:
|
||||
"""Apply a row's language change: retitle it and persist (#805)."""
|
||||
row = refs.get("row")
|
||||
if row is not None:
|
||||
lang_id = picker.get_active_id() or ""
|
||||
lang_info = cast(Optional[dict], SUPPORTED_LANGUAGES.get(lang_id))
|
||||
lang_name = str(lang_info["name"]) if lang_info else lang_id
|
||||
row.set_title(lang_name)
|
||||
self._on_language_shortcut_row_changed(picker)
|
||||
|
||||
def _on_language_shortcut_row_changed(self, *args: object) -> None:
|
||||
"""Persist the bindings whenever a row's language or key changes (#805)."""
|
||||
self._persist_language_shortcuts()
|
||||
|
||||
def _on_record_language_shortcut_clicked(
|
||||
self, refs: dict[str, Any], button: Gtk.Button
|
||||
) -> None:
|
||||
"""Arm key-capture aimed at a language row's entry (#805)."""
|
||||
|
||||
def _apply(shortcut: str) -> None:
|
||||
# _commit_recorded_shortcut already wrote the captured key to the
|
||||
# entry, whose changed signal persisted it; setting the same text
|
||||
# again would write config a second time and rebuild the
|
||||
# listeners for one recorded key.
|
||||
if refs["shortcut_entry"].get_text() != shortcut:
|
||||
refs["shortcut_entry"].set_text(shortcut)
|
||||
|
||||
self._begin_shortcut_recording(
|
||||
refs["shortcut_entry"],
|
||||
_apply,
|
||||
refs["record_button"],
|
||||
self.language_shortcuts_info_label,
|
||||
)
|
||||
|
||||
def _on_remove_language_shortcut_clicked(
|
||||
self, refs: dict[str, Any], button: Gtk.Button
|
||||
) -> None:
|
||||
"""Remove a language binding row and persist (#805)."""
|
||||
# An armed Record capture aimed at this row's entry must be cancelled
|
||||
# first: the commit would otherwise write into a destroyed widget.
|
||||
target = getattr(self, "_recording_shortcut_target", None)
|
||||
if (
|
||||
getattr(self, "_recording_shortcut", False)
|
||||
and target is not None
|
||||
and target[0] is refs["shortcut_entry"]
|
||||
):
|
||||
self._stop_recording_shortcut()
|
||||
row = refs["row"]
|
||||
self.language_shortcuts_group.listbox.remove(row)
|
||||
if row in self.language_shortcuts_group.rows:
|
||||
self.language_shortcuts_group.rows.remove(row)
|
||||
self._language_shortcut_rows.remove(refs)
|
||||
row.destroy()
|
||||
self._persist_language_shortcuts()
|
||||
|
||||
def _on_add_language_shortcut_clicked(self, button: Gtk.Button) -> None:
|
||||
"""Append a fresh binding row (defaults to auto-detect) (#805)."""
|
||||
self._add_language_shortcut_row("auto", "")
|
||||
self.language_shortcuts_group.show_all()
|
||||
self._persist_language_shortcuts()
|
||||
|
||||
def _collect_language_shortcuts(self) -> list[dict[str, str]]:
|
||||
"""Read the current rows as [{shortcut, language}] for config (#805)."""
|
||||
entries = []
|
||||
for refs in self._language_shortcut_rows:
|
||||
entries.append(
|
||||
{
|
||||
"shortcut": refs["shortcut_entry"].get_text(),
|
||||
"language": refs["language_picker"].get_active_id() or "auto",
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
def _persist_language_shortcuts(self) -> None:
|
||||
"""Write the rows to config and refresh the live listeners (#805).
|
||||
|
||||
A row mid-edit — empty, half-typed, or claiming a key another row
|
||||
already holds — would be dropped by config normalization, erasing its
|
||||
binding when the dialog closes. Such rows keep their last persisted
|
||||
binding instead, and a rejected duplicate is called out on the info
|
||||
label.
|
||||
"""
|
||||
if self._initializing:
|
||||
return
|
||||
entries = []
|
||||
rejected = []
|
||||
claimed = set()
|
||||
for refs in self._language_shortcut_rows:
|
||||
language = refs["language_picker"].get_active_id() or "auto"
|
||||
shortcut = refs["shortcut_entry"].get_text().strip().lower()
|
||||
last_valid = refs.get("last_valid_shortcut", "")
|
||||
if shortcut and is_valid_shortcut(shortcut) and shortcut not in claimed:
|
||||
refs["last_valid_shortcut"] = shortcut
|
||||
claimed.add(shortcut)
|
||||
elif last_valid and last_valid not in claimed:
|
||||
if shortcut and is_valid_shortcut(shortcut):
|
||||
rejected.append(shortcut)
|
||||
shortcut = last_valid
|
||||
claimed.add(shortcut)
|
||||
else:
|
||||
if shortcut and is_valid_shortcut(shortcut):
|
||||
rejected.append(shortcut)
|
||||
continue
|
||||
entries.append({"shortcut": shortcut, "language": language})
|
||||
self.config_manager.set_language_shortcuts(entries)
|
||||
self.config_manager.save_settings()
|
||||
if self.language_shortcuts_update_callback:
|
||||
self.language_shortcuts_update_callback()
|
||||
self._report_language_shortcut_rejections(rejected)
|
||||
|
||||
def _report_language_shortcut_rejections(self, rejected: list[str]) -> None:
|
||||
"""Surface dropped duplicate keys on the section's info label (#805)."""
|
||||
label = getattr(self, "language_shortcuts_info_label", None)
|
||||
if label is None:
|
||||
return
|
||||
if rejected:
|
||||
keys = ", ".join(sorted(set(rejected)))
|
||||
label.set_markup(
|
||||
f"<span foreground='#e01b24'>Shortcut "
|
||||
f"<b>{GLib.markup_escape_text(keys)}</b> is already bound on "
|
||||
"another row.</span>"
|
||||
)
|
||||
elif not getattr(self, "_recording_shortcut", False):
|
||||
label.set_text(
|
||||
"Language shortcuts take effect immediately and follow the " "shortcut mode above."
|
||||
)
|
||||
|
||||
def _is_preset_shortcut(self, shortcut: str) -> bool:
|
||||
"""Return True if shortcut is one of the built-in double-tap presets."""
|
||||
return shortcut in SUPPORTED_SHORTCUTS
|
||||
@@ -4636,20 +4902,46 @@ class SettingsDialog(Gtk.Dialog):
|
||||
|
||||
def _on_record_shortcut_clicked(self, widget):
|
||||
"""Begin capturing the next key combo pressed in the dialog."""
|
||||
self._begin_shortcut_recording(
|
||||
self.custom_shortcut_entry,
|
||||
self._apply_custom_shortcut,
|
||||
self.record_shortcut_button,
|
||||
self.shortcut_info_label,
|
||||
)
|
||||
|
||||
def _begin_shortcut_recording(
|
||||
self,
|
||||
entry: Gtk.Entry,
|
||||
apply_fn: Callable[[str], None],
|
||||
button: Gtk.Button,
|
||||
hint_label: Gtk.Label,
|
||||
) -> None:
|
||||
"""Arm key-capture for one shortcut target.
|
||||
|
||||
``apply_fn`` receives the captured shortcut string; ``hint_label`` gets
|
||||
the press-keys hint and any error/cancel messages for this capture.
|
||||
"""
|
||||
if self._recording_shortcut:
|
||||
# Only one capture can be armed: reset the previously armed
|
||||
# button instead of leaving two targets captioned "Press keys…".
|
||||
self._stop_recording_shortcut()
|
||||
self._recording_shortcut = True
|
||||
self.record_shortcut_button.set_label("Press keys…")
|
||||
self.shortcut_info_label.set_markup(
|
||||
self._recording_shortcut_target = (entry, apply_fn, button, hint_label)
|
||||
button.set_label("Press keys…")
|
||||
hint_label.set_markup(
|
||||
"<i>Press a modifier + key (e.g. Alt+R), or an F-key. Press Esc to cancel.</i>"
|
||||
)
|
||||
self._start_evdev_shortcut_recorder()
|
||||
|
||||
def _stop_recording_shortcut(self):
|
||||
"""Exit key-capture mode and restore the Record button."""
|
||||
def _stop_recording_shortcut(self) -> None:
|
||||
"""Exit key-capture mode and restore the armed Record button."""
|
||||
self._recording_shortcut = False
|
||||
self._stop_evdev_shortcut_recorder()
|
||||
if getattr(self, "record_shortcut_button", None) is not None:
|
||||
target = getattr(self, "_recording_shortcut_target", None)
|
||||
self._recording_shortcut_target = None
|
||||
if target is not None:
|
||||
try:
|
||||
self.record_shortcut_button.set_label("Record")
|
||||
target[2].set_label("Record")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -4677,9 +4969,13 @@ class SettingsDialog(Gtk.Dialog):
|
||||
return False
|
||||
if not shortcut or not is_valid_shortcut(shortcut):
|
||||
return False
|
||||
self.custom_shortcut_entry.set_text(shortcut)
|
||||
target = getattr(self, "_recording_shortcut_target", None)
|
||||
if target is None:
|
||||
return False
|
||||
entry, apply_fn, _button, _hint_label = target
|
||||
entry.set_text(shortcut)
|
||||
self._stop_recording_shortcut()
|
||||
self._apply_custom_shortcut(shortcut)
|
||||
apply_fn(shortcut)
|
||||
return True
|
||||
|
||||
def _on_evdev_recorded_shortcut(self, shortcut: str) -> None:
|
||||
@@ -4715,12 +5011,14 @@ class SettingsDialog(Gtk.Dialog):
|
||||
return True
|
||||
if not getattr(self, "_recording_shortcut", False):
|
||||
return True
|
||||
target = getattr(self, "_recording_shortcut_target", None)
|
||||
hint_label = target[3] if target is not None else self.shortcut_info_label
|
||||
if keyname == "Escape" and not shortcut:
|
||||
self._stop_recording_shortcut()
|
||||
self.shortcut_info_label.set_text("Recording cancelled.")
|
||||
hint_label.set_text("Recording cancelled.")
|
||||
return True
|
||||
|
||||
self.shortcut_info_label.set_markup(
|
||||
hint_label.set_markup(
|
||||
"<span foreground='#e01b24'>Need a modifier + key, or an F1–F24 "
|
||||
"function key alone. Try again or press Esc to cancel.</span>"
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import os
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Optional, Sequence, cast
|
||||
|
||||
import gi
|
||||
@@ -53,10 +54,15 @@ from ..utils.update_monitor import UpdateMonitor
|
||||
from ..utils.whispercpp_model_info import TDRZ_MODEL, WHISPERCPP_MODEL_INFO, is_model_downloaded
|
||||
from . import notifications
|
||||
from .config_manager import get_shared_config_manager
|
||||
from .keyboard_backends import DEFAULT_SHORTCUT, DEFAULT_SHORTCUT_MODE
|
||||
from .keyboard_backends import (
|
||||
DEFAULT_SHORTCUT,
|
||||
DEFAULT_SHORTCUT_MODE,
|
||||
SHORTCUT_MODES,
|
||||
shortcut_gestures_collide,
|
||||
)
|
||||
from .keyboard_shortcuts import KeyboardShortcutManager
|
||||
from .settings_dialog import ModelDownloadDialog, SettingsDialog, recommended_model_for_engine
|
||||
from .transcription_history import TranscriptionHistory
|
||||
from .transcription_history import DEFAULT_MAX_ITEMS, TranscriptionHistory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -125,6 +131,19 @@ class TrayIndicator:
|
||||
the speech recognition process.
|
||||
"""
|
||||
|
||||
# Tests build TrayIndicator stubs with __new__ that skip __init__; keep a
|
||||
# class-level default so teardown paths like _quit() still find the list.
|
||||
_language_shortcut_managers: list = []
|
||||
|
||||
#: Same for the push-to-talk owner slot: a __new__-built stub must answer
|
||||
#: "no binding owns the live session" instead of raising on release paths.
|
||||
_ptt_owner: Optional["KeyboardShortcutManager"] = None
|
||||
|
||||
#: Serializes push-to-talk press/release handling. Press and release
|
||||
#: callbacks run on separate backend threads, so a release landing while
|
||||
#: start_recognition is still running must not be lost (#805).
|
||||
_ptt_lock = threading.RLock()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
speech_engine: SpeechRecognitionManagerProtocol,
|
||||
@@ -168,6 +187,15 @@ class TrayIndicator:
|
||||
|
||||
# Initialize keyboard shortcut manager with configured shortcut and mode
|
||||
self.shortcut_manager = KeyboardShortcutManager(shortcut=shortcut, mode=mode)
|
||||
# One listener per bound language shortcut (#805); rebuilt by
|
||||
# _setup_language_shortcuts alongside the main one.
|
||||
self._language_shortcut_managers: list[KeyboardShortcutManager] = []
|
||||
# The binding whose press started the live push-to-talk session, so an
|
||||
# unrelated key's release cannot end it (#805).
|
||||
self._ptt_owner: Optional[KeyboardShortcutManager] = None
|
||||
# Serializes the press/release pair across backend threads, so a
|
||||
# release landing while start_recognition runs cannot be lost.
|
||||
self._ptt_lock = threading.RLock()
|
||||
|
||||
# Ensure icon directory exists
|
||||
os.makedirs(ICON_DIR, exist_ok=True)
|
||||
@@ -328,6 +356,9 @@ class TrayIndicator:
|
||||
# in over D-Bus (see VocalinuxDBusService).
|
||||
if self._external_activation_active():
|
||||
logger.info("Internal hotkey listener disabled (external activation via D-Bus)")
|
||||
# The whole internal listener family is off; per-language
|
||||
# listeners must not keep firing either.
|
||||
self._stop_language_shortcut_managers()
|
||||
return
|
||||
|
||||
# Get configured mode from config
|
||||
@@ -340,11 +371,109 @@ class TrayIndicator:
|
||||
elif mode == "push_to_talk":
|
||||
# Register press/release callbacks for push-to-talk mode
|
||||
self.shortcut_manager.register_press_callback(self._start_recognition)
|
||||
self.shortcut_manager.register_release_callback(self._stop_recognition)
|
||||
self.shortcut_manager.register_release_callback(self._release_main_push_to_talk)
|
||||
|
||||
# Start the keyboard shortcut manager
|
||||
self.shortcut_manager.start()
|
||||
|
||||
self._setup_language_shortcuts()
|
||||
|
||||
def _setup_language_shortcuts(self) -> None:
|
||||
"""(Re)build a listener per configured language shortcut (#805).
|
||||
|
||||
Each entry gets its own KeyboardShortcutManager because the backends
|
||||
open the input devices read-only and never grab them, so parallel
|
||||
listeners do not interfere. A binding whose gesture collides with the
|
||||
main shortcut or an earlier language binding is skipped: the same
|
||||
gesture cannot fire both.
|
||||
"""
|
||||
# Same stop-first protection as _setup_keyboard_shortcuts: rebuilding
|
||||
# below removes the release callback a held push-to-talk key needs to
|
||||
# end its session, so the hold must be ended before it can be lost.
|
||||
if self.speech_engine.state != RecognitionState.IDLE:
|
||||
logger.info("Stopping active recognition before rebuilding language shortcuts")
|
||||
self._stop_recognition()
|
||||
|
||||
self._stop_language_shortcut_managers()
|
||||
|
||||
mode = self.config_manager.get_str("shortcuts", "mode", DEFAULT_SHORTCUT_MODE)
|
||||
if mode not in SHORTCUT_MODES:
|
||||
# A hand-edited mode must not take down the whole shortcut setup
|
||||
# via a backend constructor's ValueError.
|
||||
logger.warning(f"Unknown shortcut mode {mode!r} for language shortcuts")
|
||||
mode = DEFAULT_SHORTCUT_MODE
|
||||
main_shortcut = (
|
||||
self.config_manager.get_str("shortcuts", "toggle_recognition", DEFAULT_SHORTCUT)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
|
||||
# Collisions are judged on the gestures the backends match, not the
|
||||
# strings: "ctrl+alt+r" and "alt+ctrl+r" name the same key press, and
|
||||
# "left_ctrl+left_ctrl" fires inside "ctrl+ctrl"'s key set.
|
||||
bound = [main_shortcut]
|
||||
for entry in self.config_manager.get_language_shortcuts():
|
||||
shortcut = entry["shortcut"]
|
||||
language = entry["language"]
|
||||
if any(shortcut_gestures_collide(shortcut, other) for other in bound):
|
||||
logger.warning(
|
||||
f"Skipping language shortcut {shortcut!r} for {language}: "
|
||||
"it shares its gesture with another binding"
|
||||
)
|
||||
continue
|
||||
bound.append(shortcut)
|
||||
|
||||
try:
|
||||
manager = KeyboardShortcutManager(shortcut=shortcut, mode=mode)
|
||||
except ValueError as exc:
|
||||
logger.warning(f"Skipping language shortcut {shortcut!r} for {language}: {exc}")
|
||||
continue
|
||||
if mode == "toggle":
|
||||
manager.register_toggle_callback(
|
||||
partial(self._toggle_recognition_in_language, language)
|
||||
)
|
||||
else:
|
||||
manager.register_press_callback(
|
||||
partial(self._start_recognition_in_language, language, manager)
|
||||
)
|
||||
manager.register_release_callback(
|
||||
partial(self._release_language_push_to_talk, manager)
|
||||
)
|
||||
manager.start()
|
||||
self._language_shortcut_managers.append(manager)
|
||||
logger.info(f"Language shortcut {shortcut} -> {language} armed ({mode} mode)")
|
||||
|
||||
def _apply_history_settings(self) -> None:
|
||||
"""Push the saved history preferences onto the live store (#805).
|
||||
|
||||
Toggling history off must stop retention immediately — not after a
|
||||
restart — and the snippets-keep limit applies to what's already held.
|
||||
"""
|
||||
if self.transcription_history is None:
|
||||
return
|
||||
enabled = self.config_manager.get_bool("history", "enabled", True)
|
||||
max_items = self.config_manager.get_int("history", "max_items", DEFAULT_MAX_ITEMS)
|
||||
self.transcription_history.set_max_items(max_items)
|
||||
self.transcription_history.set_enabled(enabled)
|
||||
|
||||
def _stop_language_shortcut_managers(self) -> None:
|
||||
"""Stop every per-language listener and drop the managers."""
|
||||
for manager in self._language_shortcut_managers:
|
||||
try:
|
||||
manager.stop()
|
||||
except Exception:
|
||||
logger.exception("Failed to stop a language shortcut manager")
|
||||
self._language_shortcut_managers = []
|
||||
|
||||
def refresh_language_shortcuts(self) -> None:
|
||||
"""Rebuild the per-language listeners after a settings change (#805)."""
|
||||
if self._external_activation_active():
|
||||
# The internal listener family is off; a binding edit must not
|
||||
# arm language listeners behind the external-activation setting.
|
||||
self._stop_language_shortcut_managers()
|
||||
return
|
||||
self._setup_language_shortcuts()
|
||||
|
||||
def _init_icons(self):
|
||||
"""Initialize the icon files for the tray indicator."""
|
||||
# Ensure icon directory exists
|
||||
@@ -558,17 +687,79 @@ class TrayIndicator:
|
||||
dialog.show()
|
||||
return False
|
||||
|
||||
def _toggle_recognition(self):
|
||||
def _toggle_recognition(self) -> None:
|
||||
"""Toggle the recognition state between IDLE and LISTENING."""
|
||||
if self.speech_engine.state == RecognitionState.IDLE:
|
||||
self.speech_engine.start_recognition()
|
||||
else:
|
||||
self.speech_engine.stop_recognition()
|
||||
with self._ptt_lock:
|
||||
self._ptt_owner = None
|
||||
if self.speech_engine.state == RecognitionState.IDLE:
|
||||
self.speech_engine.start_recognition()
|
||||
else:
|
||||
self.speech_engine.stop_recognition()
|
||||
|
||||
def _start_recognition(self):
|
||||
def _start_recognition(self) -> None:
|
||||
"""Start voice recognition (for push-to-talk mode)."""
|
||||
if self.speech_engine.state == RecognitionState.IDLE:
|
||||
self.speech_engine.start_recognition(mode="push_to_talk")
|
||||
self._push_to_talk_press(
|
||||
self.shortcut_manager,
|
||||
lambda: self.speech_engine.start_recognition(mode="push_to_talk"),
|
||||
)
|
||||
|
||||
def _push_to_talk_press(
|
||||
self, manager: Optional[KeyboardShortcutManager], start: Callable[[], bool]
|
||||
) -> None:
|
||||
"""Push-to-talk press shared by the main and per-language bindings (#805).
|
||||
|
||||
The release runs on another backend thread and used to be dropped when
|
||||
it landed between LISTENING and the owner assignment, leaving
|
||||
dictation running with the key already up. Holding the lock through
|
||||
the whole start makes the release wait instead: it then finds the
|
||||
owner and ends the session normally.
|
||||
"""
|
||||
with self._ptt_lock:
|
||||
if self.speech_engine.state == RecognitionState.IDLE and start():
|
||||
self._ptt_owner = manager
|
||||
|
||||
def _push_to_talk_release(self, manager: KeyboardShortcutManager) -> None:
|
||||
"""Push-to-talk release: end the session only when this binding owns it."""
|
||||
with self._ptt_lock:
|
||||
if self._ptt_owner is manager:
|
||||
self._stop_recognition()
|
||||
|
||||
def _release_main_push_to_talk(self) -> None:
|
||||
"""End a push-to-talk session only when the main binding started it (#805)."""
|
||||
self._push_to_talk_release(self.shortcut_manager)
|
||||
|
||||
def _release_language_push_to_talk(self, manager: KeyboardShortcutManager) -> None:
|
||||
"""End a push-to-talk session only when this binding started it (#805).
|
||||
|
||||
Sharing one stop callback across listeners let an unrelated binding's
|
||||
release end a session it never started.
|
||||
"""
|
||||
self._push_to_talk_release(manager)
|
||||
|
||||
def _toggle_recognition_in_language(self, language: str) -> None:
|
||||
"""Toggle-style trigger for a per-language shortcut (#805).
|
||||
|
||||
While idle it starts a one-shot dictation in ``language``; while
|
||||
dictating it stops, matching the main toggle's behaviour regardless
|
||||
of which language the current utterance is in.
|
||||
"""
|
||||
with self._ptt_lock:
|
||||
self._ptt_owner = None
|
||||
if self.speech_engine.state == RecognitionState.IDLE:
|
||||
self.speech_engine.start_recognition_with_language(language)
|
||||
else:
|
||||
self.speech_engine.stop_recognition()
|
||||
|
||||
def _start_recognition_in_language(
|
||||
self, language: str, manager: Optional[KeyboardShortcutManager] = None
|
||||
) -> None:
|
||||
"""Push-to-talk start for a per-language shortcut (#805)."""
|
||||
self._push_to_talk_press(
|
||||
manager,
|
||||
lambda: self.speech_engine.start_recognition_with_language(
|
||||
language, mode="push_to_talk"
|
||||
),
|
||||
)
|
||||
|
||||
def _external_start(self) -> None:
|
||||
"""Start recognition for an external (D-Bus) trigger.
|
||||
@@ -577,18 +768,24 @@ class TrayIndicator:
|
||||
`vocalinux --start` transcribes immediately/with silence detection
|
||||
rather than deferring until a Stop.
|
||||
"""
|
||||
if self.speech_engine.state == RecognitionState.IDLE:
|
||||
self.speech_engine.start_recognition()
|
||||
with self._ptt_lock:
|
||||
self._ptt_owner = None
|
||||
if self.speech_engine.state == RecognitionState.IDLE:
|
||||
self.speech_engine.start_recognition()
|
||||
|
||||
def _external_stop(self) -> None:
|
||||
"""Stop recognition for an external (D-Bus) trigger."""
|
||||
if self.speech_engine.state != RecognitionState.IDLE:
|
||||
self.speech_engine.stop_recognition()
|
||||
with self._ptt_lock:
|
||||
self._ptt_owner = None
|
||||
if self.speech_engine.state != RecognitionState.IDLE:
|
||||
self.speech_engine.stop_recognition()
|
||||
|
||||
def _stop_recognition(self):
|
||||
def _stop_recognition(self) -> None:
|
||||
"""Stop voice recognition (for push-to-talk mode)."""
|
||||
if self.speech_engine.state != RecognitionState.IDLE:
|
||||
self.speech_engine.stop_recognition()
|
||||
with self._ptt_lock:
|
||||
self._ptt_owner = None
|
||||
if self.speech_engine.state != RecognitionState.IDLE:
|
||||
self.speech_engine.stop_recognition()
|
||||
|
||||
def _add_menu_item(self, label: str, callback: Callable):
|
||||
"""
|
||||
@@ -1159,6 +1356,8 @@ class TrayIndicator:
|
||||
),
|
||||
overlay_enabled_callback=self.set_overlay_enabled,
|
||||
hotkey_listener_update_callback=self._setup_keyboard_shortcuts,
|
||||
language_shortcuts_update_callback=self.refresh_language_shortcuts,
|
||||
history_update_callback=self._apply_history_settings,
|
||||
)
|
||||
dialog.connect("response", self._on_settings_dialog_response)
|
||||
dialog.connect("destroy", self._on_settings_dialog_destroyed)
|
||||
@@ -1552,8 +1751,9 @@ class TrayIndicator:
|
||||
|
||||
self._cleanup_input_monitor()
|
||||
|
||||
# Stop the keyboard shortcut manager
|
||||
# Stop the keyboard shortcut managers
|
||||
self.shortcut_manager.stop()
|
||||
self._stop_language_shortcut_managers()
|
||||
|
||||
if getattr(self, "overlay", None) is not None:
|
||||
self.overlay.destroy()
|
||||
|
||||
@@ -0,0 +1,911 @@
|
||||
"""Per-language dictation shortcuts: config schema, one-shot engine override,
|
||||
tray listeners, and settings-row plumbing (#805)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import vocalinux.ui
|
||||
from vocalinux.common_types import RecognitionState
|
||||
from vocalinux.ui.config_manager import ConfigManager, normalize_language_shortcuts
|
||||
from vocalinux.utils.vosk_model_info import SUPPORTED_LANGUAGES
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def settings_dialog() -> Iterator[Any]:
|
||||
"""Import settings_dialog with real bases for its GTK subclasses.
|
||||
|
||||
Same approach as tests/test_follow_keyboard_layout.py; both sys.modules
|
||||
and the package attribute are restored so later tests patching the module
|
||||
by name do not reach a second module object (#686).
|
||||
"""
|
||||
repository = sys.modules["gi.repository"]
|
||||
bases = {name: type(name, (), {}) for name in ("Box", "ListBoxRow", "Dialog")}
|
||||
saved_module = sys.modules.pop("vocalinux.ui.settings_dialog", None)
|
||||
saved_attribute = getattr(vocalinux.ui, "settings_dialog", None)
|
||||
try:
|
||||
with patch.object(repository, "Gtk", MagicMock(**bases)):
|
||||
module = importlib.import_module("vocalinux.ui.settings_dialog")
|
||||
yield module
|
||||
finally:
|
||||
sys.modules.pop("vocalinux.ui.settings_dialog", None)
|
||||
if saved_module is not None:
|
||||
sys.modules["vocalinux.ui.settings_dialog"] = saved_module
|
||||
if saved_attribute is not None:
|
||||
vocalinux.ui.settings_dialog = saved_attribute
|
||||
elif hasattr(vocalinux.ui, "settings_dialog"):
|
||||
del vocalinux.ui.settings_dialog
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dialog_class(settings_dialog: Any) -> type[Any]:
|
||||
return settings_dialog.SettingsDialog
|
||||
|
||||
|
||||
# --- config normalization ---------------------------------------------------
|
||||
|
||||
|
||||
def test_normalize_language_shortcuts_keeps_valid_bindings() -> None:
|
||||
entries = normalize_language_shortcuts(
|
||||
[
|
||||
{"shortcut": "alt+d", "language": "de"},
|
||||
{"shortcut": "ctrl+alt+g", "language": "hu"},
|
||||
{"shortcut": "f5", "language": "auto"},
|
||||
]
|
||||
)
|
||||
assert entries == [
|
||||
{"shortcut": "alt+d", "language": "de"},
|
||||
{"shortcut": "ctrl+alt+g", "language": "hu"},
|
||||
{"shortcut": "f5", "language": "auto"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", [None, "alt+d", 42, {"shortcut": "alt+d"}])
|
||||
def test_normalize_language_shortcuts_rejects_non_lists(raw: Any) -> None:
|
||||
assert normalize_language_shortcuts(raw) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"item",
|
||||
[
|
||||
"alt+d", # not a mapping
|
||||
{"shortcut": "alt+d"}, # missing language
|
||||
{"language": "de"}, # missing shortcut
|
||||
{"shortcut": 5, "language": "de"}, # wrong types
|
||||
{"shortcut": "alt+d", "language": "klingon"}, # not a catalog id
|
||||
{"shortcut": "alt", "language": "de"}, # bare modifier, unbindable
|
||||
{"shortcut": "", "language": "de"}, # empty shortcut
|
||||
{"shortcut": "alt+d", "language": "layout"}, # sentinel, not a language
|
||||
],
|
||||
)
|
||||
def test_normalize_language_shortcuts_drops_bad_entries(item: Any) -> None:
|
||||
assert normalize_language_shortcuts([item]) == []
|
||||
|
||||
|
||||
def test_normalize_language_shortcuts_dedupes_by_shortcut() -> None:
|
||||
"""The first binding wins when two rows claim the same key."""
|
||||
entries = normalize_language_shortcuts(
|
||||
[
|
||||
{"shortcut": "alt+d", "language": "de"},
|
||||
{"shortcut": "alt+d", "language": "fr"},
|
||||
]
|
||||
)
|
||||
assert entries == [{"shortcut": "alt+d", "language": "de"}]
|
||||
|
||||
|
||||
def test_language_shortcuts_roundtrip_through_config(tmp_path: Any) -> None:
|
||||
config_dir = tmp_path / "vocalinux"
|
||||
config_dir.mkdir()
|
||||
config_file = config_dir / "config.json"
|
||||
with (
|
||||
patch("vocalinux.ui.config_manager.CONFIG_DIR", str(config_dir)),
|
||||
patch("vocalinux.ui.config_manager.CONFIG_FILE", str(config_file)),
|
||||
patch("vocalinux.utils.system_language.detect_system_language", return_value=None),
|
||||
):
|
||||
manager = ConfigManager()
|
||||
assert manager.get_language_shortcuts() == []
|
||||
|
||||
manager.set_language_shortcuts(
|
||||
[
|
||||
{"shortcut": "alt+d", "language": "de"},
|
||||
{"shortcut": "broken", "language": "fr"}, # dropped by normalize
|
||||
]
|
||||
)
|
||||
assert manager.config["shortcuts"]["language_shortcuts"] == [
|
||||
{"shortcut": "alt+d", "language": "de"}
|
||||
]
|
||||
assert manager.get_language_shortcuts() == [{"shortcut": "alt+d", "language": "de"}]
|
||||
|
||||
# Persisted to disk.
|
||||
manager.save_config()
|
||||
reloaded = ConfigManager()
|
||||
assert reloaded.get_language_shortcuts() == [{"shortcut": "alt+d", "language": "de"}]
|
||||
|
||||
|
||||
def test_get_language_shortcuts_survives_a_broken_shortcuts_section() -> None:
|
||||
"""A hand-edited config replacing the section with a scalar reads as empty."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
config_dir = os.path.join(temp_dir, "vocalinux")
|
||||
config_file = os.path.join(config_dir, "config.json")
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
with open(config_file, "w") as handle:
|
||||
handle.write('{"shortcuts": "oops"}')
|
||||
with (
|
||||
patch("vocalinux.ui.config_manager.CONFIG_DIR", config_dir),
|
||||
patch("vocalinux.ui.config_manager.CONFIG_FILE", config_file),
|
||||
patch(
|
||||
"vocalinux.utils.system_language.detect_system_language",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
manager = ConfigManager()
|
||||
assert manager.get_language_shortcuts() == []
|
||||
manager.set_language_shortcuts([{"shortcut": "alt+d", "language": "de"}])
|
||||
assert manager.get_language_shortcuts() == [{"shortcut": "alt+d", "language": "de"}]
|
||||
|
||||
|
||||
# --- one-shot language override on the manager ------------------------------
|
||||
|
||||
|
||||
def _manager_stub(
|
||||
engine: str = "whisper_cpp",
|
||||
model_size: str = "small",
|
||||
language: str = "en-us",
|
||||
language_preference: str = "en-us",
|
||||
) -> Any:
|
||||
"""A manager carrying only the fields the one-shot path touches."""
|
||||
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
||||
|
||||
manager = SpeechRecognitionManager.__new__(SpeechRecognitionManager)
|
||||
manager.engine = engine
|
||||
manager.model_size = model_size
|
||||
manager.language = language
|
||||
manager.language_preference = language_preference
|
||||
manager.state = RecognitionState.IDLE
|
||||
manager.state_callbacks = []
|
||||
manager._faster_whisper_engine = None
|
||||
manager.command_processor = MagicMock()
|
||||
manager._pending_language_override = None
|
||||
manager._oneshot_language_restore = None
|
||||
manager._segment_started_at = None
|
||||
return manager
|
||||
|
||||
|
||||
def test_language_shortcut_starts_dictation_in_that_language() -> None:
|
||||
import vocalinux.speech_recognition.recognition_manager as rm
|
||||
|
||||
manager = _manager_stub()
|
||||
manager.start_recognition = MagicMock(
|
||||
side_effect=lambda mode="toggle": (manager._apply_pending_language_override(), True)[1]
|
||||
)
|
||||
|
||||
assert manager.start_recognition_with_language("de") is True
|
||||
manager.start_recognition.assert_called_once_with(mode="toggle")
|
||||
assert manager.language == "de"
|
||||
assert manager.command_processor.set_language.call_args.args == ("de",)
|
||||
# Consumed: a later plain start must not see the override.
|
||||
assert manager._pending_language_override is None
|
||||
assert manager._oneshot_language_restore == "en-us"
|
||||
|
||||
|
||||
def test_language_shortcut_wins_over_follow_layout() -> None:
|
||||
"""An explicit key press outranks the follow-mode resolution (#821)."""
|
||||
import vocalinux.speech_recognition.recognition_manager as rm
|
||||
|
||||
manager = _manager_stub(language_preference="layout")
|
||||
|
||||
def fake_start(mode: str = "toggle") -> bool:
|
||||
manager._refresh_language_from_layout()
|
||||
manager._apply_pending_language_override()
|
||||
return True
|
||||
|
||||
manager.start_recognition = MagicMock(side_effect=fake_start)
|
||||
with patch.object(rm, "language_for_active_layout", return_value="fr"):
|
||||
assert manager.start_recognition_with_language("de") is True
|
||||
|
||||
assert manager.language == "de"
|
||||
assert manager._oneshot_language_restore == "fr"
|
||||
manager._update_state(RecognitionState.IDLE)
|
||||
assert manager.language == "fr"
|
||||
|
||||
|
||||
def test_language_is_restored_when_dictation_ends() -> None:
|
||||
"""The configured language comes back on the IDLE transition."""
|
||||
manager = _manager_stub()
|
||||
manager._oneshot_language_restore = "en-us"
|
||||
manager.language = "de"
|
||||
|
||||
manager._update_state(RecognitionState.IDLE)
|
||||
|
||||
assert manager.language == "en-us"
|
||||
assert manager._oneshot_language_restore is None
|
||||
manager.command_processor.set_language.assert_called_with("en-us")
|
||||
|
||||
|
||||
def test_language_is_restored_on_error_too() -> None:
|
||||
manager = _manager_stub()
|
||||
manager._oneshot_language_restore = "en-us"
|
||||
manager.language = "de"
|
||||
|
||||
manager._update_state(RecognitionState.ERROR)
|
||||
|
||||
assert manager.language == "en-us"
|
||||
assert manager._oneshot_language_restore is None
|
||||
|
||||
|
||||
def test_shortcut_language_matching_current_language_leaves_no_restore() -> None:
|
||||
"""No-op overrides never register a restore."""
|
||||
manager = _manager_stub(language="de")
|
||||
manager._pending_language_override = "de"
|
||||
|
||||
manager._apply_pending_language_override()
|
||||
|
||||
assert manager.language == "de"
|
||||
assert manager._oneshot_language_restore is None
|
||||
manager.command_processor.set_language.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("engine,model_size", [("vosk", "small"), ("parakeet", "turbo")])
|
||||
def test_unsupported_engine_refuses_instead_of_dictating_wrong(
|
||||
engine: str, model_size: str
|
||||
) -> None:
|
||||
"""VOSK/Parakeet cannot honour a per-utterance language; refuse loudly."""
|
||||
import vocalinux.speech_recognition.recognition_manager as rm
|
||||
|
||||
manager = _manager_stub(engine=engine, model_size=model_size, language="en-us")
|
||||
manager.start_recognition = MagicMock(return_value=True)
|
||||
|
||||
with (
|
||||
patch.object(rm, "play_error_sound") as mock_error,
|
||||
patch.object(rm, "_show_notification") as mock_notify,
|
||||
):
|
||||
assert manager.start_recognition_with_language("de") is False
|
||||
|
||||
manager.start_recognition.assert_not_called()
|
||||
mock_error.assert_called_once()
|
||||
mock_notify.assert_called_once()
|
||||
assert manager.language == "en-us"
|
||||
assert manager._pending_language_override is None
|
||||
|
||||
|
||||
def test_english_only_whispercpp_model_refuses_the_shortcut() -> None:
|
||||
"""small.en has no non-English weights to aim the shortcut at."""
|
||||
import vocalinux.speech_recognition.recognition_manager as rm
|
||||
|
||||
manager = _manager_stub(model_size="small.en")
|
||||
manager.start_recognition = MagicMock(return_value=True)
|
||||
|
||||
with (
|
||||
patch.object(rm, "play_error_sound") as mock_error,
|
||||
patch.object(rm, "_show_notification") as mock_notify,
|
||||
):
|
||||
assert manager.start_recognition_with_language("de") is False
|
||||
|
||||
manager.start_recognition.assert_not_called()
|
||||
mock_error.assert_called_once()
|
||||
mock_notify.assert_called_once()
|
||||
|
||||
|
||||
def test_same_language_shortcut_runs_on_english_only_model() -> None:
|
||||
"""No language switch is needed to dictate in the model's own language."""
|
||||
import vocalinux.speech_recognition.recognition_manager as rm
|
||||
|
||||
manager = _manager_stub(model_size="small.en", language="en-us")
|
||||
manager.start_recognition = MagicMock(return_value=True)
|
||||
|
||||
with patch.object(rm, "_show_notification") as mock_notify:
|
||||
assert manager.start_recognition_with_language("en-us") is True
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
|
||||
|
||||
def test_pending_override_is_consumed_even_when_start_is_refused() -> None:
|
||||
"""A refused start (auto-pause, model missing) must not leak the override."""
|
||||
manager = _manager_stub()
|
||||
manager.start_recognition = MagicMock(return_value=False)
|
||||
|
||||
assert manager.start_recognition_with_language("de") is False
|
||||
assert manager._pending_language_override is None
|
||||
assert manager._oneshot_language_restore is None
|
||||
|
||||
|
||||
def test_reconfiguring_the_language_clears_a_pending_restore() -> None:
|
||||
"""A mid-dictation language change must not be undone by the restore."""
|
||||
manager = _manager_stub()
|
||||
manager._oneshot_language_restore = "en-us"
|
||||
manager.language = "de"
|
||||
manager._voice_commands_preference = None
|
||||
manager._snapshot_reconfigure_state = MagicMock(return_value={})
|
||||
|
||||
manager.reconfigure(language="fr")
|
||||
|
||||
assert manager._oneshot_language_restore is None
|
||||
assert manager.language == "fr"
|
||||
|
||||
|
||||
# --- tray wiring ------------------------------------------------------------
|
||||
|
||||
|
||||
def _tray_stub(shortcut_mode: str = "toggle", entries: Any = None) -> Any:
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
tray = TrayIndicator.__new__(TrayIndicator)
|
||||
tray.config_manager = MagicMock()
|
||||
config_values = {
|
||||
"mode": shortcut_mode,
|
||||
"toggle_recognition": "right_alt+right_alt",
|
||||
}
|
||||
tray.config_manager.get_str.side_effect = lambda section, key, default="": config_values.get(
|
||||
key, default
|
||||
)
|
||||
tray.config_manager.get_language_shortcuts.return_value = entries or []
|
||||
tray.speech_engine = MagicMock()
|
||||
tray.speech_engine.state = RecognitionState.IDLE
|
||||
tray._language_shortcut_managers = []
|
||||
# D-Bus activation unavailable -> the internal listener family is on,
|
||||
# which is what these tests exercise.
|
||||
tray._external_activation_unavailable = True
|
||||
return tray
|
||||
|
||||
|
||||
def test_tray_builds_one_listener_per_binding() -> None:
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
tray = _tray_stub(
|
||||
entries=[
|
||||
{"shortcut": "alt+d", "language": "de"},
|
||||
{"shortcut": "ctrl+alt+g", "language": "hu"},
|
||||
]
|
||||
)
|
||||
with patch("vocalinux.ui.tray_indicator.KeyboardShortcutManager") as manager_class:
|
||||
created = [MagicMock(), MagicMock()]
|
||||
manager_class.side_effect = created
|
||||
|
||||
TrayIndicator._setup_language_shortcuts(tray)
|
||||
|
||||
assert manager_class.call_count == 2
|
||||
manager_class.assert_any_call(shortcut="alt+d", mode="toggle")
|
||||
manager_class.assert_any_call(shortcut="ctrl+alt+g", mode="toggle")
|
||||
assert tray._language_shortcut_managers == created
|
||||
for manager in created:
|
||||
manager.register_toggle_callback.assert_called_once()
|
||||
manager.start.assert_called_once()
|
||||
|
||||
|
||||
def test_tray_skips_bindings_that_collide() -> None:
|
||||
"""A language shortcut equal to the main one cannot fire both."""
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
tray = _tray_stub(
|
||||
entries=[
|
||||
{"shortcut": "right_alt+right_alt", "language": "de"}, # main
|
||||
{"shortcut": "alt+d", "language": "de"},
|
||||
{"shortcut": "alt+d", "language": "fr"}, # duplicate of previous
|
||||
]
|
||||
)
|
||||
with patch("vocalinux.ui.tray_indicator.KeyboardShortcutManager") as manager_class:
|
||||
TrayIndicator._setup_language_shortcuts(tray)
|
||||
|
||||
manager_class.assert_called_once_with(shortcut="alt+d", mode="toggle")
|
||||
|
||||
|
||||
def test_tray_language_managers_follow_push_to_talk() -> None:
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
tray = _tray_stub(
|
||||
shortcut_mode="push_to_talk",
|
||||
entries=[{"shortcut": "alt+d", "language": "de"}],
|
||||
)
|
||||
with patch("vocalinux.ui.tray_indicator.KeyboardShortcutManager") as manager_class:
|
||||
manager = MagicMock()
|
||||
manager_class.return_value = manager
|
||||
|
||||
TrayIndicator._setup_language_shortcuts(tray)
|
||||
|
||||
manager_class.assert_called_once_with(shortcut="alt+d", mode="push_to_talk")
|
||||
manager.register_press_callback.assert_called_once()
|
||||
# The release is routed through the owner-gated helper for this binding,
|
||||
# not a stop callback shared by every listener.
|
||||
release_callback = manager.register_release_callback.call_args.args[0]
|
||||
assert release_callback.func == tray._release_language_push_to_talk
|
||||
assert release_callback.args == (manager,)
|
||||
|
||||
|
||||
def test_tray_toggle_in_language_starts_a_one_shot() -> None:
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
tray = _tray_stub()
|
||||
tray.speech_engine.state = RecognitionState.IDLE
|
||||
|
||||
TrayIndicator._toggle_recognition_in_language(tray, "de")
|
||||
|
||||
tray.speech_engine.start_recognition_with_language.assert_called_once_with("de")
|
||||
|
||||
|
||||
def test_tray_toggle_in_language_stops_while_dictating() -> None:
|
||||
"""Same as the main toggle: a press while dictating ends it."""
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
tray = _tray_stub()
|
||||
tray.speech_engine.state = RecognitionState.LISTENING
|
||||
|
||||
TrayIndicator._toggle_recognition_in_language(tray, "de")
|
||||
|
||||
tray.speech_engine.stop_recognition.assert_called_once()
|
||||
tray.speech_engine.start_recognition_with_language.assert_not_called()
|
||||
|
||||
|
||||
def test_tray_push_to_talk_in_language_starts_a_one_shot() -> None:
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
tray = _tray_stub()
|
||||
TrayIndicator._start_recognition_in_language(tray, "de")
|
||||
|
||||
tray.speech_engine.start_recognition_with_language.assert_called_once_with(
|
||||
"de", mode="push_to_talk"
|
||||
)
|
||||
|
||||
|
||||
def test_tray_language_release_stops_only_its_own_session() -> None:
|
||||
"""Another binding's release must not end a hold it did not start."""
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
tray = _tray_stub(shortcut_mode="push_to_talk")
|
||||
german, french = MagicMock(), MagicMock()
|
||||
tray.speech_engine.state = RecognitionState.LISTENING
|
||||
tray._ptt_owner = german
|
||||
|
||||
TrayIndicator._release_language_push_to_talk(tray, french)
|
||||
tray.speech_engine.stop_recognition.assert_not_called()
|
||||
|
||||
TrayIndicator._release_language_push_to_talk(tray, german)
|
||||
tray.speech_engine.stop_recognition.assert_called_once()
|
||||
assert tray._ptt_owner is None
|
||||
|
||||
|
||||
def test_tray_ptt_release_during_start_still_ends_the_session() -> None:
|
||||
"""A release landing mid-start waits for the owner claim, then stops (#805)."""
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
tray = _tray_stub(shortcut_mode="push_to_talk")
|
||||
german = MagicMock()
|
||||
tray.speech_engine.state = RecognitionState.IDLE
|
||||
|
||||
def fake_start(*_args: Any, **_kwargs: Any) -> bool:
|
||||
tray.speech_engine.state = RecognitionState.LISTENING
|
||||
releaser.start()
|
||||
return True
|
||||
|
||||
releaser = threading.Thread(
|
||||
target=TrayIndicator._release_language_push_to_talk, args=(tray, german)
|
||||
)
|
||||
tray.speech_engine.start_recognition_with_language.side_effect = fake_start
|
||||
|
||||
TrayIndicator._start_recognition_in_language(tray, "de", german)
|
||||
releaser.join(timeout=5)
|
||||
|
||||
tray.speech_engine.stop_recognition.assert_called_once()
|
||||
assert tray._ptt_owner is None
|
||||
|
||||
|
||||
def test_tray_main_ptt_release_during_start_still_ends_the_session() -> None:
|
||||
"""The main binding has the same deferred-release guarantee (#805)."""
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
tray = _tray_stub(shortcut_mode="push_to_talk")
|
||||
tray.shortcut_manager = MagicMock()
|
||||
tray.speech_engine.state = RecognitionState.IDLE
|
||||
|
||||
def fake_start(*_args: Any, **_kwargs: Any) -> bool:
|
||||
tray.speech_engine.state = RecognitionState.LISTENING
|
||||
releaser.start()
|
||||
return True
|
||||
|
||||
releaser = threading.Thread(target=TrayIndicator._release_main_push_to_talk, args=(tray,))
|
||||
tray.speech_engine.start_recognition.side_effect = fake_start
|
||||
|
||||
TrayIndicator._start_recognition(tray)
|
||||
releaser.join(timeout=5)
|
||||
|
||||
tray.speech_engine.stop_recognition.assert_called_once()
|
||||
assert tray._ptt_owner is None
|
||||
|
||||
|
||||
def test_tray_skips_gesture_equivalent_bindings() -> None:
|
||||
"""Different spellings of the same gesture still collide."""
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
tray = _tray_stub(
|
||||
entries=[
|
||||
{"shortcut": "ctrl+alt+r", "language": "de"},
|
||||
{"shortcut": "alt+ctrl+r", "language": "fr"}, # reordered, same gesture
|
||||
{"shortcut": "ctrl+enter", "language": "es"},
|
||||
{"shortcut": "alt+return", "language": "hu"}, # enter/return alias
|
||||
]
|
||||
)
|
||||
with patch("vocalinux.ui.tray_indicator.KeyboardShortcutManager") as manager_class:
|
||||
TrayIndicator._setup_language_shortcuts(tray)
|
||||
|
||||
armed = [call.kwargs["shortcut"] for call in manager_class.call_args_list]
|
||||
assert armed == ["ctrl+alt+r", "ctrl+enter"]
|
||||
|
||||
|
||||
def test_tray_skips_pure_modifier_gesture_overlap() -> None:
|
||||
"""A one-sided hold fires inside the both-sides binding's key set."""
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
tray = _tray_stub(
|
||||
entries=[
|
||||
{"shortcut": "left_ctrl+left_ctrl", "language": "de"},
|
||||
{"shortcut": "ctrl+ctrl", "language": "fr"}, # left ctrl fires both
|
||||
{"shortcut": "right_ctrl+right_ctrl", "language": "es"}, # other side
|
||||
]
|
||||
)
|
||||
with patch("vocalinux.ui.tray_indicator.KeyboardShortcutManager") as manager_class:
|
||||
TrayIndicator._setup_language_shortcuts(tray)
|
||||
|
||||
armed = [call.kwargs["shortcut"] for call in manager_class.call_args_list]
|
||||
assert armed == ["left_ctrl+left_ctrl", "right_ctrl+right_ctrl"]
|
||||
|
||||
|
||||
def test_refresh_does_not_rearm_under_external_activation() -> None:
|
||||
"""A binding edit must not arm listeners while internal hotkeys are off."""
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
tray = _tray_stub(entries=[{"shortcut": "alt+d", "language": "de"}])
|
||||
tray._external_activation_unavailable = False
|
||||
tray.config_manager.get_bool.return_value = True # disable_internal_hotkey
|
||||
|
||||
with patch("vocalinux.ui.tray_indicator.KeyboardShortcutManager") as manager_class:
|
||||
TrayIndicator.refresh_language_shortcuts(tray)
|
||||
|
||||
manager_class.assert_not_called()
|
||||
assert tray._language_shortcut_managers == []
|
||||
|
||||
|
||||
def test_refresh_stops_a_held_push_to_talk_first() -> None:
|
||||
"""Rebuilding listeners cannot strand a session waiting on its release."""
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
tray = _tray_stub(
|
||||
shortcut_mode="push_to_talk",
|
||||
entries=[{"shortcut": "alt+d", "language": "de"}],
|
||||
)
|
||||
tray.speech_engine.state = RecognitionState.LISTENING
|
||||
|
||||
with patch("vocalinux.ui.tray_indicator.KeyboardShortcutManager"):
|
||||
TrayIndicator.refresh_language_shortcuts(tray)
|
||||
|
||||
tray.speech_engine.stop_recognition.assert_called_once()
|
||||
|
||||
|
||||
def test_refresh_rebuilds_the_language_listeners() -> None:
|
||||
"""Settings changes must reach the live listeners immediately."""
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
tray = _tray_stub(entries=[{"shortcut": "alt+d", "language": "de"}])
|
||||
with patch("vocalinux.ui.tray_indicator.KeyboardShortcutManager") as manager_class:
|
||||
old = MagicMock()
|
||||
manager_class.return_value = old
|
||||
TrayIndicator._setup_language_shortcuts(tray)
|
||||
|
||||
tray.config_manager.get_language_shortcuts.return_value = [
|
||||
{"shortcut": "alt+d", "language": "de"},
|
||||
{"shortcut": "ctrl+alt+f", "language": "fr"},
|
||||
]
|
||||
manager_class.side_effect = [MagicMock(), MagicMock()]
|
||||
|
||||
TrayIndicator.refresh_language_shortcuts(tray)
|
||||
|
||||
old.stop.assert_called_once()
|
||||
assert len(tray._language_shortcut_managers) == 2
|
||||
|
||||
|
||||
def test_stop_language_shortcut_managers_survives_a_bad_stop() -> None:
|
||||
"""One failing listener must not orphan the rest during teardown."""
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
tray = _tray_stub()
|
||||
bad, good = MagicMock(), MagicMock()
|
||||
bad.stop.side_effect = OSError("device vanished")
|
||||
tray._language_shortcut_managers = [bad, good]
|
||||
|
||||
TrayIndicator._stop_language_shortcut_managers(tray)
|
||||
|
||||
bad.stop.assert_called_once()
|
||||
good.stop.assert_called_once()
|
||||
assert tray._language_shortcut_managers == []
|
||||
|
||||
|
||||
def test_oneshot_restore_is_part_of_the_reconfigure_snapshot() -> None:
|
||||
"""Rollback must be able to revive the pending one-shot restore (#805)."""
|
||||
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
||||
|
||||
assert "_oneshot_language_restore" in SpeechRecognitionManager._RECONFIGURE_STATE_ATTRS
|
||||
|
||||
|
||||
def test_failed_reconfigure_settles_the_pending_oneshot_restore() -> None:
|
||||
"""Rollback revives the pending restore, then settles it before re-init."""
|
||||
manager = _manager_stub(language="de")
|
||||
manager._oneshot_language_restore = "en-us"
|
||||
manager._voice_commands_preference = None
|
||||
manager._defer_download = True
|
||||
manager._model_lock = threading.Lock()
|
||||
manager._snapshot_reconfigure_state = MagicMock(
|
||||
return_value={"language": "de", "_oneshot_language_restore": "en-us"}
|
||||
)
|
||||
manager._init_selected_engine = MagicMock(side_effect=[RuntimeError("boom"), None])
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
manager.reconfigure(language="fr", model_size="tiny")
|
||||
|
||||
assert manager.language == "en-us"
|
||||
assert manager._oneshot_language_restore is None
|
||||
|
||||
|
||||
def test_language_shortcut_allowed_when_layout_will_swap_model() -> None:
|
||||
"""Follow-layout can swap in the multilingual sibling the shortcut needs."""
|
||||
import vocalinux.speech_recognition.recognition_manager as rm
|
||||
|
||||
manager = _manager_stub(model_size="small.en", language_preference="layout")
|
||||
manager.start_recognition = MagicMock(return_value=True)
|
||||
|
||||
with (
|
||||
patch.object(rm, "is_model_downloaded", return_value=True),
|
||||
patch.object(rm, "_multilingual_sibling", return_value="small"),
|
||||
):
|
||||
assert manager.start_recognition_with_language("de") is True
|
||||
|
||||
manager.start_recognition.assert_called_once()
|
||||
|
||||
|
||||
def test_parakeet_auto_shortcut_is_still_allowed() -> None:
|
||||
"""Normalizing to auto is only a refusal when it drops the request."""
|
||||
manager = _manager_stub(engine="parakeet", model_size="turbo", language="auto")
|
||||
manager.start_recognition = MagicMock(return_value=True)
|
||||
|
||||
assert manager.start_recognition_with_language("auto") is True
|
||||
|
||||
|
||||
def test_dictation_language_tracks_the_session_snapshot() -> None:
|
||||
"""Workers read the session binding, not the live configured language."""
|
||||
manager = _manager_stub(language="en-us")
|
||||
manager._session_language = "de"
|
||||
assert manager._dictation_language() == "de"
|
||||
manager._session_language = None
|
||||
assert manager._dictation_language() == "en-us"
|
||||
|
||||
|
||||
def test_dictation_language_prefers_the_segment_snapshot() -> None:
|
||||
"""The stamped snapshot beats the session binding and the preference."""
|
||||
manager = _manager_stub(language="en-us")
|
||||
manager._session_language = "fr"
|
||||
assert manager._dictation_language("de") == "de"
|
||||
|
||||
|
||||
def test_stale_worker_keeps_the_segments_own_language() -> None:
|
||||
"""A worker draining after a newer start must not adopt its language."""
|
||||
manager = _manager_stub(language="en-us")
|
||||
manager._segment_queue = queue.Queue(maxsize=32)
|
||||
manager.should_record = False
|
||||
manager._session_language = "de"
|
||||
manager._process_audio_buffer = MagicMock()
|
||||
|
||||
manager._enqueue_audio_segment([b"audio"])
|
||||
# A newer dictation binds its language before the stale worker drains.
|
||||
manager._session_language = "fr"
|
||||
manager._signal_recognition_stop()
|
||||
manager._perform_recognition()
|
||||
|
||||
manager._process_audio_buffer.assert_called_once()
|
||||
args = manager._process_audio_buffer.call_args.args
|
||||
assert args[0] == [b"audio"]
|
||||
assert args[0].language == "de"
|
||||
assert args[1] == "de"
|
||||
|
||||
|
||||
# --- settings dialog plumbing ------------------------------------------------
|
||||
|
||||
|
||||
def _dialog_stub() -> Any:
|
||||
"""A dialog carrying only what the language-shortcut plumbing reads."""
|
||||
dialog = MagicMock()
|
||||
dialog._initializing = False
|
||||
dialog.config_manager = MagicMock()
|
||||
dialog.language_shortcuts_update_callback = MagicMock()
|
||||
dialog._language_shortcut_rows = []
|
||||
return dialog
|
||||
|
||||
|
||||
def test_collect_language_shortcuts_reads_every_row(dialog_class: type[Any]) -> None:
|
||||
dialog = _dialog_stub()
|
||||
picker, entry = MagicMock(), MagicMock()
|
||||
picker.get_active_id.return_value = "de"
|
||||
entry.get_text.return_value = "alt+d"
|
||||
dialog._language_shortcut_rows = [{"language_picker": picker, "shortcut_entry": entry}]
|
||||
|
||||
assert dialog_class._collect_language_shortcuts(dialog) == [
|
||||
{"shortcut": "alt+d", "language": "de"}
|
||||
]
|
||||
|
||||
|
||||
def test_persist_writes_config_and_refreshes_listeners(
|
||||
dialog_class: type[Any],
|
||||
) -> None:
|
||||
dialog = _dialog_stub()
|
||||
picker, entry = MagicMock(), MagicMock()
|
||||
picker.get_active_id.return_value = "de"
|
||||
entry.get_text.return_value = "alt+d"
|
||||
dialog._language_shortcut_rows = [{"language_picker": picker, "shortcut_entry": entry}]
|
||||
collected = dialog_class._collect_language_shortcuts(dialog)
|
||||
assert collected == [{"shortcut": "alt+d", "language": "de"}]
|
||||
dialog._collect_language_shortcuts = lambda: collected
|
||||
|
||||
dialog_class._persist_language_shortcuts(dialog)
|
||||
|
||||
dialog.config_manager.set_language_shortcuts.assert_called_once_with(
|
||||
[{"shortcut": "alt+d", "language": "de"}]
|
||||
)
|
||||
dialog.config_manager.save_settings.assert_called_once()
|
||||
dialog.language_shortcuts_update_callback.assert_called_once()
|
||||
|
||||
|
||||
def test_persist_is_a_no_op_during_dialog_build(
|
||||
dialog_class: type[Any],
|
||||
) -> None:
|
||||
"""Rows being populated must not write config or rebuild listeners."""
|
||||
dialog = _dialog_stub()
|
||||
dialog._initializing = True
|
||||
|
||||
dialog_class._persist_language_shortcuts(dialog)
|
||||
|
||||
dialog.config_manager.set_language_shortcuts.assert_not_called()
|
||||
dialog.language_shortcuts_update_callback.assert_not_called()
|
||||
|
||||
|
||||
def test_recorded_key_is_written_to_the_row_entry(
|
||||
dialog_class: type[Any],
|
||||
) -> None:
|
||||
"""The shared recorder must serve a language row, not just the main entry."""
|
||||
dialog = _dialog_stub()
|
||||
entry, apply_fn = MagicMock(), MagicMock()
|
||||
dialog._recording_shortcut = True
|
||||
dialog._recording_shortcut_target = (entry, apply_fn, MagicMock(), MagicMock())
|
||||
|
||||
assert dialog_class._commit_recorded_shortcut(dialog, "alt+d") is True
|
||||
|
||||
entry.set_text.assert_called_once_with("alt+d")
|
||||
apply_fn.assert_called_once_with("alt+d")
|
||||
|
||||
|
||||
def test_recording_stays_target_free_when_not_recording(
|
||||
dialog_class: type[Any],
|
||||
) -> None:
|
||||
dialog = _dialog_stub()
|
||||
dialog._recording_shortcut = False
|
||||
|
||||
assert dialog_class._commit_recorded_shortcut(dialog, "alt+d") is False
|
||||
|
||||
|
||||
def test_picker_change_retitles_the_row(dialog_class: type[Any]) -> None:
|
||||
"""The row title follows the picked language so search stays honest."""
|
||||
dialog = _dialog_stub()
|
||||
row = MagicMock()
|
||||
picker = MagicMock()
|
||||
picker.get_active_id.return_value = "de"
|
||||
refs = {"row": row}
|
||||
dialog._persist_language_shortcuts = lambda: dialog_class._persist_language_shortcuts(dialog)
|
||||
dialog._on_language_shortcut_row_changed = (
|
||||
lambda *a: dialog_class._on_language_shortcut_row_changed(dialog, *a)
|
||||
)
|
||||
|
||||
dialog_class._on_language_shortcut_picker_changed(dialog, refs, picker)
|
||||
|
||||
row.set_title.assert_called_once_with(SUPPORTED_LANGUAGES["de"]["name"])
|
||||
dialog.language_shortcuts_update_callback.assert_called_once()
|
||||
|
||||
|
||||
def test_persist_keeps_last_valid_binding_during_partial_edit(
|
||||
dialog_class: type[Any],
|
||||
) -> None:
|
||||
"""A half-typed key cannot erase the row's persisted binding."""
|
||||
dialog = _dialog_stub()
|
||||
picker, entry = MagicMock(), MagicMock()
|
||||
picker.get_active_id.return_value = "de"
|
||||
entry.get_text.return_value = "alt+" # mid-typing
|
||||
dialog._language_shortcut_rows = [
|
||||
{
|
||||
"language_picker": picker,
|
||||
"shortcut_entry": entry,
|
||||
"last_valid_shortcut": "alt+d",
|
||||
}
|
||||
]
|
||||
|
||||
dialog_class._persist_language_shortcuts(dialog)
|
||||
|
||||
dialog.config_manager.set_language_shortcuts.assert_called_once_with(
|
||||
[{"shortcut": "alt+d", "language": "de"}]
|
||||
)
|
||||
|
||||
|
||||
def test_persist_reports_a_rejected_duplicate(dialog_class: type[Any]) -> None:
|
||||
"""A row claiming a taken key keeps its last binding and says why."""
|
||||
dialog = _dialog_stub()
|
||||
first_picker, second_picker = MagicMock(), MagicMock()
|
||||
first_entry, second_entry = MagicMock(), MagicMock()
|
||||
first_picker.get_active_id.return_value = "de"
|
||||
second_picker.get_active_id.return_value = "fr"
|
||||
first_entry.get_text.return_value = "alt+d"
|
||||
second_entry.get_text.return_value = "alt+d"
|
||||
dialog._language_shortcut_rows = [
|
||||
{
|
||||
"language_picker": first_picker,
|
||||
"shortcut_entry": first_entry,
|
||||
"last_valid_shortcut": "alt+d",
|
||||
},
|
||||
{
|
||||
"language_picker": second_picker,
|
||||
"shortcut_entry": second_entry,
|
||||
"last_valid_shortcut": "alt+f",
|
||||
},
|
||||
]
|
||||
dialog._report_language_shortcut_rejections = (
|
||||
lambda rejected: dialog_class._report_language_shortcut_rejections(dialog, rejected)
|
||||
)
|
||||
|
||||
dialog_class._persist_language_shortcuts(dialog)
|
||||
|
||||
dialog.config_manager.set_language_shortcuts.assert_called_once_with(
|
||||
[
|
||||
{"shortcut": "alt+d", "language": "de"},
|
||||
{"shortcut": "alt+f", "language": "fr"},
|
||||
]
|
||||
)
|
||||
dialog.language_shortcuts_info_label.set_markup.assert_called_once()
|
||||
|
||||
|
||||
def test_removing_an_armed_row_cancels_recording(dialog_class: type[Any]) -> None:
|
||||
"""A capture aimed at a removed row is cancelled before it is destroyed."""
|
||||
dialog = _dialog_stub()
|
||||
entry, button = MagicMock(), MagicMock()
|
||||
refs = {
|
||||
"row": MagicMock(),
|
||||
"language_picker": MagicMock(),
|
||||
"shortcut_entry": entry,
|
||||
"record_button": button,
|
||||
}
|
||||
dialog._language_shortcut_rows = [refs]
|
||||
dialog._recording_shortcut = True
|
||||
dialog._recording_shortcut_target = (entry, MagicMock(), button, MagicMock())
|
||||
|
||||
dialog_class._on_remove_language_shortcut_clicked(dialog, refs, button)
|
||||
|
||||
dialog._stop_recording_shortcut.assert_called_once()
|
||||
|
||||
|
||||
def test_begin_recording_disarms_the_previous_target(dialog_class: type[Any]) -> None:
|
||||
"""Arming a second row resets the first row's 'Press keys…' button."""
|
||||
dialog = _dialog_stub()
|
||||
dialog._recording_shortcut = True
|
||||
dialog._recording_shortcut_target = (
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
)
|
||||
|
||||
dialog_class._begin_shortcut_recording(
|
||||
dialog, MagicMock(), MagicMock(), MagicMock(), MagicMock()
|
||||
)
|
||||
|
||||
dialog._stop_recording_shortcut.assert_called_once()
|
||||
@@ -2175,15 +2175,16 @@ class TestEnqueueAudioSegment(unittest.TestCase):
|
||||
buf = [b"\x00\x01", b"\x02\x03"]
|
||||
manager._enqueue_audio_segment(buf)
|
||||
assert not manager._segment_queue.empty()
|
||||
assert manager._segment_queue.get_nowait() == buf
|
||||
# Segments carry the session language they were recorded under (#805).
|
||||
assert manager._segment_queue.get_nowait() == (buf, "en-us")
|
||||
|
||||
def test_enqueue_drops_oldest_when_queue_full(self):
|
||||
"""When queue is full, oldest item is dropped and new one inserted."""
|
||||
manager = _make_manager()
|
||||
manager._segment_queue = queue.Queue(maxsize=1)
|
||||
manager._segment_queue.put_nowait([b"old"])
|
||||
manager._segment_queue.put_nowait(([b"old"], "en-us"))
|
||||
manager._enqueue_audio_segment([b"new"])
|
||||
assert manager._segment_queue.get_nowait() == [b"new"]
|
||||
assert manager._segment_queue.get_nowait() == ([b"new"], "en-us")
|
||||
|
||||
|
||||
class TestPerformRecognition(unittest.TestCase):
|
||||
@@ -2206,7 +2207,7 @@ class TestPerformRecognition(unittest.TestCase):
|
||||
with patch.object(manager, "_process_audio_buffer") as mock_proc:
|
||||
with patch.object(manager, "_update_state"):
|
||||
t = self._run_recognition(manager)
|
||||
manager._segment_queue.put([b"\x00\x01"])
|
||||
manager._segment_queue.put(([b"\x00\x01"], "en-us"))
|
||||
manager.should_record = False
|
||||
manager._segment_queue.put(None)
|
||||
t.join(timeout=2)
|
||||
@@ -2222,8 +2223,8 @@ class TestPerformRecognition(unittest.TestCase):
|
||||
with patch.object(manager, "_process_audio_buffer") as mock_proc:
|
||||
with patch.object(manager, "_update_state"):
|
||||
t = self._run_recognition(manager)
|
||||
manager._segment_queue.put([b"first"])
|
||||
manager._segment_queue.put([b"second"])
|
||||
manager._segment_queue.put(([b"first"], "en-us"))
|
||||
manager._segment_queue.put(([b"second"], "en-us"))
|
||||
manager.should_record = False
|
||||
manager._segment_queue.put(None)
|
||||
t.join(timeout=2)
|
||||
@@ -2285,13 +2286,13 @@ class TestPerformRecognition(unittest.TestCase):
|
||||
states = self._track_states(manager)
|
||||
|
||||
with patch.object(manager, "_process_audio_buffer") as mock_proc:
|
||||
manager._segment_queue.put([b"leftover"])
|
||||
manager._segment_queue.put(([b"leftover"], "en-us"))
|
||||
manager._segment_queue.put(None)
|
||||
t = threading.Thread(target=manager._perform_recognition)
|
||||
t.start()
|
||||
t.join(timeout=2)
|
||||
assert not t.is_alive()
|
||||
mock_proc.assert_called_once_with([b"leftover"])
|
||||
mock_proc.assert_called_once_with([b"leftover"], "en-us")
|
||||
|
||||
assert RecognitionState.PROCESSING not in states
|
||||
assert RecognitionState.LISTENING not in states
|
||||
@@ -2308,12 +2309,12 @@ class TestPerformRecognition(unittest.TestCase):
|
||||
with patch.object(manager, "_process_audio_buffer") as mock_proc:
|
||||
# None is consumed first; leftover is drained from the remaining queue.
|
||||
manager._segment_queue.put(None)
|
||||
manager._segment_queue.put([b"leftover"])
|
||||
manager._segment_queue.put(([b"leftover"], "en-us"))
|
||||
t = threading.Thread(target=manager._perform_recognition)
|
||||
t.start()
|
||||
t.join(timeout=2)
|
||||
assert not t.is_alive()
|
||||
mock_proc.assert_called_once_with([b"leftover"])
|
||||
mock_proc.assert_called_once_with([b"leftover"], "en-us")
|
||||
|
||||
assert RecognitionState.PROCESSING not in states
|
||||
assert manager.state == RecognitionState.IDLE
|
||||
@@ -2327,13 +2328,13 @@ class TestPerformRecognition(unittest.TestCase):
|
||||
states = self._track_states(manager)
|
||||
processed = threading.Event()
|
||||
|
||||
def _after_process(_segment):
|
||||
def _after_process(_segment, _language):
|
||||
processed.set()
|
||||
|
||||
with patch.object(manager, "_process_audio_buffer", side_effect=_after_process):
|
||||
t = threading.Thread(target=manager._perform_recognition)
|
||||
t.start()
|
||||
manager._segment_queue.put([b"live"])
|
||||
manager._segment_queue.put(([b"live"], "en-us"))
|
||||
assert processed.wait(timeout=2)
|
||||
deadline = time.time() + 1
|
||||
while RecognitionState.LISTENING not in states and time.time() < deadline:
|
||||
@@ -2361,11 +2362,11 @@ class TestPerformRecognition(unittest.TestCase):
|
||||
manager.state = RecognitionState.IDLE
|
||||
states = self._track_states(manager)
|
||||
|
||||
def _start_new_session(_segment):
|
||||
def _start_new_session(_segment, _language):
|
||||
manager.state = RecognitionState.LISTENING
|
||||
|
||||
with patch.object(manager, "_process_audio_buffer", side_effect=_start_new_session):
|
||||
manager._segment_queue.put([b"leftover"])
|
||||
manager._segment_queue.put(([b"leftover"], "en-us"))
|
||||
manager._segment_queue.put(None)
|
||||
t = threading.Thread(target=manager._perform_recognition)
|
||||
t.start()
|
||||
|
||||
@@ -53,7 +53,7 @@ def session(monkeypatch: pytest.MonkeyPatch) -> Generator[Session, None, None]:
|
||||
manager.model = object()
|
||||
manager._model_initialized = True
|
||||
|
||||
def collect_transcription(audio: list[bytes]) -> None:
|
||||
def collect_transcription(audio: list[bytes], language: str | None = None) -> None:
|
||||
transcribed.append(audio)
|
||||
|
||||
monkeypatch.setattr(manager, "_record_audio", record)
|
||||
|
||||
Reference in New Issue
Block a user