Repository navigation
Apply per-voice occlusion from Client.Media Play/Update - #163
Merged
Merged
Conversation
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.
Contributor
Author
|
Note for readers: the description's line that a Play without |
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.
The field
Client.Media.PlayandClient.Media.Updateaccept an optionalocclusion: 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 (
mediaFieldsinsrc/audio/mediaPayloads.ts) asoptNumber(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 throwsMediaPayloadError, the frame is recorded asINVALID_PAYLOADin the audio diagnostics ring, and the voice keeps the amount it had.How it is applied
Cacophony 0.34.0 has
setOcclusion(amount, duration)onPlaybackonly.Sounddoes not forward it, soMediaServiceapplies it to each Playback (renderOcclusion).startVoice): the amount is set with duration 0 on the prepared, still stopped Playback, beforeplayback.play(). A sound that starts behind a closed door never leaks an unfiltered attack. A new voice without the field is clear.Update, or aPlaythat keeps the playing voice (same key and source), glides to the new amount overOCCLUSION_GLIDE_MS(150 ms). No restart, no seek.setOcclusionis not called.applyLevels.Why it survives re-routing
The occlusion stage sits inside the Playback, between the voice's effects and its panner. Everything
MediaServicere-routes acts on the Playback's output, downstream of that stage:sound.routeTofor named chains and inline effect buses, chain replacement and removal, andattachPlaybackon both FOA renderers (which reconnectplayback.outputNode). A seek, a native loop or a timer-driven segment repeat keeps the same Playback, and Cacophony'srecreateSourcere-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
Updatethat moves the segment (finish,endorloopStart) replays the originalPlay. That replay now carries the amount in effect at the time, so an amount set by a laterUpdateis not lost.If a voice has no
setOcclusion, or the call throws, the sound plays unoccluded and aCAPABILITY_UNAVAILABLEdiagnostic is recorded. Nothing is approximated with volume or a filter.Capability flag
Client.Media.EffectsSupportgainsocclusion: boolean.buildEffectsSupport()feature-detects it from the engine (typeof Playback.prototype.setOcclusion === 'function'), so it istruewith the installed Cacophony and would befalsewith 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:occlusion: 0.8getssetOcclusion(0.8, 0)beforeplay(); an Update with0.2getssetOcclusion(0.2, 150); an Update without the field makes no call; a Play that keeps the voice glides and does not restart or seek.-0.1,1.5and"x"on Play and on Update throwMediaPayloadError, recordINVALID_PAYLOADand leave the amount unchanged.setOcclusionrecordsCAPABILITY_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()includesocclusion: true; it isfalsefor an engine voice withoutsetOcclusion.Run locally, as CI does:
npm run typechecknpm testnpm run test:audio-cachenpm run lint(pre-commit)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 ownocclusion.test.ts, and the routing claims above come from reading the installedcacophony/dist/playback.mjs. It has not been listened to against a live server.