Files
feuerwerk/src/viewport/fxNodes.ts
T
adminandClaude Opus 5.5 8c53ce0cbe
game-maker / gate (push) Successful in 4m13s
game-maker / suite (push) Successful in 16m21s
Replace the 5-Band-EQ with the Parametric EQ and remove it (#395)
The Parametric EQ does all the 5-Band-EQ did and more, so the editor
keeps only one EQ. The old node was used in one project only, with
nothing linked to its band inputs.

- "Garden of Sacrifices" was converted once on the project file, not
  by a load migration (#137). Its 5 nodes are Parametric EQs now with
  the same filters: low shelf 80 Hz, peaks at 250 Hz, 1 kHz and 4 kHz
  with Q 1, high shelf 12 kHz. A band at 0 became no band.
- Two values below -24 dB were held to -24, as the Parametric EQ goes
  no further. Both are in the asset "Delay" (high -60, low -36). The
  user will rework them by ear.
- The write went through the API's own Store (lock, revision 20181 to
  20182), so a tab with the old revision gets a 409 instead of
  overwriting it.
- Removed with the node: its stage kind "filter" and the type
  FxFilter, which nothing else made, and the player's branch for it.
  A Pan is now the last branch of fxNodes, a Flanger that of stagesOf.
- Tests that took the 5-Band-EQ as "some effect" use the Pan or the
  Parametric EQ now. The test for a parameter feeding a band now
  feeds the cutoff of a Filter, which had no such test yet.
- A project that still names the type, such as an old snapshot of
  "Garden of Sacrifices", loads without those nodes. Unknown types
  were always left out on load.

Tests:
- Unit (stein2): soundfx 67, instrument 36, envelope 13, sound-library
  127, all green
- Counter-check: with the cutoff not taken from the parameter, only
  the new Filter test goes red
- The converted project loads and compiles: Delay, Vocals and Drums
  play their bands (3+1, 4 and 5)
- e2e 34 (atx): ALL PASSED, 136 checks, the palette check now looks
  for the Parametric EQ
- Gate (atx): CHECK EXIT 0, 288 files, 4417 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:46:03 +02:00

735 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);
}
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);
}