From 77def71354598d3e78a5747a13d79824115d7be0 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Tue, 29 Sep 2026 22:26:16 -0700 Subject: [PATCH] fix(audio): stop passing explicit index when zero devices enumerate (#891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(audio): stop passing explicit index when zero devices enumerate With zero audio devices, _resolve_valid_input_device returned the saved preferred_index, so _open_capture_stream opened input_device_index= — a device that cannot exist — and Pa_OpenStream aborted the whole process (SIGSEGV) before the clean IOError->ERROR path could run. Zero enumerated devices now returns None so PyAudio falls back to the system default and fails catchably; a negative count (enumeration failure) still passes the preferred index through. * test(audio): cover capture path omitting device index * test(audio): cover saved-device zero-enumeration capture path * test(audio): annotate new resolver tests --- .../speech_recognition/recognition_manager.py | 10 +++- tests/test_recognition_manager_helpers.py | 60 ++++++++++++++++++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 4c273ee..1f868f1 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -498,8 +498,14 @@ def _resolve_valid_input_device(audio, preferred_index: Optional[int] = None) -> # MagicMock-based tests or misbehaving drivers can yield non-int counts. return preferred_index - if device_count <= 0: - # No enumeration available; let PyAudio fall back to system default. + if device_count == 0: + # Enumeration worked and reported zero devices: no index can be valid, + # and PortAudio aborts the process when asked to open one. Returning + # None omits the explicit index so PyAudio uses the system default and + # surfaces a catchable error instead. + return None + if device_count < 0: + # Enumeration failed; the preferred device may still exist by index. return preferred_index for i in range(device_count): diff --git a/tests/test_recognition_manager_helpers.py b/tests/test_recognition_manager_helpers.py index 2bc8d48..7d5bbf4 100644 --- a/tests/test_recognition_manager_helpers.py +++ b/tests/test_recognition_manager_helpers.py @@ -396,11 +396,21 @@ class TestResolveValidInputDevice: audio.get_device_count.side_effect = OSError("driver dead") assert _resolve_valid_input_device(audio, preferred_index=3) == 3 - def test_zero_device_count_returns_preferred(self): + def test_zero_device_count_returns_none(self) -> None: audio = MagicMock() audio.get_default_input_device_info.side_effect = IOError("nope") audio.get_device_count.return_value = 0 - assert _resolve_valid_input_device(audio, preferred_index=7) == 7 + # Zero enumerated devices means no index can exist — the caller must + # fall back to the system default rather than open an explicit index. + assert _resolve_valid_input_device(audio, preferred_index=7) is None + + def test_negative_device_count_returns_preferred(self) -> None: + audio = MagicMock() + audio.get_default_input_device_info.side_effect = IOError("nope") + audio.get_device_count.return_value = -1 + # Enumeration failure (host API error sentinel) cannot tell whether + # the preferred index exists, so it passes through. + assert _resolve_valid_input_device(audio, preferred_index=3) == 3 def test_non_dict_info_is_treated_as_valid(self): audio = MagicMock() @@ -422,6 +432,52 @@ class TestResolveValidInputDevice: audio.get_device_info_by_index.side_effect = info assert _resolve_valid_input_device(audio, preferred_index=None) == 1 + def test_capture_stream_omits_index_when_resolver_returns_none(self) -> None: + mock_pa_mod = MagicMock() + mock_pa_mod.paInt16 = 8 + + audio = MagicMock() + audio.get_default_input_device_info.return_value = { + "name": "Default Mic", + "index": 0, + "maxInputChannels": 1, + "defaultSampleRate": 16000, + } + + with patch.dict("sys.modules", {"pyaudio": mock_pa_mod}): + channels, rate, stream = rm._open_capture_stream(audio, device_index=None) + + assert stream is audio.open.return_value + audio.open.assert_called_once() + assert "input_device_index" not in audio.open.call_args.kwargs + assert (channels, rate) == (1, 16000) + + def test_saved_device_zero_enumeration_opens_default(self) -> None: + # End-to-end check of the fix: a stale saved index must not reach + # PortAudio when enumeration reports zero devices. + mock_pa_mod = MagicMock() + mock_pa_mod.paInt16 = 8 + + audio = MagicMock() + audio.get_default_input_device_info.side_effect = IOError("no default") + audio.get_device_count.return_value = 0 + + resolved = _resolve_valid_input_device(audio, preferred_index=7) + assert resolved is None + + audio.get_default_input_device_info.side_effect = None + audio.get_default_input_device_info.return_value = { + "name": "Default Mic", + "index": 0, + "maxInputChannels": 1, + "defaultSampleRate": 16000, + } + with patch.dict("sys.modules", {"pyaudio": mock_pa_mod}): + rm._open_capture_stream(audio, device_index=resolved) + + audio.open.assert_called_once() + assert "input_device_index" not in audio.open.call_args.kwargs + class TestDetectPywhispercppGpuBackend: """Coverage for the runtime GPU library detector."""