diff --git a/sendspin/audio_devices.py b/sendspin/audio_devices.py index a365188..36f85bf 100644 --- a/sendspin/audio_devices.py +++ b/sendspin/audio_devices.py @@ -13,6 +13,8 @@ logger = logging.getLogger(__name__) +_PORTAUDIO_NO_OUTPUT_DEVICE_MATCHING = "No output device matching" + SOUNDDEVICE_DTYPE_MAP: dict[int, str] = { 16: "int16", 24: "int24", @@ -268,6 +270,11 @@ def list_alsa_devices() -> list[tuple[str, str]]: return devices +def _alsa_device_exists(name: str) -> bool: + """Return True if ALSA lists *name* as a PCM device.""" + return any(dev_name == name for dev_name, _ in list_alsa_devices()) + + def resolve_audio_device(device_arg: str | None) -> AudioDevice: """Resolve audio device from a CLI argument. @@ -319,13 +326,20 @@ def _try_alsa_device(name: str) -> AudioDevice | None: """ try: sounddevice.check_output_settings(device=name) - except sounddevice.PortAudioError: - return None + except sounddevice.PortAudioError as err: + # sounddevice exposes this PortAudio lookup failure only in the + # error message, without a stable code to distinguish it from + # actual device-open failures. + # Validate it exists in ALSA before accepting it with safe defaults. + err_msg = str(err.args[0]) if err.args else "" + if _PORTAUDIO_NO_OUTPUT_DEVICE_MATCHING not in err_msg: + return None + if not _alsa_device_exists(name): + return None except ValueError: # PortAudio doesn't recognize this device name (e.g. hw:CARD=...,DEV=...). # Validate it exists in ALSA before accepting it with safe defaults. - alsa_names = {dev_name for dev_name, _ in list_alsa_devices()} - if name not in alsa_names: + if not _alsa_device_exists(name): return None # Try to query device info from PortAudio diff --git a/tests/test_audio_devices.py b/tests/test_audio_devices.py index dc52e42..2edc1c9 100644 --- a/tests/test_audio_devices.py +++ b/tests/test_audio_devices.py @@ -68,6 +68,39 @@ def test_try_alsa_device_accepts_hw_card_format_known_to_alsa(): assert result.sample_rate == 48000.0 +def test_try_alsa_device_accepts_hw_card_format_when_portaudio_reports_no_match(): + """hw:CARD=...,DEV=... should work when ALSA lists it but PortAudio can't match it.""" + alsa_list = [ + ( + "hw:CARD=sndrpihifiberry,DEV=0", + "snd_rpi_hifiberry_dacplus, HiFiBerry DAC+ HiFi", + ), + ] + with ( + patch.object( + sounddevice, + "check_output_settings", + side_effect=sounddevice.PortAudioError( + f"{_mod._PORTAUDIO_NO_OUTPUT_DEVICE_MATCHING} 'hw:CARD=sndrpihifiberry,DEV=0'", + -1, + "", + ), + ), + patch.object( + sounddevice, + "query_devices", + side_effect=ValueError("not found"), + ), + patch.object(_mod, "list_alsa_devices", return_value=alsa_list), + ): + result = _try_alsa_device("hw:CARD=sndrpihifiberry,DEV=0") + + assert result is not None + assert result.alsa_device_name == "hw:CARD=sndrpihifiberry,DEV=0" + assert result.output_channels == 2 + assert result.sample_rate == 48000.0 + + def test_try_alsa_device_returns_none_for_unknown_hw_card(): """hw:CARD=...,DEV=... not in ALSA list should return None.""" with (