Refactoring round 10: split SoundLibrary, fold sound duplicates (#85)
game-maker / gate (push) Successful in 4m3s
game-maker / suite (push) Successful in 14m52s

Refactoring round 10 covers everything since round 9 (808e1ee): 110
commits with about 47 000 new lines, nearly all of them sound, sound
FX, instruments, patterns and songs. Behaviour is unchanged.

Duplicate code (jscpd over src, 50 tokens, TypeScript only, measured
on 808e1ee and here alike: 47 clones / 426 lines before, 33 / 245
after; round 9 itself ended at 22 / 160 by this count):

- PreviewParams (new, editor/soundfx) is the header's parameter bar
  of the Sound FX and the Instrument tab: values, defaults, fields
  and the voices a moved field reaches. Both tabs had it line for
  line.
- fxWorklet: an Effect base runs the bit crusher, phaser, flanger,
  limiter, LFO and bus shifter, a Source base the pitch player and
  the Chip. Start is the one wait for a voice's first sample, which
  four processors wrote out each.
- nodes/shared has checkPort, detachedPort, fxParamsPort and
  letRingPort. The Play and Stop nodes of sounds, notes, patterns and
  songs spelled each port out, up to five times.
- parameterValues builds both Sound FX parameters and Instrument
  parameters, which differed in name and text only.
- SetLoopCommand sets the ruler loop of a pattern or a song. It
  replaces SetPatternLoopCommand and SetSongLoopCommand.
- addLink in music/routing is the one rule for a link that both the
  song routing and the Master routing keep.
- eqCurves works out each band and their sum for the EQ tab, which
  did that in its drawing code. It replaces eqCurveDb, which only a
  test called.
- Smaller ones: the eight neighbour directions of a tile cell, and
  drawFloatingBox for the shadowed box of Popup and FloatWindow.
- Left on purpose: Play pattern and Play song stay two parallel node
  definitions, and the smaller clones under ten lines stay.

Architecture:

- SoundLibrary.ts goes from 2897 to 2188 lines. What a stage of a
  sound FX becomes as Web Audio nodes, and how the knobs of a playing
  voice move, is now viewport/fxNodes.ts. None of it keeps state of
  the library.

Dead code (TS language service, references per export and member):

- soundRefIds, LFO_SHAPE_INDEX, TREMOLO_ROWS, VIBRATO_ROWS and
  REVERB_ROWS had no reader. The four routing node defs are
  registered without an unused exported name.
- soundFileOf and portTypeOf were called by tests only. The tests
  now ask findSound and portOf, which the editor uses. DRY moves
  from src into the one test that uses it.
- 51 values used only in their own file lose their `export`. Types
  and the node catalogues stay exported.
- Kept on purpose: neighboursOf is the cell by cell reading the
  indexed neighboursIn is checked against, and PitchShifter.latency
  is the test's one view of how late the shifter is.

Comments and docs:

- Seven comments told history ("no longer", "any more", "as
  before", "of before #354"). Nine comment blocks ran far past the
  line width. Proof that this changed no code: the bundle was md5
  identical, and the touched tests and e2e modules were identical
  after esbuild.
- README: sound FX, instrument, pattern, song and bus in the asset
  list, the pages Instruments, Patterns and Songs, 1919 checks.
- Wiki: see its own commit.

Tests:

  Gate (melektaus-atx)    274 files, 4236 tests, CHECK EXIT 0
  e2e 34, 64, 65          ALL PASSED, 180 checks
  Unit, touched areas     16 files, 804 tests

No check was added, the e2e sum stays at 1919. Counter-checks, each
put back by copy and compared with cmp:

- addLink without the loop rule: 2 tests fail, one per routing.
- eqCurves with the sum of the last band only: 1 test fails.
- Start in the worklet never waiting: e2e 34 fails in exactly the
  four checks that hang on a voice's first sample (Flanger, Tremolo,
  Chip, a pitched note planned ahead).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 15:31:33 +02:00
1 parent 76f5097a38
commit 25084f8576
72 files changed
+1369 -1458

No files matched your search

+24 -733
View File
@@ -1,36 +1,20 @@
import {
DELAY_MAX,
DRY_VOICE,
FLAT_Q,
passQ,
fxChainOf,
fxLatencyOf,
type FxChain,
type FxChip,
type FxCompress,
type FxCrush,
type FxDelay,
type FxEnvelope,
type FxFlanger,
type FxLfo,
type FxMod,
type FxPass,
type FxPhaser,
type FxReverb,
type FxSaturate,
fxRings,
fxTailOf,
impulsesOf,
type FxParams,
type FxPlan,
type FxStage,
type FxStagePlan,
type FxVoice,
type FxWet,
delayShares,
stageLatency,
} from '../core/soundfx/nodes';
import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope';
import { releaseTime } from '../core/instrument/envelope';
import type { EaseShare } from '../core/model/animation';
import { type BusLevel, Playbacks, type SequenceNeeds, type SequencePlay } from '../core/music/playbacks';
import { arrivalOf, sendDelays, type TimedBus } from '../core/music/routing';
@@ -42,29 +26,41 @@ import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
import { DEFAULT_TEMPO } from '../core/music/time';
import { type CutRange, cutChannels, cutRange, parseCutName } from '../core/model/sound';
import { shifterLatency } from '../core/soundfx/pitchPlayer';
import { LFO_SHAPES } from '../core/soundfx/lfo';
import { REVERB_MAX_DECAY, reverbImpulse } from '../core/soundfx/reverb';
import { driveGain, saturationCurve, SHAPER_LATENCY_FRAMES } from '../core/soundfx/saturation';
import { REVERB_MAX_DECAY } from '../core/soundfx/reverb';
import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio';
import type { ChipOptions, FlangerOptions, LfoOptions, PhaserOptions, PitchOptions } from './fxWorklet';
import type { ChipOptions, PitchOptions } from './fxWorklet';
import fxWorkletUrl from './fxWorklet.ts?worker&url';
import {
bothAtCenter,
endedProcessor,
envelopeDriver,
FADE,
type Follow,
followCurve,
fadeOut,
gainNode,
GLIDE,
glideKnobs,
heldBack,
type Knob,
levelsOf,
move,
stageNodes,
releaseEnvelope,
stopProcessor,
} from './fxNodes';
import { wake } from './wake';
/** A voice is no louder than a sound item of a song turned all the way up. */
const MAX_VOLUME = dbToGain(SOUND_ITEM_MAX_DB);
const DEFAULT_BASE = '/game-maker/data/sounds';
/** Seconds a changed parameter takes to get most of the way there: quick, but no click (#315). */
const GLIDE = 0.03;
/** The rate a sound loaded ahead of its first play is decoded at; a source resamples it on the way out. */
const DECODE_RATE = 48000;
/** The longest a send of a track is held back to meet the latest way of it (#350). */
const MAX_SEND_DELAY = 1;
/** Seconds a stopped voice takes to fall silent, so it does not click (#324). */
const FADE = 0.01;
/** Samples a level meter looks back over (#359): a frame of 40 ms at 48 kHz misses no peak. */
const METER_SPAN = 2048;
/** The loudest the limiter at the output lets a sample be (#359), and how fast it lets go again. */
@@ -81,19 +77,6 @@ const PEAK_BUCKETS = 1024;
/** Samples the cuts made so far may hold together before the oldest are let go; one is always kept. */
const CUT_BUDGET = 64 * 1024 * 1024;
/**
* 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.
*/
interface Knob {
param: AudioParam | null;
target: number;
even: 'value' | 'ratio';
}
/**
* One way's sound as a voice holds it: a buffer source, or the pitch worklet where the way has a
* Pitch (#317). A voice that can ring on gives each of its sources a `fade` of its own, so they can
@@ -591,8 +574,8 @@ export class SoundLibrary implements AudioSink {
/**
* A bus by its name, made on its first use into `into`, or into the master; only the master is
* heard straight out (#325). It is not to go any more. The context comes first: the one made now
* sets up the mix, whose bus this may be, and that one is to be kept (#369).
* heard straight out (#325). One that was being let go stays. The context comes first: the one
* made now sets up the mix, whose bus this may be, and that one is to be kept (#369).
*/
private bus(name: string, into: string | null = null): Bus {
const context = this.audio();
@@ -2191,468 +2174,6 @@ function loopIn(buffer: AudioBuffer, start: number, end: number): { loopStart: n
return from < to ? { loopStart: from, loopEnd: to } : { loopStart: 0, loopEnd: last };
}
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. */
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 === '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' },
]);
if (stage.kind === 'pan') {
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' }],
};
}
const filter = context.createBiquadFilter();
filter.type = stage.filter;
filter.frequency.value = stage.frequency;
filter.gain.value = stage.mod ? 0 : stage.gain;
filter.Q.value = stage.q;
if (stage.mod) follow?.(stage.mod, filter.gain, false);
return {
input: filter,
output: filter,
knobs: [{ param: stage.mod ? null : filter.gain, target: stage.gain, 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) };
}
/** The loudest sample of each side over the stretch a meter looks back over, 1 at full scale. */
function readMeter(meter: BusMeter): [number, number] {
const [left, right] = meter.sides.map((side) => {
@@ -2664,234 +2185,4 @@ function readMeter(meter: BusMeter): [number, number] {
return [left!, right!];
}
/**
* 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.
*/
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. */
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. */
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 `FADE` seconds from wherever it is now. */
function fadeOut(param: AudioParam, now: number): void {
param.cancelScheduledValues(now);
param.setValueAtTime(param.value, now);
param.linearRampToValueAtTime(0, now + FADE);
}
/**
* 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.
*/
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. */
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. */
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') into.push(stage.gain);
}
export const soundLibrary = new SoundLibrary();