diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 19d1f3e..3c39c32 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -2970,51 +2970,60 @@ class SpeechRecognitionManager: def _perform_recognition(self): """Perform speech recognition in real-time.""" logger.debug("_perform_recognition thread started") - while True: - logger.debug( - f"Recognition loop - should_record={self.should_record}, queue_empty={self._segment_queue.empty()}" - ) - try: - segment = 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(): - logger.debug( - "Recognition loop - not recording and queue empty, checking for final items..." - ) - # Give a brief moment for any final items to be enqueued - try: - segment = self._segment_queue.get(timeout=0.5) - except queue.Empty: - logger.debug("Recognition loop - no more items, exiting") - break - else: - logger.debug("Recognition loop - queue timeout, continuing") - continue + try: + while True: + logger.debug( + f"Recognition loop - should_record={self.should_record}, queue_empty={self._segment_queue.empty()}" + ) + try: + segment = 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(): + logger.debug( + "Recognition loop - not recording and queue empty, checking for final items..." + ) + # Give a brief moment for any final items to be enqueued + try: + segment = self._segment_queue.get(timeout=0.5) + except queue.Empty: + logger.debug("Recognition loop - no more items, exiting") + break + else: + logger.debug("Recognition loop - queue timeout, continuing") + continue - if segment 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: - logger.debug( - f"Recognition loop - processing remaining segment with {len(remaining)} chunks" - ) - self._update_state(RecognitionState.PROCESSING) - self._process_audio_buffer(remaining) - except queue.Empty: - break - logger.debug("Recognition loop - exiting after None signal") - break + if segment 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: + logger.debug( + f"Recognition loop - processing remaining segment with {len(remaining)} chunks" + ) + if self.should_record: + self._update_state(RecognitionState.PROCESSING) + self._process_audio_buffer(remaining) + except queue.Empty: + break + logger.debug("Recognition loop - exiting after None signal") + break - logger.debug(f"Recognition loop - processing segment with {len(segment)} chunks") - self._update_state(RecognitionState.PROCESSING) - self._process_audio_buffer(segment) - if self.should_record: - self._update_state(RecognitionState.LISTENING) - logger.debug("_perform_recognition thread exiting") + 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) + if self.should_record: + self._update_state(RecognitionState.LISTENING) + finally: + logger.debug("_perform_recognition thread exiting") + # Do not force IDLE here. stop_recognition() already does that, + # and a leftover worker can finish after the user has started + # again (LISTENING, should_record still false during the start + # sound). Forcing IDLE would paint the tray idle and make the + # next Alt+F12 start a second session (#739). def _enqueue_audio_segment(self, audio_buffer: list[bytes]): """Queue an audio segment for asynchronous transcription.""" diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 41c40b1..597fe6f 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -515,11 +515,17 @@ class TrayIndicator: Update the UI based on the recognition state. Args: - state: The current recognition state + state: Hint from the state-changed callback. The engine's current + state wins: GLib.idle_add can deliver a captured PROCESSING + argument after stop already went IDLE (#739). """ if not hasattr(self, "indicator"): return False + engine_state = getattr(self.speech_engine, "state", None) + if isinstance(engine_state, RecognitionState): + state = engine_state + if state == RecognitionState.IDLE: self.indicator.set_icon_full(self._icon_keys["default"], "Microphone off") self._set_menu_item_enabled("Start Voice Typing", True) diff --git a/tests/test_recognition_manager_device_config.py b/tests/test_recognition_manager_device_config.py index 3c69b52..a14edfd 100644 --- a/tests/test_recognition_manager_device_config.py +++ b/tests/test_recognition_manager_device_config.py @@ -2019,6 +2019,122 @@ class TestPerformRecognition(unittest.TestCase): t.join(timeout=2) assert not t.is_alive() + def _track_states(self, manager): + states = [] + original = manager._update_state + + def _tracked(new_state): + states.append(new_state) + original(new_state) + + manager._update_state = _tracked + return states + + def test_leftover_after_stop_does_not_stick_in_processing(self): + """After stop, leftover audio is transcribed but state returns to IDLE. + + Reproduces #739: stop_recognition can time out while the recognition + thread still marks leftover drain as PROCESSING and never goes idle. + The tray then stays on the active/processing icon until another + Alt+F12 toggle. + """ + manager = _make_manager() + manager._segment_queue = queue.Queue() + manager.should_record = False + manager.state = RecognitionState.IDLE + 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(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"]) + + assert RecognitionState.PROCESSING not in states + assert RecognitionState.LISTENING not in states + assert manager.state == RecognitionState.IDLE + + def test_none_signal_drain_after_stop_returns_idle(self): + """Segments still queued behind the stop sentinel must not leave PROCESSING.""" + manager = _make_manager() + manager._segment_queue = queue.Queue() + manager.should_record = False + manager.state = RecognitionState.IDLE + states = self._track_states(manager) + + 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"]) + 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"]) + + assert RecognitionState.PROCESSING not in states + assert manager.state == RecognitionState.IDLE + + def test_processing_still_set_while_recording(self): + """Live dictation still flips PROCESSING then LISTENING.""" + manager = _make_manager() + manager._segment_queue = queue.Queue() + manager.should_record = True + manager.state = RecognitionState.LISTENING + states = self._track_states(manager) + processed = threading.Event() + + def _after_process(_segment): + 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"]) + assert processed.wait(timeout=2) + deadline = time.time() + 1 + while RecognitionState.LISTENING not in states and time.time() < deadline: + time.sleep(0.01) + manager.should_record = False + manager._segment_queue.put(None) + t.join(timeout=2) + assert not t.is_alive() + + assert RecognitionState.PROCESSING in states + assert RecognitionState.LISTENING in states + assert manager.state == RecognitionState.LISTENING + + def test_leftover_exit_does_not_clobber_new_listening_session(self): + """Leftover drain finishing after a new start must not force IDLE. + + stop_recognition() join can time out, then Alt+F12 starts again. + start_recognition() sets LISTENING before should_record. If the + leftover worker forced IDLE on exit, the tray would go idle and + the next hotkey would start a second session. + """ + manager = _make_manager() + manager._segment_queue = queue.Queue() + manager.should_record = False + manager.state = RecognitionState.IDLE + states = self._track_states(manager) + + def _start_new_session(_segment): + 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(None) + t = threading.Thread(target=manager._perform_recognition) + t.start() + t.join(timeout=2) + assert not t.is_alive() + + assert manager.state == RecognitionState.LISTENING + assert RecognitionState.IDLE not in states + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index 52ebde2..470533e 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -524,6 +524,7 @@ class TestTrayIndicator(unittest.TestCase): mock_menu_item.get_label.return_value = "Start Voice Typing" self.tray_indicator.menu = MagicMock() self.tray_indicator.menu.get_children.return_value = [mock_menu_item] + self.mock_speech_engine.state = self.RecognitionState.LISTENING with patch("vocalinux.ui.tray_indicator.Gtk") as patched_gtk: # Make isinstance check pass for our mock @@ -542,6 +543,7 @@ class TestTrayIndicator(unittest.TestCase): mock_menu_item.get_label.return_value = "Start Voice Typing" self.tray_indicator.menu = MagicMock() self.tray_indicator.menu.get_children.return_value = [mock_menu_item] + self.mock_speech_engine.state = self.RecognitionState.PROCESSING with patch("vocalinux.ui.tray_indicator.Gtk") as patched_gtk: patched_gtk.MenuItem = type(mock_menu_item) @@ -559,6 +561,7 @@ class TestTrayIndicator(unittest.TestCase): mock_menu_item.get_label.return_value = "Start Voice Typing" self.tray_indicator.menu = MagicMock() self.tray_indicator.menu.get_children.return_value = [mock_menu_item] + self.mock_speech_engine.state = self.RecognitionState.ERROR with patch("vocalinux.ui.tray_indicator.Gtk") as patched_gtk: patched_gtk.MenuItem = type(mock_menu_item) @@ -811,6 +814,24 @@ class TestTrayIndicator(unittest.TestCase): result = self.tray_indicator._update_ui(RecognitionState.IDLE) self.assertEqual(result, False) + def test_update_ui_uses_engine_state_not_stale_arg(self): + """A delayed PROCESSING idle_add must not override a later IDLE engine state.""" + self.tray_indicator.indicator = MagicMock() + mock_menu_item = MagicMock() + mock_menu_item.get_label.return_value = "Start Voice Typing" + self.tray_indicator.menu = MagicMock() + self.tray_indicator.menu.get_children.return_value = [mock_menu_item] + self.mock_speech_engine.state = self.RecognitionState.IDLE + + with patch("vocalinux.ui.tray_indicator.Gtk") as patched_gtk: + patched_gtk.MenuItem = type(mock_menu_item) + result = self.tray_indicator._update_ui(self.RecognitionState.PROCESSING) + + self.tray_indicator.indicator.set_icon_full.assert_called_once_with( + self.tray_indicator._icon_keys["default"], "Microphone off" + ) + self.assertEqual(result, False) + def test_set_menu_item_enabled_noop_when_menu_missing(self): if hasattr(self.tray_indicator, "menu"): delattr(self.tray_indicator, "menu")