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

+27
View File
@@ -332,6 +332,33 @@ function setCollapsed(node: GraphNode, collapsed: boolean): void {
else delete node.collapsed;
}
/**
* The size a node's face was dragged to (#385). The previous size is read at execution time, so a
* caller that previewed the drag must restore the node before executing.
*/
export class ResizeNodeCommand implements Command {
readonly label = 'Resize node';
private previous: GraphNode['size'];
constructor(
private readonly assetId: string,
private readonly nodeId: string,
private readonly size: { w: number; h: number },
) {}
execute(project: Project): void {
const node = requireNode(project, this.assetId, this.nodeId);
this.previous = node.size && { ...node.size };
node.size = { w: Math.round(this.size.w), h: Math.round(this.size.h) };
}
undo(project: Project): void {
const node = requireNode(project, this.assetId, this.nodeId);
if (this.previous) node.size = { ...this.previous };
else delete node.size;
}
}
/** Other nodes and edges in place of a graph's, as one step: an instrument's Preset (#329). Groups and notes stay. */
export class ReplaceGraphCommand implements Command {
private previous?: Pick<Graph, 'nodes' | 'edges'>;
+2
View File
@@ -52,6 +52,8 @@ export interface GraphNode {
splits?: PortSplit[];
/** Drawn as its title bar alone, every link meeting at one point per side. Purely visual. */
collapsed?: boolean;
/** The size its face was dragged to in the graph (#385), for the nodes whose face may be. Purely visual. */
size?: { w: number; h: number };
}
/**
+7 -2
View File
@@ -393,6 +393,11 @@ function held<K extends string>(
/** Room between the nodes of a preset, left to right and top to bottom: a Chip is a tall node. */
const COLUMN = 240;
const ROW = 340;
/** Columns a node takes that is drawn as its editor in the graph (#385): a Filter's curve is wide. */
const COLUMNS_OF: Readonly<Record<string, number>> = { [SoundFxFilter.type]: 4 };
/** The column right of a node, past all it takes. */
const columnAfter = (node: GraphNode): number => node.x / COLUMN + (COLUMNS_OF[node.type] ?? 1);
/** A node to make: its type and its params. */
type Step = readonly [type: string, params: PropertyBag];
@@ -416,7 +421,7 @@ class Sketch {
line(from: GraphNode, ...steps: readonly Step[]): GraphNode {
let last = from;
for (const [type, params] of steps) {
const next = this.add(type, last.x / COLUMN + 1, last.y / ROW, params);
const next = this.add(type, columnAfter(last), last.y / ROW, params);
this.wire(last, 'out', next, 'in');
last = next;
}
@@ -510,7 +515,7 @@ const echo = (time: number, feedback: number, wet: number, pingPong = false) =>
/** Two ways into one Mix, a column right of the further one; `b` weighs `share`. */
function mixed(s: Sketch, a: GraphNode, b: GraphNode, share: number, curve = 'crossfade'): GraphNode {
const mix = s.add(SoundFxMix.type, Math.max(a.x, b.x) / COLUMN + 1, Math.min(a.y, b.y) / ROW, {
const mix = s.add(SoundFxMix.type, Math.max(columnAfter(a), columnAfter(b)), Math.min(a.y, b.y) / ROW, {
curve,
mix: share,
});
+26 -3
View File
@@ -16,7 +16,7 @@ import {
toneCutoff,
} from './saturation';
import { PHASER_STAGES } from './phaser';
import { eqBandsOf, eqStagesOf, hasGain, isEqBandHeard, MAX_EQ_BANDS } from './parametricEq';
import { eqBandsOf, eqStagesOf, hasGain, isEqBandHeard, MAX_EQ_BANDS, type PassBand } from './parametricEq';
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
@@ -630,7 +630,7 @@ export const SoundFxParametricEq: SoundFxNodeDef = effect(
type: 'SoundFxParametricEq',
label: 'Parametric EQ',
category: 'sound',
description: `Up to ${MAX_EQ_BANDS} filters working together, drawn as one curve and edited right in the node: double-click the curve to add a band, drag a band for its frequency and gain, turn the mouse wheel over it for its Q, and right-click it to remove it. Double-click the node's title to open the same in a tab, where the wheel anywhere else zooms the dB the curve shows. Each band is a peak, a low or high shelf, a low or high cut, a band-pass or a notch, and a cut, a band-pass or a notch can be steeper with its slope. While the node or its tab is seen, the spectrum of what passes the node is drawn behind the curve: faint as it comes in, strong as it goes on.${BLENDS}`,
description: `Up to ${MAX_EQ_BANDS} filters working together, drawn as one curve and edited right in the node: double-click the curve to add a band, drag a band for its frequency and gain, turn the mouse wheel over it for its Q, and right-click it to remove it. Double-click the node's title to open the same in a tab, where the wheel anywhere else zooms the dB the curve shows. Drag the node's lower right corner to make it larger or smaller. Each band is a peak, a low or high shelf, a low or high cut, a band-pass or a notch, and a cut, a band-pass or a notch can be steeper with its slope. While the node or its tab is seen, the spectrum of what passes the node is drawn behind the curve: faint as it comes in, strong as it goes on.${BLENDS}`,
inputs: [audioIn],
outputs: [audioOut],
params: [],
@@ -715,11 +715,12 @@ export const SoundFxFilter: SoundFxNodeDef = effect(
label: 'Filter',
category: 'sound',
description:
'Takes away what is above "frequency" (low-pass), what is below it (high-pass), all but a band around it (band-pass) or just that band (notch). A low or high shelf raises or lowers everything below or above it by "gain" in dB, a peak a band around it. An all-pass leaves every frequency as loud as it was and only turns its phase. "Q" from 0.1 to 30 says how sharp it is: at 0.707 a low- or high-pass has no bump, higher it rings at the frequency, and a band, a notch or a peak gets narrower. "Slope" makes a low-pass, high-pass, band-pass or notch steeper: 12 dB an octave is one filter, 24 two and 48 four in a row, and only the last of them has the Q. All three numbers can change while the sound plays, the frequency gliding in octaves, and in an instrument an Envelope can move them, for a filter sweep.' +
'Takes away what is above "frequency" (low-pass), what is below it (high-pass), all but a band around it (band-pass) or just that band (notch). A low or high shelf raises or lowers everything below or above it by "gain" in dB, a peak a band around it. An all-pass leaves every frequency as loud as it was and only turns its phase. "Q" from 0.1 to 30 says how sharp it is: at 0.707 a low- or high-pass has no bump, higher it rings at the frequency, and a band, a notch or a peak gets narrower. "Slope" makes a low-pass, high-pass, band-pass or notch steeper: 12 dB an octave is one filter, 24 two and 48 four in a row, and only the last of them has the Q. All three numbers can change while the sound plays, the frequency gliding in octaves, and in an instrument an Envelope can move them, for a filter sweep. The node draws what it does as a curve and is edited right there: drag the handle for the frequency and the gain, and type the numbers below it; a number fed by a link cannot be changed there. Double-click the title of the node to open the same in a tab, where the wheel turns the Q over the handle and zooms the dB elsewhere. While the node or its tab is seen, the spectrum of what passes the node is drawn behind the curve: faint as it comes in, strong as it goes on. Drag the lower right corner of the node to make it larger or smaller.' +
BLENDS,
inputs: [audioIn, followingIn(CUTOFF), followingIn(Q)],
outputs: [audioOut],
params: [PASS, SLOPE],
tapped: true,
idleParams: (params) => (SLOPED.has(passOf(params)) ? [] : [SLOPE.key]),
derivePorts: ({ node }) => ({
inputs: GAINED.has(passOf(node.params)) ? [followingIn(FILTER_GAIN)] : [],
@@ -739,6 +740,28 @@ export const SoundFxFilter: SoundFxNodeDef = effect(
'blend',
);
/** The shapes a Filter offers and its two settings, for its editor (#385). */
export const FILTER_TYPES: readonly { value: FxPassFilter; label: string }[] = PASS_FILTERS;
export const FILTER_SETTINGS = { type: PASS.key, slope: SLOPE.key };
/** A Filter as one band of the curve, from what is typed into it (#385). */
export function filterBandOf(params: PropertyBag): PassBand {
const type = passOf(params);
const number = (row: FlatDef<'number'>) => {
const value = params[row.key];
const n = typeof value === 'number' && Number.isFinite(value) ? value : row.default;
return Math.max(row.min ?? n, Math.min(row.max ?? n, n));
};
const slope = Number(params[SLOPE.key]);
return {
type,
frequency: number(CUTOFF),
gain: GAINED.has(type) ? number(FILTER_GAIN) : 0,
q: number(Q),
slope: slope === 24 || slope === 48 ? slope : 12,
};
}
const MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.5 };
const MIX_CURVE = {
key: 'curve',
+20 -13
View File
@@ -12,8 +12,9 @@ export const MAX_EQ_BANDS = 10;
export type EqBandType = Exclude<FxPassFilter, 'allpass'>;
export type EqSlope = 12 | 24 | 48;
export interface EqBand {
type: EqBandType;
/** One filter as the curve draws it: a band of the EQ, or a Filter node (#385), all-pass and all. */
export interface PassBand {
type: FxPassFilter;
frequency: number;
gain: number;
/** As a factor, as the Filter node takes it: 0.707 is a low- or high-pass with no bump. */
@@ -21,6 +22,10 @@ export interface EqBand {
slope: EqSlope;
}
export interface EqBand extends PassBand {
type: EqBandType;
}
export const EQ_BAND_TYPES: readonly { value: EqBandType; label: string }[] = [
{ value: 'peaking', label: 'Peaking' },
{ value: 'lowshelf', label: 'Low shelf' },
@@ -37,13 +42,13 @@ export const EQ_GAIN = { min: -24, max: 24 };
export const EQ_Q = { min: 0.1, max: 30 };
/** The shapes with a gain, and those a slope makes steeper. */
const GAINED: ReadonlySet<EqBandType> = new Set(['lowshelf', 'highshelf', 'peaking']);
const SLOPED: ReadonlySet<EqBandType> = new Set(['lowpass', 'highpass', 'bandpass', 'notch']);
const GAINED: ReadonlySet<FxPassFilter> = new Set(['lowshelf', 'highshelf', 'peaking']);
const SLOPED: ReadonlySet<FxPassFilter> = new Set(['lowpass', 'highpass', 'bandpass', 'notch']);
export const hasGain = (type: EqBandType): boolean => GAINED.has(type);
export const hasSlope = (type: EqBandType): boolean => SLOPED.has(type);
export const hasGain = (type: FxPassFilter): boolean => GAINED.has(type);
export const hasSlope = (type: FxPassFilter): boolean => SLOPED.has(type);
/** Web Audio's shelves have no Q: theirs is fixed. */
export const hasQ = (type: EqBandType): boolean => type !== 'lowshelf' && type !== 'highshelf';
export const hasQ = (type: FxPassFilter): boolean => type !== 'lowshelf' && type !== 'highshelf';
const FLAT_Q = Math.SQRT1_2;
@@ -92,10 +97,10 @@ export function withBandSet(bands: readonly EqBand[], index: number, change: Par
}
/** Whether a band changes the sound at all: a peak or a shelf at 0 dB does not. */
export const isEqBandHeard = (band: EqBand): boolean => !hasGain(band.type) || band.gain !== 0;
export const isEqBandHeard = (band: PassBand): boolean => !hasGain(band.type) || band.gain !== 0;
/** How many filters a band runs in a row: one, or more for a steeper slope. */
export const eqStagesOf = (band: EqBand): number => (hasSlope(band.type) ? band.slope / 12 : 1);
export const eqStagesOf = (band: PassBand): number => (hasSlope(band.type) ? band.slope / 12 : 1);
/** The nominal rate the curve is drawn at; the context's own differs only near the very top. */
const EQ_SAMPLE_RATE = 48000;
@@ -110,7 +115,7 @@ interface Biquad {
}
/** The coefficients of one filter as the Web Audio specification gives them. */
function biquad(type: EqBandType, frequency: number, q: number, gain: number, rate: number): Biquad {
function biquad(type: FxPassFilter, frequency: number, q: number, gain: number, rate: number): Biquad {
const w0 = (2 * Math.PI * Math.min(frequency, rate / 2)) / rate;
const cos = Math.cos(w0);
const sin = Math.sin(w0);
@@ -142,6 +147,8 @@ function biquad(type: EqBandType, frequency: number, q: number, gain: number, ra
return { b0: alpha, b1: 0, b2: -alpha, a0: 1 + alpha, a1: -2 * cos, a2: 1 - alpha };
case 'notch':
return { b0: 1, b1: -2 * cos, b2: 1, a0: 1 + alpha, a1: -2 * cos, a2: 1 - alpha };
case 'allpass':
return { b0: 1 - alpha, b1: -2 * cos, b2: 1 + alpha, a0: 1 + alpha, a1: -2 * cos, a2: 1 - alpha };
case 'peaking':
return {
b0: 1 + alpha * a,
@@ -190,7 +197,7 @@ function biquadDb(filter: Biquad, frequency: number, rate: number): number {
* What one band does, in dB at any frequency – its filters in a row, only the last with its Q. The
* coefficients are worked out once, for a curve of many points.
*/
export function eqBandResponse(band: EqBand, rate = EQ_SAMPLE_RATE): (frequency: number) => number {
export function eqBandResponse(band: PassBand, rate = EQ_SAMPLE_RATE): (frequency: number) => number {
const stages = eqStagesOf(band);
const gain = hasGain(band.type) ? band.gain : 0;
const last = biquad(band.type, band.frequency, band.q, gain, rate);
@@ -207,7 +214,7 @@ export function eqBandResponse(band: EqBand, rate = EQ_SAMPLE_RATE): (frequency:
* together: the lines the EQ tab draws. Each band's coefficients are worked out once.
*/
export function eqCurves(
bands: readonly EqBand[],
bands: readonly PassBand[],
frequencies: readonly number[],
rate = EQ_SAMPLE_RATE,
): { bands: number[][]; sum: number[] } {
@@ -258,7 +265,7 @@ export const yToDb = (y: number, area: EqArea): number => ((area.h / 2 - y) / (a
* stays within `overflow` of the area, so a deep cut runs out of sight rather than down to -300 dB.
*/
export function eqCurvePoints(
bands: readonly EqBand[],
bands: readonly PassBand[],
area: EqArea,
steps: number,
overflow = 0,
+4
View File
@@ -595,6 +595,10 @@ function normalizeGraph(raw: unknown, keep?: (def: NodeDef) => boolean): Graph {
const splits = normalizeSplits(n.splits);
if (splits.length > 0) node.splits = splits;
if (n.collapsed === true) node.collapsed = true;
const size = asRecord(n.size);
const w = toNumber(size.w);
const h = toNumber(size.h);
if (w > 0 && h > 0) node.size = { w, h };
graph.nodes.push(node);
}
const edgeIds = new Set<string>();