diff --git a/README.md b/README.md index 0df9a404..2a0cfe7a 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, 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 diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index c995af42..db02937c 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -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 }]); }, }, diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 9f6cc392..44a73ff2 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -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. diff --git a/src/editor/soundfx/KnobPanel.ts b/src/editor/soundfx/KnobPanel.ts index e89e2940..9ee4e8ab 100644 --- a/src/editor/soundfx/KnobPanel.ts +++ b/src/editor/soundfx/KnobPanel.ts @@ -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; diff --git a/src/editor/soundfx/hardwareView.ts b/src/editor/soundfx/hardwareView.ts index 2f9fca36..0a31e79c 100644 --- a/src/editor/soundfx/hardwareView.ts +++ b/src/editor/soundfx/hardwareView.ts @@ -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; diff --git a/src/editor/soundfx/nodeFaces.ts b/src/editor/soundfx/nodeFaces.ts index cee6f4ad..12730092 100644 --- a/src/editor/soundfx/nodeFaces.ts +++ b/src/editor/soundfx/nodeFaces.ts @@ -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; diff --git a/tests/editor/hardware-view.test.ts b/tests/editor/hardware-view.test.ts index 744157d6..4dcf5832 100644 --- a/tests/editor/hardware-view.test.ts +++ b/tests/editor/hardware-view.test.ts @@ -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); + }); +}); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 909be35f..e0776ad3 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -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;