jevstrudel.git / website / src / jev / meter.mjs

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';
23const TICK_MS = 100;
24const KEEP = 4000; // readings, about 6-7 minutes
25const SILENT_RMS = 0.003;

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).

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}

The newest reading (total and per part, linear rms and peak), or null 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}

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.

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}