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>
150 lines
5.8 KiB
TypeScript
150 lines
5.8 KiB
TypeScript
/**
|
||
* The taps of the spectrum (#374): a voice's tap feeds the analyser of its side only while its node
|
||
* is listened to, and once that stops – or the voice does – nothing feeds one any more.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import type { FxTap } from '../../src/core/soundfx/nodes';
|
||
import { SpectrumTaps } from '../../src/viewport/spectrum';
|
||
|
||
/** A node that remembers what it is linked to. */
|
||
class FakeNode {
|
||
readonly links = new Set<FakeNode>();
|
||
fftSize = 0;
|
||
frequencyBinCount = 4;
|
||
constructor(readonly context: FakeContext) {}
|
||
connect(to: FakeNode): FakeNode {
|
||
this.links.add(to);
|
||
return to;
|
||
}
|
||
disconnect(to?: FakeNode): void {
|
||
if (!to) this.links.clear();
|
||
else if (!this.links.delete(to)) throw new Error('not linked');
|
||
}
|
||
getFloatFrequencyData(data: Float32Array): void {
|
||
data.fill(this.context.made.length);
|
||
}
|
||
/** What the analyser heard last: `wave`, at the end of `data` like the newest samples. */
|
||
wave: number[] = [];
|
||
getFloatTimeDomainData(data: Float32Array): void {
|
||
data.fill(0);
|
||
data.set(this.wave, data.length - this.wave.length);
|
||
}
|
||
}
|
||
|
||
class FakeContext {
|
||
readonly sampleRate = 48000;
|
||
readonly made: FakeNode[] = [];
|
||
private make(): FakeNode {
|
||
const node = new FakeNode(this);
|
||
this.made.push(node);
|
||
return node;
|
||
}
|
||
createGain(): FakeNode {
|
||
return this.make();
|
||
}
|
||
createAnalyser(): FakeNode {
|
||
return this.make();
|
||
}
|
||
}
|
||
|
||
const tap = (id: string, at: FxTap['at']): FxTap => ({ kind: 'tap', id, at });
|
||
|
||
describe('the taps of the spectrum', () => {
|
||
it('feed nothing and make nothing before their node is listened to', () => {
|
||
const context = new FakeContext();
|
||
const taps = new SpectrumTaps();
|
||
taps.hold(tap('fx:eq', 'in'), new FakeNode(context) as never);
|
||
expect(taps.info()).toEqual({ listened: '', held: 1, feeding: 0 });
|
||
expect(context.made).toHaveLength(0);
|
||
expect(taps.read('fx:eq')).toBeNull();
|
||
});
|
||
|
||
it('feed the analyser of their side while listened to, a voice started before as well', () => {
|
||
const context = new FakeContext();
|
||
const taps = new SpectrumTaps();
|
||
const before = new FakeNode(context);
|
||
taps.hold(tap('fx:eq', 'in'), before as never);
|
||
taps.listen('fx:eq');
|
||
const after = new FakeNode(context);
|
||
taps.hold(tap('fx:eq', 'out'), after as never);
|
||
const other = new FakeNode(context);
|
||
taps.hold(tap('fx:other', 'out'), other as never);
|
||
expect(taps.info()).toEqual({ listened: 'fx:eq', held: 3, feeding: 2 });
|
||
// Two sums, each into its analyser: the in side made first.
|
||
const [sumIn, , sumOut] = context.made;
|
||
expect([...before.links]).toEqual([sumIn]);
|
||
expect([...after.links]).toEqual([sumOut]);
|
||
expect(other.links.size).toBe(0);
|
||
expect(taps.read('fx:eq')?.binHz).toBeCloseTo(48000 / 8192, 9);
|
||
});
|
||
|
||
it('let go of the analysers once nobody listens, or once the voice stops', () => {
|
||
const context = new FakeContext();
|
||
const taps = new SpectrumTaps();
|
||
taps.listen('fx:eq');
|
||
const voice = new FakeNode(context);
|
||
const stopVoice = taps.hold(tap('fx:eq', 'in'), voice as never);
|
||
const bus = new FakeNode(context);
|
||
taps.hold(tap('fx:eq', 'out'), bus as never);
|
||
stopVoice();
|
||
stopVoice();
|
||
expect(voice.links.size).toBe(0);
|
||
expect(taps.info()).toEqual({ listened: 'fx:eq', held: 1, feeding: 1 });
|
||
taps.stop();
|
||
expect(bus.links.size).toBe(0);
|
||
expect(taps.info()).toEqual({ listened: '', held: 1, feeding: 0 });
|
||
expect(taps.read('fx:eq')).toBeNull();
|
||
});
|
||
|
||
it('listen to two nodes at once, and let one go without the other (#378)', () => {
|
||
const context = new FakeContext();
|
||
const taps = new SpectrumTaps();
|
||
const eq = new FakeNode(context);
|
||
const compressor = new FakeNode(context);
|
||
taps.hold(tap('fx:eq', 'in'), eq as never);
|
||
taps.hold(tap('fx:comp', 'in'), compressor as never);
|
||
taps.listen('fx:eq');
|
||
taps.listen('fx:comp');
|
||
expect(taps.info()).toEqual({ listened: 'fx:eq fx:comp', held: 2, feeding: 2 });
|
||
expect(eq.links.size).toBe(1);
|
||
expect(compressor.links.size).toBe(1);
|
||
taps.stop('fx:eq');
|
||
expect(eq.links.size).toBe(0);
|
||
expect(compressor.links.size).toBe(1);
|
||
expect(taps.info()).toEqual({ listened: 'fx:comp', held: 2, feeding: 1 });
|
||
expect(taps.read('fx:comp')).not.toBeNull();
|
||
});
|
||
|
||
it('give the loudest sample of the last stretch on each side, in dB (#378)', () => {
|
||
const context = new FakeContext();
|
||
const taps = new SpectrumTaps();
|
||
expect(taps.levels('fx:comp', 0.01)).toBeNull();
|
||
taps.listen('fx:comp');
|
||
taps.hold(tap('fx:comp', 'in'), new FakeNode(context) as never);
|
||
const [, analyserIn, , analyserOut] = context.made;
|
||
// A peak of 1 long ago, then 0.5: a window of 10 ms (480 samples) only sees the 0.5.
|
||
analyserIn!.wave = [1, ...new Array<number>(1000).fill(0), -0.5, 0.25];
|
||
analyserOut!.wave = [];
|
||
const levels = taps.levels('fx:comp', 0.01);
|
||
expect(levels?.in).toBeCloseTo(20 * Math.log10(0.5), 9);
|
||
expect(levels?.out).toBe(-Infinity);
|
||
expect(taps.levels('fx:comp', 1)?.in).toBeCloseTo(0, 9);
|
||
});
|
||
|
||
it('read the side in front as late as the node is, so a peak is met by what it became (#397)', () => {
|
||
const context = new FakeContext();
|
||
const taps = new SpectrumTaps();
|
||
taps.listen('fx:lim');
|
||
taps.hold(tap('fx:lim', 'in'), new FakeNode(context) as never);
|
||
const [, analyserIn, , analyserOut] = context.made;
|
||
// A peak of 1 that came in 5 ms (240 samples) before the newest sample, and the sound at 0.125 since.
|
||
analyserIn!.wave = [1, ...new Array<number>(240).fill(0.125)];
|
||
analyserOut!.wave = [0.5];
|
||
const window = 1 / 480;
|
||
expect(taps.levels('fx:lim', window)?.in).toBeCloseTo(20 * Math.log10(0.125), 9);
|
||
const late = taps.levels('fx:lim', window, 0.005);
|
||
expect(late?.in).toBeCloseTo(0, 9);
|
||
expect(late?.out).toBeCloseTo(20 * Math.log10(0.5), 9);
|
||
});
|
||
});
|