/** * 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 & Partial>; 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(); 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, 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; 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 & Partial>; 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); }