/** * 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({}), ...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(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(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: '' }); }); });