Meter each track and the song, and limit the output at -1 dBFS (#359)
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:
1 parent
dc666016db
commit
490fcba88f
12 files changed
+810
-137
No files matched your search
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user