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>
93 lines
3.0 KiB
TypeScript
93 lines
3.0 KiB
TypeScript
/**
|
|
* 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]);
|
|
});
|
|
});
|