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
7 changes: 7 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@
- **`Rect.toPolygon()` handed out the rectangle's live vertices** — `Polygon.setVertices` stores a `Vector2d[]` by reference, so the "new" polygon shared the rect's actual points array: transforming or mutating the polygon silently corrupted the rectangle. The vertices are now cloned.
- **`loader.load()` mutated the caller's asset descriptor, so retries double-prepended `baseURL`** — the resolved URL (fontface `url()` unwrap + `baseURL` prefix) was written back into `asset.src`; `loader.reload()` (which re-loads the same stored object) — or simply `load()`ing the same manifest entry twice — then fetched `base + base + src`. The URL is now resolved into a copy handed to the parser; the caller's object is never touched.
- **`loader.unload()` of a never-loaded fontface threw a TypeError** — the fontface case was the only one without a membership guard, and `FontFaceSet.delete(undefined)` is a WebIDL type error rather than a `false` return. It now returns `false` like every other asset type.
- **Re-preloading an audio manifest silently replaced each Howl and leaked the old one** — the audio parser was the only asset parser without an already-loaded guard, so returning to a stage that preloads (a common pattern) churned every clip's decoded buffers / HTML5 nodes. An already-loaded clip is now reported as cached (like every other asset type); `me.audio.unload()` first to genuinely reload one.
- **Parallel audio loads shared a single retry counter** — three failures of one flaky file pushed *another* sound's first failure straight over the give-up threshold, throwing a spurious fatal load error (the default `stopOnAudioError`). Retries are now budgeted per sound.
- **`me.audio.stopOnAudioError` was documented as settable but couldn't be set** — it's a module export, and assigning through the namespace throws a TypeError, so the documented "disable audio instead of throwing" mode was unreachable. New **`me.audio.setStopOnAudioError(value)`** is the supported way to change it.
- **`me.audio.seek()` / `rate()` setter forms returned Howler's `Howl` object while typed as `number`** — the same get/set contract lie fixed for `stereo`/`position`/`orientation`/`panner` in 19.5.0; both now return nothing when setting and a number when reading.
- **`me.audio.position()` / `stereo()` getters returned `null` for sounds never positioned/panned** — Howler keeps the group state at `null` until first set, and the getters passed that through while typed as a tuple / number. They now return the neutral defaults (`[0, 0, 0]` / `0`).
- **`me.audio.resume()` without an id could spawn a new instance instead of resuming** — Howler's bare `play()` only auto-resumes when *exactly one* instance is paused; with two or more (e.g. after `pause()` without an id, which pauses the whole group) it started a brand-new playback from 0 and left the paused instances stuck forever. `resume()` now explicitly resumes every paused instance, as its documentation always promised.
- **`me.audio.unload()` left `getCurrentTrack()` pointing at the unloaded track** — the current-track pointer is now cleared when the unloaded clip is the active track, so `pauseTrack()`/`resumeTrack()` don't silently act on a ghost.

## [19.8.0] (melonJS 2) - _2026-06-26_

