diff --git a/src/vocalinux/common_types.py b/src/vocalinux/common_types.py index 5f04471..2affdf1 100644 --- a/src/vocalinux/common_types.py +++ b/src/vocalinux/common_types.py @@ -21,7 +21,7 @@ class SpeechRecognitionManagerProtocol(Protocol): state: RecognitionState - def start_recognition(self) -> None: + def start_recognition(self, mode: str = "toggle") -> None: """Start the speech recognition process.""" ... diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 25660e6..af68d70 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -561,6 +561,7 @@ class SpeechRecognitionManager: # Recording control flags self.should_record = False + self._recognition_mode = "toggle" # "toggle" or "push_to_talk" self.audio_buffer = [] self._buffer_lock = threading.Lock() # Thread safety for audio_buffer self._model_lock = threading.Lock() # Thread safety for model/recognizer access @@ -1499,7 +1500,7 @@ class SpeechRecognitionManager: """Check if the model is initialized and ready for recognition.""" return self._model_initialized and self.model is not None - def start_recognition(self): + def start_recognition(self, mode: str = "toggle"): """Start the speech recognition process.""" if self.state != RecognitionState.IDLE: logger.warning(f"Cannot start recognition in current state: {self.state}") @@ -1526,6 +1527,7 @@ class SpeechRecognitionManager: # Set recording flag self.should_record = True + self._recognition_mode = mode self.audio_buffer = [] self._segment_queue = queue.Queue(maxsize=32) @@ -1588,6 +1590,7 @@ class SpeechRecognitionManager: if self.recognition_thread and self.recognition_thread.is_alive(): self.recognition_thread.join(timeout=1.0) + self._recognition_mode = "toggle" self._update_state(RecognitionState.IDLE) def _record_audio(self): @@ -1765,9 +1768,15 @@ class SpeechRecognitionManager: silence_counter += CHUNK / RATE # Convert chunks to seconds if silence_counter > self.silence_timeout: # Use self.silence_timeout if len(self.audio_buffer) > 0: - logger.debug("Silence detected, queueing audio segment") - self._enqueue_audio_segment(self.audio_buffer) - self.audio_buffer = [] + if self._recognition_mode == "push_to_talk": + logger.debug( + "Silence detected in push-to-talk mode, " + "deferring transcription until key release" + ) + else: + logger.debug("Silence detected, queueing audio segment") + self._enqueue_audio_segment(self.audio_buffer) + self.audio_buffer = [] silence_counter = 0 else: # Speech if not speech_detected_in_session: diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index 3b91f11..3d4f134 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -9,6 +9,7 @@ import logging import os import shutil import subprocess +import threading import time from enum import Enum from typing import Optional # noqa: F401 @@ -446,8 +447,11 @@ class TextInjector: if result: logger.info("Text injection completed successfully") if self._should_copy_to_clipboard(): - self._copy_to_clipboard(text) - logger.debug("Text also copied to clipboard (setting enabled)") + threading.Thread( + target=self._copy_to_clipboard, + args=(text,), + daemon=True, + ).start() return result else: logger.error("IBus injector not initialized") @@ -478,8 +482,11 @@ class TextInjector: logger.info("Text injection completed successfully") if self._should_copy_to_clipboard(): - self._copy_to_clipboard(text) - logger.debug("Text also copied to clipboard (setting enabled)") + threading.Thread( + target=self._copy_to_clipboard, + args=(text,), + daemon=True, + ).start() return True except Exception as e: diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 4a24444..1611cb0 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -237,7 +237,7 @@ class TrayIndicator: def _start_recognition(self): """Start voice recognition (for push-to-talk mode).""" if self.speech_engine.state == RecognitionState.IDLE: - self.speech_engine.start_recognition() + self.speech_engine.start_recognition(mode="push_to_talk") def _stop_recognition(self): """Stop voice recognition (for push-to-talk mode).""" diff --git a/tests/test_recognition_manager_core.py b/tests/test_recognition_manager_core.py index e45086d..8ba4bd1 100644 --- a/tests/test_recognition_manager_core.py +++ b/tests/test_recognition_manager_core.py @@ -525,6 +525,72 @@ class TestStartStopRecognition(unittest.TestCase): mock_audio_feedback.play_stop_sound.assert_not_called() +class TestPushToTalkMode(unittest.TestCase): + """Test push-to-talk recognition mode prevents premature transcription on silence.""" + + def setUp(self): + self.mockKaldi = patch.object(sys.modules["vosk"], "KaldiRecognizer") + self.mockModel = patch.object(sys.modules["vosk"], "Model") + self.mockMakeDirs = patch("os.makedirs") + self.mockThread = patch("threading.Thread") + self.mockPath = patch.object(SpeechRecognitionManager, "_get_vosk_model_path") + self.mockDownload = patch.object(SpeechRecognitionManager, "_download_vosk_model") + + self.kaldiMock = self.mockKaldi.start() + self.modelMock = self.mockModel.start() + self.makeDirsMock = self.mockMakeDirs.start() + self.threadMock = self.mockThread.start() + self.pathMock = self.mockPath.start() + self.downloadMock = self.mockDownload.start() + + self.pathMock.return_value = "/mock/path/vosk-model" + self.recognizerMock = MagicMock() + self.kaldiMock.return_value = self.recognizerMock + self.recognizerMock.FinalResult.return_value = '{"text": ""}' + self.threadInstance = MagicMock() + self.threadMock.return_value = self.threadInstance + + mock_audio_feedback.play_start_sound.reset_mock() + mock_audio_feedback.play_stop_sound.reset_mock() + mock_audio_feedback.play_error_sound.reset_mock() + + self.patcher_exists = patch("os.path.exists", return_value=True) + self.mock_exists = self.patcher_exists.start() + + def tearDown(self): + self.mockKaldi.stop() + self.mockModel.stop() + self.mockMakeDirs.stop() + self.mockThread.stop() + self.mockPath.stop() + self.mockDownload.stop() + self.patcher_exists.stop() + + def test_default_recognition_mode_is_toggle(self): + manager = SpeechRecognitionManager(engine="vosk") + self.assertEqual(manager._recognition_mode, "toggle") + + def test_start_recognition_sets_toggle_mode_by_default(self): + manager = SpeechRecognitionManager(engine="vosk") + manager.start_recognition() + self.assertEqual(manager._recognition_mode, "toggle") + + def test_start_recognition_sets_push_to_talk_mode(self): + manager = SpeechRecognitionManager(engine="vosk") + manager.start_recognition(mode="push_to_talk") + self.assertEqual(manager._recognition_mode, "push_to_talk") + + def test_stop_recognition_resets_mode_to_toggle(self): + manager = SpeechRecognitionManager(engine="vosk") + manager.start_recognition(mode="push_to_talk") + self.assertEqual(manager._recognition_mode, "push_to_talk") + + manager.audio_thread = self.threadInstance + manager.recognition_thread = self.threadInstance + manager.stop_recognition() + self.assertEqual(manager._recognition_mode, "toggle") + + class TestWhisperInitialization(unittest.TestCase): """Test cases for Whisper engine initialization."""