Skip to content

Distance cues: level reverb sends and facing voices - #166

Merged
ctoth merged 2 commits into
masterfrom
feat/distance-cues
Oct 5, 2026
Merged

ctoth merged 2 commits into
masterfrom
feat/distance-cues

Conversation

@ctoth

@ctoth ctoth commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Two distance cues for positioned audio. One commit each, tests with each.

1. The reverb send no longer falls with distance

Cacophony takes a send from a voice's output, after its gain (routableSource.ts: outputNode.connect(sendGain), and outputNode is the gain node that follows the panner). The client applies a positioned sound's distance attenuation at that same gain, so the level reaching the room's reverb fell exactly as fast as the direct level: a far sound was quieter but no wetter than a near one.

The send is now given send × min(1 / distanceGain, 32), so the chain receives volume × send wherever the source is, and only the direct level falls with distance.

  • Re-gained in place whenever the distance gain changes (listener moves, source moves, spatial profile changes) and whenever send or the chain changes. All of those already pass through applyLevels.
  • The feed from a sound's inline effect chain into its named chain is downstream of the same gain and is compensated the same way.
  • A change of send on a live aux send now re-gains it in the engine. Before, the send was removed and added again.
  • Unchanged: the direct level, the distance model, occlusion (it sits before the tap and still dims direct and reverb alike), non-positional sounds (no distance gain, wire send), ambisonic sounds (no named chains).

The cap. MAX_SEND_DISTANCE_BOOST = 32 (+30 dB), in distanceModel.ts. The engine accepts any finite send gain and does not clamp it, and the distance curves give no usable limit: the default one floors at 1 / 10 000 and a linear one reaches 0, where the tap is silent and nothing can be recovered. So the cap is a choice: under the default curve the reverberant level is even out to 32 m, and beyond that it falls with the direct sound. It caps the boost, not the send, so that distance is the same whatever the send.

2. Voices have a facing, and their reverb send is level with distance too

  • A positional voice takes its orientation from the speaking entity's forward, which Client.Spatial.EntityMove already delivers and tweens into the spatial store (in Web Audio axes), with a gentle cone: full level within 60° either side of the facing, falling evenly to −6 dB directly behind (VOICE_CONE_INNER_ANGLE 120, VOICE_CONE_OUTER_ANGLE 360, VOICE_CONE_OUTER_GAIN 10^(−6/20)).
  • The facing follows the entity through the store subscription that already moves the voice. A turn ramps like a move.
  • An entity with no forward is omnidirectional. A non-positional voice has no panner, so no cone and no distance, as before.
  • The distance curve was already the media default: the voice panner took the same three constants DEFAULT_SPATIAL_PROFILE is built from. It is now derived from that profile by name (profilePannerDistance). Web Audio's inverse model is the profile's formula exactly, so the rolloff stays in the panner; there is no second gain stage.
  • A voice's rolloff is inside its panner, ahead of the gain node the send is tapped from, so its send fell with distance as a sound's did. It now gets send / distanceGain with the same cap. A panner's gain cannot be read back, so the distance gain is computed from the profile, the voice's position and the engine's listener position.

Not done

  • The cone (for voices and for media sounds) is also ahead of the tap, so a source facing away sends up to its cone attenuation less to the room. Not compensated.
  • A voice's panner position is smoothed by the engine (30 ms time constant) and its send is re-gained at once, so the two disagree briefly while a voice or the listener is moving.
  • No new GMCP message or field, no change to Cacophony, the ambisonic paths or the UI.

Existing tests changed

  • Media.test.ts, "rebuilds on an Update to 3D, carrying the state the voice has now": the rebuilt 3D voice is 2 m away, so its send is now 0.6, not the wire 0.3. The non-positional case still asserts 0.3, and both assert the carried wire send.
  • LiveKitSpatialAudioBridge.test.ts: the two attachVoice argument assertions gain the third argument (null, no facing), and the mock voice gains setFacing.

Verification

npm run typecheck, npm test (128 files, 1564 tests) and npm run test:audio-cache pass locally. Nothing was listened to: this is verified by unit tests against mocked engine objects and by reading the engine source, not by ear and not in a browser.

ctoth added 2 commits October 4, 2026 22:47
Cacophony takes a send from a voice's output, after its gain
(routableSource.ts: outputNode.connect(sendGain), where outputNode is
the gain node that follows the panner). The client applies a positioned
sound's distance attenuation at that same gain, so the level reaching
the room's reverb fell exactly as fast as the direct level. A far sound
was quieter but no wetter than a near one, and the direct-to-reverb
ratio, the main cue for distance other than loudness, never changed.

The send is now given the gain send / distanceGain, so the chain
receives volume x send wherever the source is and the direct level
alone falls with distance. It is re-gained in place whenever the
distance gain changes (the listener or the source moves, the spatial
profile changes) and whenever the send or the chain changes. The feed
from a sound's inline effect chain into its named chain is downstream
of the same gain and is compensated the same way.

The boost is capped at 32 x (+30 dB). The engine accepts any finite
send gain and does not clamp it, and the distance curves give no usable
limit: the default one floors at 1 / 10 000 and a linear one reaches 0,
where the tap is silent. Under the default curve the reverberant level
is even out to 32 m and falls with the direct sound beyond.

A change of send on a live aux send now re-gains it in the engine
instead of removing the send and adding it again.

The direct level, the distance model and occlusion are unchanged:
occlusion sits before the tap and still dims direct and reverb alike.
Non-positional sounds have no distance gain and keep their wire send.
Ambisonic sounds do not use named chains.
A voice in the scene was an omnidirectional point source: the speaking
entity's forward vector arrives with Client.Spatial.EntityMove and is
tweened into the spatial store, and nothing read it. A positional voice
now takes its orientation from that vector, already in Web Audio axes,
with a gentle cone: full level within 60 degrees either side of the
speaker's facing, falling evenly to -6 dB directly behind (panner cone
120 / 360 / 0.501). The facing follows the entity through the store
subscription that already moves the voice; a turn ramps like a move. An
entity with no forward has no cone, and a non-positional voice has no
panner at all, as before.

The distance curve was already the one media sounds use by default:
the voice panner took the same three constants DEFAULT_SPATIAL_PROFILE
is built from. It is now derived from that profile by name
(profilePannerDistance), so the two cannot drift apart. Web Audio's
inverse model is the profile's formula exactly, so the rolloff stays in
the panner and there is no separate distance gain for a voice.

That rolloff is inside the panner, which is ahead of the gain node the
engine taps a send from, so a voice's reverb send fell with distance
just as a sound's did. The send is now given send / distanceGain with
the same cap, where the distance gain is computed from the profile, the
voice's position and the engine's listener position, since a panner's
gain cannot be read back. It is re-gained in place when the speaker or
the listener moves and when the send or chain changes.

The cone is also ahead of the tap: a speaker facing away sends up to
6 dB less to the room. That is not compensated here.
@ctoth
ctoth merged commit 37fffe1 into master Oct 5, 2026
2 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