Show a Pan node as a one-knob device in cyan (#392)
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:
1 parent
fe4e0d9f1a
commit
389d9555fa
8 files changed
+102
-12
No files matched your search
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -999,7 +999,7 @@ export const SoundFxFlanger: SoundFxNodeDef = effect(
|
|||||||
'own',
|
'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(
|
export const SoundFxPan: SoundFxNodeDef = effect(
|
||||||
{
|
{
|
||||||
@@ -1009,11 +1009,11 @@ export const SoundFxPan: SoundFxNodeDef = effect(
|
|||||||
description:
|
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.' +
|
'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,
|
BLENDS,
|
||||||
inputs: [audioIn, followingIn(PAN)],
|
inputs: [audioIn, followingIn(PAN_ROW)],
|
||||||
outputs: [audioOut],
|
outputs: [audioOut],
|
||||||
params: [],
|
params: [],
|
||||||
emit: (sounds, amounts) => {
|
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 }]);
|
return withStages(arriving(sounds), isNone(pan) ? [] : [{ kind: 'pan', pan }]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
* its knob, the Mix's lever and the node's ports.
|
||||||
*/
|
*/
|
||||||
gain: { panel: (nodeId: string) => knobPanel(nodeId) },
|
gain: { panel: (nodeId: string) => knobPanel(nodeId) },
|
||||||
pitch: { panel: (nodeId: string) => knobPanel(nodeId) },
|
pitch: { panel: (nodeId: string) => knobPanel(nodeId) },
|
||||||
mix: { 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
|
* 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.
|
* knobs, which settings are idle, the picked wave, the lever, both screens and the node's ports.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* The devices with one big chrome knob, what their nodes show in the graph: the Gain (#384) on an
|
* 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
|
* orange plate, the Pitch (#390) on a green one, the Mix (#390) on a blue one with a lever for
|
||||||
* its curve. All on the Compressor's plate (#379).
|
* 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 { Graphics, type Text } from 'pixi.js';
|
||||||
import { Knob, Widget } from '../../ui';
|
import { Knob, Widget } from '../../ui';
|
||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
GAIN_ROW,
|
GAIN_ROW,
|
||||||
MIX_CURVE_ROW,
|
MIX_CURVE_ROW,
|
||||||
MIX_ROW,
|
MIX_ROW,
|
||||||
|
PAN_ROW,
|
||||||
PITCH_ROW,
|
PITCH_ROW,
|
||||||
SoundFxGain,
|
SoundFxGain,
|
||||||
SoundFxMix,
|
SoundFxMix,
|
||||||
|
SoundFxPan,
|
||||||
SoundFxPitch,
|
SoundFxPitch,
|
||||||
} from '../../core/soundfx/nodes';
|
} from '../../core/soundfx/nodes';
|
||||||
import { drawPlate, engrave, INK, Lever, placeEngraving, type PlateLook } from './CompressorPanel';
|
import { drawPlate, engrave, INK, Lever, placeEngraving, type PlateLook } from './CompressorPanel';
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
knobEnds,
|
knobEnds,
|
||||||
MIX_LAYOUT,
|
MIX_LAYOUT,
|
||||||
MIX_PANEL,
|
MIX_PANEL,
|
||||||
|
PAN_TAPER,
|
||||||
PITCH_TAPER,
|
PITCH_TAPER,
|
||||||
} from './hardwareView';
|
} 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 {
|
export class KnobPanel extends Widget {
|
||||||
private readonly plate = new Graphics();
|
private readonly plate = new Graphics();
|
||||||
readonly knob: Knob;
|
readonly knob: Knob;
|
||||||
|
|||||||
@@ -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. */
|
/** The Pitch's knob (#390), ±64 semitones round 0: an octave either way takes 43 % of a half turn. */
|
||||||
export const PITCH_TAPER = 2;
|
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. */
|
/** How much lower the Mix's knob sits than the Gain's, for its curve lever above it. */
|
||||||
const MIX_LEVER_ROOM = 40;
|
const MIX_LEVER_ROOM = 40;
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ import { EnvelopeFace } from './EnvelopeFace';
|
|||||||
import { EqPanel } from './EqPanel';
|
import { EqPanel } from './EqPanel';
|
||||||
import { FilterPanel } from './FilterPanel';
|
import { FilterPanel } from './FilterPanel';
|
||||||
import { KnobFace } from './KnobFace';
|
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';
|
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 {
|
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 === InstrumentChip.type) return new ChipFace(state, assetId, node.id);
|
||||||
if (node.type === InstrumentEnvelope.type) return new EnvelopeFace(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)
|
if (node.type === SoundFxParametricEq.type)
|
||||||
return new CurveFace(new EqPanel(state, true), assetId, node.id);
|
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 === 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 (knobbed) return new KnobFace(state, assetId, node.id, knobbed);
|
||||||
if (node.type === SoundFxReverb.type) return new ReverbFace(state, assetId, node.id);
|
if (node.type === SoundFxReverb.type) return new ReverbFace(state, assetId, node.id);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* The Compressor's hardware panel (#379): every part on the plate and none over another, the meter's
|
* 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
|
* 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 { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
@@ -27,11 +28,12 @@ import {
|
|||||||
MIX_PANEL,
|
MIX_PANEL,
|
||||||
needleAngle,
|
needleAngle,
|
||||||
needleStep,
|
needleStep,
|
||||||
|
PAN_TAPER,
|
||||||
PANEL,
|
PANEL,
|
||||||
PANEL_LAYOUT,
|
PANEL_LAYOUT,
|
||||||
PITCH_TAPER,
|
PITCH_TAPER,
|
||||||
} from '../../src/editor/soundfx/hardwareView';
|
} 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';
|
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;
|
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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1359,6 +1359,34 @@ export async function sound(t) {
|
|||||||
await mutate(`
|
await mutate(`
|
||||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
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));`);
|
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 showTab('scene');
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||||||
|
|||||||
Reference in new issue
Block a user