BLIT386BLIT386

Audio

Audio buses, SFX voice pool, crossfading music, and procedural synthesis.

Bus volume, mute, and the browser autoplay-unlock state. For the higher-level subsystem walkthrough (bus graph layout, locked vs. unlocked, and web autoplay constraints), see the Audio Guide.

The audio graph has three buses: 'sfx' and 'music' feed into 'main', which feeds the browser's audio destination. All three are independently controllable.

sfx    ─┐
music  ─┼─► main ─► destination

AudioBus is the type of a bus name:

AudioBus Since 1.3.0
declare const : ; // 'main' | 'music' | 'sfx'

Volume

BT.audioVolumeSet Since 1.3.0 BT.audioVolumeGet Since 1.3.0

BT.audioVolumeSet sets a bus's volume, optionally fading to it. BT.audioVolumeGet reads it back.

.('music', 0.5); // immediate change
.('sfx', 0, { : 300 }); // linear fade to silence over 300 ms
.('main', 0.8, { : 500, : 'ease-out' }); // eased fade

.('music'); // 0.5

options fields:

Prop

Type

With no fadeMs, the bus gain changes immediately. With fadeMs, 'linear' easing schedules a linear ramp; any other easing curve is sampled into a value curve so the fade follows that curve rather than a straight line.

Mute

BT.audioMuteSet Since 1.3.0 BT.isAudioMuted Since 1.3.0

BT.audioMuteSet mutes or unmutes a bus without touching its configured volume. BT.isAudioMuted reports the current mute state.

.('music', 0.75);

.('music', true); // silences the bus immediately (no fade)
.('music'); // true
.('music'); // still 0.75 - muting never overwrites the configured level

.('music', false); // restores audio at 0.75

BT.audioVolumeGet always returns the logical (pre-mute) volume, so reading it while muted still reports the level you configured, not 0.

Unlock state

BT.isAudioUnlocked Since 1.3.0

Browsers block audio playback until a user gesture. BT.isAudioUnlocked is false until the first pointerdown, pointerup, touchend, or keydown on the canvas successfully resumes the audio context, and stays true for the rest of the session. On a touch screen that is the end of the first tap, since touchstart is not a user activation.

if (!.) {
  // Show a "click or press a key to enable audio" prompt.
}

See Web audio constraints for what happens to volume/mute calls made before the gesture, and why no configure flag can skip this requirement.

A BT.renderAt seek suspends the context while the loop is stopped (a gesture that unlocks audio mid-seek stays suspended too) and drops BT.soundPlay calls made by stepped updates; BT.resume() brings it back. See Seeking a frame with renderAt.

Loading

AudioClip Since 1.3.0

AudioClip decodes an audio file into a reusable AudioBuffer, exposing the winning source URL, duration, and sample rate. Loading and decoding work even while the audio context is locked (suspended, pre-gesture) - only real-time playback needs an unlocked context. See Preloading audio clips.

import {  } from 'blit386';

// Load a single clip (cached by its resolved URL)
const  = await .('audio/theme.mp3');

// Fallback list: tries each URL in order, resolving with the first that decodes
const  = await .(['audio/hit.ogg', 'audio/hit.mp3']);

// Load multiple clips in parallel - each entry is a single URL or a fallback list
const  = await .(['audio/theme.mp3', ['audio/hit.ogg', 'audio/hit.mp3']]);

// Check cache before loading
if (.('audio/theme.mp3')) {
  // already cached
}

// Number of clip loads currently in flight
.;

For a single loading-screen signal that also covers sprite sheet images, see BT.loadingAssetsCount.

Pass onProgress to report phased load progress:

await .('audio/theme.mp3', {
  : () => {
    .(., .);
  },
});

Prop

Type

Release a clip's decoded buffer with unload() once you no longer need it:

.(); // releases the decoded buffer; safe to call more than once
  • AudioClip.load() and loadAll() throw a beginner-friendly error covering network/CORS failures, HTTP status errors, unsupported container/codec decode failures, and loading before the engine has started.
  • 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.
  • Playing a loaded clip back as SFX is covered in Playback (SFX) below; as looping, crossfading music in Playback (Music).

Under a Vite dev server with the blit386/vite plugin installed, editing an audio file under a watched asset directory (public/ by default) swaps the decoded buffer inside the same AudioClip instance - demo-held references stay valid. Any SFX voice still playing the old buffer is stopped; if the replaced clip is the currently playing music track, playback restarts immediately (fadeMs: 0, no crossfade). duration/sampleRate reflect the buffer decoded at initial load and are not updated by a hot reload. See Hot Reload for the full asset type matrix.

Synth

SynthParams Since 1.3.0

AudioClip.synth renders a clip procedurally from a SynthParams descriptor - no source file, no network request, and no OfflineAudioContext. Rendering runs entirely on the CPU and is deterministic: identical params (including seed) always produce identical sample data, so a SynthParams object can be stored and replayed as a preset. Unlike Loading, there is nothing to fetch or decode from the network - a synthesized clip is ready the instant synth() resolves, even before BT.isAudioUnlocked is true (see Unlock state above).

