Shape a sound with every filter type, a Q and a slope (#318)
The Filter node from #335 only knew low-pass, high-pass and band-pass and had a Resonance from 0 to 1. It now has every type Web Audio offers, a Q as a plain factor and a slope up to 48 dB per octave. - Types: low-pass, high-pass, band-pass, notch, low shelf, high shelf, peaking and all-pass. The cutoff row is now labelled Frequency, its key stays `cutoff`. - Q from 0.1 to 30, 0.707 by default, replaces Resonance. Web Audio takes the Q of a low- or high-pass in dB, so it is converted when the voice is wired. No project used the node. - Gain in dB is an input only for the shelves and the peak. It is a derived port, so the sound FX compiler now resolves derived ports. - Slope 12, 24 or 48 dB/oct runs 1, 2 or 4 filters in a row. They share one cutoff carried by a ConstantSource, only the last has the Q. Shelves, peak and all-pass hide the row. - The Chip Presets get the Q their resonance gave before. Mutate moves Q in octaves and gain by up to a dB. - bindStage in the instrument compiler binds every amount of a stage generically, so new stages need no case of their own there. Finding: FLAT_Q was in dB and the delay damping used it. It is a factor now, the damping takes it through passQ and is unchanged. Tests: - Unit envelope 13, sound-library 96 (2 new), chip-presets 14, soundfx 45, all green. - Counter-checks: the Q on every filter in a row, the gain row for every type. Each turns one test red. - e2e 34 (melektaus-atx): ALL PASSED, 76 checks (1 new). Chrome offline, 2 kHz through a low-pass at 1 kHz: -12.37 dB at 12 dB/oct, -49.5 dB at 48. A +6 dB peak gives 6.00 dB, a notch -124 dB. - 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
daeffe0f9d
commit
e1ca6a5f5a
9 files changed
+282
-98
No files matched your search
@@ -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<Record<string, { octaves: number } | { by: number }>> = {
|
||||
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 },
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
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<string, { fingerprint: string; notes: Map<number, InstrumentBuild> }>();
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user