Skip to content

Commit 92934c1

Browse files
OnFreundclaude
andauthored
Fix ALSA hardware volume detection for CARD=<name> and bare sysdefault devices (#279)
## Summary - `parse_alsa_card()` in `sendspin/alsa_volume.py` only recognized PortAudio's numeric `hw:N,M` card annotation. Devices selected via `--audio-device` using the `plughw:CARD=<name>,DEV=<n>` / `dmix:CARD=<name>,DEV=<n>` convention — often needed because raw `hw:N,M` bypasses ALSA's `plug`/`dmix` conversion layer and fails to open for playback at all — weren't recognized, so hardware volume control silently fell back to software volume even when a real mixer element existed. - `parse_alsa_card()` now also extracts the card's string name from a `CARD=<name>` pattern, and `find_mixer_element()`/`AlsaVolumeController`/`async_check_alsa_available()` were generalized to accept `int | str` card identifiers. Confirmed via the `amixer` source that `-c` resolves either a numeric index or a card name through `snd_card_get_index()` internally, so passing the name straight through works. - Also handles PortAudio's bare `sysdefault`/`default` device enumeration (no card info at all in the name), which turned up in real-world testing on an ODROID-N2. Since the string carries no card identity, this cross-references `aplay -L`'s fully-qualified hints (e.g. `sysdefault:CARD=ODROIDN2`) to recover the card, and only resolves when exactly one hint matches — on a system with multiple cards each exposing their own default hint, it backs off to software volume rather than guessing which one is meant. ## Test plan - [x] `uv run ruff check --fix .` - [x] `uv run ruff format .` - [x] `uv run mypy sendspin` — no issues - [x] `uv run pytest tests/ -q` — 150 passed (11 new tests covering `CARD=<name>` parsing, the plughw/dmix discovery flow, and the bare-sysdefault resolution including the ambiguous multi-card and no-hint cases) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 22b9ff3 commit 92934c1

2 files changed

Lines changed: 177 additions & 14 deletions

File tree

sendspin/alsa_volume.py

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
AVAILABLE = sys.platform.startswith("linux") and shutil.which("amixer") is not None
2121

2222
_HW_CARD_RE = re.compile(r"\bhw:(\d+)")
23+
_CARD_NAME_RE = re.compile(r"\bCARD=([^,\s]+)")
2324

2425
_SCONTROL_RE = re.compile(r"Simple mixer control '([^']+)'")
2526
_VOLUME_RE = re.compile(r"\[(\d+)%\]")
@@ -34,20 +35,64 @@
3435
# - PCM: bcm2835 headphones, some USB DACs
3536
_PREFERRED_ELEMENTS: tuple[str, ...] = ("Digital", "Master", "PCM")
3637

38+
# PortAudio's ALSA host API enumerates the system default device using a
39+
# bare alias with no card info ("sysdefault", "default"), distinct from the
40+
# fully-qualified hints (e.g. "sysdefault:CARD=vc4hdmi") that `aplay -L`
41+
# reports for the same underlying device.
42+
_BARE_DEFAULT_NAMES = ("sysdefault", "default")
3743

38-
def parse_alsa_card(device_name: str) -> int | None:
39-
"""Extract the ALSA card number from a PortAudio device name.
44+
45+
def _resolve_bare_default_card(bare_name: str) -> str | None:
46+
"""Resolve a bare ALSA default alias to a card name via ``aplay -L``.
47+
48+
PortAudio may report the default output device as a bare alias like
49+
"sysdefault" with no card info, while ``aplay -L`` (ALSA's own device-hint
50+
listing) reports the same device fully-qualified, e.g.
51+
"sysdefault:CARD=vc4hdmi". Cross-referencing recovers the card without
52+
guessing at ALSA's own default-card resolution rules (env vars, config
53+
overrides, etc.).
54+
55+
Only resolves when exactly one hint matches ``<bare_name>:CARD=`` — on a
56+
system with multiple cards each exposing their own default hint, the
57+
match is ambiguous and can't be safely disambiguated from names alone.
58+
"""
59+
from sendspin.audio_devices import list_alsa_devices
60+
61+
prefix = f"{bare_name}:CARD="
62+
matches = [name for name, _ in list_alsa_devices() if name.startswith(prefix)]
63+
if len(matches) != 1:
64+
return None
65+
m = _CARD_NAME_RE.search(matches[0])
66+
return m.group(1) if m else None
67+
68+
69+
def parse_alsa_card(device_name: str) -> int | str | None:
70+
"""Extract the ALSA card identifier from a device name.
4071
4172
PortAudio names hardware devices like:
4273
"snd_rpi_hifiberry_dacplus: ... (hw:1,0)"
74+
which gives a numeric card index.
4375
44-
Returns the card index or None for virtual devices.
76+
Raw ALSA device names (e.g. from ``--audio-device plughw:CARD=vc4hdmi,DEV=0``,
77+
used to reach the ``plug``/``dmix`` conversion layer that ``hw:N,M`` bypasses)
78+
identify the card by string name instead:
79+
"plughw:CARD=vc4hdmi,DEV=0"
80+
``amixer -c`` accepts either form directly (it resolves a name via
81+
``snd_card_get_index()`` internally), so both are returned as-is.
82+
83+
Returns the card index or name, or None for virtual devices
84+
(pipewire, pulse, default, etc.) that don't reference a specific card.
4585
"""
4686
m = _HW_CARD_RE.search(device_name)
47-
return int(m.group(1)) if m else None
87+
if m:
88+
return int(m.group(1))
89+
m = _CARD_NAME_RE.search(device_name)
90+
if m:
91+
return m.group(1)
92+
return None
4893

4994

50-
async def _has_playback_volume(card: int, element: str) -> bool:
95+
async def _has_playback_volume(card: int | str, element: str) -> bool:
5196
"""Check if an ALSA mixer element has playback volume capability.
5297
5398
Accepts both ``pvolume`` (standard playback volume, e.g. HiFiBerry DAC+,
@@ -75,7 +120,7 @@ async def _has_playback_volume(card: int, element: str) -> bool:
75120
return "pvolume" in caps or "volume" in caps
76121

77122

78-
async def find_mixer_element(card: int) -> str | None:
123+
async def find_mixer_element(card: int | str) -> str | None:
79124
"""Discover the playback volume mixer element on an ALSA card.
80125
81126
Runs ``amixer -c <card> scontrols``, then checks each element for
@@ -100,12 +145,12 @@ async def find_mixer_element(card: int) -> str | None:
100145
return None
101146

102147
if proc.returncode != 0:
103-
logger.debug("amixer -c %d scontrols failed (exit %d)", card, proc.returncode)
148+
logger.debug("amixer -c %s scontrols failed (exit %d)", card, proc.returncode)
104149
return None
105150

106151
available: list[str] = _SCONTROL_RE.findall(stdout.decode())
107152
if not available:
108-
logger.debug("ALSA card %d has no mixer controls", card)
153+
logger.debug("ALSA card %s has no mixer controls", card)
109154
return None
110155

111156
seen: set[str] = set()
@@ -119,7 +164,7 @@ async def find_mixer_element(card: int) -> str | None:
119164

120165
if not volume_elements:
121166
logger.debug(
122-
"ALSA card %d: no playback volume element among %s",
167+
"ALSA card %s: no playback volume element among %s",
123168
card,
124169
sorted(seen),
125170
)
@@ -128,26 +173,32 @@ async def find_mixer_element(card: int) -> str | None:
128173
# Prefer well-known element names used by common DAC HATs.
129174
for preferred in _PREFERRED_ELEMENTS:
130175
if preferred in volume_elements:
131-
logger.debug("ALSA card %d: selected preferred mixer element %r", card, preferred)
176+
logger.debug("ALSA card %s: selected preferred mixer element %r", card, preferred)
132177
return preferred
133178

134179
# Fallback: first element with playback volume (e.g. USB DACs with non-standard names).
135180
selected = volume_elements[0]
136-
logger.debug("ALSA card %d: selected mixer element %r", card, selected)
181+
logger.debug("ALSA card %s: selected mixer element %r", card, selected)
137182
return selected
138183

139184

140185
async def async_check_alsa_available(
141186
audio_device: AudioDevice,
142-
) -> tuple[int, str] | None:
187+
) -> tuple[int | str, str] | None:
143188
"""Check if ALSA mixer volume control is available for a device.
144189
145-
Returns ``(card_number, mixer_element)`` if available, or None.
190+
Falls back to resolving bare default aliases ("sysdefault", "default")
191+
against ``aplay -L`` hints when the device name itself carries no card
192+
info (see ``_resolve_bare_default_card``).
193+
194+
Returns ``(card, mixer_element)`` if available, or None.
146195
"""
147196
if not AVAILABLE:
148197
return None
149198

150199
card = parse_alsa_card(audio_device.name)
200+
if card is None and audio_device.name in _BARE_DEFAULT_NAMES:
201+
card = _resolve_bare_default_card(audio_device.name)
151202
if card is None:
152203
return None
153204

@@ -165,7 +216,7 @@ class AlsaVolumeController:
165216
on the ALSA card, giving true hardware volume control on DAC HATs.
166217
"""
167218

168-
def __init__(self, card: int, element: str) -> None:
219+
def __init__(self, card: int | str, element: str) -> None:
169220
self._card = str(card)
170221
self._element = element
171222
self._watch_task: asyncio.Task[None] | None = None

tests/test_alsa_volume.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import pytest
99

10+
import sendspin.audio_devices as _audio_devices_mod
1011
import sendspin.alsa_volume as _alsa_mod
1112
from sendspin.alsa_volume import (
1213
AlsaVolumeController,
@@ -62,6 +63,19 @@ def test_parse_card_returns_none_for_virtual_device() -> None:
6263
assert parse_alsa_card("dmix") is None
6364

6465

66+
def test_parse_card_from_plughw_card_name() -> None:
67+
"""Raw ALSA device names use CARD=<name> instead of a numeric hw:N index."""
68+
assert parse_alsa_card("plughw:CARD=vc4hdmi,DEV=0") == "vc4hdmi"
69+
70+
71+
def test_parse_card_from_dmix_card_name() -> None:
72+
assert parse_alsa_card("dmix:CARD=vc4hdmi,DEV=0") == "vc4hdmi"
73+
74+
75+
def test_parse_card_from_hw_card_name() -> None:
76+
assert parse_alsa_card("hw:CARD=Amp,DEV=0") == "Amp"
77+
78+
6579
# -- find_mixer_element -------------------------------------------------------
6680

6781

@@ -202,6 +216,24 @@ async def fake_exec(*argv: object, **kwargs: object) -> _FakeProcess:
202216
assert calls == [("amixer", "-M", "-c", "1", "sset", "Digital", "playback", "75%", "unmute")]
203217

204218

219+
async def test_set_state_with_string_card_name(monkeypatch) -> None:
220+
"""set_state passes a string card name straight through to amixer -c.
221+
222+
amixer -c resolves a card name via snd_card_get_index() internally, so
223+
this works without translating the name to a numeric index ourselves.
224+
"""
225+
calls: list[tuple[str, ...]] = []
226+
227+
async def fake_exec(*argv: object, **kwargs: object) -> _FakeProcess:
228+
calls.append(argv)
229+
return _FakeProcess()
230+
231+
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
232+
ctrl = AlsaVolumeController(card="vc4hdmi", element="PCM")
233+
await ctrl.set_state(75, muted=False)
234+
assert calls == [("amixer", "-M", "-c", "vc4hdmi", "sset", "PCM", "playback", "75%", "unmute")]
235+
236+
205237
async def test_set_state_muted(monkeypatch) -> None:
206238
"""When muted, amixer is called with 'mute'."""
207239
calls: list[tuple[str, ...]] = []
@@ -355,6 +387,86 @@ async def fake_exec(*argv: object, **kwargs: object) -> _FakeProcess:
355387
assert result == (1, "Digital")
356388

357389

390+
async def test_alsa_available_for_plughw_card_name_device(monkeypatch) -> None:
391+
"""Returns (card_name, element) for a plughw:CARD=<name> device.
392+
393+
This is the raw ALSA device path (e.g. --audio-device plughw:CARD=vc4hdmi,DEV=0),
394+
used to reach the plug/dmix conversion layer that hw:N,M bypasses.
395+
"""
396+
scontrols = "Simple mixer control 'Digital',0\n"
397+
sget_pvolume = " Capabilities: pvolume pswitch\n"
398+
calls: list[tuple[object, ...]] = []
399+
400+
async def fake_exec(*argv: object, **kwargs: object) -> _FakeProcess:
401+
calls.append(argv)
402+
if "scontrols" in argv:
403+
return _FakeProcess(stdout=scontrols.encode())
404+
return _FakeProcess(stdout=sget_pvolume.encode())
405+
406+
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
407+
monkeypatch.setattr(_alsa_mod, "AVAILABLE", True)
408+
device = SimpleNamespace(name="plughw:CARD=vc4hdmi,DEV=0", is_default=False)
409+
result = await async_check_alsa_available(device)
410+
assert result == ("vc4hdmi", "Digital")
411+
assert calls[0] == ("amixer", "-c", "vc4hdmi", "scontrols")
412+
413+
414+
# Real `aplay -L` hint output from an ODROID-N2 (single card, reported in a
415+
# user issue): PortAudio's own device enumeration reports this same default
416+
# output device as the bare "sysdefault", with no CARD= info at all.
417+
_ODROIDN2_APLAY_L = [
418+
("null", "Discard all samples (playback) or generate zero samples (capture)"),
419+
("hw:CARD=ODROIDN2,DEV=0", "ODROID-N2,"),
420+
("plughw:CARD=ODROIDN2,DEV=0", "ODROID-N2,"),
421+
("sysdefault:CARD=ODROIDN2", "ODROID-N2,"),
422+
("dmix:CARD=ODROIDN2,DEV=0", "ODROID-N2,"),
423+
]
424+
425+
426+
async def test_alsa_available_for_bare_sysdefault_via_aplay_l(monkeypatch) -> None:
427+
"""Resolves bare 'sysdefault' (no CARD= info) via aplay -L hints."""
428+
scontrols = "Simple mixer control 'Digital',0\n"
429+
sget_pvolume = " Capabilities: pvolume pswitch\n"
430+
calls: list[tuple[object, ...]] = []
431+
432+
async def fake_exec(*argv: object, **kwargs: object) -> _FakeProcess:
433+
calls.append(argv)
434+
if "scontrols" in argv:
435+
return _FakeProcess(stdout=scontrols.encode())
436+
return _FakeProcess(stdout=sget_pvolume.encode())
437+
438+
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
439+
monkeypatch.setattr(_alsa_mod, "AVAILABLE", True)
440+
monkeypatch.setattr(_audio_devices_mod, "list_alsa_devices", lambda: _ODROIDN2_APLAY_L)
441+
device = SimpleNamespace(name="sysdefault", is_default=True)
442+
result = await async_check_alsa_available(device)
443+
assert result == ("ODROIDN2", "Digital")
444+
assert calls[0] == ("amixer", "-c", "ODROIDN2", "scontrols")
445+
446+
447+
async def test_alsa_not_available_for_bare_sysdefault_when_ambiguous(monkeypatch) -> None:
448+
"""Refuses to guess when multiple cards each expose a sysdefault hint."""
449+
monkeypatch.setattr(_alsa_mod, "AVAILABLE", True)
450+
monkeypatch.setattr(
451+
_audio_devices_mod,
452+
"list_alsa_devices",
453+
lambda: [
454+
("sysdefault:CARD=ODROIDN2", "ODROID-N2,"),
455+
("sysdefault:CARD=USBDAC", "USB DAC,"),
456+
],
457+
)
458+
device = SimpleNamespace(name="sysdefault", is_default=True)
459+
assert await async_check_alsa_available(device) is None
460+
461+
462+
async def test_alsa_not_available_for_bare_sysdefault_when_no_hint(monkeypatch) -> None:
463+
"""Falls back to None when aplay -L has no matching sysdefault hint."""
464+
monkeypatch.setattr(_alsa_mod, "AVAILABLE", True)
465+
monkeypatch.setattr(_audio_devices_mod, "list_alsa_devices", lambda: [])
466+
device = SimpleNamespace(name="sysdefault", is_default=True)
467+
assert await async_check_alsa_available(device) is None
468+
469+
358470
async def test_alsa_not_available_for_virtual_device(monkeypatch) -> None:
359471
"""Returns None for virtual devices (no hw: in name)."""
360472
monkeypatch.setattr(_alsa_mod, "AVAILABLE", True)

0 commit comments

Comments
 (0)