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
@@ -94,7 +94,10 @@ describe('a preset (#329)', () => {
|
||||
const chain = fxChainOf(build.layers[0]!);
|
||||
expect('chip' in chain && chain.stages.map((s) => s.kind), value).toEqual([
|
||||
'envelope',
|
||||
// The Filter is tapped for its spectrum (#385).
|
||||
'tap',
|
||||
'pass',
|
||||
'tap',
|
||||
...(flanged ? ['flanger'] : []),
|
||||
'crush',
|
||||
]);
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* The Filter's editor and the resizable EQ and Filter nodes (#385): a Filter as one band of the
|
||||
* curve, the size a node is dragged to, and what a panel leaves out when it has too little room.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { ResizeNodeCommand } from '../../src/core/commands/graphCommands';
|
||||
import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { filterBandOf, SoundFxParametricEq } from '../../src/core/soundfx/nodes';
|
||||
import { eqBandResponse } from '../../src/core/soundfx/parametricEq';
|
||||
import { apartLabels, fittingCount, shownRows } from '../../src/editor/soundfx/eqView';
|
||||
import { graphEditor, makeProject, roundTrip } from '../helpers';
|
||||
|
||||
function eqNode() {
|
||||
const fixture = makeProject();
|
||||
const create = new CreateAssetCommand('soundfx', 'Music EQ');
|
||||
fixture.stack.execute(create);
|
||||
const asset = create.asset as SoundFxAsset;
|
||||
const node = graphEditor(fixture, asset as unknown as BehaviorAsset).addNode(SoundFxParametricEq.type);
|
||||
return { ...fixture, asset, node };
|
||||
}
|
||||
|
||||
describe('a Filter as one band of the curve', () => {
|
||||
it('reads what is typed in, with the defaults of its rows', () => {
|
||||
expect(filterBandOf({})).toEqual({ type: 'lowpass', frequency: 1000, gain: 0, q: 0.707, slope: 12 });
|
||||
expect(filterBandOf({ filter: 'peaking', cutoff: 250, gain: -30, q: 4, slope: '48' })).toEqual({
|
||||
type: 'peaking',
|
||||
frequency: 250,
|
||||
gain: -30,
|
||||
q: 4,
|
||||
slope: 48,
|
||||
});
|
||||
});
|
||||
|
||||
it('has a gain only where its shape has one, and holds a number to its row', () => {
|
||||
expect(filterBandOf({ filter: 'highpass', gain: 12 }).gain).toBe(0);
|
||||
expect(filterBandOf({ cutoff: 99999, q: 0 })).toMatchObject({ frequency: 20000, q: 0.1 });
|
||||
expect(filterBandOf({ filter: 'lowshelf', gain: 80 }).gain).toBe(40);
|
||||
expect(filterBandOf({ filter: 'no-such' }).type).toBe('lowpass');
|
||||
});
|
||||
|
||||
it('draws an all-pass flat: it only turns the phase', () => {
|
||||
const response = eqBandResponse(filterBandOf({ filter: 'allpass', q: 8 }));
|
||||
for (const hz of [20, 200, 1000, 5000, 18000]) expect(Math.abs(response(hz))).toBeLessThan(1e-9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the size a node is dragged to', () => {
|
||||
it('is one undo step, and the node goes back to the size its face takes by itself', () => {
|
||||
const { stack, asset, node } = eqNode();
|
||||
stack.execute(new ResizeNodeCommand(asset.id, node.id, { w: 400.4, h: 199.6 }));
|
||||
expect(node.size).toEqual({ w: 400, h: 200 });
|
||||
stack.execute(new ResizeNodeCommand(asset.id, node.id, { w: 500, h: 300 }));
|
||||
stack.undo();
|
||||
expect(node.size).toEqual({ w: 400, h: 200 });
|
||||
stack.undo();
|
||||
expect('size' in node).toBe(false);
|
||||
});
|
||||
|
||||
it('survives the save, and a size in the file that is none is left out', () => {
|
||||
const { project, stack, asset, node } = eqNode();
|
||||
stack.execute(new ResizeNodeCommand(asset.id, node.id, { w: 320, h: 180 }));
|
||||
const loaded = findAsset(roundTrip(project), asset.id) as SoundFxAsset;
|
||||
expect(loaded.graph.nodes.find((n) => n.id === node.id)!.size).toEqual({ w: 320, h: 180 });
|
||||
|
||||
const raw = serializeProject(project) as {
|
||||
assets: { id: string; graph?: { nodes: Record<string, unknown>[] } }[];
|
||||
};
|
||||
raw.assets.find((a) => a.id === asset.id)!.graph!.nodes[0]!.size = { w: -5, h: 'x' };
|
||||
const broken = findAsset(deserializeProject(raw), asset.id) as SoundFxAsset;
|
||||
expect('size' in broken.graph.nodes[0]!).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a panel leaves out when it has too little room', () => {
|
||||
it('draws a row from the left as far as its groups fit, and leaves the rest out whole', () => {
|
||||
expect(fittingCount([40, 100, 60], 8, 216)).toBe(3);
|
||||
expect(fittingCount([40, 100, 60], 8, 215)).toBe(2);
|
||||
expect(fittingCount([40, 100, 60], 8, 148)).toBe(2);
|
||||
expect(fittingCount([40, 100, 60], 8, 147)).toBe(1);
|
||||
expect(fittingCount([40, 100, 60], 8, 39)).toBe(0);
|
||||
});
|
||||
|
||||
it('brings the rows under the curve in only once the curve keeps its room above them', () => {
|
||||
// Curve room 120, a row of 28 and a help line of 16, 8 between.
|
||||
expect(shownRows(155, 120, [28, 16], 8)).toBe(0);
|
||||
expect(shownRows(156, 120, [28, 16], 8)).toBe(1);
|
||||
expect(shownRows(179, 120, [28, 16], 8)).toBe(1);
|
||||
expect(shownRows(180, 120, [28, 16], 8)).toBe(2);
|
||||
});
|
||||
|
||||
it('leaves out an axis label that would run into the one before it', () => {
|
||||
const spans = [
|
||||
{ from: 0, to: 20 },
|
||||
{ from: 22, to: 40 },
|
||||
{ from: 30, to: 50 },
|
||||
{ from: 60, to: 80 },
|
||||
];
|
||||
expect(apartLabels(spans, 4)).toEqual([true, false, true, true]);
|
||||
expect(apartLabels(spans, 0)).toEqual([true, true, false, true]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user