Metering and the metrics ring buffer
PedalKernel has an optional real-time metering layer that lets a UI, TUI, or external tool observe what’s going on inside a running CompiledPedal without touching the audio thread. It’s the hook you’d use to build a VU meter, a tube-glow shader, a compressor GR display, a stage-level scope, or a power-supply-sag indicator.
The code lives in pedalkernel/src/metering.rs (~500 lines). It’s always compiled in, but zero-cost when disabled: the per-sample hot path skips a single Option::is_some() check when metering isn’t enabled, and when it is enabled it never allocates.
What’s recorded
One snapshot — a UiMetrics struct — is published every block (every ~128 samples, so ~375 snapshots/sec at 48 kHz). That’s comfortably above UI refresh rates (60 fps = ~16.7 ms) so the reader always has fresh data.
UiMetrics is #[repr(C)] Copy, ~200 bytes, and fits a fixed shape regardless of circuit complexity. It has four logical sections:
Signal levels. RMS and peak in dB for the stage input and output, plus signal_min / signal_max (per-block envelope) and lfo_phase (for modulation-source displays).
Compression. gain_reduction_db — sampled from the active compressor control voltage or, for tube-based compressors, the bias-shift deflection.
Per-stage levels. stage_levels[16] — normalized RMS at the output of each stage in the pipeline. Every stage kind participates (WdfStage, MultiNlStage, OpAmpStage, PushPullStage). stage_count reports how many are active.
Device state. tube_plate_current[12] (mA) and tube_dissipation[12] (W) for up to 12 tubes, ready to drive a glow shader. supply_voltage and supply_sag for a “B+ droops under load” meter. transformer_flux[4] — currently a stub (zeroed; the Jiles-Atherton transformer model
now carries H, Hdot, M, Mdot per sample in JaState, so the underlying data is available — what’s missing is a way for the accumulator to walk the WDF leaf list and pull each transformer core’s M and B).
The ring buffer
MetricsRingBuffer is a single-producer, single-consumer lock-free ring:
- Capacity: 16 frames. Power-of-2 so wrap is a bitmask (
idx & mask), not a modulo. - Time coverage: ~43 ms at the 375 Hz write rate. More than enough head-room for a 60 fps reader to always land on a fresh frame even under jitter.
- Allocation:
Box’d once at construction. Never grows, never reallocates. - Synchronisation: a single
AtomicUsizewrite index.
Write path
The audio thread does two things per sample: accumulate, and occasionally reduce.
Accumulation is cheap — cumulative sum-of-squares for RMS, max for peak, per-stage samples indexed by position in the stage vector. All branch-free after the initial if let Some(ref mut acc) = self.metrics_accumulator gate.
if let Some(ref mut acc) = self.metrics_accumulator {
acc.accumulate_levels(input, output);
for (i, &lvl) in stage_levels.iter().enumerate() {
acc.accumulate_stage(i, lvl);
}
// tube plate current, LFO phase, supply voltage...
}
Every ~128 samples acc.is_block_complete() returns true, and the accumulator reduces itself into a UiMetrics (RMS via sqrt(sum_sq / n), peak hold with slow decay, per-stage normalisation) and publishes into the ring buffer:
let metrics = acc.reduce(); // drains + resets accumulators
if let Some(ref buffer) = self.metrics_buffer {
buffer.write(metrics);
}
The write itself is wait-free — one volatile store to the slot, one atomic increment on the write index with Release ordering:
let idx = self.write_idx.load(Ordering::Relaxed);
let slot = idx & self.mask;
unsafe { std::ptr::write_volatile(ptr.add(slot), metrics); }
self.write_idx.store(idx.wrapping_add(1), Ordering::Release);
No locks, no allocations, no system calls. write_volatile keeps the compiler from reordering the store past the index bump.
Read path
Consumers (a UI, a TUI panel, an external tool over IPC) call read_latest() and get the most recently published UiMetrics:
pub fn read_latest(&self) -> UiMetrics {
let idx = self.write_idx.load(Ordering::Acquire);
if idx == 0 { return UiMetrics::default(); }
let slot = (idx.wrapping_sub(1)) & self.mask;
unsafe { std::ptr::read_volatile(ptr.add(slot)) }
}
Acquire on the read pairs with Release on the write so any data stores before the index bump are visible to the reader. The read_volatile matches the write_volatile on the other side.
The consumer API on CompiledPedal:
pedal.metrics_buffer() -> Option<Arc<MetricsRingBuffer>>— shares the buffer handle, intended for UI threads to hold across samples.pedal.read_metrics() -> UiMetrics— convenience that reads from the latest slot or returns a default if the buffer is empty.
There is no blocking-wait or callback API today. Polling at display rate is the intended pattern.
Stage integration
All four stage kinds feed stage_levels[]. The process loop samples the post-stage output into the accumulator regardless of stage kind, so the array covers WDF trees (with all RootKind variants — passive or nonlinear), multi-NL R-type stages (whether they take the IIR or state-space fast path or solve the full coupled NR), op-amp stages, and push-pull triode pairs uniformly. For a wah-and-delay pedal you’d see one level per stage in the order the compiler placed them.
Tube-specific fields are tapped from RootKind::Triode and RootKind::Pentode variants inside Stage::Wdf. For push-pull topologies both tubes are sampled independently so a glow shader can show them breathing out of phase.
Enabling at runtime
Metering is opt-in. Creating a pedal leaves it off; you turn it on when you want data:
let mut pedal = compile_pedal(&def, 48_000.0)?;
pedal.enable_metering(); // allocates the accumulator and ring buffer
// ... later, on the UI thread:
let metrics = pedal.read_metrics();
draw_vu(metrics.output_rms_db);
When metering is disabled, self.metrics_accumulator is None and the per-sample path skips all accumulation.
Known limitations
Four things worth flagging for contributors:
- Transformer flux is a stub.
transformer_flux[4]is zeroed every block. The Jiles-Atherton core now carriesM(magnetisation) andB(flux density, derivable fromMandH) per sample inJaState, so the underlying data is live — what’s missing is the accumulator pass that walks each transformer’s WDF leaf and copies the right scalar out. Shovel-ready follow-up. - Peak decay happens on the audio thread.
peak *= 0.9995per block means peak holds decay at ~73% per 10 ms. A UI layer should not apply its own decay on top — you’d end up double-smoothing and the meters would look sluggish. - Polling only. No
wait_next_frame()or callback API. A UI thread pinned tovsyncis fine; a latency-sensitive consumer that needs “new data” signalling would have to build that on top. - Single consumer. The SPSC design means only one reader. Multiple panels sharing a pedal need to share an
Arc<MetricsRingBuffer>and pull from the same slot, or the architecture needs a broadcast layer.
No UI consumes this yet — the infrastructure landed ahead of the display work. If you’re building a VST UI panel or a TUI meter, start with pedal.metrics_buffer() and a 60 Hz poll.
Where to look
metering.rs— types (UiMetrics,MetricsAccumulator,MetricsRingBuffer), constants (MAX_TUBES,MAX_TRANSFORMERS,MAX_STAGES), block-reduction math.compiler/compiled.rs— where the process loop accumulates per sample and triggers the block reduction. Look formetrics_accumulatorandmetrics_buffer.