Files
feuerwerk/src/viewport/fxNodes.ts
T
adminandClaude Opus 5.5 f92d69d902
game-maker / gate (push) Successful in 4m23s
game-maker / suite (push) Failing after 18m20s
Rebuild the Acid node after the acid voice of nodeseq (#421)
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>
2026-10-13 12:39:01 +02:00

739 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}