Edit a Parametric EQ in a tab of its own (#370)
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:
1 parent
db15422372
commit
9ab1bbc507
6 files changed
+150
-129
No files matched your search
+43
-4
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user