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
@@ -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(
|
||||
|
||||
+58
-16
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user