Expand Down
7 changes: 7 additions & 0 deletions packages/melonjs/src/audio/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { play } from "./playback.ts";
export {
getAudioContext,
getMasterGain,
setStopOnAudioError,
stopOnAudioError,
} from "./backend.ts";
export {
Expand Down Expand Up @@ -276,6 +277,12 @@ export function unload(sound_name: string): boolean {
return false;
}

// forget the current-track pointer if it referenced this sound, so
// getCurrentTrack() / pauseTrack() / resumeTrack() don't act on a ghost
if (state.currentTrackId === sound_name) {
state.currentTrackId = null;
}

// destroy the Howl object
sound.unload();
delete state.tracks[sound_name];
Comment on lines +280 to 288
Expand Down
35 changes: 31 additions & 4 deletions packages/melonjs/src/audio/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,30 @@ import { Howl, Howler } from "howler";
*
* When `true`, melonJS throws an exception and aborts loading.
* When `false`, melonJS disables sound and logs a warning to the console.
*
* Read-only through the `me.audio` namespace (module namespace properties
* can't be assigned) — change it with {@link setStopOnAudioError}.
* @default true
* @see setStopOnAudioError
*/
// eslint-disable-next-line prefer-const
export let stopOnAudioError: boolean = true;

/**
* Set the {@link stopOnAudioError} flag — whether an audio clip that still
* fails after its retries throws (aborting loading) or just disables sound
* with a console warning. This setter is the supported way to change the
* flag: assigning `me.audio.stopOnAudioError = false` directly throws a
* TypeError, because module namespace properties are read-only.
* @param value - `true` to throw on a failed load, `false` to disable sound instead
* @example
* // don't abort the whole game when audio fails to load
* me.audio.setStopOnAudioError(false);
* @category Audio
*/
export function setStopOnAudioError(value: boolean): void {
stopOnAudioError = value;
}

/**
* Cross-module mutable state. A single object so multiple consumers
* can read and mutate the same fields without the "ESM `let` exports
Expand All @@ -33,14 +52,16 @@ export let stopOnAudioError: boolean = true;
* even though the type signature wouldn't normally admit it.
* - `currentTrackId` — the name of the currently-playing track managed
* by the `playTrack` / `stopTrack` helpers.
* - `retryCounter` — retry counter for `soundLoadError`'s back-off.
* - `retryCounters` — per-sound retry counters for `soundLoadError`'s
* back-off, keyed by sound name (a single shared counter let parallel
* loads steal each other's retry budget).
* - `audioExts` — the active list of audio formats set by `init`.
* @ignore
*/
export const state = {
tracks: {} as Record<string, Howl | undefined>,
currentTrackId: null as string | null,
retryCounter: 0,
retryCounters: {} as Record<string, number>,
audioExts: [] as string[],
};

Expand Down Expand Up @@ -70,7 +91,12 @@ export const soundLoadError = function (
onerror_cb?: () => void,
stopOnError: boolean = true,
): void {
if (state.retryCounter++ >= 3) {
// per-sound retry budget — a single shared counter let parallel loads
// steal each other's retries: three failures of one flaky file pushed
// ANOTHER sound's first failure straight over the give-up threshold
const retries = state.retryCounters[sound_name] ?? 0;
if (retries >= 3) {
delete state.retryCounters[sound_name];
const errmsg = `melonJS: failed loading ${sound_name}`;
if (!stopOnError) {
// disable audio
Expand All @@ -82,6 +108,7 @@ export const soundLoadError = function (
throw new Error(errmsg);
}
} else {
state.retryCounters[sound_name] = retries + 1;
state.tracks[sound_name]?.load();
}
};
Expand Down
108 changes: 94 additions & 14 deletions packages/melonjs/src/audio/playback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ export function load(
onerrorcb?: () => void,
settings: LoadSettings = {},
): number {
// already loaded? Return 0 ("cached", like every other asset parser) —
// re-preloading a manifest (e.g. re-entering a stage that preloads) used
// to silently replace the Howl and leak the old instance's decoded
// buffers / HTML5 nodes. Unload first to genuinely reload a clip.
if (typeof state.tracks[sound.name] !== "undefined") {
return 0;
}
Comment on lines +50 to +52
const urls: string[] = [];
if (state.audioExts.length === 0) {
throw new Error(
Expand Down Expand Up @@ -71,7 +78,7 @@ export function load(
soundLoadError.call(this, sound.name, onerrorcb, stopOnAudioError);
},
onload() {
state.retryCounter = 0;
delete state.retryCounters[sound.name];
if (typeof onloadcb === "function") {
onloadcb();
}
Expand Down Expand Up @@ -143,38 +150,81 @@ export function fade(
getSoundOrThrow(sound_name).fade(from, to, duration, id);
}

/** @inheritDoc */
export function seek(sound_name: string): number;
/** @inheritDoc */
export function seek(sound_name: string, seek: number, id?: number): void;
/**
* Get or set the playback position of a sound.
* @param sound_name - Audio clip name (case-sensitive).
* @param args - Optional seek position in seconds, optionally followed
* by the sound instance ID.
* @returns The current seek position when no extra arguments are given.
* @param pos - Seek position in seconds. Omit to read.
* @param id - Sound instance ID. When omitted, all sounds in the group
* are affected.
* @returns The current seek position when called as a getter; nothing
* when called as a setter (the Howl object Howler returns from the
* setter form is an internal, not part of this API).
* @example
* // read the current position of the background music
* let current_pos = me.audio.seek("dst-gameforest");
* // rewind the background music to the beginning
* me.audio.seek("dst-gameforest", 0);
* @category Audio
*/
export function seek(sound_name: string, ...args: number[]): number {
return getSoundOrThrow(sound_name).seek(...args);
export function seek(
sound_name: string,
pos?: number,
id?: number,
): number | void {
const sound = getSoundOrThrow(sound_name);
if (pos === undefined) {
return sound.seek();
}
// forward the exact arity — Howler's core methods dispatch on argument
// count, so an explicit trailing undefined would be parsed as an id
if (id === undefined) {
sound.seek(pos);
} else {
sound.seek(pos, id);
}
}

/** @inheritDoc */
export function rate(sound_name: string): number;
/** @inheritDoc */
export function rate(sound_name: string, rate: number, id?: number): void;
/**
* Get or set the playback rate of a sound.
* @param sound_name - Audio clip name (case-sensitive).
* @param args - Optional playback rate (`0.5..4.0`, where `1.0` is
* normal speed), optionally followed by the sound instance ID.
* @returns The current playback rate when no extra arguments are given.
* @param rate - Playback rate (`0.5..4.0`, where `1.0` is normal
* speed). Omit to read.
* @param id - Sound instance ID. When omitted, all sounds in the group
* are affected.
* @returns The current playback rate when called as a getter; nothing
* when called as a setter (the Howl object Howler returns from the
* setter form is an internal, not part of this API).
* @example
* // read the current playback rate
* let rate = me.audio.rate("dst-gameforest");
* // speed it up 2×
* me.audio.rate("dst-gameforest", 2.0);
* @category Audio
*/
export function rate(sound_name: string, ...args: number[]): number {
return getSoundOrThrow(sound_name).rate(...args);
export function rate(
sound_name: string,
rate?: number,
id?: number,
): number | void {
const sound = getSoundOrThrow(sound_name);
if (rate === undefined) {
return sound.rate();
}
// forward the exact arity — Howler's core methods dispatch on argument
// count, so an explicit trailing undefined would be parsed as an id
if (id === undefined) {
sound.rate(rate);
} else {
sound.rate(rate, id);
}
}

/** @inheritDoc */
Expand Down Expand Up @@ -205,7 +255,9 @@ export function stereo(
): number | void {
const sound = getSoundOrThrow(sound_name);
if (pan === undefined) {
return sound.stereo();
// Howler keeps the group pan at null until it's first set — the
// documented return type is a number, so map that to centered
return sound.stereo() ?? 0;
}
sound.stereo(pan, id);
}
Expand Down Expand Up @@ -245,7 +297,9 @@ export function position(
): [number, number, number] | void {
const sound = getSoundOrThrow(sound_name);
if (x === undefined) {
return sound.pos();
// Howler keeps the group position at null until it's first set — the
// documented return type is a tuple, so map that to the origin
return sound.pos() ?? [0, 0, 0];
}
sound.pos(x, y, z, id);
}
Expand Down Expand Up @@ -383,5 +437,31 @@ export function pause(sound_name: string, id?: number): void {
* @category Audio
*/
export function resume(sound_name: string, id?: number): void {
getSoundOrThrow(sound_name).play(id);
const sound = getSoundOrThrow(sound_name);
if (typeof id !== "undefined") {
sound.play(id);
return;
}
// "all sounds in the group are resumed" (see JSDoc above): Howler's bare
// play() only auto-resumes when EXACTLY ONE instance is paused — with two
// or more (e.g. pause() without id pauses the whole group) it spawns a
// brand-new instance from 0 and leaves the paused ones stuck. Howler has
// no public instance list, so read _sounds directly (same justified
// private access as the Howler `_muted` read in audio.ts).
const sounds = (
sound as unknown as {
_sounds: { _paused: boolean; _ended: boolean; _id: number }[];
}
)._sounds;
const paused = sounds.filter((s) => {
return s._paused && !s._ended;
});
if (paused.length === 0) {
// nothing to resume — keep the legacy start-playback behavior
sound.play();
return;
}
for (const s of paused) {
sound.play(s._id);
}
}
Loading
Loading