Concept

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

Music + MusicVoice — public 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 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.

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;

How to end it

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

If you skip outro, skipToOutro() falls through to a clean stop — calling code doesn't need 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 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.

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 > 30 s.
All three parts decode into RAM. For long tracks (background mixes, streaming radio), use loadStream instead — it routes through HTMLAudioElement + MediaElementAudioSource so memory stays flat.