Files
feuerwerk/tests/viewport/spectrum.test.ts
T
adminandClaude Opus 5.5 1df38bd430 Edit a Compressor in a tab of its own, with knobs and levels (#378)
A Compressor had only its number rows in the node. Its numbers are
easier to set by feel on a dial, and what it does is easier to judge
when the level going in and the level going out are seen side by side.

- Knob is a new widget in the toolkit: a slider bent into a circle,
  its name above and a number field below. A drag up or down turns
  it, Shift a tenth as far, a notch of the wheel a fiftieth of the
  turn, a double-click sets the default, the number takes a typed
  value. A taper gives the low end more of the turn.
- The Compressor's tab opens on a double-click, one per node, like
  the Parametric EQ's. It has a knob for each of the seven numbers,
  greyed while its input is linked.
- Its curve shows level in to level out from -60 to 0 dB. The point
  where it bends sets the threshold, the end of the curve sets the
  ratio that ends there.
- Its levels show the last 4 s on both sides of the node. Where it
  takes more than 0.5 dB away, the stretch below the level coming in
  is tinted. The makeup is left out of that.
- The Compressor is tapped like the EQ. SpectrumTaps listens to more
  than one node at once and reads the loudest sample of the last
  25 ms on each side.
- The node tabs share one map in the editor (nodeTabs), so a third
  kind needs no new plumbing.

Findings:

- CI task 614 for 23922ee was red on the memory check of the stream:
  the tab's renderer grew by 94 MB with nothing decoded. Play mode
  made garbage of its own and was read before a collection. Both
  readings in play now come after one, and the check compares the
  growth streamed with the growth decoded (3.7 MB against 59.8 MB).
- The ratio handle sat on the right edge of the picture, where a
  widget's hit area ends. The curve is inset by the grab radius.

Tests:

- Gate (melektaus-atx): 282 files, 4338 tests (17 new)
- e2e 34 (melektaus-atx): ALL PASSED, 116 checks (6 new)
- Counter-checks: an inverted ratio search, stop() letting go of
  every node and a drag without its least step each fail exactly
  their test.
- Looked at the tab in a screenshot: a blip at -4.4 dB came out at
  -16.7 dB, the gap tinted, the threshold line at -24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:40:12 +02:00

134 lines
5.0 KiB
TypeScript
Raw 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);
});
});