Goal
In the /amplifiers rack UI, show a real level meter (peak + RMS, L/R) per room slot. Levels must reflect what the speakers are actually playing — including TTS, chimes from the test buttons, sendspin streams, and anything else routed through the room's ALSA device — not a network-protocol-level proxy.
Approach: snd-aloop fanout + Python meter daemon
ALSA-level capture without modifying the audio path itself. Each room's playback fans out to both the real amp channels AND a private snd-aloop substream; a metering daemon captures the loopback side, computes peak/RMS, pushes to the browser over a WebSocket.
1. Persist snd-aloop
echo snd-aloop | sudo tee /etc/modules-load.d/snd-aloop.conf
echo "options snd-aloop pcm_substreams=16" | sudo tee /etc/modprobe.d/snd-aloop.conf
sudo modprobe snd-aloop pcm_substreams=16
16 substream pairs is enough for current 8 rooms with headroom. Each substream pair = one playback target (hw:Loopback,0,N) and a matching capture source (hw:Loopback,1,N).
2. Modify generate_alsa_config.py
Each room_<id> device today plugs into _internal_<id>_route (which routes stereo to specific amp channels, sometimes cross-device via multi). Wrap that in a new outer multi so the stereo input is duplicated to:
- existing
_internal_<id>_route → amp output (unchanged)
plug:hw:Loopback,0,N → meter tap
multi requires duplicated input channels via a preceding route (input ch 0 → outputs 0+2, input ch 1 → outputs 1+3), then multi binds:
- 0 → amp.L
- 1 → amp.R
- 2 → tap.0
- 3 → tap.1
N is allocated by sorted room order at generate time. Allocation table is emitted alongside the asound.conf (e.g. as a JSON sidecar /etc/asound.conf.meters.json with {room_id: substream_index}) so the daemon knows which substream to listen on.
3. Meter daemon (asyncio task inside webui)
- For each row in the allocation table, opens
arecord -D hw:Loopback,1,N -f S16_LE -r 48000 -c 2 -t raw as a subprocess pipe
- Reads PCM in ~50 ms windows (4800 frames × 2 ch × 2 bytes = 19,200 bytes)
- Per channel:
- peak = max abs sample → dBFS =
20*log10(peak/32768)
- RMS =
sqrt(sum(s² )/N) → dBFS = 20*log10(rms/32768)
- Holds latest
{room_id: {peak_l, peak_r, rms_l, rms_r, ts}}
- Reloads on apply (substream allocations may have changed)
4. WebSocket endpoint
/ws/levels — server pushes the latest dict at ~30 Hz. Multiple browsers can subscribe.
5. Frontend
Inside each room slot in the patch panel, a thin LED-style L/R meter bar next to the volume slider:
- Gradient: green → yellow above –12 dB → red above –3 dB
- Peak-hold tick that decays after ~1.5 s
- Bar value smoothed with ~200 ms decay so it falls cleanly when audio stops
Alternatives considered (and rejected)
| Approach |
Why not |
Sendspin's built-in visualizer role (loudness/spectrum frames) |
Sendspin-protocol-level only; misses TTS/chimes/AirPlay/anything that doesn't go through sendspin. User wants ALSA level. |
ALSA meter plugin with custom scope |
Scopes are loadable .so plugins with a private shm protocol; would need writing C code for our scope. Not worth it vs. aloop. |
ALSA file plugin to FIFO |
Synchronous: if reader stalls, playback hangs. Daemon supervision is fragile. |
bytes_received delta on sendspin WebSocket |
Only "is data flowing", not actual level; sendspin-only. |
| Reading dmix shared memory directly |
Per-amp only (mixed across rooms), not per-room. Format is internal. |
| eBPF on USB writes |
Way too invasive. |
Risks
- CPU: each room runs an extra ALSA path + arecord. On RPi 5, modest (~1–2 % per room) but real.
- Cross-device pairs (Küche, Esszimmer) make the route stage hairy — need careful channel duplication. Test this first.
- Allocation drift: rooms added/removed between applies could shift substream indices. Daemon must reload from the sidecar JSON on every apply.
- Daemon crash: meter daemon dying must not block playback. Aloop is non-blocking on the playback side (kernel buffers), so this is safer than a
file plugin FIFO — but verify under load.
- Substream cap: 16 today; bump if needed.
Effort
Half a day to a full day, depending on how clean the cross-device pair fanout turns out.
Implementation checklist
Goal
In the
/amplifiersrack UI, show a real level meter (peak + RMS, L/R) per room slot. Levels must reflect what the speakers are actually playing — including TTS, chimes from the test buttons, sendspin streams, and anything else routed through the room's ALSA device — not a network-protocol-level proxy.Approach: snd-aloop fanout + Python meter daemon
ALSA-level capture without modifying the audio path itself. Each room's playback fans out to both the real amp channels AND a private
snd-aloopsubstream; a metering daemon captures the loopback side, computes peak/RMS, pushes to the browser over a WebSocket.1. Persist
snd-aloop16 substream pairs is enough for current 8 rooms with headroom. Each substream pair = one playback target (
hw:Loopback,0,N) and a matching capture source (hw:Loopback,1,N).2. Modify
generate_alsa_config.pyEach
room_<id>device today plugs into_internal_<id>_route(which routes stereo to specific amp channels, sometimes cross-device viamulti). Wrap that in a new outermultiso the stereo input is duplicated to:_internal_<id>_route→ amp output (unchanged)plug:hw:Loopback,0,N→ meter tapmultirequires duplicated input channels via a precedingroute(input ch 0 → outputs 0+2, input ch 1 → outputs 1+3), thenmultibinds:Nis allocated by sorted room order at generate time. Allocation table is emitted alongside the asound.conf (e.g. as a JSON sidecar/etc/asound.conf.meters.jsonwith{room_id: substream_index}) so the daemon knows which substream to listen on.3. Meter daemon (asyncio task inside webui)
arecord -D hw:Loopback,1,N -f S16_LE -r 48000 -c 2 -t rawas a subprocess pipe20*log10(peak/32768)sqrt(sum(s² )/N)→ dBFS =20*log10(rms/32768){room_id: {peak_l, peak_r, rms_l, rms_r, ts}}4. WebSocket endpoint
/ws/levels— server pushes the latest dict at ~30 Hz. Multiple browsers can subscribe.5. Frontend
Inside each room slot in the patch panel, a thin LED-style L/R meter bar next to the volume slider:
Alternatives considered (and rejected)
visualizerrole (loudness/spectrum frames)meterplugin with custom scopefileplugin to FIFObytes_receiveddelta on sendspin WebSocketRisks
fileplugin FIFO — but verify under load.Effort
Half a day to a full day, depending on how clean the cross-device pair fanout turns out.
Implementation checklist
snd-aloop(modules-load.d + modprobe.d)generate_alsa_config.pyto allocate substreams and emit fanout PCM defs + sidecar JSONMeterServiceto webui (asyncio task, arecord subprocess per room)/ws/levelsWebSocket endpointamplifiers.html/api/config/apply