Files
feuerwerk/tests/editor/limiter-view.test.ts
adminandClaude Opus 5.5 28909f0adf
game-maker / gate (push) Successful in 4m23s
game-maker / suite (push) Successful in 16m6s
Show a Limiter node as a device that holds what it took away (#397)
A Limiter only said what it does in rows of numbers. How much it took
away, and when, could not be seen at all, and a short sound is over
before any needle could show it. Now the node is a device of its own
size, and its screen catches the reduction from the moment a sound
starts and holds it once the sound ends.

- LimiterPanel draws a 720x280 plate of brushed brick. The threshold
  knob is large, ceiling and release are small beside it, auto-gain is
  a lever. A LIMIT lamp lights while it takes something away.
- The screen hangs the reduction down from the top, 0 to 24 dB by the
  square root like the Compressor's meter. The level coming in runs
  along the bottom, orange above the limit. Time runs from the start
  of the sound over 1, 2, 4 or 8 s, and a longer sound rolls.
- ReductionCapture in limiterView holds the capture after 0.4 s of
  silence, without the silence, until the next sound. It writes the
  most taken away and when, and NOW or LASTED on the right.
- The Limiter is tapped now, like the Compressor. The face reads the
  loudest sample since the last frame on both sides.
- SpectrumTaps.levels takes a lateBy. The side in front is read 5 ms
  earlier, so a peak going in is met by what it became.
- limiterReduction leaves the auto-gain out and solves Wet's blend for
  the limited part. Less than 0.2 dB counts as none.
- A capture is also held when the node leaves the picture. Changing a
  knob or the lever leaves it as it is.
- Hook __gm.limiter.panel(id) for the sound FX and instrument graphs.

Decided myself:

- The capture stays held until the next sound, not for a fixed time.
- The screen has no wet knob, as the Compressor's has none.
- The Limiter's two taps cost two gains of 1 per voice. The analysers
  only exist while the node is in the picture.

Measured on stein2: the seeded blip at -4.4 dBFS against -12 dB showed
7.56 dB. Three hits at 0, -8 and -2 dBFS showed 11.9 dB at the first.
In e2e 34 on melektaus-atx, -10.44 dBFS in against -20 dB showed
9.56 dB, the difference to the hundredth.

Tests:
- Unit: limiter-view 13 tests (new), spectrum 6 (1 new), soundfx 67
  with the Limiter's taps.
- Counter-test: without Wet's share in the formula the blend test
  fails.
- e2e 34: ALL PASSED, 138 checks (1 new).
- Gate: CHECK EXIT 0, 290 files, 4445 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:30:59 +02:00

194 lines
8.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A Limiter's node as a device (#397): every part on the plate and none over another, what it takes
* away as read from its taps, and the capture of that from the moment a sound starts, held once the
* sound ends.
*/
import { describe, expect, it } from 'vitest';
import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView';
import {
captureSpan,
captureTexts,
idleLimiterKeys,
LIMITER_KEYS,
LIMITER_LAYOUT,
LIMITER_PANEL,
limiterAutoGainText,
limiterGainDb,
limiterReduction,
type LimiterValues,
limiterValues,
QUIET_SECONDS,
ReductionCapture,
reductionDepth,
timeMarks,
} from '../../src/editor/soundfx/limiterView';
const overlap = (a: Box, b: Box) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
const inside = (a: Box) =>
a.x >= 0 && a.y >= 0 && a.x + a.w <= LIMITER_PANEL.w && a.y + a.h <= LIMITER_PANEL.h;
const values = (over: Partial<LimiterValues> = {}): LimiterValues => ({ ...limiterValues({}), ...over });
const db = (gain: number) => 20 * Math.log10(gain);
describe('the Limiter plate', () => {
const L = LIMITER_LAYOUT;
const screws = L.screws.map((s) => ({ x: s.x - 7, y: s.y - 7, w: 14, h: 14 }));
// A lever's ball hangs 8 below its box when it is off.
const lever = { ...L.autoGain, h: L.autoGain.h + 8 };
const lamp = { x: L.lamp.x - L.lamp.r, y: L.lamp.y - L.lamp.r, w: 2 * L.lamp.r, h: 2 * L.lamp.r };
it('keeps the screen, the lever, the lamp and the knobs on the plate, clear of each other and the screws', () => {
const parts: Box[] = [L.bezel, lever, ...LIMITER_KEYS.map((key) => knobBox(L.knobs[key])), lamp, L.title];
for (const part of parts) {
expect(inside(part)).toBe(true);
for (const screw of screws) expect(overlap(part, screw)).toBe(false);
}
for (let i = 0; i < parts.length; i++)
for (let j = i + 1; j < parts.length; j++) expect(overlap(parts[i]!, parts[j]!)).toBe(false);
});
});
describe('the Limiter numbers', () => {
it('are the defaults of its rows where none is typed in, and idle at wet 0', () => {
expect(values()).toEqual({ threshold: -6, ceiling: -1, release: 0.1, wet: 1, autoGain: true });
expect(limiterValues({ threshold: -12, wet: 0.5, autoGain: false })).toMatchObject({
threshold: -12,
wet: 0.5,
autoGain: false,
});
expect([...idleLimiterKeys(values())]).toEqual([]);
expect([...idleLimiterKeys(values({ wet: 0 }))]).toEqual(['threshold', 'ceiling', 'release', 'autoGain']);
});
it('raise the limit to the ceiling with auto-gain, and nothing where the threshold is above it', () => {
expect(limiterGainDb(values())).toBe(5);
expect(limiterAutoGainText(values())).toBe('+5 dB');
expect(limiterGainDb(values({ threshold: 0, ceiling: -3 }))).toBe(0);
expect(limiterGainDb(values({ autoGain: false }))).toBe(0);
expect(limiterAutoGainText(values({ autoGain: false }))).toBe('off');
});
});
describe('what the Limiter takes away', () => {
it('is the level in less the level out without the auto-gain after it', () => {
// Full scale in, limited to -6 and raised to -1.
expect(limiterReduction({ in: 0, out: -1 }, values())).toBeCloseTo(6, 9);
expect(limiterReduction({ in: 0, out: -6 }, values({ autoGain: false }))).toBeCloseTo(6, 9);
// Under the threshold it only gets the auto-gain.
expect(limiterReduction({ in: -20, out: -15 }, values())).toBe(0);
});
it('solves the blend for the limited sound when Wet is under 1', () => {
// Half the sound limited to half, half as it came: 0.75 comes out.
const reading = { in: 0, out: db(0.75) };
expect(limiterReduction(reading, values({ wet: 0.5, autoGain: false }))).toBeCloseTo(-db(0.5), 9);
expect(limiterReduction(reading, values({ wet: 0, autoGain: false }))).toBe(0);
});
it('is nothing for silence, nothing read, or less than the two sides can be told apart by', () => {
expect(limiterReduction(null, values())).toBe(0);
expect(limiterReduction({ in: -Infinity, out: -Infinity }, values())).toBe(0);
expect(limiterReduction({ in: -70, out: -90 }, values())).toBe(0);
expect(limiterReduction({ in: -3, out: -3.1 }, values({ autoGain: false }))).toBe(0);
expect(limiterReduction({ in: 6, out: -60 }, values({ autoGain: false }))).toBe(24);
});
});
describe('the screen of the Limiter', () => {
it('hangs the reduction down by its root, as the Compressor’s meter reads', () => {
expect(reductionDepth(0)).toBe(0);
expect(reductionDepth(6)).toBeCloseTo(0.5, 9);
expect(reductionDepth(24)).toBe(1);
expect(reductionDepth(40)).toBe(1);
});
it('grows its time through 1, 2, 4 and 8 seconds as the sound goes on, four marks to a span', () => {
expect([0, 0.3, 1, 1.5, 3, 7, 20].map(captureSpan)).toEqual([1, 1, 1, 2, 4, 8, 8]);
expect(timeMarks(2)).toEqual([0, 0.5, 1, 1.5, 2]);
});
});
describe('the capture of the Limiter', () => {
const loud = { in: -2, db: 4 };
const soft = { in: -20, db: 0 };
const quiet = { in: -Infinity, db: 0 };
/** A reading every 20 ms from `at` on, one for each of `readings`. Gives the time after the last. */
const play = (capture: ReductionCapture, at: number, readings: (typeof loud | null)[]) => {
readings.forEach((r, i) => capture.feed(at + i * 0.02, r));
return at + readings.length * 0.02;
};
it('waits for a sound, then catches a point a frame from the moment it starts', () => {
const capture = new ReductionCapture();
expect(capture.feed(1, null)).toBe(false);
expect(capture.feed(1.02, quiet)).toBe(false);
expect(capture.state).toBe('waiting');
play(capture, 2, [soft, loud, { in: -1, db: 6 }, soft]);
expect(capture.state).toBe('live');
expect(capture.points.map((p) => +p.t.toFixed(3))).toEqual([0, 0.02, 0.04, 0.06]);
expect(capture.most).toMatchObject({ db: 6 });
expect(capture.most!.t).toBeCloseTo(0.04, 9);
expect(capture.now).toBe(0);
});
it('holds what it caught once the sound has been quiet a while, without the silence after it', () => {
const capture = new ReductionCapture();
const after = play(capture, 0, [loud, loud, soft]);
const silence = Math.ceil(QUIET_SECONDS / 0.02);
play(capture, after, new Array<typeof quiet>(silence - 1).fill(quiet));
expect(capture.state).toBe('live');
play(capture, after + (silence - 1) * 0.02, [quiet, quiet]);
expect(capture.state).toBe('held');
expect(capture.points).toHaveLength(3);
expect(capture.to).toBeCloseTo(0.04, 9);
// Held, it stays as it is whatever is read, until the next sound.
expect(capture.feed(5, quiet)).toBe(false);
expect(capture.feed(5.02, null)).toBe(false);
expect(capture.points).toHaveLength(3);
capture.feed(6, soft);
expect([capture.state, capture.points.length, capture.most]).toEqual(['live', 1, null]);
});
it('is held at once when its node is no longer seen', () => {
const capture = new ReductionCapture();
play(capture, 0, [loud, loud]);
capture.hold();
expect(capture.state).toBe('held');
expect(capture.points).toHaveLength(2);
const empty = new ReductionCapture();
empty.hold();
expect(empty.state).toBe('waiting');
});
it('keeps the last 8 seconds of a longer sound, the times still from its start', () => {
const capture = new ReductionCapture();
play(capture, 0, new Array<typeof loud>(600).fill(loud));
expect(capture.to).toBeCloseTo(11.98, 6);
expect(capture.from).toBeGreaterThanOrEqual(capture.to - 8 - 1e-9);
expect(capture.from).toBeLessThan(capture.to - 7.9);
expect(capture.span).toBe(8);
});
it('writes its state, the most taken away and when, and what it takes now or how long the sound lasted', () => {
const capture = new ReductionCapture();
expect(captureTexts(capture, false)).toEqual({ status: 'WAITING FOR SOUND', most: '', side: '' });
play(capture, 0, [soft, loud]);
expect(captureTexts(capture, false)).toEqual({
status: 'LIVE',
most: 'MOST -4 dB AT 20 ms',
side: 'NOW -4 dB',
});
capture.hold();
expect(captureTexts(capture, false)).toEqual({
status: 'HELD',
most: 'MOST -4 dB AT 20 ms',
side: 'LASTED 20 ms',
});
const clean = new ReductionCapture();
play(clean, 0, [soft, soft]);
expect(captureTexts(clean, false)).toMatchObject({ most: 'NOTHING LIMITED', side: 'NOW 0 dB' });
expect(captureTexts(capture, true)).toEqual({ status: 'DRY', most: '', side: '' });
});
});