From e70954115c1f2b23843d2b946aa6a4bf1901d007 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 10 Oct 2026 17:37:03 +0200 Subject: [PATCH] Show Pitch and Mix nodes as one-knob devices in green and blue (#390) The Gain has been a small device with one big knob since #384. The Pitch and the Mix are just as much a single number, so they now look and work the same way. The Mix also gets a lever for its curve, which until now was only a row in the inspector. - GainPanel and GainFace become KnobPanel and KnobFace. Each node is described by a KnobDevice: its plate, its row, how the knob turns, and an optional lever. The Gain looks as before. - Pitch: a green 160x160 plate with a bipolar knob for Semitones. 0 stands straight up and taper 2 puts an octave at 43 % of each half, so small steps near 0 are easy and the full +-64 is still there. - Mix: a blue 180x200 plate, the lever on top and the knob below. The knob has A and B engraved at the ends of its turn. The lever reads "crossfade" down and "both at center" up with its lamp lit. A flip is one undo step. - A wired Semitones or Mix greys the knob, as for the Gain. - The instrument presets give Pitch and Mix two columns. In the graph they are 281 and 261 units wide, more than one column of 240. - The test hooks __gm.pitch.panel and __gm.mix.panel return the same shape as __gm.gain.panel, plus the lever. Tests: - Unit hardware-view: 15 tests, 3 new (Pitch turn, lever above the knob and ends on the Mix plate). Counter-check: taper 1 turns the Pitch test red. - e2e 34 (melektaus-atx): 134 checks, 1 new. - Gate (melektaus-atx, together with #391): 287 files, 4397 tests. - Pictures looked at: Gain, Pitch and Mix in a row, Pitch at -24.5, Mix with both curves, the Bell preset without overlaps. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/instrument/chipPresets.ts | 4 +- src/core/soundfx/nodes.ts | 18 +-- src/editor/debug/graphHooks.ts | 60 +++++---- src/editor/soundfx/GainFace.ts | 59 --------- src/editor/soundfx/GainPanel.ts | 81 ------------ src/editor/soundfx/KnobFace.ts | 70 +++++++++++ src/editor/soundfx/KnobPanel.ts | 194 +++++++++++++++++++++++++++++ src/editor/soundfx/hardwareView.ts | 27 ++++ src/editor/soundfx/nodeFaces.ts | 9 +- tests/editor/hardware-view.test.ts | 56 ++++++++- tools/e2e/34-sound.mjs | 72 +++++++++++ 12 files changed, 468 insertions(+), 184 deletions(-) delete mode 100644 src/editor/soundfx/GainFace.ts delete mode 100644 src/editor/soundfx/GainPanel.ts create mode 100644 src/editor/soundfx/KnobFace.ts create mode 100644 src/editor/soundfx/KnobPanel.ts diff --git a/README.md b/README.md index 30b6337e..91ebf9cf 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, 1964 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1965 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/instrument/chipPresets.ts b/src/core/instrument/chipPresets.ts index 9a2ff183..c186cb93 100644 --- a/src/core/instrument/chipPresets.ts +++ b/src/core/instrument/chipPresets.ts @@ -395,10 +395,12 @@ const COLUMN = 240; const ROW = 340; /** * Columns a node takes that is drawn as its editor in the graph (#385): a Filter's curve is wide, - * a Chip's device (#386) wider, an Envelope's (#388) a little less. + * a Chip's device (#386) wider, an Envelope's (#388) a little less, a Pitch's and a Mix's (#390) two. */ const COLUMNS_OF: Readonly> = { [SoundFxFilter.type]: 4, + [SoundFxPitch.type]: 2, + [SoundFxMix.type]: 2, [INSTRUMENT_CHIP]: 5, [INSTRUMENT_ENVELOPE]: 3, }; diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index 713a8499..c995af42 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -560,7 +560,7 @@ export const SoundFxGain: SoundFxNodeDef = effect( 'blend', ); -const SEMITONES = numberRow('semitones', 'Semitones', -64, 64, 0.1); +export const PITCH_ROW = numberRow('semitones', 'Semitones', -64, 64, 0.1); /** The semitones of a Pitch taken by its Wet (#354): a typed-in share is worked in at once. */ function pitchTimes(semitones: FxAmount, wet: FxAmount): FxAmount { @@ -577,11 +577,11 @@ export const SoundFxPitch: SoundFxNodeDef = effect( category: 'sound', description: 'Plays the sound higher or lower, in semitones: 12 is an octave up, 0.5 a quarter tone. The sound keeps its length, also when the pitch changes while it plays. The pitch is made where the sound is read, so an effect before the Pitch acts as if it came after it. On a Chip it moves the frequency of the Chip itself. "Bypass" leaves the node out as if it were not there. "wet" takes the semitones from 0 up to all of them: at 0.5 a Pitch of 12 plays 6 semitones higher.', - inputs: [audioIn, followingIn(SEMITONES), numberIn(WET)], + inputs: [audioIn, followingIn(PITCH_ROW), numberIn(WET)], outputs: [audioOut], params: [], emit: (sounds, amounts) => { - const semitones = pitchTimes(held(amounts.semitones, SEMITONES), held(amounts.wet, WET)); + const semitones = pitchTimes(held(amounts.semitones, PITCH_ROW), held(amounts.wet, WET)); return isNone(semitones) ? arriving(sounds) : withPitch(arriving(sounds), semitones); }, }, @@ -762,8 +762,8 @@ export function filterBandOf(params: PropertyBag): PassBand { }; } -const MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.5 }; -const MIX_CURVE = { +export const MIX_ROW = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.5 }; +export const MIX_CURVE_ROW = { key: 'curve', label: 'Curve', kind: 'enum' as const, @@ -783,17 +783,17 @@ export const SoundFxMix: SoundFxNodeDef = { inputs: [ { key: 'a', label: 'a', type: 'audio' }, { key: 'b', label: 'b', type: 'audio' }, - followingIn(MIX), + followingIn(MIX_ROW), ], outputs: [audioOut], - params: [MIX_CURVE], + params: [MIX_CURVE_ROW], emit: (sounds, amounts, settings) => { - const mix = held(amounts.mix, MIX); + const mix = held(amounts.mix, MIX_ROW); const a = sounds.a ?? EMPTY_PLAN; const b = sounds.b ?? EMPTY_PLAN; if (isFixed(mix) && mix.value <= 0) return a; if (isFixed(mix) && mix.value >= 1) return b; - return settings[MIX_CURVE.key] === 'both' + return settings[MIX_CURVE_ROW.key] === 'both' ? { mix, curve: 'both', a, b, stages: [] } : { mix, a, b, stages: [] }; }, diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 5f3510aa..9f6cc392 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -6,7 +6,7 @@ import { currentNodePalette } from '../graph/NodePalette'; import { ChipPanel } from '../soundfx/ChipPanel'; import { EnvelopePanel } from '../soundfx/EnvelopePanel'; import { CompressorPanel } from '../soundfx/CompressorPanel'; -import { GainPanel } from '../soundfx/GainPanel'; +import { KnobPanel } from '../soundfx/KnobPanel'; import { ReverbPanel } from '../soundfx/ReverbPanel'; import { CurvePanel } from '../soundfx/CurvePanel'; import type { EqEditor } from '../soundfx/EqEditor'; @@ -16,6 +16,33 @@ import { centreOf, snapButton, type HookContext } from './hookHelpers'; /** The node graphs: behaviors, state machines, particles and shaders. */ export function graphHooks({ state, editor }: HookContext) { const { graphEditor } = editor; + const knobPanel = (nodeId: string) => { + const canvas = [editor.soundFxEditor.canvas, editor.instrumentEditor.canvas].find( + (c) => c.faceOf(nodeId)?.widget instanceof KnobPanel, + ); + const panel = canvas?.faceOf(nodeId)?.widget; + if (!canvas || !(panel instanceof KnobPanel)) 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(), + }, + lever: panel.lever + ? { on: panel.lever.lit, says: panel.lever.says.text, at: screenCentre(panel.lever) } + : null, + }; + }; return { graph: { current: () => graphEditor.currentAssetId, @@ -247,33 +274,12 @@ 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. + * The panel a Gain node shows (#384), and a Pitch or a Mix (#390): whether it is shown and where, + * its knob, the Mix's lever 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(), - }, - }; - }, - }, + gain: { panel: (nodeId: string) => knobPanel(nodeId) }, + pitch: { panel: (nodeId: string) => knobPanel(nodeId) }, + mix: { panel: (nodeId: string) => knobPanel(nodeId) }, /** * The panel a Chip node shows in the instrument graph (#386): whether it is shown and where, its * knobs, which settings are idle, the picked wave, the lever, both screens and the node's ports. diff --git a/src/editor/soundfx/GainFace.ts b/src/editor/soundfx/GainFace.ts deleted file mode 100644 index d073d524..00000000 --- a/src/editor/soundfx/GainFace.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 7ab2ad6c..00000000 --- a/src/editor/soundfx/GainPanel.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * 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 caption: { ink: Text; edge: Text }; - - constructor(host: GainHost) { - super(); - this.minWidth = GAIN_PANEL.w; - this.minHeight = GAIN_PANEL.h; - this.addChild(this.plate); - 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 spot = GAIN_LAYOUT.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); - } - - override redraw(): void { - drawPlate(this.plate.clear(), GAIN_PANEL, ORANGE, GAIN_LAYOUT.screws); - } -} diff --git a/src/editor/soundfx/KnobFace.ts b/src/editor/soundfx/KnobFace.ts new file mode 100644 index 00000000..7423613b --- /dev/null +++ b/src/editor/soundfx/KnobFace.ts @@ -0,0 +1,70 @@ +import type { GraphNode } from '../../core/graph'; +import { SetNodeParamCommand } from '../../core/commands/graphCommands'; +import { findGraphAsset } from '../../core/model/Project'; +import type { NodeFace } from '../graph/nodeFace'; +import type { EditorState } from '../state'; +import { compressorWired } from './CompressorEditor'; +import { type KnobDevice, type KnobHost, KnobPanel } from './KnobPanel'; +import { NodeParamDrag } from './nodeParamDrag'; + +/** + * A node drawn as a device with one knob (#384, #390), turned right in the graph. It listens to + * nothing, so it has nothing to move between changes. + */ +export class KnobFace implements NodeFace, KnobHost { + readonly widget: KnobPanel; + private readonly drag: NodeParamDrag; + + constructor( + private readonly state: EditorState, + private readonly assetId: string, + private readonly nodeId: string, + private readonly device: KnobDevice, + ) { + this.widget = new KnobPanel(this, device); + this.drag = new NodeParamDrag(state, 'knob-face'); + } + + private node(): GraphNode | null { + const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find( + (n) => n.id === this.nodeId, + ); + return node?.type === this.device.type ? node : null; + } + + sync(): void { + const node = this.node(); + if (!node) return; + const lever = this.device.lever; + const value = node.params[this.device.row.key]; + this.widget.sync( + typeof value === 'number' && Number.isFinite(value) ? value : this.device.row.default, + compressorWired(this.state.project, this.assetId, this.nodeId, this.device.row.key), + lever !== undefined && node.params[lever.key] === lever.values[1], + ); + } + + tick(): boolean { + return false; + } + + hide(): void {} + + preview(value: number): void { + const node = this.node(); + if (node) this.drag.preview(node, this.device.row.key, value); + } + + commit(value: number): void { + const node = this.node(); + if (node) this.drag.commit(this.assetId, node, this.device.row.key, value); + } + + flip(on: boolean): void { + const lever = this.device.lever; + if (lever && this.node()) + this.state.execute( + new SetNodeParamCommand(this.assetId, this.nodeId, lever.key, lever.values[on ? 1 : 0]), + ); + } +} diff --git a/src/editor/soundfx/KnobPanel.ts b/src/editor/soundfx/KnobPanel.ts new file mode 100644 index 00000000..e89e2940 --- /dev/null +++ b/src/editor/soundfx/KnobPanel.ts @@ -0,0 +1,194 @@ +/** + * The devices with one big chrome knob, what their nodes show in the graph: the Gain (#384) on an + * orange plate, the Pitch (#390) on a green one, and the Mix (#390) on a blue one with a lever for + * its curve. All on the Compressor's plate (#379). + */ +import { Graphics, type Text } from 'pixi.js'; +import { Knob, Widget } from '../../ui'; +import { + GAIN_ROW, + MIX_CURVE_ROW, + MIX_ROW, + PITCH_ROW, + SoundFxGain, + SoundFxMix, + SoundFxPitch, +} from '../../core/soundfx/nodes'; +import { drawPlate, engrave, INK, Lever, placeEngraving, type PlateLook } from './CompressorPanel'; +import { + GAIN_LAYOUT, + GAIN_PANEL, + GAIN_TAPER, + KNOB_CAPTION, + knobEnds, + MIX_LAYOUT, + MIX_PANEL, + PITCH_TAPER, +} from './hardwareView'; + +/** What the panel asks of the node it sits in. */ +export interface KnobHost { + preview(value: number): void; + commit(value: number): void; + /** The lever, on a device that has one. */ + flip(on: boolean): void; +} + +export interface KnobDevice { + /** The node type it is drawn for. */ + type: string; + /** Engraved above the knob. */ + caption: string; + row: { key: string; default: number; min?: number; max?: number; step?: number }; + taper: number; + bipolar?: boolean; + decimals: number; + plate: PlateLook; + /** The knob's value arc, dark where the plate would swallow the usual orange. */ + arc: number; + /** Engraved at the start and the end of the turn. */ + ends?: [string, string]; + /** A lever for a setting of two values, off and on, and what it `says` at each. */ + lever?: { caption: string; key: string; values: [string, string]; says: [string, string] }; +} + +export const GAIN_DEVICE: KnobDevice = { + type: SoundFxGain.type, + caption: 'DB', + row: GAIN_ROW, + taper: GAIN_TAPER, + decimals: 1, + plate: { + stops: [ + [0, 0xffb25e], + [0.5, 0xf39433], + [1, 0xd8761c], + ], + rim: 0xa85616, + grain: 0x8a3e08, + }, + arc: 0x3b1a06, +}; + +export const PITCH_DEVICE: KnobDevice = { + type: SoundFxPitch.type, + caption: 'SEMITONES', + row: PITCH_ROW, + taper: PITCH_TAPER, + bipolar: true, + decimals: 1, + plate: { + stops: [ + [0, 0x9be08a], + [0.5, 0x6cc65a], + [1, 0x4ea53f], + ], + rim: 0x37802b, + grain: 0x1f5a17, + }, + arc: 0x123a0d, +}; + +export const MIX_DEVICE: KnobDevice = { + type: SoundFxMix.type, + caption: 'MIX', + row: MIX_ROW, + taper: 1, + decimals: 2, + plate: { + stops: [ + [0, 0x8ec5ff], + [0.5, 0x5ea6f2], + [1, 0x3f86d6], + ], + rim: 0x2b64a8, + grain: 0x173f75, + }, + arc: 0x0d2748, + ends: ['A', 'B'], + lever: { + caption: 'CURVE', + key: MIX_CURVE_ROW.key, + values: ['crossfade', 'both'], + says: ['crossfade', 'both at center'], + }, +}; + +export class KnobPanel extends Widget { + private readonly plate = new Graphics(); + readonly knob: Knob; + readonly lever: Lever | null = null; + private readonly caption: { ink: Text; edge: Text }; + private readonly ends: { ink: Text; edge: Text }[]; + private readonly size: { w: number; h: number }; + private readonly spots: typeof GAIN_LAYOUT; + + constructor( + host: KnobHost, + readonly device: KnobDevice, + ) { + super(); + this.size = device.lever ? MIX_PANEL : GAIN_PANEL; + this.spots = device.lever ? MIX_LAYOUT : GAIN_LAYOUT; + this.minWidth = this.size.w; + this.minHeight = this.size.h; + this.addChild(this.plate); + this.caption = engrave(this, device.caption, 11); + this.ends = (device.ends ?? []).map((end) => engrave(this, end, 11)); + const row = device.row; + this.knob = new Knob('', row.default, { + min: row.min ?? 0, + max: row.max ?? 0, + step: row.step ?? 1, + taper: device.taper, + bipolar: device.bipolar, + default: row.default, + decimals: device.decimals, + size: GAIN_LAYOUT.knob.size, + look: 'chrome', + typed: false, + ink: INK, + arc: device.arc, + onInput: (value) => host.preview(value), + onChange: (value) => host.commit(value), + }); + this.addChild(this.knob); + if (device.lever) { + this.lever = new Lever(device.lever.caption, (on) => host.flip(on)); + this.addChild(this.lever); + } + } + + override measure(): { w: number; h: number } { + return { ...this.size }; + } + + /** The node's number and whether a link feeds it, which greys the knob; and the lever's setting. */ + sync(value: number, wired: boolean, on = false): void { + this.knob.setValue(value); + this.knob.setEnabled(!wired); + const lever = this.device.lever; + if (this.lever && lever) this.lever.set(on, lever.says[on ? 1 : 0]); + } + + override layout(): void { + const spot = this.spots.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); + const ends = knobEnds(spot); + const [a, b] = this.ends; + if (a) placeEngraving(a, ends.a.x, ends.a.y - 7); + if (b) placeEngraving(b, ends.b.x, ends.b.y - 7); + if (this.lever) { + const box = MIX_LAYOUT.curve; + this.lever.position.set(box.x, box.y); + this.lever.resize(box.w, box.h); + } + } + + override redraw(): void { + drawPlate(this.plate.clear(), this.size, this.device.plate, this.spots.screws); + } +} diff --git a/src/editor/soundfx/hardwareView.ts b/src/editor/soundfx/hardwareView.ts index 1c4a3976..2f9fca36 100644 --- a/src/editor/soundfx/hardwareView.ts +++ b/src/editor/soundfx/hardwareView.ts @@ -3,6 +3,7 @@ * one size, how the needle of its meter reads and moves, which LEDs are lit, and the grain of the * brushed plate. Kept apart from the drawing so it is checked without a browser. */ +import { KNOB_START, KNOB_SWEEP } from '../../ui/knob'; import { clamp } from '../../ui/math'; import type { Levels } from '../../viewport/spectrum'; import { LEVEL_FLOOR_DB } from './compressorView'; @@ -92,6 +93,32 @@ export const GAIN_LAYOUT = { */ export const GAIN_TAPER = 0.26; +/** The Pitch's knob (#390), ±64 semitones round 0: an octave either way takes 43 % of a half turn. */ +export const PITCH_TAPER = 2; + +/** How much lower the Mix's knob sits than the Gain's, for its curve lever above it. */ +const MIX_LEVER_ROOM = 40; + +/** The Mix (#390): the Gain's plate, a little wider and taller by its curve lever on top. */ +export const MIX_PANEL = { w: 180, h: GAIN_PANEL.h + MIX_LEVER_ROOM }; + +export const MIX_LAYOUT = { + knob: { ...GAIN_LAYOUT.knob, x: MIX_PANEL.w / 2, y: GAIN_LAYOUT.knob.y + MIX_LEVER_ROOM }, + // Between the top screws, its lamp, lever and words in the middle. + curve: { x: (MIX_PANEL.w - 130) / 2, y: 12, w: 130, h: 40 }, + screws: screwsOf(MIX_PANEL), +}; + +/** Where the ends of a knob's turn are named, just outside its dial: `a` at the start, `b` at the end. */ +export function knobEnds(spot: KnobSpot): { a: { x: number; y: number }; b: { x: number; y: number } } { + const r = spot.size / 2 + 8; + const at = (angle: number) => ({ + x: Math.round(spot.x + Math.cos(angle) * r), + y: Math.round(spot.y + Math.sin(angle) * r), + }); + return { a: at(KNOB_START), b: at(KNOB_START + KNOB_SWEEP) }; +} + /** 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 39bbe040..cee6f4ad 100644 --- a/src/editor/soundfx/nodeFaces.ts +++ b/src/editor/soundfx/nodeFaces.ts @@ -3,7 +3,6 @@ import { InstrumentChip, InstrumentEnvelope } from '../../core/instrument/nodes' import { SoundFxCompressor, SoundFxFilter, - SoundFxGain, SoundFxParametricEq, SoundFxReverb, } from '../../core/soundfx/nodes'; @@ -15,10 +14,11 @@ import { CurveFace } from './CurveFace'; import { EnvelopeFace } from './EnvelopeFace'; import { EqPanel } from './EqPanel'; import { FilterPanel } from './FilterPanel'; -import { GainFace } from './GainFace'; +import { KnobFace } from './KnobFace'; +import { GAIN_DEVICE, MIX_DEVICE, PITCH_DEVICE } from './KnobPanel'; import { ReverbFace } from './ReverbFace'; -/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388, #389), by type. */ +/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390), by type. */ export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null { if (node.type === InstrumentChip.type) return new ChipFace(state, assetId, node.id); if (node.type === InstrumentEnvelope.type) return new EnvelopeFace(state, assetId, node.id); @@ -26,7 +26,8 @@ export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode if (node.type === SoundFxParametricEq.type) return new CurveFace(new EqPanel(state, true), assetId, node.id); if (node.type === SoundFxFilter.type) return new CurveFace(new FilterPanel(state, true), assetId, node.id); - if (node.type === SoundFxGain.type) return new GainFace(state, assetId, node.id); + const knobbed = [GAIN_DEVICE, PITCH_DEVICE, MIX_DEVICE].find((device) => device.type === node.type); + if (knobbed) return new KnobFace(state, assetId, node.id, knobbed); if (node.type === SoundFxReverb.type) return new ReverbFace(state, assetId, node.id); return null; } diff --git a/tests/editor/hardware-view.test.ts b/tests/editor/hardware-view.test.ts index bc1f86ef..744157d6 100644 --- a/tests/editor/hardware-view.test.ts +++ b/tests/editor/hardware-view.test.ts @@ -1,7 +1,7 @@ /** * 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. The Gain's plate - * (#384) and how its one knob turns. + * (#384) and how its one knob turns, the Pitch's knob and the Mix's lever and ends (#390). */ import { describe, expect, it } from 'vitest'; import { @@ -14,6 +14,7 @@ import { GAIN_TAPER, gainReduction, knobBox, + knobEnds, LED_COUNT, ledColour, ledDb, @@ -22,12 +23,15 @@ import { METER_LABELS, METER_RANGE_DB, meterPosition, + MIX_LAYOUT, + MIX_PANEL, needleAngle, needleStep, PANEL, PANEL_LAYOUT, + PITCH_TAPER, } from '../../src/editor/soundfx/hardwareView'; -import { GAIN_ROW } from '../../src/core/soundfx/nodes'; +import { GAIN_ROW, PITCH_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; @@ -163,3 +167,51 @@ describe('the Gain (#384)', () => { expect(knobPosition(-6, range)).toBeGreaterThan(0.4); }); }); + +describe('the Pitch and the Mix (#390)', () => { + it('turns the Pitch from 0 straight up, an octave either way alike and well inside the turn', () => { + const range = { + min: PITCH_ROW.min ?? 0, + max: PITCH_ROW.max ?? 0, + step: 0.1, + taper: PITCH_TAPER, + bipolar: true, + }; + expect(knobPosition(0, range)).toBeCloseTo(0.5, 5); + const up = knobPosition(12, range); + expect(up - 0.5).toBeCloseTo(0.5 - knobPosition(-12, range), 5); + expect(up).toBeGreaterThan(0.7); + expect(up).toBeLessThan(0.75); + expect(knobPosition(64, range)).toBe(1); + }); + + it('holds the Mix’s lever above the knob, centred and clear of it and of the screws', () => { + const lever = MIX_LAYOUT.curve; + const spot = MIX_LAYOUT.knob; + const knob = knobBox(spot); + expect(lever.x).toBeGreaterThanOrEqual(0); + expect(lever.x + lever.w).toBeLessThanOrEqual(MIX_PANEL.w); + expect(lever.x + lever.w / 2).toBe(MIX_PANEL.w / 2); + expect(spot.x).toBe(MIX_PANEL.w / 2); + // A lever that is down reaches 8 below its box (`Lever.redraw`). + expect(lever.y + lever.h + 8).toBeLessThanOrEqual(knob.y); + for (const screw of MIX_LAYOUT.screws) + expect(overlap(lever, { x: screw.x - 7, y: screw.y - 7, w: 14, h: 14 })).toBe(false); + // Below the dial as much air as on the Gain. + expect(MIX_PANEL.h - (spot.y + spot.size / 2)).toBe(GAIN_PANEL.h - (GAIN_LAYOUT.knob.y + spot.size / 2)); + expect(MIX_PANEL.w).toBeLessThan(MIX_PANEL.h); + }); + + it('names the ends of the Mix’s turn just outside the dial, left and right below its middle', () => { + const spot = MIX_LAYOUT.knob; + const { a, b } = knobEnds(spot); + for (const end of [a, b]) { + expect(Math.hypot(end.x - spot.x, end.y - spot.y)).toBeGreaterThan(spot.size / 2); + expect(end.y).toBeGreaterThan(spot.y); + expect(end.y).toBeLessThan(MIX_PANEL.h); + } + expect(a.x).toBeLessThan(spot.x); + expect(b.x).toBeGreaterThan(spot.x); + expect(a.y).toBe(b.y); + }); +}); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 1d229f11..909be35f 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -1287,6 +1287,78 @@ export async function sound(t) { 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-rv');`); + // #390: a Pitch is drawn as the Gain's device in green, a Mix in blue with a lever for its curve. + // The Pitch in the chain has its semitones wired, so its knob is locked; a free one turns. + await mutate(` + const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; + graph.nodes.push( + ${JSON.stringify(node('fx-pt', 'SoundFxPitch', 0, 400, { semitones: -12 }))}, + ${JSON.stringify(node('fx-mx', 'SoundFxMix', 400, 400, { mix: 0.3 }))}, + );`); + await evaluate('__gm.soundfx.fit()'); + await sleep(100); + const knobParams = (id) => + json( + `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === '${id}').params`, + ); + const ptWired = await json("__gm.pitch.panel('fx-pitch')"); + const ptShown = await json("__gm.pitch.panel('fx-pt')"); + await drag(ptShown.knob.dial.x, ptShown.knob.dial.y, ptShown.knob.dial.x, ptShown.knob.dial.y - 20); + const ptTurned = (await knobParams('fx-pt')).semitones; + await press('z', { ctrl: true }); + const ptUndone = (await knobParams('fx-pt')).semitones; + const mxShown = await json("__gm.mix.panel('fx-mx')"); + const mxRect = mxShown.rect; + await click(mxShown.lever.at.x, mxShown.lever.at.y); + const mxBoth = { params: await knobParams('fx-mx'), panel: await json("__gm.mix.panel('fx-mx')") }; + await press('z', { ctrl: true }); + const mxUndone = { params: await knobParams('fx-mx'), panel: await json("__gm.mix.panel('fx-mx')") }; + await drag(mxShown.knob.dial.x, mxShown.knob.dial.y, mxShown.knob.dial.x, mxShown.knob.dial.y - 20); + const mxTurned = (await knobParams('fx-mx')).mix; + await press('z', { ctrl: true }); + const mxTurnUndone = (await knobParams('fx-mx')).mix; + check( + 'a Pitch node is a green one-knob device, locked while its semitones are wired; a Mix node is a blue one whose lever switches the curve, each change one undo step', + ptWired.size.w === 160 && + !ptWired.knob.enabled && + ptWired.lever === null && + ptShown.knob.enabled && + ptShown.knob.number === '-12' && + ptTurned > -12 && + ptUndone === -12 && + mxShown.size.w === 180 && + mxShown.size.h === 200 && + mxShown.knob.number === '0.3' && + // The lever stands above the knob, both in the middle of the plate. + mxShown.lever.at.y < mxShown.knob.dial.y && + Math.abs(mxShown.lever.at.x - mxShown.knob.dial.x) < 1 && + Object.values(mxShown.inputs).length === 3 && + Object.values(mxShown.inputs).every((p) => p.x < mxRect.x) && + Object.values(mxShown.outputs).every((p) => p.x > mxRect.x + mxRect.w) && + mxShown.lever.on === false && + mxShown.lever.says === 'crossfade' && + mxBoth.params.curve === 'both' && + mxBoth.panel.lever.on && + mxBoth.panel.lever.says === 'both at center' && + mxUndone.params.curve !== 'both' && + mxUndone.panel.lever.says === 'crossfade' && + mxTurned > 0.3 && + mxTurnUndone === 0.3, + JSON.stringify({ + ptWired, + ptShown, + ptTurned, + ptUndone, + mxShown, + mxBoth, + mxUndone, + mxTurned, + mxTurnUndone, + }), + ); + await mutate(` + const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; + graph.nodes = graph.nodes.filter((n) => !['fx-pt', 'fx-mx'].includes(n.id));`); await showTab('scene'); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;