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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user