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

+91
View File
@@ -0,0 +1,91 @@
import { clamp } from '../../ui';
/** The scale of a level meter (#359) in dBFS: what is quieter shows nothing, past 0 it clips. */
export const METER_MIN_DB = -48;
export const METER_MAX_DB = 6;
/** From here on a meter is yellow; past 0 dBFS it is red. */
export const METER_WARM_DB = -6;
/** Seconds the loudest peak stays put before it falls. */
export const PEAK_HOLD = 1.5;
/** dB a second a level and a peak fall. */
export const METER_FALL = 24;
/** One side of a meter: what it shows now, the loudest peak and when it came, and whether it clipped. */
export interface MeterSide {
level: number;
peak: number;
peakAt: number;
clipped: boolean;
}
export const QUIET_SIDE: MeterSide = { level: -Infinity, peak: -Infinity, peakAt: 0, clipped: false };
export function gainToDb(gain: number): number {
return gain > 0 ? 20 * Math.log10(gain) : -Infinity;
}
/** How far up the meter a level is, 0 to 1. */
export function meterShare(db: number): number {
return clamp((db - METER_MIN_DB) / (METER_MAX_DB - METER_MIN_DB), 0, 1);
}
export type MeterZone = 'ok' | 'warm' | 'hot';
export function meterZone(db: number): MeterZone {
return db > 0 ? 'hot' : db >= METER_WARM_DB ? 'warm' : 'ok';
}
/**
* A side `dt` seconds on, at `now`, with `gain` the loudest sample since: the level jumps up to it
* and falls back slowly, the peak holds `PEAK_HOLD` seconds and then falls the same way. A sample
* past full scale lights the clip lamp, and it stays lit until it is cleared.
*/
export function stepSide(side: MeterSide, gain: number, now: number, dt: number): MeterSide {
const db = gainToDb(gain);
const fall = (from: number) => {
const to = from - METER_FALL * Math.max(0, dt);
return to < METER_MIN_DB ? -Infinity : to;
};
const level = Math.max(db, fall(side.level));
const fresh = db >= side.peak;
const held = now - side.peakAt < PEAK_HOLD;
return {
level,
peak: fresh ? db : held ? side.peak : Math.max(db, fall(side.peak)),
peakAt: fresh ? now : side.peakAt,
clipped: side.clipped || gain > 1,
};
}
/** Both sides of a meter, left and right. */
export type StereoMeter = readonly [MeterSide, MeterSide];
export const QUIET_METER: StereoMeter = [QUIET_SIDE, QUIET_SIDE];
export function stepMeter(
meter: StereoMeter,
gains: readonly [number, number],
now: number,
dt: number,
): StereoMeter {
return [stepSide(meter[0], gains[0], now, dt), stepSide(meter[1], gains[1], now, dt)];
}
/** What it shows goes; only the clip lamp stays as it was. */
export function hushMeter(meter: StereoMeter): StereoMeter {
return [
{ ...QUIET_SIDE, clipped: meter[0].clipped },
{ ...QUIET_SIDE, clipped: meter[1].clipped },
];
}
export function meterClipped(meter: StereoMeter): boolean {
return meter[0].clipped || meter[1].clipped;
}
/** "-3.2", "+1.5", "-inf": a peak as the Out meter writes it, in letters the UI font has. */
export function peakText(db: number): string {
if (!Number.isFinite(db)) return '-inf';
const shown = Math.round(db * 10) / 10;
return `${shown > 0 ? '+' : ''}${shown.toFixed(1)}`;
}