Files
feuerwerk/src/viewport/SoundLibrary.ts
T
adminandClaude Opus 5.5 daeffe0f9d
game-maker / gate (push) Successful in 3m47s
game-maker / suite (push) Failing after 14m20s
Route the tracks of a song through a graph (#350)
Every track of a song went straight into its output. Now the Song tab
has a second view, Routing: a graph with a node per track and one
Output, the links between them sends. A track can go into other
tracks, into several at once, and a link has its own volume and the
point of the track it taps.

- The song asset has a graph like an instrument (SongTrack, SongOutput).
  heldSongGraph keeps it in step with the tracks on load and in
  SetSongCommand: a node per track named after it, one output, a new
  track and every track of an old song linked to the output. Links of
  gone tracks, doubled links and links closing a loop are dropped.
- A link carries an optional send (volume in dB, tap). The inspector
  shows Volume and Mode for a picked link of a song: Post volume (Post
  pan), Pre volume (Post FX), Pre volume (Pre FX). SetSendCommand.
- NodeDef has two new flags. `fixed` nodes are never added or deleted
  by hand. Links between `acyclic` nodes may not close a loop, checked
  in canConnect, dropTargetPort and ConnectCommand.
- songBuses gives each track its sends instead of one `into`, and a
  bus `song:<song>:<track>:#items` for its own items. That bus is what
  solo closes on a track that only passes a soloed one on.
- Mute and solo now act on the sends (trackGates). A muted track passes
  nothing on, on any tap. A soloed track is heard with what goes into
  it, through the tracks it goes through, their own items silent.
  Only sends on a way through a soloed track stay open, so a track
  going into it and into the output is heard through it only.
- SoundLibrary: a bus has either `into` or sends. Each send is tap,
  delay, level, target. busLatency takes the latest way (arrivalOf),
  and each send is held back so all ways of a track arrive together
  (sendDelays). Taps are wired again whenever a chain comes or goes.
- The Song tab has Timeline and Routing in its header. The routing
  uses GraphCanvas with an empty palette. A track linked nowhere shows
  "Goes nowhere". Delete takes links, never a track node.
- Game playings route the same way, the output being the master of
  the playing.

Decided without asking: a send ranges -60 to +12 dB like a track,
default 0 dB Post. Mute closes the sends, the track's own level stays.

Found on the way: the inspector built the Mode row with the send as it
was when the rows were made. Setting Volume and then Mode wrote the old
volume back. Both rows now read the current send when they change.

Tests:
- Unit song-routing (new): 13 green. Sound library: 4 new, 94 green.
  11 counter-probes (loop on load, canConnect, solo, solo past the
  soloed track, mute, preFx latency, graph sync, delete guard, delays,
  taps, glide), each red.
- Existing tests adjusted to the new bus names and send shape: song,
  bus-fx, song-play, graph registry, edge description.
- e2e 65 on melektaus-atx: ALL PASSED, 43 checks (5 new). The first
  run caught the inspector bug. Counter-probe with the old closure:
  2 FAILED.
- Gate on melektaus-atx: typecheck, lint, format clean, 2 tests red
  (registry rule, edge shape), fixed and rerun locally: 56 green.
- Not rerun after the per-send solo fix: e2e 65 and the gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:18:09 +02:00

2398 lines
90 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.
import {
DELAY_MAX,
DRY_VOICE,
FLAT_Q,
fxChainOf,
type FxChain,
type FxChip,
type FxCrush,
type FxDelay,
type FxEnvelope,
type FxFlanger,
type FxMod,
type FxPass,
type FxPhaser,
fxRings,
fxTailOf,
type FxParams,
type FxPlan,
type FxStage,
type FxStagePlan,
type FxVoice,
} from '../core/soundfx/nodes';
import { envelopeLevel, modCurve, releaseTime, SETTLE } 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';
import type { SendTap } from '../core/graph/Graph';
import { MASTER_MIX, type MixBus, sameMix } from '../core/music/mixer';
import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler';
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 type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio';
import type { ChipOptions, FlangerOptions, PhaserOptions, PitchOptions } from './fxWorklet';
import fxWorkletUrl from './fxWorklet.ts?worker&url';
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;
/** How finely `peaks` cuts a sound up. */
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
* fall silent without a click while the echoes carry on (#324).
*/
interface Source {
/**
* At that second of the audio clock (#332), `offset` seconds into the sound (#334); a worklet was
* told both when it was made.
*/
start(at: number, offset: number): void;
stop(): void;
ended(then: () => void): void;
fade: GainNode | null;
/** Ends with the note (#331): a release fades it out, where everything else plays on. */
gate: boolean;
/** Plays on past its loop to the end of the sound; null where a release leaves it as it is. */
leave: (() => void) | null;
}
/** Something of a voice that is started with it and stopped when it goes. */
interface Driver {
start(): void;
stop(): void;
}
/** Whether a note's end leaves a source as it is: a one-shot, which plays to the end of its sound. */
const playsThrough = (source: Source) => !source.gate && !source.leave;
/**
* One Envelope of a note (#335): what carries its curve on the audio thread, and the sources whose
* volume it is – they end with its release. One that only moves numbers has none.
*/
interface EnvelopeRun {
envelope: FxEnvelope;
driver: ConstantSourceNode;
sources: Source[];
released: boolean;
}
/**
* One sound in flight: its sources, its own gain, the file it came from and who is making it. A
* voice has a source per way through its sound FX (#316), started together and as long as each
* other. A note of an instrument (#331) is one voice too, its layers side by side, each way with a
* file of its own. Its knobs are in the order `levelsOf` lists the numbers of its chains.
*/
interface Voice {
/** What `play` returned for it, the handle a `tune` reaches it by. */
id: number;
/** The file it plays, or for a note what `playNote` was given to name its instrument by. */
file: string;
/** The behavior this voice belongs to; `''` for one nothing owns (see `AudioSink.play`). */
owner: string;
/** The bus it is heard on, `''` straight out. */
bus: string;
/** When it started on the audio clock, what its Envelopes count from. */
at: number;
sources: Source[];
/** Sources already set to end, by a release or a stop. */
ending: Set<Source>;
/**
* What carries a number of it on the audio thread (a Mix "Both at center", an Envelope) or runs
* there until told otherwise (a Bitcrusher): stopped when it goes.
*/
drivers: Driver[];
envelopes: EnvelopeRun[];
/** Calls the end of one source in, once however it comes: by itself or by a release. */
finish: ((source: Source) => void) | null;
gain: GainNode;
fx: Layered;
knobs: Knob[];
/** Whether its FX can ring on after the sources end (#324), and whether that is all it does now. */
rings: boolean;
ringing: boolean;
/** Calls off the clock that ends its ringing. */
quiet: (() => void) | null;
}
/**
* The plans of a voice and the values of their parameters: one plan for a sound played through its
* FX, one per layer for a note (#331), and the tempo a Delay synced to beats counts by (#332).
*/
interface Layered {
layers: readonly FxPlan[];
params: FxParams;
tempo: number;
}
/** One way of a chain: what a source plays. */
type Way = Exclude<FxChain, { mix: number }>;
/**
* What one voice is wired from: its buffers by file, the file and loop a way without a source of
* its own plays (the sound a `play` was given), and what has been made for it so far.
*/
interface Wiring {
context: AudioContext;
/** When its sources start on the audio clock. */
at: number;
buffers: ReadonlyMap<string, AudioBuffer>;
file: string;
loop: boolean;
rings: boolean;
/** Seconds into the sound its ways start, and whether it is a clip of a song, which fades out at its end (#334). */
offset: number;
clip: boolean;
sources: Source[];
drivers: Driver[];
envelopes: Map<string, EnvelopeRun>;
pitches: number[];
knobs: Knob[];
stages: number;
}
/** Everything a voice is started with, the same whether it waits on its bytes first or not. */
interface Start {
id: number;
file: string;
/** The files its ways read, every one of them loaded before it starts. */
files: readonly string[];
volume: number;
loop: boolean;
owner: string;
fx: Layered;
/** Seconds until a note is released (#331); 0 holds it until it is released or stopped. */
duration: number;
/** When it starts on the audio clock (#332); one already past starts now. */
at: number;
/**
* A clip of a song (#334): seconds into the sound it starts, and the second of the audio clock it
* fades out at.
*/
clip?: { offset: number; end: number };
/** The track of a song it is heard on (#334), `''` straight out. */
bus?: string;
}
/**
* A play still waiting on its bytes: what a quick `tune` in the meantime writes into, and the timed
* ones, which have to start with the voice rather than run out before it is heard.
*/
interface Pending {
id: number;
bus: string;
fx: Layered;
timed: { params: FxParams; time: number; ease: EaseShare }[];
/** Stopped by its number before it started. */
stopped: boolean;
/** A note released before it started: it starts and is released at once, so a one-shot is heard. */
released: boolean;
}
/**
* Plays uploaded sounds, keyed by file name – the texture library's twin on the audio side. Files
* are content addressed, so a decoded buffer can never go stale and nothing is evicted.
*
* Everything here is asynchronous on the way in and synchronous on the way out: `play` returns at
* once and starts the voice when the bytes have been fetched and decoded. A sound fired in the
* first frame of a run is therefore heard a moment later rather than not at all, and a graph never
* waits on the network.
*
* The context is created on the first play, not at start-up: a browser refuses one before the page
* has been interacted with, and an editor that was only ever looked at should not be holding an
* audio device open. What `preload` fetches before that is decoded by an offline context, which
* holds no device.
*
* A way with a Pitch is played by a worklet (#317), loaded into the context with the first play that
* needs it; that play waits for it as it would for its bytes.
*/
export class SoundLibrary implements AudioSink {
private base = DEFAULT_BASE;
private context: AudioContext | null = null;
private offline: OfflineAudioContext | null = null;
private readonly buffers = new Map<string, AudioBuffer>();
/**
* The stretches of files that sound assets are cut to (#343), made from the whole file on first
* play, oldest first. Unlike a file they can be made again at any time, so they are let go past a
* budget: a cut dragged about in its tab would otherwise leave a copy of the sound per try.
*/
private readonly cuts = new Map<string, AudioBuffer>();
/** The pitch worklet being loaded, and whether it is there once it is known; null before that. */
private workletLoading: Promise<boolean> | null = null;
private hasWorklet: boolean | null = null;
/** The files whose samples the worklet holds already, so the next voice of one is sent without. */
private readonly sent = new Set<string>();
private readonly loading = new Map<string, Promise<AudioBuffer | null>>();
/** Never retried, so one broken reference cannot spam the network. */
private readonly failed = new Set<string>();
private voices: Voice[] = [];
private pending: Pending[] = [];
/** The number the next play is given; 0 stays free for "nothing plays". */
private nextId = 1;
private started = 0;
private stopped = 0;
private tuned = 0;
/** Second of the audio clock the last change moved a playing voice: a timed one runs from there. */
private tunedAt: number | null = null;
/** The last voice actually started, with the values it was started at – what a check reads back. */
private last: LastVoice | null = null;
/**
* How often a stop has reached each file, and how often one has silenced everything. Counters
* rather than a "cancelled" flag: what a play has to know is whether a stop happened *after it
* was asked for*, and a flag cannot tell that from one that happened long before – which would
* make the first play of a sound disappear whenever anything had ever stopped it.
*/
private readonly stops = new Map<string, number>();
private allStops = 0;
/** The same counter per owner, for the same reason – a behavior destroyed while bytes were in
* flight must not be given its voice afterwards. */
private readonly ownerStops = new Map<string, number>();
/** The tracks of the songs played so far (#334), by name. */
private readonly buses = new Map<string, Bus>();
private readonly peaked = new Map<string, Float32Array>();
/**
* The patterns and songs a game plays, each on a scheduler of its own. What it asks after are its
* own voices – asking `playing`, which counts the playings too, would ask the list it is in.
*/
private readonly sequences = new Playbacks({
now: () => this.time(),
latency: () => this.latency(),
playing: (id) => this.sounding(id),
stop: (voices, letRing) => {
for (const id of voices) this.stopVoice(id, letRing);
},
wake,
prepare: (needs, id) => this.ready(needs, id),
ahead: (id) => this.groupAhead(id),
level: (id, gain, at, seconds) => this.level(id, gain, at, seconds),
after: (at, then) => void this.after(at, then),
closed: () => this.sweep(),
});
/** The playings whose buses are still there: a master each, and the tracks of a song into it. */
private readonly groups = new Set<number>();
/** Held by the pause of the toolbar: nothing that reads the clock may wake it then. */
private held = false;
/** Counts the times buses were let go, so a later use of one can keep it. */
private leavings = 0;
/**
* The master and the buses of the project (#325) as last set, made when the context is: setting
* them must not open an audio device for an editor nobody has listened to yet.
*/
private mix: readonly MixBus[] = [];
/** The levels a running game set its buses to, over those of their assets; until `resetMix`. */
private readonly mixVolumes = new Map<string, number>();
/** Where the files are served from. Set once at start-up, before anything plays. */
configure(base: string): void {
this.base = base.replace(/\/+$/, '');
}
/**
* The number is handed out at once, before the bytes are there: a graph can tune a voice in the
* same frame it asked for it, and the voice then starts with those values.
*/
play(file: string, volume: number, loop: boolean, owner = '', fx: FxVoice = DRY_VOICE, bus = ''): number {
if (file === '' || this.hasFailed(file)) return 0;
const layered = { layers: [fx.plan], params: fx.params, tempo: fx.tempo ?? DEFAULT_TEMPO };
return this.begin({
id: 0,
file,
files: [file],
volume,
loop,
owner,
fx: layered,
duration: 0,
at: 0,
bus,
});
}
/**
* One note of an instrument (#331): its layers played side by side as one voice, each way with its
* own file. `name` stands for the instrument where a file would stand – what `levels` finds it by.
* A `duration` above 0 releases it that many seconds after it starts. `timing.at` starts it at that
* second of the audio clock, as the scheduler plans a note (#332).
*/
playNote(
name: string,
layers: readonly FxPlan[],
params: FxParams,
volume: number,
owner = '',
duration = 0,
timing: NoteTiming = {},
): number {
const files = [...new Set(layers.flatMap(filesOf))].filter(
(file) => file !== '' && !this.hasFailed(file),
);
if (files.length === 0 && !layers.some(hasChip)) return 0;
const fx = { layers, params, tempo: timing.tempo ?? DEFAULT_TEMPO };
const at = timing.at ?? 0;
const bus = timing.bus ?? '';
return this.begin({ id: 0, file: name, files, volume, loop: false, owner, fx, duration, at, bus });
}
/**
* A sound as a clip of a song (#334): from `at` on the audio clock, `offset` seconds into it, round
* and round until `duration` seconds have passed, then faded out – its echoes ring on. One that
* gets its bytes late starts as far into the sound as it would be by then.
*/
playClip(file: string, clip: ClipTiming, fx: FxVoice = DRY_VOICE): number {
if (file === '' || this.hasFailed(file)) return 0;
const layered = { layers: [fx.plan], params: fx.params, tempo: fx.tempo ?? DEFAULT_TEMPO };
const { at, offset, duration, volume, bus } = clip;
return this.begin({
id: 0,
file,
files: [file],
volume,
loop: true,
owner: '',
fx: layered,
duration: 0,
at,
clip: { offset, end: at + Math.max(0, duration) },
bus,
});
}
/**
* Sets the level and the pan of a track of a song (#334) in a quick glide, so a change reaches
* what is sounding on it already. A bus is made on its first use and kept.
*/
setBus(name: string, gain: number, pan: number): void {
const bus = this.bus(name);
const now = this.audio().currentTime;
bus.set = { gain: Math.max(0, gain), pan: Math.max(-1, Math.min(1, pan)) };
bus.gain.gain.setTargetAtTime(Math.max(0, gain), now, GLIDE / 3);
bus.pan.pan.setTargetAtTime(Math.max(-1, Math.min(1, pan)), now, GLIDE / 3);
}
/**
* The tracks of a song in the Song tab (#334), each set as `setBus` sets one, with the bus it goes
* into and its sound FX (#346). A chain is made again only where its FX has changed. Done once the
* chains are there – the worklet some of them need has to be loaded first.
*/
async setBuses(levels: readonly BusLevel[]): Promise<void> {
const waits: Promise<void>[] = [];
for (const { name, gain, pan, into, sends, fx } of levels) {
if (into !== undefined) this.bus(into);
const bus = this.bus(name, into ?? null);
if (sends)
this.setSends(
bus,
sends.map((send) => ({ ...send, to: send.to ?? MASTER_MIX })),
);
else this.route(name, bus, into ?? null);
this.setBus(name, gain, pan);
waits.push(this.setFx(bus, fx ?? null));
}
await Promise.all(waits);
}
/**
* Lets these buses go once what is on them now has faded (#346): their chains would otherwise run
* on for nothing. One used again before then stays.
*/
releaseBuses(names: readonly string[]): void {
const going = names.flatMap((name) => this.buses.get(name) ?? []);
if (going.length === 0) return;
const token = ++this.leavings;
for (const bus of going) bus.leaving = token;
const wait = Math.max(0, ...names.map((name) => this.ringOf(name)));
this.after(this.audio().currentTime + FADE + wait, () => {
for (const name of names) {
const bus = this.buses.get(name);
if (bus && bus.leaving === token) this.dropBus(name, bus);
}
});
}
/**
* Seconds what goes in on that bus is late by the time it is heard (#346): its chain and those of
* the buses it goes into, along the latest of its sends (#350). 0 straight out.
*/
busLatency(name: string): number {
return arrivalOf(name || MASTER_MIX, this.timed);
}
private readonly timed = (name: string): TimedBus | undefined => {
const bus = this.buses.get(name);
if (!bus) return undefined;
return { latency: bus.chain?.latency ?? 0, into: bus.into, ...(bus.sends && { sends: bus.sends }) };
};
/** What each track's bus was last set to, with whether its sound FX is there and how late (test hook). */
busLevels(): {
name: string;
gain: number;
pan: number;
into: string | null;
fx: boolean;
latency: number;
sends?: { to: string; tap: SendTap; gain: number; delay: number }[];
}[] {
return [...this.buses].map(([name, bus]) => ({
name,
...bus.set,
into: bus.sends ? null : bus.into,
fx: bus.chain !== null,
latency: bus.chain?.latency ?? 0,
...(bus.sends && {
sends: bus.sends.map(({ to, tap, gain, delay }) => ({ to, tap, gain, delay: delay.delayTime.value })),
}),
}));
}
/** Where the voices of a bus go in; the master for `''` (#325). */
private busInput(name: string): AudioNode {
return this.bus(name || MASTER_MIX).input;
}
/**
* 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.
*/
private bus(name: string, into: string | null = null): Bus {
const known = this.buses.get(name);
if (known) {
known.leaving = 0;
return known;
}
const context = this.audio();
const target = name === MASTER_MIX ? null : (into ?? MASTER_MIX);
const made: Bus = {
input: context.createGain(),
gain: context.createGain(),
pan: context.createStereoPanner(),
set: { gain: 1, pan: 0 },
into: target,
sends: null,
fx: null,
chain: null,
leaving: 0,
};
made.input.connect(made.gain).connect(made.pan).connect(this.outOf(target));
this.buses.set(name, made);
return made;
}
private outOf(into: string | null): AudioNode {
return into ? this.bus(into).input : this.audio().destination;
}
/** Sends a bus on into another one where it went somewhere else; the master stays heard. */
private route(name: string, bus: Bus, into: string | null): void {
const target = name === MASTER_MIX ? null : (into ?? MASTER_MIX);
if (bus.into === target && !bus.sends) return;
this.dropSends(bus);
bus.pan.disconnect();
bus.pan.connect(this.outOf(target));
bus.into = target;
}
/**
* A track of a song on along each of its sends (#350): from its tap, through a delay that makes it
* as late as the latest way of the track, at its level, into the bus it names. Only the levels
* glide where the sends are the same ones as before.
*/
private setSends(bus: Bus, sends: readonly { to: string; gain: number; tap: SendTap }[]): void {
const now = this.audio().currentTime;
const same =
bus.sends?.length === sends.length &&
bus.sends.every((old, i) => old.to === sends[i]!.to && old.tap === sends[i]!.tap);
if (same) {
bus.sends!.forEach((old, i) => {
old.gain = Math.max(0, sends[i]!.gain);
old.level.gain.setTargetAtTime(old.gain, now, GLIDE / 3);
});
return;
}
if (bus.sends) this.dropSends(bus);
else bus.pan.disconnect();
const context = this.audio();
bus.sends = sends.map(({ to, gain, tap }) => {
const delay = context.createDelay(MAX_SEND_DELAY);
const level = context.createGain();
level.gain.value = Math.max(0, gain);
delay.connect(level).connect(this.bus(to).input);
return { to, tap, gain: level.gain.value, delay, level };
});
this.wireTaps(bus);
this.alignSends();
}
/** The node a send takes its sound from: after the pan, after the sound FX, or the input. */
private tapOf(bus: Bus, tap: SendTap): AudioNode {
return tap === 'post' ? bus.pan : tap === 'postFx' ? (bus.chain?.out ?? bus.input) : bus.input;
}
/** Feeds every send from its tap; done again whenever a chain comes or goes, which moves a tap. */
private wireTaps(bus: Bus): void {
for (const send of bus.sends ?? []) this.tapOf(bus, send.tap).connect(send.delay);
}
private dropSends(bus: Bus): void {
for (const send of bus.sends ?? []) {
try {
this.tapOf(bus, send.tap).disconnect(send.delay);
} catch {
// Already cut where its chain was made again.
}
send.delay.disconnect();
send.level.disconnect();
}
bus.sends = null;
}
/** Holds every send back as far as `sendDelays` says, so no way of a track is heard before another. */
private alignSends(): void {
for (const [name, bus] of this.buses) {
if (!bus.sends) continue;
const delays = sendDelays(name, this.timed);
bus.sends.forEach((send, i) => {
send.delay.delayTime.value = Math.min(MAX_SEND_DELAY, delays[i] ?? 0);
});
}
}
/**
* The master and the buses of the project (#325): each into the bus it names, at its level unless
* a running game set another, through its sound FX. A bus whose asset is gone is let go. Applied
* once there is a context; done once the chains are there. The same mix again changes nothing, so
* the editor can hand it over on every change of the project.
*/
async setMix(buses: readonly MixBus[]): Promise<void> {
if (sameMix(this.mix, buses)) return;
this.mix = buses;
if (this.context) await this.applyMix();
}
private async applyMix(): Promise<void> {
const names = new Set(this.mix.map((bus) => bus.name));
const waits: Promise<void>[] = [];
for (const { name, volume, into, fx } of this.mix) {
const bus = this.bus(name, into);
this.route(name, bus, into);
this.glideLevel(bus, this.mixVolumes.get(name) ?? volume);
waits.push(this.setFx(bus, fx));
}
const gone = [...this.buses.keys()].filter(
(name) => name.startsWith(`${MASTER_MIX}:`) && !names.has(name),
);
for (const name of gone) this.mixVolumes.delete(name);
this.releaseBuses(gone);
await Promise.all(waits);
}
/** A quick glide of the level of a bus to `gain`. */
private glideLevel(bus: Bus, gain: number, time = 0, ease: EaseShare = 'linear'): void {
const to = Math.max(0, gain);
move(bus.gain.gain, to, this.audio().currentTime, time, false, ease);
bus.set = { ...bus.set, gain: to };
}
/**
* The level a running game sets a bus of the mix to (#325), over `time` seconds along `ease`; it
* holds until `resetMix`, whatever the mix is set to meanwhile.
*/
setMixVolume(name: string, volume: number, time = 0, ease: EaseShare = 'linear'): void {
const level = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
this.mixVolumes.set(name, level);
if (this.context || this.buses.has(name)) this.glideLevel(this.bus(name), level, Math.max(0, time), ease);
}
/** The level a bus of the mix is at: what a game set it to, else that of its asset. */
mixVolume(name: string): number {
return this.mixVolumes.get(name) ?? this.mix.find((bus) => bus.name === name)?.volume ?? 1;
}
/** Back to the levels of the assets, as a new run starts and an old one ends. */
resetMix(): void {
const set = [...this.mixVolumes.keys()];
this.mixVolumes.clear();
if (!this.context) return;
for (const name of set) {
const bus = this.buses.get(name);
if (bus) this.glideLevel(bus, this.mixVolume(name));
}
}
/**
* The sound FX of a bus of the mix (#325), as `tune` changes those of a voice. Says whether there
* is such a bus.
*/
tuneMix(name: string, params: FxParams, time = 0, ease: EaseShare = 'linear'): boolean {
const bus = this.buses.get(name);
if (!bus) return this.mix.some((known) => known.name === name);
this.tuneBus(bus, params, time, ease);
return true;
}
/**
* Sets the sound FX of a bus (#346), its chain made again only where the FX is another or counts by
* another tempo. Done once the chain is there, after the worklet it needs.
*/
private async setFx(bus: Bus, fx: FxVoice | null): Promise<void> {
if (bus.fx?.plan === fx?.plan && bus.fx?.tempo === fx?.tempo) return;
bus.fx = fx;
this.unchain(bus);
if (!fx) return;
if (needsWorklet(fx.plan)) await this.fxWorklet();
if (bus.fx === fx && !bus.chain) this.chain(bus);
}
/** Puts the chain of its sound FX between the input of a bus and its level. */
private chain(bus: Bus): void {
const fx = bus.fx;
if (!fx) return;
const context = this.audio();
const made: BusChain = { knobs: [], latency: 0, stops: [], nodes: [], out: bus.input };
bus.input.disconnect();
const { out, latency } = this.chainOf(context, fxChainOf(fx.plan, fx.params, fx.tempo), bus.input, made);
out.connect(bus.gain);
made.latency = latency;
made.out = out;
bus.chain = made;
this.wireTaps(bus);
this.alignSends();
}
private unchain(bus: Bus): void {
const chain = bus.chain;
if (!chain) return;
bus.chain = null;
for (const stop of chain.stops) stop();
for (const node of chain.nodes) node.disconnect();
bus.input.disconnect();
bus.input.connect(bus.gain);
this.wireTaps(bus);
this.alignSends();
}
/**
* The nodes a chain of a bus becomes, fed from `input`, in the order `wire` makes those of a voice
* so their knobs line up with `levelsOf`. A way has no source of its own: both ways of a Mix are fed
* the bus. A Pitch is a shifter of the sound going by (#346), late by a frame, and the other way of
* a Mix is held back as far, so the two stay together. Says where it comes out and how late.
*/
private chainOf(
context: AudioContext,
chain: FxChain,
input: AudioNode,
made: BusChain,
): { out: AudioNode; latency: number } {
const { knobs, nodes } = made;
let tail: AudioNode = input;
let latency = 0;
if ('mix' in chain) {
const both = chain.curve === 'both';
const a = this.chainOf(context, chain.a, input, made);
const shareA = gainNode(context, both ? 0 : 1 - chain.mix);
if (!both) knobs.push({ param: shareA.gain, target: 1 - chain.mix, even: 'value' });
const b = this.chainOf(context, chain.b, input, made);
const shareB = gainNode(context, both ? 0 : chain.mix);
if (!both) knobs.push({ param: shareB.gain, target: chain.mix, even: 'value' });
else {
const driver = bothAtCenter(context, chain.mix, shareA, shareB);
driver.start();
knobs.push({ param: driver.offset, target: chain.mix, even: 'value' });
made.stops.push(() => driver.stop());
nodes.push(driver);
}
latency = Math.max(a.latency, b.latency);
tail = context.createGain();
for (const [way, share] of [
[a, shareA],
[b, shareB],
] as const) {
let out = way.out;
if (way.latency < latency) {
const line = context.createDelay(latency);
line.delayTime.value = latency - way.latency;
out.connect(line);
nodes.push(line);
out = line;
}
out.connect(share).connect(tail);
nodes.push(share);
}
nodes.push(tail);
} else if (chain.shifted) {
const shifted = this.shifter(context, chain.pitch, input, made);
tail = shifted.out;
latency = shifted.latency;
}
for (const stage of chain.stages) {
// A sound FX has no Envelope; one would only silence the bus.
if (stage.kind === 'envelope') continue;
const stageMade = stageNodes(context, stage, undefined, context.currentTime);
tail.connect(stageMade.input);
tail = stageMade.output;
knobs.push(...stageMade.knobs);
if (stageMade.stop) made.stops.push(stageMade.stop);
nodes.push(stageMade.input, stageMade.output);
}
return { out: tail, latency };
}
/** The Pitch of a way of a bus (#346); without the worklet the sound goes by as it is, on time. */
private shifter(
context: AudioContext,
pitch: number,
input: AudioNode,
made: BusChain,
): { out: AudioNode; latency: number } {
let node: AudioWorkletNode;
try {
if (!this.hasWorklet) throw new Error('no worklet');
node = new AudioWorkletNode(context, 'pitch-shifter', {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [2],
channelCount: 2,
channelCountMode: 'explicit',
parameterData: { pitch },
});
} catch {
made.knobs.push({ param: null, target: pitch, even: 'value' });
return { out: input, latency: 0 };
}
input.connect(node);
made.nodes.push(node);
made.stops.push(() => node.port.postMessage('stop'));
made.knobs.push({ param: node.parameters.get('pitch')!, target: pitch, even: 'value' });
return { out: node, latency: shifterLatency(context.sampleRate) };
}
/**
* Seconds after its sound stops that a bus is still heard (#346): the echoes of its chain, after
* what it and the buses it goes into hold back.
*/
private ringOf(name: string): number {
const fx = this.buses.get(name)?.fx;
const tail = fx && fxRings(fx.plan) ? fxTailOf(fxChainOf(fx.plan, fx.params, fx.tempo)) : 0;
return tail + this.busLatency(name);
}
private dropBus(name: string, bus: Bus): void {
this.dropSends(bus);
this.unchain(bus);
bus.input.disconnect();
bus.gain.disconnect();
bus.pan.disconnect();
this.buses.delete(name);
}
/**
* A sequence without notes or clips plays nothing, and is given no number. Each playing is heard
* through a master of its own, silent until it starts (#336): its level and its fades. The tracks
* of a song go into it, each set as it was given; a note or clip naming no track goes straight in.
*/
playSequence(name: string, play: SequencePlay, owner = ''): number {
if (play.sequence.notes.length === 0 && !play.sequence.clips?.length) return 0;
const id = this.nextId++;
const buses = play.buses ?? [];
this.open(id, buses, play.fx ?? null, play.bus ?? MASTER_MIX);
const tracks = new Set(buses.map((bus) => bus.name));
const into = (bus: string | undefined) => groupBus(id, bus !== undefined && tracks.has(bus) ? bus : '');
const { strike, strikeClip } = play;
const chains = [...buses.flatMap((bus) => bus.fx ?? []), ...(play.fx ? [play.fx] : [])];
const routed: SequencePlay = {
...play,
needs: { ...play.needs, layers: [...play.needs.layers, ...chains.map((fx) => fx.plan)] },
strike: (note) => strike({ ...note, bus: into(note.bus) }),
...(strikeClip && {
strikeClip: (clip: ScheduledClip) => strikeClip({ ...clip, bus: into(clip.bus) }),
}),
};
this.sequences.start(id, name, routed, owner);
return id;
}
stopSequence(name: string, letRing = false, fade = 0): void {
this.sequences.stopNamed(name, letRing, fade);
}
stopPlayback(id: number, fade: number): void {
if (this.sequences.has(id)) this.sequences.stopId(id, false, fade);
else this.stopVoice(id);
}
/** The patterns and songs playing, oldest first, with the notes and clips last planned of each (test hook). */
playbacks(): {
id: number;
name: string;
owner: string;
playing: boolean;
fading: number | null;
planned: ScheduledNote[];
clips: ScheduledClip[];
}[] {
return this.sequences.current().map(({ id, name, owner, scheduler, fading, planned, clips }) => ({
id,
name,
owner,
playing: scheduler.playing,
fading,
planned: [...planned],
clips: [...clips],
}));
}
/**
* The buses of a playing, its tracks into its master and a pattern item's into its track (#346),
* each with the sound FX it was given. Their chains are made once the worklet is there (`ready`).
*/
private open(id: number, tracks: readonly BusLevel[], fx: FxVoice | null, into: string): void {
const masterName = groupBus(id, '');
const master = this.bus(masterName, into);
master.gain.gain.value = 0;
master.set = { gain: 0, pan: 0 };
master.fx = fx;
const names = new Set(tracks.map((track) => track.name));
const inside = (bus: string | undefined) =>
bus !== undefined && names.has(bus) ? groupBus(id, bus) : masterName;
const made = tracks.map(({ name, gain, pan, fx: trackFx }) => {
const track = this.bus(groupBus(id, name), masterName);
track.gain.gain.value = Math.max(0, gain);
track.pan.pan.value = Math.max(-1, Math.min(1, pan));
track.set = { gain: track.gain.gain.value, pan: track.pan.pan.value };
track.fx = trackFx ?? null;
return track;
});
tracks.forEach(({ name, into, sends }, i) => {
const track = made[i]!;
if (sends)
this.setSends(
track,
sends.map((send) => ({ ...send, to: inside(send.to) })),
);
else this.route(groupBus(id, name), track, inside(into));
});
this.groups.add(id);
}
/** What a playing needs loaded, then the chains of its buses (#346): before its first note is planned. */
private async ready(needs: SequenceNeeds, id: number): Promise<void> {
await this.prepare(needs.layers, needs.files);
for (const name of this.groupBuses(id)) {
const bus = this.buses.get(name);
if (bus?.fx && !bus.chain) this.chain(bus);
}
}
/** The names of the buses of a playing: its master and every track inside it. */
private groupBuses(id: number): string[] {
const master = groupBus(id, '');
return [...this.buses.keys()].filter((bus) => bus === master || bus.startsWith(`${master}:`));
}
/** How late the latest way through the buses of a playing is (#346): how early its notes start. */
private groupAhead(id: number): number {
return Math.max(0, ...this.groupBuses(id).map((name) => this.busLatency(name)));
}
/** The master of a playing, from where it stands at `at` to `gain`, there `seconds` later. */
private level(id: number, gain: number, at: number, seconds: number): void {
const master = this.buses.get(groupBus(id, ''));
if (!master) return;
const param = master.gain.gain;
const from = param.value;
param.cancelScheduledValues(at);
param.setValueAtTime(seconds > 0 ? from : gain, at);
if (seconds > 0) param.linearRampToValueAtTime(gain, at + seconds);
master.set = { ...master.set, gain };
}
/**
* Lets go of the buses of every playing that is gone and has nothing left on them – a fade later,
* so a voice cut a moment ago is not cut off mid-fade.
*/
private sweep(): void {
if (this.groups.size === 0) return;
for (const id of [...this.groups]) {
if (!this.groups.has(id) || this.sequences.has(id)) continue;
const on = (bus: string) => bus === groupBus(id, '') || bus.startsWith(`${groupBus(id, '')}:`);
if (this.voices.some((voice) => on(voice.bus))) continue;
if (this.pending.some((waiting) => !waiting.stopped && on(waiting.bus))) continue;
this.groups.delete(id);
const names = [...this.buses.keys()].filter(on);
// Its echoes, and what its shifters still hold, are heard after its last voice (#346).
const ring = Math.max(0, ...names.map((name) => this.ringOf(name)));
this.after(this.audio().currentTime + FADE + ring, () => {
for (const name of names) {
const bus = this.buses.get(name);
if (bus) this.dropBus(name, bus);
}
});
}
}
/**
* The end of a note (#331): the ways that end with it fade out, a looping one that does not leaves
* its loop, the others play on, and the voice goes once they have all ended – or rings on first
* where it has echoes. `at` is the second of the audio clock it ends at (#332): the fade is set on
* the clock to the sample, leaving a loop waits for the page.
*/
release(id: number, at = 0): void {
const waiting = this.pending.find((other) => other.id === id);
if (waiting) waiting.released = true;
const voice = this.voices.find((other) => other.id === id);
if (!voice) return;
this.letGo(voice, Math.max(at, this.audio().currentTime), false);
}
/**
* Ends a note at `when`, or with `all` lets a stopped one ring (#335). Its Envelopes fall away in
* their release, and the sources whose volume they are end with it – but one under only one-shots
* holds, unless `all`. Of the rest the gated ones fade out at once, with `all` every one.
*/
private letGo(voice: Voice, when: number, all: boolean): void {
for (const run of voice.envelopes) {
if (run.released) continue;
if (!all && run.sources.length > 0 && run.sources.every(playsThrough)) continue;
run.released = true;
releaseEnvelope(run.driver.offset, run.envelope, voice.at, when);
this.endAt(voice, run.sources, when + releaseTime(run.envelope));
}
const context = this.audio();
const leaving = voice.sources.filter((source) => !source.gate && source.leave);
const leave = () => {
for (const source of leaving) source.leave!();
};
if (leaving.length > 0 && when > context.currentTime)
this.after(when, () => this.voices.includes(voice) && leave());
else leave();
this.endAt(
voice,
voice.sources.filter((source) => all || source.gate),
when,
);
}
/** Fades these sources of a voice out from `at` and lets them go then, each only once. */
private endAt(voice: Voice, sources: readonly Source[], at: number): void {
const going = sources.filter((source) => !voice.ending.has(source));
if (going.length === 0) return;
for (const source of going) {
voice.ending.add(source);
if (source.fade) fadeOut(source.fade.gain, at);
}
this.after(at + FADE, () => {
if (!this.voices.includes(voice)) return;
for (const source of going) {
source.stop();
voice.finish?.(source);
}
});
}
private begin(start: Start): number {
const id = this.nextId++;
const asked: Start = { ...start, id };
const loaded = start.files.every((file) => this.buffer(file) !== null);
const worklet = start.fx.layers.some(needsWorklet) ? this.fxWorklet() : null;
if (loaded && (!worklet || this.hasWorklet !== null)) {
this.start(asked);
return id;
}
const askedAt = this.cancelsFor(start.file, start.owner);
const waiting: Pending = {
id,
bus: start.bus ?? '',
fx: { ...start.fx, params: { ...start.fx.params } },
timed: [],
stopped: false,
released: false,
};
this.pending.push(waiting);
void Promise.all([Promise.all(start.files.map((file) => this.load(file))), worklet]).then(([buffers]) => {
this.pending = this.pending.filter((other) => other !== waiting);
// A `Stop sound` – or the destruction of the behavior that asked – that happened while the
// bytes were in flight has to win, or a sound the run already ended would start afterwards.
const silent = buffers.every((buffer) => !buffer) && !start.fx.layers.some(hasChip);
if (silent || waiting.stopped) return;
if (this.cancelsFor(start.file, start.owner) !== askedAt) return;
this.start({ ...asked, fx: waiting.fx });
for (const { params, time, ease } of waiting.timed) this.tune(id, params, time, ease);
if (waiting.released) this.release(id);
});
return id;
}
/** Says whether the voice was still there to be reached – playing, or waiting on its bytes. */
tune(id: number, params: FxParams, time = 0, ease: EaseShare = 'linear'): boolean {
const waiting = this.pending.find((other) => other.id === id);
if (waiting) {
// A quick change is where the voice starts; a timed one – and anything after it, so the order
// holds – runs from the moment it is heard.
if (time > 0 || waiting.timed.length > 0) waiting.timed.push({ params, time, ease });
else waiting.fx = { ...waiting.fx, params: { ...waiting.fx.params, ...params } };
return true;
}
const voice = this.voices.find((other) => other.id === id);
if (!voice) return this.sequences.has(id) && this.tuneMaster(id, params, time, ease);
voice.fx = { ...voice.fx, params: { ...voice.fx.params, ...params } };
if (this.glide(voice, time, ease)) this.tunedAt = this.audio().currentTime;
// A longer feedback makes for a longer tail: it is counted again from where the change ends.
if (voice.ringing) this.ringFor(voice, Math.max(0, time) + this.tailOf(voice.fx));
this.tuned += 1;
return true;
}
/**
* The sound FX of a pattern or song playing (#346), on its master; one still loading starts with
* the values. Says it was reached, whether or not it has a sound FX.
*/
private tuneMaster(id: number, params: FxParams, time: number, ease: EaseShare): boolean {
const master = this.buses.get(groupBus(id, ''));
if (master) this.tuneBus(master, params, time, ease);
return true;
}
private tuneBus(bus: Bus, params: FxParams, time: number, ease: EaseShare): void {
const fx = bus.fx;
if (!fx) return;
bus.fx = { ...fx, params: { ...fx.params, ...params } };
// The chain stays: `setFx` would see the same plan, and the knobs move it.
const levels = levelsOf(fxChainOf(fx.plan, bus.fx.params, fx.tempo));
if (bus.chain && glideKnobs(bus.chain.knobs, levels, this.audio().currentTime, time, ease))
this.tunedAt = this.audio().currentTime;
this.tuned += 1;
}
/**
* Seconds a voice rings on after its sound has ended, at the values it has now (#324); 0 for a
* voice without echoes and for one that is not there.
*/
tail(id: number): number {
const voice =
this.voices.find((other) => other.id === id) ?? this.pending.find((other) => other.id === id);
return voice ? this.tailOf(voice.fx) : 0;
}
/** The second of the audio clock a voice started at; null while it waits on its bytes or is gone. */
startedAt(id: number): number | null {
return this.voices.find((voice) => voice.id === id)?.at ?? null;
}
/** Whether a voice is still heard – or waiting on its bytes to be. A pattern or song counts as one. */
playing(id: number): boolean {
return this.sounding(id) || this.sequences.has(id);
}
private sounding(id: number): boolean {
return this.voices.some((voice) => voice.id === id) || this.pending.some((other) => other.id === id);
}
/** `letRing` stops the sound itself and lets its echoes fade as they would (#324). */
stop(file: string, letRing = false): void {
if (file === '') this.sequences.stopNamed('', letRing);
this.silence(file === '' ? this.voices : this.voices.filter((voice) => voice.file === file), letRing);
if (file === '') this.allStops += 1;
else this.stops.set(file, (this.stops.get(file) ?? 0) + 1);
}
/** One voice by its number, also one still waiting on its bytes, or a pattern. */
stopVoice(id: number, letRing = false): void {
this.sequences.stopId(id, letRing);
const waiting = this.pending.find((other) => other.id === id);
if (waiting) waiting.stopped = true;
this.silence(
this.voices.filter((voice) => voice.id === id),
letRing,
);
}
/**
* Every voice of one behavior. An empty owner is deliberately no one: those are the voices that
* were started to outlive their behavior, so "stop the owner" must not reach them.
*/
stopOwner(owner: string): void {
if (owner === '') return;
this.sequences.stopOwner(owner);
this.silence(this.voices.filter((voice) => voice.owner === owner));
this.ownerStops.set(owner, (this.ownerStops.get(owner) ?? 0) + 1);
}
/** Every voice at once: what ending a run does, so nothing outlives the world that started it. */
stopAll(): void {
this.stop('');
}
/**
* A pause holds the sounds where they are; resuming carries them on mid-note. Until then nothing
* wakes the clock – a pattern or song reading it every few milliseconds would undo the pause.
*/
setSuspended(suspended: boolean): void {
this.held = suspended;
const context = this.context;
if (!context) return;
if (suspended) void context.suspend();
else void context.resume();
}
/** What has been played and what is playing – the only witness a muted check script has. */
stats(): {
playing: number;
started: number;
stopped: number;
tuned: number;
tunedAt: number | null;
decoded: number;
failed: number;
/** Whether the pitch worklet is there; null while nothing has asked for it or it is loading. */
worklet: boolean | null;
last: LastVoice | null;
} {
return {
playing: this.voices.length,
started: this.started,
stopped: this.stopped,
tuned: this.tuned,
tunedAt: this.tunedAt,
decoded: this.buffers.size,
failed: this.failed.size,
worklet: this.hasWorklet,
last: this.last,
};
}
/**
* Where the knobs of each voice of that file are right now, oldest first, a pitch in semitones –
* what a check reads to see a change arrive at one voice and not another (test hook).
*/
levels(file: string): number[][] {
return this.voices
.filter((voice) => voice.file === file)
.map((voice) => voice.knobs.map((knob) => knob.param?.value ?? knob.target));
}
/**
* Seconds on the audio clock, 0 before there is one (test hook): what a check times a voice by,
* since that clock and the page's need not run alike.
*/
clock(): number {
return this.context?.currentTime ?? 0;
}
/** `running`, `suspended` by a pause, or `none` before there is a context (test hook). */
clockState(): string {
return this.context?.state ?? 'none';
}
/** Seconds on the audio clock, opening the context where there is none yet: what the scheduler plans by (#332). */
time(): number {
const context = this.audio();
if (context.state === 'suspended' && !this.held) void context.resume();
return context.currentTime;
}
/** The context everything plays on, opened where there is none yet: a take is recorded on its clock (#342). */
liveContext(): AudioContext {
return this.audio();
}
/** Seconds between the audio clock and what is heard; 0 where the browser does not say. */
latency(): number {
const context = this.context;
if (!context) return 0;
// Older Safari and Firefox have no `outputLatency`, some no `baseLatency` either.
const out = context.outputLatency;
if (Number.isFinite(out) && out > 0) return out;
return Number.isFinite(context.baseLatency) ? context.baseLatency : 0;
}
/**
* Loads every file and the worklet these notes need, so the first note the scheduler plans is
* not late waiting on its bytes (#332).
*/
async prepare(layers: readonly FxPlan[], files: readonly string[] = []): Promise<void> {
await Promise.all([
...[...new Set([...layers.flatMap(filesOf), ...files])]
.filter((file) => file !== '')
.map((file) => this.load(file)),
layers.some(needsWorklet) ? this.fxWorklet() : null,
]);
}
/** Where the FX worklet is served from, for a check that runs it in an offline context (test hook). */
fxWorkletUrl(): string {
return fxWorkletUrl;
}
/**
* How long a decoded sound runs, in seconds – a cut (#343) as long as what it keeps; null while it
* has not been loaded (test hook).
*/
duration(file: string): number | null {
const range = this.rangeOf(file);
return range ? (range.to - range.from) / range.whole.sampleRate : null;
}
/** The decoded samples of a file as it is, without a cut; null while it has not been loaded. */
decoded(file: string): AudioBuffer | null {
return this.buffers.get(file) ?? null;
}
/** Whether a file could not be fetched or decoded – it is not tried again. */
failedToLoad(file: string): boolean {
return this.hasFailed(file);
}
length(file: string): number {
return this.duration(file) ?? 0;
}
/**
* The loudest sample of each of `PEAK_BUCKETS` equal stretches of a loaded sound, from 0 to 1 –
* what a clip of a song draws as its waveform (#334); null while it has not been loaded.
*/
peaks(file: string): Float32Array | null {
const known = this.peaked.get(file);
if (known) return known;
const range = this.rangeOf(file);
if (!range) return null;
const { whole, from, to, reverse } = range;
const peaks = new Float32Array(PEAK_BUCKETS);
for (let c = 0; c < whole.numberOfChannels; c++) {
const data = whole.getChannelData(c);
for (let i = from; i < to; i++) {
const heard = reverse ? to - 1 - i : i - from;
const bucket = Math.floor((heard / (to - from)) * PEAK_BUCKETS);
peaks[bucket] = Math.max(peaks[bucket]!, Math.abs(data[i]!));
}
}
for (let b = 0; b < PEAK_BUCKETS; b++) peaks[b] = Math.min(1, peaks[b]!);
this.peaked.set(file, peaks);
return peaks;
}
/**
* Starts loading every file not held yet. Waiting for the first play left that play late: its
* start was not heard, and a graph tuning it in the meantime had moved on by the time it started.
*/
preload(files: Iterable<string>): void {
for (const file of files) if (file) void this.load(file);
}
/**
* Fetches and decodes once per file; a second play while it is in flight waits on the first. A cut
* (#343) loads its file and is made from it.
*/
async load(file: string): Promise<AudioBuffer | null> {
if (parseCutName(file).cut) {
const whole = await this.load(parseCutName(file).file);
return whole ? this.buffer(file) : null;
}
const held = this.buffers.get(file);
if (held) return held;
if (this.failed.has(file)) return null;
const running = this.loading.get(file);
if (running) return running;
const work = (async () => {
try {
const response = await fetch(`${this.base}/${file}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const bytes = await response.arrayBuffer();
const buffer = await this.decoder().decodeAudioData(bytes);
this.buffers.set(file, buffer);
return buffer;
} catch (error: unknown) {
this.failed.add(file);
console.error(`[sounds] cannot load ${file}`, error);
return null;
} finally {
this.loading.delete(file);
}
})();
this.loading.set(file, work);
return work;
}
/**
* Ends these voices now: `onended` only fires next frame and the counts have to be right here. A
* voice is faded out rather than cut, so it does not click. With `letRing` one with echoes only
* loses its sound and stays until they have faded (#324); one already ringing is left to it. A
* note with Envelopes falls away in their release first (#335).
*/
private silence(going: Voice[], letRing = false): void {
this.stopped += going.filter((voice) => !(letRing && voice.ringing)).length;
for (const voice of going) {
if (letRing && voice.envelopes.length > 0) {
this.letGo(voice, this.audio().currentTime, true);
continue;
}
if (letRing && voice.rings) {
if (!voice.ringing) this.ringOut(voice);
continue;
}
this.cut(voice);
}
}
/** Fades the whole voice out and lets it go. */
private cut(voice: Voice): void {
this.forget(voice);
const context = this.audio();
const done = () => {
for (const source of voice.sources) source.stop();
for (const driver of voice.drivers) driver.stop();
voice.gain.disconnect();
};
// A suspended clock would hold the fade – and the sound – until the next run resumes it.
if (context.state !== 'running') return done();
fadeOut(voice.gain.gain, context.currentTime);
this.after(context.currentTime + FADE, done);
}
/** Fades the sources of a voice with echoes out and lets the echoes ring on (#324). */
private ringOut(voice: Voice): void {
voice.ringing = true;
const context = this.audio();
for (const source of voice.sources) if (source.fade) fadeOut(source.fade.gain, context.currentTime);
this.after(context.currentTime + FADE, () => {
for (const source of voice.sources) source.stop();
});
this.ringFor(voice, FADE + this.tailOf(voice.fx));
}
/** The sources of a voice have ended by themselves: it goes, or rings on first where it has echoes. */
private ended(voice: Voice): void {
if (voice.ringing || !this.voices.includes(voice)) return;
const tail = voice.rings ? this.tailOf(voice.fx) : 0;
if (tail <= 0) return this.done(voice);
voice.ringing = true;
this.ringFor(voice, tail);
}
/** (Re)sets the clock that ends a ringing voice, `seconds` from now. */
private ringFor(voice: Voice, seconds: number): void {
voice.quiet?.();
voice.quiet = this.after(this.audio().currentTime + seconds, () => {
voice.quiet = null;
this.done(voice);
});
}
/** A voice whose sound and echoes have run out: out of the count, and its nodes let go. */
private done(voice: Voice): void {
this.forget(voice);
for (const driver of voice.drivers) driver.stop();
// An echo is a loop of nodes; left connected it would keep the audio thread busy for nothing.
if (voice.rings) voice.gain.disconnect();
}
private forget(voice: Voice): void {
voice.quiet?.();
voice.quiet = null;
this.voices = this.voices.filter((other) => other !== voice);
if (voice.bus !== '') this.sweep();
}
/** The longest tail of its layers: they are heard side by side. */
private tailOf(fx: Layered): number {
return fx.layers.reduce(
(longest, plan) =>
fxRings(plan) ? Math.max(longest, fxTailOf(fxChainOf(plan, fx.params, fx.tempo))) : longest,
0,
);
}
/**
* Calls `then` once the audio clock reaches `at` – the clock, not the page's, so a paused game
* holds a tail where it is. A silent source that ends then is the only timer Web Audio has. Gives
* back what calls it off.
*/
private after(at: number, then: () => void): () => void {
const context = this.audio();
const clock = context.createConstantSource();
clock.offset.value = 0;
clock.connect(context.destination);
clock.onended = () => {
clock.disconnect();
then();
};
clock.start();
clock.stop(at);
return () => {
clock.onended = null;
clock.disconnect();
};
}
private start({
id,
file,
files,
volume,
loop,
owner,
fx,
duration,
at: heard,
clip: asked,
bus = '',
}: Start): void {
const context = this.audio();
// A context starts suspended where the page has not been interacted with; without this the
// voice would run silently and end as if it had played.
if (context.state === 'suspended' && !this.held) void context.resume();
// As much earlier as its bus is late, so it is heard when it was planned (#346).
const early = this.busLatency(bus);
const at = heard > 0 ? heard - early : heard;
const clip = asked && { ...asked, end: asked.end - early };
const when = Math.max(at, context.currentTime);
// A clip whose bytes came too late is over already, or starts as far in as it would be by now.
if (clip && clip.end <= when) return;
const gain = context.createGain();
gain.gain.value = Math.max(0, Math.min(MAX_VOLUME, volume));
const rings = fx.layers.some(fxRings);
const wiring: Wiring = {
context,
at: when,
// Held here, not looked up later: a cut can be let go while its voice plays.
buffers: new Map(
files.flatMap((name) => {
const buffer = this.buffer(name);
return buffer ? [[name, buffer] as const] : [];
}),
),
file,
loop,
rings,
offset: clip ? clip.offset + (when - at) : 0,
clip: clip !== undefined,
sources: [],
drivers: [],
envelopes: new Map(),
pitches: [],
knobs: [],
stages: 0,
};
for (const plan of fx.layers) this.wire(wiring, fxChainOf(plan, fx.params, fx.tempo)).connect(gain);
gain.connect(this.busInput(bus));
const { sources } = wiring;
this.last = {
file,
volume: gain.gain.value,
loop,
layers: fx.layers.length,
pitches: wiring.pitches,
stages: wiring.stages,
duration: Math.max(0, ...files.map((name) => this.duration(name) ?? 0)),
at: when,
envelopes: wiring.envelopes.size,
bus,
};
const voice: Voice = {
id,
file,
owner,
bus,
at: when,
sources,
ending: new Set(),
drivers: wiring.drivers,
envelopes: [...wiring.envelopes.values()],
finish: null,
gain,
fx,
knobs: wiring.knobs,
rings,
ringing: false,
quiet: null,
};
const going = new Set(sources);
voice.finish = (source) => {
if (going.delete(source) && going.size === 0) this.ended(voice);
};
for (const source of sources) source.ended(() => voice.finish?.(source));
this.voices.push(voice);
this.started += 1;
for (const driver of wiring.drivers) driver.start();
for (const source of sources) source.start(when, wiring.offset);
// Silent once its decay is over, however long the note is held (#329).
for (const run of voice.envelopes)
if (run.envelope.sustain <= 0)
this.endAt(voice, run.sources, when + run.envelope.attack + run.envelope.decay);
if (duration > 0) this.release(id, when + duration);
if (clip) this.endAt(voice, sources, clip.end);
}
/**
* The nodes a chain becomes, up to the one that carries its sound on. Every way has a source of
* its own, so each can have its own pitch; a Mix weighs each of its sounds by its share before
* they are summed.
*/
private wire(wiring: Wiring, chain: FxChain): AudioNode {
const { context, knobs } = wiring;
const follow = this.follower(wiring);
const first = wiring.sources.length;
let tail: AudioNode;
if ('mix' in chain) {
tail = context.createGain();
const both = chain.curve === 'both';
// An Envelope moving the share stands in for the knob: a parameter no longer reaches it.
const moved = chain.mixMod;
const wayA = this.wire(wiring, chain.a);
const shareA = gainNode(context, both || moved ? 0 : 1 - chain.mix);
wayA.connect(shareA).connect(tail);
if (!both) {
knobs.push({ param: moved ? null : shareA.gain, target: 1 - chain.mix, even: 'value' });
if (moved) follow(moved, shareA.gain, false, (mix) => 1 - mix);
}
const wayB = this.wire(wiring, chain.b);
const shareB = gainNode(context, both || moved ? 0 : chain.mix);
wayB.connect(shareB).connect(tail);
if (!both) {
knobs.push({ param: moved ? null : shareB.gain, target: chain.mix, even: 'value' });
if (moved) follow(moved, shareB.gain, false);
} else {
const driver = bothAtCenter(context, moved ? 0 : chain.mix, shareA, shareB);
knobs.push({ param: moved ? null : driver.offset, target: chain.mix, even: 'value' });
if (moved) follow(moved, driver.offset, false);
wiring.drivers.push(driver);
}
} else tail = this.source(wiring, chain);
const under = wiring.sources.slice(first);
for (const stage of chain.stages) {
if (stage.kind === 'envelope') this.envelope(wiring, stage.envelope).sources.push(...under);
const made = stageNodes(context, stage, follow, wiring.at);
tail.connect(made.input);
tail = made.output;
knobs.push(...made.knobs);
if (made.stop) wiring.drivers.push({ start: () => {}, stop: made.stop });
}
wiring.stages += chain.stages.length;
return tail;
}
/** Sends the curve of an Envelope into a number of a voice, shaped to what that number holds (#335). */
private follower(wiring: Wiring): Follow {
return (mod, param, ratio, map) =>
followCurve(wiring.context, this.envelope(wiring, mod.envelope).driver, mod, param, ratio, map);
}
/** The run of an Envelope in a voice, started on its first use: one curve however much it moves. */
private envelope(wiring: Wiring, envelope: FxEnvelope): EnvelopeRun {
const known = wiring.envelopes.get(envelope.id);
if (known) return known;
const driver = envelopeDriver(wiring.context, envelope, wiring.at);
wiring.drivers.push(driver);
const run: EnvelopeRun = { envelope, driver, sources: [], released: false };
wiring.envelopes.set(envelope.id, run);
return run;
}
/**
* A way's sound: the pitch worklet where the way has a Pitch, which keeps the length whatever the
* pitch does (#317), and otherwise the buffer as it is. Without the worklet a pitched way plays at
* its own pitch rather than not at all. A way with a source of its own (#331) plays that file, at
* its volume, and can fade out by itself when its note ends.
*/
private source(wiring: Wiring, chain: Way): AudioNode {
const sound = chain.chip ? this.chip(wiring, chain, chain.chip) : this.sound(wiring, chain);
const own = chain.source;
if (!wiring.rings && !own && !chain.chip && !wiring.clip) return sound;
const fade = gainNode(wiring.context, own?.volume ?? 1);
wiring.sources.at(-1)!.fade = fade;
return sound.connect(fade);
}
private sound(wiring: Wiring, { pitch, shifted, source: own, pitchMods }: Way): AudioNode {
const { context } = wiring;
const file = own?.file ?? wiring.file;
const loop = own ? own.loop : wiring.loop;
const gate = own?.gate ?? false;
// A file that could not be loaded leaves its way silent rather than the whole note.
const buffer = wiring.buffers.get(file) ?? context.createBuffer(1, 1, context.sampleRate);
wiring.pitches.push(pitch);
if (shifted && this.hasWorklet) {
const options: PitchOptions = {
file,
step: buffer.sampleRate / context.sampleRate,
loop,
startAt: wiring.at,
};
if (wiring.offset > 0) options.skip = Math.round((wiring.offset % buffer.duration) * buffer.sampleRate);
if (own && loop) Object.assign(options, loopIn(buffer, own.loopStart, own.loopEnd));
if (!this.sent.has(file)) {
options.channels = Array.from({ length: buffer.numberOfChannels }, (_, c) =>
buffer.getChannelData(c),
);
this.sent.add(file);
}
const node = new AudioWorkletNode(context, 'pitch-player', {
numberOfInputs: 0,
outputChannelCount: [buffer.numberOfChannels],
parameterData: { pitch },
processorOptions: options,
});
wiring.sources.push({
start: () => {},
stop: () => {
node.port.postMessage('stop');
node.disconnect();
},
ended: (then) => {
node.port.onmessage = (event: MessageEvent) => {
if (event.data === 'ended') then();
};
},
fade: null,
gate,
leave: own && loop ? () => node.port.postMessage('leave') : null,
});
const knob = node.parameters.get('pitch')!;
wiring.knobs.push({ param: knob, target: pitch, even: 'value' });
// On top of the pitch the way has anyway, which a parameter can still move.
const follow = this.follower(wiring);
for (const mod of pitchMods ?? []) follow(mod, knob, false);
return node;
}
const source = context.createBufferSource();
source.buffer = buffer;
source.loop = loop;
if (own && loop) {
source.loopStart = own.loopStart;
source.loopEnd = own.loopEnd;
}
wiring.sources.push({
// Web Audio holds an offset past the end of a looping buffer to its end, not round it.
start: (at, offset) => source.start(at, offset > 0 ? offset % buffer.duration : 0),
stop: () => source.stop(),
ended: (then) => {
source.onended = then;
},
fade: null,
gate,
// Web Audio plays a buffer that stops looping on to its end, past `loopEnd`.
leave:
own && loop
? () => {
source.loop = false;
}
: null,
});
if (shifted) wiring.knobs.push({ param: null, target: pitch, even: 'value' });
return source;
}
/**
* A Chip (#329): a worklet of its own that ends with the note. A Pitch on its way moves its
* frequency itself. Without the worklet the way is silent and ends at once.
*/
private chip(wiring: Wiring, { pitch, shifted, pitchMods }: Way, chip: FxChip): AudioNode {
const { context, knobs } = wiring;
wiring.pitches.push(pitch);
const { frequency, duty, frequencyMod, dutyMod, ...shape } = chip;
if (!this.hasWorklet) {
const silent = context.createBufferSource();
silent.buffer = context.createBuffer(1, 1, context.sampleRate);
wiring.sources.push({
start: (at) => silent.start(at),
stop: () => silent.stop(),
ended: (then) => {
silent.onended = then;
},
fade: null,
gate: true,
leave: null,
});
if (shifted) knobs.push({ param: null, target: pitch, even: 'value' });
knobs.push(
{ param: null, target: frequency, even: 'ratio' },
{ param: null, target: duty, even: 'value' },
);
return silent;
}
const options: ChipOptions = { ...shape, startAt: wiring.at };
const node = new AudioWorkletNode(context, 'chip-player', {
numberOfInputs: 0,
outputChannelCount: [1],
parameterData: { frequency: frequencyMod ? 0 : frequency, duty: dutyMod ? 0 : duty, pitch },
processorOptions: options,
});
wiring.sources.push({
start: () => {},
stop: () => {
node.port.postMessage('stop');
node.disconnect();
},
ended: (then) => {
node.port.onmessage = (event: MessageEvent) => {
if (event.data === 'ended') then();
};
},
fade: null,
gate: true,
leave: null,
});
const param = (name: string) => node.parameters.get(name)!;
if (shifted) knobs.push({ param: param('pitch'), target: pitch, even: 'value' });
knobs.push(
{ param: frequencyMod ? null : param('frequency'), target: frequency, even: 'ratio' },
{ param: dutyMod ? null : param('duty'), target: duty, even: 'value' },
);
const follow = this.follower(wiring);
if (frequencyMod) follow(frequencyMod, param('frequency'), true);
if (dutyMod) follow(dutyMod, param('duty'), false);
for (const mod of pitchMods ?? []) follow(mod, param('pitch'), false);
return node;
}
/**
* Sends every knob of a voice towards what its values say now, in `time` seconds of the audio
* clock along `ease`, or else in a quick glide. Says whether any of them had somewhere to go.
*/
private glide(voice: Voice, time: number, ease: EaseShare): boolean {
const levels: number[] = [];
for (const plan of voice.fx.layers) levelsOf(fxChainOf(plan, voice.fx.params, voice.fx.tempo), levels);
return glideKnobs(voice.knobs, levels, this.audio().currentTime, time, ease);
}
private audio(): AudioContext {
if (this.context) return this.context;
this.context = new AudioContext();
if (this.mix.length > 0) void this.applyMix();
return this.context;
}
/** What a name plays, a cut made from its file the first time; null while the file is not loaded. */
private buffer(name: string): AudioBuffer | null {
const whole = this.buffers.get(name);
if (whole) return whole;
const held = this.cuts.get(name);
if (held) {
this.cuts.delete(name);
this.cuts.set(name, held);
return held;
}
const range = this.rangeOf(name);
if (!range) return null;
const made = this.decoder().createBuffer(
range.whole.numberOfChannels,
range.to - range.from,
range.whole.sampleRate,
);
const channels = Array.from({ length: made.numberOfChannels }, (_, c) => range.whole.getChannelData(c));
cutChannels(channels, range).forEach((data, c) => made.copyToChannel(data, c));
this.cuts.set(name, made);
const samples = (buffer: AudioBuffer) => buffer.length * buffer.numberOfChannels;
let total = [...this.cuts.values()].reduce((sum, buffer) => sum + samples(buffer), 0);
for (const [other, buffer] of this.cuts) {
if (total <= CUT_BUDGET || other === name) break;
this.cuts.delete(other);
total -= samples(buffer);
}
return made;
}
/** The samples of its loaded file a name plays: all of them, or the stretch a cut keeps. */
private rangeOf(name: string): (CutRange & { whole: AudioBuffer }) | null {
const { file, cut } = parseCutName(name);
const whole = this.buffers.get(file);
if (!whole) return null;
if (!cut)
return {
whole,
from: 0,
to: whole.length,
fadeIn: 0,
fadeOut: 0,
curveIn: 'linear',
curveOut: 'linear',
reverse: false,
};
return { whole, ...cutRange(whole.length, whole.sampleRate, cut) };
}
/** Whether the file of a name could not be had: a cut of it cannot either. */
private hasFailed(name: string): boolean {
return this.failed.has(parseCutName(name).file);
}
/** Loads the FX worklet (Pitch, Bitcrusher, Chip, Phaser, Flanger) into the context once; says whether it is there. */
private fxWorklet(): Promise<boolean> {
return (this.workletLoading ??= (async () => {
try {
await this.audio().audioWorklet.addModule(fxWorkletUrl);
return (this.hasWorklet = true);
} catch (error: unknown) {
console.error('[sounds] cannot load the FX worklet, its effects and Chips are not heard', error);
return (this.hasWorklet = false);
}
})());
}
/** The context that is already playing, or else one that decodes without opening a device. */
private decoder(): BaseAudioContext {
if (this.context || typeof OfflineAudioContext === 'undefined') return this.audio();
return (this.offline ??= new OfflineAudioContext(1, 1, DECODE_RATE));
}
/** Everything that would have silenced this file for this owner, counted rather than flagged. */
private cancelsFor(file: string, owner: string): number {
return (this.stops.get(file) ?? 0) + this.allStops + (this.ownerStops.get(owner) ?? 0);
}
}
/** The bus of a game's playing (#336): its master for `''`, else the track of that name inside it. */
function groupBus(id: number, track: string): string {
return track === '' ? `play:${id}` : `play:${id}:${track}`;
}
/**
* A track of a song (#334): what goes in passes its sound FX (#346), then its level, then its pan,
* then the bus it goes into or straight out – or each of its sends (#350); `set` is what level and
* pan glide to.
*/
interface Bus {
input: GainNode;
gain: GainNode;
pan: StereoPannerNode;
set: { gain: number; pan: number };
/** Where it goes as a whole while it has no sends; null straight out. */
into: string | null;
sends: BusSendNodes[] | null;
/** The sound FX it is set to, and the chain made of it once its worklet is there. */
fx: FxVoice | null;
chain: BusChain | null;
/** Set while it is to be let go a moment later, unless it is used again first. */
leaving: number;
}
/** One send of a track (#350): where it goes, where it taps, its level and how far it is held back. */
interface BusSendNodes {
to: string;
tap: SendTap;
gain: number;
delay: DelayNode;
level: GainNode;
}
/** The sound FX of a bus as nodes (#346): one chain everything on the bus goes through. */
interface BusChain {
/** What comes out of it, on to the level of the bus: where a send after the sound FX taps. */
out: AudioNode;
/** In the order `levelsOf` lists the numbers of its chain. */
knobs: Knob[];
/** Seconds it is late: its pitch shifters, the other way of a Mix held back to match them. */
latency: number;
stops: (() => void)[];
nodes: AudioNode[];
}
/** What `stats` says about the last voice: enough for a check to tell which effects it went through. */
interface LastVoice {
file: string;
volume: number;
loop: boolean;
/** How many layers of a note it played side by side (#331); 1 for a sound. */
layers: number;
/** Semitones of each way (#316), and how many gain and filter nodes it passed. */
pitches: number[];
stages: number;
/** Seconds of what was played: a pitched voice is as long as the sound itself (#312, #317). */
duration: number;
/** When it started on the audio clock, what a check times it from. */
at: number;
/** How many Envelopes shape it (#335). */
envelopes: number;
/** The bus it went into, `''` for the master (#325). */
bus: string;
}
/** The stages that run on the worklet. */
const ON_WORKLET: ReadonlySet<FxStagePlan['kind']> = new Set(['crush', 'phaser', 'flanger']);
/** Whether a plan has a Pitch, a Chip or a stage of the worklet anywhere, and so needs the worklet. */
function needsWorklet(plan: FxPlan): boolean {
if (plan.stages.some((stage) => ON_WORKLET.has(stage.kind))) return true;
return 'mix' in plan ? needsWorklet(plan.a) || needsWorklet(plan.b) : plan.pitch.length > 0 || !!plan.chip;
}
/** Whether a way of a plan plays a Chip (#329), which needs no file. */
function hasChip(plan: FxPlan): boolean {
return 'mix' in plan ? hasChip(plan.a) || hasChip(plan.b) : plan.chip !== undefined;
}
/** The files the ways of a plan read with a source of their own (#331). */
function filesOf(plan: FxPlan): string[] {
if ('mix' in plan) return [...filesOf(plan.a), ...filesOf(plan.b)];
return plan.source ? [plan.source.file] : [];
}
/**
* Where a loop runs in the samples of a sound, as the worklet takes it: between `start` and `end`
* seconds, 0 at the end being the end of the sound. One that does not fit is the whole sound, as
* Web Audio has it for a buffer source.
*/
function loopIn(buffer: AudioBuffer, start: number, end: number): { loopStart: number; loopEnd: number } {
const last = buffer.length;
const from = Math.round(Math.max(0, start) * buffer.sampleRate);
const to = end > 0 ? Math.min(last, Math.round(end * buffer.sampleRate)) : last;
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,
): 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);
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): a cutoff that moves in octaves, and a Q – a band-pass's as a factor, so in ratios too. */
function passNodes(context: BaseAudioContext, stage: FxPass, follow?: Follow): StageNodes {
const filter = context.createBiquadFilter();
filter.type = stage.filter;
filter.frequency.value = stage.cutoffMod ? 0 : stage.cutoff;
filter.Q.value = stage.qMod ? 0 : stage.q;
const qEven = stage.filter === 'bandpass' ? 'ratio' : 'value';
if (stage.cutoffMod) follow?.(stage.cutoffMod, filter.frequency, true);
if (stage.qMod) follow?.(stage.qMod, filter.Q, qEven === 'ratio');
return {
input: filter,
output: filter,
knobs: [
{ param: stage.cutoffMod ? null : filter.frequency, target: stage.cutoff, even: 'ratio' },
{ param: stage.qMod ? null : filter.Q, target: stage.q, even: qEven },
],
};
}
/**
* 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);
}
/**
* An echo (#327): the sound goes on as it is, and through a delay line whose output is damped, fed
* back into it and added at `mix`. 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();
input.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 = FLAT_Q;
const feedback = gainNode(context, stage.feedback);
const wet = gainNode(context, stage.mix);
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: stage.mix, 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: () => {
node.port.postMessage('stop');
node.disconnect();
},
};
}
/** 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;
}
/** 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)
if (stage.kind === 'delay') into.push(stage.time, stage.feedback, stage.damping, stage.mix);
else if (stage.kind === 'pass') into.push(stage.cutoff, stage.q);
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 !== 'envelope') into.push(stage.gain);
return into;
}
export const soundLibrary = new SoundLibrary();