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>
263 lines
8.8 KiB
TypeScript
263 lines
8.8 KiB
TypeScript
/**
|
||
* 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`, 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. */
|
||
declare const sampleRate: number;
|
||
declare const currentTime: number;
|
||
declare class AudioWorkletProcessor {
|
||
readonly port: MessagePort;
|
||
}
|
||
declare function registerProcessor<T>(
|
||
name: string,
|
||
processor: new (options: { processorOptions: T }) => object,
|
||
): void;
|
||
|
||
export interface PitchOptions {
|
||
/** The file the samples are of: they are sent with the first voice of it only and kept here. */
|
||
file: string;
|
||
channels?: Float32Array[];
|
||
/** The sound's rate over the context's. */
|
||
step: number;
|
||
loop: boolean;
|
||
/** Where a loop runs, in samples of the sound (#331); without them it is the whole sound. */
|
||
loopStart?: number;
|
||
loopEnd?: number;
|
||
/** The second of the audio clock its first sample is heard at (#332); before it, silence. */
|
||
startAt?: number;
|
||
/** Samples of the sound before where it starts, for a clip of a song taken up mid-way (#334). */
|
||
skip?: number;
|
||
}
|
||
|
||
/** Every sound a voice has been played from, for the next voice of it – processors share this scope. */
|
||
const sounds = new Map<string, Float32Array[]>();
|
||
|
||
class PitchProcessor extends AudioWorkletProcessor {
|
||
private readonly player: PitchPlayer;
|
||
private stopped = false;
|
||
private startAt: number;
|
||
|
||
constructor({ processorOptions }: { processorOptions: PitchOptions }) {
|
||
super();
|
||
const { file, channels, step, loop, loopStart, loopEnd, startAt, skip } = processorOptions;
|
||
this.startAt = startAt ?? 0;
|
||
if (channels) sounds.set(file, channels);
|
||
const region = loop && loopEnd !== undefined ? { start: loopStart ?? 0, end: loopEnd } : loop;
|
||
this.player = new PitchPlayer(sounds.get(file) ?? [], step, sampleRate, region, skip);
|
||
this.port.onmessage = (event: MessageEvent) => {
|
||
if (event.data === 'leave') this.player.leaveLoop();
|
||
else this.stopped = true;
|
||
};
|
||
}
|
||
|
||
process(
|
||
_inputs: Float32Array[][],
|
||
outputs: Float32Array[][],
|
||
parameters: Record<string, Float32Array>,
|
||
): boolean {
|
||
if (this.stopped) return false;
|
||
let out = outputs[0]!;
|
||
let pitch = parameters.pitch!;
|
||
// A block is silence until then, the one it falls in starts on that sample.
|
||
if (this.startAt > currentTime) {
|
||
const wait = Math.round((this.startAt - currentTime) * sampleRate);
|
||
if (wait >= (out[0]?.length ?? 0)) return true;
|
||
out = out.map((channel) => channel.subarray(wait));
|
||
if (pitch.length > 1) pitch = pitch.subarray(wait);
|
||
this.startAt = 0;
|
||
}
|
||
if (this.player.render(out, pitch)) return true;
|
||
this.port.postMessage('ended');
|
||
return false;
|
||
}
|
||
|
||
static get parameterDescriptors() {
|
||
return [{ name: 'pitch', defaultValue: 0 }];
|
||
}
|
||
}
|
||
|
||
registerProcessor('pitch-player', PitchProcessor);
|
||
|
||
/** A Bitcrusher (#322) between two stages of a voice; it runs until the voice tells it to stop. */
|
||
class CrushProcessor extends AudioWorkletProcessor {
|
||
private readonly crusher = new Crusher(sampleRate);
|
||
private stopped = false;
|
||
|
||
constructor() {
|
||
super();
|
||
this.port.onmessage = () => {
|
||
this.stopped = true;
|
||
};
|
||
}
|
||
|
||
process(
|
||
inputs: Float32Array[][],
|
||
outputs: Float32Array[][],
|
||
parameters: Record<string, Float32Array>,
|
||
): boolean {
|
||
if (this.stopped) return false;
|
||
this.crusher.render(inputs[0] ?? [], outputs[0]!, parameters.bits!, parameters.rate!, parameters.mix!);
|
||
return true;
|
||
}
|
||
|
||
static get parameterDescriptors() {
|
||
return [
|
||
{ name: 'bits', defaultValue: 16 },
|
||
{ name: 'rate', defaultValue: 48000 },
|
||
{ name: 'mix', defaultValue: 0 },
|
||
];
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/** The Chip of a note (#329): a source of its own, its frequency, duty and pitch AudioParams. */
|
||
class ChipProcessor extends AudioWorkletProcessor {
|
||
private readonly player: ChipPlayer;
|
||
private stopped = false;
|
||
private startAt: number;
|
||
|
||
constructor({ processorOptions }: { processorOptions: ChipOptions }) {
|
||
super();
|
||
this.player = new ChipPlayer(processorOptions, sampleRate);
|
||
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 out = outputs[0]!;
|
||
let { frequency, duty, pitch } = parameters as Record<'frequency' | 'duty' | 'pitch', Float32Array>;
|
||
if (this.startAt > currentTime) {
|
||
const wait = Math.round((this.startAt - currentTime) * sampleRate);
|
||
if (wait >= (out[0]?.length ?? 0)) return true;
|
||
const from = (values: Float32Array) => (values.length > 1 ? values.subarray(wait) : values);
|
||
out = out.map((channel) => channel.subarray(wait));
|
||
[frequency, duty, pitch] = [from(frequency), from(duty), from(pitch)];
|
||
this.startAt = 0;
|
||
}
|
||
if (this.player.render(out, frequency, duty, pitch)) return true;
|
||
this.port.postMessage('ended');
|
||
return false;
|
||
}
|
||
|
||
static get parameterDescriptors() {
|
||
return [
|
||
{ name: 'frequency', defaultValue: 440 },
|
||
{ name: 'duty', defaultValue: 0.5 },
|
||
{ name: 'pitch', defaultValue: 0 },
|
||
];
|
||
}
|
||
}
|
||
|
||
registerProcessor('chip-player', ChipProcessor);
|