Skip to content

Commit 41ceafb

Browse files
committed
Reject capture-tagged boost controls in ALSA volume auto-detection
- `_has_playback_volume()` accepts a bare `volume` capability (in addition to `pvolume`) to support TAS58xx-based amplifier HATs, whose output PGA is playback-only and reports `Capabilities: volume` rather than `pvolume`. But ALSA also tags bidirectional analog gain stages with bare `volume` when a codec driver registers them via SOC_SINGLE_TLV / SOC_DOUBLE_TLV without a "Playback"/"Capture" qualifier in the control name. Those are capture-side gain stages, disconnected from the playback chain, and were being misdetected as playback-volume elements. - Concrete case: Realtek RT5616 analog codec (FriendlyElec NanoPC-T6, RK3588). Its "ADC Boost", "IN1 Boost", and "IN2 Boost" controls each report bare `volume` with *both* a `Playback channels:` and a `Capture channels:` line. "ADC Boost" enumerates before the real playback elements in `scontrols` order, and none of RT5616's genuine playback controls (DAC1/HP/OUT) match `_PREFERRED_ELEMENTS` (Digital/Master/PCM) — so `find_mixer_element()` returned "ADC Boost". Hardware-volume mode then silently adjusted an unused capture boost stage with zero audible effect on playback loudness. - Fix: only accept a bare `volume` / `volume-joined` match when the element's `sget` output has no `Capture channels:` line, i.e. it is genuinely output-only (the real TAS58xx case). The `pvolume` branch is an unambiguous true positive and is left unchanged. With this, RT5616 correctly falls back to DAC1 (the first genuine pvolume element in enumeration order). The existing TAS58xx fixtures have no `Capture channels:` line and remain detected. Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 511645a commit 41ceafb

2 files changed

Lines changed: 156 additions & 2 deletions

File tree

