Move a sound left or right with a Pan node (#319)

A sound FX had no way to place a sound in the stereo field. The new
Pan node does that on a voice, a bus and a track alike.

- One input, Pan from -1 (left) to 1 (right), 0 by default. Behind it
  is a StereoPannerNode. A typed-in 0 leaves the node out, a pan fed
  from a parameter stays at any value.
- It moves with Set sound FX parameters, and an Envelope can move it
  in an instrument.
- As the issue decided, no pan from the position in Play sound and no
  width control for stereo sounds.

Finding: the issue and the plan said a stereo sound gets a balance.
Web Audio pans stereo differently: the side it goes to stays and the
other side moves over into it. The node description and the wiki say
so, the e2e check measures it.

Tests:
- Unit soundfx 46 (1 new), sound-library 97 (1 new), green.
- Counter-check: a pan from a parameter at 0 left out, 1 test red.
- e2e 34 (melektaus-atx): ALL PASSED, 77 checks (1 new). Chrome
  offline, a mono sine at 0.5 gives 0.5 / 0 at -1 and 0.354 / 0.354
  at 0. A stereo one at 0.5 panned 0.5 gives 0.354 / 0.854.
- The gate runs once on the last commit of this series.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 20:47:53 +02:00
1 parent e1ca6a5f5a
commit c99bda6489
5 files changed
+94 -2

No files matched your search

+29 -2
View File
@@ -29,7 +29,8 @@ export type FxStage =
| FxDelay
| FxCrush
| FxPhaser
| FxFlanger;
| FxFlanger
| { kind: 'pan'; pan: number; mod?: FxMod };
/** A Phaser (#341): its allpass stages, where they turn, how fast and how far an LFO moves them. */
export interface FxPhaser {
@@ -218,7 +219,8 @@ export type FxStagePlan =
'frequency' | 'rate' | 'depth' | 'feedback' | 'mix',
FxAmount
>)
| ({ kind: 'flanger' } & Record<'offset' | 'sweep' | 'rate' | 'depth' | 'feedback' | 'mix', FxAmount>);
| ({ kind: 'flanger' } & Record<'offset' | 'sweep' | 'rate' | 'depth' | 'feedback' | 'mix', FxAmount>)
| { kind: 'pan'; pan: FxAmount };
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
export type FxPlan =
@@ -707,6 +709,23 @@ export const SoundFxFlanger: SoundFxNodeDef = {
},
};
const PAN = numberRow('pan', 'Pan', -1, 1, 0.01);
export const SoundFxPan: SoundFxNodeDef = {
type: 'SoundFxPan',
label: 'Pan',
category: 'sound',
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.',
inputs: [audioIn, followingIn(PAN)],
outputs: [audioOut],
params: [],
emit: (sounds, amounts) => {
const pan = held(amounts.pan, PAN);
return withStages(arriving(sounds), isNone(pan) ? [] : [{ kind: 'pan', pan }]);
},
};
/** Where damping 0 leaves the echoes and damping 1 takes them: evenly in octaves between. */
const UNDAMPED_HZ = 20000;
const DAMPED_HZ = 500;
@@ -727,6 +746,7 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
SoundFxBitcrusher,
SoundFxPhaser,
SoundFxFlanger,
SoundFxPan,
];
/** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */
@@ -838,6 +858,13 @@ function stagesOf(plan: FxPlan, params: FxParams, tempo: number): FxStage[] {
});
continue;
}
if (stage.kind === 'pan') {
const pan = amountOf(stage.pan, params);
const mod = modOf(stage.pan, params);
if (pan !== 0 || stage.pan.param !== undefined || mod)
stages.push({ kind: 'pan', pan, ...(mod && { mod }) });
continue;
}
if (stage.kind === 'flanger') {
const offsetMod = modOf(stage.offset, params);
const mixMod = modOf(stage.mix, params);