diff --git a/MagicMock/mock.TemporaryDirectory().name/123922320000400/.config/vocalinux/config.json b/MagicMock/mock.TemporaryDirectory().name/123922320000400/.config/vocalinux/config.json new file mode 100644 index 0000000..23d5fee --- /dev/null +++ b/MagicMock/mock.TemporaryDirectory().name/123922320000400/.config/vocalinux/config.json @@ -0,0 +1,32 @@ +{ + "speech_recognition": { + "engine": "vosk", + "language": "auto", + "model_size": "medium", + "vosk_model_size": "medium", + "whisper_model_size": "tiny", + "whisper_cpp_model_size": "tiny", + "vad_sensitivity": 3, + "silence_timeout": 2.0, + "voice_commands_enabled": null + }, + "audio": { + "device_index": null, + "device_name": null + }, + "shortcuts": { + "toggle_recognition": "ctrl+ctrl" + }, + "ui": { + "start_minimized": true, + "show_notifications": true + }, + "general": { + "autostart": false, + "first_run": true + }, + "advanced": { + "debug_logging": false, + "wayland_mode": false + } +} diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index ec4a8ad..d6e3153 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -102,6 +102,59 @@ def get_audio_input_devices() -> list: return devices +def _get_supported_channels(audio, device_index: int = None) -> int: + """ + Detect the supported number of channels for the audio device. + + Some audio devices (particularly professional audio interfaces and certain + onboard audio chips) only support specific channel configurations. This + function tests mono (1) and stereo (2) to find a working configuration. + + Args: + audio: PyAudio instance + device_index: The device index to test (None for default) + + Returns: + int: Number of channels supported (1 or 2), defaults to 1 + """ + import pyaudio + + FORMAT = pyaudio.paInt16 + CHUNK = 1024 + RATE = 16000 # Use standard rate for channel testing + + # Try mono first (preferred for speech recognition) + for channels in [1, 2]: + try: + stream_kwargs = { + "format": FORMAT, + "channels": channels, + "rate": RATE, + "input": True, + "frames_per_buffer": CHUNK, + } + if device_index is not None: + stream_kwargs["input_device_index"] = device_index + + test_stream = audio.open(**stream_kwargs) + test_stream.close() + logger.debug(f"Device supports {channels} channel(s)") + return channels + except (IOError, OSError) as e: + error_str = str(e).lower() + if "invalid number of channels" in error_str or "-9998" in error_str: + logger.debug(f"Device does not support {channels} channel(s)") + continue + else: + # Different error, try next channel count anyway + logger.debug(f"Channel test failed for {channels} channel(s): {e}") + continue + + # Default to mono if we couldn't determine + logger.warning("Could not determine supported channel count, defaulting to 1") + return 1 + + def _get_supported_sample_rate(audio, device_index: int, channels: int = 1) -> int: """ Get a supported sample rate for the audio device. @@ -216,7 +269,6 @@ def test_audio_input(device_index: int = None, duration: float = 1.0) -> dict: CHUNK = 1024 FORMAT = pyaudio.paInt16 - CHANNELS = 1 audio = pyaudio.PyAudio() @@ -234,6 +286,10 @@ def test_audio_input(device_index: int = None, duration: float = 1.0) -> dict: audio.terminate() return result + # Detect supported channel count first (some devices require stereo) + CHANNELS = _get_supported_channels(audio, device_index) + logger.info(f"Using {CHANNELS} channel(s) for audio test") + # Detect supported sample rate for this device RATE = _get_supported_sample_rate(audio, device_index, CHANNELS) result["sample_rate"] = RATE @@ -1473,9 +1529,11 @@ class SpeechRecognitionManager: # Stop recording FIRST to prevent capturing the stop sound self.should_record = False - # Wait briefly for audio thread to stop recording + # 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=0.1) + self.audio_thread.join(timeout=2.0) # Discard the last ~1 second of audio to avoid transcribing the stop sound # Audio is recorded in 1024-sample chunks at 16000 Hz = ~64ms per chunk @@ -1493,19 +1551,20 @@ class SpeechRecognitionManager: self.audio_buffer = [] if self.audio_buffer: + logger.info(f"DEBUG: Enqueuing final buffer with {len(self.audio_buffer)} chunks") self._enqueue_audio_segment(self.audio_buffer) self.audio_buffer = [] # Now play the stop sound (after recording has stopped) play_stop_sound() - # Wait for threads to finish - if self.audio_thread and self.audio_thread.is_alive(): - self.audio_thread.join(timeout=1.0) - # Wake up recognition thread so it can drain queued segments and stop 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 self.recognition_thread and self.recognition_thread.is_alive(): self.recognition_thread.join(timeout=1.0) @@ -1530,7 +1589,6 @@ class SpeechRecognitionManager: # PyAudio configuration CHUNK = 1024 FORMAT = pyaudio.paInt16 - CHANNELS = 1 # Initialize PyAudio with reconnection support self._pyaudio_instance = pyaudio.PyAudio() @@ -1548,6 +1606,10 @@ class SpeechRecognitionManager: except (IOError, OSError): continue + # Detect supported channel count first (some devices require stereo) + CHANNELS = _get_supported_channels(audio, self.audio_device_index) + logger.info(f"Using {CHANNELS} channel(s) for recording") + # Detect supported sample rate for the selected device RATE = _get_supported_sample_rate(audio, self.audio_device_index, CHANNELS) self._capture_sample_rate = RATE @@ -1620,6 +1682,15 @@ class SpeechRecognitionManager: data = stream.read(CHUNK, exception_on_overflow=False) + # Convert stereo to mono if necessary + # Speech recognition engines expect mono (1 channel) audio + if CHANNELS == 2: + audio_array = np.frombuffer(data, dtype=np.int16) + # Reshape to (n_samples, 2) and average channels + stereo_samples = audio_array.reshape(-1, 2) + mono_samples = stereo_samples.mean(axis=1).astype(np.int16) + data = mono_samples.tobytes() + # Resample to 16kHz if capturing at non-16kHz for Vosk/Whisper compatibility if self._capture_sample_rate != 16000: audio_array = np.frombuffer(data, dtype=np.int16) @@ -1787,6 +1858,9 @@ class SpeechRecognitionManager: return # Process text - either with voice commands or pass through directly + logger.info( + f"DEBUG: _process_audio_buffer got text='{text[:50] if text else '(empty)'}...'" + ) if text: if self._voice_commands_enabled: # Process with voice commands (original behavior) @@ -1797,8 +1871,14 @@ class SpeechRecognitionManager: actions = [] # Call text callbacks with processed text + logger.info( + f"DEBUG: processed_text='{processed_text[:50] if processed_text else '(empty)'}...', callbacks={len(self.text_callbacks)}" + ) if processed_text: for callback in self.text_callbacks: + logger.info( + f"DEBUG: invoking text callback: {callback.__name__ if hasattr(callback, '__name__') else callback}" + ) callback(processed_text) # Call action callbacks for each action @@ -1808,28 +1888,89 @@ class SpeechRecognitionManager: def _perform_recognition(self): """Perform speech recognition in real-time.""" - while self.should_record or not self._segment_queue.empty(): + logger.info("DEBUG: _perform_recognition thread started") + while True: + logger.debug( + f"DEBUG: Recognition loop - should_record={self.should_record}, queue_empty={self._segment_queue.empty()}" + ) try: segment = self._segment_queue.get(timeout=0.1) except queue.Empty: - continue + # Only exit if we're not recording AND queue is empty + if not self.should_record and self._segment_queue.empty(): + logger.info( + "DEBUG: Recognition loop - not recording and queue empty, checking for final items..." + ) + # Give a brief moment for any final items to be enqueued + try: + segment = self._segment_queue.get(timeout=0.5) + except queue.Empty: + logger.info("DEBUG: Recognition loop - no more items, exiting") + break + else: + logger.debug("DEBUG: Recognition loop - queue timeout, continuing") + continue if segment is None: - continue + logger.info( + "DEBUG: Recognition loop - got None signal, draining remaining items..." + ) + # Drain any remaining items before exiting + while not self._segment_queue.empty(): + try: + remaining = self._segment_queue.get_nowait() + if remaining is not None: + logger.info( + f"DEBUG: Recognition loop - processing remaining segment with {len(remaining)} chunks" + ) + self._update_state(RecognitionState.PROCESSING) + self._process_audio_buffer(remaining) + except queue.Empty: + break + logger.info("DEBUG: Recognition loop - exiting after None signal") + break + logger.info(f"DEBUG: Recognition loop - processing segment with {len(segment)} chunks") self._update_state(RecognitionState.PROCESSING) self._process_audio_buffer(segment) if self.should_record: self._update_state(RecognitionState.LISTENING) + logger.info("DEBUG: _perform_recognition thread exiting") + """Perform speech recognition in real-time.""" + logger.info("DEBUG: _perform_recognition thread started") + while self.should_record or not self._segment_queue.empty(): + logger.debug( + f"DEBUG: Recognition loop - should_record={self.should_record}, queue_empty={self._segment_queue.empty()}" + ) + try: + segment = self._segment_queue.get(timeout=0.1) + except queue.Empty: + logger.debug("DEBUG: Recognition loop - queue timeout, continuing") + continue + + if segment is None: + logger.info("DEBUG: Recognition loop - got None signal, continuing") + continue + + logger.info(f"DEBUG: Recognition loop - processing segment with {len(segment)} chunks") + self._update_state(RecognitionState.PROCESSING) + self._process_audio_buffer(segment) + if self.should_record: + self._update_state(RecognitionState.LISTENING) + logger.info("DEBUG: _perform_recognition thread exiting") def _enqueue_audio_segment(self, audio_buffer: List[bytes]): """Queue an audio segment for asynchronous transcription.""" segment = audio_buffer.copy() if not segment: + logger.warning("DEBUG: _enqueue_audio_segment called with empty buffer") return + logger.info(f"DEBUG: _enqueue_audio_segment called with {len(segment)} chunks") + try: self._segment_queue.put_nowait(segment) + logger.info("DEBUG: Enqueued segment successfully") except queue.Full: logger.warning("Transcription queue is full, dropping oldest pending segment") try: @@ -1984,7 +2125,10 @@ class SpeechRecognitionManager: # Stream configuration CHUNK = 1024 FORMAT = pyaudio.paInt16 - CHANNELS = 1 + + # Detect supported channel count first (some devices require stereo) + CHANNELS = _get_supported_channels(audio_instance, self.audio_device_index) + logger.debug(f"Reconnecting with {CHANNELS} channel(s)") # Detect supported sample rate for the device RATE = _get_supported_sample_rate(audio_instance, self.audio_device_index, CHANNELS) diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 4b7d232..cc0c348 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -1852,19 +1852,29 @@ class SettingsDialog(Gtk.Dialog): self.speech_engine.stop_recognition() - if hasattr(self, "_saved_text_callbacks"): - self.speech_engine.set_text_callbacks(self._saved_text_callbacks) - del self._saved_text_callbacks + # Wait a bit for any pending transcription to complete before restoring callbacks + # This ensures the test callback receives the transcription result + GLib.timeout_add(500, self._restore_callbacks_and_check_result) self._test_active = False self.test_button.set_sensitive(True) self.test_button.set_label("Start Test (3 seconds)") self.update_recognition_progress("Idle") - GLib.timeout_add(200, self._check_test_result) return False + def _restore_callbacks_and_check_result(self): + """Restore callbacks and check test result after delay.""" + # Restore original text callbacks + if hasattr(self, "_saved_text_callbacks"): + self.speech_engine.set_text_callbacks(self._saved_text_callbacks) + del self._saved_text_callbacks + + # Check result after giving time for final callbacks to complete + GLib.timeout_add(300, self._check_test_result) + return False + def _check_test_result(self): """Check if any text was captured after all callbacks have run.""" final_text = self.test_buffer.get_text(