* feat(dictionary): combined custom dictionary with corrections Rebased onto latest main (conflict-resolution squash for PR #818). * fix(dictionary): stabilize the terms and corrections file contracts - corrections_path() stays at the fixed config_dir() location promised by the file contract instead of following a custom or --dictionary-file terms path, which could strand or duplicate corrections; a leftover file beside the terms file is still read once and copied over. - _atomic_write() preserves the replaced file's permission mode instead of resetting it to the process umask. - build_initial_prompt() streams terms and stops at the configured cap instead of reading and processing the whole file for every segment. * fix(settings): restore the selected dictionary pane after a search Searching can auto-switch the Custom Dictionary management pane to reveal a match, but clearing the query only restored widget visibility. Record the pane selection in the search baseline snapshot and restore it alongside the previous page so users return to the pane they chose. * fix(dictionary): close the remaining custom dictionary file-contract gaps - A --dictionary-file session override no longer imports a corrections file found beside the override into the standard config directory; the override scopes to terms only. - Terms reads validate the whole file before yielding: corrupt UTF-8 mid-file now yields no terms at all, matching the invalid status Settings reports instead of leaking a partial dictionary to recognition. - The terms read is bounded by MAX_TERMS_FILE_BYTES (1 MiB); a larger file is invalid everywhere with a dedicated status instead of being read on every segment. * fix(dictionary): keep usable terms prefix on oversized files and legacy corrections under overrides * fix(dictionary): cap yielded terms and keep added terms visible on oversized files * fix(dictionary): keep added terms visible without dropping scanner-owned lines * fix(dictionary): keep oversized terms manageable and honor mid-search pane switches - terms list gains a Show all / Show first 500 toggle; adding a term beyond the cap auto-expands so the new row renders with a Remove button instead of being invisible until the file is hand-edited - a manual dictionary pane switch during an active search drops the saved pre-search pane so clearing the query no longer rolls back the user's newer selection - annotate the new settings tests and type model_info so the parakeet branch stops adding fresh mypy errors * fix(settings): do not auto-expand terms on add past display cap Rebuilding up to thousands of term rows on the UI thread stalls the dialog; the overflow button already shows the true count, so the added term stays one click away. * test(dictionary): stub segment_callbacks on bare manager for history dispatch * fix(dictionary): stream oversized terms files when building the prompt build_initial_prompt decoded the whole 1 MiB read window before every transcription segment even though it only needs the first max_words terms. _iter_terms_for_prompt now reads oversized files line by line and stops at the prompt limit, keeping the same contract: complete leading lines only, invalid UTF-8 inside the usable window yields nothing. * fix(history): only the ended session's worker may extend its snippet A late segment could extend the newest history entry regardless of which session produced it, so a worker finishing after the next dictation session leaked its text into that session's entry. Track the worker that owned the ended session and require late segments to arrive on the same thread before merging; anything else forms its own snippet. * fix(speech): transcribe queued segments the reload worker never drained The recognition worker can exit during stop_recognition's bounded audio-thread join, so the released key's final buffer lands in the queue after the worker is gone and was silently dropped when the reload teardown replaced the queue. Drain whatever remains before swapping it — except on capture failure or an explicit cancel, which still discard the session's leftovers. * fix(settings): build term rows in idle slices and pin hidden additions Two problems shared one cause. Rebuilding up to 4096 rows synchronously stalled the dialog, and a term appended past the 500-row cap reported success while staying invisible. Rows now build in bounded GLib idle slices guarded by a refresh token, and a saved term outside the visible window is pinned to the top of the capped list until it disappears or becomes visible on its own. Adding under "show all" scrolls to the new term instead. * test(engine): annotate new test method return types * fix(ci): match a candidate row that leads the latest track block snapcraft status normally prints candidate as a continuation line under the latest track (channel in column 1). If candidate ever leads the track block it rides the track line instead (channel in column 3), and the resolver missed it and refused to promote. Match both layouts in the resolve and the pre-release re-verify. * fix(dictionary): bound prompt reads and scroll to landed terms - _iter_terms_for_prompt now reads each line via readline() capped by the remaining byte window, so an overlong line can never pull more than MAX_TERMS_FILE_BYTES into memory - the prompt path keeps decoding every complete line inside the usable window after the term limit is collected, so invalid UTF-8 anywhere in the window still yields no terms and matches the reader's invalid status - under "show all" Settings scrolls to the index where an added term landed (prepended or appended), verified against the row's label, instead of always scrolling to the end * fix(history,reload): keep late-segment ownership and drain stamped tuples - Track session_workers_seen so a worker from an earlier session can never merge into a newer snippet while a session is open - Unpack (segment, language) queue tuples in _transcribe_queued_segments; passing the tuple as audio raised TypeError and lost final speech - Refuse 'delete that' when a manual pad edit blurred segment boundaries - Translate term-row coordinates into the scrolled card's space so the scroll actually reaches rows below the fold - Type _on_dictionary_pane_changed's varargs * style: drop unneeded nonlocal for mutated worker set --------- Co-authored-by: Graham <graham@thegeeky.ninja>
304 lines
11 KiB
Python
304 lines
11 KiB
Python
"""Recording and model reload must overlap without losing a released utterance."""
|
|
|
|
import sys
|
|
import threading
|
|
import time
|
|
from collections.abc import Generator
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from vocalinux.common_types import RecognitionState
|
|
from vocalinux.speech_recognition import recognition_manager as rm
|
|
|
|
Session = tuple[
|
|
rm.SpeechRecognitionManager,
|
|
threading.Event,
|
|
threading.Event,
|
|
threading.Event,
|
|
list[list[bytes]],
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def session(monkeypatch: pytest.MonkeyPatch) -> Generator[Session, None, None]:
|
|
"""Use real worker threads with a deterministic, blocked model loader."""
|
|
|
|
def skip_vosk_init(_manager: rm.SpeechRecognitionManager) -> None:
|
|
return None
|
|
|
|
def skip_silero_load() -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(rm.SpeechRecognitionManager, "_init_vosk", skip_vosk_init)
|
|
monkeypatch.setattr(rm, "load_silero_vad", skip_silero_load)
|
|
for name in ("play_start_sound", "play_stop_sound", "play_error_sound", "_show_notification"):
|
|
monkeypatch.setattr(rm, name, Mock())
|
|
manager = rm.SpeechRecognitionManager(engine="vosk", buffer_during_reload=True)
|
|
manager._idle_unloaded = True
|
|
manager.stop_sound_guard_ms = 0
|
|
captured = threading.Event()
|
|
loading = threading.Event()
|
|
release_load = threading.Event()
|
|
transcribed: list[list[bytes]] = []
|
|
|
|
def record() -> None:
|
|
with manager._buffer_lock:
|
|
manager.audio_buffer = [b"early speech"]
|
|
manager._recording_segment_has_speech = True
|
|
captured.set()
|
|
|
|
def load() -> None:
|
|
loading.set()
|
|
assert release_load.wait(3)
|
|
manager.model = object()
|
|
manager._model_initialized = True
|
|
|
|
def collect_transcription(audio: list[bytes], language: str | None = None) -> None:
|
|
transcribed.append(audio)
|
|
|
|
monkeypatch.setattr(manager, "_record_audio", record)
|
|
monkeypatch.setattr(manager, "_init_selected_engine", load)
|
|
monkeypatch.setattr(manager, "_process_audio_buffer", collect_transcription)
|
|
yield manager, captured, loading, release_load, transcribed
|
|
release_load.set()
|
|
if manager.should_record:
|
|
manager.stop_recognition()
|
|
if manager.recognition_thread:
|
|
manager.recognition_thread.join(3)
|
|
assert not manager.recognition_thread.is_alive()
|
|
|
|
|
|
def test_release_before_reload_finishes_preserves_whole_recording(session: Session) -> None:
|
|
manager, captured, loading, release_load, transcribed = session
|
|
assert manager.start_recognition(mode="push_to_talk")
|
|
assert captured.wait(1)
|
|
assert loading.wait(1)
|
|
manager.audio_buffer.append(b"later speech")
|
|
assert transcribed == []
|
|
|
|
manager.stop_recognition()
|
|
assert not release_load.is_set()
|
|
assert manager.state == RecognitionState.PROCESSING
|
|
assert not manager.start_recognition()
|
|
manager.stop_recognition() # Repeated stop must not erase pending audio.
|
|
release_load.set()
|
|
manager.recognition_thread.join(2)
|
|
|
|
assert transcribed == [[b"early speech", b"later speech"]]
|
|
assert manager.state == RecognitionState.IDLE
|
|
assert manager.audio_buffer == []
|
|
assert not manager.is_idle_unloaded
|
|
|
|
|
|
def test_worker_exit_during_release_join_still_transcribes_final_buffer(
|
|
session: Session, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A release whose final enqueue lands after the worker exits is kept.
|
|
|
|
stop_recognition joins the audio thread with a bounded wait; while it
|
|
waits, the recognition worker can notice ``should_record`` is false and
|
|
exit. The final buffer it then enqueues must still be transcribed by
|
|
the reload teardown instead of being thrown away with the queue.
|
|
"""
|
|
manager, captured, loading, release_load, transcribed = session
|
|
capture_release = threading.Event()
|
|
|
|
def slow_record() -> None:
|
|
with manager._buffer_lock:
|
|
manager.audio_buffer = [b"final speech"]
|
|
manager._recording_segment_has_speech = True
|
|
captured.set()
|
|
assert capture_release.wait(3)
|
|
|
|
monkeypatch.setattr(manager, "_record_audio", slow_record)
|
|
assert manager.start_recognition(mode="push_to_talk")
|
|
assert captured.wait(1)
|
|
assert loading.wait(1)
|
|
release_load.set() # Model loads; the worker starts polling an empty queue.
|
|
|
|
stopper = threading.Thread(target=manager.stop_recognition)
|
|
stopper.start()
|
|
# The worker's 0.5s second-chance window elapses while the stop path is
|
|
# still joining the audio thread, so the tail buffer is enqueued only
|
|
# after the worker has already exited.
|
|
time.sleep(1.2)
|
|
capture_release.set()
|
|
stopper.join(3)
|
|
manager.recognition_thread.join(3)
|
|
|
|
assert transcribed == [[b"final speech"]]
|
|
assert manager.state == RecognitionState.IDLE
|
|
|
|
|
|
def test_toggle_transcribes_queued_speech_after_reload(session: Session) -> None:
|
|
manager, captured, loading, release_load, transcribed = session
|
|
assert manager.start_recognition()
|
|
assert captured.wait(1)
|
|
assert loading.wait(1)
|
|
manager._enqueue_audio_segment(manager.audio_buffer)
|
|
manager.audio_buffer = []
|
|
manager.stop_recognition()
|
|
release_load.set()
|
|
manager.recognition_thread.join(2)
|
|
assert transcribed == [[b"early speech"]]
|
|
|
|
|
|
def test_disabled_option_keeps_synchronous_reload(
|
|
session: Session, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
manager, captured, loading, release_load, transcribed = session
|
|
manager.buffer_during_reload = False
|
|
ensure = Mock(return_value=False)
|
|
monkeypatch.setattr(manager, "ensure_model_loaded", ensure)
|
|
assert not manager.start_recognition()
|
|
ensure.assert_called_once()
|
|
assert not captured.is_set()
|
|
assert not loading.is_set()
|
|
|
|
|
|
def test_auto_pause_does_not_start_microphone(session: Session) -> None:
|
|
manager, captured, loading, release_load, transcribed = session
|
|
manager._auto_paused = True
|
|
assert not manager.start_recognition()
|
|
assert not captured.is_set()
|
|
assert not loading.is_set()
|
|
|
|
|
|
def test_reload_failure_after_release_allows_retry(
|
|
session: Session, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
manager, captured, loading, release_load, transcribed = session
|
|
|
|
def fail() -> None:
|
|
loading.set()
|
|
assert release_load.wait(3)
|
|
raise RuntimeError("model unavailable")
|
|
|
|
monkeypatch.setattr(manager, "_init_selected_engine", fail)
|
|
assert manager.start_recognition()
|
|
assert captured.wait(1)
|
|
assert loading.wait(1)
|
|
manager.stop_recognition()
|
|
assert manager.state == RecognitionState.PROCESSING
|
|
release_load.set()
|
|
manager.recognition_thread.join(2)
|
|
assert not manager.recognition_thread.is_alive()
|
|
assert not manager.should_record
|
|
assert manager.state == RecognitionState.IDLE
|
|
assert manager.audio_buffer == []
|
|
assert manager._segment_queue.empty()
|
|
assert transcribed == []
|
|
assert manager.is_idle_unloaded
|
|
|
|
manager.model = object()
|
|
manager._model_initialized = True
|
|
assert manager.start_recognition()
|
|
manager.stop_recognition()
|
|
assert manager.state == RecognitionState.IDLE
|
|
|
|
|
|
def test_reload_that_returns_without_a_ready_model_stops_and_allows_retry(
|
|
session: Session, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
manager, captured, _loading, _release_load, transcribed = session
|
|
|
|
def leave_model_unready() -> None:
|
|
assert captured.wait(1)
|
|
|
|
monkeypatch.setattr(manager, "_init_selected_engine", leave_model_unready)
|
|
assert manager.start_recognition()
|
|
manager.recognition_thread.join(2)
|
|
|
|
assert not manager.recognition_thread.is_alive()
|
|
assert not manager.should_record
|
|
assert manager.state == RecognitionState.IDLE
|
|
assert manager.audio_buffer == []
|
|
assert transcribed == []
|
|
assert manager.is_idle_unloaded
|
|
|
|
|
|
def test_reload_does_not_hide_unexpected_worker_errors(
|
|
session: Session, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
manager, _captured, _loading, _release_load, _transcribed = session
|
|
|
|
def fail_with_programming_error() -> None:
|
|
raise AssertionError("worker defect")
|
|
|
|
manager._buffered_reload_session = True
|
|
manager._capture_finished.set()
|
|
monkeypatch.setattr(manager, "_init_selected_engine", fail_with_programming_error)
|
|
|
|
with pytest.raises(AssertionError, match="worker defect"):
|
|
manager._reload_and_recognize()
|
|
|
|
assert manager.state == RecognitionState.IDLE
|
|
assert not manager._buffered_reload_session
|
|
|
|
|
|
def test_unload_cancels_pending_transcription(session: Session) -> None:
|
|
manager, captured, loading, release_load, transcribed = session
|
|
assert manager.start_recognition()
|
|
assert captured.wait(1)
|
|
assert loading.wait(1)
|
|
unloading = threading.Thread(target=manager.unload_model, kwargs={"reason": "auto_pause"})
|
|
unloading.start()
|
|
assert manager._cancel_buffered_session.wait(1)
|
|
release_load.set()
|
|
unloading.join(2)
|
|
assert not unloading.is_alive()
|
|
assert transcribed == []
|
|
assert manager.model is None
|
|
assert manager.is_auto_paused
|
|
|
|
|
|
def test_capture_failure_survives_key_release_during_reload(session: Session) -> None:
|
|
manager, captured, loading, release_load, transcribed = session
|
|
assert manager.start_recognition()
|
|
assert captured.wait(1)
|
|
assert loading.wait(1)
|
|
manager._buffered_capture_failed = True
|
|
manager._update_state(RecognitionState.ERROR)
|
|
manager.stop_recognition()
|
|
release_load.set()
|
|
manager.recognition_thread.join(2)
|
|
assert transcribed == []
|
|
assert manager.state == RecognitionState.IDLE
|
|
assert manager.audio_buffer == []
|
|
|
|
|
|
def test_capture_thread_failure_releases_reload_worker(
|
|
session: Session, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A dead mic during buffered reload must not hang the reload worker."""
|
|
manager, _captured, _loading, _release_load, _transcribed = session
|
|
manager._buffered_reload_session = True
|
|
manager._buffered_capture_failed = False
|
|
manager._capture_finished.clear()
|
|
# The audio thread exits through its ImportError teardown: no
|
|
# stop_recognition handoff is left to set _capture_finished.
|
|
monkeypatch.setitem(sys.modules, "pyaudio", None)
|
|
monkeypatch.setattr("vocalinux.ui.audio_feedback.play_error_sound", Mock())
|
|
# Bypass the fixture's _record_audio stub to run the real capture thread.
|
|
rm.SpeechRecognitionManager._record_audio(manager)
|
|
|
|
assert manager._buffered_capture_failed
|
|
assert not manager.should_record
|
|
assert manager.state == RecognitionState.ERROR
|
|
assert manager._capture_finished.is_set()
|
|
|
|
manager._cancel_buffered_session.set()
|
|
worker = threading.Thread(target=manager._reload_and_recognize)
|
|
worker.start()
|
|
worker.join(2)
|
|
assert not worker.is_alive()
|
|
assert manager.state == RecognitionState.IDLE
|
|
assert not manager._buffered_reload_session
|
|
|
|
|
|
def test_setting_defaults_to_disabled() -> None:
|
|
from vocalinux.ui.config_manager import DEFAULT_CONFIG
|
|
|
|
assert DEFAULT_CONFIG["model_keepalive"]["buffer_during_reload"] is False
|