From c0879930004cfd88739e33146f56fd445a7e49fd Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 10 Oct 2026 14:30:00 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/core/commands/graphCommands.ts | 27 + src/core/graph/Graph.ts | 2 + src/core/instrument/chipPresets.ts | 9 +- src/core/soundfx/nodes.ts | 29 +- src/core/soundfx/parametricEq.ts | 33 +- src/core/store/serialize.ts | 4 + src/editor/Editor.ts | 19 +- src/editor/debug/graphHooks.ts | 50 +- src/editor/graph/GraphCanvas.ts | 21 +- src/editor/graph/NodeView.ts | 63 +- src/editor/graph/graphDrags.ts | 70 +- src/editor/graph/nodeFace.ts | 5 + src/editor/soundfx/BandCurve.ts | 348 ++++++++++ .../soundfx/{EqFace.ts => CurveFace.ts} | 18 +- src/editor/soundfx/CurvePanel.ts | 353 ++++++++++ src/editor/soundfx/EqEditor.ts | 43 +- src/editor/soundfx/EqPanel.ts | 633 ++---------------- src/editor/soundfx/FilterPanel.ts | 100 +++ src/editor/soundfx/eqView.ts | 51 +- src/editor/soundfx/nodeFaces.ts | 12 +- src/editor/soundfx/nodeTabs.ts | 24 +- tests/core/chip-presets.test.ts | 3 + tests/core/envelope.test.ts | 14 +- tests/core/filter-editor.test.ts | 104 +++ tools/e2e/34-sound.mjs | 133 ++++ 26 files changed, 1514 insertions(+), 656 deletions(-) create mode 100644 src/editor/soundfx/BandCurve.ts rename src/editor/soundfx/{EqFace.ts => CurveFace.ts} (65%) create mode 100644 src/editor/soundfx/CurvePanel.ts create mode 100644 src/editor/soundfx/FilterPanel.ts create mode 100644 tests/core/filter-editor.test.ts diff --git a/README.md b/README.md index 9795d462..38e9775c 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1957 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1961 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/graphCommands.ts b/src/core/commands/graphCommands.ts index 5d8240e5..87837b16 100644 --- a/src/core/commands/graphCommands.ts +++ b/src/core/commands/graphCommands.ts @@ -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; diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index 025e2412..562108d1 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -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 }; } /** diff --git a/src/core/instrument/chipPresets.ts b/src/core/instrument/chipPresets.ts index 4d4d5b53..0a7910d1 100644 --- a/src/core/instrument/chipPresets.ts +++ b/src/core/instrument/chipPresets.ts @@ -393,6 +393,11 @@ function held( /** 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> = { [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, }); diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index 34a6c4ad..e12808d1 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -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', diff --git a/src/core/soundfx/parametricEq.ts b/src/core/soundfx/parametricEq.ts index ec51c850..066bcf28 100644 --- a/src/core/soundfx/parametricEq.ts +++ b/src/core/soundfx/parametricEq.ts @@ -12,8 +12,9 @@ export const MAX_EQ_BANDS = 10; export type EqBandType = Exclude; 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 = new Set(['lowshelf', 'highshelf', 'peaking']); -const SLOPED: ReadonlySet = new Set(['lowpass', 'highpass', 'bandpass', 'notch']); +const GAINED: ReadonlySet = new Set(['lowshelf', 'highshelf', 'peaking']); +const SLOPED: ReadonlySet = 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, diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 73183ae2..24221785 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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(); diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 2a19d81f..ab39c25d 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -125,6 +125,7 @@ export type CentreTab = | 'routing' | 'eq' | 'compressor' + | 'filter' | 'management'; /** A tab a plugin added; the prefix keeps it apart from every built-in one. */ @@ -135,7 +136,7 @@ export type TabId = CentreTab | PluginTabId; * An open document (#171): an asset by its id, or what a Variables tab shows (#252) by its * `valuesKey` – as many of those as were opened. The user management (#179) and the master routing * (#351) are no documents, but they open and close like one. A Parametric EQ has a tab per node (#370), - * and so has a Compressor (#378). + * and so have a Compressor (#378) and a Filter (#385). */ export type DocumentId = `asset:${string}` | `values:${string}` | `${NodeTabKind}:${string}` | 'routing' | 'management'; @@ -183,6 +184,7 @@ const PANE_ORDER = [ 'routing', 'eq', 'compressor', + 'filter', 'management', ] as const; @@ -210,6 +212,7 @@ const TAB_LABELS: Record = { routing: 'Master routing', eq: 'Parametric EQ', compressor: 'Compressor', + filter: 'Filter', management: 'User management', }; @@ -279,6 +282,7 @@ export class Editor implements PlayHost { readonly routingEditor: RoutingEditor; readonly eqEditor: EqEditor; readonly compressorEditor: CompressorEditor; + readonly filterEditor: EqEditor; /** What each open Variables tab shows, by its document. */ private readonly valueTargets = new Map(); /** The node behind each tab of an EQ (#370) or a Compressor (#378). */ @@ -384,6 +388,7 @@ export class Editor implements PlayHost { this.routingEditor = new RoutingEditor(state); this.eqEditor = new EqEditor(state); this.compressorEditor = new CompressorEditor(state); + this.filterEditor = new EqEditor(state, 'filter'); this.hierarchy = new HierarchyPanel(state); this.inspector = new Inspector(state); this.tilemapPanel = new TilemapPanel(state); @@ -508,6 +513,7 @@ export class Editor implements PlayHost { routing: this.routingEditor, eq: this.eqEditor, compressor: this.compressorEditor, + filter: this.filterEditor, management: this.managementPane, }; // Following a call goes on in the same tab: the tab changes its document, not its place. @@ -614,7 +620,7 @@ export class Editor implements PlayHost { if (!this.valueTargets.has(doc)) this.valueTargets.set(doc, target); this.openDocument(doc); }); - // A tab per Parametric EQ (#370) or Compressor (#378) node, opened by a double-click on it. + // A tab per Parametric EQ (#370), Compressor (#378) or Filter (#385) node, opened by a double-click on it. state.on('nodeTab', () => { const target = state.openedNode; const kind = target && nodeTabOf(target.type); @@ -642,6 +648,7 @@ export class Editor implements PlayHost { this.nodeTargets.clear(); this.eqEditor.close(); this.compressorEditor.close(); + this.filterEditor.close(); this.showTab('scene'); }); state.on('mode', () => this.editorRun.modeChanged()); @@ -712,6 +719,9 @@ export class Editor implements PlayHost { // The selection is the EQ's node, still selected from the double-click: it is never what goes. this.eqEditor.removePicked(); return true; + case 'filter': + // A Filter has its one band and nothing else to take; its node stays as well. + return true; default: return false; } @@ -908,6 +918,7 @@ export class Editor implements PlayHost { if (this.nodeTargets.delete(doc)) { if (kind === 'eq') this.eqEditor.close(); if (kind === 'compressor') this.compressorEditor.close(); + if (kind === 'filter') this.filterEditor.close(); } if (doc === 'routing') this.routingEditor.close(); } @@ -1145,6 +1156,7 @@ export class Editor implements PlayHost { const node = this.nodeTargets.get(doc); if (kind === 'eq' && node) this.eqEditor.show(node.assetId, node.nodeId); if (kind === 'compressor' && node) this.compressorEditor.show(node.assetId, node.nodeId); + if (kind === 'filter' && node) this.filterEditor.show(node.assetId, node.nodeId); } /** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */ @@ -1335,9 +1347,11 @@ export class Editor implements PlayHost { ['instrument', this.instrumentEditor.canvas], ] as const; if (!this.shows('eq')) this.eqEditor.hideSpectrum(); + if (!this.shows('filter')) this.filterEditor.hideSpectrum(); if (!this.shows('compressor')) this.compressorEditor.hideLevels(); for (const [kind, canvas] of faces) if (!this.shows(kind)) canvas.hideFaces(); if (this.shows('eq')) this.eqEditor.tick(); + if (this.shows('filter')) this.filterEditor.tick(); if (this.shows('compressor')) this.compressorEditor.tick(); this.facesMoving = false; for (const [kind, canvas] of faces) @@ -1379,6 +1393,7 @@ export class Editor implements PlayHost { (this.shows('song') && this.songEditor.isPlaying) || (this.shows('sound') && this.soundEditor.isPlaying) || (this.shows('eq') && this.eqEditor.hasSpectrum) || + (this.shows('filter') && this.filterEditor.hasSpectrum) || (this.shows('compressor') && this.compressorEditor.moving) || this.facesMoving ); diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index c4be3b4f..6b3d5ac8 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -5,7 +5,8 @@ import type { Widget } from '../../ui'; import { currentNodePalette } from '../graph/NodePalette'; import { CompressorPanel } from '../soundfx/CompressorPanel'; import { GainPanel } from '../soundfx/GainPanel'; -import { EqPanel } from '../soundfx/EqPanel'; +import { CurvePanel } from '../soundfx/CurvePanel'; +import type { EqEditor } from '../soundfx/EqEditor'; import { rectOf } from '../textProbe'; import { centreOf, snapButton, type HookContext } from './hookHelpers'; @@ -127,17 +128,7 @@ export function graphHooks({ state, editor }: HookContext) { }, /** The Parametric EQ's tab (#367, #370): for which node it is showing, its bands and where to aim. */ eq: { - shown: () => (editor.tab === 'eq' ? editor.eqEditor.shownFor : null), - bands: () => editor.eqEditor.panel.bands(), - picked: () => editor.eqEditor.panel.picked, - /** The dB the curve is zoomed to, above and below 0 (#376). */ - range: () => editor.eqEditor.panel.range(), - /** The dB the curve draws right now, and whether a zoom is on its way (#377). */ - zoomShown: () => editor.eqEditor.panel.curve.zoomShown, - handleAt: (index: number) => editor.eqEditor.panel.curve.handleGlobal(index), - pointAt: (hz: number, db: number) => editor.eqEditor.panel.curve.pointGlobal(hz, db), - fields: () => eqFields(editor.eqEditor.panel), - typeAt: () => centreOf(editor.eqEditor.panel.typeButton), + ...curveTabHooks(editor.eqEditor, () => editor.tab === 'eq'), /** * The spectra drawn behind the curve (#374): how many, the loudest bin in front of the node and * behind it in the last one, and the taps – which node is listened to, how many play, how many @@ -151,7 +142,7 @@ export function graphHooks({ state, editor }: HookContext) { panel: (nodeId: string) => { const canvas = editor.soundFxEditor.canvas; const panel = canvas.faceOf(nodeId)?.widget; - if (!(panel instanceof EqPanel)) return null; + if (!(panel instanceof CurvePanel)) return null; const node = canvas.nodeScreen(nodeId); const ports = (keys: string[], isOutput: boolean) => Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)])); @@ -161,6 +152,7 @@ export function graphHooks({ state, editor }: HookContext) { size: { w: panel.w, h: panel.h }, curve: screenRect(panel.curve), node: node?.rect ?? null, + corner: node?.corner ?? null, inputs: ports(node?.inputs ?? [], false), outputs: ports(node?.outputs ?? [], true), bands: panel.bands(), @@ -169,14 +161,21 @@ export function graphHooks({ state, editor }: HookContext) { handles: panel.bands().map((_, i) => panel.curve.handleGlobal(i)), type: screenRect(panel.typeButton), spectrum: panel.curve.seen, + rows: panel.shownRows, + labels: panel.curve.shownLabels, }; }, /** Where a frequency and a gain are on the curve of a node's panel, on screen. */ panelPointAt: (nodeId: string, hz: number, db: number) => { const panel = editor.soundFxEditor.canvas.faceOf(nodeId)?.widget; - return panel instanceof EqPanel ? panel.curve.pointGlobal(hz, db) : null; + return panel instanceof CurvePanel ? panel.curve.pointGlobal(hz, db) : null; }, }, + /** The Filter's tab (#385), as the EQ's; its node's panel is `eq.panel` too. */ + filter: { + ...curveTabHooks(editor.filterEditor, () => editor.tab === 'filter'), + spectrum: () => ({ ...editor.filterEditor.panel.curve.seen, taps: soundLibrary.spectrumTaps() }), + }, /** The Compressor's tab (#378): its node, its numbers and knobs, and where to aim on the curve. */ compressor: { shown: () => (editor.tab === 'compressor' ? editor.compressorEditor.shownFor : null), @@ -381,7 +380,7 @@ function screenRect(widget: Widget): { x: number; y: number; w: number; h: numbe } /** The picked band's numbers as an EQ panel shows them; null for one its band type has not. */ -function eqFields(panel: EqPanel) { +function eqFields(panel: CurvePanel) { return { type: panel.typeButton.text, slope: panel.slopeButton.enabled ? panel.slopeButton.text : null, @@ -390,3 +389,24 @@ function eqFields(panel: EqPanel) { frequency: panel.frequencyField.value, }; } + +/** A tab with a curve (#370, #385): for which node it is showing, its bands and where to aim. */ +function curveTabHooks(tab: EqEditor, seen: () => boolean) { + const panel = tab.panel; + return { + shown: () => (seen() ? tab.shownFor : null), + bands: () => panel.bands(), + picked: () => panel.picked, + /** The dB the curve is zoomed to, above and below 0 (#376). */ + range: () => panel.range(), + /** The dB the curve draws right now, and whether a zoom is on its way (#377). */ + zoomShown: () => panel.curve.zoomShown, + handleAt: (index: number) => panel.curve.handleGlobal(index), + pointAt: (hz: number, db: number) => panel.curve.pointGlobal(hz, db), + fields: () => eqFields(panel), + typeAt: () => centreOf(panel.typeButton), + /** What is drawn under the curve, and which axis labels (#385). */ + rows: () => panel.shownRows, + labels: () => panel.curve.shownLabels, + }; +} diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 4516a279..dc23d8c4 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -371,7 +371,11 @@ export class GraphCanvas extends Widget { const asset = this.asset(); if (!asset) return; this.annotationLayer.sync(asset.graph); - for (const node of asset.graph.nodes) this.views.get(node.id)?.position.set(node.x, node.y); + for (const node of asset.graph.nodes) { + const view = this.views.get(node.id); + view?.position.set(node.x, node.y); + view?.refit(); + } this.redraw(); } @@ -539,14 +543,17 @@ export class GraphCanvas extends Widget { return this.views.get(nodeId)?.face ?? null; } - /** A node's box on screen and the keys of its ports, for the check scripts. */ - nodeScreen(nodeId: string): { rect: Rect; inputs: string[]; outputs: string[] } | null { + /** A node's box on screen, the keys of its ports and the corner it is resized by (#385), for the check scripts. */ + nodeScreen( + nodeId: string, + ): { rect: Rect; inputs: string[]; outputs: string[]; corner: { x: number; y: number } | null } | null { const view = this.views.get(nodeId); if (!view) return null; return { rect: globalRect(view, view.w, view.h), inputs: view.def.inputs.map((p) => p.key), outputs: view.def.outputs.map((p) => p.key), + corner: view.cornerGlobal(), }; } @@ -697,6 +704,12 @@ export class GraphCanvas extends Widget { this.wiring.startDrag({ nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, e.global); return; } + if (view.cornerAt(local.x, local.y)) { + this.state.select(nodeSelection(this.assetId, [view.node.id])); + this.nodeLayer.addChild(view); // raise + this.drags.grabCorner(view, e.global); + return; + } // Like the hierarchy's chevron: it folds, and leaves the selection alone. if (view.chevronAt(local.x, local.y)) { this.state.execute(new CollapseNodesCommand(this.assetId, [view.node.id], !view.collapsed)); @@ -843,7 +856,7 @@ export class GraphCanvas extends Widget { return; } this.panning = null; - if (this.drags.releaseBox()) { + if (this.drags.releaseCorner() || this.drags.releaseBox()) { this.redraw(); return; } diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index b9f68731..a061ae94 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -1,4 +1,4 @@ -import { CanvasTextMetrics, Graphics, Rectangle, type Text } from 'pixi.js'; +import { CanvasTextMetrics, Container, Graphics, Rectangle, type Text } from 'pixi.js'; import { asNumber, asString, @@ -31,6 +31,7 @@ import { crispText, } from '../../ui'; import { keyLabel, referenceLabel } from '../inspector/fieldFactory'; +import { RESIZE_HANDLE } from './AnnotationView'; import { collapsedNodeWidth } from './folding'; import type { NodeFace } from './nodeFace'; import { inputTextRoom, valueSpan } from './portRow'; @@ -164,6 +165,10 @@ export class NodeView extends Widget { private drawnOf = ''; /** How wide the sides of a face are, with the pins and their names (#380). */ private sides = { left: 0, right: 0 }; + /** The corner a face that may be resized is dragged by (#385); it only shows the cursor. */ + private readonly corner = new Container(); + /** What the node was last drawn from, so a drag of its corner can draw it again (#385). */ + private last: { graph: Graph; project: Project | null; problem: string | null } | null = null; /** How often the texts and the box were really made, for the checks. */ readonly made = { texts: 0, box: 0 }; @@ -188,6 +193,10 @@ export class NodeView extends Widget { this.ports.context.batchMode = 'batch'; this.addChild(this.box, this.ports); if (face) this.addChild(face.widget); + this.corner.eventMode = 'static'; + this.corner.cursor = 'nwse-resize'; + this.corner.hitArea = new Rectangle(0, 0, RESIZE_HANDLE, RESIZE_HANDLE); + this.addChild(this.corner); this.sync(node, { nodes: [node], edges: [] }, null, def); } @@ -216,6 +225,7 @@ export class NodeView extends Widget { ): void { this.def = def; this.problem = problem; + this.last = { graph, project, problem }; this.alpha = def.dimmed?.(node.params) ? DIMMED_ALPHA : 1; const hidden = new Set([ ...(this.def.hiddenParams?.(node.params) ?? []), @@ -284,6 +294,8 @@ export class NodeView extends Widget { if (face) this.placeFace(face); else this.face.hide(); } + this.corner.visible = this.resizable; + this.corner.position.set(this.w - RESIZE_HANDLE, this.h - RESIZE_HANDLE); const texts = this.textSignature(); if (texts !== this.textsOf) { this.textsOf = texts; @@ -324,6 +336,7 @@ export class NodeView extends Widget { this.linked, this.targetPort, this.headerHeight(), + this.resizable, this.def.inputs.map(port), this.def.outputs.map(port), ]); @@ -428,10 +441,52 @@ export class NodeView extends Widget { return (!this.collapsed && this.face) || null; } + /** The size the node was dragged to (#385), never below the least its face takes; else the face's own. */ private faceSize(): { w: number; h: number } { + const min = this.face?.minSize; + const size = this.node.size; + if (min && size) return { w: Math.max(min.w, size.w), h: Math.max(min.h, size.h) }; return this.face?.widget.measure() ?? { w: 0, h: 0 }; } + /** Whether the node has a corner to resize its face by: an open node whose face may be (#385). */ + get resizable(): boolean { + return this.shownFace()?.minSize !== undefined; + } + + /** Whether a local point is on that corner. */ + cornerAt(x: number, y: number): boolean { + return ( + this.resizable && + x >= this.w - RESIZE_HANDLE && + x <= this.w && + y >= this.h - RESIZE_HANDLE && + y <= this.h + ); + } + + cornerGlobal(): { x: number; y: number } | null { + if (!this.resizable) return null; + const at = this.toGlobal({ x: this.w - RESIZE_HANDLE / 2, y: this.h - RESIZE_HANDLE / 2 }); + return { x: at.x, y: at.y }; + } + + /** How much of the node is not its face: the least the node itself can be dragged to follows from it. */ + get faceChrome(): { w: number; h: number } { + const size = this.faceSize(); + return { w: this.w - size.w, h: this.h - size.h }; + } + + /** A drag of the corner changed the node's size in the model: the node is drawn at it. */ + refit(): void { + const last = this.last; + const face = this.shownFace(); + if (!last || !face) return; + const size = this.faceSize(); + if (size.w === face.widget.w && size.h === face.widget.h) return; + this.sync(this.node, last.graph, last.project, this.def, last.problem); + } + /** Each side of a face as wide as the longest name on it. */ private faceSides(): { left: number; right: number } { const side = (ports: PortDef[]) => @@ -591,6 +646,12 @@ export class NodeView extends Widget { this.box .roundRect(2.5, 2.5, this.w - 5, this.h - 5, Theme.radius) .stroke({ width: 2, color: c.hot, alpha: this.stateMark === 'current' ? 1 : 0.35 }); + if (this.resizable) { + // The floating window's corner (#371), so it reads as the same handle. + for (const at of [4, 8, 12]) + this.box.moveTo(this.w - 2 - at, this.h - 2).lineTo(this.w - 2, this.h - 2 - at); + this.box.stroke({ width: 1, color: c.textMuted }); + } this.ports.clear(); const r = PORT_RADIUS; diff --git a/src/editor/graph/graphDrags.ts b/src/editor/graph/graphDrags.ts index 1a1ccb48..455a028c 100644 --- a/src/editor/graph/graphDrags.ts +++ b/src/editor/graph/graphDrags.ts @@ -1,8 +1,9 @@ import { annotationNoun, normalizeBox } from '../../core/commands/annotationCommands'; import { oneStep } from '../../core/commands/CompoundCommand'; +import { ResizeNodeCommand } from '../../core/commands/graphCommands'; import { findAnnotation, MIN_ANNOTATION_SIZE } from '../../core/graph'; import { cancelFrameWork, onFrame } from '../../ui'; -import { dragOffset, resizeSize } from '../framing'; +import { type Box, dragOffset, resizeSize } from '../framing'; import { boxOf, type BoxSeed, @@ -15,6 +16,7 @@ import { import type { AnnotationView } from './AnnotationView'; import type { GraphCanvasHost } from './graphHost'; import { GRID_STEP } from './graphView'; +import type { NodeView } from './NodeView'; import type { Point } from './transitions'; /** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */ @@ -38,6 +40,19 @@ export class GraphDrags { pointer: Point; moved: boolean; } | null = null; + /** + * A node dragged by its corner (#385): the node as it was grabbed, how much of it is not its face, + * and the least it can be. The model holds the face's size, the drag works on the whole node. + */ + private corner: { + nodeId: string; + start: Omit; + chrome: { w: number; h: number }; + min: { w: number; h: number }; + before: { w: number; h: number } | undefined; + pointer: Point; + moved: boolean; + } | null = null; /** * The gesture writes the model on every pointer move, the canvas follows once a frame (#213) – and @@ -90,9 +105,27 @@ export class GraphDrags { }; } + /** A press on the corner of a node whose face may be resized (#385). */ + grabCorner(view: NodeView, global: Point): void { + const min = view.face?.minSize; + if (!min) return; + const chrome = view.faceChrome; + const node = view.node; + this.corner = { + nodeId: node.id, + start: { x: node.x, y: node.y, w: view.w, h: view.h }, + chrome, + min: { w: min.w + chrome.w, h: min.h + chrome.h }, + before: node.size && { ...node.size }, + pointer: this.host.toGraph(global), + moved: false, + }; + } + /** Moves what is dragged in the model, for the preview; false when nothing is. */ move(global: Point): boolean { - if (this.box) this.previewBox(this.host.toGraph(global)); + if (this.corner) this.previewCorner(this.host.toGraph(global)); + else if (this.box) this.previewBox(this.host.toGraph(global)); else if (this.nodes) this.previewNodes(this.host.toGraph(global)); else return false; return true; @@ -135,6 +168,39 @@ export class GraphDrags { this.follow(); } + /** Sizes the node in the model, for the preview: its far corner on the grid when snapping, as a group's. */ + private previewCorner(g: Point): void { + const drag = this.corner!; + const dx = g.x - drag.pointer.x; + const dy = g.y - drag.pointer.y; + if (!drag.moved && Math.hypot(dx, dy) * this.host.zoom() < DRAG_THRESHOLD) return; + drag.moved = true; + const node = this.host.asset()?.graph.nodes.find((n) => n.id === drag.nodeId); + if (!node) return; + const size = resizeSize(drag.start, dx, dy, this.host.snapping() ? GRID_STEP : 0, drag.min); + node.size = { + w: Math.round(Math.max(drag.min.w, size.w) - drag.chrome.w), + h: Math.round(Math.max(drag.min.h, size.h) - drag.chrome.h), + }; + this.follow(); + } + + /** Puts the node's size back and records it as one undo step. False when no corner was dragged. */ + releaseCorner(): boolean { + const drag = this.corner; + if (!drag) return false; + this.corner = null; + cancelFrameWork(this.frameKey); + const node = this.host.asset()?.graph.nodes.find((n) => n.id === drag.nodeId); + const assetId = this.host.assetId(); + if (!drag.moved || !node?.size || !assetId) return true; + const size = node.size; + if (drag.before) node.size = drag.before; + else delete node.size; + this.host.state.execute(new ResizeNodeCommand(assetId, node.id, size)); + return true; + } + /** Puts the previewed boxes back and records the whole gesture as one undo step. False when no box was dragged. */ releaseBox(): boolean { const drag = this.box; diff --git a/src/editor/graph/nodeFace.ts b/src/editor/graph/nodeFace.ts index 879b3dda..9d6f8dfa 100644 --- a/src/editor/graph/nodeFace.ts +++ b/src/editor/graph/nodeFace.ts @@ -6,6 +6,11 @@ import type { Widget } from '../../ui'; */ export interface NodeFace { readonly widget: Widget; + /** + * The least it can be dragged to, for a face whose node has a corner to resize it by (#385); the + * widget's `measure()` is its size until then. + */ + readonly minSize?: { w: number; h: number }; /** After every change to the model: the widget shows the node as it now is. */ sync(): void; /** A frame on while the node is in the picture. Whether it still moves. */ diff --git a/src/editor/soundfx/BandCurve.ts b/src/editor/soundfx/BandCurve.ts new file mode 100644 index 00000000..6614200e --- /dev/null +++ b/src/editor/soundfx/BandCurve.ts @@ -0,0 +1,348 @@ +/** + * The curve of a Parametric EQ (#367) and of a Filter (#385): what its bands do together, worked out + * as Web Audio plays them (`parametricEq.ts`). A band is dragged for frequency and gain and wheeled + * for its Q. The wheel on free space zooms the dB the curve shows, saved on the node (#376) – in the + * tab only, in the node every wheel zooms the graph (#383). While it is seen, the spectrum of what + * passes the node may be drawn behind the curve (#374). + */ +import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, type Text } from 'pixi.js'; +import { + dbToY, + type EqArea, + EQ_RANGES, + eqCurvePoints, + fillShapes, + frequencyToX, + hasGain, + hasQ, + MAX_EQ_BANDS, + type PassBand, + xToFrequency, + yToDb, +} from '../../core/soundfx/parametricEq'; +import type { Spectrum } from '../../viewport/spectrum'; +import { crispText, pressCount, Theme, textStyle, Widget } from '../../ui'; +import { ViewTween } from '../viewTween'; +import { + apartLabels, + bandAt, + EQ_GRID_FREQUENCIES, + eqGridDb, + handleOf, + SPECTRUM_FLOOR_DB, + spectrumPeak, + spectrumYs, + wheeledQ, + wheeledRange, +} from './eqView'; + +/** The least the curve shrinks to; otherwise it takes what its panel leaves it. */ +const MIN_CURVE_WIDTH = 160; +const MIN_CURVE_HEIGHT = 80; +const HANDLE_RADIUS = 7; +const GRAB_RADIUS = HANDLE_RADIUS + 4; +/** Pixels of travel before a press on a band becomes a drag. */ +const DRAG_THRESHOLD = 3; +/** Points the curve is drawn through, evenly in octaves. */ +const CURVE_STEPS = 320; +const BAND_COLORS = [ + 0xf2c84b, 0x6fcf97, 0x56ccf2, 0xe57373, 0xb066ff, 0xf2a75c, 0xff9ad5, 0x9b8cff, 0x3fb8c9, 0xd4c25a, +]; +/** Pixels between the points the spectrum is drawn through (#374). */ +const SPECTRUM_STEP = 2; +/** Pixels kept between two labels on an axis; one that comes closer is left out (#385). */ +const LABEL_GAP = 4; + +/** What the curve asks of the panel around it. */ +export interface CurveHost { + bands(): readonly PassBand[]; + range(): number; + zoom(range: number): void; + readonly picked: number; + pick(index: number): void; + /** A band changed as a drag goes, written into the node for a preview. */ + drag(index: number, change: Partial): void; + /** The drag is over: its undo step. */ + drop(): void; + /** A band changed as an undo step of its own. */ + set(index: number, change: Partial): void; + /** A double-click on the free curve; false when the panel takes no band there. */ + addBand(frequency: number, gain: number): boolean; + /** A right-click on a band; false when the band stays. */ + removeBand(index: number): boolean; +} + +export class BandCurve extends Widget { + private readonly bg = new Graphics(); + private readonly grid = new Graphics(); + /** What passes the node, behind the curve (#374): faint as it comes in, strong as it goes on. */ + private readonly spectrum = new Graphics(); + private readonly lines = new Graphics(); + private readonly handles = new Graphics(); + private readonly labels: Text[] = []; + /** As many as the most lines a range has; those of a range with fewer are hidden. */ + private readonly dbLabels: Text[] = []; + private readonly numbers: Text[] = []; + private dragging: { index: number; startX: number; startY: number; moved: boolean } | null = null; + /** The spectrum drawn now; null while nothing has passed the node or it has fallen away. */ + private heard: Spectrum | null = null; + /** The dB drawn while a zoom is on its way (#377); null draws the node's. */ + private shownRange: number | null = null; + private readonly tween = new ViewTween({ + read: () => ({ scale: 1 / this.range, x: 0, y: 0 }), + write: (view) => (this.shownRange = 1 / view.scale), + draw: () => { + if (!this.tween.moving) this.shownRange = null; + this.redraw(); + }, + }); + /** How many spectra were drawn, and the loudest bin on either side of the last one (checks). */ + seen = { frames: 0, peakIn: 0, peakOut: 0, levelOut: -Infinity, binHz: 0 }; + + constructor(private readonly host: CurveHost) { + super(); + this.minWidth = MIN_CURVE_WIDTH; + this.minHeight = MIN_CURVE_HEIGHT; + this.cursor = 'crosshair'; + this.addChild(this.bg, this.grid, this.spectrum, this.lines, this.handles); + const muted = textStyle('small').clone(); + muted.fill = Theme.colors.textMuted; + const onHandle = textStyle('small').clone(); + onHandle.fill = Theme.colors.symbolOutline; + for (const { label } of EQ_GRID_FREQUENCIES) this.labels.push(crispText({ text: label, style: muted })); + const most = Math.max(...EQ_RANGES.map((range) => eqGridDb(range).length)); + for (let i = 0; i < most; i++) this.dbLabels.push(crispText({ text: '', style: muted })); + for (let i = 0; i < MAX_EQ_BANDS; i++) + this.numbers.push(crispText({ text: `${i + 1}`, style: onHandle })); + for (const text of [...this.labels, ...this.dbLabels, ...this.numbers]) { + text.eventMode = 'none'; + this.addChild(text); + } + this.on('pointerdown', (e: FederatedPointerEvent) => this.press(e)); + this.on('globalpointermove', (e: FederatedPointerEvent) => this.move(e)); + this.onPointerRelease(() => this.release()); + this.on('wheel', (e: FederatedWheelEvent) => this.wheel(e)); + } + + get area(): EqArea { + return { w: this.w, h: this.h, range: this.range }; + } + + private get range(): number { + return this.shownRange ?? this.host.range(); + } + + /** The dB drawn and whether a zoom is on its way – the check scripts sample it. */ + get zoomShown(): { range: number; moving: boolean } { + return { range: this.range, moving: this.tween.moving }; + } + + /** The axis labels drawn, for the checks: those that would run into another are left out. */ + get shownLabels(): { frequencies: string[]; dbs: string[] } { + const shown = (texts: Text[]) => texts.filter((t) => t.visible).map((t) => t.text); + return { frequencies: shown(this.labels), dbs: shown(this.dbLabels) }; + } + + /** Another node: a zoom on its way to the one before ends. */ + settle(): void { + this.tween.land(); + this.shownRange = null; + } + + /** Where a band's handle is on screen – the check scripts aim with it. */ + handleGlobal(index: number): { x: number; y: number } | null { + const band = this.host.bands()[index]; + if (!band) return null; + const at = this.toGlobal(handleOf(band, this.area)); + return { x: at.x, y: at.y }; + } + + /** Where a frequency and a gain are on screen, at the graph's zoom in the node. */ + pointGlobal(frequency: number, db: number): { x: number; y: number } { + const at = this.toGlobal({ x: frequencyToX(frequency, this.area), y: dbToY(db, this.area) }); + return { x: at.x, y: at.y }; + } + + private local(e: FederatedPointerEvent): { x: number; y: number } { + return this.toLocal(e.global); + } + + private press(e: FederatedPointerEvent): void { + const at = this.local(e); + const bands = this.host.bands(); + const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS); + if (e.button === 2) { + if (index >= 0 && this.host.removeBand(index)) e.stopPropagation(); + return; + } + if (e.button !== 0) return; + // In the node a press on the curve is the curve's: it neither picks nor moves the node. + e.stopPropagation(); + if (index >= 0) { + this.host.pick(index); + this.dragging = { index, startX: e.global.x, startY: e.global.y, moved: false }; + return; + } + // A double-click on the free curve may put a band there; a single one lets go of the picked one. + if (pressCount(e, this) === 2) { + const frequency = Math.round(xToFrequency(at.x, this.area)); + if (this.host.addBand(frequency, Math.round(yToDb(at.y, this.area) * 10) / 10)) return; + } + this.host.pick(-1); + } + + private move(e: FederatedPointerEvent): void { + const drag = this.dragging; + if (!drag) return; + if (!drag.moved && Math.hypot(e.global.x - drag.startX, e.global.y - drag.startY) < DRAG_THRESHOLD) + return; + drag.moved = true; + const at = this.local(e); + const band = this.host.bands()[drag.index]; + if (!band) return; + const frequency = Math.round(xToFrequency(at.x, this.area)); + const gain = Math.round(yToDb(at.y, this.area) * 10) / 10; + this.host.drag(drag.index, hasGain(band.type) ? { frequency, gain } : { frequency }); + } + + private release(): void { + const drag = this.dragging; + this.dragging = null; + if (drag?.moved) this.host.drop(); + } + + /** + * The wheel over a band turns its Q, anywhere else it zooms the dB the curve shows (#376). Only in + * the tab: in the node the graph takes every wheel before the curve sees it (#383). + */ + private wheel(e: FederatedWheelEvent): void { + if (e.deltaY === 0) return; + const at = this.toLocal(e.global); + const bands = this.host.bands(); + const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS); + const band = bands[index]; + e.stopPropagation(); + if (!band) { + const range = wheeledRange(this.host.range(), e.deltaY); + this.tween.change(() => { + this.shownRange = null; + this.host.zoom(range); + }); + return; + } + if (!hasQ(band.type)) return; + this.host.pick(index); + this.host.set(index, { q: wheeledQ(band.q, e.deltaY) }); + } + + override redraw(): void { + const c = Theme.colors; + const area = this.area; + this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground).stroke({ color: c.border, width: 1 }); + + this.grid.clear(); + const frequencySpans = EQ_GRID_FREQUENCIES.map(({ hz }, i) => { + const x = Math.round(frequencyToX(hz, area)) + 0.5; + this.grid.moveTo(x, 0).lineTo(x, this.h); + const text = this.labels[i]!; + text.position.set(Math.min(this.w - text.width - 2, x + 3), this.h - text.height - 2); + return { from: text.x, to: text.x + text.width }; + }); + apartLabels(frequencySpans, LABEL_GAP).forEach((shown, i) => (this.labels[i]!.visible = shown)); + // The lines of the range a zoom is heading to, so they slide to their place with whole numbers. + const dbs = eqGridDb(this.host.range()); + const dbSpans: { from: number; to: number }[] = []; + const placed: Text[] = []; + this.dbLabels.forEach((text, i) => { + const db = dbs[i]; + const y = db === undefined ? -1 : Math.round(dbToY(db, area)) + 0.5; + text.visible = y > 0 && y < this.h; + if (!text.visible || db === undefined) return; + this.grid.moveTo(0, y).lineTo(this.w, y); + text.text = db > 0 ? `+${db}` : `${db}`; + text.position.set(3, y - text.height - 1); + dbSpans.push({ from: text.y, to: text.y + text.height }); + placed.push(text); + }); + apartLabels(dbSpans, 0).forEach((shown, i) => (placed[i]!.visible = shown)); + this.grid.stroke({ color: c.border, width: 1, alpha: 0.6 }); + const zero = Math.round(dbToY(0, area)) + 0.5; + this.grid.moveTo(0, zero).lineTo(this.w, zero).stroke({ color: c.textMuted, width: 1, alpha: 0.5 }); + + const bands = this.host.bands(); + this.lines.clear(); + const curves = eqCurvePoints(bands, area, CURVE_STEPS, 2); + const xs = curves.xs; + curves.bands.forEach((ys, i) => { + trace(this.lines, xs, ys); + this.lines.stroke({ color: BAND_COLORS[i]!, width: 1, alpha: i === this.host.picked ? 0.9 : 0.4 }); + }); + for (const shape of fillShapes(xs, curves.sum, zero)) + this.lines.poly(shape).fill({ color: c.accent, alpha: 0.12 }); + trace(this.lines, xs, curves.sum); + this.lines.stroke({ color: c.accent, width: 2 }); + + this.handles.clear(); + this.numbers.forEach((text, i) => { + const band = bands[i]; + text.visible = Boolean(band); + if (!band) return; + const at = handleOf(band, area); + this.handles.circle(at.x, at.y, HANDLE_RADIUS).fill(BAND_COLORS[i]); + if (i === this.host.picked) + this.handles.circle(at.x, at.y, HANDLE_RADIUS + 3).stroke({ color: c.symbol, width: 2 }); + text.position.set(Math.round(at.x - text.width / 2), Math.round(at.y - text.height / 2)); + }); + this.drawSpectrum(); + } + + /** The spectrum a frame on (#374); one all below the floor of its scale counts as none. */ + showSpectrum(spectrum: Spectrum | null): void { + const peakIn = spectrum && spectrumPeak(spectrum.in, spectrum.binHz); + const peakOut = spectrum && spectrumPeak(spectrum.out, spectrum.binHz); + const loud = Math.max(peakIn?.db ?? -Infinity, peakOut?.db ?? -Infinity) > SPECTRUM_FLOOR_DB; + const shown = loud ? spectrum : null; + if (!shown && !this.heard) return; + this.heard = shown; + if (shown) + this.seen = { + frames: this.seen.frames + 1, + peakIn: peakIn?.hz ?? 0, + peakOut: peakOut?.hz ?? 0, + levelOut: peakOut?.db ?? -Infinity, + binHz: shown.binHz, + }; + this.drawSpectrum(); + } + + get hasSpectrum(): boolean { + return this.heard !== null; + } + + private drawSpectrum(): void { + this.spectrum.clear(); + const heard = this.heard; + if (!heard) return; + const c = Theme.colors; + const count = Math.floor(this.w / SPECTRUM_STEP); + const xs = Array.from({ length: count + 1 }, (_, i) => (i / count) * this.w); + for (const [dbs, color, alpha, line] of [ + [heard.in, c.textMuted, 0.12, false], + [heard.out, c.portAudio, 0.18, true], + ] as const) { + const ys = spectrumYs(dbs, heard.binHz, xs, this.area); + this.spectrum + .poly([0, this.h, ...xs.flatMap((x, i) => [x, ys[i]!]), this.w, this.h]) + .fill({ color, alpha }); + if (!line) continue; + trace(this.spectrum, xs, ys); + this.spectrum.stroke({ color, width: 1, alpha: 0.6 }); + } + } +} + +/** A path through the points. */ +function trace(g: Graphics, xs: readonly number[], ys: readonly number[]): void { + g.moveTo(xs[0]!, ys[0]!); + for (let i = 1; i < xs.length; i++) g.lineTo(xs[i]!, ys[i]!); +} diff --git a/src/editor/soundfx/EqFace.ts b/src/editor/soundfx/CurveFace.ts similarity index 65% rename from src/editor/soundfx/EqFace.ts rename to src/editor/soundfx/CurveFace.ts index 3040dd07..a8639714 100644 --- a/src/editor/soundfx/EqFace.ts +++ b/src/editor/soundfx/CurveFace.ts @@ -1,24 +1,22 @@ import { tapId } from '../../core/soundfx/nodes'; import { soundLibrary } from '../../viewport/SoundLibrary'; import type { NodeFace } from '../graph/nodeFace'; -import type { EditorState } from '../state'; -import { EqPanel } from './EqPanel'; +import { CURVE_NODE_MIN, type CurvePanel } from './CurvePanel'; /** - * A Parametric EQ node drawn as its editor (#381): the panel of its tab, worked right in the graph, - * with the spectrum behind the curve while the node is in the picture. + * A Parametric EQ node (#381) or a Filter node (#385) drawn as its editor: the panel of its tab, + * worked right in the graph, with the spectrum behind the curve while the node is in the picture. + * It can be dragged to another size (#385). */ -export class EqFace implements NodeFace { - readonly widget: EqPanel; +export class CurveFace implements NodeFace { + readonly minSize = CURVE_NODE_MIN; private listening = false; constructor( - state: EditorState, + readonly widget: CurvePanel, private readonly assetId: string, private readonly nodeId: string, - ) { - this.widget = new EqPanel(state, true); - } + ) {} sync(): void { this.widget.show(this.assetId, this.nodeId); diff --git a/src/editor/soundfx/CurvePanel.ts b/src/editor/soundfx/CurvePanel.ts new file mode 100644 index 00000000..1d80779f --- /dev/null +++ b/src/editor/soundfx/CurvePanel.ts @@ -0,0 +1,353 @@ +/** + * What the editors of a Parametric EQ (#367) and of a Filter (#385) share: the curve, the numbers of + * the picked band in a row under it and, in a tab, a line of help. A tab shows the panel, and so + * does the node in the graph (#381), at the size the node was dragged to (#385). + * + * Too little room leaves things out rather than cutting them (#385): the row comes in only once the + * curve keeps `CURVE_ROOM` above it, the help only once the row has come in too, and a group of the + * row that does not fit is not drawn – the one on the right goes first. + */ +import type { FederatedPointerEvent } from 'pixi.js'; +import { oneStep } from '../../core/commands/CompoundCommand'; +import { SetNodeParamCommand } from '../../core/commands/graphCommands'; +import type { GraphNode } from '../../core/graph'; +import type { PropertyValue } from '../../core/model/Properties'; +import type { FxPassFilter } from '../../core/soundfx/nodes'; +import { + EQ_FREQUENCY, + EQ_Q, + EQ_RANGE_PARAM, + EQ_SLOPES, + eqRangeOf, + hasGain, + hasQ, + hasSlope, + type PassBand, +} from '../../core/soundfx/parametricEq'; +import { + Dropdown, + HStack, + Label, + Menu, + NumberField, + type Size, + Spacer, + Theme, + VStack, + Widget, +} from '../../ui'; +import { FramePreview } from '../framePreview'; +import type { EditorState } from '../state'; +import { BandCurve, type CurveHost } from './BandCurve'; +import { fittingCount, shownRows } from './eqView'; + +/** The height the curve keeps before the row under it comes in (#385). */ +export const CURVE_ROOM = 120; +/** The least a node's panel can be dragged to (#385): the curve alone, with nothing under it. */ +export const CURVE_NODE_MIN: Size = { w: 240, h: 120 }; + +/** A row of groups, drawn from the left as far as they fit; the rest are not drawn at all (#385). */ +class FittingRow extends HStack { + private readonly names: string[] = []; + + private groups(): Widget[] { + return this.children.filter((c): c is Widget => c instanceof Widget); + } + + addGroup(name: string, group: HStack): HStack { + this.names.push(name); + return this.add(group); + } + + override measure(): Size { + const sizes = this.groups().map((group) => group.measure()); + const w = sizes.reduce((sum, size) => sum + size.w, 0) + Math.max(0, sizes.length - 1) * this.gap; + const h = Math.max(0, ...sizes.map((size) => size.h)); + return { + w: Math.max(this.minWidth, w + this.padding * 2), + h: Math.max(this.minHeight, h + this.padding * 2), + }; + } + + override layout(): void { + const groups = this.groups(); + const fit = fittingCount( + groups.map((group) => group.measure().w), + this.gap, + this.w - this.padding * 2, + ); + groups.forEach((group, i) => (group.visible = i < fit)); + super.layout(); + } + + /** The groups drawn, by name – the check scripts read it. */ + get shown(): string[] { + return this.names.filter((_, i) => this.groups()[i]?.visible); + } +} + +export interface CurvePanelOptions { + /** What the panel is at in a node until the node is dragged to another size. */ + nodeSize: Size; + types: readonly { value: FxPassFilter; label: string }[]; + gain: { min: number; max: number }; + help: string; + /** A group before the type, and one at the right end of the row. */ + lead?: Widget; + end?: Widget; +} + +export abstract class CurvePanel extends VStack implements CurveHost { + readonly curve: BandCurve; + protected assetId = ''; + protected nodeId = ''; + picked = -1; + readonly typeButton: Dropdown; + readonly slopeButton: Dropdown; + readonly frequencyField: NumberField; + readonly gainField: NumberField; + readonly qField: NumberField; + protected readonly row = new FittingRow({ gap: Theme.gap, align: 'center' }); + private readonly help: Label | null; + /** What the node held before a drag or a scrub began – its undo step's other side. */ + private readonly before = new Map(); + private readonly perFrame: FramePreview; + + constructor( + protected readonly state: EditorState, + readonly inNode: boolean, + private readonly options: CurvePanelOptions, + frameName: string, + ) { + super({ gap: Theme.padding, padding: inNode ? Theme.padding : Theme.padding * 2 }); + this.perFrame = new FramePreview(state, frameName); + this.curve = new BandCurve(this); + const numbers = (change: (value: number) => Partial) => ({ + onInput: (value: number) => this.drag(this.picked, change(value)), + onChange: (value: number) => this.set(this.picked, change(value)), + }); + this.typeButton = new Dropdown('—', { width: 104, onClick: () => this.openTypeMenu() }); + this.slopeButton = new Dropdown('12 dB/oct', { width: 96, onClick: () => this.openSlopeMenu() }); + this.frequencyField = new NumberField(1000, { + width: 64, + min: EQ_FREQUENCY.min, + max: EQ_FREQUENCY.max, + step: 1, + decimals: 0, + ...numbers((frequency) => ({ frequency })), + }); + this.gainField = new NumberField(0, { + width: 52, + min: options.gain.min, + max: options.gain.max, + step: 0.1, + decimals: 1, + ...numbers((gain) => ({ gain })), + }); + this.qField = new NumberField(1, { + width: 52, + min: EQ_Q.min, + max: EQ_Q.max, + step: 0.01, + decimals: 3, + ...numbers((q) => ({ q })), + }); + // In the node a press on a menu button is not one on the node: it neither picks nor moves it. + if (inNode) + for (const button of [this.typeButton, this.slopeButton]) + button.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation()); + if (options.lead) this.group('Band', options.lead); + for (const [name, widget] of [ + ['Type', this.typeButton], + ['Hz', this.frequencyField], + ['dB', this.gainField], + ['Q', this.qField], + ['Slope', this.slopeButton], + ] as const) + this.group(name, new Label(name, { style: 'small' }), widget); + if (options.end) { + // The last group takes what is left, so what it holds sits at the right end. + const spacer = new Spacer(); + const end = this.group('End', spacer, options.end); + end.flex = 1; + spacer.flex = 1; + } + this.help = inNode ? null : new Label(options.help, { style: 'muted' }); + this.add(this.curve, 1); + this.add(this.row); + if (this.help) this.add(this.help); + } + + /** A group of the row: a name and what it names, drawn or left out together. */ + private group(name: string, ...widgets: Widget[]): HStack { + const group = new HStack({ gap: Theme.gap, align: 'center' }); + for (const widget of widgets) group.add(widget); + return this.row.addGroup(name, group); + } + + override measure(): Size { + return this.inNode ? this.options.nodeSize : super.measure(); + } + + override layout(): void { + const rows = this.help ? [this.row, this.help] : [this.row]; + const shown = shownRows( + this.h - this.padding * 2, + CURVE_ROOM, + rows.map((row) => row.measure().h), + this.gap, + ); + rows.forEach((row, i) => (row.visible = i < shown)); + super.layout(); + } + + /** What the panel draws under the curve, for the checks: the groups of the row and the help. */ + get shownRows(): { row: string[]; help: boolean } { + return { row: this.row.visible ? this.row.shown : [], help: this.help?.visible ?? false }; + } + + /** Points the panel at a node; what was picked stays when it is the same node again. */ + show(assetId: string, nodeId: string): void { + if (assetId !== this.assetId || nodeId !== this.nodeId) { + this.curve.settle(); + this.perFrame.cancel(); + this.curve.showSpectrum(null); + this.before.clear(); + this.assetId = assetId; + this.nodeId = nodeId; + this.picked = this.bands().length > 0 ? 0 : -1; + } + this.sync(); + } + + /** The panel lets go of its node. */ + close(): void { + this.perFrame.cancel(); + this.curve.showSpectrum(null); + const node = this.node(); + if (node) this.restore(node); + this.assetId = ''; + this.nodeId = ''; + this.picked = -1; + } + + get shownFor(): { assetId: string; nodeId: string } | null { + return this.nodeId ? { assetId: this.assetId, nodeId: this.nodeId } : null; + } + + protected abstract node(): GraphNode | null; + abstract bands(): PassBand[]; + abstract drag(index: number, change: Partial): void; + abstract drop(): void; + abstract set(index: number, change: Partial): void; + + addBand(_frequency: number, _gain: number): boolean { + return false; + } + + removeBand(_index: number): boolean { + return false; + } + + /** Which of the picked band's numbers are fed by a link and cannot be typed or dragged. */ + protected locked(): { frequency: boolean; gain: boolean; q: boolean } { + return { frequency: false, gain: false, q: false }; + } + + range(): number { + return eqRangeOf(this.node()?.params ?? {}); + } + + zoom(range: number): void { + this.state.setNodeView(this.assetId, this.nodeId, EQ_RANGE_PARAM, range); + } + + pick(index: number): void { + if (this.picked === index) return; + this.picked = index; + this.sync(); + } + + /** Writes values into the node as they are dragged; `commit` makes the undo step of them. */ + protected preview(values: Record): void { + const node = this.node(); + if (!node) return; + for (const [key, value] of Object.entries(values)) { + if (!this.before.has(key)) this.before.set(key, node.params[key]); + node.params[key] = value; + } + this.perFrame.request(); + } + + /** The values as one undo step, after what a drag wrote is put back; none when nothing changes. */ + protected commit(values: Record, label: string): void { + this.perFrame.cancel(); + const node = this.node(); + if (!node) return; + this.restore(node); + const commands = Object.entries(values) + .filter(([key, value]) => JSON.stringify(value) !== JSON.stringify(node.params[key])) + .map(([key, value]) => new SetNodeParamCommand(this.assetId, this.nodeId, key, value)); + if (commands.length === 0) this.state.preview(); + else this.state.execute(oneStep(label, commands)); + } + + /** What a drag wrote as one undo step. */ + protected commitPreview(label: string): void { + const node = this.node(); + if (!node) return; + const values: Record = {}; + for (const key of this.before.keys()) if (node.params[key] !== undefined) values[key] = node.params[key]; + this.commit(values, label); + } + + private restore(node: GraphNode): void { + for (const [key, value] of this.before) { + if (value === undefined) delete node.params[key]; + else node.params[key] = value; + } + this.before.clear(); + } + + private openTypeMenu(): void { + const band = this.bands()[this.picked]; + if (!band) return; + const items = this.options.types.map((type) => ({ + label: type.label, + current: type.value === band.type, + onSelect: () => this.set(this.picked, { type: type.value }), + })); + new Menu(items).openBelow(this.typeButton); + } + + private openSlopeMenu(): void { + const band = this.bands()[this.picked]; + if (!band) return; + const items = EQ_SLOPES.map((slope) => ({ + label: `${slope} dB/oct`, + current: slope === band.slope, + onSelect: () => this.set(this.picked, { slope }), + })); + new Menu(items).openBelow(this.slopeButton); + } + + /** After every change to the model: the row shows the picked band as it now is. */ + sync(): void { + if (!this.node()) return; + const bands = this.bands(); + if (this.picked >= bands.length) this.picked = -1; + const band = bands[this.picked]; + const locked = this.locked(); + this.typeButton.setText(this.options.types.find((t) => t.value === band?.type)?.label ?? '—'); + this.slopeButton.setText(band ? `${band.slope} dB/oct` : '—'); + this.frequencyField.setNumber(band?.frequency ?? 1000); + this.gainField.setNumber(band?.gain ?? 0); + this.qField.setNumber(band?.q ?? 1); + this.typeButton.setEnabled(Boolean(band)); + this.frequencyField.setEnabled(Boolean(band && !locked.frequency)); + this.gainField.setEnabled(Boolean(band && hasGain(band.type) && !locked.gain)); + this.qField.setEnabled(Boolean(band && hasQ(band.type) && !locked.q)); + this.slopeButton.setEnabled(Boolean(band && hasSlope(band.type))); + this.curve.invalidate(); + } +} diff --git a/src/editor/soundfx/EqEditor.ts b/src/editor/soundfx/EqEditor.ts index 32015228..3a923db3 100644 --- a/src/editor/soundfx/EqEditor.ts +++ b/src/editor/soundfx/EqEditor.ts @@ -1,27 +1,35 @@ /** - * The tab of a Parametric EQ node (#370): the panel of `EqPanel.ts` under a header, pointed at the - * node whose tab is showing. While the tab is seen, the spectrum of what passes the node is drawn - * behind the curve (#374). + * The tab of a Parametric EQ node (#370) or a Filter node (#385): its panel under a header, pointed + * at the node whose tab is showing. While the tab is seen, the spectrum of what passes the node is + * drawn behind the curve (#374, #385). */ import { findGraphAsset, type Project } from '../../core/model/Project'; -import { SoundFxParametricEq, tapId } from '../../core/soundfx/nodes'; +import { SoundFxFilter, tapId } from '../../core/soundfx/nodes'; import { soundLibrary } from '../../viewport/SoundLibrary'; import { Label, Spacer, VStack } from '../../ui'; import type { EditorState } from '../state'; import { TabHeader } from '../TabHeader'; +import type { CurvePanel } from './CurvePanel'; import { EqPanel } from './EqPanel'; +import { FilterPanel } from './FilterPanel'; + +const TITLES = { eq: 'Parametric EQ', filter: 'Filter' } as const; /** One editor, pointed at the node whose tab is showing; once the node is gone the tab row is told. */ export class EqEditor extends VStack { - readonly panel: EqPanel; - private readonly title = new Label('Parametric EQ', { style: 'bodyBold' }); + readonly panel: CurvePanel; + private readonly title: Label; private readonly header = new TabHeader(); /** Whether the tab holds the node's tap; the node in the graph listens to the same one (#381). */ private listening = false; - constructor(private readonly state: EditorState) { + constructor( + private readonly state: EditorState, + private readonly kind: 'eq' | 'filter' = 'eq', + ) { super(); - this.panel = new EqPanel(state); + this.panel = kind === 'eq' ? new EqPanel(state) : new FilterPanel(state); + this.title = new Label(TITLES[kind], { style: 'bodyBold' }); this.header.add(this.title); this.header.add(new Spacer(), 1); this.add(this.header); @@ -69,16 +77,16 @@ export class EqEditor extends VStack { return this.panel.curve.hasSpectrum; } - /** Delete in the tab takes the picked band – never the node the tab is about. */ + /** Delete in an EQ's tab takes the picked band – never the node the tab is about. */ removePicked(): void { - this.panel.removePicked(); + if (this.panel instanceof EqPanel) this.panel.removePicked(); } private sync(): void { const shown = this.panel.shownFor; if (!shown) return; this.panel.sync(); - const title = `Parametric EQ – ${findGraphAsset(this.state.project, shown.assetId)?.name ?? ''}`; + const title = `${TITLES[this.kind]} – ${findGraphAsset(this.state.project, shown.assetId)?.name ?? ''}`; if (title === this.title.text) return; this.title.setText(title); this.relayout(); @@ -86,12 +94,15 @@ export class EqEditor extends VStack { } /** - * What an EQ's tab says: its asset's name, and which EQ it is when the asset has more than one. + * What an EQ's or a Filter's tab says: its asset's name, and which one it is when the asset has + * more than one of them. */ export function eqTabLabel(project: Project, assetId: string, nodeId: string): string { const asset = findGraphAsset(project, assetId); - if (!asset) return ''; - const eqs = asset.graph.nodes.filter((n) => n.type === SoundFxParametricEq.type); - const index = eqs.findIndex((n) => n.id === nodeId); - return eqs.length > 1 ? `${asset.name} · EQ ${index + 1}` : `${asset.name} · EQ`; + const type = asset?.graph.nodes.find((n) => n.id === nodeId)?.type; + if (!asset || !type) return ''; + const noun = type === SoundFxFilter.type ? 'Filter' : 'EQ'; + const same = asset.graph.nodes.filter((n) => n.type === type); + const index = same.findIndex((n) => n.id === nodeId); + return same.length > 1 ? `${asset.name} · ${noun} ${index + 1}` : `${asset.name} · ${noun}`; } diff --git a/src/editor/soundfx/EqPanel.ts b/src/editor/soundfx/EqPanel.ts index c5f1a6be..dbc58898 100644 --- a/src/editor/soundfx/EqPanel.ts +++ b/src/editor/soundfx/EqPanel.ts @@ -1,506 +1,58 @@ /** - * The editor of a Parametric EQ node (#367): the curve and the numbers of the picked band. Its tab - * (#370) shows it, and since #381 so does the node itself in the graph. The curve is the sum of the - * bands, worked out as Web Audio plays them (`parametricEq.ts`); a band is dragged for frequency and - * gain, wheeled for its Q, and its numbers can be typed below the curve. A drag or a scrub is one - * undo step. The wheel on free space zooms the dB the curve shows, saved on the node (#376) – in the - * tab only, in the node every wheel zooms the graph (#383). While it is seen, the spectrum of what passes the node is - * drawn behind the curve (#374). + * The editor of a Parametric EQ node (#367): the curve of its bands and the numbers of the picked + * one. Its tab (#370) shows it, and since #381 so does the node itself in the graph. A double-click + * on the free curve adds a band, a right-click on one removes it. While it is seen, the spectrum of + * what passes the node is drawn behind the curve (#374). */ -import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, type Text } from 'pixi.js'; -import { SetNodeParamCommand } from '../../core/commands/graphCommands'; import type { GraphNode } from '../../core/graph'; import { findGraphAsset, type Project } from '../../core/model/Project'; -import type { PropertyValue } from '../../core/model/Properties'; +import { SoundFxParametricEq } from '../../core/soundfx/nodes'; import { - dbToY, - type EqArea, type EqBand, EQ_BAND_TYPES, EQ_BANDS_PARAM, - EQ_FREQUENCY, EQ_GAIN, - EQ_Q, - EQ_RANGE_PARAM, - EQ_RANGES, - EQ_SLOPES, eqBandsOf, eqBandsValue, - eqCurvePoints, - eqRangeOf, - fillShapes, - frequencyToX, - hasGain, - hasQ, - hasSlope, MAX_EQ_BANDS, newEqBand, + type PassBand, withBandSet, - xToFrequency, - yToDb, } from '../../core/soundfx/parametricEq'; -import { SoundFxParametricEq } from '../../core/soundfx/nodes'; -import type { Spectrum } from '../../viewport/spectrum'; -import { - crispText, - Dropdown, - HStack, - Label, - Menu, - NumberField, - pressCount, - Spacer, - Theme, - textStyle, - VStack, - Widget, -} from '../../ui'; -import { FramePreview } from '../framePreview'; +import { Label } from '../../ui'; import type { EditorState } from '../state'; -import { ViewTween } from '../viewTween'; -import { - bandAt, - EQ_GRID_FREQUENCIES, - eqGridDb, - handleOf, - SPECTRUM_FLOOR_DB, - spectrumPeak, - spectrumYs, - wheeledQ, - wheeledRange, -} from './eqView'; +import { CurvePanel } from './CurvePanel'; -/** The least the curve shrinks to; otherwise it takes the tab. */ -const MIN_CURVE_WIDTH = 320; -const MIN_CURVE_HEIGHT = 160; -const HANDLE_RADIUS = 7; -const GRAB_RADIUS = HANDLE_RADIUS + 4; -/** Pixels of travel before a press on a band becomes a drag. */ -const DRAG_THRESHOLD = 3; -/** Points the curve is drawn through, evenly in octaves. */ -const CURVE_STEPS = 320; -const BAND_COLORS = [ - 0xf2c84b, 0x6fcf97, 0x56ccf2, 0xe57373, 0xb066ff, 0xf2a75c, 0xff9ad5, 0x9b8cff, 0x3fb8c9, 0xd4c25a, -]; -/** Pixels between the points the spectrum is drawn through (#374). */ -const SPECTRUM_STEP = 2; /** The panel in the node (#381): a little narrower than the Compressor's, and taller. */ export const EQ_NODE_SIZE = { w: 720, h: 360 }; -/** What the curve asks of the panel around it. */ -interface EqHost { - bands(): EqBand[]; - range(): number; - zoom(range: number): void; - picked: number; - pick(index: number): void; - preview(bands: EqBand[]): void; - commit(bands: EqBand[]): void; -} - -class EqCurve extends Widget { - private readonly bg = new Graphics(); - private readonly grid = new Graphics(); - /** What passes the node, behind the curve (#374): faint as it comes in, strong as it goes on. */ - private readonly spectrum = new Graphics(); - private readonly lines = new Graphics(); - private readonly handles = new Graphics(); - private readonly labels: Text[] = []; - /** As many as the most lines a range has; those of a range with fewer are hidden. */ - private readonly dbLabels: Text[] = []; - private readonly numbers: Text[] = []; - private drag: { index: number; startX: number; startY: number; moved: boolean } | null = null; - /** The spectrum drawn now; null while nothing has passed the node or it has fallen away. */ - private heard: Spectrum | null = null; - /** The dB drawn while a zoom is on its way (#377); null draws the node's. */ - private shownRange: number | null = null; - private readonly tween = new ViewTween({ - read: () => ({ scale: 1 / this.range, x: 0, y: 0 }), - write: (view) => (this.shownRange = 1 / view.scale), - draw: () => { - if (!this.tween.moving) this.shownRange = null; - this.redraw(); - }, - }); - /** How many spectra were drawn, and the loudest bin on either side of the last one (checks). */ - seen = { frames: 0, peakIn: 0, peakOut: 0, levelOut: -Infinity, binHz: 0 }; - - constructor(private readonly host: EqHost) { - super(); - this.minWidth = MIN_CURVE_WIDTH; - this.minHeight = MIN_CURVE_HEIGHT; - this.cursor = 'crosshair'; - this.addChild(this.bg, this.grid, this.spectrum, this.lines, this.handles); - const muted = textStyle('small').clone(); - muted.fill = Theme.colors.textMuted; - const onHandle = textStyle('small').clone(); - onHandle.fill = Theme.colors.symbolOutline; - for (const { label } of EQ_GRID_FREQUENCIES) this.labels.push(crispText({ text: label, style: muted })); - const most = Math.max(...EQ_RANGES.map((range) => eqGridDb(range).length)); - for (let i = 0; i < most; i++) this.dbLabels.push(crispText({ text: '', style: muted })); - for (let i = 0; i < MAX_EQ_BANDS; i++) - this.numbers.push(crispText({ text: `${i + 1}`, style: onHandle })); - for (const text of [...this.labels, ...this.dbLabels, ...this.numbers]) { - text.eventMode = 'none'; - this.addChild(text); - } - this.on('pointerdown', (e: FederatedPointerEvent) => this.press(e)); - this.on('globalpointermove', (e: FederatedPointerEvent) => this.move(e)); - this.onPointerRelease(() => this.release()); - this.on('wheel', (e: FederatedWheelEvent) => this.wheel(e)); - } - - get area(): EqArea { - return { w: this.w, h: this.h, range: this.range }; - } - - private get range(): number { - return this.shownRange ?? this.host.range(); - } - - /** The dB drawn and whether a zoom is on its way – the check scripts sample it. */ - get zoomShown(): { range: number; moving: boolean } { - return { range: this.range, moving: this.tween.moving }; - } - - /** Another node: a zoom on its way to the one before ends. */ - settle(): void { - this.tween.land(); - this.shownRange = null; - } - - /** Where a band's handle is on screen – the check scripts aim with it. */ - handleGlobal(index: number): { x: number; y: number } | null { - const band = this.host.bands()[index]; - if (!band) return null; - const at = this.toGlobal(handleOf(band, this.area)); - return { x: at.x, y: at.y }; - } - - /** Where a frequency and a gain are on screen, at the graph's zoom in the node. */ - pointGlobal(frequency: number, db: number): { x: number; y: number } { - const at = this.toGlobal({ x: frequencyToX(frequency, this.area), y: dbToY(db, this.area) }); - return { x: at.x, y: at.y }; - } - - private local(e: FederatedPointerEvent): { x: number; y: number } { - return this.toLocal(e.global); - } - - private press(e: FederatedPointerEvent): void { - const at = this.local(e); - const bands = this.host.bands(); - const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS); - if (e.button === 2) { - if (index >= 0) { - e.stopPropagation(); - this.host.pick(-1); - this.host.commit(bands.filter((_, i) => i !== index)); - } - return; - } - if (e.button !== 0) return; - // In the node a press on the curve is the curve's: it neither picks nor moves the node. - e.stopPropagation(); - if (index >= 0) { - this.host.pick(index); - this.drag = { index, startX: e.global.x, startY: e.global.y, moved: false }; - return; - } - // A double-click on the free curve puts a band there; a single one lets go of the picked one. - if (pressCount(e, this) === 2 && bands.length < MAX_EQ_BANDS) { - const band = newEqBand( - Math.round(xToFrequency(at.x, this.area)), - Math.round(yToDb(at.y, this.area) * 10) / 10, - ); - this.host.commit([...bands, band]); - this.host.pick(bands.length); - return; - } - this.host.pick(-1); - } - - private move(e: FederatedPointerEvent): void { - const drag = this.drag; - if (!drag) return; - if (!drag.moved && Math.hypot(e.global.x - drag.startX, e.global.y - drag.startY) < DRAG_THRESHOLD) - return; - drag.moved = true; - const at = this.local(e); - const bands = this.host.bands(); - const band = bands[drag.index]; - if (!band) return; - const frequency = Math.round(xToFrequency(at.x, this.area)); - const gain = Math.round(yToDb(at.y, this.area) * 10) / 10; - this.host.preview( - withBandSet(bands, drag.index, hasGain(band.type) ? { frequency, gain } : { frequency }), - ); - } - - private release(): void { - const drag = this.drag; - this.drag = null; - if (!drag?.moved) return; - this.host.commit(this.host.bands()); - } - - /** - * The wheel over a band turns its Q, anywhere else it zooms the dB the curve shows (#376). Only in - * the tab: in the node the graph takes every wheel before the curve sees it (#383). - */ - private wheel(e: FederatedWheelEvent): void { - if (e.deltaY === 0) return; - const at = this.toLocal(e.global); - const bands = this.host.bands(); - const index = bandAt(bands, at.x, at.y, this.area, GRAB_RADIUS); - const band = bands[index]; - e.stopPropagation(); - if (!band) { - const range = wheeledRange(this.host.range(), e.deltaY); - this.tween.change(() => { - this.shownRange = null; - this.host.zoom(range); - }); - return; - } - if (!hasQ(band.type)) return; - this.host.pick(index); - this.host.commit(withBandSet(bands, index, { q: wheeledQ(band.q, e.deltaY) })); - } - - override redraw(): void { - const c = Theme.colors; - const area = this.area; - this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground).stroke({ color: c.border, width: 1 }); - - this.grid.clear(); - let label = 0; - for (const { hz } of EQ_GRID_FREQUENCIES) { - const x = Math.round(frequencyToX(hz, area)) + 0.5; - this.grid.moveTo(x, 0).lineTo(x, this.h); - const text = this.labels[label++]!; - text.position.set(Math.min(this.w - text.width - 2, x + 3), this.h - text.height - 2); - } - // The lines of the range a zoom is heading to, so they slide to their place with whole numbers. - const dbs = eqGridDb(this.host.range()); - this.dbLabels.forEach((text, i) => { - const db = dbs[i]; - const y = db === undefined ? -1 : Math.round(dbToY(db, area)) + 0.5; - text.visible = y > 0 && y < this.h; - if (!text.visible || db === undefined) return; - this.grid.moveTo(0, y).lineTo(this.w, y); - text.text = db > 0 ? `+${db}` : `${db}`; - text.position.set(3, y - text.height - 1); - }); - this.grid.stroke({ color: c.border, width: 1, alpha: 0.6 }); - const zero = Math.round(dbToY(0, area)) + 0.5; - this.grid.moveTo(0, zero).lineTo(this.w, zero).stroke({ color: c.textMuted, width: 1, alpha: 0.5 }); - - const bands = this.host.bands(); - this.lines.clear(); - const curves = eqCurvePoints(bands, area, CURVE_STEPS, 2); - const xs = curves.xs; - curves.bands.forEach((ys, i) => { - trace(this.lines, xs, ys); - this.lines.stroke({ color: BAND_COLORS[i]!, width: 1, alpha: i === this.host.picked ? 0.9 : 0.4 }); - }); - for (const shape of fillShapes(xs, curves.sum, zero)) - this.lines.poly(shape).fill({ color: c.accent, alpha: 0.12 }); - trace(this.lines, xs, curves.sum); - this.lines.stroke({ color: c.accent, width: 2 }); - - this.handles.clear(); - this.numbers.forEach((text, i) => { - const band = bands[i]; - text.visible = Boolean(band); - if (!band) return; - const at = handleOf(band, area); - this.handles.circle(at.x, at.y, HANDLE_RADIUS).fill(BAND_COLORS[i]); - if (i === this.host.picked) - this.handles.circle(at.x, at.y, HANDLE_RADIUS + 3).stroke({ color: c.symbol, width: 2 }); - text.position.set(Math.round(at.x - text.width / 2), Math.round(at.y - text.height / 2)); - }); - this.drawSpectrum(); - } - - /** The spectrum a frame on (#374); one all below the floor of its scale counts as none. */ - showSpectrum(spectrum: Spectrum | null): void { - const peakIn = spectrum && spectrumPeak(spectrum.in, spectrum.binHz); - const peakOut = spectrum && spectrumPeak(spectrum.out, spectrum.binHz); - const loud = Math.max(peakIn?.db ?? -Infinity, peakOut?.db ?? -Infinity) > SPECTRUM_FLOOR_DB; - const shown = loud ? spectrum : null; - if (!shown && !this.heard) return; - this.heard = shown; - if (shown) - this.seen = { - frames: this.seen.frames + 1, - peakIn: peakIn?.hz ?? 0, - peakOut: peakOut?.hz ?? 0, - levelOut: peakOut?.db ?? -Infinity, - binHz: shown.binHz, - }; - this.drawSpectrum(); - } - - get hasSpectrum(): boolean { - return this.heard !== null; - } - - private drawSpectrum(): void { - this.spectrum.clear(); - const heard = this.heard; - if (!heard) return; - const c = Theme.colors; - const count = Math.floor(this.w / SPECTRUM_STEP); - const xs = Array.from({ length: count + 1 }, (_, i) => (i / count) * this.w); - for (const [dbs, color, alpha, line] of [ - [heard.in, c.textMuted, 0.12, false], - [heard.out, c.portAudio, 0.18, true], - ] as const) { - const ys = spectrumYs(dbs, heard.binHz, xs, this.area); - this.spectrum - .poly([0, this.h, ...xs.flatMap((x, i) => [x, ys[i]!]), this.w, this.h]) - .fill({ color, alpha }); - if (!line) continue; - trace(this.spectrum, xs, ys); - this.spectrum.stroke({ color, width: 1, alpha: 0.6 }); - } - } -} - -/** A path through the points. */ -function trace(g: Graphics, xs: readonly number[], ys: readonly number[]): void { - g.moveTo(xs[0]!, ys[0]!); - for (let i = 1; i < xs.length; i++) g.lineTo(xs[i]!, ys[i]!); -} - -/** - * The curve and the picked band's numbers, pointed at one node. The tab points its one panel at the - * node it shows; a node in the graph has a panel of its own at `EQ_NODE_SIZE`. - */ -export class EqPanel extends VStack implements EqHost { - readonly curve: EqCurve; - private assetId = ''; - private nodeId = ''; - picked = -1; - /** The bands before a drag or a scrub began – its undo step's other side; undefined when none ran. */ - private before: PropertyValue | undefined | null = null; - private readonly bandLabel = new Label('', { style: 'small' }); - private readonly count = new Label('', { style: 'muted' }); - readonly typeButton: Dropdown; - readonly slopeButton: Dropdown; - readonly frequencyField: NumberField; - readonly gainField: NumberField; - readonly qField: NumberField; - private readonly row = new HStack({ gap: Theme.gap, align: 'center' }); - private readonly perFrame: FramePreview; - /** The texts the panel was last laid out for; a drag changes only numbers and needs no layout. */ +export class EqPanel extends CurvePanel { + private readonly bandLabel: Label; + private readonly count: Label; + /** The texts the row was last laid out for; a drag changes only numbers and needs no layout. */ private laidOut = ''; - constructor( - private readonly state: EditorState, - readonly inNode = false, - ) { - super({ gap: Theme.padding, padding: inNode ? Theme.padding : Theme.padding * 2 }); - this.perFrame = new FramePreview(state, inNode ? 'eq-node' : 'eq-band'); - this.curve = new EqCurve(this); - - const numbers = (change: (value: number) => Partial) => ({ - onInput: (value: number) => this.previewPicked(change(value)), - onChange: (value: number) => this.commitPicked(change(value)), - }); - this.typeButton = new Dropdown('Peaking', { width: 104, onClick: () => this.openTypeMenu() }); - this.slopeButton = new Dropdown('12 dB/oct', { width: 96, onClick: () => this.openSlopeMenu() }); - this.frequencyField = new NumberField(1000, { - width: 64, - min: EQ_FREQUENCY.min, - max: EQ_FREQUENCY.max, - step: 1, - decimals: 0, - ...numbers((frequency) => ({ frequency })), - }); - this.gainField = new NumberField(0, { - width: 52, - min: EQ_GAIN.min, - max: EQ_GAIN.max, - step: 0.1, - decimals: 1, - ...numbers((gain) => ({ gain })), - }); - this.qField = new NumberField(1, { - width: 52, - min: EQ_Q.min, - max: EQ_Q.max, - step: 0.01, - decimals: 3, - ...numbers((q) => ({ q })), - }); - // In the node a press on a menu button is not one on the node: it neither picks nor moves it. - if (inNode) - for (const button of [this.typeButton, this.slopeButton]) - button.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation()); - const row = this.row; - row.add(this.bandLabel); - for (const [name, widget] of [ - ['Type', this.typeButton], - ['Hz', this.frequencyField], - ['dB', this.gainField], - ['Q', this.qField], - ['Slope', this.slopeButton], - ] as const) { - row.add(new Label(name, { style: 'small' })); - row.add(widget); - } - row.add(new Spacer(), 1); - row.add(this.count); - - this.add(this.curve, 1); - this.add(row); - this.add( - new Label( - inNode - ? 'Double-click: add a band. Drag: frequency, gain. Right-click: remove.' - : 'Double-click: add a band. Drag: frequency, gain. Wheel: Q on a band, else zoom. Right-click: remove.', - { style: 'muted' }, - ), + constructor(state: EditorState, inNode = false) { + const bandLabel = new Label('', { style: 'small' }); + const count = new Label('', { style: 'muted' }); + super( + state, + inNode, + { + nodeSize: EQ_NODE_SIZE, + types: EQ_BAND_TYPES, + gain: EQ_GAIN, + help: 'Double-click: add a band. Drag: frequency, gain. Wheel: Q on a band, else zoom. Right-click: remove.', + lead: bandLabel, + end: count, + }, + inNode ? 'eq-node' : 'eq-band', ); + this.bandLabel = bandLabel; + this.count = count; } - override measure(): { w: number; h: number } { - return this.inNode ? EQ_NODE_SIZE : super.measure(); - } - - /** Points the panel at a node; the band picked stays when it is the same node again. */ - show(assetId: string, nodeId: string): void { - if (assetId !== this.assetId || nodeId !== this.nodeId) { - this.curve.settle(); - this.perFrame.cancel(); - this.curve.showSpectrum(null); - this.before = null; - this.assetId = assetId; - this.nodeId = nodeId; - this.picked = this.bands().length > 0 ? 0 : -1; - } - this.sync(); - } - - /** The panel lets go of its node. */ - close(): void { - this.perFrame.cancel(); - this.curve.showSpectrum(null); - const node = this.node(); - if (node) this.restore(node); - this.assetId = ''; - this.nodeId = ''; - this.picked = -1; - this.laidOut = ''; - } - - get shownFor(): { assetId: string; nodeId: string } | null { - return this.nodeId ? { assetId: this.assetId, nodeId: this.nodeId } : null; - } - - private node(): GraphNode | null { + protected node(): GraphNode | null { return eqNodeOf(this.state.project, this.assetId, this.nodeId); } @@ -509,119 +61,64 @@ export class EqPanel extends VStack implements EqHost { return node ? eqBandsOf(node.params) : []; } - range(): number { - return eqRangeOf(this.node()?.params ?? {}); + /** A band's values as `withBandSet` holds them: a shape the EQ has not, like an all-pass, is none. */ + private changed(index: number, change: Partial): EqBand[] { + return withBandSet(this.bands(), index, change as Partial); } - zoom(range: number): void { - this.state.setNodeView(this.assetId, this.nodeId, EQ_RANGE_PARAM, range); + private commitBands(bands: EqBand[]): void { + this.commit({ [EQ_BANDS_PARAM]: eqBandsValue(bands) }, 'Set bands'); } - pick(index: number): void { - if (this.picked === index) return; - this.picked = index; - this.sync(); + drag(index: number, change: Partial): void { + if (index >= 0) this.preview({ [EQ_BANDS_PARAM]: eqBandsValue(this.changed(index, change)) }); } - /** - * Writes the bands into the node as they are dragged; `commit` makes the undo step of it. The - * panels hear of it once a frame, however fast the mouse reports. - */ - preview(bands: EqBand[]): void { - const node = this.node(); - if (!node) return; - if (this.before === null) this.before = node.params[EQ_BANDS_PARAM]; - node.params[EQ_BANDS_PARAM] = eqBandsValue(bands); - this.perFrame.request(); + drop(): void { + this.commitPreview('Set bands'); } - commit(bands: EqBand[]): void { - this.perFrame.cancel(); - const node = this.node(); - if (!node) return; - this.restore(node); - const value = eqBandsValue(bands); - if (JSON.stringify(value) === JSON.stringify(eqBandsValue(eqBandsOf(node.params)))) { - this.state.preview(); - return; - } - this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, EQ_BANDS_PARAM, value)); + set(index: number, change: Partial): void { + if (index >= 0) this.commitBands(this.changed(index, change)); } - private restore(node: GraphNode): void { - if (this.before === null) return; - if (this.before === undefined) delete node.params[EQ_BANDS_PARAM]; - else node.params[EQ_BANDS_PARAM] = this.before; - this.before = null; + override addBand(frequency: number, gain: number): boolean { + const bands = this.bands(); + if (bands.length >= MAX_EQ_BANDS) return false; + this.commitBands([...bands, newEqBand(frequency, gain)]); + this.pick(bands.length); + return true; } - private previewPicked(change: Partial): void { - if (this.picked >= 0) this.preview(withBandSet(this.bands(), this.picked, change)); - } - - private commitPicked(change: Partial): void { - if (this.picked >= 0) this.commit(withBandSet(this.bands(), this.picked, change)); + override removeBand(index: number): boolean { + this.pick(-1); + this.commitBands(this.bands().filter((_, i) => i !== index)); + return true; } /** Delete in the tab takes the picked band – never the node the tab is about. */ removePicked(): void { - const index = this.picked; - if (index < 0) return; - this.picked = -1; - this.commit(this.bands().filter((_, i) => i !== index)); + if (this.picked >= 0) this.removeBand(this.picked); } - private openTypeMenu(): void { - const band = this.bands()[this.picked]; - if (!band) return; - const items = EQ_BAND_TYPES.map((type) => ({ - label: type.label, - current: type.value === band.type, - onSelect: () => this.commitPicked({ type: type.value }), - })); - new Menu(items).openBelow(this.typeButton); + override close(): void { + super.close(); + this.laidOut = ''; } - private openSlopeMenu(): void { - const band = this.bands()[this.picked]; - if (!band) return; - const items = EQ_SLOPES.map((slope) => ({ - label: `${slope} dB/oct`, - current: slope === band.slope, - onSelect: () => this.commitPicked({ slope }), - })); - new Menu(items).openBelow(this.slopeButton); - } - - /** After every change to the model: the panel shows its node as it now is. */ - sync(): void { - const node = this.node(); - if (!node) return; - const bands = eqBandsOf(node.params); - if (this.picked >= bands.length) this.picked = -1; - const band = bands[this.picked]; - const count = `${bands.length} / ${MAX_EQ_BANDS} bands`; - const bandLabel = band ? `Band ${this.picked + 1}` : 'No band'; + override sync(): void { + if (!this.node()) return; + super.sync(); + const count = `${this.bands().length} / ${MAX_EQ_BANDS} bands`; + const bandLabel = this.picked >= 0 ? `Band ${this.picked + 1}` : 'No band'; this.count.setText(count); this.bandLabel.setText(bandLabel); - this.typeButton.setText(EQ_BAND_TYPES.find((t) => t.value === band?.type)?.label ?? '—'); - this.slopeButton.setText(band ? `${band.slope} dB/oct` : '—'); - this.frequencyField.setNumber(band?.frequency ?? 1000); - this.gainField.setNumber(band?.gain ?? 0); - this.qField.setNumber(band?.q ?? 1); - this.typeButton.setEnabled(Boolean(band)); - this.frequencyField.setEnabled(Boolean(band)); - this.gainField.setEnabled(Boolean(band && hasGain(band.type))); - this.qField.setEnabled(Boolean(band && hasQ(band.type))); - this.slopeButton.setEnabled(Boolean(band && hasSlope(band.type))); const texts = [count, bandLabel].join('\n'); - if (texts !== this.laidOut) { - this.laidOut = texts; - this.relayout(); - // Its own size stays, so the row would not lay its labels out anew by itself. - this.row.relayout(); - } - this.curve.invalidate(); + if (texts === this.laidOut) return; + this.laidOut = texts; + this.relayout(); + // Its own size stays, so the row would not lay its labels out anew by itself. + this.row.relayout(); } } diff --git a/src/editor/soundfx/FilterPanel.ts b/src/editor/soundfx/FilterPanel.ts new file mode 100644 index 00000000..5dae7a20 --- /dev/null +++ b/src/editor/soundfx/FilterPanel.ts @@ -0,0 +1,100 @@ +/** + * The editor of a Filter node (#385): the curve of the Parametric EQ with the Filter as its one band, + * and its numbers under it. Its tab shows it, and so does the node itself in the graph. A number fed + * by a link is the link's: it can be neither typed nor dragged here. + */ +import type { GraphNode } from '../../core/graph'; +import { findGraphAsset, type Project } from '../../core/model/Project'; +import type { PropertyValue } from '../../core/model/Properties'; +import { + FILTER_ROWS, + FILTER_SETTINGS, + FILTER_TYPES, + filterBandOf, + SoundFxFilter, +} from '../../core/soundfx/nodes'; +import type { PassBand } from '../../core/soundfx/parametricEq'; +import type { EditorState } from '../state'; +import { compressorWired } from './CompressorEditor'; +import { CurvePanel } from './CurvePanel'; + +/** The panel in the node until it is dragged to another size: room for the whole row. */ +export const FILTER_NODE_SIZE = { w: 560, h: 280 }; + +const GAIN = { min: FILTER_ROWS.gain.min ?? -40, max: FILTER_ROWS.gain.max ?? 40 }; + +/** A number on its step, within its row. */ +function held(value: number, row: { min?: number; max?: number }, step: number): number { + const per = Math.round(1 / step); + return Math.max(row.min ?? value, Math.min(row.max ?? value, Math.round(value * per) / per)); +} + +export class FilterPanel extends CurvePanel { + constructor(state: EditorState, inNode = false) { + super( + state, + inNode, + { + nodeSize: FILTER_NODE_SIZE, + types: FILTER_TYPES, + gain: GAIN, + help: 'Drag: frequency, gain. Wheel: Q on the handle, else zoom.', + }, + inNode ? 'filter-node' : 'filter-band', + ); + } + + protected node(): GraphNode | null { + return filterNodeOf(this.state.project, this.assetId, this.nodeId); + } + + bands(): PassBand[] { + const node = this.node(); + return node ? [filterBandOf(node.params)] : []; + } + + /** Its one band stays picked. */ + override pick(): void {} + + protected override locked(): { frequency: boolean; gain: boolean; q: boolean } { + const wired = (key: string) => compressorWired(this.state.project, this.assetId, this.nodeId, key); + return { + frequency: wired(FILTER_ROWS.cutoff.key), + gain: wired(FILTER_ROWS.gain.key), + q: wired(FILTER_ROWS.q.key), + }; + } + + /** A change of the band as what the node holds, without the numbers a link feeds. */ + private values(change: Partial): Record { + const locked = this.locked(); + const values: Record = {}; + if (change.frequency !== undefined && !locked.frequency) + values[FILTER_ROWS.cutoff.key] = held(change.frequency, FILTER_ROWS.cutoff, 1); + if (change.gain !== undefined && !locked.gain) + values[FILTER_ROWS.gain.key] = held(change.gain, GAIN, 0.1); + if (change.q !== undefined && !locked.q) values[FILTER_ROWS.q.key] = held(change.q, FILTER_ROWS.q, 0.001); + if (change.type !== undefined) values[FILTER_SETTINGS.type] = change.type; + if (change.slope !== undefined) values[FILTER_SETTINGS.slope] = String(change.slope); + return values; + } + + drag(_index: number, change: Partial): void { + const values = this.values(change); + if (Object.keys(values).length > 0) this.preview(values); + } + + drop(): void { + this.commitPreview('Set filter'); + } + + set(_index: number, change: Partial): void { + this.commit(this.values(change), 'Set filter'); + } +} + +/** The Filter node a panel is about, or null once it, or its asset, is gone. */ +export function filterNodeOf(project: Project, assetId: string, nodeId: string): GraphNode | null { + const node = findGraphAsset(project, assetId)?.graph.nodes.find((n) => n.id === nodeId); + return node?.type === SoundFxFilter.type ? node : null; +} diff --git a/src/editor/soundfx/eqView.ts b/src/editor/soundfx/eqView.ts index c9f5d1c2..686e1a7c 100644 --- a/src/editor/soundfx/eqView.ts +++ b/src/editor/soundfx/eqView.ts @@ -4,7 +4,7 @@ */ import { type EqArea, - type EqBand, + type PassBand, dbToY, EQ_Q, EQ_RANGES, @@ -17,13 +17,19 @@ import { * Where a band's handle sits: at its frequency, and at its gain or – without one – on the 0 dB line. * A gain beyond what the curve is zoomed to keeps its handle on the edge, in sight and in reach. */ -export function handleOf(band: EqBand, area: EqArea): { x: number; y: number } { +export function handleOf(band: PassBand, area: EqArea): { x: number; y: number } { const y = dbToY(hasGain(band.type) ? band.gain : 0, area); return { x: frequencyToX(band.frequency, area), y: Math.max(0, Math.min(area.h, y)) }; } /** The band whose handle is under a point, the nearest one if several are; -1 for none. */ -export function bandAt(bands: readonly EqBand[], x: number, y: number, area: EqArea, radius: number): number { +export function bandAt( + bands: readonly PassBand[], + x: number, + y: number, + area: EqArea, + radius: number, +): number { let found = -1; let best = radius * radius; bands.forEach((band, i) => { @@ -37,6 +43,45 @@ export function bandAt(bands: readonly EqBand[], x: number, y: number, area: EqA return found; } +/** + * How many of a row's groups fit into `room`, from the left (#385): those after them are not drawn + * at all, rather than cut or scrolled. + */ +export function fittingCount(widths: readonly number[], gap: number, room: number): number { + let used = 0; + for (let i = 0; i < widths.length; i++) { + used += (i > 0 ? gap : 0) + widths[i]!; + if (used > room) return i; + } + return widths.length; +} + +/** + * How many of the rows below the curve a height has room for (#385): each comes in only once the + * curve keeps `curveRoom` above it, the first row first. + */ +export function shownRows(height: number, curveRoom: number, rows: readonly number[], gap: number): number { + let used = curveRoom; + for (let i = 0; i < rows.length; i++) { + used += gap + rows[i]!; + if (used > height) return i; + } + return rows.length; +} + +/** + * Which labels along one axis are drawn (#385): each one `gap` clear of the last one drawn, in the + * order given. One that would run into it is left out rather than drawn over it. + */ +export function apartLabels(spans: readonly { from: number; to: number }[], gap: number): boolean[] { + let last: { from: number; to: number } | null = null; + return spans.map((span) => { + if (last && span.from < last.to + gap && span.to > last.from - gap) return false; + last = span; + return true; + }); +} + /** The Q after a turn of the wheel: a notch of it is a sixth of an octave narrower or wider. */ export function wheeledQ(q: number, deltaY: number): number { const turned = q * 2 ** (-Math.sign(deltaY) / 6); diff --git a/src/editor/soundfx/nodeFaces.ts b/src/editor/soundfx/nodeFaces.ts index 02ba418d..77ceeb3a 100644 --- a/src/editor/soundfx/nodeFaces.ts +++ b/src/editor/soundfx/nodeFaces.ts @@ -1,15 +1,19 @@ import type { GraphNode } from '../../core/graph'; -import { SoundFxCompressor, SoundFxGain, SoundFxParametricEq } from '../../core/soundfx/nodes'; +import { SoundFxCompressor, SoundFxFilter, SoundFxGain, SoundFxParametricEq } from '../../core/soundfx/nodes'; import type { NodeFace } from '../graph/nodeFace'; import type { EditorState } from '../state'; import { CompressorFace } from './CompressorFace'; -import { EqFace } from './EqFace'; +import { CurveFace } from './CurveFace'; +import { EqPanel } from './EqPanel'; +import { FilterPanel } from './FilterPanel'; import { GainFace } from './GainFace'; -/** The nodes drawn as a face of their own instead of rows (#380, #381, #384), by type. */ +/** The nodes drawn as a face of their own instead of rows (#380, #381, #384, #385), by type. */ export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null { if (node.type === SoundFxCompressor.type) return new CompressorFace(state, assetId, node.id); - if (node.type === SoundFxParametricEq.type) return new EqFace(state, assetId, node.id); + if (node.type === SoundFxParametricEq.type) + return new CurveFace(new EqPanel(state, true), assetId, node.id); + if (node.type === SoundFxFilter.type) return new CurveFace(new FilterPanel(state, true), assetId, node.id); if (node.type === SoundFxGain.type) return new GainFace(state, assetId, node.id); return null; } diff --git a/src/editor/soundfx/nodeTabs.ts b/src/editor/soundfx/nodeTabs.ts index 4ffe6415..b9c7abcb 100644 --- a/src/editor/soundfx/nodeTabs.ts +++ b/src/editor/soundfx/nodeTabs.ts @@ -1,27 +1,35 @@ /** * The nodes of a sound FX graph with a tab of their own, opened by a double-click on them: a - * Parametric EQ (#370) and a Compressor (#378). A tab is a document named by its kind, the asset - * and the node (`eq::`). + * Parametric EQ (#370), a Compressor (#378) and a Filter (#385). A tab is a document named by its + * kind, the asset and the node (`eq::`). */ import type { GraphNode } from '../../core/graph'; import type { Project } from '../../core/model/Project'; -import { SoundFxCompressor, SoundFxParametricEq } from '../../core/soundfx/nodes'; +import { SoundFxCompressor, SoundFxFilter, SoundFxParametricEq } from '../../core/soundfx/nodes'; import { compressorNodeOf, compressorTabLabel } from './CompressorEditor'; import { eqTabLabel } from './EqEditor'; import { eqNodeOf } from './EqPanel'; +import { filterNodeOf } from './FilterPanel'; -export type NodeTabKind = 'eq' | 'compressor'; +export type NodeTabKind = 'eq' | 'compressor' | 'filter'; -export const NODE_TAB_KINDS: readonly NodeTabKind[] = ['eq', 'compressor']; +export const NODE_TAB_KINDS: readonly NodeTabKind[] = ['eq', 'compressor', 'filter']; const KIND_OF_TYPE: Readonly> = { [SoundFxParametricEq.type]: 'eq', [SoundFxCompressor.type]: 'compressor', + [SoundFxFilter.type]: 'filter', }; /** The kind of tab a node opens, or null for one without. */ export const nodeTabOf = (type: string): NodeTabKind | null => KIND_OF_TYPE[type] ?? null; +const NODE_OF: Readonly> = { + eq: eqNodeOf, + compressor: compressorNodeOf, + filter: filterNodeOf, +}; + /** The node a tab is about, or null once it, or its asset, is gone. */ export function nodeOfTab( project: Project, @@ -29,9 +37,11 @@ export function nodeOfTab( assetId: string, nodeId: string, ): GraphNode | null { - return kind === 'eq' ? eqNodeOf(project, assetId, nodeId) : compressorNodeOf(project, assetId, nodeId); + return NODE_OF[kind](project, assetId, nodeId); } export function nodeTabLabel(project: Project, kind: NodeTabKind, assetId: string, nodeId: string): string { - return kind === 'eq' ? eqTabLabel(project, assetId, nodeId) : compressorTabLabel(project, assetId, nodeId); + return kind === 'compressor' + ? compressorTabLabel(project, assetId, nodeId) + : eqTabLabel(project, assetId, nodeId); } diff --git a/tests/core/chip-presets.test.ts b/tests/core/chip-presets.test.ts index f9d5db75..dd00f766 100644 --- a/tests/core/chip-presets.test.ts +++ b/tests/core/chip-presets.test.ts @@ -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', ]); diff --git a/tests/core/envelope.test.ts b/tests/core/envelope.test.ts index ffec204e..23bc97d2 100644 --- a/tests/core/envelope.test.ts +++ b/tests/core/envelope.test.ts @@ -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 = (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 } }, }); diff --git a/tests/core/filter-editor.test.ts b/tests/core/filter-editor.test.ts new file mode 100644 index 00000000..91cacb54 --- /dev/null +++ b/tests/core/filter-editor.test.ts @@ -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[] } }[]; + }; + 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]); + }); +}); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index fc427de4..a5a3a65e 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -735,6 +735,139 @@ export async function sound(t) { JSON.stringify(eqNodeHeard.spectrum), ); await shot('34-eq-node'); + // #385: the EQ node is resized by its lower right corner, one undo step: small, the row under its + // curve goes, and a little larger it comes back with what fits of it, from the left. + const eqCorner = (await eqPanel()).corner; + await drag(eqCorner.x, eqCorner.y, eqCorner.x - 600, eqCorner.y - 400); + const eqLeast = { + panel: await eqPanel(), + size: await json( + `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-eq').size`, + ), + }; + await press('z', { ctrl: true }); + const eqFull = await eqPanel(); + const zoom = eqFull.rect.w / eqFull.size.w; + await drag(eqFull.corner.x, eqFull.corner.y, eqFull.corner.x - 300 * zoom, eqFull.corner.y - 160 * zoom); + const eqMid = await eqPanel(); + const eqRow = ['Band', 'Type', 'Hz', 'dB', 'Q', 'Slope', 'End']; + const leftPart = (row) => row.every((name, i) => name === eqRow[i]); + check( + 'an EQ node is resized by its corner as one undo step: small, its row goes; larger, the groups that fit come back from the left', + eqLeast.size?.w === 240 && + eqLeast.size.h === 120 && + eqLeast.panel.size.w === 240 && + eqLeast.panel.rows.row.length === 0 && + eqFull.size.w === 720 && + eqFull.rows.row.join() === eqRow.join() && + eqFull.rows.help === false && + eqMid.size.w < 720 && + eqMid.rows.row.length >= 1 && + eqMid.rows.row.length < eqRow.length && + leftPart(eqMid.rows.row), + JSON.stringify({ + eqLeast, + eqFull: { size: eqFull.size, rows: eqFull.rows }, + eqMid: { size: eqMid.size, rows: eqMid.rows }, + }), + ); + await press('z', { ctrl: true }); + // #385: a Filter node is edited in its node as one band of the curve: its handle is dragged for the + // frequency and the gain as one undo step. A double-click on its title opens the same in a tab, + // Delete there leaves the node, and a small window draws what fits of the row and no help. + await mutate(` + const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; + graph.nodes.push(${JSON.stringify(node('fx-filter', 'SoundFxFilter', -240, 600, { filter: 'peaking', cutoff: 600, gain: -8, q: 2 }))}); + graph.edges = graph.edges.filter((e) => e.id !== 'fx-e11'); + graph.edges.push( + ${JSON.stringify(edge('fx-e12', 'fx-eq', 'out', 'fx-filter', 'in'))}, + ${JSON.stringify(edge('fx-e13', 'fx-filter', 'out', 'fx-out', 'in'))}, + );`); + await evaluate('__gm.soundfx.fit()'); + await sleep(100); + const filterPanel = () => json("__gm.eq.panel('fx-filter')"); + const filterFace = await filterPanel(); + const filterRect = filterFace.rect; + const filterTo = await json("__gm.eq.panelPointAt('fx-filter', 2000, 6)"); + await drag(filterFace.handles[0].x, filterFace.handles[0].y, filterTo.x, filterTo.y); + const filterDragged = await filterPanel(); + await press('z', { ctrl: true }); + const filterUndone = await filterPanel(); + check( + 'a Filter node shows its curve with one band between its ports, dragged for frequency and gain as one undo step', + filterFace.shown && + filterFace.size.w === 560 && + filterFace.size.h === 280 && + Object.values(filterFace.inputs).every((p) => p.x < filterRect.x) && + Object.values(filterFace.outputs).every((p) => p.x > filterRect.x + filterRect.w) && + filterFace.rows.row.join() === 'Type,Hz,dB,Q,Slope' && + filterFace.fields.type === 'Peaking' && + bandNear(filterFace.bands[0], 600, -8) && + bandNear(filterDragged.bands[0], 2000, 6) && + bandNear(filterUndone.bands[0], 600, -8) && + filterDragged.bands[0].q === 2, + JSON.stringify({ filterFace, filterDragged: filterDragged.bands, filterUndone: filterUndone.bands }), + ); + const filterTitle = await json("__gm.soundfx.nodeAt('fx-filter')"); + await t.doubleClick(filterTitle.x, filterTitle.y); + const filterTab = { + shown: await json('__gm.filter.shown()'), + rows: await json('__gm.filter.rows()'), + docs: await json('__gm.documents()'), + }; + await press('Delete'); + const filterKept = await json( + `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.some((n) => n.id === 'fx-filter')`, + ); + await evaluate(`__gm.floatDocument(${JSON.stringify(`filter:${fxId}:fx-filter`)})`); + const filterWindow = (await json('__gm.floatWindows()')).find((w) => w.kind === 'filter'); + await drag(filterWindow.grip.x, filterWindow.grip.y, filterWindow.box.x + 470, filterWindow.box.y + 250); + const filterSmall = { rows: await json('__gm.filter.rows()'), labels: await json('__gm.filter.labels()') }; + await shot('34-filter-window'); + const filterRow = ['Type', 'Hz', 'dB', 'Q', 'Slope']; + check( + 'a double-click opens the Filter in a tab with its help, Delete there leaves the node, and a small window draws what fits of the row and no help', + filterTab.shown?.nodeId === 'fx-filter' && + filterTab.docs.active === `filter:${fxId}:fx-filter` && + filterTab.rows.row.join() === filterRow.join() && + filterTab.rows.help === true && + filterKept && + filterSmall.rows.help === false && + filterSmall.rows.row.length >= 1 && + filterSmall.rows.row.length < filterRow.length && + filterSmall.rows.row.every((name, i) => name === filterRow[i]), + JSON.stringify({ filterTab, filterKept, filterSmall }), + ); + // #385: played, the Filter node and its window both draw what passes it, as the EQ does. + await evaluate('__gm.sound.stopAll()'); + const filterSpectra = async () => ({ + node: (await filterPanel()).spectrum, + tab: await json('__gm.filter.spectrum()'), + }); + const filterFrames = await filterSpectra(); + await evaluate('__gm.soundfx.play()'); + const filterHeard = await waitFor( + filterSpectra, + (s) => s.node.frames >= filterFrames.node.frames + 3 && s.tab.frames >= filterFrames.tab.frames + 3, + ); + await evaluate('__gm.sound.stopAll()'); + check( + 'played, the Filter node and its window draw the blip’s 880 Hz behind the curve', + filterHeard.node.frames >= filterFrames.node.frames + 3 && + filterHeard.tab.frames >= filterFrames.tab.frames + 3 && + nearBlip(filterHeard.node.peakIn) && + nearBlip(filterHeard.tab.peakIn) && + filterHeard.tab.taps.listened.split(' ').includes(`${fxId}:fx-filter`), + JSON.stringify({ filterFrames, filterHeard }), + ); + const filterClose = (await json('__gm.floatWindows()')).find((w) => w.kind === 'filter'); + await click(filterClose.close.x, filterClose.close.y); + await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`); + await mutate(` + const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; + graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-filter'); + graph.edges = graph.edges.filter((e) => !['fx-e12', 'fx-e13'].includes(e.id)); + graph.edges.push(${JSON.stringify(edge('fx-e11', 'fx-eq', 'out', 'fx-out', 'in'))});`); await evaluate('__gm.sound.stopAll()'); const eqBefore = await stats(); await click(fxPlayAt.x, fxPlayAt.y);