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>
93 lines
3.2 KiB
TypeScript
93 lines
3.2 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);
|
||
}
|
||
}
|
||
|
||
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();
|
||
});
|
||
});
|