‎sendspin/alsa_volume.py‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,16 @@ async def _has_playback_volume(card: int | str, element: str) -> bool:
9999
USB interfaces) and ``volume`` (used by TAS58xx-based amplifier HATs such
100100
as the Sonocotta Louder Raspberry — reported as ``volume`` in stereo mode
101101
or ``volume volume-joined`` in mono mode).
102+
103+
The bare ``volume`` capability is ambiguous: ALSA also tags bidirectional
104+
analog gain stages with it when a codec driver registers them via
105+
SOC_SINGLE_TLV/SOC_DOUBLE_TLV without a "Playback"/"Capture" qualifier in
106+
the control name (e.g. RT5616's "ADC Boost", "IN1 Boost", "IN2 Boost" — all
107+
capture-side mic/line boost gain, disconnected from the playback chain).
108+
Such elements report both a ``Playback channels:`` and a ``Capture
109+
channels:`` line. A bare ``volume`` match is therefore only accepted when
110+
the element is genuinely output-only (no ``Capture channels:`` line), which
111+
is the real TAS58xx case. The unambiguous ``pvolume`` branch is unaffected.
102112
"""
103113
try:
104114
proc = await asyncio.create_subprocess_exec(
@@ -116,8 +126,11 @@ async def _has_playback_volume(card: int | str, element: str) -> bool:
116126
return False
117127
if proc.returncode != 0:
118128
return False
119-
caps = set(stdout.decode().split())
120-
return "pvolume" in caps or "volume" in caps
129+
output = stdout.decode()
130+
caps = set(output.split())
131+
if "pvolume" in caps:
132+
return True
133+
return "volume" in caps and "Capture channels:" not in output
121134

122135

123136
async def find_mixer_element(card: int | str) -> str | None:

‎tests/test_alsa_volume.py‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import sendspin.alsa_volume as _alsa_mod
1212
from sendspin.alsa_volume import (
1313
AlsaVolumeController,
14+
_has_playback_volume,
1415
async_check_alsa_available,
1516
find_mixer_element,
1617
parse_alsa_card,
@@ -634,3 +635,143 @@ async def test_louder_raspberry_get_volume(monkeypatch) -> None:
634635
volume, muted = await ctrl.get_state()
635636
assert volume == 57
636637
assert muted is False
638+
639+
640+
# -- Realtek RT5616 analog codec (NanoPC-T6 / RK3588) -------------------------
641+
# The mainline RT5616 codec driver registers several capture-side analog boost
642+
# gain stages ("ADC Boost", "IN1 Boost", "IN2 Boost") with bare `volume`
643+
# capability and *both* Playback and Capture channels. These must NOT be
644+
# detected as playback-volume controls — they are disconnected from the
645+
# playback chain and adjusting them has zero effect on output loudness.
646+
# The genuine playback elements (DAC1, HP, OUT) all carry `pvolume`.
647+
648+
# False positives: capture-side boost/preamp gain tagged with bare `volume`
649+
# plus a `Capture channels:` line.
650+
_RT5616_SGET_ADC_BOOST = (
651+
"Simple mixer control 'ADC Boost',0\n"
652+
" Capabilities: volume\n"
653+
" Playback channels: Front Left - Front Right\n"
654+
" Capture channels: Front Left - Front Right\n"
655+
" Limits: 0 - 3\n"
656+
" Front Left: 0 [0%] [0.00dB]\n"
657+
" Front Right: 0 [0%] [0.00dB]\n"
658+
)
659+
660+
_RT5616_SGET_IN1_BOOST = (
661+
"Simple mixer control 'IN1 Boost',0\n"
662+
" Capabilities: volume volume-joined\n"
663+
" Playback channels: Mono\n"
664+
" Capture channels: Mono\n"
665+
" Limits: 0 - 8\n"
666+
" Mono: 0 [0%] [0.00dB]\n"
667+
)
668+
669+
_RT5616_SGET_IN2_BOOST = (
670+
"Simple mixer control 'IN2 Boost',0\n"
671+
" Capabilities: volume volume-joined\n"
672+
" Playback channels: Mono\n"
673+
" Capture channels: Mono\n"
674+
" Limits: 0 - 8\n"
675+
" Mono: 0 [0%] [0.00dB]\n"
676+
)
677+
678+
# Capture-only control: already correctly excluded (cvolume, no playback).
679+
_RT5616_SGET_ADC = (
680+
"Simple mixer control 'ADC',0\n"
681+
" Capabilities: cvolume cswitch\n"
682+
" Capture channels: Front Left - Front Right\n"
683+
" Limits: Capture 0 - 127\n"
684+
" Front Left: Capture 47 [37%] [0.00dB] [on]\n"
685+
" Front Right: Capture 47 [37%] [0.00dB] [on]\n"
686+
)
687+
688+
# True playback-volume elements (must remain detected).
689+
_RT5616_SGET_DAC1 = (
690+
"Simple mixer control 'DAC1',0\n"
691+
" Capabilities: pvolume\n"
692+
" Playback channels: Front Left - Front Right\n"
693+
" Limits: Playback 0 - 175\n"
694+
" Front Left: Playback 175 [100%] [0.00dB]\n"
695+
" Front Right: Playback 175 [100%] [0.00dB]\n"
696+
)
697+
698+
_RT5616_SGET_HP = (
699+
"Simple mixer control 'HP',0\n"
700+
" Capabilities: pvolume pswitch\n"
701+
" Playback channels: Front Left - Front Right\n"
702+
" Limits: Playback 0 - 39\n"
703+
" Front Left: Playback 39 [100%] [0.00dB] [on]\n"
704+
" Front Right: Playback 39 [100%] [0.00dB] [on]\n"
705+
)
706+
707+
_RT5616_SGET_OUT = (
708+
"Simple mixer control 'OUT',0\n"
709+
" Capabilities: pvolume pswitch\n"
710+
" Playback channels: Front Left - Front Right\n"
711+
" Limits: Playback 0 - 39\n"
712+
" Front Left: Playback 39 [100%] [0.00dB] [on]\n"
713+
" Front Right: Playback 39 [100%] [0.00dB] [on]\n"
714+
)
715+
716+
# Enumeration order as reported by `amixer -c2 scontrols`: the capture-side
717+
# boost stages enumerate *before* the real playback elements, which is what
718+
# previously caused "ADC Boost" to win.
719+
_RT5616_SCONTROLS = (
720+
"Simple mixer control 'ADC Boost',0\n"
721+
"Simple mixer control 'IN1 Boost',0\n"
722+
"Simple mixer control 'IN2 Boost',0\n"
723+
"Simple mixer control 'ADC',0\n"
724+
"Simple mixer control 'DAC1',0\n"
725+
"Simple mixer control 'HP',0\n"
726+
"Simple mixer control 'OUT',0\n"
727+
)
728+
729+
_RT5616_SGET_BY_ELEMENT = {
730+
"ADC Boost": _RT5616_SGET_ADC_BOOST,
731+
"IN1 Boost": _RT5616_SGET_IN1_BOOST,
732+
"IN2 Boost": _RT5616_SGET_IN2_BOOST,
733+
"ADC": _RT5616_SGET_ADC,
734+
"DAC1": _RT5616_SGET_DAC1,
735+
"HP": _RT5616_SGET_HP,
736+
"OUT": _RT5616_SGET_OUT,
737+
}
738+
739+
740+
def _rt5616_exec() -> _AmixerExecFactory:
741+
"""Build a fake amixer exec over the full RT5616 control set."""
742+
743+
async def fake_exec(*argv: object, **kwargs: object) -> _FakeProcess:
744+
if "scontrols" in argv:
745+
return _FakeProcess(stdout=_RT5616_SCONTROLS.encode())
746+
for element, output in _RT5616_SGET_BY_ELEMENT.items():
747+
if element in argv:
748+
return _FakeProcess(stdout=output.encode())
749+
return _FakeProcess(stdout=b"")
750+
751+
return fake_exec
752+
753+
754+
@pytest.mark.parametrize(
755+
"element",
756+
["ADC", "ADC Boost", "IN1 Boost", "IN2 Boost"],
757+
)
758+
async def test_rt5616_rejects_capture_side_controls(monkeypatch, element: str) -> None:
759+
"""Capture-side boost gain (bare `volume` + Capture channels) is rejected."""
760+
monkeypatch.setattr(asyncio, "create_subprocess_exec", _rt5616_exec())
761+
assert await _has_playback_volume(2, element) is False
762+
763+
764+
@pytest.mark.parametrize(
765+
"element",
766+
["DAC1", "HP", "OUT"],
767+
)
768+
async def test_rt5616_accepts_playback_controls(monkeypatch, element: str) -> None:
769+
"""Genuine pvolume playback elements remain detected."""
770+
monkeypatch.setattr(asyncio, "create_subprocess_exec", _rt5616_exec())
771+
assert await _has_playback_volume(2, element) is True
772+
773+
774+
async def test_rt5616_find_mixer_element_returns_dac1(monkeypatch) -> None:
775+
"""find_mixer_element picks DAC1, not the earlier-enumerated 'ADC Boost'."""
776+
monkeypatch.setattr(asyncio, "create_subprocess_exec", _rt5616_exec())
777+
assert await find_mixer_element(2) == "DAC1"

0 commit comments

Comments
 (0)