chore: nearby voice chat | audio stream deduplication#8817
Conversation
Single active sid per participant — every ECS consumer now reads through
the frame-activity resolver (slice 2) instead of the per-identity sid array:
- NearbyAudioStreamerComponent: string[] StreamSidsSnapshot → string CurrentSid
- NearbyLivekitBridgeSystem: Add / Refresh (ref-mutate) / Remove around
GetActiveSid, with HasAudioStream as the all-zeros-window guard
- NearbyAudioBindingSystem: iterates CurrentSid (one sid per avatar)
- NearbyAudioCleanupSystem: reap predicate flipped to !IsActiveSid —
reaps both evicted and demoted (ghost) sids
- NearbyVoiceChatDebugSystem: audio-source counter via HasAudioStream
- INearbyAudioStreamRegistry: deprecated GetAudioSidsArray / IsStreamGone
removed; resolver is the only read surface
EditMode tests migrated to the new API; T6–T9 from the spec land here:
- Bridge attach on null → sid
- Bridge in-place CurrentSid mutate on sid → sid'
- Bridge wait in all-zeros window, drop on HasAudioStream=false
- Cleanup reaps ghost sid losing the resolver pick
Move per-entity World.TryGet / World.Has checks into archetype-level
[All]/[None] filters so the source-generated queries do the gating
instead of the body. Same observable behavior, fewer hash lookups on
the hot path, more idiomatic ECS.
NearbyAudioPositionSystem:
- SyncPositionsAndSpatialAngles (one query + World.TryGet<InAudibleRangeTag>)
splits into SyncActiveAudio ([All<InAudibleRangeTag>]) and
SyncInactiveOutOfRangeAudio ([None<InAudibleRangeTag>]).
- Shared Stop/diff-write logic extracted to static StopIfPlaying.
NearbyAudioCleanupSystem:
- CleanupDoomedSource (one query + two World.Has + three predicate checks)
splits into three archetype-filtered queries:
- ReapOrphanedSource — [None<NearbyAudioStreamerComponent>] (trigger #1)
- ReapOutOfRangeSource — [None<InAudibleRangeTag>] (trigger #2)
- ReapFilteredSource — registry/blocking/ban check in body (triggers #3/#4/#5)
- Shared dispose+remove logic extracted to DisposeAndRemove.
Triggers #1/#2 are now unconditional reaps on archetype filter — no
per-entity Has lookup. Only the steady-state hot path (#3-#5) does
registry/blocking/banned predicate checks.
|
Windows and Mac build successful in Unity Cloud! You can find a link to the downloadable artifact below. |
…edup oracle
Post-slice-4 the audio-source component lives directly on the avatar entity,
and the per-entity binding query already filters on its absence. The
HashSet<StreamKey> kept around as a parallel "what's bound" index was
fully derived state — every Add was paired with World.Add and every
Remove with World.Remove. The one-avatar-per-walletId invariant from the
profile layer means the [None<NearbyAudioSourceComponent>] archetype
filter also dedups by StreamKey, so the HashSet has no remaining job.
- NearbyAudioBindingSystem: drop bindings field + ctor param, remove
Contains-guard and Add. Drop the unused MAX_CREATIONS_PER_FRAME
constant and the stale "throttled to a fixed budget per frame" XML
doc (no throttling has been wired up since the rewrite).
- NearbyAudioCleanupSystem: drop bindings field + ctor param.
DisposeAndRemove no longer copies StreamKey before World.Remove
(nothing to do with the key afterwards). DisposeDyingAvatarSources
and the listening-gate / device-change bulk path lose their
bindings.Clear / Remove maintenance calls. Drop the
System.Collections.Generic using.
- VoiceChatPlugin: drop the nearbyAudioBindings field, its
HashSet<StreamKey>(32) allocation, both InjectToWorld arguments,
and the no-longer-used LiveKit.Rooms.Streaming / Streaming.Audio /
System.Collections.Generic usings.
EditMode tests:
- NearbyAudioBindingSystemShould / NearbyAudioCleanupSystemShould:
drop bindings field, setup, teardown, both ctor params, and ~14
bindings.Contains / bindings.Is.Empty assertions — observable
behaviour stays covered by CountEntities / world.Has<>
assertions already present in the same tests.
- Cleanup test summary updated; binding-system class doc rewritten to
drop the throttled-per-tick contract line that referenced the removed
constant.
PerformanceTests:
- NearbyAudioFullCyclePerformanceTest / NearbyAudioFullCycleManualTest:
drop bindings field, allocation, ctor params, teardown clear, and
the HUD line that printed bindings.Count.
- ComputeRampUpTicks now returns 2 (binding materializes every ready
avatar in a single tick — no per-tick budget to amortise).
…ream-deduplication' into chore/nearby-voice-chat/audio-stream-deduplication
…s StreamKey to IsActiveSid
- NearbyAudioCleanupSystem: fold World.Remove<NearbyAudioSourceComponent>
into DisposeAllLiveSources via DisposeAndRemove; drop LIVE_AUDIO_QUERY.
- INearbyAudioStreamRegistry.IsActiveSid: take StreamKey instead of
(walletId, sid) — callsite already holds the key on the component.
- ReapFilteredSource: invert keep→remove for direct reading.
- Update registry impl + all mocks/fakes/asserts in EditMode and Performance
tests to the new signature.
…ream-deduplication' into chore/nearby-voice-chat/audio-stream-deduplication
|
@claude review |
This comment has been minimized.
This comment has been minimized.
#69) Add a cheap monotonic timestamp on every AudioStream that ticks inside the FFI callback whenever a media frame is decoded. This is the foundation for the NearbyVoiceChat single-active-sid resolver (stream deduplication): a stream that's alive is decoding frames; a ghost never decodes. - AudioStreamInternal: private `lastFrameReceivedAt` (init -1), Volatile.Read/Write around Environment.TickCount, set right before the buffer write under the existing channel/sampleRate guards. - AudioStream: pass-through `LastFrameReceivedAt`. - IAudioStreams.GetLastFrameReceivedAt(StreamKey): returns -1 when the stream is missing or has never emitted (sentinel chosen over 0 because TickCount is signed and can legitimately be 0/negative). explorer consumer - decentraland/unity-explorer#8817
…ream-deduplication' into chore/nearby-voice-chat/audio-stream-deduplication
This comment has been minimized.
This comment has been minimized.
|
Claude finished @popuz's task in 2m 58s —— View job Review (after sync —
|
| File | Change | Source | Type | Scope | Risk | Confidence |
|---|---|---|---|---|---|---|
Packages/manifest.json:13 |
com.decentraland.livekit-sdk repointed from branch ref to default branch |
git (decentraland org) | source | runtime | LOW | HIGH |
Packages/packages-lock.json:93 |
hash bumped 9c06f180… → 9caeba7fb8… |
git | source | runtime | LOW | HIGH |
The repointed SDK ships from the same org as the project, capability is proportionate (read-only frame-activity counter accessor consumed by NearbyAudioStreamsRegistry.GetLastFrameReceivedAt), and the lockfile pins the resolved hash. No binaries, asmdef edits, install hooks, or scoped-registry changes. No transitive surprises in the lockfile delta.
DEPENDENCY_REVIEW: PASS
- Read CLAUDE.md and load PR context
- Diff against
origin/devto see changes since last review - Inspect dependency changes (
manifest.json,packages-lock.json) - Re-verify the three review notes from the previous pass
- Post updated review
Summary: notes 1, 2, 4 resolved; note 3 (DisposeAndRemove signature) still open and worth addressing for the policy reason rather than a behavior bug. Dependency review now passes — branch ref removed, lockfile pinned, internal publisher.
· Branch
…ream-deduplication' into chore/nearby-voice-chat/audio-stream-deduplication
This comment has been minimized.
This comment has been minimized.
|
Claude encountered an error after 3m 25s —— View job
|
|
Claude finished @popuz's task in 4m 8s —— View job Review (sync —
|
|
Claude encountered an error after 2m 58s —— View job
|
|
PR #8817, run #26225373717 Builds: Windows change, Windows baseline, macOS change, macOS baseline Framework 13 i7
|

Pull Request Description
What does this PR change?
Fixes the long-standing "ghost stream" pathology in Nearby Voice Chat — the case where LiveKit silently fails to deliver a track-unsubscribe event and the Explorer keeps stale audio sources around for the same participant. Until now multiple
LivekitAudioSourceinstances could exist for one speaker, of which at most one carried real audio; the rest played silence but still consumed Unity audio voices, position-system updates, and spatialisation budget. Visible symptoms: another player sounds choppy, silent, or "missing" even though they are clearly speaking.The fix turns the registry into the authoritative oracle for which sid is alive right now:
AudioStreamexposesLastFrameReceivedAt(a monotonic tick written from inside the FFI frame callback). Ghosts never decode a frame, so their tick stays at the sentinel value.INearbyAudioStreamRegistry— new resolver:GetActiveSid(walletId)returns the sid with the freshest frame tick (single active candidate per participant);IsActiveSid(StreamKey)is the cleanup predicate. Wrap-safe arithmetic, allocation-free, no memoisation across ticks.NearbyAudioStreamerComponentdrops thestring[]snapshot in favour of a singlestring CurrentSid.NearbyAudioSourceComponentloses itsAvatarEntityback-reference and now lives directly on the avatar entity (no separate audio entity, no cross-entity transform lookup, no secondWorld.Create).NearbyLivekitBridgeSystempolls the resolver each tick (add / refresh / remove branches with an "all-zeros" guard so a freshly-reconnected speaker isn't dropped before the first frame).NearbyAudioBindingSystemreadsCurrentSidonly — no foreach over a sids array.NearbyAudioPositionSystembecomes a single same-entity query.NearbyAudioCleanupSystemditches the "sid disappeared from snapshot" predicate, reaps demoted ghost sids via!IsActiveSid, and inlines the bulk-remove path into the per-entity query.NearbyAudioStreamRegistryShould,NearbyAudioBindingSystemShould,NearbyAudioCleanupSystemShould,NearbyLivekitBridgeSystemShouldagainst the new contract; performance harness updated for the 1:1 layout.Test Instructions
Prerequisites
metaforge account create --clearon a second machine).Test Steps
1. Baseline — two avatars speaking, no overlap
2. Reconnect — ghost-sid eradication (primary fix scenario)
3. Range — audible region join / leave
4. Block — mid-conversation user block
5. Mute toggle per participant
6. Output device change
7. Suppression gates — call / loading / scene gate
NearbyVoiceChatEnabledflag isfalse(use any test scene that disables it).8. Crowd — many speakers nearby
metaforge) or coordinate 4+ accounts at the same parcel.9. Avatar leaves / reappears
10. Self-suppression sanity
Additional Testing Notes
Quality Checklist
docs/superpowers/specs/)Code Review Reference
Please review our Branch & PR Standards before submitting.