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
@@ -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)}`;
|
||||
}
|
||||
Reference in new issue
Block a user