Music
Stinger → loop → outro, with skip-to-outro at the natural loop boundary.
TL;DR
A Music source is a three-part asset — optional intro,
mandatory loop, optional outro — that plays the way game music actually
works: stinger plays once, loop body runs forever, and (on cue) the
outro fires at the next loop boundary so the music ends musically
instead of cutting off mid-bar.
API surface
class Music {
readonly name: string;
readonly loopDuration: number;
readonly hasIntro: boolean;
readonly hasOutro: boolean;
play(options?: { volume?: number; fadeIn?: number }): MusicVoice;
}
class MusicVoice {
readonly currentPart: 'intro' | 'loop' | 'outro' | 'ended';
readonly ended: Promise<void>;
fade(opts: { to: number; duration: number; curve?: FadeCurve }): Promise<void>;
stop(opts?: { fade?: number }): void;
skipToOutro(opts?: { at?: 'loop-end' | 'now' }): void;
} Load a three-part asset
Each part accepts the same shape as loadSound's second
argument: a single URL or a codec ladder. Loading goes through the same
decoder cache and the same resolveAsset
hook, so codec fallback and external asset systems apply per part.
// engine.loadMusic — three parts; codec ladders allowed per part.
await engine.loadMusic('boss-theme', {
intro: ['/music/boss-intro.webm', '/music/boss-intro.m4a'],
loop: ['/music/boss-loop.webm', '/music/boss-loop.m4a'],
outro: ['/music/boss-outro.webm', '/music/boss-outro.m4a'],
}, { bus: 'music', loopCrossfade: 0.05 }); Play it
// Start the music. The intro plays once, then the loop runs forever.
const m = engine.music('boss-theme').play({ volume: 0.7, fadeIn: 0.2 });
// Later, when the user clears the room:
m.skipToOutro(); // → finishes current loop iteration, then plays outro,
// then resolves m.ended.
await m.ended; How to end it
// Two ways to end the music.
//
// 'loop-end' (default) — wait for the current loop iteration to complete,
// then play the outro at the natural loop boundary. The musical answer.
m.skipToOutro({ at: 'loop-end' });
// 'now' — fade the loop out (~50 ms) and start the outro immediately.
// Less musical, more responsive — right call for "user pressed Stop."
m.skipToOutro({ at: 'now' });
// Hard cut, no outro.
m.stop(); // ~8 ms click-free fade
m.stop({ fade: 0 }); // immediate
m.stop({ fade: 1 }); // 1-second fade-out Outro is optional
If you skip outro, skipToOutro() falls through
to a clean stop — calling code doesn't need to branch on
music.hasOutro.
// Outro is optional. Loop-only assets work fine.
await engine.loadMusic('menu', { loop: '/music/menu.webm' }, { bus: 'music' });
const m = engine.music('menu').play({ fadeIn: 0.3 });
m.skipToOutro(); // no outro buffer → falls through to a clean fade-out Click-free loops
loopCrossfade on loadMusic works the same way
as PlayOptions.loopCrossfade
on a regular Voice: spawns a parallel buffer source one crossfade-window
before each loop boundary and equal-power-ramps between them. Off by
default; set non-zero to opt in.
// Click-free loop boundaries with loopCrossfade.
await engine.loadMusic('combat', {
loop: '/music/combat-loop.webm',
}, { bus: 'music', loopCrossfade: 0.05 });
// Same trick as PlayOptions.loopCrossfade on a Voice — a parallel buffer
// source spawns one crossfade-window before each boundary and equal-power
// ramps between them. Off by default; set > 0 to opt in. Pitfalls
Music for short SFX.play() spawns a fresh chain of buffer-sources and a
couple of timers. For one-shot clicks and stingers, Sound
or Sprite is cheaper and the right shape.
Music for tracks > 30 s.loadStream
instead — it routes through HTMLAudioElement + MediaElementAudioSource
so memory stays flat.