Files
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

150 lines
5.8 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.
/**
* 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);
});
});