The first Acid hardly squelched. Its tanh ate the resonance, and its sweep fell evenly in octaves. The user pointed at the acid tone of nodeseq.com/studio, so the node now follows how that voice is built from Web Audio parts. - The filter is a two-pole 12 dB low-pass with Q in dB up to 17.5, like a BiquadFilterNode. The ladder and its tanh are gone. - One ADSR drives both the volume and the filter. The sweep falls back in Hz, fast from the top and long at the bottom. - Every note glides from the one before. A new note cuts the last one off, and the node plays its release itself. - Knobs: cutoff, reso, env, attack, decay, sustain, release and glide. The defaults are the acid preset of nodeseq. - Accent, slide, drive and tuning are dropped. Old projects were not kept, the user had only placed the node for a try. - A hook __gm.acid.compare renders a line offline through our worklet and through the nodeseq graph, for e2e 34. Found on the way: in Chrome an oscillator that starts inside a render block runs its first samples at 440 Hz. The comparison evens this out. Tests: - tsc, eslint, Prettier clean - Unit: acid.test.ts rewritten with 12 tests. chip-presets and sound-library follow the new node. - e2e 34 on melektaus-atx: ALL PASSED, 148 checks. Against nodeseq the loudness per 10 ms window is at most 0.14 dB apart, and 95 to 97 % of the windows are equally bright. - With a linear Q instead of Q in dB the comparison is red. - Gate on melektaus-atx: green, 306 files, 4752 tests. That is 4754 less the 14 old and plus the 12 new Acid tests. - Not checked: the sound by ear, the master chain of nodeseq, the keyboard and a bake with the new node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
739 lines
28 KiB
TypeScript
739 lines
28 KiB
TypeScript
/**
|
||
* The Web Audio side of a sound FX plan: what each stage of a chain becomes as nodes (#311), the
|
||
* knobs a playing voice moves (#315), and the drivers an Envelope or a Mix carries its numbers on.
|
||
* The sound library wires voices and buses from these; nothing here keeps a voice or a bus.
|
||
*/
|
||
import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope';
|
||
import type { EaseShare } from '../core/model/animation';
|
||
import { LFO_SHAPES } from '../core/soundfx/lfo';
|
||
import {
|
||
DELAY_MAX,
|
||
delayShares,
|
||
FLAT_Q,
|
||
type FxChain,
|
||
type FxCompress,
|
||
type FxCrush,
|
||
type FxDelay,
|
||
type FxEnvelope,
|
||
type FxFlanger,
|
||
type FxLfo,
|
||
type FxMod,
|
||
type FxPass,
|
||
type FxPhaser,
|
||
type FxReverb,
|
||
type FxSaturate,
|
||
type FxStage,
|
||
type FxWet,
|
||
passQ,
|
||
stageLatency,
|
||
} from '../core/soundfx/nodes';
|
||
import { reverbImpulse } from '../core/soundfx/reverb';
|
||
import { driveGain, saturationCurve, SHAPER_LATENCY_FRAMES } from '../core/soundfx/saturation';
|
||
import type { FlangerOptions, LfoOptions, PhaserOptions } from './fxWorklet';
|
||
|
||
/** Seconds a changed parameter takes to get most of the way there: quick, but no click (#315). */
|
||
export const GLIDE = 0.03;
|
||
|
||
/** Seconds a stopped voice takes to fall silent, so it does not click (#324). */
|
||
export const FADE = 0.01;
|
||
|
||
/**
|
||
* One number of a voice that can still change while it plays (#315): a level, a share of a Mix, the
|
||
* gain of a band, the pitch of a way in semitones, a number of an echo. `even` says what a timed
|
||
* change runs evenly in: a gain stage in decibels (its node holds the factor) and the damping of an
|
||
* echo in octaves (its node holds the cutoff), everything else in what it holds. A way whose pitch
|
||
* has no worklet to play it has no `param`: it keeps its own pitch.
|
||
*/
|
||
export interface Knob {
|
||
param: AudioParam | null;
|
||
target: number;
|
||
even: 'value' | 'ratio';
|
||
}
|
||
|
||
export function gainNode(context: BaseAudioContext, level: number): GainNode {
|
||
const gain = context.createGain();
|
||
gain.gain.value = level;
|
||
return gain;
|
||
}
|
||
|
||
/** What a stage becomes: where its sound goes in, where it comes out, and the numbers a parameter can move. */
|
||
export interface StageNodes {
|
||
input: AudioNode;
|
||
output: AudioNode;
|
||
knobs: Knob[];
|
||
/** Ends a stage that runs on the audio thread until it is told to. */
|
||
stop?: () => void;
|
||
}
|
||
|
||
/**
|
||
* Moves `param` along the curve of an Envelope (#335), from `from` to `to` in equal steps or equal
|
||
* ratios, `map` turning that into what the node holds. The param's own value is added, so one that
|
||
* follows alone is set to 0 first.
|
||
*/
|
||
export type Follow = (mod: FxMod, param: AudioParam, ratio: boolean, map?: (value: number) => number) => void;
|
||
|
||
/**
|
||
* Shapes the curve an Envelope's `driver` carries into `param` through a WaveShaper. Exported for a
|
||
* check that renders it offline in a real browser (test hook).
|
||
*/
|
||
export function followCurve(
|
||
context: BaseAudioContext,
|
||
driver: AudioNode,
|
||
mod: FxMod,
|
||
param: AudioParam,
|
||
ratio: boolean,
|
||
map?: (value: number) => number,
|
||
): void {
|
||
const shape = context.createWaveShaper();
|
||
shape.curve = modCurve(mod, ratio, map);
|
||
driver.connect(shape).connect(param);
|
||
}
|
||
|
||
/**
|
||
* 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. `at` is when the voice
|
||
* starts.
|
||
*/
|
||
export function stageNodes(
|
||
context: BaseAudioContext,
|
||
stage: FxStage,
|
||
follow?: Follow,
|
||
at = context.currentTime,
|
||
impulse: AudioBuffer | null = null,
|
||
): StageNodes {
|
||
if (stage.kind === 'gain') {
|
||
const gain = gainNode(context, stage.mod ? 0 : stage.gain);
|
||
if (stage.mod) follow?.(stage.mod, gain.gain, true);
|
||
const knob: Knob = { param: stage.mod ? null : gain.gain, target: stage.gain, even: 'ratio' };
|
||
return { input: gain, output: gain, knobs: [knob] };
|
||
}
|
||
if (stage.kind === 'envelope') {
|
||
const gain = gainNode(context, 0);
|
||
follow?.({ envelope: stage.envelope, from: 0, to: 1 }, gain.gain, false);
|
||
return { input: gain, output: gain, knobs: [] };
|
||
}
|
||
if (stage.kind === 'tap') {
|
||
const gain = gainNode(context, 1);
|
||
return { input: gain, output: gain, knobs: [] };
|
||
}
|
||
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);
|
||
if (stage.kind === 'compress') return compressNodes(context, stage);
|
||
if (stage.kind === 'saturate') return saturateNodes(context, stage);
|
||
if (stage.kind === 'reverb') return reverbNodes(context, stage, impulse);
|
||
if (stage.kind === 'tremolo' || stage.kind === 'vibrato') return lfoNodes(context, stage, at);
|
||
if (stage.kind === 'wet') return wetNodes(context, stage, follow, at);
|
||
if (stage.kind === 'limit')
|
||
return workletNodes(context, 'limiter', [
|
||
{ name: 'threshold', value: stage.threshold, even: 'value' },
|
||
{ name: 'ceiling', value: stage.ceiling, even: 'value' },
|
||
{ name: 'release', value: stage.release, even: 'ratio' },
|
||
{ name: 'autoGain', value: stage.autoGain, even: 'value' },
|
||
]);
|
||
const panner = context.createStereoPanner();
|
||
panner.pan.value = stage.mod ? 0 : stage.pan;
|
||
if (stage.mod) follow?.(stage.mod, panner.pan, false);
|
||
return {
|
||
input: panner,
|
||
output: panner,
|
||
knobs: [{ param: stage.mod ? null : panner.pan, target: stage.pan, even: 'value' }],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A Filter (#335, #318): a cutoff that moves in octaves, a Q – as a factor in ratios too, in dB for a
|
||
* low- or high-pass – and a gain in dB. Several in a row share one cutoff, carried by a source of its
|
||
* own into each of them; only the last has the Q, the others none.
|
||
*/
|
||
function passNodes(context: BaseAudioContext, stage: FxPass, follow?: Follow): StageNodes {
|
||
const qEven = stage.filter === 'lowpass' || stage.filter === 'highpass' ? 'value' : 'ratio';
|
||
const filters = Array.from({ length: Math.max(1, stage.stages) }, () => {
|
||
const filter = context.createBiquadFilter();
|
||
filter.type = stage.filter;
|
||
filter.Q.value = passQ(stage.filter, FLAT_Q);
|
||
return filter;
|
||
});
|
||
filters.slice(1).forEach((filter, i) => filters[i]!.connect(filter));
|
||
const last = filters.at(-1)!;
|
||
last.Q.value = stage.qMod ? 0 : stage.q;
|
||
last.gain.value = stage.gainMod ? 0 : stage.gain;
|
||
if (stage.qMod) follow?.(stage.qMod, last.Q, qEven === 'ratio');
|
||
if (stage.gainMod) follow?.(stage.gainMod, last.gain, false);
|
||
let cutoff = filters[0]!.frequency;
|
||
let stop: (() => void) | undefined;
|
||
if (filters.length > 1) {
|
||
const driver = context.createConstantSource();
|
||
for (const filter of filters) {
|
||
filter.frequency.value = 0;
|
||
driver.connect(filter.frequency);
|
||
}
|
||
driver.start();
|
||
cutoff = driver.offset;
|
||
stop = () => driver.stop();
|
||
}
|
||
cutoff.value = stage.cutoffMod ? 0 : stage.cutoff;
|
||
if (stage.cutoffMod) follow?.(stage.cutoffMod, cutoff, true);
|
||
return {
|
||
input: filters[0]!,
|
||
output: last,
|
||
knobs: [
|
||
{ param: stage.cutoffMod ? null : cutoff, target: stage.cutoff, even: 'ratio' },
|
||
{ param: stage.qMod ? null : last.Q, target: stage.q, even: qEven },
|
||
{ param: stage.gainMod ? null : last.gain, target: stage.gain, even: 'value' },
|
||
],
|
||
...(stop && { stop }),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* What carries the curve of an Envelope (#335) from `at`: a straight line up to 1, then easing down
|
||
* towards the sustain. Its caller starts it and stops it with the voice. Exported for a check that
|
||
* renders it offline in a real browser (test hook).
|
||
*/
|
||
export function envelopeDriver(
|
||
context: BaseAudioContext,
|
||
envelope: FxEnvelope,
|
||
at: number,
|
||
): ConstantSourceNode {
|
||
const driver = context.createConstantSource();
|
||
const level = driver.offset;
|
||
level.value = 0;
|
||
const top = at + envelope.attack;
|
||
if (envelope.attack > 0) {
|
||
level.setValueAtTime(0, at);
|
||
level.linearRampToValueAtTime(1, top);
|
||
} else level.setValueAtTime(1, at);
|
||
if (envelope.sustain < 1) {
|
||
if (envelope.decay > 0) level.setTargetAtTime(envelope.sustain, top, envelope.decay / SETTLE);
|
||
else level.setValueAtTime(envelope.sustain, top);
|
||
}
|
||
return driver;
|
||
}
|
||
|
||
/**
|
||
* Lets an Envelope that started at `startedAt` fall away from `at`, from the level it has by then –
|
||
* mid-attack too – to silence in its release. Exported for the same check (test hook).
|
||
*/
|
||
export function releaseEnvelope(
|
||
level: AudioParam,
|
||
envelope: FxEnvelope,
|
||
startedAt: number,
|
||
at: number,
|
||
): void {
|
||
const held = level as Omit<AudioParam, 'cancelAndHoldAtTime'> &
|
||
Partial<Pick<AudioParam, 'cancelAndHoldAtTime'>>;
|
||
if (held.cancelAndHoldAtTime) held.cancelAndHoldAtTime(at);
|
||
else {
|
||
// Firefox: the level by then is known, the curve up to it is what was planned.
|
||
const reached = envelopeLevel(envelope, at - startedAt);
|
||
level.cancelScheduledValues(at);
|
||
if (at - startedAt < envelope.attack) level.linearRampToValueAtTime(reached, at);
|
||
else level.setValueAtTime(reached, at);
|
||
}
|
||
level.setTargetAtTime(0, at, releaseTime(envelope) / SETTLE);
|
||
}
|
||
|
||
/**
|
||
* What one node adds, blended with what arrives at it (#354): its stages one after another at `wet`,
|
||
* and the sound as it came in at the rest, held back as far as the stages are late.
|
||
*/
|
||
function wetNodes(
|
||
context: BaseAudioContext,
|
||
stage: FxWet,
|
||
follow: Follow | undefined,
|
||
at: number,
|
||
): StageNodes {
|
||
const input = context.createGain();
|
||
const output = context.createGain();
|
||
const knobs: Knob[] = [];
|
||
const stops: (() => void)[] = [];
|
||
let tail: AudioNode = input;
|
||
for (const inner of stage.stages) {
|
||
const made = stageNodes(context, inner, follow, at);
|
||
tail.connect(made.input);
|
||
tail = made.output;
|
||
knobs.push(...made.knobs);
|
||
if (made.stop) stops.push(made.stop);
|
||
}
|
||
const moved = stage.wetMod;
|
||
const wet = gainNode(context, moved ? 0 : stage.wet);
|
||
const dry = gainNode(context, moved ? 0 : 1 - stage.wet);
|
||
if (moved) {
|
||
follow?.(moved, wet.gain, false);
|
||
follow?.(moved, dry.gain, false, (share) => 1 - share);
|
||
}
|
||
tail.connect(wet).connect(output);
|
||
heldBack(context, input, stageLatency(stage, context.sampleRate)).connect(dry).connect(output);
|
||
knobs.push(
|
||
{ param: moved ? null : wet.gain, target: stage.wet, even: 'value' },
|
||
{ param: moved ? null : dry.gain, target: 1 - stage.wet, even: 'value' },
|
||
);
|
||
return { input, output, knobs, ...(stops.length > 0 && { stop: () => stops.forEach((stop) => stop()) }) };
|
||
}
|
||
|
||
/**
|
||
* A Compressor (#326): Web Audio's node, and a gain after it for makeup and auto-gain. Its times
|
||
* move in ratios, everything in dB as it is.
|
||
*/
|
||
function compressNodes(context: BaseAudioContext, stage: FxCompress): StageNodes {
|
||
const node = context.createDynamicsCompressor();
|
||
node.threshold.value = stage.threshold;
|
||
node.knee.value = stage.knee;
|
||
node.ratio.value = stage.ratio;
|
||
node.attack.value = stage.attack;
|
||
node.release.value = stage.release;
|
||
const gain = gainNode(context, stage.gain);
|
||
node.connect(gain);
|
||
return {
|
||
input: node,
|
||
output: gain,
|
||
knobs: [
|
||
{ param: node.threshold, target: stage.threshold, even: 'value' },
|
||
{ param: node.knee, target: stage.knee, even: 'value' },
|
||
{ param: node.ratio, target: stage.ratio, even: 'ratio' },
|
||
{ param: node.attack, target: stage.attack, even: 'ratio' },
|
||
{ param: node.release, target: stage.release, even: 'ratio' },
|
||
{ param: gain.gain, target: stage.gain, even: 'ratio' },
|
||
],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A Tremolo or a Vibrato (#320) on the worklet, its LFO at phase 0 when its voice starts at `at`.
|
||
* A Vibrato's delay swings around the center its start values give, which is how late it is.
|
||
*/
|
||
function lfoNodes(context: BaseAudioContext, stage: FxLfo, at: number): StageNodes {
|
||
const options: LfoOptions & { vibrato: boolean } = {
|
||
shape: LFO_SHAPES.findIndex((option) => option.value === stage.shape),
|
||
startAt: at,
|
||
center: stage.kind === 'vibrato' ? stageLatency(stage, context.sampleRate) : 0,
|
||
vibrato: stage.kind === 'vibrato',
|
||
};
|
||
return workletNodes(
|
||
context,
|
||
'lfo',
|
||
[
|
||
{ name: 'rate', value: stage.rate, even: 'ratio' },
|
||
{ name: 'depth', value: stage.depth, even: 'value' },
|
||
],
|
||
undefined,
|
||
options,
|
||
);
|
||
}
|
||
|
||
/** Impulse responses made for a Reverb, by rate, decay and damping: the last few are kept. */
|
||
const impulses = new Map<string, AudioBuffer>();
|
||
const KEPT_IMPULSES = 16;
|
||
|
||
/** The impulse response a Reverb makes for itself (#328), made once for these numbers. */
|
||
function madeImpulse(context: BaseAudioContext, decay: number, damping: number): AudioBuffer {
|
||
const key = `${context.sampleRate}|${decay}|${damping}`;
|
||
let buffer = impulses.get(key);
|
||
if (!buffer) {
|
||
const channels = reverbImpulse(context.sampleRate, decay, damping);
|
||
buffer = context.createBuffer(channels.length, channels[0]!.length, context.sampleRate);
|
||
channels.forEach((data, c) => buffer!.copyToChannel(data as Float32Array<ArrayBuffer>, c));
|
||
impulses.set(key, buffer);
|
||
if (impulses.size > KEPT_IMPULSES) impulses.delete(impulses.keys().next().value!);
|
||
}
|
||
return buffer;
|
||
}
|
||
|
||
/**
|
||
* A Reverb (#328): the sound itself, and beside it through the pre-delay into a ConvolverNode with
|
||
* the impulse response made for it – or the one of a sound, `impulse`, where it takes a sound; a
|
||
* sound not loaded leaves the reverb silent. Only the mix moves while it plays.
|
||
*/
|
||
function reverbNodes(context: BaseAudioContext, stage: FxReverb, impulse: AudioBuffer | null): StageNodes {
|
||
const input = context.createGain();
|
||
const output = context.createGain();
|
||
const convolver = context.createConvolver();
|
||
const buffer = stage.impulse ? impulse : madeImpulse(context, stage.decay, stage.damping);
|
||
if (buffer) convolver.buffer = buffer;
|
||
const wet = gainNode(context, stage.mix);
|
||
const dry = gainNode(context, 1 - stage.mix);
|
||
heldBack(context, input, stage.predelay).connect(convolver).connect(wet).connect(output);
|
||
input.connect(dry).connect(output);
|
||
return {
|
||
input,
|
||
output,
|
||
knobs: [
|
||
{ param: null, target: stage.decay, even: 'value' },
|
||
{ param: null, target: stage.damping, even: 'value' },
|
||
{ param: null, target: stage.predelay, even: 'value' },
|
||
{ param: wet.gain, target: stage.mix, even: 'value' },
|
||
{ param: dry.gain, target: 1 - stage.mix, even: 'value' },
|
||
],
|
||
};
|
||
}
|
||
|
||
/** Below what a Saturation takes out the DC its bent sound gained (#323). */
|
||
const DC_BLOCK_HZ = 10;
|
||
|
||
/**
|
||
* A Saturation (#323): the sound driven into the curve of its type, oversampled 4x, its DC taken
|
||
* out, through the low-pass of its tone and set to its level – and the sound itself beside it,
|
||
* held back as far as the oversampling makes the bent one late, so the two meet without a comb.
|
||
*/
|
||
function saturateNodes(context: BaseAudioContext, stage: FxSaturate): StageNodes {
|
||
const input = context.createGain();
|
||
const output = context.createGain();
|
||
const drive = gainNode(context, driveGain(stage.drive));
|
||
const shaper = context.createWaveShaper();
|
||
shaper.curve = saturationCurve(stage.curve) as Float32Array<ArrayBuffer>;
|
||
shaper.oversample = '4x';
|
||
const dc = context.createBiquadFilter();
|
||
dc.type = 'highpass';
|
||
dc.frequency.value = DC_BLOCK_HZ;
|
||
dc.Q.value = passQ('highpass', FLAT_Q);
|
||
const tone = context.createBiquadFilter();
|
||
tone.type = 'lowpass';
|
||
tone.frequency.value = stage.tone;
|
||
tone.Q.value = passQ('lowpass', FLAT_Q);
|
||
const level = gainNode(context, stage.level);
|
||
const wet = gainNode(context, stage.mix);
|
||
const dry = gainNode(context, 1 - stage.mix);
|
||
input.connect(drive).connect(shaper).connect(dc).connect(tone).connect(level).connect(wet).connect(output);
|
||
heldBack(context, input, SHAPER_LATENCY_FRAMES / context.sampleRate)
|
||
.connect(dry)
|
||
.connect(output);
|
||
return {
|
||
input,
|
||
output,
|
||
knobs: [
|
||
{ param: drive.gain, target: driveGain(stage.drive), even: 'ratio' },
|
||
{ param: tone.frequency, target: stage.tone, even: 'ratio' },
|
||
{ param: level.gain, target: stage.level, even: 'ratio' },
|
||
{ param: wet.gain, target: stage.mix, even: 'value' },
|
||
{ param: dry.gain, target: 1 - stage.mix, even: 'value' },
|
||
],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* An echo (#327): the sound goes on, and through a delay line whose output is damped, fed back into
|
||
* it and added – each at its share of the wet `mix` (#354). Ping-pong sends the sound into the left
|
||
* of the line only and swaps the sides on every round.
|
||
*/
|
||
function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes {
|
||
const input = context.createGain();
|
||
const output = context.createGain();
|
||
const shares = delayShares(stage.mix);
|
||
const dry = gainNode(context, shares.dry);
|
||
input.connect(dry).connect(output);
|
||
const line = context.createDelay(DELAY_MAX);
|
||
line.delayTime.value = stage.time;
|
||
const damp = context.createBiquadFilter();
|
||
damp.type = 'lowpass';
|
||
damp.frequency.value = stage.damping;
|
||
damp.Q.value = passQ('lowpass', FLAT_Q);
|
||
const feedback = gainNode(context, stage.feedback);
|
||
const wet = gainNode(context, shares.echo);
|
||
line.connect(damp);
|
||
damp.connect(wet).connect(output);
|
||
if (stage.pingPong) {
|
||
const mono = context.createGain();
|
||
mono.channelCount = 1;
|
||
mono.channelCountMode = 'explicit';
|
||
const left = context.createChannelMerger(2);
|
||
input.connect(mono).connect(left, 0, 0);
|
||
left.connect(line);
|
||
const sides = context.createChannelSplitter(2);
|
||
const crossed = context.createChannelMerger(2);
|
||
damp.connect(sides);
|
||
sides.connect(crossed, 0, 1);
|
||
sides.connect(crossed, 1, 0);
|
||
crossed.connect(feedback).connect(line);
|
||
} else {
|
||
input.connect(line);
|
||
damp.connect(feedback).connect(line);
|
||
}
|
||
return {
|
||
input,
|
||
output,
|
||
knobs: [
|
||
{ param: line.delayTime, target: stage.time, even: 'value' },
|
||
{ param: feedback.gain, target: stage.feedback, even: 'value' },
|
||
{ param: damp.frequency, target: stage.damping, even: 'ratio' },
|
||
{ param: wet.gain, target: shares.echo, even: 'value' },
|
||
{ param: dry.gain, target: shares.dry, even: 'value' },
|
||
],
|
||
};
|
||
}
|
||
|
||
/** 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 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 workletNodes(
|
||
context: BaseAudioContext,
|
||
processor: string,
|
||
numbers: readonly WorkletNumber[],
|
||
follow?: Follow,
|
||
processorOptions?: object,
|
||
): StageNodes {
|
||
let node: AudioWorkletNode;
|
||
try {
|
||
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 knobs = numbers.map((n): Knob => ({ param: null, target: n.value, even: n.even }));
|
||
return { input: through, output: through, knobs };
|
||
}
|
||
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, stop: () => stopProcessor(node) };
|
||
}
|
||
|
||
/**
|
||
* Stops a processor and lets go of its port. A port that is listened to is never collected, and
|
||
* with it everything its listener reaches: the voice and every node of it, still on its bus.
|
||
*/
|
||
export function stopProcessor(node: AudioWorkletNode): void {
|
||
node.port.postMessage('stop');
|
||
node.port.onmessage = null;
|
||
node.port.close();
|
||
node.disconnect();
|
||
}
|
||
|
||
/** Calls `then` once a source on the worklet has played to its end, and lets go of its port. */
|
||
export function endedProcessor(node: AudioWorkletNode, then: () => void): void {
|
||
node.port.onmessage = (event: MessageEvent) => {
|
||
if (event.data !== 'ended') return;
|
||
node.port.onmessage = null;
|
||
node.port.close();
|
||
then();
|
||
};
|
||
}
|
||
|
||
/** 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]);
|
||
|
||
/**
|
||
* The shares of a Mix with the curve "Both at center": a and b each stay full up to the middle and
|
||
* fade on the other half. A share is not a straight line of `mix` there, so two ramps of their own
|
||
* would dip at the middle when `mix` glides across it. The source carries `mix` itself, and each
|
||
* share is shaped from it on the audio thread. Its caller starts it and stops it with the voice.
|
||
* Exported for a check that renders it offline in a real browser (test hook).
|
||
*/
|
||
export function bothAtCenter(
|
||
context: BaseAudioContext,
|
||
mix: number,
|
||
a: GainNode,
|
||
b: GainNode,
|
||
): ConstantSourceNode {
|
||
const driver = context.createConstantSource();
|
||
driver.offset.value = mix;
|
||
for (const [curve, share] of [
|
||
[SHARE_A, a],
|
||
[SHARE_B, b],
|
||
] as const) {
|
||
const shape = context.createWaveShaper();
|
||
shape.curve = curve;
|
||
driver.connect(shape).connect(share.gain);
|
||
}
|
||
return driver;
|
||
}
|
||
|
||
/** A node's sound held back `seconds` by a delay line after it, or the node itself for none. */
|
||
export function heldBack(context: BaseAudioContext, node: AudioNode, seconds: number): AudioNode {
|
||
if (!(seconds > 1e-9)) return node;
|
||
const line = context.createDelay(seconds);
|
||
line.delayTime.value = seconds;
|
||
return node.connect(line);
|
||
}
|
||
|
||
/** Sends a level to silence in `seconds` from wherever it is now. */
|
||
export function fadeOut(param: AudioParam, now: number, seconds = FADE): void {
|
||
param.cancelScheduledValues(now);
|
||
param.setValueAtTime(param.value, now);
|
||
param.linearRampToValueAtTime(0, now + seconds);
|
||
}
|
||
|
||
/**
|
||
* Sends one knob from where it is right now to `to`. Whatever was still planned for it is dropped
|
||
* first, so a change never queues behind an older one. A timed change runs along `ease` – a `ratio`
|
||
* one in its logarithm, which is what decibels are. Linear and step are the clock's
|
||
* own ramps, every other easing is handed over as a curve of samples.
|
||
*/
|
||
export function move(
|
||
param: AudioParam,
|
||
to: number,
|
||
now: number,
|
||
time: number,
|
||
ratio: boolean,
|
||
ease: EaseShare = 'linear',
|
||
): void {
|
||
const from = param.value;
|
||
// Firefox has no cancelAndHoldAtTime: there the value it reads out now is held by hand.
|
||
const held = param as Omit<AudioParam, 'cancelAndHoldAtTime'> &
|
||
Partial<Pick<AudioParam, 'cancelAndHoldAtTime'>>;
|
||
if (held.cancelAndHoldAtTime) held.cancelAndHoldAtTime(now);
|
||
else {
|
||
param.cancelScheduledValues(now);
|
||
param.setValueAtTime(from, now);
|
||
}
|
||
const logarithmic = ratio && from > 0 && to > 0;
|
||
if (time <= 0) param.setTargetAtTime(to, now, GLIDE);
|
||
else if (ease === 'step') param.setValueAtTime(to, now + time);
|
||
else if (ease === 'linear' && logarithmic) param.exponentialRampToValueAtTime(to, now + time);
|
||
else if (ease === 'linear') param.linearRampToValueAtTime(to, now + time);
|
||
else param.setValueCurveAtTime(easedCurve(from, to, time, logarithmic, ease), now, time);
|
||
}
|
||
|
||
/** Sends each knob to its level, as `glide` says; whether any of them had somewhere to go. */
|
||
export function glideKnobs(
|
||
knobs: readonly Knob[],
|
||
levels: readonly number[],
|
||
now: number,
|
||
time: number,
|
||
ease: EaseShare,
|
||
): boolean {
|
||
let moved = false;
|
||
knobs.forEach((knob, i) => {
|
||
const level = levels[i];
|
||
if (level === undefined || level === knob.target) return;
|
||
knob.target = level;
|
||
moved = true;
|
||
if (knob.param) move(knob.param, level, now, time, knob.even === 'ratio', ease);
|
||
});
|
||
return moved;
|
||
}
|
||
|
||
/** Samples per second of an eased curve: a bounce keeps its corners, a long sweep stays small. */
|
||
const CURVE_RATE = 200;
|
||
|
||
function easedCurve(
|
||
from: number,
|
||
to: number,
|
||
time: number,
|
||
logarithmic: boolean,
|
||
share: (t: number) => number,
|
||
): Float32Array {
|
||
const count = Math.min(Math.max(Math.ceil(time * CURVE_RATE) + 1, 16), 4096);
|
||
const curve = new Float32Array(count);
|
||
for (let i = 0; i < count; i++) {
|
||
const s = share(i / (count - 1));
|
||
curve[i] = logarithmic ? from * (to / from) ** s : from + (to - from) * s;
|
||
}
|
||
return curve;
|
||
}
|
||
|
||
/** The numbers of a chain in the order `wire` makes their knobs: a stage's `gain` is what its node holds. */
|
||
export function levelsOf(chain: FxChain, into: number[] = []): number[] {
|
||
if ('mix' in chain && chain.curve === 'both') {
|
||
levelsOf(chain.a, into);
|
||
levelsOf(chain.b, into);
|
||
into.push(chain.mix);
|
||
} else if ('mix' in chain) {
|
||
levelsOf(chain.a, into);
|
||
into.push(1 - chain.mix);
|
||
levelsOf(chain.b, into);
|
||
into.push(chain.mix);
|
||
} else {
|
||
if (chain.shifted) into.push(chain.pitch);
|
||
if (chain.chip) into.push(chain.chip.frequency, chain.chip.duty);
|
||
if (chain.acid) {
|
||
const { cutoff, resonance, envMod } = chain.acid;
|
||
into.push(cutoff, resonance, envMod);
|
||
}
|
||
}
|
||
for (const stage of chain.stages) stageLevels(stage, into);
|
||
return into;
|
||
}
|
||
|
||
function stageLevels(stage: FxStage, into: number[]): void {
|
||
if (stage.kind === 'delay') {
|
||
const shares = delayShares(stage.mix);
|
||
into.push(stage.time, stage.feedback, stage.damping, shares.echo, shares.dry);
|
||
} else if (stage.kind === 'wet') {
|
||
for (const inner of stage.stages) stageLevels(inner, into);
|
||
into.push(stage.wet, 1 - stage.wet);
|
||
} else if (stage.kind === 'pass') into.push(stage.cutoff, stage.q, stage.gain);
|
||
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 === 'pan') into.push(stage.pan);
|
||
else if (stage.kind === 'compress')
|
||
into.push(stage.threshold, stage.knee, stage.ratio, stage.attack, stage.release, stage.gain);
|
||
else if (stage.kind === 'limit') into.push(stage.threshold, stage.ceiling, stage.release, stage.autoGain);
|
||
else if (stage.kind === 'tremolo' || stage.kind === 'vibrato') into.push(stage.rate, stage.depth);
|
||
else if (stage.kind === 'reverb')
|
||
into.push(stage.decay, stage.damping, stage.predelay, stage.mix, 1 - stage.mix);
|
||
else if (stage.kind === 'saturate')
|
||
into.push(driveGain(stage.drive), stage.tone, stage.level, stage.mix, 1 - stage.mix);
|
||
else if (stage.kind !== 'envelope' && stage.kind !== 'tap') into.push(stage.gain);
|
||
}
|