fix(audio): detect and use device-supported channel count (#275)
* fix(audio): detect and use device-supported channel count This fixes the "Invalid number of channels" (PyAudio error -9998) that occurs with certain audio devices (e.g., HD-Audio Generic interfaces) that require stereo (2 channel) input instead of mono (1 channel). Changes: - Add _get_supported_channels() helper function to detect supported channel configuration by testing mono first, then falling back to stereo - Update test_audio_input() to detect channels before opening stream - Update _record_audio() to detect channels and convert stereo to mono - Update _attempt_audio_reconnection() to use detected channel count - Add stereo-to-mono conversion in audio processing loop to ensure speech recognition engines receive mono audio as expected Fixes issues introduced by voice commands feature where audio settings test would fail with "no speech detected" because the audio stream couldn't be opened due to channel mismatch. Closes audio device compatibility issues * fix(settings): delay callback restoration to prevent race condition in test The recognition test was restoring text callbacks immediately after stop_recognition(), but the recognition thread has a 1-second timeout. If whisper.cpp transcription took longer than 1 second, the callbacks would be restored before transcription completed, causing the text to be sent to the wrong callback (the main app callback instead of the test callback). Changes: - Add 500ms delay before restoring callbacks to allow transcription to complete - Add new _restore_callbacks_and_check_result() helper method - Increase result check delay from 200ms to 300ms for more reliable detection This fixes the "No speech detected during test" issue where audio was being recorded and transcribed, but the result wasn't being captured by the test callback. * debug: add diagnostic logging to trace recognition test flow * fix(recognition): prevent race condition between audio enqueue and recognition thread - Increase audio thread join timeout from 0.1s to 2.0s to ensure audio thread fully exits - Remove redundant second audio thread join - Add comprehensive debug logging to trace recognition thread lifecycle - Add logging for final buffer enqueue to diagnose race conditions The race condition occurred because: 1. should_record=False was set 2. Recognition thread could check queue.empty() before audio was enqueued 3. Recognition thread would exit before processing the final audio segment This fix ensures the audio thread completes before we process the final buffer. * fix(recognition): use poison pill pattern to prevent premature thread exit The recognition thread was exiting immediately when should_record=False and the queue was empty, even if audio was about to be enqueued. Changes: - Recognition loop now runs until it receives None signal (poison pill) - When queue times out and should_record=False, waits 0.5s for final items - When None is received, drains all remaining queue items before exiting - Increased recognition thread join timeout from 1s to 5s for transcription This ensures the recognition thread stays alive long enough to process the final audio buffer that is enqueued after should_record is set to False.
This commit is contained in:
+32
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user