Skip to content

Real per-room VU meters in the rack UI (ALSA-level capture) #1

@tobsch

Description

@tobsch

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

  • Persist snd-aloop (modules-load.d + modprobe.d)
  • Update generate_alsa_config.py to allocate substreams and emit fanout PCM defs + sidecar JSON
  • Validate fanout on a single same-amp room first (e.g. Wohnzimmer)
  • Validate cross-device pair (Esszimmer or Küche)
  • Add MeterService to webui (asyncio task, arecord subprocess per room)
  • Add /ws/levels WebSocket endpoint
  • Add CSS meter bars to room slots in amplifiers.html
  • Apply pipeline reloads meter daemon allocations on /api/config/apply
  • Stress test: run all rooms with audio for 30+ minutes, check for dropouts and CPU

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