Audio · class

AudioGraph

Explained in Your first game.

class AudioGraph
import { AudioGraph } from '@driftengine/audio';

Properties

NameTypeDescription
contextreadonlyBaseAudioContext
registryreadonlySoundRegistry

Accessors

NameTypeDescription
consolegetMixConsoleThe mix this graph plays into, for a caller that wants a bus of its own.
audiblegetbooleanWhether the context is actually producing sound.
More

A context built without a user gesture is suspended: nodes run, sources are scheduled, and nothing is heard. A caller that has something to say about that — an intro film with a score, say — needs to be able to ask.

playinggetbooleanWhether the stems are running.
heldgetbooleanWhether the transport is stopped mid-track, as opposed to not yet started.
startsInSecgetnumberSeconds until the scheduled start of whatever is playing, or 0 if it is already sounding.
More

Reads the instant launch scheduled, which is the only authority on when the stems begin: everything else about the transport is a consequence of it.

layoutgetDefaultLayoutThe layout this graph plays into: its buses, its inserts and its returns.
More

This is where the mix went in 3.0.0. Every setter this class used to carry — the two volumes, the lift, the slam, the master filter, the three sends and the delay — is a method on a bus or an insert now, and PORTING.md maps them one for one. They were removed rather than left forwarding, because a shim that works forever is a second answer to every question the console already answers, and the two would drift the first time one of them grew a clamp.

levelsgetMixLevelsThe two levels a player chose, read from the buses that hold them.
More

Derived rather than mirrored, which it was until 3.0.0. A mirror is a second place the answer is decided, and the reason the old one existed — that a level is ramped, so mid-ramp the parameter is between two values — is answered by the bus itself keeping its own fader setting. A duck does not move it, which is the distinction fadeMusic needed a paragraph for.

Methods

createstatic

static create(options: AudioGraphOptions): Promise<AudioGraph | null>

Build a graph, or return null only if the browser has no audio to give.

ParameterTypeDescription
optionsAudioGraphOptions
More

Null means "this browser will not do audio at all", never "not yet". The distinction is the whole of a bug that silenced audio on mobile devices, both Android and iOS: this used to await context.resume() inside the try, so a browser that rejects that call — which is what a rejection means when autoplay is blocked — threw a perfectly good graph into the catch and reported no audio. The caller latches its load so it happens once, so that null was permanent: silence for the session, with a wake() that had nothing left to wake.

A suspended context is not a failure. Its clock does not advance, so nothing scheduled on it is missed, and wake() exists to start it on the first gesture. The resume is still attempted here, because when this is called from a gesture — or on a site the browser already trusts — it starts immediately and there is no reason to wait for a tap that already happened. It is just no longer awaited, and no longer fatal.

Desktop cannot show you this. Chrome grants autoplay to a site its user keeps visiting, so on the machine this game is built on the context comes up already running. It only breaks on a device that has not earned that trust, which is every phone arriving from a share link.

wake

wake(): void

Ask the browser to start the context, if it will.

More

Safe to call from anywhere and safe to call repeatedly: outside a gesture the promise simply rejects, which is not an error state — it is the policy working. Anything already scheduled begins when it succeeds, because a suspended context's clock does not advance, so nothing is missed in the meantime.

loadStem

loadStem(index: number, buffer: AudioBuffer): void
ParameterTypeDescription
indexnumber
bufferAudioBuffer

start

start(): void

Start every stem at one scheduled instant.

More

Layers must be sample-locked: started independently they drift apart by however long each start() call happened to take, and a bassline a few milliseconds off its drums is heard as flamming rather than as one track.

hold

hold(): void

Stop the stems where they are.

More

The tape stops — this is a pause, not a duck, and the distinction is what several rounds of feedback kept correcting toward: the pause itself was right, it only ever needed a longer tail of effects to keep the music in the background. So the source stops and the sends carry what was already in flight. Reverb and delay live downstream of the stems, so cutting the source is exactly what leaves a decaying tail behind, and setLongReverbSend is how far that tail reaches.

Idempotent: the caller is a per-frame mix that knows a state, not an event.

release

release(): void

Start the stems again from where hold left them.

More

