From 9c3ed49317c0189baf6699412f2d4b893a8c18d2 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Fri, 28 Aug 2026 23:29:46 -0700 Subject: [PATCH] fix(tray): stay idle after leftover transcription on toggle stop (#741) * fix(tray): stay idle after leftover transcription on toggle stop Toggle stop can time out while leftover audio is still transcribing. The recognition thread then painted PROCESSING and never went idle, so the tray stayed red until another Alt+F12. Skip PROCESSING and LISTENING updates once recording has stopped, always set IDLE when that thread exits, and paint the engine's current state in the tray instead of a stale GLib.idle_add argument. * fix(tray): leftover drain must not idle a new dictation session stop_recognition already marks IDLE. Forcing IDLE when the leftover worker exits can clobber a new LISTENING start during leftover drain. --- .../speech_recognition/recognition_manager.py | 95 +++++++------- src/vocalinux/ui/tray_indicator.py | 8 +- .../test_recognition_manager_device_config.py | 116 ++++++++++++++++++ tests/test_tray_indicator.py | 21 ++++ 4 files changed, 196 insertions(+), 44 deletions(-) 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")