* 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>
946 lines
32 KiB
Python
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)
|