fix(audio): open stereo mics at native channel count (#673)

Prefer 2ch before 1ch when PortAudio reports two or more input
channels. Intel SOF digital mics (the Raptor Lake device in #666)
often only support stereo at the PCM; opening them as converted
mono can abort with heap corruption right after capture starts.

Mono-only devices are still never probed with 2 channels (#567).
This commit is contained in:
Jatin K Malik
2026-08-13 00:04:55 -07:00
committed by GitHub
parent 00680c35c5
commit c3a9695a5b
2 changed files with 64 additions and 2 deletions
@@ -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.
@@ -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.