Skip to content

Commit ccb2bfd

Browse files
balloobclaude
andauthored
Fix ALSA device matching for hw:CARD=...,DEV=... format (#224)
## Summary - Catch `ValueError` (in addition to `PortAudioError`) from `sounddevice.check_output_settings` — PortAudio raises `ValueError` for `hw:CARD=...,DEV=...` names it doesn't recognize - Validate the device exists in the ALSA device list before accepting it with safe defaults - Add focused tests for `_try_alsa_device` Simpler alternative to #218 — 6 lines changed in production code vs ~80. Fixes #208 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7620865 commit ccb2bfd

2 files changed

Lines changed: 126 additions & 0 deletions

File tree

‎sendspin/audio_devices.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,12 @@ def _try_alsa_device(name: str) -> AudioDevice | None:
321321
sounddevice.check_output_settings(device=name)
322322
except sounddevice.PortAudioError:
323323
return None
324+
except ValueError:
325+
# PortAudio doesn't recognize this device name (e.g. hw:CARD=...,DEV=...).
326+
# Validate it exists in ALSA before accepting it with safe defaults.
327+
alsa_names = {dev_name for dev_name, _ in list_alsa_devices()}
328+
if name not in alsa_names:
329+
return None
324330

325331
# Try to query device info from PortAudio
326332
try:

‎tests/test_audio_devices.py‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Tests for ALSA device resolution in audio_devices.py."""
2+
3+
from __future__ import annotations
4+
5+
from unittest.mock import patch
6+
7+
import sounddevice
8+
9+
import sendspin.audio_devices as _mod
10+
from sendspin.audio_devices import _try_alsa_device
11+
12+
13+
def test_try_alsa_device_returns_device_when_portaudio_accepts():
14+
"""Device recognized by PortAudio is returned with queried info."""
15+
info = {
16+
"name": "jsm_speaker_left",
17+
"max_output_channels": 128,
18+
"default_samplerate": 48000.0,
19+
}
20+
with (
21+
patch.object(sounddevice, "check_output_settings"),
22+
patch.object(sounddevice, "query_devices", return_value=info),
23+
):
24+
result = _try_alsa_device("jsm_speaker_left")
25+
26+
assert result is not None
27+
assert result.alsa_device_name == "jsm_speaker_left"
28+
assert result.output_channels == 128
29+
assert result.sample_rate == 48000.0
30+
31+
32+
def test_try_alsa_device_returns_none_on_portaudio_error():
33+
"""PortAudioError from check_output_settings means device can't be opened."""
34+
with patch.object(
35+
sounddevice,
36+
"check_output_settings",
37+
side_effect=sounddevice.PortAudioError("err", -1, ""),
38+
):
39+
assert _try_alsa_device("nonexistent") is None
40+
41+
42+
def test_try_alsa_device_accepts_hw_card_format_known_to_alsa():
43+
"""hw:CARD=...,DEV=... that PortAudio doesn't know but ALSA lists should work."""
44+
alsa_list = [
45+
(
46+
"hw:CARD=sndrpihifiberry,DEV=0",
47+
"snd_rpi_hifiberry_dacplus, HiFiBerry DAC+ HiFi",
48+
),
49+
]
50+
with (
51+
patch.object(
52+
sounddevice,
53+
"check_output_settings",
54+
side_effect=ValueError("not found"),
55+
),
56+
patch.object(
57+
sounddevice,
58+
"query_devices",
59+
side_effect=ValueError("not found"),
60+
),
61+
patch.object(_mod, "list_alsa_devices", return_value=alsa_list),
62+
):
63+
result = _try_alsa_device("hw:CARD=sndrpihifiberry,DEV=0")
64+
65+
assert result is not None
66+
assert result.alsa_device_name == "hw:CARD=sndrpihifiberry,DEV=0"
67+
assert result.output_channels == 2
68+
assert result.sample_rate == 48000.0
69+
70+
71+
def test_try_alsa_device_returns_none_for_unknown_hw_card():
72+
"""hw:CARD=...,DEV=... not in ALSA list should return None."""
73+
with (
74+
patch.object(
75+
sounddevice,
76+
"check_output_settings",
77+
side_effect=ValueError("not found"),
78+
),
79+
patch.object(_mod, "list_alsa_devices", return_value=[]),
80+
):
81+
assert _try_alsa_device("hw:CARD=nonexistent,DEV=0") is None
82+
83+
84+
def test_try_alsa_device_uses_safe_defaults_when_query_fails():
85+
"""PortAudio accepts the device but can't query info → safe defaults."""
86+
with (
87+
patch.object(sounddevice, "check_output_settings"),
88+
patch.object(
89+
sounddevice,
90+
"query_devices",
91+
side_effect=sounddevice.PortAudioError("err", -1, ""),
92+
),
93+
):
94+
result = _try_alsa_device("jsm_speaker_left")
95+
96+
assert result is not None
97+
assert result.output_channels == 2
98+
assert result.sample_rate == 48000.0
99+
100+
101+
def test_try_alsa_device_accepts_alsa_only_device():
102+
"""ALSA-only device (e.g. bluealsa) not known to PortAudio should work."""
103+
alsa_list = [("bluealsa", "Bluetooth Audio")]
104+
with (
105+
patch.object(
106+
sounddevice,
107+
"check_output_settings",
108+
side_effect=ValueError("not found"),
109+
),
110+
patch.object(
111+
sounddevice,
112+
"query_devices",
113+
side_effect=ValueError("not found"),
114+
),
115+
patch.object(_mod, "list_alsa_devices", return_value=alsa_list),
116+
):
117+
result = _try_alsa_device("bluealsa")
118+
119+
assert result is not None
120+
assert result.alsa_device_name == "bluealsa"

0 commit comments

Comments
 (0)