Skip to content

Add --alsa-mixer: hardware volume through an ALSA mixer control - #96

Open
TermeHansen wants to merge 8 commits into
Sendspin:mainfrom
TermeHansen:hardware_mixer
Open

TermeHansen wants to merge 8 commits into
Sendspin:mainfrom
TermeHansen:hardware_mixer

Conversation

@TermeHansen

@TermeHansen TermeHansen commented Oct 22, 2026 •

Copy link
Copy Markdown

Summary

  • --alsa-mixer <card:control> drives a hardware mixer for volume and mute instead of scaling the stream in software, mapped over the control's dB range the way alsamixer lays its scale out.
  • The level the mixer is at at startup seeds the player and is shown in status until a server sends its own.
  • A mixer that cannot be opened or set falls back to software gain; the flag is refused off the ALSA backend and alongside --fixed-volume.

Testing

  • tests/cli_test.cpp and tests/config_file_test.cpp cover the flag parsing and config-file handling.

--alsa-mixer <card:control> drives a hardware mixer for volume and mute
instead of scaling the stream in software, mapped over the control's dB
range the way alsamixer lays its scale out. The level the mixer is at at
startup seeds the player and is shown in status until a server sends its
own. A mixer that cannot be opened or set falls back to software gain;
the flag is refused off the ALSA backend and alongside --fixed-volume.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

@chrisuthe chrisuthe left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. Hardware volume is a feature we want. I checked it against the player section of the spec. Two spec MUSTs are broken, so I'm requesting changes. The rest are suggestions.

Blocking

  1. A volume change can clear mute. When the control has no playback switch, mute falls back to software gain 0. The next server volume command then resets the multiplier to unity, and audio plays while we still report muted: true. Details inline.
  2. External mixer changes are never reported. We read the mixer once at startup. After that, a change from amixer, alsamixer, another app or a hardware knob never reaches client/state. Details inline.

Non-blocking suggestions

  1. Curve. The cubic curve is twice the spec's dB slope: −18.1 dB at 50 against −9.0 dB. Inline.
  2. Ramp. The spec says volume changes SHOULD ramp: "To avoid audible clicks, clients SHOULD apply volume changes over a short ramp." Mixer writes jump in one step, while the software path ramps through current_multiplier_. A short stepped write would cover it, but we're fine either way.
  3. Volume 0 is not silent. Inline.

Checked and conformant: supported_commands is unchanged. The value seeded from the mixer at startup goes out in the first client/state. Preferring the mixer level over the persisted one is fine, since persistence is only RECOMMENDED.

