Repository navigation
Spatial audio: a movable listener, sounds placed in world pixels - #1724
Merged
Merged
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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,atandstopWithTargetonaudio.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.audio.setListener(target),audio.listener(x, y, z)andaudio.listenerOrientation(fx, fy, fz, ux, uy, uz), all of which read back when called with no arguments. ACamera3dtarget contributes its basis as well as its position. Source positions become absolute world coordinates rather than offsets from the player.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()andposition()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 firstplay()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.tone()/noise()and anything hung offgetMasterGain()as well as ordinary clips. Gated by the existingpauseOnBlurandstopOnBlursettings 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/listeneror passesfollow/at, no frame handler is installed, the defaults are untouched, and every existingposition/stereo/pannercall 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
Checklist
pnpm lintpasses)pnpm testpasses)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 andstopWithTargetin both directions were each reintroduced as a defect and every one made a test fail.PlayOptions,PannerAttributesandSoundEventsare exported as public types, which is what documents the new play options.panner()call made before the firstplay()now reaches the group, blur mutes and focus restores, no page errors.The
melonjs-audioskill 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