import {  } from 'blit386';

const  = await .({
  : 'sawtooth',
  : 880,
  : 0.3,
  : { : 110 },
  : 1,
});

SynthParams fields:

Prop

Type

SynthEnvelope Since 1.3.0

SynthEnvelope fields (all optional, times in seconds):

Prop

Type

The release phase always finishes exactly at duration, even on a very short clip where the attack, decay, and release phases overlap - a percussive hit is never cut off mid-fade.

SynthPitchSweep Since 1.3.0

SynthPitchSweep fields:

Prop

Type

SynthVibrato Since 1.3.0

SynthVibrato fields (both optional):

Prop

Type

import { ,  } from 'blit386';

// A short, noisy boom: mostly noise, with a touch of low square tone, quick attack and decay.
const  = await .({
  : 'square',
  : 90,
  : 0.4,
  : 0.8,
  : { : 0, : 0.05, : 0.3, : 0.3 },
  : 42,
});

.(, { : 0.7 });

Not cached, not deduplicated

Unlike AudioClip.load(), AudioClip.synth() never populates the URL-keyed resolved cache and never deduplicates concurrent calls - every call renders a fresh, independent AudioBuffer, even for byte-identical params. unload() still works the same way (releases the buffer, stops any voice playing it), it just has no cache entry to clear.

Presets

BT.synthPreset Since 1.3.0

BT.synthPreset bundles six ready-made SynthParams factories for common sound effects: jump, pickup, explosion, laser, hit, blip. Each takes an optional seed and applies small, bounded, deterministic jitter to a couple of hand-picked fields (frequency, duration, or noise mix) - the same seed always renders the exact same variant, so a preset stays reproducible even though it varies from call to call.

import { ,  } from 'blit386';

// Same character every time - useful for a UI sound that should stay consistent.
const  = await .(..());

// A different seed per pickup keeps repeated collects from sounding robotic, while staying
// reproducible - a wall-clock seed like Date.now() would defeat that, since it can never be
// replayed the same way twice.
let  = 0;
const  = await .(..(++));

.();
.(, { : 0.8 });

Omitting seed (or passing 0) always renders the same baseline variant - useful when you want a preset's default character rather than per-play variation. See Playback (SFX) below for playing the resulting clip through the SFX voice pool, and Design a sound in the Audio Guide for a walkthrough of tuning SynthParams by hand and storing presets as data.

Playback (SFX)

BT.soundPlay Since 1.3.0 SoundRef Since 1.3.0 BT.soundStop Since 1.3.0 BT.isSoundPlaying Since 1.3.0

BT.soundPlay plays a loaded AudioClip through a fixed-size pool of SFX voices. Each call returns an opaque SoundRef handle - pass it to BT.soundStop and the per-sound volume, pitch, and pan controls.

import { ,  } from 'blit386';

const  = await .('audio/hit.mp3');

const  = .(, { : 0.8 });

.(); // true

.(, { : 200 }); // fades out over 200 ms
SoundPlayOptions Since 1.3.0

options fields:

Prop

Type

Voice cap and stealing

HardwareSettings.audioVoices (default 16) sizes a fixed pool of voices - it never grows. When every voice is in use, a new BT.soundPlay call steals the lowest-priority active voice at or below the incoming priority (ties broken by whichever voice started first). If every active voice outranks the incoming priority, the new sound is dropped silently: no throw, and the returned SoundRef is inert (BT.isSoundPlaying reports false for it immediately).

Set isOverlayAudioMetersEnabled: true to see active/total voices, steal count, and drop count live in the overlay instead of reasoning about the pool from code - see Audio meters.

A common pattern - vary pitch slightly per play so a repeated sound (footsteps, hits) doesn't sound robotic, using the seedable BT.random:

import { ,  } from 'blit386';

const  = await .('audio/footstep.mp3');

function () {
  const  = ..(0.9, 1.1); // 0.9-1.1x

  .(, { , : 0.6 });
}
BT.soundVolumeSet Since 1.3.0 BT.soundVolumeGet Since 1.3.0 BT.soundPitchSet Since 1.3.0 BT.soundPitchGet Since 1.3.0 BT.soundPanSet Since 1.3.0 BT.soundPanGet Since 1.3.0

Per-sound controls, all silent no-ops on a stale or invalid SoundRef (already stopped, stolen, or completed - never throws):

.(, 0.5, { : 100 });
.(); // 0.5

.(, 1.2);
.(); // 1.2

.(, -0.3);
.(); // -0.3

BT.soundPlay also returns an inert SoundRef (no throw) when clip hasn't finished loading yet, or was already released with clip.unload().

Playback (Music)

BT.musicPlay Since 1.3.0 BT.musicStop Since 1.3.0 BT.isMusicPlaying Since 1.3.0

BT.musicPlay plays a loaded AudioClip through a single looping music player, distinct from the SFX voice pool - there is no SoundRef to manage, and calling it again crossfades from whatever is currently playing into the new track rather than layering the two.

import { ,  } from 'blit386';

