Skip to content

Live per-speaker volume during real-time playback (sendspin streams) #2

@tobsch

Description

@tobsch

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions