import { DELAY_MAX, DRY_VOICE, FLAT_Q, fxChainOf, type FxChain, type FxChip, type FxCrush, type FxDelay, type FxEnvelope, type FxMod, type FxPass, fxRings, fxTailOf, type FxParams, type FxPlan, type FxStage, 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 SequencePlay } from '../core/music/playbacks'; 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 type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio'; import type { ChipOptions, 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; /** 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; /** * 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; /** * 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; 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; 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(); /** * 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(); /** The pitch worklet being loaded, and whether it is there once it is known; null before that. */ private workletLoading: Promise | 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(); private readonly loading = new Map>(); /** Never retried, so one broken reference cannot spam the network. */ private readonly failed = new Set(); 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(); 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(); /** The tracks of the songs played so far (#334), by name. */ private readonly buses = new Map(); private readonly peaked = new Map(); /** * 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: ({ layers, files }) => this.prepare(layers, files), 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(); /** Held by the pause of the toolbar: nothing that reads the clock may wake it then. */ private held = false; /** 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): 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 }); } /** * 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); } /** What each track's bus was last set to (test hook). */ busLevels(): { name: string; gain: number; pan: number }[] { return [...this.buses].map(([name, bus]) => ({ name, ...bus.set })); } /** Where the voices of a track go in; straight out for `''`. */ private busInput(name: string): AudioNode { return name === '' ? this.audio().destination : this.bus(name).gain; } private bus(name: string, out?: AudioNode): Bus { const known = this.buses.get(name); if (known) return known; const context = this.audio(); const made = { gain: context.createGain(), pan: context.createStereoPanner(), set: { gain: 1, pan: 0 } }; made.gain.connect(made.pan).connect(out ?? context.destination); this.buses.set(name, made); return made; } /** * 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); 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 routed: SequencePlay = { ...play, 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], })); } private open(id: number, tracks: readonly BusLevel[]): void { const master = this.bus(groupBus(id, '')); master.gain.gain.value = 0; master.set = { gain: 0, pan: 0 }; for (const { name, gain, pan } of tracks) { const track = this.bus(groupBus(id, name), master.gain); 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 }; } this.groups.add(id); } /** 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); this.after(this.audio().currentTime + FADE, () => { for (const name of names) { const bus = this.buses.get(name); bus?.gain.disconnect(); bus?.pan.disconnect(); this.buses.delete(name); } }); } } /** * 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 false; 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; } /** * 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 { 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 } = 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 bucket = Math.floor(((i - from) / (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): 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 { 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, clip, 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(); 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, }; 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); 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); const now = this.audio().currentTime; let moved = false; voice.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; } private audio(): AudioContext { return (this.context ??= new AudioContext()); } /** 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', }; 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) into the context once; says whether it is there. */ private fxWorklet(): Promise { 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, a Pitch, Bitcrusher or Chip is 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): its level, then its pan, then out; `set` is what they glide to. */ interface Bus { gain: GainNode; pan: StereoPannerNode; set: { gain: number; pan: number }; } /** 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; } /** Whether a plan has a Pitch, a Bitcrusher or a Chip anywhere, and so needs the worklet. */ function needsWorklet(plan: FxPlan): boolean { if (plan.stages.some((stage) => stage.kind === 'crush')) 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. */ export function stageNodes(context: BaseAudioContext, stage: FxStage, follow?: Follow): 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); 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 & Partial>; if (held.cancelAndHoldAtTime) held.cancelAndHoldAtTime(at); else { // Firefox: the level by then is known, the curve up to it is what was planned. const reached = envelopeLevel(envelope, at - startedAt); level.cancelScheduledValues(at); if (at - startedAt < envelope.attack) level.linearRampToValueAtTime(reached, at); else level.setValueAtTime(reached, at); } level.setTargetAtTime(0, at, releaseTime(envelope) / SETTLE); } /** * 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 Bitcrusher (#322) on the worklet. Bits and mix move in what they hold, the rate in octaves. * Where the worklet is not there the sound goes through as it is. */ function crushNodes(context: BaseAudioContext, stage: FxCrush, follow?: Follow): StageNodes { let node: AudioWorkletNode; try { node = new AudioWorkletNode(context, 'bit-crusher', { parameterData: { bits: stage.bitsMod ? 0 : stage.bits, rate: stage.rateMod ? 0 : stage.rate, mix: stage.mixMod ? 0 : stage.mix, }, }); } catch { const through = context.createGain(); const still = (target: number): Knob => ({ param: null, target, even: 'value' }); return { input: through, output: through, knobs: [still(stage.bits), still(stage.rate), still(stage.mix)], }; } const param = (name: string) => node.parameters.get(name)!; if (stage.bitsMod) follow?.(stage.bitsMod, param('bits'), false); if (stage.rateMod) follow?.(stage.rateMod, param('rate'), true); if (stage.mixMod) follow?.(stage.mixMod, param('mix'), false); return { input: node, output: node, knobs: [ { param: stage.bitsMod ? null : param('bits'), target: stage.bits, even: 'value' }, { param: stage.rateMod ? null : param('rate'), target: stage.rate, even: 'ratio' }, { param: stage.mixMod ? null : param('mix'), target: stage.mix, even: 'value' }, ], stop: () => { node.port.postMessage('stop'); node.disconnect(); }, }; } /** 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 & Partial>; if (held.cancelAndHoldAtTime) held.cancelAndHoldAtTime(now); else { param.cancelScheduledValues(now); param.setValueAtTime(from, now); } const logarithmic = ratio && from > 0 && to > 0; if (time <= 0) param.setTargetAtTime(to, now, GLIDE); else if (ease === 'step') param.setValueAtTime(to, now + time); else if (ease === 'linear' && logarithmic) param.exponentialRampToValueAtTime(to, now + time); else if (ease === 'linear') param.linearRampToValueAtTime(to, now + time); else param.setValueCurveAtTime(easedCurve(from, to, time, logarithmic, ease), now, time); } /** 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 !== 'envelope') into.push(stage.gain); return into; } export const soundLibrary = new SoundLibrary();