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

+92
View File
@@ -0,0 +1,92 @@
/**
* The level meters of the Song tab (#359): the dB scale, how a level and its peak rise and fall, and
* the clip lamp that stays lit.
*/
import { describe, expect, it } from 'vitest';
import {
gainToDb,
hushMeter,
METER_FALL,
meterClipped,
meterShare,
meterZone,
PEAK_HOLD,
peakText,
QUIET_METER,
QUIET_SIDE,
stepMeter,
stepSide,
} from '../../src/editor/song/meterView';
describe('meter scale', () => {
it('runs from -48 to +6 dBFS, full scale at 0 dB', () => {
expect(gainToDb(1)).toBe(0);
expect(gainToDb(0.5)).toBeCloseTo(-6.02, 2);
expect(gainToDb(0)).toBe(-Infinity);
expect(meterShare(-48)).toBe(0);
expect(meterShare(-Infinity)).toBe(0);
expect(meterShare(6)).toBe(1);
expect(meterShare(0)).toBeCloseTo(48 / 54, 6);
});
it('is green, yellow from -6 dB and red past 0 dB', () => {
expect(meterZone(-6.1)).toBe('ok');
expect(meterZone(-6)).toBe('warm');
expect(meterZone(0)).toBe('warm');
expect(meterZone(0.1)).toBe('hot');
});
it('writes a peak to a tenth of a dB', () => {
expect(peakText(-Infinity)).toBe('-inf');
expect(peakText(-3.24)).toBe('-3.2');
expect(peakText(1.46)).toBe('+1.5');
expect(peakText(-0.01)).toBe('0.0');
});
});
describe('a side of a meter', () => {
it('jumps up at once and falls back slowly', () => {
const up = stepSide(QUIET_SIDE, 1, 0, 0.016);
expect(up.level).toBe(0);
const later = stepSide(up, 0, 0.5, 0.5);
expect(later.level).toBeCloseTo(-METER_FALL * 0.5, 6);
const louder = stepSide(later, 0.5, 0.516, 0.016);
expect(louder.level).toBeCloseTo(-6.02, 2);
});
it('holds its peak, then lets it fall', () => {
const hit = stepSide(QUIET_SIDE, 1, 10, 0.016);
const held = stepSide(hit, 0.01, 10 + PEAK_HOLD - 0.1, 0.016);
expect(held.peak).toBe(0);
const falling = stepSide(held, 0.01, 10 + PEAK_HOLD + 0.1, 0.2);
expect(falling.peak).toBeCloseTo(-METER_FALL * 0.2, 6);
const gone = stepSide(falling, 0, 20, 3);
expect(gone.peak).toBe(-Infinity);
expect(gone.level).toBe(-Infinity);
});
it('lights the clip lamp past full scale and keeps it lit', () => {
expect(stepSide(QUIET_SIDE, 1, 0, 0).clipped).toBe(false);
const clipped = stepSide(QUIET_SIDE, 1.01, 0, 0);
expect(clipped.clipped).toBe(true);
expect(stepSide(clipped, 0, 30, 30).clipped).toBe(true);
});
it('keeps the two sides apart: a sound panned hard clips one side only', () => {
const meter = stepMeter(QUIET_METER, [2, 0], 0, 0);
expect(meter[0].clipped).toBe(true);
expect(meter[1].clipped).toBe(false);
expect(meter[0].level).toBeCloseTo(6.02, 2);
expect(meter[1].level).toBe(-Infinity);
expect(meterClipped(meter)).toBe(true);
});
it('goes dark when nothing plays, its clip lamp still lit', () => {
const hushed = hushMeter(stepMeter(QUIET_METER, [2, 0.5], 0, 0));
expect(hushed.map((side) => [side.level, side.peak])).toEqual([
[-Infinity, -Infinity],
[-Infinity, -Infinity],
]);
expect(hushed.map((side) => side.clipped)).toEqual([true, false]);
});
});