# @yagejs/audio

Depends on `@yagejs/core`, `@pixi/sound`. Channel-based audio playback.

`AudioPlugin` loads `@pixi/sound` when it installs, so the package imports
outside a browser too — a level check or a test run in Node can import a module
that uses `sound()` or `SoundComponent`. A registration made before the plugin
installs is applied when it does.

## Setup

```ts yage-context="engine"
import { AudioPlugin } from "@yagejs/audio";

engine.use(
  new AudioPlugin({
    masterVolume: 1, // default: 1, finite 0–1
    channels: {
      sfx: { volume: 1 },
      music: { volume: 0.7 },
    },
    autoMuteOnBlur: true, // default: true — pause AudioContext on window blur
  }),
);
```

## Unlock & Tab Mute

Browsers suspend the `AudioContext` until the user interacts with the page. `@pixi/sound` already resumes it on the first pointer/touch gesture, so "play on click" works without extra setup. That means **music scheduled on page-load stays silent until first click** — not a bug, but surprising. Use `isUnlocked` / `onUnlock` to schedule autoplay that survives the delay:

```ts yage-context="scene-enter"
import { AudioManagerKey } from "@yagejs/audio";

const audio = this.use(AudioManagerKey);
const startMusic = () => {
  audio.play("music/title", { channel: "music", loop: true });
};

audio.isUnlocked(); // boolean — AudioContext.state === "running"
audio.onUnlock(startMusic);
audio.offUnlock(startMusic); // remove a pending listener (disposer from onUnlock also works)

audio.autoMuteOnBlur = true; // default true — toggles @pixi/sound's WebAudioContext.autoPause (suspends context on window blur)
```

- `onUnlock(cb)` fires synchronously if already unlocked; otherwise once on the first gesture that resumes the context. Returns a disposer.
- `isUnlocked()` is never flipped by `autoMuteOnBlur` — it is strictly the browser capability check.
- Pausing scenes on tab blur is a scene-lifecycle concern, not an audio one: use `SceneManager.autoPauseOnBlur` (see `core.md`).

## Asset Factory

```ts
import { sound } from "@yagejs/audio";

const CoinSfx = sound("assets/coin.wav");
// Add to scene preload: readonly preload = [CoinSfx];
```

## AudioManager

```ts yage-context="scene-enter"
import { AudioManagerKey, sound } from "@yagejs/audio";
import type { AudioPlayOptions } from "@yagejs/audio";

const CoinSfx = sound("assets/coin.wav");
const opts: AudioPlayOptions = { channel: "sfx" };
const audio = this.use(AudioManagerKey);

// Play — takes a `sound()` handle or the alias string it registers
const handle = audio.play(CoinSfx, {
  channel: "sfx",
  volume: 1,
  loop: false,
  speed: 1,
});
const next = audio.crossfade(handle, "music/next", {
  duration: 1.2,
  channel: "music",
  loop: true,
});
audio.playOnce(CoinSfx, opts); // skips playback if already playing
const request = audio.requestOnce(CoinSfx, opts); // one releasable request for shared playback
audio.playRandom([CoinSfx, "assets/step.wav"], opts); // random pick

request.active; // boolean
request.release({ fadeOut: 0.02 }); // fade only if this is the final owner

// SoundHandle
handle.playing; // boolean
handle.channel; // readonly mixer channel
handle.volume; // get/set, per-sound volume before the channel multiplier
handle.speed; // get/set
handle.paused; // get/set
handle.muted; // get/set
handle.fadeTo(0, { duration: 0.6, stopOnComplete: true }); // returns Process
handle.stop();

// Stop
audio.stop(handle);
audio.stopChannel("sfx");
audio.stopAll();

// Master and channel volume
audio.masterVolume = 0.5;
audio.masterVolume; // get/set, default: 1
audio.getChannelNames(); // readonly string[], fresh snapshot in creation order
audio.setChannelVolume("music", 0.5);
audio.getChannelVolume("music");

// Mute
audio.muteChannel("sfx");
audio.unmuteChannel("sfx");
audio.muteAll();
audio.unmuteAll();

// Pause
audio.pauseChannel("music");
audio.resumeChannel("music");
```

Fade types and signatures:

```ts
import {
  AudioManager as BaseAudioManager,
  SoundHandle as BaseSoundHandle,
} from "@yagejs/audio";
import type {
  AudioCrossfadeOptions as BaseAudioCrossfadeOptions,
  AudioFadeOptions as BaseAudioFadeOptions,
  SoundRef,
} from "@yagejs/audio";
import type { EasingFunction, Process } from "@yagejs/core";

interface AudioFadeOptions extends BaseAudioFadeOptions {
  duration: number;
  easing?: EasingFunction; // default: easeLinear
  stopOnComplete?: boolean; // requires target volume 0
}

interface AudioCrossfadeOptions extends BaseAudioCrossfadeOptions {
  duration: number;
  easing?: EasingFunction; // default: easeLinear
}

declare class SoundHandle extends BaseSoundHandle {
  fadeTo(volume: number, options: AudioFadeOptions): Process;
}

declare class AudioManager extends BaseAudioManager {
  crossfade(
    outgoing: SoundHandle,
    next: SoundRef,
    options: AudioCrossfadeOptions,
  ): SoundHandle;
}
```

