Edit a Parametric EQ in a tab of its own (#370)
game-maker / gate (push) Successful in 4m21s
game-maker / suite (push) Successful in 15m17s

The EQ's editor floated over the editor as a window since #367. The
user wants it as a tab for now. Whether any tab can float and dock
again is weighed apart from this.

- A double-click on the node asks for the tab through
  `EditorState.openEq`, as `openValues` does for a Variables tab.
- The tab is a document `eq:<asset>:<node>`, one per EQ node. A
  second double-click brings the open one up.
- Its name is the asset's, with `EQ 2`, `EQ 3` when the asset has
  more than one. The icon is the sound FX's.
- The tab closes with its node, its asset, or a new project, through
  the same `syncDocuments` as every document.
- `EqWindow` becomes `EqEditor`, a `VStack` with a `TabHeader`. The
  curve takes the room of the tab, 320 x 160 at least.
- The window's title bar, dragging and Close button are gone.
- Delete in the EQ's tab removes the picked band. It never falls
  through to the selection, which is still the EQ's node.
- The hint below the curve is shorter, it was cut off at 1280 px.

Tests:
- e2e 34 (melektaus-atx): ALL PASSED, 96 checks, 2 rewritten. The
  tab opens on a double-click and goes with its node.
- Gate (melektaus-atx): CHECK EXIT 0, 274 files, 4231 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 13:45:02 +02:00
1 parent db15422372
commit 9ab1bbc507
6 files changed
+150 -129

No files matched your search

+43 -4
View File
@@ -95,6 +95,7 @@ import { PluginsPane } from './plugins/PluginsPane';
import { ManagementPane } from './users/ManagementPane';
import { UsersPane } from './users/UsersPane';
import { RoutingEditor } from './routing/RoutingEditor';
import { EqEditor, eqNodeOf, eqTabLabel } from './soundfx/EqEditor';
export type CentreTab =
| 'scene'
@@ -117,6 +118,7 @@ export type CentreTab =
| 'container'
| 'variables'
| 'routing'
| 'eq'
| 'management';
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
@@ -126,9 +128,9 @@ 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.
* (#351) are no documents, but they open and close like one. A Parametric EQ has a tab per node (#370).
*/
export type DocumentId = `asset:${string}` | `values:${string}` | 'routing' | 'management';
export type DocumentId = `asset:${string}` | `values:${string}` | `eq:${string}` | 'routing' | 'management';
/** The account behind this session; the menu for it sits at the far right of the toolbar. */
export interface EditorAccount {
/** Who is signed in – the menu says so, since the toolbar has no room for a second label. */
@@ -146,7 +148,7 @@ export type StripTab = FixedTab | PluginTabId | DocumentId;
type FixedTab = (typeof FIXED_TABS)[number];
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */
type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'routing' | 'management'>;
type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'routing' | 'eq' | 'management'>;
const FIXED_TABS = ['scene', 'game'] as const;
@@ -171,6 +173,7 @@ const PANE_ORDER = [
'container',
'variables',
'routing',
'eq',
'management',
] as const;
@@ -196,6 +199,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
container: 'Container',
variables: 'Variables',
routing: 'Master routing',
eq: 'Parametric EQ',
management: 'User management',
};
@@ -206,7 +210,13 @@ const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
};
function isDocument(tab: StripTab): tab is DocumentId {
return tab === 'management' || tab === 'routing' || tab.startsWith('asset:') || tab.startsWith('values:');
return (
tab === 'management' ||
tab === 'routing' ||
tab.startsWith('asset:') ||
tab.startsWith('values:') ||
tab.startsWith('eq:')
);
}
const MIN_LEFT_WIDTH = 160;
@@ -250,8 +260,11 @@ export class Editor implements PlayHost {
readonly containerEditor: ContainerEditor;
readonly variablesEditor: VariablesEditor;
readonly routingEditor: RoutingEditor;
readonly eqEditor: EqEditor;
/** What each open Variables tab shows, by its document. */
private readonly valueTargets = new Map<DocumentId, ValuesTarget>();
/** The node behind each EQ tab (#370). */
private readonly eqTargets = new Map<DocumentId, { assetId: string; nodeId: string }>();
/** The switch per file in `data/plugins/`, in the user management tab (#179). */
readonly pluginsPane = new PluginsPane();
/** The accounts, above the plugins; null unless an administrator is signed in. */
@@ -341,6 +354,7 @@ export class Editor implements PlayHost {
this.containerEditor = new ContainerEditor(state);
this.variablesEditor = new VariablesEditor(state);
this.routingEditor = new RoutingEditor(state);
this.eqEditor = new EqEditor(state);
this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state);
this.tilemapPanel = new TilemapPanel(state);
@@ -463,6 +477,7 @@ export class Editor implements PlayHost {
...this.assetEditors,
variables: this.variablesEditor,
routing: this.routingEditor,
eq: this.eqEditor,
management: this.managementPane,
};
// Following a call goes on in the same tab: the tab changes its document, not its place.
@@ -562,6 +577,14 @@ export class Editor implements PlayHost {
if (!this.valueTargets.has(doc)) this.valueTargets.set(doc, target);
this.openDocument(doc);
});
// A tab per Parametric EQ node (#370), opened by a double-click on it.
state.on('eq', () => {
const target = state.openedEq;
if (!target) return;
const doc: DocumentId = `eq:${target.assetId}:${target.nodeId}`;
if (!this.eqTargets.has(doc)) this.eqTargets.set(doc, target);
this.openDocument(doc);
});
// The tabs play through the buses and the master as the game would (#325).
const syncMix = () => void soundLibrary.setMix(mixOf(state.project));
syncMix();
@@ -577,6 +600,8 @@ export class Editor implements PlayHost {
for (const doc of this.documents.docs) this.tabs.removeTab(doc);
this.documents = { docs: [], active: null };
this.valueTargets.clear();
this.eqTargets.clear();
this.eqEditor.close();
this.showTab('scene');
});
state.on('mode', () => this.editorRun.modeChanged());
@@ -631,6 +656,10 @@ export class Editor implements PlayHost {
return this.patternEditor.canvas.deleteSelection();
case 'song':
return this.songEditor.canvas.deleteSelection();
case 'eq':
// The selection is the EQ's node, still selected from the double-click: it is never what goes.
this.eqEditor.removePicked();
return true;
default:
return false;
}
@@ -802,6 +831,7 @@ export class Editor implements PlayHost {
if (kind && kind in this.assetEditors)
this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
this.valueTargets.delete(doc);
if (this.eqTargets.delete(doc) && kind === 'eq') this.eqEditor.close();
if (doc === 'routing') this.routingEditor.close();
if (!wasShowing) return;
if (this.documents.active) this.showDocument(this.documents.active);
@@ -869,6 +899,10 @@ export class Editor implements PlayHost {
return target && valuesTitle(this.state, target) !== null ? 'variables' : null;
}
if (doc === 'management' || doc === 'routing') return doc;
if (doc.startsWith('eq:')) {
const target = this.eqTargets.get(doc);
return target && eqNodeOf(this.state.project, target.assetId, target.nodeId) ? 'eq' : null;
}
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
const kind = type ? TAB_OF_ASSET[type] : undefined;
return kind && kind !== 'scene' ? kind : null;
@@ -883,6 +917,8 @@ export class Editor implements PlayHost {
// Read on every showing: an account may have been changed from another tab in the meantime.
if (kind === 'management') void this.usersPane?.reload();
if (kind === 'routing') this.routingEditor.open();
const eq = this.eqTargets.get(doc);
if (kind === 'eq' && eq) this.eqEditor.show(eq.assetId, eq.nodeId);
this.display(kind, doc);
}
@@ -890,6 +926,8 @@ export class Editor implements PlayHost {
private labelOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
if (doc === 'routing') return TAB_LABELS.routing;
const eq = this.eqTargets.get(doc);
if (eq) return eqTabLabel(this.state.project, eq.assetId, eq.nodeId);
const target = this.valueTargets.get(doc);
if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? '';
const asset = findAsset(this.state.project, doc.slice('asset:'.length));
@@ -900,6 +938,7 @@ export class Editor implements PlayHost {
private iconOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG;
if (doc === 'routing') return ASSET_ICON_SVG.bus;
if (doc.startsWith('eq:')) return ASSET_ICON_SVG.soundfx;
const target = this.valueTargets.get(doc);
if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables;
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
+15 -25
View File
@@ -1,6 +1,5 @@
import { NameTransitionCommand } from '../../core/commands/graphCommands';
import { currentNodePalette } from '../graph/NodePalette';
import { currentEqEditor } from '../soundfx/EqEditor';
import { rectOf } from '../textProbe';
import { centreOf, snapButton, type HookContext } from './hookHelpers';
@@ -117,33 +116,24 @@ export function graphHooks({ state, editor }: HookContext) {
setParam: (key: string, value: number) => editor.soundFxEditor.setPreviewValue(key, value),
nodeAt: (nodeId: string) => editor.soundFxEditor.canvas.nodeGlobal(nodeId),
},
/** The Parametric EQ's window (#367): for which node it is open, its bands and where to aim. */
/** The Parametric EQ's tab (#367, #370): for which node it is showing, its bands and where to aim. */
eq: {
shown: () => currentEqEditor()?.shownFor ?? null,
bands: () => currentEqEditor()?.bands() ?? null,
picked: () => currentEqEditor()?.picked ?? null,
handleAt: (index: number) => currentEqEditor()?.curve.handleGlobal(index) ?? null,
pointAt: (hz: number, db: number) => currentEqEditor()?.curve.pointGlobal(hz, db) ?? null,
shown: () => (editor.tab === 'eq' ? editor.eqEditor.shownFor : null),
bands: () => editor.eqEditor.bands(),
picked: () => editor.eqEditor.picked,
handleAt: (index: number) => editor.eqEditor.curve.handleGlobal(index),
pointAt: (hz: number, db: number) => editor.eqEditor.curve.pointGlobal(hz, db),
fields: () => {
const eq = currentEqEditor();
return (
eq && {
type: eq.typeButton.text,
slope: eq.slopeButton.enabled ? eq.slopeButton.text : null,
gain: eq.gainField.enabled ? eq.gainField.value : null,
q: eq.qField.enabled ? eq.qField.value : null,
frequency: eq.frequencyField.value,
}
);
},
typeAt: () => {
const eq = currentEqEditor();
return eq ? centreOf(eq.typeButton) : null;
},
closeAt: () => {
const eq = currentEqEditor();
return eq ? centreOf(eq.closeButton) : null;
const eq = editor.eqEditor;
return {
type: eq.typeButton.text,
slope: eq.slopeButton.enabled ? eq.slopeButton.text : null,
gain: eq.gainField.enabled ? eq.gainField.value : null,
q: eq.qField.enabled ? eq.qField.value : null,
frequency: eq.frequencyField.value,
};
},
typeAt: () => centreOf(editor.eqEditor.typeButton),
},
instrument: {
current: () => editor.instrumentEditor.currentAssetId,
+1 -2
View File
@@ -31,7 +31,6 @@ import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { soundFxBuildFor } from '../../core/soundfx/compile';
import { SoundFxParametricEq } from '../../core/soundfx/nodes';
import { openEqEditor } from '../soundfx/EqEditor';
import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile';
import { masterRoutingProblems } from '../../core/music/projectRouting';
import { routingProblems } from '../../core/music/routing';
@@ -672,7 +671,7 @@ export class GraphCanvas extends Widget {
private openCalledBehavior(node: GraphNode): boolean {
if (node.type === SoundFxParametricEq.type) {
openEqEditor(this.state, this.assetId, node.id);
this.state.openEq(this.assetId, node.id);
return true;
}
// A node of the master routing (#351) stands for its asset; a bus has no tab, its node shows it already.
+67 -89
View File
@@ -1,6 +1,5 @@
/**
* The editor of a Parametric EQ node (#367): a window that floats over the editor and leaves it
* usable, so the tab's Play can be pressed while the bands are set. The curve is the sum of the
* The editor of a Parametric EQ node (#367), in a tab of its own (#370). 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.
@@ -8,7 +7,7 @@
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 } from '../../core/model/Project';
import { findGraphAsset, type Project } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import {
type EqBand,
@@ -29,6 +28,7 @@ import {
newEqBand,
withBandSet,
} from '../../core/soundfx/parametricEq';
import { SoundFxParametricEq } from '../../core/soundfx/nodes';
import {
Button,
crispText,
@@ -37,16 +37,16 @@ import {
Label,
Menu,
NumberField,
Popup,
pressCount,
Spacer,
Theme,
textStyle,
ui,
VStack,
Widget,
} from '../../ui';
import { FramePreview } from '../framePreview';
import type { EditorState } from '../state';
import { TabHeader } from '../TabHeader';
import {
bandAt,
dbToY,
@@ -61,8 +61,9 @@ import {
yToDb,
} from './eqView';
const CURVE_WIDTH = 640;
const CURVE_HEIGHT = 260;
/** 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. */
@@ -73,7 +74,7 @@ const BAND_COLORS = [
0xf2c84b, 0x6fcf97, 0x56ccf2, 0xe57373, 0xb066ff, 0xf2a75c, 0xff9ad5, 0x9b8cff, 0x3fb8c9, 0xd4c25a,
];
/** What the curve asks of the window around it. */
/** What the curve asks of the tab around it. */
interface EqHost {
bands(): EqBand[];
picked: number;
@@ -94,8 +95,8 @@ class EqCurve extends Widget {
constructor(private readonly host: EqHost) {
super();
this.minWidth = CURVE_WIDTH;
this.minHeight = CURVE_HEIGHT;
this.minWidth = MIN_CURVE_WIDTH;
this.minHeight = MIN_CURVE_HEIGHT;
this.cursor = 'crosshair';
this.addChild(this.bg, this.grid, this.lines, this.handles);
const muted = textStyle('small').clone();
@@ -271,10 +272,10 @@ class EqCurve extends Widget {
}
/**
* One window for the node last double-clicked. It closes with its node, with the project, or by its
* own button.
* The tab of a Parametric EQ node (#370): one editor, pointed at the node whose tab is showing. It
* follows its node; once the node is gone the editor tells the tab row through `onGone`.
*/
export class EqWindow extends Popup implements EqHost {
export class EqEditor extends VStack implements EqHost {
readonly curve: EqCurve;
private assetId = '';
private nodeId = '';
@@ -283,7 +284,8 @@ export class EqWindow extends Popup implements EqHost {
private before: PropertyValue | undefined | null = null;
private readonly title = new Label('Parametric EQ', { style: 'bodyBold' });
private readonly count = new Label('', { style: 'muted' });
private readonly header = new HStack({ gap: Theme.gap, align: 'center' });
private readonly header = new TabHeader();
private readonly body = new VStack({ gap: Theme.padding, padding: Theme.padding * 2 });
private readonly bandLabel = new Label('', { style: 'small' });
readonly typeButton: Dropdown;
readonly slopeButton: Dropdown;
@@ -291,37 +293,17 @@ export class EqWindow extends Popup implements EqHost {
readonly gainField: NumberField;
readonly qField: NumberField;
readonly removeButton: Button;
readonly closeButton: Button;
private readonly unsubscribe: (() => void)[] = [];
private readonly perFrame: FramePreview;
/** The texts the window was last laid out for; a drag changes only numbers and needs no layout. */
/** The texts the tab was last laid out for; a drag changes only numbers and needs no layout. */
private laidOut = '';
private grab: { x: number; y: number } | null = null;
constructor(private readonly state: EditorState) {
super({ backdrop: false, padding: Theme.padding * 2, gap: Theme.padding });
super();
this.perFrame = new FramePreview(state, 'eq-band');
this.curve = new EqCurve(this);
this.closeButton = new Button('Close', { variant: 'ghost', onClick: () => this.close() });
const header = this.header;
header.add(this.title);
header.add(this.count);
header.add(new Spacer(), 1);
header.add(this.closeButton);
header.cursor = 'move';
header.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.target !== header) return;
this.grab = { x: e.global.x - this.x, y: e.global.y - this.y };
});
this.on('globalpointermove', (e: FederatedPointerEvent) => {
if (!this.grab) return;
const screen = ui().app.screen;
this.position.set(
Math.round(Math.max(0, Math.min(screen.width - this.w, e.global.x - this.grab.x))),
Math.round(Math.max(0, Math.min(screen.height - this.h, e.global.y - this.grab.y))),
);
});
this.onPointerRelease(() => (this.grab = null));
this.header.add(this.title);
this.header.add(this.count);
this.header.add(new Spacer(), 1);
const numbers = (change: (value: number) => Partial<EqBand>) => ({
onInput: (value: number) => this.previewPicked(change(value)),
@@ -369,48 +351,47 @@ export class EqWindow extends Popup implements EqHost {
row.add(new Spacer(), 1);
row.add(this.removeButton);
this.content.add(header);
this.content.add(this.curve);
this.content.add(row);
this.content.add(
new Label(
`Double-click to add a band (up to ${MAX_EQ_BANDS}). Drag a band for its frequency and gain, turn the mouse wheel for its Q, right-click it to remove it.`,
{ style: 'muted' },
),
this.body.add(this.curve, 1);
this.body.add(row);
this.body.add(
new Label('Double-click: add a band. Drag: frequency and gain. Wheel: Q. Right-click: remove.', {
style: 'muted',
}),
);
this.add(this.header);
this.add(this.body, 1);
state.on('project', () => this.sync());
}
/** Opens for a node, or turns an open window to it. */
/** Points the tab at a node; the band picked stays when it is the same node again. */
show(assetId: string, nodeId: string): void {
const other = assetId !== this.assetId || nodeId !== this.nodeId;
this.assetId = assetId;
this.nodeId = nodeId;
if (other) this.picked = this.bands().length > 0 ? 0 : -1;
if (!this.isOpen) {
this.unsubscribe.push(
this.state.on('project', () => this.sync()),
this.state.on('load', () => this.close()),
);
this.openCentered();
if (assetId !== this.assetId || nodeId !== this.nodeId) {
this.perFrame.cancel();
this.before = null;
this.assetId = assetId;
this.nodeId = nodeId;
this.picked = this.bands().length > 0 ? 0 : -1;
}
this.sync();
}
get shownFor(): { assetId: string; nodeId: string } | null {
return this.isOpen ? { assetId: this.assetId, nodeId: this.nodeId } : null;
/** The tab is closed: the editor lets go of its node. */
close(): void {
this.perFrame.cancel();
const node = this.node();
if (node) this.restore(node);
this.assetId = '';
this.nodeId = '';
this.picked = -1;
this.laidOut = '';
}
override close(): void {
for (const off of this.unsubscribe.splice(0)) off();
this.perFrame.cancel();
this.laidOut = '';
super.close();
get shownFor(): { assetId: string; nodeId: string } | null {
return this.nodeId ? { assetId: this.assetId, nodeId: this.nodeId } : null;
}
private node(): GraphNode | null {
return (
findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find((n) => n.id === this.nodeId) ?? null
);
return eqNodeOf(this.state.project, this.assetId, this.nodeId);
}
bands(): EqBand[] {
@@ -471,7 +452,8 @@ export class EqWindow extends Popup implements EqHost {
if (this.picked >= 0) this.commit(withBandSet(this.bands(), this.picked, change));
}
private removePicked(): void {
/** 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;
@@ -500,19 +482,13 @@ export class EqWindow extends Popup implements EqHost {
new Menu(items).openBelow(this.slopeButton);
}
/** Follows the node: gone, the window goes with it. */
private sync(): void {
if (!this.isOpen) return;
const node = this.node();
if (!node) {
this.close();
return;
}
if (!node) return;
const bands = eqBandsOf(node.params);
if (this.picked >= bands.length) this.picked = -1;
const band = bands[this.picked];
const name = findGraphAsset(this.state.project, this.assetId)?.name ?? '';
const title = `Parametric EQ – ${name}`;
const title = `Parametric EQ – ${findGraphAsset(this.state.project, this.assetId)?.name ?? ''}`;
const count = `${bands.length} / ${MAX_EQ_BANDS} bands`;
const bandLabel = band ? `Band ${this.picked + 1}` : 'No band';
this.title.setText(title);
@@ -532,23 +508,25 @@ export class EqWindow extends Popup implements EqHost {
const texts = [title, count, bandLabel].join('\n');
if (texts !== this.laidOut) {
this.laidOut = texts;
this.header.relayout();
this.content.relayout();
this.relayout();
}
this.curve.invalidate();
}
}
let eqWindow: EqWindow | null = null;
/** Opens the editor of a Parametric EQ node; one window, turned to whichever node asked last. */
export function openEqEditor(state: EditorState, assetId: string, nodeId: string): EqWindow {
eqWindow ??= new EqWindow(state);
eqWindow.show(assetId, nodeId);
return eqWindow;
/** The Parametric EQ node a tab is about, or null once it, or its asset, is gone. */
export function eqNodeOf(project: Project, assetId: string, nodeId: string): GraphNode | null {
const node = findGraphAsset(project, assetId)?.graph.nodes.find((n) => n.id === nodeId);
return node?.type === SoundFxParametricEq.type ? node : null;
}
/** The open window, for the check scripts. */
export function currentEqEditor(): EqWindow | null {
return eqWindow?.isOpen ? eqWindow : null;
/**
* What an EQ's tab says: its asset's name, and which EQ it is when the asset has more than one.
*/
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`;
}
+11
View File
@@ -226,6 +226,8 @@ export type EditorEvent =
| 'selection'
/** A Variables tab was asked for (`openedValues`). */
| 'values'
/** A Parametric EQ's tab was asked for (`openedEq`). */
| 'eq'
/** An asset was opened (double-click, Enter, or just created): its tab should show. */
| 'open'
/** An asset should light up where it is listed (`pingedAssetId`), without being selected. */
@@ -254,6 +256,8 @@ export class EditorState {
selection: Selection = null;
/** The last target asked for by `openValues`; the tabs themselves are the editor's. */
openedValues: ValuesTarget | null = null;
/** The last Parametric EQ node asked for by `openEq`. */
openedEq: { assetId: string; nodeId: string } | null = null;
mode: EditorMode = 'edit';
/**
* A sub-state of play rather than a third mode: the runtime copy, the keyboard and unsaved edits all
@@ -317,6 +321,7 @@ export class EditorState {
this.commands = new CommandStack(project);
this.selection = null;
this.openedValues = null;
this.openedEq = null;
this.clipboard = null;
this.patternPlaces.clear();
this.saveStatus = 'saved';
@@ -485,6 +490,12 @@ export class EditorState {
this.emit('values');
}
/** Asks for the tab of a Parametric EQ node (#370), the way `openValues` asks for a Variables tab. */
openEq(assetId: string, nodeId: string): void {
this.openedEq = { assetId, nodeId };
this.emit('eq');
}
/**
* Selects the asset and asks for its tab. Selecting alone never switches tabs (a click in the
* Assets panel is a look at the inspector); this is the way a double-click or a fresh asset gets
+13 -9
View File
@@ -523,6 +523,7 @@ export async function sound(t) {
// #367: a Parametric EQ behind the gain. A double-click on the node opens its editor, one on the
// curve adds a peak there, a drag moves it as one undo step, the wheel narrows it, and the
// voice passes one filter for it.
// #370: the editor is a tab of its own, and goes with its node.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-eq', 'SoundFxParametricEq', -240, 0))});
@@ -534,20 +535,23 @@ export async function sound(t) {
const eqNode = await json("__gm.soundfx.nodeAt('fx-eq')");
await t.doubleClick(eqNode.x, eqNode.y);
const eqShown = await json('__gm.eq.shown()');
const eqDocs = await json('__gm.documents()');
const eqPeakAt = await json('__gm.eq.pointAt(1000, 6)');
await t.doubleClick(eqPeakAt.x, eqPeakAt.y);
const eqAdded = await json('{ bands: __gm.eq.bands(), fields: __gm.eq.fields() }');
const bandNear = (band, hz, db) =>
band && Math.abs(Math.log2(band.frequency / hz)) < 0.05 && Math.abs(band.gain - db) < 0.5;
check(
'a double-click opens the Parametric EQ’s editor, and one on its curve adds a peak there',
'a double-click opens the Parametric EQ in a tab, and one on its curve adds a peak there',
eqShown?.nodeId === 'fx-eq' &&
eqDocs.active === `eq:${fxId}:fx-eq` &&
eqDocs.docs.includes(`asset:${fxId}`) &&
eqAdded.bands.length === 1 &&
eqAdded.bands[0].type === 'peaking' &&
bandNear(eqAdded.bands[0], 1000, 6) &&
eqAdded.fields.type === 'Peaking' &&
eqAdded.fields.slope === null,
JSON.stringify({ eqShown, eqAdded }),
JSON.stringify({ eqShown, eqDocs, eqAdded }),
);
const eqHandle = await json('__gm.eq.handleAt(0)');
const eqTo = await json('__gm.eq.pointAt(4000, -6)');
@@ -568,23 +572,23 @@ export async function sound(t) {
JSON.stringify({ eqDragged, eqUndone, eqWheeled }),
);
await shot('34-parametric-eq');
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
await evaluate('__gm.sound.stopAll()');
const eqBefore = await stats();
await click(fxPlayAt.x, fxPlayAt.y);
const eqHeard = await waitFor(stats, (s) => s.started > eqBefore.started);
await evaluate('__gm.sound.stopAll()');
const eqClose = await json('__gm.eq.closeAt()');
await click(eqClose.x, eqClose.y);
check(
'the voice passes the gain and the band, and the editor closes by its button',
eqHeard.last?.stages === 2 && (await json('__gm.eq.shown()')) === null,
JSON.stringify(eqHeard.last),
);
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-eq');
graph.edges = graph.edges.filter((e) => !['fx-e10', 'fx-e11'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
const eqDocsAfter = await json('__gm.documents()');
check(
'the voice passes the gain and the band, and the EQ’s tab goes with its node',
eqHeard.last?.stages === 2 && !eqDocsAfter.docs.some((doc) => doc.startsWith('eq:')),
JSON.stringify({ last: eqHeard.last, eqDocsAfter }),
);
await showTab('scene');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;