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:
adminandClaude Opus 5.5 committed 2026-10-07 20:43:49 +02:00
1 parent daeffe0f9d
commit e1ca6a5f5a
9 files changed
+282 -98

No files matched your search

+18 -6
View File
@@ -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 },
+7 -32
View File
@@ -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> }>();
/**
+4 -1
View File
@@ -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<SoundFxNodeDef, FxValue>(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<string, FxAmount> = {};
+62 -25
View File
@@ -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<FxPassFilter> = new Set(['lowshelf', 'highshelf', 'peaking']);
const SLOPED: ReadonlySet<FxPassFilter> = 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;
}