`AudioConfig.masterVolume?: number` sets the initial master volume (default 1).
`AudioManager.masterVolume` accepts finite values from 0 to 1 and throws before
changing state for invalid inputs. Playback volume is `masterVolume × channel
volume × SoundHandle.volume`. Master volume affects this manager's playing and
future sounds, including channels created later. It preserves channel and
sound volumes, active fades, and mute/pause state. It does not control external
audio contexts or playback outside this manager.

`getChannelNames(): readonly string[]` returns a fresh snapshot of configured
and subsequently created channels in creation order, including silent channels.
Playing a sound or setting a channel's volume, mute or pause state creates an
unknown channel. `getChannelVolume(name)` returns 1 for an unknown channel
without creating it.

`SoundHandle.volume` is a value from 0 to 1 before master and channel volumes
are applied. Changing either keeps each handle's volume and any active fade. A second
`fadeTo` on the same handle cancels and replaces its current fade. The returned
`Process` can be cancelled or awaited with `toPromise()`.

`crossfade` starts `next` at zero, fades it to `options.volume ?? 1`, fades the
outgoing handle to zero, and stops the outgoing handle at completion. It
returns the incoming handle immediately. When `options.channel` is absent, the
incoming sound uses the outgoing handle's channel. One easing function applies
to both fades.

Fades use the engine-global frame process pool. They continue across scene
changes, follow `ProcessSystem.timeScale`, and are not gated by an individual
scene's pause state. Duration and volume inputs are validated before playback
changes. Easing output is clamped to the fade interval; a non-finite result
throws at the volume write.

`play`, `playOnce`, `requestOnce`, and `playRandom` throw naming the alias when no sound is registered under it — a typo, or playback before the asset finished preloading. Preload it with `sound(path)` or register it with `registerSound(alias, buffer)`.

`hasSound(ref)` reports whether an alias is registered, which is the check those throws make. It takes an alias or a `sound()` handle, so a game that assembles an alias at runtime — one variant per surface, per weapon, per language — can choose a fallback rather than risk the throw. It reports nothing about whether audio is audible: the browser's autoplay unlock is `isUnlocked()`, and mute is `muteChannel` / `muteAll`.

`playOnce` and `requestOnce` share one playback for each alias and channel.
`playOnce` holds one implicit owner; repeated calls return the same
`SoundHandle` without adding owners. Each `requestOnce` call returns an
independent `SoundRequestHandle`. Releasing a request stops the shared sound
only when no requests or `playOnce` owner remain. Natural completion makes all
request handles inactive and calls `onEnd` for each request that was still
active. A released request receives no callback. Stopping the shared
`SoundHandle`, its channel, or all audio makes every request inactive.

`request.release({ fadeOut: seconds })` accepts `SoundRequestReleaseOptions`.
`fadeOut` must be finite and non-negative; omitting it or passing zero stops
immediately when the final owner releases. A positive fade requires an installed
`AudioPlugin`. Other requests and `playOnce` owners keep the recording playing
at its current volume. Only the final request's fade applies.

The final request remains `active` through its fade, then becomes inactive when
the sound stops or ends. A released request receives no `onEnd` callback,
including if the recording ends during its fade. A new `requestOnce` or
`playOnce` call during the fade starts a fresh recording. Channel controls still
apply to both recordings.

## Runtime sounds

**`registerSound(alias, buffer)` / `unregisterSound(alias)`** — register a runtime-generated `AudioBuffer` under an alias so it resolves and plays exactly like a preloaded sound, through the same `AudioManager` channels, mute, and blur auto-pause. Audio analogue of the renderer's `registerTexture(key, texture)`.

```ts yage-context="scene-enter"
import { registerSound, AudioManagerKey } from "@yagejs/audio";
import { synthBuffer, synthPresets } from "@yagejs-addons/synth";

// Any code that produces an AudioBuffer; here, @yagejs-addons/synth
const buffer = synthBuffer(synthPresets.shoot());
registerSound("shoot", buffer);

const audio = this.use(AudioManagerKey);
audio.play("shoot");
```

Semantics:

- Runtime buffers are not persisted by YAGE. If game-owned save data contains
  an alias, re-register the buffer under that alias before reconstructing
  playback.
- Callable at module scope, before `AudioPlugin` installs: the alias is held and added to the library the plugin loads.
- Registered aliases are engine-global and live until `unregisterSound(alias)`.
- `unregisterSound` is a no-op for aliases it never registered. An `AudioBuffer` has no destroy step, so unlike `unregisterTexture` there is nothing to release beyond the alias itself.
- Re-registering an alias replaces the entry.
- Registering an alias already used by a loaded sound asset (or any entry the API didn't create) throws — shadowing a loaded asset would let that asset's unload destroy the registered sound.

## SoundComponent

Entity-bound audio. Auto-stops on entity destroy.

```ts yage-context="entity"
import { SoundComponent, sound } from "@yagejs/audio";

const CoinSfx = sound("assets/coin.wav");

entity.add(
  new SoundComponent({
    alias: CoinSfx.path,
    channel: "sfx",
    playOnAdd: true,
    loop: false,
    volume: 1,
  }),
);

// Control
const sc = entity.get(SoundComponent);
sc.play(); // returns SoundHandle
sc.stop();
sc.handle; // SoundHandle | null

// Read the config back (also what the Inspector reports for the component)
sc.alias;
sc.channel;
sc.loop;
sc.volume;
```
