Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@

## [20.9.0] (melonJS 2) - _unreleased_

### Added
- **Spatial audio placed in world coordinates**: `audio.play(name, { follow: renderable })` tracks a sound to a renderable every frame, `{ at: { x, y } }` pins one to a fixed world point, and `{ stopWithTarget: true }` ends it when that renderable is destroyed. The numbers are world pixels with y measured down, the same ones already in `pos`, so a game converts nothing by hand. `audio.unfollow(id)` detaches a sound and leaves it playing where it is
- **A movable listener**: `audio.setListener(target)` puts the ear on a renderable and keeps it there, while `audio.listener(x, y, z)` and `audio.listenerOrientation(fx, fy, fz, ux, uy, uz)` place it by hand and read it back when called with no arguments. Source positions become absolute world coordinates instead of offsets from the player, and a `Camera3d` target contributes orientation as well, taken from its own basis. It is opt-in end to end: until a game asks for a listener no frame handler is installed, and every existing `position` / `stereo` / `panner` call behaves exactly as it did
- **Panner defaults shaped for pixels**: a placed sound now starts from `refDistance: 240`, `maxDistance: 10000`, `distanceModel: "inverse"` and `panningModel: "equalpower"`, so it carries across a screen instead of going near silent a few tiles out. `audio.setSpatialDefaults()` and `audio.getSpatialDefaults()` change what new voices inherit

### Fixed
- Audio: `stereo()` and `position()` drive the same per-instance panner node, and the first of the two to be called decided what kind of node it was, which made the other a silent no-op for the life of that voice. The node now follows whichever was called last, so the order no longer matters
- Audio: `audio.panner(name, attributes)` with no playback id wrote only the voices already playing and never the clip's own defaults, so a call before the first `play()` changed nothing at all, the getter read back construction-time values forever, and every later voice inherited those. It writes the group now, and merges rather than replaces, so setting one attribute leaves the others alone
- Audio: sound effects kept playing while the window did not have focus, because pausing the game only ever reached the current music track. A looping effect such as an engine hum or an alarm played on over whatever the player had switched to. The mix is muted on blur and restored on focus, which covers `tone()` and `noise()` and anything a game hangs off `getMasterGain()` as well as ordinary clips, and it is gated by the existing `pauseOnBlur` and `stopOnBlur` settings so a game that deliberately keeps running in the background keeps its audio too
- Skills: the UI skill listed `NineSliceSprite` panels among what it covers but never mentioned them. It now shows both ways to make one (`createSpriteFromName(name, { width, height }, true)` from an atlas, or the constructor from an image) and the three things that trip people up: `width` and `height` are the size to stretch to and are mandatory, `insetx` / `insety` default to a quarter of the image rather than its border, and the anchor is the centre
- Skills: the audio skill now covers what positional sound needs and did not say: the listener is fixed at the origin with no public call to move it, so positions are relative to the player; the panner defaults assume metres, so a sound placed in pixels is near silent a few tiles out (`refDistance: 1` puts one 100 px away at about 1%); and `stereo()` and `position()` drive the same per-instance panner, so whichever runs first makes the other a silent no-op. It also shows how to put reverb, echo or a compressor on the whole mix through `getMasterGain()`, noting that a streamed clip bypasses it, and documents `hasAudio()`, `hasFormat()`, runtime `audio.load()`, and the `rate` and `fade` ranges
- Skills: the audio skill now covers what positional sound needs and did not say. It documents the movable listener and the `follow` / `at` play options, the pixel-shaped panner defaults and how to widen them, and that every one of these calls takes world coordinates with y measured down. It also shows how to put reverb, echo or a compressor on the whole mix through `getMasterGain()`, noting that a streamed clip bypasses it, and documents `hasAudio()`, `hasFormat()`, runtime `audio.load()`, and the `rate` and `fade` ranges

## [20.8.0] (melonJS 2) - _2026-10-06_

Expand Down
137 changes: 109 additions & 28 deletions packages/melonjs/skills/melonjs-audio/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,9 @@ audio.play("sfx", { sprite: "jump" });

The optional third element marks a region as looping. `play()`'s second argument
takes either the original `loop` boolean or a `PlayOptions` object
(`sprite`, `loop`, `onend`, `volume`) — both forms work. Object fields win over
the positional `onend` / `volume` arguments when both are supplied.
(`sprite`, `loop`, `onend`, `volume`, plus the spatial `follow`, `at` and
`stopWithTarget`) — both forms work. Object fields win over the positional
`onend` / `volume` arguments when both are supplied.

## Clip state

