jevstrudel.git / website / src / jev / meter.mjs
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}