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.
This commit is contained in:
Jatin K Malik
2026-08-28 23:29:46 -07:00
committed by GitHub
parent 7a4d050dba
commit 9c3ed49317
4 changed files with 196 additions and 44 deletions
@@ -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."""
+7 -1
View File
@@ -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)
@@ -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"])
+21
View File
@@ -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")