Expand Down Expand Up @@ -111,6 +112,14 @@ audio.muted(); // global mute state
`audio.unload(name)` / `audio.unloadAll()` free the decoded buffers; `unload`
also clears the current-track pointer if it named that clip.

### Losing focus mutes everything

When the window loses focus the engine mutes all audio, and unmutes it on
focus. That covers sound effects, procedural `tone` / `noise` and the mixer
effects, not just the music track that `state.pause()` pauses. Set
`pauseOnBlur: false` in the application settings if a clip has to keep playing
in the background.

## Spatial audio

```js
Expand All @@ -126,44 +135,113 @@ Called with just the clip name, each of the four returns the current value —
`stereo` gives `0` and `position` gives `[0, 0, 0]` before they have ever been
set.

### The listener is fixed at the origin
### Let the engine place the sound: `follow` and `at`

The shortest path to positional audio is a play option. Both take **world
pixels**, the same numbers as `pos.x` / `pos.y`, and the engine converts to
audio space for you:

```js
// tracks the renderable every frame, and stops when it is destroyed
audio.play("ufo", { follow: this, stopWithTarget: true });

// pinned to a fixed world point
audio.play("portal", { at: { x: 1200, y: 480 } });
```

`follow` and `at` are mutually exclusive: passing both throws, rather than
quietly picking one. `follow` re-reads the target's **absolute** position each
frame, so a renderable nested in a moving container is placed correctly. `stopWithTarget` is what you
want for a sound that belongs to an entity: without it a looping clip on a
destroyed renderable keeps playing from wherever it last was. `audio.unfollow(id)`
detaches a sound and leaves it playing where it is.

The ear sits at `(0, 0, 0)` facing `-z`, and **there is no public call to move
it**. Positions are therefore *relative to the listener*: for a sound to come
from the left of the player, pass the source's position minus the player's,
every frame either one moves.
### Move the listener, don't do the subtraction yourself

```js
// in the emitter's update()
audio.position("engine", this.pos.x - player.pos.x, 0, -0.5, this.engineId);
audio.setListener(player); // tracked every frame
audio.listener(x, y, z); // or place it by hand
audio.listener(); // → [x, y, z] in world pixels
audio.listenerOrientation(fx, fy, fz, ux, uy, uz);
audio.setListener(null); // stop tracking
```

### Distances are in the units you pass — and the defaults assume metres
With a listener in the world, source positions are **absolute world
coordinates**, not offsets. The older idiom — passing `source.pos.x -
player.pos.x` every frame with the ear stuck at the origin — still works, but
only if you leave the listener at `(0, 0, 0)`; mixing the two double-counts the
player's position.

`setListener` places the ear at the target's absolute position after the world
updates. A plain renderable contributes position only, so a side-scroller keeps
the default "facing into the screen" and gets left-right panning plus distance
falloff from the one call.

The panner defaults are `distanceModel: "inverse"`, `refDistance: 1`,
`rolloffFactor: 1`, `maxDistance: 10000`, `panningModel: "HRTF"`. With
`refDistance: 1`, a source 100 units away plays at about **1%** volume, so a
sound positioned in raw pixels is effectively silent a few tiles out. Either
set `refDistance` to the distance in pixels at which it should still be at full
volume, or scale positions down (divide by a tile size) before passing them:
A `Camera3d` target also contributes its **orientation**, taken from its own
basis, so turning the camera swings the stereo image with it. That is the whole
of what a posed 3D scene needs:

```js
audio.panner("engine", { refDistance: 200, rolloffFactor: 1 });
// or: audio.position("engine", dx / 32, 0, -0.5);
audio.setListener(app.viewport); // in a 3D scene this is a Camera3d
audio.play("drone", { follow: saucer }); // scene coordinates, directly
```

### `stereo` and `position` are one or the other
**Y is down.** Every one of these calls takes and returns melonJS world
coordinates, where y grows downward; the Y-up flip Web Audio wants happens
inside the engine. Pass `pos.y` as it is.

### The defaults are tuned for pixels

`audio.setSpatialDefaults()` / `getSpatialDefaults()` set the panner attributes
a sound placed through `follow` or `at` starts from, and the shipped values
assume a 2D game measured in pixels:

| attribute | default |
|---|---|
| `refDistance` | `240` |
| `maxDistance` | `10000` |
| `panningModel` | `"equalpower"` |
| `distanceModel` | `"inverse"` |

With those, a placed sound is at full volume out to 240 px and then halves
each time the distance doubles: about half at 480, a quarter at 960, roughly
an eighth at 2000. `maxDistance` is where it stops getting quieter.

**They apply to placed sounds only.** A plain `audio.play()` followed by a
bare `audio.position()` keeps WebAudio's own defaults, where `refDistance` is
`1` METRE and a source 100 px out is already near silent. That path is
unchanged, which is what keeps old code working.

Widen or tighten the field once, globally:

```js
audio.setSpatialDefaults({ refDistance: 400, maxDistance: 20000 });
```

`audio.panner(name, attrs, id?)` still overrides per clip or per voice, and it
merges over the current values rather than replacing them, so writing one field
no longer resets the rest. Called without an `id` it writes the **group**
defaults, which is what a clip-wide tuning call looks like.

For a 3D scene where front-to-back matters, `panningModel: "HRTF"` is worth the
cost; for a 2D one it mostly muddies the stereo image.

### `stereo` and `position`: last writer wins

Both drive the same per-instance panner node, and the engine swaps the node to
match whichever you called **last**: `stereo()` after `position()` gives you
plain left-right panning, and `position()` after `stereo()` gives you 3D
falloff. Neither silently does nothing any more, and the order you call them in
no longer matters.

Both drive the same per-instance panner node, and **the first one called
decides what that node is**: a stereo panner after `stereo()`, a 3D panner after
`position()`. After that the other call silently does nothing on that instance.
For a 2D game, left-right `stereo()` is usually all you want; reach for
`position()` only when distance falloff matters, and then use it alone.
Still pick one per voice and stay with it. A 2D game usually wants `stereo()`
alone; reach for `position()` (or `follow` / `at`) when distance falloff
matters.

**Spatial audio is WebAudio-only.** On a clip loaded with `stream: true` (or
`html5: true`) `stereo` / `position` / `orientation` return early and do
nothing — which is exactly the case for the long music tracks people reach for
first.
first. `follow` and `at` are equally inert on a streamed clip.

## Procedural sound

Expand Down Expand Up @@ -327,10 +405,13 @@ either way — blank screen, empty console.
| a streamed clip ignores `withCredentials` | `stream: true` plays through an `<audio>` element; preload it buffered instead |
| `audio.tone(440, {...})` does nothing / errors | `tone` takes a single options object with a required `duration` |
| `tone` / `noise` are silent with no error | no WebAudio context — `getAudioContext()` returned `null` |
| a positioned sound is silent, or only audible right next to the player | positions in pixels with the default `refDistance: 1` — set `refDistance` in pixels, or scale positions down |
| a positioned sound does not follow the player | the listener never moves — pass positions relative to the player, every frame |
| `position()` does nothing on a clip that pans with `stereo()` (or the reverse) | the first call fixed the instance's panner type — use one or the other |
| a positioned sound is silent, or only audible right next to the player | placed by hand with `audio.position()`, which keeps WebAudio's metre defaults (`refDistance: 1`) — use `follow` / `at`, or set `refDistance` in pixels with `audio.panner()` |
| a positioned sound does not follow the player | nothing is placing it — `audio.play(name, { follow: entity })`, and `audio.setListener(player)` once |
| a followed sound pans to the wrong side as the player moves | the listener was moved **and** positions are still relative to the player — with a listener in the world, pass absolute world coordinates |
| a looping sound outlives the entity that owns it | `follow` without `stopWithTarget: true` |
| a voice panned with `stereo()` loses its 3D placement, or the reverse | one panner node per voice and last writer wins — pick one per voice and stay with it |
| a reverb / compressor on the master gain does not affect the music | the music is `stream: true`, which bypasses the master gain — preload it buffered |
| sound effects keep playing after the window loses focus | `pauseOnBlur` and `stopOnBlur` are both off, so the engine is deliberately leaving the game running in the background |

## Related skills

Expand Down
11 changes: 11 additions & 0 deletions packages/melonjs/src/application/application.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { muteOnBlur, unmuteOnFocus } from "../audio/autopause.ts";
import type Camera2d from "./../camera/camera2d.ts";
import { AUTO, CANVAS, WEBGL, WEBGPU } from "../const.ts";
import type { PhysicsAdapter } from "../physics/adapter.ts";
Expand Down Expand Up @@ -94,7 +95,7 @@
if (physic === "none") {
return { adapter: undefined, physicLabel: "none" };
}
if (physic === undefined || physic === "builtin") {

Check warning on line 98 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, the types have no overlap

Check warning on line 98 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, the types have no overlap

Check warning on line 98 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, the types have no overlap
return { adapter: undefined, physicLabel: "builtin" };
}
// instance or { adapter } object — extract and pass through. The
Expand All @@ -104,7 +105,7 @@
// predating the `physicLabel` field.
const adapter =
typeof physic === "object" && "adapter" in physic ? physic.adapter : physic;
return { adapter, physicLabel: adapter?.physicLabel ?? "builtin" };

Check warning on line 108 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 108 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 108 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary optional chain on a non-nullish value
}

/**
Expand Down Expand Up @@ -357,7 +358,7 @@

const merged = {
...defaultApplicationSettings,
...(options || {}),

Check warning on line 361 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 361 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 361 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, value is always truthy
};

const autoScale =
Expand Down Expand Up @@ -402,7 +403,7 @@
this.settings = settings;

// identify parent element and/or the html target for resizing
this.parentElement = device.getElement(settings.parent!);

Check warning on line 406 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 406 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 406 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Forbidden non-null assertion
if (typeof settings.scaleTarget !== "undefined") {
settings.scaleTarget = device.getElement(settings.scaleTarget);
}
Expand Down Expand Up @@ -531,7 +532,7 @@
// a previous init() attempt may have constructed a renderer before
// rejecting (e.g. the WebGPU device negotiation failed) — release
// it before building a new one, so a retry does not leak a backend
this.renderer?.destroy();

Check warning on line 535 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 535 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 535 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary optional chain on a non-nullish value

if (typeof this.settings.renderer === "number") {
switch (this.settings.renderer) {
Expand All @@ -542,7 +543,7 @@
// rejection falls through to the synchronous candidates
// (autoDetectRenderer) instead of failing the application.
let negotiated;
if (typeof globalThis.navigator?.gpu !== "undefined") {

Check warning on line 546 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 546 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 546 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary optional chain on a non-nullish value
const attempt = new WebGPURenderer(this.settings);
attempt.parentApplication = this;
try {
Expand Down Expand Up @@ -616,14 +617,14 @@
// negotiation for WebGPU. This await is why `init()` is asynchronous.
// (optional-chained so a duck-typed custom renderer that does not
// extend `Renderer` keeps working without the new lifecycle hook)
await this.renderer.init?.();

Check warning on line 620 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 620 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 620 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary optional chain on a non-nullish value

// destroy() may have run while the backend was negotiating its
// context — finishing the bootstrap now would resurrect a torn-down
// application (re-registered listeners, an appended canvas, a live
// GPU device nothing will ever release, and the `game` global
// pointing at a dead app)
if (this._destroyed) {

Check warning on line 627 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always falsy

Check warning on line 627 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always falsy

Check warning on line 627 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, value is always falsy
this.renderer.destroy();
throw new Error(
"Application: destroyed while init() was awaiting the renderer — " +
Expand Down Expand Up @@ -716,7 +717,7 @@
if (this.settings.consoleHeader) {
if (this.world.physic === "none") {
console.log("physics: disabled");
} else if (this.world.adapter) {

Check warning on line 720 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 720 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 720 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, value is always truthy
const a = this.world.adapter as {
constructor: { name: string };
name?: string;
Expand Down Expand Up @@ -747,7 +748,7 @@
// app starting time
this.lastUpdate = globalThis.performance.now();
// only register event listeners once per instance
if (!this.isInitialized) {

Check warning on line 751 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 751 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 751 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, value is always truthy
/* eslint-disable @typescript-eslint/unbound-method */
on(STATE_CHANGE, this.repaint, this);
on(STATE_RESTART, this.repaint, this);
Expand Down Expand Up @@ -1075,6 +1076,12 @@
if (this.pauseOnBlur) {
state.pause(true);
}
if (this.stopOnBlur || this.pauseOnBlur) {
// `state.stop()` / `state.pause()` only reach the current music
// track, so without this a looping sound effect plays on over
// whatever the player switched to
muteOnBlur();
}
}

/**
Expand All @@ -1088,6 +1095,10 @@
if (this.resumeOnFocus) {
state.resume(true);
}
// unconditional: it only undoes a mute this module itself applied, and
// a game that stops on blur without resuming on focus would otherwise
// be left silent for good
unmuteOnFocus();
}

/**
Expand Down
15 changes: 15 additions & 0 deletions packages/melonjs/src/audio/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@
* `pause`, `resume`, `stop`, `fade`, `seek`, `rate`, `stereo`,
* `position`, `orientation`, `panner`).
* - {@link ./procedural.ts} — procedural primitives (`tone`, `noise`).
* - {@link ./spatial.ts} — the world-space layer: a movable listener,
* sounds that follow a renderable, and the pixel-shaped defaults.
* - {@link ./autopause.ts} — silencing the mix while the window is away.
* - {@link ./types.ts} — public TypeScript shapes.
*
* This file owns the remaining lifecycle / track / mix / unload
* helpers, plus the barrel re-exports that compose the namespace.
*/

import { play } from "./playback.ts";
import { releaseSpatialClip } from "./spatial.ts";
import {
state as audioState,
getGlobalVolume,
Expand Down Expand Up @@ -45,6 +49,15 @@ export {
stop,
} from "./playback.ts";
export { noise, tone } from "./procedural.ts";

export {
getSpatialDefaults,
listener,
listenerOrientation,
setListener,
setSpatialDefaults,
unfollow,
} from "./spatial.ts";
// Public re-exports from the split modules.
export {
getAudioContext,
Expand Down Expand Up @@ -275,6 +288,7 @@ export function muted(): boolean {
* @category Audio
*/
export function unload(sound_name: string): boolean {
releaseSpatialClip(sound_name);
const sound = audioState.tracks[sound_name];
if (!sound) {
return false;
Expand Down Expand Up @@ -303,6 +317,7 @@ export function unload(sound_name: string): boolean {
* @category Audio
*/
export function unloadAll(): void {
releaseSpatialClip();
for (const sound_name in audioState.tracks) {
if (Object.prototype.hasOwnProperty.call(audioState.tracks, sound_name)) {
unload(sound_name);
Expand Down
96 changes: 96 additions & 0 deletions packages/melonjs/src/audio/autopause.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Silence the game's sound effects while the window does not have focus.
*
* `Application` pauses the game on blur and resumes it on focus (its
* `pauseOnBlur` / `resumeOnFocus` settings, both on by default), and
* `state.pause()` already pauses the MUSIC TRACK. Nothing ever silenced the
* sound effects, so a looping one — an engine hum, an alarm, ambience — kept
* playing over whatever the player switched to.
*
* Driven from `Application#_onBlur` / `_onFocus` rather than from
* `STATE_PAUSE`, deliberately. A state pause is not always a focus loss:
* `state.freeze()` forwards its `music` flag into `state.pause()` for a
* hit-stop measured in tens of milliseconds, and muting there would punch a
* hole in the very impact sound the freeze exists to emphasise. A game's own
* pause menu is the same story, and would lose its UI clicks. Focus loss is
* the case where silence is unambiguously right.
*
* Driving it from the application also means the existing `stopOnBlur` /
* `pauseOnBlur` / `resumeOnFocus` settings gate it, instead of a fourth
* setting that says almost the same thing. A game that opted out of pausing
* in the background is a game that wants to keep running there, audio
* included.
*
* MUTED rather than stopped or paused:
*
* - stopping discards playback ids, and an id is the handle a game holds to
* reposition or fade a sound, so a loop could never be resumed in place;
* - per-voice pausing would have to be taught about every source in turn, and
* would miss the procedural `tone` / `noise` primitives and anything a game
* hangs off `getMasterGain()`;
* - muting zeroes the master gain, which everything routes through, and it
* lets a short one-shot finish silently instead of queueing up to fire the
* moment the player comes back.
*/

