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/examples/src/examples/pool-matter/audio.ts b/packages/examples/src/examples/pool-matter/audio.ts new file mode 100644 index 000000000..7dd52b082 --- /dev/null +++ b/packages/examples/src/examples/pool-matter/audio.ts @@ -0,0 +1,105 @@ +/** + * melonJS — Pool (Matter) example: procedural sound effects. + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + * + * Five thin wrappers around `audio.tone` — no asset files, no loaders. + * Each maps a gameplay event to a single envelope-shaped tone: + * - playStrike cue-on-ball thwack on release + * - playBallClick classic billiards click on ball-ball contact + * - playRailBounce softer thud on cushion bounce + * - playPocketDrop two-partial ring on a successful pot + * - playScratch descending fail tone when the cue ball is pocketed + * + * Most wrappers take a normalised `velocity` factor in `[0, 1]` so the + * gain scales with impact intensity, plus a `pan` in `[-1, 1]` so a + * shot on the left rail clacks left and a shot on the right rail + * clacks right — same trick as the plinko peg pans. + */ + +import { audio } from "melonjs"; +import { VIEWPORT_W } from "./constants"; + +/** + * Convert a world x-coordinate to a stereo pan in `[-1, 1]`. Centre of + * the table is 0; left edge is -1, right edge is 1. + */ +export const panForX = (x: number): number => { + const normalised = (x / VIEWPORT_W) * 2 - 1; + return Math.max(-1, Math.min(1, normalised)); +}; + +/** + * Normalise a matter body's per-step velocity magnitude into a `[0, 1]` + * factor for gain scaling. Pool ball velocities at full break are + * roughly in the 8–12 px/step range; we cap at 10 so a hard break + * tops out at gain factor 1 and lighter contacts scale linearly down. + */ +export const velocityFactor = (speed: number): number => { + return Math.max(0, Math.min(1, speed / 10)); +}; + +/** Soft wood/leather "thock" when the cue tip strikes the cue ball. */ +export const playStrike = (power: number, pan = 0): void => { + audio.tone({ + freq: 350, + duration: 0.1, + gain: 0.18 * Math.max(0.2, power), + pan, + pitchSlide: 0.3, + }); +}; + +/** + * Phenolic-resin click on ball-ball contact — two partials (mid body + + * upper bite) with random jitter so a multi-ball break stays granular, + * very short duration so it reads as a dry click without lingering ring. + */ +export const playBallClick = (velocity: number, pan = 0): void => { + const v = velocityFactor(velocity); + if (v < 0.05) return; // skip vanishingly soft taps + const jitter = Math.random(); + audio.tone({ + freq: [420 + jitter * 80, 820 + jitter * 120], + duration: 0.035, + gain: 0.12 * v, + pan, + pitchSlide: 0.5, + }); +}; + +/** Softer low thud on cushion bounces. Distinguishable from ball-ball + * by frequency band (mid-low vs upper-mid). */ +export const playRailBounce = (velocity: number, pan = 0): void => { + const v = velocityFactor(velocity); + if (v < 0.05) return; + audio.tone({ + freq: 350, + duration: 0.1, + gain: 0.12 * v, + pan, + pitchSlide: 0.5, + }); +}; + +/** Satisfying two-partial ring when a numbered ball drops in a pocket. */ +export const playPocketDrop = (pan = 0): void => { + audio.tone({ + freq: [440, 660], + duration: 0.25, + gain: 0.18, + pan, + }); +}; + +/** Descending sawtooth "buzzer" when the cue ball gets pocketed. */ +export const playScratch = (pan = 0): void => { + audio.tone({ + freq: 400, + duration: 0.4, + wave: "sawtooth", + gain: 0.15, + pan, + pitchSlide: 0.3, + }); +}; diff --git a/packages/examples/src/examples/pool-matter/entities/ball.ts b/packages/examples/src/examples/pool-matter/entities/ball.ts index 56e41afa1..c7e1cb673 100644 --- a/packages/examples/src/examples/pool-matter/entities/ball.ts +++ b/packages/examples/src/examples/pool-matter/entities/ball.ts @@ -5,15 +5,18 @@ */ import type { MatterAdapter } from "@melonjs/matter-adapter"; import { + type CollisionResponse, type Container, collision, Ellipse, type PhysicsAdapter, + type Renderable, type Renderer, Sprite, Tween, Vector2d, } from "melonjs"; +import { panForX, playBallClick, playRailBounce } from "../audio"; import { BALL_DENSITY, BALL_FRICTION_AIR_ROLL, @@ -177,6 +180,28 @@ export class Ball extends Sprite { this.alwaysUpdate = true; } + /** + * Procedural sound on collision: ball-ball gets the upper-mid click, + * ball-rail (anything that's not a Ball and not a pocket sensor) gets + * the lower thud. Gain scales with the receiver's velocity at the + * moment of contact; pan tracks the ball's world x. + */ + override onCollisionStart( + _response: CollisionResponse, + other: Renderable, + ): void { + if (this.sinking || !this.body) return; + const v = this.body.getVelocity(restVelScratch); + const speed = Math.sqrt(v.x * v.x + v.y * v.y); + const pan = panForX(this.pos.x + this.width / 2); + if (other instanceof Ball) { + playBallClick(speed, pan); + } else if (other.body?.isSensor !== true) { + // Not a Ball, not a sensor → rail / cushion. + playRailBounce(speed, pan); + } + } + /** Is the ball nearly motionless? Used to gate "can the player strike?" */ isAtRest(): boolean { if (!this.body) return true; diff --git a/packages/examples/src/examples/pool-matter/entities/cue.ts b/packages/examples/src/examples/pool-matter/entities/cue.ts index d0ec0edb6..6cf212fbc 100644 --- a/packages/examples/src/examples/pool-matter/entities/cue.ts +++ b/packages/examples/src/examples/pool-matter/entities/cue.ts @@ -4,6 +4,7 @@ * See `packages/examples/LICENSE.md` for full license + asset credits. */ import { event, input, type Pointer, type Renderer } from "melonjs"; +import { panForX, playStrike } from "../audio"; import { BALL_RADIUS, MAX_DRAG, @@ -143,6 +144,10 @@ export class CueBall extends Ball { dy /= dist; const impulse = power * STRIKE_FORCE_SCALE; this.body.applyImpulse(dx * impulse, dy * impulse); + // Cue strike — pitched-down "thwack" panned to the cue ball's + // current x. Power scales gain, so soft taps are quieter than + // full break shots. + playStrike(power, panForX(cueCenterX)); } override draw(renderer: Renderer): void { diff --git a/packages/examples/src/examples/pool-matter/entities/pocket.ts b/packages/examples/src/examples/pool-matter/entities/pocket.ts index b204e27f8..289ffd79a 100644 --- a/packages/examples/src/examples/pool-matter/entities/pocket.ts +++ b/packages/examples/src/examples/pool-matter/entities/pocket.ts @@ -15,6 +15,7 @@ import { Tween, Vector2d, } from "melonjs"; +import { panForX, playPocketDrop, playScratch } from "../audio"; import { gameState } from "../gameState"; import { Ball } from "./ball"; import { CUE_SPAWN_X, CUE_SPAWN_Y, CueBall } from "./cue"; @@ -132,6 +133,9 @@ export class Pocket extends Renderable { } if (other instanceof CueBall) { + // Descending fail tone panned to the pocket where the cue + // went down (not the respawn position). + playScratch(panForX(this.pos.x + this.width / 2)); // respawn cue at head-spot, zero velocity. `setPosition` is an // adapter call (no body-level equivalent); `setVelocity` is on // the body and takes primitives, no scratch needed. @@ -157,6 +161,8 @@ export class Pocket extends Renderable { // top-left of the bounds rect with width = height = 2 * radius). const centerX = this.pos.x + this.width / 2; const centerY = this.pos.y + this.height / 2; + // Satisfying drop ring, panned to the pocket location. + playPocketDrop(panForX(centerX)); other.startSink(this.adapter, centerX, centerY); // "+1" pop at the pocket — visible feedback for the score tick. const parent = this.ancestor as Container | undefined; diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index f132673a1..7d7f7d10f 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -32,6 +32,10 @@ - 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. Routed through the audio module's master gain so `muteAll` / `setVolume` apply uniformly. +- Audio: **`audio.noise(opts)` — procedural single-shot noise burst**, the non-pitched companion to `tone`. Picks spectral colour via `type` (`"white"` / `"pink"` / `"brown"`), shares the same gain-envelope + pan plumbing as `tone`, and accepts an optional band-shaping filter (`{ type, frequency, Q }`) with an optional exponential sweep on the filter frequency (`filterSweep`). Covers the percussive-without-pitch slice of game SFX — explosions (brown + lowpass + downward sweep), hi-hats (white + highpass), swooshes (bandpass + rising sweep), wind (pink + bandpass), footsteps (brown + lowpass). Same WebAudio gating and mute / volume routing as `tone`. +- 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. +- Audio: **`audio.getMasterGain()` — expose the master gain `GainNode`** the audio module routes all playback through. The right place to connect a custom analyser / filter / convolver so the result still respects `audio.muteAll()` and `audio.setVolume()`. Also lets `tone()` and `noise()` route their output without knowing which underlying audio backend the engine uses, so the dependency on Howler is now isolated to the two escape-hatch getters. - 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. @@ -48,6 +52,7 @@ - Physics: **`Trigger` and `Collectable`** now use the new sensor pattern added to the built-in physics (`bodyDef.isSensor: true`). - Renderable: **Lifecycle hook signatures widened in the base class** for `onCollision`, `onDestroyEvent`, and `Container.onActivateEvent` / `onDeactivateEvent`. The base methods now declare typed-but-unused parameters so subclass overrides like `onCollision(response: CollisionResponse, other: Renderable)` or `onDestroyEvent(app: Application)` remain structurally assignable to the base type under TypeScript's `strictFunctionTypes` rule. Runtime behavior unchanged (the engine already forwards `app` to `onDestroyEvent` via `Renderable.destroy.apply(this, arguments)` and dispatches `(response, other)` to the collision hook). Fixes a TS trap that bit any user-side `extends Renderable` that typed its hook params. - Docs: **Wiki physics section** — three new pages on the GitHub wiki: [Migrating to the Physics Adapter API](https://github.com/melonjs/melonJS/wiki/Migrating-to-the-Physics-Adapter-API) (legacy `me.Body` → 19.5 adapter API, all on Builtin), [Switching Physics Adapters](https://github.com/melonjs/melonJS/wiki/Switching-Physics-Adapters) (Builtin → matter migration with 6 portable+matter-only recipes), and [BuiltinAdapter Quirks](https://github.com/melonjs/melonJS/wiki/BuiltinAdapter-Quirks) (10 SAT-specific behaviors that don't carry to other engines). The matter-adapter README mirrors the recipes section. +- Audio: **`audio.panner()` now always returns the current `PannerAttributes` snapshot**, regardless of whether the call was a get or a set. Previously the "set" form returned the underlying `Howl` instance cast to `PannerAttributes` — the documented `@returns` was lying. Callers that read the returned value now get the attribute object the docstring promised; callers that ignored the return value see no change. ### Fixed - Physics: **`Body.ignoreGravity` marked `@deprecated`** — the portable equivalent is `gravityScale = 0` (or `bodyDef.gravityScale = 0` at construction, or `body.setGravityScale(0)` at runtime). `ignoreGravity` is read only by `BuiltinAdapter.applyGravity` and `Body.update`'s falling-state machine; `MatterAdapter` silently ignores it. The duplicate check in both call sites is kept (so legacy code that sets `ignoreGravity = true` still works), with a comment noting the redundancy. `Body.update`'s falling/jumping flag update now also gates on `gravityScale !== 0` so floating bodies (`gravityScale: 0`) no longer get mistakenly marked "falling" on a side-on collision. diff --git a/packages/melonjs/src/audio/audio.ts b/packages/melonjs/src/audio/audio.ts index b5f3e7666..1194f976c 100644 --- a/packages/melonjs/src/audio/audio.ts +++ b/packages/melonjs/src/audio/audio.ts @@ -1,117 +1,78 @@ -// external import -import { Howl, Howler } from "howler"; -import { clamp } from "./../math/math.ts"; -import { isDataUrl } from "./../utils/string.ts"; - -/** - * 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; -} - -/** - * audio channel list - * @ignore - */ -const audioTracks: Record = {}; - -/** - * current active track - * @ignore - */ -let current_track_id: string | null = null; - -/** - * error retry counter - * @ignore - */ -let retry_counter: number = 0; - /** - * list of active audio formats - * @ignore - */ -let audioExts: string[] = []; - -/** - * event listener callback on load error - * @ignore - */ -const soundLoadError = function ( - sound_name: string, - onerror_cb?: () => void, -): void { - // check the retry counter - if (retry_counter++ > 3) { - // something went wrong - const errmsg = `melonJS: failed loading ${sound_name}`; - if (!stopOnAudioError) { - // disable audio - disable(); - // call error callback if defined - onerror_cb?.(); - // warning - console.log(`${errmsg}, disabling audio`); - } else { - onerror_cb?.(); - // throw an exception and stop everything ! - throw new Error(errmsg); - } - // else try loading again ! - } else { - audioTracks[sound_name].load(); - } -}; - -/** - * Specify either to stop on audio loading error or not
- * if true, melonJS will throw an exception and stop loading
- * if false, melonJS will disable sounds and output a warning message - * in the console
- * @default true - */ -// eslint-disable-next-line prefer-const -export let stopOnAudioError: boolean = true; - -/** - * Initialize and configure the audio support.
- * For a maximum browser coverage the recommendation is to use at least two of them, - * typically default to webm and then fallback to mp3 for the best balance of small filesize and high quality, - * webm has nearly full browser coverage with a great combination of compression and quality, and mp3 will fallback gracefully for other browsers. - * It is important to remember that melonJS selects the first compatible sound based on the list of extensions and given order passed here. - * So if you want webm to be used before mp3, you need to put the audio format in that order. - * @param [format="mp3"] - audio format to prioritize ("mp3"|"mpeg"|"opus"|"ogg"|"oga"|"wav"|"aac"|"caf"|"m4a"|"m4b"|"mp4"|"weba"|"webm"|"dolby"|"flac") - * @returns Indicates whether audio initialization was successful + * `me.audio` — the audio module's public surface. + * + * Composition: + * - {@link ./backend.ts} — shared internal state + the two WebAudio + * escape hatches (`getAudioContext`, `getMasterGain`) + the + * `stopOnAudioError` flag. + * - {@link ./playback.ts} — file-based playback (`load`, `play`, + * `pause`, `resume`, `stop`, `fade`, `seek`, `rate`, `stereo`, + * `position`, `orientation`, `panner`). + * - {@link ./procedural.ts} — procedural primitives (`tone`, `noise`). + * - {@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 { + getGlobalVolume, + getSoundOrThrow, + hasCodec, + isAudioAvailable, + isGlobalMuted, + setGlobalMuted, + setGlobalVolume, + state, +} from "./backend.ts"; +import { play } from "./playback.ts"; + +// Public re-exports from the split modules. +export { + getAudioContext, + getMasterGain, + stopOnAudioError, +} from "./backend.ts"; +export { + fade, + load, + orientation, + panner, + pause, + play, + position, + rate, + resume, + seek, + stereo, + stop, +} from "./playback.ts"; +export { noise, tone } from "./procedural.ts"; +// Public type surface. +export type { + LoadSettings, + NoiseFilter, + NoiseOptions, + PannerAttributes, + SoundAsset, + ToneOptions, +} from "./types.ts"; + +/** + * Initialize and configure the audio module. + * + * For maximum browser coverage, list at least two formats — typically + * webm first then mp3. Webm has near-universal modern coverage with + * great compression / quality balance; mp3 covers older browsers. + * Order matters: melonJS picks the first compatible format from the + * list, so put the preferred one first. + * @param format - Comma-separated audio formats to prioritize. + * One or more of: `"mp3"`, `"mpeg"`, `"opus"`, `"ogg"`, `"oga"`, + * `"wav"`, `"aac"`, `"caf"`, `"m4a"`, `"m4b"`, `"mp4"`, `"weba"`, + * `"webm"`, `"dolby"`, `"flac"`. Defaults to `"mp3"`. + * @returns `true` when audio support was successfully initialised. * @example - * // initialize the "sound engine", giving "webm" as default desired audio format, and "mp3" as a fallback + * // initialise with webm preferred, mp3 as fallback * if (!me.audio.init("webm,mp3")) { * alert("Sorry but your browser does not support html 5 audio !"); * return; @@ -119,35 +80,32 @@ export let stopOnAudioError: boolean = true; * @category Audio */ export function init(format: string = "mp3"): boolean { - // convert it into an array - audioExts = format.split(","); - - return !Howler.noAudio; + state.audioExts = format.split(","); + return isAudioAvailable(); } /** - * check if the given audio format is supported - * @param codec - the audio format to check for support - * @returns return true if the given audio format is supported + * Check whether the given audio codec is supported by the browser. + * @param codec - The audio format to check. + * @returns `true` when the format is supported. * @category Audio */ export function hasFormat(codec: string): boolean { - return hasAudio() && Howler.codecs(codec); + return hasCodec(codec); } /** - * check if audio (HTML5 or WebAudio) is supported - * @returns return true if audio (HTML5 or WebAudio) is supported + * Check whether audio (HTML5 or WebAudio) is supported by the browser. + * @returns `true` when at least one audio backend is available. * @category Audio */ export function hasAudio(): boolean { - return !Howler.noAudio; + return isAudioAvailable(); } /** - * enable audio output
- * only useful if audio supported and previously disabled through - * @see {@link disable} + * Enable audio output. Only useful if audio is supported and was + * previously disabled through {@link disable}. * @category Audio */ export function enable(): void { @@ -155,7 +113,7 @@ export function enable(): void { } /** - * disable audio output + * Disable audio output. * @category Audio */ export function disable(): void { @@ -163,443 +121,98 @@ export function disable(): void { } /** - * Load an audio file - * @param sound - sound asset descriptor - * @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) - * @returns the amount of asset loaded (always 1 if successful) - * @category Audio - */ -export function load( - sound: SoundAsset, - onloadcb?: () => void, - onerrorcb?: () => void, - settings: LoadSettings = {}, -): number { - const urls: string[] = []; - if (audioExts.length === 0) { - throw new Error( - "target audio extension(s) should be set through me.audio.init() before calling the preloader.", - ); - } - if (isDataUrl(sound.src)) { - urls.push(sound.src); - } else { - for (let i = 0; i < audioExts.length; i++) { - urls.push( - `${sound.src + sound.name}.${audioExts[i]}${settings.nocache ?? ""}`, - ); - } - } - - audioTracks[sound.name] = new Howl({ - src: urls, - volume: Howler.volume(), - autoplay: sound.autoplay === true, - loop: (sound.loop = true), - html5: sound.stream === true || sound.html5 === true, - // @ts-expect-error xhrWithCredentials is a valid Howl option but not in the type definitions - xhrWithCredentials: settings.withCredentials, - onloaderror() { - soundLoadError.call(this, sound.name, onerrorcb); - }, - onload() { - retry_counter = 0; - if (typeof onloadcb === "function") { - onloadcb(); - } - }, - }); - - return 1; -} - -/** - * play the specified sound - * @param sound_name - audio clip name - case sensitive - * @param [loop=false] - loop audio - * @param [onend] - Function to call when sound instance ends playing. - * @param [volume=default] - Float specifying volume (0.0 - 1.0 values accepted). - * @returns the sound instance ID. - * @example - * // play the "cling" audio clip - * me.audio.play("cling"); - * // play & repeat the "engine" audio clip - * me.audio.play("engine", true); - * // play the "gameover_sfx" audio clip and call myFunc when finished - * me.audio.play("gameover_sfx", false, myFunc); - * // play the "gameover_sfx" audio clip with a lower volume level - * me.audio.play("gameover_sfx", false, null, 0.5); - * @category Audio - */ -export function play( - sound_name: string, - loop: boolean = false, - onend?: (() => void) | null, - volume?: number, -): number { - const sound = audioTracks[sound_name]; - if (sound && typeof sound !== "undefined") { - const id = sound.play(); - if (typeof loop === "boolean") { - // arg[0] can take different types in howler 2.0 - sound.loop(loop, id); - } - sound.volume( - typeof volume === "number" ? clamp(volume, 0.0, 1.0) : Howler.volume(), - id, - ); - if (typeof onend === "function") { - if (loop) { - sound.on("end", onend, id); - } else { - sound.once("end", onend, id); - } - } - return id; - } else { - throw new Error(`audio clip ${sound_name} does not exist`); - } -} - -/** - * Fade a currently playing sound between two volumes. - * @param sound_name - audio clip name - case sensitive - * @param from - Volume to fade from (0.0 to 1.0). - * @param to - Volume to fade to (0.0 to 1.0). - * @param duration - Time in milliseconds to fade. - * @param [id] - the sound instance ID. If none is passed, all sounds in group will fade. - * @category Audio - */ -export function fade( - sound_name: string, - from: number, - to: number, - duration: number, - id?: number, -): void { - const sound = audioTracks[sound_name]; - if (sound && typeof sound !== "undefined") { - sound.fade(from, to, duration, id); - } else { - throw new Error(`audio clip ${sound_name} does not exist`); - } -} - -/** - * get/set the position of playback for a sound. - * @param sound_name - audio clip name - case sensitive - * @param args - optional seek position (in seconds) and optional sound instance ID - * @returns return the current seek position (if no extra parameters were given) - * @example - * // return the current position of the background music - * let current_pos = me.audio.seek("dst-gameforest"); - * // set back the position of the background music to the beginning - * me.audio.seek("dst-gameforest", 0); - * @category Audio - */ -export function seek(sound_name: string, ...args: number[]): number { - const sound = audioTracks[sound_name]; - if (sound && typeof sound !== "undefined") { - return sound.seek(...args); - } else { - throw new Error(`audio clip ${sound_name} does not exist`); - } -} - -/** - * get or set the rate of playback for a sound. - * @param sound_name - audio clip name - case sensitive - * @param args - optional playback rate (0.5 to 4.0, with 1.0 being normal speed) and optional sound instance ID - * @returns return the current playback rate (if no extra parameters were given) - * @example - * // get the playback rate of the background music - * let rate = me.audio.rate("dst-gameforest"); - * // speed up the playback of the background music - * me.audio.rate("dst-gameforest", 2.0); - * @category Audio - */ -export function rate(sound_name: string, ...args: number[]): number { - const sound = audioTracks[sound_name]; - if (sound && typeof sound !== "undefined") { - return sound.rate(...args); - } else { - throw new Error(`audio clip ${sound_name} does not exist`); - } -} - -/** - * get or set the stereo panning for the specified sound. - * @param sound_name - audio clip name - case sensitive - * @param [pan] - the panning value - A value of -1.0 is all the way left and 1.0 is all the way right. - * @param [id] - the sound instance ID. If none is passed, all sounds in group will be changed. - * @returns the current panning value - * @example - * me.audio.stereo("cling", -1); - * @category Audio - */ -export function stereo(sound_name: string, pan?: number, id?: number): number { - const sound = audioTracks[sound_name]; - if (sound && typeof sound !== "undefined") { - return ( - pan !== undefined ? sound.stereo(pan, id) : sound.stereo() - ) as number; - } else { - throw new Error(`audio clip ${sound_name} does not exist`); - } -} - -/** - * get or set the 3D spatial position for the specified sound. - * @param sound_name - audio clip name - case sensitive - * @param x - the x-position of the audio source. - * @param y - the y-position of the audio source. - * @param z - the z-position of the audio source. - * @param [id] - the sound instance ID. If none is passed, all sounds in group will be changed. - * @returns the current 3D spatial position: [x, y, z] - * @category Audio - */ -export function position( - sound_name: string, - x: number, - y: number, - z: number, - id?: number, -): number[] { - const sound = audioTracks[sound_name]; - if (sound && typeof sound !== "undefined") { - return sound.pos(x, y, z, id) as unknown as number[]; - } else { - throw new Error(`audio clip ${sound_name} does not exist`); - } -} - -/** - * Get/set the direction the audio source is pointing in the 3D cartesian coordinate space. - * Depending on how direction the sound is, based on the `cone` attributes, a sound pointing away from the listener can be quiet or silent. - * @param sound_name - audio clip name - case sensitive - * @param x - the x-orientation of the audio source. - * @param y - the y-orientation of the audio source. - * @param z - the z-orientation of the audio source. - * @param [id] - the sound instance ID. If none is passed, all sounds in group will be changed. - * @returns the current 3D spatial orientation: [x, y, z] - * @category Audio - */ -export function orientation( - sound_name: string, - x: number, - y: number, - z: number, - id?: number, -): number[] { - const sound = audioTracks[sound_name]; - if (sound && typeof sound !== "undefined") { - return sound.orientation(x, y, z, id) as unknown as number[]; - } else { - throw new Error(`audio clip ${sound_name} does not exist`); - } -} - -/** - * 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. - * @example - * me.audio.panner("cling", { - * panningModel: 'HRTF', - * refDistance: 0.8, - * rolloffFactor: 2.5, - * distanceModel: 'exponential' - * }); - * @category Audio - */ -export function panner( - sound_name: string, - attributes?: PannerAttributes, - 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 { - throw new Error(`audio clip ${sound_name} does not exist`); - } -} - -/** - * stop the specified sound on all channels - * @param [sound_name] - audio clip name (case sensitive). If none is passed, all sounds are stopped. - * @param [id] - the sound instance ID. If none is passed, all sounds in group will stop. - * @example - * me.audio.stop("cling"); - * @category Audio - */ -export function stop(sound_name?: string, id?: number): void { - if (typeof sound_name !== "undefined") { - const sound = audioTracks[sound_name]; - if (sound && typeof sound !== "undefined") { - sound.stop(id); - // remove the defined onend callback (if any defined) - sound.off("end", undefined, id); - } else { - throw new Error(`audio clip ${sound_name} does not exist`); - } - } else { - Howler.stop(); - } -} - -/** - * pause the specified sound on all channels
- * this function does not reset the currentTime property - * @param sound_name - audio clip name - case sensitive - * @param [id] - the sound instance ID. If none is passed, all sounds in group will pause. - * @example - * me.audio.pause("cling"); - * @category Audio - */ -export function pause(sound_name: string, id?: number): void { - const sound = audioTracks[sound_name]; - if (sound && typeof sound !== "undefined") { - sound.pause(id); - } else { - throw new Error(`audio clip ${sound_name} does not exist`); - } -} - -/** - * resume the specified sound on all channels
- * @param sound_name - audio clip name - case sensitive - * @param [id] - the sound instance ID. If none is passed, all sounds in group will resume. - * @example - * // play an audio clip - * let id = me.audio.play("myClip"); - * ... - * // pause it - * me.audio.pause("myClip", id); - * ... - * // resume - * me.audio.resume("myClip", id); - * @category Audio - */ -export function resume(sound_name: string, id?: number): void { - const sound = audioTracks[sound_name]; - if (sound && typeof sound !== "undefined") { - sound.play(id); - } else { - throw new Error(`audio clip ${sound_name} does not exist`); - } -} - -/** - * play the specified audio track
- * this function automatically set the loop property to true
- * and keep track of the current sound being played. - * @param sound_name - audio track name - case sensitive - * @param [volume=default] - Float specifying volume (0.0 - 1.0 values accepted). - * @returns the sound instance ID. + * Play the specified audio track. Automatically loops the clip and + * tracks it as the current track for {@link stopTrack} / {@link pauseTrack} + * / {@link resumeTrack}. + * @param sound_name - Audio track name (case-sensitive). + * @param volume - Playback volume, `0.0..1.0`. Defaults to the current + * global volume. + * @returns The sound instance ID. * @example * me.audio.playTrack("awesome_music"); * @category Audio */ export function playTrack(sound_name: string, volume?: number): number { - current_track_id = sound_name; - return play(current_track_id, true, null, volume); + state.currentTrackId = sound_name; + return play(state.currentTrackId, true, null, volume); } /** - * stop the current audio track + * Stop the current audio track. * @see {@link playTrack} * @example - * // play an awesome music * me.audio.playTrack("awesome_music"); - * // stop the current music * me.audio.stopTrack(); * @category Audio */ export function stopTrack(): void { - if (current_track_id !== null) { - audioTracks[current_track_id].stop(); - current_track_id = null; + if (state.currentTrackId !== null) { + state.tracks[state.currentTrackId]?.stop(); + state.currentTrackId = null; } } /** - * pause the current audio track + * Pause the current audio track. * @example * me.audio.pauseTrack(); * @category Audio */ export function pauseTrack(): void { - if (current_track_id !== null) { - audioTracks[current_track_id].pause(); + if (state.currentTrackId !== null) { + state.tracks[state.currentTrackId]?.pause(); } } /** - * resume the previously paused audio track + * Resume the previously paused audio track. * @example - * // play an awesome music * me.audio.playTrack("awesome_music"); - * // pause the audio track * me.audio.pauseTrack(); - * // resume the music * me.audio.resumeTrack(); * @category Audio */ export function resumeTrack(): void { - if (current_track_id !== null) { - audioTracks[current_track_id].play(); + if (state.currentTrackId !== null) { + state.tracks[state.currentTrackId]?.play(); } } /** - * returns the current track Id - * @returns audio track name + * Return the name of the current audio track, or `null` if none is set. + * @returns The current track name, or `null`. * @category Audio */ export function getCurrentTrack(): string | null { - return current_track_id; + return state.currentTrackId; } /** - * set the default global volume - * @param volume - Float specifying volume (0.0 - 1.0 values accepted). + * Set the default global volume. + * @param volume - Volume value, `0.0..1.0`. * @category Audio */ export function setVolume(volume: number): void { - Howler.volume(volume); + setGlobalVolume(volume); } /** - * get the default global volume - * @returns current volume value in Float [0.0 - 1.0] . + * Get the default global volume. + * @returns The current volume value, `0.0..1.0`. * @category Audio */ export function getVolume(): number { - return Howler.volume(); + return getGlobalVolume(); } /** - * mute or unmute the specified sound, but does not pause the playback. - * @param sound_name - audio clip name - case sensitive - * @param [id] - the sound instance ID. If none is passed, all sounds in group will mute. - * @param [shouldMute=true] - True to mute and false to unmute + * Mute or unmute the specified sound. Playback continues; only the + * audible output is suppressed. + * @param sound_name - Audio clip name (case-sensitive). + * @param id - Sound instance ID. When omitted, all sounds in the group + * are affected. + * @param shouldMute - `true` to mute, `false` to unmute. Defaults to + * `true`. * @example * // mute the background music * me.audio.mute("awesome_music"); @@ -610,18 +223,14 @@ export function mute( id?: number, shouldMute: boolean = true, ): void { - const sound = audioTracks[sound_name]; - if (sound && typeof sound !== "undefined") { - sound.mute(shouldMute, id); - } else { - throw new Error(`audio clip ${sound_name} does not exist`); - } + getSoundOrThrow(sound_name).mute(shouldMute, id); } /** - * unmute the specified sound - * @param sound_name - audio clip name - * @param [id] - the sound instance ID. If none is passed, all sounds in group will unmute. + * Unmute the specified sound. + * @param sound_name - Audio clip name (case-sensitive). + * @param id - Sound instance ID. When omitted, all sounds in the group + * are affected. * @category Audio */ export function unmute(sound_name: string, id?: number): void { @@ -629,58 +238,59 @@ export function unmute(sound_name: string, id?: number): void { } /** - * mute all audio + * Mute all audio. * @category Audio */ export function muteAll(): void { - Howler.mute(true); + setGlobalMuted(true); } /** - * unmute all audio + * Unmute all audio. * @category Audio */ export function unmuteAll(): void { - Howler.mute(false); + setGlobalMuted(false); } /** - * Returns true if audio is muted globally. - * @returns true if audio is muted globally + * Return whether audio is currently muted globally. + * @returns `true` when audio is muted globally. * @category Audio */ export function muted(): boolean { - return (Howler as any)._muted; + return isGlobalMuted(); } /** - * unload specified audio track to free memory - * @param sound_name - audio track name - case sensitive - * @returns true if unloaded + * Unload the specified audio track to free memory. + * @param sound_name - Audio track name (case-sensitive). + * @returns `true` when the track was found and unloaded. * @example * me.audio.unload("awesome_music"); * @category Audio */ export function unload(sound_name: string): boolean { - if (!(sound_name in audioTracks)) { + const sound = state.tracks[sound_name]; + if (!sound) { return false; } // destroy the Howl object - audioTracks[sound_name].unload(); - delete audioTracks[sound_name]; + sound.unload(); + delete state.tracks[sound_name]; return true; } /** - * unload all audio to free memory + * Unload every loaded audio track to free memory. * @example * me.audio.unloadAll(); * @category Audio */ export function unloadAll(): void { - for (const sound_name in audioTracks) { - if (Object.prototype.hasOwnProperty.call(audioTracks, sound_name)) { + for (const sound_name in state.tracks) { + if (Object.prototype.hasOwnProperty.call(state.tracks, sound_name)) { unload(sound_name); } } diff --git a/packages/melonjs/src/audio/backend.ts b/packages/melonjs/src/audio/backend.ts new file mode 100644 index 000000000..7912a01ff --- /dev/null +++ b/packages/melonjs/src/audio/backend.ts @@ -0,0 +1,218 @@ +/** + * Audio backend — the shared internal surface every other audio module + * (procedural, playback) builds on. Keeps the Howler reference and the + * cross-module mutable state in one place so the public surface modules + * stay backend-agnostic. + * + * Not part of the public `me.audio.*` API — the two getters + * `getAudioContext` / `getMasterGain` are re-exported from `audio.ts` + * for end users; everything else (the `state` object, `soundLoadError`) + * is internal. + */ + +import { Howl, Howler } from "howler"; + +/** + * Whether to stop on an audio loading error. + * + * When `true`, melonJS throws an exception and aborts loading. + * When `false`, melonJS disables sound and logs a warning to the console. + * @default true + */ +// eslint-disable-next-line prefer-const +export let stopOnAudioError: boolean = true; + +/** + * Cross-module mutable state. A single object so multiple consumers + * can read and mutate the same fields without the "ESM `let` exports + * don't share writes across modules" footgun. + * + * Fields: + * - `tracks` — loaded Howl instances keyed by logical sound name. + * `Howl | undefined` because missing keys return undefined at runtime + * 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. + * - `audioExts` — the active list of audio formats set by `init`. + * @ignore + */ +export const state = { + tracks: {} as Record, + currentTrackId: null as string | null, + retryCounter: 0, + audioExts: [] as string[], +}; + +/** + * Look up a loaded `Howl` instance by logical name, or throw a + * uniform "audio clip X does not exist" error if it isn't loaded. + * Used by every per-clip helper across `playback.ts` / `audio.ts` so + * the error contract stays identical across the whole surface. + * @ignore + */ +export function getSoundOrThrow(sound_name: string): Howl { + const sound = state.tracks[sound_name]; + if (!sound) { + throw new Error(`audio clip ${sound_name} does not exist`); + } + return sound; +} + +/** + * Event listener callback on load error. Retries the load up to 3 + * times, then either throws or disables audio (depending on the + * `stopOnAudioError` flag re-exported from `audio.ts`). + * @ignore + */ +export const soundLoadError = function ( + sound_name: string, + onerror_cb?: () => void, + stopOnError: boolean = true, +): void { + if (state.retryCounter++ >= 3) { + const errmsg = `melonJS: failed loading ${sound_name}`; + if (!stopOnError) { + // disable audio + Howler.mute(true); + onerror_cb?.(); + console.warn(`${errmsg}, disabling audio`); + } else { + onerror_cb?.(); + throw new Error(errmsg); + } + } else { + state.tracks[sound_name]?.load(); + } +}; + +/** + * 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 + * 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. + * @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` null. Nudging + // `Howler.volume()` triggers Howler's internal `setupAudioContext` + // without changing the master volume. + // + // Gate the nudge on `usingWebAudio` so we don't fire it on every + // call in HTML5-only mode — `Howler.ctx` is permanently null there + // and the nudge can't help (the runtime check would just re-decide + // "no WebAudio" and return immediately). + if (Howler.usingWebAudio && !Howler.ctx) { + Howler.volume(Howler.volume()); + } + // `ctx` is declared non-nullable in @types/howler but can still be + // null when WebAudio is unavailable (HTML5-only mode). + return Howler.ctx ?? null; +} + +/** + * Return the audio module's master gain node — the single `GainNode` + * every playback path runs through on its way to `ctx.destination`, + * and the lever that {@link setVolume} / {@link muteAll} manipulate. + * + * Connect to this node (instead of `ctx.destination`) whenever you + * build a custom WebAudio graph and want the result to respect the + * engine's mute / volume state. Returns `null` when audio is disabled + * or unavailable. + * @category Audio + */ +export function getMasterGain(): GainNode | null { + // Chains through `getAudioContext` so the same lazy-init nudge + // covers both — when audio runs on HTML5 Audio instead of WebAudio, + // `Howler.ctx` is null and we short-circuit here. The remaining + // `?? null` defends against the narrow iOS-8-webview edge case where + // ctx is created but `masterGain` isn't (Howler flips + // `usingWebAudio` to false between the two steps). + if (!getAudioContext()) return null; + return Howler.masterGain ?? null; +} + +// --------------------------------------------------------------------- +// Thin wrappers over Howler's global surface. Kept internal (not +// re-exported from `audio.ts`) so users still go through the public +// `setVolume` / `muteAll` / `hasFormat` / etc. helpers. Their job is +// to isolate the Howler reference to this file — when the backend +// gets swapped, only these wrappers change. +// --------------------------------------------------------------------- + +/** + * Get the audio module's global volume. + * @ignore + */ +export function getGlobalVolume(): number { + return Howler.volume(); +} + +/** + * Set the audio module's global volume. + * @ignore + */ +export function setGlobalVolume(v: number): void { + Howler.volume(v); +} + +/** + * Mute or unmute the audio module globally. + * @ignore + */ +export function setGlobalMuted(muted: boolean): void { + Howler.mute(muted); +} + +/** + * Whether the audio module is currently muted globally. + * @ignore + */ +export function isGlobalMuted(): boolean { + // Howler doesn't expose a public muted getter — peek at the private + // flag that `Howler.mute(true/false)` sets internally. Narrow cast + // (vs. `as any`) documents the single field we're reaching for. + return (Howler as unknown as { _muted: boolean })._muted; +} + +/** + * Stop every playing sound on every channel. + * @ignore + */ +export function stopAllPlayback(): void { + Howler.stop(); +} + +/** + * Whether the given audio codec is supported by the backend / browser. + * @ignore + */ +export function hasCodec(codec: string): boolean { + if (!isAudioAvailable()) return false; + // `Howler.codecs(...)` is declared `boolean` in @types/howler but at + // runtime returns `undefined` for unrecognised codecs (lookup in a + // dict). Widen the cast so a strict comparison yields a clean + // boolean for the public surface (`audio.hasFormat`). + return (Howler.codecs(codec) as boolean | undefined) === true; +} + +/** + * Whether at least one audio backend (HTML5 or WebAudio) is available. + * @ignore + */ +export function isAudioAvailable(): boolean { + return !Howler.noAudio; +} diff --git a/packages/melonjs/src/audio/playback.ts b/packages/melonjs/src/audio/playback.ts new file mode 100644 index 000000000..edb94288e --- /dev/null +++ b/packages/melonjs/src/audio/playback.ts @@ -0,0 +1,387 @@ +/** + * File-based playback — load audio assets, then play / pause / fade / + * seek / etc. Every function in this module operates on the shared + * `state.tracks` map exposed from `backend.ts`, so the audio module's + * other surfaces (track helpers, mix, unload) can see the same set of + * loaded sounds. + */ + +import { Howl } from "howler"; +import { clamp } from "../math/math.ts"; +import { isDataUrl } from "../utils/string.ts"; +import { + getGlobalVolume, + getSoundOrThrow, + soundLoadError, + state, + stopAllPlayback, + stopOnAudioError, +} from "./backend.ts"; +import type { LoadSettings, PannerAttributes, SoundAsset } from "./types.ts"; + +/** + * Load an audio file. + * + * `sound.src` is treated as a base path / prefix; the URL is built as + * `${sound.src}${sound.name}.${ext}` for each extension configured by + * {@link init}, until one loads. Data URLs (`data:audio/...`) are + * used as-is and skip the prefix-and-extension dance. + * @param sound - The {@link SoundAsset} descriptor — logical `name`, + * `src` base path / prefix (or data URL), and optional playback + * flags (`autoplay`, `loop`, `stream`, `html5`). + * @param onloadcb - Called when the resource has finished loading. + * @param onerrorcb - Called when loading fails. + * @param settings - Optional {@link LoadSettings} — `nocache` (query + * string appended for cache busting) and `withCredentials` (forwarded + * to the underlying XHR for cross-origin authenticated requests). + * @returns The number of assets loaded (always `1` on success). + * @category Audio + */ +export function load( + sound: SoundAsset, + onloadcb?: () => void, + onerrorcb?: () => void, + settings: LoadSettings = {}, +): number { + const urls: string[] = []; + if (state.audioExts.length === 0) { + throw new Error( + "target audio extension(s) should be set through me.audio.init() before calling the preloader.", + ); + } + if (isDataUrl(sound.src)) { + urls.push(sound.src); + } else { + for (let i = 0; i < state.audioExts.length; i++) { + urls.push( + `${sound.src + sound.name}.${state.audioExts[i]}${settings.nocache ?? ""}`, + ); + } + } + + state.tracks[sound.name] = new Howl({ + src: urls, + volume: getGlobalVolume(), + autoplay: sound.autoplay === true, + loop: sound.loop === true, + html5: sound.stream === true || sound.html5 === true, + // @ts-expect-error xhrWithCredentials is a valid Howl option but not in the type definitions + xhrWithCredentials: settings.withCredentials, + onloaderror() { + soundLoadError.call(this, sound.name, onerrorcb, stopOnAudioError); + }, + onload() { + state.retryCounter = 0; + if (typeof onloadcb === "function") { + onloadcb(); + } + }, + }); + + return 1; +} + +/** + * Play the specified sound. + * @param sound_name - Audio clip name (case-sensitive). + * @param loop - Whether to loop the clip. Defaults to `false`. + * @param onend - Called when the sound instance ends playing. + * @param volume - Playback volume, `0.0..1.0`. Defaults to the current + * global volume. + * @returns The sound instance ID. + * @example + * // play the "cling" audio clip + * me.audio.play("cling"); + * // play & loop the "engine" audio clip + * me.audio.play("engine", true); + * // play the "gameover_sfx" audio clip and call myFunc when finished + * me.audio.play("gameover_sfx", false, myFunc); + * // play the "gameover_sfx" audio clip at half volume + * me.audio.play("gameover_sfx", false, null, 0.5); + * @category Audio + */ +export function play( + sound_name: string, + loop: boolean = false, + onend?: (() => void) | null, + volume?: number, +): number { + const sound = getSoundOrThrow(sound_name); + const id = sound.play(); + sound.loop(loop, id); + sound.volume( + typeof volume === "number" ? clamp(volume, 0.0, 1.0) : getGlobalVolume(), + id, + ); + if (typeof onend === "function") { + if (loop) { + sound.on("end", onend, id); + } else { + sound.once("end", onend, id); + } + } + return id; +} + +/** + * Fade a currently playing sound between two volumes. + * @param sound_name - Audio clip name (case-sensitive). + * @param from - Volume to fade from, `0.0..1.0`. + * @param to - Volume to fade to, `0.0..1.0`. + * @param duration - Fade time in milliseconds. + * @param id - Sound instance ID. When omitted, all sounds in the group + * are faded. + * @category Audio + */ +export function fade( + sound_name: string, + from: number, + to: number, + duration: number, + id?: number, +): void { + getSoundOrThrow(sound_name).fade(from, to, duration, id); +} + +/** + * 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. + * @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); +} + +/** + * 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. + * @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); +} + +/** @inheritDoc */ +export function stereo(sound_name: string): number; +/** @inheritDoc */ +export function stereo(sound_name: string, pan: number, id?: number): void; +/** + * Get or set the stereo panning for a sound. + * + * Call with just `sound_name` to read back the group's current pan; + * call with a `pan` value (and optionally `id`) to write it. + * @param sound_name - Audio clip name (case-sensitive). + * @param pan - Pan value, `-1.0` (full left) to `1.0` (full right). + * Omit to read the current value. + * @param id - Sound instance ID. When omitted, all sounds in the group + * are affected. + * @returns The current pan value when called as a getter; nothing when + * called as a setter. + * @example + * me.audio.stereo("cling", -1); // set + * me.audio.stereo("cling"); // read + * @category Audio + */ +export function stereo( + sound_name: string, + pan?: number, + id?: number, +): number | void { + const sound = getSoundOrThrow(sound_name); + if (pan === undefined) { + return sound.stereo(); + } + sound.stereo(pan, id); +} + +/** @inheritDoc */ +export function position(sound_name: string): [number, number, number]; +/** @inheritDoc */ +export function position( + sound_name: string, + x: number, + y?: number, + z?: number, + id?: number, +): void; +/** + * Get or set the 3D spatial position of a sound. + * + * Call with just `sound_name` to read back the group's current + * position; call with `x` (and optionally `y` / `z` / `id`) to write + * it. Missing `y` / `z` default to `0` and `-0.5` respectively. + * @param sound_name - Audio clip name (case-sensitive). + * @param x - X-coordinate of the audio source. Omit to read. + * @param y - Y-coordinate. Defaults to `0` when setting. + * @param z - Z-coordinate. Defaults to `-0.5` when setting. + * @param id - Sound instance ID. When omitted, all sounds in the group + * are affected. + * @returns The current `[x, y, z]` when called as a getter; nothing + * when called as a setter. + * @category Audio + */ +export function position( + sound_name: string, + x?: number, + y?: number, + z?: number, + id?: number, +): [number, number, number] | void { + const sound = getSoundOrThrow(sound_name); + if (x === undefined) { + return sound.pos(); + } + sound.pos(x, y, z, id); +} + +/** @inheritDoc */ +export function orientation(sound_name: string): [number, number, number]; +/** @inheritDoc */ +export function orientation( + sound_name: string, + x: number, + y?: number, + z?: number, + id?: number, +): void; +/** + * Get or set the direction the audio source is pointing in 3D space. + * Combined with the {@link PannerAttributes} cone settings, a sound + * pointing away from the listener will be quieter or silent. + * + * Call with just `sound_name` to read back the group's current + * orientation; call with `x` (and optionally `y` / `z` / `id`) to write + * it. + * @param sound_name - Audio clip name (case-sensitive). + * @param x - X-component of the orientation vector. Omit to read. + * @param y - Y-component. Defaults to the current value when setting. + * @param z - Z-component. Defaults to the current value when setting. + * @param id - Sound instance ID. When omitted, all sounds in the group + * are affected. + * @returns The current `[x, y, z]` when called as a getter; nothing + * when called as a setter. + * @category Audio + */ +export function orientation( + sound_name: string, + x?: number, + y?: number, + z?: number, + id?: number, +): [number, number, number] | void { + const sound = getSoundOrThrow(sound_name); + if (x === undefined) { + return sound.orientation(); + } + sound.orientation(x, y, z, id); +} + +/** + * Get or set the panner-node attributes for a sound or sound group. + * @param sound_name - Audio clip name (case-sensitive). + * @param attributes - The {@link PannerAttributes} to apply (cone angles, + * distance model, panning algorithm, …). See the interface for + * per-field defaults. + * @param id - Sound instance ID. When omitted, all sounds in the group + * are affected. + * @returns The resulting {@link PannerAttributes} after the update. + * @example + * me.audio.panner("cling", { + * panningModel: "HRTF", + * refDistance: 0.8, + * rolloffFactor: 2.5, + * distanceModel: "exponential", + * }); + * @category Audio + */ +export function panner( + sound_name: string, + attributes?: PannerAttributes, + id?: number, +): PannerAttributes { + const sound = getSoundOrThrow(sound_name); + if (attributes !== undefined) { + // "set" overload returns the Howl for chaining; we still want + // to hand the caller the current attribute snapshot back. Our + // `distanceModel` covers the full WebAudio union (including + // `"exponential"`) while Howler's declared parameter type only + // lists `"linear" | "inverse"` — its runtime accepts all three. + // Cast at the boundary so the type check passes; the upstream + // `@types/howler` declaration is incomplete here. + const attrs = attributes as Parameters[0]; + if (id !== undefined) sound.pannerAttr(attrs, id); + else sound.pannerAttr(attrs); + } + return id !== undefined ? sound.pannerAttr(id) : sound.pannerAttr(); +} + +/** + * Stop the specified sound on all channels. + * @param sound_name - Audio clip name (case-sensitive). When omitted, + * every sound currently playing is stopped. + * @param id - Sound instance ID. When omitted, all sounds in the group + * are stopped. + * @example + * me.audio.stop("cling"); + * @category Audio + */ +export function stop(sound_name?: string, id?: number): void { + if (sound_name === undefined) { + stopAllPlayback(); + return; + } + const sound = getSoundOrThrow(sound_name); + sound.stop(id); + // remove the defined onend callback (if any defined) + sound.off("end", undefined, id); +} + +/** + * Pause the specified sound on all channels. Does not reset the + * current playback position. + * @param sound_name - Audio clip name (case-sensitive). + * @param id - Sound instance ID. When omitted, all sounds in the group + * are paused. + * @example + * me.audio.pause("cling"); + * @category Audio + */ +export function pause(sound_name: string, id?: number): void { + getSoundOrThrow(sound_name).pause(id); +} + +/** + * Resume the specified sound on all channels. + * @param sound_name - Audio clip name (case-sensitive). + * @param id - Sound instance ID. When omitted, all sounds in the group + * are resumed. + * @example + * // play an audio clip + * let id = me.audio.play("myClip"); + * // ... + * // pause it + * me.audio.pause("myClip", id); + * // ... + * // resume + * me.audio.resume("myClip", id); + * @category Audio + */ +export function resume(sound_name: string, id?: number): void { + getSoundOrThrow(sound_name).play(id); +} diff --git a/packages/melonjs/src/audio/procedural.ts b/packages/melonjs/src/audio/procedural.ts new file mode 100644 index 000000000..c777ce757 --- /dev/null +++ b/packages/melonjs/src/audio/procedural.ts @@ -0,0 +1,356 @@ +/** + * Procedural audio — fire-and-forget envelope-shaped primitives that + * build on the shared `AudioContext` exposed by `backend.ts`. Designed + * for "just play a beep / explosion / whoosh" use cases where loading + * an audio file would be overkill. + * + * Two primitives: + * - `tone` for pitched sources (clicks, chimes, lasers, chord pings). + * - `noise` for non-pitched sources (explosions, hi-hats, swooshes, + * wind, footsteps). + * + * Both share the same envelope + output routing rails so future + * primitives can be added with zero copy-paste. + */ + +import { getAudioContext, getMasterGain } from "./backend.ts"; +import type { NoiseOptions, ToneOptions } from "./types.ts"; + +// --------------------------------------------------------------------- +// Procedural-audio internals — shared between `tone` and `noise`. +// --------------------------------------------------------------------- + +/** + * Resume the AudioContext if a browser autoplay policy has it + * suspended. Best-effort and idempotent; the promise is intentionally + * unawaited because we want the same call site to work in both gesture + * and non-gesture contexts. + * @ignore + */ +function _resumeIfSuspended(ctx: AudioContext): void { + if (ctx.state === "suspended") { + ctx.resume().catch(() => { + /* ignore */ + }); + } +} + +/** + * Build the linear-attack / exponential-decay gain envelope used by + * every procedural primitive. + * + * The decay uses an exponential ramp when there's enough headroom for + * a smooth taper (peak gain greater than the `0.0001` near-silence + * floor). Otherwise we use a linear ramp to `0`, which covers both + * `gain === 0` (exp ramps reject a `0` start value with + * `InvalidStateError`) AND tiny positive gains where a target of + * `0.0001` would ramp UP and produce an audible click. + * @ignore + */ +function _buildGainEnvelope( + ctx: AudioContext, + t0: number, + t1: number, + attack: number, + duration: number, + gain: number, +): GainNode { + // Clamp attack to `[0.001, duration / 2]`. For very short durations + // (< 2 ms) the range is degenerate — the upper bound wins so the + // envelope still fits inside the playback window. + const atk = Math.min(duration / 2, Math.max(0.001, attack)); + const env = ctx.createGain(); + env.gain.setValueAtTime(0, t0); + env.gain.linearRampToValueAtTime(gain, t0 + atk); + if (gain > 0.0001) { + env.gain.exponentialRampToValueAtTime(0.0001, t1); + } else { + env.gain.linearRampToValueAtTime(0, t1); + } + return env; +} + +/** + * Final hop of any procedural primitive — route `source` to the audio + * module's master gain (so `muteAll` / `setVolume` apply), optionally + * through a `StereoPanner`. Falls back to `ctx.destination` only if the + * master gain isn't available (very restricted audio envs). + * + * Returns the `StereoPannerNode` it created (or `null` if `pan === 0`) + * so the caller can disconnect it once playback ends. + * @ignore + */ +function _connectToOutput( + ctx: AudioContext, + source: AudioNode, + pan: number, + t0: number, +): StereoPannerNode | null { + const out = getMasterGain() ?? ctx.destination; + if (pan === 0) { + source.connect(out); + return null; + } + const panner = ctx.createStereoPanner(); + panner.pan.setValueAtTime(Math.max(-1, Math.min(1, pan)), t0); + source.connect(panner).connect(out); + return panner; +} + +/** + * 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 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. + * + * **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 - 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 }); + * // 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; + + const freqs = Array.isArray(freq) ? freq : [freq]; + // Empty partial list = nothing to play. Bail before building any + // nodes so we don't leave an env/panner connected forever (no + // oscillator would ever fire `onended` to clean them up). + if (freqs.length === 0) return; + + _resumeIfSuspended(ctx); + + const dur = Math.max(0.001, duration); + const t0 = ctx.currentTime; + const t1 = t0 + dur; + const env = _buildGainEnvelope(ctx, t0, t1, attack, dur, gain); + const panner = _connectToOutput(ctx, env, pan, t0); + + // Count oscillators down to zero so the LAST one to end is the one + // that disconnects the shared envelope + panner — otherwise we'd + // leave the graph half-wired. + let remaining = freqs.length; + for (const f of freqs) { + const osc = ctx.createOscillator(); + osc.type = wave; + osc.frequency.setValueAtTime(f, t0); + // Pitch slide also goes through `exponentialRampToValueAtTime`, + // which needs the starting frequency to be > 0. Skip the ramp + // when `f <= 0` (silent / DC) so it can't throw. + if (pitchSlide !== 1 && f > 0) { + 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); + osc.onended = () => { + osc.disconnect(); + if (--remaining === 0) { + env.disconnect(); + panner?.disconnect(); + } + }; + } +} + +/** + * Fill a buffer in-place with single-channel noise samples of the + * requested spectral colour. Pink uses Paul Kellet's refined coefficients; + * brown is a leaky integrator over white. Output is roughly normalised + * to `[-1, 1]` for both coloured variants. + * @ignore + */ +function fillNoiseBuffer( + data: Float32Array, + type: "white" | "pink" | "brown", +): void { + const n = data.length; + if (type === "white") { + for (let i = 0; i < n; i++) { + data[i] = Math.random() * 2 - 1; + } + return; + } + if (type === "pink") { + let b0 = 0; + let b1 = 0; + let b2 = 0; + let b3 = 0; + let b4 = 0; + let b5 = 0; + let b6 = 0; + for (let i = 0; i < n; i++) { + const w = Math.random() * 2 - 1; + b0 = 0.99886 * b0 + w * 0.0555179; + b1 = 0.99332 * b1 + w * 0.0750759; + b2 = 0.969 * b2 + w * 0.153852; + b3 = 0.8665 * b3 + w * 0.3104856; + b4 = 0.55 * b4 + w * 0.5329522; + b5 = -0.7616 * b5 - w * 0.016898; + data[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362) * 0.11; + b6 = w * 0.115926; + } + return; + } + // brown: leaky integrator over white, rescaled into audible range. + let last = 0; + for (let i = 0; i < n; i++) { + const w = Math.random() * 2 - 1; + last = (last + 0.02 * w) / 1.02; + data[i] = last * 3.5; + } +} + +/** + * Fire a single-shot envelope-shaped noise burst on the shared + * {@link getAudioContext} context. Sits alongside {@link tone} as the + * non-pitched half of the procedural-audio surface — `tone` is the right + * tool for anything with a clear pitch (clicks, chimes, lasers), `noise` + * is the right tool for anything percussive without one (explosions, + * hi-hats, swooshes, footsteps, wind). + * + * The output runs through the master gain shared with file-based + * playback, so {@link muteAll} / {@link setVolume} apply uniformly. + * Browser autoplay gating applies — the first call after a user + * gesture lets every subsequent call play. + * + * **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. + * @param opts - the {@link NoiseOptions} (duration, spectral colour, + * envelope, pan, optional filter + sweep). See the interface for + * per-field defaults. + * @example + * // Explosion: brown rumble closing into a thud + * me.audio.noise({ + * duration: 0.8, + * type: "brown", + * gain: 0.4, + * filter: { type: "lowpass", frequency: 800 }, + * filterSweep: 0.3, + * }); + * // Hi-hat: short, bright, top-end only + * me.audio.noise({ + * duration: 0.05, + * filter: { type: "highpass", frequency: 7000 }, + * gain: 0.2, + * }); + * // Swoosh: bandpass white with rising sweep — UI transition, melee whoosh + * me.audio.noise({ + * duration: 0.3, + * filter: { type: "bandpass", frequency: 400, Q: 1.5 }, + * filterSweep: 4, + * gain: 0.3, + * }); + * // Wind / breath: long, low-pink, no sweep + * me.audio.noise({ + * duration: 2, + * type: "pink", + * filter: { type: "bandpass", frequency: 600 }, + * gain: 0.08, + * }); + * // Footstep on dirt — short brown thump + * me.audio.noise({ + * duration: 0.08, + * type: "brown", + * filter: { type: "lowpass", frequency: 200 }, + * gain: 0.25, + * }); + * @category Audio + */ +export function noise(opts: NoiseOptions): void { + const ctx = getAudioContext(); + if (!ctx) return; + + const { + duration, + type = "white", + gain = 0.1, + attack = 0.005, + pan = 0, + filter, + filterSweep = 1, + } = opts; + + _resumeIfSuspended(ctx); + + const dur = Math.max(0.001, duration); + const t0 = ctx.currentTime; + const t1 = t0 + dur; + const env = _buildGainEnvelope(ctx, t0, t1, attack, dur, gain); + + // Source: a one-shot buffer of `dur * sampleRate` random samples. + const sampleCount = Math.max(1, Math.ceil(dur * ctx.sampleRate)); + const buffer = ctx.createBuffer(1, sampleCount, ctx.sampleRate); + fillNoiseBuffer(buffer.getChannelData(0), type); + const src = ctx.createBufferSource(); + src.buffer = buffer; + src.connect(env); + + // Optional band-shaping filter (with optional sweep on its frequency). + let tail: AudioNode = env; + if (filter !== undefined) { + const biquad = ctx.createBiquadFilter(); + biquad.type = filter.type; + biquad.frequency.setValueAtTime(filter.frequency, t0); + if (filter.Q !== undefined) { + biquad.Q.setValueAtTime(filter.Q, t0); + } + // Filter sweep also goes through `exponentialRampToValueAtTime` + // — only schedule the ramp when both start and target are > 0. + if (filterSweep !== 1 && filter.frequency > 0) { + biquad.frequency.exponentialRampToValueAtTime( + Math.max(0.01, filter.frequency * filterSweep), + t1, + ); + } + tail.connect(biquad); + tail = biquad; + } + + const panner = _connectToOutput(ctx, tail, pan, t0); + + src.start(t0); + src.stop(t1 + 0.02); + src.onended = () => { + src.disconnect(); + env.disconnect(); + // If a filter was inserted between env and the output, tail !== env. + if (tail !== env) tail.disconnect(); + panner?.disconnect(); + }; +} diff --git a/packages/melonjs/src/audio/types.ts b/packages/melonjs/src/audio/types.ts new file mode 100644 index 000000000..dc0eb7290 --- /dev/null +++ b/packages/melonjs/src/audio/types.ts @@ -0,0 +1,210 @@ +/** + * 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. + */ + +/** + * Sound asset descriptor passed to `audio.load`. + * @category Audio + */ +export interface SoundAsset { + /** Logical name used to play / stop / reference the sound later. */ + name: string; + /** + * Base path / prefix for the audio resource. The loader builds the + * full URL as `${src}${name}.${ext}` for each format configured by + * `audio.init()`, so `src` is typically a directory ending in `/`. + * Data URLs (`data:audio/...`) are used as-is and skip the prefix + * + extension construction. + */ + 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 `