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:
@@ -94,6 +94,14 @@ class SpeechRecognitionManagerProtocol(Protocol):
|
||||
"""Start recognition in ``language`` for this utterance only (#805)."""
|
||||
...
|
||||
|
||||
def is_transcribing(self) -> bool:
|
||||
"""Whether a stopped dictation session is still being transcribed."""
|
||||
...
|
||||
|
||||
def cancel_transcription(self) -> bool:
|
||||
"""Cancel the active dictation session and discard its result."""
|
||||
...
|
||||
|
||||
def stop_recognition(self) -> None:
|
||||
"""Stop the speech recognition process."""
|
||||
...
|
||||
|
||||
@@ -121,6 +121,7 @@ class _AudioSegment(list):
|
||||
chunks: list[bytes],
|
||||
started_at: Optional[float],
|
||||
language: Optional[str] = None,
|
||||
cancel_event: Optional[threading.Event] = None,
|
||||
release_gate: Optional[_ReleaseGate] = None,
|
||||
) -> None:
|
||||
super().__init__(chunks)
|
||||
@@ -129,6 +130,7 @@ class _AudioSegment(list):
|
||||
# outlives its session must not transcribe a stale segment in a newer
|
||||
# session's language.
|
||||
self.language = language
|
||||
self.cancel_event = cancel_event
|
||||
self.release_gate = release_gate
|
||||
|
||||
|
||||
@@ -239,6 +241,20 @@ def resolve_language_preference(language: str) -> str:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WHISPERCPP_ABORT_CALLBACK_SUPPORT: dict[type, bool] = {}
|
||||
|
||||
|
||||
def _supports_whispercpp_abort_callback(model_class: type) -> bool:
|
||||
"""Return whether a whisper.cpp model class accepts abort_callback."""
|
||||
if model_class not in _WHISPERCPP_ABORT_CALLBACK_SUPPORT:
|
||||
try:
|
||||
_WHISPERCPP_ABORT_CALLBACK_SUPPORT[model_class] = (
|
||||
"abort_callback" in inspect.signature(getattr(model_class, "transcribe")).parameters
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
_WHISPERCPP_ABORT_CALLBACK_SUPPORT[model_class] = False
|
||||
return _WHISPERCPP_ABORT_CALLBACK_SUPPORT[model_class]
|
||||
|
||||
|
||||
def _filter_non_speech(text: str) -> str:
|
||||
"""
|
||||
@@ -414,6 +430,10 @@ class SpeechRecognitionManager:
|
||||
speech recognition engines (VOSK and Whisper).
|
||||
"""
|
||||
|
||||
_STOP_WORKER_JOIN_TIMEOUT_S: float = 5.0
|
||||
_STOP_WORKER_FINAL_JOIN_TIMEOUT_S: float = 1.0
|
||||
_idle_on_worker_exit: Optional[threading.Event] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: str = "vosk",
|
||||
@@ -454,6 +474,8 @@ class SpeechRecognitionManager:
|
||||
self.state = RecognitionState.IDLE
|
||||
self.audio_thread: Optional[threading.Thread] = None
|
||||
self.recognition_thread: Optional[threading.Thread] = None
|
||||
self._worker_exited: Optional[threading.Event] = None
|
||||
self._idle_on_worker_exit: Optional[threading.Event] = None
|
||||
self._recognition_workers: list[threading.Thread] = []
|
||||
self._recognition_workers_lock = threading.Lock()
|
||||
self.model: Any = None
|
||||
@@ -551,6 +573,7 @@ class SpeechRecognitionManager:
|
||||
|
||||
# Recording control flags
|
||||
self.should_record = False
|
||||
self._session_cancel = threading.Event()
|
||||
self._recognition_mode = "toggle" # "toggle" or "push_to_talk"
|
||||
self.audio_buffer: list[bytes] = []
|
||||
# Monotonic time the current buffer's first chunk was captured,
|
||||
@@ -1940,6 +1963,11 @@ class SpeechRecognitionManager:
|
||||
"""
|
||||
import time
|
||||
|
||||
cancel_event = getattr(audio_buffer, "cancel_event", None)
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
logger.debug("Skipping whisper.cpp transcription for a cancelled session")
|
||||
return ""
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
|
||||
@@ -1972,6 +2000,10 @@ class SpeechRecognitionManager:
|
||||
logger.warning("Model is None during transcription, returning empty result")
|
||||
return ""
|
||||
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
logger.debug("Skipping whisper.cpp language detection for a cancelled session")
|
||||
return ""
|
||||
|
||||
if lang is None and self.whispercpp_language_candidates:
|
||||
detected, lang_probs = self.model.auto_detect_language(
|
||||
audio_float,
|
||||
@@ -2021,10 +2053,14 @@ class SpeechRecognitionManager:
|
||||
# Transcribe with whisper.cpp
|
||||
# pywhispercpp expects audio as numpy array
|
||||
transcribe_start = time.time()
|
||||
transcribe_kwargs = {"language": lang}
|
||||
transcribe_kwargs: dict[str, Any] = {"language": lang}
|
||||
# pywhispercpp reuses native parameter state. Passing an empty
|
||||
# value explicitly clears a prompt that was active last segment.
|
||||
transcribe_kwargs["initial_prompt"] = self._get_whispercpp_prompt() or ""
|
||||
if cancel_event is not None and _supports_whispercpp_abort_callback(
|
||||
type(self.model)
|
||||
):
|
||||
transcribe_kwargs["abort_callback"] = cancel_event.is_set
|
||||
segments = self.model.transcribe(audio_float, **transcribe_kwargs)
|
||||
transcribe_duration = time.time() - transcribe_start
|
||||
|
||||
@@ -3339,6 +3375,10 @@ class SpeechRecognitionManager:
|
||||
self._session_language = self.language
|
||||
|
||||
logger.info("Starting speech recognition")
|
||||
with self._buffer_lock:
|
||||
self._session_cancel = threading.Event()
|
||||
self._worker_exited = threading.Event()
|
||||
self._idle_on_worker_exit = None
|
||||
self._update_state(RecognitionState.LISTENING)
|
||||
|
||||
# Play the start sound
|
||||
@@ -3367,13 +3407,60 @@ class SpeechRecognitionManager:
|
||||
self._arm_playback_duck()
|
||||
return True
|
||||
|
||||
def stop_recognition(self):
|
||||
def is_transcribing(self) -> bool:
|
||||
"""True while a stopped session's audio is still being transcribed."""
|
||||
return self.state == RecognitionState.PROCESSING and not self.should_record
|
||||
|
||||
def cancel_transcription(self) -> bool:
|
||||
"""Abort the current dictation session and discard its text.
|
||||
|
||||
Returns True if something was cancelled.
|
||||
"""
|
||||
if self.state not in (RecognitionState.LISTENING, RecognitionState.PROCESSING):
|
||||
return False
|
||||
|
||||
cancel = self._session_cancel
|
||||
with self._buffer_lock:
|
||||
if (
|
||||
self._session_cancel is not cancel
|
||||
or self.state not in (RecognitionState.LISTENING, RecognitionState.PROCESSING)
|
||||
or cancel.is_set()
|
||||
):
|
||||
return False
|
||||
logger.info("Cancelling transcription")
|
||||
cancel.set()
|
||||
self._idle_on_worker_exit = None
|
||||
segment_queue = self._segment_queue
|
||||
buffered = getattr(self, "_buffered_reload_session", False)
|
||||
if buffered:
|
||||
self._cancel_buffered_session.set()
|
||||
|
||||
while True:
|
||||
try:
|
||||
segment_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
self._signal_recognition_stop()
|
||||
|
||||
if self.should_record:
|
||||
self.stop_recognition()
|
||||
elif not buffered:
|
||||
self._recognition_mode = "toggle"
|
||||
self._update_state(RecognitionState.IDLE)
|
||||
return True
|
||||
|
||||
def stop_recognition(self) -> None:
|
||||
"""Stop the speech recognition process."""
|
||||
if self.state == RecognitionState.IDLE:
|
||||
return
|
||||
|
||||
if getattr(self, "_buffered_reload_session", False) and not self.should_record:
|
||||
return
|
||||
session_cancel = self._session_cancel
|
||||
audio_thread = self.audio_thread
|
||||
recognition_thread = self.recognition_thread
|
||||
worker_exited = getattr(self, "_worker_exited", None)
|
||||
logger.info("Stopping speech recognition")
|
||||
|
||||
# Mark recording over before touching the duck so a concurrent arm
|
||||
@@ -3387,8 +3474,8 @@ class SpeechRecognitionManager:
|
||||
# Wait for audio thread to finish recording and enqueue any pending audio
|
||||
# This is critical to prevent race condition where recognition thread exits
|
||||
# before the final audio segment is enqueued
|
||||
if self.audio_thread and self.audio_thread.is_alive():
|
||||
self.audio_thread.join(timeout=2.0)
|
||||
if audio_thread and audio_thread.is_alive():
|
||||
audio_thread.join(timeout=2.0)
|
||||
|
||||
self.release_playback_duck()
|
||||
|
||||
@@ -3396,34 +3483,53 @@ class SpeechRecognitionManager:
|
||||
# Kept before buffer processing so the cue still feels immediate.
|
||||
play_stop_sound()
|
||||
|
||||
# Trim only a small tail to avoid the stop sound without clipping the user's final word.
|
||||
with self._buffer_lock:
|
||||
stop_sound_guard_chunks = self._get_stop_sound_guard_chunks()
|
||||
if stop_sound_guard_chunks > 0 and len(self.audio_buffer) > stop_sound_guard_chunks:
|
||||
discarded_chunks = self.audio_buffer[-stop_sound_guard_chunks:]
|
||||
self.audio_buffer = self.audio_buffer[:-stop_sound_guard_chunks]
|
||||
logger.debug(
|
||||
"Discarded %s audio chunks (~%sms) to avoid transcribing feedback sound",
|
||||
len(discarded_chunks),
|
||||
self.stop_sound_guard_ms,
|
||||
)
|
||||
if self._session_cancel is session_cancel:
|
||||
# Trim only a small tail to avoid the stop sound without clipping the user's final word.
|
||||
with self._buffer_lock:
|
||||
if self._session_cancel is not session_cancel:
|
||||
return
|
||||
if session_cancel.is_set():
|
||||
if self.audio_buffer:
|
||||
logger.debug("Dropping final audio buffer from a cancelled session")
|
||||
self.audio_buffer = []
|
||||
self._recording_segment_has_speech = False
|
||||
else:
|
||||
stop_sound_guard_chunks = self._get_stop_sound_guard_chunks()
|
||||
if (
|
||||
stop_sound_guard_chunks > 0
|
||||
and len(self.audio_buffer) > stop_sound_guard_chunks
|
||||
):
|
||||
discarded_chunks = self.audio_buffer[-stop_sound_guard_chunks:]
|
||||
self.audio_buffer = self.audio_buffer[:-stop_sound_guard_chunks]
|
||||
logger.debug(
|
||||
"Discarded %s audio chunks (~%sms) to avoid transcribing feedback sound",
|
||||
len(discarded_chunks),
|
||||
self.stop_sound_guard_ms,
|
||||
)
|
||||
|
||||
if self.audio_buffer and self._recording_segment_has_speech:
|
||||
logger.debug(f"Enqueuing final speech buffer with {len(self.audio_buffer)} chunks")
|
||||
self._enqueue_audio_segment(
|
||||
self.audio_buffer,
|
||||
release_gate=(
|
||||
self._key_release_gate if self._recognition_mode == "push_to_talk" else None
|
||||
),
|
||||
)
|
||||
self.audio_buffer = []
|
||||
elif self.audio_buffer:
|
||||
logger.debug(
|
||||
"Dropping final audio buffer with no detected speech "
|
||||
f"({len(self.audio_buffer)} chunks)"
|
||||
)
|
||||
self.audio_buffer = []
|
||||
self._recording_segment_has_speech = False
|
||||
if self.audio_buffer and self._recording_segment_has_speech:
|
||||
logger.debug(
|
||||
f"Enqueuing final speech buffer with {len(self.audio_buffer)} chunks"
|
||||
)
|
||||
self._enqueue_audio_segment(
|
||||
self.audio_buffer,
|
||||
release_gate=(
|
||||
self._key_release_gate
|
||||
if self._recognition_mode == "push_to_talk"
|
||||
else None
|
||||
),
|
||||
)
|
||||
self.audio_buffer = []
|
||||
elif self.audio_buffer:
|
||||
logger.debug(
|
||||
"Dropping final audio buffer with no detected speech "
|
||||
f"({len(self.audio_buffer)} chunks)"
|
||||
)
|
||||
self.audio_buffer = []
|
||||
self._recording_segment_has_speech = False
|
||||
|
||||
if self._session_cancel is not session_cancel:
|
||||
return
|
||||
|
||||
if getattr(self, "_buffered_reload_session", False):
|
||||
# Keep the session busy until its queued audio has been transcribed.
|
||||
@@ -3434,16 +3540,38 @@ class SpeechRecognitionManager:
|
||||
return
|
||||
|
||||
# Wake up recognition thread so it can drain queued segments and stop
|
||||
self._signal_recognition_stop()
|
||||
if not session_cancel.is_set():
|
||||
self._signal_recognition_stop()
|
||||
|
||||
if self.recognition_thread and self.recognition_thread.is_alive():
|
||||
self.recognition_thread.join(timeout=5.0) # Increased timeout for transcription
|
||||
self._signal_recognition_stop()
|
||||
if recognition_thread and recognition_thread.is_alive():
|
||||
recognition_thread.join(timeout=self._STOP_WORKER_JOIN_TIMEOUT_S)
|
||||
if not session_cancel.is_set():
|
||||
self._signal_recognition_stop()
|
||||
|
||||
if self.recognition_thread and self.recognition_thread.is_alive():
|
||||
self.recognition_thread.join(timeout=1.0)
|
||||
if recognition_thread and recognition_thread.is_alive():
|
||||
recognition_thread.join(timeout=self._STOP_WORKER_FINAL_JOIN_TIMEOUT_S)
|
||||
|
||||
self._recognition_mode = "toggle"
|
||||
with self._buffer_lock:
|
||||
if self._session_cancel is not session_cancel:
|
||||
return
|
||||
worker_still_running = (
|
||||
recognition_thread is not None
|
||||
and worker_exited is not None
|
||||
and not worker_exited.is_set()
|
||||
and not session_cancel.is_set()
|
||||
)
|
||||
if worker_still_running:
|
||||
self._idle_on_worker_exit = session_cancel
|
||||
else:
|
||||
self._recognition_mode = "toggle"
|
||||
if worker_still_running:
|
||||
logger.info(
|
||||
"Transcription still running after stop; staying in PROCESSING until it finishes "
|
||||
"or is cancelled"
|
||||
)
|
||||
return
|
||||
if self._session_cancel is not session_cancel:
|
||||
return
|
||||
self._update_state(RecognitionState.IDLE)
|
||||
|
||||
def _track_recognition_worker(self, worker: threading.Thread) -> None:
|
||||
@@ -3580,6 +3708,7 @@ class SpeechRecognitionManager:
|
||||
def _record_audio(self) -> None:
|
||||
"""Record audio from the microphone with reconnection logic."""
|
||||
gate = self._key_release_gate
|
||||
session_cancel = self._session_cancel
|
||||
# Lazy import to avoid circular dependency
|
||||
from ..ui.audio_feedback import play_error_sound # noqa: F401
|
||||
|
||||
@@ -3641,7 +3770,12 @@ class SpeechRecognitionManager:
|
||||
logger.error("This may indicate a problem with the audio device or permissions.")
|
||||
|
||||
# Attempt reconnection
|
||||
if not self._attempt_audio_reconnection(audio):
|
||||
if not self._attempt_audio_reconnection(audio, session_cancel):
|
||||
if not self._capture_session_current(session_cancel):
|
||||
logger.info("Audio capture stopped: its session was cancelled or replaced")
|
||||
if audio is not None:
|
||||
audio.terminate()
|
||||
return
|
||||
self.should_record = False
|
||||
self.release_playback_duck()
|
||||
play_error_sound()
|
||||
@@ -3676,10 +3810,13 @@ class SpeechRecognitionManager:
|
||||
if self._silero_vad is not None:
|
||||
self._silero_vad.reset()
|
||||
|
||||
while self.should_record:
|
||||
while self._capture_session_current(session_cancel):
|
||||
try:
|
||||
# Check buffer size and enforce limits (with lock for thread safety)
|
||||
with self._buffer_lock:
|
||||
# A newer session owns the shared buffer once this one is cancelled.
|
||||
if session_cancel.is_set() or self._session_cancel is not session_cancel:
|
||||
break
|
||||
if len(self.audio_buffer) >= self._max_buffer_size:
|
||||
if getattr(self, "_reload_pending", False):
|
||||
raise RuntimeError("Audio buffer filled while reloading the model")
|
||||
@@ -3692,6 +3829,8 @@ class SpeechRecognitionManager:
|
||||
logger.info(f"Buffer trimmed by {remove_count} chunks")
|
||||
|
||||
data = source.read_chunk()
|
||||
if session_cancel.is_set() or self._session_cancel is not session_cancel:
|
||||
break
|
||||
|
||||
if not self.audio_buffer:
|
||||
# First chunk of a new segment: remember when its
|
||||
@@ -3838,6 +3977,9 @@ class SpeechRecognitionManager:
|
||||
),
|
||||
)
|
||||
except (IOError, OSError) as e:
|
||||
if not self._capture_session_current(session_cancel):
|
||||
logger.info("Audio capture stopped: its session was cancelled or replaced")
|
||||
break
|
||||
current_time = time.time()
|
||||
logger.error(f"Audio device error: {e}")
|
||||
|
||||
@@ -3847,13 +3989,18 @@ class SpeechRecognitionManager:
|
||||
): # Prevent rapid reconnection attempts
|
||||
self._last_audio_error_time = current_time
|
||||
|
||||
if self._attempt_audio_reconnection(audio):
|
||||
if self._attempt_audio_reconnection(audio, session_cancel):
|
||||
logger.info("Audio reconnection successful, continuing recording")
|
||||
# The session source may have been rebuilt (e.g.
|
||||
# the configured device switched between a mic and
|
||||
# a PipeWire sink) — keep reading the new source.
|
||||
source = self._capture_source
|
||||
continue # Continue recording with the reopened source
|
||||
elif not self._capture_session_current(session_cancel):
|
||||
logger.info(
|
||||
"Audio capture stopped: its session was cancelled or replaced"
|
||||
)
|
||||
break
|
||||
else:
|
||||
logger.error("Audio reconnection failed, stopping recording")
|
||||
capture_failed = True
|
||||
@@ -3976,12 +4123,26 @@ class SpeechRecognitionManager:
|
||||
self,
|
||||
gate: Optional[_ReleaseGate],
|
||||
force: bool = False,
|
||||
cancel: Optional[threading.Event] = None,
|
||||
) -> None:
|
||||
"""Deliver queued transcripts once their push-to-talk gate opens."""
|
||||
if gate is None or (not gate.is_set() and not force):
|
||||
"""Deliver queued transcripts once their push-to-talk gate opens.
|
||||
|
||||
Held text from a cancelled session is dropped instead of delivered.
|
||||
"""
|
||||
if gate is None:
|
||||
return
|
||||
if cancel is not None and cancel.is_set():
|
||||
if gate.held:
|
||||
logger.info("Discarding held push-to-talk text from a cancelled session")
|
||||
gate.held.clear()
|
||||
return
|
||||
if not gate.is_set() and not force:
|
||||
return
|
||||
|
||||
while gate.held:
|
||||
if cancel is not None and cancel.is_set():
|
||||
gate.held.clear()
|
||||
return
|
||||
transcript = gate.held.pop(0)
|
||||
self._deliver_transcript(
|
||||
transcript.text,
|
||||
@@ -3998,6 +4159,10 @@ class SpeechRecognitionManager:
|
||||
it was queued (#805); None falls back to the segment's own stamp and
|
||||
then to the live session language.
|
||||
"""
|
||||
cancel_event = getattr(audio_buffer, "cancel_event", None)
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
logger.debug("Skipping audio processing for a cancelled session")
|
||||
return
|
||||
if not audio_buffer:
|
||||
return
|
||||
if language is None:
|
||||
@@ -4023,10 +4188,13 @@ class SpeechRecognitionManager:
|
||||
logger.error(f"Unknown engine: {self.engine}")
|
||||
return
|
||||
text = transcriber()
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
logger.info("Discarding transcription from a cancelled session")
|
||||
return
|
||||
if text is None:
|
||||
gate = getattr(audio_buffer, "release_gate", None)
|
||||
if gate is not None and gate.is_set():
|
||||
self._release_held_transcripts(gate)
|
||||
self._release_held_transcripts(gate, cancel=cancel_event)
|
||||
return
|
||||
|
||||
# Process text - either with voice commands or pass through directly
|
||||
@@ -4045,6 +4213,10 @@ class SpeechRecognitionManager:
|
||||
processed_text = text.strip()
|
||||
actions = []
|
||||
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
logger.info("Discarding transcription from a cancelled session")
|
||||
return
|
||||
|
||||
gate = getattr(audio_buffer, "release_gate", None)
|
||||
if gate is not None and not gate.is_set():
|
||||
if processed_text or actions:
|
||||
@@ -4058,7 +4230,7 @@ class SpeechRecognitionManager:
|
||||
logger.info("Holding transcribed text until the push-to-talk key is released")
|
||||
return
|
||||
elif gate is not None:
|
||||
self._release_held_transcripts(gate)
|
||||
self._release_held_transcripts(gate, cancel=cancel_event)
|
||||
|
||||
self._deliver_transcript(
|
||||
processed_text,
|
||||
@@ -4070,23 +4242,37 @@ class SpeechRecognitionManager:
|
||||
"""Perform speech recognition in real-time."""
|
||||
gate = self._key_release_gate
|
||||
logger.debug("_perform_recognition thread started")
|
||||
cancel = self._session_cancel
|
||||
segment_queue = self._segment_queue
|
||||
worker_exited = getattr(self, "_worker_exited", None)
|
||||
try:
|
||||
while True:
|
||||
if cancel.is_set():
|
||||
logger.debug("Recognition loop exiting for a cancelled session")
|
||||
break
|
||||
logger.debug(
|
||||
f"Recognition loop - should_record={self.should_record}, queue_empty={self._segment_queue.empty()}"
|
||||
f"Recognition loop - should_record={self.should_record}, queue_empty={segment_queue.empty()}"
|
||||
)
|
||||
try:
|
||||
queued = self._segment_queue.get(timeout=0.1)
|
||||
queued = segment_queue.get(timeout=0.1)
|
||||
if cancel.is_set():
|
||||
logger.debug("Recognition loop discarded an item for a cancelled session")
|
||||
break
|
||||
except queue.Empty:
|
||||
self._release_held_transcripts(gate)
|
||||
self._release_held_transcripts(gate, cancel=cancel)
|
||||
# Only exit if we're not recording AND queue is empty
|
||||
if not self.should_record and self._segment_queue.empty():
|
||||
if not self.should_record and 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:
|
||||
queued = self._segment_queue.get(timeout=0.5)
|
||||
queued = segment_queue.get(timeout=0.5)
|
||||
if cancel.is_set():
|
||||
logger.debug(
|
||||
"Recognition loop discarded an item for a cancelled session"
|
||||
)
|
||||
break
|
||||
except queue.Empty:
|
||||
logger.debug("Recognition loop - no more items, exiting")
|
||||
break
|
||||
@@ -4097,9 +4283,17 @@ class SpeechRecognitionManager:
|
||||
if queued is None:
|
||||
logger.debug("Recognition loop - got None signal, draining remaining items...")
|
||||
# Drain any remaining items before exiting
|
||||
while not self._segment_queue.empty():
|
||||
while not segment_queue.empty():
|
||||
if cancel.is_set():
|
||||
logger.debug("Recognition loop drain stopped for a cancelled session")
|
||||
break
|
||||
try:
|
||||
remaining = self._segment_queue.get_nowait()
|
||||
remaining = segment_queue.get_nowait()
|
||||
if cancel.is_set():
|
||||
logger.debug(
|
||||
"Recognition loop discarded a drained item for a cancelled session"
|
||||
)
|
||||
break
|
||||
if remaining is not None:
|
||||
remaining_segment, remaining_language = remaining
|
||||
logger.debug(
|
||||
@@ -4107,7 +4301,7 @@ class SpeechRecognitionManager:
|
||||
)
|
||||
if self.should_record:
|
||||
self._update_state(RecognitionState.PROCESSING)
|
||||
self._release_held_transcripts(gate)
|
||||
self._release_held_transcripts(gate, cancel=cancel)
|
||||
self._process_audio_buffer(remaining_segment, remaining_language)
|
||||
except queue.Empty:
|
||||
break
|
||||
@@ -4118,18 +4312,28 @@ class SpeechRecognitionManager:
|
||||
logger.debug(f"Recognition loop - processing segment with {len(segment)} chunks")
|
||||
if self.should_record:
|
||||
self._update_state(RecognitionState.PROCESSING)
|
||||
self._release_held_transcripts(gate)
|
||||
self._release_held_transcripts(gate, cancel=cancel)
|
||||
self._process_audio_buffer(segment, segment_language)
|
||||
if self.should_record:
|
||||
if self.should_record and self._session_cancel is cancel:
|
||||
self._update_state(RecognitionState.LISTENING)
|
||||
finally:
|
||||
logger.debug("_perform_recognition thread exiting")
|
||||
self._release_held_transcripts(gate, force=True)
|
||||
# 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).
|
||||
self._release_held_transcripts(gate, force=True, cancel=cancel)
|
||||
hand_off = False
|
||||
if worker_exited is not None:
|
||||
with self._buffer_lock:
|
||||
worker_exited.set()
|
||||
hand_off = self._idle_on_worker_exit is cancel
|
||||
if hand_off:
|
||||
self._idle_on_worker_exit = None
|
||||
if (
|
||||
hand_off
|
||||
and self._session_cancel is cancel
|
||||
and not cancel.is_set()
|
||||
and not self.should_record
|
||||
):
|
||||
self._recognition_mode = "toggle"
|
||||
self._update_state(RecognitionState.IDLE)
|
||||
|
||||
def _enqueue_audio_segment(
|
||||
self,
|
||||
@@ -4140,6 +4344,7 @@ class SpeechRecognitionManager:
|
||||
segment = _AudioSegment(
|
||||
audio_buffer.copy(),
|
||||
self._segment_started_at,
|
||||
cancel_event=self._session_cancel,
|
||||
release_gate=release_gate,
|
||||
)
|
||||
if not segment:
|
||||
@@ -4467,12 +4672,29 @@ class SpeechRecognitionManager:
|
||||
if source.audio is not None:
|
||||
self._pyaudio_instance = source.audio
|
||||
|
||||
def _attempt_audio_reconnection(self, audio_instance) -> bool:
|
||||
def _capture_session_current(self, session_cancel: threading.Event) -> bool:
|
||||
"""Whether a capture thread's session is still recording and not cancelled.
|
||||
|
||||
A cancel returns to IDLE without joining the capture thread, so a
|
||||
thread still sleeping in reconnection must not touch a newer session.
|
||||
"""
|
||||
return (
|
||||
self.should_record
|
||||
and self._session_cancel is session_cancel
|
||||
and not session_cancel.is_set()
|
||||
)
|
||||
|
||||
def _attempt_audio_reconnection(
|
||||
self, audio_instance: Any, session_cancel: Optional[threading.Event] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Attempt to reconnect to the audio device.
|
||||
|
||||
Args:
|
||||
audio_instance: The PyAudio instance to use for reconnection
|
||||
session_cancel: The capture thread's session token. When given, a
|
||||
session cancelled or replaced during the backoff gives up
|
||||
without reopening the device.
|
||||
|
||||
Returns:
|
||||
bool: True if reconnection was successful, False otherwise
|
||||
@@ -4494,6 +4716,10 @@ class SpeechRecognitionManager:
|
||||
# Wait before attempting reconnection
|
||||
time.sleep(delay)
|
||||
|
||||
if session_cancel is not None and not self._capture_session_current(session_cancel):
|
||||
logger.info("Skipping audio reconnection for a cancelled or replaced session")
|
||||
return False
|
||||
|
||||
source = self._capture_source_for_session()
|
||||
# The source may not know about the stream it is asked to replace
|
||||
# (e.g. when tests drive this method directly).
|
||||
|
||||
@@ -2523,6 +2523,8 @@ class SettingsDialog(AboutSectionMixin):
|
||||
None
|
||||
)
|
||||
self._test_active = False
|
||||
self._test_token = 0
|
||||
self._test_cancelled = False
|
||||
self._test_result = ""
|
||||
self._initializing = True # Flag to prevent auto-apply during initialization
|
||||
self._populating_models = False # Flag to prevent model change handler during population
|
||||
@@ -5925,12 +5927,22 @@ class SettingsDialog(AboutSectionMixin):
|
||||
self.recognition_audio_level = self.audio_level_bar
|
||||
footer.pack_start(self.audio_level_bar, False, False, 0)
|
||||
|
||||
test_button_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
|
||||
self.test_button = Gtk.Button(label="Test Dictation")
|
||||
self.test_button.set_tooltip_text(
|
||||
"Record 3 seconds of speech and show the transcription here"
|
||||
)
|
||||
self.test_button.connect("clicked", self._on_test_clicked)
|
||||
footer.pack_start(self.test_button, False, False, 0)
|
||||
test_button_row.pack_start(self.test_button, False, False, 0)
|
||||
|
||||
self.test_cancel_button = Gtk.Button(label="Cancel")
|
||||
self.test_cancel_button.set_tooltip_text("Stop the test and discard its result")
|
||||
self.test_cancel_button.connect("clicked", self._on_test_cancel_clicked)
|
||||
self.test_cancel_button.set_no_show_all(True)
|
||||
self.test_cancel_button.hide()
|
||||
test_button_row.pack_start(self.test_cancel_button, False, False, 0)
|
||||
footer.pack_start(test_button_row, False, False, 0)
|
||||
|
||||
# One shared status line (audio test results and recognition info)
|
||||
self.progress_info_label = Gtk.Label(label="", use_markup=True, xalign=0)
|
||||
@@ -9166,7 +9178,7 @@ class SettingsDialog(AboutSectionMixin):
|
||||
|
||||
return settings
|
||||
|
||||
def _on_test_clicked(self, widget):
|
||||
def _on_test_clicked(self, widget: Any) -> None:
|
||||
"""Handle click on the test button."""
|
||||
if self._test_active:
|
||||
logger.warning("Test already in progress.")
|
||||
@@ -9249,9 +9261,13 @@ class SettingsDialog(AboutSectionMixin):
|
||||
return
|
||||
|
||||
self._test_active = True
|
||||
self._test_token += 1
|
||||
self._test_cancelled = False
|
||||
self._test_timeout_cancel_attempted = False
|
||||
self.test_button.set_sensitive(False)
|
||||
self.test_button.set_label("Testing… Speak Now!")
|
||||
self.test_cancel_button.set_sensitive(True)
|
||||
self.test_cancel_button.show()
|
||||
self.test_output_revealer.set_reveal_child(True)
|
||||
self.test_buffer.set_text("")
|
||||
self._test_result = ""
|
||||
@@ -9259,13 +9275,54 @@ class SettingsDialog(AboutSectionMixin):
|
||||
|
||||
# Enough room for one utterance plus the configured silence window.
|
||||
delay = float(selected_settings.get("silence_timeout", 1.2)) + 2.0
|
||||
threading.Thread(target=self._stop_test_after_delay, args=(delay,)).start()
|
||||
threading.Thread(target=self._stop_test_after_delay, args=(delay, self._test_token)).start()
|
||||
|
||||
def _test_text_callback(self, text: str):
|
||||
def _on_test_cancel_clicked(self, widget: Any) -> None:
|
||||
"""Cancel the test and discard any text already captured."""
|
||||
if not self._test_active or self._test_cancelled:
|
||||
return
|
||||
|
||||
self._test_cancelled = True
|
||||
self.test_button.set_sensitive(False)
|
||||
self.test_cancel_button.set_sensitive(False)
|
||||
self.test_button.set_label("Cancelling…")
|
||||
|
||||
def cancel_test() -> None:
|
||||
try:
|
||||
self.speech_engine.cancel_transcription()
|
||||
except (AttributeError, OSError, RuntimeError, TypeError):
|
||||
logger.exception("Test Dictation cancellation failed")
|
||||
GLib.idle_add(self._finish_test_after_user_cancel)
|
||||
|
||||
threading.Thread(
|
||||
target=cancel_test,
|
||||
daemon=True,
|
||||
name="vocalinux-test-dictation-cancel",
|
||||
).start()
|
||||
|
||||
def _finish_test_after_user_cancel(self) -> bool:
|
||||
"""Restore test UI and callbacks without checking for captured speech."""
|
||||
self._test_active = False
|
||||
self._test_timeout_cancel_attempted = False
|
||||
self.test_button.set_sensitive(True)
|
||||
self.test_button.set_label("Test Dictation")
|
||||
self.test_cancel_button.hide()
|
||||
self.test_cancel_button.set_sensitive(True)
|
||||
self.update_recognition_progress("Idle")
|
||||
if hasattr(self, "_saved_text_callbacks"):
|
||||
self.speech_engine.set_text_callbacks(self._saved_text_callbacks)
|
||||
del self._saved_text_callbacks
|
||||
self.speech_engine.test_capture_ceiling = time.monotonic()
|
||||
self.test_buffer.set_text("(Test cancelled)")
|
||||
return False
|
||||
|
||||
def _test_text_callback(self, text: str) -> None:
|
||||
"""Callback specifically for the test recognition."""
|
||||
GLib.idle_add(self._append_test_result, text)
|
||||
|
||||
def _append_test_result(self, text: str):
|
||||
def _append_test_result(self, text: str) -> bool:
|
||||
if getattr(self, "_test_cancelled", False) is True:
|
||||
return False
|
||||
current_text = self.test_buffer.get_text(
|
||||
self.test_buffer.get_start_iter(), self.test_buffer.get_end_iter(), False
|
||||
)
|
||||
@@ -9275,10 +9332,10 @@ class SettingsDialog(AboutSectionMixin):
|
||||
self.test_textview.scroll_to_mark(mark, 0.0, True, 0.0, 1.0)
|
||||
return False
|
||||
|
||||
def _stop_test_after_delay(self, delay: float):
|
||||
def _stop_test_after_delay(self, delay: float, token: Optional[int] = None) -> None:
|
||||
"""Stops the recognition test after a specified delay."""
|
||||
time.sleep(delay)
|
||||
GLib.idle_add(self._finalize_test)
|
||||
GLib.idle_add(self._finalize_test, token)
|
||||
|
||||
def _test_engine_busy(self) -> bool:
|
||||
"""Return whether recognition can still deliver text to the test callback."""
|
||||
@@ -9290,32 +9347,36 @@ class SettingsDialog(AboutSectionMixin):
|
||||
or (callable(has_pending_transcription) and has_pending_transcription() is True)
|
||||
)
|
||||
|
||||
def _finalize_test(self) -> bool:
|
||||
def _finalize_test(self, token: Optional[int] = None) -> bool:
|
||||
"""Finalize the test state and UI updates."""
|
||||
if not self._test_active:
|
||||
if (token is not None and token != getattr(self, "_test_token", 0)) or not getattr(
|
||||
self, "_test_active", False
|
||||
):
|
||||
return False
|
||||
if getattr(self, "_test_cancelled", False):
|
||||
return False
|
||||
|
||||
self.speech_engine.stop_recognition()
|
||||
self.test_button.set_sensitive(False)
|
||||
self.test_button.set_label("Processing…")
|
||||
self.test_cancel_button.set_sensitive(True)
|
||||
self.test_cancel_button.show()
|
||||
self.update_recognition_progress("Processing")
|
||||
|
||||
# Keep test callbacks installed until no worker can deliver the test
|
||||
# utterance. Use GLib polling rather than joining on the GTK thread.
|
||||
if self._test_engine_busy():
|
||||
self.test_button.set_sensitive(False)
|
||||
self.test_button.set_label("Processing…")
|
||||
self.update_recognition_progress("Processing")
|
||||
self._test_idle_wait_ticks = 0
|
||||
self._test_timeout_cancel_attempted = False
|
||||
GLib.timeout_add(100, self._wait_for_idle_then_restore_callbacks)
|
||||
return False
|
||||
def stop_test() -> None:
|
||||
try:
|
||||
self.speech_engine.stop_recognition()
|
||||
except (AttributeError, OSError, RuntimeError, TypeError):
|
||||
logger.exception("Test Dictation stop failed")
|
||||
|
||||
# Briefly settle for any GLib.idle_add text delivery already in flight.
|
||||
GLib.timeout_add(500, self._restore_callbacks_and_check_result)
|
||||
threading.Thread(
|
||||
target=stop_test,
|
||||
daemon=True,
|
||||
name="vocalinux-test-dictation-stop",
|
||||
).start()
|
||||
|
||||
self._test_active = False
|
||||
self.test_button.set_sensitive(True)
|
||||
self.test_button.set_label("Test Dictation")
|
||||
|
||||
self.update_recognition_progress("Idle")
|
||||
self._test_idle_wait_ticks = 0
|
||||
self._test_timeout_cancel_attempted = False
|
||||
GLib.timeout_add(100, self._wait_for_idle_then_restore_callbacks)
|
||||
|
||||
return False
|
||||
|
||||
@@ -9333,10 +9394,14 @@ class SettingsDialog(AboutSectionMixin):
|
||||
test callbacks and ``_test_active`` so live injectors are not restored
|
||||
early and later dictation cannot be hijacked into the test buffer.
|
||||
"""
|
||||
if getattr(self, "_test_cancelled", False) is True:
|
||||
return False
|
||||
|
||||
if not hasattr(self, "_saved_text_callbacks"):
|
||||
self._test_active = False
|
||||
self.test_button.set_sensitive(True)
|
||||
self.test_button.set_label("Test Dictation")
|
||||
self.test_cancel_button.hide()
|
||||
return False
|
||||
|
||||
if self._test_engine_busy():
|
||||
@@ -9430,6 +9495,8 @@ class SettingsDialog(AboutSectionMixin):
|
||||
self._test_timeout_cancel_attempted = False
|
||||
self.test_button.set_sensitive(True)
|
||||
self.test_button.set_label("Test Dictation")
|
||||
self.test_cancel_button.hide()
|
||||
self.test_cancel_button.set_sensitive(True)
|
||||
self.update_recognition_progress("Idle")
|
||||
return self._restore_callbacks_and_check_result()
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ class TrayIndicator:
|
||||
# Tests build TrayIndicator stubs with __new__ that skip __init__; keep a
|
||||
# class-level default so teardown paths like _quit() still find the list.
|
||||
_language_shortcut_managers: list = []
|
||||
_indicator_title: str = "Vocalinux"
|
||||
|
||||
#: Same for the push-to-talk owner slot: a __new__-built stub must answer
|
||||
#: "no binding owns the live session" instead of raising on release paths.
|
||||
@@ -235,6 +236,7 @@ class TrayIndicator:
|
||||
# IconThemePath so gray→red→gray→red actually turns red again.
|
||||
self._last_icon_key: Optional[str] = None
|
||||
self._icon_theme_nudge = False
|
||||
self._indicator_title = "Vocalinux"
|
||||
|
||||
# Register for speech recognition state changes
|
||||
self.speech_engine.register_state_callback(self._on_recognition_state_changed)
|
||||
@@ -557,6 +559,7 @@ class TrayIndicator:
|
||||
self.indicator.set_icon_theme_path(ICON_DIR)
|
||||
self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
|
||||
self.indicator.set_title("Vocalinux")
|
||||
self._indicator_title = "Vocalinux"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create AppIndicator: {e}")
|
||||
GLib.idle_add(self._show_appindicator_error_dialog, str(e))
|
||||
@@ -576,6 +579,11 @@ class TrayIndicator:
|
||||
# Add menu items
|
||||
self._add_menu_item("Start Voice Typing", self._on_start_clicked)
|
||||
self._add_menu_item("Stop Voice Typing", self._on_stop_clicked)
|
||||
self._cancel_menu_item = self._add_menu_item(
|
||||
"Cancel Transcription", self._on_cancel_transcription_clicked
|
||||
)
|
||||
self._cancel_menu_item.set_no_show_all(True)
|
||||
self._cancel_menu_item.hide()
|
||||
self._add_menu_item("Transcribe Audio File\u2026", self._on_transcribe_file_clicked)
|
||||
self._add_menu_separator()
|
||||
|
||||
@@ -706,6 +714,8 @@ class TrayIndicator:
|
||||
|
||||
def _toggle_recognition(self) -> None:
|
||||
"""Toggle the recognition state between IDLE and LISTENING."""
|
||||
if self._cancel_if_transcribing():
|
||||
return
|
||||
with self._ptt_lock:
|
||||
self._ptt_owner = None
|
||||
if self.speech_engine.state == RecognitionState.IDLE:
|
||||
@@ -715,11 +725,23 @@ class TrayIndicator:
|
||||
|
||||
def _start_recognition(self) -> None:
|
||||
"""Start voice recognition (for push-to-talk mode)."""
|
||||
if self._cancel_if_transcribing():
|
||||
return
|
||||
self._push_to_talk_press(
|
||||
self.shortcut_manager,
|
||||
lambda: self.speech_engine.start_recognition(mode="push_to_talk"),
|
||||
)
|
||||
|
||||
def _cancel_if_transcribing(self) -> bool:
|
||||
"""Cancel a running transcription; the recording hotkey doubles as cancel (#991)."""
|
||||
if self.speech_engine.state != RecognitionState.PROCESSING:
|
||||
return False
|
||||
is_transcribing = getattr(self.speech_engine, "is_transcribing", None)
|
||||
if not callable(is_transcribing) or is_transcribing() is not True:
|
||||
return False
|
||||
cancel = getattr(self.speech_engine, "cancel_transcription", None)
|
||||
return callable(cancel) and cancel() is True
|
||||
|
||||
def _push_to_talk_press(
|
||||
self, manager: Optional[KeyboardShortcutManager], start: Callable[[], bool]
|
||||
) -> None:
|
||||
@@ -760,6 +782,8 @@ class TrayIndicator:
|
||||
dictating it stops, matching the main toggle's behaviour regardless
|
||||
of which language the current utterance is in.
|
||||
"""
|
||||
if self._cancel_if_transcribing():
|
||||
return
|
||||
with self._ptt_lock:
|
||||
self._ptt_owner = None
|
||||
if self.speech_engine.state == RecognitionState.IDLE:
|
||||
@@ -771,6 +795,8 @@ class TrayIndicator:
|
||||
self, language: str, manager: Optional[KeyboardShortcutManager] = None
|
||||
) -> None:
|
||||
"""Push-to-talk start for a per-language shortcut (#805)."""
|
||||
if self._cancel_if_transcribing():
|
||||
return
|
||||
self._push_to_talk_press(
|
||||
manager,
|
||||
lambda: self.speech_engine.start_recognition_with_language(
|
||||
@@ -1158,7 +1184,13 @@ class TrayIndicator:
|
||||
self.indicator.set_icon_full(icon_key, description)
|
||||
self._last_icon_key = icon_key
|
||||
|
||||
def _update_ui(self, state: RecognitionState):
|
||||
def _set_indicator_title(self, title: str) -> None:
|
||||
if getattr(self, "_indicator_title", "Vocalinux") == title:
|
||||
return
|
||||
self.indicator.set_title(title)
|
||||
self._indicator_title = title
|
||||
|
||||
def _update_ui(self, state: RecognitionState) -> bool:
|
||||
"""
|
||||
Update the UI based on the recognition state.
|
||||
|
||||
@@ -1181,19 +1213,36 @@ class TrayIndicator:
|
||||
if not hasattr(self, "indicator"):
|
||||
return False
|
||||
|
||||
is_transcribing = getattr(self.speech_engine, "is_transcribing", None)
|
||||
transcribing = callable(is_transcribing) and is_transcribing() is True
|
||||
show_cancel = state == RecognitionState.PROCESSING and transcribing
|
||||
self._set_cancel_menu_item_visible(show_cancel)
|
||||
|
||||
if state == RecognitionState.IDLE:
|
||||
self._set_indicator_title("Vocalinux")
|
||||
self._set_indicator_icon(self._icon_keys["default"], "Microphone off")
|
||||
self._set_menu_item_enabled("Start Voice Typing", True)
|
||||
self._set_menu_item_enabled("Stop Voice Typing", False)
|
||||
elif state == RecognitionState.LISTENING:
|
||||
self._set_indicator_title("Vocalinux")
|
||||
self._set_indicator_icon(self._icon_keys["active"], "Microphone on")
|
||||
self._set_menu_item_enabled("Start Voice Typing", False)
|
||||
self._set_menu_item_enabled("Stop Voice Typing", True)
|
||||
elif state == RecognitionState.PROCESSING:
|
||||
self._set_indicator_icon(self._icon_keys["processing"], "Processing speech")
|
||||
if transcribing:
|
||||
shortcut_manager = getattr(self, "shortcut_manager", None)
|
||||
name = getattr(shortcut_manager, "shortcut_display_name", None)
|
||||
if not isinstance(name, str) or not name:
|
||||
name = "the recording shortcut"
|
||||
description = f"Transcribing, press {name} again to cancel"
|
||||
else:
|
||||
description = "Processing speech"
|
||||
self._set_indicator_title(description if transcribing else "Vocalinux")
|
||||
self._set_indicator_icon(self._icon_keys["processing"], description)
|
||||
self._set_menu_item_enabled("Start Voice Typing", False)
|
||||
self._set_menu_item_enabled("Stop Voice Typing", True)
|
||||
elif state == RecognitionState.ERROR:
|
||||
self._set_indicator_title("Vocalinux")
|
||||
self._set_indicator_icon(self._icon_keys["default"], "Error")
|
||||
self._set_menu_item_enabled("Start Voice Typing", True)
|
||||
self._set_menu_item_enabled("Stop Voice Typing", False)
|
||||
@@ -1236,6 +1285,13 @@ class TrayIndicator:
|
||||
item.set_sensitive(enabled)
|
||||
break
|
||||
|
||||
def _set_cancel_menu_item_visible(self, visible: bool) -> None:
|
||||
item = getattr(self, "_cancel_menu_item", None)
|
||||
if item is None or getattr(self, "menu", None) is None:
|
||||
return
|
||||
item.set_sensitive(visible)
|
||||
item.set_visible(visible)
|
||||
|
||||
def _on_start_clicked(self, widget):
|
||||
"""Handle click on the Start Voice Typing menu item."""
|
||||
logger.debug("Start Voice Typing clicked")
|
||||
@@ -1246,6 +1302,11 @@ class TrayIndicator:
|
||||
logger.debug("Stop Voice Typing clicked")
|
||||
self.speech_engine.stop_recognition()
|
||||
|
||||
def _on_cancel_transcription_clicked(self, widget: Any) -> None:
|
||||
"""Cancel an in-progress transcription from the tray menu."""
|
||||
logger.debug("Cancel Transcription clicked")
|
||||
self.speech_engine.cancel_transcription()
|
||||
|
||||
def _refresh_history_menu(self) -> bool:
|
||||
"""Rebuild the Recent Snippets submenu from the current history."""
|
||||
if self._history_menu_item is None or self.transcription_history is None:
|
||||
|
||||
Reference in New Issue
Block a user