diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index cf34b41..b749745 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -210,6 +210,57 @@ def _is_virtual_device(device_name: str) -> bool: return any(pattern in name_lower for pattern in virtual_patterns) +def _is_bluetooth_device(device_name: Optional[str]) -> bool: + """Return True if the device name looks like a Bluetooth headset/mic.""" + if not device_name: + return False + name_lower = device_name.lower() + # Prefer explicit Bluetooth/BlueZ markers. Avoid bare "headset" which also + # matches many wired USB headsets that do not need SCO settle delays. + bluetooth_patterns = ( + "bluetooth", + "bluez", + "hands-free", + "handsfree", + ) + return any(pattern in name_lower for pattern in bluetooth_patterns) + + +def _safe_close_stream(stream) -> None: + """Stop and close a PortAudio stream without raising. + + Closing an active stream (especially Bluetooth SCO/HFP capture) without + stopping it first is a known trigger for PortAudio heap corruption and + process abort via malloc assertions. + """ + if stream is None: + return + try: + stop = getattr(stream, "stop_stream", None) + if callable(stop): + stop() + except Exception: + pass + try: + close = getattr(stream, "close", None) + if callable(close): + close() + except Exception: + pass + + +def _get_device_info_safe(audio, device_index: Optional[int] = None) -> dict: + """Fetch PortAudio device info, returning {} on failure.""" + try: + if device_index is not None: + info = audio.get_device_info_by_index(device_index) + else: + info = audio.get_default_input_device_info() + return info if isinstance(info, dict) else {} + except (IOError, OSError, TypeError, ValueError, AttributeError): + return {} + + def get_audio_input_devices() -> list: """ Get a list of available audio input devices, excluding virtual devices. @@ -357,52 +408,63 @@ def _resolve_valid_input_device(audio, preferred_index: Optional[int] = None) -> return input_device_indices[0] -def _get_supported_channels(audio, device_index: Optional[int] = None) -> int: +def _open_capture_stream(audio, device_index: Optional[int] = None) -> tuple[int, int, object]: """ - Detect the supported number of channels for the audio device. + Negotiate a working (channels, sample_rate) and return the opened stream. - 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. + Historically Vocalinux probed channels and sample rates separately, each + opening and closing PortAudio streams, then opened the real capture stream + on top — three open/close cycles in under a second. Bluetooth headset + capture (SCO/HFP / PipeWire "Bluetooth internal capture stream") is + especially sensitive to that pattern and can abort the process with malloc + heap corruption (see GitHub issue #567). - Pro-audio USB interfaces (MUPRO, Vocaster, etc.) often only support 48kHz - and will reject 16kHz probes. This function uses the device's default - sample rate first, then falls back to common rates. + This function opens PortAudio exactly once per capture session: candidate + formats are tried in order (device default rate first, mono before stereo, + stereo skipped entirely for mono-only devices) and the FIRST successfully + opened stream is returned to the caller for actual capture — never closed + and reopened. Args: audio: PyAudio instance - device_index: The device index to test (None for default) + device_index: The device index to open (None for default) Returns: - int: Number of channels supported (1 or 2), defaults to 1 + Tuple of (channels, sample_rate, stream). If no format works, returns + (1, 16000, None) and the caller may attempt its own fallback open. """ import pyaudio FORMAT = pyaudio.paInt16 CHUNK = 1024 - COMMON_RATES = [48000, 44100, 32000, 22050, 16000, 8000] - rates_to_try = [] - try: - if device_index is not None: - device_info = audio.get_device_info_by_index(device_index) - else: - device_info = audio.get_default_input_device_info() + device_info = _get_device_info_safe(audio, device_index) + device_name = device_info.get("name") + rates_to_try: list[int] = [] - default_rate = int(device_info.get("defaultSampleRate", 0)) - if default_rate > 0: - rates_to_try.append(default_rate) - logger.debug(f"Device reports default sample rate: {default_rate}Hz") - except (IOError, OSError) as e: - logger.debug(f"Could not get device info for channel probing: {e}") + default_rate = int(device_info.get("defaultSampleRate", 0) or 0) + if default_rate > 0: + rates_to_try.append(default_rate) + logger.debug(f"Device reports default sample rate: {default_rate}Hz") for rate in COMMON_RATES: if rate not in rates_to_try: rates_to_try.append(rate) - for channels in [1, 2]: + # Never probe stereo on a device that reports a single input channel + # (opening with more channels than supported is itself a known + # PortAudio/ALSA corruption trigger). + reported_channels = int(device_info.get("maxInputChannels", 0) or 0) + channel_options = [1] if reported_channels == 1 else [1, 2] + + bluetooth = _is_bluetooth_device(device_name) + # Brief settle helps BlueZ/Pulse release SCO between failed open attempts. + settle_s = 0.15 if bluetooth else 0.0 + + for channels in channel_options: for rate in rates_to_try: + stream = None try: stream_kwargs = { "format": FORMAT, @@ -414,20 +476,41 @@ def _get_supported_channels(audio, device_index: Optional[int] = None) -> int: 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) at {rate}Hz") - return channels + stream = audio.open(**stream_kwargs) + logger.debug(f"Opened capture stream: {channels} channel(s) at {rate}Hz") + return channels, rate, stream except (IOError, OSError) as e: + _safe_close_stream(stream) error_str = str(e).lower() if "invalid number of channels" in error_str or "-9998" in error_str: logger.debug(f"Device rejected {channels} channel(s) at {rate}Hz: {e}") else: - logger.debug(f"Channel test failed at {rate}Hz: {e}") - continue + logger.debug(f"Capture open failed ({channels}ch @ {rate}Hz): {e}") + if settle_s: + time.sleep(settle_s) - logger.warning("Could not determine supported channel count, defaulting to 1") - return 1 + logger.warning("Could not open capture stream, defaulting to 1ch/16000Hz") + return 1, 16000, None + + +def _get_supported_channels(audio, device_index: Optional[int] = None) -> int: + """ + Detect the supported number of channels for the audio device. + + Production code should use :func:`_open_capture_stream`, which keeps the + negotiated stream open instead of closing and reopening it. This helper is + retained for standalone format queries. + + 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 + """ + channels, _rate, stream = _open_capture_stream(audio, device_index) + _safe_close_stream(stream) + return channels def _get_supported_sample_rate(audio, device_index: Optional[int], channels: int = 1) -> int: @@ -438,6 +521,10 @@ def _get_supported_sample_rate(audio, device_index: Optional[int], channels: int (e.g., 48kHz) and will fail with the default 16kHz. This function tests common sample rates and returns the highest supported one. + Production code should use :func:`_open_capture_stream`, which negotiates + channels and rate with a single PortAudio open. This helper is retained + for standalone format queries. + Args: audio: PyAudio instance device_index: The device index to test @@ -454,38 +541,21 @@ def _get_supported_sample_rate(audio, device_index: Optional[int], channels: int # Common sample rates to try, ordered from highest to lowest quality COMMON_RATES = [48000, 44100, 32000, 22050, 16000, 8000] - # First, try the device's default sample rate - try: - if device_index is not None: - device_info = audio.get_device_info_by_index(device_index) - else: - device_info = audio.get_default_input_device_info() + device_info = _get_device_info_safe(audio, device_index) + device_name = device_info.get("name") + bluetooth = _is_bluetooth_device(device_name) + settle_s = 0.15 if bluetooth else 0.0 - default_rate = int(device_info.get("defaultSampleRate", 0)) - if default_rate > 0 and default_rate in COMMON_RATES: - # Test if the default rate actually works - try: - stream_kwargs = { - "format": FORMAT, - "channels": channels, - "rate": default_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"Using device default sample rate: {default_rate}Hz") - return default_rate - except (IOError, OSError): - logger.debug(f"Device default rate {default_rate}Hz failed, trying common rates") - except (IOError, OSError) as e: - logger.debug(f"Could not get device default rate: {e}") - - # Try common sample rates in order of preference + rates_to_try: list[int] = [] + default_rate = int(device_info.get("defaultSampleRate", 0) or 0) + if default_rate > 0: + rates_to_try.append(default_rate) for rate in COMMON_RATES: + if rate not in rates_to_try: + rates_to_try.append(rate) + + for rate in rates_to_try: + test_stream = None try: stream_kwargs = { "format": FORMAT, @@ -498,11 +568,15 @@ def _get_supported_sample_rate(audio, device_index: Optional[int], channels: int stream_kwargs["input_device_index"] = device_index test_stream = audio.open(**stream_kwargs) - test_stream.close() + _safe_close_stream(test_stream) + test_stream = None logger.debug(f"Found supported sample rate: {rate}Hz") return rate except (IOError, OSError): - continue + if settle_s: + time.sleep(settle_s) + finally: + _safe_close_stream(test_stream) # Fallback to 16kHz if nothing works logger.warning("Could not find supported sample rate, defaulting to 16000Hz") @@ -561,31 +635,32 @@ 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) + # Negotiate the format and open the capture stream in ONE PortAudio + # open — Bluetooth SCO devices abort with heap corruption when the + # stream is opened, closed, and quickly reopened (issue #567). + CHANNELS, RATE, stream = _open_capture_stream(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 - # Open stream - 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 + if stream is None: + # Negotiation failed; try one last plain open so the error + # message reflects the real failure. + 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 - stream = audio.open(**stream_kwargs) - except (IOError, OSError) as e: - result["error"] = f"Cannot open audio stream: {e}" - audio.terminate() - return result + stream = audio.open(**stream_kwargs) + except (IOError, OSError) as e: + result["error"] = f"Cannot open audio stream: {e}" + audio.terminate() + return result # Record and analyze all_amplitudes = [] @@ -601,8 +676,7 @@ def test_audio_input(device_index: int = None, duration: float = 1.0) -> dict: result["error"] = f"Error reading audio: {e}" break - stream.stop_stream() - stream.close() + _safe_close_stream(stream) audio.terminate() if all_amplitudes: @@ -2460,32 +2534,14 @@ class SpeechRecognitionManager: except (IOError, OSError): continue - # Detect supported channel count first (some devices require stereo) - CHANNELS = _get_supported_channels(audio, resolved_device_index) + # Negotiate the format and open the capture stream in ONE PortAudio + # open — Bluetooth SCO devices abort with heap corruption when the + # stream is opened, closed, and quickly reopened (issue #567). + CHANNELS, RATE, negotiated_stream = _open_capture_stream(audio, resolved_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, resolved_device_index, CHANNELS) self._capture_sample_rate = RATE logger.info(f"Using sample rate: {RATE}Hz") - # Open microphone stream with optional device selection and reconnection logic - stream_kwargs = { - "format": FORMAT, - "channels": CHANNELS, - "rate": RATE, - "input": True, - "frames_per_buffer": CHUNK, - } - - # Use the resolved device (skip if already system default) - try: - default_idx = audio.get_default_input_device_info().get("index") - except (IOError, OSError): - default_idx = None - if resolved_device_index != default_idx: - stream_kwargs["input_device_index"] = resolved_device_index - try: device_info = audio.get_device_info_by_index(resolved_device_index) logger.info( @@ -2494,21 +2550,45 @@ class SpeechRecognitionManager: except (IOError, OSError): logger.warning(f"Could not get info for device index {resolved_device_index}") - try: - self._audio_stream = audio.open(**stream_kwargs) - stream = self._audio_stream - except (IOError, OSError) as e: - logger.error(f"Failed to open audio stream: {e}") - logger.error("This may indicate a problem with the audio device or permissions.") + if negotiated_stream is not None: + self._audio_stream = negotiated_stream + stream = negotiated_stream + else: + # Negotiation failed; fall back to a plain open so the + # existing reconnection/error path still applies. + stream_kwargs = { + "format": FORMAT, + "channels": CHANNELS, + "rate": RATE, + "input": True, + "frames_per_buffer": CHUNK, + } - # Attempt reconnection - if self._attempt_audio_reconnection(audio): + # Use the resolved device (skip if already system default) + try: + default_idx = audio.get_default_input_device_info().get("index") + except (IOError, OSError): + default_idx = None + if resolved_device_index != default_idx: + stream_kwargs["input_device_index"] = resolved_device_index + + try: + self._audio_stream = audio.open(**stream_kwargs) stream = self._audio_stream - else: - play_error_sound() - audio.terminate() - self._update_state(RecognitionState.ERROR) - return + except (IOError, OSError) as e: + logger.error(f"Failed to open audio stream: {e}") + logger.error( + "This may indicate a problem with the audio device or permissions." + ) + + # Attempt reconnection + if self._attempt_audio_reconnection(audio): + stream = self._audio_stream + else: + play_error_sound() + audio.terminate() + self._update_state(RecognitionState.ERROR) + return logger.info("Audio recording started") @@ -3089,33 +3169,33 @@ class SpeechRecognitionManager: CHUNK = 1024 FORMAT = pyaudio.paInt16 - # Detect supported channel count first (some devices require stereo) - CHANNELS = _get_supported_channels(audio_instance, resolved_device_index) + # Negotiate the format and open the capture stream in ONE PortAudio + # open — Bluetooth SCO devices abort with heap corruption when the + # stream is opened, closed, and quickly reopened (issue #567). + CHANNELS, RATE, new_stream = _open_capture_stream(audio_instance, resolved_device_index) logger.debug(f"Reconnecting with {CHANNELS} channel(s)") - - # Detect supported sample rate for the device - RATE = _get_supported_sample_rate(audio_instance, resolved_device_index, CHANNELS) self._capture_sample_rate = RATE logger.debug(f"Reconnecting with sample rate: {RATE}Hz") - stream_kwargs = { - "format": FORMAT, - "channels": CHANNELS, - "rate": RATE, - "input": True, - "frames_per_buffer": CHUNK, - } + if new_stream is None: + # Negotiation failed; fall back to a plain open. + stream_kwargs = { + "format": FORMAT, + "channels": CHANNELS, + "rate": RATE, + "input": True, + "frames_per_buffer": CHUNK, + } - # Use resolved device (skip if already system default) - try: - default_idx = audio_instance.get_default_input_device_info().get("index") - except (IOError, OSError): - default_idx = None - if resolved_device_index != default_idx: - stream_kwargs["input_device_index"] = resolved_device_index + # Use resolved device (skip if already system default) + try: + default_idx = audio_instance.get_default_input_device_info().get("index") + except (IOError, OSError): + default_idx = None + if resolved_device_index != default_idx: + stream_kwargs["input_device_index"] = resolved_device_index - # Attempt to open new stream - new_stream = audio_instance.open(**stream_kwargs) + new_stream = audio_instance.open(**stream_kwargs) # Test the stream by reading a small amount of data test_data = new_stream.read(CHUNK, exception_on_overflow=False) @@ -3126,11 +3206,7 @@ class SpeechRecognitionManager: return True else: logger.error("Reconnected stream returned no data") - try: - new_stream.stop_stream() - new_stream.close() - except Exception: - pass + _safe_close_stream(new_stream) return False except (IOError, OSError) as e: diff --git a/tests/test_recognition_manager_device_config.py b/tests/test_recognition_manager_device_config.py index 666e534..f83e858 100644 --- a/tests/test_recognition_manager_device_config.py +++ b/tests/test_recognition_manager_device_config.py @@ -21,7 +21,7 @@ import threading import time import unittest from unittest import mock -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, call, patch import pytest @@ -31,7 +31,11 @@ from vocalinux.speech_recognition.recognition_manager import ( _filter_non_speech, _get_supported_channels, _get_supported_sample_rate, + _is_bluetooth_device, + _open_capture_stream, + _safe_close_stream, get_audio_input_devices, + test_audio_input, ) @@ -48,6 +52,230 @@ def _make_manager(engine="whisper_cpp", **kw): class TestAudioDeviceDetection(unittest.TestCase): """Test audio device enumeration functions.""" + def test_is_bluetooth_device_detection(self): + """Bluetooth headset/mic names should be recognized (Issue #567).""" + assert _is_bluetooth_device("Bluetooth internal capture stream for HUAWEI FreeBuds Pro 3") + assert _is_bluetooth_device("bluez_source.XX_XX_XX") + assert _is_bluetooth_device("Hands-Free AG Speech") + assert not _is_bluetooth_device("WH-1000XM5 Headset") # no bluetooth marker + assert not _is_bluetooth_device("USB Audio Device") + assert not _is_bluetooth_device(None) + assert not _is_bluetooth_device("") + + def test_safe_close_stream_stops_before_close(self): + """PortAudio streams must be stopped before close to avoid heap corruption.""" + mock_stream = MagicMock() + _safe_close_stream(mock_stream) + mock_stream.stop_stream.assert_called_once() + mock_stream.close.assert_called_once() + # stop must happen before close + assert mock_stream.mock_calls.index(mock.call.stop_stream()) < mock_stream.mock_calls.index( + mock.call.close() + ) + + def test_safe_close_stream_tolerates_errors(self): + """Cleanup must not raise even if stop/close fail.""" + mock_stream = MagicMock() + mock_stream.stop_stream.side_effect = OSError("already stopped") + mock_stream.close.side_effect = OSError("already closed") + _safe_close_stream(mock_stream) # should not raise + _safe_close_stream(None) # should not raise + + def test_open_capture_stream_returns_live_stream(self): + """Negotiation must return the opened stream, never close-and-reopen.""" + mock_audio = MagicMock() + mock_stream = MagicMock() + mock_audio.open.return_value = mock_stream + mock_audio.get_device_info_by_index.return_value = { + "name": "USB Mic", + "defaultSampleRate": 48000, + "maxInputChannels": 1, + } + mock_pyaudio = MagicMock(paInt16=8) + + with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}): + channels, rate, stream = _open_capture_stream(mock_audio, 0) + assert channels == 1 + assert rate == 48000 + assert stream is mock_stream + assert mock_audio.open.call_count == 1 + # The negotiated stream is handed to the caller live. + mock_stream.stop_stream.assert_not_called() + mock_stream.close.assert_not_called() + + def test_open_capture_stream_bluetooth_single_open(self): + """Bluetooth SCO capture must be opened exactly once (Issue #567). + + The previous channels-then-rate-then-real-open flow opened the SCO + capture stream three times in under a second, which aborts with + malloc heap corruption on devices like the HUAWEI FreeBuds Pro 3. + """ + mock_audio = MagicMock() + mock_stream = MagicMock() + mock_audio.open.return_value = mock_stream + mock_audio.get_device_info_by_index.return_value = { + "name": "Bluetooth internal capture stream for HUAWEI FreeBuds Pro 3", + "defaultSampleRate": 16000, + "maxInputChannels": 1, + } + mock_pyaudio = MagicMock(paInt16=8) + + with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}): + channels, rate, stream = _open_capture_stream(mock_audio, 14) + assert channels == 1 + assert rate == 16000 + assert stream is mock_stream + assert mock_audio.open.call_count == 1 + + def test_open_capture_stream_mono_device_never_probes_stereo(self): + """Mono-only devices must never be opened with 2 channels. + + Opening with more channels than the device supports is itself a + PortAudio/ALSA heap-corruption trigger. + """ + mock_audio = MagicMock() + mock_audio.open.side_effect = IOError("cannot open") + mock_audio.get_device_info_by_index.return_value = { + "name": "Bluetooth internal capture stream", + "defaultSampleRate": 16000, + "maxInputChannels": 1, + } + mock_pyaudio = MagicMock(paInt16=8) + + with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}): + with patch("vocalinux.speech_recognition.recognition_manager.time.sleep"): + channels, rate, stream = _open_capture_stream(mock_audio, 14) + assert stream is None + assert (channels, rate) == (1, 16000) + assert all(call.kwargs.get("channels") == 1 for call in mock_audio.open.call_args_list) + + def test_open_capture_stream_failure_returns_none_stream(self): + """Total negotiation failure returns (1, 16000, None) for caller fallback.""" + mock_audio = MagicMock() + mock_audio.open.side_effect = IOError("no device") + mock_audio.get_device_info_by_index.side_effect = IOError("gone") + mock_pyaudio = MagicMock(paInt16=8) + + with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}): + channels, rate, stream = _open_capture_stream(mock_audio, 3) + assert (channels, rate, stream) == (1, 16000, None) + + def test_test_audio_input_single_portaudio_open(self): + """The mic test must perform exactly ONE PortAudio open (Issue #567).""" + from vocalinux.speech_recognition.recognition_manager import test_audio_input + + mock_stream = MagicMock() + mock_stream.read.return_value = b"\x00\x01" * 1024 + mock_audio = MagicMock() + mock_audio.open.return_value = mock_stream + mock_audio.get_device_info_by_index.return_value = { + "index": 14, + "name": "Bluetooth internal capture stream for HUAWEI FreeBuds Pro 3", + "defaultSampleRate": 16000, + "maxInputChannels": 1, + } + mock_pyaudio = MagicMock(paInt16=8) + mock_pyaudio.PyAudio.return_value = mock_audio + + with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}): + # Other test modules poison sys.modules["numpy"] with a MagicMock + # at import time; drop it so test_audio_input gets real numpy. + # patch.dict restores the original sys.modules afterwards. + if isinstance(sys.modules.get("numpy"), MagicMock): + del sys.modules["numpy"] + result = test_audio_input(device_index=14, duration=0.1) + + assert result["success"] is True + assert mock_audio.open.call_count == 1 + # The stream that was read from is the negotiated stream itself. + assert mock_stream.read.called + mock_stream.stop_stream.assert_called_once() + mock_stream.close.assert_called_once() + + def test_test_audio_input_negotiation_fallback_open(self): + """When negotiation returns no stream, mic test falls back to a plain open.""" + mock_stream = MagicMock() + mock_stream.read.return_value = b"\x00\x01" * 1024 + mock_audio = MagicMock() + mock_audio.open.return_value = mock_stream + mock_audio.get_device_info_by_index.return_value = { + "index": 14, + "name": "Bluetooth internal capture stream", + "defaultSampleRate": 16000, + "maxInputChannels": 1, + } + mock_pyaudio = MagicMock(paInt16=8) + mock_pyaudio.PyAudio.return_value = mock_audio + + with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}): + if isinstance(sys.modules.get("numpy"), MagicMock): + del sys.modules["numpy"] + with patch( + "vocalinux.speech_recognition.recognition_manager._open_capture_stream", + return_value=(1, 16000, None), + ): + result = test_audio_input(device_index=14, duration=0.1) + + assert result["success"] is True + mock_audio.open.assert_called_once() + + def test_test_audio_input_negotiation_and_fallback_both_fail(self): + """When negotiation and fallback open both fail, return a clear error.""" + mock_audio = MagicMock() + mock_audio.open.side_effect = IOError("device busy") + mock_audio.get_device_info_by_index.return_value = { + "index": 14, + "name": "Bluetooth internal capture stream", + "defaultSampleRate": 16000, + "maxInputChannels": 1, + } + mock_pyaudio = MagicMock(paInt16=8) + mock_pyaudio.PyAudio.return_value = mock_audio + + with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}): + with patch( + "vocalinux.speech_recognition.recognition_manager._open_capture_stream", + return_value=(1, 16000, None), + ): + result = test_audio_input(device_index=14, duration=0.1) + + assert result["success"] is False + assert "Cannot open audio stream" in result["error"] + + def test_get_supported_sample_rate_bluetooth_settle_on_failure(self): + """Bluetooth devices should pause between failed sample-rate probes.""" + mock_audio = MagicMock() + mock_audio.open.side_effect = IOError("busy") + mock_audio.get_device_info_by_index.return_value = { + "name": "Bluetooth internal capture stream", + "defaultSampleRate": 48000, + } + mock_pyaudio = MagicMock(paInt16=8) + + with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}): + with patch("vocalinux.speech_recognition.recognition_manager.time.sleep") as mock_sleep: + rate = _get_supported_sample_rate(mock_audio, 0, channels=1) + + assert rate == 16000 + assert mock_sleep.called + + def test_open_capture_stream_logs_channel_rejection(self): + """Invalid-channel errors during negotiation should be logged distinctly.""" + mock_audio = MagicMock() + mock_audio.open.side_effect = IOError("[Errno -9998] Invalid number of channels") + mock_audio.get_device_info_by_index.return_value = { + "name": "USB Mic", + "defaultSampleRate": 48000, + "maxInputChannels": 2, + } + mock_pyaudio = MagicMock(paInt16=8) + + with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}): + channels, rate, stream = _open_capture_stream(mock_audio, 0) + + assert (channels, rate, stream) == (1, 16000, None) + assert mock_audio.open.call_count >= 1 + def test_get_supported_channels_mono_success(self): """Test mono channel support detection.""" mock_audio = MagicMock() @@ -207,6 +435,59 @@ class TestAudioDeviceDetection(unittest.TestCase): assert rate == 16000 # Default fallback +class TestRecordAudioNegotiationFallback(unittest.TestCase): + """Cover _record_audio fallback when stream negotiation returns None.""" + + def test_record_audio_falls_back_when_negotiation_returns_none(self): + """Negotiation failure must fall back to a plain PortAudio open.""" + manager = _make_manager(engine="whisper_cpp") + manager.should_record = True + manager.state = RecognitionState.LISTENING + manager.silence_timeout = 0.05 + manager._silero_vad = None + + mock_stream = MagicMock() + + def _read_once(*_args, **_kwargs): + manager.should_record = False + return b"\x00" * 2048 + + mock_stream.read.side_effect = _read_once + + mock_audio = MagicMock() + mock_audio.get_device_count.return_value = 1 + mock_audio.get_device_info_by_index.return_value = { + "index": 0, + "name": "test mic", + "maxInputChannels": 1, + } + mock_audio.get_default_input_device_info.return_value = {"index": 0} + mock_audio.open.return_value = mock_stream + + mock_pyaudio = MagicMock(paInt16=8) + mock_pyaudio.PyAudio.return_value = mock_audio + + with ( + patch.dict("sys.modules", {"pyaudio": mock_pyaudio}), + patch( + "vocalinux.speech_recognition.recognition_manager._resolve_device_by_name", + return_value=0, + ), + patch( + "vocalinux.speech_recognition.recognition_manager._open_capture_stream", + return_value=(1, 16000, None), + ), + patch( + "vocalinux.speech_recognition.recognition_manager.play_error_sound", + ), + ): + if isinstance(sys.modules.get("numpy"), MagicMock): + del sys.modules["numpy"] + manager._record_audio() + + mock_audio.open.assert_called_once() + + class TestFilterNonSpeech(unittest.TestCase): """Test the _filter_non_speech function.""" diff --git a/tests/test_recognition_manager_downloads.py b/tests/test_recognition_manager_downloads.py index 59af5e1..4a3e5bb 100644 --- a/tests/test_recognition_manager_downloads.py +++ b/tests/test_recognition_manager_downloads.py @@ -481,6 +481,50 @@ class TestAudioReconnection: assert second_delay > first_delay assert second_delay == first_delay * 2 + def test_attempt_audio_reconnection_negotiation_fallback(self): + """When negotiation returns no stream, reconnect falls back to plain open.""" + manager = _make_manager(engine="whisper_cpp") + + mock_pyaudio_mod = MagicMock() + mock_pyaudio_mod.paInt16 = 8 + mock_stream = MagicMock() + mock_stream.read.return_value = b"\x00" * 1024 + mock_audio_instance = MagicMock() + mock_audio_instance.open.return_value = mock_stream + + with ( + patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}), + patch("time.sleep"), + patch( + "vocalinux.speech_recognition.recognition_manager._open_capture_stream", + return_value=(1, 16000, None), + ), + ): + result = manager._attempt_audio_reconnection(mock_audio_instance) + + assert result is True + assert manager._audio_stream == mock_stream + mock_audio_instance.open.assert_called_once() + + def test_attempt_audio_reconnection_empty_read_closes_stream(self): + """A reconnected stream that returns no data must be closed safely.""" + manager = _make_manager(engine="whisper_cpp") + + mock_pyaudio_mod = MagicMock() + mock_pyaudio_mod.paInt16 = 8 + mock_stream = MagicMock() + mock_stream.read.return_value = b"" + mock_audio_instance = MagicMock() + mock_audio_instance.open.return_value = mock_stream + + with patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}): + with patch("time.sleep"): + result = manager._attempt_audio_reconnection(mock_audio_instance) + + assert result is False + mock_stream.stop_stream.assert_called_once() + mock_stream.close.assert_called_once() + class TestIBusEngineUtilities: """Test ibus_engine utility functions."""