const  = await .('audio/theme.mp3');

.(, { : 0.8 });

.; // true

.({ : 500 }); // fades out over 500 ms
MusicPlayOptions Since 1.3.0

options fields:

Prop

Type

Crossfading

fadeMs sets how long each side of a crossfade takes; overlap sets how the two sides line up in time:

import { ,  } from 'blit386';

const  = await .('audio/calm.mp3');
const  = await .('audio/battle.mp3');

.();

// Later, when the fight starts:
.(, { : 800, : 1 }); // calm fades out while battle fades in, at the same time
.(, { : 800, : 0 }); // battle starts fading in exactly as calm finishes fading out
.(, { : 800, : -1 }); // an 800 ms silence gap between the two

Calling BT.musicPlay again before a crossfade finishes immediately cuts the track that was already fading out - only one crossfade is ever in flight, so rapid calls (menu navigation, quick scene changes) never pile up.

Loop points

Loop the whole track (the default), a one-shot, or a specific region such as a bridge that repeats while an intro plays only once:

import { ,  } from 'blit386';

const  = await .('audio/theme.mp3');

.(); // loops the whole track (default)
.(, { : false }); // plays once and stops
.(, { : 8, : 32 }); // an 8s intro, then loops the 8-32s region forever

loopStart and loopEnd must be given together, with 0 <= loopStart < loopEnd <= duration - BT.musicPlay throws a beginner-friendly error otherwise, since a mismatched pair is always a programmer mistake rather than something that can happen from normal play.

BT.musicVolumeSet Since 1.3.0 BT.musicVolumeGet Since 1.3.0

BT.musicVolumeSet sets the current track's gain, optionally fading to it; BT.musicVolumeGet reads back the last requested target (not a mid-fade instantaneous value):

.(0.4, { : 300 });
.(); // 0.4

Remembered while locked, not dropped

Unlike BT.soundPlay, a BT.musicPlay call made before BT.isAudioUnlocked is true is not dropped - the engine remembers the most recent pending request and starts it automatically the instant the context unlocks. Calling BT.musicPlay again while still locked replaces the remembered request; only the latest survives to unlock.

BT.musicPlay silently does nothing (no throw) when clip hasn't finished loading yet, or was already released with clip.unload() - the same as BT.soundPlay.

See Playing music in the Audio Guide for a state-based music switching pattern and an intro-then-loop recipe.

Hardware settings

audioVoices (default 16) caps the number of simultaneous SFX voices - see Playback (SFX) for the allocation and stealing policy. Documented in Hardware settings.

API history

SymbolSinceLast changedStatus
AudioBus1.3.0–stable
AudioClip1.3.0–stable
BT.audioMuteSet1.3.0–stable
BT.audioVolumeGet1.3.0–stable
BT.audioVolumeSet1.3.0–stable
BT.isAudioMuted1.3.0–stable
BT.isAudioUnlocked1.3.0–stable
BT.isMusicPlaying1.3.0–stable
BT.isSoundPlaying1.3.0–stable
BT.musicPlay1.3.0–stable
BT.musicStop1.3.0–stable
BT.musicVolumeGet1.3.0–stable
BT.musicVolumeSet1.3.0–stable
BT.soundPanGet1.3.0–stable
BT.soundPanSet1.3.0–stable
BT.soundPitchGet1.3.0–stable
BT.soundPitchSet1.3.0–stable
BT.soundPlay1.3.0–stable
BT.soundStop1.3.0–stable
BT.soundVolumeGet1.3.0–stable
BT.soundVolumeSet1.3.0–stable
BT.synthPreset1.3.0–stable
MusicPlayOptions1.3.0–stable
SoundPlayOptions1.3.0–stable
SoundRef1.3.0–stable
SynthEnvelope1.3.0–stable
SynthParams1.3.0–stable
SynthPitchSweep1.3.0–stable
SynthVibrato1.3.0–stable

1.3.0 - Mon Jul 13 2026

  • AddedAudioBus
  • AddedAudioClip
  • AddedBT.audioMuteSet
  • AddedBT.audioVolumeGet
  • AddedBT.audioVolumeSet
  • AddedBT.isAudioMuted
  • AddedBT.isAudioUnlocked
  • AddedBT.isMusicPlaying
  • AddedBT.isSoundPlaying
  • AddedBT.musicPlay
  • AddedBT.musicStop
  • AddedBT.musicVolumeGet
  • AddedBT.musicVolumeSet
  • AddedBT.soundPanGet
  • AddedBT.soundPanSet
  • AddedBT.soundPitchGet
  • AddedBT.soundPitchSet
  • AddedBT.soundPlay
  • AddedBT.soundStop
  • AddedBT.soundVolumeGet
  • AddedBT.soundVolumeSet
  • AddedBT.synthPreset
  • AddedMusicPlayOptions
  • AddedSoundPlayOptions
  • AddedSoundRef
  • AddedSynthEnvelope
  • AddedSynthParams
  • AddedSynthPitchSweep
  • AddedSynthVibrato

See also

Last updated on October 18, 2026

On this page