Skip to content

feat(audio): procedural audio primitives (tone + noise) + audio.ts cleanup#1456

Open
obiot wants to merge 19 commits into
masterfrom
feat/audio-tone-helper
Open

feat(audio): procedural audio primitives (tone + noise) + audio.ts cleanup#1456
obiot wants to merge 19 commits into
masterfrom
feat/audio-tone-helper

Conversation

@obiot
Copy link
Copy Markdown
Member

@obiot obiot commented May 19, 2026

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-partial freq (array → chord/bell), gain envelope, StereoPanner pan, percussive pitchSlide. Covers UI clicks, hit confirms, simple chimes, placeholder SFX.
  • noise(opts) — single-shot noise burst with "white" | "pink" | "brown" spectral colour, optional BiquadFilterNode band-shape with an exponential filterSweep. Covers explosions, hi-hats, swooshes, wind, footsteps.

Both run on the shared context, route through Howler.masterGain, so audio.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 from plinko-planck/audio.ts.

audio.ts cleanup pass:

  • console.logconsole.warn for 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), redundant typeof sound !== "undefined" guard halves dropped across 11 functions, missing optional-chaining added to 5 sites.
  • Pre-existing panner() bug fixed: was casting a Howl instance to PannerAttributes via as 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, @returns wording drift, optional-bracket inconsistency). Now all in one voice. Functional behaviour unchanged.

Test plan

  • pnpm test — 25 tests in tests/audio.spec.js (was 11). New coverage: getAudioContext exports + 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/unmuteAll respect).
  • pnpm build — melonjs lint + types + bundle all clean.
  • pnpm doc — TypeDoc emits 0 audio-specific warnings.
  • Plinko-planck verified manually in the dev server — peg clacks still arpeggiate + pan, slot chimes still ring pentatonic per tier, 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 namespace

Commits in this PR

7 in scope order:

  • b8031b66b feat(audio): expose getAudioContext() + add tone() procedural primitive
  • b51371ae7 fix(audio): nudge Howler.ctx creation in getAudioContext()
  • 9c9d6c9a6 refactor(audio): tidy tone() module layout
  • a0427d826 fix(audio): tone() respects master mute / volume + tighten PannerAttributes
  • 16ffd94fc feat(audio): add noise() — procedural noise burst with optional bandshape
  • 2ef9356c5 chore(audio): cleanup pass on audio.ts
  • a49f25494 docs(audio): normalize JSDoc style across the audio module

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings May 19, 2026 23:15
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) and audio.getAudioContext() public APIs in packages/melonjs/src/audio/audio.ts, with full JSDoc and a ToneOptions interface.
  • Test suite extended in tests/audio.spec.js covering exports, shared context identity, option permutations, pan clamping, zero/negative duration tolerance, and scheduling on the shared context.
  • plinko-planck/audio.ts rewritten to consume the new primitive; throttle switched from ctx.currentTime to performance.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.

obiot and others added 2 commits May 20, 2026 07:29
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>
Copilot AI review requested due to automatic review settings May 19, 2026 23:34
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

obiot and others added 2 commits May 20, 2026 07:45
…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>
Copilot AI review requested due to automatic review settings May 19, 2026 23:51
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comment thread packages/melonjs/CHANGELOG.md
Comment thread packages/melonjs/src/audio/audio.ts Outdated
obiot and others added 2 commits May 20, 2026 08:02
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>
Copilot AI review requested due to automatic review settings May 20, 2026 00:25
@obiot obiot changed the title feat(audio): expose getAudioContext() + add tone() procedural primitive feat(audio): procedural audio primitives (tone + noise) + audio.ts cleanup May 20, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 uses env.gain.exponentialRampToValueAtTime(...), which requires the gain value at the start of the exponential ramp to be > 0. If callers pass gain: 0/negative, this can throw InvalidStateError. Clamp gain to a small positive floor (or avoid exponential ramps when gain <= 0) to keep noise() truly no-throw.
	const env = ctx.createGain();
	env.gain.setValueAtTime(0, t0);
	env.gain.linearRampToValueAtTime(gain, t0 + atk);
	env.gain.exponentialRampToValueAtTime(0.0001, t1);

