diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index af7dd2b7..39a7a79f 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -20,21 +20,17 @@ import { eqBandsOf, eqStagesOf, hasGain, isEqBandHeard, MAX_EQ_BANDS, type PassB export { SOUND_FX_INPUT, SOUND_FX_OUTPUT }; -/** The filter shapes the EQ uses – Web Audio's names, so the player hands them on as they are. */ -export type FxFilter = 'lowshelf' | 'peaking' | 'highshelf'; - /** The shapes of a Filter (#335, #318), Web Audio's names. */ export type FxPassFilter = 'lowpass' | 'highpass' | 'bandpass' | 'notch' | 'lowshelf' | 'highshelf' | 'peaking' | 'allpass'; /** - * One step a voice passes on its way out: a volume, one band of an equalizer, a filter, the volume an + * One step a voice passes on its way out: a volume, a filter or a band of an EQ, the volume an * Envelope gives a note (#335), or an echo (#327). An echo's `damping` is already the cutoff of the * low-pass in its feedback, in Hz. A `mod` is an Envelope moving that number while the note plays. */ export type FxStage = | { kind: 'gain'; gain: number; mod?: FxMod } - | { kind: 'filter'; filter: FxFilter; frequency: number; gain: number; q: number; mod?: FxMod } | FxPass | { kind: 'envelope'; envelope: FxEnvelope } | FxDelay @@ -298,7 +294,6 @@ export interface FxAmount { export type FxStagePlan = | { kind: 'gain'; db: FxAmount } - | { kind: 'filter'; filter: FxFilter; frequency: number; gain: FxAmount; q: number } | { kind: 'pass'; filter: FxPassFilter; stages: number; cutoff: FxAmount; q: FxAmount; gain: FxAmount } | { kind: 'envelope'; envelope: FxEnvelopePlan } | { @@ -588,43 +583,6 @@ export const SoundFxPitch: SoundFxNodeDef = effect( 'own', ); -/** The five bands of the EQ: a shelf at either end and three peaks between them. */ -const EQ_BANDS: readonly { key: string; label: string; filter: FxFilter; frequency: number }[] = [ - { key: 'low', label: '80 Hz', filter: 'lowshelf', frequency: 80 }, - { key: 'lowMid', label: '250 Hz', filter: 'peaking', frequency: 250 }, - { key: 'mid', label: '1 kHz', filter: 'peaking', frequency: 1000 }, - { key: 'highMid', label: '4 kHz', filter: 'peaking', frequency: 4000 }, - { key: 'high', label: '12 kHz', filter: 'highshelf', frequency: 12000 }, -]; - -/** About an octave and a half wide: neighbouring peaks overlap, so no frequency falls between them. */ -const PEAK_Q = 1; -const EQ_ROWS = EQ_BANDS.map((band) => numberRow(band.key, band.label, -60, 24, 0.5)); - -export const SoundFxEq: SoundFxNodeDef = effect( - { - type: 'SoundFxEq', - label: '5-Band-EQ', - category: 'sound', - description: - 'A five-band equalizer: raises or lowers the lows (80 Hz), low mids (250 Hz), mids (1 kHz), high mids (4 kHz) and highs (12 kHz), each in decibels. A band at 0 does nothing.' + - BLENDS, - inputs: [audioIn, ...EQ_ROWS.map(followingIn)], - outputs: [audioOut], - params: [], - emit: (sounds, amounts) => { - const stages: FxStagePlan[] = []; - EQ_BANDS.forEach((band, i) => { - const gain = held(amounts[band.key], EQ_ROWS[i]!); - if (!isNone(gain)) - stages.push({ kind: 'filter', filter: band.filter, frequency: band.frequency, gain, q: PEAK_Q }); - }); - return withStages(arriving(sounds), stages); - }, - }, - 'blend', -); - export const SoundFxParametricEq: SoundFxNodeDef = effect( { type: 'SoundFxParametricEq', @@ -1326,7 +1284,6 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [ SoundFxOutput, SoundFxGain, SoundFxPitch, - SoundFxEq, SoundFxParametricEq, SoundFxFilter, SoundFxMix, @@ -1542,26 +1499,19 @@ function stagesOf(plan: readonly FxStagePlan[], params: FxParams, tempo: number) stages.push({ kind: 'pan', pan, ...(mod && { mod }) }); continue; } - if (stage.kind === 'flanger') { - const offsetMod = modOf(stage.offset, params); - const mixMod = modOf(stage.mix, params); - stages.push({ - kind: 'flanger', - offset: amountOf(stage.offset, params), - sweep: amountOf(stage.sweep, params), - rate: amountOf(stage.rate, params), - depth: amountOf(stage.depth, params), - feedback: amountOf(stage.feedback, params), - mix: amountOf(stage.mix, params), - ...(offsetMod && { offsetMod }), - ...(mixMod && { mixMod }), - }); - continue; - } - const gain = amountOf(stage.gain, params); - const mod = modOf(stage.gain, params); - if (gain !== 0 || stage.gain.param !== undefined || mod) - stages.push({ ...stage, gain, ...(mod && { mod }) }); + const offsetMod = modOf(stage.offset, params); + const mixMod = modOf(stage.mix, params); + stages.push({ + kind: 'flanger', + offset: amountOf(stage.offset, params), + sweep: amountOf(stage.sweep, params), + rate: amountOf(stage.rate, params), + depth: amountOf(stage.depth, params), + feedback: amountOf(stage.feedback, params), + mix: amountOf(stage.mix, params), + ...(offsetMod && { offsetMod }), + ...(mixMod && { mixMod }), + }); } return stages; } diff --git a/src/viewport/fxNodes.ts b/src/viewport/fxNodes.ts index cd0fb622..f7284daa 100644 --- a/src/viewport/fxNodes.ts +++ b/src/viewport/fxNodes.ts @@ -133,26 +133,13 @@ export function stageNodes( { name: 'release', value: stage.release, even: 'ratio' }, { name: 'autoGain', value: stage.autoGain, even: 'value' }, ]); - 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; - filter.gain.value = stage.mod ? 0 : stage.gain; - filter.Q.value = stage.q; - if (stage.mod) follow?.(stage.mod, filter.gain, false); + const panner = context.createStereoPanner(); + panner.pan.value = stage.mod ? 0 : stage.pan; + if (stage.mod) follow?.(stage.mod, panner.pan, false); return { - input: filter, - output: filter, - knobs: [{ param: stage.mod ? null : filter.gain, target: stage.gain, even: 'value' }], + input: panner, + output: panner, + knobs: [{ param: stage.mod ? null : panner.pan, target: stage.pan, even: 'value' }], }; } diff --git a/tests/core/envelope.test.ts b/tests/core/envelope.test.ts index 23bc97d2..cae507b8 100644 --- a/tests/core/envelope.test.ts +++ b/tests/core/envelope.test.ts @@ -198,21 +198,21 @@ describe('an Envelope in an instrument', () => { }); }); - it('moves a Pitch on top of the one the way has, a Gain in its factor, a band and a Mix', () => { + it('moves a Pitch on top of the one the way has, a Gain in its factor, a Pan and a Mix', () => { const f = makeInstrument(); const envelope = f.edit.addNode('InstrumentEnvelope', { from: -12, to: 0 }); const pitch = f.edit.addNode('SoundFxPitch'); const gain = f.edit.addNode('SoundFxGain'); - const eq = f.edit.addNode('SoundFxEq'); + const pan = f.edit.addNode('SoundFxPan'); const mix = f.edit.addNode('SoundFxMix'); f.edit.wire([ [f.sample, 'out', pitch, 'in'], [envelope, 'value', pitch, 'semitones'], [pitch, 'out', gain, 'in'], [envelope, 'value', gain, 'gain'], - [gain, 'out', eq, 'in'], - [envelope, 'value', eq, 'mid'], - [eq, 'out', mix, 'a'], + [gain, 'out', pan, 'in'], + [envelope, 'value', pan, 'pan'], + [pan, 'out', mix, 'a'], [f.sample, 'out', mix, 'b'], [envelope, 'value', mix, 'mix'], [mix, 'out', f.output, 'in'], @@ -227,7 +227,8 @@ describe('an Envelope in an instrument', () => { expect(chain.a.pitchMods).toMatchObject([{ from: -12, to: 0 }]); expect(chain.a.stages).toMatchObject([ { kind: 'gain', mod: { from: 10 ** (-12 / 20), to: 1 } }, - { kind: 'filter', frequency: 1000, mod: { from: -12, to: 0 } }, + // The Pan rests at "from" as well, held to -1..1. + { kind: 'pan', mod: { from: -1, to: 0 } }, ]); }); diff --git a/tests/core/instrument.test.ts b/tests/core/instrument.test.ts index 06ee0bab..c9fef438 100644 --- a/tests/core/instrument.test.ts +++ b/tests/core/instrument.test.ts @@ -307,7 +307,7 @@ describe('compiling a note', () => { f.instrument.graph.edges = f.instrument.graph.edges.filter((e) => e.to.node !== output.id); const byVelocity = f.edit.addNode('SoundFxGain'); const byParam = f.edit.addNode('SoundFxGain'); - const byNote = f.edit.addNode('SoundFxEq'); + const byNote = f.edit.addNode('SoundFxPan'); f.stack.execute( new ConnectCommand( f.instrument.id, @@ -320,21 +320,21 @@ describe('compiling a note', () => { [byVelocity, 'out', byParam, 'in'], [params, 'level', byParam, 'gain'], [byParam, 'out', byNote, 'in'], - [notes, 'note', byNote, 'high'], + [notes, 'note', byNote, 'pan'], [byNote, 'out', output, 'in'], ]); const plan = compile(f, 62).layers[0]!; expect(plan.stages).toEqual([ { kind: 'gain', db: { value: 1, param: VELOCITY_PARAM, min: -120, max: 24 } }, { kind: 'gain', db: { value: -3, param: 'level', min: -120, max: 24 } }, - // The note is a number by now: 62, held to the band's row. - { kind: 'filter', filter: 'highshelf', frequency: 12000, gain: { value: 24 }, q: 1 }, + // The note is a number by now: 62, held to the Pan's row. + { kind: 'pan', pan: { value: 1 } }, ]); const chain = fxChainOf(plan, { [VELOCITY_PARAM]: -6, level: -9 }); - expect(chain.stages.map((s) => ('gain' in s ? s.gain : 0))).toEqual([ + expect(chain.stages.map((s) => (s.kind === 'pan' ? s.pan : 'gain' in s ? s.gain : 0))).toEqual([ 10 ** (-6 / 20), 10 ** (-9 / 20), - 24, + 1, ]); }); diff --git a/tests/core/soundfx.test.ts b/tests/core/soundfx.test.ts index 6df79233..d8f0ec79 100644 --- a/tests/core/soundfx.test.ts +++ b/tests/core/soundfx.test.ts @@ -80,6 +80,15 @@ function line(effects: { type: string; params?: PropertyBag }[]) { return { ...f, input, output }; } +/** A shelf of a Parametric EQ as its node keeps it. */ +const shelf = (type: 'lowshelf' | 'highshelf', frequency: number, gain: number) => ({ + type, + frequency, + gain, + q: 1, + slope: 12, +}); + describe('the sound FX asset', () => { it('is not placeable, starts with an empty graph and is what the fx picker offers', () => { expect(isPlaceableType('soundfx')).toBe(false); @@ -128,7 +137,6 @@ describe('the sound FX asset', () => { 'SoundFxOutput', 'SoundFxGain', 'SoundFxPitch', - 'SoundFxEq', 'SoundFxParametricEq', 'SoundFxFilter', 'SoundFxMix', @@ -149,7 +157,6 @@ describe('the sound FX asset', () => { expect(ids()).toEqual([ 'SoundFxGain', 'SoundFxPitch', - 'SoundFxEq', 'SoundFxParametricEq', 'SoundFxFilter', 'SoundFxMix', @@ -194,24 +201,28 @@ describe('compiling a sound FX graph', () => { const f = line([ { type: 'SoundFxGain', params: { gain: -6 } }, { type: 'SoundFxPitch', params: { semitones: 12 } }, - { type: 'SoundFxEq', params: { low: 6, high: -3 } }, + { + type: 'SoundFxParametricEq', + params: { bands: [shelf('lowshelf', 80, 6), shelf('highshelf', 12000, -3)] }, + }, { type: 'SoundFxPitch', params: { semitones: -5 } }, ]); const build = compileSoundFx(f.fx.graph); expect(build.effects).toBe(4); const chain = fxChainOf(build.compiled!); expect(pitchOf(chain)).toBe(7); - expect(chain.stages).toHaveLength(3); - expect(chain.stages[0]!.kind).toBe('gain'); - expect(gainOf(chain.stages[0])).toBeCloseTo(0.501, 3); - expect(chain.stages.slice(1)).toEqual([ - { kind: 'filter', filter: 'lowshelf', frequency: 80, gain: 6, q: 1 }, - { kind: 'filter', filter: 'highshelf', frequency: 12000, gain: -3, q: 1 }, + const stages = chain.stages.filter((s) => s.kind !== 'tap'); + expect(stages).toHaveLength(3); + expect(stages[0]!.kind).toBe('gain'); + expect(gainOf(stages[0])).toBeCloseTo(0.501, 3); + expect(stages.slice(1)).toEqual([ + { kind: 'pass', filter: 'lowshelf', stages: 1, cutoff: 80, q: 1, gain: 6 }, + { kind: 'pass', filter: 'highshelf', stages: 1, cutoff: 12000, q: 1, gain: -3 }, ]); }); it('leaves out what does nothing and holds a value to its row', () => { - const quiet = compileSoundFx(line([{ type: 'SoundFxGain' }, { type: 'SoundFxEq' }]).fx.graph); + const quiet = compileSoundFx(line([{ type: 'SoundFxGain' }, { type: 'SoundFxPan' }]).fx.graph); expect(quiet.compiled).toEqual(EMPTY_PLAN); expect(quiet.effects).toBe(2); @@ -232,21 +243,21 @@ describe('compiling a sound FX graph', () => { }); describe('a Mix in a sound FX graph (#316)', () => { - /** Input → Pitch +12 → Mix a, Input → Gain -6 → Mix b, Mix → EQ → Output. */ + /** Input → Pitch +12 → Mix a, Input → Gain -6 → Mix b, Mix → Pan → Output. */ function forked(mix: Record = {}) { const f = makeFx(); const input = f.edit.addNode('SoundFxInput'); const pitch = f.edit.addNode('SoundFxPitch', { semitones: 12 }); const gain = f.edit.addNode('SoundFxGain', { gain: -6 }); const node = f.edit.addNode('SoundFxMix', mix); - const eq = f.edit.addNode('SoundFxEq', { low: 6 }); + const pan = f.edit.addNode('SoundFxPan', { pan: 0.5 }); const output = f.edit.addNode('SoundFxOutput'); f.edit.connect(input, 'out', pitch, 'in'); f.edit.connect(input, 'out', gain, 'in'); f.edit.connect(pitch, 'out', node, 'a'); f.edit.connect(gain, 'out', node, 'b'); - f.edit.connect(node, 'out', eq, 'in'); - f.edit.connect(eq, 'out', output, 'in'); + f.edit.connect(node, 'out', pan, 'in'); + f.edit.connect(pan, 'out', output, 'in'); return { ...f, input, mix: node }; } @@ -258,17 +269,17 @@ describe('a Mix in a sound FX graph (#316)', () => { mix: 0.5, a: { pitch: 12, stages: [] }, b: { pitch: 0, stages: [{ kind: 'gain' }] }, - stages: [{ kind: 'filter', filter: 'lowshelf', gain: 6 }], + stages: [{ kind: 'pan', pan: 0.5 }], }); }); it('is only the one sound at 0 or 1, with what follows the Mix', () => { expect(fxChainOf(compileSoundFx(forked({ mix: 0 }).fx.graph).compiled!)).toMatchObject({ pitch: 12, - stages: [{ kind: 'filter' }], + stages: [{ kind: 'pan' }], }); const b = fxChainOf(compileSoundFx(forked({ mix: 5 }).fx.graph).compiled!); - expect(b).toMatchObject({ pitch: 0, stages: [{ kind: 'gain' }, { kind: 'filter' }] }); + expect(b).toMatchObject({ pitch: 0, stages: [{ kind: 'gain' }, { kind: 'pan' }] }); }); it('says its curve only where it is "Both at center", and is still one sound at 0 or 1', () => { @@ -294,15 +305,15 @@ describe('a Mix in a sound FX graph (#316)', () => { it('hands a Pitch behind it to both of its sounds', () => { const f = forked(); const output = f.fx.graph.nodes.find((n) => n.type === 'SoundFxOutput')!; - const eq = f.fx.graph.nodes.find((n) => n.type === 'SoundFxEq')!; + const pan = f.fx.graph.nodes.find((n) => n.type === 'SoundFxPan')!; f.fx.graph.edges = f.fx.graph.edges.filter((e) => e.to.node !== output.id); const after = f.edit.addNode('SoundFxPitch', { semitones: -5 }); - f.edit.connect(eq, 'out', after, 'in'); + f.edit.connect(pan, 'out', after, 'in'); f.edit.connect(after, 'out', output, 'in'); expect(fxChainOf(compileSoundFx(f.fx.graph).compiled!)).toMatchObject({ a: { pitch: 7 }, b: { pitch: -5 }, - stages: [{ kind: 'filter' }], + stages: [{ kind: 'pan' }], }); }); @@ -626,7 +637,6 @@ describe('a Reverb in a sound FX graph (#328)', () => { describe('Bypass and Wet (#354)', () => { /** Every effect, and how it says its wet: a row of its own, or the "mix" it had. */ const EFFECTS: Record = { - SoundFxEq: 'wet', SoundFxBitcrusher: 'mix', SoundFxCompressor: 'wet', SoundFxDelay: 'mix', @@ -665,12 +675,12 @@ describe('Bypass and Wet (#354)', () => { def.type === 'SoundFxMix', ]); } - expect(Object.keys(EFFECTS)).toHaveLength(16); + expect(Object.keys(EFFECTS)).toHaveLength(15); }); it('bypassed, an effect is left out as if it were not there, and its node is drawn faded', () => { for (const type of Object.keys(EFFECTS)) { - const on = { gain: -6, semitones: 5, low: 3, pan: 1, depth: 50, bands: [{ gain: 6 }] }; + const on = { gain: -6, semitones: 5, pan: 1, depth: 50, bands: [{ gain: 6 }] }; expect([type, stagesOf(type, on).length > 0 || type === 'SoundFxPitch']).toEqual([type, true]); // The taps a Parametric EQ keeps do nothing to the sound (#374). const bypassed = compileSoundFx(line([{ type, params: { ...on, bypass: true } }]).fx.graph).compiled!; @@ -691,7 +701,8 @@ describe('Bypass and Wet (#354)', () => { { kind: 'wet', wet: { value: 0.25 }, stages: [{ kind: 'gain', db: { value: -6 } }] }, ]); // Every band of an EQ in one blend, not each band on its own. - const eq = stagesOf('SoundFxEq', { low: 6, high: -3, wet: 0.5 }); + const bands = [shelf('lowshelf', 80, 6), shelf('highshelf', 12000, -3)]; + const eq = stagesOf('SoundFxParametricEq', { bands, wet: 0.5 }).filter((s) => s.kind !== 'tap'); expect(eq).toMatchObject([{ kind: 'wet', stages: [{ filter: 'lowshelf' }, { filter: 'highshelf' }] }]); // What came before the node stays outside the blend. const f = line([ @@ -953,10 +964,10 @@ describe('the parameters of a sound FX', () => { expect(fxChainOf(soundFxBuildFor(typed.fx).compiled!)).toMatchObject({ pitch: 0, shifted: false }); }); - it('feed an EQ band as well', () => { - const f = withParam('SoundFxEq', 'mid', [{ key: 'body', kind: 'number', default: 3 }]); - const chain = fxChainOf(soundFxBuildFor(f.fx).compiled!, { body: -4 }); - expect(chain.stages).toEqual([{ kind: 'filter', filter: 'peaking', frequency: 1000, gain: -4, q: 1 }]); + it('feed the cutoff of a Filter as well', () => { + const f = withParam('SoundFxFilter', 'cutoff', [{ key: 'bright', kind: 'number', default: 500 }]); + const chain = fxChainOf(soundFxBuildFor(f.fx).compiled!, { bright: 2000 }); + expect(chain.stages.filter((s) => s.kind !== 'tap')).toMatchObject([{ kind: 'pass', cutoff: 2000 }]); }); it('are kept with their defaults through a save, a default only on a number', () => { diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index f4b2af5f..f8882e90 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -422,7 +422,13 @@ afterEach(() => { function planOf(chain: Chain): FxPlan { const stages = chain.stages.map((stage): FxStagePlan => { if (stage.kind === 'gain') return { kind: 'gain', db: { value: 20 * Math.log10(stage.gain) } }; - if (stage.kind === 'filter') return { ...stage, gain: { value: stage.gain } }; + if (stage.kind === 'pass') + return { + ...stage, + cutoff: { value: stage.cutoff }, + q: { value: stage.q }, + gain: { value: stage.gain }, + }; if (stage.kind === 'crush') return { kind: 'crush', @@ -450,7 +456,16 @@ function open(): FxPlan { mix: { value: 0.25, param: 'blend', min: 0, max: 1 }, a: { pitch: [{ value: 0, param: 'depth', min: -64, max: 64 }], stages: [] }, b: { pitch: [], stages: [{ kind: 'gain', db: { value: 0, param: 'loud', min: -120, max: 24 } }] }, - stages: [{ kind: 'filter', filter: 'lowshelf', frequency: 80, gain: { value: 6 }, q: 1 }], + stages: [ + { + kind: 'pass', + filter: 'lowshelf', + stages: 1, + cutoff: { value: 80 }, + gain: { value: 6 }, + q: { value: 1 }, + }, + ], }; } @@ -642,7 +657,7 @@ describe('the sound library', () => { pitch: 12, stages: [ { kind: 'gain', gain: 0.5 }, - { kind: 'filter', filter: 'lowshelf', frequency: 80, gain: 6, q: 1 }, + { kind: 'pass', filter: 'lowshelf', stages: 1, cutoff: 80, gain: 6, q: 1 }, ], }; await play(library, FILE, {}, fx); @@ -791,7 +806,7 @@ describe('the sound library', () => { expect(worklets).toHaveLength(0); expect(sources).toHaveLength(2); expect(library.tune(voice, { depth: 12 })).toBe(true); - expect(library.levels(FILE)).toEqual([[12, 0.75, 1, 0.25, 6]]); + expect(library.levels(FILE)).toEqual([[12, 0.75, 1, 0.25, 80, 1, 6]]); expect(console.error).toHaveBeenCalledTimes(1); }); @@ -805,7 +820,7 @@ describe('the sound library', () => { mix: 0.25, a: { pitch: 12, stages: [] }, b: { pitch: 0, stages: [{ kind: 'gain', gain: 0.5 }] }, - stages: [{ kind: 'filter', filter: 'lowshelf', frequency: 80, gain: 6, q: 1 }], + stages: [{ kind: 'pass', filter: 'lowshelf', stages: 1, cutoff: 80, gain: 6, q: 1 }], }, ); expect(wires).toEqual([ diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 26f8d835..2cfff47a 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -355,7 +355,7 @@ export async function sound(t) { const fxPalette = await json('__gm.overlayTexts()'); check( 'its palette offers the sound effects and no behavior node', - ['Input', 'Output', 'Gain', 'Pitch', '5-Band-EQ', 'Mix', 'Delay'].every((label) => + ['Input', 'Output', 'Gain', 'Pitch', 'Parametric EQ', 'Mix', 'Delay'].every((label) => fxPalette.includes(label), ) && !fxPalette.includes('Branch'), JSON.stringify(fxPalette.slice(0, 12)),