Show a Gain node as an orange device with one knob (#384)

A Gain node now draws a small device in the graph, as the Compressor
does since #380: the brushed plate in orange, 160x220 graph units, with
one chrome knob between its inputs and its output.

- GainPanel and GainFace join the node faces. The knob drags, resets
  on a double-click and keeps the wheel for the graph (#383). Each turn
  is one undo step, a linked Gain (dB) greys it.
- The knob turns with a taper of 0.26, so 0 dB stands straight up and
  the range below -24 dB takes a fifth of the turn.
- drawPlate, engrave and the brushed look move out of CompressorPanel
  so both devices share them. The Knob takes an arc colour, because
  the orange arc vanished on the orange plate.
- No meter and no tap. Gain sits in every voice of an instrument, and
  a tap would add two audio nodes to each note for a display nobody
  asked for.

Found on the way:
- Knob.dialGlobal added half the dial unscaled. In a zoomed graph it
  pointed beside the dial. The Compressor's e2e only hit by luck.

Tests:
- Unit: 2 new for the Gain plate and its taper.
- e2e 34 on atx: ALL PASSED, 126 checks (1 new).
- Gate on atx: CHECK EXIT 0, 283 files, 4359 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 13:07:44 +02:00
1 parent 79a04e6764
commit 01ee61868b
12 files changed
+348 -48

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1956 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1957 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+3 -3
View File
@@ -535,7 +535,7 @@ export const SoundFxOutput: SoundFxNodeDef = {
emit: arriving,
};
const GAIN = numberRow('gain', 'Gain (dB)', -120, 24, 0.5);
export const GAIN_ROW = numberRow('gain', 'Gain (dB)', -120, 24, 0.5);
/** How an effect with a Bypass and a Wet says so, at the end of its description (#354). */
const BLENDS =
@@ -549,11 +549,11 @@ export const SoundFxGain: SoundFxNodeDef = effect(
description:
'Makes the sound louder or quieter, in decibels: 0 leaves it as it is, -6 is about half as loud, +6 twice. Above 0 it can clip.' +
BLENDS,
inputs: [audioIn, followingIn(GAIN)],
inputs: [audioIn, followingIn(GAIN_ROW)],
outputs: [audioOut],
params: [],
emit: (sounds, amounts) => {
const db = held(amounts.gain, GAIN);
const db = held(amounts.gain, GAIN_ROW);
return withStages(arriving(sounds), isNone(db) ? [] : [{ kind: 'gain', db }]);
},
},
+29
View File
@@ -4,6 +4,7 @@ import { soundLibrary } from '../../viewport/SoundLibrary';
import type { Widget } from '../../ui';
import { currentNodePalette } from '../graph/NodePalette';
import { CompressorPanel } from '../soundfx/CompressorPanel';
import { GainPanel } from '../soundfx/GainPanel';
import { EqPanel } from '../soundfx/EqPanel';
import { rectOf } from '../textProbe';
import { centreOf, snapButton, type HookContext } from './hookHelpers';
@@ -243,6 +244,34 @@ export function graphHooks({ state, editor }: HookContext) {
};
},
},
/**
* The panel a Gain node shows in the sound FX graph (#384): whether it is shown and where, its
* knob, and the node's ports.
*/
gain: {
panel: (nodeId: string) => {
const canvas = editor.soundFxEditor.canvas;
const panel = canvas.faceOf(nodeId)?.widget;
if (!(panel instanceof GainPanel)) return null;
const node = canvas.nodeScreen(nodeId);
const ports = (keys: string[], isOutput: boolean) =>
Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)]));
return {
shown: shownOnScreen(panel),
rect: screenRect(panel),
size: { w: panel.w, h: panel.h },
node: node?.rect ?? null,
inputs: ports(node?.inputs ?? [], false),
outputs: ports(node?.outputs ?? [], true),
knob: {
value: panel.knob.value,
enabled: panel.knob.enabled,
number: panel.knob.shown,
dial: panel.knob.dialGlobal(),
},
};
},
},
instrument: {
current: () => editor.instrumentEditor.currentAssetId,
info: () => editor.instrumentEditor.shownInfo,
+2 -2
View File
@@ -501,8 +501,8 @@ export class CompressorEditor extends VStack implements CompressorHost {
}
}
/** Whether a link decides one of a Compressor's numbers: its knob is greyed then. */
export function compressorWired(project: Project, assetId: string, nodeId: string, key: Key): boolean {
/** Whether a link decides one of a Compressor's (or a Gain's, #384) numbers: its knob is greyed then. */
export function compressorWired(project: Project, assetId: string, nodeId: string, key: string): boolean {
const graph = findGraphAsset(project, assetId)?.graph;
return Boolean(graph?.edges.some((e) => e.to.node === nodeId && e.to.port === key));
}
+54 -29
View File
@@ -46,12 +46,12 @@ export interface PanelHost {
switchAutoGain(on: boolean): void;
}
const INK = 0x26282c;
export const INK = 0x26282c;
const LCD_GLOW = 0x9cf58c;
const LED_LIT = { green: 0x4dff5a, amber: 0xffc21a, red: 0xff3b2a };
/** Captions are engraved: dark letters with a light edge under them. */
function engrave(parent: Container, text: string, size = 10): { ink: Text; edge: Text } {
export function engrave(parent: Container, text: string, size = 10): { ink: Text; edge: Text } {
const style = (fill: number): TextStyle => {
const s = textStyle('small').clone();
s.fill = fill;
@@ -71,12 +71,11 @@ function engrave(parent: Container, text: string, size = 10): { ink: Text; edge:
return { ink, edge };
}
function placeEngraving(e: { ink: Text; edge: Text }, x: number, y: number): void {
export function placeEngraving(e: { ink: Text; edge: Text }, x: number, y: number): void {
e.ink.position.set(Math.round(x), Math.round(y));
e.edge.position.set(Math.round(x), Math.round(y) + 1);
}
let plateFill: FillGradient | null = null;
let faceFill: FillGradient | null = null;
const gradient = (stops: [number, number][], end = { x: 0, y: 1 }) =>
new FillGradient({
@@ -87,6 +86,55 @@ const gradient = (stops: [number, number][], end = { x: 0, y: 1 }) =>
textureSpace: 'local',
});
/** The metal of a plate: its shade from top to bottom, its rim, and the dark strokes of the brush. */
export interface PlateLook {
stops: [number, number][];
rim: number;
grain: number;
}
/** Brushed aluminium, the Compressor's. */
const ALUMINIUM: PlateLook = {
stops: [
[0, 0xe2e4e7],
[0.5, 0xcfd2d6],
[1, 0xb4b8bd],
],
rim: 0x8a8e94,
grain: 0x6c7076,
};
const plateFills = new WeakMap<PlateLook, FillGradient>();
/** A brushed plate with a screw in each corner. */
export function drawPlate(
g: Graphics,
size: { w: number; h: number },
look: PlateLook,
screws: readonly { x: number; y: number }[],
): void {
const { w, h } = size;
let fill = plateFills.get(look);
if (!fill) plateFills.set(look, (fill = gradient(look.stops)));
g.roundRect(0, 0, w, h, 6).fill(fill);
for (const stroke of brushStrokes(h - 4))
g.rect(3, stroke.y + 2, w - 6, 1).fill({
color: stroke.light ? 0xffffff : look.grain,
alpha: stroke.alpha,
});
g.roundRect(0.5, 0.5, w - 1, h - 1, 6).stroke({ color: look.rim, width: 1 });
g.moveTo(3, 1.5)
.lineTo(w - 3, 1.5)
.stroke({ color: 0xffffff, width: 1, alpha: 0.8 });
for (const screw of screws) {
g.circle(screw.x, screw.y, 6).fill(0x9a9ea4).stroke({ color: 0x5f6369, width: 1 });
g.circle(screw.x - 1, screw.y - 1, 4).fill({ color: 0xffffff, alpha: 0.35 });
g.moveTo(screw.x - 4, screw.y + 3)
.lineTo(screw.x + 4, screw.y - 3)
.stroke({ color: 0x45484d, width: 1.5 });
}
}
/** The meter: a lit face, a scale from 0 to the range, and a needle that swings as a VU's does. */
class ReductionMeter extends Widget {
private readonly face = new Graphics();
@@ -201,7 +249,7 @@ class ReductionMeter extends Widget {
}
/** The level going out, as a column of LEDs: lit at once, going out slowly. */
class OutputLeds extends Widget {
export class OutputLeds extends Widget {
private readonly lights = new Graphics();
shown = -Infinity;
private lit = -1;
@@ -430,30 +478,7 @@ export class CompressorPanel extends Widget {
}
override redraw(): void {
const { w, h } = PANEL;
const g = this.plate.clear();
plateFill ??= gradient([
[0, 0xe2e4e7],
[0.5, 0xcfd2d6],
[1, 0xb4b8bd],
]);
g.roundRect(0, 0, w, h, 6).fill(plateFill);
for (const stroke of brushStrokes(h - 4))
g.rect(3, stroke.y + 2, w - 6, 1).fill({
color: stroke.light ? 0xffffff : 0x6c7076,
alpha: stroke.alpha,
});
g.roundRect(0.5, 0.5, w - 1, h - 1, 6).stroke({ color: 0x8a8e94, width: 1 });
g.moveTo(3, 1.5)
.lineTo(w - 3, 1.5)
.stroke({ color: 0xffffff, width: 1, alpha: 0.8 });
for (const screw of PANEL_LAYOUT.screws) {
g.circle(screw.x, screw.y, 6).fill(0x9a9ea4).stroke({ color: 0x5f6369, width: 1 });
g.circle(screw.x - 1, screw.y - 1, 4).fill({ color: 0xffffff, alpha: 0.35 });
g.moveTo(screw.x - 4, screw.y + 3)
.lineTo(screw.x + 4, screw.y - 3)
.stroke({ color: 0x45484d, width: 1.5 });
}
drawPlate(this.plate.clear(), PANEL, ALUMINIUM, PANEL_LAYOUT.screws);
const lcd = PANEL_LAYOUT.lcd;
this.lcdBox
.clear()
+59
View File
@@ -0,0 +1,59 @@
import type { GraphNode } from '../../core/graph';
import { findGraphAsset } from '../../core/model/Project';
import { GAIN_ROW, SoundFxGain } from '../../core/soundfx/nodes';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor';
import { type GainHost, GainPanel } from './GainPanel';
import { NodeParamDrag } from './nodeParamDrag';
/**
* A Gain node drawn as its device (#384): one knob, turned right in the graph. It listens to
* nothing, so it has nothing to move between changes.
*/
export class GainFace implements NodeFace, GainHost {
readonly widget: GainPanel;
private readonly drag: NodeParamDrag;
constructor(
private readonly state: EditorState,
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new GainPanel(this);
this.drag = new NodeParamDrag(state, 'gain-face');
}
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === SoundFxGain.type ? node : null;
}
sync(): void {
const node = this.node();
if (!node) return;
const db = node.params[GAIN_ROW.key];
this.widget.sync(
typeof db === 'number' && Number.isFinite(db) ? db : GAIN_ROW.default,
compressorWired(this.state.project, this.assetId, this.nodeId, GAIN_ROW.key),
);
}
tick(): boolean {
return false;
}
hide(): void {}
preview(value: number): void {
const node = this.node();
if (node) this.drag.preview(node, GAIN_ROW.key, value);
}
commit(value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, GAIN_ROW.key, value);
}
}
+85
View File
@@ -0,0 +1,85 @@
/**
* The Gain as a device (#384), what its node shows in the graph: the Compressor's plate (#379) in
* orange, with one big chrome knob.
*/
import { Graphics, type Text } from 'pixi.js';
import { Knob, Widget } from '../../ui';
import { GAIN_ROW } from '../../core/soundfx/nodes';
import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel';
import { GAIN_LAYOUT, GAIN_PANEL, GAIN_TAPER, KNOB_CAPTION } from './hardwareView';
/** What the panel asks of the node it sits in. */
export interface GainHost {
preview(value: number): void;
commit(value: number): void;
}
/** Brushed anodised orange. */
const ORANGE: PlateLook = {
stops: [
[0, 0xffb25e],
[0.5, 0xf39433],
[1, 0xd8761c],
],
rim: 0xa85616,
grain: 0x8a3e08,
};
/** The knob's value arc: the usual orange would vanish on this plate. */
const DARK_ARC = 0x3b1a06;
export class GainPanel extends Widget {
private readonly plate = new Graphics();
readonly knob: Knob;
private readonly title: { ink: Text; edge: Text };
private readonly caption: { ink: Text; edge: Text };
constructor(host: GainHost) {
super();
this.minWidth = GAIN_PANEL.w;
this.minHeight = GAIN_PANEL.h;
this.addChild(this.plate);
this.title = engrave(this, 'GAIN', 14);
this.caption = engrave(this, 'DB', 11);
const row = GAIN_ROW;
this.knob = new Knob('', row.default, {
min: row.min ?? 0,
max: row.max ?? 0,
step: row.step ?? 1,
taper: GAIN_TAPER,
default: row.default,
decimals: 1,
size: GAIN_LAYOUT.knob.size,
look: 'chrome',
typed: false,
ink: INK,
arc: DARK_ARC,
onInput: (value) => host.preview(value),
onChange: (value) => host.commit(value),
});
this.addChild(this.knob);
}
override measure(): { w: number; h: number } {
return { ...GAIN_PANEL };
}
/** The node's gain, and whether a link feeds it, which greys the knob. */
sync(db: number, wired: boolean): void {
this.knob.setValue(db);
this.knob.setEnabled(!wired);
}
override layout(): void {
const L = GAIN_LAYOUT;
const spot = L.knob;
const size = this.knob.measure();
this.knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
this.knob.resize(size.w, size.h);
placeEngraving(this.caption, spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
placeEngraving(this.title, L.title.x, L.title.y + 6);
}
override redraw(): void {
drawPlate(this.plate.clear(), GAIN_PANEL, ORANGE, GAIN_LAYOUT.screws);
}
}
+26 -6
View File
@@ -46,6 +46,16 @@ export interface PanelLayout {
const SCREW_INSET = 14;
/** A screw in each corner of a plate. */
export function screwsOf(plate: { w: number; h: number }): { x: number; y: number }[] {
return [
{ x: SCREW_INSET, y: SCREW_INSET },
{ x: plate.w - SCREW_INSET, y: SCREW_INSET },
{ x: SCREW_INSET, y: plate.h - SCREW_INSET },
{ x: plate.w - SCREW_INSET, y: plate.h - SCREW_INSET },
];
}
/** Threshold big on the left, makeup big on the right, the times and the shape small beside the meter. */
export const PANEL_LAYOUT: PanelLayout = {
title: { x: 34, y: 14, w: 220, h: 30 },
@@ -61,14 +71,24 @@ export const PANEL_LAYOUT: PanelLayout = {
release: { x: 598, y: 205, size: SMALL_DIAL },
makeup: { x: 712, y: 160, size: BIG_DIAL },
},
screws: [
{ x: SCREW_INSET, y: SCREW_INSET },
{ x: PANEL.w - SCREW_INSET, y: SCREW_INSET },
{ x: SCREW_INSET, y: PANEL.h - SCREW_INSET },
{ x: PANEL.w - SCREW_INSET, y: PANEL.h - SCREW_INSET },
],
screws: screwsOf(PANEL),
};
/** The Gain as a device (#384): an orange plate with one big knob. */
export const GAIN_PANEL = { w: 160, h: 220 };
export const GAIN_LAYOUT = {
title: { x: GAIN_PANEL.w / 2, y: 14 },
knob: { x: GAIN_PANEL.w / 2, y: 120, size: BIG_DIAL },
screws: screwsOf(GAIN_PANEL),
};
/**
* How the Gain's knob turns, from -120 to +24 dB: below 1 the loud end gets more of the turn, and
* 0.26 puts 0 dB straight up, so the quiet tail, which is all but silence, goes by quickly.
*/
export const GAIN_TAPER = 0.26;
/** All a knob takes on the plate: its name above, its dial and its number below. */
export function knobBox(spot: KnobSpot): Box {
const w = Math.max(spot.size, KNOB_LEAST_WIDTH);
+4 -2
View File
@@ -1,13 +1,15 @@
import type { GraphNode } from '../../core/graph';
import { SoundFxCompressor, SoundFxParametricEq } from '../../core/soundfx/nodes';
import { SoundFxCompressor, SoundFxGain, SoundFxParametricEq } from '../../core/soundfx/nodes';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { CompressorFace } from './CompressorFace';
import { EqFace } from './EqFace';
import { GainFace } from './GainFace';
/** The nodes drawn as a face of their own instead of rows (#380, #381), by type. */
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384), by type. */
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
if (node.type === SoundFxCompressor.type) return new CompressorFace(state, assetId, node.id);
if (node.type === SoundFxParametricEq.type) return new EqFace(state, assetId, node.id);
if (node.type === SoundFxGain.type) return new GainFace(state, assetId, node.id);
return null;
}
+6 -4
View File
@@ -41,6 +41,8 @@ export interface KnobOptions extends KnobRange {
format?: (value: number) => string;
/** The shown number's colour, to stand on a panel's own plate. */
ink?: number;
/** The colour of a chrome dial's value arc, when the plate is too near its orange (#384). */
arc?: number;
}
/** One for all chrome dials, laid over each cap's own bounds: light from the upper left. */
@@ -157,7 +159,7 @@ class Dial extends Widget {
g.arc(x, y, track, angle(0), angle(1)).stroke({ color: 0x55595f, width: 3, alpha: 0.35 });
if (at !== origin)
g.arc(x, y, track, angle(Math.min(at, origin)), angle(Math.max(at, origin))).stroke({
color: this.knob.enabled ? CHROME_ARC : 0x8b8f95,
color: this.knob.enabled ? (this.knob.options.arc ?? CHROME_ARC) : 0x8b8f95,
width: 3,
});
// The black skirt with its grip, the metal cap on it.
@@ -278,9 +280,9 @@ export class Knob extends VStack {
return this.options.format?.(shown) ?? formatNumber(shown, this.options.decimals);
}
/** Where the dial is on screen, for the check scripts. */
/** Where the dial's middle is on screen, for the check scripts; also in a zoomed graph (#384). */
dialGlobal(): { x: number; y: number } {
const at = this.dial.getGlobalPosition();
return { x: at.x + this.dial.w / 2, y: at.y + this.dial.h / 2 };
const at = this.dial.toGlobal({ x: this.dial.w / 2, y: this.dial.h / 2 });
return { x: at.x, y: at.y };
}
}
+29 -1
View File
@@ -1,11 +1,15 @@
/**
* The Compressor's hardware panel (#379): every part on the plate and none over another, the meter's
* scale and needle, the LEDs, and the grain of the plate the same every time.
* scale and needle, the LEDs, and the grain of the plate the same every time. The Gain's plate
* (#384) and how its one knob turns.
*/
import { describe, expect, it } from 'vitest';
import {
type Box,
brushStrokes,
GAIN_LAYOUT,
GAIN_PANEL,
GAIN_TAPER,
gainReduction,
knobBox,
LED_COUNT,
@@ -21,6 +25,8 @@ import {
PANEL,
PANEL_LAYOUT,
} from '../../src/editor/soundfx/hardwareView';
import { GAIN_ROW } from '../../src/core/soundfx/nodes';
import { knobPosition } from '../../src/ui/knob';
const overlap = (a: Box, b: Box) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
@@ -129,3 +135,25 @@ describe('the LEDs', () => {
expect(ledStep(-6, -Infinity, 10)).toBe(-11);
});
});
describe('the Gain (#384)', () => {
const L = GAIN_LAYOUT;
const knob = knobBox(L.knob);
it('holds its knob inside the plate, clear of the screws and below the title', () => {
expect(knob.x).toBeGreaterThanOrEqual(0);
expect(knob.x + knob.w).toBeLessThanOrEqual(GAIN_PANEL.w);
expect(knob.y + knob.h).toBeLessThanOrEqual(GAIN_PANEL.h);
expect(knob.y).toBeGreaterThanOrEqual(L.title.y + 30);
expect(knob.x + knob.w / 2).toBe(GAIN_PANEL.w / 2);
for (const screw of L.screws)
expect(overlap(knob, { x: screw.x - 7, y: screw.y - 7, w: 14, h: 14 })).toBe(false);
});
it('turns 0 dB straight up and the quiet tail by in a fifth of the turn', () => {
const range = { min: GAIN_ROW.min ?? 0, max: GAIN_ROW.max ?? 0, step: 0.5, taper: GAIN_TAPER };
expect(knobPosition(0, range)).toBeCloseTo(0.5, 2);
expect(knobPosition(-24, range)).toBeLessThan(0.22);
expect(knobPosition(-6, range)).toBeGreaterThan(0.4);
});
});
+50
View File
@@ -1026,6 +1026,56 @@ export async function sound(t) {
hwBesideTab.seen.needle > 3,
JSON.stringify({ hwTab, seen: hwBesideTab.seen }),
);
// #384: a Gain node is drawn as an orange device with its one knob, worked as the Compressor's.
// The Compressor's node is wide enough to lie over the Gain's: the Gain goes below it here.
const gainUnder = await json("__gm.compressor.panel('fx-hw').node");
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.find((n) => n.id === 'fx-gain').y = 400;`);
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const gainPanel = () => json("__gm.gain.panel('fx-gain')");
const gainShown = await gainPanel();
const gainRect = gainShown.rect;
const gainBeside = (p) => p.y > gainRect.y && p.y < gainRect.y + gainRect.h;
const gainDb = () =>
json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-gain').params.gain`,
);
const gainDial = gainShown.knob.dial;
await drag(gainDial.x, gainDial.y, gainDial.x, gainDial.y - 20);
const gainDragged = await gainDb();
await press('z', { ctrl: true });
const gainUndone = await gainDb();
await t.wheel(gainDial.x, gainDial.y, -100);
const gainWheeled = { db: await gainDb(), panel: await gainPanel() };
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const gainRefit = (await gainPanel()).knob.dial;
await t.doubleClick(gainRefit.x, gainRefit.y);
const gainReset = { db: await gainDb(), docs: await json('__gm.documents()') };
await press('z', { ctrl: true });
check(
'a Gain node shows its panel with one knob between its ports; a drag turns it as one undo step, the wheel zooms the graph, a double-click resets it without a tab',
gainShown.shown &&
gainShown.size.w === 160 &&
gainShown.size.h === 220 &&
gainShown.knob.number === '-6' &&
gainShown.knob.enabled &&
Object.values(gainShown.inputs).length === 3 &&
Object.values(gainShown.inputs).every((p) => p.x < gainRect.x && gainBeside(p)) &&
Object.values(gainShown.outputs).every((p) => p.x > gainRect.x + gainRect.w && gainBeside(p)) &&
gainDragged > -6 &&
gainUndone === -6 &&
gainWheeled.db === -6 &&
gainWheeled.panel.rect.w > gainRect.w + 5 &&
gainReset.db === 0 &&
gainReset.docs.active === `asset:${fxId}`,
JSON.stringify({ gainUnder, gainShown, gainDragged, gainUndone, gainWheeled, gainReset }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.find((n) => n.id === 'fx-gain').y = 0;`);
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-hw');