BLIT386BLIT386

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 lists

AudioManager 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

StateWhat 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.
UnlockedBT.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.activeBackend or 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), where AudioManager.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 like BT.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. Calling BT.musicPlay again 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

Last updated on October 18, 2026

On this page