feat(audio): procedural audio primitives (tone + noise) + audio.ts cleanup#1456
feat(audio): procedural audio primitives (tone + noise) + audio.ts cleanup#1456obiot wants to merge 19 commits into
Conversation
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) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds two new public audio APIs to melonJS — getAudioContext() to expose the shared Howler AudioContext and tone() to fire a single‑shot envelope‑shaped oscillator (multi‑partial freq, gain envelope, pan, pitch slide). The plinko‑planck demo's private synth helper is rewritten on top of the new primitive, dropping ~70 lines of bespoke WebAudio code.
Changes:
- New
audio.tone(opts)andaudio.getAudioContext()public APIs inpackages/melonjs/src/audio/audio.ts, with full JSDoc and aToneOptionsinterface. - Test suite extended in
tests/audio.spec.jscovering exports, shared context identity, option permutations, pan clamping, zero/negative duration tolerance, and scheduling on the shared context. plinko-planck/audio.tsrewritten to consume the new primitive; throttle switched fromctx.currentTimetoperformance.now(); CHANGELOG entries added.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| packages/melonjs/src/audio/audio.ts | Adds getAudioContext() and tone() (plus ToneOptions) on top of Howler's shared context. |
| packages/melonjs/tests/audio.spec.js | New procedural audio describe block exercising the new APIs and edge cases. |
| packages/examples/src/examples/plinko-planck/audio.ts | Replaces private WebAudio synth with calls to audio.tone; uses performance.now() for throttle. |
| packages/melonjs/CHANGELOG.md | Documents the two new audio APIs in the unreleased section. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
…ibutes - 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) <noreply@anthropic.com>
…hape
Sits alongside `tone` as the non-pitched half of the procedural-audio
surface — `tone` covers anything with a clear pitch (clicks, chimes,
lasers); `noise` covers anything percussive without one (explosions,
hi-hats, swooshes, footsteps, wind, breath).
API:
audio.noise({
duration: number,
type?: "white" | "pink" | "brown", // default "white"
gain?: number, // default 0.1
attack?: number, // default 0.005
pan?: number, // default 0
filter?: { type, frequency, Q? }, // optional band-shape
filterSweep?: number, // freq multiplier over duration
});
Implementation mirrors `tone` end-to-end: lazy WebAudio context via
`getAudioContext`, identical linear-attack / exp-decay envelope, same
StereoPanner pan, routed through the master gain so `muteAll` /
`setVolume` apply uniformly. White is uniform random; pink uses Paul
Kellet's refined coefficient bank; brown is a leaky integrator over
white.
Adds 7 tests covering: export, every option combination, optional
filter + sweep, pan clamping, zero/negative duration tolerance,
shared-context scheduling, mute respect. Total audio spec is now
25 tests.
JSDoc ships five worked recipes (explosion, hi-hat, swoosh, wind,
footstep) so the function reads as a recipe book rather than a primitive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small hygiene fixes:
- `console.log` → `console.warn` for the "disabling audio" failure
message — `.log` was semantically wrong for an error path.
- `(Howler as any)._muted` narrowed to a typed cast
`(Howler as unknown as { _muted: boolean })._muted` — still a private
peek (Howler has no public muted getter) but no longer weakens the
surrounding call site to `any`.
- `audioTracks` retyped `Record<string, Howl | undefined>`. The previous
`Record<string, Howl>` told TypeScript every key resolves to a Howl,
so the existing defensive `if (sound && typeof sound !== "undefined")`
guards across ~11 sites looked like dead code to lint. Now the type
matches runtime: index reads return `Howl | undefined`, the truthy
checks become meaningful, and the redundant `typeof !== "undefined"`
half of each guard drops out. Track helpers (`stopTrack`, `pauseTrack`,
`resumeTrack`, `unload`, soundLoadError retry) get optional-chaining /
local-binding guards to match.
Build clean, 25 audio tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audio.ts accumulated years of mixed JSDoc style — lowercase-no-period summaries on the legacy file-based functions, sentence-case-with-period on the new procedural ones; mixed @param phrasing (`audio clip name - case sensitive` vs `Volume to fade from (0.0 to 1.0).`); stale `<br>` tags from a pre-TypeDoc renderer; inconsistent @returns wording. This pass normalises everything to a single voice: - Summaries start with a verb in sentence case, end with a period. - Drop stale `<br>` tags (TypeDoc handles paragraph breaks via blanks). - @param descriptions: `Sentence-case, ends with period.` - Drop `[optional]` brackets — TS signature already says it. - `@param x - Default $X` becomes `Defaults to $X` prose at the end. - @returns consistently describes the value, not "returns the value". - `audio clip name - case sensitive` → `Audio clip name (case-sensitive).` - Cross-link relevant types with `{@link}` (PannerAttributes, etc.). - Fix the `panner` example: `distanceModel: "exponential"` was invalid per Howler's narrow `"linear" | "inverse"` union; use `"inverse"`. Functional behaviour unchanged. Build clean, 25 audio tests still green, TypeDoc emits 0 audio-specific warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Comments suppressed due to low confidence (1)
packages/melonjs/src/audio/audio.ts:972
- Same as
tone(): the noise envelope usesenv.gain.exponentialRampToValueAtTime(...), which requires the gain value at the start of the exponential ramp to be > 0. If callers passgain: 0/negative, this can throwInvalidStateError. Clampgainto a small positive floor (or avoid exponential ramps whengain <= 0) to keepnoise()truly no-throw.
const env = ctx.createGain();
env.gain.setValueAtTime(0, t0);
env.gain.linearRampToValueAtTime(gain, t0 + atk);
env.gain.exponentialRampToValueAtTime(0.0001, t1);
…tore distanceModel "exponential"
Three real bugs Copilot caught:
- `tone({ gain: 0 })` threw `InvalidStateError` because the decay used
`exponentialRampToValueAtTime`, which requires the value at the start
of the ramp to be strictly positive. Now we branch: exp decay when
peak gain > 0, linear decay when gain is 0. Silent either way, no
throw.
- `tone({ freq <= 0, pitchSlide !== 1 })` threw for the same reason on
the oscillator frequency ramp. Now we skip the slide when the
starting frequency isn't positive (the oscillator is silent / DC
anyway, so the slide had no audible effect).
- `noise({ filter: { frequency <= 0 }, filterSweep !== 1 })` threw for
the same reason on the biquad frequency ramp. Same guard.
Type regression caught in the same review:
- `PannerAttributes.distanceModel` previously accepted `"linear" |
"inverse" | "exponential"` (the full WebAudio `PannerNode.distanceModel`
union). The earlier types-cleanup pass narrowed it to match Howler's
declared `"linear" | "inverse"` and dropped `"exponential"`, which was
a breaking change for users who had been passing it (Howler accepts
it at runtime — only the `@types/howler` declaration is incomplete).
Restored via `DistanceModelType`; the cast to Howler's narrower union
is now isolated to the single `pannerAttr` call site.
Doc fix: `getAudioContext()`'s example connected `ctx.destination →
analyser`, which is backwards (destination is the terminal node, not a
source). Replaced with a side-channel beep example that exercises the
shared context end-to-end.
CHANGELOG note added for the `panner()` return-shape change shipped in
the cleanup pass (previously returned a `Howl` instance cast to
`PannerAttributes` — the docstring's `@returns` contract was lying).
Adds 4 regression tests covering the three exp-ramp edge cases that
would otherwise throw. Suite is now 29 tests, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The function returns an `AudioContext` — there's nothing engine-specific to demonstrate. Anyone reaching for `getAudioContext` already knows WebAudio. Trim the JSDoc to description + category. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (3)
packages/melonjs/src/audio/audio.ts:1020
noise()also connects envelope/filter/panner nodes toHowler.masterGainwithout any teardown. Even thoughBufferSourceNodestops, the connected Gain/Biquad/StereoPanner nodes can remain in the graph indefinitely, causing graph growth over time. Add anonendedhandler (or timed cleanup) to disconnect the created nodes once playback completes.
// 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;
}
// Route through master gain so mute/volume apply uniformly.
const out = Howler.masterGain;
if (pan === 0) {
tail.connect(out);
} else {
const panner = ctx.createStereoPanner();
panner.pan.setValueAtTime(Math.max(-1, Math.min(1, pan)), t0);
tail.connect(panner).connect(out);
}
src.start(t0);
src.stop(t1 + 0.02);
packages/melonjs/src/audio/types.ts:47
LoadSettingsis described as being forwarded to afetchrequest /RequestInit, but audio loading is done via Howler (Howl) which uses XHR internally (xhrWithCredentials). The docs should avoid referencingfetch.credentialsand instead describe how these fields affect Howler’s request behavior.
/**
* Optional settings forwarded to the underlying `fetch` request used
* to load audio resources. Mirrors a subset of the standard `RequestInit`
* surface (see
* {@link https://developer.mozilla.org/en-US/docs/Web/API/RequestInit | MDN — RequestInit}).
* @category Audio
*/
export interface LoadSettings {
/** Cache-busting query string appended to the resource URL. */
nocache?: string;
/** Forwarded to `fetch.credentials` for cross-origin authenticated requests. */
withCredentials?: boolean;
packages/melonjs/src/audio/types.ts:188
ToneOptions.attackis documented as capped atduration / 2, buttone()also enforces a minimum attack floor (0.001s) after flooring duration. For very short durations the cap description becomes inaccurate. Please tweak the prose to match the actual clamping rules.
/**
* Attack time in seconds — linear ramp from 0 up to `gain`.
* Capped at `duration / 2`. Defaults to `0.005`.
*/
attack?: number;
Sibling to `getAudioContext()` — exposes the underlying `GainNode` the audio module routes all playback through (the master mix point that `setVolume` / `muteAll` manipulate). User-facing benefit: connecting a custom analyser / filter / convolver to this node keeps the result subject to engine-wide mute and volume, which is what you almost always want. Internal benefit: `tone()` and `noise()` no longer reference Howler directly. The Howler dependency is now isolated to the two escape-hatch getters (`getAudioContext`, `getMasterGain`) and the module's own backend management (load / play / volume / mute / etc.). Swapping Howler later means rewriting two helpers + the management functions, not chasing references through procedural-audio code. `getMasterGain` chains through `getAudioContext` for the lazy-init nudge — the master gain is created alongside the context, so a single setup ritual covers both. Falls back to `ctx.destination` in the procedural functions when master gain isn't available (very restricted audio envs). +2 tests, suite is now 31. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The comment overstated what the null-coalesce was protecting against. Cleaner explanation: - The broad case (no WebAudio, HTML5-Audio-only) is already covered by the `getAudioContext()` short-circuit at the top of the function — in that mode both `ctx` and `masterGain` are null in lockstep. - The `?? null` defends against the one path where they diverge: iOS 8 non-Safari webview, where Howler creates the context then flips `usingWebAudio` to false before the masterGain step. No code change, comment-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (2)
packages/melonjs/src/audio/audio.ts:1042
noise()schedulessrc.stop(...), but the created nodes (env, optionalbiquad, optional stereo panner) are never disconnected. Because these nodes are connected toout, repeated calls can grow the audio graph unnecessarily. Consider usingsrc.onendedto disconnect the chain (and clear references) once the burst finishes.
src.start(t0);
src.stop(t1 + 0.02);
packages/melonjs/src/audio/audio.ts:1039
noise()assumesctx.createStereoPanner()exists wheneverpan !== 0. To keep this API robust across WebAudio implementations, add a feature check and either skip panning or fall back to aPannerNode-based approach whencreateStereoPanneris unavailable.
} else {
const panner = ctx.createStereoPanner();
panner.pan.setValueAtTime(Math.max(-1, Math.min(1, pan)), t0);
tail.connect(panner).connect(out);
}
Extracts three private helpers shared by both procedural primitives: - `_resumeIfSuspended(ctx)` — autoplay-policy resume kick. - `_buildGainEnvelope(ctx, t0, t1, attack, duration, gain)` — the linear-attack / exp-decay envelope (with the linear fallback when peak gain is 0). - `_connectToOutput(ctx, source, pan, t0)` — final hop to the master gain (or destination fallback), optional StereoPanner. `tone()` and `noise()` each shed ~25 lines of boilerplate; the bodies now read as "build the source, hand off to the shared rails". Future primitives (FM pair, Karplus-strong pluck, gated noise drone, …) get envelope + output for free instead of copy-pasting. Behavioural diff: none. Public API unchanged. 31 audio tests still green; the test cases that exercise gain/freq/filter edge cases (`gain: 0`, `freq <= 0` with slide, `filter.frequency <= 0` with sweep) still pass because the helpers preserve the exact same guarding logic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment was misleading scar tissue from an old migration: - Framed the variadic behaviour as 2.0-specific when it still works the same way in current Howler. - Referenced the 1-arg variadic ambiguity, which doesn't apply to our call (`sound.loop(loop, id)` always passes 2 args — that's the unambiguous "set this instance's loop" overload). The `typeof loop === "boolean"` guard now reads as plain TS narrowing, no spurious historical context implied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 8 comments.
Comments suppressed due to low confidence (1)
packages/melonjs/src/audio/types.ts:188
- Same as
NoiseOptions.attack:ToneOptions.attackis documented as only capped atduration / 2, but runtime clamps to a minimum of0.001seconds. Consider reflecting that minimum in the public type docs so the API docs match behavior.
/**
* Attack time in seconds — linear ramp from 0 up to `gain`.
* Capped at `duration / 2`. Defaults to `0.005`.
*/
attack?: number;
`loop: boolean = false` — the type signature already constrains this to a boolean, the default catches `undefined`, and the guard was only shielding against untyped callers passing weird values (null, numbers, etc.). Untyped callers already break the contract; the function isn't the right place to coddle them. The call now reads as the plain forwarding it always was. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n/orientation overloads
Two changes in one commit:
1. **Module split.** `audio/audio.ts` was a ~1100-line file mixing
everything from file loading to procedural synths. Split into four
files along functional seams:
- `audio/backend.ts` — shared internal state (the `state` object
replacing the scattered module-level `let`s), `soundLoadError`
helper, `stopOnAudioError` flag, and the two public WebAudio
escape hatches (`getAudioContext`, `getMasterGain`).
- `audio/playback.ts` — file-based playback: `load`, `play`, `fade`,
`seek`, `rate`, `stereo`, `position`, `orientation`, `panner`,
`stop`, `pause`, `resume`.
- `audio/procedural.ts` — `tone`, `noise`, and their shared
private envelope / output helpers.
- `audio/audio.ts` — barrel of public re-exports plus the
remaining lifecycle / track / mix / unload helpers.
Public API surface (`me.audio.*`) is byte-for-byte identical.
Cross-module mutable state lives in `state` (an object), avoiding
the "ESM `let` exports don't share writes" footgun.
2. **`position()` / `orientation()` honest overloads.** Both functions
previously claimed `: number[]` and `return … as unknown as number[]`,
but Howler's `pos(x, y, z, id)` overload returns `this` (a Howl
instance) — the cast was laundering a Howl as a number[]. Now they
mirror Howler's actual API: overloaded as either `(name)` →
`[number, number, number]` (group getter) or
`(name, x, y?, z?, id?)` → `void` (setter). No more dishonest casts.
Build clean. Audio test count up to 36 (+5 for position/orientation:
existence + throw-on-missing-clip for both getter and setter shapes;
the round-trip path requires a loaded sound which the spec doesn't
set up, same as for every other file-playback function in the spec).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
packages/melonjs/src/audio/playback.ts:297
orientation()docs claim omittedy/zdefault to the current values, but the setter path forwardsundefinedtosound.orientation(x, y, z, id). Consider reading the current orientation first (or otherwise applying defaults) so the documented behavior is guaranteed, or adjust the docs to reflect the actual behavior.
const sound = state.tracks[sound_name];
if (!sound) {
throw new Error(`audio clip ${sound_name} does not exist`);
}
if (x === undefined) {
return sound.orientation();
}
sound.orientation(x, y, z, id);
}
…rip tests
Adds 7 thin internal wrappers to `backend.ts` so the rest of the audio
module talks to the backend through named helpers instead of
`Howler.X` directly. Pure isolation — public API unchanged.
Wrappers added:
- `getGlobalVolume` / `setGlobalVolume`
- `setGlobalMuted` / `isGlobalMuted`
- `stopAllPlayback`
- `hasCodec`
- `isAudioAvailable`
After this:
- `audio.ts` Howler refs: 8 → **0**.
- `playback.ts` Howler refs (other than `new Howl(...)` + per-instance
`sound.X()` method calls): 4 → **0**.
- `procedural.ts` Howler refs: still **0**.
- `backend.ts` absorbs the wrappers — Howler is now isolated to a
single module.
The remaining Howler boundary is the `new Howl(...)` construction in
`load()` and the per-instance method calls on stored `Howl` objects
(via `state.tracks[name].X`). Killing those is item (4) — a full
`AudioBackend` interface — and is a separate, larger PR.
Test improvements:
- 7 new direct tests for the wrapped surface — `muted` / `hasFormat` /
`hasAudio` / `enable` / `disable` / `stop()` no-args / `getCurrentTrack`.
- 2 round-trip tests for `position` and `orientation` that actually
load a clip — by generating a valid silent WAV in-memory and serving
it as a data URL. The earlier attempt with a hand-typed base64 WAV
failed Howler's decode; constructing the WAV bytes programmatically
(proper RIFF/fmt/data headers, 16-bit PCM, 8 kHz mono) works.
- `hasCodec` strict-compare so `audio.hasFormat("bogus")` returns
`false` instead of `undefined`. `@types/howler` declares
`Howler.codecs(...)` as `boolean` but runtime returns `undefined`
for unknown codecs.
Suite: 36 → 45 tests, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two clean-up bits in one commit:
**1. DRY the per-clip lookup.** 11 functions across `playback.ts` and
`audio.ts:mute()` all start with the same shape:
const sound = state.tracks[sound_name];
if (sound) { /* ... */ }
else { throw new Error(`audio clip ${sound_name} does not exist`); }
Extracted into a single `getSoundOrThrow(name)` helper in
`backend.ts`. Returns `Howl` (non-nullable) so callers can chain
straight into method calls — most function bodies collapse to one
line. Uniform error message preserved verbatim. The helper is
named for behaviour, not implementation, so a future backend swap
doesn't force a rename at the 11 call sites.
**2. Lock in the throw contract via parameterised tests.** Before
this, only `position` and `orientation` had explicit throw-on-missing
coverage. A small loop now hits every per-clip function (`play`,
`fade`, `seek`, `rate`, `stereo`, `pause`, `resume`, `panner`,
`stop("name")`, `mute`) and asserts each one throws. If anyone later
swaps a `getSoundOrThrow` for an optional-chain, CI tells us which
function regressed.
**Plus** one positive-path test that loads a real silent WAV via the
existing data-URL harness (now hoisted to module-scope so it's shared
across the whole spec) and exercises every per-clip API call against
it. Doesn't verify audio actually plays — that's human-ear work — but
catches dumb crashes / argument-shape regressions on the happy path.
Suite: 45 → 56 tests, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
packages/melonjs/src/audio/playback.ts:64
load()sets the Howlerloopoption via an assignment (loop: (sound.loop = true)), which forces every loaded sound to loop and mutates the caller’sSoundAsset. This should pass the actualsound.loopboolean (defaulting tofalse) without modifying the input object.
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
Real bugs:
- **`load()` forced `loop: true` for every clip.** The Howl config line
was `loop: (sound.loop = true)` — assignment, not comparison —
which always set the local prop to `true` AND mutated the input
SoundAsset. Fix: `loop: sound.loop === true`.
- **`tone()` / `noise()` leaked WebAudio nodes.** Stopped oscillators
/ buffer sources stayed connected to the master gain via the shared
envelope (and optional panner) — over many calls the graph grew
unboundedly even though the sources were silent. Wired `osc.onended`
(with countdown for multi-partial `tone`) and `src.onended` to
disconnect env, filter, panner, and the source itself.
- **Tiny-gain envelope inversion.** `_buildGainEnvelope` ramps to
`0.0001` when peak gain > 0; for `0 < gain < 0.0001` that's a
ramp UP at the tail — opposite of decay, audible click. Switched
the threshold from `gain > 0` to `gain > 0.0001`; smaller positive
gains take the linear-to-zero path instead.
- **`soundLoadError` off-by-one.** Comment said "up to 3 times" but
`state.retryCounter++ > 3` actually allowed 4 retries. Changed to
`>= 3` so the runtime matches the doc (3 retries total).
Doc accuracy:
- `load()` JSDoc: clarify `sound.src` is a base path / prefix (full URL
built as `${src}${name}.${ext}`), not "without extension"; clarify
`settings.withCredentials` is forwarded to XHR (not fetch).
- `SoundAsset.src` JSDoc updated to match — data URLs skip the
prefix-and-extension dance.
- `ToneOptions.attack` / `NoiseOptions.attack` JSDoc now mention the
`0.001` minimum floor (was only described as capped at `duration/2`).
- `panner()` cast comment reworded — the cast narrows our type to
Howler's declared one (not "widens").
Test defensiveness:
- Guard `AudioContext` / `GainNode` global lookups in the procedural
audio tests. `webkitAudioContext`-only environments expose a working
AudioContext via the `webkitAudioContext` constructor but don't
define top-level `AudioContext` / `GainNode` globals — the old
`expect(...).toBeInstanceOf(AudioContext)` would `ReferenceError`
in those.
Suite: 56/56 still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`LoadSettings` and its `withCredentials` field still pointed at the `fetch` / `RequestInit` API even though the loader uses XHR under the hood (via Howler) and maps `withCredentials` to `xhr.withCredentials`. Updated to describe the actual mechanism — XHR-backed load with an `xhr.withCredentials` knob for cross-origin authenticated CDN access. Doc-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| const freqs = Array.isArray(freq) ? freq : [freq]; | ||
| // 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 atk = Math.max(0.001, Math.min(attack, duration / 2)); | ||
| 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); | ||
| } |
Summary
Lands the procedural-audio surface for melonJS 19.5, plus a clean-up pass on the audio module while we're in there.
New public API on
me.audio:getAudioContext(): AudioContext | null— exposes the shared WebAudio context used by file-based playback so user code can build custom WebAudio graphs without spawning a second context.tone(opts)— fire-and-forget envelope-shaped oscillator with multi-partialfreq(array → chord/bell), gain envelope,StereoPannerpan, percussivepitchSlide. Covers UI clicks, hit confirms, simple chimes, placeholder SFX.noise(opts)— single-shot noise burst with"white" | "pink" | "brown"spectral colour, optionalBiquadFilterNodeband-shape with an exponentialfilterSweep. Covers explosions, hi-hats, swooshes, wind, footsteps.Both run on the shared context, route through
Howler.masterGain, soaudio.muteAll()/audio.setVolume()apply uniformly; both are silent no-ops when WebAudio is unavailable.Types factored out into
audio/types.ts:ToneOptions,NoiseOptions,NoiseFilter,PannerAttributes(now exported alongside the function),SoundAsset,LoadSettings. Each field documented with defaults in prose.Plinko-planck demo refactored to two thin wrappers around
audio.tone— drops ~70 lines of bespoke envelope code fromplinko-planck/audio.ts.audio.tscleanup pass:console.log→console.warnfor the audio-disabled failure path.(Howler as any)._muted→ narrow(Howler as unknown as { _muted: boolean })._muted.audioTracks: Record<string, Howl>→Record<string, Howl | undefined>(matches runtime), redundanttypeof sound !== "undefined"guard halves dropped across 11 functions, missing optional-chaining added to 5 sites.panner()bug fixed: was casting aHowlinstance toPannerAttributesviaas unknown as; now does a proper get-after-set.panner()example fixed:distanceModel: "exponential"isn't in Howler's union — switched to"inverse".JSDoc normalization sweep over every function in
audio.ts. Pre-existing module had mixed style (lowercase-no-period summaries vs sentence-case-with-period, stale<br>tags from a pre-TypeDoc renderer,@returnswording drift, optional-bracket inconsistency). Now all in one voice. Functional behaviour unchanged.Test plan
pnpm test— 25 tests intests/audio.spec.js(was 11). New coverage:getAudioContextexports + context sharing,tone(every option combination, pan clamping, zero/negative duration, multi-partial freq, shared-context scheduling),noise(every option combination, optional filter + sweep, pan clamping, zero/negative duration, shared-context scheduling,muteAll/unmuteAllrespect).pnpm build— melonjs lint + types + bundle all clean.pnpm doc— TypeDoc emits 0 audio-specific warnings.audio.muteAll()from the console silences everything.API surface checklist
me.audio.getAudioContext()me.audio.tone({ freq, duration, wave?, gain?, attack?, pan?, pitchSlide? })me.audio.noise({ duration, type?, gain?, attack?, pan?, filter?, filterSweep? })me.audio.ToneOptions/NoiseOptions/NoiseFilter/PannerAttributes/SoundAsset/LoadSettings— types resolvable via the audio namespaceCommits in this PR
7 in scope order:
b8031b66bfeat(audio): expose getAudioContext() + add tone() procedural primitiveb51371ae7fix(audio): nudge Howler.ctx creation in getAudioContext()9c9d6c9a6refactor(audio): tidy tone() module layouta0427d826fix(audio): tone() respects master mute / volume + tighten PannerAttributes16ffd94fcfeat(audio): add noise() — procedural noise burst with optional bandshape2ef9356c5chore(audio): cleanup pass on audio.tsa49f25494docs(audio): normalize JSDoc style across the audio module🤖 Generated with Claude Code