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

+55 -3
View File
@@ -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();
});
});