Files
feuerwerk/tests/viewport/spectrum.test.ts
T
adminandClaude Opus 5.5 687377682c Draw the EQ's curve in its node and a spectrum behind it (#375, #374)
Both were asked for in #367 "for later". The node of a Parametric EQ
now shows its curve in small, and its tab shows what passes the node
while it is seen.

#375, the curve in the node:

- NodeDef has an optional `picture`: a height, and shapes made from
  the settings as plain point lists (guide, area, line). It is
  general, the Parametric EQ is its first user.
- NodeView draws it in a box below the rows, again only when the box
  or the settings change. A drag in the EQ tab changes them, so the
  node follows it live.
- Without a band the box stays, flat on its 0 dB line, so the node
  does not grow at the first band.
- The axes of the curve, `eqCurvePoints` and `fillShapes` move from
  editor/soundfx/eqView to core/soundfx/parametricEq. Tab and node
  draw the sum from the same function.

#374, the spectrum:

- A node marked `tapped` gets a `tap` stage in front of its own
  stages and one behind them when compiled, in a sound FX and in an
  instrument alike. Its id is asset and node, since a copied asset
  keeps its node ids.
- A tap is a gain of 1. SpectrumTaps (new, viewport/spectrum.ts)
  holds the tap of every voice and bus while it plays.
- While the EQ tab is seen, in the tab row or in a window, the taps
  of that node feed one analyser per side (8192 points). Nothing is
  made before a tap of the node plays, so no AudioContext is opened
  early.
- The tab draws both behind the curve, on a scale of 0 to -96 dBFS:
  the sound coming in faint and grey, the sound going on in the
  colour of a sound port. The analyser smooths it, and once the
  sound has gone the sum still hands it silence, so it falls away.
- Once the tab is not seen, Editor.tick lets the analysers go, like
  the meters of the Song tab.
- A tap does nothing to the sound, so `stats().last.stages` leaves it
  out, and a bypassed EQ keeps its taps.

Decided on my own:

- Every sound passing the node counts: Play of the sound FX tab, an
  instrument's keys, a song, a run. The issue proposed that.
- The spectrum has no labels of its own. Its scale is in the wiki.

Tests:

  Gate (melektaus-atx)     275 files, 4247 tests (11 new), EXIT 0
  e2e 34 (melektaus-atx)   ALL PASSED, 98 checks (2 new), twice

Counter-checks, each put back by copy and compared with cmp:

- The node's curve drawn without its bands: 2 unit tests fail.
- The spectrum without the loudest bin between two points: 1 fails.
- SpectrumTaps.stop leaving the taps linked: 1 fails.
- Taps that never feed the analyser: e2e 34 fails in exactly the
  spectrum check (frames 0). Run in the container only.

The screenshots of the second run were looked at: the blip as one
peak behind the curve, and the dip at 4 kHz in the node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:58:53 +02:00

93 lines
3.2 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);
}
}
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()).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()?.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()).toBeNull();
});
});