import { isGlobalMuted, setGlobalMuted } from "./state.ts";

/**
* Whether this module is the reason audio is muted.
*
* A game that muted itself must come back muted: the player chose silence,
* and a focus round trip is not consent to undo that. Latched, so two blurs
* followed by one focus cannot capture the muted state we set ourselves and
* then restore it as though the game had asked for sound.
*/
let mutedByBlur = false;

/**
* Mute the mix on blur, unless the game was already muted.
*
* Called by the application when the window loses focus and its settings say
* the game should not keep running in the background.
* @internal
* @ignore
*/
export function muteOnBlur(): void {
if (mutedByBlur || isGlobalMuted()) {
// already silent, by us or by the game: nothing to capture, and
// nothing to restore later
return;
}
mutedByBlur = true;
setGlobalMuted(true);
}

/**
* Restore the mix on focus, but only if we are the ones who muted it.
* @internal
* @ignore
*/
export function unmuteOnFocus(): void {
if (!mutedByBlur) {
return;
}
mutedByBlur = false;
setGlobalMuted(false);
}

/**
* Whether the mix is currently silenced because the window lost focus.
* @returns true while blur-muted
* @internal
* @ignore
*/
export function isMutedByBlur(): boolean {
return mutedByBlur;
}

/**
* Forget that we muted, without touching the mix. For tests and teardown.
* @internal
* @ignore
*/
export function resetAutoPause(): void {
mutedByBlur = false;
}
Loading
Loading