Skip to content

Apply per-voice occlusion from Client.Media Play/Update - #163

Merged
ctoth merged 2 commits into
masterfrom
feat/media-occlusion
Oct 5, 2026
Merged

ctoth merged 2 commits into
masterfrom
feat/media-occlusion

Conversation

@ctoth

@ctoth ctoth commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

The field

Client.Media.Play and Client.Media.Update accept an optional occlusion: a number from 0 (clear path) to 1 (fully occluded, about -18 dB and an 800 Hz low-pass). It is this voice's occlusion amount and nothing more; the client has no notion of doors or rooms. No new GMCP message, no Cacophony change.

It is parsed with the other media fields (mediaFields in src/audio/mediaPayloads.ts) as optNumber(r, 'occlusion', what, { min: 0, max: 1 }). Like every other ranged number there, a value outside the range or of the wrong type is rejected, not clamped: the decoder throws MediaPayloadError, the frame is recorded as INVALID_PAYLOAD in the audio diagnostics ring, and the voice keeps the amount it had.

How it is applied

Cacophony 0.34.0 has setOcclusion(amount, duration) on Playback only. Sound does not forward it, so MediaService applies it to each Playback (renderOcclusion).

  • New voice (startVoice): the amount is set with duration 0 on the prepared, still stopped Playback, before playback.play(). A sound that starts behind a closed door never leaks an unfiltered attack. A new voice without the field is clear.
  • Kept voice: an Update, or a Play that keeps the playing voice (same key and source), glides to the new amount over OCCLUSION_GLIDE_MS (150 ms). No restart, no seek.
  • Field absent: the amount is unchanged and setOcclusion is not called.
  • Occlusion is independent of volume, fades, distance gain and the reverb send. It is not part of applyLevels.

Why it survives re-routing

The occlusion stage sits inside the Playback, between the voice's effects and its panner. Everything MediaService re-routes acts on the Playback's output, downstream of that stage: sound.routeTo for named chains and inline effect buses, chain replacement and removal, and attachPlayback on both FOA renderers (which reconnect playback.outputNode). A seek, a native loop or a timer-driven segment repeat keeps the same Playback, and Cacophony's recreateSource re-attaches the stage. So both panning modes and the ambisonic and positional-FOA routes support it without re-applying anything.

One path does build a new voice: an Update that moves the segment (finish, end or loopStart) replays the original Play. That replay now carries the amount in effect at the time, so an amount set by a later Update is not lost.

If a voice has no setOcclusion, or the call throws, the sound plays unoccluded and a CAPABILITY_UNAVAILABLE diagnostic is recorded. Nothing is approximated with volume or a filter.

Capability flag

Client.Media.EffectsSupport gains occlusion: boolean. buildEffectsSupport() feature-detects it from the engine (typeof Playback.prototype.setOcclusion === 'function'), so it is true with the installed Cacophony and would be false with an engine that lacks the stage. The server can send the field only to clients that advertise it.

Tests

Written first and seen failing (34 failed, 142 passed in the three touched test files), then implemented.

src/gmcp/Client/Media.test.ts, per-voice occlusion:

  • For stereo and for HRTF panning: a new voice with occlusion: 0.8 gets setOcclusion(0.8, 0) before play(); an Update with 0.2 gets setOcclusion(0.2, 150); an Update without the field makes no call; a Play that keeps the voice glides and does not restart or seek.
  • A new voice without the field gets no call and stays at 0. A replacement voice for the same key starts clear.
  • A voice routed through a named chain keeps its amount when the chain is redefined, when it is re-pointed to another chain with a send, when it gets inline effects, and when a chain is removed.
  • A moved segment rebuilds the voice at the current amount, with duration 0, before it starts.
  • Timer-driven segment repeats do not prepare a new voice or touch the amount.
  • Positional-FOA and 4-channel ambisonic voices are occluded before they start and glide on Update.
  • -0.1, 1.5 and "x" on Play and on Update throw MediaPayloadError, record INVALID_PAYLOAD and leave the amount unchanged.
  • A voice without setOcclusion records CAPABILITY_UNAVAILABLE, and volume and routing are untouched.

src/audio/mediaPayloads.test.ts: accepts 0, 0.8 and 1; leaves the field absent when not sent; rejects below 0, above 1, a string and NaN on Play and Update.

src/audio/effects/MediaEffects.test.ts: buildEffectsSupport() includes occlusion: true; it is false for an engine voice without setOcclusion.

Run locally, as CI does:

Command Result
npm run typecheck clean
npm test 128 files, 1467 tests passed
npm run test:audio-cache pass (18 audio chunks and 16 feature chunks still excluded from startup)
npm run lint (pre-commit) 0 errors; 9 warnings, all on lines that predate this change

The mocks record calls to setOcclusion; they do not run a Web Audio graph. That the stage survives pause/resume, source recreation and panner replacement is covered by Cacophony's own occlusion.test.ts, and the routing claims above come from reading the installed cacophony/dist/playback.mjs. It has not been listened to against a live server.

ctoth added 2 commits October 4, 2026 19:37
Client.Media.Play and Client.Media.Update may now carry occlusion, a number
from 0 (clear path) to 1 (fully occluded). It is rendered by Cacophony's
per-voice occlusion stage (Playback.setOcclusion), so the server no longer
has to fake a door with a volume multiplier and an inline lowpass.

A Play that starts a new voice applies the amount to the prepared, still
stopped Playback, so a sound that starts behind a closed door never leaks an
unfiltered attack. An Update, or a Play that keeps the playing voice, glides
to the new amount over 150 ms. A message without the field leaves the amount
unchanged; a new voice without it is clear. A value outside 0..1 or not a
number rejects the message, as other out-of-range numbers do.

Cacophony exposes occlusion on Playback only, not on Sound, and renders it
between the voice's effects and its panner. Named chains, inline effect
buses and the FOA renderers all hang off the Playback's output, downstream
of that stage, and a seek or loop restart keeps the stage, so the amount is
applied where a voice is prepared and where it changes. An Update that moves
the segment replays the original Play into a new voice; it now carries the
amount in effect, not the one the original Play named. Occlusion stays out of
applyLevels.

Client.Media.EffectsSupport gains occlusion, true when the engine's Playback
has setOcclusion. A voice without it records CAPABILITY_UNAVAILABLE and plays
unoccluded.
A Play is a full-state message, so one without occlusion now means 0, not
"keep the current amount". A listener who hears a sound through a door and
then walks into its room is re-Played the same key with no occlusion field;
the kept voice stayed muffled. It now glides to clear over 150 ms.

A kept, playing voice glides only when the amount differs from the one it
has; an unchanged amount makes no engine call. A new voice still gets its
amount before the first sample, and no call when it is 0. An Update without
the field still leaves the amount alone.

The segment replay in resegment is internal, not a Play from the server, so
it keeps naming the amount in effect.
@ctoth

ctoth commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Note for readers: the description's line that a Play without occlusion leaves the amount unchanged was superseded by 9c97e56. A Play is full state, so absent means 0 (clear); only an Update without the field keeps the current amount. docs/GMCP/packages/client-media.md has the final rule.

@ctoth
ctoth merged commit a4bad1f 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