Meter each track and the song, and limit the output at -1 dBFS (#359)
game-maker / gate (push) Successful in 3m43s
game-maker / suite (push) Failing after 25m11s

A song could clip at the output without anyone noticing: a track and a
sound item at +12 dB each add up to +24 dB. The Song tab now shows how
loud each track and the whole song are, and a fixed limiter behind the
master keeps every sample under -1 dBFS in the editor and in the game.

- Each track head has a stereo meter up its right edge. The head is
  190 px wide instead of 176.
- Out sits in the corner above the heads, with the loudest peak since
  Play in dB. In the tab header it made the row too wide for a 1280 px
  window, and the inspector lost its fields.
- The meters read AnalyserNodes beside the buses, one per side after a
  channel splitter, so a peak panned hard is not halved. They are tapped
  only while the song plays and its tab is shown.
- A clip lamp lights past 0 dBFS and stays through Stop. The next Play
  or a click on the meter puts it out.
- The meter of a track that mute or solo keeps from being heard is grey.
- Music always plays through a bus of its own, with or without a sound
  FX. That bus is what Out measures.
- The limiter is the one from #326 and needs the FX worklet. Until that
  is loaded the master goes straight out.
- It is 5 ms late. latency() counts that in, so the playhead and the
  alignment of a recorded take follow it.
- Unit tests build SoundLibrary without the limiter, as their wiring
  checks follow every node of a voice.
- Out shows "-inf" while nothing was loud. The UI font has no infinity
  sign, which the font test found.

Tests:
- Unit meter-view 8 new, sound-library 112 (2 new), 3 counter-tests
  each red
- e2e 65 (melektaus-atx): ALL PASSED, 55 checks (3 new). Out wrote
  +16.6 dB, behind the limiter the loudest sample was 0.8913
- Gate (melektaus-atx): 272 files, 4209 tests, one red in the font
  test. Fixed after it, then the font and meter tests ran green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-08 22:08:39 +02:00
1 parent dc666016db
commit 490fcba88f
12 files changed
+810 -137

No files matched your search

+147 -8
View File
@@ -36,6 +36,7 @@ import { type BusLevel, Playbacks, type SequenceNeeds, type SequencePlay } from
import { arrivalOf, sendDelays, type TimedBus } from '../core/music/routing';
import type { SendTap } from '../core/graph/Graph';
import { MASTER_MIX, type MixBus, sameMix } from '../core/music/mixer';
import { limiterLatency } from '../core/soundfx/dynamics';
import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler';
import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
import { DEFAULT_TEMPO } from '../core/music/time';
@@ -63,6 +64,12 @@ const MAX_SEND_DELAY = 1;
/** Seconds a stopped voice takes to fall silent, so it does not click (#324). */
const FADE = 0.01;
/** Samples a level meter looks back over (#359): a frame of 40 ms at 48 kHz misses no peak. */
const METER_SPAN = 2048;
/** The loudest the limiter at the output lets a sample be (#359), and how fast it lets go again. */
const GUARD_DB = -1;
const GUARD_RELEASE = 0.1;
/**
* The most ways that play through the pitch worklet at once, a phase vocoder each (#317). Measured in
* Chrome on the Windows runner: 12 take half of the audio thread, 24 all of it, and past that every
@@ -266,6 +273,15 @@ interface Pending {
export class SoundLibrary implements AudioSink {
private base = DEFAULT_BASE;
private context: AudioContext | null = null;
/**
* Where the master goes out (#359): through a limiter that keeps every sample at `GUARD_DB` or
* below, once the worklet is there; straight to the speakers until then. Late by the limiter.
*/
private guard: { input: GainNode; limiter: AudioWorkletNode | null; latency: number } | null = null;
private guardMeter: BusMeter | null = null;
/** `guarded` false leaves the limiter out: for checks that follow every node of a voice. */
constructor(private readonly guarded = true) {}
private offline: OfflineAudioContext | null = null;
private readonly buffers = new Map<string, AudioBuffer>();
/**
@@ -502,6 +518,72 @@ export class SoundLibrary implements AudioSink {
}));
}
/**
* The loudest sample on each side of these buses over the last moment (#359), as they leave them
* after level and pan, 1 at full scale. A bus is tapped the first time it is asked after, one not
* there is left out. The taps stay until `unmeter`.
*/
busPeaks(names: readonly string[]): Map<string, [number, number]> {
const found = new Map<string, [number, number]>();
for (const name of names) {
const bus = this.buses.get(name);
if (!bus) continue;
found.set(name, readMeter((bus.meter ??= this.tapMeter(bus.pan))));
}
return found;
}
/**
* The loudest sample on each side as it leaves the limiter at the output (#359), tapped the first
* time it is asked (test hook); null while there is no limiter.
*/
guardPeaks(): [number, number] | null {
const guard = this.guard;
if (!guard?.limiter) return null;
return readMeter((this.guardMeter ??= this.tapMeter(guard.limiter)));
}
/** Lets every level meter go. */
unmeter(): void {
for (const bus of this.buses.values()) this.dropMeter(bus);
}
/** How many buses are tapped for a meter (test hook). */
metered(): number {
return [...this.buses.values()].filter((bus) => bus.meter).length;
}
private tapMeter(from: AudioNode): BusMeter {
const context = this.audio();
const split = context.createChannelSplitter(2);
const sides = [0, 1].map((channel) => {
const side = context.createAnalyser();
side.fftSize = METER_SPAN;
split.connect(side, channel);
return side;
});
from.connect(split);
return { split, sides, samples: new Float32Array(METER_SPAN) };
}
private dropMeter(bus: Bus): void {
const meter = bus.meter;
if (!meter) return;
bus.meter = null;
try {
bus.pan.disconnect(meter.split);
} catch {
// Already cut with the rest of the bus.
}
meter.split.disconnect();
}
/** Cuts the pan of a bus from where it went; its meter stays on. */
private cutPan(bus: Bus): void {
bus.pan.disconnect();
if (bus.meter) bus.pan.connect(bus.meter.split);
}
/** Where the voices of a bus go in; the master for `''` (#325). */
private busInput(name: string): AudioNode {
return this.bus(name || MASTER_MIX).input;
@@ -529,6 +611,7 @@ export class SoundLibrary implements AudioSink {
fx: null,
chain: null,
leaving: 0,
meter: null,
};
made.input.connect(made.gain).connect(made.pan).connect(this.outOf(target));
this.buses.set(name, made);
@@ -536,7 +619,9 @@ export class SoundLibrary implements AudioSink {
}
private outOf(into: string | null): AudioNode {
return into ? this.bus(into).input : this.audio().destination;
if (into) return this.bus(into).input;
const context = this.audio();
return this.guard?.input ?? context.destination;
}
/** Sends a bus on into another one where it went somewhere else; the master stays heard. */
@@ -544,7 +629,7 @@ export class SoundLibrary implements AudioSink {
const target = name === MASTER_MIX ? null : (into ?? MASTER_MIX);
if (bus.into === target && !bus.sends) return;
this.dropSends(bus);
bus.pan.disconnect();
this.cutPan(bus);
bus.pan.connect(this.outOf(target));
bus.into = target;
}
@@ -567,7 +652,7 @@ export class SoundLibrary implements AudioSink {
return;
}
if (bus.sends) this.dropSends(bus);
else bus.pan.disconnect();
else this.cutPan(bus);
const context = this.audio();
bus.sends = sends.map(({ to, gain, tap }) => {
const delay = context.createDelay(MAX_SEND_DELAY);
@@ -840,6 +925,7 @@ export class SoundLibrary implements AudioSink {
}
private dropBus(name: string, bus: Bus): void {
this.dropMeter(bus);
this.dropSends(bus);
this.unchain(bus);
bus.input.disconnect();
@@ -1267,14 +1353,18 @@ export class SoundLibrary implements AudioSink {
return this.audio();
}
/** Seconds between the audio clock and what is heard; 0 where the browser does not say. */
/**
* Seconds between the audio clock and what is heard: the limiter at the output (#359) and the
* device, where the browser says.
*/
latency(): number {
const context = this.context;
if (!context) return 0;
const guard = this.guard?.latency ?? 0;
// Older Safari and Firefox have no `outputLatency`, some no `baseLatency` either.
const out = context.outputLatency;
if (Number.isFinite(out) && out > 0) return out;
return Number.isFinite(context.baseLatency) ? context.baseLatency : 0;
if (Number.isFinite(out) && out > 0) return guard + out;
return guard + (Number.isFinite(context.baseLatency) ? context.baseLatency : 0);
}
/**
@@ -1857,9 +1947,38 @@ export class SoundLibrary implements AudioSink {
private audio(): AudioContext {
if (this.context) return this.context;
this.context = new AudioContext();
const context = (this.context = new AudioContext());
if (this.guarded) this.guardOut(context);
if (this.mix.length > 0) void this.applyMix();
return this.context;
return context;
}
private guardOut(context: AudioContext): void {
const guard: NonNullable<SoundLibrary['guard']> = {
input: context.createGain(),
limiter: null,
latency: 0,
};
this.guard = guard;
guard.input.connect(context.destination);
void this.fxWorklet().then((loaded) => {
if (!loaded || this.guard !== guard) return;
let limiter: AudioWorkletNode;
try {
limiter = new AudioWorkletNode(context, 'limiter', {
outputChannelCount: [2],
channelCount: 2,
channelCountMode: 'explicit',
parameterData: { threshold: GUARD_DB, ceiling: GUARD_DB, release: GUARD_RELEASE, autoGain: 0 },
});
} catch {
return;
}
guard.input.disconnect();
guard.input.connect(limiter).connect(context.destination);
guard.limiter = limiter;
guard.latency = limiterLatency(context.sampleRate);
});
}
/** What a name plays, a cut made from its file the first time; null while the file is not loaded. */
@@ -1967,6 +2086,15 @@ interface Bus {
chain: BusChain | null;
/** Set while it is to be let go a moment later, unless it is used again first. */
leaving: number;
/** Its level meter (#359), while one is asked after. */
meter: BusMeter | null;
}
/** A tap after the pan of a bus: an analyser for each side, so a peak panned hard is not halved. */
interface BusMeter {
split: ChannelSplitterNode;
sides: AnalyserNode[];
samples: Float32Array<ArrayBuffer>;
}
/** One send of a track (#350): where it goes, where it taps, its level and how far it is held back. */
@@ -2514,6 +2642,17 @@ function workletNodes(
return { input: node, output: node, knobs, stop: () => stopProcessor(node) };
}
/** The loudest sample of each side over the stretch a meter looks back over, 1 at full scale. */
function readMeter(meter: BusMeter): [number, number] {
const [left, right] = meter.sides.map((side) => {
side.getFloatTimeDomainData(meter.samples);
let peak = 0;
for (const sample of meter.samples) peak = Math.max(peak, Math.abs(sample));
return peak;
});
return [left!, right!];
}
/**
* Stops a processor and lets go of its port. A port that is listened to is never collected, and
* with it everything its listener reaches: the voice and every node of it, still on its bus.