diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index 7f44a9b9..f15b7d8e 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -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); diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index e55900c8..88a7e32a 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -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; } diff --git a/tests/core/soundfx.test.ts b/tests/core/soundfx.test.ts index 4a21bb88..eb908868 100644 --- a/tests/core/soundfx.test.ts +++ b/tests/core/soundfx.test.ts @@ -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) => + 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', diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index e5a6e058..2d81f52f 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -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' }]); + }); }); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 09e34180..d26738ff 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -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