A sound FX could not control dynamics. A Compressor squeezes the loudness together, and a Limiter keeps every peak below a line. Both work on a voice and on a bus, where they tame everything at once. - Compressor: DynamicsCompressorNode with Threshold, Ratio, Knee, Attack and Release, then Makeup and Auto-gain from 0 to 1. After the node the sound gets (1 - w) * makeup + w * A dB. A is what the curve takes off at 0 dBFS. - The node adds 0.6 of A by itself, as the spec says. That gain is taken back out, so 0 really means no makeup. - The knee shape is left to the browser by the spec. dynamics.ts ports Chromium's curve. It gives the gain Chrome was measured to add at four settings to three places. - Limiter: a class on the FX worklet with Threshold, Ceiling, Release and Auto-gain. It looks 5 ms ahead: the smallest gain of the window ahead, averaged over the window, then a release. No sample passes the lower of threshold and ceiling. Auto-gain raises it toward the ceiling. - Deviation from the plan in the issue: the Limiter has a Threshold as well. Without it Auto-gain has nothing to measure against. - Latency is made up on voices now too. A voice starts as much earlier as its stages are late (Compressor 6 ms, Limiter 5 ms). The other way of a Mix and the other layers of a note are held back to the latest, and a bus counts it into its latency. - A voice with a Limiter counts as ringing, so the worklet is stopped only after what it still holds is out. Tests: - Unit dynamics (new) 8, soundfx 49 (3 new), sound-library 100 (4 new), green. - Counter-checks, each turns tests red: no look-ahead in the Limiter (3), no taking back of the node's gain (1), a voice not started earlier (1), no stage latency on a bus (1), no hold-back in a Mix (1). - e2e 34 (melektaus-atx): ALL PASSED, 78 checks (1 new). Chrome offline: Compressor 288 samples late, Limiter 240. Full scale through -12 dB 4:1 hard knee: +0.19 dB with full auto-gain, -8.81 dB without. A sine at 2 through the Limiter at -6 dB peaks at -6.00 dB, with auto-gain 1 at -1.00 dB. - The gate runs once on the last commit of this series. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2497 lines
95 KiB
TypeScript
2497 lines
95 KiB
TypeScript
import {
|
||
DELAY_MAX,
|
||
DRY_VOICE,
|
||
FLAT_Q,
|
||
passQ,
|
||
fxChainOf,
|
||
fxLatencyOf,
|
||
type FxChain,
|
||
type FxChip,
|
||
type FxCompress,
|
||
type FxCrush,
|
||
type FxDelay,
|
||
type FxEnvelope,
|
||
type FxFlanger,
|
||
type FxMod,
|
||
type FxPass,
|
||
type FxPhaser,
|
||
fxRings,
|
||
fxTailOf,
|
||
type FxParams,
|
||
type FxPlan,
|
||
type FxStage,
|
||
type FxStagePlan,
|
||
type FxVoice,
|
||
stageLatency,
|
||
} 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);
|
||
latency += stageLatency(stage, context.sampleRate);
|
||
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. What its stages hold back (#326)
|
||
* is still to be heard once its sources end, too.
|
||
*/
|
||
private tailOf(fx: Layered): number {
|
||
const rate = this.audio().sampleRate;
|
||
return fx.layers.reduce((longest, plan) => {
|
||
if (!fxRings(plan)) return longest;
|
||
const chain = fxChainOf(plan, fx.params, fx.tempo);
|
||
return Math.max(longest, fxTailOf(chain) + fxLatencyOf(chain, rate));
|
||
}, 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 and its own stages are late, so it is heard when it was planned
|
||
// (#346, #326). A layer that is less late is held back to the latest.
|
||
const chains = fx.layers.map((plan) => fxChainOf(plan, fx.params, fx.tempo));
|
||
const lates = chains.map((chain) => fxLatencyOf(chain, context.sampleRate));
|
||
const late = Math.max(0, ...lates);
|
||
const early = this.busLatency(bus) + late;
|
||
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,
|
||
};
|
||
chains.forEach((chain, i) => heldBack(context, this.wire(wiring, chain), late - lates[i]!).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;
|
||
// The later way sets when both are heard; the other is held back to it (#326).
|
||
const lateA = fxLatencyOf(chain.a, context.sampleRate);
|
||
const lateB = fxLatencyOf(chain.b, context.sampleRate);
|
||
const wayA = heldBack(context, this.wire(wiring, chain.a), Math.max(lateA, lateB) - lateA);
|
||
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 = heldBack(context, this.wire(wiring, chain.b), Math.max(lateA, lateB) - lateB);
|
||
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', 'limit']);
|
||
|
||
/** 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);
|
||
if (stage.kind === 'compress') return compressNodes(context, stage);
|
||
if (stage.kind === 'limit')
|
||
return workletNodes(context, 'limiter', [
|
||
{ name: 'threshold', value: stage.threshold, even: 'value' },
|
||
{ name: 'ceiling', value: stage.ceiling, even: 'value' },
|
||
{ name: 'release', value: stage.release, even: 'ratio' },
|
||
{ name: 'autoGain', value: stage.autoGain, even: 'value' },
|
||
]);
|
||
if (stage.kind === 'pan') {
|
||
const panner = context.createStereoPanner();
|
||
panner.pan.value = stage.mod ? 0 : stage.pan;
|
||
if (stage.mod) follow?.(stage.mod, panner.pan, false);
|
||
return {
|
||
input: panner,
|
||
output: panner,
|
||
knobs: [{ param: stage.mod ? null : panner.pan, target: stage.pan, even: 'value' }],
|
||
};
|
||
}
|
||
const filter = context.createBiquadFilter();
|
||
filter.type = stage.filter;
|
||
filter.frequency.value = stage.frequency;
|
||
filter.gain.value = stage.mod ? 0 : stage.gain;
|
||
filter.Q.value = stage.q;
|
||
if (stage.mod) follow?.(stage.mod, filter.gain, false);
|
||
return {
|
||
input: filter,
|
||
output: filter,
|
||
knobs: [{ param: stage.mod ? null : filter.gain, target: stage.gain, even: 'value' }],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A Filter (#335, #318): a cutoff that moves in octaves, a Q – as a factor in ratios too, in dB for a
|
||
* low- or high-pass – and a gain in dB. Several in a row share one cutoff, carried by a source of its
|
||
* own into each of them; only the last has the Q, the others none.
|
||
*/
|
||
function passNodes(context: BaseAudioContext, stage: FxPass, follow?: Follow): StageNodes {
|
||
const qEven = stage.filter === 'lowpass' || stage.filter === 'highpass' ? 'value' : 'ratio';
|
||
const filters = Array.from({ length: Math.max(1, stage.stages) }, () => {
|
||
const filter = context.createBiquadFilter();
|
||
filter.type = stage.filter;
|
||
filter.Q.value = passQ(stage.filter, FLAT_Q);
|
||
return filter;
|
||
});
|
||
filters.slice(1).forEach((filter, i) => filters[i]!.connect(filter));
|
||
const last = filters.at(-1)!;
|
||
last.Q.value = stage.qMod ? 0 : stage.q;
|
||
last.gain.value = stage.gainMod ? 0 : stage.gain;
|
||
if (stage.qMod) follow?.(stage.qMod, last.Q, qEven === 'ratio');
|
||
if (stage.gainMod) follow?.(stage.gainMod, last.gain, false);
|
||
let cutoff = filters[0]!.frequency;
|
||
let stop: (() => void) | undefined;
|
||
if (filters.length > 1) {
|
||
const driver = context.createConstantSource();
|
||
for (const filter of filters) {
|
||
filter.frequency.value = 0;
|
||
driver.connect(filter.frequency);
|
||
}
|
||
driver.start();
|
||
cutoff = driver.offset;
|
||
stop = () => driver.stop();
|
||
}
|
||
cutoff.value = stage.cutoffMod ? 0 : stage.cutoff;
|
||
if (stage.cutoffMod) follow?.(stage.cutoffMod, cutoff, true);
|
||
return {
|
||
input: filters[0]!,
|
||
output: last,
|
||
knobs: [
|
||
{ param: stage.cutoffMod ? null : cutoff, target: stage.cutoff, even: 'ratio' },
|
||
{ param: stage.qMod ? null : last.Q, target: stage.q, even: qEven },
|
||
{ param: stage.gainMod ? null : last.gain, target: stage.gain, even: 'value' },
|
||
],
|
||
...(stop && { stop }),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* What carries the curve of an Envelope (#335) from `at`: a straight line up to 1, then easing down
|
||
* towards the sustain. Its caller starts it and stops it with the voice. Exported for a check that
|
||
* renders it offline in a real browser (test hook).
|
||
*/
|
||
export function envelopeDriver(
|
||
context: BaseAudioContext,
|
||
envelope: FxEnvelope,
|
||
at: number,
|
||
): ConstantSourceNode {
|
||
const driver = context.createConstantSource();
|
||
const level = driver.offset;
|
||
level.value = 0;
|
||
const top = at + envelope.attack;
|
||
if (envelope.attack > 0) {
|
||
level.setValueAtTime(0, at);
|
||
level.linearRampToValueAtTime(1, top);
|
||
} else level.setValueAtTime(1, at);
|
||
if (envelope.sustain < 1) {
|
||
if (envelope.decay > 0) level.setTargetAtTime(envelope.sustain, top, envelope.decay / SETTLE);
|
||
else level.setValueAtTime(envelope.sustain, top);
|
||
}
|
||
return driver;
|
||
}
|
||
|
||
/**
|
||
* Lets an Envelope that started at `startedAt` fall away from `at`, from the level it has by then –
|
||
* mid-attack too – to silence in its release. Exported for the same check (test hook).
|
||
*/
|
||
export function releaseEnvelope(
|
||
level: AudioParam,
|
||
envelope: FxEnvelope,
|
||
startedAt: number,
|
||
at: number,
|
||
): void {
|
||
const held = level as Omit<AudioParam, 'cancelAndHoldAtTime'> &
|
||
Partial<Pick<AudioParam, 'cancelAndHoldAtTime'>>;
|
||
if (held.cancelAndHoldAtTime) held.cancelAndHoldAtTime(at);
|
||
else {
|
||
// Firefox: the level by then is known, the curve up to it is what was planned.
|
||
const reached = envelopeLevel(envelope, at - startedAt);
|
||
level.cancelScheduledValues(at);
|
||
if (at - startedAt < envelope.attack) level.linearRampToValueAtTime(reached, at);
|
||
else level.setValueAtTime(reached, at);
|
||
}
|
||
level.setTargetAtTime(0, at, releaseTime(envelope) / SETTLE);
|
||
}
|
||
|
||
/**
|
||
* A Compressor (#326): Web Audio's node, and a gain after it for makeup and auto-gain. Its times
|
||
* move in ratios, everything in dB as it is.
|
||
*/
|
||
function compressNodes(context: BaseAudioContext, stage: FxCompress): StageNodes {
|
||
const node = context.createDynamicsCompressor();
|
||
node.threshold.value = stage.threshold;
|
||
node.knee.value = stage.knee;
|
||
node.ratio.value = stage.ratio;
|
||
node.attack.value = stage.attack;
|
||
node.release.value = stage.release;
|
||
const gain = gainNode(context, stage.gain);
|
||
node.connect(gain);
|
||
return {
|
||
input: node,
|
||
output: gain,
|
||
knobs: [
|
||
{ param: node.threshold, target: stage.threshold, even: 'value' },
|
||
{ param: node.knee, target: stage.knee, even: 'value' },
|
||
{ param: node.ratio, target: stage.ratio, even: 'ratio' },
|
||
{ param: node.attack, target: stage.attack, even: 'ratio' },
|
||
{ param: node.release, target: stage.release, even: 'ratio' },
|
||
{ param: gain.gain, target: stage.gain, even: 'ratio' },
|
||
],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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 = passQ('lowpass', 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;
|
||
}
|
||
|
||
/** A node's sound held back `seconds` by a delay line after it, or the node itself for none. */
|
||
function heldBack(context: BaseAudioContext, node: AudioNode, seconds: number): AudioNode {
|
||
if (!(seconds > 1e-9)) return node;
|
||
const line = context.createDelay(seconds);
|
||
line.delayTime.value = seconds;
|
||
return node.connect(line);
|
||
}
|
||
|
||
/** Sends a level to silence in `FADE` seconds from wherever it is now. */
|
||
function fadeOut(param: AudioParam, now: number): void {
|
||
param.cancelScheduledValues(now);
|
||
param.setValueAtTime(param.value, now);
|
||
param.linearRampToValueAtTime(0, now + FADE);
|
||
}
|
||
|
||
/**
|
||
* Sends one knob from where it is right now to `to`. Whatever was still planned for it is dropped
|
||
* first, so a change never queues behind an older one. A timed change runs along `ease` – a `ratio`
|
||
* one in its logarithm, which is what decibels are. Linear and step are the clock's
|
||
* own ramps, every other easing is handed over as a curve of samples.
|
||
*/
|
||
function move(
|
||
param: AudioParam,
|
||
to: number,
|
||
now: number,
|
||
time: number,
|
||
ratio: boolean,
|
||
ease: EaseShare = 'linear',
|
||
): void {
|
||
const from = param.value;
|
||
// Firefox has no cancelAndHoldAtTime: there the value it reads out now is held by hand.
|
||
const held = param as Omit<AudioParam, 'cancelAndHoldAtTime'> &
|
||
Partial<Pick<AudioParam, 'cancelAndHoldAtTime'>>;
|
||
if (held.cancelAndHoldAtTime) held.cancelAndHoldAtTime(now);
|
||
else {
|
||
param.cancelScheduledValues(now);
|
||
param.setValueAtTime(from, now);
|
||
}
|
||
const logarithmic = ratio && from > 0 && to > 0;
|
||
if (time <= 0) param.setTargetAtTime(to, now, GLIDE);
|
||
else if (ease === 'step') param.setValueAtTime(to, now + time);
|
||
else if (ease === 'linear' && logarithmic) param.exponentialRampToValueAtTime(to, now + time);
|
||
else if (ease === 'linear') param.linearRampToValueAtTime(to, now + time);
|
||
else param.setValueCurveAtTime(easedCurve(from, to, time, logarithmic, ease), now, time);
|
||
}
|
||
|
||
/** Sends each knob to its level, as `glide` says; whether any of them had somewhere to go. */
|
||
function glideKnobs(
|
||
knobs: readonly Knob[],
|
||
levels: readonly number[],
|
||
now: number,
|
||
time: number,
|
||
ease: EaseShare,
|
||
): boolean {
|
||
let moved = false;
|
||
knobs.forEach((knob, i) => {
|
||
const level = levels[i];
|
||
if (level === undefined || level === knob.target) return;
|
||
knob.target = level;
|
||
moved = true;
|
||
if (knob.param) move(knob.param, level, now, time, knob.even === 'ratio', ease);
|
||
});
|
||
return moved;
|
||
}
|
||
|
||
/** Samples per second of an eased curve: a bounce keeps its corners, a long sweep stays small. */
|
||
const CURVE_RATE = 200;
|
||
|
||
function easedCurve(
|
||
from: number,
|
||
to: number,
|
||
time: number,
|
||
logarithmic: boolean,
|
||
share: (t: number) => number,
|
||
): Float32Array {
|
||
const count = Math.min(Math.max(Math.ceil(time * CURVE_RATE) + 1, 16), 4096);
|
||
const curve = new Float32Array(count);
|
||
for (let i = 0; i < count; i++) {
|
||
const s = share(i / (count - 1));
|
||
curve[i] = logarithmic ? from * (to / from) ** s : from + (to - from) * s;
|
||
}
|
||
return curve;
|
||
}
|
||
|
||
/** The numbers of a chain in the order `wire` makes their knobs: a stage's `gain` is what its node holds. */
|
||
function levelsOf(chain: FxChain, into: number[] = []): number[] {
|
||
if ('mix' in chain && chain.curve === 'both') {
|
||
levelsOf(chain.a, into);
|
||
levelsOf(chain.b, into);
|
||
into.push(chain.mix);
|
||
} else if ('mix' in chain) {
|
||
levelsOf(chain.a, into);
|
||
into.push(1 - chain.mix);
|
||
levelsOf(chain.b, into);
|
||
into.push(chain.mix);
|
||
} else {
|
||
if (chain.shifted) into.push(chain.pitch);
|
||
if (chain.chip) into.push(chain.chip.frequency, chain.chip.duty);
|
||
}
|
||
for (const stage of chain.stages)
|
||
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, stage.gain);
|
||
else if (stage.kind === 'crush') into.push(stage.bits, stage.rate, stage.mix);
|
||
else if (stage.kind === 'phaser')
|
||
into.push(stage.frequency, stage.rate, stage.depth, stage.feedback, stage.mix);
|
||
else if (stage.kind === 'flanger')
|
||
into.push(stage.offset, stage.sweep, stage.rate, stage.depth, stage.feedback, stage.mix);
|
||
else if (stage.kind === 'pan') into.push(stage.pan);
|
||
else if (stage.kind === 'compress')
|
||
into.push(stage.threshold, stage.knee, stage.ratio, stage.attack, stage.release, stage.gain);
|
||
else if (stage.kind === 'limit') into.push(stage.threshold, stage.ceiling, stage.release, stage.autoGain);
|
||
else if (stage.kind !== 'envelope') into.push(stage.gain);
|
||
return into;
|
||
}
|
||
|
||
export const soundLibrary = new SoundLibrary();
|