Audio
The audio subsystem layout, locked vs. unlocked gesture state, and web audio constraints.
Bus volume, mute, and worked examples live in API: Audio. This guide maps the internal subsystem, the locked/unlocked gesture state, and the web platform constraints that shape it.
Subsystem layout
src/audio/
AudioManager.ts # Web Audio context, bus graph, unlock state machine, mute/volume, SFX + music playback
audioDecodeContext.ts # Decode-context registry AudioClip reads from to decode
VoicePool.ts # Fixed-size SFX voice pool: allocation/stealing, generational refs, per-voice fades
MusicPlayer.ts # Crossfading music player: current/previous voice pair, fadeMs/overlap timing, loop points
src/assets/
AudioClip.ts # Decoded AudioBuffer asset: streamed fetch+decode, cache/dedup, fallback URL listsAudioManager is owned by the internal BTAPI singleton (created and torn down alongside pointer, keyboard, and
gamepad input) and is never exposed to demo code directly - only through the BT.audio*/BT.sound*/BT.music* methods
and the BT.isAudioUnlocked/BT.isMusicPlaying getters documented in API: Audio. On attach(),
AudioManager registers its Web Audio context in audioDecodeContext.ts, a small bridge that AudioClip reads from to
decode audio data without needing a direct reference to AudioManager itself.
AudioClip (see Loading) lives alongside the other asset loaders in src/assets/ and uses that
bridge to decode fetched audio bytes into a reusable AudioBuffer.
Tests mock the Web Audio API with src/__test__/webaudio-mock.ts (including a configurable decodeAudioData for
AudioClip tests), since neither Node.js nor happy-dom implement it.
Locked vs. unlocked
| State | What that means |
|---|---|
| Locked (default until a gesture) | BT.isAudioUnlocked is false. audioVolumeSet/audioMuteSet calls still update engine-side state, but the browser's audio context is suspended, so nothing is audible yet. |
| Unlocked | BT.isAudioUnlocked is true. Set once AudioContext.resume() resolves after the first pointerdown, pointerup, touchend, or keydown on the canvas. Stays unlocked for the rest of the session. |
The engine listens for all four gesture types at once and removes the listeners as soon as one of them succeeds, so
whichever input method a player uses first (mouse, keyboard, or touch) unlocks audio. A mouse press or a key press
unlocks immediately; a tap unlocks when the finger lifts, because browsers do not count touchstart or a touch
pointerdown as a user activation, only touchend / pointerup.
Parked during a seek
While BT.renderAt holds the game loop stopped, the audio context is
suspended so nothing plays out of step with a frozen clock. A gesture that unlocks audio mid-seek flips
BT.isAudioUnlocked but leaves the context suspended until BT.resume() restarts the loop; a context that was still
locked stays locked until the next gesture, as without a seek.
Usage example
import { , type IBTDemo, } from 'blit386';
class implements IBTDemo {
async () {
.('music', 0.6);
.('sfx', 0.9);
return true;
}
() {}
() {
if (!.) {
.(new (8, 8), 2, 'Click or press a key to enable audio');
}
}
}Preloading audio clips
AudioClip.load() downloads and decodes audio even while the context is locked (suspended, pre-gesture) - decoding only
needs a registered context, not an unlocked one. That makes a title screen or a loading state a good place to preload
every clip a level needs, so they're ready the instant the player's first gesture unlocks audio. Poll
BT.loadingAssetsCount to show progress while image and audio loads are still in
flight.
import { , type IBTDemo } from 'blit386';
class implements IBTDemo {
async () {
// Decodes now, even before the player's first gesture unlocks the context.
await .(['audio/theme.mp3', ['audio/hit.ogg', 'audio/hit.mp3']]);
return true;
}
() {}
() {}
}Prefer a fallback list (for example ['theme.ogg', 'theme.mp3']) for any clip whose primary format might not decode in
every browser - see Audio formats for the current per-browser matrix.
Web audio constraints
Every major browser enforces an autoplay policy: an AudioContext starts 'suspended' and stays that way until a user
gesture calls resume(). This is a platform rule, not something BLIT386 can configure around - there is no flag to
start audio unlocked, and none is planned.
- Volume and mute calls made before the gesture are not lost; they update the engine's internal bus state and take effect immediately once the context resumes.
- The gesture requirement is independent of the render backend. Unlocking audio has nothing to do with
BT.activeBackendor the WebGPU/Canvas 2D fallback described in Browser Support - a demo can be fully unlocked on the software renderer, or fully locked on WebGPU. - Loading and decoding clips is implemented via
AudioClip(see Loading) and works regardless of lock state.BT.soundPlay(see Playback (SFX)) behaves differently from a pre-unlock bus call:playSound()drops the request entirely before unlock (no voice allocated, no throw, counted as a dropped SFX request), whereAudioManager.volumeSet()(BT.audioVolumeSet) still updates the engine's internal bus state and applies once the context resumes - it is only inaudible while locked, not dropped. BT.musicPlay(see Playback (Music)) sits between those two behaviors: a pre-unlock call isn't dropped likeBT.soundPlay, but it isn't merely inaudible-and-applied like a bus call either - it is remembered and starts automatically the instant the context unlocks. CallingBT.musicPlayagain while still locked replaces the remembered request, so only the last call before unlock actually plays.
Playing SFX
BT.soundPlay routes every SFX voice through the 'sfx' bus (see Subsystem layout above), so
BT.audioVolumeSet('sfx', ...) and BT.audioMuteSet('sfx', ...) affect every currently-playing sound at once, on top
of each sound's own per-voice volume from BT.soundVolumeSet.
The pool itself (src/audio/VoicePool.ts) is a fixed-size array sized by HardwareSettings.audioVoices - it never
grows at runtime. Each BT.soundPlay call either claims a free slot or steals the lowest-priority active voice at or
below the incoming priority; see Playback (SFX) for the exact policy and a worked example.
This cap exists because each slot is a real AudioBufferSourceNode -> GainNode -> StereoPannerNode chain - letting the
pool grow unbounded would let a busy scene (an explosion with dozens of debris impacts, for example) spend unbounded CPU
on nodes the player can't meaningfully hear over each other anyway.
To see the pool cap and stealing policy in action instead of reasoning about it in the abstract, enable
isOverlayAudioMetersEnabled: true - the overlay's audio meter band shows live main/music/sfx bus levels plus a
voices used/total, steal, and drop readout. See Audio meters in the Overlay API
reference.
A sound that plays often (footsteps, hits, bullet casings) reads as repetitive at a fixed pitch - vary it slightly per
play instead (call BT.randomSeed with the same seed, and keep the random-call order, to reproduce a run):
import { , } from 'blit386';
const = await .('audio/footstep.mp3');
function () {
const = ..(0.9, 1.1); // 0.9-1.1x
.(, { , : 0.6 });
}Playing music
BT.musicPlay (see Playback (Music)) drives a single looping music player through the
'music' bus - there is no per-track handle to manage like SoundRef, and a second call crossfades from whatever is
currently playing into the new track instead of layering the two. Two patterns cover most games: switching tracks by
game state, and looping a region after a one-shot intro.
Switching music by game state
The common shape: preload every track a scene needs, then call BT.musicPlay from wherever your game logic already
transitions state, guarding against re-triggering the crossfade while a state is merely held:
import { , } from 'blit386';
type = 'menu' | 'exploring' | 'combat';
const = {
: await .('audio/menu.mp3'),
: await .('audio/exploring.mp3'),
: await .('audio/combat.mp3'),
};
let : = 'menu';
function (: ) {
if ( === ) {
return; // holding a state shouldn't retrigger the crossfade every frame
}
= ;
.([], { : 1000 }); // simultaneous crossfade (default overlap)
}Reach for overlap: 0 instead of the default simultaneous crossfade when the outgoing track should finish cleanly
before the new one starts (a menu jingle that shouldn't blend into gameplay music), or overlap: -1 for a deliberate
beat of silence between tracks (a boss intro sting) - see Crossfading for the exact timing
math.
Intro then loop
A theme with a distinct opening flourish sounds wrong repeating on every loop. loopStart/loopEnd let the intro play
once while only a later region repeats:
import { , } from 'blit386';
const = await .('audio/theme.mp3');
// The first 8 seconds play once, then the engine loops the 8-32s region forever.
.(, { : 8, : 32 });loopStart and loopEnd must be given together and satisfy 0 <= loopStart < loopEnd <= duration - see
Loop points for the exact validation rule and its error.
Design a sound
Beyond loading audio files, AudioClip.synth generates a clip entirely on the CPU from a SynthParams descriptor - no
source file, no network fetch, and no OfflineAudioContext. Rendering is deterministic and synchronous: the same params
(including seed) always produce the same samples, so a SynthParams value behaves like ordinary game data rather than
a recorded asset. See Synth for the full field reference.
Start by hand-tuning a few fields and listening to the result:
import { , } from 'blit386';
const = await .({
: 'sawtooth',
: 1200,
: 0.18,
: { : 200 },
: { : 0, : 0.02, : 0.6, : 0.1 },
: 1,
});
.();BT.synthPreset bundles ready-made starting points for common effects (jump, pickup, explosion, laser, hit,
blip) - see Presets for the full list. Passing a different seed to a preset applies small,
bounded jitter to a couple of its fields, so a repeated sound (footsteps, hits, pickups) doesn't sound identical every
time, while staying reproducible for any given seed:
import { , } from 'blit386';
// A distinct footstep every step, but the same sequence of seeds always sounds the same.
async function (: number) {
const = await .(..());
.(, { : 0.5 });
}Because SynthParams is plain data (numbers, strings, and nested objects - no functions or class instances), a tuned
sound can be stored, checked into a level file, or embedded in a save alongside the rest of your game's JSON, then
handed to AudioClip.synth whenever it's needed:
import { , type SynthParams } from 'blit386';
const : SynthParams = {
: 'sawtooth',
: 90,
: 0.6,
: 0.85,
: { : 0, : 0.1, : 0.3, : 0.4 },
: 7,
};
// Round-trips losslessly through JSON, so it can live in a level file or save data.
const = .();
const = .() as SynthParams;
const = await .();API history
See also
API: Audio
API: Assets
API: Overlay
API: Browser Support
Input Guide
Last updated on October 18, 2026