diff --git a/src/core/instrument/chipPresets.ts b/src/core/instrument/chipPresets.ts index 899e57fe..4695b583 100644 --- a/src/core/instrument/chipPresets.ts +++ b/src/core/instrument/chipPresets.ts @@ -7,6 +7,7 @@ import type { ChipWave } from '../soundfx/chip'; import { CRUSH_ROWS, FILTER_ROWS, + FLAT_Q, type FxPassFilter, passOf, SoundFxBitcrusher, @@ -341,12 +342,22 @@ function envelopeNumbers(p: Sfxr): Record<'attack' | 'decay' | 'sustain' | 'rele }; } +/** + * The Q a Filter had at a resonance from 0 to 1 before it took a Q (#318): from no bump up to a + * 20 dB ring for a low- or high-pass, from about two octaves wide to a narrow ring for a band-pass. + */ +function resonanceQ(filter: FxPassFilter, resonance: number): number { + const r = Math.max(0, Math.min(1, resonance)); + if (filter === 'bandpass') return 0.7 * (20 / 0.7) ** r; + return FLAT_Q * (10 / FLAT_Q) ** r; +} + /** Where sfxr's filters come out, run 8 times a sample: about 17.8 kHz times `f^1.5` low, 5.6 kHz times `f²` high. */ -function filterNumbers(p: Sfxr, filter: FxPassFilter): Record<'cutoff' | 'resonance', number> { - if (filter === 'highpass') return { cutoff: 5615 * p.hpfFreq ** 2, resonance: 0 }; +function filterNumbers(p: Sfxr, filter: FxPassFilter): Record<'cutoff' | 'q', number> { + if (filter === 'highpass') return { cutoff: 5615 * p.hpfFreq ** 2, q: FLAT_Q }; // Wide open, as far as the row goes. - if (p.lpfFreq >= 1) return { cutoff: Infinity, resonance: 0 }; - return { cutoff: 17756 * Math.max(0, p.lpfFreq) ** 1.5, resonance: Math.abs(p.lpfResonance) }; + if (p.lpfFreq >= 1) return { cutoff: Infinity, q: FLAT_Q }; + return { cutoff: 17756 * Math.max(0, p.lpfFreq) ** 1.5, q: resonanceQ(filter, Math.abs(p.lpfResonance)) }; } /** @@ -488,7 +499,7 @@ function chipOn( const envelope = (attack: number, decay: number, sustain: number, release: number) => [INSTRUMENT_ENVELOPE, held({ attack, decay, sustain, release }, ENVELOPE_ROWS)] as const; const pass = (filter: FxPassFilter, cutoff: number, resonance = 0) => - [SoundFxFilter.type, { filter, cutoff, resonance }] as const; + [SoundFxFilter.type, { filter, cutoff, q: heldTo(resonanceQ(filter, resonance), FILTER_ROWS.q) }] as const; const pitch = (semitones: number) => [SoundFxPitch.type, { semitones }] as const; const phaser = (stages: number, frequency: number, rate: number, depth: number, feedback: number) => [SoundFxPhaser.type, { stages: String(stages), frequency, rate, depth, feedback, mix: 1 }] as const; @@ -801,7 +812,8 @@ const MUTATE: Readonly> = { attack: { by: 0.005 }, decay: { by: 0.02 }, release: { by: 0.01 }, - resonance: { by: 0.05 }, + q: { octaves: 0.25 }, + gain: { by: 1 }, bits: { by: 0.5 }, offset: { by: 0.1 }, sweep: { by: 2 }, diff --git a/src/core/instrument/compile.ts b/src/core/instrument/compile.ts index d383619c..3d28f1e7 100644 --- a/src/core/instrument/compile.ts +++ b/src/core/instrument/compile.ts @@ -360,42 +360,17 @@ function throughFxWith(fx: FxPlan, sound: FxPlan, bind: (amount: FxAmount) => Fx return withStages(pitched, stages); } +/** Every number of a stage, bound; an Envelope's own numbers are the instrument's and stay. */ function bindStage(stage: FxStagePlan, bind: (amount: FxAmount) => FxAmount): FxStagePlan { - if (stage.kind === 'gain') return { ...stage, db: bind(stage.db) }; - if (stage.kind === 'filter') return { ...stage, gain: bind(stage.gain) }; - if (stage.kind === 'pass') - return { ...stage, cutoff: bind(stage.cutoff), resonance: bind(stage.resonance) }; if (stage.kind === 'envelope') return stage; - if (stage.kind === 'crush') - return { ...stage, bits: bind(stage.bits), rate: bind(stage.rate), mix: bind(stage.mix) }; - if (stage.kind === 'phaser') - return { - ...stage, - frequency: bind(stage.frequency), - rate: bind(stage.rate), - depth: bind(stage.depth), - feedback: bind(stage.feedback), - mix: bind(stage.mix), - }; - if (stage.kind === 'flanger') - return { - ...stage, - offset: bind(stage.offset), - sweep: bind(stage.sweep), - rate: bind(stage.rate), - depth: bind(stage.depth), - feedback: bind(stage.feedback), - mix: bind(stage.mix), - }; - return { - ...stage, - time: bind(stage.time), - feedback: bind(stage.feedback), - damping: bind(stage.damping), - mix: bind(stage.mix), - }; + const bound: Record = {}; + for (const [key, value] of Object.entries(stage)) bound[key] = isAmount(value) ? bind(value) : value; + return bound as FxStagePlan; } +const isAmount = (value: unknown): value is FxAmount => + typeof value === 'object' && value !== null && typeof (value as FxAmount).value === 'number'; + const builds = new Map }>(); /** diff --git a/src/core/soundfx/compile.ts b/src/core/soundfx/compile.ts index a46154dc..58a4865a 100644 --- a/src/core/soundfx/compile.ts +++ b/src/core/soundfx/compile.ts @@ -1,6 +1,7 @@ import { graphEmitter } from '../graph/emitGraph'; import { type DeclaredPort, type Graph, graphFingerprint, type GraphNode } from '../graph/Graph'; -import { terminalNode } from '../graph/nested'; +import { resolveDef, terminalNode } from '../graph/nested'; +import { NO_LOOKUP } from '../graph/lookup'; import { inlineValue, nodeDef } from '../graph/NodeRegistry'; import { asNumber } from '../graph/values'; import type { SoundFxAsset } from '../model/Asset'; @@ -50,6 +51,8 @@ export function compileSoundFx(graph: Graph): SoundFxBuild { const walk = graphEmitter(graph, { isDef: isSoundFxNodeDef, noun: 'sound FX', + resolve: (node, def) => + def.derivePorts ? ((resolveDef(node, graph, NO_LOOKUP) as SoundFxNodeDef | undefined) ?? def) : def, emit: (def, inputs, node) => { if (node.type === SOUND_FX_INPUT) return inputOutputs(node); const amounts: Record = {}; diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index 09ee4c69..7f44a9b9 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -12,8 +12,9 @@ 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). */ -export type FxPassFilter = 'lowpass' | 'highpass' | 'bandpass'; +/** 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 @@ -67,14 +68,21 @@ export interface FxCrush { mixMod?: FxMod; } -/** A filter as Web Audio takes it: `q` in dB for a low- or high-pass, as a factor for a band-pass. */ +/** + * A filter as Web Audio takes it: `q` in dB for a low- or high-pass, as a factor for the others, + * `gain` in dB for a shelf or a peak. `stages` of them run one after another for a steeper slope + * (#318); only the last has the Q. + */ export interface FxPass { kind: 'pass'; filter: FxPassFilter; + stages: number; cutoff: number; q: number; + gain: number; cutoffMod?: FxMod; qMod?: FxMod; + gainMod?: FxMod; } /** @@ -193,7 +201,7 @@ export interface FxAmount { export type FxStagePlan = | { kind: 'gain'; db: FxAmount } | { kind: 'filter'; filter: FxFilter; frequency: number; gain: FxAmount; q: number } - | { kind: 'pass'; filter: FxPassFilter; cutoff: FxAmount; resonance: FxAmount } + | { kind: 'pass'; filter: FxPassFilter; stages: number; cutoff: FxAmount; q: FxAmount; gain: FxAmount } | { kind: 'envelope'; envelope: FxEnvelopePlan } | { kind: 'delay'; @@ -406,28 +414,27 @@ export const SoundFxEq: SoundFxNodeDef = { }, }; -/** The Q of a low- or high-pass with no bump at its cutoff, in the decibels Web Audio takes it in. */ -export const FLAT_Q = 20 * Math.log10(Math.SQRT1_2); -/** How far a low- or high-pass at resonance 1 rises at its cutoff, in dB: it rings, but stays usable. */ -const RESONANT_Q = 20; -/** A band-pass from about two octaves wide at resonance 0 to a narrow ring at 1, as a factor. */ -const WIDE_BAND_Q = 0.7; -const NARROW_BAND_Q = 20; +/** The Q of a filter with no bump at its cutoff, as a factor. */ +export const FLAT_Q = Math.SQRT1_2; -/** The Q a Filter of that shape has at a resonance from 0 to 1. */ -export function passQ(filter: FxPassFilter, resonance: number): number { - const r = Math.max(0, Math.min(1, resonance)); - if (filter === 'bandpass') return WIDE_BAND_Q * (NARROW_BAND_Q / WIDE_BAND_Q) ** r; - return FLAT_Q + (RESONANT_Q - FLAT_Q) * r; +/** A Q as Web Audio takes it for that shape: in dB for a low- or high-pass, else the factor itself. */ +export function passQ(filter: FxPassFilter, q: number): number { + return filter === 'lowpass' || filter === 'highpass' ? 20 * Math.log10(q) : q; } -const CUTOFF = { ...numberRow('cutoff', 'Cutoff (Hz)', 20, 20000, 1), default: 1000 }; -const RESONANCE = numberRow('resonance', 'Resonance', 0, 1, 0.01); -export const FILTER_ROWS = { cutoff: CUTOFF, resonance: RESONANCE }; +const CUTOFF = { ...numberRow('cutoff', 'Frequency (Hz)', 20, 20000, 1), default: 1000 }; +const Q = { ...numberRow('q', 'Q', 0.1, 30, 0.001), default: 0.707 }; +const FILTER_GAIN = numberRow('gain', 'Gain (dB)', -40, 40, 0.5); +export const FILTER_ROWS = { cutoff: CUTOFF, q: Q, gain: FILTER_GAIN }; const PASS_FILTERS: { value: FxPassFilter; label: string }[] = [ { value: 'lowpass', label: 'Low-pass' }, { value: 'highpass', label: 'High-pass' }, { value: 'bandpass', label: 'Band-pass' }, + { value: 'notch', label: 'Notch' }, + { value: 'lowshelf', label: 'Low shelf' }, + { value: 'highshelf', label: 'High shelf' }, + { value: 'peaking', label: 'Peaking' }, + { value: 'allpass', label: 'All-pass' }, ]; const PASS = { key: 'filter', @@ -436,27 +443,53 @@ const PASS = { default: 'lowpass', options: PASS_FILTERS, }; +/** How steep a filter falls off (#318): 12 dB an octave for one, 24 for two, 48 for four in a row. */ +const SLOPE = { + key: 'slope', + label: 'Slope', + kind: 'enum' as const, + default: '12', + options: [ + { value: '12', label: '12 dB/oct' }, + { value: '24', label: '24 dB/oct' }, + { value: '48', label: '48 dB/oct' }, + ], +}; +/** The shapes that have a gain, and those that get steeper with more of them in a row. */ +const GAINED: ReadonlySet = new Set(['lowshelf', 'highshelf', 'peaking']); +const SLOPED: ReadonlySet = new Set(['lowpass', 'highpass', 'bandpass', 'notch']); export function passOf(settings: PropertyBag): FxPassFilter { return PASS_FILTERS.find((option) => option.value === settings[PASS.key])?.value ?? 'lowpass'; } +/** How many filters a Filter runs in a row: one for a shape a slope does nothing for. */ +export function passStagesOf(settings: PropertyBag): number { + if (!SLOPED.has(passOf(settings))) return 1; + const slope = settings[SLOPE.key]; + return slope === '48' ? 4 : slope === '24' ? 2 : 1; +} + export const SoundFxFilter: SoundFxNodeDef = { type: 'SoundFxFilter', label: 'Filter', category: 'sound', description: - 'Takes away what is above "cutoff" (low-pass), what is below it (high-pass), or both, leaving a band around it (band-pass). "Resonance" from 0 to 1 raises what is right at the cutoff: at 0 a low- or high-pass has no bump, towards 1 it rings, and a band-pass gets narrower. In an instrument an Envelope can move both while a note plays, for a filter sweep.', - inputs: [audioIn, followingIn(CUTOFF), followingIn(RESONANCE)], + 'Takes away what is above "frequency" (low-pass), what is below it (high-pass), all but a band around it (band-pass) or just that band (notch). A low or high shelf raises or lowers everything below or above it by "gain" in dB, a peak a band around it. An all-pass leaves every frequency as loud as it was and only turns its phase. "Q" from 0.1 to 30 says how sharp it is: at 0.707 a low- or high-pass has no bump, higher it rings at the frequency, and a band, a notch or a peak gets narrower. "Slope" makes a low-pass, high-pass, band-pass or notch steeper: 12 dB an octave is one filter, 24 two and 48 four in a row, and only the last of them has the Q. All three numbers can change while the sound plays, the frequency gliding in octaves, and in an instrument an Envelope can move them, for a filter sweep.', + inputs: [audioIn, followingIn(CUTOFF), followingIn(Q)], outputs: [audioOut], - params: [PASS], + params: [PASS, SLOPE], + idleParams: (params) => (SLOPED.has(passOf(params)) ? [] : [SLOPE.key]), + derivePorts: ({ node }) => (GAINED.has(passOf(node.params)) ? { inputs: [followingIn(FILTER_GAIN)] } : {}), emit: (sounds, amounts, settings) => withStages(arriving(sounds), [ { kind: 'pass', filter: passOf(settings), + stages: passStagesOf(settings), cutoff: held(amounts.cutoff, CUTOFF), - resonance: held(amounts.resonance, RESONANCE), + q: held(amounts.q, Q), + gain: GAINED.has(passOf(settings)) ? held(amounts.gain, FILTER_GAIN) : { value: 0 }, }, ]), }; @@ -748,14 +781,18 @@ function stagesOf(plan: FxPlan, params: FxParams, tempo: number): FxStage[] { } if (stage.kind === 'pass') { const cutoffMod = modOf(stage.cutoff, params); - const qMod = modOf(stage.resonance, params, (r) => passQ(stage.filter, r)); + const qMod = modOf(stage.q, params, (q) => passQ(stage.filter, q)); + const gainMod = modOf(stage.gain, params); stages.push({ kind: 'pass', filter: stage.filter, + stages: stage.stages, cutoff: amountOf(stage.cutoff, params), - q: passQ(stage.filter, amountOf(stage.resonance, params)), + q: passQ(stage.filter, amountOf(stage.q, params)), + gain: amountOf(stage.gain, params), ...(cutoffMod && { cutoffMod }), ...(qMod && { qMod }), + ...(gainMod && { gainMod }), }); continue; } diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 0c0ff8fb..e55900c8 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -2,6 +2,7 @@ import { DELAY_MAX, DRY_VOICE, FLAT_Q, + passQ, fxChainOf, type FxChain, type FxChip, @@ -2048,22 +2049,48 @@ export function stageNodes( }; } -/** A Filter (#335): a cutoff that moves in octaves, and a Q – a band-pass's as a factor, so in ratios too. */ +/** + * A Filter (#335, #318): a cutoff that moves in octaves, a Q – as a factor in ratios too, in dB for a + * low- or high-pass – and a gain in dB. Several in a row share one cutoff, carried by a source of its + * own into each of them; only the last has the Q, the others none. + */ function passNodes(context: BaseAudioContext, stage: FxPass, follow?: Follow): StageNodes { - const filter = context.createBiquadFilter(); - filter.type = stage.filter; - filter.frequency.value = stage.cutoffMod ? 0 : stage.cutoff; - filter.Q.value = stage.qMod ? 0 : stage.q; - const qEven = stage.filter === 'bandpass' ? 'ratio' : 'value'; - if (stage.cutoffMod) follow?.(stage.cutoffMod, filter.frequency, true); - if (stage.qMod) follow?.(stage.qMod, filter.Q, qEven === 'ratio'); + const qEven = stage.filter === 'lowpass' || stage.filter === 'highpass' ? 'value' : 'ratio'; + const filters = Array.from({ length: Math.max(1, stage.stages) }, () => { + const filter = context.createBiquadFilter(); + filter.type = stage.filter; + filter.Q.value = passQ(stage.filter, FLAT_Q); + return filter; + }); + filters.slice(1).forEach((filter, i) => filters[i]!.connect(filter)); + const last = filters.at(-1)!; + last.Q.value = stage.qMod ? 0 : stage.q; + last.gain.value = stage.gainMod ? 0 : stage.gain; + if (stage.qMod) follow?.(stage.qMod, last.Q, qEven === 'ratio'); + if (stage.gainMod) follow?.(stage.gainMod, last.gain, false); + let cutoff = filters[0]!.frequency; + let stop: (() => void) | undefined; + if (filters.length > 1) { + const driver = context.createConstantSource(); + for (const filter of filters) { + filter.frequency.value = 0; + driver.connect(filter.frequency); + } + driver.start(); + cutoff = driver.offset; + stop = () => driver.stop(); + } + cutoff.value = stage.cutoffMod ? 0 : stage.cutoff; + if (stage.cutoffMod) follow?.(stage.cutoffMod, cutoff, true); return { - input: filter, - output: filter, + input: filters[0]!, + output: last, knobs: [ - { param: stage.cutoffMod ? null : filter.frequency, target: stage.cutoff, even: 'ratio' }, - { param: stage.qMod ? null : filter.Q, target: stage.q, even: qEven }, + { param: stage.cutoffMod ? null : cutoff, target: stage.cutoff, even: 'ratio' }, + { param: stage.qMod ? null : last.Q, target: stage.q, even: qEven }, + { param: stage.gainMod ? null : last.gain, target: stage.gain, even: 'value' }, ], + ...(stop && { stop }), }; } @@ -2129,7 +2156,7 @@ function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes { const damp = context.createBiquadFilter(); damp.type = 'lowpass'; damp.frequency.value = stage.damping; - damp.Q.value = FLAT_Q; + damp.Q.value = passQ('lowpass', FLAT_Q); const feedback = gainNode(context, stage.feedback); const wet = gainNode(context, stage.mix); line.connect(damp); @@ -2384,7 +2411,7 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] { } for (const stage of chain.stages) if (stage.kind === 'delay') into.push(stage.time, stage.feedback, stage.damping, stage.mix); - else if (stage.kind === 'pass') into.push(stage.cutoff, stage.q); + else if (stage.kind === 'pass') into.push(stage.cutoff, stage.q, stage.gain); else if (stage.kind === 'crush') into.push(stage.bits, stage.rate, stage.mix); else if (stage.kind === 'phaser') into.push(stage.frequency, stage.rate, stage.depth, stage.feedback, stage.mix); diff --git a/tests/core/chip-presets.test.ts b/tests/core/chip-presets.test.ts index 18931761..f9d5db75 100644 --- a/tests/core/chip-presets.test.ts +++ b/tests/core/chip-presets.test.ts @@ -121,7 +121,7 @@ describe('a preset (#329)', () => { arpeggio: 0, }); expect(byType(low, 'InstrumentEnvelope').params).toMatchObject({ attack: 0, decay: 0.068, sustain: 0 }); - expect(byType(low, 'SoundFxFilter').params).toEqual({ filter: 'lowpass', cutoff: 20000, resonance: 0 }); + expect(byType(low, 'SoundFxFilter').params).toEqual({ filter: 'lowpass', cutoff: 20000, q: 0.707 }); expect(byType(low, 'SoundFxBitcrusher').params).toEqual({ bits: 8, rate: 22050, mix: 1 }); // Near 1 the coin jumps up by sfxr's arpeggio: a period 0.676 times as long is about 7 semitones. const high = byType( diff --git a/tests/core/envelope.test.ts b/tests/core/envelope.test.ts index 3de512c2..27a73603 100644 --- a/tests/core/envelope.test.ts +++ b/tests/core/envelope.test.ts @@ -11,9 +11,20 @@ import { portsMeet } from '../../src/core/graph/NodeRegistry'; import { compileInstrument, instrumentSourcesOf } from '../../src/core/instrument/compile'; import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../../src/core/instrument/envelope'; import { VELOCITY_PARAM } from '../../src/core/instrument/nodes'; -import type { BehaviorAsset, InstrumentAsset } from '../../src/core/model/Asset'; +import { NO_LOOKUP } from '../../src/core/graph/lookup'; +import type { PropertyBag } from '../../src/core/model/Properties'; +import { resolveDef } from '../../src/core/graph/nested'; +import type { BehaviorAsset, InstrumentAsset, SoundFxAsset } from '../../src/core/model/Asset'; import { compileSoundFx } from '../../src/core/soundfx/compile'; -import { FLAT_Q, fxChainOf, type FxEnvelope, type FxPlan, passQ } from '../../src/core/soundfx/nodes'; +import { + FLAT_Q, + fxChainOf, + type FxEnvelope, + type FxPlan, + passQ, + passStagesOf, + SoundFxFilter, +} from '../../src/core/soundfx/nodes'; import { graphEditor, makeProject } from '../helpers'; const FILE = `${'c3'.repeat(32)}.wav`; @@ -70,29 +81,58 @@ describe('the curve of an Envelope', () => { }); describe('a Filter', () => { - it('has no bump at resonance 0 and rings towards 1; a band-pass gets narrower', () => { - expect(passQ('lowpass', 0)).toBeCloseTo(FLAT_Q, 9); - expect(passQ('highpass', 1)).toBe(20); - expect(passQ('bandpass', 0)).toBeCloseTo(0.7, 9); - expect(passQ('bandpass', 1)).toBeCloseTo(20, 9); - expect(passQ('lowpass', 7)).toBe(20); + it('takes its Q as a factor, and hands it to Web Audio in dB for a low- or high-pass', () => { + expect(passQ('lowpass', FLAT_Q)).toBeCloseTo(-3.0103, 4); + expect(passQ('highpass', 10)).toBeCloseTo(20, 9); + expect(passQ('bandpass', 4)).toBe(4); + expect(passQ('peaking', 0.5)).toBe(0.5); }); - it('is an effect of a sound FX as well, its cutoff in Hz and its resonance as a Q', () => { + const filterIn = (params: PropertyBag) => { const f = makeProject(); const made = new CreateAssetCommand('soundfx', 'Muffle'); f.stack.execute(made); const fx = made.asset as unknown as BehaviorAsset; const edit = graphEditor(f, fx); const input = edit.addNode('SoundFxInput'); - const filter = edit.addNode('SoundFxFilter', { filter: 'highpass', cutoff: 300, resonance: 1 }); + const filter = edit.addNode('SoundFxFilter', params); const output = edit.addNode('SoundFxOutput'); edit.wire([ [input, 'out', filter, 'in'], [filter, 'out', output, 'in'], ]); - const plan = compileSoundFx((fx as unknown as { graph: never }).graph).compiled!; - expect(fxChainOf(plan).stages).toEqual([{ kind: 'pass', filter: 'highpass', cutoff: 300, q: 20 }]); + return { fx: fx as unknown as SoundFxAsset, filter }; + }; + + it('is an effect of a sound FX as well, its frequency in Hz and its Q held to 0.1 to 30', () => { + const { fx } = filterIn({ filter: 'highpass', cutoff: 300, q: 50 }); + const plan = compileSoundFx(fx.graph).compiled!; + const [stage] = fxChainOf(plan).stages; + expect(stage).toMatchObject({ kind: 'pass', filter: 'highpass', stages: 1, cutoff: 300, gain: 0 }); + // 30 as a factor, in dB. + expect((stage as { q: number }).q).toBeCloseTo(29.54, 2); + }); + + it('has a gain only as a shelf or a peak, and a slope only where it gets steeper (#318)', () => { + const peak = filterIn({ filter: 'peaking', gain: 6, slope: '48' }); + const def = (node: GraphNode, fx: SoundFxAsset) => resolveDef(node, fx.graph, NO_LOOKUP)!; + expect(def(peak.filter, peak.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'gain']); + expect(SoundFxFilter.idleParams!(peak.filter.params)).toEqual(['slope']); + expect(fxChainOf(compileSoundFx(peak.fx.graph).compiled!).stages[0]).toMatchObject({ + stages: 1, + gain: 6, + }); + + const notch = filterIn({ filter: 'notch', gain: 6, slope: '48' }); + expect(def(notch.filter, notch.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q']); + expect(SoundFxFilter.idleParams!(notch.filter.params)).toEqual([]); + // A gain typed in before the type changed does nothing. + expect(fxChainOf(compileSoundFx(notch.fx.graph).compiled!).stages[0]).toMatchObject({ + stages: 4, + gain: 0, + }); + expect(passStagesOf({ filter: 'lowpass', slope: '24' })).toBe(2); + expect(passStagesOf({ filter: 'lowpass' })).toBe(1); }); }); @@ -111,15 +151,15 @@ describe('an Envelope in an instrument', () => { ]); }); - it('moves the cutoff of a Filter from "from" to "to", and its resonance as a Q', () => { + it('moves the cutoff of a Filter from "from" to "to", and its Q', () => { const f = makeInstrument(); const envelope = f.edit.addNode('InstrumentEnvelope', { from: 200, to: 5000 }); - const sweep = f.edit.addNode('InstrumentEnvelope', { decay: 1, from: 0, to: 1 }); + const sweep = f.edit.addNode('InstrumentEnvelope', { decay: 1, from: Math.SQRT1_2, to: 10 }); const filter = f.edit.addNode('SoundFxFilter'); f.edit.wire([ [f.sample, 'out', filter, 'in'], [envelope, 'value', filter, 'cutoff'], - [sweep, 'value', filter, 'resonance'], + [sweep, 'value', filter, 'q'], [filter, 'out', f.output, 'in'], ]); const build = f.compile(); @@ -131,8 +171,10 @@ describe('an Envelope in an instrument', () => { filter: 'lowpass', cutoff: 200, cutoffMod: { envelope: { id: envelope.id }, from: 200, to: 5000 }, - qMod: { envelope: { id: sweep.id, decay: 1 }, from: FLAT_Q, to: 20 }, + qMod: { envelope: { id: sweep.id, decay: 1 } }, }); + const { qMod } = stage as { qMod: { from: number; to: number } }; + expect([qMod.from, qMod.to]).toEqual([expect.closeTo(-3.01, 2), expect.closeTo(20, 9)]); }); it('holds both ends to the row of the number it moves, and fills in what a note hands it', () => { diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index 7501da73..e5a6e058 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -19,7 +19,7 @@ import type { MixBus } from '../../src/core/music/mixer'; import type { SequencePlay } from '../../src/core/music/playbacks'; import type { ScheduledNote } from '../../src/core/music/Scheduler'; import type { PitchOptions } from '../../src/viewport/fxWorklet'; -import { SoundLibrary } from '../../src/viewport/SoundLibrary'; +import { SoundLibrary, stageNodes } from '../../src/viewport/SoundLibrary'; /** What a test wants to hear: a chain whose pitch, where it is not 0, is a Pitch typed in. */ type Chain = @@ -1451,8 +1451,10 @@ describe('notes with an Envelope (#335)', () => { const sweep: FxStagePlan = { kind: 'pass', filter: 'lowpass', + stages: 1, cutoff: { value: 200, mod: { envelope: PLANNED, from: { value: 200 }, to: { value: 5000 } } }, - resonance: { value: 0 }, + q: { value: Math.SQRT1_2 }, + gain: { value: 0 }, }; const voice = await note(library, way(true, false, [sweep])); // The cutoff is all the curve; the Q stays as it is. @@ -1469,7 +1471,7 @@ describe('notes with an Envelope (#335)', () => { expect(gains[1]!.ramps).toEqual([['linear', 0, 0.01]]); expect(driver().offset.glides).toEqual([0.5, 0]); // A parameter does not reach a number an Envelope moves. - expect(library.levels('instrument:i')).toEqual([[200, expect.closeTo(-3.01, 2)]]); + expect(library.levels('instrument:i')).toEqual([[200, expect.closeTo(-3.01, 2), 0]]); }); it('ends once its decay is over where the sustain is 0, however long the note is held (#329)', async () => { @@ -2204,3 +2206,53 @@ describe('the master and the buses of the project (#325)', () => { expect(library.tuneMix('mix:gone', { b: 3 })).toBe(false); }); }); + +describe('the stages of #318 to #328', () => { + it('runs a steeper Filter as several in a row, one cutoff for all, the Q on the last only (#318)', () => { + const context = fakeContext() as unknown as BaseAudioContext; + const made = stageNodes(context, { + kind: 'pass', + filter: 'lowpass', + stages: 4, + cutoff: 800, + q: 12, + gain: 0, + }); + expect(filters.map((f) => f.type)).toEqual(['lowpass', 'lowpass', 'lowpass', 'lowpass']); + expect(wires.filter((w) => w === 'filter > filter')).toHaveLength(3); + expect(filters.map((f) => f.frequency.value)).toEqual([0, 0, 0, 0]); + expect(wires.filter((w) => w === 'clock > ?')).toHaveLength(4); + expect(clocks[0]!.started).toBe(true); + expect(clocks[0]!.offset.value).toBe(800); + expect(filters.slice(0, 3).map((f) => f.Q.value)).toEqual([ + expect.closeTo(-3.01, 2), + expect.closeTo(-3.01, 2), + expect.closeTo(-3.01, 2), + ]); + expect(filters[3]!.Q.value).toBe(12); + expect(made.knobs.map((k) => k.param)).toEqual([clocks[0]!.offset, filters[3]!.Q, filters[3]!.gain]); + made.stop!(); + expect(clocks[0]!.stopped).toBe(true); + }); + + it('gives a shelf its gain, and a single filter its own cutoff (#318)', () => { + const context = fakeContext() as unknown as BaseAudioContext; + const made = stageNodes(context, { + kind: 'pass', + filter: 'highshelf', + stages: 1, + cutoff: 5000, + q: 1, + gain: -6, + }); + expect(filters).toHaveLength(1); + expect([filters[0]!.type, filters[0]!.frequency.value, filters[0]!.gain.value]).toEqual([ + 'highshelf', + 5000, + -6, + ]); + expect(clocks).toHaveLength(0); + expect(made.knobs.map((k) => k.even)).toEqual(['ratio', 'ratio', 'value']); + expect(made.stop).toBeUndefined(); + }); +}); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 9b6eb33c..09e34180 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -939,6 +939,42 @@ export async function sound(t) { JSON.stringify(swirled.sweeping.slice(1).map(([i]) => i)) === JSON.stringify([533, 534]), JSON.stringify(swirled), ); + // #318: the Filter rendered offline. A 2 kHz sine at 0.5 through a low-pass at 1 kHz with no bump + // falls by 12.3 dB at 12 dB/oct and by four times that at 48 dB/oct. A peak of +6 dB at + // 1 kHz raises a 1 kHz sine by 6 dB, a notch there takes it out. + const filtered = await evaluate(` + (async () => { + const RATE = 48000; + const level = async (hz, stage) => { + const context = new OfflineAudioContext(1, RATE / 2, RATE); + const tone = context.createOscillator(); + tone.frequency.value = hz; + const half = context.createGain(); + half.gain.value = 0.5; + const nodes = __gm.sound.stageNodes(context, { kind: 'pass', stages: 1, cutoff: 1000, q: 0.7071, gain: 0, ...stage }); + tone.connect(half).connect(nodes.input); + nodes.output.connect(context.destination); + tone.start(); + const out = (await context.startRendering()).getChannelData(0).subarray(RATE / 4); + let sum = 0; + for (const v of out) sum += v * v; + return +(20 * Math.log10(Math.sqrt(sum / out.length) / (0.5 * Math.SQRT1_2))).toFixed(2); + }; + return { + low12: await level(2000, { filter: 'lowpass', q: -3.0103 }), + low48: await level(2000, { filter: 'lowpass', q: -3.0103, stages: 4 }), + peak: await level(1000, { filter: 'peaking', q: 1, gain: 6 }), + notch: await level(1000, { filter: 'notch', q: 1 }), + }; + })()`); + check( + 'a Filter at 48 dB/oct falls four times as steeply as at 12, a peak raises its frequency by its gain, a notch takes it out', + Math.abs(filtered.low12 + 12.3) < 0.2 && + Math.abs(filtered.low48 + 49.2) < 0.8 && + Math.abs(filtered.peak - 6) < 0.1 && + filtered.notch < -40, + JSON.stringify(filtered), + ); // #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