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:
1 parent
e1ca6a5f5a
commit
c99bda6489
5 files changed
+94
-2
No files matched your search
@@ -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);
|
||||
|
||||
@@ -2036,6 +2036,16 @@ export function stageNodes(
|
||||
if (stage.kind === 'crush') return crushNodes(context, stage, follow);
|
||||
if (stage.kind === 'phaser') return phaserNodes(context, stage, follow);
|
||||
if (stage.kind === 'flanger') return flangerNodes(context, stage, at, follow);
|
||||
if (stage.kind === 'pan') {
|
||||
const panner = context.createStereoPanner();
|
||||
panner.pan.value = stage.mod ? 0 : stage.pan;
|
||||
if (stage.mod) follow?.(stage.mod, panner.pan, false);
|
||||
return {
|
||||
input: panner,
|
||||
output: panner,
|
||||
knobs: [{ param: stage.mod ? null : panner.pan, target: stage.pan, even: 'value' }],
|
||||
};
|
||||
}
|
||||
const filter = context.createBiquadFilter();
|
||||
filter.type = stage.filter;
|
||||
filter.frequency.value = stage.frequency;
|
||||
@@ -2417,6 +2427,7 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] {
|
||||
into.push(stage.frequency, stage.rate, stage.depth, stage.feedback, stage.mix);
|
||||
else if (stage.kind === 'flanger')
|
||||
into.push(stage.offset, stage.sweep, stage.rate, stage.depth, stage.feedback, stage.mix);
|
||||
else if (stage.kind === 'pan') into.push(stage.pan);
|
||||
else if (stage.kind !== 'envelope') into.push(stage.gain);
|
||||
return into;
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ describe('the sound FX asset', () => {
|
||||
'SoundFxBitcrusher',
|
||||
'SoundFxPhaser',
|
||||
'SoundFxFlanger',
|
||||
'SoundFxPan',
|
||||
]);
|
||||
f.edit.addNode('SoundFxInput');
|
||||
f.edit.addNode('SoundFxOutput');
|
||||
@@ -137,6 +138,7 @@ describe('the sound FX asset', () => {
|
||||
'SoundFxBitcrusher',
|
||||
'SoundFxPhaser',
|
||||
'SoundFxFlanger',
|
||||
'SoundFxPan',
|
||||
]);
|
||||
|
||||
const behavior = makeBehavior(f, 'Logic');
|
||||
@@ -399,6 +401,21 @@ describe('a Phaser and a Flanger in a sound FX graph (#341)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a Pan in a sound FX graph (#319)', () => {
|
||||
const at = (params?: Record<string, number>) =>
|
||||
fxChainOf(compileSoundFx(line([{ type: 'SoundFxPan', params }]).fx.graph).compiled!).stages;
|
||||
|
||||
it('moves the sound by its pan held to -1 to 1, and is left out at a typed-in 0', () => {
|
||||
expect(at({ pan: -0.5 })).toEqual([{ kind: 'pan', pan: -0.5 }]);
|
||||
expect(at({ pan: 3 })).toEqual([{ kind: 'pan', pan: 1 }]);
|
||||
expect(at()).toEqual([]);
|
||||
const f = withParam('SoundFxPan', 'pan', [{ key: 'side', kind: 'number', default: 0 }]);
|
||||
const plan = soundFxBuildFor(f.fx).compiled!;
|
||||
expect(fxChainOf(plan).stages).toEqual([{ kind: 'pan', pan: 0 }]);
|
||||
expect(fxChainOf(plan, { side: -2 }).stages).toEqual([{ kind: 'pan', pan: -1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the tail of a voice (#324)', () => {
|
||||
const echo = (time: number, feedback: number, mix: number): FxStage => ({
|
||||
kind: 'delay',
|
||||
|
||||
@@ -2255,4 +2255,11 @@ describe('the stages of #318 to #328', () => {
|
||||
expect(made.knobs.map((k) => k.even)).toEqual(['ratio', 'ratio', 'value']);
|
||||
expect(made.stop).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pans with a StereoPanner whose pan glides like any knob (#319)', () => {
|
||||
const context = fakeContext() as unknown as BaseAudioContext;
|
||||
const made = stageNodes(context, { kind: 'pan', pan: -0.4 });
|
||||
expect(panners.map((p) => p.value)).toEqual([-0.4]);
|
||||
expect(made.knobs).toEqual([{ param: panners[0], target: -0.4, even: 'value' }]);
|
||||
});
|
||||
});
|
||||
@@ -975,6 +975,36 @@ export async function sound(t) {
|
||||
filtered.notch < -40,
|
||||
JSON.stringify(filtered),
|
||||
);
|
||||
// #319: Pan rendered offline. A mono sine at 0.5 all to the left leaves the right silent, in the
|
||||
// middle each side gets 0.707 of it. A stereo sound panned half right keeps its right side
|
||||
// and moves part of its left into it, as Web Audio pans stereo: left 0.707, right 1 + 0.707.
|
||||
const panned = await evaluate(`
|
||||
(async () => {
|
||||
const RATE = 48000;
|
||||
const peaks = async (pan, channels) => {
|
||||
const context = new OfflineAudioContext(2, RATE / 10, RATE);
|
||||
const sound = context.createBuffer(channels, RATE / 10, RATE);
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const data = sound.getChannelData(c);
|
||||
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE) * 0.5;
|
||||
}
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = sound;
|
||||
const nodes = __gm.sound.stageNodes(context, { kind: 'pan', pan });
|
||||
source.connect(nodes.input);
|
||||
nodes.output.connect(context.destination);
|
||||
source.start();
|
||||
const out = await context.startRendering();
|
||||
return [0, 1].map((c) => +Math.max(...out.getChannelData(c).map(Math.abs)).toFixed(3));
|
||||
};
|
||||
return { left: await peaks(-1, 1), middle: await peaks(0, 1), balance: await peaks(0.5, 2) };
|
||||
})()`);
|
||||
check(
|
||||
'Pan moves a mono sound between the sides, and a stereo one towards the side it goes to',
|
||||
JSON.stringify(panned) ===
|
||||
JSON.stringify({ left: [0.5, 0], middle: [0.354, 0.354], balance: [0.354, 0.854] }),
|
||||
JSON.stringify(panned),
|
||||
);
|
||||
// #329: the Chip on the worklet, rendered offline. A square at 220 Hz with a pitch of 12 and a duty
|
||||
// of 0.25, starting at 0.25 s: silence until then, 440 periods a second after it, up a
|
||||
// quarter of each. Sliding two octaves a second down from 440 Hz with a lowest frequency of
|
||||
|
||||
Reference in new issue
Block a user