From 01ee61868b3aca346c1808f33b15ee21a9ea6040 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 10 Oct 2026 13:07:44 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/core/soundfx/nodes.ts | 6 +- src/editor/debug/graphHooks.ts | 29 +++++++++ src/editor/soundfx/CompressorEditor.ts | 4 +- src/editor/soundfx/CompressorPanel.ts | 83 ++++++++++++++++--------- src/editor/soundfx/GainFace.ts | 59 ++++++++++++++++++ src/editor/soundfx/GainPanel.ts | 85 ++++++++++++++++++++++++++ src/editor/soundfx/hardwareView.ts | 32 ++++++++-- src/editor/soundfx/nodeFaces.ts | 6 +- src/ui/widgets/Knob.ts | 10 +-- tests/editor/hardware-view.test.ts | 30 ++++++++- tools/e2e/34-sound.mjs | 50 +++++++++++++++ 12 files changed, 348 insertions(+), 48 deletions(-) create mode 100644 src/editor/soundfx/GainFace.ts create mode 100644 src/editor/soundfx/GainPanel.ts diff --git a/README.md b/README.md index 4bffd9b1..9795d462 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 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 diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index 4a5b9b68..34a6c4ad 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -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 }]); }, }, diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 3db19f6c..c4be3b4f 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -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, diff --git a/src/editor/soundfx/CompressorEditor.ts b/src/editor/soundfx/CompressorEditor.ts index 832c206f..c700f61d 100644 --- a/src/editor/soundfx/CompressorEditor.ts +++ b/src/editor/soundfx/CompressorEditor.ts @@ -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)); } diff --git a/src/editor/soundfx/CompressorPanel.ts b/src/editor/soundfx/CompressorPanel.ts index 68229aed..405a1a5c 100644 --- a/src/editor/soundfx/CompressorPanel.ts +++ b/src/editor/soundfx/CompressorPanel.ts @@ -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(); + +/** 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() diff --git a/src/editor/soundfx/GainFace.ts b/src/editor/soundfx/GainFace.ts new file mode 100644 index 00000000..d073d524 --- /dev/null +++ b/src/editor/soundfx/GainFace.ts @@ -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); + } +} diff --git a/src/editor/soundfx/GainPanel.ts b/src/editor/soundfx/GainPanel.ts new file mode 100644 index 00000000..c36e6423 --- /dev/null +++ b/src/editor/soundfx/GainPanel.ts @@ -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); + } +} diff --git a/src/editor/soundfx/hardwareView.ts b/src/editor/soundfx/hardwareView.ts index 1d8dc520..cc4fb448 100644 --- a/src/editor/soundfx/hardwareView.ts +++ b/src/editor/soundfx/hardwareView.ts @@ -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); diff --git a/src/editor/soundfx/nodeFaces.ts b/src/editor/soundfx/nodeFaces.ts index fa3915e5..02ba418d 100644 --- a/src/editor/soundfx/nodeFaces.ts +++ b/src/editor/soundfx/nodeFaces.ts @@ -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; } diff --git a/src/ui/widgets/Knob.ts b/src/ui/widgets/Knob.ts index 238529cd..5559a281 100644 --- a/src/ui/widgets/Knob.ts +++ b/src/ui/widgets/Knob.ts @@ -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 }; } } diff --git a/tests/editor/hardware-view.test.ts b/tests/editor/hardware-view.test.ts index 9b9cf5b4..33a5c165 100644 --- a/tests/editor/hardware-view.test.ts +++ b/tests/editor/hardware-view.test.ts @@ -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); + }); +}); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index b810eca6..1d21b937 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -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');