Swirl and comb a sound with a Phaser and a Flanger node (#341)

sfxr's "phaser" is a comb filter, the sound plus a copy delayed by up
to 2.9 ms. That is a flanger, not the phaser a guitarist knows. Both
effects come in as nodes of their own, so each name fits its sound and
sfxr is covered in full. Both run on the FX worklet like the
Bitcrusher, because a feedback loop in Web Audio costs a render quantum
per round.

- Phaser: first-order allpasses (Stages 2, 4, 6, 8, 12) around
  Frequency, moved by a sine LFO Depth octaves wide at Rate Hz, with
  Feedback and Mix. An Envelope can move Frequency and Mix.
- Flanger: a delay line of Offset ms. Sweep moves it on in ms/s from
  the start of the voice, an LFO adds up to Depth ms. As in sfxr the
  delay is mirrored at 0, and it never goes past 20 ms. Feedback comes
  back exactly one delay later. An Envelope can move Offset and Mix.
- Mix 0 leaves the sound as it is, 1 is half sound and half copy. The
  notches are deepest there and the peaks stay at the sound's level.
  sfxr adds both at full, so its sound comes out 6 dB louder.
- The Flanger's processor gets the start of its voice and begins the
  sweep on that sample. stageNodes takes it as a new argument `at`.
- Explosion gets a Flanger in one draw out of two, with sfxr's offset
  and sweep and no LFO. Offset is offset^2 * 2.89 ms, Sweep is
  ramp^2 * 125 ms/s, signed so the direction holds.
- Randomize draws a Flanger's offset and sweep like sfxr. Its LFO and
  feedback and all of a Phaser are drawn within bounds of their own.
  Mix and Stages stay, as a Bitcrusher's Mix does. Mutate moves them
  too, and a 0 stays 0.
- New instrument preset Phased Pad: saw, Envelope, low-pass, Phaser of
  six stages. The other 14 are unchanged.
- The Bitcrusher's worklet setup is now shared with both nodes
  (workletNodes), its behaviour is unchanged.

Tests:
- Unit phaser (new): 8 green. Counter-check with clamping at 0 instead
  of mirroring and a flipped allpass coefficient: 3 red.
- Unit soundfx, sound-library, chip-presets: 45 / 76 / 14 green.
  Counter-checks: start time not passed on, phaser frequency eased
  linearly, sweep sign dropped, Phaser left out of Mutate, each red.
- Unit core, viewport, graph: 179 files, 2979 tests green.
- e2e 34 (melektaus-atx): ALL PASSED, 70 checks (1 new), first run.
  Chrome offline: 1 kHz notch RMS 0.0000 with two stages, 0.5657
  with four. Flanger echo after 48 samples, swept echo at 533/534.
  About 47 Phasers (12 stages) or 63 Flangers fill the audio thread.
- Gate (melektaus-atx): CHECK EXIT 0, 257 files, 3991 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 12:11:54 +02:00
1 parent 29b604dc07
commit 01fd6e6464
13 files changed
+999 -48

No files matched your search

+106 -32
View File
@@ -8,13 +8,16 @@ import {
type FxCrush,
type FxDelay,
type FxEnvelope,
type FxFlanger,
type FxMod,
type FxPass,
type FxPhaser,
fxRings,
fxTailOf,
type FxParams,
type FxPlan,
type FxStage,
type FxStagePlan,
type FxVoice,
} from '../core/soundfx/nodes';
import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope';
@@ -25,7 +28,7 @@ import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
import { DEFAULT_TEMPO } from '../core/music/time';
import { type CutRange, cutChannels, cutRange, parseCutName } from '../core/model/sound';
import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio';
import type { ChipOptions, PitchOptions } from './fxWorklet';
import type { ChipOptions, FlangerOptions, PhaserOptions, PitchOptions } from './fxWorklet';
import fxWorkletUrl from './fxWorklet.ts?worker&url';
import { wake } from './wake';
@@ -1083,7 +1086,7 @@ export class SoundLibrary implements AudioSink {
const under = wiring.sources.slice(first);
for (const stage of chain.stages) {
if (stage.kind === 'envelope') this.envelope(wiring, stage.envelope).sources.push(...under);
const made = stageNodes(context, stage, follow);
const made = stageNodes(context, stage, follow, wiring.at);
tail.connect(made.input);
tail = made.output;
knobs.push(...made.knobs);
@@ -1343,14 +1346,14 @@ export class SoundLibrary implements AudioSink {
return this.failed.has(parseCutName(name).file);
}
/** Loads the FX worklet (Pitch, Bitcrusher, Chip) into the context once; says whether it is there. */
/** Loads the FX worklet (Pitch, Bitcrusher, Chip, Phaser, Flanger) into the context once; says whether it is there. */
private fxWorklet(): Promise<boolean> {
return (this.workletLoading ??= (async () => {
try {
await this.audio().audioWorklet.addModule(fxWorkletUrl);
return (this.hasWorklet = true);
} catch (error: unknown) {
console.error('[sounds] cannot load the FX worklet, a Pitch, Bitcrusher or Chip is not heard', error);
console.error('[sounds] cannot load the FX worklet, its effects and Chips are not heard', error);
return (this.hasWorklet = false);
}
})());
@@ -1398,9 +1401,12 @@ interface LastVoice {
envelopes: number;
}
/** Whether a plan has a Pitch, a Bitcrusher or a Chip anywhere, and so needs the worklet. */
/** The stages that run on the worklet. */
const ON_WORKLET: ReadonlySet<FxStagePlan['kind']> = new Set(['crush', 'phaser', 'flanger']);
/** Whether a plan has a Pitch, a Chip or a stage of the worklet anywhere, and so needs the worklet. */
function needsWorklet(plan: FxPlan): boolean {
if (plan.stages.some((stage) => stage.kind === 'crush')) return true;
if (plan.stages.some((stage) => ON_WORKLET.has(stage.kind))) return true;
return 'mix' in plan ? needsWorklet(plan.a) || needsWorklet(plan.b) : plan.pitch.length > 0 || !!plan.chip;
}
@@ -1468,9 +1474,15 @@ export function followCurve(
/**
* Exported for a check that renders a stage offline in a real browser (test hook). Without `follow`
* a number an Envelope moves stays at 0, and an Envelope's own stage silent.
* a number an Envelope moves stays at 0, and an Envelope's own stage silent. `at` is when the voice
* starts.
*/
export function stageNodes(context: BaseAudioContext, stage: FxStage, follow?: Follow): StageNodes {
export function stageNodes(
context: BaseAudioContext,
stage: FxStage,
follow?: Follow,
at = context.currentTime,
): StageNodes {
if (stage.kind === 'gain') {
const gain = gainNode(context, stage.mod ? 0 : stage.gain);
if (stage.mod) follow?.(stage.mod, gain.gain, true);
@@ -1485,6 +1497,8 @@ export function stageNodes(context: BaseAudioContext, stage: FxStage, follow?: F
if (stage.kind === 'pass') return passNodes(context, stage, follow);
if (stage.kind === 'delay') return delayNodes(context, stage);
if (stage.kind === 'crush') return crushNodes(context, stage, follow);
if (stage.kind === 'phaser') return phaserNodes(context, stage, follow);
if (stage.kind === 'flanger') return flangerNodes(context, stage, at, follow);
const filter = context.createBiquadFilter();
filter.type = stage.filter;
filter.frequency.value = stage.frequency;
@@ -1613,41 +1627,46 @@ function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes {
};
}
/** A number of a stage on the worklet: what it holds, the Envelope moving it instead, how it moves. */
interface WorkletNumber {
name: string;
value: number;
mod?: FxMod | undefined;
even: Knob['even'];
}
/**
* A Bitcrusher (#322) on the worklet. Bits and mix move in what they hold, the rate in octaves.
* Where the worklet is not there the sound goes through as it is.
* A stage on the worklet (#322, #341), its numbers AudioParams of the processor. One an Envelope
* moves starts at 0 and leaves its knob to it. Where the worklet is not there the sound goes
* through as it is.
*/
function crushNodes(context: BaseAudioContext, stage: FxCrush, follow?: Follow): StageNodes {
function workletNodes(
context: BaseAudioContext,
processor: string,
numbers: readonly WorkletNumber[],
follow?: Follow,
processorOptions?: object,
): StageNodes {
let node: AudioWorkletNode;
try {
node = new AudioWorkletNode(context, 'bit-crusher', {
parameterData: {
bits: stage.bitsMod ? 0 : stage.bits,
rate: stage.rateMod ? 0 : stage.rate,
mix: stage.mixMod ? 0 : stage.mix,
},
node = new AudioWorkletNode(context, processor, {
parameterData: Object.fromEntries(numbers.map((n) => [n.name, n.mod ? 0 : n.value])),
...(processorOptions && { processorOptions }),
});
} catch {
const through = context.createGain();
const still = (target: number): Knob => ({ param: null, target, even: 'value' });
return {
input: through,
output: through,
knobs: [still(stage.bits), still(stage.rate), still(stage.mix)],
};
const knobs = numbers.map((n): Knob => ({ param: null, target: n.value, even: n.even }));
return { input: through, output: through, knobs };
}
const param = (name: string) => node.parameters.get(name)!;
if (stage.bitsMod) follow?.(stage.bitsMod, param('bits'), false);
if (stage.rateMod) follow?.(stage.rateMod, param('rate'), true);
if (stage.mixMod) follow?.(stage.mixMod, param('mix'), false);
const knobs = numbers.map(({ name, value, mod, even }): Knob => {
const param = node.parameters.get(name)!;
if (mod) follow?.(mod, param, even === 'ratio');
return { param: mod ? null : param, target: value, even };
});
return {
input: node,
output: node,
knobs: [
{ param: stage.bitsMod ? null : param('bits'), target: stage.bits, even: 'value' },
{ param: stage.rateMod ? null : param('rate'), target: stage.rate, even: 'ratio' },
{ param: stage.mixMod ? null : param('mix'), target: stage.mix, even: 'value' },
],
knobs,
stop: () => {
node.port.postMessage('stop');
node.disconnect();
@@ -1655,6 +1674,57 @@ function crushNodes(context: BaseAudioContext, stage: FxCrush, follow?: Follow):
};
}
/** A Bitcrusher (#322): bits and mix move in what they hold, the rate in octaves. */
function crushNodes(context: BaseAudioContext, stage: FxCrush, follow?: Follow): StageNodes {
return workletNodes(
context,
'bit-crusher',
[
{ name: 'bits', value: stage.bits, mod: stage.bitsMod, even: 'value' },
{ name: 'rate', value: stage.rate, mod: stage.rateMod, even: 'ratio' },
{ name: 'mix', value: stage.mix, mod: stage.mixMod, even: 'value' },
],
follow,
);
}
/** A Phaser (#341): its frequency moves in octaves, the rest in what they hold. */
function phaserNodes(context: BaseAudioContext, stage: FxPhaser, follow?: Follow): StageNodes {
const options: PhaserOptions = { stages: stage.stages };
return workletNodes(
context,
'phaser',
[
{ name: 'frequency', value: stage.frequency, mod: stage.frequencyMod, even: 'ratio' },
{ name: 'rate', value: stage.rate, even: 'value' },
{ name: 'depth', value: stage.depth, even: 'value' },
{ name: 'feedback', value: stage.feedback, even: 'value' },
{ name: 'mix', value: stage.mix, mod: stage.mixMod, even: 'value' },
],
follow,
options,
);
}
/** A Flanger (#341), whose sweep counts from `at`, when the voice starts. */
function flangerNodes(context: BaseAudioContext, stage: FxFlanger, at: number, follow?: Follow): StageNodes {
const options: FlangerOptions = { startAt: at };
return workletNodes(
context,
'flanger',
[
{ name: 'offset', value: stage.offset, mod: stage.offsetMod, even: 'value' },
{ name: 'sweep', value: stage.sweep, even: 'value' },
{ name: 'rate', value: stage.rate, even: 'value' },
{ name: 'depth', value: stage.depth, even: 'value' },
{ name: 'feedback', value: stage.feedback, even: 'value' },
{ name: 'mix', value: stage.mix, mod: stage.mixMod, even: 'value' },
],
follow,
options,
);
}
/** The shares of a and b over `mix` at 0, 0.5 and 1: a WaveShaper reads -1…1, so its curve starts below 0. */
const SHARE_A = new Float32Array([1, 1, 1, 1, 0]);
const SHARE_B = new Float32Array([0, 0, 0, 1, 1]);
@@ -1761,6 +1831,10 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] {
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 === '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);
else if (stage.kind === 'flanger')
into.push(stage.offset, stage.sweep, stage.rate, stage.depth, stage.feedback, stage.mix);
else if (stage.kind !== 'envelope') into.push(stage.gain);
return into;
}