diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index f037f0c..946c933 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -501,9 +501,18 @@ def _open_capture_stream(audio, device_index: Optional[int] = None) -> tuple[int # 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). + # PortAudio/ALSA corruption trigger). Stereo-capable devices must be + # opened at their native layout first: Intel SOF DMICs (Raptor Lake + # "Digital Microphone", etc.) often only support 2ch at the PCM, and + # PortAudio can abort with heap corruption if open() accepts a converted + # 1ch stream and the callback then overruns (#666). reported_channels = int(device_info.get("maxInputChannels", 0) or 0) - channel_options = [1] if reported_channels == 1 else [1, 2] + if reported_channels == 1: + channel_options = [1] + elif reported_channels >= 2: + channel_options = [2, 1] + else: + channel_options = [1, 2] bluetooth = _is_bluetooth_device(device_name) # Brief settle helps BlueZ/Pulse release SCO between failed open attempts. diff --git a/tests/test_recognition_manager_device_config.py b/tests/test_recognition_manager_device_config.py index 4021618..c2c526c 100644 --- a/tests/test_recognition_manager_device_config.py +++ b/tests/test_recognition_manager_device_config.py @@ -334,6 +334,59 @@ class TestAudioDeviceDetection(unittest.TestCase): assert stream is mock_stream assert mock_audio.open.call_count == 1 + def test_open_capture_stream_stereo_device_prefers_native_channels(self): + """Stereo devices must be opened 2ch-first, not converted mono (#666). + + Intel SOF digital mics report maxInputChannels=2. Opening them as + 1ch can succeed then abort in PortAudio's callback with + ``free(): corrupted unsorted chunks``. + """ + mock_audio = MagicMock() + mock_stream = MagicMock() + mock_audio.open.return_value = mock_stream + mock_audio.get_device_info_by_index.return_value = { + "name": "Raptor Lake-P/U/H cAVS Digital Microphone", + "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, 18) + + assert channels == 2 + assert rate == 48000 + assert stream is mock_stream + assert mock_audio.open.call_count == 1 + assert mock_audio.open.call_args.kwargs.get("channels") == 2 + + def test_open_capture_stream_stereo_device_falls_back_to_mono(self): + """If native stereo open fails, still try mono on a 2ch-reporting device.""" + mock_audio = MagicMock() + mock_stream = MagicMock() + + def open_side_effect(**kwargs): + if kwargs.get("channels") == 2: + raise IOError("[Errno -9998] Invalid number of channels") + return mock_stream + + mock_audio.open.side_effect = open_side_effect + mock_audio.get_device_info_by_index.return_value = { + "name": "USB Microphone", + "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 == 1 + assert rate == 48000 + assert stream is mock_stream + assert mock_audio.open.call_args.kwargs.get("channels") == 1 + assert any(call.kwargs.get("channels") == 2 for call in mock_audio.open.call_args_list) + def test_open_capture_stream_mono_device_never_probes_stereo(self): """Mono-only devices must never be opened with 2 channels.