Music
Stinger, loop, outro, with skip-to-outro at the natural loop boundary.
The player wins, you stop the combat track, and it cuts off half a bar into a phrase. Nobody can say what is wrong, but everyone can hear it.
A Music source is a three-part
asset. There is an optional intro, a mandatory loop, and an optional
outro. The
intro plays once, the loop body runs until told otherwise, and the outro
fires at the next loop boundary so the track ends on the beat instead of
wherever the stop happened to land. It is the shape Wwise and FMOD have
used for interactive music for twenty years.
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 takes the same shape as loadSound's second
argument, either a single URL or a codec ladder. All three go through the same
decoder cache and the same resolveAsset
hook, and they are fetched in parallel.
// 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; Why is my music cutting off mid-phrase?
Because stop() is a click-free cut, not a musical one. It
fades over a few milliseconds and ends. When you want the track to
finish a phrase, call skipToOutro({ at: 'loop-end' }) and let
the loop reach its boundary first.
// 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
Without an outro, skipToOutro() falls through to
a clean stop, so calling code never has 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 behaves the same way
as PlayOptions.loopCrossfade
on a regular voice. One crossfade window before each loop boundary it
spawns a parallel buffer source and equal-power ramps between them. Off by
default; set a non-zero value 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 over 30 s.loadStream,
which routes through HTMLAudioElement and
MediaElementAudioSource so memory stays flat.