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>
194 lines
8.3 KiB
TypeScript
194 lines
8.3 KiB
TypeScript
/**
|
||
* 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: '' });
|
||
});
|
||
});
|