Skip to content

feat: EN-062 - spatial audio v2: live voices, real distance/pan model, doppler#116

Merged
proggeramlug merged 1 commit into
mainfrom
feat/en-062-spatial-audio-v2
Jul 16, 2026
Merged

feat: EN-062 - spatial audio v2: live voices, real distance/pan model, doppler#116
proggeramlug merged 1 commit into
mainfrom
feat/en-062-spatial-audio-v2

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

What

The mixer could only fire-and-forget. This adds live spatial voices — looping, moveable emitters with per-voice volume/pitch/low-pass and a click-free stop — plus a real spatialization model, all engine-side:

  • Voice ids on every play; play_sound_3d_ex + voice_set_position/volume/pitch/lowpass/stop
  • Inverse-clamped distance model (per-voice ref/rolloff/max; ref=1, rolloff=1 is byte-for-byte the legacy 1/d curve)
  • Equal-power panning at 0.85 width (was linear: centered sources sat 6 dB down)
  • Air absorption (distance-driven low-pass) + rear head-shadow cue (low-pass + dip behind the listener)
  • Doppler from per-block distance deltas, clamped/smoothed, with a teleport guard for re-targeted pool voices
  • Per-block gain ramps — per-frame position/volume rides cannot zipper (gain changes land one block late by design; the duck test measures the settled block now)
  • Command ring 256 → 1024

Bugs fixed on the way through

  • Stereo was mirrored since the feature shipped: the listener "right" was cross(up, fwd) — screen LEFT per mat4_look_at's own s vector. Every spatial sound panned to the wrong side. Now pinned by a test.
  • WASAPI never reported the device rate to the renderer, and voices ignored the asset rate: 44.1 kHz files on the typical 48 kHz endpoint played ~9% fast/sharp, forever. The new fractional resampler plays assets at their authored rate (music streams untouched — separate concern).

Testing

23/23 audio tests (10 new: loop lifecycle, stereo crossing, legacy-curve equivalence, equal-power, air absorption, rear cue, pitch rate, doppler zero-crossing count, max-dist cull/return, per-voice occlusion). watchOS stubs regenerated; web target exports the same six calls. First consumer: shooter SH-052 (verified live in-game there).

https://claude.ai/code/session_015wJLdkwm71jQCAqypaFeNE

Summary by CodeRabbit

  • New Features
    • Added advanced 3D spatial audio with looping and individually controllable voices.
    • Voices can now be repositioned, stopped with fades, and adjusted for volume, pitch, and low-pass filtering.
    • Improved spatial effects include accurate panning, distance attenuation, air absorption, rear occlusion, and Doppler shifting.
    • Added fractional resampling so pitch changes preserve authored playback speed.
  • Bug Fixes
    • Corrected left/right stereo panning mirroring.
  • Documentation
    • Documented the Spatial Audio v2 release and supported controls.

Voice ids on every play; play_sound_3d_ex + voice_set_position/volume/
pitch/lowpass/stop make looping, moveable emitters possible (river, wind,
creatures). Equal-power pan (0.85 width), inverse-clamped distance model
(legacy 1/d preserved at ref=1/rolloff=1), distance air absorption, rear
head-shadow cue, doppler with teleport guard, per-block gain ramps.

Fixes on the way through:
- Stereo was MIRRORED: listener right was cross(up,fwd) = screen left.
- Windows/WASAPI never told the renderer the device rate; with the new
  fractional resampler assets also now play at their authored pitch
  (44.1k files on 48k endpoints ran ~9% sharp since forever).

10 new tests; watchOS stubs regenerated; web target exports the same API.

Claude-Session: https://claude.ai/code/session_015wJLdkwm71jQCAqypaFeNE
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds EN-062 spatial audio v2 with stable controllable voices, looping and fades, distance attenuation, panning, occlusion, doppler, fractional resampling, gain ramping, expanded bindings, platform support, and tests.

Changes

Spatial audio v2

