Concept

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

The Music and MusicVoice interface ts
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.

ts ts
// 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

ts ts
// 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.

ts ts
// 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.

ts ts
// 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.

ts ts
// 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

Don't use Music for short SFX.
Each 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.
Don't use Music for tracks over 30 s.
All three parts decode into RAM, at four bytes per sample per channel. For long tracks, use loadStream, which routes through HTMLAudioElement and MediaElementAudioSource so memory stays flat.