feat(recognition): cancel an in-progress transcription with the recording hotkey (#1004)
* feat(recognition): cancel in-progress transcription with hotkey Adds per-session cancellation across the manager, tray, and Test Dictation. Fixes #991 * fix(settings): keep Test Dictation cancellation responsive Fixes #991 * fix(recognition): keep late workers cancellable (#991) * test(tray): avoid early tray import in cancellation tests * fix(recognition): keep a cancelled capture thread out of the next session A cancel returns to IDLE without joining the capture thread, so a thread still in its reconnection backoff could wake inside a newer recording, reopen that session's device and write into its buffer. Tie the capture loop and the reconnection to the session's cancel token: a cancelled or replaced session stops reading, skips the reopen and exits without raising an error.
This commit is contained in:
@@ -6,6 +6,7 @@ SpeechRecognitionManager.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -491,6 +492,75 @@ class TestTrayIndicatorResumeFlow(unittest.TestCase):
|
||||
|
||||
mock_glib.source_remove.assert_not_called()
|
||||
|
||||
def test_tray_hotkey_cancels_late_worker_after_stop_join_timeout(self) -> None:
|
||||
import vocalinux.speech_recognition.recognition_manager as recognition_module
|
||||
from vocalinux.speech_recognition.recognition_manager import (
|
||||
SpeechRecognitionManager,
|
||||
)
|
||||
|
||||
duck = MagicMock()
|
||||
duck.enabled.return_value = False
|
||||
with patch.object(SpeechRecognitionManager, "_init_whispercpp"):
|
||||
manager = SpeechRecognitionManager(
|
||||
engine="whisper_cpp",
|
||||
model_size="small",
|
||||
language="en-us",
|
||||
playback_duck=duck,
|
||||
)
|
||||
manager.model = MagicMock()
|
||||
manager._model_initialized = True
|
||||
manager._voice_commands_enabled = False
|
||||
|
||||
transcriber_started = threading.Event()
|
||||
release_transcriber = threading.Event()
|
||||
delivered: list[str] = []
|
||||
|
||||
def record_audio() -> None:
|
||||
return None
|
||||
|
||||
def transcribe(_audio: list[bytes], _language: str | None = None) -> str:
|
||||
transcriber_started.set()
|
||||
release_transcriber.wait(timeout=3.0)
|
||||
return "cancelled text"
|
||||
|
||||
manager._record_audio = record_audio
|
||||
manager._transcribe_with_whispercpp = transcribe
|
||||
manager.register_text_callback(delivered.append)
|
||||
with patch.object(recognition_module, "play_start_sound"):
|
||||
with patch.object(manager, "_arm_playback_duck"):
|
||||
assert manager.start_recognition()
|
||||
manager._enqueue_audio_segment([b"slow audio"])
|
||||
assert transcriber_started.wait(timeout=1.0)
|
||||
|
||||
worker_exited = manager._worker_exited
|
||||
assert worker_exited is not None
|
||||
worker_thread = manager.recognition_thread
|
||||
assert worker_thread is not None
|
||||
try:
|
||||
with patch.object(SpeechRecognitionManager, "_STOP_WORKER_JOIN_TIMEOUT_S", 0.05):
|
||||
with patch.object(
|
||||
SpeechRecognitionManager, "_STOP_WORKER_FINAL_JOIN_TIMEOUT_S", 0.01
|
||||
):
|
||||
with patch.object(recognition_module, "play_stop_sound"):
|
||||
manager.stop_recognition()
|
||||
|
||||
assert manager.state is RecognitionState.PROCESSING
|
||||
assert manager.is_transcribing()
|
||||
tray = self._make_tray_indicator()
|
||||
tray.speech_engine = manager
|
||||
tray._ptt_lock = threading.Lock()
|
||||
tray._ptt_owner = None
|
||||
tray._toggle_recognition()
|
||||
assert manager.state is RecognitionState.IDLE
|
||||
finally:
|
||||
release_transcriber.set()
|
||||
worker_thread.join(timeout=2.0)
|
||||
|
||||
assert not worker_thread.is_alive()
|
||||
assert worker_exited.is_set()
|
||||
assert manager.state is RecognitionState.IDLE
|
||||
assert delivered == []
|
||||
|
||||
|
||||
class TestTrayAutoPauseWiring(unittest.TestCase):
|
||||
"""TrayIndicator auto-pause config + pause/resume handlers.
|
||||
|
||||
Reference in New Issue
Block a user