Show a Pan node as a one-knob device in cyan (#392)
game-maker / gate (push) Successful in 4m13s
game-maker / suite (push) Failing after 15m23s

After the Gain, the Pitch and the Mix, the Pan is the next node that
is just one number. It now gets the same device in the graph instead
of a row for its pan.

- PAN_DEVICE: the Gain's 160x160 plate in cyan with one bipolar knob
  for Pan. The middle stands straight up, and the turn goes evenly to
  L and R, which are engraved at its ends like A and B on the Mix.
- PAN_TAPER in hardwareView is 1, so left and right are heard alike.
  Device and unit test read the same constant.
- PAN_ROW is exported from the core, so the knob has the node's range.
- A linked Pan greys the knob, as for the Gain.
- The test hook __gm.pan.panel returns the same shape as the Gain's.
- No instrument preset uses a Pan, so no preset columns change.

Tests:
- Unit hardware-view: 17 tests, 2 new (the turn, and L and R inside
  the plate and clear of the screws). Counter-check: PAN_TAPER 2
  turns the turn test red.
- e2e 34 (melektaus-atx): 135 checks, 1 new.
- Gate (melektaus-atx): 287 files, 4399 tests.
- Pictures looked at: Gain, Pitch, Mix and Pan in a row, Pan at -0.35.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 18:26:17 +02:00
1 parent fe4e0d9f1a
commit 389d9555fa
8 files changed
+102 -12

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, 1966 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1967 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
@@ -999,7 +999,7 @@ export const SoundFxFlanger: SoundFxNodeDef = effect(
'own',
);
const PAN = numberRow('pan', 'Pan', -1, 1, 0.01);
export const PAN_ROW = numberRow('pan', 'Pan', -1, 1, 0.01);
export const SoundFxPan: SoundFxNodeDef = effect(
{
@@ -1009,11 +1009,11 @@ export const SoundFxPan: SoundFxNodeDef = effect(
description:
'Moves the sound left or right: -1 is all left, 1 all right, 0 leaves it in the middle. A mono sound is spread on both sides and then moved. A stereo sound keeps the side it goes to, and the other side moves over into it: at 1 both sides are heard on the right. It can change while the sound plays, and in an instrument an Envelope can move it.' +
BLENDS,
inputs: [audioIn, followingIn(PAN)],
inputs: [audioIn, followingIn(PAN_ROW)],
outputs: [audioOut],
params: [],
emit: (sounds, amounts) => {
const pan = held(amounts.pan, PAN);
const pan = held(amounts.pan, PAN_ROW);
return withStages(arriving(sounds), isNone(pan) ? [] : [{ kind: 'pan', pan }]);
},
},
+2 -1
View File
@@ -274,12 +274,13 @@ export function graphHooks({ state, editor }: HookContext) {
},
},
/**
* The panel a Gain node shows (#384), and a Pitch or a Mix (#390): whether it is shown and where,
* The panel a Gain node shows (#384), a Pitch or a Mix (#390), a Pan (#392): whether it is shown and where,
* its knob, the Mix's lever and the node's ports.
*/
gain: { panel: (nodeId: string) => knobPanel(nodeId) },
pitch: { panel: (nodeId: string) => knobPanel(nodeId) },
mix: { panel: (nodeId: string) => knobPanel(nodeId) },
pan: { 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.
+25 -2
View File
@@ -1,7 +1,7 @@
/**
* 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).
* orange plate, the Pitch (#390) on a green one, the Mix (#390) on a blue one with a lever for
* its curve, and the Pan (#392) on a cyan one. All on the Compressor's plate (#379).
*/
import { Graphics, type Text } from 'pixi.js';
import { Knob, Widget } from '../../ui';
@@ -9,9 +9,11 @@ import {
GAIN_ROW,
MIX_CURVE_ROW,
MIX_ROW,
PAN_ROW,
PITCH_ROW,
SoundFxGain,
SoundFxMix,
SoundFxPan,
SoundFxPitch,
} from '../../core/soundfx/nodes';
import { drawPlate, engrave, INK, Lever, placeEngraving, type PlateLook } from './CompressorPanel';
@@ -23,6 +25,7 @@ import {
knobEnds,
MIX_LAYOUT,
MIX_PANEL,
PAN_TAPER,
PITCH_TAPER,
} from './hardwareView';
@@ -114,6 +117,26 @@ export const MIX_DEVICE: KnobDevice = {
},
};
export const PAN_DEVICE: KnobDevice = {
type: SoundFxPan.type,
caption: 'PAN',
row: PAN_ROW,
taper: PAN_TAPER,
bipolar: true,
decimals: 2,
plate: {
stops: [
[0, 0x8ff0ea],
[0.5, 0x52d6cf],
[1, 0x2fb5ae],
],
rim: 0x1f8a85,
grain: 0x0e5c58,
},
arc: 0x07393a,
ends: ['L', 'R'],
};
export class KnobPanel extends Widget {
private readonly plate = new Graphics();
readonly knob: Knob;
+3
View File
@@ -96,6 +96,9 @@ 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;
/** The Pan's knob (#392), -1 to 1 round the middle: evenly, so left and right are heard alike. */
export const PAN_TAPER = 1;
/** How much lower the Mix's knob sits than the Gain's, for its curve lever above it. */
const MIX_LEVER_ROOM = 40;
+5 -3
View File
@@ -15,10 +15,10 @@ import { EnvelopeFace } from './EnvelopeFace';
import { EqPanel } from './EqPanel';
import { FilterPanel } from './FilterPanel';
import { KnobFace } from './KnobFace';
import { GAIN_DEVICE, MIX_DEVICE, PITCH_DEVICE } from './KnobPanel';
import { GAIN_DEVICE, MIX_DEVICE, PAN_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–#390), by type. */
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392), 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,9 @@ 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);
const knobbed = [GAIN_DEVICE, PITCH_DEVICE, MIX_DEVICE].find((device) => device.type === node.type);
const knobbed = [GAIN_DEVICE, PITCH_DEVICE, MIX_DEVICE, PAN_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;
+35 -2
View File
@@ -1,7 +1,8 @@
/**
* 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, the Pitch's knob and the Mix's lever and ends (#390).
* (#384) and how its one knob turns, the Pitch's knob and the Mix's lever and ends (#390), the
* Pan's knob and ends on the Gain's plate (#392).
*/
import { describe, expect, it } from 'vitest';
import {
@@ -27,11 +28,12 @@ import {
MIX_PANEL,
needleAngle,
needleStep,
PAN_TAPER,
PANEL,
PANEL_LAYOUT,
PITCH_TAPER,
} from '../../src/editor/soundfx/hardwareView';
import { GAIN_ROW, PITCH_ROW } from '../../src/core/soundfx/nodes';
import { GAIN_ROW, PAN_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;
@@ -215,3 +217,34 @@ describe('the Pitch and the Mix (#390)', () => {
expect(a.y).toBe(b.y);
});
});
describe('the Pan (#392)', () => {
it('turns from the middle straight up, as far left as right, the whole turn for -1 to 1', () => {
const range = {
min: PAN_ROW.min ?? 0,
max: PAN_ROW.max ?? 0,
step: 0.01,
taper: PAN_TAPER,
bipolar: true,
};
expect(knobPosition(0, range)).toBeCloseTo(0.5, 5);
expect(knobPosition(0.5, range)).toBeCloseTo(0.75, 5);
expect(knobPosition(-0.5, range)).toBeCloseTo(0.25, 5);
expect(knobPosition(-1, range)).toBe(0);
expect(knobPosition(1, range)).toBe(1);
});
it('names L and R at the ends of the turn inside the Gain’s plate, clear of the screws', () => {
const spot = GAIN_LAYOUT.knob;
const { a, b } = knobEnds(spot);
for (const end of [a, b]) {
expect(end.x).toBeGreaterThan(0);
expect(end.x).toBeLessThan(GAIN_PANEL.w);
expect(end.y).toBeLessThan(GAIN_PANEL.h);
for (const screw of GAIN_LAYOUT.screws)
expect(Math.hypot(end.x - screw.x, end.y - screw.y)).toBeGreaterThan(14);
}
expect(a.x).toBeLessThan(spot.x);
expect(b.x).toBeGreaterThan(spot.x);
});
});
+28
View File
@@ -1359,6 +1359,34 @@ 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) => !['fx-pt', 'fx-mx'].includes(n.id));`);
// #392: a Pan is the same device in cyan, its 0 straight up, L and R at the ends of the turn.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-pn', 'SoundFxPan', 0, 400, { pan: -0.35 }))});`);
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const pnShown = await json("__gm.pan.panel('fx-pn')");
await drag(pnShown.knob.dial.x, pnShown.knob.dial.y, pnShown.knob.dial.x, pnShown.knob.dial.y - 20);
const pnTurned = (await knobParams('fx-pn')).pan;
await press('z', { ctrl: true });
const pnUndone = (await knobParams('fx-pn')).pan;
check(
'a Pan node is a cyan one-knob device whose knob turns the pan, one undo step',
pnShown.size.w === 160 &&
pnShown.size.h === 160 &&
pnShown.lever === null &&
pnShown.knob.enabled &&
pnShown.knob.number === '-0.35' &&
Object.values(pnShown.inputs).every((p) => p.x < pnShown.rect.x) &&
Object.values(pnShown.outputs).every((p) => p.x > pnShown.rect.x + pnShown.rect.w) &&
pnTurned > -0.35 &&
pnTurned <= 1 &&
pnUndone === -0.35,
JSON.stringify({ pnShown, pnTurned, pnUndone }),
);
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-pn');`);
await showTab('scene');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;