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

+86 -5
View File
@@ -10,9 +10,13 @@ import {
type FxPassFilter,
passOf,
SoundFxBitcrusher,
FLANGER_ROWS,
PHASER_ROWS,
SoundFxDelay,
SoundFxFilter,
SoundFxFlanger,
SoundFxMix,
SoundFxPhaser,
SoundFxPitch,
} from '../soundfx/nodes';
import {
@@ -30,7 +34,8 @@ import { HIGHEST_NOTE, LOWEST_NOTE } from './notes';
/**
* The Preset menu, Randomize and Mutate of the Instrument tab (#329), the way sfxr makes its sounds:
* its generators and its randomizer draw sfxr's settings, 0 to 1 as its sliders have them, which are
* then worked out into the numbers of a Chip, an Envelope, a Filter and a Bitcrusher. sfxr is
* then worked out into the numbers of a Chip, an Envelope, a Filter, a Bitcrusher and a Flanger (#341),
* which sfxr calls its phaser. sfxr is
* Copyright (c) 2007 Tomas Pettersson, MIT licence.
*
* Next to those sounds the menu has instruments to play tunes on: Chips that follow the note and hold
@@ -45,6 +50,7 @@ export type TonePreset =
| 'brass'
| 'detunedSaws'
| 'stringPad'
| 'phasedPad'
| 'organ'
| 'flute'
| 'bell'
@@ -62,6 +68,7 @@ export const TONE_PRESETS: readonly { value: TonePreset; label: string }[] = [
{ value: 'brass', label: 'Synth Brass' },
{ value: 'detunedSaws', label: 'Detuned Saws' },
{ value: 'stringPad', label: 'String Pad' },
{ value: 'phasedPad', label: 'Phased Pad' },
{ value: 'organ', label: 'Organ' },
{ value: 'flute', label: 'Flute' },
{ value: 'bell', label: 'Bell' },
@@ -112,6 +119,8 @@ interface Sfxr {
repeatSpeed: number;
arpSpeed: number;
arpMod: number;
phaOffset: number;
phaRamp: number;
}
const reset = (): Sfxr => ({
@@ -133,6 +142,8 @@ const reset = (): Sfxr => ({
repeatSpeed: 0,
arpSpeed: 0,
arpMod: 0,
phaOffset: 0,
phaRamp: 0,
});
/** sfxr's waves by their number there. */
@@ -192,6 +203,10 @@ function presetSfxr(preset: EffectPreset, random: Random): Sfxr {
if (r(2) === 0) p.repeatSpeed = 0.3 + f(0.5);
p.envSustain = 0.1 + f(0.3);
p.envDecay = f(0.5);
if (r(1)) {
p.phaOffset = -0.3 + f(0.9);
p.phaRamp = -f(0.3);
}
if (r(1)) {
p.vibStrength = f(0.7);
p.vibSpeed = f(0.6);
@@ -277,6 +292,9 @@ function randomSfxr(random: Random): Sfxr {
p.repeatSpeed = signed();
p.arpSpeed = signed();
p.arpMod = signed();
// Drawn last, not where sfxr has them, so the numbers before stay as they were.
p.phaOffset = signed() ** 3;
p.phaRamp = signed() ** 3;
return p;
}
@@ -331,6 +349,20 @@ function filterNumbers(p: Sfxr, filter: FxPassFilter): Record<'cutoff' | 'resona
return { cutoff: 17756 * Math.max(0, p.lpfFreq) ** 1.5, resonance: Math.abs(p.lpfResonance) };
}
/**
* sfxr's phaser as a Flanger: its delay is `offset²` of 1020 samples taken 8 times over, and moves
* `ramp²` of one of them every sample. Mirrored at 0 as it is, a sweep towards 0 is one with the
* other sign than the offset.
*/
function flangerNumbers(p: Sfxr): Record<'offset' | 'sweep', number> {
const taken = 8 * SFXR_RATE;
const away = Math.sign(p.phaOffset || 1) * Math.sign(p.phaRamp);
return {
offset: ((p.phaOffset ** 2 * 1020) / taken) * 1000,
sweep: (away * p.phaRamp ** 2 * SFXR_RATE * 1000) / taken,
};
}
/** A number held to its row and rounded to as many places as its step has. */
function heldTo(value: number, row: FlatDef<'number'>): number {
const places = Math.max(0, -Math.floor(Math.log10(row.step ?? 0.01)));
@@ -391,7 +423,7 @@ const everyNote: PropertyBag = { from: String(LOWEST_NOTE), to: String(HIGHEST_N
/**
* An effect preset as a whole graph (#329): every note plays one Chip, which ends by itself through
* an Envelope, a Filter and a Bitcrusher. The Chip keeps to the preset's own frequency, as sfxr does,
* an Envelope, a Filter, sfxr's phaser where it has one, and a Bitcrusher. The Chip keeps to the preset's own frequency, as sfxr does,
* so every key plays the same sound until "follow note" is turned on. Each call draws it anew.
*/
function effectGraph(preset: EffectPreset, random: Random): Graph {
@@ -406,11 +438,21 @@ function effectGraph(preset: EffectPreset, random: Random): Graph {
...held(chipNumbers(p), CHIP_ROWS),
});
s.wire(notes, 'any', chip, 'trigger');
const flanged: Step[] =
p.phaOffset !== 0 || p.phaRamp !== 0
? [
[
SoundFxFlanger.type,
held({ ...flangerNumbers(p), rate: 0, depth: 0, feedback: 0, mix: 1 }, FLANGER_ROWS),
],
]
: [];
return s.end(
s.line(
chip,
[INSTRUMENT_ENVELOPE, held(envelopeNumbers(p), ENVELOPE_ROWS)],
[SoundFxFilter.type, { filter, ...held(filterNumbers(p, filter), FILTER_ROWS) }],
...flanged,
[
SoundFxBitcrusher.type,
held({ bits: crushed ? 8 : 16, rate: crushed ? 22050 : 44100, mix: 1 }, CRUSH_ROWS),
@@ -448,6 +490,8 @@ const envelope = (attack: number, decay: number, sustain: number, release: numbe
const pass = (filter: FxPassFilter, cutoff: number, resonance = 0) =>
[SoundFxFilter.type, { filter, cutoff, resonance }] 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;
const echo = (time: number, feedback: number, mix: number, pingPong = false) =>
[SoundFxDelay.type, { time, sync: 'beats', feedback, damping: 0.4, mix, pingPong }] as const;
@@ -587,6 +631,14 @@ const TONES: Record<Exclude<TonePreset, 'drumKit'>, (s: Sketch, notes: GraphNode
envelope(0.5, 0.5, 0.8, 1),
pass('lowpass', 2200, 0.1),
),
// A saw pad, slow to come, swirled by a phaser of six stages.
phasedPad: (s, notes) =>
s.line(
chipOn(s, notes, 0, 'saw', { vibratoDepth: 0.1, vibratoSpeed: 5 }),
envelope(0.3, 0.5, 0.8, 0.8),
pass('lowpass', 3000, 0.1),
phaser(6, 600, 0.3, 2.5, 0.5),
),
// A square and the square an octave up, held at full while the key is.
organ: (s, notes) => {
const low = chipOn(s, notes, 0, 'square');
@@ -661,6 +713,10 @@ function rowsOf(type: string): Record<string, FlatDef<'number'>> | null {
return FILTER_ROWS;
case SoundFxBitcrusher.type:
return CRUSH_ROWS;
case SoundFxPhaser.type:
return PHASER_ROWS;
case SoundFxFlanger.type:
return FLANGER_ROWS;
default:
return null;
}
@@ -684,15 +740,36 @@ function randomNumbers(node: GraphNode, random: Random): Record<string, number>
return held(filterNumbers(randomSfxr(random), passOf(node.params)), FILTER_ROWS);
case SoundFxBitcrusher.type:
return held({ bits: 4 + random() * 12, rate: 2000 * 24 ** random() }, CRUSH_ROWS);
case SoundFxPhaser.type:
return held(
{
frequency: 200 * 20 ** random(),
rate: 0.05 * 100 ** random(),
depth: 0.5 + random() * 3.5,
feedback: random() * 0.8,
},
PHASER_ROWS,
);
case SoundFxFlanger.type:
return held(
{
...flangerNumbers(randomSfxr(random)),
rate: 0.05 * 100 ** random(),
depth: random() * 4,
feedback: random() * 0.8,
},
FLANGER_ROWS,
);
default:
return null;
}
}
/**
* Randomize (#329): the numbers of every Chip, Envelope, Filter and Bitcrusher drawn anew, as sfxr's
* randomizer draws them. Waves, filter types, the Mix of a Bitcrusher and an Envelope's "from" and
* "to" stay, and so does everything else in the graph.
* Randomize (#329): the numbers of every Chip, Envelope, Filter, Bitcrusher, Phaser and Flanger
* (#341) drawn anew, as sfxr's randomizer draws them; a Phaser's, and a Flanger's LFO, which sfxr
* has not, within bounds of their own. Waves, filter types, the stages of a Phaser, every Mix and
* an Envelope's "from" and "to" stay, and so does everything else in the graph.
*/
export function randomizedParams(graph: Graph, random: Random = Math.random): ParamChange[] {
const changes: ParamChange[] = [];
@@ -726,6 +803,10 @@ const MUTATE: Readonly<Record<string, { octaves: number } | { by: number }>> = {
release: { by: 0.01 },
resonance: { by: 0.05 },
bits: { by: 0.5 },
offset: { by: 0.1 },
sweep: { by: 2 },
depth: { by: 0.2 },
feedback: { by: 0.05 },
};
/**
+19
View File
@@ -355,6 +355,25 @@ function bindStage(stage: FxStagePlan, bind: (amount: FxAmount) => FxAmount): Fx
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),
+177 -2
View File
@@ -5,6 +5,7 @@ import type { PropertyBag } from '../model/Properties';
import { DEFAULT_TEMPO, heldTempo, secondsOfQuarters } from '../music/time';
import type { FlatDef } from '../properties/PropertySchema';
import type { ChipShape, ChipWave } from './chip';
import { PHASER_STAGES } from './phaser';
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
@@ -25,7 +26,35 @@ export type FxStage =
| FxPass
| { kind: 'envelope'; envelope: FxEnvelope }
| FxDelay
| FxCrush;
| FxCrush
| FxPhaser
| FxFlanger;
/** A Phaser (#341): its allpass stages, where they turn, how fast and how far an LFO moves them. */
export interface FxPhaser {
kind: 'phaser';
stages: number;
frequency: number;
rate: number;
depth: number;
feedback: number;
mix: number;
frequencyMod?: FxMod;
mixMod?: FxMod;
}
/** A Flanger (#341): its delay in ms, how it sweeps and swings, all as its worklet takes them. */
export interface FxFlanger {
kind: 'flanger';
offset: number;
sweep: number;
rate: number;
depth: number;
feedback: number;
mix: number;
offsetMod?: FxMod;
mixMod?: FxMod;
}
/** A Bitcrusher (#322): bits, the rate in Hz it holds the sound at, and how much of it is heard. */
export interface FxCrush {
@@ -176,7 +205,12 @@ export type FxStagePlan =
mix: FxAmount;
pingPong: boolean;
}
| { kind: 'crush'; bits: FxAmount; rate: FxAmount; mix: FxAmount };
| { kind: 'crush'; bits: FxAmount; rate: FxAmount; mix: FxAmount }
| ({ kind: 'phaser'; stages: number } & Record<
'frequency' | 'rate' | 'depth' | 'feedback' | 'mix',
FxAmount
>)
| ({ kind: 'flanger' } & Record<'offset' | 'sweep' | 'rate' | 'depth' | 'feedback' | 'mix', FxAmount>);
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
export type FxPlan =
@@ -533,6 +567,113 @@ export const SoundFxBitcrusher: SoundFxNodeDef = {
},
};
const PHASER_FREQUENCY = { ...numberRow('frequency', 'Frequency (Hz)', 20, 20000, 1), default: 800 };
const PHASER_RATE = { ...numberRow('rate', 'Rate (Hz)', 0, 10, 0.01), default: 0.5 };
const PHASER_DEPTH = { ...numberRow('depth', 'Depth (oct)', 0, 6, 0.1), default: 2 };
const PHASER_FEEDBACK = { ...numberRow('feedback', 'Feedback', 0, 0.95, 0.01), default: 0.4 };
const PHASER_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 1 };
export const PHASER_ROWS = {
frequency: PHASER_FREQUENCY,
rate: PHASER_RATE,
depth: PHASER_DEPTH,
feedback: PHASER_FEEDBACK,
mix: PHASER_MIX,
};
const PHASER_STAGE_ROW = {
key: 'stages',
label: 'Stages',
kind: 'enum' as const,
default: '4',
options: PHASER_STAGES.map((n) => ({ value: String(n), label: String(n) })),
};
export function phaserStagesOf(settings: PropertyBag): number {
const stages = Number(settings[PHASER_STAGE_ROW.key] ?? PHASER_STAGE_ROW.default);
return PHASER_STAGES.find((n) => n === stages) ?? 4;
}
export const SoundFxPhaser: SoundFxNodeDef = {
type: 'SoundFxPhaser',
label: 'Phaser',
category: 'sound',
description:
'Sweeps notches through the sound, the swirl of a phaser pedal. "stages" is how many allpass stages the sound passes: every two make one notch, so 4 has two. They sit around "frequency" and an LFO moves them up and down, "depth" octaves wide, "rate" times a second. "feedback" sharpens the notches into a ring. "mix" 0 leaves the sound as it is, 1 is half sound and half phased, where the notches are deepest; it never gets louder. In an instrument an Envelope can move frequency and mix while a note plays.',
inputs: [
audioIn,
followingIn(PHASER_FREQUENCY),
numberIn(PHASER_RATE),
numberIn(PHASER_DEPTH),
numberIn(PHASER_FEEDBACK),
followingIn(PHASER_MIX),
],
outputs: [audioOut],
params: [PHASER_STAGE_ROW],
emit: (sounds, amounts, settings) => {
const mix = held(amounts.mix, PHASER_MIX);
if (isNone(mix)) return arriving(sounds);
return withStages(arriving(sounds), [
{
kind: 'phaser',
stages: phaserStagesOf(settings),
frequency: held(amounts.frequency, PHASER_FREQUENCY),
rate: held(amounts.rate, PHASER_RATE),
depth: held(amounts.depth, PHASER_DEPTH),
feedback: held(amounts.feedback, PHASER_FEEDBACK),
mix,
},
]);
},
};
const FLANGER_OFFSET = { ...numberRow('offset', 'Offset (ms)', 0, 10, 0.01), default: 1 };
const FLANGER_SWEEP = numberRow('sweep', 'Sweep (ms/s)', -125, 125, 0.1);
const FLANGER_RATE = { ...numberRow('rate', 'Rate (Hz)', 0, 10, 0.01), default: 0.25 };
const FLANGER_DEPTH = { ...numberRow('depth', 'Depth (ms)', 0, 10, 0.01), default: 2 };
const FLANGER_FEEDBACK = { ...numberRow('feedback', 'Feedback', 0, 0.95, 0.01), default: 0.5 };
const FLANGER_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 1 };
export const FLANGER_ROWS = {
offset: FLANGER_OFFSET,
sweep: FLANGER_SWEEP,
rate: FLANGER_RATE,
depth: FLANGER_DEPTH,
feedback: FLANGER_FEEDBACK,
mix: FLANGER_MIX,
};
export const SoundFxFlanger: SoundFxNodeDef = {
type: 'SoundFxFlanger',
label: 'Flanger',
category: 'sound',
description:
'Blends the sound with a copy of itself a few milliseconds late, which combs out a row of frequencies: the jet sound of a flanger, and what sfxr calls its phaser. "offset" is how late the copy is when the sound starts. "sweep" moves that on by so many ms a second, for as long as the sound plays; one sweeping down passes 0 and comes back up, as in sfxr. An LFO adds up to "depth" ms to it, "rate" times a second; at depth 0 the copy only sweeps. The copy is never more than 20 ms late. "feedback" sends it back in, for a sharper, ringing comb. "mix" 0 leaves the sound as it is, 1 is half sound and half copy, where the comb is deepest; it never gets louder. In an instrument an Envelope can move offset and mix while a note plays.',
inputs: [
audioIn,
followingIn(FLANGER_OFFSET),
numberIn(FLANGER_SWEEP),
numberIn(FLANGER_RATE),
numberIn(FLANGER_DEPTH),
numberIn(FLANGER_FEEDBACK),
followingIn(FLANGER_MIX),
],
outputs: [audioOut],
params: [],
emit: (sounds, amounts) => {
const mix = held(amounts.mix, FLANGER_MIX);
if (isNone(mix)) return arriving(sounds);
return withStages(arriving(sounds), [
{
kind: 'flanger',
offset: held(amounts.offset, FLANGER_OFFSET),
sweep: held(amounts.sweep, FLANGER_SWEEP),
rate: held(amounts.rate, FLANGER_RATE),
depth: held(amounts.depth, FLANGER_DEPTH),
feedback: held(amounts.feedback, FLANGER_FEEDBACK),
mix,
},
]);
},
};
/** Where damping 0 leaves the echoes and damping 1 takes them: evenly in octaves between. */
const UNDAMPED_HZ = 20000;
const DAMPED_HZ = 500;
@@ -551,6 +692,8 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
SoundFxMix,
SoundFxDelay,
SoundFxBitcrusher,
SoundFxPhaser,
SoundFxFlanger,
];
/** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */
@@ -642,6 +785,38 @@ function stagesOf(plan: FxPlan, params: FxParams, tempo: number): FxStage[] {
});
continue;
}
if (stage.kind === 'phaser') {
const frequencyMod = modOf(stage.frequency, params);
const mixMod = modOf(stage.mix, params);
stages.push({
kind: 'phaser',
stages: stage.stages,
frequency: amountOf(stage.frequency, params),
rate: amountOf(stage.rate, params),
depth: amountOf(stage.depth, params),
feedback: amountOf(stage.feedback, params),
mix: amountOf(stage.mix, params),
...(frequencyMod && { frequencyMod }),
...(mixMod && { mixMod }),
});
continue;
}
if (stage.kind === 'flanger') {
const offsetMod = modOf(stage.offset, params);
const mixMod = modOf(stage.mix, params);
stages.push({
kind: 'flanger',
offset: amountOf(stage.offset, params),
sweep: amountOf(stage.sweep, params),
rate: amountOf(stage.rate, params),
depth: amountOf(stage.depth, params),
feedback: amountOf(stage.feedback, params),
mix: amountOf(stage.mix, params),
...(offsetMod && { offsetMod }),
...(mixMod && { mixMod }),
});
continue;
}
const gain = amountOf(stage.gain, params);
const mod = modOf(stage.gain, params);
if (gain !== 0 || stage.gain.param !== undefined || mod)
+138
View File
@@ -0,0 +1,138 @@
/**
* The Phaser and the Flanger (#341) sample by sample, as their worklet runs them. Both blend the
* sound with a shifted copy of itself, so some frequencies cancel: `mix` 0 leaves the sound as it
* is, 1 is half and half, where the notches are deepest and the peaks stay at full. Each number may
* be one value for the whole block or one per sample, as an AudioParam hands it over.
*/
/** How many allpass stages a Phaser can have: every two make one notch. */
export const PHASER_STAGES = [2, 4, 6, 8, 12] as const;
export type PhaserNumbers = Readonly<
Record<'frequency' | 'rate' | 'depth' | 'feedback' | 'mix', Float32Array>
>;
export type FlangerNumbers = Readonly<
Record<'offset' | 'sweep' | 'rate' | 'depth' | 'feedback' | 'mix', Float32Array>
>;
/** Feedback past this would ring on for ever. */
const MAX_FEEDBACK = 0.95;
/**
* The classic phaser: a chain of first-order allpasses, each turning the phase by 90° at a
* frequency a sine LFO moves `depth` octaves wide around `frequency`. What leaves the chain is fed
* back into it by `feedback`, a sample later, which sharpens the notches.
*/
export class Phaser {
private readonly stages: number;
private lfo = 0;
/** Per channel: the last input and output of each stage, and what left the chain. */
private inputs: Float64Array[] = [];
private outputs: Float64Array[] = [];
private last: number[] = [];
constructor(
private readonly sampleRate: number,
stages: number,
) {
this.stages = PHASER_STAGES.includes(stages as never) ? stages : 4;
}
render(input: readonly Float32Array[], output: readonly Float32Array[], numbers: PhaserNumbers): void {
const length = output[0]?.length ?? 0;
const top = this.sampleRate * 0.45;
for (let i = 0; i < length; i++) {
const sweep = Math.sin(this.lfo);
this.lfo =
(this.lfo + (2 * Math.PI * Math.max(0, at(numbers.rate, i))) / this.sampleRate) % (2 * Math.PI);
const f = Math.max(
10,
Math.min(top, at(numbers.frequency, i) * 2 ** ((at(numbers.depth, i) / 2) * sweep)),
);
const t = Math.tan((Math.PI * f) / this.sampleRate);
const a = (t - 1) / (t + 1);
const feedback = clamp(at(numbers.feedback, i), -MAX_FEEDBACK, MAX_FEEDBACK);
const share = clamp(at(numbers.mix, i), 0, 1) / 2;
output.forEach((channel, c) => {
const dry = input[c]?.[i] ?? input[0]?.[i] ?? 0;
const xs = (this.inputs[c] ??= new Float64Array(this.stages));
const ys = (this.outputs[c] ??= new Float64Array(this.stages));
let v = dry + feedback * (this.last[c] ?? 0);
for (let s = 0; s < this.stages; s++) {
const y = a * v + xs[s]! - a * ys[s]!;
xs[s] = v;
ys[s] = y;
v = y;
}
this.last[c] = v;
channel[i] = dry + (v - dry) * share;
});
}
}
}
/** The longest a Flanger delays its copy, in ms; a delay past it stays there, as sfxr's does at its own end. */
export const FLANGER_MAX_MS = 20;
/**
* The flanger, which sfxr calls its phaser: the sound and a copy `offset` ms late. `sweep` moves
* that delay on by so many ms a second from the start of the sound, and an LFO adds up to `depth` ms
* to it, `rate` times a second. As in sfxr the delay is mirrored at 0, so one sweeping down passes
* through 0 and comes back up. `feedback` sends the copy back into the line.
*/
export class Flanger {
private readonly lines: Float32Array[] = [];
private write = 0;
private lfo = 0;
/** Ms the sweep has moved the delay by so far. */
private swept = 0;
constructor(private readonly sampleRate: number) {}
private get size(): number {
return Math.ceil((FLANGER_MAX_MS / 1000) * this.sampleRate) + 2;
}
render(input: readonly Float32Array[], output: readonly Float32Array[], numbers: FlangerNumbers): void {
const length = output[0]?.length ?? 0;
const size = this.size;
const msToSamples = this.sampleRate / 1000;
for (let i = 0; i < length; i++) {
const swing = (1 - Math.cos(this.lfo)) / 2;
this.lfo =
(this.lfo + (2 * Math.PI * Math.max(0, at(numbers.rate, i))) / this.sampleRate) % (2 * Math.PI);
const offset = at(numbers.offset, i);
const ms = Math.min(
FLANGER_MAX_MS,
Math.abs(offset + this.swept) + Math.max(0, at(numbers.depth, i)) * swing,
);
// Held where it can no longer move the delay, so it never drifts off into the far numbers.
this.swept = clamp(
this.swept + at(numbers.sweep, i) / this.sampleRate,
-2 * FLANGER_MAX_MS,
2 * FLANGER_MAX_MS,
);
const delay = Math.min(size - 2, ms * msToSamples);
const whole = Math.floor(delay);
const part = delay - whole;
const feedback = clamp(at(numbers.feedback, i), 0, MAX_FEEDBACK);
const share = clamp(at(numbers.mix, i), 0, 1) / 2;
output.forEach((channel, c) => {
const dry = input[c]?.[i] ?? input[0]?.[i] ?? 0;
const line = (this.lines[c] ??= new Float32Array(size));
// Read before this sample is written, so every echo of the feedback comes `delay` later.
const near = whole === 0 ? dry : line[(this.write - whole + size) % size]!;
const far = line[(this.write - whole - 1 + size) % size]!;
const late = near + (far - near) * part;
line[this.write] = dry + feedback * late;
channel[i] = dry + (late - dry) * share;
});
this.write = (this.write + 1) % size;
}
}
}
const clamp = (value: number, low: number, high: number) =>
Math.max(low, Math.min(high, Number.isFinite(value) ? value : 0));
const at = (values: Float32Array, i: number) => (values.length > 1 ? values[i]! : values[0]!);
+6 -2
View File
@@ -73,8 +73,12 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
playbacks: () => soundLibrary.playbacks(),
/** Whether the audio clock is held by the pause of the toolbar or runs. */
clockState: () => soundLibrary.clockState(),
/** The nodes a stage of a voice becomes, in any context – an offline one renders what it does. */
stageNodes: (context: BaseAudioContext, stage: FxStage) => stageNodes(context, stage),
/**
* The nodes a stage of a voice becomes, in any context – an offline one renders what it does.
* `at` is when its voice starts, which a Flanger's sweep counts from (#341).
*/
stageNodes: (context: BaseAudioContext, stage: FxStage, at?: number) =>
stageNodes(context, stage, undefined, at),
/** What drives the two shares of a Mix "Both at center", in any context (unstarted). */
bothAtCenter: (context: BaseAudioContext, mix: number, a: GainNode, b: GainNode) =>
bothAtCenter(context, mix, a, b),
+3 -2
View File
@@ -91,11 +91,12 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
onClick: () => this.openPresetMenu(),
});
this.randomizeButton = new Button('Randomize', {
tooltip: 'Draw every number of the Chips, Envelopes, Filters and Bitcrushers anew',
tooltip: 'Draw every number of the Chips, Envelopes, Filters, Bitcrushers, Phasers and Flangers anew',
onClick: () => this.randomize(),
});
this.mutateButton = new Button('Mutate', {
tooltip: 'Move some numbers of the Chips, Envelopes, Filters and Bitcrushers a little',
tooltip:
'Move some numbers of the Chips, Envelopes, Filters, Bitcrushers, Phasers and Flangers a little',
onClick: () => this.mutate(),
});
this.auditionButton = new Button('Audition', {
+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;
}
+92 -1
View File
@@ -2,11 +2,12 @@
* The audio thread's side of the effects that Web Audio has no node for. A pitched voice (#317)
* plays one sound through a `PitchPlayer`, its pitch the AudioParam `pitch` in semitones, so it
* glides, ramps and eases like any other knob. A Bitcrusher (#322) runs a `Crusher`, a Chip
* (#329) a `ChipPlayer`. Loaded into the context once by the sound library; nothing on the page
* (#329) a `ChipPlayer`, a Phaser and a Flanger (#341) theirs. Loaded into the context once by the sound library; nothing on the page
* imports it.
*/
import { type ChipShape, ChipPlayer } from '../core/soundfx/chip';
import { Crusher } from '../core/soundfx/crusher';
import { Flanger, type FlangerNumbers, Phaser, type PhaserNumbers } from '../core/soundfx/phaser';
import { PitchPlayer } from '../core/soundfx/pitchPlayer';
/** What the audio thread has and the page's types do not. */
@@ -118,6 +119,96 @@ class CrushProcessor extends AudioWorkletProcessor {
registerProcessor('bit-crusher', CrushProcessor);
export interface PhaserOptions {
stages: number;
}
const PHASER_PARAMS = ['frequency', 'rate', 'depth', 'feedback', 'mix'] as const;
/** A Phaser (#341) between two stages of a voice; it runs until the voice tells it to stop. */
class PhaserProcessor extends AudioWorkletProcessor {
private readonly phaser: Phaser;
private stopped = false;
constructor({ processorOptions }: { processorOptions: PhaserOptions }) {
super();
this.phaser = new Phaser(sampleRate, processorOptions.stages);
this.port.onmessage = () => {
this.stopped = true;
};
}
process(
inputs: Float32Array[][],
outputs: Float32Array[][],
parameters: Record<string, Float32Array>,
): boolean {
if (this.stopped) return false;
this.phaser.render(inputs[0] ?? [], outputs[0]!, parameters as PhaserNumbers);
return true;
}
static get parameterDescriptors() {
return PHASER_PARAMS.map((name) => ({ name, defaultValue: 0 }));
}
}
registerProcessor('phaser', PhaserProcessor);
export interface FlangerOptions {
/** The second of the audio clock its sound starts at: the sweep counts from there. */
startAt: number;
}
const FLANGER_PARAMS = ['offset', 'sweep', 'rate', 'depth', 'feedback', 'mix'] as const;
/** A Flanger (#341) between two stages of a voice; its sweep starts with the voice. */
class FlangerProcessor extends AudioWorkletProcessor {
private readonly flanger = new Flanger(sampleRate);
private stopped = false;
private startAt: number;
constructor({ processorOptions }: { processorOptions: FlangerOptions }) {
super();
this.startAt = processorOptions.startAt;
this.port.onmessage = () => {
this.stopped = true;
};
}
process(
inputs: Float32Array[][],
outputs: Float32Array[][],
parameters: Record<string, Float32Array>,
): boolean {
if (this.stopped) return false;
let input = inputs[0] ?? [];
let out = outputs[0]!;
let numbers = parameters as FlangerNumbers;
// Nothing is heard before the voice starts, and the sweep waits for its first sample.
if (this.startAt > currentTime) {
const wait = Math.round((this.startAt - currentTime) * sampleRate);
for (const channel of out) channel.fill(0, 0, wait);
if (wait >= (out[0]?.length ?? 0)) return true;
const from = (values: Float32Array) => (values.length > 1 ? values.subarray(wait) : values);
input = input.map(from);
out = out.map((channel) => channel.subarray(wait));
numbers = Object.fromEntries(
FLANGER_PARAMS.map((name) => [name, from(numbers[name])]),
) as FlangerNumbers;
this.startAt = 0;
}
this.flanger.render(input, out, numbers);
return true;
}
static get parameterDescriptors() {
return FLANGER_PARAMS.map((name) => ({ name, defaultValue: 0 }));
}
}
registerProcessor('flanger', FlangerProcessor);
export interface ChipOptions extends ChipShape {
/** The second of the audio clock its first sample is heard at; before it, silence. */
startAt: number;
+66 -2
View File
@@ -1,6 +1,7 @@
/**
* The Preset menu, Randomize and Mutate of the Instrument tab (#329): sfxr's generators worked out
* into a Chip, an Envelope, a Filter and a Bitcrusher, and its randomizer and mutator on their numbers.
* into a Chip, an Envelope, a Filter, a Bitcrusher and a Flanger (#341), and its randomizer and mutator
* on their numbers.
*/
import { describe, expect, it } from 'vitest';
import { ReplaceGraphCommand } from '../../src/core/commands/graphCommands';
@@ -21,11 +22,13 @@ import type { InstrumentAsset } from '../../src/core/model/Asset';
import type { FlatDef } from '../../src/core/properties/PropertySchema';
import {
CRUSH_ROWS,
FLANGER_ROWS,
type FxChipPlan,
type FxEnvelopePlan,
FILTER_ROWS,
type FxPlan,
fxChainOf,
PHASER_ROWS,
} from '../../src/core/soundfx/nodes';
import { makeProject } from '../helpers';
@@ -44,6 +47,8 @@ const ROWS: Record<string, Record<string, FlatDef<'number'>>> = {
InstrumentEnvelope: ENVELOPE_ROWS,
SoundFxFilter: FILTER_ROWS,
SoundFxBitcrusher: CRUSH_ROWS,
SoundFxPhaser: PHASER_ROWS,
SoundFxFlanger: FLANGER_ROWS,
};
function withinRows(node: GraphNode, keys: string[]): void {
@@ -61,13 +66,16 @@ describe('a preset (#329)', () => {
...TONE_PRESETS.map((p) => p.value),
...EFFECT_PRESETS.map((p) => p.value),
]);
expect(TONE_PRESETS).toHaveLength(14);
expect(TONE_PRESETS).toHaveLength(15);
});
it('of sfxr is a chain from every note through Chip, Envelope, Filter and Bitcrusher, ending by itself', () => {
const sources = instrumentSourcesOf(makeProject().project);
for (const { value } of EFFECT_PRESETS) {
const graph = chipPresetGraph(value, seeded());
// Only an explosion can have sfxr's phaser, as a Flanger after the Filter.
const flanged = graph.nodes.some((n) => n.type === 'SoundFxFlanger');
expect(flanged && value !== 'explosion', value).toBe(false);
expect(
graph.nodes.map((n) => n.type),
value,
@@ -76,6 +84,7 @@ describe('a preset (#329)', () => {
'InstrumentChip',
'InstrumentEnvelope',
'SoundFxFilter',
...(flanged ? ['SoundFxFlanger'] : []),
'SoundFxBitcrusher',
'InstrumentOutput',
]);
@@ -86,6 +95,7 @@ describe('a preset (#329)', () => {
expect('chip' in chain && chain.stages.map((s) => s.kind), value).toEqual([
'envelope',
'pass',
...(flanged ? ['flanger'] : []),
'crush',
]);
const envelope = chain.stages[0]!;
@@ -124,6 +134,31 @@ describe('a preset (#329)', () => {
expect(byType(chipPresetGraph('explosion', seeded(3)), 'InstrumentChip').params.wave).toBe('noise');
});
it("gives one explosion in two sfxr's phaser, as a Flanger with its offset and sweep (#341)", () => {
// Near 1: sfxr's offset 0.5991 and ramp -0.2997, a delay of 1.04 ms falling 11.2 ms a second.
expect(
byType(
chipPresetGraph('explosion', () => 0.999),
'SoundFxFlanger',
).params,
).toEqual({
offset: 1.04,
sweep: -11.2,
rate: 0,
depth: 0,
feedback: 0,
mix: 1,
});
expect(chipPresetGraph('explosion', () => 0).nodes.some((n) => n.type === 'SoundFxFlanger')).toBe(false);
const random = seeded(4);
const flangers = Array.from({ length: 400 }, () => chipPresetGraph('explosion', random)).flatMap(
(graph) => graph.nodes.filter((n) => n.type === 'SoundFxFlanger'),
);
expect(flangers.length).toBeGreaterThan(160);
expect(flangers.length).toBeLessThan(240);
for (const flanger of flangers) withinRows(flanger, Object.keys(FLANGER_ROWS));
});
it('is drawn anew every time', () => {
const random = seeded(7);
const a = byType(chipPresetGraph('laser', random), 'InstrumentChip').params;
@@ -202,6 +237,9 @@ describe('an instrument preset', () => {
arpeggio: 12,
repeat: 0.08,
});
// Phased Pad: a saw through a Phaser of six stages (#341).
const pad = build(chipPresetGraph('phasedPad'), 60).layers[0]!;
expect(fxChainOf(pad).stages.at(-1)).toMatchObject({ kind: 'phaser', stages: 6, frequency: 600, mix: 1 });
});
it('Drum Kit: kick, snare and hi-hats on their General MIDI keys, each ending by itself', () => {
@@ -287,6 +325,32 @@ describe('Randomize and Mutate (#329)', () => {
expect(mutatedParams(graph, () => 0.1)).toEqual([]);
});
it('draw and move the numbers of a Phaser and a Flanger too, but not their mix or stages (#341)', () => {
const graph = chipPresetGraph('explosion', () => 0.999);
const pad = chipPresetGraph('phasedPad');
graph.nodes.push(byType(pad, 'SoundFxPhaser'));
byType(graph, 'SoundFxFlanger').params.mix = 0.4;
const changes = randomizedParams(graph, seeded(13));
const keysOf = (type: string) =>
changes.filter((c) => c.node === byType(graph, type).id).map((c) => c.key);
expect(keysOf('SoundFxPhaser')).toEqual(['frequency', 'rate', 'depth', 'feedback']);
expect(keysOf('SoundFxFlanger')).toEqual(['offset', 'sweep', 'rate', 'depth', 'feedback']);
apply(graph, changes);
withinRows(byType(graph, 'SoundFxPhaser'), Object.keys(PHASER_ROWS));
withinRows(byType(graph, 'SoundFxFlanger'), Object.keys(FLANGER_ROWS));
expect(byType(graph, 'SoundFxPhaser').params).toMatchObject({ stages: '6', mix: 1 });
expect(byType(graph, 'SoundFxFlanger').params.mix).toBe(0.4);
// Mutate leaves the Flanger's LFO off and moves its offset and sweep, and the Phaser's numbers.
const fresh = chipPresetGraph('explosion', () => 0.999);
fresh.nodes.push(byType(chipPresetGraph('phasedPad'), 'SoundFxPhaser'));
const moved = (type: string) =>
mutatedParams(fresh, () => 0.9)
.filter((c) => c.node === byType(fresh, type).id)
.map((c) => c.key);
expect(moved('SoundFxFlanger')).toEqual(['offset', 'sweep']);
expect(moved('SoundFxPhaser')).toEqual(['frequency', 'rate', 'depth', 'feedback']);
});
it('find nothing to change in an instrument of Samples', () => {
expect(hasChipNumbers(starterInstrumentGraph())).toBe(false);
expect(randomizedParams(starterInstrumentGraph())).toEqual([]);
+135
View File
@@ -0,0 +1,135 @@
/** The Phaser's and the Flanger's samples (#341), as their worklet makes them. */
import { describe, expect, it } from 'vitest';
import { Flanger, type FlangerNumbers, Phaser, type PhaserNumbers } from '../../src/core/soundfx/phaser';
const RATE = 48000;
const one = (value: number) => new Float32Array([value]);
function flange(
input: Float32Array,
numbers: Partial<Record<keyof FlangerNumbers, number>>,
flanger = new Flanger(RATE),
) {
const out = new Float32Array(input.length);
const all = { offset: 0, sweep: 0, rate: 0, depth: 0, feedback: 0, mix: 1, ...numbers };
flanger.render(
[input],
[out],
Object.fromEntries(Object.entries(all).map(([k, v]) => [k, one(v)])) as FlangerNumbers,
);
return out;
}
function phase(input: Float32Array, numbers: Partial<Record<keyof PhaserNumbers, number>>, stages = 4) {
const out = new Float32Array(input.length);
const all = { frequency: 1000, rate: 0, depth: 0, feedback: 0, mix: 1, ...numbers };
new Phaser(RATE, stages).render(
[input],
[out],
Object.fromEntries(Object.entries(all).map(([k, v]) => [k, one(v)])) as PhaserNumbers,
);
return out;
}
const impulse = (length: number, at = 0) => {
const samples = new Float32Array(length);
samples[at] = 1;
return samples;
};
const sine = (hz: number, seconds: number) =>
Float32Array.from({ length: Math.round(seconds * RATE) }, (_, i) =>
Math.sin((2 * Math.PI * hz * i) / RATE),
);
const rms = (samples: Float32Array, from = 0, to = samples.length) => {
let sum = 0;
for (let i = from; i < to; i++) sum += samples[i]! ** 2;
return Math.sqrt(sum / (to - from));
};
/** Where an impulse comes out again, after its own sample: the samples with anything in them. */
const heard = (samples: Float32Array, from = 1) =>
[...samples].flatMap((v, i) => (i >= from && Math.abs(v) > 1e-6 ? [[i, +v.toFixed(4)]] : []));
describe('the Flanger (#341)', () => {
it('adds the sound offset ms late, half and half at mix 1, and leaves it be at mix 0', () => {
const out = flange(impulse(200), { offset: 1 });
expect(out[0]).toBeCloseTo(0.5);
expect(heard(out)).toEqual([[48, 0.5]]);
expect([...flange(impulse(200), { offset: 1, mix: 0 })]).toEqual([...impulse(200)]);
});
it('feeds the copy back, every echo one offset after the last', () => {
expect(heard(flange(impulse(200), { offset: 1, feedback: 0.5 }))).toEqual([
[48, 0.5],
[96, 0.25],
[144, 0.125],
[192, 0.0625],
]);
});
it('sweeps the delay on from the start of the sound, and mirrors it at 0 as sfxr does', () => {
// 100 ms/s from 0: after 0.1 s the copy is 10 ms late and still moving, so an impulse then
// comes back at j = 480 + 0.1 j, 533.3 samples on.
const flanger = new Flanger(RATE);
flange(new Float32Array(4800), { sweep: 100 }, flanger);
const later = heard(flange(impulse(800), { sweep: 100 }, flanger));
expect(later.map(([i]) => i)).toEqual([533, 534]);
// Read 0.9 samples a sample while the line grows: the copy is drawn out, by 1/0.9 on average.
const drawn = later.reduce((sum, [, v]) => sum + v!, 0);
expect(drawn).toBeGreaterThan(0.5);
expect(drawn).toBeLessThan(0.6);
// From 1 ms down at 100 ms/s: 20 ms in, the delay has passed 0 and is 1 ms again, growing.
const down = new Flanger(RATE);
flange(new Float32Array(960), { offset: 1, sweep: -100 }, down);
const back = flange(impulse(200), { offset: 1, sweep: -100 }, down);
expect(back[0]).toBeCloseTo(0.5);
expect(heard(back).map(([i]) => i)).toEqual([53, 54]);
});
it('swings the delay up to depth ms with its LFO, and never past 20 ms', () => {
// A quarter of a 10 Hz swing is 25 ms in: half way up from 1 ms towards 1 + 4 ms.
const flanger = new Flanger(RATE);
flange(new Float32Array(1200), { offset: 1, rate: 10, depth: 4 }, flanger);
const out = flange(impulse(400), { offset: 1, rate: 10, depth: 4 }, flanger);
const at = heard(out)[0]![0]!;
expect(at).toBeGreaterThan(48 * 2.5);
expect(at).toBeLessThan(48 * 3.6);
const far = new Flanger(RATE);
flange(new Float32Array(48000), { sweep: 125 }, far);
expect(heard(flange(impulse(2000), { sweep: 125 }, far))).toEqual([[960, 0.5]]);
});
});
describe('the Phaser (#341)', () => {
it('cuts a notch at its frequency with two stages, where four make a peak', () => {
const tone = sine(1000, 0.2);
expect(rms(phase(tone, {}, 2), 4800)).toBeLessThan(0.01);
expect(rms(phase(tone, {}, 4), 4800)).toBeCloseTo(Math.SQRT1_2, 2);
// Far below the notch the two stages hardly turn the phase: the sound stays.
expect(rms(phase(sine(50, 0.4), {}, 2), 4800)).toBeGreaterThan(0.69);
});
it('leaves the sound be at mix 0', () => {
const tone = sine(1000, 0.05);
expect([...phase(tone, { mix: 0 }, 2)]).toEqual([...tone]);
});
it('moves the notch with its LFO, depth octaves wide', () => {
// At 1 Hz over 4 octaves the notch passes 1 kHz twice a second and is two octaves off between,
// where two stages turn it by 2 * 2 * atan(1/4): cos(28°) of it is left, 0.62 of the RMS.
const out = phase(sine(1000, 1), { rate: 1, depth: 4 }, 2);
const windows = Array.from({ length: 40 }, (_, w) => rms(out, w * 1200, (w + 1) * 1200));
expect(Math.min(...windows)).toBeLessThan(0.15);
expect(Math.max(...windows)).toBeGreaterThan(0.58);
expect(Math.max(...windows)).toBeLessThan(0.63);
});
it('stays bounded at the most feedback there is', () => {
const noise = Float32Array.from({ length: RATE }, (_, i) => Math.sin(i * 12.9898) % 1);
const out = phase(noise, { feedback: 0.95, rate: 2, depth: 4 }, 12);
expect(Math.max(...out.map(Math.abs))).toBeLessThan(20);
expect(out.every(Number.isFinite)).toBe(true);
});
});
+34
View File
@@ -122,6 +122,8 @@ describe('the sound FX asset', () => {
'SoundFxMix',
'SoundFxDelay',
'SoundFxBitcrusher',
'SoundFxPhaser',
'SoundFxFlanger',
]);
f.edit.addNode('SoundFxInput');
f.edit.addNode('SoundFxOutput');
@@ -133,6 +135,8 @@ describe('the sound FX asset', () => {
'SoundFxMix',
'SoundFxDelay',
'SoundFxBitcrusher',
'SoundFxPhaser',
'SoundFxFlanger',
]);
const behavior = makeBehavior(f, 'Logic');
@@ -365,6 +369,36 @@ describe('a Bitcrusher in a sound FX graph (#322)', () => {
});
});
describe('a Phaser and a Flanger in a sound FX graph (#341)', () => {
const at = (type: string, params?: Record<string, string | number>) =>
fxChainOf(compileSoundFx(line([{ type, params }]).fx.graph).compiled!).stages;
it('play at their defaults and hold their numbers to their rows', () => {
expect(at('SoundFxPhaser')).toEqual([
{ kind: 'phaser', stages: 4, frequency: 800, rate: 0.5, depth: 2, feedback: 0.4, mix: 1 },
]);
expect(at('SoundFxPhaser', { stages: '12', frequency: 5, feedback: 2, depth: -1 })).toMatchObject([
{ stages: 12, frequency: 20, feedback: 0.95, depth: 0 },
]);
expect(at('SoundFxPhaser', { stages: '5' })).toMatchObject([{ stages: 4 }]);
expect(at('SoundFxFlanger')).toEqual([
{ kind: 'flanger', offset: 1, sweep: 0, rate: 0.25, depth: 2, feedback: 0.5, mix: 1 },
]);
expect(at('SoundFxFlanger', { offset: 50, sweep: -500, depth: 99 })).toMatchObject([
{ offset: 10, sweep: -125, depth: 10 },
]);
});
it('are left out at a typed-in mix of 0, and kept for a mix from a parameter', () => {
expect(at('SoundFxPhaser', { mix: 0 })).toEqual([]);
expect(at('SoundFxFlanger', { mix: 0 })).toEqual([]);
const f = withParam('SoundFxFlanger', 'mix', [{ key: 'wet', kind: 'number', default: 0 }]);
const plan = soundFxBuildFor(f.fx).compiled!;
expect(fxChainOf(plan).stages).toMatchObject([{ kind: 'flanger', mix: 0 }]);
expect(fxChainOf(plan, { wet: 0.3 }).stages).toMatchObject([{ kind: 'flanger', mix: 0.3 }]);
});
});
describe('the tail of a voice (#324)', () => {
const echo = (time: number, feedback: number, mix: number): FxStage => ({
kind: 'delay',
+56 -1
View File
@@ -311,7 +311,14 @@ beforeEach(() => {
const parameters = new Map(
Object.entries(options.parameterData).map(([key, value]) => [key, param(value)] as const),
);
const kind = name === 'bit-crusher' ? 'crush' : name === 'chip-player' ? 'chip' : 'pitch';
const kind =
name === 'bit-crusher'
? 'crush'
: name === 'chip-player'
? 'chip'
: name === 'phaser' || name === 'flanger'
? name
: 'pitch';
const node: FakeWorklet = wired(kind, {
options,
pitch: parameters.get('pitch') ?? param(0),
@@ -562,6 +569,54 @@ describe('the sound library', () => {
expect(node.parameters.get('mix')!.ramps).toEqual([]);
});
it('sends a voice through a Phaser and a Flanger on the worklet, the sweep from its start (#341)', async () => {
const library = new SoundLibrary();
const amounts = (values: Record<string, number>) =>
Object.fromEntries(Object.entries(values).map(([key, value]) => [key, { value }]));
const plan: FxPlan = {
pitch: [],
stages: [
{
kind: 'phaser',
stages: 6,
...amounts({ rate: 0.5, depth: 2, feedback: 0.4, mix: 1 }),
frequency: { value: 800, param: 'f' },
} as FxStagePlan,
{
kind: 'flanger',
...amounts({ sweep: -3, rate: 0, depth: 0, feedback: 0, mix: 1 }),
offset: { value: 1.5, param: 'o' },
} as FxStagePlan,
],
};
const voice = await play(library, FILE, {}, { plan, params: {} });
expect(wires).toEqual(['source > phaser', 'phaser > flanger', 'flanger > gain', 'gain > destination']);
const [phaser, flanger] = worklets as [FakeWorklet, FakeWorklet];
expect(phaser.options.parameterData).toEqual({
frequency: 800,
rate: 0.5,
depth: 2,
feedback: 0.4,
mix: 1,
});
expect(phaser.options.processorOptions).toEqual({ stages: 6 });
expect(flanger.options.parameterData).toEqual({
offset: 1.5,
sweep: -3,
rate: 0,
depth: 0,
feedback: 0,
mix: 1,
});
expect(flanger.options.processorOptions).toEqual({ startAt: library.stats().last!.at });
expect(library.tune(voice, { f: 1600, o: 3 }, 0.5)).toBe(true);
expect(phaser.parameters.get('frequency')!.ramps).toEqual([['exponential', 1600, 0.5]]);
expect(flanger.parameters.get('offset')!.ramps).toEqual([['linear', 3, 0.5]]);
library.stopVoice(voice);
clockTo();
expect([phaser.posted, flanger.posted]).toEqual([['stop'], ['stop']]);
});
it('without the worklet a Bitcrusher lets the sound through as it is (#322)', async () => {
loadModule = () => Promise.reject(new Error('no worklets here'));
vi.spyOn(console, 'error').mockImplementation(() => {});
+81 -1
View File
@@ -859,6 +859,86 @@ export async function sound(t) {
crushed.halfOff < 1e-6,
JSON.stringify(crushed),
);
// #341: Phaser and Flanger on the worklet, rendered offline. A 1 kHz sine at 0.8 through a Phaser
// at 1 kHz: two stages cut it out, four leave it whole. Swept 4 octaves wide once a second,
// the notch passes it and is two octaves off between, where cos(28°) of it is left. A
// Flanger of a voice starting at 0.25 s: an impulse 1 ms later comes back after 48 samples
// at half. Sweeping 100 ms/s from that start, an impulse 0.1 s in comes back 533 samples
// later. Then 64 stereo voices of each for 2 s, timed.
const swirled = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (voices, seconds, channels, stage, fill, at = 0) => {
const context = new OfflineAudioContext(channels, RATE * seconds, RATE);
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
const sound = context.createBuffer(channels, RATE * seconds, RATE);
for (let c = 0; c < channels; c++) fill(sound.getChannelData(c));
for (let v = 0; v < voices; v++) {
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, stage, at);
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
}
const started = performance.now();
const out = (await context.startRendering()).getChannelData(0);
return { out, ms: performance.now() - started };
};
const sine = (data) => {
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE) * 0.8;
};
const rms = (out, from, to) => {
let sum = 0;
for (let i = from; i < to; i++) sum += out[i] ** 2;
return Math.sqrt(sum / (to - from));
};
const phaser = (stages, rate, depth) =>
({ kind: 'phaser', stages, frequency: 1000, rate, depth, feedback: 0, mix: 1 });
const notch = (await render(1, 0.3, 1, phaser(2, 0, 0), sine)).out;
const peak = (await render(1, 0.3, 1, phaser(4, 0, 0), sine)).out;
const swept = (await render(1, 1, 1, phaser(2, 1, 4), sine)).out;
const windows = Array.from({ length: 40 }, (_, w) => rms(swept, w * 1200, (w + 1) * 1200));
const flanger = (offset, sweep) =>
({ kind: 'flanger', offset, sweep, rate: 0, depth: 0, feedback: 0, mix: 1 });
const impulse = (at) => (data) => { data[Math.round(at * RATE)] = 1; };
const heard = (out, from) => {
const found = [];
for (let i = from; i < from + 2000; i++) if (Math.abs(out[i]) > 1e-4) found.push([i - from, +out[i].toFixed(3)]);
return found;
};
const still = heard((await render(1, 0.5, 1, flanger(1, 0), impulse(0.251), 0.25)).out, Math.round(0.251 * RATE));
const sweeping = heard((await render(1, 0.5, 1, flanger(0, 100), impulse(0.35), 0.25)).out, Math.round(0.35 * RATE));
const noise = (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin(i * 12.9898) % 1 * 0.5; };
const phaserLoad = await render(64, 2, 2, { ...phaser(12, 0.5, 2), feedback: 0.6 }, noise);
const flangerLoad = await render(64, 2, 2, { kind: 'flanger', offset: 1, sweep: 2, rate: 0.3, depth: 3, feedback: 0.5, mix: 1 }, noise);
return {
notch: +rms(notch, 4800, notch.length).toFixed(4),
peak: +rms(peak, 4800, peak.length).toFixed(4),
sweptLow: +Math.min(...windows).toFixed(3),
sweptHigh: +Math.max(...windows).toFixed(3),
still,
sweeping,
phaserVoices: Math.round((64 * 2000) / phaserLoad.ms),
flangerVoices: Math.round((64 * 2000) / flangerLoad.ms),
};
})()`);
check(
'the Phaser cuts a notch at its frequency and sweeps it, the Flanger comes back offset ms late and sweeps from its voice',
swirled.notch < 0.01 &&
Math.abs(swirled.peak - 0.8 * Math.SQRT1_2) < 0.01 &&
swirled.sweptLow < 0.1 &&
swirled.sweptHigh > 0.46 &&
swirled.sweptHigh < 0.51 &&
JSON.stringify(swirled.still) ===
JSON.stringify([
[0, 0.5],
[48, 0.5],
]) &&
swirled.sweeping[0]?.[1] === 0.5 &&
JSON.stringify(swirled.sweeping.slice(1).map(([i]) => i)) === JSON.stringify([533, 534]),
JSON.stringify(swirled),
);
// #329: the Chip on the worklet, rendered offline. A square at 220 Hz with a pitch of 12 and a duty
// of 0.25, starting at 0.25 s: silence until then, 440 periods a second after it, up a
// quarter of each. Sliding two octaves a second down from 440 Hz with a lowest frequency of
@@ -1215,7 +1295,7 @@ export async function sound(t) {
const unled = await insGraph();
check(
'Preset → Square Lead plays every note at its own pitch and holds it; one undo takes it back',
['Square Lead', 'Organ', 'Bell', 'Drum Kit'].every((label) => presetMenu.includes(label)) &&
['Square Lead', 'Phased Pad', 'Organ', 'Bell', 'Drum Kit'].every((label) => presetMenu.includes(label)) &&
leadGraph.chip?.followNote === true &&
leadGraph.envelope?.sustain > 0 &&
lead.last?.file === `instrument:${insId}` &&