Skip to content

Spatial audio: a movable listener, sounds placed in world pixels - #1724

Merged
obiot merged 2 commits into
masterfrom
feat/spatial-audio-world-space
Oct 7, 2026
Merged

obiot merged 2 commits into
masterfrom
feat/spatial-audio-world-space

Conversation

@obiot

@obiot obiot commented Oct 7, 2026

Copy link
Copy Markdown
Member

Description

Positional audio could only be reached by doing the work the engine should be doing.

The listener was welded to the origin with no public call to move it, so a game had to subtract the player's position from every source, every frame, and re-express it in the listener's axes by hand. The panner defaults were WebAudio's, which are metres, so a sound given pixel coordinates went near silent a few tiles out. And the two ways to place a sound quietly excluded each other.

Added

  • follow, at and stopWithTarget on audio.play(). A sound tracks a renderable's absolute position after every world update, or pins to a fixed world point, and can end with the entity that owns it. audio.unfollow(id) detaches one and leaves it playing where it is.
  • A movable listener: audio.setListener(target), audio.listener(x, y, z) and audio.listenerOrientation(fx, fy, fz, ux, uy, uz), all of which read back when called with no arguments. A Camera3d target contributes its basis as well as its position. Source positions become absolute world coordinates rather than offsets from the player.
  • Panner defaults shaped for pixels: refDistance: 240, maxDistance: 10000, inverse, equalpower. audio.setSpatialDefaults() / getSpatialDefaults() change what new voices inherit.

Everything takes melonJS world coordinates with y measured down, the same numbers already in pos. The flip into Web Audio's Y-up space happens in exactly one place.

Fixed

All three were found while building the above.

  • stereo() and position() drive one panner node per voice, and the first of the two to be called decided what kind of node it was, which made the other a silent no-op for the life of that voice. The node follows whichever was called last now, so the order no longer matters.
  • audio.panner(name, attributes) with no playback id wrote only the voices already playing and never the clip's own defaults, so a call before the first play() changed nothing at all, the getter read back construction-time values forever, and every later voice inherited those. It writes the group now, and merges rather than replaces.
  • Sound effects kept playing while the window did not have focus, because pausing the game only ever reached the current music track. A looping effect such as an engine hum played on over whatever the player had switched to. The mix is muted on blur and restored on focus, which covers tone() / noise() and anything hung off getMasterGain() as well as ordinary clips. Gated by the existing pauseOnBlur and stopOnBlur settings rather than a fourth one, so a game that deliberately keeps running in the background keeps its audio too.

Backward compatibility

Opt-in end to end. Until a game calls setListener / listener or passes follow / at, no frame handler is installed, the defaults are untouched, and every existing position / stereo / panner call behaves exactly as it did. The three fixes only change behaviour that was broken: a call that previously did nothing now does what it says.

Type of change

  • Bug fix
  • New feature
  • Documentation update
  • Performance improvement
  • Refactoring (no functional changes)

Checklist

  • I have read the Contributing Guide
  • My code follows the existing code style (pnpm lint passes)
  • I have tested my changes locally (pnpm test passes)
  • I have added tests that cover my changes (if applicable)
  • The build succeeds (pnpm build)

Verification

  • tests/audio-spatial.spec.js, 26 new tests covering the listener, placement, the pixel defaults, both call orders, the group write and the blur mute. Each one was mutation tested: the Y flip, the panner-type swap, the group write, the merge, the blur mute, the per-frame follow, the defaults and stopWithTarget in both directions were each reintroduced as a defect and every one made a test fail.
  • One of those is a regression test for a bug this PR's own first draft had: a listener read in world space compared against a source read in audio space passes even with no conversion at all, so it compares the two as the panner sees them instead.
  • Full suite: 314 files, 7674 passed, 10 skipped.
  • typedoc warnings 166 to 163; PlayOptions, PannerAttributes and SoundEvents are exported as public types, which is what documents the new play options.
  • Driven in a real game in a browser: the listener tracks the camera exactly frame to frame, a panner() call made before the first play() now reaches the group, blur mutes and focus restores, no page errors.

The melonjs-audio skill is rewritten to match, and the CHANGELOG entry that described the old behaviour as a documented limitation is corrected rather than left to contradict the code.

Related issues

🤖 Generated with Claude Code

https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

Positional audio could only be reached by doing the work the engine
should be doing. The listener was welded to the origin with no public
call to move it, so a game had to subtract the player's position from
every source, every frame, and re-express it in the listener's axes by
hand. The panner defaults were WebAudio's, which are metres, so a sound
given pixel coordinates went near silent a few tiles out. And the two
ways to place a sound quietly excluded each other.

Added:

- `follow`, `at` and `stopWithTarget` on `audio.play()`. A sound tracks a
  renderable's absolute position after every world update, or pins to a
  fixed point, and can end with the entity that owns it. `audio.unfollow`
  detaches one and leaves it playing.
- `audio.setListener(target)`, `audio.listener()` and
  `audio.listenerOrientation()`. A `Camera3d` target contributes its
  basis as well as its position. Source positions become absolute world
  coordinates rather than offsets from the player.
- `audio.setSpatialDefaults()` / `getSpatialDefaults()`, starting from
  `refDistance: 240`, `maxDistance: 10000` and `equalpower`.

Everything takes melonJS world coordinates, y measured down; the Y flip
into Web Audio's Y-up space happens in exactly one place.

Fixed, all found while building the above:

- `stereo()` and `position()` drive one panner node per voice, and the
  first call fixed which kind it was, making the other a silent no-op for
  that voice's life. The node follows the last writer now.
- `audio.panner(name, attrs)` with no id never wrote the clip's own
  defaults, so a call before the first `play()` was lost entirely and
  every later voice inherited construction-time values. It writes the
  group, and merges rather than replaces.
- Sound effects kept playing while the window was away, because pausing
  the game only ever reached the music track. The mix is muted on blur
  and restored on focus, gated by the existing `pauseOnBlur` and
  `stopOnBlur` settings rather than a new one.

Opt-in end to end: until a game asks for a listener or a placement, no
frame handler is installed and every existing call behaves as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Copilot AI balanced review requested due to automatic review settings October 7, 2026 11:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

A read-back of the prose against the code, which found four claims that
were wrong and one stray import.

- The spatial defaults apply to sounds placed through `follow` / `at`,
  NOT to every new voice. The skill said every voice, which would have
  sent a reader chasing a `refDistance` that never applied to their
  `audio.position()` call. The symptom table had lost the same cause.
- The falloff was described as "inaudible past ~2000 px", a number
  nothing measures. Replaced with what the inverse model actually does
  from `refDistance: 240`: full volume to 240, then halving per doubling,
  so 0.5 at 480, 0.25 at 960, 0.125 at 2000.
- `setListener` on a `Camera3d` contributes ORIENTATION as well as
  position, which is the whole of what a posed 3D scene needs and the
  skill did not mention at all.
- `follow` and `at` together throws. Undocumented in both the skill and
  `PlayOptions`.

Three symptom rows described bugs this line fixes rather than symptoms a
reader can still hit, so they are recast or dropped; a skill documents
the engine in front of the reader, not its history.

Also hoists a `Vector3d` import that had been left in the middle of
`spatial.ts`, which no linter flagged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Copilot AI balanced review requested due to automatic review settings October 7, 2026 11:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@obiot
obiot merged commit 7aa69de into master Oct 7, 2026
6 checks passed
@obiot
obiot deleted the feat/spatial-audio-world-space branch October 7, 2026 23:15
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