Comment thread src/alsa_sink.cpp Outdated
this->volume_.store(volume > 100 ? 100 : volume);
// Hardware first: a mixer that takes the value leaves the PCM scaling at unity.
if (!this->mixer_spec_.empty() && set_mixer_volume_(this->mixer_spec_, volume)) {
this->target_multiplier_.store(Q32_ONE, std::memory_order_relaxed);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (spec MUST). The spec says: "volume and muted are independent: a volume change ... MUST NOT clear the mute state."

When the control has no playback switch, set_mixer_mute_ returns false and set_muted(true) falls back to update_target_multiplier_(), so the target is 0. The next set_volume succeeds on the mixer, and this line stores Q32_ONE. Audio is audible again while the player still reports muted: true.

Suggested change
this->target_multiplier_.store(Q32_ONE, std::memory_order_relaxed);
this->target_multiplier_.store(this->muted_.load() ? 0 : Q32_ONE, std::memory_order_relaxed);

Comment thread src/alsa_sink.cpp Outdated
void AlsaAudioSink::set_muted(bool muted) {
this->muted_.store(muted);
if (!this->mixer_spec_.empty() && set_mixer_mute_(this->mixer_spec_, muted)) {
this->target_multiplier_.store(Q32_ONE, std::memory_order_relaxed);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue on the mute path. Once the switch has muted, unity is right here. Using the same muted_ ? 0 : Q32_ONE form in both places keeps the two paths from drifting apart.

Comment thread src/alsa_sink.h Outdated
static uint8_t volume_for_mixer_dB_(long min, long max, long dB);
/// @return true when the control took the volume; false leaves the caller on software gain.
static bool set_mixer_volume_(const std::string& mixer_spec, uint8_t volume);
/// @return true when the control took the mute, or has no switch for the caller to fall back.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doc says set_mixer_mute_ returns true when the control has no switch, but the code returns false (has_playback_switch && ...). The software fallback in set_muted relies on false, so I'd fix the comment. Either way, please make them agree.

Comment thread src/alsa_sink.cpp Outdated
if (elem == nullptr) {
return -1;
}
muted = !playback_switch_(elem);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related: for a control without a switch, playback_switch_ returns false, so this sets muted = true. The header says "false without one". A player on a switchless control then starts muted and reports muted: true. Combined with the issue in set_volume, it then unmutes on the first volume command. Something like muted = snd_mixer_selem_has_playback_switch(elem) && !playback_switch_(elem); would fix it.

Comment thread src/main.cpp Outdated
// hardware value is what a controller should show until the server sends its own.
bool mixer_muted = false;
if (!opts.alsa_mixer.empty()) {
const int mixer_volume = AlsaAudioSink::read_mixer_volume(opts.alsa_mixer, mixer_muted);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (spec MUST). The spec says: "State updates MUST be sent whenever a field in the player state object changes, including when the volume was changed through a server/command or via device controls."

This is the only time we read the mixer. A change from amixer, alsamixer, another app or a hardware knob afterwards never reaches the player state, so controllers show a stale volume and mute.

We need to watch the mixer. Keep a handle open and add its snd_mixer_poll_descriptors to a loop that calls snd_mixer_handle_events, with an elem callback (a periodic re-read would also work). On a change, map dB back to 0-100 and push the volume and mute through the player so client/state goes out. Skip echoes of our own writes.

Comment thread src/alsa_sink.cpp Outdated
if (volume == 0) {
return min;
}
// alsamixer's curve: linear in perceived loudness between the control's endpoints, not in

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (spec SHOULD, non-blocking). The spec curve is amplitude = (v/100)^1.5, which is 30·log10(v/100) dB. The alsamixer/Snapcast mapping here works out to 60·log10(v/100) dB, twice the slope. At volume 50 that is −18.1 dB, against −9.0 dB in the spec. A --alsa-mixer player would be 9 dB quieter than a software-volume player in the same group, and the level jumps when we fall back to software gain, since pcm_volume.h uses ^1.5.

The cubic curve is the Linux norm (alsamixer, PulseAudio, PipeWire), so we won't block on it. If you're open to it, the spec curve in dB is max + 3000·log10(v/100) in 0.01 dB units, clamped to min, with the inverse for read-back. Also, alsamixer itself uses a linear-in-dB scale for controls with a range of 24 dB or less, and this mapping doesn't.

Comment thread src/alsa_sink.cpp
if (volume >= 100) {
return max;
}
if (volume == 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (non-blocking). Volume 0 maps to the control's dB floor. That is only silent when the floor is SND_CTL_TLV_DB_GAIN_MUTE. Many controls bottom out at something like −60 dB, which is still audible. The spec maps volume 0 to amplitude 0, where "0 is silent". Consider also turning the switch off, or using software gain 0, at volume 0.

Rasmus and others added 5 commits October 27, 2026 08:58
…r watch

Two spec MUSTs were broken. A volume change could clear a mute that fell
back to software gain, because set_volume forced the PCM multiplier to
unity regardless of muted_; the software gain is now derived from both
fields, so mute survives it. And the mixer was read once at startup, so a
change from amixer, alsamixer or a hardware knob never reached client/state;
the sink now keeps the control open, watches its poll descriptors, and
publishes a change through the player role.

Also from the review: the volume-to-dB curve now follows the spec's
30*log10(v/100) instead of alsamixer's twice-as-steep slope (-9.0 dB at 50,
not -18.1), volume 0 silences the stream even when the control's floor is
not silent, mixer writes step over a short ramp, set_mixer_mute_'s doc
matches its return, and a switchless control no longer reports muted.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
Resolve conflicts between --alsa-mixer and the Windows port plus
--release-device that landed upstream. Both sides added independent
enumerators, options, config keys, long options, help text, and tests, so
every conflict is additive and keeps both features.

- src/cli.h, src/cli.cpp: keep Opt::AlsaMixer and Opt::ReleaseDevice,
  both SettableOption rows, both config setters, both long options, both
  usage blocks, and both DAEMON_ONLY entries.
- src/player_listener.h: keep on_sink_volume_changed() and
  release_device_between_streams().
- docs/wiki/Configuration.md: keep both option-table rows.
- tests/cli_test.cpp, tests/config_file_test.cpp: keep both test groups.

Also correct the --alsa-mixer help text, which still described the old
alsamixer-shaped curve; the review commit moved it to the spec's curve.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
The flag shipped without a README row; add one next to --fixed-volume,
matching the wiki's Configuration table.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
The hardware-mixer volume curve was hardcoded to the spec's 30*log10(v/100).
--alsa-mixer-curve now picks between that and the steeper cubic curve
(Snapcast's), whose inverse 100*10^(dB/60) is alsamixer's slider position, so
the two track each other on a control with a wide range. Default stays spec;
the flag needs --alsa-mixer, is settable from a config file, and rejects
anything but spec or cubic.

A control that mutes at its lowest step reports that step as
SND_CTL_TLV_DB_GAIN_MUTE (-9999999), so its dB range came back as
-99999.99 dB .. 0 dB. As the curve floor that collapsed every volume but 100%
onto it and inflated the ramp step to the whole sentinel range. playback_dB_range_
now rebuilds the floor from the raw range and the per-step dB, which is where
alsamixer gets the same numbers; a range without the sentinel is untouched.

Verified on an IQaudIO DAC (card 1, Digital): the floor resolves to -103.50 dB
instead of the sentinel, and the read-back matches (raw 153 -> -27.00 dB ->
volume 12 spec / 35 cubic).

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
@TermeHansen

TermeHansen commented Oct 30, 2026 •

Copy link
Copy Markdown
Author

@chrisuthe

Two additions on top of the review feedback, both pushed to hardware_mixer.

--alsa-mixer-curve <spec|cubic>

The spec's 30*log10(v/100) curve is the right default, but it doesn't line up with what alsamixer shows for the same control. alsamixer's slider position is 100*10^(dB/60) (alsa-utils volume_mapping.c), and that is exactly the inverse of a steeper, cubic curve — amplitude (v/100)^3, i.e. 60*log10(v/100) dB.

So on a control with a wide range the two disagree badly. On my DAC (raw 0–207, ~0.5 dB/step, so ~103 dB of range) a setting that alsamixer calls 50% lands at volume 71 on the spec curve. With cubic selected the two track each other: volume 50 → −18.06 dB → alsamixer 50%, volume 25 → −36.12 dB → alsamixer 25%.

cubic is also what Snapcast uses for hardware volume, which is what I was used to before, so this keeps the feel I had there. Default stays spec, so nothing changes unless the flag is given. It needs --alsa-mixer, is settable from the config file, and rejects anything but spec/cubic.

Floor of silence on the DAC

My IQaudIO DAC's Digital control mutes at its lowest step, so ALSA reports that step as SND_CTL_TLV_DB_GAIN_MUTE (−9999999) instead of a real level. snd_mixer_selem_get_playback_dB_range() then returns −99999.99 dB .. 0 dB, and as the curve floor that collapsed every volume but 100% onto it and inflated the ramp step to the whole sentinel range.

playback_dB_range_() now rebuilds the floor from the raw range and the per-step dB when it sees the sentinel — the same numbers alsamixer derives. A range without the sentinel is untouched, so controls that already report a real floor (my other card's PCM, −51 dB .. 0 dB) are unaffected.

Verified on the DAC: the floor resolves to −103.50 dB instead of the sentinel, and the read-back matches (raw 153 → −27.00 dB → volume 12 on spec, 35 on cubic).

Tests: 548 pass on the Pi (aarch64) and locally, including the new curve and floor cases.

@TermeHansen
TermeHansen requested a review from chrisuthe October 30, 2026 10:53
Rasmus and others added 2 commits October 30, 2026 12:23
Four issues on the mixer path, each reachable with a real control.

A refused mixer write left the software gain at unity, so a control that
rejected writes (or a switch write it would not take) kept playing at the old
level. Track whether the control actually carries the level and the mute in
mixer_carries_volume_/mixer_carries_mute_, cleared by a refused write and
restored by one that succeeds; set from write_mixer_dB_, so a failure partway
through a ramp is caught too.

A newer volume request that took the immediate-write branch left any ramp in
flight running, and the next poll resumed writing toward the older target. The
same happened when an outside change was accepted. Cancel the pending ramp in
both places.

On a control with no playback switch, an outside dB change read muted as false
and assigned it to muted_, audibly unmuting a stream the user had muted. Only a
control with a switch can report a mute change, so leave muted_ alone when
there is none, and report the preserved state.

A mute-at-minimum control leaves mixer_reported_dB_ at the mute sentinel, so the
next ramp started near -100,000 dB: every intermediate write sat at the mute
step and then jumped. Clamp the ramp's start to the repaired range, leaving the
reported value itself alone since it recognizes echoes.

Verified on the IQaudIO DAC: the ramp from the mute step walks 0 -> 50 -> 101 ->
151 -> 201 instead of pinning at the mute step and jumping, and an outside change
on the switchless Analogue control no longer clears the mute.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
report_mixer_change_() cancelled the volume ramp on any event, including a
mute-only change. Muting mid-ramp stopped the mixer at an intermediate level,
and unmuting then adopted that level as volume_ and reported it to the server,
losing the level that was actually asked for. Cancel the ramp and adopt the level
only for an outside volume change; a mute-only change leaves the ramp to finish
at its target.

A ramp step that failed cleared the carry flag but left the software multiplier
at the unity a running ramp uses, so the level stayed wherever the control last
accepted a write while software scaled nothing. Refresh the multiplier when a
ramp is abandoned.

open_mixer_() closed the handle on every failure path but left the caller's
pointer dangling. The persistent mixer_ member then held freed memory and the
destructor's mixer_close_() freed it again, so a typo in --alsa-mixer crashed the
process on exit instead of falling back to software volume. Clear the pointer
wherever the handle is closed.

Verified on the IQaudIO DAC: an external mute mid-ramp leaves the ramp running
and adopts the mute, a failed ramp step hands the level to the software gain
(multiplier 3667107365, not unity), and an unopenable control exits cleanly
instead of segfaulting in snd_mixer_close.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants