Goal
Moving the channel-fader on the rack should change the audible volume immediately, even while sendspin is streaming music to that room. Currently:
- Test playback (chime / TTS via the ▶ buttons) already respects the live slider value: the test endpoint pre-scales the audio with
sox … vol G | aplay. This works because the test endpoint pipes the audio in real-time from the webui process.
- Real playback (sendspin → room device → ALSA → amp) only picks up volume changes after
Apply, which regenerates /etc/asound.conf and restarts the affected sendspin services (audio gap).
Why it's harder
ALSA's route plugin bakes volume into a fixed ttable coefficient at config-parse time. There's no way to mutate ttable values at runtime — already-open streams hold their own copy.
The right ALSA primitive for runtime-tunable per-channel gain is softvol. It registers a kernel mixer control whose value is changeable any time via amixer cset, and changes are picked up live by all open streams using that softvol.
Proposed implementation
1. Refactor generate_alsa_config.py
For each speaker (amp+channel pair), emit a mono softvol-wrapped path:
pcm._internal_<spk>_route {
type route
slave.pcm "<amp>_dmix"
slave.channels 8
ttable.0.<ch_idx> <max_vol>
}
pcm._internal_<spk> {
type softvol
slave.pcm "_internal_<spk>_route"
control { name "vol_<spk>" card <amp_id> }
min_dB -60.0
max_dB 0.0
resolution 256
}
Room devices fan out via multi over those mono softvol paths:
pcm.room_<id> {
type plug
slave.pcm {
type multi
slaves.l.pcm "_internal_<left_spk>" channels 1
slaves.r.pcm "_internal_<right_spk>" channels 1
bindings.0 { slave l channel 0 }
bindings.1 { slave r channel 0 }
}
}
Per-channel test devices (amp1_chN) keep their existing ttable scaling (no softvol) since the test endpoint already does live volume via sox.
2. Apply pipeline
After installing the new /etc/asound.conf, run amixer cset for every speaker to seed each softvol with the current speaker.volume (translated from 0–100 linear to softvol-percentage):
# slider 0..100 → amixer % via dB conversion
db = 20 * log10(max(volume / 100, 0.001))
amixer_pct = (db - min_db) / (max_db - min_db) * 100
amixer -c <amp_id> sset "vol_<spk>" {amixer_pct}%
3. New API endpoint
POST /api/system/channel-volume
body: {amp, channel, volume} # volume 0..100 linear
-> looks up speaker, runs amixer cset, returns OK
4. Frontend
- Slider
oninput calls setChannelVolume() (existing) AND a debounced (~30 ms) live-update fetch to /api/system/channel-volume with the new value.
- Apply still writes the JSON; the softvol values are reseeded after the regen so live changes are committed atomically.
Considerations
- dB vs linear scale: softvol is dB-scale. A linear 0–100 slider feels logarithmic in dB. We can either (a) convert in the frontend (slider linear → backend converts to amixer-pct via log10), or (b) leave it dB-style and accept the perceived behaviour (which actually matches human loudness perception). Recommend (a) so the JSON values keep their current linear meaning.
- Cross-device pairs (Esszimmer, Küche): each side's softvol is on a different amp's card. Two amixer calls per change. Trivial.
- Static updates on Apply: when speaker assignment changes (drag a cable to a different channel), the old softvol stays at its last value; the new channel's softvol gets seeded. We'd need to track this in the apply diff.
amp1_vol (existing softvol on each amp): probably an artifact of an older config. New softvols use unique per-speaker names, so no clash.
- Substream limits: softvol creates a control per channel; each amp card already has plenty of headroom (only 5 controls today).
Effort
~2-3 hours including cross-device pair stress test.
Out of scope
This issue is just per-speaker volume. Global max_volume could move to its own softvol per amp later, but works fine as the existing ttable factor for now.
Goal
Moving the channel-fader on the rack should change the audible volume immediately, even while sendspin is streaming music to that room. Currently:
sox … vol G | aplay. This works because the test endpoint pipes the audio in real-time from the webui process.Apply, which regenerates/etc/asound.confand restarts the affected sendspin services (audio gap).Why it's harder
ALSA's
routeplugin bakes volume into a fixedttablecoefficient at config-parse time. There's no way to mutate ttable values at runtime — already-open streams hold their own copy.The right ALSA primitive for runtime-tunable per-channel gain is
softvol. It registers a kernel mixer control whose value is changeable any time viaamixer cset, and changes are picked up live by all open streams using that softvol.Proposed implementation
1. Refactor
generate_alsa_config.pyFor each speaker (amp+channel pair), emit a mono softvol-wrapped path:
Room devices fan out via
multiover those mono softvol paths:Per-channel test devices (
amp1_chN) keep their existing ttable scaling (no softvol) since the test endpoint already does live volume via sox.2. Apply pipeline
After installing the new
/etc/asound.conf, runamixer csetfor every speaker to seed each softvol with the currentspeaker.volume(translated from 0–100 linear to softvol-percentage):3. New API endpoint
4. Frontend
oninputcallssetChannelVolume()(existing) AND a debounced (~30 ms) live-update fetch to/api/system/channel-volumewith the new value.Considerations
amp1_vol(existing softvol on each amp): probably an artifact of an older config. New softvols use unique per-speaker names, so no clash.Effort
~2-3 hours including cross-device pair stress test.
Out of scope
This issue is just per-speaker volume. Global
max_volumecould move to its ownsoftvolper amp later, but works fine as the existing ttable factor for now.