Show a Compressor's panel in its node, its tab on a double-click (#380)
The panel of #379 opened in a window of its own and replaced the tab. The user wanted the device in the graph instead, and the tab of #378 back on a double-click. The switch Hardware look goes again. - A node can show a face instead of its rows (`NodeFace`). Its inputs sit on the left of it and its outputs on the right, in the middle of the height. Each side is as wide as its longest port name. - A Compressor node shows the panel at full size, 820x280 graph units. Its settings and numbers are not listed as rows, the panel has them. - The knobs and the lever work in the node. A drag, a notch of the wheel and a double-click are one undo step each. A press on them neither picks nor moves the node, and the wheel does not zoom the graph. The number below a knob is only shown, typing stays in the tab and the inspector. - The meter and the LEDs run while the node is in the picture of a sound FX or instrument graph that is seen. Out of the picture, folded or hidden, the node lets go of its taps. - The window of one size, `fixedSize`, `refit`, `keptSize` and the rule that kept a Compressor out of the tab row are gone again. - A knob's drag as one undo step lives in `nodeParamDrag.ts` now, for the tab and the node. The numbers of a node and what auto-gain says moved to `compressorView.ts`. Found and fixed on the way: - The tab and the node listen to the same taps. The tab let go of them on every frame it was not seen, so the node's meter stood still while the tab was open. Now the tab lets go once, and in a frame everything unseen lets go before anything seen listens. Tests: - Unit: compressor-view (2 new), documents (the test of `keptSize` is gone with it) - e2e 34 (stein2, melektaus-atx was off): ALL PASSED, 122 checks. The four checks of #379 are now four of the node: ports beside the panel, knobs and lever in the node, the needle when played (21.7 dB with the tab open), and the double-click opening the tab. - Gate (stein2): CHECK EXIT 0, 283 files, 4356 tests - Counter-checks: an old auto-gain number read as off turns exactly its unit test red. A tab that lets go on every frame leaves the node's needle at 0 dB and no LED lit (probe on stein2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9917253751
commit
1f2b64fb4c
18 files changed
+490
-285
No files matched your search
@@ -1021,14 +1021,6 @@ export function switchOn(settings: PropertyBag, row: { key: string; default: boo
|
|||||||
return value === undefined ? row.default : booleanOf(value);
|
return value === undefined ? row.default : booleanOf(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a Compressor's editor is the hardware panel (#379) or the tab of #378. Only its editor reads it. */
|
|
||||||
export const COMPRESS_HARDWARE_LOOK = {
|
|
||||||
key: 'hardwareLook',
|
|
||||||
label: 'Hardware look',
|
|
||||||
kind: 'boolean' as const,
|
|
||||||
default: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The numbers of a Compressor, in the order its tab shows them as knobs (#378). */
|
/** The numbers of a Compressor, in the order its tab shows them as knobs (#378). */
|
||||||
export const COMPRESS_ROWS = {
|
export const COMPRESS_ROWS = {
|
||||||
threshold: COMPRESS_THRESHOLD,
|
threshold: COMPRESS_THRESHOLD,
|
||||||
@@ -1046,11 +1038,11 @@ export const SoundFxCompressor: SoundFxNodeDef = effect(
|
|||||||
category: 'sound',
|
category: 'sound',
|
||||||
tapped: true,
|
tapped: true,
|
||||||
description:
|
description:
|
||||||
'Squeezes the loudness of the sound together: what goes above "threshold" (dB) comes out only 1/"ratio" as much louder, so 4 makes 8 dB over it 2 dB. "knee" (dB) eases into that from the threshold on instead of at once. "attack" is how fast it turns down once the sound is over the threshold, "release" how fast it lets go, both in seconds. "makeup" (dB) raises it again afterwards. "Auto-gain" on gives back what it takes from a sound at -18 dB, so that one comes out as loud as it went in; the makeup is added on top. It is 6 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays. Double-click the node to open its editor: with "Hardware look" on, a panel of a fixed size in a window, with a meter of what it takes away; off, a tab with a knob for each number, its curve with threshold and ratio to drag, and the level coming in and going out over the last seconds.' +
|
'Squeezes the loudness of the sound together: what goes above "threshold" (dB) comes out only 1/"ratio" as much louder, so 4 makes 8 dB over it 2 dB. "knee" (dB) eases into that from the threshold on instead of at once. "attack" is how fast it turns down once the sound is over the threshold, "release" how fast it lets go, both in seconds. "makeup" (dB) raises it again afterwards. "Auto-gain" on gives back what it takes from a sound at -18 dB, so that one comes out as loud as it went in; the makeup is added on top. It is 6 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays. The node is drawn as a device: a knob for each number, a meter of what it takes away and LEDs for the level going out. Double-click it to open its tab: its curve with threshold and ratio to drag, and the level coming in and going out over the last seconds.' +
|
||||||
BLENDS,
|
BLENDS,
|
||||||
inputs: [audioIn, ...Object.values(COMPRESS_ROWS).map(numberIn)],
|
inputs: [audioIn, ...Object.values(COMPRESS_ROWS).map(numberIn)],
|
||||||
outputs: [audioOut],
|
outputs: [audioOut],
|
||||||
params: [COMPRESS_AUTO_GAIN, COMPRESS_HARDWARE_LOOK],
|
params: [COMPRESS_AUTO_GAIN],
|
||||||
emit: (sounds, amounts, settings) =>
|
emit: (sounds, amounts, settings) =>
|
||||||
withStages(arriving(sounds), [
|
withStages(arriving(sounds), [
|
||||||
{
|
{
|
||||||
|
|||||||
+18
-26
@@ -98,8 +98,7 @@ import { PluginsPane } from './plugins/PluginsPane';
|
|||||||
import { ManagementPane } from './users/ManagementPane';
|
import { ManagementPane } from './users/ManagementPane';
|
||||||
import { UsersPane } from './users/UsersPane';
|
import { UsersPane } from './users/UsersPane';
|
||||||
import { RoutingEditor } from './routing/RoutingEditor';
|
import { RoutingEditor } from './routing/RoutingEditor';
|
||||||
import { CompressorEditor, compressorNodeOf } from './soundfx/CompressorEditor';
|
import { CompressorEditor } from './soundfx/CompressorEditor';
|
||||||
import { COMPRESS_HARDWARE_LOOK, switchOn } from '../core/soundfx/nodes';
|
|
||||||
import { EqEditor } from './soundfx/EqEditor';
|
import { EqEditor } from './soundfx/EqEditor';
|
||||||
import { NODE_TAB_KINDS, nodeOfTab, nodeTabLabel, type NodeTabKind, nodeTabOf } from './soundfx/nodeTabs';
|
import { NODE_TAB_KINDS, nodeOfTab, nodeTabLabel, type NodeTabKind, nodeTabOf } from './soundfx/nodeTabs';
|
||||||
|
|
||||||
@@ -334,6 +333,8 @@ export class Editor implements PlayHost {
|
|||||||
/** Drawn-frame count at the last measurement, not a counter of its own. */
|
/** Drawn-frame count at the last measurement, not a counter of its own. */
|
||||||
private fpsFrames = 0;
|
private fpsFrames = 0;
|
||||||
private fpsElapsed = 0;
|
private fpsElapsed = 0;
|
||||||
|
/** Whether a node's face (#380) moved in the last frame: a meter falling back still needs drawing. */
|
||||||
|
private facesMoving = false;
|
||||||
/** What this editor registered outside itself, as the calls that take it away again (#177). */
|
/** What this editor registered outside itself, as the calls that take it away again (#177). */
|
||||||
private readonly cleanups: (() => void)[] = [];
|
private readonly cleanups: (() => void)[] = [];
|
||||||
private readonly autosave: { flush: () => Promise<void>; stop: () => void };
|
private readonly autosave: { flush: () => Promise<void>; stop: () => void };
|
||||||
@@ -867,10 +868,6 @@ export class Editor implements PlayHost {
|
|||||||
this.showInWindow(kind, doc);
|
this.showInWindow(kind, doc);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.windowOnly(doc)) {
|
|
||||||
this.floatDocument(doc);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.documents.active === doc) {
|
if (this.documents.active === doc) {
|
||||||
// Still worth a look at the name: a Variables tab's owner may have been renamed.
|
// Still worth a look at the name: a Variables tab's owner may have been renamed.
|
||||||
this.syncStrip();
|
this.syncStrip();
|
||||||
@@ -964,7 +961,7 @@ export class Editor implements PlayHost {
|
|||||||
/** Puts a window's document back into the tab row, showing, and its pane back below the row. */
|
/** Puts a window's document back into the tab row, showing, and its pane back below the row. */
|
||||||
dockWindow(kind: CentreTab): void {
|
dockWindow(kind: CentreTab): void {
|
||||||
const float = this.floats.get(kind);
|
const float = this.floats.get(kind);
|
||||||
if (!float || this.windowOnly(float.doc)) return;
|
if (!float) return;
|
||||||
this.takeBack(kind);
|
this.takeBack(kind);
|
||||||
this.documents = openDocument(this.documents, float.doc);
|
this.documents = openDocument(this.documents, float.doc);
|
||||||
this.syncStrip();
|
this.syncStrip();
|
||||||
@@ -1007,7 +1004,6 @@ export class Editor implements PlayHost {
|
|||||||
this.forget(old);
|
this.forget(old);
|
||||||
}
|
}
|
||||||
this.pointPane(kind, doc);
|
this.pointPane(kind, doc);
|
||||||
float.window.refit();
|
|
||||||
float.window.setTitle(this.labelOf(doc), this.iconOf(doc));
|
float.window.setTitle(this.labelOf(doc), this.iconOf(doc));
|
||||||
float.window.toFront();
|
float.window.toFront();
|
||||||
this.pressedWindow = kind;
|
this.pressedWindow = kind;
|
||||||
@@ -1024,7 +1020,6 @@ export class Editor implements PlayHost {
|
|||||||
dock: { x: number; y: number };
|
dock: { x: number; y: number };
|
||||||
close: { x: number; y: number };
|
close: { x: number; y: number };
|
||||||
front: boolean;
|
front: boolean;
|
||||||
fixed: boolean;
|
|
||||||
}[] {
|
}[] {
|
||||||
const centre = (w: Widget) => {
|
const centre = (w: Widget) => {
|
||||||
const at = w.getGlobalPosition();
|
const at = w.getGlobalPosition();
|
||||||
@@ -1040,7 +1035,6 @@ export class Editor implements PlayHost {
|
|||||||
dock: centre(window.dockButton),
|
dock: centre(window.dockButton),
|
||||||
close: centre(window.closeButton),
|
close: centre(window.closeButton),
|
||||||
front: window.parent?.children.at(-1) === window,
|
front: window.parent?.children.at(-1) === window,
|
||||||
fixed: window.fixedSize !== null,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1198,25 +1192,12 @@ export class Editor implements PlayHost {
|
|||||||
/** After an edit: a deleted asset takes its tab along, a renamed one renames it. */
|
/** After an edit: a deleted asset takes its tab along, a renamed one renames it. */
|
||||||
private syncDocuments(): void {
|
private syncDocuments(): void {
|
||||||
for (const doc of this.documents.docs) if (!this.kindOf(doc)) this.closeDocument(doc);
|
for (const doc of this.documents.docs) if (!this.kindOf(doc)) this.closeDocument(doc);
|
||||||
// Switched to its hardware look, a Compressor leaves the tab row for a window (#379).
|
|
||||||
for (const doc of [...this.documents.docs]) if (this.windowOnly(doc)) this.floatDocument(doc);
|
|
||||||
for (const [kind, { doc, window }] of [...this.floats])
|
for (const [kind, { doc, window }] of [...this.floats])
|
||||||
if (!this.kindOf(doc)) this.closeWindow(kind);
|
if (!this.kindOf(doc)) this.closeWindow(kind);
|
||||||
else {
|
else window.setTitle(this.labelOf(doc), this.iconOf(doc));
|
||||||
window.refit();
|
|
||||||
window.setTitle(this.labelOf(doc), this.iconOf(doc));
|
|
||||||
}
|
|
||||||
this.syncStrip();
|
this.syncStrip();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a document has no place in the tab row: a Compressor in its hardware look (#379). */
|
|
||||||
private windowOnly(doc: DocumentId): boolean {
|
|
||||||
const target = this.nodeTargets.get(doc);
|
|
||||||
if (nodeTabOfDocument(doc) !== 'compressor' || !target) return false;
|
|
||||||
const node = compressorNodeOf(this.state.project, target.assetId, target.nodeId);
|
|
||||||
return node !== null && switchOn(node.params, COMPRESS_HARDWARE_LOOK);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shows the pane for `tab`, with `strip` lit in the row – none for an empty kind reached by `showTab`. */
|
/** Shows the pane for `tab`, with `strip` lit in the row – none for an empty kind reached by `showTab`. */
|
||||||
private display(tab: TabId, strip: StripTab | null): void {
|
private display(tab: TabId, strip: StripTab | null): void {
|
||||||
// Fullscreen is the game view's; a tab that takes its place (Stop goes back to the scene) ends it.
|
// Fullscreen is the game view's; a tab that takes its place (Stop goes back to the scene) ends it.
|
||||||
@@ -1349,8 +1330,18 @@ export class Editor implements PlayHost {
|
|||||||
if (this.shows('sound')) this.soundEditor.tick();
|
if (this.shows('sound')) this.soundEditor.tick();
|
||||||
if (this.shows('eq')) this.eqEditor.tick();
|
if (this.shows('eq')) this.eqEditor.tick();
|
||||||
else this.eqEditor.hideSpectrum();
|
else this.eqEditor.hideSpectrum();
|
||||||
|
// A Compressor's tab and its node (#380) listen to the same taps: what is not seen lets go of
|
||||||
|
// them first, so it cannot take them from what is.
|
||||||
|
const faces = [
|
||||||
|
['soundfx', this.soundFxEditor.canvas],
|
||||||
|
['instrument', this.instrumentEditor.canvas],
|
||||||
|
] as const;
|
||||||
|
if (!this.shows('compressor')) this.compressorEditor.hideLevels();
|
||||||
|
for (const [kind, canvas] of faces) if (!this.shows(kind)) canvas.hideFaces();
|
||||||
if (this.shows('compressor')) this.compressorEditor.tick();
|
if (this.shows('compressor')) this.compressorEditor.tick();
|
||||||
else this.compressorEditor.hideLevels();
|
this.facesMoving = false;
|
||||||
|
for (const [kind, canvas] of faces)
|
||||||
|
if (this.shows(kind)) this.facesMoving = canvas.tickFaces() || this.facesMoving;
|
||||||
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
|
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
|
||||||
// to do it, and its states move just the same.
|
// to do it, and its states move just the same.
|
||||||
if (this.shows('statemachine') && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
|
if (this.shows('statemachine') && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
|
||||||
@@ -1388,7 +1379,8 @@ export class Editor implements PlayHost {
|
|||||||
(this.shows('song') && this.songEditor.isPlaying) ||
|
(this.shows('song') && this.songEditor.isPlaying) ||
|
||||||
(this.shows('sound') && this.soundEditor.isPlaying) ||
|
(this.shows('sound') && this.soundEditor.isPlaying) ||
|
||||||
(this.shows('eq') && this.eqEditor.hasSpectrum) ||
|
(this.shows('eq') && this.eqEditor.hasSpectrum) ||
|
||||||
(this.shows('compressor') && this.compressorEditor.moving)
|
(this.shows('compressor') && this.compressorEditor.moving) ||
|
||||||
|
this.facesMoving
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type Container, type FederatedPointerEvent, Graphics, Rectangle } from 'pixi.js';
|
import { type Container, type FederatedPointerEvent, Graphics, Rectangle } from 'pixi.js';
|
||||||
import { Button, crisp, drawFloatingBox, Icon, Label, Spacer, Theme, ui, Widget } from '../ui';
|
import { Button, crisp, drawFloatingBox, Icon, Label, Spacer, Theme, ui, Widget } from '../ui';
|
||||||
import { keptSize, leastBox, onScreen, sized, type WindowBox } from './floating';
|
import { leastBox, onScreen, sized, type WindowBox } from './floating';
|
||||||
import { TabHeader } from './TabHeader';
|
import { TabHeader } from './TabHeader';
|
||||||
|
|
||||||
/** The corner taken hold of to size the window. */
|
/** The corner taken hold of to size the window. */
|
||||||
@@ -13,11 +13,6 @@ export interface FloatWindowHost {
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A pane that is one size only (#379); null while it takes any. */
|
|
||||||
export interface SizedPane {
|
|
||||||
readonly fixedSize: { w: number; h: number } | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A tab's pane in a window of its own (#371), above the editor. The pane is lent, not owned: the
|
* A tab's pane in a window of its own (#371), above the editor. The pane is lent, not owned: the
|
||||||
* editor puts it in with `setContent` and takes it back before the window goes. The title bar
|
* editor puts it in with `setContent` and takes it back before the window goes. The title bar
|
||||||
@@ -78,28 +73,7 @@ export class FloatWindow extends Widget {
|
|||||||
/** Where the title bar can be taken hold of: left of the buttons, clear of the name. */
|
/** Where the title bar can be taken hold of: left of the buttons, clear of the name. */
|
||||||
barPoint(): { x: number; y: number } {
|
barPoint(): { x: number; y: number } {
|
||||||
const at = this.bar.getGlobalPosition();
|
const at = this.bar.getGlobalPosition();
|
||||||
const first = this.dockButton.visible ? this.dockButton : this.closeButton;
|
return { x: at.x + this.dockButton.x - Theme.padding * 2, y: at.y + this.bar.h / 2 };
|
||||||
return { x: at.x + first.x - Theme.padding * 2, y: at.y + this.bar.h / 2 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The size of a window around a pane of one size, or null where it can be sized. */
|
|
||||||
get fixedSize(): { w: number; h: number } | null {
|
|
||||||
const size = (this.content as Partial<SizedPane> | null)?.fixedSize;
|
|
||||||
return size ? { w: size.w + 2, h: size.h + this.bar.measure().h + 2 } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* After the pane has changed what it is: a pane of one size has the window at that size, with no
|
|
||||||
* corner to size it and no Dock (#379) – it cannot go into the tab row.
|
|
||||||
*/
|
|
||||||
refit(): void {
|
|
||||||
const fixed = this.fixedSize !== null;
|
|
||||||
this.grip.visible = !fixed;
|
|
||||||
if (this.dockButton.visible === fixed) {
|
|
||||||
this.dockButton.visible = !fixed;
|
|
||||||
this.bar.relayout();
|
|
||||||
}
|
|
||||||
if (this.isOpen) this.place(this.box);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The middle of the sizing corner, for the check scripts. */
|
/** The middle of the sizing corner, for the check scripts. */
|
||||||
@@ -120,7 +94,7 @@ export class FloatWindow extends Widget {
|
|||||||
this.addChildAt(content, this.getChildIndex(this.bar));
|
this.addChildAt(content, this.getChildIndex(this.bar));
|
||||||
this.layout();
|
this.layout();
|
||||||
// A pane that needs more than the window has grows the window.
|
// A pane that needs more than the window has grows the window.
|
||||||
this.refit();
|
if (this.isOpen) this.place(this.box);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The name and the icon of the document shown, as its tab had them. */
|
/** The name and the icon of the document shown, as its tab had them. */
|
||||||
@@ -182,10 +156,7 @@ export class FloatWindow extends Widget {
|
|||||||
|
|
||||||
private place(box: WindowBox): void {
|
private place(box: WindowBox): void {
|
||||||
const screen = ui().app.screen;
|
const screen = ui().app.screen;
|
||||||
const fixed = this.fixedSize;
|
const fit = onScreen(box, screen.width, screen.height, this.least());
|
||||||
const fit = fixed
|
|
||||||
? onScreen(keptSize(box, fixed), screen.width, screen.height, fixed)
|
|
||||||
: onScreen(box, screen.width, screen.height, this.least());
|
|
||||||
this.position.set(fit.x, fit.y);
|
this.position.set(fit.x, fit.y);
|
||||||
this.resize(fit.w, fit.h);
|
this.resize(fit.w, fit.h);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Container } from 'pixi.js';
|
|||||||
import { NameTransitionCommand } from '../../core/commands/graphCommands';
|
import { NameTransitionCommand } from '../../core/commands/graphCommands';
|
||||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||||
import { currentNodePalette } from '../graph/NodePalette';
|
import { currentNodePalette } from '../graph/NodePalette';
|
||||||
|
import { CompressorPanel } from '../soundfx/CompressorPanel';
|
||||||
import { rectOf } from '../textProbe';
|
import { rectOf } from '../textProbe';
|
||||||
import { centreOf, snapButton, type HookContext } from './hookHelpers';
|
import { centreOf, snapButton, type HookContext } from './hookHelpers';
|
||||||
|
|
||||||
@@ -119,6 +120,7 @@ export function graphHooks({ state, editor }: HookContext) {
|
|||||||
params: () => editor.soundFxEditor.previewValues,
|
params: () => editor.soundFxEditor.previewValues,
|
||||||
setParam: (key: string, value: number) => editor.soundFxEditor.setPreviewValue(key, value),
|
setParam: (key: string, value: number) => editor.soundFxEditor.setPreviewValue(key, value),
|
||||||
nodeAt: (nodeId: string) => editor.soundFxEditor.canvas.nodeGlobal(nodeId),
|
nodeAt: (nodeId: string) => editor.soundFxEditor.canvas.nodeGlobal(nodeId),
|
||||||
|
fit: () => editor.soundFxEditor.canvas.fit(),
|
||||||
},
|
},
|
||||||
/** The Parametric EQ's tab (#367, #370): for which node it is showing, 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: {
|
eq: {
|
||||||
@@ -184,16 +186,30 @@ export function graphHooks({ state, editor }: HookContext) {
|
|||||||
*/
|
*/
|
||||||
levels: () => ({ ...editor.compressorEditor.seen, taps: soundLibrary.spectrumTaps() }),
|
levels: () => ({ ...editor.compressorEditor.seen, taps: soundLibrary.spectrumTaps() }),
|
||||||
/**
|
/**
|
||||||
* The hardware panel (#379): whether it is shown and where, its knobs' dials, the reduction the
|
* The panel a Compressor node shows in the sound FX graph (#379, #380): whether it is shown and
|
||||||
* needle shows and is heading for, the LEDs lit, the most of both so far, the auto-gain lever
|
* where, its knobs' dials and numbers, the reduction the needle shows and is heading for, the
|
||||||
* and the LCD.
|
* LEDs lit, the most of both so far, the auto-gain lever, the LCD, and the node's ports.
|
||||||
*/
|
*/
|
||||||
panel: () => {
|
panel: (nodeId: string) => {
|
||||||
const panel = editor.compressorEditor.panel;
|
const canvas = editor.soundFxEditor.canvas;
|
||||||
|
const panel = canvas.faceOf(nodeId)?.widget;
|
||||||
|
if (!(panel instanceof CompressorPanel)) return null;
|
||||||
const seen = (node: Container | null): boolean => !node || (node.visible && seen(node.parent));
|
const seen = (node: Container | null): boolean => !node || (node.visible && seen(node.parent));
|
||||||
|
const node = canvas.nodeScreen(nodeId);
|
||||||
|
const ports = (keys: string[], isOutput: boolean) =>
|
||||||
|
Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)]));
|
||||||
return {
|
return {
|
||||||
shown: seen(panel),
|
shown: seen(panel),
|
||||||
rect: rectOf(panel),
|
/** On screen, at the graph's zoom; `size` is the panel's own. */
|
||||||
|
rect: (() => {
|
||||||
|
const from = panel.toGlobal({ x: 0, y: 0 });
|
||||||
|
const to = panel.toGlobal({ x: panel.w, y: panel.h });
|
||||||
|
return { x: from.x, y: from.y, w: to.x - from.x, h: to.y - from.y };
|
||||||
|
})(),
|
||||||
|
size: { w: panel.w, h: panel.h },
|
||||||
|
node: node?.rect ?? null,
|
||||||
|
inputs: ports(node?.inputs ?? [], false),
|
||||||
|
outputs: ports(node?.outputs ?? [], true),
|
||||||
dials: Object.fromEntries(
|
dials: Object.fromEntries(
|
||||||
Object.entries(panel.knobs).map(([key, knob]) => [key, knob.dialGlobal()]),
|
Object.entries(panel.knobs).map(([key, knob]) => [key, knob.dialGlobal()]),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -43,17 +43,6 @@ export function onScreen(box: WindowBox, screenW: number, screenH: number, least
|
|||||||
return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) };
|
return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `box` at `size`, around the same middle: a window of one size (#379) opened in a box of another. */
|
|
||||||
export function keptSize(box: WindowBox, size: { w: number; h: number }): WindowBox {
|
|
||||||
if (box.w === size.w && box.h === size.h) return box;
|
|
||||||
return {
|
|
||||||
x: Math.round(box.x + (box.w - size.w) / 2),
|
|
||||||
y: Math.round(box.y + (box.h - size.h) / 2),
|
|
||||||
w: size.w,
|
|
||||||
h: size.h,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `box` with its lower right corner pulled by `dx`, `dy`: at least the minimum, at most to the screen's edge. */
|
/** `box` with its lower right corner pulled by `dx`, `dy`: at least the minimum, at most to the screen's edge. */
|
||||||
export function sized(
|
export function sized(
|
||||||
box: WindowBox,
|
box: WindowBox,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { graphLookup } from '../../core/model/graphLookup';
|
|||||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||||
import { soundFxBuildFor } from '../../core/soundfx/compile';
|
import { soundFxBuildFor } from '../../core/soundfx/compile';
|
||||||
|
import { nodeFaceFor } from '../soundfx/nodeFaces';
|
||||||
import { nodeTabOf } from '../soundfx/nodeTabs';
|
import { nodeTabOf } from '../soundfx/nodeTabs';
|
||||||
import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile';
|
import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile';
|
||||||
import { masterRoutingProblems } from '../../core/music/projectRouting';
|
import { masterRoutingProblems } from '../../core/music/projectRouting';
|
||||||
@@ -79,6 +80,7 @@ import { NodePalette, type PaletteAction } from './NodePalette';
|
|||||||
import { splitEntries } from './splitMenu';
|
import { splitEntries } from './splitMenu';
|
||||||
import { type PaletteEntry, paletteEntries } from './paletteEntries';
|
import { type PaletteEntry, paletteEntries } from './paletteEntries';
|
||||||
import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView';
|
import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView';
|
||||||
|
import type { NodeFace } from './nodeFace';
|
||||||
import { drawGraphGrid, FIT_MARGIN, inView, ZOOM_LIMITS, zoomedAbout } from './graphView';
|
import { drawGraphGrid, FIT_MARGIN, inView, ZOOM_LIMITS, zoomedAbout } from './graphView';
|
||||||
import { GraphWiring } from './wiring';
|
import { GraphWiring } from './wiring';
|
||||||
|
|
||||||
@@ -330,7 +332,7 @@ export class GraphCanvas extends Widget {
|
|||||||
let view = this.views.get(node.id);
|
let view = this.views.get(node.id);
|
||||||
if (!view || view.node !== node) {
|
if (!view || view.node !== node) {
|
||||||
view?.destroy({ children: true });
|
view?.destroy({ children: true });
|
||||||
view = new NodeView(node, def);
|
view = new NodeView(node, def, nodeFaceFor(this.state, asset.id, node));
|
||||||
view.on('pointerdown', (e: FederatedPointerEvent) => this.onNodePointerDown(view!, e));
|
view.on('pointerdown', (e: FederatedPointerEvent) => this.onNodePointerDown(view!, e));
|
||||||
this.views.set(node.id, view);
|
this.views.set(node.id, view);
|
||||||
this.nodeLayer.addChild(view);
|
this.nodeLayer.addChild(view);
|
||||||
@@ -506,6 +508,41 @@ export class GraphCanvas extends Widget {
|
|||||||
requestRender();
|
requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A frame on for the nodes drawn with a face (#380): those in the picture read what they hear,
|
||||||
|
* the others stop listening. Whether any of them still moves.
|
||||||
|
*/
|
||||||
|
tickFaces(): boolean {
|
||||||
|
let moving = false;
|
||||||
|
for (const view of this.views.values()) {
|
||||||
|
if (!view.face) continue;
|
||||||
|
if (view.visible && !view.collapsed) moving = view.face.tick() || moving;
|
||||||
|
else view.face.hide();
|
||||||
|
}
|
||||||
|
return moving;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The graph is not seen: no face listens. */
|
||||||
|
hideFaces(): void {
|
||||||
|
for (const view of this.views.values()) view.face?.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A node's face, for the check scripts. */
|
||||||
|
faceOf(nodeId: string): NodeFace | null {
|
||||||
|
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 {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Takes the nodes outside the picture out of the drawing – see `inView`. */
|
/** Takes the nodes outside the picture out of the drawing – see `inView`. */
|
||||||
private cull(): void {
|
private cull(): void {
|
||||||
const view = { pan: this.pan, zoom: this.zoom };
|
const view = { pan: this.pan, zoom: this.zoom };
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
import { keyLabel, referenceLabel } from '../inspector/fieldFactory';
|
import { keyLabel, referenceLabel } from '../inspector/fieldFactory';
|
||||||
import { collapsedNodeWidth } from './folding';
|
import { collapsedNodeWidth } from './folding';
|
||||||
|
import type { NodeFace } from './nodeFace';
|
||||||
import { inputTextRoom, valueSpan } from './portRow';
|
import { inputTextRoom, valueSpan } from './portRow';
|
||||||
|
|
||||||
/** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */
|
/** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */
|
||||||
@@ -48,6 +49,8 @@ const CHEVRON_SLOT = 14;
|
|||||||
const TITLE_X = PAD + CHEVRON_SLOT;
|
const TITLE_X = PAD + CHEVRON_SLOT;
|
||||||
/** Right of a collapsed title: the side's link dot sits centred on the edge. */
|
/** Right of a collapsed title: the side's link dot sits centred on the edge. */
|
||||||
const COLLAPSED_TITLE_END = PAD + PORT_RADIUS;
|
const COLLAPSED_TITLE_END = PAD + PORT_RADIUS;
|
||||||
|
/** From a node's edge to the name of a port. */
|
||||||
|
const PORT_TEXT_X = PAD + PORT_RADIUS + 4;
|
||||||
|
|
||||||
function categoryColor(category: NodeCategory): number {
|
function categoryColor(category: NodeCategory): number {
|
||||||
const c = Theme.colors;
|
const c = Theme.colors;
|
||||||
@@ -163,6 +166,8 @@ export class NodeView extends Widget {
|
|||||||
private textsOf = '';
|
private textsOf = '';
|
||||||
private drawnOf = '';
|
private drawnOf = '';
|
||||||
private pictureOf = '';
|
private pictureOf = '';
|
||||||
|
/** How wide the sides of a face are, with the pins and their names (#380). */
|
||||||
|
private sides = { left: 0, right: 0 };
|
||||||
/** How often the texts and the box were really made, for the checks. */
|
/** How often the texts and the box were really made, for the checks. */
|
||||||
readonly made = { texts: 0, box: 0 };
|
readonly made = { texts: 0, box: 0 };
|
||||||
|
|
||||||
@@ -175,6 +180,8 @@ export class NodeView extends Widget {
|
|||||||
constructor(
|
constructor(
|
||||||
readonly node: GraphNode,
|
readonly node: GraphNode,
|
||||||
def: NodeDef,
|
def: NodeDef,
|
||||||
|
/** What the node shows instead of its rows (#380), if anything. */
|
||||||
|
readonly face: NodeFace | null = null,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.def = def;
|
this.def = def;
|
||||||
@@ -185,6 +192,7 @@ export class NodeView extends Widget {
|
|||||||
this.picture.context.batchMode = 'batch';
|
this.picture.context.batchMode = 'batch';
|
||||||
this.ports.context.batchMode = 'batch';
|
this.ports.context.batchMode = 'batch';
|
||||||
this.addChild(this.box, this.picture, this.ports);
|
this.addChild(this.box, this.picture, this.ports);
|
||||||
|
if (face) this.addChild(face.widget);
|
||||||
this.sync(node, { nodes: [node], edges: [] }, null, def);
|
this.sync(node, { nodes: [node], edges: [] }, null, def);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,16 +259,37 @@ export class NodeView extends Widget {
|
|||||||
)
|
)
|
||||||
this.inlineTexts.set(port.key, NULL_TEXT);
|
this.inlineTexts.set(port.key, NULL_TEXT);
|
||||||
}
|
}
|
||||||
|
// A face shows the settings and the values itself: its sides name the ports only.
|
||||||
|
if (this.shownFace()) {
|
||||||
|
this.paramLines = [];
|
||||||
|
this.inlineTexts = new Map();
|
||||||
|
}
|
||||||
this.linked = {
|
this.linked = {
|
||||||
inputs: graph.edges.some((e) => e.to.node === node.id),
|
inputs: graph.edges.some((e) => e.to.node === node.id),
|
||||||
outputs: graph.edges.some((e) => e.from.node === node.id),
|
outputs: graph.edges.some((e) => e.from.node === node.id),
|
||||||
};
|
};
|
||||||
const picture = this.shownPicture();
|
const picture = this.shownPicture();
|
||||||
|
const face = this.shownFace();
|
||||||
|
if (face) this.sides = this.faceSides();
|
||||||
const height = this.collapsed
|
const height = this.collapsed
|
||||||
? TITLE_HEIGHT
|
? TITLE_HEIGHT
|
||||||
: this.rowsEnd() + PAD + (picture ? picture.height + PAD / 2 : 0);
|
: face
|
||||||
|
? this.headerHeight() + this.faceSize().h + PAD
|
||||||
|
: this.rowsEnd() + PAD + (picture ? picture.height + PAD / 2 : 0);
|
||||||
// Sized before the texts: output labels are placed against the right edge.
|
// Sized before the texts: output labels are placed against the right edge.
|
||||||
this.resize(this.collapsed ? this.collapsedWidth() : NODE_WIDTH, height);
|
this.resize(
|
||||||
|
this.collapsed
|
||||||
|
? this.collapsedWidth()
|
||||||
|
: face
|
||||||
|
? this.sides.left + this.faceSize().w + this.sides.right
|
||||||
|
: NODE_WIDTH,
|
||||||
|
height,
|
||||||
|
);
|
||||||
|
if (this.face) {
|
||||||
|
this.face.widget.visible = face !== null;
|
||||||
|
if (face) this.placeFace(face);
|
||||||
|
else this.face.hide();
|
||||||
|
}
|
||||||
const texts = this.textSignature();
|
const texts = this.textSignature();
|
||||||
if (texts !== this.textsOf) {
|
if (texts !== this.textsOf) {
|
||||||
this.textsOf = texts;
|
this.textsOf = texts;
|
||||||
@@ -400,6 +429,36 @@ export class NodeView extends Widget {
|
|||||||
return this.headerHeight() + this.rowCount() * ROW_HEIGHT;
|
return this.headerHeight() + this.rowCount() * ROW_HEIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The node's face, while the node is open. */
|
||||||
|
private shownFace(): NodeFace | null {
|
||||||
|
return (!this.collapsed && this.face) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private faceSize(): { w: number; h: number } {
|
||||||
|
return this.face?.widget.measure() ?? { w: 0, h: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each side of a face as wide as the longest name on it. */
|
||||||
|
private faceSides(): { left: number; right: number } {
|
||||||
|
const side = (ports: PortDef[]) =>
|
||||||
|
Math.ceil(
|
||||||
|
PORT_TEXT_X +
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
...ports.map((p) => CanvasTextMetrics.measureText(p.label, textStyle(rowStyle(p))).width),
|
||||||
|
) +
|
||||||
|
PAD,
|
||||||
|
);
|
||||||
|
return { left: side(this.def.inputs), right: side(this.def.outputs) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private placeFace(face: NodeFace): void {
|
||||||
|
const size = this.faceSize();
|
||||||
|
face.widget.position.set(this.sides.left, this.headerHeight());
|
||||||
|
face.widget.resize(size.w, size.h);
|
||||||
|
face.sync();
|
||||||
|
}
|
||||||
|
|
||||||
/** The node's picture, while the node is open. */
|
/** The node's picture, while the node is open. */
|
||||||
private shownPicture(): NodePicture | null {
|
private shownPicture(): NodePicture | null {
|
||||||
return (!this.collapsed && this.def.picture) || null;
|
return (!this.collapsed && this.def.picture) || null;
|
||||||
@@ -444,8 +503,10 @@ export class NodeView extends Widget {
|
|||||||
return { x: isOutput ? this.w : 0, y: this.collapsed ? TITLE_HEIGHT / 2 : this.rowY(index) };
|
return { x: isOutput ? this.w : 0, y: this.collapsed ? TITLE_HEIGHT / 2 : this.rowY(index) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A face has its ports in the middle of its sides. */
|
||||||
private rowY(index: number): number {
|
private rowY(index: number): number {
|
||||||
return this.headerHeight() + index * ROW_HEIGHT + ROW_HEIGHT / 2;
|
const top = this.shownFace() ? (this.faceSize().h - this.rowCount() * ROW_HEIGHT) / 2 : 0;
|
||||||
|
return this.headerHeight() + top + index * ROW_HEIGHT + ROW_HEIGHT / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -506,15 +567,13 @@ export class NodeView extends Widget {
|
|||||||
add(line, 'small', PAD + 2, y + 2);
|
add(line, 'small', PAD + 2, y + 2);
|
||||||
y += ROW_HEIGHT;
|
y += ROW_HEIGHT;
|
||||||
}
|
}
|
||||||
if (this.problem) {
|
if (this.problem) add(this.problem, 'warning', PAD + 2, y + 2);
|
||||||
add(this.problem, 'warning', PAD + 2, y + 2);
|
const inputX = PORT_TEXT_X;
|
||||||
y += ROW_HEIGHT;
|
const face = this.shownFace() !== null;
|
||||||
}
|
|
||||||
const inputX = PAD + PORT_RADIUS + 4;
|
|
||||||
for (let i = 0; i < this.rowCount(); i++) {
|
for (let i = 0; i < this.rowCount(); i++) {
|
||||||
const input = this.def.inputs[i];
|
const input = this.def.inputs[i];
|
||||||
const output = this.def.outputs[i];
|
const output = this.def.outputs[i];
|
||||||
const rowTop = y + i * ROW_HEIGHT + 2;
|
const rowTop = this.rowY(i) - ROW_HEIGHT / 2 + 2;
|
||||||
const outputText = output
|
const outputText = output
|
||||||
? add(
|
? add(
|
||||||
output.label,
|
output.label,
|
||||||
@@ -522,15 +581,17 @@ export class NodeView extends Widget {
|
|||||||
this.w - PAD - PORT_RADIUS - 4,
|
this.w - PAD - PORT_RADIUS - 4,
|
||||||
rowTop,
|
rowTop,
|
||||||
'right',
|
'right',
|
||||||
NODE_WIDTH / 2 - 10,
|
face ? this.sides.right : NODE_WIDTH / 2 - 10,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
const room = inputTextRoom(inputX, NODE_WIDTH / 2 + 20, outputText ? outputText.x : null);
|
const room = face
|
||||||
|
? this.sides.left
|
||||||
|
: inputTextRoom(inputX, NODE_WIDTH / 2 + 20, outputText ? outputText.x : null);
|
||||||
const inputText = input
|
const inputText = input
|
||||||
? add(this.inputText(input), rowStyle(input), inputX, rowTop, 'left', room)
|
? add(this.inputText(input), rowStyle(input), inputX, rowTop, 'left', room)
|
||||||
: null;
|
: null;
|
||||||
this.rowTexts.push({ input: inputText, output: outputText });
|
this.rowTexts.push({ input: inputText, output: outputText });
|
||||||
if (output && outputText && output.type !== 'exec' && !output.ghost) {
|
if (!face && output && outputText && output.type !== 'exec' && !output.ghost) {
|
||||||
const inputEnd = inputText ? inputText.x + inputText.width : PAD;
|
const inputEnd = inputText ? inputText.x + inputText.width : PAD;
|
||||||
this.valueSlots.set(output.key, { ...valueSpan(inputEnd, outputText.x), top: rowTop + 1 });
|
this.valueSlots.set(output.key, { ...valueSpan(inputEnd, outputText.x), top: rowTop + 1 });
|
||||||
}
|
}
|
||||||
@@ -538,6 +599,11 @@ export class NodeView extends Widget {
|
|||||||
this.placeValues();
|
this.placeValues();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
|
||||||
|
this.face?.hide();
|
||||||
|
super.destroy(options);
|
||||||
|
}
|
||||||
|
|
||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
this.drawPicture();
|
this.drawPicture();
|
||||||
const drawn = this.drawSignature();
|
const drawn = this.drawSignature();
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Widget } from '../../ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a node draws instead of its rows (#380): a widget of one size between its inputs on the
|
||||||
|
* left and its outputs on the right, worked right there in the graph.
|
||||||
|
*/
|
||||||
|
export interface NodeFace {
|
||||||
|
readonly widget: Widget;
|
||||||
|
/** 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. */
|
||||||
|
tick(): boolean;
|
||||||
|
/** Out of the picture, folded or gone: it stops listening. */
|
||||||
|
hide(): void;
|
||||||
|
}
|
||||||
@@ -8,15 +8,8 @@ import { Container, type FederatedPointerEvent, Graphics, type Text } from 'pixi
|
|||||||
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
|
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||||
import type { GraphNode } from '../../core/graph';
|
import type { GraphNode } from '../../core/graph';
|
||||||
import { findGraphAsset, type Project } from '../../core/model/Project';
|
import { findGraphAsset, type Project } from '../../core/model/Project';
|
||||||
import type { PropertyValue } from '../../core/model/Properties';
|
import { AUTO_GAIN_REFERENCE_DB } from '../../core/soundfx/dynamics';
|
||||||
import { AUTO_GAIN_REFERENCE_DB, compressorAutoGain } from '../../core/soundfx/dynamics';
|
import { COMPRESS_AUTO_GAIN, SoundFxCompressor, tapId } from '../../core/soundfx/nodes';
|
||||||
import {
|
|
||||||
switchOn,
|
|
||||||
COMPRESS_AUTO_GAIN,
|
|
||||||
COMPRESS_HARDWARE_LOOK,
|
|
||||||
SoundFxCompressor,
|
|
||||||
tapId,
|
|
||||||
} from '../../core/soundfx/nodes';
|
|
||||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||||
import {
|
import {
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@@ -32,8 +25,6 @@ import {
|
|||||||
VStack,
|
VStack,
|
||||||
Widget,
|
Widget,
|
||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
import { FramePreview } from '../framePreview';
|
|
||||||
import type { SizedPane } from '../FloatWindow';
|
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import { TabHeader } from '../TabHeader';
|
import { TabHeader } from '../TabHeader';
|
||||||
import {
|
import {
|
||||||
@@ -42,7 +33,9 @@ import {
|
|||||||
COMPRESSOR_ROWS,
|
COMPRESSOR_ROWS,
|
||||||
type CompressorKey,
|
type CompressorKey,
|
||||||
type CompressorNumbers,
|
type CompressorNumbers,
|
||||||
|
autoGainText,
|
||||||
compressorGainDb,
|
compressorGainDb,
|
||||||
|
compressorNumbers,
|
||||||
CURVE_FLOOR_DB,
|
CURVE_FLOOR_DB,
|
||||||
curveDbAtX,
|
curveDbAtX,
|
||||||
curveDbAtY,
|
curveDbAtY,
|
||||||
@@ -63,8 +56,7 @@ import {
|
|||||||
reductionShapes,
|
reductionShapes,
|
||||||
thresholdHandle,
|
thresholdHandle,
|
||||||
} from './compressorView';
|
} from './compressorView';
|
||||||
import { CompressorPanel, type PanelHost } from './CompressorPanel';
|
import { NodeParamDrag } from './nodeParamDrag';
|
||||||
import { gainReduction, PANEL } from './hardwareView';
|
|
||||||
|
|
||||||
type Key = CompressorKey;
|
type Key = CompressorKey;
|
||||||
const KEYS = COMPRESSOR_KEYS;
|
const KEYS = COMPRESSOR_KEYS;
|
||||||
@@ -316,13 +308,8 @@ class Pictures extends Widget {
|
|||||||
* The tab of a Compressor node (#378): one editor, pointed at the node whose tab is showing, as
|
* The tab of a Compressor node (#378): one editor, pointed at the node whose tab is showing, as
|
||||||
* the EQ's is (#370).
|
* the EQ's is (#370).
|
||||||
*/
|
*/
|
||||||
export class CompressorEditor extends VStack implements CompressorHost, PanelHost, SizedPane {
|
export class CompressorEditor extends VStack implements CompressorHost {
|
||||||
readonly levels: LevelPicture;
|
readonly levels: LevelPicture;
|
||||||
/** The node's hardware look (#379), shown instead of the tab's header and body. */
|
|
||||||
readonly panel: CompressorPanel;
|
|
||||||
/** What the tab needs at the least, for when it is shown again after the panel. */
|
|
||||||
private readonly tabLeast: { w: number; h: number };
|
|
||||||
private lastTick = 0;
|
|
||||||
readonly curve: CurvePicture;
|
readonly curve: CurvePicture;
|
||||||
readonly knobs = {} as Record<Key, Knob>;
|
readonly knobs = {} as Record<Key, Knob>;
|
||||||
/** Auto-gain: a switch, with the dB it gives below it where a knob has its number. */
|
/** Auto-gain: a switch, with the dB it gives below it where a knob has its number. */
|
||||||
@@ -331,18 +318,18 @@ export class CompressorEditor extends VStack implements CompressorHost, PanelHos
|
|||||||
private readonly autoColumn = new VStack({ gap: 2, align: 'center' });
|
private readonly autoColumn = new VStack({ gap: 2, align: 'center' });
|
||||||
private assetId = '';
|
private assetId = '';
|
||||||
private nodeId = '';
|
private nodeId = '';
|
||||||
/** The value a drag began with – its undo step's other side; undefined when none was typed in. */
|
private readonly drag: NodeParamDrag;
|
||||||
private before: { key: Key; value: PropertyValue | undefined } | null = null;
|
/** Whether the tab holds its node's taps: let go of once, not on every frame it is not seen. */
|
||||||
|
private listening = false;
|
||||||
private readonly title = new Label('Compressor', { style: 'bodyBold' });
|
private readonly title = new Label('Compressor', { style: 'bodyBold' });
|
||||||
private readonly header = new TabHeader();
|
private readonly header = new TabHeader();
|
||||||
private readonly body = new VStack({ gap: Theme.padding, padding: Theme.padding * 2 });
|
private readonly body = new VStack({ gap: Theme.padding, padding: Theme.padding * 2 });
|
||||||
private readonly perFrame: FramePreview;
|
|
||||||
/** Readings so far, and the most taken away in one (checks). */
|
/** Readings so far, and the most taken away in one (checks). */
|
||||||
seen = { frames: 0, in: -Infinity, out: -Infinity, reduction: 0 };
|
seen = { frames: 0, in: -Infinity, out: -Infinity, reduction: 0 };
|
||||||
|
|
||||||
constructor(private readonly state: EditorState) {
|
constructor(private readonly state: EditorState) {
|
||||||
super();
|
super();
|
||||||
this.perFrame = new FramePreview(state, 'compressor-knob');
|
this.drag = new NodeParamDrag(state, 'compressor-knob');
|
||||||
this.levels = new LevelPicture(this);
|
this.levels = new LevelPicture(this);
|
||||||
this.curve = new CurvePicture(this);
|
this.curve = new CurvePicture(this);
|
||||||
this.header.add(this.title);
|
this.header.add(this.title);
|
||||||
@@ -394,20 +381,14 @@ export class CompressorEditor extends VStack implements CompressorHost, PanelHos
|
|||||||
// line is left out of the width: it is cut short instead.
|
// line is left out of the width: it is cut short instead.
|
||||||
this.minWidth = Math.max(knobs.measure().w, pictures.measure().w) + this.body.padding * 2;
|
this.minWidth = Math.max(knobs.measure().w, pictures.measure().w) + this.body.padding * 2;
|
||||||
this.minHeight = this.measure().h;
|
this.minHeight = this.measure().h;
|
||||||
this.tabLeast = { w: this.minWidth, h: this.minHeight };
|
|
||||||
this.panel = new CompressorPanel(this);
|
|
||||||
this.panel.visible = false;
|
|
||||||
this.add(this.panel, 1);
|
|
||||||
state.on('project', () => this.sync());
|
state.on('project', () => this.sync());
|
||||||
}
|
}
|
||||||
|
|
||||||
show(assetId: string, nodeId: string): void {
|
show(assetId: string, nodeId: string): void {
|
||||||
if (assetId !== this.assetId || nodeId !== this.nodeId) {
|
if (assetId !== this.assetId || nodeId !== this.nodeId) {
|
||||||
this.perFrame.cancel();
|
this.drag.forget();
|
||||||
this.hideLevels();
|
this.hideLevels();
|
||||||
this.levels.clear();
|
this.levels.clear();
|
||||||
this.panel.reset();
|
|
||||||
this.before = null;
|
|
||||||
this.assetId = assetId;
|
this.assetId = assetId;
|
||||||
this.nodeId = nodeId;
|
this.nodeId = nodeId;
|
||||||
this.seen = { frames: 0, in: -Infinity, out: -Infinity, reduction: 0 };
|
this.seen = { frames: 0, in: -Infinity, out: -Infinity, reduction: 0 };
|
||||||
@@ -416,10 +397,10 @@ export class CompressorEditor extends VStack implements CompressorHost, PanelHos
|
|||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.perFrame.cancel();
|
this.drag.cancel();
|
||||||
this.hideLevels();
|
this.hideLevels();
|
||||||
const node = this.node();
|
const node = this.node();
|
||||||
if (node) this.restore(node);
|
if (node) this.drag.restore(node);
|
||||||
this.assetId = '';
|
this.assetId = '';
|
||||||
this.nodeId = '';
|
this.nodeId = '';
|
||||||
}
|
}
|
||||||
@@ -433,16 +414,11 @@ export class CompressorEditor extends VStack implements CompressorHost, PanelHos
|
|||||||
if (!this.nodeId) return;
|
if (!this.nodeId) return;
|
||||||
const id = tapId(this.assetId, this.nodeId);
|
const id = tapId(this.assetId, this.nodeId);
|
||||||
soundLibrary.listen(id);
|
soundLibrary.listen(id);
|
||||||
|
this.listening = true;
|
||||||
const levels = soundLibrary.nodeLevels(id, LEVEL_WINDOW);
|
const levels = soundLibrary.nodeLevels(id, LEVEL_WINDOW);
|
||||||
const now = performance.now() / 1000;
|
if (!levels && !this.levels.history.heard) return;
|
||||||
const dt = this.lastTick ? now - this.lastTick : 0;
|
this.levels.read(performance.now() / 1000, levels);
|
||||||
this.lastTick = now;
|
this.curve.hear(levels?.in ?? -Infinity);
|
||||||
if (this.panel.visible)
|
|
||||||
this.panel.read(gainReduction(levels, compressorGainDb(this.numbers())), levels?.out ?? -Infinity, dt);
|
|
||||||
else if (levels || this.levels.history.heard) {
|
|
||||||
this.levels.read(now, levels);
|
|
||||||
this.curve.hear(levels?.in ?? -Infinity);
|
|
||||||
}
|
|
||||||
if (levels && levels.in > LEVEL_FLOOR_DB) {
|
if (levels && levels.in > LEVEL_FLOOR_DB) {
|
||||||
this.seen = {
|
this.seen = {
|
||||||
frames: this.seen.frames + 1,
|
frames: this.seen.frames + 1,
|
||||||
@@ -455,37 +431,14 @@ export class CompressorEditor extends VStack implements CompressorHost, PanelHos
|
|||||||
|
|
||||||
/** The tab is not seen: no analyser is fed for it. */
|
/** The tab is not seen: no analyser is fed for it. */
|
||||||
hideLevels(): void {
|
hideLevels(): void {
|
||||||
if (this.nodeId) soundLibrary.unlisten(tapId(this.assetId, this.nodeId));
|
if (this.listening) soundLibrary.unlisten(tapId(this.assetId, this.nodeId));
|
||||||
|
this.listening = false;
|
||||||
this.curve.hear(-Infinity);
|
this.curve.hear(-Infinity);
|
||||||
this.lastTick = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the level picture or the meter is moving, so the tab has to be drawn every frame. */
|
/** Whether the level picture is moving, so the tab has to be drawn every frame. */
|
||||||
get moving(): boolean {
|
get moving(): boolean {
|
||||||
return this.panel.visible ? this.panel.moving : this.levels.history.heard;
|
return this.levels.history.heard;
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the node shown is in its hardware look (#379). */
|
|
||||||
get hardware(): boolean {
|
|
||||||
const node = this.node();
|
|
||||||
return node !== null && switchOn(node.params, COMPRESS_HARDWARE_LOOK);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The panel is one size; the tab takes any. */
|
|
||||||
get fixedSize(): { w: number; h: number } | null {
|
|
||||||
return this.panel.visible ? { ...PANEL } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The panel or the tab, as the node's look says. */
|
|
||||||
private showFace(hardware: boolean): void {
|
|
||||||
if (this.panel.visible === hardware) return;
|
|
||||||
this.panel.visible = hardware;
|
|
||||||
this.header.visible = !hardware;
|
|
||||||
this.body.visible = !hardware;
|
|
||||||
const least = hardware ? PANEL : this.tabLeast;
|
|
||||||
this.minWidth = least.w;
|
|
||||||
this.minHeight = least.h;
|
|
||||||
this.relayout();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private node(): GraphNode | null {
|
private node(): GraphNode | null {
|
||||||
@@ -493,69 +446,40 @@ export class CompressorEditor extends VStack implements CompressorHost, PanelHos
|
|||||||
}
|
}
|
||||||
|
|
||||||
numbers(): CompressorNumbers {
|
numbers(): CompressorNumbers {
|
||||||
const params = this.node()?.params ?? {};
|
return compressorNumbers(this.node()?.params ?? {});
|
||||||
const numbers = {} as CompressorNumbers;
|
|
||||||
for (const key of KEYS) {
|
|
||||||
const value = params[key];
|
|
||||||
numbers[key] = typeof value === 'number' && Number.isFinite(value) ? value : ROWS[key].default;
|
|
||||||
}
|
|
||||||
numbers.autoGain = switchOn(params, COMPRESS_AUTO_GAIN);
|
|
||||||
return numbers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The dB auto-gain gives at these numbers, or that it is off. */
|
/** The dB auto-gain gives at these numbers, or that it is off. */
|
||||||
private showAutoGainDb(n: CompressorNumbers): void {
|
private showAutoGainDb(n: CompressorNumbers): void {
|
||||||
const text = autoGainText(n);
|
const text = autoGainText(n);
|
||||||
this.panel.autoGain.set(n.autoGain, text);
|
|
||||||
if (text === this.autoGainDb.text) return;
|
if (text === this.autoGainDb.text) return;
|
||||||
this.autoGainDb.setText(text);
|
this.autoGainDb.setText(text);
|
||||||
this.autoColumn.relayout();
|
this.autoColumn.relayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auto-gain on or off: one undo step, as a click on a knob is. */
|
/** Auto-gain on or off: one undo step, as a click on a knob is. */
|
||||||
switchAutoGain(on: boolean): void {
|
private switchAutoGain(on: boolean): void {
|
||||||
if (!this.node()) return;
|
if (!this.node()) return;
|
||||||
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, COMPRESS_AUTO_GAIN.key, on));
|
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, COMPRESS_AUTO_GAIN.key, on));
|
||||||
}
|
}
|
||||||
|
|
||||||
wired(key: Key): boolean {
|
wired(key: Key): boolean {
|
||||||
const graph = findGraphAsset(this.state.project, this.assetId)?.graph;
|
return compressorWired(this.state.project, this.assetId, this.nodeId, key);
|
||||||
return Boolean(graph?.edges.some((e) => e.to.node === this.nodeId && e.to.port === key));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Writes a number into the node as it is dragged; `commit` makes the undo step of it. */
|
/** Writes a number into the node as it is dragged; `commit` makes the undo step of it. */
|
||||||
preview(key: Key, value: number): void {
|
preview(key: Key, value: number): void {
|
||||||
const node = this.node();
|
const node = this.node();
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
if (this.before?.key !== key) {
|
this.drag.preview(node, key, value);
|
||||||
this.restore(node);
|
|
||||||
this.before = { key, value: node.params[key] };
|
|
||||||
}
|
|
||||||
node.params[key] = value;
|
|
||||||
this.perFrame.request();
|
|
||||||
this.showAutoGainDb(this.numbers());
|
this.showAutoGainDb(this.numbers());
|
||||||
this.levels.invalidate();
|
this.levels.invalidate();
|
||||||
this.curve.invalidate();
|
this.curve.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
commit(key: Key, value: number): void {
|
commit(key: Key, value: number): void {
|
||||||
this.perFrame.cancel();
|
|
||||||
const node = this.node();
|
const node = this.node();
|
||||||
if (!node) return;
|
if (node) this.drag.commit(this.assetId, node, key, value);
|
||||||
this.restore(node);
|
|
||||||
if (node.params[key] === value) {
|
|
||||||
this.state.preview();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, key, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
private restore(node: GraphNode): void {
|
|
||||||
const before = this.before;
|
|
||||||
if (!before) return;
|
|
||||||
if (before.value === undefined) delete node.params[before.key];
|
|
||||||
else node.params[before.key] = before.value;
|
|
||||||
this.before = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sync(): void {
|
private sync(): void {
|
||||||
@@ -567,10 +491,7 @@ export class CompressorEditor extends VStack implements CompressorHost, PanelHos
|
|||||||
}
|
}
|
||||||
this.autoGain.setChecked(numbers.autoGain);
|
this.autoGain.setChecked(numbers.autoGain);
|
||||||
this.showAutoGainDb(numbers);
|
this.showAutoGainDb(numbers);
|
||||||
const name = findGraphAsset(this.state.project, this.assetId)?.name ?? '';
|
const title = `Compressor – ${findGraphAsset(this.state.project, this.assetId)?.name ?? ''}`;
|
||||||
this.panel.sync(numbers, (key) => this.wired(key), name);
|
|
||||||
this.showFace(this.hardware);
|
|
||||||
const title = `Compressor – ${name}`;
|
|
||||||
if (title !== this.title.text) {
|
if (title !== this.title.text) {
|
||||||
this.title.setText(title);
|
this.title.setText(title);
|
||||||
this.relayout();
|
this.relayout();
|
||||||
@@ -580,9 +501,10 @@ export class CompressorEditor extends VStack implements CompressorHost, PanelHos
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What auto-gain gives at these numbers, or that it is off. */
|
/** Whether a link decides one of a Compressor's numbers: its knob is greyed then. */
|
||||||
function autoGainText(n: CompressorNumbers): string {
|
export function compressorWired(project: Project, assetId: string, nodeId: string, key: Key): boolean {
|
||||||
return n.autoGain ? `+${compressorAutoGain(n.threshold, n.knee, n.ratio).toFixed(1)} dB` : 'off';
|
const graph = findGraphAsset(project, assetId)?.graph;
|
||||||
|
return Boolean(graph?.edges.some((e) => e.to.node === nodeId && e.to.port === key));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The Compressor node a tab is about, or null once it, or its asset, is gone. */
|
/** The Compressor node a tab is about, or null once it, or its asset, is gone. */
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||||
|
import type { GraphNode } from '../../core/graph';
|
||||||
|
import { findGraphAsset } from '../../core/model/Project';
|
||||||
|
import { COMPRESS_AUTO_GAIN, tapId } from '../../core/soundfx/nodes';
|
||||||
|
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||||
|
import type { NodeFace } from '../graph/nodeFace';
|
||||||
|
import type { EditorState } from '../state';
|
||||||
|
import { compressorNodeOf, compressorWired } from './CompressorEditor';
|
||||||
|
import { CompressorPanel, type PanelHost } from './CompressorPanel';
|
||||||
|
import {
|
||||||
|
autoGainText,
|
||||||
|
type CompressorKey,
|
||||||
|
compressorGainDb,
|
||||||
|
compressorNumbers,
|
||||||
|
LEVEL_WINDOW,
|
||||||
|
} from './compressorView';
|
||||||
|
import { gainReduction } from './hardwareView';
|
||||||
|
import { NodeParamDrag } from './nodeParamDrag';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Compressor node drawn as its device (#380): the panel of #379, turned right in the graph. Each
|
||||||
|
* node has its own, and its meter runs while the node is in the picture.
|
||||||
|
*/
|
||||||
|
export class CompressorFace implements NodeFace, PanelHost {
|
||||||
|
readonly widget: CompressorPanel;
|
||||||
|
private readonly drag: NodeParamDrag;
|
||||||
|
private listening = false;
|
||||||
|
private lastTick = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly state: EditorState,
|
||||||
|
private readonly assetId: string,
|
||||||
|
private readonly nodeId: string,
|
||||||
|
) {
|
||||||
|
this.widget = new CompressorPanel(this);
|
||||||
|
this.drag = new NodeParamDrag(state, 'compressor-face');
|
||||||
|
}
|
||||||
|
|
||||||
|
private node(): GraphNode | null {
|
||||||
|
return compressorNodeOf(this.state.project, this.assetId, this.nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sync(): void {
|
||||||
|
const node = this.node();
|
||||||
|
if (!node) return;
|
||||||
|
const project = this.state.project;
|
||||||
|
const numbers = compressorNumbers(node.params);
|
||||||
|
this.widget.sync(
|
||||||
|
numbers,
|
||||||
|
(key) => compressorWired(project, this.assetId, this.nodeId, key),
|
||||||
|
findGraphAsset(project, this.assetId)?.name ?? '',
|
||||||
|
);
|
||||||
|
this.widget.autoGain.set(numbers.autoGain, autoGainText(numbers));
|
||||||
|
}
|
||||||
|
|
||||||
|
tick(): boolean {
|
||||||
|
const node = this.node();
|
||||||
|
if (!node) return false;
|
||||||
|
const id = tapId(this.assetId, this.nodeId);
|
||||||
|
soundLibrary.listen(id);
|
||||||
|
this.listening = true;
|
||||||
|
const levels = soundLibrary.nodeLevels(id, LEVEL_WINDOW);
|
||||||
|
const now = performance.now() / 1000;
|
||||||
|
const dt = this.lastTick ? now - this.lastTick : 0;
|
||||||
|
this.lastTick = now;
|
||||||
|
const numbers = compressorNumbers(node.params);
|
||||||
|
this.widget.read(gainReduction(levels, compressorGainDb(numbers)), levels?.out ?? -Infinity, dt);
|
||||||
|
return this.widget.moving;
|
||||||
|
}
|
||||||
|
|
||||||
|
hide(): void {
|
||||||
|
if (!this.listening) return;
|
||||||
|
this.listening = false;
|
||||||
|
this.lastTick = 0;
|
||||||
|
soundLibrary.unlisten(tapId(this.assetId, this.nodeId));
|
||||||
|
this.widget.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
preview(key: CompressorKey, value: number): void {
|
||||||
|
const node = this.node();
|
||||||
|
if (!node) return;
|
||||||
|
this.drag.preview(node, key, value);
|
||||||
|
const numbers = compressorNumbers(node.params);
|
||||||
|
this.widget.autoGain.set(numbers.autoGain, autoGainText(numbers));
|
||||||
|
}
|
||||||
|
|
||||||
|
commit(key: CompressorKey, value: number): void {
|
||||||
|
const node = this.node();
|
||||||
|
if (node) this.drag.commit(this.assetId, node, key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
switchAutoGain(on: boolean): void {
|
||||||
|
if (!this.node()) return;
|
||||||
|
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, COMPRESS_AUTO_GAIN.key, on));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* The Compressor as a device (#379): a brushed plate of one size with chrome knobs, a meter whose
|
* The Compressor as a device (#379): a brushed plate of one size with chrome knobs, a meter whose
|
||||||
* needle shows what is taken away, a lever for auto-gain and LEDs for the level going out. Its
|
* needle shows what is taken away, a lever for auto-gain and LEDs for the level going out. It is
|
||||||
* node says whether it is shown instead of the tab of #378.
|
* what its node shows in the graph (#380).
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
PANEL_LAYOUT,
|
PANEL_LAYOUT,
|
||||||
} from './hardwareView';
|
} from './hardwareView';
|
||||||
|
|
||||||
/** What the panel asks of the editor around it. */
|
/** What the panel asks of the node it sits in. */
|
||||||
export interface PanelHost {
|
export interface PanelHost {
|
||||||
preview(key: CompressorKey, value: number): void;
|
preview(key: CompressorKey, value: number): void;
|
||||||
commit(key: CompressorKey, value: number): void;
|
commit(key: CompressorKey, value: number): void;
|
||||||
@@ -264,6 +264,8 @@ class AutoGainLever extends Widget {
|
|||||||
this.says = crispText({ text: 'off', style });
|
this.says = crispText({ text: 'off', style });
|
||||||
this.says.eventMode = 'none';
|
this.says.eventMode = 'none';
|
||||||
this.addChild(this.says);
|
this.addChild(this.says);
|
||||||
|
// A press on the lever is not one on the node: it neither picks nor moves it.
|
||||||
|
this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
|
||||||
this.on('pointertap', (e: FederatedPointerEvent) => {
|
this.on('pointertap', (e: FederatedPointerEvent) => {
|
||||||
if (e.button === 0) onToggle(!this.lit);
|
if (e.button === 0) onToggle(!this.lit);
|
||||||
});
|
});
|
||||||
@@ -341,6 +343,7 @@ export class CompressorPanel extends Widget {
|
|||||||
decimals: look.decimals,
|
decimals: look.decimals,
|
||||||
size: PANEL_LAYOUT.knobs[key].size,
|
size: PANEL_LAYOUT.knobs[key].size,
|
||||||
look: 'chrome',
|
look: 'chrome',
|
||||||
|
typed: false,
|
||||||
onInput: (value) => host.preview(key, value),
|
onInput: (value) => host.preview(key, value),
|
||||||
onChange: (value) => host.commit(key, value),
|
onChange: (value) => host.commit(key, value),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
* grabbed. Kept apart from the drawing so it is checked without a browser.
|
* grabbed. Kept apart from the drawing so it is checked without a browser.
|
||||||
*/
|
*/
|
||||||
import { compressorAutoGain, compressorCurve } from '../../core/soundfx/dynamics';
|
import { compressorAutoGain, compressorCurve } from '../../core/soundfx/dynamics';
|
||||||
import { COMPRESS_ROWS } from '../../core/soundfx/nodes';
|
import type { PropertyBag } from '../../core/model/Properties';
|
||||||
|
import { COMPRESS_AUTO_GAIN, COMPRESS_ROWS, switchOn } from '../../core/soundfx/nodes';
|
||||||
import { clamp, snapToStep } from '../../ui/math';
|
import { clamp, snapToStep } from '../../ui/math';
|
||||||
import type { Levels } from '../../viewport/spectrum';
|
import type { Levels } from '../../viewport/spectrum';
|
||||||
|
|
||||||
@@ -53,6 +54,22 @@ export interface CompressorNumbers {
|
|||||||
const dbToLinear = (db: number) => 10 ** (db / 20);
|
const dbToLinear = (db: number) => 10 ** (db / 20);
|
||||||
const linearToDb = (value: number) => (value <= 0 ? -Infinity : 20 * Math.log10(value));
|
const linearToDb = (value: number) => (value <= 0 ? -Infinity : 20 * Math.log10(value));
|
||||||
|
|
||||||
|
/** A Compressor's numbers as its node has them: the defaults where none is set. */
|
||||||
|
export function compressorNumbers(params: PropertyBag): CompressorNumbers {
|
||||||
|
const numbers = {} as CompressorNumbers;
|
||||||
|
for (const key of COMPRESSOR_KEYS) {
|
||||||
|
const value = params[key];
|
||||||
|
numbers[key] = typeof value === 'number' && Number.isFinite(value) ? value : COMPRESSOR_ROWS[key].default;
|
||||||
|
}
|
||||||
|
numbers.autoGain = switchOn(params, COMPRESS_AUTO_GAIN);
|
||||||
|
return numbers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What auto-gain gives at these numbers, or that it is off. */
|
||||||
|
export function autoGainText(n: CompressorNumbers): string {
|
||||||
|
return n.autoGain ? `+${compressorAutoGain(n.threshold, n.knee, n.ratio).toFixed(1)} dB` : 'off';
|
||||||
|
}
|
||||||
|
|
||||||
/** What the gain after the compressing adds, in dB: the makeup, and the auto-gain where it is on. */
|
/** What the gain after the compressing adds, in dB: the makeup, and the auto-gain where it is on. */
|
||||||
export function compressorGainDb(n: CompressorNumbers): number {
|
export function compressorGainDb(n: CompressorNumbers): number {
|
||||||
return n.makeup + (n.autoGain ? compressorAutoGain(n.threshold, n.knee, n.ratio) : 0);
|
return n.makeup + (n.autoGain ? compressorAutoGain(n.threshold, n.knee, n.ratio) : 0);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { GraphNode } from '../../core/graph';
|
||||||
|
import { SoundFxCompressor } from '../../core/soundfx/nodes';
|
||||||
|
import type { NodeFace } from '../graph/nodeFace';
|
||||||
|
import type { EditorState } from '../state';
|
||||||
|
import { CompressorFace } from './CompressorFace';
|
||||||
|
|
||||||
|
/** The nodes drawn as a face of their own instead of rows (#380), by type. */
|
||||||
|
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
|
||||||
|
return node.type === SoundFxCompressor.type ? new CompressorFace(state, assetId, node.id) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||||
|
import type { GraphNode } from '../../core/graph';
|
||||||
|
import type { PropertyValue } from '../../core/model/Properties';
|
||||||
|
import { FramePreview } from '../framePreview';
|
||||||
|
import type { EditorState } from '../state';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A number of a node turned by a knob: written into the node as it goes, one undo step when it
|
||||||
|
* ends. A Compressor's tab (#378) and its node (#380) each have one.
|
||||||
|
*/
|
||||||
|
export class NodeParamDrag {
|
||||||
|
/** The value a drag began with – its undo step's other side; undefined when none was typed in. */
|
||||||
|
private before: { key: string; value: PropertyValue | undefined } | null = null;
|
||||||
|
private readonly perFrame: FramePreview;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly state: EditorState,
|
||||||
|
name: string,
|
||||||
|
) {
|
||||||
|
this.perFrame = new FramePreview(state, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
preview(node: GraphNode, key: string, value: number): void {
|
||||||
|
if (this.before?.key !== key) {
|
||||||
|
this.restore(node);
|
||||||
|
this.before = { key, value: node.params[key] };
|
||||||
|
}
|
||||||
|
node.params[key] = value;
|
||||||
|
this.perFrame.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
commit(assetId: string, node: GraphNode, key: string, value: PropertyValue): void {
|
||||||
|
this.perFrame.cancel();
|
||||||
|
this.restore(node);
|
||||||
|
if (node.params[key] === value) {
|
||||||
|
this.state.preview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state.execute(new SetNodeParamCommand(assetId, node.id, key, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The node as it was before the drag, which is left without a step. */
|
||||||
|
restore(node: GraphNode): void {
|
||||||
|
const before = this.before;
|
||||||
|
if (!before) return;
|
||||||
|
if (before.value === undefined) delete node.params[before.key];
|
||||||
|
else node.params[before.key] = before.value;
|
||||||
|
this.before = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Another node: what was begun on the last one is dropped. */
|
||||||
|
forget(): void {
|
||||||
|
this.perFrame.cancel();
|
||||||
|
this.before = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
this.perFrame.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ export interface KnobOptions extends KnobRange {
|
|||||||
size?: number;
|
size?: number;
|
||||||
/** `chrome`: a metal cap on a black skirt in a ring of marks, as on a device (#379). */
|
/** `chrome`: a metal cap on a black skirt in a ring of marks, as on a device (#379). */
|
||||||
look?: 'flat' | 'chrome';
|
look?: 'flat' | 'chrome';
|
||||||
|
/** Whether a click on the number types one; a knob on a node (#380) only shows it. */
|
||||||
|
typed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One for all chrome dials, laid over each cap's own bounds: light from the upper left. */
|
/** One for all chrome dials, laid over each cap's own bounds: light from the upper left. */
|
||||||
@@ -204,6 +206,7 @@ export class Knob extends VStack {
|
|||||||
onInput: (typed) => this.preview(typed),
|
onInput: (typed) => this.preview(typed),
|
||||||
onChange: (typed) => this.commit(typed),
|
onChange: (typed) => this.commit(typed),
|
||||||
});
|
});
|
||||||
|
if (options.typed === false) this.field.eventMode = 'none';
|
||||||
// Without a name the knob has none above it: a panel engraves its own.
|
// Without a name the knob has none above it: a panel engraves its own.
|
||||||
if (label) this.add(this.caption);
|
if (label) this.add(this.caption);
|
||||||
this.add(this.dial);
|
this.add(this.dial);
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
autoGainText,
|
||||||
type CompressorNumbers,
|
type CompressorNumbers,
|
||||||
compressorGainDb,
|
compressorGainDb,
|
||||||
|
compressorNumbers,
|
||||||
curveDbAtX,
|
curveDbAtX,
|
||||||
curveDbAtY,
|
curveDbAtY,
|
||||||
curveOutDb,
|
curveOutDb,
|
||||||
@@ -75,6 +77,26 @@ describe('the curve of a Compressor', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the numbers of a Compressor node (#380)', () => {
|
||||||
|
it('takes what the node has set, its defaults where it has none or no number, and reads an old auto-gain', () => {
|
||||||
|
expect(compressorNumbers({ threshold: -30, ratio: 'x', autoGain: 0.5 })).toEqual({
|
||||||
|
threshold: -30,
|
||||||
|
ratio: 4,
|
||||||
|
knee: 6,
|
||||||
|
attack: 0.003,
|
||||||
|
release: 0.25,
|
||||||
|
makeup: 0,
|
||||||
|
autoGain: true,
|
||||||
|
});
|
||||||
|
expect(compressorNumbers({}).autoGain).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says what auto-gain gives, or that it is off', () => {
|
||||||
|
expect(autoGainText({ ...numbers, autoGain: false })).toBe('off');
|
||||||
|
expect(autoGainText({ ...numbers, autoGain: true })).toMatch(/^\+\d+\.\d dB$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('the level picture of a Compressor', () => {
|
describe('the level picture of a Compressor', () => {
|
||||||
it('spans +6 to -60 dB top to bottom, silence at the bottom', () => {
|
it('spans +6 to -60 dB top to bottom, silence at the bottom', () => {
|
||||||
expect(levelY(6, 660)).toBe(0);
|
expect(levelY(6, 660)).toBe(0);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { closeDocument, neighbourOf, openDocument, replaceDocument } from '../../src/editor/documents';
|
import { closeDocument, neighbourOf, openDocument, replaceDocument } from '../../src/editor/documents';
|
||||||
import {
|
import {
|
||||||
keptSize,
|
|
||||||
leastBox,
|
leastBox,
|
||||||
MIN_WINDOW_HEIGHT,
|
MIN_WINDOW_HEIGHT,
|
||||||
MIN_WINDOW_WIDTH,
|
MIN_WINDOW_WIDTH,
|
||||||
@@ -105,13 +104,6 @@ describe('a tab in a window (#371)', () => {
|
|||||||
expect(onScreen({ ...box, w: 400, h: 300 }, 1280, 800, least)).toEqual({ ...box, w: 701, h: 450 });
|
expect(onScreen({ ...box, w: 400, h: 300 }, 1280, 800, least)).toEqual({ ...box, w: 701, h: 450 });
|
||||||
expect(onScreen({ ...box, w: 400, h: 300 }, 600, 400, least)).toEqual({ x: 0, y: 0, w: 600, h: 400 });
|
expect(onScreen({ ...box, w: 400, h: 300 }, 600, 400, least)).toEqual({ x: 0, y: 0, w: 600, h: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('takes the size of a pane of one size around the middle it had, and keeps a box of that size (#379)', () => {
|
|
||||||
const box = { x: 100, y: 100, w: 800, h: 600 };
|
|
||||||
expect(keptSize(box, { w: 400, h: 200 })).toEqual({ x: 300, y: 300, w: 400, h: 200 });
|
|
||||||
const kept = { x: 7, y: 9, w: 400, h: 200 };
|
|
||||||
expect(keptSize(kept, { w: 400, h: 200 })).toBe(kept);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('tab row without an active tab', () => {
|
describe('tab row without an active tab', () => {
|
||||||
|
|||||||
+67
-65
@@ -691,7 +691,6 @@ export async function sound(t) {
|
|||||||
threshold: -30,
|
threshold: -30,
|
||||||
ratio: 4,
|
ratio: 4,
|
||||||
knee: 0,
|
knee: 0,
|
||||||
hardwareLook: false,
|
|
||||||
}),
|
}),
|
||||||
)});
|
)});
|
||||||
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
|
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
|
||||||
@@ -837,7 +836,8 @@ export async function sound(t) {
|
|||||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||||
graph.edges.push(${JSON.stringify(edge('fx-e14', 'fx-in', 'depth', 'fx-comp', 'ratio'))});`);
|
graph.edges.push(${JSON.stringify(edge('fx-e14', 'fx-in', 'depth', 'fx-comp', 'ratio'))});`);
|
||||||
const compLinked = await json('__gm.compressor.knobs()');
|
const compLinked = await json('__gm.compressor.knobs()');
|
||||||
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
|
// Not the sound FX graph: the node there listens to the same taps (#380).
|
||||||
|
await showTab('scene');
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
const compUnseen = await json('__gm.compressor.levels()');
|
const compUnseen = await json('__gm.compressor.levels()');
|
||||||
await mutate(`
|
await mutate(`
|
||||||
@@ -854,10 +854,10 @@ export async function sound(t) {
|
|||||||
!compDocsAfter.docs.some((doc) => doc.startsWith('compressor:')),
|
!compDocsAfter.docs.some((doc) => doc.startsWith('compressor:')),
|
||||||
JSON.stringify({ compLinked, unseen: compUnseen.taps, compDocsAfter }),
|
JSON.stringify({ compLinked, unseen: compUnseen.taps, compDocsAfter }),
|
||||||
);
|
);
|
||||||
// #379: in its hardware look – a Compressor's default – the double-click opens a window of one
|
// #379, #380: a Compressor node is drawn as its device – its inputs on the left, its output on the
|
||||||
// size, with no corner to size it, no Dock and no tab. Its knobs and lever work as the tab's,
|
// right, the panel between them. Its knobs and its lever work right in the graph, and
|
||||||
// and played, the meter's needle swings and the LEDs light. The look switched off, the
|
// played, the meter's needle swings and the LEDs light. A double-click opens the tab of #378.
|
||||||
// window can be docked; switched on again, the tab leaves the row for a window.
|
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||||
graph.nodes.push(${JSON.stringify(node('fx-hw', 'SoundFxCompressor', -240, 0, { threshold: -50, ratio: 8, knee: 0, makeup: 20 }))});
|
graph.nodes.push(${JSON.stringify(node('fx-hw', 'SoundFxCompressor', -240, 0, { threshold: -50, ratio: 8, knee: 0, makeup: 20 }))});
|
||||||
@@ -866,87 +866,89 @@ export async function sound(t) {
|
|||||||
${JSON.stringify(edge('fx-e15', 'fx-gain', 'out', 'fx-hw', 'in'))},
|
${JSON.stringify(edge('fx-e15', 'fx-gain', 'out', 'fx-hw', 'in'))},
|
||||||
${JSON.stringify(edge('fx-e16', 'fx-hw', 'out', 'fx-out', 'in'))},
|
${JSON.stringify(edge('fx-e16', 'fx-hw', 'out', 'fx-out', 'in'))},
|
||||||
);`);
|
);`);
|
||||||
const hwDoc = `compressor:${fxId}:fx-hw`;
|
await evaluate('__gm.soundfx.fit()');
|
||||||
const hwNode = await json("__gm.soundfx.nodeAt('fx-hw')");
|
|
||||||
await t.doubleClick(hwNode.x, hwNode.y);
|
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
const hwWindow = async () => (await json('__gm.floatWindows()')).find((w) => w.kind === 'compressor');
|
const hwPanel = () => json("__gm.compressor.panel('fx-hw')");
|
||||||
const hwOpened = {
|
const hwShown = await hwPanel();
|
||||||
window: await hwWindow(),
|
const hwRect = hwShown.rect;
|
||||||
docs: await json('__gm.documents()'),
|
const hwInputs = Object.values(hwShown.inputs);
|
||||||
panel: await json('__gm.compressor.panel()'),
|
const hwOutputs = Object.values(hwShown.outputs);
|
||||||
};
|
const besideThePanel = (p) => p.y > hwRect.y && p.y < hwRect.y + hwRect.h;
|
||||||
await t.drag(
|
|
||||||
hwOpened.window.grip.x,
|
|
||||||
hwOpened.window.grip.y,
|
|
||||||
hwOpened.window.grip.x + 300,
|
|
||||||
hwOpened.window.grip.y + 200,
|
|
||||||
);
|
|
||||||
const hwSized = await hwWindow();
|
|
||||||
check(
|
check(
|
||||||
'a Compressor in its hardware look opens in a window of one size, with no tab, no Dock and no corner to size it',
|
'a Compressor node shows its panel at full size, its inputs on the left of it and its output on the right',
|
||||||
hwOpened.window?.doc === hwDoc &&
|
hwShown.shown &&
|
||||||
hwOpened.window.fixed &&
|
hwShown.size.w === 820 &&
|
||||||
hwOpened.window.box.w === hwOpened.panel.rect.w + 2 &&
|
hwShown.size.h === 280 &&
|
||||||
hwOpened.panel.shown &&
|
hwInputs.length === 8 &&
|
||||||
hwOpened.panel.rect.w === 820 &&
|
hwInputs.every((p) => p.x < hwRect.x && besideThePanel(p)) &&
|
||||||
hwOpened.panel.rect.h === 280 &&
|
hwOutputs.length === 1 &&
|
||||||
!hwOpened.docs.docs.includes(hwDoc) &&
|
hwOutputs.every((p) => p.x > hwRect.x + hwRect.w && besideThePanel(p)) &&
|
||||||
hwSized.box.w === hwOpened.window.box.w &&
|
hwShown.node.w > hwRect.w &&
|
||||||
hwSized.box.h === hwOpened.window.box.h,
|
hwShown.node.h > hwRect.h,
|
||||||
JSON.stringify({ hwOpened, hwSized: hwSized.box }),
|
JSON.stringify(hwShown),
|
||||||
);
|
);
|
||||||
const hwThreshold = hwOpened.panel.dials.threshold;
|
const hwThreshold = hwShown.dials.threshold;
|
||||||
await drag(hwThreshold.x, hwThreshold.y, hwThreshold.x, hwThreshold.y - 20);
|
await drag(hwThreshold.x, hwThreshold.y, hwThreshold.x, hwThreshold.y - 20);
|
||||||
const hwDragged = await json('__gm.compressor.numbers()');
|
const hwNumbers = () =>
|
||||||
|
json(
|
||||||
|
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-hw').params`,
|
||||||
|
);
|
||||||
|
const hwDragged = await hwNumbers();
|
||||||
await press('z', { ctrl: true });
|
await press('z', { ctrl: true });
|
||||||
const hwUndone = await json('__gm.compressor.numbers()');
|
const hwUndone = await hwNumbers();
|
||||||
await click(hwOpened.panel.autoGain.at.x, hwOpened.panel.autoGain.at.y);
|
const hwRatio = hwShown.dials.ratio;
|
||||||
|
await t.wheel(hwRatio.x, hwRatio.y, -100);
|
||||||
|
const hwWheeled = { numbers: await hwNumbers(), panel: await hwPanel() };
|
||||||
|
await t.doubleClick(hwRatio.x, hwRatio.y);
|
||||||
|
const hwReset = { numbers: await hwNumbers(), docs: await json('__gm.documents()') };
|
||||||
|
await click(hwShown.autoGain.at.x, hwShown.autoGain.at.y);
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
const hwLever = await json('({ numbers: __gm.compressor.numbers(), panel: __gm.compressor.panel() })');
|
const hwLever = { numbers: await hwNumbers(), panel: await hwPanel() };
|
||||||
check(
|
check(
|
||||||
'a chrome knob turns with a drag up as one undo step, and the lever switches auto-gain and says what it gives',
|
'in the node, a knob turns with a drag up as one undo step and a notch of the wheel without zooming the graph, a double-click resets it without opening the tab, and the lever switches auto-gain',
|
||||||
hwDragged.threshold > -50 &&
|
hwDragged.threshold > -50 &&
|
||||||
hwUndone.threshold === -50 &&
|
hwUndone.threshold === -50 &&
|
||||||
|
hwWheeled.numbers.ratio > 8 &&
|
||||||
|
Math.abs(hwWheeled.panel.rect.w - hwRect.w) < 0.5 &&
|
||||||
|
hwReset.numbers.ratio === 4 &&
|
||||||
|
hwReset.docs.active === `asset:${fxId}` &&
|
||||||
hwLever.numbers.autoGain === true &&
|
hwLever.numbers.autoGain === true &&
|
||||||
hwLever.panel.autoGain.on &&
|
hwLever.panel.autoGain.on &&
|
||||||
/^\+\d+\.\d dB$/.test(hwLever.panel.autoGain.says),
|
/^\+\d+\.\d dB$/.test(hwLever.panel.autoGain.says),
|
||||||
JSON.stringify({ hwDragged, hwUndone, hwLever }),
|
JSON.stringify({ hwDragged, hwUndone, hwWheeled, hwReset, hwLever }),
|
||||||
);
|
);
|
||||||
await press('z', { ctrl: true });
|
await press('z', { ctrl: true });
|
||||||
await evaluate('__gm.sound.stopAll()');
|
await evaluate('__gm.sound.stopAll()');
|
||||||
await evaluate('__gm.soundfx.play()');
|
await evaluate('__gm.soundfx.play()');
|
||||||
const hwPlayed = await waitFor(
|
const hwPlayed = await waitFor(hwPanel, (p) => p.seen.needle > 3 && p.seen.leds > 0);
|
||||||
() => json('__gm.compressor.panel()'),
|
await shot('34-compressor-node');
|
||||||
(p) => p.seen.needle > 3 && p.seen.leds > 0,
|
|
||||||
);
|
|
||||||
await shot('34-compressor-hardware');
|
|
||||||
check(
|
check(
|
||||||
'played, the needle swings towards what is taken away and the LEDs light with the level going out',
|
'played, the needle in the node swings towards what is taken away and the LEDs light with the level going out',
|
||||||
hwPlayed.seen.needle > 3 && hwPlayed.seen.leds > 0 && /GR +\d+\.\d dB/.test(hwPlayed.lcd),
|
hwPlayed.seen.needle > 3 && hwPlayed.seen.leds > 0 && /GR +\d+\.\d dB/.test(hwPlayed.lcd),
|
||||||
JSON.stringify(hwPlayed),
|
JSON.stringify(hwPlayed),
|
||||||
);
|
);
|
||||||
await evaluate('__gm.sound.stopAll()');
|
await evaluate('__gm.sound.stopAll()');
|
||||||
await mutate(`
|
const hwTitle = await json("__gm.soundfx.nodeAt('fx-hw')");
|
||||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
await t.doubleClick(hwTitle.x, hwTitle.y);
|
||||||
graph.nodes.find((n) => n.id === 'fx-hw').params.hardwareLook = false;`);
|
await sleep(100);
|
||||||
const hwStandard = { window: await hwWindow(), panel: await json('__gm.compressor.panel()') };
|
const hwTab = {
|
||||||
await click(hwStandard.window.dock.x, hwStandard.window.dock.y);
|
docs: await json('__gm.documents()'),
|
||||||
const hwDocked = { windows: await json('__gm.floatWindows()'), docs: await json('__gm.documents()') };
|
windows: await json('__gm.floatWindows()'),
|
||||||
await mutate(`
|
shown: await json('__gm.compressor.shown()'),
|
||||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
};
|
||||||
graph.nodes.find((n) => n.id === 'fx-hw').params.hardwareLook = true;`);
|
// The tab and the node listen to the same taps: the tab, open but not seen, must leave them be.
|
||||||
const hwBack = { window: await hwWindow(), docs: await json('__gm.documents()') };
|
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
|
||||||
|
await sleep(100);
|
||||||
|
await evaluate('__gm.soundfx.play()');
|
||||||
|
const hwBesideTab = await waitFor(hwPanel, (p) => p.seen.needle > 3);
|
||||||
|
await evaluate('__gm.sound.stopAll()');
|
||||||
check(
|
check(
|
||||||
'the look switched off, the window can be sized and docked; switched on again, the tab leaves the row for a window',
|
'a double-click on the node opens its tab in the row, and with the tab open the node’s needle still swings',
|
||||||
!hwStandard.window.fixed &&
|
hwTab.docs.active === `compressor:${fxId}:fx-hw` &&
|
||||||
!hwStandard.panel.shown &&
|
hwTab.windows.length === 0 &&
|
||||||
hwDocked.windows.length === 0 &&
|
hwTab.shown?.nodeId === 'fx-hw' &&
|
||||||
hwDocked.docs.active === hwDoc &&
|
hwBesideTab.seen.needle > 3,
|
||||||
hwBack.window?.doc === hwDoc &&
|
JSON.stringify({ hwTab, seen: hwBesideTab.seen }),
|
||||||
hwBack.window.fixed &&
|
|
||||||
!hwBack.docs.docs.includes(hwDoc),
|
|
||||||
JSON.stringify({ hwStandard, hwDocked, hwBack }),
|
|
||||||
);
|
);
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||||
|
|||||||
Reference in new issue
Block a user