# Music · zvuk

zvuk v1.14.1 · MIT · https://zvuk.schmooky.dev/concepts/music/

Stinger, loop, outro, with skip-to-outro at the natural loop boundary.

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`](/api/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`](/guides/asset-resolution/) 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`](/concepts/voice/) 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](/concepts/sound/) or [Sprite](/concepts/sound/) 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`](/guides/loading/), which routes through `HTMLAudioElement` and `MediaElementAudioSource` so memory stays flat.
