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:
1 parent
17f47f6956
commit
c087993000
26 files changed
+1514
-656
No files matched your search
@@ -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'>;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in new issue
Block a user