Skip to content
Open
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
17 changes: 15 additions & 2 deletions sendspin/alsa_volume.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ async def _has_playback_volume(card: int, element: str) -> bool:
USB interfaces) and ``volume`` (used by TAS58xx-based amplifier HATs such
as the Sonocotta Louder Raspberry β€” reported as ``volume`` in stereo mode
or ``volume volume-joined`` in mono mode).

The bare ``volume`` capability is ambiguous: ALSA also tags bidirectional
analog gain stages with it when a codec driver registers them via
SOC_SINGLE_TLV/SOC_DOUBLE_TLV without a "Playback"/"Capture" qualifier in
the control name (e.g. RT5616's "ADC Boost", "IN1 Boost", "IN2 Boost" β€” all
capture-side mic/line boost gain, disconnected from the playback chain).
Such elements report both a ``Playback channels:`` and a ``Capture
channels:`` line. A bare ``volume`` match is therefore only accepted when
the element is genuinely output-only (no ``Capture channels:`` line), which
is the real TAS58xx case. The unambiguous ``pvolume`` branch is unaffected.
"""
try:
proc = await asyncio.create_subprocess_exec(
Expand All @@ -71,8 +81,11 @@ async def _has_playback_volume(card: int, element: str) -> bool:
return False
if proc.returncode != 0:
return False
caps = set(stdout.decode().split())
return "pvolume" in caps or "volume" in caps
output = stdout.decode()
caps = set(output.split())
if "pvolume" in caps:
return True
return "volume" in caps and "Capture channels:" not in output


async def find_mixer_element(card: int) -> str | None:
Expand Down
141 changes: 141 additions & 0 deletions tests/test_alsa_volume.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import sendspin.alsa_volume as _alsa_mod
from sendspin.alsa_volume import (
AlsaVolumeController,
_has_playback_volume,
async_check_alsa_available,
find_mixer_element,
parse_alsa_card,
Expand Down Expand Up @@ -522,3 +523,143 @@ async def test_louder_raspberry_get_volume(monkeypatch) -> None:
volume, muted = await ctrl.get_state()
assert volume == 57
assert muted is False


# -- Realtek RT5616 analog codec (NanoPC-T6 / RK3588) -------------------------
# The mainline RT5616 codec driver registers several capture-side analog boost
# gain stages ("ADC Boost", "IN1 Boost", "IN2 Boost") with bare `volume`
# capability and *both* Playback and Capture channels. These must NOT be
# detected as playback-volume controls β€” they are disconnected from the
# playback chain and adjusting them has zero effect on output loudness.
# The genuine playback elements (DAC1, HP, OUT) all carry `pvolume`.

# False positives: capture-side boost/preamp gain tagged with bare `volume`
# plus a `Capture channels:` line.
_RT5616_SGET_ADC_BOOST = (
"Simple mixer control 'ADC Boost',0\n"
" Capabilities: volume\n"
" Playback channels: Front Left - Front Right\n"
" Capture channels: Front Left - Front Right\n"
" Limits: 0 - 3\n"
" Front Left: 0 [0%] [0.00dB]\n"
" Front Right: 0 [0%] [0.00dB]\n"
)

_RT5616_SGET_IN1_BOOST = (
"Simple mixer control 'IN1 Boost',0\n"
" Capabilities: volume volume-joined\n"
" Playback channels: Mono\n"
" Capture channels: Mono\n"
" Limits: 0 - 8\n"
" Mono: 0 [0%] [0.00dB]\n"
)

_RT5616_SGET_IN2_BOOST = (
"Simple mixer control 'IN2 Boost',0\n"
" Capabilities: volume volume-joined\n"
" Playback channels: Mono\n"
" Capture channels: Mono\n"
" Limits: 0 - 8\n"
" Mono: 0 [0%] [0.00dB]\n"
)

# Capture-only control: already correctly excluded (cvolume, no playback).
_RT5616_SGET_ADC = (
"Simple mixer control 'ADC',0\n"
" Capabilities: cvolume cswitch\n"
" Capture channels: Front Left - Front Right\n"
" Limits: Capture 0 - 127\n"
" Front Left: Capture 47 [37%] [0.00dB] [on]\n"
" Front Right: Capture 47 [37%] [0.00dB] [on]\n"
)

# True playback-volume elements (must remain detected).
_RT5616_SGET_DAC1 = (
"Simple mixer control 'DAC1',0\n"
" Capabilities: pvolume\n"
" Playback channels: Front Left - Front Right\n"
" Limits: Playback 0 - 175\n"
" Front Left: Playback 175 [100%] [0.00dB]\n"
" Front Right: Playback 175 [100%] [0.00dB]\n"
)

_RT5616_SGET_HP = (
"Simple mixer control 'HP',0\n"
" Capabilities: pvolume pswitch\n"
" Playback channels: Front Left - Front Right\n"
" Limits: Playback 0 - 39\n"
" Front Left: Playback 39 [100%] [0.00dB] [on]\n"
" Front Right: Playback 39 [100%] [0.00dB] [on]\n"
)

_RT5616_SGET_OUT = (
"Simple mixer control 'OUT',0\n"
" Capabilities: pvolume pswitch\n"
" Playback channels: Front Left - Front Right\n"
" Limits: Playback 0 - 39\n"
" Front Left: Playback 39 [100%] [0.00dB] [on]\n"
" Front Right: Playback 39 [100%] [0.00dB] [on]\n"
)

# Enumeration order as reported by `amixer -c2 scontrols`: the capture-side
# boost stages enumerate *before* the real playback elements, which is what
# previously caused "ADC Boost" to win.
_RT5616_SCONTROLS = (
"Simple mixer control 'ADC Boost',0\n"
"Simple mixer control 'IN1 Boost',0\n"
"Simple mixer control 'IN2 Boost',0\n"
"Simple mixer control 'ADC',0\n"
"Simple mixer control 'DAC1',0\n"
"Simple mixer control 'HP',0\n"
"Simple mixer control 'OUT',0\n"
)

_RT5616_SGET_BY_ELEMENT = {
"ADC Boost": _RT5616_SGET_ADC_BOOST,
"IN1 Boost": _RT5616_SGET_IN1_BOOST,
"IN2 Boost": _RT5616_SGET_IN2_BOOST,
"ADC": _RT5616_SGET_ADC,
"DAC1": _RT5616_SGET_DAC1,
"HP": _RT5616_SGET_HP,
"OUT": _RT5616_SGET_OUT,
}


def _rt5616_exec() -> _AmixerExecFactory:
"""Build a fake amixer exec over the full RT5616 control set."""

async def fake_exec(*argv: object, **kwargs: object) -> _FakeProcess:
if "scontrols" in argv:
return _FakeProcess(stdout=_RT5616_SCONTROLS.encode())
for element, output in _RT5616_SGET_BY_ELEMENT.items():
if element in argv:
return _FakeProcess(stdout=output.encode())
return _FakeProcess(stdout=b"")

return fake_exec


@pytest.mark.parametrize(
"element",
["ADC", "ADC Boost", "IN1 Boost", "IN2 Boost"],
)
async def test_rt5616_rejects_capture_side_controls(monkeypatch, element: str) -> None:
"""Capture-side boost gain (bare `volume` + Capture channels) is rejected."""
monkeypatch.setattr(asyncio, "create_subprocess_exec", _rt5616_exec())
assert await _has_playback_volume(2, element) is False


@pytest.mark.parametrize(
"element",
["DAC1", "HP", "OUT"],
)
async def test_rt5616_accepts_playback_controls(monkeypatch, element: str) -> None:
"""Genuine pvolume playback elements remain detected."""
monkeypatch.setattr(asyncio, "create_subprocess_exec", _rt5616_exec())
assert await _has_playback_volume(2, element) is True


async def test_rt5616_find_mixer_element_returns_dac1(monkeypatch) -> None:
"""find_mixer_element picks DAC1, not the earlier-enumerated 'ADC Boost'."""
monkeypatch.setattr(asyncio, "create_subprocess_exec", _rt5616_exec())
assert await find_mixer_element(2) == "DAC1"