Comment thread packages/melonjs/src/audio/types.ts Outdated
Comment thread packages/melonjs/src/audio/audio.ts Outdated
Comment thread packages/melonjs/src/audio/audio.ts Outdated
Comment thread packages/melonjs/src/audio/audio.ts Outdated
Comment thread packages/melonjs/src/audio/audio.ts Outdated
Comment thread packages/melonjs/tests/audio.spec.js
obiot and others added 2 commits May 20, 2026 09:17
…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>
Copilot AI review requested due to automatic review settings May 20, 2026 01:57
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to Howler.masterGain without any teardown. Even though BufferSourceNode stops, the connected Gain/Biquad/StereoPanner nodes can remain in the graph indefinitely, causing graph growth over time. Add an onended handler (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

  • LoadSettings is described as being forwarded to a fetch request / RequestInit, but audio loading is done via Howler (Howl) which uses XHR internally (xhrWithCredentials). The docs should avoid referencing fetch.credentials and 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.attack is documented as capped at duration / 2, but tone() 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;

Comment thread packages/melonjs/src/audio/audio.ts Outdated
Comment thread packages/melonjs/src/audio/audio.ts Outdated
Comment thread packages/melonjs/src/audio/types.ts Outdated
Comment thread packages/melonjs/src/audio/types.ts
obiot and others added 2 commits May 20, 2026 10:17
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>
Copilot AI review requested due to automatic review settings May 20, 2026 02:28
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() schedules src.stop(...), but the created nodes (env, optional biquad, optional stereo panner) are never disconnected. Because these nodes are connected to out, repeated calls can grow the audio graph unnecessarily. Consider using src.onended to 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() assumes ctx.createStereoPanner() exists whenever pan !== 0. To keep this API robust across WebAudio implementations, add a feature check and either skip panning or fall back to a PannerNode-based approach when createStereoPanner is unavailable.
	} else {
		const panner = ctx.createStereoPanner();
		panner.pan.setValueAtTime(Math.max(-1, Math.min(1, pan)), t0);
		tail.connect(panner).connect(out);
	}

Comment thread packages/melonjs/src/audio/audio.ts Outdated
Comment thread packages/melonjs/src/audio/audio.ts Outdated
obiot and others added 2 commits May 20, 2026 10:41
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>
Copilot AI review requested due to automatic review settings May 20, 2026 02:54
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.attack is documented as only capped at duration / 2, but runtime clamps to a minimum of 0.001 seconds. 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;

Comment thread packages/melonjs/src/audio/audio.ts Outdated
Comment thread packages/melonjs/src/audio/audio.ts Outdated
Comment thread packages/melonjs/src/audio/audio.ts Outdated
Comment thread packages/melonjs/src/audio/audio.ts Outdated
Comment thread packages/melonjs/src/audio/audio.ts Outdated
Comment thread packages/melonjs/tests/audio.spec.js Outdated
Comment thread packages/melonjs/tests/audio.spec.js Outdated
Comment thread packages/melonjs/src/audio/types.ts
obiot and others added 2 commits May 20, 2026 11:00
`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>
Copilot AI review requested due to automatic review settings May 20, 2026 04:00
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 omitted y/z default to the current values, but the setter path forwards undefined to sound.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);
}

Comment thread packages/melonjs/src/audio/playback.ts Outdated
Comment thread packages/melonjs/src/audio/playback.ts
Comment thread packages/melonjs/src/audio/playback.ts
Comment thread packages/melonjs/src/audio/backend.ts Outdated
obiot and others added 2 commits May 20, 2026 12:50
…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>
Copilot AI review requested due to automatic review settings May 20, 2026 05:21
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Howler loop option via an assignment (loop: (sound.loop = true)), which forces every loaded sound to loop and mutates the caller’s SoundAsset. This should pass the actual sound.loop boolean (defaulting to false) 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

Comment thread packages/melonjs/src/audio/playback.ts Outdated
Comment thread packages/melonjs/src/audio/types.ts Outdated
obiot and others added 2 commits May 20, 2026 13:32
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>
Copilot AI review requested due to automatic review settings May 20, 2026 06:20
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comment on lines +149 to +154
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) {
Comment on lines +58 to +66
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);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants