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