Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions sendspin/audio_devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions tests/test_audio_devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down