1// What the page actually played, measured: taps on what superdough sends 2// the speakers (after its master limiter, `output.speakers`) and on each 3// orbit's output, read a few times a second and filed under the 4// scheduler's cycle, so jev() can tell Jev how each section sounded 5// (loudness, peak, clipping), in total and per part, instead of only what 6// its code says. TypeSafe's models read text, not audio; these numbers are 7// the audio, as text. 8// 9// Parts: songs put each part Jev levels on its own orbit, named for its 10// question (`part('riff', …)` plays on `.orbit('riff')`). Superdough creates 11// an orbit the first time a sound uses it, so new ones are tapped as they 12// appear. Numbered orbits (the default is 1) are not parts: they are 13// reported together as `other`. 14// 15// Every sample counts: each tap is an AudioWorklet that sums squares and 16// keeps the peak of all it hears, and a reading takes what accumulated 17// since the last. (An analyser's snapshot saw 2048 of the ~4800 samples in 18// a 100 ms tick, so peaks between snapshots went unseen and two runs of one 19// mix read peaks 3 dB apart, 2026-09-25.) A reading carries its sample 20// count, so a throttled timer's long tick weighs what it covered. 21import { getSuperdoughAudioController } from '@strudel/webaudio'; 22 23const TICK_MS = 100; 24const KEEP = 4000; // readings, about 6-7 minutes 25const SILENT_RMS = 0.003; 26 27// node -> { sumsq, n, peak } accumulating; a replaced node (a reset) gets a new tap 28const taps = new WeakMap(); 29// 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});`; 50 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; 67// Orbits outlive songs (superdough keeps every one it made), so a part 68// counts only once it has sounded in this play: another song's orbit is 69// not this song's silent part. 70let sounded = new Set(); 71 72// What a node played since the last call (every sample of it), or null 73// 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} 97 98// 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 }); 100 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); 138 139// Loudness, peak and share of silence over readings of { rms, peak, n }, 140// 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} 149 150// Per part over readings; a part missing from a reading was silent then 151// (its orbit did not exist yet, or had not sounded in this play). 152export function summarizeParts(span) { 153 const names = [...new Set(span.flatMap((r) => Object.keys(r.parts ?? {})))].sort(); 154 return Object.fromEntries( 155 names.map((name) => [name, summarize(span.map((r) => r.parts?.[name] ?? { rms: 0, peak: 0, n: r.n }))]), 156 ); 157} 158 159// The newest reading (total and per part, linear rms and peak), or null 160// while nothing plays: the mixer's live meters. 161export function latest() { 162 const r = readings[readings.length - 1]; 163 const scheduler = globalThis.strudelMirror?.repl?.scheduler; 164 return r && scheduler?.started ? r : null; 165} 166 167// 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} 179 180// For tools (tools/measure): everything measured since the play began. 181if (typeof window !== 'undefined') { 182 window.jevMeter = { 183 hear, 184 all: () => { 185 if (readings.length < 3) return null; 186 return { total: summarize(readings), parts: summarizeParts(readings), readings: readings.length }; 187 }, 188 }; 189}