What the page actually played, measured: taps on what superdough sends
the speakers (after its master limiter, output.speakers) and on each
orbit's output, read a few times a second and filed under the
scheduler's cycle, so jev() can tell Jev how each section sounded
(loudness, peak, clipping), in total and per part, instead of only what
its code says. TypeSafe's models read text, not audio; these numbers are
the audio, as text.
Parts: songs put each part Jev levels on its own orbit, named for its
question (part('riff', …) plays on .orbit('riff')). Superdough creates
an orbit the first time a sound uses it, so new ones are tapped as they
appear. Numbered orbits (the default is 1) are not parts: they are
reported together as other.
Every sample counts: each tap is an AudioWorklet that sums squares and keeps the peak of all it hears, and a reading takes what accumulated since the last. (An analyser's snapshot saw 2048 of the ~4800 samples in a 100 ms tick, so peaks between snapshots went unseen and two runs of one mix read peaks 3 dB apart, 2026-09-25.) A reading carries its sample count, so a throttled timer's long tick weighs what it covered.
21import { getSuperdoughAudioController } from '@strudel/webaudio';
node -> { sumsq, n, peak } accumulating; a replaced node (a reset) gets a new tap
28const taps = new WeakMap();
context -> 'loading' | 'ready'; a failed load is forgotten, so the next tick retries
30const worklets = new WeakMap(); 31const WORKLET = `registerProcessor('jev-meter', class extends AudioWorkletProcessor { 32 constructor() { super(); this.sumsq = 0; this.n = 0; this.peak = 0; } 33 process(inputs) { 34 for (const channel of inputs[0] ?? []) { 35 for (let i = 0; i < channel.length; i++) { 36 const x = channel[i]; 37 this.sumsq += x * x; 38 const a = x < 0 ? -x : x; 39 if (a > this.peak) this.peak = a; 40 } 41 this.n += channel.length; 42 } 43 if (this.n >= 2048) { 44 this.port.postMessage({ sumsq: this.sumsq, n: this.n, peak: this.peak }); 45 this.sumsq = 0; this.n = 0; this.peak = 0; 46 } 47 return true; 48 } 49});`;
51function workletReady(ctx) { 52 const state = worklets.get(ctx); 53 if (state) return state === 'ready'; 54 worklets.set(ctx, 'loading'); 55 const url = URL.createObjectURL(new Blob([WORKLET], { type: 'application/javascript' })); 56 ctx.audioWorklet 57 .addModule(url) 58 .then(() => worklets.set(ctx, 'ready')) 59 .catch((e) => { 60 worklets.delete(ctx); 61 console.warn('[jev] meter: could not load its AudioWorklet; retrying', e); 62 }); 63 return false; 64} 65let readings = []; // { cycle, rms, peak, parts: { name: { rms, peak } } } 66let lastCycle = -Infinity;
Orbits outlive songs (superdough keeps every one it made), so a part counts only once it has sounded in this play: another song's orbit is not this song's silent part.
70let sounded = new Set();
What a node played since the last call (every sample of it), or null while its tap is starting or has heard nothing yet.
74function level(node) { 75 let tap = taps.get(node); 76 if (!tap) { 77 // the node's own context: the page's may have been replaced since 78 const ctx = node.context; 79 if (!workletReady(ctx)) return null; 80 const meter = new AudioWorkletNode(ctx, 'jev-meter', { numberOfInputs: 1, numberOfOutputs: 0 }); 81 tap = { sumsq: 0, n: 0, peak: 0 }; 82 meter.port.onmessage = ({ data }) => { 83 tap.sumsq += data.sumsq; 84 tap.n += data.n; 85 tap.peak = Math.max(tap.peak, data.peak); 86 }; 87 node.connect(meter); 88 taps.set(node, tap); 89 } 90 if (!tap.n) return null; 91 const l = { rms: Math.sqrt(tap.sumsq / tap.n), peak: tap.peak, n: tap.n }; 92 tap.sumsq = 0; 93 tap.n = 0; 94 tap.peak = 0; 95 return l; 96}
Sums of squares add for uncorrelated signals; close enough for "other".
99const combine = (a, b) => ({ rms: Math.sqrt(a.rms ** 2 + b.rms ** 2), peak: Math.max(a.peak, b.peak), n: a.n });
101export const partName = (orbit) => (/^\d+$/.test(String(orbit)) ? 'other' : String(orbit)); 102 103function read() { 104 try { 105 const scheduler = globalThis.strudelMirror?.repl?.scheduler; 106 const controller = getSuperdoughAudioController(); 107 // post-limiter: the total is what the listener hears 108 const output = controller?.output?.speakers; 109 if (!scheduler?.started || !output) return; 110 const cycle = scheduler.now(); 111 // a new play (stop resets the count) starts a new record 112 if (cycle < lastCycle) { 113 readings = []; 114 sounded = new Set(); 115 } 116 lastCycle = cycle; 117 const parts = {}; 118 for (const [orbit, node] of Object.entries(controller.nodes ?? {})) { 119 if (!node?.output) continue; 120 const name = partName(orbit); 121 const l = level(node.output); 122 if (!l) continue; 123 if (l.rms >= SILENT_RMS) sounded.add(name); 124 if (!sounded.has(name)) continue; 125 parts[name] = parts[name] ? combine(parts[name], l) : l; 126 } 127 const total = level(output); 128 if (!total) return; 129 readings.push({ cycle, ...total, parts }); 130 if (readings.length > KEEP) readings = readings.slice(-KEEP); 131 } catch { 132 // no audio yet (before the first click), or a context being replaced 133 } 134} 135if (typeof window !== 'undefined') setInterval(read, TICK_MS); 136 137const db = (x) => (x > 0 ? Math.round(20 * Math.log10(x) * 10) / 10 : -Infinity);
Loudness, peak and share of silence over readings of { rms, peak, n }, each weighted by the samples it covered (n, 1 when unknown).
141export function summarize(span) { 142 const weight = (r) => r.n ?? 1; 143 const total = span.reduce((sum, r) => sum + weight(r), 0); 144 const rms = Math.sqrt(span.reduce((sum, r) => sum + r.rms * r.rms * weight(r), 0) / total); 145 const peak = Math.max(...span.map((r) => r.peak)); 146 const quiet = span.filter((r) => r.rms < SILENT_RMS).reduce((sum, r) => sum + weight(r), 0) / total; 147 return { loudnessDbfs: db(rms), peakDbfs: db(peak), silentShare: Math.round(quiet * 100) / 100 }; 148}
Per part over readings; a part missing from a reading was silent then (its orbit did not exist yet, or had not sounded in this play).
The newest reading (total and per part, linear rms and peak), or null while nothing plays: the mixer's live meters.
What played between two cycles, or null when nothing was measured there.
168export function hear(from, to) { 169 const span = readings.filter((r) => r.cycle >= from && r.cycle < to); 170 if (span.length < 3) return null; 171 const total = summarize(span); 172 const parts = summarizeParts(span); 173 return { 174 ...total, 175 clipped: Math.max(...span.map((r) => r.peak)) >= 0.99, 176 ...(Object.keys(parts).length ? { parts } : {}), 177 }; 178}
For tools (tools/measure): everything measured since the play began.