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

+69
View File
@@ -1203,6 +1203,75 @@ export async function song(t) {
JSON.stringify({ ownRows, letGo, backAgain }),
);
// 65q. Level meters (#359): a sound item and its track each turned up 12 dB clip. The meter of
// its track and Out go past 0 dB and light their clip lamps, while the limiter at the output
// keeps every sample under -1 dBFS. The lamps stay lit after Stop while the taps go; a click
// on a meter puts its lamp out.
await evaluate('__gm.sound.stopAll()');
const hotItem = (await model()).items.find((item) => item.kind === 'sound');
const hotRow = (await model()).tracks.findIndex((track) => track.id === hotItem.track);
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
// Every track up, since 65l sends the first through the second at -6 dB.
for (const track of song.tracks) Object.assign(track, { mute: false, solo: false, volume: 12 });
song.items.find((i) => i.id === ${JSON.stringify(hotItem.id)}).volume = 12;`);
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
await sleep(200);
p = await json(`__gm.song.rulerAt(${hotItem.tick})`);
await click(p.x, p.y);
await press('Space');
const hot = await waitFor(
() => json('__gm.song.meters()'),
(m) => m.out.clipped && m.tracks[hotItem.track]?.some((side) => side.clipped),
40,
);
const hotTrack = hot.tracks[hotItem.track];
check(
'a track and Out past 0 dB: their meters rise above 0 dB and light the clip lamp, each track and Out tapped',
hot.out.clipped &&
hot.out.loudest > 0 &&
hotTrack?.some((side) => side.clipped) &&
hot.metered === (await model()).tracks.length + 1,
JSON.stringify(hot),
);
// Every frame for a few seconds, so the short blip is not missed between two asks.
const guarded = await evaluate(`new Promise((done) => {
const until = performance.now() + 6000;
let most = 0;
const step = () => {
const sides = __gm.song.guard();
if (sides) most = Math.max(most, ...sides);
if (performance.now() < until) requestAnimationFrame(step);
else done({ most, loudest: __gm.song.meters().out.loudest });
};
step();
})`);
check(
'behind the limiter at the output no sample of it passes -1 dBFS',
guarded.most > 0.3 && guarded.most <= 10 ** (-1 / 20) + 1e-4 && guarded.loudest > 0,
JSON.stringify(guarded),
);
await shot('03-meters');
await press('Space');
await sleep(150);
const after = await json('__gm.song.meters()');
p = await json(`__gm.song.meterAt(${hotRow})`);
await click(p.x, p.y);
await click(...Object.values(await json('__gm.song.outAt()')));
await sleep(100);
const cleared = await json('__gm.song.meters()');
check(
'Stop lets the taps go and the bars fall, the clip lamps stay lit until a click on the meter',
after.metered === 0 &&
after.out.clipped &&
after.tracks[hotItem.track].some((side) => side.clipped) &&
after.tracks[hotItem.track].every((side) => side.level === null) &&
!cleared.out.clipped &&
cleared.out.loudest === null &&
cleared.tracks[hotItem.track].every((side) => !side.clipped),
JSON.stringify({ after, cleared }),
);
// 65j. The step takes back everything it made.
await evaluate('__gm.sound.stopAll()');
await mutate(`