Layer / File(s) Summary
Spatial voice rendering
native/shared/src/audio/render.rs
Adds stable voice commands and state, fractional interpolation, distance and panning calculations, air absorption, rear attenuation, doppler, low-pass filtering, gain ramps, culling, and fade-out stopping.
Mixer voice API and validation
native/shared/src/audio/mod.rs
Allocates voice IDs, emits playback and control commands, expands command capacity, and tests looping, spatialization, pitch, doppler, culling, filtering, and ducking behavior.
Public and platform bindings
src/audio/index.ts, native/shared/src/ffi_core/audio_ffi.rs, native/web/src/lib.rs, native/watchos/src/ffi_stubs.rs, native/windows/src/lib.rs, package.json
Exposes extended playback and per-voice controls across TypeScript, native FFI, WebAssembly, watchOS, and native-library declarations; Windows now supplies the device sample rate to the renderer.
EN-062 feature record
docs/tickets.md
Documents the spatial audio v2 behavior, implementation details, tests, and first consumer.
Estimated code review effort: 4 (Complex) ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GameAudio
  participant AudioAPI
  participant NativeFFI
  participant AudioMixer
  participant AudioRenderer
  GameAudio->>AudioAPI: Play or control spatial voice
  AudioAPI->>NativeFFI: Forward voice parameters
  NativeFFI->>AudioMixer: Emit voice command
  AudioMixer->>AudioRenderer: Send command through audio ring
  AudioRenderer->>AudioRenderer: Spatialize, resample, filter, and mix voice
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: spatial audio v2 with live voices and Doppler.
Description check ✅ Passed The description covers the change, testing, and reviewer notes, so it includes the required content.
Docstring Coverage ✅ Passed Docstring coverage is 93.75% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/en-062-spatial-audio-v2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
native/shared/src/audio/render.rs (1)

668-683: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Downmix spatial stereo sources before applying point-source panning.

Left input currently receives only g_l and right input only g_r. A stereo asset with content in one channel therefore cannot move across the stereo field; it merely changes level. Downmix spatial voices to mono before panning, or apply a proper stereo panning matrix.

Proposed fix
                 let mut sl = s0l + (s1l - s0l) * frac;
                 let mut sr = s0r + (s1r - s0r) * frac;
+
+                if v.spatial.is_some() {
+                    let mono = (sl + sr) * 0.5;
+                    sl = mono;
+                    sr = mono;
+                }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@native/shared/src/audio/render.rs` around lines 668 - 683, Update the spatial
rendering path around the `sl`/`sr` gain calculations so stereo source samples
are downmixed to a mono signal before applying point-source panning. Use that
mono value as the input to the existing `v.g_l` and `v.g_r` gains, ensuring
either source channel can be positioned across the stereo field rather than
remaining isolated to its original output channel.
🧹 Nitpick comments (1)
native/shared/src/audio/mod.rs (1)

845-862: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test per-voice isolation with two voices sharing one sound.

The current separate-mixer comparison only proves filtering works; a broken per-sound implementation would still pass. Play a muffled and unfiltered voice from the same handle and assert the latter remains open.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@native/shared/src/audio/mod.rs` around lines 845 - 862, Update
per_voice_lowpass_muffles_one_emitter_only to use a single AudioMixer and shared
sound handle for both muffled and unfiltered voices. Apply set_voice_lowpass
only to the first voice, mix their output separately or otherwise isolate each
voice’s result, and assert the unfiltered voice retains the open response so
per-voice isolation is verified.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/tickets.md`:
- Line 2047: Update the first-consumer ticket reference in the “web target
exports the same six calls” record from SH-050 to SH-052, leaving the
surrounding text unchanged.

In `@native/shared/src/audio/mod.rs`:
- Around line 159-174: Update the voice lifecycle flow around send_play and the
Cmd::PlaySound/StopVoice paths so play returns failure when the command cannot
be enqueued, and stopping a voice cannot be permanently lost under ring
pressure. Guarantee delivery or coalesce lifecycle commands separately from
lossy updates, preserving the existing voice ID behavior only for successfully
enqueued plays.

In `@native/shared/src/audio/render.rs`:
- Around line 694-700: Update the looping branch in the frame-position
advancement logic around v.frame_pos so wrapping handles steps equal to or
larger than the full frames count, keeping looping positions within bounds
instead of subtracting frames only once. Preserve the existing ended-and-break
behavior for non-looping voices.

---

Outside diff comments:
In `@native/shared/src/audio/render.rs`:
- Around line 668-683: Update the spatial rendering path around the `sl`/`sr`
gain calculations so stereo source samples are downmixed to a mono signal before
applying point-source panning. Use that mono value as the input to the existing
`v.g_l` and `v.g_r` gains, ensuring either source channel can be positioned
across the stereo field rather than remaining isolated to its original output
channel.

---

Nitpick comments:
In `@native/shared/src/audio/mod.rs`:
- Around line 845-862: Update per_voice_lowpass_muffles_one_emitter_only to use
a single AudioMixer and shared sound handle for both muffled and unfiltered
voices. Apply set_voice_lowpass only to the first voice, mix their output
separately or otherwise isolate each voice’s result, and assert the unfiltered
voice retains the open response so per-voice isolation is verified.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d51ed9ea-5e81-41dc-84c1-bc2aac2c7dae

📥 Commits

Reviewing files that changed from the base of the PR and between 61f1746 and 07c2c29.

📒 Files selected for processing (9)
  • docs/tickets.md
  • native/shared/src/audio/mod.rs
  • native/shared/src/audio/render.rs
  • native/shared/src/ffi_core/audio_ffi.rs
  • native/watchos/src/ffi_stubs.rs
  • native/web/src/lib.rs
  • native/windows/src/lib.rs
  • package.json
  • src/audio/index.ts

Comment thread docs/tickets.md
Tests: 10 new (loop lifecycle, stereo crossing, legacy-curve equivalence,
equal-power, air absorption, rear cue, pitch rate, doppler zero-crossing
count, max-dist cull/return, per-voice occlusion). watchOS stubs regenerated;
web target exports the same six calls. First consumer: shooter SH-050.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the first-consumer ticket reference.

The PR objective identifies the shooter integration as SH-052, but this record says SH-050.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/tickets.md` at line 2047, Update the first-consumer ticket reference in
the “web target exports the same six calls” record from SH-050 to SH-052,
leaving the surrounding text unchanged.

