Files
voculinux/tests/test_cancel_transcription.py
T
e66bd218d9 fix(audio): prevent dictation hangs on stalled microphones (#974)
* fix(audio): bound capture reads and support cancellation

* fix(recognition): bound and serialize capture shutdown

* fix(recognition): cancel retries and report stalled microphones

* fix(audio): prefer pulse when the ALSA default is unavailable

* fix(recognition): guard late callbacks and finalize capture tests

* docs(audio): explain input selection and stall recovery

* fix(speech): enforce auto-pause across buffered reload timeouts

* fix(speech): recover auto-resume after reload timeout

* test(speech): fix auto-pause follow-up test regressions

* fix(speech): retain auto-resume after reconfigure timeout

* fix(tray): preserve auto-pause recovery across suspend

* test(capture-shutdown): give the bare tray the suspend path's shortcut managers

_on_system_suspend now releases the main and per-language hotkey
listeners before sleep (#1025); the fixture builds TrayIndicator without
__init__, so it needs the two attributes present.

* fix(tray): release hotkey listeners on suspend even while auto-paused

Merging #1025's suspend cleanup with this branch's auto-pause handoff
left an early return that skipped shortcut_manager.stop() whenever a
configured game was active, keeping EVIOCGRABs held through sleep. The
paused branch now only owns the model-lifecycle work; listener release
and stopping active recognition run for every suspend.

* fix(tray): hold the apply lock only around the download's verify and save

Wrapping the whole recommended-model download in _apply_settings_lock
blocked every GTK-thread settings apply (e.g. Test Dictation) for the
length of the transfer. The engine already serializes reconfigures, so
the lock only needs to cover verifying the download still owns the
receipt and saving it; an apply that lands mid-download now proceeds
instead of freezing the app.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-26 21:25:50 -07:00

946 lines
32 KiB
Python

"""Tests for cancelling an active speech recognition session."""
import queue
import threading
import time
from typing import Callable, Optional
from unittest.mock import MagicMock, patch
import vocalinux.speech_recognition.recognition_manager as recognition_module
from vocalinux.common_types import RecognitionState
from vocalinux.speech_recognition.recognition_manager import (
SpeechRecognitionManager,
_AudioSegment,
_ReleaseGate,
)
def _make_manager() -> 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
return manager
def _start_session(manager: SpeechRecognitionManager, record_audio: Callable[[], None]) -> None:
manager._record_audio = record_audio
with patch.object(recognition_module, "play_start_sound"):
with patch.object(manager, "_arm_playback_duck"):
assert manager.start_recognition()
def _stop_session(manager: SpeechRecognitionManager) -> None:
with patch.object(recognition_module, "play_stop_sound"):
manager.stop_recognition()
def _no_audio_capture() -> None:
return None
def _stop_with_short_worker_joins(manager: SpeechRecognitionManager) -> None:
with patch.object(SpeechRecognitionManager, "_STOP_WORKER_JOIN_TIMEOUT_S", 0.05):
with patch.object(SpeechRecognitionManager, "_STOP_WORKER_FINAL_JOIN_TIMEOUT_S", 0.01):
_stop_session(manager)
def _wait_for_state(manager: SpeechRecognitionManager, state: RecognitionState) -> None:
deadline = time.monotonic() + 2.0
while manager.state is not state and time.monotonic() < deadline:
time.sleep(0.005)
assert manager.state is state
def _record_until_stopped(
manager: SpeechRecognitionManager, capture_started: threading.Event
) -> None:
manager._recording_segment_has_speech = False
capture_started.set()
while manager.should_record:
time.sleep(0.005)
def test_processing_cancel_is_immediate_and_new_session_remains_isolated() -> None:
manager = _make_manager()
capture_started = threading.Event()
transcriber_started = threading.Event()
release_old_transcriber = threading.Event()
new_transcription_finished = threading.Event()
delivered: list[str] = []
segments: list[str] = []
transcriptions: list[str] = []
def record_audio() -> None:
_record_until_stopped(manager, capture_started)
def transcribe(audio_buffer: list[bytes], language: Optional[str] = None) -> str:
del audio_buffer, language
if not transcriptions:
transcriptions.append("old")
transcriber_started.set()
release_old_transcriber.wait(timeout=5.0)
return "old session"
transcriptions.append("new")
new_transcription_finished.set()
return "new session"
manager._transcribe_with_whispercpp = transcribe
manager._voice_commands_enabled = False
manager.register_text_callback(delivered.append)
manager.register_segment_callback(lambda text, _started_at: segments.append(text))
capture_started.clear()
_start_session(manager, record_audio)
assert capture_started.wait(timeout=1.0)
old_cancel = manager._session_cancel
with manager._buffer_lock:
manager.audio_buffer = [b"old audio"]
manager._recording_segment_has_speech = True
stop_thread = threading.Thread(target=_stop_session, args=(manager,))
stop_thread.start()
assert transcriber_started.wait(timeout=2.0)
assert manager.is_transcribing()
old_queue = manager._segment_queue
cancel_started = time.monotonic()
assert manager.cancel_transcription()
assert time.monotonic() - cancel_started < 0.5
assert manager.state is RecognitionState.IDLE
assert old_queue.get_nowait() is None
assert old_queue.empty()
capture_started.clear()
_start_session(manager, record_audio)
assert capture_started.wait(timeout=1.0)
new_cancel = manager._session_cancel
assert new_cancel is not old_cancel
assert not new_cancel.is_set()
manager._enqueue_audio_segment([b"new audio"])
assert new_transcription_finished.wait(timeout=2.0)
assert delivered == ["new session"]
assert segments == ["new session"]
release_old_transcriber.set()
stop_thread.join(timeout=2.0)
assert not stop_thread.is_alive()
assert manager.state is RecognitionState.LISTENING
assert delivered == ["new session"]
_stop_session(manager)
assert manager.state is RecognitionState.IDLE
def test_cancel_while_listening_discards_final_buffer() -> None:
manager = _make_manager()
capture_started = threading.Event()
manager._voice_commands_enabled = False
def record_audio() -> None:
_record_until_stopped(manager, capture_started)
_start_session(manager, record_audio)
assert capture_started.wait(timeout=1.0)
with manager._buffer_lock:
manager.audio_buffer = [b"final audio"]
manager._recording_segment_has_speech = True
with patch.object(manager, "_enqueue_audio_segment") as enqueue:
assert manager.cancel_transcription()
assert manager.state is RecognitionState.IDLE
assert manager._session_cancel.is_set()
assert not manager.audio_buffer
enqueue.assert_not_called()
def test_cancel_when_idle_returns_false() -> None:
manager = _make_manager()
assert not manager.cancel_transcription()
assert manager.state is RecognitionState.IDLE
def test_is_transcribing_requires_stopped_processing_state() -> None:
manager = _make_manager()
manager.state = RecognitionState.PROCESSING
manager.should_record = False
assert manager.is_transcribing()
manager.should_record = True
assert not manager.is_transcribing()
manager.should_record = False
manager.state = RecognitionState.LISTENING
assert not manager.is_transcribing()
def test_cancel_rejects_an_already_cancelled_session() -> None:
manager = _make_manager()
manager.state = RecognitionState.PROCESSING
manager._session_cancel.set()
assert not manager.cancel_transcription()
assert manager.state is RecognitionState.PROCESSING
def test_cancel_buffered_reload_sets_flag_and_leaves_processing_state() -> None:
manager = _make_manager()
manager.state = RecognitionState.PROCESSING
manager._buffered_reload_session = True
assert manager.cancel_transcription()
assert manager._session_cancel.is_set()
assert manager._cancel_buffered_session.is_set()
assert manager.state is RecognitionState.PROCESSING
assert manager._segment_queue.get_nowait() is None
def test_enqueue_stamps_segment_with_current_session_cancel_event() -> None:
manager = _make_manager()
manager._enqueue_audio_segment([b"audio"])
segment, _language = manager._segment_queue.get_nowait()
assert isinstance(segment, _AudioSegment)
assert segment.cancel_event is manager._session_cancel
def test_cancelled_transcriber_result_is_discarded_before_callbacks() -> None:
manager = _make_manager()
manager._voice_commands_enabled = True
cancel_event = threading.Event()
segment = _AudioSegment([b"audio"], None, cancel_event=cancel_event)
transcriber_started = threading.Event()
release_transcriber = threading.Event()
callbacks: list[str] = []
actions: list[str] = []
manager.command_processor.process_text = MagicMock(return_value=("discard me", ["action"]))
manager.action_callbacks.append(actions.append)
def transcribe(audio_buffer: list[bytes], language: Optional[str] = None) -> str:
del audio_buffer, language
transcriber_started.set()
release_transcriber.wait(timeout=2.0)
return "discard me"
manager._transcribe_with_whispercpp = transcribe
manager.register_text_callback(callbacks.append)
manager.register_segment_callback(lambda text, _started_at: callbacks.append(text))
worker = threading.Thread(target=manager._process_audio_buffer, args=(segment,))
worker.start()
assert transcriber_started.wait(timeout=1.0)
cancel_event.set()
release_transcriber.set()
worker.join(timeout=2.0)
assert not worker.is_alive()
assert not callbacks
assert not actions
manager.command_processor.process_text.assert_not_called()
def test_cancelled_buffer_is_not_transcribed() -> None:
manager = _make_manager()
cancel_event = threading.Event()
cancel_event.set()
segment = _AudioSegment([b"audio"], None, cancel_event=cancel_event)
manager._transcribe_with_whispercpp = MagicMock(return_value="must not run")
manager._process_audio_buffer(segment)
manager._transcribe_with_whispercpp.assert_not_called()
def test_perform_recognition_worker_keeps_its_original_queue() -> None:
manager = _make_manager()
manager.should_record = True
old_cancel = manager._session_cancel
old_queue: queue.Queue[tuple[_AudioSegment, str]] = queue.Queue()
new_queue: queue.Queue[tuple[_AudioSegment, str]] = queue.Queue()
manager._segment_queue = old_queue
process_started = threading.Event()
release_process = threading.Event()
processed: list[str] = []
def process(segment: list[bytes], _language: Optional[str] = None) -> None:
processed.append(segment[0].decode())
process_started.set()
release_process.wait(timeout=2.0)
manager._process_audio_buffer = process
old_queue.put((_AudioSegment([b"old"], None), "en-us"))
worker = threading.Thread(target=manager._perform_recognition)
worker.start()
assert process_started.wait(timeout=1.0)
old_cancel.set()
manager._session_cancel = threading.Event()
manager._segment_queue = new_queue
new_queue.put((_AudioSegment([b"new"], None), "en-us"))
release_process.set()
worker.join(timeout=2.0)
assert not worker.is_alive()
assert processed == ["old"]
assert new_queue.qsize() == 1
def test_whispercpp_abort_callback_is_passed_and_tracks_cancellation() -> None:
class FakeAudioArray:
def astype(self, _dtype: object) -> "FakeAudioArray":
return self
def __truediv__(self, _divisor: float) -> "FakeAudioArray":
return self
def __len__(self) -> int:
return 16000
class FakeNumpy:
int16 = object()
float32 = object()
@staticmethod
def frombuffer(_audio: bytes, dtype: object) -> FakeAudioArray:
del dtype
return FakeAudioArray()
class SupportedModel:
def __init__(self) -> None:
self.abort_callback: Optional[Callable[[], bool]] = None
def transcribe(
self,
_audio: FakeAudioArray,
language: Optional[str] = None,
initial_prompt: str = "",
abort_callback: Optional[Callable[[], bool]] = None,
) -> list[object]:
del language, initial_prompt
self.abort_callback = abort_callback
return []
manager = _make_manager()
model = SupportedModel()
manager.model = model
cancel_event = threading.Event()
segment = _AudioSegment([b"\x00\x00"], None, cancel_event=cancel_event)
with patch.dict("sys.modules", {"numpy": FakeNumpy}):
assert manager._transcribe_with_whispercpp(segment) == ""
assert model.abort_callback is not None
assert not model.abort_callback()
cancel_event.set()
assert model.abort_callback()
def test_whispercpp_without_abort_callback_signature_still_transcribes() -> None:
class FakeAudioArray:
def astype(self, _dtype: object) -> "FakeAudioArray":
return self
def __truediv__(self, _divisor: float) -> "FakeAudioArray":
return self
def __len__(self) -> int:
return 16000
class FakeNumpy:
int16 = object()
float32 = object()
@staticmethod
def frombuffer(_audio: bytes, dtype: object) -> FakeAudioArray:
del dtype
return FakeAudioArray()
class UnsupportedModel:
def __init__(self) -> None:
self.called = False
def transcribe(
self,
_audio: FakeAudioArray,
language: Optional[str] = None,
initial_prompt: str = "",
) -> list[object]:
del language, initial_prompt
self.called = True
return []
manager = _make_manager()
model = UnsupportedModel()
manager.model = model
segment = _AudioSegment([b"\x00\x00"], None, cancel_event=threading.Event())
with patch.dict("sys.modules", {"numpy": FakeNumpy}):
assert manager._transcribe_with_whispercpp(segment) == ""
assert model.called
def test_whispercpp_skips_language_detection_when_cancelled() -> None:
manager = _make_manager()
manager.whispercpp_language_candidates = ["en"]
manager.model.auto_detect_language = MagicMock()
cancel_event = threading.Event()
cancel_event.set()
segment = _AudioSegment([b"\x00\x00"], None, cancel_event=cancel_event)
assert manager._transcribe_with_whispercpp(segment) == ""
manager.model.auto_detect_language.assert_not_called()
def test_late_worker_handoff_keeps_processing_until_transcription_finishes() -> None:
manager = _make_manager()
transcriber_started = threading.Event()
release_transcriber = threading.Event()
delivered: list[str] = []
def transcribe(_audio: list[bytes], _language: Optional[str] = None) -> str:
transcriber_started.set()
assert release_transcriber.wait(timeout=3.0)
return "late text"
manager._transcribe_with_whispercpp = transcribe
manager._voice_commands_enabled = False
manager.register_text_callback(delivered.append)
_start_session(manager, _no_audio_capture)
manager._enqueue_audio_segment([b"slow audio"])
assert transcriber_started.wait(timeout=1.0)
worker_thread = manager.recognition_thread
_stop_with_short_worker_joins(manager)
assert manager.state is RecognitionState.PROCESSING
assert manager.is_transcribing()
release_transcriber.set()
_wait_for_state(manager, RecognitionState.IDLE)
assert delivered == ["late text"]
assert manager._worker_exited is not None
assert manager._worker_exited.is_set()
assert worker_thread is not None
worker_thread.join(timeout=1.0)
assert not worker_thread.is_alive()
def test_worker_finishing_during_stop_handoff_returns_to_idle() -> None:
"""Completion between stop's final check and handoff cannot strand PROCESSING."""
manager = _make_manager()
worker_finishing = threading.Event()
release_worker = threading.Event()
release_held = manager._release_held_transcripts
def finish_worker(
gate: Optional[_ReleaseGate],
force: bool = False,
cancel: Optional[threading.Event] = None,
) -> None:
release_held(gate, force=force, cancel=cancel)
if force:
worker_finishing.set()
assert release_worker.wait(timeout=3.0)
manager._release_held_transcripts = finish_worker
_start_session(manager, _no_audio_capture)
worker = manager.recognition_thread
worker_exited = manager._worker_exited
assert worker is not None and worker_exited is not None
is_set = worker_exited.is_set
stopper = threading.current_thread()
handoff_checked = threading.Event()
def finish_during_check() -> bool:
finished = is_set()
if threading.current_thread() is stopper and not finished:
assert worker_finishing.is_set()
handoff_checked.set()
release_worker.set()
# A synchronized handoff blocks completion until stop releases it.
worker.join(timeout=0.2)
return finished
try:
with patch.object(worker_exited, "is_set", side_effect=finish_during_check):
_stop_with_short_worker_joins(manager)
finally:
release_worker.set()
worker.join(timeout=1.0)
assert handoff_checked.is_set()
assert not worker.is_alive()
assert manager.state is RecognitionState.IDLE
assert not manager.is_transcribing()
def test_late_worker_idle_callback_does_not_hold_up_another_stop() -> None:
"""An arbitrary state callback runs outside the lifecycle critical section."""
manager = _make_manager()
transcribing = threading.Event()
release_transcriber = threading.Event()
callback_entered = threading.Event()
release_callback = threading.Event()
stopped = threading.Event()
def transcribe(_audio: list[bytes], _language: Optional[str] = None) -> str:
transcribing.set()
assert release_transcriber.wait(3)
return "final text"
def state_changed(state: RecognitionState) -> None:
if state is RecognitionState.IDLE:
callback_entered.set()
assert release_callback.wait(3)
def stop_again() -> None:
manager.stop_recognition()
stopped.set()
manager._transcribe_with_whispercpp = transcribe
manager._voice_commands_enabled = False
_start_session(manager, _no_audio_capture)
manager._enqueue_audio_segment([b"slow audio"])
assert transcribing.wait(1)
_stop_with_short_worker_joins(manager)
manager.register_state_callback(state_changed)
worker = manager.recognition_thread
assert worker is not None
stopper = threading.Thread(target=stop_again)
try:
release_transcriber.set()
assert callback_entered.wait(1)
stopper.start()
assert stopped.wait(0.5)
assert not release_callback.is_set()
finally:
release_transcriber.set()
release_callback.set()
worker.join(1)
if stopper.ident is not None:
stopper.join(1)
assert not worker.is_alive()
assert not stopper.is_alive()
def test_cancel_late_worker_discards_text_and_next_session_succeeds() -> None:
manager = _make_manager()
transcriber_started = threading.Event()
release_transcriber = threading.Event()
next_transcription_finished = threading.Event()
next_text_delivered = threading.Event()
delivered: list[str] = []
transcribe_calls = 0
def transcribe(_audio: list[bytes], _language: Optional[str] = None) -> str:
nonlocal transcribe_calls
transcribe_calls += 1
if transcribe_calls == 1:
transcriber_started.set()
assert release_transcriber.wait(timeout=3.0)
return "cancelled text"
next_transcription_finished.set()
return "next session"
def on_text(text: str) -> None:
delivered.append(text)
if text == "next session":
next_text_delivered.set()
manager._transcribe_with_whispercpp = transcribe
manager._voice_commands_enabled = False
manager.register_text_callback(on_text)
_start_session(manager, _no_audio_capture)
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
_stop_with_short_worker_joins(manager)
assert manager.is_transcribing()
assert manager.cancel_transcription()
assert manager.state is RecognitionState.IDLE
release_transcriber.set()
assert worker_exited.wait(timeout=2.0)
assert worker_thread is not None
worker_thread.join(timeout=1.0)
assert not worker_thread.is_alive()
assert manager.state is RecognitionState.IDLE
assert delivered == []
_start_session(manager, _no_audio_capture)
manager._enqueue_audio_segment([b"next audio"])
assert next_transcription_finished.wait(timeout=1.0)
assert next_text_delivered.wait(timeout=1.0)
assert delivered == ["next session"]
_stop_session(manager)
assert manager.state is RecognitionState.IDLE
def test_cancelled_old_worker_does_not_change_new_session_state() -> None:
manager = _make_manager()
old_transcriber_started = threading.Event()
release_old_transcriber = threading.Event()
new_transcription_finished = threading.Event()
new_text_delivered = threading.Event()
delivered: list[str] = []
transcribe_calls = 0
def transcribe(_audio: list[bytes], _language: Optional[str] = None) -> str:
nonlocal transcribe_calls
transcribe_calls += 1
if transcribe_calls == 1:
old_transcriber_started.set()
assert release_old_transcriber.wait(timeout=3.0)
return "old text"
new_transcription_finished.set()
return "new text"
def on_text(text: str) -> None:
delivered.append(text)
if text == "new text":
new_text_delivered.set()
manager._transcribe_with_whispercpp = transcribe
manager._voice_commands_enabled = False
manager.register_text_callback(on_text)
_start_session(manager, _no_audio_capture)
manager._enqueue_audio_segment([b"old audio"])
assert old_transcriber_started.wait(timeout=1.0)
old_worker_exited = manager._worker_exited
assert old_worker_exited is not None
old_worker_thread = manager.recognition_thread
_stop_with_short_worker_joins(manager)
old_cancel = manager._session_cancel
assert manager._idle_on_worker_exit is old_cancel
assert manager.cancel_transcription()
assert manager._idle_on_worker_exit is None
_start_session(manager, _no_audio_capture)
new_cancel = manager._session_cancel
assert new_cancel is not old_cancel
manager._enqueue_audio_segment([b"new audio"])
assert new_transcription_finished.wait(timeout=1.0)
assert new_text_delivered.wait(timeout=1.0)
assert delivered == ["new text"]
release_old_transcriber.set()
assert old_worker_thread is not None
old_worker_thread.join(timeout=2.0)
assert not old_worker_thread.is_alive()
assert old_worker_exited.is_set()
_wait_for_state(manager, RecognitionState.LISTENING)
assert manager._session_cancel is new_cancel
assert not new_cancel.is_set()
assert delivered == ["new text"]
_stop_session(manager)
assert manager.state is RecognitionState.IDLE
def test_fast_worker_still_returns_to_idle_from_stop() -> None:
manager = _make_manager()
transcriber_finished = threading.Event()
def transcribe(_audio: list[bytes], _language: Optional[str] = None) -> str:
transcriber_finished.set()
return "quick text"
manager._transcribe_with_whispercpp = transcribe
manager._voice_commands_enabled = False
_start_session(manager, _no_audio_capture)
manager._enqueue_audio_segment([b"quick audio"])
assert transcriber_finished.wait(timeout=1.0)
_stop_session(manager)
assert manager.state is RecognitionState.IDLE
assert manager._worker_exited is not None
assert manager._worker_exited.is_set()
def test_cancel_drops_push_to_talk_text_held_until_release() -> None:
manager = _make_manager()
manager._voice_commands_enabled = False
delivered: list[str] = []
manager.register_text_callback(delivered.append)
cancel_event = threading.Event()
gate = _ReleaseGate()
segment = _AudioSegment([b"audio"], None, cancel_event=cancel_event, release_gate=gate)
manager._transcribe_with_whispercpp = MagicMock(return_value="held words")
manager._process_audio_buffer(segment)
assert len(gate.held) == 1
cancel_event.set()
gate.set()
manager._release_held_transcripts(gate, force=True, cancel=cancel_event)
assert not gate.held
assert not delivered
def test_cancelled_worker_exit_does_not_force_release_held_text() -> None:
manager = _make_manager()
delivered: list[str] = []
manager.register_text_callback(delivered.append)
manager.should_record = False
gate = _ReleaseGate()
manager._key_release_gate = gate
manager._session_cancel.set()
gate.held.append(recognition_module._HeldTranscript("held words", [], None))
manager._perform_recognition()
assert not gate.held
assert not delivered
def test_uncancelled_release_still_delivers_held_text() -> None:
manager = _make_manager()
delivered: list[str] = []
manager.register_text_callback(delivered.append)
gate = _ReleaseGate()
gate.held.append(recognition_module._HeldTranscript("held words", [], None))
gate.set()
manager._release_held_transcripts(gate, cancel=threading.Event())
assert delivered == ["held words"]
def test_capture_session_current_tracks_cancel_and_replacement() -> None:
manager = _make_manager()
manager.should_record = True
session = manager._session_cancel
assert manager._capture_session_current(session)
manager._session_cancel = threading.Event()
assert not manager._capture_session_current(session)
manager._session_cancel = session
session.set()
assert not manager._capture_session_current(session)
def test_stale_capture_reconnection_does_not_touch_new_session_device() -> None:
manager = _make_manager()
manager.should_record = True
old_session = manager._session_cancel
new_source = MagicMock()
manager._capture_source = new_source
manager._capture_source_for_session = MagicMock(return_value=new_source)
def new_session_starts(_delay: float) -> bool:
old_session.set()
manager._session_cancel = threading.Event()
return False
with patch.object(manager._session.cancel, "wait", side_effect=new_session_starts):
assert manager._attempt_audio_reconnection(MagicMock(), old_session) is False
manager._capture_source_for_session.assert_not_called()
new_source.reopen.assert_not_called()
def test_cancelled_capture_thread_does_not_write_into_new_session_buffer() -> None:
manager = _make_manager()
manager._silero_vad = None
manager.should_record = True
old_session = manager._session_cancel
reads = 0
source = MagicMock()
source.requires_pyaudio = False
source.sample_rate = 16000
def read_chunk(should_continue: Callable[[], bool]) -> bytes:
nonlocal reads
assert should_continue()
reads += 1
if reads == 1:
old_session.set()
manager._session_cancel = threading.Event()
manager.audio_buffer = [b"new-session"]
return b"\x00\x00" * 1024
source.read_chunk.side_effect = read_chunk
manager._new_capture_source = MagicMock(return_value=source)
with patch.object(recognition_module, "play_error_sound", create=True):
manager._record_audio()
assert reads == 1
assert manager.audio_buffer == [b"new-session"]
assert manager.state is not RecognitionState.ERROR
source.close.assert_called_once()
def test_cancel_idle_callback_can_start_a_listening_session() -> None:
"""A callback's explicit start owns its state while the old stopper stays stale."""
manager = _make_manager()
manager._perform_recognition = lambda: None
restarted: list[bool] = []
def restart(state: RecognitionState) -> None:
if state is RecognitionState.IDLE and not restarted:
restarted.append(manager.start_recognition(mode="push_to_talk"))
manager.register_state_callback(restart)
_start_session(manager, _no_audio_capture)
try:
assert manager.cancel_transcription()
assert restarted == [True]
assert manager.should_record
assert manager.state is RecognitionState.LISTENING
finally:
manager.should_record = False
manager._session.cancel.set()
manager._session_cancel.set()
manager._signal_recognition_stop()
for worker in (manager.audio_thread, manager.recognition_thread):
if worker is not None:
worker.join(timeout=1.0)
def test_old_cancel_drain_cannot_stop_a_new_session() -> None:
"""Finishing an old cancellation cannot stop a recording started during its drain."""
manager = _make_manager()
manager._voice_commands_enabled = False
transcribing = threading.Event()
release_transcriber = threading.Event()
draining = threading.Event()
release_cancel = threading.Event()
cancel_results: list[bool] = []
def transcribe(_audio: list[bytes], _language: Optional[str] = None) -> str:
transcribing.set()
assert release_transcriber.wait(3)
return "old text"
manager._transcribe_with_whispercpp = transcribe
_start_session(manager, _no_audio_capture)
manager._enqueue_audio_segment([b"old audio"])
assert transcribing.wait(1)
old_queue = manager._segment_queue
get_nowait = old_queue.get_nowait
gated = False
def pause_cancel_drain() -> object:
nonlocal gated
if not gated:
gated = True
draining.set()
assert release_cancel.wait(3)
return get_nowait()
stopper = threading.Thread(target=_stop_session, args=(manager,))
canceller = threading.Thread(
target=lambda: cancel_results.append(manager.cancel_transcription())
)
try:
stopper.start()
assert manager._session.capture_finished.wait(1)
with patch.object(old_queue, "get_nowait", side_effect=pause_cancel_drain):
canceller.start()
assert draining.wait(1)
release_transcriber.set()
stopper.join(1)
assert not stopper.is_alive()
assert manager.state is RecognitionState.IDLE
_start_session(manager, _no_audio_capture)
new_session = manager._session
release_cancel.set()
canceller.join(2)
assert not canceller.is_alive()
assert cancel_results == [True]
assert manager._session is new_session
assert not new_session.cancel.is_set()
assert manager.should_record
assert manager.state is RecognitionState.LISTENING
finally:
release_cancel.set()
release_transcriber.set()
if canceller.ident is not None:
canceller.join(2)
if stopper.ident is not None:
stopper.join(2)
manager._session_cancel.set()
if manager.should_record:
_stop_session(manager)
for worker in (manager.audio_thread, manager.recognition_thread):
if worker is not None:
worker.join(1)
def test_old_stopper_signals_only_its_original_queue() -> None:
"""A delayed old stop sentinel cannot terminate the next session's consumer."""
manager = _make_manager()
manager._voice_commands_enabled = False
transcribing = threading.Event()
release_transcriber = threading.Event()
stop_signalling = threading.Event()
release_signal = threading.Event()
stop_calls = 0
stopper: threading.Thread
def transcribe(_audio: list[bytes], _language: Optional[str] = None) -> str:
transcribing.set()
assert release_transcriber.wait(3)
return "old text"
manager._transcribe_with_whispercpp = transcribe
_start_session(manager, _no_audio_capture)
manager._enqueue_audio_segment([b"old audio"])
assert transcribing.wait(1)
signal = manager._signal_recognition_stop
def pause_late_signal(segments: Optional[queue.Queue] = None) -> None:
nonlocal stop_calls
if threading.current_thread() is stopper:
stop_calls += 1
if stop_calls == 2:
stop_signalling.set()
assert release_signal.wait(3)
if segments is None:
signal()
else:
signal(segments)
stopper = threading.Thread(target=_stop_with_short_worker_joins, args=(manager,))
new_worker: Optional[threading.Thread] = None
try:
with patch.object(manager, "_signal_recognition_stop", side_effect=pause_late_signal):
stopper.start()
assert stop_signalling.wait(1)
assert manager.cancel_transcription()
_start_session(manager, _no_audio_capture)
new_worker = manager.recognition_thread
assert new_worker is not None
release_signal.set()
stopper.join(1)
assert not stopper.is_alive()
new_worker.join(0.2)
assert manager.should_record
assert manager.state is RecognitionState.LISTENING
assert new_worker.is_alive()
finally:
release_signal.set()
release_transcriber.set()
if stopper.ident is not None:
stopper.join(2)
manager._session_cancel.set()
if manager.should_record:
_stop_session(manager)
for worker in (manager.audio_thread, manager.recognition_thread):
if worker is not None:
worker.join(1)