Edit a Filter in its node and resize EQ and Filter nodes (#385)

A Filter sounds like one band of the Parametric EQ, but it keeps what
the EQ cannot: linked numbers, the all-pass and +-40 dB. So it stays a
node of its own and gets the EQ's editor with itself as the one band.
The editors also stop running out of their room: what does not fit is
left out instead of being cut.

- The curve of the EQ is now BandCurve with a host the EQ and the
  Filter both fill. CurvePanel holds what their panels share.
- FilterPanel shows the Filter as one band. A drag of its handle sets
  frequency and gain as one undo step. A number fed by a link is
  greyed out and the drag leaves it alone.
- A double-click on the Filter's title opens the same in a tab of its
  own. Delete there does nothing, the node stays.
- The Filter is tapped like the EQ, so its node and its tab draw the
  spectrum of what passes it. A tap is two gain nodes per voice, the
  analysers are made only while someone listens.
- EqFace and FilterFace are one CurveFace now, given the panel.
- EQ and Filter nodes have a corner to resize them, drawn like the
  floating window's. The size is saved on the node as `size`, each
  drag is one undo step, the least is 240 x 120.
- In the node the help line is gone. The row of numbers comes in only
  once the curve keeps 120 px of height. A group of the row that does
  not fit is not drawn, the one on the right first.
- A frequency or dB label that would run into the one before it is
  left out.
- The all-pass is drawn flat. The curve's maths takes every shape a
  Filter has.

Found on the way:
- The instrument presets set nodes 240 apart. A Filter with its
  editor is about 700 wide and covered the Bitcrusher, so a Filter
  now takes four columns.

Tests:
- Unit: 8 new (filter band, resize command and save, fitting rules),
  stage lists with a Filter now hold its taps
- e2e 34 (atx): ALL PASSED, 130 checks (4 new)
- Gate (atx): CHECK EXIT 0, 284 files, 4367 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 14:30:00 +02:00
1 parent 17f47f6956
commit c087993000
26 files changed
+1514 -656

No files matched your search

+9 -5
View File
@@ -29,6 +29,10 @@ import { graphEditor, makeProject } from '../helpers';
const FILE = `${'c3'.repeat(32)}.wav`;
/** The stages of a chain that do something to the sound: a Filter is tapped as well (#385). */
const heard = <S extends { kind: string }>(chain: { stages: readonly S[] }): S[] =>
chain.stages.filter((s) => s.kind !== 'tap');
/** An instrument with nothing in it, a Notes node and a Sample playing `FILE` on every note. */
function makeInstrument() {
const f = makeProject();
@@ -107,7 +111,7 @@ describe('a Filter', () => {
it('is an effect of a sound FX as well, its frequency in Hz and its Q held to 0.1 to 30', () => {
const { fx } = filterIn({ filter: 'highpass', cutoff: 300, q: 50 });
const plan = compileSoundFx(fx.graph).compiled!;
const [stage] = fxChainOf(plan).stages;
const [stage] = heard(fxChainOf(plan));
expect(stage).toMatchObject({ kind: 'pass', filter: 'highpass', stages: 1, cutoff: 300, gain: 0 });
// 30 as a factor, in dB.
expect((stage as { q: number }).q).toBeCloseTo(29.54, 2);
@@ -118,7 +122,7 @@ describe('a Filter', () => {
const def = (node: GraphNode, fx: SoundFxAsset) => resolveDef(node, fx.graph, NO_LOOKUP)!;
expect(def(peak.filter, peak.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'gain', 'wet']);
expect(SoundFxFilter.idleParams!(peak.filter.params)).toEqual(['slope']);
expect(fxChainOf(compileSoundFx(peak.fx.graph).compiled!).stages[0]).toMatchObject({
expect(heard(fxChainOf(compileSoundFx(peak.fx.graph).compiled!))[0]).toMatchObject({
stages: 1,
gain: 6,
});
@@ -127,7 +131,7 @@ describe('a Filter', () => {
expect(def(notch.filter, notch.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'wet']);
expect(SoundFxFilter.idleParams!(notch.filter.params)).toEqual([]);
// A gain typed in before the type changed does nothing.
expect(fxChainOf(compileSoundFx(notch.fx.graph).compiled!).stages[0]).toMatchObject({
expect(heard(fxChainOf(compileSoundFx(notch.fx.graph).compiled!))[0]).toMatchObject({
stages: 4,
gain: 0,
});
@@ -165,7 +169,7 @@ describe('an Envelope in an instrument', () => {
const build = f.compile();
// An Envelope moving numbers only shapes no sound, and nothing is missing for it.
expect(build.problems).toEqual({});
const [stage] = fxChainOf(build.layers[0]!).stages;
const [stage] = heard(fxChainOf(build.layers[0]!));
expect(stage).toMatchObject({
kind: 'pass',
filter: 'lowpass',
@@ -188,7 +192,7 @@ describe('an Envelope in an instrument', () => {
[filter, 'out', f.output, 'in'],
]);
const chain = fxChainOf(f.compile().layers[0]!, { [VELOCITY_PARAM]: 0.25 });
expect(chain.stages[0]).toMatchObject({
expect(heard(chain)[0]).toMatchObject({
cutoff: 20,
cutoffMod: { from: 20, to: 20000, envelope: { sustain: 0.25 } },
});