Comment on lines +159 to +174
self.next_voice += 1;
let voice_id = self.next_voice;
self.send(Cmd::PlaySound {
sound_id: handle.to_bits(),
voice_id,
data,
volume,
spatial: Some([x, y, z]),
spatial,
looping,
ref_dist,
max_dist,
rolloff,
pitch: 1.0,
bus, send, lowpass,
});
voice_id as f64

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Make voice creation and stopping reliable under ring pressure.

send_play returns a nonzero ID even when PlaySound is dropped, while a dropped StopVoice can leave a looping emitter audible indefinitely. Return failure when play cannot enqueue and guarantee or coalesce lifecycle commands rather than using the lossy update path.

Also applies to: 212-216

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@native/shared/src/audio/mod.rs` around lines 159 - 174, Update the voice
lifecycle flow around send_play and the Cmd::PlaySound/StopVoice paths so play
returns failure when the command cannot be enqueued, and stopping a voice cannot
be permanently lost under ring pressure. Guarantee delivery or coalesce
lifecycle commands separately from lossy updates, preserving the existing voice
ID behavior only for successfully enqueued plays.

Comment on lines +694 to +700
v.frame_pos += step;
if v.frame_pos >= frames as f64 {
if v.looping {
v.frame_pos -= frames as f64;
} else {
ended = true;
break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Wrap looping playback by the full frame count.

When step >= frames, subtracting frames once can leave frame_pos out of bounds. The next iteration exits without advancing, leaving the looping voice retained but permanently silent.

Proposed fix
                 if v.frame_pos >= frames as f64 {
                     if v.looping {
-                        v.frame_pos -= frames as f64;
+                        v.frame_pos %= frames as f64;
                     } else {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
v.frame_pos += step;
if v.frame_pos >= frames as f64 {
if v.looping {
v.frame_pos -= frames as f64;
} else {
ended = true;
break;
v.frame_pos += step;
if v.frame_pos >= frames as f64 {
if v.looping {
v.frame_pos %= frames as f64;
} else {
ended = true;
break;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@native/shared/src/audio/render.rs` around lines 694 - 700, Update the looping
branch in the frame-position advancement logic around v.frame_pos so wrapping
handles steps equal to or larger than the full frames count, keeping looping
positions within bounds instead of subtracting frames only once. Preserve the
existing ended-and-break behavior for non-looping voices.

@proggeramlug
proggeramlug merged commit 39f4eaa into main Jul 16, 2026
9 of 10 checks passed
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.

1 participant