From where it left them, rather than from where the tape would have been: a pause that catches up is a jump cut, and on a long glide it is audible as the track skipping. The cost is that airborne time puts the score behind the route's bar grid — a real trade, taken deliberately, because the hold is felt on every jump and the grid is felt once at the start line.

restart

restart(fromSec?: number): number

Stop the stems and start them again: from the top, or from fromSec into the track.

ParameterTypeDescription
fromSec?number
More

A BufferSource cannot be rewound — the spec makes it one-shot — so starting over means discarding the sources and creating new ones. That is cheap: a source node is a handle onto a buffer that is already decoded and already resident, and nothing about the graph downstream of it is rebuilt.

Exists because a caller needs the track's beat zero to coincide with something in its own world. Left running instead, a loop's downbeats land somewhere different on every attempt.

fromSec is a seek, for music held to a clock of its own: a demo's frame counter or a replay's tick has to resume the score where that clock is after every skip, and every stem starts there at one instant, wrapped by its own length because the stems loop. A caller that ran its own source into the music bus instead had a track outside every stem's lock. Anything not a positive number is the top.

Returns how long until that place is actually heard, in seconds, because launch schedules a little ahead of now and a caller lining a picture up against the music needs that number rather than an assumption. Zero when nothing started.

at

at(seconds: number | null): void

Schedule everything that follows at seconds on this context's timeline, or at "now" when null.

ParameterTypeDescription
secondsnumber | null
More

An offline render sets it once per frame and gets a mix whose every move lands where the picture is, exactly, with no clock involved. Live callers never touch it.

setStemGain

setStemGain(index: number, gain: number): void
ParameterTypeDescription
indexnumber
gainnumber

setPlaybackRate

setPlaybackRate(rate: number): void
ParameterTypeDescription
ratenumber

createLoop

createLoop(buffer: AudioBuffer | undefined): AmbientLoop | null

Start a looping environmental bed, silent until the caller gives it a level. Routed through the effects stage, so the effects slider governs the world's own sound and the music slider governs only the score.

ParameterTypeDescription
bufferAudioBuffer | undefined
More

Returns null for a missing buffer, so a caller can create loops unconditionally and let an unresolved slot simply be silent.

createKickDetector

createKickDetector(): KickDetector | null

A live kick detector listening to the music.

More

Tapped off the music stage rather than the master bus, so it hears the track and not the game's own sound effects — a splash landing on the beat would otherwise read as a kick and flash the world.

The taps are pure observers: nothing is connected onward from them, so inserting a detector cannot change what anyone hears.

captureStream

captureStream(): MediaStream | null

A stream of everything the player is hearing, for a clip recording.

More

Tapped off the mix rather than replacing the destination, so recording cannot silence the game — a clip that captures perfectly while the player hears nothing is a bug they would report as "the export broke the sound".

Off out, which is the whole mix, and not off master, which is the dry path. The send returns rejoin downstream of the master filter, so a tap on master hears the track and the speed filter and nothing wet at all. See out.

The alignment of this against the video is not ours to fix, and that was measured rather than assumed. A probe that flashed one frame white while scheduling a click at the same instant, decoded back out of the file, found the audio leading the picture by a mean of 51 ms in one run and 85 ms in the next, with outputLatency reporting 0.048 then 0.024. Feeding the tap through a delay does move it — a forced 200 ms landed at +132 ms, near one for one — but there is no constant to use: correcting by an unstable reading made a run worse, from -51 to -85. MediaRecorder aligns its tracks by when data reached it, and nothing here can see that. The offline path exists because it never asks this question.

The same tap every time. Building one per recording left the last one connected and running.

Null while the context is not running, audible false. A suspended context's stream is a live track that never carries a sample, and a recorder given one stalls on it: a recording started before the page's first gesture kept 19 frames of 120 and no sound. Asked again once the context runs, it answers the tap.

play

play(buffer: AudioBuffer | undefined, gain?: number, pan?: number): void

Fire a one-shot. Routed so it sits under the same master filter and sends.

ParameterTypeDescription
bufferAudioBuffer | undefined
gain?number
pan?number
More

pan places it across the stereo field (-1 to 1); pass the result of stereoPan. Omitted, the sound is centred, which is right for anything that happens to the player rather than somewhere near them.

dispose

dispose(): void