From 05841d7838012c03239e0dd02f984eb344b8b357 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Mon, 18 May 2026 22:35:14 +0200 Subject: [PATCH 01/12] added registry change --- .../Core/INearbyAudioStreamRegistry.cs | 14 +++ .../Core/NearbyAudioStreamsRegistry.cs | 36 ++++++++ .../NearbyAudioBindingSystemShould.cs | 4 + .../NearbyAudioCleanupSystemShould.cs | 4 + .../NearbyAudioStreamRegistryShould.cs | 92 +++++++++++++++++++ .../NearbyAudioFullCycleManualTest.cs | 4 + .../NearbyAudioFullCyclePerformanceTest.cs | 4 + Explorer/Packages/manifest.json | 2 +- Explorer/Packages/packages-lock.json | 4 +- 9 files changed, 161 insertions(+), 3 deletions(-) diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs index b59e89d14a..98ac381cc2 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs @@ -34,6 +34,20 @@ public interface INearbyAudioStreamRegistry : IDisposable bool IsStreamGone(StreamKey key); + /// + /// The single active sid for an identity (the candidate that most recently emitted a media frame across all + /// known sids), or null if the identity has no sids OR none of its candidates have ever emitted a frame. + /// The latter is a transient "not-yet-decided" window: the bridge will re-poll next tick and self-heal. + /// + string? GetActiveSid(string walletId); + + /// + /// true when is the resolver's current pick for . + /// Cleanup uses this in place of "sid disappeared from snapshot" — it also reaps demoted ghost sids that + /// still exist in the registry but lost to a fresher candidate. + /// + bool IsActiveSid(string walletId, string sid); + /// /// Returns true if was present in the latest /// snapshot. Pull-based and diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs index 2acd8c0206..1c90f8d4f6 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs @@ -134,6 +134,42 @@ public bool IsStreamGone(StreamKey key) return Array.IndexOf(sids, key.sid) < 0; } + public string? GetActiveSid(string walletId) + { + if (!DCLVolatile.Read(ref streamsByIdentity).TryGetValue(walletId, out string[]? sids) || sids.Length == 0) + return null; + + // Hot path: a single candidate is the active sid by definition; do not consult the frame oracle. + if (sids.Length == 1) return sids[0]; + + int bestTick = 0; + string? bestSid = null; + + foreach (string sid in sids) + { + int t = room.AudioStreams.GetLastFrameReceivedAt(new StreamKey(walletId, sid)); + + // -1 sentinel: AudioStream missing or never decoded a frame. Ghosts and not-yet-live tracks both + // land here; we cannot let them win against a candidate that has provably emitted audio. + if (t == -1) continue; + + // unchecked(t - bestTick) > 0 is wrap-safe across the ~49-day Environment.TickCount window. + if (bestSid is null || unchecked(t - bestTick) > 0) + { + bestTick = t; + bestSid = sid; + } + } + + return bestSid; + } + + public bool IsActiveSid(string walletId, string sid) + { + string? active = GetActiveSid(walletId); + return active is not null && string.Equals(active, sid, StringComparison.Ordinal); + } + private void OnConnectionUpdated(IRoom _, ConnectionUpdate update, LKDisconnectReason? __) { switch (update) diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs index 16ae7413b6..016122e38a 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs @@ -481,6 +481,10 @@ public Weak GetActiveStream(StreamKey key) public bool IsStreamGone(StreamKey key) => !streamsByKey.ContainsKey(key); + public string? GetActiveSid(string walletId) => null; + + public bool IsActiveSid(string walletId, string sid) => false; + public bool IsActiveSpeaker(string walletId) => false; public int RebuildEpoch => 0; diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs index dd90357357..1bbb001a31 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs @@ -532,6 +532,10 @@ public bool IsStreamGone(StreamKey key) return !sidsByIdentity.TryGetValue(key.identity, out HashSet? sids) || !sids.Contains(key.sid); } + public string? GetActiveSid(string walletId) => null; + + public bool IsActiveSid(string walletId, string sid) => false; + public bool IsActiveSpeaker(string walletId) => false; public int RebuildEpoch => 0; diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs index c57f06c0d9..92557bf8bc 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs @@ -5,6 +5,7 @@ using LiveKit.Rooms.ActiveSpeakers; using LiveKit.Rooms.Participants; using LiveKit.Rooms.Streaming; +using LiveKit.Rooms.Streaming.Audio; using LiveKit.Rooms.TrackPublications; using LiveKit.Rooms.Tracks.Hub; using NSubstitute; @@ -29,6 +30,7 @@ public class NearbyAudioStreamRegistryShould private IRoom room = null!; private IParticipantsHub participantsHub = null!; private FakeActiveSpeakers activeSpeakers = null!; + private IAudioStreams audioStreams = null!; private NearbyAudioStreamsRegistry registry = null!; [SetUp] @@ -39,6 +41,11 @@ public void SetUp() room.Participants.Returns(participantsHub); activeSpeakers = new FakeActiveSpeakers(); room.ActiveSpeakers.Returns(activeSpeakers); + audioStreams = Substitute.For(); + // -1 sentinel matches production: missing stream / never decoded a frame. + audioStreams.GetLastFrameReceivedAt(default).ReturnsForAnyArgs(-1); + audioStreams.ClearReceivedCalls(); // stub-setup call counts as received; clear so DidNotReceive assertions are clean. + room.AudioStreams.Returns(audioStreams); registry = new NearbyAudioStreamsRegistry(room); } @@ -450,6 +457,91 @@ public void IsStreamGoneReturnsTrueAfterLastSidUnsubscribed() Assert.That(registry.HasAudioStream(WALLET_A), Is.False); } + // ── Active-sid resolver (frame-activity oracle) ────────────── + + [Test] + public void ReturnNullActiveSidForUnknownWallet() + { + Assert.That(registry.GetActiveSid("0xUNKNOWN"), Is.Null); + } + + [Test] + public void ReturnSingleSidAsActiveWithoutConsultingFrameOracle() + { + RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); + + Assert.That(registry.GetActiveSid(WALLET_A), Is.EqualTo(SID_1)); + audioStreams.DidNotReceiveWithAnyArgs().GetLastFrameReceivedAt(default); + } + + [Test] + public void ReturnNullActiveSidWhenAllCandidatesAreGhosts() + { + RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); + RaiseTrackSubscribed(WALLET_A, SID_2, TrackKind.KindAudio); + // Both stay at the default -1 sentinel — none have ever decoded a frame. + + Assert.That(registry.GetActiveSid(WALLET_A), Is.Null); + } + + [Test] + public void PickTheOnlyCandidateWithFrameActivity() + { + RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); + RaiseTrackSubscribed(WALLET_A, SID_2, TrackKind.KindAudio); + + audioStreams.GetLastFrameReceivedAt(new StreamKey(WALLET_A, SID_2)).Returns(100); + // SID_1 keeps the default -1 sentinel (ghost). + + Assert.That(registry.GetActiveSid(WALLET_A), Is.EqualTo(SID_2)); + } + + [Test] + public void PickTheNewestCandidateWhenSeveralAreLive() + { + RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); + RaiseTrackSubscribed(WALLET_A, SID_2, TrackKind.KindAudio); + + audioStreams.GetLastFrameReceivedAt(new StreamKey(WALLET_A, SID_1)).Returns(100); + audioStreams.GetLastFrameReceivedAt(new StreamKey(WALLET_A, SID_2)).Returns(200); + + Assert.That(registry.GetActiveSid(WALLET_A), Is.EqualTo(SID_2)); + } + + [Test] + public void TreatTickCounterWrapAroundAsNewerNotOlder() + { + RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); + RaiseTrackSubscribed(WALLET_A, SID_2, TrackKind.KindAudio); + + // Pre-wrap (older), positive end of the range. + audioStreams.GetLastFrameReceivedAt(new StreamKey(WALLET_A, SID_1)).Returns(int.MaxValue - 50); + // Post-wrap (newer in unchecked arithmetic), negative end of the range. + audioStreams.GetLastFrameReceivedAt(new StreamKey(WALLET_A, SID_2)).Returns(int.MinValue + 50); + + Assert.That(registry.GetActiveSid(WALLET_A), Is.EqualTo(SID_2), + "unchecked(SID_2_tick - SID_1_tick) == 101 > 0 — resolver must prefer the post-wrap candidate"); + } + + [Test] + public void IsActiveSidTrueForWinnerFalseForGhostLoser() + { + RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); + RaiseTrackSubscribed(WALLET_A, SID_2, TrackKind.KindAudio); + + audioStreams.GetLastFrameReceivedAt(new StreamKey(WALLET_A, SID_2)).Returns(500); + // SID_1 keeps -1 (ghost). + + Assert.That(registry.IsActiveSid(WALLET_A, SID_2), Is.True); + Assert.That(registry.IsActiveSid(WALLET_A, SID_1), Is.False); + } + + [Test] + public void IsActiveSidFalseWhenWalletHasNoSids() + { + Assert.That(registry.IsActiveSid("0xUNKNOWN", SID_1), Is.False); + } + [Test] public void ConcurrentSubUnsubUnderContentionDoesNotLoseUpdates() { diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCycleManualTest.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCycleManualTest.cs index 609ac07ae1..f42c0cb494 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCycleManualTest.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCycleManualTest.cs @@ -367,6 +367,10 @@ public bool IsStreamGone(StreamKey key) return Array.IndexOf(arr, key.sid) < 0; } + public string? GetActiveSid(string walletId) => null; + + public bool IsActiveSid(string walletId, string sid) => false; + public bool IsActiveSpeaker(string walletId) => throw new NotImplementedException(); diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCyclePerformanceTest.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCyclePerformanceTest.cs index 1006da238a..2dd7499bce 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCyclePerformanceTest.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCyclePerformanceTest.cs @@ -574,6 +574,10 @@ public bool IsStreamGone(StreamKey key) return Array.IndexOf(arr, key.sid) < 0; } + public string? GetActiveSid(string walletId) => null; + + public bool IsActiveSid(string walletId, string sid) => false; + public bool IsActiveSpeaker(string walletId) => activeSpeakers.Contains(walletId); public int RebuildEpoch => 0; diff --git a/Explorer/Packages/manifest.json b/Explorer/Packages/manifest.json index 37fa0c6416..c15b921945 100644 --- a/Explorer/Packages/manifest.json +++ b/Explorer/Packages/manifest.json @@ -10,7 +10,7 @@ "com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask", "com.dcl.gpui-assets": "git@github.com:decentraland/unity-explorer-packages.git?path=/GPUInstancerPro/com.dcl.gpui-assets", "com.decentraland.filebrowserpro": "git@github.com:decentraland/unity-explorer-packages.git?path=/FileBrowserPro", - "com.decentraland.livekit-sdk": "https://github.com/decentraland/client-sdk-unity.git", + "com.decentraland.livekit-sdk": "https://github.com/decentraland/client-sdk-unity.git#chore/frame-activity-oracle-on-AudioStream", "com.decentraland.renum": "https://github.com/NickKhalow/REnum.git?path=REnum", "com.decentraland.renum.sourcegen": "https://github.com/NickKhalow/REnum.git#sourcegen/1.1.4", "com.decentraland.rpc-csharp": "https://github.com/decentraland/rpc-csharp.git?path=rpc-csharp/src#f3dd251c7837cc2d844e1c07e741177a53676064", diff --git a/Explorer/Packages/packages-lock.json b/Explorer/Packages/packages-lock.json index ce28bcdda9..b40a8ffafb 100644 --- a/Explorer/Packages/packages-lock.json +++ b/Explorer/Packages/packages-lock.json @@ -90,7 +90,7 @@ "hash": "f57b137a6bd76527ea144b7e03cd7743f1b39599" }, "com.decentraland.livekit-sdk": { - "version": "https://github.com/decentraland/client-sdk-unity.git", + "version": "https://github.com/decentraland/client-sdk-unity.git#chore/frame-activity-oracle-on-AudioStream", "depth": 0, "source": "git", "dependencies": { @@ -98,7 +98,7 @@ "com.nickkhalow.richtypes": "https://github.com/NickKhalow/RichTypesUnity.git?path=/Packages/RichTypes", "io.livekit.unity": "https://github.com/livekit/client-sdk-unity-web.git" }, - "hash": "dc6e479148106b1f8a68f0fbdba475bf7b96693e" + "hash": "9c06f180d260fa0905d1dcb7587e6675e674fee9" }, "com.decentraland.renum": { "version": "https://github.com/NickKhalow/REnum.git?path=REnum", From db8c7882af495bd6d4e81383a6e08e1231d9f7be Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Mon, 18 May 2026 23:17:23 +0200 Subject: [PATCH 02/12] collapse StreamerComponent + flip Bridge & Cleanup onto resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../NearbyAudioStreamerComponent.cs | 20 +- .../Core/INearbyAudioStreamRegistry.cs | 10 - .../Core/NearbyAudioStreamsRegistry.cs | 19 +- .../Systems/NearbyAudioBindingSystem.cs | 28 +-- .../Systems/NearbyAudioCleanupSystem.cs | 6 +- .../Systems/NearbyLivekitBridgeSystem.cs | 40 +-- .../Systems/NearbyVoiceChatDebugSystem.cs | 2 +- .../NearbyAudibleRangeMarkerSystemShould.cs | 2 +- .../NearbyAudioBindingSystemShould.cs | 54 ++-- .../NearbyAudioCleanupSystemShould.cs | 141 +++++++---- .../NearbyAudioStreamRegistryShould.cs | 81 ++---- .../NearbyLivekitBridgeSystemShould.cs | 235 ++++++++++++------ .../NearbyAudioFullCycleManualTest.cs | 46 +--- .../NearbyAudioFullCyclePerformanceTest.cs | 47 +--- 14 files changed, 371 insertions(+), 360 deletions(-) diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Components/NearbyAudioStreamerComponent.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Components/NearbyAudioStreamerComponent.cs index 7065048eda..9955f3c464 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Components/NearbyAudioStreamerComponent.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Components/NearbyAudioStreamerComponent.cs @@ -1,27 +1,25 @@ -using DCL.VoiceChat.Nearby.Audio; - namespace DCL.VoiceChat.Nearby { /// - /// Per-avatar mirror of the registry's audio sids for the avatar's walletId. + /// Per-avatar mirror of the registry's single active audio sid for the avatar's walletId. /// - /// is a reference to a registry-owned copy-on-write string[]. - /// Reference identity is the version signal: a different reference ↔ content changed. - /// Never mutate. - /// Never retain across frames longer than the COW guarantees in . + /// is the resolver's pick (most-recent-frame winner). Never null + /// while the component is attached — guarantees the invariant + /// by only attaching on a non-null resolver result and ref-mutating on flips. /// /// /// Remote-only: the local participant (user) is never present in the registry's streaming snapshot. - /// LiveKit does not raise TrackSubscribed for locally-published tracks, so this component is never attached to the local player avatar. + /// LiveKit does not raise TrackSubscribed for locally-published tracks, so this component is never + /// attached to the local player avatar. /// /// public struct NearbyAudioStreamerComponent { - public string[] StreamSidsSnapshot; + public string CurrentSid; - public NearbyAudioStreamerComponent(string[] streamSidsSnapshot) + public NearbyAudioStreamerComponent(string currentSid) { - StreamSidsSnapshot = streamSidsSnapshot; + CurrentSid = currentSid; } } } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs index 98ac381cc2..1fe6199af2 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs @@ -18,22 +18,12 @@ public interface INearbyAudioStreamRegistry : IDisposable /// bool HasAudioStream(string walletId); - /// - /// Raw reference to the registry's copy-on-write sid array. null when the wallet is not indexed. - /// Used by -driven systems that need to compare snapshots via - /// — a different reference ↔ content changed. - /// Never mutate. Treat the returned array as immutable from the caller's perspective. - /// - string[]? GetAudioSidsArray(string walletId); - /// /// Resolves a stream lazily. Must be called from the main thread — 's constructor /// reads Unity audio settings and performs a synchronous FFI request. /// Weak GetActiveStream(StreamKey key); - bool IsStreamGone(StreamKey key); - /// /// The single active sid for an identity (the candidate that most recently emitted a media frame across all /// known sids), or null if the identity has no sids OR none of its candidates have ever emitted a frame. diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs index 1c90f8d4f6..dbdae820b9 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs @@ -35,7 +35,7 @@ namespace DCL.VoiceChat.Nearby.Audio /// Immutability contract. Per-identity string[] arrays are copy-on-write /// (every mutation publishes a new reference); the dictionary itself is swapped atomically on rehydrate / disconnect. /// Do not reintroduce in-place mutation (array resize, pooled buffers, dict Clear()+rebuild) — - /// the bridge's ReferenceEquals freshness check and the cleanup system's "stream gone" detection both rely on these invariants. + /// iterates the array assuming snapshot semantics; in-place edits would race the resolver. /// /// public sealed class NearbyAudioStreamsRegistry : INearbyAudioStreamRegistry @@ -119,21 +119,9 @@ public bool IsActiveSpeaker(string walletId) => public bool HasAudioStream(string walletId) => DCLVolatile.Read(ref streamsByIdentity).ContainsKey(walletId); - // ReSharper disable once CanSimplifyDictionaryTryGetValueWithGetValueOrDefault - public string[]? GetAudioSidsArray(string walletId) => - DCLVolatile.Read(ref streamsByIdentity).TryGetValue(walletId, out string[]? arr) ? arr : null; - public Weak GetActiveStream(StreamKey key) => room.AudioStreams.ActiveStream(key); - public bool IsStreamGone(StreamKey key) - { - if (!DCLVolatile.Read(ref streamsByIdentity).TryGetValue(key.identity, out string[]? sids)) - return true; - - return Array.IndexOf(sids, key.sid) < 0; - } - public string? GetActiveSid(string walletId) { if (!DCLVolatile.Read(ref streamsByIdentity).TryGetValue(walletId, out string[]? sids) || sids.Length == 0) @@ -252,9 +240,8 @@ private static void AddAudioSidTo(DCLConcurrentDictionary dict } // Publishes a NEW filtered array on every successful update; never mutates 'prev'. - // Required by the immutability contract in the class XML: NearbyLivekitBridgeSystem - // uses ReferenceEquals(observed, current) for its per-frame freshness check, so any - // in-place mutation of a published array would silently hide the change from the bridge. + // Required by the immutability contract in the class XML: GetActiveSid iterates the + // array assuming snapshot semantics — in-place edits would race the resolver mid-pick. // Single-writer assumption (serial FFI dispatch) — no CAS retry needed. private void RemoveAudioSid(string identity, string sid) { diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs index c83c2288ee..6b03d2f294 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs @@ -18,7 +18,7 @@ namespace DCL.VoiceChat.Nearby.Systems /// /// Owns the creation part of the Nearby audio-source lifecycle. /// For every avatar entity ( + + + ) - /// the system iterates the snapshotted sids on the entity itself and materializes an audio-source entity per (walletId, sid) pair that does not yet have one. + /// the system materializes a single audio-source entity for the resolver-picked (walletId, CurrentSid) pair when one does not yet exist. /// Throttled to a fixed budget per frame so a crowd ramp-up does not spike a single tick. /// [UpdateInGroup(typeof(NearbyVoiceChatGroup))] @@ -71,25 +71,21 @@ private void CreateAndBindAudioSourcesToStreamers(Entity avatarEntity, in Profil // Skip blocked / scene-banned identities. Cleanup system handles already-bound entities; this filter prevents creation in the first place. if (userBlockingCache.UserIsBlocked(walletId) || roomMetadataCurrentScene.IsUserBanned(walletId)) return; - // Sids ride on the entity itself — no registry call on the per-avatar hot path. - // Bridge system guarantees SidsSnapshot is non-null for any entity that has the component. - foreach (string sid in nearby.StreamSidsSnapshot) - { - var key = new StreamKey(walletId, sid); + // The resolver dedup contract guarantees one active sid per participant — bridge keeps CurrentSid + // in sync with the registry's pick. Iterate it directly; no per-avatar registry call on the hot path. + var key = new StreamKey(walletId, nearby.CurrentSid); - if (!bindings.Contains(key)) - { - Weak stream = registry.GetActiveStream(key); + if (bindings.Contains(key)) return; - // Track was unsubscribed between collection (snapshot read) and resolve (GetActiveStream); skip to avoid a one-frame ghost source. - if (!stream.Resource.Has) continue; + Weak stream = registry.GetActiveStream(key); - LivekitAudioSource source = sourceFactory.Create(key, stream); + // Track was unsubscribed between bridge tick and resolve (GetActiveStream); skip to avoid a one-frame ghost source. + if (!stream.Resource.Has) return; - World.Create(new NearbyAudioSourceComponent(key, avatarEntity, source)); - bindings.Add(key); - } - } + LivekitAudioSource source = sourceFactory.Create(key, stream); + + World.Create(new NearbyAudioSourceComponent(key, avatarEntity, source)); + bindings.Add(key); } } } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs index f22c6abf99..7c54adbeaf 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs @@ -19,7 +19,7 @@ namespace DCL.VoiceChat.Nearby.Systems /// Detection — per tick tags doomed audio entities with on any of: /// /// Trigger #1 (avatar gone) — linked avatar entity is dead or flagged with . - /// Trigger #2 (stream gone) — registry no longer reports the bound (walletId, sid). + /// Trigger #2 (sid not active) — registry's resolver no longer picks the bound (walletId, sid) (either the sid was evicted, or a fresher candidate won). /// Trigger #3 (blocked) returns true for the bound walletId. /// Trigger #4 (scene-banned) returns true for the bound walletId. /// Trigger #5 (listening gate) — bulk removal when is in or ; @@ -93,8 +93,10 @@ private void FlagDoomedAudioEntities(Entity audioEntity, ref NearbyAudioSourceCo Entity avatar = comp.AvatarEntity; // Component absence ≠ avatar gone. Component absence ≠ specific sid gone either. Both fallbacks must remain. + // !IsActiveSid covers two cases: (1) sid evicted entirely → resolver returns different sid or null; (2) sid demoted → + // resolver picked a fresher candidate. The ghost loser of the pick is reaped here so binding can spawn the new winner. bool avatarGoneOrOutOfRange = !World.IsAlive(avatar) || World.Has(avatar) || !World.Has(avatar) || !World.Has(avatar); - if (avatarGoneOrOutOfRange || registry.IsStreamGone(comp.Key) || userBlockingCache.UserIsBlocked(comp.Key.identity) || roomMetadataCurrentScene.IsUserBanned(comp.Key.identity)) + if (avatarGoneOrOutOfRange || !registry.IsActiveSid(comp.Key.identity, comp.Key.sid) || userBlockingCache.UserIsBlocked(comp.Key.identity) || roomMetadataCurrentScene.IsUserBanned(comp.Key.identity)) World.Add(audioEntity); } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyLivekitBridgeSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyLivekitBridgeSystem.cs index 7e39184524..448cd6e8ee 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyLivekitBridgeSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyLivekitBridgeSystem.cs @@ -15,9 +15,9 @@ namespace DCL.VoiceChat.Nearby.Systems /// Runs every tick in , before . /// Stateless — pass-through under the listening gate (markers reflect LiveKit, not nearby-chat policy; consumers gate on policy). /// - /// Maintains the avatar's as a - /// reference to the registry's copy-on-write sid array — ReferenceEquals is the - /// freshness signal, no version counter is needed. + /// Maintains the avatar's as the resolver's pick. + /// A flip from one active sid to another mutates the field in place; the cleanup system reaps the + /// old (walletId, oldSid) audio entity on the next tick and binding creates the new one. /// /// [UpdateInGroup(typeof(NearbyVoiceChatGroup))] @@ -35,8 +35,8 @@ internal NearbyLivekitBridgeSystem(World world, INearbyAudioStreamRegistry regis protected override void Update(float t) { // Order matters: - AddStreamingQuery(World); // 1. Attach NearbyAudioStreamerComponent to avatars whose stream just appeared. - RefreshStreamingQuery(World); // 2a. Refresh SidsSnapshot reference on avatars that already carry the component (ref-mutation only, no structural changes). + AddStreamingQuery(World); // 1. Attach NearbyAudioStreamerComponent to avatars whose resolver just picked an active sid. + RefreshStreamingQuery(World); // 2a. Mutate CurrentSid in place when the resolver flipped to a different sid (ref-mutation only, no structural changes). RemoveStreamingQuery(World); // 2b. Cascade-remove on avatars whose stream disappeared (structural changes). AddSpeakingQuery(World); // 3. Tag avatars whose active-speaker signal just rose (only those still streaming). RemoveSpeakingQuery(World); // 4. Untag avatars whose active-speaker signal just dropped. @@ -50,9 +50,11 @@ private void AddStreaming(Entity entity, in Profile profile) string walletId = profile.UserId; if (string.IsNullOrEmpty(walletId)) return; - string[]? arr = registry.GetAudioSidsArray(walletId); - if (arr != null) - World.Add(entity, new NearbyAudioStreamerComponent(arr)); + // Resolver null = either no sids (case a → no-op here, RemoveStreaming has no component to drop) + // or all-zeros window (case b → wait for next tick). Either way, do nothing on the Add path. + string? activeSid = registry.GetActiveSid(walletId); + if (activeSid != null) + World.Add(entity, new NearbyAudioStreamerComponent(activeSid)); } [Query] @@ -63,12 +65,15 @@ private void RefreshStreaming(in Profile profile, ref NearbyAudioStreamerCompone string userId = profile.UserId; if (string.IsNullOrEmpty(userId)) return; - string[]? current = registry.GetAudioSidsArray(userId); - if (current == null) return; // cleanup is RemoveStreaming's responsibility + // Null means: registry has no sids (RemoveStreaming handles), or all-zeros window (wait). + // Either way, do not touch CurrentSid here. + string? activeSid = registry.GetActiveSid(userId); + if (activeSid == null) return; - // Refresh path — registry published a new array (content changed) since we last observed. - if (!ReferenceEquals(nearby.StreamSidsSnapshot, current)) - nearby.StreamSidsSnapshot = current; + // Flip path — resolver picked a different sid since we last observed (winner was demoted by a fresher candidate, + // or the previous sid was unsubscribed and a new one is active). Cleanup reaps the old (walletId, oldSid) entity. + if (!string.Equals(nearby.CurrentSid, activeSid, System.StringComparison.Ordinal)) + nearby.CurrentSid = activeSid; } [Query] @@ -77,7 +82,14 @@ private void RefreshStreaming(in Profile profile, ref NearbyAudioStreamerCompone private void RemoveStreaming(Entity entity, in Profile profile) { string userId = profile.UserId; - if (string.IsNullOrEmpty(userId) || registry.GetAudioSidsArray(userId) != null) return; + if (string.IsNullOrEmpty(userId)) return; + + // Critical guard: resolver returning null is ambiguous. + // a) HasAudioStream == false → identity has zero sids → drop. + // b) HasAudioStream == true → all-zeros window (>=1 sid registered, none have emitted a frame yet) → wait, do not drop. + // RefreshStreaming already kept CurrentSid in sync with whichever sid is currently picked, + // so the only remaining job of RemoveStreaming is to detect case (a). + if (registry.HasAudioStream(userId)) return; // Drop NearbyAudioStreamerComponent and every dependent marker so invariants (speaking ⊆ streaming, audible ⊆ streaming) hold. World.Remove(entity); diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyVoiceChatDebugSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyVoiceChatDebugSystem.cs index 9eb6719f77..2f62f7907d 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyVoiceChatDebugSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyVoiceChatDebugSystem.cs @@ -197,7 +197,7 @@ private void PollMetrics(float t) ulong audioSourceCount = 0; if (isConnected) foreach (KeyValuePair entry in islandRoom.Participants.RemoteParticipantIdentities()) - audioSourceCount += (ulong)(streamRegistry.GetAudioSidsArray(entry.Key)?.Length ?? 0); + audioSourceCount += streamRegistry.HasAudioStream(entry.Key) ? 1ul : 0ul; activeAudioSourcesBinding.Value = audioSourceCount; ulong componentCount = (ulong)World.CountEntities(in COMPONENT_QUERY); diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudibleRangeMarkerSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudibleRangeMarkerSystemShould.cs index 5993f8ac62..5af8a6b5e7 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudibleRangeMarkerSystemShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudibleRangeMarkerSystemShould.cs @@ -342,7 +342,7 @@ private Entity CreateStreamingAvatar(float distance, bool initialized = true) { string wallet = $"wallet-{distance}"; Entity e = CreateAvatarEntityAtDistance(wallet, distance, initialized); - world.Add(e, new NearbyAudioStreamerComponent(new[] { "sid-test" })); + world.Add(e, new NearbyAudioStreamerComponent("sid-test")); return e; } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs index 016122e38a..1aa815d960 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs @@ -93,25 +93,12 @@ public void SingleAvatarSingleStreamCreatesOneEntity() Assert.That(comp.LivekitAudioSource, Is.Not.Null); } - [Test] - public void MultiStreamPerAvatarCreatesDistinctEntities() - { - const string WALLET = "wallet-alice"; - CreateStreamingAvatar(WALLET, "sid-1", "sid-2"); - registry.SeedActiveStream(WALLET, "sid-1"); - registry.SeedActiveStream(WALLET, "sid-2"); - - system.Update(0); - - Assert.That(CountAudioEntities(), Is.EqualTo(2)); - } - [Test] public void AvatarWithoutAvatarBaseIsSkipped() { const string WALLET = "wallet-alice"; Entity avatarEntity = world.Create(new Profile(WALLET, WALLET, new Avatar())); - world.Add(avatarEntity, new NearbyAudioStreamerComponent(new[] { "sid-1" })); + world.Add(avatarEntity, new NearbyAudioStreamerComponent("sid-1")); world.Add(avatarEntity); registry.SeedActiveStream(WALLET, "sid-1"); @@ -288,7 +275,7 @@ public void BindsAvatarWithStreamingComponentAndAudibleRangeTag() const string SID = "sid-1"; Entity avatarEntity = CreateAvatarEntity(WALLET); - world.Add(avatarEntity, new NearbyAudioStreamerComponent(new[] { SID })); + world.Add(avatarEntity, new NearbyAudioStreamerComponent(SID)); world.Add(avatarEntity); registry.SeedActiveStream(WALLET, SID); @@ -305,7 +292,7 @@ public void RespectsUserBlockingWhenStreamingComponentPresent() const string SID = "sid-1"; Entity avatarEntity = CreateAvatarEntity(WALLET); - world.Add(avatarEntity, new NearbyAudioStreamerComponent(new[] { SID })); + world.Add(avatarEntity, new NearbyAudioStreamerComponent(SID)); world.Add(avatarEntity); registry.SeedActiveStream(WALLET, SID); userBlockingCache.UserIsBlocked(WALLET).Returns(true); @@ -323,7 +310,7 @@ public void DoesNotBindAvatarWithoutAudibleRangeTagEvenWithStreamingComponent() const string SID = "sid-1"; Entity avatarEntity = CreateAvatarEntity(WALLET); - world.Add(avatarEntity, new NearbyAudioStreamerComponent(new[] { SID })); + world.Add(avatarEntity, new NearbyAudioStreamerComponent(SID)); // intentionally no InAudibleRangeTag — out of range registry.SeedActiveStream(WALLET, SID); @@ -356,16 +343,17 @@ public void SpawnsAudioSourcePlayingAndMutedInitially() // ── B2.1: zero-alloc data path on the per-avatar hot path ─── [Test] - public void BindingIteratesSidsFromComponentWithoutCallingRegistryGetAudioSids() + public void BindingReadsCurrentSidFromComponentWithoutQueryingRegistryResolver() { - // Verifies the §1 design goal: CollectPendingCreations reads sids from the entity, - // not the registry. A mock registry counts data-path reads — must be zero after Update. + // Verifies the dedup design goal: the per-avatar hot path reads CurrentSid from the entity, + // never re-asks the registry for the active sid or wallet presence. Only GetActiveStream + // (the resolve step) is allowed on the hot path. A mock registry counts data-path reads. INearbyAudioStreamRegistry mock = Substitute.For(); - mock.GetAudioSidsArray(Arg.Any()).Returns((string[]?)null); - mock.HasAudioStream(Arg.Any()).Returns(false); - mock.GetActiveStream(Arg.Any()).Returns(Weak.Null); - mock.IsStreamGone(Arg.Any()).Returns(false); - mock.IsActiveSpeaker(Arg.Any()).Returns(false); + mock.HasAudioStream(Arg.Any()).ReturnsForAnyArgs(false); + mock.GetActiveStream(Arg.Any()).ReturnsForAnyArgs(Weak.Null); + mock.GetActiveSid(Arg.Any()).ReturnsForAnyArgs((string?)null); + mock.IsActiveSid(Arg.Any(), Arg.Any()).ReturnsForAnyArgs(false); + mock.IsActiveSpeaker(Arg.Any()).ReturnsForAnyArgs(false); // Replace registry with the mock for the lifetime of this test. var localBindings = new HashSet(); @@ -384,11 +372,9 @@ public void BindingIteratesSidsFromComponentWithoutCallingRegistryGetAudioSids() localSystem.Update(0); - mock.DidNotReceive().GetAudioSidsArray(Arg.Any()); - // ReadOnlySpan-returning overload — also untouched by the hot path. - // Substitute treats it like any other call; verifying the array-returning overload - // is sufficient since both feed off the same internal storage. + mock.DidNotReceive().GetActiveSid(Arg.Any()); mock.DidNotReceive().HasAudioStream(Arg.Any()); + mock.DidNotReceive().IsActiveSid(Arg.Any(), Arg.Any()); } finally { @@ -422,10 +408,12 @@ private Entity CreateAvatarEntity(string walletId) // After B2.1 the binding query is gated by StreamingAudioComponent + InAudibleRangeTag; // the helper seeds both directly so existing trigger tests do not depend on Bridge. - private Entity CreateStreamingAvatar(string walletId, params string[] sids) + // After the resolver-dedup collapse, the component carries a single CurrentSid; tests calling + // this helper with multi-sid signatures are obsolete (the multi-sid case can no longer exist). + private Entity CreateStreamingAvatar(string walletId, string sid) { Entity entity = CreateAvatarEntity(walletId); - world.Add(entity, new NearbyAudioStreamerComponent(sids)); + world.Add(entity, new NearbyAudioStreamerComponent(sid)); world.Add(entity); return entity; } @@ -468,8 +456,6 @@ public void MarkStreamAsUnsubscribed(string walletId, string sid) public bool HasAudioStream(string walletId) => false; - public string[]? GetAudioSidsArray(string walletId) => null; - public Weak GetActiveStream(StreamKey key) { if (unsubscribed.Contains(key)) return Weak.Null; @@ -479,8 +465,6 @@ public Weak GetActiveStream(StreamKey key) : Weak.Null; } - public bool IsStreamGone(StreamKey key) => !streamsByKey.ContainsKey(key); - public string? GetActiveSid(string walletId) => null; public bool IsActiveSid(string walletId, string sid) => false; diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs index 1bbb001a31..5cf30d6018 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs @@ -267,14 +267,16 @@ public void KeepsAudioEntityWhenMarkerPresentAndRegistryAlive() } [Test] - public void FlagsAudioEntityWhenOneOfNSidsGoneButMarkerPresent() + public void FlagsLosingSidAudioEntityWhenResolverPicksSibling() { - // Multi-sid granularity — the marker is per-walletId, so removing one sid leaves - // the marker and the other sid's audio entity in place. Per-sid doom must arrive - // through the registry.IsStreamGone(comp.Key) fallback, not the marker shortcut. + // Ghost-vs-winner — the registry still holds two candidate sids for the wallet (LiveKit + // dropped an unsubscribe), but the resolver now picks only one. Both audio entities exist + // as a hangover from before the flip (sid-1 was the previous pick, sid-2 just won). + // Per-sid doom arrives via !registry.IsActiveSid(comp.Key.identity, comp.Key.sid) — the + // demoted ghost sid is reaped, the winner survives, the marker stays (it tracks the wallet, + // not the sid). const string SID_2 = "sid-2"; (Entity audioEntity1, Entity avatarEntity, LivekitAudioSource source1) = SeedBinding(PARTICIPANT_A, SID_1); - registry.Add(PARTICIPANT_A, SID_2); // Sid-2 entity coexists; both audio entities share the same avatar (and its marker). var key2 = new StreamKey(PARTICIPANT_A, SID_2); @@ -282,17 +284,44 @@ public void FlagsAudioEntityWhenOneOfNSidsGoneButMarkerPresent() Entity audioEntity2 = world.Create(new NearbyAudioSourceComponent(key2, avatarEntity, source2)); bindings.Add(key2); - // Drop only sid-1 from the registry. Marker stays (sid-2 still present). - registry.RemoveSid(PARTICIPANT_A, SID_1); + // Resolver flips to sid-2; sid-1 stays in the registry as a ghost. + registry.SetActiveSid(PARTICIPANT_A, SID_2); system.Update(0); AssertCleanedUp(audioEntity1, source1, PARTICIPANT_A, SID_1); Assert.That(world.Has(audioEntity2), Is.False, - "sibling sid must remain alive — multi-sid is the case the registry fallback exists for"); + "winning sid must remain alive — only the demoted ghost is reaped"); Assert.That(source2 == null, Is.False); } + [Test] + public void GhostSidLosingResolverPickCausesCleanup() + { + // Test 9 from the spec — the audio entity's sid is no longer the resolver's pick. + // Distinct from RegistryMissingSidCausesCleanup: here the sid still EXISTS in the registry + // (HasAudioStream=true), it just lost the active-pick race. The !IsActiveSid predicate + // must reap it regardless of whether the registry still indexes the sid. + (Entity audioEntity, _, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); + + // Resolver demotes sid-1 in favour of a fresher candidate; HasAudioStream stays true + // (registry still holds candidates for the identity). + const string SID_FRESH = "sid-fresh"; + registry.SetActiveSid(PARTICIPANT_A, SID_FRESH); + + // Sanity-check the precondition the test depends on. + Assert.That(registry.HasAudioStream(PARTICIPANT_A), Is.True, + "precondition: identity still indexed (only the active pick changed)"); + Assert.That(registry.IsActiveSid(PARTICIPANT_A, SID_1), Is.False, + "precondition: bound sid is no longer the active one"); + // The Asserts above bumped IsActiveSidCallCount via the precondition; reset before the system tick. + registry.ResetCallCounters(); + + system.Update(0); + + AssertCleanedUp(audioEntity, source, PARTICIPANT_A, SID_1); + } + [Test] public void FlagsAudioEntityWhenAvatarHasDeleteIntentionButMarkerRemains() { @@ -315,7 +344,7 @@ public void FlagsAudioEntityWhenAvatarHasDeleteIntentionButMarkerRemains() public void DoesNotQueryRegistryWhenMarkerAbsentPathDoomsEntity() { // Optional sanity — proves the cheap shortcut actually short-circuits the registry call. - // If the marker-absence clause fires, registry.IsStreamGone must NOT be invoked. + // If the marker-absence clause fires, registry.IsActiveSid must NOT be invoked. (_, Entity avatarEntity, _) = SeedBinding(PARTICIPANT_A, SID_1); world.Remove(avatarEntity); @@ -323,7 +352,7 @@ public void DoesNotQueryRegistryWhenMarkerAbsentPathDoomsEntity() system.Update(0); - Assert.That(registry.IsStreamGoneCallCount, Is.EqualTo(0), + Assert.That(registry.IsActiveSidCallCount, Is.EqualTo(0), "marker-absence must short-circuit before the registry lookup"); Assert.That(userBlockingCache.ReceivedCalls(), Is.Empty, "marker-absence must short-circuit before the blocking cache lookup"); @@ -357,7 +386,7 @@ public void DoesNotInvokeRegistryWhenAudibleRangeAbsentPathDooms() system.Update(0); - Assert.That(registry.IsStreamGoneCallCount, Is.EqualTo(0), + Assert.That(registry.IsActiveSidCallCount, Is.EqualTo(0), "InAudibleRangeTag absence must short-circuit before the registry lookup"); Assert.That(userBlockingCache.ReceivedCalls(), Is.Empty, "InAudibleRangeTag absence must short-circuit before the blocking cache lookup"); @@ -433,8 +462,8 @@ private static void AssertSourceTornDown(LivekitAudioSource source, string? mess // a live audio entity is both markers present — Bridge applied StreamingAudioComponent // and AudibleRangeMarker applied InAudibleRangeTag before Binding spawned the entity. // Pair all three in the seed so existing trigger tests exercise the intended fallbacks - // (IsStreamGone / UserIsBlocked / lifecycle), not the marker-absence shortcuts by accident. - world.Add(avatarEntity, new NearbyAudioStreamerComponent(new[] { sid })); + // (!IsActiveSid / UserIsBlocked / lifecycle), not the marker-absence shortcuts by accident. + world.Add(avatarEntity, new NearbyAudioStreamerComponent(sid)); world.Add(avatarEntity); registry.Add(walletId, sid); @@ -475,67 +504,81 @@ private GameObject CreateTrackedGameObject(string name) private sealed class FakeStreamRegistry : INearbyAudioStreamRegistry { - // Per-wallet sid set as a HashSet — cleanup tests only need point-lookup semantics - // (IsStreamGone), not COW reference identity. Bridge tests are the place that - // exercises reference-equality contract. - private readonly Dictionary> sidsByIdentity = new (); + // Per-wallet active sid map. Cleanup interrogates the registry via IsActiveSid; + // the resolver-dedup contract guarantees at most one active sid per identity, so a + // plain wallet → sid map suffices. + private readonly Dictionary activeSidByIdentity = new (); + + // Per-wallet "registry has at least one sid for this identity" flag, decoupled from + // active-pick state so tests can model the all-zeros window (HasAudioStream=true, + // GetActiveSid=null) and ghost-demotion (sid exists in the registry but lost the pick). + private readonly HashSet hasAudioStreamByIdentity = new (); // Call counters for asserting short-circuit behaviour. NSubstitute is overkill here — // INearbyAudioStreamRegistry has a hand-rolled fake to drive trigger combinations, // and a single counter keeps the cleanup-shortcut test honest. - public int IsStreamGoneCallCount { get; private set; } + public int IsActiveSidCallCount { get; private set; } - public void ResetCallCounters() => IsStreamGoneCallCount = 0; + public void ResetCallCounters() => IsActiveSidCallCount = 0; public void Add(string walletId, string sid) { - if (!sidsByIdentity.TryGetValue(walletId, out HashSet? sids)) - { - sids = new HashSet(); - sidsByIdentity[walletId] = sids; - } - - sids.Add(sid); + // Mirrors production: an Add publishes a new active pick. Tests that need to model + // ghost-vs-winner can use SetActiveSid / DemoteToGhost directly. + hasAudioStreamByIdentity.Add(walletId); + activeSidByIdentity[walletId] = sid; } - public void RemoveAll(string walletId) => - sidsByIdentity.Remove(walletId); + public void RemoveAll(string walletId) + { + hasAudioStreamByIdentity.Remove(walletId); + activeSidByIdentity.Remove(walletId); + } public void RemoveSid(string walletId, string sid) { - if (sidsByIdentity.TryGetValue(walletId, out HashSet? sids)) - sids.Remove(sid); + // Mirrors production semantics — when the registry drops the sid that was the active + // pick, the identity has no winner anymore. If it was not the active pick (i.e. a + // ghost), HasAudioStream stays unchanged. + if (activeSidByIdentity.TryGetValue(walletId, out string? active) && active == sid) + { + activeSidByIdentity.Remove(walletId); + hasAudioStreamByIdentity.Remove(walletId); + } } - public void ClearAll() => - sidsByIdentity.Clear(); - - public bool HasAudioStream(string walletId) => - sidsByIdentity.TryGetValue(walletId, out HashSet? sids) && sids.Count > 0; - - public string[]? GetAudioSidsArray(string walletId) + /// + /// Promotes a different sid to the active pick for , leaving the + /// previous one as a "ghost" — it still exists in the registry (HasAudioStream=true) but + /// returns false for it. Models the resolver flipping winners. + /// + public void SetActiveSid(string walletId, string sid) { - if (!sidsByIdentity.TryGetValue(walletId, out HashSet? sids) || sids.Count == 0) - return null; + hasAudioStreamByIdentity.Add(walletId); + activeSidByIdentity[walletId] = sid; + } - var arr = new string[sids.Count]; - sids.CopyTo(arr); - return arr; + public void ClearAll() + { + hasAudioStreamByIdentity.Clear(); + activeSidByIdentity.Clear(); } + public bool HasAudioStream(string walletId) => + hasAudioStreamByIdentity.Contains(walletId); + public Weak GetActiveStream(StreamKey key) => Weak.Null; - public bool IsStreamGone(StreamKey key) + public string? GetActiveSid(string walletId) => + activeSidByIdentity.TryGetValue(walletId, out string? sid) ? sid : null; + + public bool IsActiveSid(string walletId, string sid) { - IsStreamGoneCallCount++; - return !sidsByIdentity.TryGetValue(key.identity, out HashSet? sids) || !sids.Contains(key.sid); + IsActiveSidCallCount++; + return activeSidByIdentity.TryGetValue(walletId, out string? active) && active == sid; } - public string? GetActiveSid(string walletId) => null; - - public bool IsActiveSid(string walletId, string sid) => false; - public bool IsActiveSpeaker(string walletId) => false; public int RebuildEpoch => 0; diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs index 92557bf8bc..b8398ce90a 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs @@ -160,14 +160,14 @@ public void RemoveParticipantEntryWhenLastSidUnsubscribed() RaiseTrackUnsubscribed(WALLET_A, SID_1); Assert.That(registry.HasAudioStream(WALLET_A), Is.False); - Assert.That(registry.GetAudioSidsArray(WALLET_A), Is.Null); + Assert.That(registry.GetActiveSid(WALLET_A), Is.Null); } [Test] public void ReturnNullForUnknownWallet() { Assert.That(registry.HasAudioStream("0xUNKNOWN"), Is.False); - Assert.That(registry.GetAudioSidsArray("0xUNKNOWN"), Is.Null); + Assert.That(registry.GetActiveSid("0xUNKNOWN"), Is.Null); } [Test] @@ -344,26 +344,25 @@ public void IsolateActiveSpeakersFromAudioSidsIndex() [Test] public void ReaderNeverObservesPartialSnapshotDuringConcurrentReconnect() { - // Atomic-publish invariant: a main-thread reader polling GetAudioSidsArray for an identity - // that is present both before and after rehydrate must never observe null mid-rehydrate. - // With in-place Clear()+rebuild the reader would see a transient null window; with - // Interlocked.Exchange-based snapshot swap it sees only pre-state or post-state references. + // Atomic-publish invariant: a main-thread reader polling HasAudioStream for an identity + // that is present both before and after rehydrate must never observe a transient false + // mid-rehydrate. With in-place Clear()+rebuild the reader would see a "wallet missing" + // window; with Interlocked.Exchange-based snapshot swap it only sees pre- or post-state. SetupRemoteParticipants((WALLET_A, new[] { (SID_1, TrackKind.KindAudio) })); RaiseConnectionUpdated(ConnectionUpdate.Connected); const int RECONNECT_ITERATIONS = 500; using var cts = new CancellationTokenSource(); - string? observedNullAt = null; + string? observedMissingAt = null; Task reader = Task.Run(() => { int loops = 0; while (!cts.IsCancellationRequested) { - string[]? arr = registry.GetAudioSidsArray(WALLET_A); - if (arr == null) + if (!registry.HasAudioStream(WALLET_A)) { - observedNullAt = "loop " + loops; + observedMissingAt = "loop " + loops; return; } loops++; @@ -380,8 +379,8 @@ public void ReaderNeverObservesPartialSnapshotDuringConcurrentReconnect() cts.Cancel(); Assert.That(reader.Wait(millisecondsTimeout: 5000), Is.True, "reader must terminate within timeout"); - Assert.That(observedNullAt, Is.Null, - $"WALLET_A is present pre- and post-rehydrate; reader observed transient null at {observedNullAt}"); + Assert.That(observedMissingAt, Is.Null, + $"WALLET_A is present pre- and post-rehydrate; reader observed transient absence at {observedMissingAt}"); } [Test] @@ -404,57 +403,25 @@ public void NotThrowWhenMutationsAreInterleavedWithReadsFromMultipleThreads() { for (int i = 0; i < ITERATIONS && !cts.IsCancellationRequested; i++) { - string[]? snapshot = registry.GetAudioSidsArray(WALLET_A); - if (snapshot == null) continue; - // Iterate the snapshot — the reference is immutable by COW contract, - // so no torn state is observable even while writer is pushing new versions. - for (int j = 0; j < snapshot.Length; j++) { _ = snapshot[j]; } + // Pull-based reader: the resolver iterates a COW array internally; any snapshot + // it touches is immutable post-publication, so no torn state is observable even + // while writer is pushing new versions. Discard the result — we only care that + // the call returns cleanly under contention. + _ = registry.GetActiveSid(WALLET_A); } }); Assert.DoesNotThrow(() => Task.WaitAll(new[] { writer, reader }, millisecondsTimeout: 5000)); } - // ── B2.1: COW reference-equality contract ─────────────────── - [Test] - public void SubscribingThenUnsubscribingSameSidProducesDistinctArrayReferences() + public void HasAudioStreamReturnsFalseAfterLastSidUnsubscribed() { RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); - string[] ref1 = registry.GetAudioSidsArray(WALLET_A)!; - - RaiseTrackSubscribed(WALLET_A, SID_2, TrackKind.KindAudio); - string[] ref2 = registry.GetAudioSidsArray(WALLET_A)!; - RaiseTrackUnsubscribed(WALLET_A, SID_1); - string[] ref3 = registry.GetAudioSidsArray(WALLET_A)!; - - Assert.That(ReferenceEquals(ref1, ref2), Is.False, "subscribe must publish a NEW array reference"); - Assert.That(ReferenceEquals(ref2, ref3), Is.False, "unsubscribe must publish a NEW array reference"); - Assert.That(ReferenceEquals(ref1, ref3), Is.False, "no path may reuse a prior reference"); - } - - [Test] - public void SameSidSetObservedTwiceReturnsSameReference() - { - RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); - string[]? a = registry.GetAudioSidsArray(WALLET_A); - string[]? b = registry.GetAudioSidsArray(WALLET_A); - - Assert.That(a, Is.Not.Null); - Assert.That(ReferenceEquals(a, b), Is.True, - "two reads with no intervening event must return the same reference — Bridge's no-op invariant"); - } - - [Test] - public void IsStreamGoneReturnsTrueAfterLastSidUnsubscribed() - { - RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); - RaiseTrackUnsubscribed(WALLET_A, SID_1); - - Assert.That(registry.IsStreamGone(new StreamKey(WALLET_A, SID_1)), Is.True); Assert.That(registry.HasAudioStream(WALLET_A), Is.False); + Assert.That(registry.GetActiveSid(WALLET_A), Is.Null); } // ── Active-sid resolver (frame-activity oracle) ────────────── @@ -566,11 +533,13 @@ public void ConcurrentSubUnsubUnderContentionDoesNotLoseUpdates() } } - private bool ContainsSid(string walletId, string sid) - { - string[]? arr = registry.GetAudioSidsArray(walletId); - return arr != null && Array.IndexOf(arr, sid) >= 0; - } + // Post-dedup the registry no longer exposes the underlying sid array. For tests that + // subscribe a single sid, IsActiveSid is an equivalent presence probe (single candidate + // is automatically the active pick by hot-path contract). Multi-sid tests that depended + // on per-sid index probing were either deleted (reference-equality on the array) or were + // already updated to use the resolver semantics directly. + private bool ContainsSid(string walletId, string sid) => + registry.IsActiveSid(walletId, sid); private void RaiseTrackSubscribed(string identity, string sid, TrackKind kind, TrackSource source = TrackSource.SourceMicrophone) { diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyLivekitBridgeSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyLivekitBridgeSystemShould.cs index b17bcc5628..a704425913 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyLivekitBridgeSystemShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyLivekitBridgeSystemShould.cs @@ -16,11 +16,12 @@ namespace DCL.VoiceChat.Nearby.Tests { /// - /// Documents the contract of : every avatar's - /// reflects the registry's COW sid array - /// (reference-equal); reflects the registry's active-speaker - /// snapshot. Pure pull-mirror; pass-through under listening gate. - /// Invariant I1: IsActivelySpeakingTag ⊆ StreamingAudioComponent. + /// Documents the contract of after the resolver-dedup collapse: + /// every avatar's reflects the registry's single + /// active pick via ; + /// reflects the registry's active-speaker snapshot. Pure pull-mirror; + /// pass-through under listening gate. + /// Invariant I1: IsActivelySpeakingTag ⊆ NearbyAudioStreamerComponent. /// public class NearbyLivekitBridgeSystemShould : UnitySystemTestBase { @@ -37,11 +38,11 @@ public void SetUp() EcsTestsUtils.SetUpFeaturesRegistry(); registry = Substitute.For(); - // Default: no streams, no active speakers — explicit so individual tests only override - // the slot they care about. NSubstitute returns null/false for unstubbed reference/bool - // returns, but stating the contract makes intent legible. - registry.GetAudioSidsArray(Arg.Any()).Returns((string[]?)null); - registry.IsActiveSpeaker(Arg.Any()).Returns(false); + // Default: no active sid, no participants indexed, no active speakers. ReturnsForAnyArgs so the + // default stub does not count as a received call for short-circuit assertions in DoesNotRevisit... + registry.GetActiveSid(Arg.Any()).ReturnsForAnyArgs((string?)null); + registry.HasAudioStream(Arg.Any()).ReturnsForAnyArgs(false); + registry.IsActiveSpeaker(Arg.Any()).ReturnsForAnyArgs(false); system = new NearbyLivekitBridgeSystem(world, registry); } @@ -58,17 +59,17 @@ protected override void OnTearDown() // ── AddStreaming query ────────────────────────────────────── [Test] - public void AddStreamingAttachesComponentWhenRegistryHasSids() + public void AddStreamingAttachesComponentWhenResolverReturnsActiveSid() { const string WALLET = "wallet-a"; + const string SID = "sid-1"; Entity e = CreateAvatarEntity(WALLET); - string[] sids = StubStreaming(WALLET, "sid-1"); + StubStreaming(WALLET, SID); system.Update(0); Assert.That(world.Has(e), Is.True); - Assert.That(ReferenceEquals(world.Get(e).StreamSidsSnapshot, sids), Is.True, - "SidsSnapshot must be reference-equal to the registry's COW array"); + Assert.That(world.Get(e).CurrentSid, Is.EqualTo(SID)); } [Test] @@ -87,35 +88,34 @@ public void AddStreamingSkipsAvatarWithDeleteEntityIntention() [Test] public void AddStreamingSkipsAvatarAlreadyHavingComponent() { - // Filter [None] must prevent the AddStreaming query from - // re-attaching a fresh component on an avatar that already carries one. UpdateStreaming + // Filter [None] must prevent the AddStreaming query from + // re-attaching a fresh component on an avatar that already carries one. RefreshStreaming // is the single place that mutates an existing component. const string WALLET = "wallet-a"; Entity e = CreateAvatarEntity(WALLET); - string[] preexisting = { "sid-pre" }; - world.Add(e, new NearbyAudioStreamerComponent(preexisting)); + world.Add(e, new NearbyAudioStreamerComponent("sid-pre")); StubStreaming(WALLET, "sid-other"); system.Update(0); - // Either Update kept it (preexisting reference) or refreshed it to the registry's array; - // either way, AddStreaming must NOT have piled on a second component. + // Either Update kept it or refreshed CurrentSid in place; either way, AddStreaming must NOT + // have piled on a second component (would throw on Arch). Assert.That(world.Has(e), Is.True); } [Test] public void AddStreamingSkipsAvatarWithEmptyWalletId() { - // Avatar with empty UserId. Poison the empty-string slot with a non-null sids array - // so an impl which fails to short-circuit on empty walletId would mistakenly attach - // the component. The empty-walletId guard is the observable behavior. + // Avatar with empty UserId. Poison the empty-string slot with a non-null active sid so an + // impl which fails to short-circuit on empty walletId would mistakenly attach the component. var avatarGo = CreateTrackedGameObject("Avatar_empty"); AvatarBase avatarBase = avatarGo.AddComponent(); var anchorGo = CreateTrackedGameObject("HeadAnchor_empty"); anchorGo.transform.SetParent(avatarGo.transform); HEAD_ANCHOR_FIELD.SetValue(avatarBase, anchorGo.transform); - registry.GetAudioSidsArray("").Returns(new[] { "sid-poison" }); + registry.GetActiveSid("").Returns("sid-poison"); + registry.HasAudioStream("").Returns(true); Entity e = world.Create(new Profile("", "", new Avatar()), avatarBase); @@ -124,46 +124,132 @@ public void AddStreamingSkipsAvatarWithEmptyWalletId() Assert.That(world.Has(e), Is.False); } - // ── UpdateStreaming query ─────────────────────────────────── + [Test] + public void AddStreamingWaitsDuringAllZerosWindow() + { + // All-zeros window: registry has the identity but the resolver has not picked a sid yet + // (no candidate has emitted a frame). The bridge must not attach a component — wait for + // the next tick, the resolver self-heals on the first frame. + const string WALLET = "wallet-a"; + Entity e = CreateAvatarEntity(WALLET); + registry.GetActiveSid(WALLET).Returns((string?)null); + registry.HasAudioStream(WALLET).Returns(true); + + system.Update(0); + + Assert.That(world.Has(e), Is.False, + "all-zeros window must defer attachment until the resolver picks a sid"); + } + + // ── RefreshStreaming query ─────────────────────────────────── [Test] - public void UpdateStreamingNoOpWhenReferenceUnchanged() + public void RefreshStreamingNoOpWhenResolverStable() { const string WALLET = "wallet-a"; Entity e = CreateAvatarEntity(WALLET); - string[] sids = StubStreaming(WALLET, "sid-1"); + StubStreaming(WALLET, "sid-1"); system.Update(0); Assert.That(world.Has(e), Is.True); - // Two more ticks; registry returns the same array reference each time. + // Two more ticks; resolver picks the same sid each time. system.Update(0); system.Update(0); - Assert.That(ReferenceEquals(world.Get(e).StreamSidsSnapshot, sids), Is.True, - "stable registry → SidsSnapshot reference must remain unchanged across ticks"); + Assert.That(world.Get(e).CurrentSid, Is.EqualTo("sid-1"), + "stable resolver → CurrentSid must remain unchanged across ticks"); } [Test] - public void UpdateStreamingRefreshesReferenceWhenRegistryArrayChanged() + public void T6_AvatarGetsComponentWhenResolverFlipsFromNullToSid() { + // Test 6 from the spec: avatar starts with no component (resolver returns null — either + // unknown identity or all-zeros window). On the next tick the resolver picks sid-42; the + // bridge must attach NearbyAudioStreamerComponent(CurrentSid = "sid-42"). const string WALLET = "wallet-a"; + const string SID_42 = "sid-42"; Entity e = CreateAvatarEntity(WALLET); - string[] firstRef = StubStreaming(WALLET, "sid-1"); + + // Resolver: null on tick 1 (no pick yet), wallet not indexed. + registry.GetActiveSid(WALLET).Returns((string?)null); + registry.HasAudioStream(WALLET).Returns(false); system.Update(0); - Assert.That(ReferenceEquals(world.Get(e).StreamSidsSnapshot, firstRef), Is.True); + Assert.That(world.Has(e), Is.False, "precondition: no component while resolver returns null"); - // Registry publishes a NEW array (content changed, reference changed) — simulate the - // post-OnTrackSubscribed COW snapshot. Bridge must observe the new reference and refresh - // the entity's snapshot. - string[] secondRef = { "sid-1", "sid-2" }; - registry.GetAudioSidsArray(WALLET).Returns(secondRef); + // Resolver: sid-42 on tick 2. + registry.GetActiveSid(WALLET).Returns(SID_42); + registry.HasAudioStream(WALLET).Returns(true); system.Update(0); - Assert.That(ReferenceEquals(world.Get(e).StreamSidsSnapshot, secondRef), Is.True, - "new registry reference → SidsSnapshot must adopt the new reference"); + Assert.That(world.Has(e), Is.True, "resolver flip null → sid-42 must attach the component"); + Assert.That(world.Get(e).CurrentSid, Is.EqualTo(SID_42)); + } + + [Test] + public void T7_AvatarCurrentSidMutatesInPlaceWhenResolverFlipsSids() + { + // Test 7 from the spec: avatar already carries the component with CurrentSid = sid-42; on a + // later tick the resolver picks sid-7 instead (the previous winner was demoted by a fresher + // candidate). Bridge must ref-mutate CurrentSid without dropping/re-adding the component — + // structural change here would invalidate the speaking/audible cascade invariants. + const string WALLET = "wallet-a"; + const string SID_42 = "sid-42"; + const string SID_7 = "sid-7"; + Entity e = CreateAvatarEntity(WALLET); + + StubStreaming(WALLET, SID_42); + system.Update(0); + Assert.That(world.Get(e).CurrentSid, Is.EqualTo(SID_42), "precondition: CurrentSid = sid-42"); + + // Resolver flips winner. + registry.GetActiveSid(WALLET).Returns(SID_7); + // HasAudioStream stays true — only the active pick changed. + + system.Update(0); + + Assert.That(world.Has(e), Is.True, "component must persist across the flip (structural stability)"); + Assert.That(world.Get(e).CurrentSid, Is.EqualTo(SID_7), "resolver flip sid-42 → sid-7 must ref-mutate CurrentSid"); + } + + [Test] + public void T8_AvatarLosesComponentWhenHasAudioStreamGoesFalseButNotDuringAllZerosWindow() + { + // Test 8 from the spec — combined two-step assertion. + // Step A — all-zeros window: HasAudioStream=true, GetActiveSid=null → wait, do NOT drop. + // Step B — identity gone: HasAudioStream=false, GetActiveSid=null → drop with cascade. + const string WALLET = "wallet-a"; + Entity e = CreateAvatarEntity(WALLET); + + StubStreaming(WALLET, "sid-1"); + registry.IsActiveSpeaker(WALLET).Returns(true); + + system.Update(0); + Assert.That(world.Has(e), Is.True, "precondition: component attached"); + Assert.That(world.Has(e), Is.True, "precondition: speaking tag set"); + world.Add(e, new InAudibleRangeTag { IsSuspended = false }); + + // Step A — resolver enters all-zeros window (identity still indexed, no candidate emitting). + registry.GetActiveSid(WALLET).Returns((string?)null); + registry.HasAudioStream(WALLET).Returns(true); + + system.Update(0); + + Assert.That(world.Has(e), Is.True, + "all-zeros window (HasAudioStream && active=null) must NOT drop the component"); + Assert.That(world.Has(e), Is.True, "speaking tag must persist through the all-zeros window"); + Assert.That(world.Has(e), Is.True, "audible-range tag must persist through the all-zeros window"); + + // Step B — identity disappears entirely. + registry.HasAudioStream(WALLET).Returns(false); + + system.Update(0); + + Assert.That(world.Has(e), Is.False, "HasAudioStream=false must drop the component"); + Assert.That(world.Has(e), Is.False, "cascade must drop speaking tag (invariant I1)"); + Assert.That(world.Has(e), Is.False, "cascade must drop audible-range tag"); } [Test] @@ -197,9 +283,12 @@ public void BulkCascadeRemovalProcessesEveryEntityWithinSameTick() world.Add(entities[i], new InAudibleRangeTag { IsSuspended = false }); } - // Streams disappear for every wallet on the same tick. + // Streams disappear for every wallet on the same tick — identity fully gone (not the all-zeros window). for (int i = 0; i < COUNT; i++) - registry.GetAudioSidsArray(wallets[i]).Returns((string[]?)null); + { + registry.GetActiveSid(wallets[i]).Returns((string?)null); + registry.HasAudioStream(wallets[i]).Returns(false); + } system.Update(0); @@ -211,39 +300,14 @@ public void BulkCascadeRemovalProcessesEveryEntityWithinSameTick() } } - [Test] - public void UpdateStreamingCascadesFullRemovalWhenRegistryReturnsNull() - { - const string WALLET = "wallet-a"; - Entity e = CreateAvatarEntity(WALLET); - StubStreaming(WALLET, "sid-1"); - registry.IsActiveSpeaker(WALLET).Returns(true); - - system.Update(0); - Assert.That(world.Has(e), Is.True, "precondition: component attached"); - Assert.That(world.Has(e), Is.True, "precondition: speaking tag set"); - - // Seed the dependent marker AudibleRangeSystem would have placed (suspended-flag rides inside it). - world.Add(e, new InAudibleRangeTag { IsSuspended = true }); - - // Stream disappears. - registry.GetAudioSidsArray(WALLET).Returns((string[]?)null); - - system.Update(0); - - Assert.That(world.Has(e), Is.False); - Assert.That(world.Has(e), Is.False, "cascade must drop speaking (invariant I1)"); - Assert.That(world.Has(e), Is.False, "cascade must drop audible-range (suspended flag is enforced as subset by type)"); - } - [Test] public void DoesNotRevisitEntityAfterStructuralChangeInSameQuery() { - // The filter-trick: AddStreaming's [None] excludes the entity - // from its own iterator after `World.Add` migrates it; RefreshStreaming and - // RemoveStreaming's [All] symmetrically catch it. Verified by - // call count — AddStreaming reads the registry once, RefreshStreaming once, - // RemoveStreaming once (the steady-state non-null path returns early). + // The filter-trick: AddStreaming's [None] excludes the entity + // from its own iterator after `World.Add` migrates it; RefreshStreaming and RemoveStreaming's + // [All] symmetrically catch it. Verified by call count — + // AddStreaming reads the resolver once, RefreshStreaming once (refresh path), RemoveStreaming + // reads HasAudioStream once (the steady-state guard, returns early since identity is indexed). const string WALLET = "wallet-a"; CreateAvatarEntity(WALLET); StubStreaming(WALLET, "sid-1"); @@ -251,7 +315,10 @@ public void DoesNotRevisitEntityAfterStructuralChangeInSameQuery() system.Update(0); - registry.Received(3).GetAudioSidsArray(WALLET); + // AddStreaming + RefreshStreaming each call GetActiveSid once. + registry.Received(2).GetActiveSid(WALLET); + // RemoveStreaming guards via HasAudioStream — exactly one call (identity is indexed → early-return). + registry.Received(1).HasAudioStream(WALLET); } // ── Speaking / cascade ────────────────────────────────────── @@ -273,24 +340,24 @@ public void AppliesSpeakingTagWhenRegistryReportsActiveSpeakerWithStreamingCompo [Test] public void DoesNotApplySpeakingTagToAvatarWithoutStreamingComponent() { - // Pins invariant I1: AddSpeaking's [All] filter must prevent + // Pins invariant I1: AddSpeaking's [All] filter must prevent // the speaking tag from ever materializing on a non-streaming avatar. const string WALLET = "wallet-a"; Entity e = CreateAvatarEntity(WALLET); registry.IsActiveSpeaker(WALLET).Returns(true); - // No StubStreaming — registry reports no audio for this walletId. + // No StubStreaming — resolver reports no audio for this walletId. system.Update(0); Assert.That(world.Has(e), Is.False); Assert.That(world.Has(e), Is.False, - "speaking tag must require StreamingAudioComponent (invariant I1)"); + "speaking tag must require NearbyAudioStreamerComponent (invariant I1)"); } [Test] public void DoesNotChurnSpeakingTagWhenStreamingPersists() { - // Regression guard: an unconditional speaking-cascade in UpdateStreaming would drop + // Regression guard: an unconditional speaking-cascade in Refresh/Remove would drop // IsActivelySpeakingTag every tick, then AddSpeaking would re-add it on the same tick — // costing a structural-change pair per active speaker per tick. Steady-state contract: // both tags settled, only RemoveSpeaking re-checks IsActiveSpeaker. Measure call count. @@ -323,7 +390,7 @@ public void RemovesSpeakingTagWhenWalletIdDropsFromActiveSpeakersButStillStreami system.Update(0); Assert.That(world.Has(e), Is.True, - "stream is unchanged — StreamingAudioComponent must persist"); + "stream is unchanged — NearbyAudioStreamerComponent must persist"); Assert.That(world.Has(e), Is.False); } @@ -360,10 +427,16 @@ public void AppliesComponentRegardlessOfListeningGateState() // ── Helpers ───────────────────────────────────────────────── - private string[] StubStreaming(string walletId, params string[] sids) + /// + /// Pins both halves of the resolver contract: GetActiveSid returns the given sid AND + /// HasAudioStream is true. Mirrors a steady-state participant — Bridge's RemoveStreaming + /// guard ([HasAudioStream==false] is the drop trigger) treats this identity as live. + /// + private string StubStreaming(string walletId, string sid) { - registry.GetAudioSidsArray(walletId).Returns(sids); - return sids; + registry.GetActiveSid(walletId).Returns(sid); + registry.HasAudioStream(walletId).Returns(true); + return sid; } private Entity CreateAvatarEntity(string walletId) diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCycleManualTest.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCycleManualTest.cs index f42c0cb494..e4a442e2e1 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCycleManualTest.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCycleManualTest.cs @@ -313,32 +313,17 @@ private void OnGUI() private void ForceMassCleanup() => registry?.ClearAll(); // ── Fake stream registry ──────────────────────────────────── - // Mirrors the binding test fake: Owned(null) yields a Weak whose Resource.Has - // is true, so the binding system actually instantiates LivekitAudioSource through the real - // factory — we want the integration cost, not a stubbed-out short-circuit. + // Post-dedup: one active sid per wallet. Owned(null) yields a Weak whose + // Resource.Has is true, so the binding system actually instantiates LivekitAudioSource through + // the real factory — we want the integration cost, not a stubbed-out short-circuit. private sealed class FakeStreamRegistry : INearbyAudioStreamRegistry { - // COW-style storage to mirror production semantics: every Add publishes a NEW - // string[] reference, matching the registry's reference-equality contract. - private readonly Dictionary sidsByIdentity = new (); + private readonly Dictionary activeSidByIdentity = new (); private readonly Dictionary> streamsByKey = new (); public void Add(string walletId, string sid) { - if (sidsByIdentity.TryGetValue(walletId, out string[]? prev)) - { - if (Array.IndexOf(prev, sid) < 0) - { - var next = new string[prev.Length + 1]; - Array.Copy(prev, next, prev.Length); - next[prev.Length] = sid; - sidsByIdentity[walletId] = next; - } - } - else - { - sidsByIdentity[walletId] = new[] { sid }; - } + activeSidByIdentity[walletId] = sid; var key = new StreamKey(walletId, sid); if (!streamsByKey.ContainsKey(key)) @@ -346,30 +331,23 @@ public void Add(string walletId, string sid) } public void RemoveAll(string walletId) => - sidsByIdentity.Remove(walletId); + activeSidByIdentity.Remove(walletId); public void ClearAll() => - sidsByIdentity.Clear(); + activeSidByIdentity.Clear(); - public bool HasAudioStream(string walletId) => sidsByIdentity.ContainsKey(walletId); - - public string[]? GetAudioSidsArray(string walletId) => - sidsByIdentity.TryGetValue(walletId, out string[]? arr) ? arr : null; + public bool HasAudioStream(string walletId) => activeSidByIdentity.ContainsKey(walletId); public Weak GetActiveStream(StreamKey key) => streamsByKey.TryGetValue(key, out Owned? owned) ? owned.Downgrade() : Weak.Null; - public bool IsStreamGone(StreamKey key) - { - if (!sidsByIdentity.TryGetValue(key.identity, out string[]? arr)) return true; - return Array.IndexOf(arr, key.sid) < 0; - } - - public string? GetActiveSid(string walletId) => null; + public string? GetActiveSid(string walletId) => + activeSidByIdentity.TryGetValue(walletId, out string? sid) ? sid : null; - public bool IsActiveSid(string walletId, string sid) => false; + public bool IsActiveSid(string walletId, string sid) => + activeSidByIdentity.TryGetValue(walletId, out string? active) && active == sid; public bool IsActiveSpeaker(string walletId) => throw new NotImplementedException(); diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCyclePerformanceTest.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCyclePerformanceTest.cs index 2dd7499bce..ac2892c887 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCyclePerformanceTest.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCyclePerformanceTest.cs @@ -353,7 +353,7 @@ public void BoundaryChurnAt30Publishers() /// B2-1 — Binding hot-path allocation profile at 100 avatars in steady state. Isolates /// from the rest of the chain so Unity's /// Performance Testing reporter attributes GC bytes/frame and ms/tick directly to the - /// per-avatar sid iteration over . + /// per-avatar read of . /// /// Pre-B2 baseline: ~128 B / frame per matching avatar from /// ConcurrentDictionary<string,byte>.GetEnumerator (≈ 2 KB / frame at this @@ -522,34 +522,20 @@ private GameObject CreateTrackedGameObject(string name) } // ── Fake stream registry ──────────────────────────────────── - // Mirrors NearbyAudioBindingSystemShould's fake: Owned(null) yields a Weak - // whose Resource.Has is true, so binding actually creates LivekitAudioSource instances - // through the real factory — we want the integration cost, not a stubbed-out short-circuit. + // After the resolver-dedup collapse, the registry exposes GetActiveSid (single winner per + // identity) and IsActiveSid (was-it-this-sid predicate). One sid per wallet — multi-sid + // tests are out of scope post-dedup. Owned(null) yields a Weak whose + // Resource.Has is true, so binding actually creates LivekitAudioSource through the real + // factory — we want the integration cost, not a stubbed-out short-circuit. private sealed class FakeStreamRegistry : INearbyAudioStreamRegistry { - // COW-style storage to mirror production semantics — every Add publishes a NEW - // string[] reference, so Bridge's UpdateStreaming ReferenceEquals check behaves - // identically against this fake. - private readonly Dictionary sidsByIdentity = new (); + private readonly Dictionary activeSidByIdentity = new (); private readonly Dictionary> streamsByKey = new (); private readonly HashSet activeSpeakers = new (); public void Add(string walletId, string sid) { - if (sidsByIdentity.TryGetValue(walletId, out string[]? prev)) - { - if (Array.IndexOf(prev, sid) < 0) - { - var next = new string[prev.Length + 1]; - Array.Copy(prev, next, prev.Length); - next[prev.Length] = sid; - sidsByIdentity[walletId] = next; - } - } - else - { - sidsByIdentity[walletId] = new[] { sid }; - } + activeSidByIdentity[walletId] = sid; var key = new StreamKey(walletId, sid); if (!streamsByKey.ContainsKey(key)) @@ -558,25 +544,18 @@ public void Add(string walletId, string sid) public void MarkAsActiveSpeaker(string walletId) => activeSpeakers.Add(walletId); - public bool HasAudioStream(string walletId) => sidsByIdentity.ContainsKey(walletId); - - public string[]? GetAudioSidsArray(string walletId) => - sidsByIdentity.TryGetValue(walletId, out string[]? arr) ? arr : null; + public bool HasAudioStream(string walletId) => activeSidByIdentity.ContainsKey(walletId); public Weak GetActiveStream(StreamKey key) => streamsByKey.TryGetValue(key, out Owned? owned) ? owned.Downgrade() : Weak.Null; - public bool IsStreamGone(StreamKey key) - { - if (!sidsByIdentity.TryGetValue(key.identity, out string[]? arr)) return true; - return Array.IndexOf(arr, key.sid) < 0; - } - - public string? GetActiveSid(string walletId) => null; + public string? GetActiveSid(string walletId) => + activeSidByIdentity.TryGetValue(walletId, out string? sid) ? sid : null; - public bool IsActiveSid(string walletId, string sid) => false; + public bool IsActiveSid(string walletId, string sid) => + activeSidByIdentity.TryGetValue(walletId, out string? active) && active == sid; public bool IsActiveSpeaker(string walletId) => activeSpeakers.Contains(walletId); From 00334bf16d735a7674f8ee53ab1800ed011ad5b3 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Tue, 19 May 2026 00:08:39 +0200 Subject: [PATCH 03/12] removed one-to-many ecs approach --- .../Rooms/Interior/InteriorVideoStreams.cs | 4 +- .../Connections/Rooms/Logs/LogVideoStreams.cs | 15 +- .../Rooms/Nulls/NullVideoStreams.cs | 4 +- .../Components/NearbyAudioSourceComponent.cs | 8 +- .../Systems/NearbyAudioBindingSystem.cs | 4 +- .../Systems/NearbyAudioCleanupSystem.cs | 70 ++++--- .../Systems/NearbyAudioPositionSystem.cs | 19 +- .../NearbyAudioBindingSystemShould.cs | 13 +- .../NearbyAudioCleanupSystemShould.cs | 181 ++++++++---------- .../NearbyAudioPositionSystemShould.cs | 14 +- ...arbyAudioPositionHotPathPerformanceTest.cs | 6 +- ...earbyAudioPositionSystemPerformanceTest.cs | 5 +- 12 files changed, 184 insertions(+), 159 deletions(-) diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Interior/InteriorVideoStreams.cs b/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Interior/InteriorVideoStreams.cs index 98ba7f7b81..76178c8eb0 100644 --- a/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Interior/InteriorVideoStreams.cs +++ b/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Interior/InteriorVideoStreams.cs @@ -6,7 +6,6 @@ using LiveKit.Rooms.Streaming.Audio; using LiveKit.Rooms.VideoStreaming; using RichTypes; -using System; using System.Collections.Generic; namespace DCL.Multiplayer.Connections.Rooms.Interior @@ -75,6 +74,9 @@ public void AssignRoom(Room room) assigned.AssignRoom(room); } + public int GetLastFrameReceivedAt(StreamKey streamKey) => + assigned.GetLastFrameReceivedAt(streamKey); + public void Assign(IAudioStreams value, out IAudioStreams? previous) { previous = assigned; diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Logs/LogVideoStreams.cs b/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Logs/LogVideoStreams.cs index ed8d566f71..fa02104196 100644 --- a/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Logs/LogVideoStreams.cs +++ b/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Logs/LogVideoStreams.cs @@ -4,7 +4,6 @@ using LiveKit.Rooms.Streaming.Audio; using LiveKit.Rooms.VideoStreaming; using RichTypes; -using System; using System.Collections.Generic; namespace DCL.Multiplayer.Connections.Rooms.Logs @@ -66,6 +65,18 @@ public LogVideoStreams(IStreams origin) : base(or public class LogAudioStreams : LogStreams, IAudioStreams { - public LogAudioStreams(IStreams origin) : base(origin, nameof(LogVideoStreams)) { } + private readonly IAudioStreams origin; + + public LogAudioStreams(IAudioStreams origin) : base(origin, nameof(LogAudioStreams)) + { + this.origin = origin; + } + + public int GetLastFrameReceivedAt(StreamKey streamKey) + { + int tick = origin.GetLastFrameReceivedAt(streamKey); + ReportHub.Log(ReportCategory.LIVEKIT, $"{nameof(LogAudioStreams)}: {nameof(GetLastFrameReceivedAt)}: {streamKey.identity}, {streamKey.sid} -> {tick};"); + return tick; + } } } diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Nulls/NullVideoStreams.cs b/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Nulls/NullVideoStreams.cs index 96d51aa56a..43ba3092b0 100644 --- a/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Nulls/NullVideoStreams.cs +++ b/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Nulls/NullVideoStreams.cs @@ -3,7 +3,6 @@ using LiveKit.Rooms.Streaming.Audio; using LiveKit.Rooms.VideoStreaming; using RichTypes; -using System; using System.Collections.Generic; namespace DCL.Multiplayer.Connections.Rooms.Nulls @@ -44,5 +43,8 @@ public class NullVideoStreams : NullStreams, IVid public class NullAudioStreams : NullStreams, IAudioStreams { public static readonly NullAudioStreams INSTANCE = new (); + + // -1 is the resolver's sentinel for "stream missing / never decoded a frame" + public int GetLastFrameReceivedAt(StreamKey streamKey) => -1; } } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Components/NearbyAudioSourceComponent.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Components/NearbyAudioSourceComponent.cs index c6ef1668b0..c8d9d1cf0f 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Components/NearbyAudioSourceComponent.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Components/NearbyAudioSourceComponent.cs @@ -1,17 +1,12 @@ -using Arch.Core; using LiveKit.Rooms.Streaming; using LiveKit.Rooms.Streaming.Audio; using UnityEngine; namespace DCL.VoiceChat { - /// - /// Lives on a dedicated audio-source entity. Bound 1:1 to a (participant, sid) pair. - /// public struct NearbyAudioSourceComponent { public readonly StreamKey Key; - public readonly Entity AvatarEntity; public readonly LivekitAudioSource LivekitAudioSource; public uint LastSeenMuteVersion; @@ -19,10 +14,9 @@ public struct NearbyAudioSourceComponent public bool LastInactive; public Vector3 LastWrittenPos; - public NearbyAudioSourceComponent(StreamKey key, Entity avatarEntity, LivekitAudioSource livekitAudioSource) + public NearbyAudioSourceComponent(StreamKey key, LivekitAudioSource livekitAudioSource) { Key = key; - AvatarEntity = avatarEntity; LivekitAudioSource = livekitAudioSource; LastSeenMuteVersion = 0; diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs index 6b03d2f294..d1d6c6bf15 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs @@ -61,7 +61,7 @@ protected override void Update(float t) } [Query] - [None(typeof(DeleteEntityIntention))] + [None(typeof(NearbyAudioSourceComponent), typeof(DeleteEntityIntention))] [All(typeof(AvatarBase), typeof(NearbyAudioStreamerComponent), typeof(InAudibleRangeTag))] private void CreateAndBindAudioSourcesToStreamers(Entity avatarEntity, in Profile profile, in NearbyAudioStreamerComponent nearby) { @@ -83,8 +83,8 @@ private void CreateAndBindAudioSourcesToStreamers(Entity avatarEntity, in Profil if (!stream.Resource.Has) return; LivekitAudioSource source = sourceFactory.Create(key, stream); + World.Add(avatarEntity, new NearbyAudioSourceComponent(key, source)); - World.Create(new NearbyAudioSourceComponent(key, avatarEntity, source)); bindings.Add(key); } } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs index 7c54adbeaf..3cd015a81f 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs @@ -14,22 +14,19 @@ namespace DCL.VoiceChat.Nearby.Systems { /// - /// Detection + teardown for Nearby audio-source entities. + /// Detection + teardown for Nearby audio-source components (co-located on the avatar entity). /// - /// Detection — per tick tags doomed audio entities with on any of: + /// Triggers: /// - /// Trigger #1 (avatar gone) — linked avatar entity is dead or flagged with . - /// Trigger #2 (sid not active) — registry's resolver no longer picks the bound (walletId, sid) (either the sid was evicted, or a fresher candidate won). - /// Trigger #3 (blocked) returns true for the bound walletId. - /// Trigger #4 (scene-banned) returns true for the bound walletId. - /// Trigger #5 (listening gate) — bulk removal when is in or ; + /// #1 streamer marker gone — avatar lost . + /// #2 out of range — avatar lost . + /// #3 sid not active — registry's resolver no longer picks the bound (walletId, sid). + /// #4 blocked returns true. + /// #5 scene-banned returns true. + /// #6 listening gate — bulk removal when state is / . + /// #7 avatar dying — avatar carries ; dispose source, component goes away with the entity. /// /// - /// - /// Teardown — reacts to entities now carrying : - /// - disposes the to the pool via (Stop → Free → SafeDestroyGameObject) - /// - removes the (walletId, sid) → entity binding. Physical entity destruction is delegated to . - /// /// [UpdateInGroup(typeof(CleanUpGroup))] [LogCategory(ReportCategory.NEARBY_VOICE_CHAT)] @@ -71,47 +68,62 @@ protected override void Update(float t) sourceFactory.InvalidateForDeviceChange(); } - // Listening-gate AND device-change are bulk archetype-moves; per-entity detection is skipped in either case. + // Listening-gate AND device-change are bulk component-removes; per-entity detection is skipped in either case. if (stateModel.IsListeningDisabled || deviceChanged) - World.Add(in LIVE_AUDIO_QUERY); + { + DisposeAllLiveSourcesQuery(World); + World.Remove(in LIVE_AUDIO_QUERY); + bindings.Clear(); + } else - FlagDoomedAudioEntitiesQuery(World); + CleanupDoomedSourceQuery(World); - TearDownMarkedAudioEntitiesQuery(World); + // Trigger #7: avatars marked for deletion still carry the component until the entity is destroyed + // elsewhere. Dispose the source; do NOT World.Remove (the avatar takes the component with it). + DisposeDyingAvatarSourcesQuery(World); } protected override void OnDispose() { - DisposeAllAudioSourcesQuery(World); + DisposeAllLiveSourcesQuery(World); + DisposeDyingAvatarSourcesQuery(World); bindings.Clear(); } + // !IsActiveSid covers two cases: (1) sid evicted entirely → resolver returns different sid or null; + // (2) sid demoted → resolver picked a fresher candidate. The ghost loser of the pick is reaped here + // so binding can spawn the new winner. [Query] [None(typeof(DeleteEntityIntention))] - private void FlagDoomedAudioEntities(Entity audioEntity, ref NearbyAudioSourceComponent comp) + private void CleanupDoomedSource(Entity entity, ref NearbyAudioSourceComponent comp) { - Entity avatar = comp.AvatarEntity; + bool doomed = !World.Has(entity) + || !World.Has(entity) + || !registry.IsActiveSid(comp.Key.identity, comp.Key.sid) + || userBlockingCache.UserIsBlocked(comp.Key.identity) + || roomMetadataCurrentScene.IsUserBanned(comp.Key.identity); + + if (!doomed) return; - // Component absence ≠ avatar gone. Component absence ≠ specific sid gone either. Both fallbacks must remain. - // !IsActiveSid covers two cases: (1) sid evicted entirely → resolver returns different sid or null; (2) sid demoted → - // resolver picked a fresher candidate. The ghost loser of the pick is reaped here so binding can spawn the new winner. - bool avatarGoneOrOutOfRange = !World.IsAlive(avatar) || World.Has(avatar) || !World.Has(avatar) || !World.Has(avatar); - if (avatarGoneOrOutOfRange || !registry.IsActiveSid(comp.Key.identity, comp.Key.sid) || userBlockingCache.UserIsBlocked(comp.Key.identity) || roomMetadataCurrentScene.IsUserBanned(comp.Key.identity)) - World.Add(audioEntity); + sourceFactory.Dispose(comp.LivekitAudioSource); + StreamKey key = comp.Key; + World.Remove(entity); + bindings.Remove(key); } [Query] - [All(typeof(DeleteEntityIntention))] - private void TearDownMarkedAudioEntities(ref NearbyAudioSourceComponent comp) + [None(typeof(DeleteEntityIntention))] + private void DisposeAllLiveSources(ref NearbyAudioSourceComponent comp) { sourceFactory.Dispose(comp.LivekitAudioSource); - bindings.Remove(comp.Key); } [Query] - private void DisposeAllAudioSources(ref NearbyAudioSourceComponent comp) + [All(typeof(DeleteEntityIntention))] + private void DisposeDyingAvatarSources(ref NearbyAudioSourceComponent comp) { sourceFactory.Dispose(comp.LivekitAudioSource); + bindings.Remove(comp.Key); } } } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioPositionSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioPositionSystem.cs index bd66bcc5a4..5b473f8dbd 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioPositionSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioPositionSystem.cs @@ -12,8 +12,9 @@ namespace DCL.VoiceChat.Nearby.Systems { /// - /// Reads position from the avatar entity referenced by - /// and drives the transform + spatial angles each frame. + /// Drives the transform + spatial angles each frame. + /// Reads from the same entity that carries the audio-source component + /// (co-located after the slice-4 collapse — no cross-entity hop). /// [UpdateInGroup(typeof(NearbyVoiceChatGroup))] [UpdateAfter(typeof(NearbyAudioBindingSystem))] @@ -36,16 +37,12 @@ protected override void Update(float t) } [Query] + [All(typeof(NearbyAudioStreamerComponent))] [None(typeof(DeleteEntityIntention))] - private void SyncPositionsAndSpatialAngles([Data] Transform listenerTransform, [Data] Vector3 playerHeadPos, ref NearbyAudioSourceComponent nearbyAudio) + private void SyncPositionsAndSpatialAngles([Data] Transform listenerTransform, [Data] Vector3 playerHeadPos, Entity entity, in AvatarBase avatarBase, ref NearbyAudioSourceComponent nearbyAudio) { - // Stale avatar entity reference — NearbyAudioCleanupSystem will tear this audio entity down in CleanUpGroup. - bool hasAvatar = World.TryGet(nearbyAudio.AvatarEntity, out AvatarBase? avatarBase); - if (!hasAvatar) return; - - // Per-frame idempotent inactive-state application — self-healing - Entity avatar = nearbyAudio.AvatarEntity; - bool inactive = !World.TryGet(avatar, out InAudibleRangeTag rangeTag) || rangeTag.IsSuspended; + // Per-frame idempotent inactive-state application — self-healing. + bool inactive = !World.TryGet(entity, out InAudibleRangeTag rangeTag) || rangeTag.IsSuspended; LivekitAudioSource src = nearbyAudio.LivekitAudioSource; @@ -61,7 +58,7 @@ private void SyncPositionsAndSpatialAngles([Data] Transform listenerTransform, [ if (inactive) return; // reprojection, so gain is calculated relative to the head and not the camera position (audioListener is on the camera) - Vector3 remoteAvatarHeadPos = avatarBase!.HeadAnchorPoint.position; + Vector3 remoteAvatarHeadPos = avatarBase.HeadAnchorPoint.position; Vector3 sourcePos = listenerTransform.position + (remoteAvatarHeadPos - playerHeadPos); if ((sourcePos - nearbyAudio.LastWrittenPos).sqrMagnitude > POSITION_EPSILON_SQR) diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs index 1aa815d960..32288863ad 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs @@ -33,7 +33,9 @@ namespace DCL.VoiceChat.Nearby.Tests /// public class NearbyAudioBindingSystemShould : UnitySystemTestBase { - private static readonly QueryDescription AUDIO_SOURCE_QUERY = new QueryDescription().WithAll(); + // Co-located after slice 4: NearbyAudioSourceComponent lives on the avatar entity itself, alongside + // AvatarBase. The "audio source count" question is therefore "how many avatars carry the component". + private static readonly QueryDescription AUDIO_SOURCE_QUERY = new QueryDescription().WithAll(); private static readonly FieldInfo HEAD_ANCHOR_FIELD = typeof(AvatarBase).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance)!; @@ -77,7 +79,7 @@ protected override void OnTearDown() } [Test] - public void SingleAvatarSingleStreamCreatesOneEntity() + public void SingleAvatarSingleStreamAddsComponentOnAvatar() { const string WALLET = "wallet-alice"; Entity avatarEntity = CreateStreamingAvatar(WALLET, "sid-1"); @@ -87,9 +89,12 @@ public void SingleAvatarSingleStreamCreatesOneEntity() Assert.That(CountAudioEntities(), Is.EqualTo(1)); - NearbyAudioSourceComponent comp = GetSingleAudioComponent(); + // Co-location: the component must live on the avatar entity itself (no separate audio entity). + Assert.That(world.Has(avatarEntity), Is.True, + "component must be added directly onto the avatar entity (slice-4 co-location)"); + + NearbyAudioSourceComponent comp = world.Get(avatarEntity); Assert.That(comp.Key, Is.EqualTo(new StreamKey(WALLET, "sid-1"))); - Assert.That(comp.AvatarEntity, Is.EqualTo(avatarEntity)); Assert.That(comp.LivekitAudioSource, Is.Not.Null); } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs index 5cf30d6018..d72ae7b38f 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs @@ -22,15 +22,16 @@ namespace DCL.VoiceChat.Nearby.Tests { /// - /// Documents contract: + /// Documents contract (slice 4 — co-located component): /// - /// - Detection marks doomed audio entities with ; physical destruction is delegated to - /// (deliberately not in this test rig). - /// - Pull-based detection: per tick, four triggers per entity — avatar gone, stream gone, identity blocked, listening gate. - /// - Teardown reacts to : disposes the - /// (Stop → Free → SafeDestroyGameObject) and removes the (walletId, sid) → entity binding. - /// - Tests assert the system's own contribution: the entity is marked + the source is disposed + the binding is removed. - /// They deliberately do NOT assert , since this system no longer owns entity destruction. + /// - Detection collects doomed avatars (lost streamer marker / out of range / sid demoted / blocked / + /// scene-banned), disposes their , then removes the + /// from the avatar entity (avatar itself stays alive). + /// - Listening-gate / device-change paths bulk-remove the component from every live entity. + /// - Avatars carrying get only their source disposed — the + /// component goes away with the entity when DestroyEntitiesSystem runs. + /// - Tests assert the system's own contribution: component removed (live triggers) / source disposed, + /// bindings index in sync. They do NOT assert avatar-entity destruction (out of scope). /// - disposes any survivors and clears bindings. /// public class NearbyAudioCleanupSystemShould : UnitySystemTestBase @@ -81,28 +82,26 @@ protected override void OnTearDown() EcsTestsUtils.TearDownFeaturesRegistry(); } - // ── Trigger #1: avatar gone ───────────────────────────────── + // ── Trigger #7: avatar dying ──────────────────────────────── + // Slice 4 collapsed the audio entity onto the avatar. The "hard-destroy the avatar" + // premise from the old test is no longer reachable in practice (avatar destruction + // is gated through DeleteEntityIntention → DestroyEntitiesSystem) — and the legacy + // separate-audio-entity test would have nothing to clean up either. The + // DeleteEntityIntention path below is the only meaningful "avatar gone" scenario now. [Test] - public void DeadAvatarEntityCausesCleanup() + public void AvatarWithDeleteEntityIntentionDisposesSource() { - (Entity audioEntity, Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); - world.Destroy(avatarEntity); - - system.Update(0); - - AssertCleanedUp(audioEntity, source, PARTICIPANT_A, SID_1); - } - - [Test] - public void AvatarWithDeleteEntityIntentionCausesCleanup() - { - (Entity audioEntity, Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); + (_, Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); world.Add(avatarEntity); system.Update(0); - AssertCleanedUp(audioEntity, source, PARTICIPANT_A, SID_1); + // Trigger #7 disposes the source and drops the bindings entry, but does NOT World.Remove the + // component — the dying avatar will take it down on physical destruction. + AssertSourceTornDown(source); + Assert.That(bindings.Contains(new StreamKey(PARTICIPANT_A, SID_1)), Is.False, + "bindings entry must be dropped when the avatar is on its way out"); } // ── Trigger #2: stream gone ───────────────────────────────── @@ -146,40 +145,42 @@ public void BlockedIdentityCausesCleanup() // ── Trigger #4: listening gate ────────────────────────────── [Test] - public void SuppressedStateTearsDownAllAudioEntities() + public void SuppressedStateTearsDownAllAudioSources() { const int COUNT = 3; - var seeded = new List<(Entity audioEntity, LivekitAudioSource source, string wallet)>(COUNT); + var seeded = new List<(Entity avatarEntity, LivekitAudioSource source, string wallet)>(COUNT); for (int i = 0; i < COUNT; i++) { - (Entity audioEntity, _, LivekitAudioSource source) = SeedBinding($"wallet-{i}", SID_1); - seeded.Add((audioEntity, source, $"wallet-{i}")); + (_, Entity avatarEntity, LivekitAudioSource source) = SeedBinding($"wallet-{i}", SID_1); + seeded.Add((avatarEntity, source, $"wallet-{i}")); } stateModel.Suppress(SuppressionReason.CALL); system.Update(0); - Assert.That(world.CountEntities(in LIVE_AUDIO_QUERY), Is.EqualTo(0), "all audio entities must be marked"); + Assert.That(world.CountEntities(in LIVE_AUDIO_QUERY), Is.EqualTo(0), "all audio components must be removed"); Assert.That(bindings, Is.Empty); - foreach ((Entity audioEntity, LivekitAudioSource source, _) in seeded) + foreach ((Entity avatarEntity, LivekitAudioSource source, _) in seeded) { - Assert.That(world.Has(audioEntity), Is.True); + Assert.That(world.Has(avatarEntity), Is.False, + "listening-gate bulk path removes the component from every live entity"); + Assert.That(world.IsAlive(avatarEntity), Is.True, "avatar itself must stay alive"); AssertSourceTornDown(source); } } [Test] - public void DisabledStateTearsDownAllAudioEntities() + public void DisabledStateTearsDownAllAudioSources() { const int COUNT = 3; - var seeded = new List<(Entity audioEntity, LivekitAudioSource source, string wallet)>(COUNT); + var seeded = new List<(Entity avatarEntity, LivekitAudioSource source, string wallet)>(COUNT); for (int i = 0; i < COUNT; i++) { - (Entity audioEntity, _, LivekitAudioSource source) = SeedBinding($"wallet-{i}", SID_1); - seeded.Add((audioEntity, source, $"wallet-{i}")); + (_, Entity avatarEntity, LivekitAudioSource source) = SeedBinding($"wallet-{i}", SID_1); + seeded.Add((avatarEntity, source, $"wallet-{i}")); } stateModel.Disable(); @@ -188,9 +189,10 @@ public void DisabledStateTearsDownAllAudioEntities() Assert.That(world.CountEntities(in LIVE_AUDIO_QUERY), Is.EqualTo(0)); Assert.That(bindings, Is.Empty); - foreach ((Entity audioEntity, LivekitAudioSource source, _) in seeded) + foreach ((Entity avatarEntity, LivekitAudioSource source, _) in seeded) { - Assert.That(world.Has(audioEntity), Is.True); + Assert.That(world.Has(avatarEntity), Is.False); + Assert.That(world.IsAlive(avatarEntity), Is.True); AssertSourceTornDown(source); } } @@ -200,25 +202,27 @@ public void DisabledStateTearsDownAllAudioEntities() [Test] public void BothTriggersPresentResultInSingleTeardown() { - (Entity audioEntity, Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); - world.Destroy(avatarEntity); + // Compound trigger — registry drops the identity AND user gets blocked in the same frame. + // Both clauses dock onto the same per-entity collect pass; the result is one teardown, not two. + (_, Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); registry.RemoveAll(PARTICIPANT_A); + userBlockingCache.UserIsBlocked(PARTICIPANT_A).Returns(true); Assert.DoesNotThrow(() => system.Update(0)); - AssertCleanedUp(audioEntity, source, PARTICIPANT_A, SID_1); + AssertCleanedUp(avatarEntity, source, PARTICIPANT_A, SID_1); } [Test] public void MassCleanupOnDisconnected() { const int COUNT = 10; - var seeded = new List<(Entity audioEntity, LivekitAudioSource source)>(COUNT); + var seeded = new List<(Entity avatarEntity, LivekitAudioSource source)>(COUNT); for (int i = 0; i < COUNT; i++) { - (Entity audioEntity, _, LivekitAudioSource source) = SeedBinding($"wallet-{i}", SID_1); - seeded.Add((audioEntity, source)); + (_, Entity avatarEntity, LivekitAudioSource source) = SeedBinding($"wallet-{i}", SID_1); + seeded.Add((avatarEntity, source)); } registry.ClearAll(); @@ -226,9 +230,9 @@ public void MassCleanupOnDisconnected() system.Update(0); Assert.That(world.CountEntities(in LIVE_AUDIO_QUERY), Is.EqualTo(0)); - foreach ((Entity audioEntity, LivekitAudioSource source) in seeded) + foreach ((Entity avatarEntity, LivekitAudioSource source) in seeded) { - Assert.That(world.Has(audioEntity), Is.True); + Assert.That(world.Has(avatarEntity), Is.False); AssertSourceTornDown(source); } } @@ -251,49 +255,27 @@ public void FlagsAudioEntityWhenAvatarLosesStreamingTag() } [Test] - public void KeepsAudioEntityWhenMarkerPresentAndRegistryAlive() + public void KeepsAudioComponentWhenMarkerPresentAndRegistryAlive() { // Steady state — marker on, registry has the sid, not blocked, avatar alive. - // The cleanup query must not flag this entity. This is the dominant per-frame path. - (Entity audioEntity, _, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); + // The cleanup query must not touch this entity. This is the dominant per-frame path. + (_, Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); system.Update(0); - Assert.That(world.Has(audioEntity), Is.False, - "healthy steady-state entity must not be flagged"); - Assert.That(world.IsAlive(audioEntity), Is.True); + Assert.That(world.Has(avatarEntity), Is.True, + "healthy steady-state entity must keep its audio-source component"); + Assert.That(world.IsAlive(avatarEntity), Is.True); Assert.That(source == null, Is.False, "LivekitAudioSource must remain alive"); Assert.That(bindings.Contains(new StreamKey(PARTICIPANT_A, SID_1)), Is.True); } - [Test] - public void FlagsLosingSidAudioEntityWhenResolverPicksSibling() - { - // Ghost-vs-winner — the registry still holds two candidate sids for the wallet (LiveKit - // dropped an unsubscribe), but the resolver now picks only one. Both audio entities exist - // as a hangover from before the flip (sid-1 was the previous pick, sid-2 just won). - // Per-sid doom arrives via !registry.IsActiveSid(comp.Key.identity, comp.Key.sid) — the - // demoted ghost sid is reaped, the winner survives, the marker stays (it tracks the wallet, - // not the sid). - const string SID_2 = "sid-2"; - (Entity audioEntity1, Entity avatarEntity, LivekitAudioSource source1) = SeedBinding(PARTICIPANT_A, SID_1); - - // Sid-2 entity coexists; both audio entities share the same avatar (and its marker). - var key2 = new StreamKey(PARTICIPANT_A, SID_2); - LivekitAudioSource source2 = CreateLivekitAudioSource(key2); - Entity audioEntity2 = world.Create(new NearbyAudioSourceComponent(key2, avatarEntity, source2)); - bindings.Add(key2); - - // Resolver flips to sid-2; sid-1 stays in the registry as a ghost. - registry.SetActiveSid(PARTICIPANT_A, SID_2); - - system.Update(0); - - AssertCleanedUp(audioEntity1, source1, PARTICIPANT_A, SID_1); - Assert.That(world.Has(audioEntity2), Is.False, - "winning sid must remain alive — only the demoted ghost is reaped"); - Assert.That(source2 == null, Is.False); - } + // FlagsLosingSidAudioEntityWhenResolverPicksSibling — DELETED. + // Premise required two NearbyAudioSourceComponent instances on the same avatar (one per sid). + // After slice-4 co-location, the component lives on the avatar entity and Arch allows at most one + // instance of a given component type per entity. The resolver-dedup contract collapsed multi-sid + // state into a single CurrentSid, and GhostSidLosingResolverPickCausesCleanup below covers the + // single-sid ghost-demotion case. [Test] public void GhostSidLosingResolverPickCausesCleanup() @@ -323,19 +305,21 @@ public void GhostSidLosingResolverPickCausesCleanup() } [Test] - public void FlagsAudioEntityWhenAvatarHasDeleteIntentionButMarkerRemains() + public void DisposesSourceWhenAvatarHasDeleteIntentionButMarkerRemains() { // F1-deliberate invariant: NearbyLivekitBridgeSystem.UpdateStreaming filters with // [None], so a doomed avatar keeps its marker until physical - // destruction. Cleanup must catch this via the World.Has(avatar) - // clause, not the marker-absence clause. - (Entity audioEntity, Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); + // destruction. The dying-avatar trigger #7 must dispose the source regardless of marker state; + // it does NOT World.Remove the audio-source component (the entity itself is on its way out). + (_, Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); world.Add(avatarEntity); // marker intentionally NOT removed — F1 contract system.Update(0); - AssertCleanedUp(audioEntity, source, PARTICIPANT_A, SID_1); + AssertSourceTornDown(source); + Assert.That(bindings.Contains(new StreamKey(PARTICIPANT_A, SID_1)), Is.False, + "bindings entry must be dropped for a dying avatar"); Assert.That(world.Has(avatarEntity), Is.True, "Bridge's [None] filter prevents component removal on a doomed avatar"); } @@ -397,13 +381,13 @@ public void DoesNotInvokeRegistryWhenAudibleRangeAbsentPathDooms() [Test] public void IdleTickWithNoTriggersDoesNotMutateWorld() { - (Entity audioEntity, _, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); + (_, Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); system.Update(0); system.Update(0); - Assert.That(world.IsAlive(audioEntity), Is.True); - Assert.That(world.Has(audioEntity), Is.False); + Assert.That(world.IsAlive(avatarEntity), Is.True); + Assert.That(world.Has(avatarEntity), Is.True); Assert.That(source == null, Is.False); Assert.That(bindings.Contains(new StreamKey(PARTICIPANT_A, SID_1)), Is.True); Assert.That(world.CountEntities(in LIVE_AUDIO_QUERY), Is.EqualTo(1)); @@ -434,10 +418,14 @@ public void DisposeDestroysAllRemainingSourcesAndClearsBindings() // ── Helpers ───────────────────────────────────────────────── - private void AssertCleanedUp(Entity audioEntity, LivekitAudioSource source, string walletId, string sid) + // Slice 4: cleanup contract for the LIVE doom path — component removed from the avatar entity, + // source torn down, binding dropped. The avatar entity itself stays alive (it's the avatar; it + // just no longer carries an audio-source pair). + private void AssertCleanedUp(Entity avatarEntity, LivekitAudioSource source, string walletId, string sid) { - Assert.That(world.IsAlive(audioEntity), Is.True, "entity destruction is delegated to DestroyEntitiesSystem and is out of scope here"); - Assert.That(world.Has(audioEntity), Is.True, "audio entity must be marked for deletion"); + Assert.That(world.IsAlive(avatarEntity), Is.True, "avatar entity destruction is out of scope here"); + Assert.That(world.Has(avatarEntity), Is.False, + "audio-source component must be removed from the avatar"); AssertSourceTornDown(source, "LivekitAudioSource must be torn down (destroyed in legacy path, parked inactive in pool path)"); Assert.That(bindings.Contains(new StreamKey(walletId, sid)), Is.False, "binding must be removed"); } @@ -454,25 +442,24 @@ private static void AssertSourceTornDown(LivekitAudioSource source, string? mess Assert.That(source.gameObject.activeSelf, Is.False, message ?? "pooled source must be inactive"); } + // Slice 4: audio-source component is co-located on the avatar. There is no separate audio entity — + // the seeded "audioEntity" returned here IS the avatar (kept named distinctly to minimise churn in + // call sites that bind both locally). private (Entity audioEntity, Entity avatarEntity, LivekitAudioSource source) SeedBinding(string walletId, string sid) { Entity avatarEntity = CreateAvatarEntity(walletId); - // After A5.2 the cleanup shortcut treats streaming-marker absence as a doom signal; - // A1 adds InAudibleRangeTag absence as a fourth shortcut clause. Realistic state for - // a live audio entity is both markers present — Bridge applied StreamingAudioComponent - // and AudibleRangeMarker applied InAudibleRangeTag before Binding spawned the entity. - // Pair all three in the seed so existing trigger tests exercise the intended fallbacks - // (!IsActiveSid / UserIsBlocked / lifecycle), not the marker-absence shortcuts by accident. + // Realistic state for a live audio component: streamer marker + audible range tag both present. + // Trigger tests that want the !IsActiveSid / UserIsBlocked / lifecycle fallbacks rely on this baseline. world.Add(avatarEntity, new NearbyAudioStreamerComponent(sid)); world.Add(avatarEntity); registry.Add(walletId, sid); var key = new StreamKey(walletId, sid); LivekitAudioSource source = CreateLivekitAudioSource(key); - Entity audioEntity = world.Create(new NearbyAudioSourceComponent(key, avatarEntity, source)); + world.Add(avatarEntity, new NearbyAudioSourceComponent(key, source)); bindings.Add(key); - return (audioEntity, avatarEntity, source); + return (avatarEntity, avatarEntity, source); } private Entity CreateAvatarEntity(string walletId) diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioPositionSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioPositionSystemShould.cs index 4a2da4c627..2a6c8420ab 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioPositionSystemShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioPositionSystemShould.cs @@ -20,11 +20,12 @@ namespace DCL.VoiceChat.Nearby.Tests /// /// Documents the Nearby Audio Position System behavior — strict read+drive, no lifecycle responsibility: /// - /// - Reads avatar position via . + /// - Reads avatar position from the SAME entity that carries + /// (co-located after slice 4 — no cross-entity hop). /// - Drives transform + spatial angles each frame. /// - Reprojects source position relative to the player head. /// - /// Structural changes for audio entities are owned exclusively by . + /// Structural changes for audio components are owned exclusively by . /// public class NearbyAudioPositionSystemShould : UnitySystemTestBase { @@ -429,14 +430,21 @@ private Entity CreateAvatarEntity(string walletId, Vector3 avatarPos, Vector3 he avatarBase, new CharacterTransform(avatarGo.transform)); + // Slice 4: PositionSystem's query is gated on NearbyAudioStreamerComponent (co-located on the + // avatar entity). Seed it so the per-frame contract tests below all hit the spatial pipeline. + world.Add(entity, new NearbyAudioStreamerComponent("sid-1")); world.Add(entity); return entity; } + // Slice 4: the audio-source component is co-located on the avatar entity. There is no separate + // audio entity anymore — this helper now just attaches the component to the existing avatar and + // returns the same Entity for tests that still bind a local to "audio entity". private Entity CreateAudioEntity(string identity, string sid, Entity avatarEntity, LivekitAudioSource source) { var key = new StreamKey(identity, sid); - return world.Create(new NearbyAudioSourceComponent(key, avatarEntity, source)); + world.Add(avatarEntity, new NearbyAudioSourceComponent(key, source)); + return avatarEntity; } private LivekitAudioSource CreateLivekitAudioSource() diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioPositionHotPathPerformanceTest.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioPositionHotPathPerformanceTest.cs index 8a78cc2d33..ddbe67b638 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioPositionHotPathPerformanceTest.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioPositionHotPathPerformanceTest.cs @@ -114,7 +114,11 @@ private void PopulateN(int n) gameObjects.Add(source.gameObject); audioSources[i] = source; - world.Create(new NearbyAudioSourceComponent(new StreamKey(id, "sid"), avatarEntity, source)); + // Slice 4: NearbyAudioSourceComponent is co-located on the avatar entity. PositionSystem's + // query is now [All(AvatarBase, NearbyAudioStreamerComponent)] [None(DeleteEntityIntention)], + // so add NearbyAudioStreamerComponent here too so the benchmark exercises the hot path. + world.Add(avatarEntity, new NearbyAudioStreamerComponent("sid")); + world.Add(avatarEntity, new NearbyAudioSourceComponent(new StreamKey(id, "sid"), source)); } } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioPositionSystemPerformanceTest.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioPositionSystemPerformanceTest.cs index 6cd8b27142..67966164ea 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioPositionSystemPerformanceTest.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioPositionSystemPerformanceTest.cs @@ -116,7 +116,10 @@ private void CreateBoundAudioSource(int index) LivekitAudioSource source = LivekitAudioSource.New(); gameObjects.Add(source.gameObject); - world.Create(new NearbyAudioSourceComponent(new StreamKey(id, "sid"), avatarEntity, source)); + // Slice 4: NearbyAudioSourceComponent is co-located on the avatar; position query is now + // gated by NearbyAudioStreamerComponent. Both must be added so the benchmark hits the hot path. + world.Add(avatarEntity, new NearbyAudioStreamerComponent("sid")); + world.Add(avatarEntity, new NearbyAudioSourceComponent(new StreamKey(id, "sid"), source)); } private GameObject CreateTrackedGameObject(string name) From 1b36f3e6b3629fbb446a764d79f03c0ebe260163 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Tue, 19 May 2026 13:50:16 +0200 Subject: [PATCH 04/12] refactor(NearbyVoiceChat ECS): split runtime queries by archetype filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) splits into SyncActiveAudio ([All]) and SyncInactiveOutOfRangeAudio ([None]). - 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] (trigger #1) - ReapOutOfRangeSource — [None] (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. --- .../Systems/NearbyAudioCleanupSystem.cs | 41 +++++++++++++----- .../Systems/NearbyAudioPositionSystem.cs | 43 +++++++++++++------ 2 files changed, 61 insertions(+), 23 deletions(-) diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs index 3cd015a81f..9bb141585d 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs @@ -76,7 +76,11 @@ protected override void Update(float t) bindings.Clear(); } else - CleanupDoomedSourceQuery(World); + { + ReapOrphanedSourceQuery(World); // #1: streamer marker gone + ReapOutOfRangeSourceQuery(World); // #2: audible-range tag gone + ReapFilteredSourceQuery(World); // #3 sid demoted / #4 blocked / #5 scene-banned + } // Trigger #7: avatars marked for deletion still carry the component until the entity is destroyed // elsewhere. Dispose the source; do NOT World.Remove (the avatar takes the component with it). @@ -90,21 +94,36 @@ protected override void OnDispose() bindings.Clear(); } - // !IsActiveSid covers two cases: (1) sid evicted entirely → resolver returns different sid or null; - // (2) sid demoted → resolver picked a fresher candidate. The ghost loser of the pick is reaped here - // so binding can spawn the new winner. + // Trigger #1: avatar lost NearbyAudioStreamerComponent — unconditional reap. + [Query] + [None(typeof(DeleteEntityIntention), typeof(NearbyAudioStreamerComponent))] + private void ReapOrphanedSource(Entity entity, ref NearbyAudioSourceComponent comp) => + DisposeAndRemove(entity, ref comp); + + // Trigger #2: avatar left audible range — unconditional reap. [Query] + [All(typeof(NearbyAudioStreamerComponent))] + [None(typeof(DeleteEntityIntention), typeof(InAudibleRangeTag))] + private void ReapOutOfRangeSource(Entity entity, ref NearbyAudioSourceComponent comp) => + DisposeAndRemove(entity, ref comp); + + // Triggers #3/#4/#5: !IsActiveSid covers sid evicted entirely (resolver picks different/null sid) + // AND sid demoted (resolver picked a fresher candidate — ghost loser reaped here so binding spawns the winner). + [Query] + [All(typeof(NearbyAudioStreamerComponent), typeof(InAudibleRangeTag))] [None(typeof(DeleteEntityIntention))] - private void CleanupDoomedSource(Entity entity, ref NearbyAudioSourceComponent comp) + private void ReapFilteredSource(Entity entity, ref NearbyAudioSourceComponent comp) { - bool doomed = !World.Has(entity) - || !World.Has(entity) - || !registry.IsActiveSid(comp.Key.identity, comp.Key.sid) - || userBlockingCache.UserIsBlocked(comp.Key.identity) - || roomMetadataCurrentScene.IsUserBanned(comp.Key.identity); + bool keep = registry.IsActiveSid(comp.Key.identity, comp.Key.sid) + && !userBlockingCache.UserIsBlocked(comp.Key.identity) + && !roomMetadataCurrentScene.IsUserBanned(comp.Key.identity); - if (!doomed) return; + if (!keep) + DisposeAndRemove(entity, ref comp); + } + private void DisposeAndRemove(Entity entity, ref NearbyAudioSourceComponent comp) + { sourceFactory.Dispose(comp.LivekitAudioSource); StreamKey key = comp.Key; World.Remove(entity); diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioPositionSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioPositionSystem.cs index 5b473f8dbd..6e0503840a 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioPositionSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioPositionSystem.cs @@ -6,7 +6,6 @@ using ECS.LifeCycle.Components; using LiveKit.Rooms.Streaming.Audio; using Unity.Mathematics; -using Unity.Profiling; using UnityEngine; namespace DCL.VoiceChat.Nearby.Systems @@ -33,30 +32,33 @@ internal NearbyAudioPositionSystem(World world, NearbyMuteService muteService, N protected override void Update(float t) { - SyncPositionsAndSpatialAnglesQuery(World, listenerState.ListenerTransform, listenerState.PlayerHeadPosition); + SyncActiveAudioQuery(World, listenerState.ListenerTransform, listenerState.PlayerHeadPosition); + SyncInactiveOutOfRangeAudioQuery(World); } + // In-range path: archetype filter guarantees InAudibleRangeTag is present — no World.TryGet needed. + // Suspended-but-in-range is the only sub-case where we early-out via StopIfPlaying. [Query] [All(typeof(NearbyAudioStreamerComponent))] [None(typeof(DeleteEntityIntention))] - private void SyncPositionsAndSpatialAngles([Data] Transform listenerTransform, [Data] Vector3 playerHeadPos, Entity entity, in AvatarBase avatarBase, ref NearbyAudioSourceComponent nearbyAudio) + private void SyncActiveAudio([Data] Transform listenerTransform, [Data] Vector3 playerHeadPos, in InAudibleRangeTag rangeTag, in AvatarBase avatarBase, ref NearbyAudioSourceComponent nearbyAudio) { - // Per-frame idempotent inactive-state application — self-healing. - bool inactive = !World.TryGet(entity, out InAudibleRangeTag rangeTag) || rangeTag.IsSuspended; + if (rangeTag.IsSuspended) + { + StopIfPlaying(ref nearbyAudio); + return; + } LivekitAudioSource src = nearbyAudio.LivekitAudioSource; - // Diff-write: Stop/Play is a state change on the AudioSource voice slot, not a topology change to the DSP graph + // Diff-write: Stop/Play is a state change on the AudioSource voice slot, not a topology change to the DSP graph. // Pessimistic init (LastInactive=false matches factory's enabled=true hand-off) forces the first-tick write when an avatar binds directly into the suspend band. - if (inactive != nearbyAudio.LastInactive) + if (nearbyAudio.LastInactive) { - if (inactive) src.AudioSource.Stop(); - else src.AudioSource.Play(); - nearbyAudio.LastInactive = inactive; + src.AudioSource.Play(); + nearbyAudio.LastInactive = false; } - if (inactive) return; - // reprojection, so gain is calculated relative to the head and not the camera position (audioListener is on the camera) Vector3 remoteAvatarHeadPos = avatarBase.HeadAnchorPoint.position; Vector3 sourcePos = listenerTransform.position + (remoteAvatarHeadPos - playerHeadPos); @@ -87,6 +89,23 @@ private void SyncPositionsAndSpatialAngles([Data] Transform listenerTransform, [ } } + // Out-of-range path: between AudibleRangeSystem removing the tag and CleanupSystem reaping the source, + [Query] + [All(typeof(NearbyAudioStreamerComponent))] + [None(typeof(InAudibleRangeTag), typeof(DeleteEntityIntention))] + private void SyncInactiveOutOfRangeAudio(ref NearbyAudioSourceComponent nearbyAudio) + { + StopIfPlaying(ref nearbyAudio); + } + + private static void StopIfPlaying(ref NearbyAudioSourceComponent nearbyAudio) + { + if (nearbyAudio.LastInactive) return; + + nearbyAudio.LivekitAudioSource.AudioSource.Stop(); + nearbyAudio.LastInactive = true; + } + private static (float azimuth, float elevation) CalculateSpatialAngles(Transform listenerTransform, Vector3 sourcePosition) { Vector3 local = listenerTransform.InverseTransformPoint(sourcePosition); From 9bf172e5ed565936884c1a239ce18c27c078743e Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Tue, 19 May 2026 17:50:59 +0200 Subject: [PATCH 05/12] =?UTF-8?q?refactor(NearbyVoiceChat):=20drop=20bindi?= =?UTF-8?q?ngs=20HashSet=20=E2=80=94=20component=20is=20the=20dedup=20orac?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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] 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(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). --- .../PluginSystem/Global/VoiceChatPlugin.cs | 9 ++---- .../Core/NearbyAudioStreamsRegistry.cs | 2 +- .../Systems/NearbyAudioBindingSystem.cs | 17 +++------- .../Systems/NearbyAudioCleanupSystem.cs | 11 +------ .../NearbyAudioBindingSystemShould.cs | 24 ++++---------- .../NearbyAudioCleanupSystemShould.cs | 31 +++++-------------- .../NearbyAudioFullCycleManualTest.cs | 11 +++---- .../NearbyAudioFullCyclePerformanceTest.cs | 13 +++----- 8 files changed, 32 insertions(+), 86 deletions(-) diff --git a/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs b/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs index a2580784aa..5f7bdc4570 100644 --- a/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs +++ b/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs @@ -18,11 +18,8 @@ using DCL.VoiceChat.Nearby; using DCL.VoiceChat.Nearby.Audio; using DCL.VoiceChat.Nearby.Systems; -using LiveKit.Rooms.Streaming; -using LiveKit.Rooms.Streaming.Audio; using LiveKit.Rooms; using System; -using System.Collections.Generic; using System.Threading; using DCL.UI; using DCL.Utilities; @@ -78,7 +75,6 @@ public class VoiceChatPlugin : IDCLGlobalPlugin private VoiceChatPanelPresenter? voiceChatPanelPresenter; private VoiceChatDebugContainer? voiceChatDebugContainer; private NearbyAudioStreamsRegistry? nearbyAudioStreamRegistry; - private HashSet? nearbyAudioBindings; private NearbyAudioSourceFactory? nearbyAudioSourceFactory; private NearbyVoiceChatSuppressor? nearbyVoiceChatSuppressor; private NearbyMicrophoneHandler? nearbyMicrophoneHandler; @@ -156,9 +152,9 @@ public void InjectToWorld(ref ArchSystemsWorldBuilder builder, NearbyLivekitBridgeSystem.InjectToWorld(ref builder, nearbyAudioStreamRegistry!); NearbyAudibleRangeSystem.InjectToWorld(ref builder, voiceChatConfiguration, listenerState); - NearbyAudioBindingSystem.InjectToWorld(ref builder, nearbyAudioStreamRegistry!, nearbyAudioBindings!, userBlockingCache, nearbyStateModel!, nearbyAudioSourceFactory!, RoomMetadataCurrentScene.Instance); + NearbyAudioBindingSystem.InjectToWorld(ref builder, nearbyAudioStreamRegistry!, userBlockingCache, nearbyStateModel!, nearbyAudioSourceFactory!, RoomMetadataCurrentScene.Instance); NearbyAudioPositionSystem.InjectToWorld(ref builder, nearbyMuteService!, listenerState); - NearbyAudioCleanupSystem.InjectToWorld(ref builder, nearbyAudioStreamRegistry!, nearbyAudioBindings!, userBlockingCache, nearbyStateModel!, nearbyAudioSourceFactory!, RoomMetadataCurrentScene.Instance); + NearbyAudioCleanupSystem.InjectToWorld(ref builder, nearbyAudioStreamRegistry!, userBlockingCache, nearbyStateModel!, nearbyAudioSourceFactory!, RoomMetadataCurrentScene.Instance); NearbyVoiceChatNametagSystem.InjectToWorld(ref builder, playerEntity, nearbyAudioStreamRegistry!, nearbyStateModel!, nearbyMuteService!); NearbyVoiceChatDebugSystem.InjectToWorld(ref builder, voiceChatConfiguration, debugContainer, roomHub.IslandRoom(), nearbyStateModel!, nearbyAudioStreamRegistry!, entityParticipantTable); @@ -221,7 +217,6 @@ public async UniTask InitializeAsync(Settings settings, CancellationToken ct) nearbyAudioStreamRegistry = new NearbyAudioStreamsRegistry(islandRoom); pluginScope.Add(nearbyAudioStreamRegistry); - nearbyAudioBindings = new HashSet(32); nearbyAudioSourceFactory = new NearbyAudioSourceFactory(voiceChatConfiguration); // State model is created in DynamicWorldContainer so analytics can subscribe to it. diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs index dbdae820b9..46b848a7d1 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs @@ -242,7 +242,7 @@ private static void AddAudioSidTo(DCLConcurrentDictionary dict // Publishes a NEW filtered array on every successful update; never mutates 'prev'. // Required by the immutability contract in the class XML: GetActiveSid iterates the // array assuming snapshot semantics — in-place edits would race the resolver mid-pick. - // Single-writer assumption (serial FFI dispatch) — no CAS retry needed. + // Single-writer assumption (serial FFI dispatch). private void RemoveAudioSid(string identity, string sid) { DCLConcurrentDictionary snap = DCLVolatile.Read(ref streamsByIdentity); diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs index d1d6c6bf15..9e9e276c88 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs @@ -11,35 +11,29 @@ using LiveKit.Rooms.Streaming; using LiveKit.Rooms.Streaming.Audio; using RichTypes; -using System.Collections.Generic; namespace DCL.VoiceChat.Nearby.Systems { /// /// Owns the creation part of the Nearby audio-source lifecycle. /// For every avatar entity ( + + + ) - /// the system materializes a single audio-source entity for the resolver-picked (walletId, CurrentSid) pair when one does not yet exist. - /// Throttled to a fixed budget per frame so a crowd ramp-up does not spike a single tick. + /// the system materializes the audio-source component for the resolver-picked (walletId, CurrentSid) pair when one does not yet exist. /// [UpdateInGroup(typeof(NearbyVoiceChatGroup))] [UpdateAfter(typeof(NearbyAudibleRangeSystem))] [UpdateBefore(typeof(NearbyAudioPositionSystem))] public partial class NearbyAudioBindingSystem : BaseUnityLoopSystem { - internal const int MAX_CREATIONS_PER_FRAME = 10; - private readonly INearbyAudioStreamRegistry registry; - private readonly HashSet bindings; private readonly IUserBlockingCache userBlockingCache; private readonly NearbyVoiceChatStateModel stateModel; private readonly INearbyAudioSourceFactory sourceFactory; private readonly RoomMetadataCurrentScene roomMetadataCurrentScene; - internal NearbyAudioBindingSystem(World world, INearbyAudioStreamRegistry registry, HashSet bindings, IUserBlockingCache userBlockingCache, NearbyVoiceChatStateModel stateModel, INearbyAudioSourceFactory sourceFactory, RoomMetadataCurrentScene roomMetadataCurrentScene) : base(world) + internal NearbyAudioBindingSystem(World world, INearbyAudioStreamRegistry registry, IUserBlockingCache userBlockingCache, NearbyVoiceChatStateModel stateModel, INearbyAudioSourceFactory sourceFactory, RoomMetadataCurrentScene roomMetadataCurrentScene) : base(world) { this.registry = registry; - this.bindings = bindings; this.userBlockingCache = userBlockingCache; this.stateModel = stateModel; this.sourceFactory = sourceFactory; @@ -73,10 +67,11 @@ private void CreateAndBindAudioSourcesToStreamers(Entity avatarEntity, in Profil // The resolver dedup contract guarantees one active sid per participant — bridge keeps CurrentSid // in sync with the registry's pick. Iterate it directly; no per-avatar registry call on the hot path. + // Dedup against duplicate creation lives in the [None] archetype filter — one + // source per avatar entity is the invariant, and the one-avatar-per-walletId invariant from the profile + // layer guarantees this also dedups by StreamKey. var key = new StreamKey(walletId, nearby.CurrentSid); - if (bindings.Contains(key)) return; - Weak stream = registry.GetActiveStream(key); // Track was unsubscribed between bridge tick and resolve (GetActiveStream); skip to avoid a one-frame ghost source. @@ -84,8 +79,6 @@ private void CreateAndBindAudioSourcesToStreamers(Entity avatarEntity, in Profil LivekitAudioSource source = sourceFactory.Create(key, stream); World.Add(avatarEntity, new NearbyAudioSourceComponent(key, source)); - - bindings.Add(key); } } } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs index 9bb141585d..d6d5812455 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs @@ -8,8 +8,6 @@ using ECS.Abstract; using ECS.Groups; using ECS.LifeCycle.Components; -using LiveKit.Rooms.Streaming; -using System.Collections.Generic; namespace DCL.VoiceChat.Nearby.Systems { @@ -36,7 +34,6 @@ public partial class NearbyAudioCleanupSystem : BaseUnityLoopSystem new QueryDescription().WithAll().WithNone(); private readonly INearbyAudioStreamRegistry registry; - private readonly HashSet bindings; private readonly IUserBlockingCache userBlockingCache; private readonly NearbyVoiceChatStateModel stateModel; private readonly INearbyAudioSourceFactory sourceFactory; @@ -45,10 +42,9 @@ public partial class NearbyAudioCleanupSystem : BaseUnityLoopSystem // Mismatch with registry.RebuildEpoch ⇒ output-device changed since last tick. private int lastSeenRebuildEpoch; - internal NearbyAudioCleanupSystem(World world, INearbyAudioStreamRegistry registry, HashSet bindings, IUserBlockingCache userBlockingCache, NearbyVoiceChatStateModel stateModel, INearbyAudioSourceFactory sourceFactory, RoomMetadataCurrentScene roomMetadataCurrentScene) : base(world) + internal NearbyAudioCleanupSystem(World world, INearbyAudioStreamRegistry registry, IUserBlockingCache userBlockingCache, NearbyVoiceChatStateModel stateModel, INearbyAudioSourceFactory sourceFactory, RoomMetadataCurrentScene roomMetadataCurrentScene) : base(world) { this.registry = registry; - this.bindings = bindings; this.userBlockingCache = userBlockingCache; this.stateModel = stateModel; this.sourceFactory = sourceFactory; @@ -73,7 +69,6 @@ protected override void Update(float t) { DisposeAllLiveSourcesQuery(World); World.Remove(in LIVE_AUDIO_QUERY); - bindings.Clear(); } else { @@ -91,7 +86,6 @@ protected override void OnDispose() { DisposeAllLiveSourcesQuery(World); DisposeDyingAvatarSourcesQuery(World); - bindings.Clear(); } // Trigger #1: avatar lost NearbyAudioStreamerComponent — unconditional reap. @@ -125,9 +119,7 @@ private void ReapFilteredSource(Entity entity, ref NearbyAudioSourceComponent co private void DisposeAndRemove(Entity entity, ref NearbyAudioSourceComponent comp) { sourceFactory.Dispose(comp.LivekitAudioSource); - StreamKey key = comp.Key; World.Remove(entity); - bindings.Remove(key); } [Query] @@ -142,7 +134,6 @@ private void DisposeAllLiveSources(ref NearbyAudioSourceComponent comp) private void DisposeDyingAvatarSources(ref NearbyAudioSourceComponent comp) { sourceFactory.Dispose(comp.LivekitAudioSource); - bindings.Remove(comp.Key); } } } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs index 32288863ad..cffda2338f 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs @@ -24,11 +24,9 @@ namespace DCL.VoiceChat.Nearby.Tests /// /// Documents contract: /// - /// - One audio-source entity per (walletId, sid) pair, created only when the avatar entity is fully ready - /// (Profile + AvatarBase + StreamingAudioComponent + InAudibleRangeTag, no DeleteEntityIntention). - /// - Throttled to per tick — large crowd ramp-ups - /// spread across multiple frames instead of spiking a single one. - /// - Idempotent: re-ticking with no registry changes does not duplicate bindings. + /// - One audio-source component per avatar entity, created only when the avatar is fully ready + /// (Profile + AvatarBase + NearbyAudioStreamerComponent + InAudibleRangeTag, no DeleteEntityIntention). + /// - Idempotent: re-ticking with no registry changes does not duplicate sources. /// - Hot path reads sids from the per-entity , not the registry. /// public class NearbyAudioBindingSystemShould : UnitySystemTestBase @@ -41,7 +39,6 @@ public class NearbyAudioBindingSystemShould : UnitySystemTestBasek__BackingField", BindingFlags.NonPublic | BindingFlags.Instance)!; private FakeStreamRegistry registry; - private HashSet bindings; private IUserBlockingCache userBlockingCache; private NearbyVoiceChatStateModel stateModel; @@ -55,12 +52,11 @@ public void SetUp() EcsTestsUtils.SetUpFeaturesRegistry(); registry = new FakeStreamRegistry(); - bindings = new HashSet(); userBlockingCache = Substitute.For(); stateModel = new NearbyVoiceChatStateModel(NearbyVoiceChatState.IDLE); sourceFactory = new FakeNearbyAudioSourceFactory(); - system = new NearbyAudioBindingSystem(world, registry, bindings, userBlockingCache, stateModel, sourceFactory, RoomMetadataCurrentScene.CreateForTest()); + system = new NearbyAudioBindingSystem(world, registry, userBlockingCache, stateModel, sourceFactory, RoomMetadataCurrentScene.CreateForTest()); } protected override void OnTearDown() @@ -72,7 +68,6 @@ protected override void OnTearDown() gameObjects.Clear(); - bindings.Clear(); stateModel.Dispose(); EcsTestsUtils.TearDownFeaturesRegistry(); @@ -154,8 +149,6 @@ public void BlockedIdentitySkipsCreation() Assert.That(CountAudioEntities(), Is.EqualTo(0), "blocked identity must not allocate an audio entity"); - Assert.That(bindings.Contains(new StreamKey(WALLET, SID)), Is.False, - "skipped creation must not poison the bindings index"); } [Test] @@ -248,8 +241,6 @@ public void RaceOnSpawnSkipsCreation() Assert.That(CountAudioEntities(), Is.EqualTo(0), "Weak.Null on resolve must not create an audio entity"); - Assert.That(bindings.Contains(new StreamKey(WALLET, SID)), Is.False, - "skipped creation must not poison the bindings index"); } // ── Archetype gate via StreamingAudioComponent / InAudibleRangeTag ─ @@ -269,7 +260,6 @@ public void DoesNotBindAvatarWithoutStreamingComponentEvenIfRegistryHasStream() Assert.That(CountAudioEntities(), Is.EqualTo(0), "absent StreamingAudioComponent must skip the avatar at archetype level"); - Assert.That(bindings.Contains(new StreamKey(WALLET, SID)), Is.False); } [Test] @@ -287,7 +277,7 @@ public void BindsAvatarWithStreamingComponentAndAudibleRangeTag() system.Update(0); Assert.That(CountAudioEntities(), Is.EqualTo(1)); - Assert.That(bindings.Contains(new StreamKey(WALLET, SID)), Is.True); + Assert.That(world.Has(avatarEntity), Is.True); } [Test] @@ -323,7 +313,6 @@ public void DoesNotBindAvatarWithoutAudibleRangeTagEvenWithStreamingComponent() Assert.That(CountAudioEntities(), Is.EqualTo(0), "absent InAudibleRangeTag must skip the avatar at archetype level"); - Assert.That(bindings.Contains(new StreamKey(WALLET, SID)), Is.False); } [Test] @@ -361,12 +350,11 @@ public void BindingReadsCurrentSidFromComponentWithoutQueryingRegistryResolver() mock.IsActiveSpeaker(Arg.Any()).ReturnsForAnyArgs(false); // Replace registry with the mock for the lifetime of this test. - var localBindings = new HashSet(); using var localStateModel = new NearbyVoiceChatStateModel(NearbyVoiceChatState.IDLE); var localFactory = new FakeNearbyAudioSourceFactory(); try { - var localSystem = new NearbyAudioBindingSystem(world, mock, localBindings, userBlockingCache, localStateModel, localFactory, RoomMetadataCurrentScene.CreateForTest()); + var localSystem = new NearbyAudioBindingSystem(world, mock, userBlockingCache, localStateModel, localFactory, RoomMetadataCurrentScene.CreateForTest()); const string WALLET = "wallet-alice"; const string SID = "sid-1"; diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs index d72ae7b38f..0af6e0c99d 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs @@ -30,9 +30,9 @@ namespace DCL.VoiceChat.Nearby.Tests /// - Listening-gate / device-change paths bulk-remove the component from every live entity. /// - Avatars carrying get only their source disposed — the /// component goes away with the entity when DestroyEntitiesSystem runs. - /// - Tests assert the system's own contribution: component removed (live triggers) / source disposed, - /// bindings index in sync. They do NOT assert avatar-entity destruction (out of scope). - /// - disposes any survivors and clears bindings. + /// - Tests assert the system's own contribution: component removed (live triggers) / source disposed. + /// They do NOT assert avatar-entity destruction (out of scope). + /// - disposes any survivors. /// public class NearbyAudioCleanupSystemShould : UnitySystemTestBase { @@ -46,7 +46,6 @@ public class NearbyAudioCleanupSystemShould : UnitySystemTestBasek__BackingField", BindingFlags.NonPublic | BindingFlags.Instance)!; private FakeStreamRegistry registry = null!; - private HashSet bindings = null!; private IUserBlockingCache userBlockingCache = null!; private NearbyVoiceChatStateModel stateModel = null!; private VoiceChatConfiguration configuration = null!; @@ -59,13 +58,12 @@ public void SetUp() EcsTestsUtils.SetUpFeaturesRegistry(); registry = new FakeStreamRegistry(); - bindings = new HashSet(); userBlockingCache = Substitute.For(); stateModel = new NearbyVoiceChatStateModel(NearbyVoiceChatState.IDLE); configuration = ScriptableObject.CreateInstance(); sourceFactory = new NearbyAudioSourceFactory(configuration); - system = new NearbyAudioCleanupSystem(world, registry, bindings, userBlockingCache, stateModel, sourceFactory, RoomMetadataCurrentScene.CreateForTest()); + system = new NearbyAudioCleanupSystem(world, registry, userBlockingCache, stateModel, sourceFactory, RoomMetadataCurrentScene.CreateForTest()); } protected override void OnTearDown() @@ -74,7 +72,6 @@ protected override void OnTearDown() if (go != null) Object.DestroyImmediate(go); gameObjects.Clear(); - bindings.Clear(); stateModel.Dispose(); if (configuration != null) Object.DestroyImmediate(configuration); @@ -97,11 +94,9 @@ public void AvatarWithDeleteEntityIntentionDisposesSource() system.Update(0); - // Trigger #7 disposes the source and drops the bindings entry, but does NOT World.Remove the - // component — the dying avatar will take it down on physical destruction. + // Trigger #7 disposes the source but does NOT World.Remove the component — the dying avatar + // will take it down on physical destruction. AssertSourceTornDown(source); - Assert.That(bindings.Contains(new StreamKey(PARTICIPANT_A, SID_1)), Is.False, - "bindings entry must be dropped when the avatar is on its way out"); } // ── Trigger #2: stream gone ───────────────────────────────── @@ -161,7 +156,6 @@ public void SuppressedStateTearsDownAllAudioSources() system.Update(0); Assert.That(world.CountEntities(in LIVE_AUDIO_QUERY), Is.EqualTo(0), "all audio components must be removed"); - Assert.That(bindings, Is.Empty); foreach ((Entity avatarEntity, LivekitAudioSource source, _) in seeded) { Assert.That(world.Has(avatarEntity), Is.False, @@ -188,7 +182,6 @@ public void DisabledStateTearsDownAllAudioSources() system.Update(0); Assert.That(world.CountEntities(in LIVE_AUDIO_QUERY), Is.EqualTo(0)); - Assert.That(bindings, Is.Empty); foreach ((Entity avatarEntity, LivekitAudioSource source, _) in seeded) { Assert.That(world.Has(avatarEntity), Is.False); @@ -267,7 +260,6 @@ public void KeepsAudioComponentWhenMarkerPresentAndRegistryAlive() "healthy steady-state entity must keep its audio-source component"); Assert.That(world.IsAlive(avatarEntity), Is.True); Assert.That(source == null, Is.False, "LivekitAudioSource must remain alive"); - Assert.That(bindings.Contains(new StreamKey(PARTICIPANT_A, SID_1)), Is.True); } // FlagsLosingSidAudioEntityWhenResolverPicksSibling — DELETED. @@ -318,8 +310,6 @@ public void DisposesSourceWhenAvatarHasDeleteIntentionButMarkerRemains() system.Update(0); AssertSourceTornDown(source); - Assert.That(bindings.Contains(new StreamKey(PARTICIPANT_A, SID_1)), Is.False, - "bindings entry must be dropped for a dying avatar"); Assert.That(world.Has(avatarEntity), Is.True, "Bridge's [None] filter prevents component removal on a doomed avatar"); } @@ -389,7 +379,6 @@ public void IdleTickWithNoTriggersDoesNotMutateWorld() Assert.That(world.IsAlive(avatarEntity), Is.True); Assert.That(world.Has(avatarEntity), Is.True); Assert.That(source == null, Is.False); - Assert.That(bindings.Contains(new StreamKey(PARTICIPANT_A, SID_1)), Is.True); Assert.That(world.CountEntities(in LIVE_AUDIO_QUERY), Is.EqualTo(1)); } @@ -412,22 +401,19 @@ public void DisposeDestroysAllRemainingSourcesAndClearsBindings() foreach (LivekitAudioSource source in sources) AssertSourceTornDown(source); - - Assert.That(bindings, Is.Empty); } // ── Helpers ───────────────────────────────────────────────── // Slice 4: cleanup contract for the LIVE doom path — component removed from the avatar entity, - // source torn down, binding dropped. The avatar entity itself stays alive (it's the avatar; it - // just no longer carries an audio-source pair). + // source torn down. The avatar entity itself stays alive (it's the avatar; it just no longer + // carries an audio-source pair). private void AssertCleanedUp(Entity avatarEntity, LivekitAudioSource source, string walletId, string sid) { Assert.That(world.IsAlive(avatarEntity), Is.True, "avatar entity destruction is out of scope here"); Assert.That(world.Has(avatarEntity), Is.False, "audio-source component must be removed from the avatar"); AssertSourceTornDown(source, "LivekitAudioSource must be torn down (destroyed in legacy path, parked inactive in pool path)"); - Assert.That(bindings.Contains(new StreamKey(walletId, sid)), Is.False, "binding must be removed"); } // A2 made source teardown reference-stable: the pool keeps the GO alive after Dispose. Both @@ -457,7 +443,6 @@ private static void AssertSourceTornDown(LivekitAudioSource source, string? mess var key = new StreamKey(walletId, sid); LivekitAudioSource source = CreateLivekitAudioSource(key); world.Add(avatarEntity, new NearbyAudioSourceComponent(key, source)); - bindings.Add(key); return (avatarEntity, avatarEntity, source); } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCycleManualTest.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCycleManualTest.cs index e4a442e2e1..1b0a133003 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCycleManualTest.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCycleManualTest.cs @@ -75,7 +75,6 @@ public class NearbyAudioFullCycleManualTest : MonoBehaviour // ── World/system state ────────────────────────────────────── private World world; private FakeStreamRegistry registry; - private HashSet bindings; private NearbyVoiceChatStateModel stateModel; private VoiceChatConfiguration configuration; private NearbyAudioSourceFactory sourceFactory; @@ -123,7 +122,6 @@ private void Awake() listenerState.BindListener(camera.transform, playerGo.transform); registry = new FakeStreamRegistry(); - bindings = new HashSet(); stateModel = new NearbyVoiceChatStateModel(NearbyVoiceChatState.IDLE); configuration = ScriptableObject.CreateInstance(); sourceFactory = new NearbyAudioSourceFactory(configuration); @@ -133,10 +131,10 @@ private void Awake() RoomMetadataCurrentScene roomMetadataCurrentScene = RoomMetadataCurrentScene.CreateForTest(); - bindingSystem = new NearbyAudioBindingSystem(world, registry, bindings, userBlockingCache, stateModel, sourceFactory, roomMetadataCurrentScene); + bindingSystem = new NearbyAudioBindingSystem(world, registry, userBlockingCache, stateModel, sourceFactory, roomMetadataCurrentScene); positionSystem = new NearbyAudioPositionSystem(world, muteService, listenerState); positionSystem.Initialize(); - cleanupSystem = new NearbyAudioCleanupSystem(world, registry, bindings, userBlockingCache, stateModel, sourceFactory, roomMetadataCurrentScene); + cleanupSystem = new NearbyAudioCleanupSystem(world, registry, userBlockingCache, stateModel, sourceFactory, roomMetadataCurrentScene); } private void Update() @@ -290,9 +288,8 @@ private void OnGUI() int markedCount = world.CountEntities(in DELETE_INTENTION_QUERY); GUI.Label(new Rect(10, 10, 480, 20), $"Avatars: {avatars.Count}/{targetAvatarCount}"); - GUI.Label(new Rect(10, 30, 480, 20), $"Bindings: {bindings.Count}"); - GUI.Label(new Rect(10, 50, 480, 20), $"Audio entities: {audioCount} (marked for delete: {markedCount})"); - GUI.Label(new Rect(10, 70, 480, 20), $"State: {stateModel.State.Value} (Inspector target: {forceState})"); + GUI.Label(new Rect(10, 30, 480, 20), $"Audio entities: {audioCount} (marked for delete: {markedCount})"); + GUI.Label(new Rect(10, 50, 480, 20), $"State: {stateModel.State.Value} (Inspector target: {forceState})"); } // ── Inspector context-menu actions ────────────────────────── diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCyclePerformanceTest.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCyclePerformanceTest.cs index ac2892c887..be977e341e 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCyclePerformanceTest.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCyclePerformanceTest.cs @@ -46,7 +46,6 @@ public class NearbyAudioFullCyclePerformanceTest : UnitySystemTestBase gameObjects = new (256); private FakeStreamRegistry registry; - private HashSet bindings; private NearbyVoiceChatStateModel stateModel; private VoiceChatConfiguration configuration; private NearbyAudioSourceFactory sourceFactory; @@ -69,7 +68,6 @@ public void SetUp() world.Create(new PlayerComponent(playerGo.transform)); registry = new FakeStreamRegistry(); - bindings = new HashSet(); IUserBlockingCache userBlockingCache = Substitute.For(); stateModel = new NearbyVoiceChatStateModel(NearbyVoiceChatState.IDLE); configuration = ScriptableObject.CreateInstance(); @@ -86,9 +84,9 @@ public void SetUp() RoomMetadataCurrentScene roomMetadataCurrentScene = RoomMetadataCurrentScene.CreateForTest(); - system = new NearbyAudioBindingSystem(world, registry, bindings, userBlockingCache, stateModel, sourceFactory, roomMetadataCurrentScene); + system = new NearbyAudioBindingSystem(world, registry, userBlockingCache, stateModel, sourceFactory, roomMetadataCurrentScene); positionSystem = new NearbyAudioPositionSystem(world, muteService, listenerState); - cleanupSystem = new NearbyAudioCleanupSystem(world, registry, bindings, userBlockingCache, stateModel, sourceFactory, roomMetadataCurrentScene); + cleanupSystem = new NearbyAudioCleanupSystem(world, registry, userBlockingCache, stateModel, sourceFactory, roomMetadataCurrentScene); markerSystem = new NearbyLivekitBridgeSystem(world, registry); audibleRangeSystem = new NearbyAudibleRangeSystem(world, configuration, listenerState); audibleRangeSystem.Initialize(); @@ -124,7 +122,6 @@ protected override void OnTearDown() if (go != null) Object.DestroyImmediate(go); gameObjects.Clear(); - bindings.Clear(); stateModel.Dispose(); if (configuration != null) Object.DestroyImmediate(configuration); @@ -496,9 +493,9 @@ private void PopulatePerfWorldMixed(int totalParticipants, int streamingParticip } } - private static int ComputeRampUpTicks(int participantCount) => - ((participantCount + NearbyAudioBindingSystem.MAX_CREATIONS_PER_FRAME - 1) - / NearbyAudioBindingSystem.MAX_CREATIONS_PER_FRAME) + 1; + // Binding now materializes every ready avatar in a single tick (no per-tick budget); two ticks are + // enough for the marker + binding + position chain to reach steady state regardless of participant count. + private static int ComputeRampUpTicks(int participantCount) => 2; private Entity CreateAvatarEntity(string walletId) { From 2a3308a48966a5757bf11896f3fafa2b3a5fd873 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Wed, 20 May 2026 12:44:12 +0200 Subject: [PATCH 06/12] refactor(NearbyVoiceChat): inline bulk remove into cleanup query, pass StreamKey to IsActiveSid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NearbyAudioCleanupSystem: fold World.Remove 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. --- .../Core/INearbyAudioStreamRegistry.cs | 4 +- .../Core/NearbyAudioStreamsRegistry.cs | 10 ++-- .../Systems/NearbyAudioBindingSystem.cs | 20 +++---- .../Systems/NearbyAudioCleanupSystem.cs | 35 +++++------- .../Systems/NearbyAudioPositionSystem.cs | 54 +++++++++---------- .../Systems/NearbyLivekitBridgeSystem.cs | 15 +----- .../NearbyAudioBindingSystemShould.cs | 6 +-- .../NearbyAudioCleanupSystemShould.cs | 6 +-- .../NearbyAudioStreamRegistryShould.cs | 8 +-- .../NearbyAudioFullCycleManualTest.cs | 4 +- .../NearbyAudioFullCyclePerformanceTest.cs | 4 +- 11 files changed, 69 insertions(+), 97 deletions(-) diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs index 1fe6199af2..60414996cf 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs @@ -32,11 +32,11 @@ public interface INearbyAudioStreamRegistry : IDisposable string? GetActiveSid(string walletId); /// - /// true when is the resolver's current pick for . + /// true when .sid is the resolver's current pick for .identity. /// Cleanup uses this in place of "sid disappeared from snapshot" — it also reaps demoted ghost sids that /// still exist in the registry but lost to a fresher candidate. /// - bool IsActiveSid(string walletId, string sid); + bool IsActiveSid(StreamKey key); /// /// Returns true if was present in the latest diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs index 46b848a7d1..9b5c061644 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs @@ -137,11 +137,9 @@ public Weak GetActiveStream(StreamKey key) => { int t = room.AudioStreams.GetLastFrameReceivedAt(new StreamKey(walletId, sid)); - // -1 sentinel: AudioStream missing or never decoded a frame. Ghosts and not-yet-live tracks both - // land here; we cannot let them win against a candidate that has provably emitted audio. + // -1 sentinel: AudioStream missing or never decoded a frame. if (t == -1) continue; - // unchecked(t - bestTick) > 0 is wrap-safe across the ~49-day Environment.TickCount window. if (bestSid is null || unchecked(t - bestTick) > 0) { bestTick = t; @@ -152,10 +150,10 @@ public Weak GetActiveStream(StreamKey key) => return bestSid; } - public bool IsActiveSid(string walletId, string sid) + public bool IsActiveSid(StreamKey key) { - string? active = GetActiveSid(walletId); - return active is not null && string.Equals(active, sid, StringComparison.Ordinal); + string? active = GetActiveSid(key.identity); + return active is not null && string.Equals(active, key.sid, StringComparison.Ordinal); } private void OnConnectionUpdated(IRoom _, ConnectionUpdate update, LKDisconnectReason? __) diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs index 9e9e276c88..fff67e3caf 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioBindingSystem.cs @@ -16,7 +16,7 @@ namespace DCL.VoiceChat.Nearby.Systems { /// /// Owns the creation part of the Nearby audio-source lifecycle. - /// For every avatar entity ( + + + ) + /// Creates audio source component for every avatar streamer in audible range /// the system materializes the audio-source component for the resolver-picked (walletId, CurrentSid) pair when one does not yet exist. /// [UpdateInGroup(typeof(NearbyVoiceChatGroup))] @@ -56,7 +56,7 @@ protected override void Update(float t) [Query] [None(typeof(NearbyAudioSourceComponent), typeof(DeleteEntityIntention))] - [All(typeof(AvatarBase), typeof(NearbyAudioStreamerComponent), typeof(InAudibleRangeTag))] + [All(typeof(AvatarBase), typeof(InAudibleRangeTag))] private void CreateAndBindAudioSourcesToStreamers(Entity avatarEntity, in Profile profile, in NearbyAudioStreamerComponent nearby) { string walletId = profile.UserId; @@ -65,20 +65,14 @@ private void CreateAndBindAudioSourcesToStreamers(Entity avatarEntity, in Profil // Skip blocked / scene-banned identities. Cleanup system handles already-bound entities; this filter prevents creation in the first place. if (userBlockingCache.UserIsBlocked(walletId) || roomMetadataCurrentScene.IsUserBanned(walletId)) return; - // The resolver dedup contract guarantees one active sid per participant — bridge keeps CurrentSid - // in sync with the registry's pick. Iterate it directly; no per-avatar registry call on the hot path. - // Dedup against duplicate creation lives in the [None] archetype filter — one - // source per avatar entity is the invariant, and the one-avatar-per-walletId invariant from the profile - // layer guarantees this also dedups by StreamKey. var key = new StreamKey(walletId, nearby.CurrentSid); - Weak stream = registry.GetActiveStream(key); - // Track was unsubscribed between bridge tick and resolve (GetActiveStream); skip to avoid a one-frame ghost source. - if (!stream.Resource.Has) return; - - LivekitAudioSource source = sourceFactory.Create(key, stream); - World.Add(avatarEntity, new NearbyAudioSourceComponent(key, source)); + if (stream.Resource.Has) + { + LivekitAudioSource source = sourceFactory.Create(key, stream); + World.Add(avatarEntity, new NearbyAudioSourceComponent(key, source)); + } } } } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs index d6d5812455..a6ab0ee4e3 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs @@ -30,9 +30,6 @@ namespace DCL.VoiceChat.Nearby.Systems [LogCategory(ReportCategory.NEARBY_VOICE_CHAT)] public partial class NearbyAudioCleanupSystem : BaseUnityLoopSystem { - private static readonly QueryDescription LIVE_AUDIO_QUERY = - new QueryDescription().WithAll().WithNone(); - private readonly INearbyAudioStreamRegistry registry; private readonly IUserBlockingCache userBlockingCache; private readonly NearbyVoiceChatStateModel stateModel; @@ -64,22 +61,17 @@ protected override void Update(float t) sourceFactory.InvalidateForDeviceChange(); } - // Listening-gate AND device-change are bulk component-removes; per-entity detection is skipped in either case. + // Listening-gate AND device-change wipe every live source; per-entity detection is skipped in either case. if (stateModel.IsListeningDisabled || deviceChanged) - { DisposeAllLiveSourcesQuery(World); - World.Remove(in LIVE_AUDIO_QUERY); - } else { ReapOrphanedSourceQuery(World); // #1: streamer marker gone ReapOutOfRangeSourceQuery(World); // #2: audible-range tag gone - ReapFilteredSourceQuery(World); // #3 sid demoted / #4 blocked / #5 scene-banned + ReapFilteredSourceQuery(World); // #3-4-5 sid demoted / user blocked or banned by the scene } - // Trigger #7: avatars marked for deletion still carry the component until the entity is destroyed - // elsewhere. Dispose the source; do NOT World.Remove (the avatar takes the component with it). - DisposeDyingAvatarSourcesQuery(World); + DisposeDyingAvatarSourcesQuery(World); // #7: avatars marked for deletion } protected override void OnDispose() @@ -88,31 +80,32 @@ protected override void OnDispose() DisposeDyingAvatarSourcesQuery(World); } - // Trigger #1: avatar lost NearbyAudioStreamerComponent — unconditional reap. + // #1: avatar is not a streamer anymore (lost NearbyAudioStreamerComponent). [Query] [None(typeof(DeleteEntityIntention), typeof(NearbyAudioStreamerComponent))] private void ReapOrphanedSource(Entity entity, ref NearbyAudioSourceComponent comp) => DisposeAndRemove(entity, ref comp); - // Trigger #2: avatar left audible range — unconditional reap. + // #2: avatar left audible range. [Query] [All(typeof(NearbyAudioStreamerComponent))] [None(typeof(DeleteEntityIntention), typeof(InAudibleRangeTag))] private void ReapOutOfRangeSource(Entity entity, ref NearbyAudioSourceComponent comp) => DisposeAndRemove(entity, ref comp); - // Triggers #3/#4/#5: !IsActiveSid covers sid evicted entirely (resolver picks different/null sid) + // #3/#4/#5: + // !IsActiveSid covers sid evicted entirely (resolver picks different/null sid) // AND sid demoted (resolver picked a fresher candidate — ghost loser reaped here so binding spawns the winner). [Query] [All(typeof(NearbyAudioStreamerComponent), typeof(InAudibleRangeTag))] [None(typeof(DeleteEntityIntention))] private void ReapFilteredSource(Entity entity, ref NearbyAudioSourceComponent comp) { - bool keep = registry.IsActiveSid(comp.Key.identity, comp.Key.sid) - && !userBlockingCache.UserIsBlocked(comp.Key.identity) - && !roomMetadataCurrentScene.IsUserBanned(comp.Key.identity); + bool remove = userBlockingCache.UserIsBlocked(comp.Key.identity) + || roomMetadataCurrentScene.IsUserBanned(comp.Key.identity) + || !registry.IsActiveSid(comp.Key); - if (!keep) + if (remove) DisposeAndRemove(entity, ref comp); } @@ -124,10 +117,8 @@ private void DisposeAndRemove(Entity entity, ref NearbyAudioSourceComponent comp [Query] [None(typeof(DeleteEntityIntention))] - private void DisposeAllLiveSources(ref NearbyAudioSourceComponent comp) - { - sourceFactory.Dispose(comp.LivekitAudioSource); - } + private void DisposeAllLiveSources(Entity entity, ref NearbyAudioSourceComponent comp) => + DisposeAndRemove(entity, ref comp); [Query] [All(typeof(DeleteEntityIntention))] diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioPositionSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioPositionSystem.cs index 6e0503840a..ad40d116e0 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioPositionSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioPositionSystem.cs @@ -12,8 +12,6 @@ namespace DCL.VoiceChat.Nearby.Systems { /// /// Drives the transform + spatial angles each frame. - /// Reads from the same entity that carries the audio-source component - /// (co-located after the slice-4 collapse — no cross-entity hop). /// [UpdateInGroup(typeof(NearbyVoiceChatGroup))] [UpdateAfter(typeof(NearbyAudioBindingSystem))] @@ -32,16 +30,22 @@ internal NearbyAudioPositionSystem(World world, NearbyMuteService muteService, N protected override void Update(float t) { - SyncActiveAudioQuery(World, listenerState.ListenerTransform, listenerState.PlayerHeadPosition); - SyncInactiveOutOfRangeAudioQuery(World); + StopPlayingOutOfRangeSourcesQuery(World); + SpatializeActiveSourceQuery(World, listenerState.ListenerTransform, listenerState.PlayerHeadPosition); + } + + [Query] + [All(typeof(NearbyAudioStreamerComponent))] + [None(typeof(InAudibleRangeTag), typeof(DeleteEntityIntention))] + private void StopPlayingOutOfRangeSources(ref NearbyAudioSourceComponent nearbyAudio) + { + StopIfPlaying(ref nearbyAudio); } - // In-range path: archetype filter guarantees InAudibleRangeTag is present — no World.TryGet needed. - // Suspended-but-in-range is the only sub-case where we early-out via StopIfPlaying. [Query] [All(typeof(NearbyAudioStreamerComponent))] [None(typeof(DeleteEntityIntention))] - private void SyncActiveAudio([Data] Transform listenerTransform, [Data] Vector3 playerHeadPos, in InAudibleRangeTag rangeTag, in AvatarBase avatarBase, ref NearbyAudioSourceComponent nearbyAudio) + private void SpatializeActiveSource([Data] Transform listenerTransform, [Data] Vector3 playerHeadPos, in InAudibleRangeTag rangeTag, in AvatarBase avatarBase, ref NearbyAudioSourceComponent nearbyAudio) { if (rangeTag.IsSuspended) { @@ -76,26 +80,7 @@ private void SyncActiveAudio([Data] Transform listenerTransform, [Data] Vector3 src.SetSpatialAngles(azimuth, elevation); } - uint cacheVersion = muteService.CacheVersion; - if (nearbyAudio.LastSeenMuteVersion != cacheVersion) - { - bool muted = muteService.IsMuted(nearbyAudio.Key.identity); - if (muted != nearbyAudio.LastAppliedMute) - { - src.AudioSource.mute = muted; - nearbyAudio.LastAppliedMute = muted; - } - nearbyAudio.LastSeenMuteVersion = cacheVersion; - } - } - - // Out-of-range path: between AudibleRangeSystem removing the tag and CleanupSystem reaping the source, - [Query] - [All(typeof(NearbyAudioStreamerComponent))] - [None(typeof(InAudibleRangeTag), typeof(DeleteEntityIntention))] - private void SyncInactiveOutOfRangeAudio(ref NearbyAudioSourceComponent nearbyAudio) - { - StopIfPlaying(ref nearbyAudio); + UpdateMute(ref nearbyAudio, src); } private static void StopIfPlaying(ref NearbyAudioSourceComponent nearbyAudio) @@ -117,5 +102,20 @@ private static (float azimuth, float elevation) CalculateSpatialAngles(Transform return (azimuth, elevation); } + + private void UpdateMute(ref NearbyAudioSourceComponent nearbyAudio, LivekitAudioSource src) + { + uint cacheVersion = muteService.CacheVersion; + if (nearbyAudio.LastSeenMuteVersion != cacheVersion) + { + bool muted = muteService.IsMuted(nearbyAudio.Key.identity); + if (muted != nearbyAudio.LastAppliedMute) + { + src.AudioSource.mute = muted; + nearbyAudio.LastAppliedMute = muted; + } + nearbyAudio.LastSeenMuteVersion = cacheVersion; + } + } } } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyLivekitBridgeSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyLivekitBridgeSystem.cs index 448cd6e8ee..1b437cda1c 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyLivekitBridgeSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyLivekitBridgeSystem.cs @@ -50,8 +50,6 @@ private void AddStreaming(Entity entity, in Profile profile) string walletId = profile.UserId; if (string.IsNullOrEmpty(walletId)) return; - // Resolver null = either no sids (case a → no-op here, RemoveStreaming has no component to drop) - // or all-zeros window (case b → wait for next tick). Either way, do nothing on the Add path. string? activeSid = registry.GetActiveSid(walletId); if (activeSid != null) World.Add(entity, new NearbyAudioStreamerComponent(activeSid)); @@ -66,12 +64,10 @@ private void RefreshStreaming(in Profile profile, ref NearbyAudioStreamerCompone if (string.IsNullOrEmpty(userId)) return; // Null means: registry has no sids (RemoveStreaming handles), or all-zeros window (wait). - // Either way, do not touch CurrentSid here. string? activeSid = registry.GetActiveSid(userId); if (activeSid == null) return; - // Flip path — resolver picked a different sid since we last observed (winner was demoted by a fresher candidate, - // or the previous sid was unsubscribed and a new one is active). Cleanup reaps the old (walletId, oldSid) entity. + // Cleanup reaps the old (walletId, oldSid) entity. if (!string.Equals(nearby.CurrentSid, activeSid, System.StringComparison.Ordinal)) nearby.CurrentSid = activeSid; } @@ -82,14 +78,7 @@ private void RefreshStreaming(in Profile profile, ref NearbyAudioStreamerCompone private void RemoveStreaming(Entity entity, in Profile profile) { string userId = profile.UserId; - if (string.IsNullOrEmpty(userId)) return; - - // Critical guard: resolver returning null is ambiguous. - // a) HasAudioStream == false → identity has zero sids → drop. - // b) HasAudioStream == true → all-zeros window (>=1 sid registered, none have emitted a frame yet) → wait, do not drop. - // RefreshStreaming already kept CurrentSid in sync with whichever sid is currently picked, - // so the only remaining job of RemoveStreaming is to detect case (a). - if (registry.HasAudioStream(userId)) return; + if (string.IsNullOrEmpty(userId) || registry.HasAudioStream(userId)) return; // Drop NearbyAudioStreamerComponent and every dependent marker so invariants (speaking ⊆ streaming, audible ⊆ streaming) hold. World.Remove(entity); diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs index cffda2338f..f4b612feb5 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs @@ -346,7 +346,7 @@ public void BindingReadsCurrentSidFromComponentWithoutQueryingRegistryResolver() mock.HasAudioStream(Arg.Any()).ReturnsForAnyArgs(false); mock.GetActiveStream(Arg.Any()).ReturnsForAnyArgs(Weak.Null); mock.GetActiveSid(Arg.Any()).ReturnsForAnyArgs((string?)null); - mock.IsActiveSid(Arg.Any(), Arg.Any()).ReturnsForAnyArgs(false); + mock.IsActiveSid(Arg.Any()).ReturnsForAnyArgs(false); mock.IsActiveSpeaker(Arg.Any()).ReturnsForAnyArgs(false); // Replace registry with the mock for the lifetime of this test. @@ -367,7 +367,7 @@ public void BindingReadsCurrentSidFromComponentWithoutQueryingRegistryResolver() mock.DidNotReceive().GetActiveSid(Arg.Any()); mock.DidNotReceive().HasAudioStream(Arg.Any()); - mock.DidNotReceive().IsActiveSid(Arg.Any(), Arg.Any()); + mock.DidNotReceive().IsActiveSid(Arg.Any()); } finally { @@ -460,7 +460,7 @@ public Weak GetActiveStream(StreamKey key) public string? GetActiveSid(string walletId) => null; - public bool IsActiveSid(string walletId, string sid) => false; + public bool IsActiveSid(StreamKey key) => false; public bool IsActiveSpeaker(string walletId) => false; diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs index 0af6e0c99d..b43ee087ad 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs @@ -286,7 +286,7 @@ public void GhostSidLosingResolverPickCausesCleanup() // Sanity-check the precondition the test depends on. Assert.That(registry.HasAudioStream(PARTICIPANT_A), Is.True, "precondition: identity still indexed (only the active pick changed)"); - Assert.That(registry.IsActiveSid(PARTICIPANT_A, SID_1), Is.False, + Assert.That(registry.IsActiveSid(new StreamKey(PARTICIPANT_A, SID_1)), Is.False, "precondition: bound sid is no longer the active one"); // The Asserts above bumped IsActiveSidCallCount via the precondition; reset before the system tick. registry.ResetCallCounters(); @@ -545,10 +545,10 @@ public Weak GetActiveStream(StreamKey key) => public string? GetActiveSid(string walletId) => activeSidByIdentity.TryGetValue(walletId, out string? sid) ? sid : null; - public bool IsActiveSid(string walletId, string sid) + public bool IsActiveSid(StreamKey key) { IsActiveSidCallCount++; - return activeSidByIdentity.TryGetValue(walletId, out string? active) && active == sid; + return activeSidByIdentity.TryGetValue(key.identity, out string? active) && active == key.sid; } public bool IsActiveSpeaker(string walletId) => false; diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs index b8398ce90a..fd272049ab 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs @@ -499,14 +499,14 @@ public void IsActiveSidTrueForWinnerFalseForGhostLoser() audioStreams.GetLastFrameReceivedAt(new StreamKey(WALLET_A, SID_2)).Returns(500); // SID_1 keeps -1 (ghost). - Assert.That(registry.IsActiveSid(WALLET_A, SID_2), Is.True); - Assert.That(registry.IsActiveSid(WALLET_A, SID_1), Is.False); + Assert.That(registry.IsActiveSid(new StreamKey(WALLET_A, SID_2)), Is.True); + Assert.That(registry.IsActiveSid(new StreamKey(WALLET_A, SID_1)), Is.False); } [Test] public void IsActiveSidFalseWhenWalletHasNoSids() { - Assert.That(registry.IsActiveSid("0xUNKNOWN", SID_1), Is.False); + Assert.That(registry.IsActiveSid(new StreamKey("0xUNKNOWN", SID_1)), Is.False); } [Test] @@ -539,7 +539,7 @@ public void ConcurrentSubUnsubUnderContentionDoesNotLoseUpdates() // on per-sid index probing were either deleted (reference-equality on the array) or were // already updated to use the resolver semantics directly. private bool ContainsSid(string walletId, string sid) => - registry.IsActiveSid(walletId, sid); + registry.IsActiveSid(new StreamKey(walletId, sid)); private void RaiseTrackSubscribed(string identity, string sid, TrackKind kind, TrackSource source = TrackSource.SourceMicrophone) { diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCycleManualTest.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCycleManualTest.cs index 1b0a133003..e40b4c3e66 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCycleManualTest.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCycleManualTest.cs @@ -343,8 +343,8 @@ public Weak GetActiveStream(StreamKey key) => public string? GetActiveSid(string walletId) => activeSidByIdentity.TryGetValue(walletId, out string? sid) ? sid : null; - public bool IsActiveSid(string walletId, string sid) => - activeSidByIdentity.TryGetValue(walletId, out string? active) && active == sid; + public bool IsActiveSid(StreamKey key) => + activeSidByIdentity.TryGetValue(key.identity, out string? active) && active == key.sid; public bool IsActiveSpeaker(string walletId) => throw new NotImplementedException(); diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCyclePerformanceTest.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCyclePerformanceTest.cs index be977e341e..c8d23bf6b4 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCyclePerformanceTest.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioFullCyclePerformanceTest.cs @@ -551,8 +551,8 @@ public Weak GetActiveStream(StreamKey key) => public string? GetActiveSid(string walletId) => activeSidByIdentity.TryGetValue(walletId, out string? sid) ? sid : null; - public bool IsActiveSid(string walletId, string sid) => - activeSidByIdentity.TryGetValue(walletId, out string? active) && active == sid; + public bool IsActiveSid(StreamKey key) => + activeSidByIdentity.TryGetValue(key.identity, out string? active) && active == key.sid; public bool IsActiveSpeaker(string walletId) => activeSpeakers.Contains(walletId); From 26ab3c39560edc3fe0cfaec2c6dd253faefd7067 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Thu, 21 May 2026 11:22:21 +0200 Subject: [PATCH 07/12] tests clean-up and docs update --- .../Core/INearbyAudioStreamRegistry.cs | 7 ++- .../NearbyAudioCleanupSystemShould.cs | 56 +++++++++---------- .../NearbyLivekitBridgeSystemShould.cs | 28 ++++++++-- 3 files changed, 54 insertions(+), 37 deletions(-) diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs index 60414996cf..89efe4c4ab 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs @@ -25,9 +25,10 @@ public interface INearbyAudioStreamRegistry : IDisposable Weak GetActiveStream(StreamKey key); /// - /// The single active sid for an identity (the candidate that most recently emitted a media frame across all - /// known sids), or null if the identity has no sids OR none of its candidates have ever emitted a frame. - /// The latter is a transient "not-yet-decided" window: the bridge will re-poll next tick and self-heal. + /// The single active sid for an identity. Returns null if the identity has no sids. + /// Single-candidate fast path: returned eagerly without consulting the frame oracle (a lone candidate is active by definition). + /// Multi-candidate: picks the sid that most recently emitted a media frame; returns null if none of the candidates has + /// ever emitted a frame — a transient "not-yet-decided" window that the bridge re-polls next tick and self-heals. /// string? GetActiveSid(string walletId); diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs index b43ee087ad..a14fafa894 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs @@ -89,7 +89,7 @@ protected override void OnTearDown() [Test] public void AvatarWithDeleteEntityIntentionDisposesSource() { - (_, Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); + (Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); world.Add(avatarEntity); system.Update(0); @@ -104,24 +104,24 @@ public void AvatarWithDeleteEntityIntentionDisposesSource() [Test] public void RegistryMissingWalletCausesCleanup() { - (Entity audioEntity, _, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); + (Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); registry.RemoveAll(PARTICIPANT_A); system.Update(0); - AssertCleanedUp(audioEntity, source, PARTICIPANT_A, SID_1); + AssertCleanedUp(avatarEntity, source, PARTICIPANT_A, SID_1); } [Test] public void RegistryMissingSidCausesCleanup() { - (Entity audioEntity, _, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); + (Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); registry.Add(PARTICIPANT_A, "sid-other"); // wallet still in registry, but bound sid is gone registry.RemoveSid(PARTICIPANT_A, SID_1); system.Update(0); - AssertCleanedUp(audioEntity, source, PARTICIPANT_A, SID_1); + AssertCleanedUp(avatarEntity, source, PARTICIPANT_A, SID_1); } // ── Trigger #3: blocked identity ──────────────────────────── @@ -129,12 +129,12 @@ public void RegistryMissingSidCausesCleanup() [Test] public void BlockedIdentityCausesCleanup() { - (Entity audioEntity, _, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); + (Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); userBlockingCache.UserIsBlocked(PARTICIPANT_A).Returns(true); system.Update(0); - AssertCleanedUp(audioEntity, source, PARTICIPANT_A, SID_1); + AssertCleanedUp(avatarEntity, source, PARTICIPANT_A, SID_1); } // ── Trigger #4: listening gate ────────────────────────────── @@ -147,7 +147,7 @@ public void SuppressedStateTearsDownAllAudioSources() for (int i = 0; i < COUNT; i++) { - (_, Entity avatarEntity, LivekitAudioSource source) = SeedBinding($"wallet-{i}", SID_1); + (Entity avatarEntity, LivekitAudioSource source) = SeedBinding($"wallet-{i}", SID_1); seeded.Add((avatarEntity, source, $"wallet-{i}")); } @@ -173,7 +173,7 @@ public void DisabledStateTearsDownAllAudioSources() for (int i = 0; i < COUNT; i++) { - (_, Entity avatarEntity, LivekitAudioSource source) = SeedBinding($"wallet-{i}", SID_1); + (Entity avatarEntity, LivekitAudioSource source) = SeedBinding($"wallet-{i}", SID_1); seeded.Add((avatarEntity, source, $"wallet-{i}")); } @@ -197,7 +197,7 @@ public void BothTriggersPresentResultInSingleTeardown() { // Compound trigger — registry drops the identity AND user gets blocked in the same frame. // Both clauses dock onto the same per-entity collect pass; the result is one teardown, not two. - (_, Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); + (Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); registry.RemoveAll(PARTICIPANT_A); userBlockingCache.UserIsBlocked(PARTICIPANT_A).Returns(true); @@ -214,7 +214,7 @@ public void MassCleanupOnDisconnected() for (int i = 0; i < COUNT; i++) { - (_, Entity avatarEntity, LivekitAudioSource source) = SeedBinding($"wallet-{i}", SID_1); + (Entity avatarEntity, LivekitAudioSource source) = SeedBinding($"wallet-{i}", SID_1); seeded.Add((avatarEntity, source)); } @@ -239,12 +239,12 @@ public void FlagsAudioEntityWhenAvatarLosesStreamingTag() // its StreamingAudioComponent (Bridge dropped it because the registry no longer reports // sids for that walletId), the audio entity must be doomed without consulting the // registry or the blocking cache. - (Entity audioEntity, Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); + (Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); world.Remove(avatarEntity); system.Update(0); - AssertCleanedUp(audioEntity, source, PARTICIPANT_A, SID_1); + AssertCleanedUp(avatarEntity, source, PARTICIPANT_A, SID_1); } [Test] @@ -252,7 +252,7 @@ public void KeepsAudioComponentWhenMarkerPresentAndRegistryAlive() { // Steady state — marker on, registry has the sid, not blocked, avatar alive. // The cleanup query must not touch this entity. This is the dominant per-frame path. - (_, Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); + (Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); system.Update(0); @@ -276,7 +276,7 @@ public void GhostSidLosingResolverPickCausesCleanup() // Distinct from RegistryMissingSidCausesCleanup: here the sid still EXISTS in the registry // (HasAudioStream=true), it just lost the active-pick race. The !IsActiveSid predicate // must reap it regardless of whether the registry still indexes the sid. - (Entity audioEntity, _, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); + (Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); // Resolver demotes sid-1 in favour of a fresher candidate; HasAudioStream stays true // (registry still holds candidates for the identity). @@ -293,7 +293,7 @@ public void GhostSidLosingResolverPickCausesCleanup() system.Update(0); - AssertCleanedUp(audioEntity, source, PARTICIPANT_A, SID_1); + AssertCleanedUp(avatarEntity, source, PARTICIPANT_A, SID_1); } [Test] @@ -303,7 +303,7 @@ public void DisposesSourceWhenAvatarHasDeleteIntentionButMarkerRemains() // [None], so a doomed avatar keeps its marker until physical // destruction. The dying-avatar trigger #7 must dispose the source regardless of marker state; // it does NOT World.Remove the audio-source component (the entity itself is on its way out). - (_, Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); + (Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); world.Add(avatarEntity); // marker intentionally NOT removed — F1 contract @@ -319,7 +319,7 @@ public void DoesNotQueryRegistryWhenMarkerAbsentPathDoomsEntity() { // Optional sanity — proves the cheap shortcut actually short-circuits the registry call. // If the marker-absence clause fires, registry.IsActiveSid must NOT be invoked. - (_, Entity avatarEntity, _) = SeedBinding(PARTICIPANT_A, SID_1); + (Entity avatarEntity, _) = SeedBinding(PARTICIPANT_A, SID_1); world.Remove(avatarEntity); registry.ResetCallCounters(); @@ -340,12 +340,12 @@ public void FlagsAudioEntityWhenAvatarLosesAudibleRangeTag() // A1 adds a fourth clause to the cheap-shortcut chain — when AudibleRangeMarker drops // InAudibleRangeTag (avatar crossed 22 m outward) Cleanup must doom the audio entity // in the same frame, before the registry / blocking-cache fallbacks fire. - (Entity audioEntity, Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); + (Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); world.Remove(avatarEntity); system.Update(0); - AssertCleanedUp(audioEntity, source, PARTICIPANT_A, SID_1); + AssertCleanedUp(avatarEntity, source, PARTICIPANT_A, SID_1); } [Test] @@ -353,7 +353,7 @@ public void DoesNotInvokeRegistryWhenAudibleRangeAbsentPathDooms() { // Cost-shortcut semantics — the marker-absence clause must short-circuit before the // registry lookup fires. Mirrors the streaming-tag short-circuit guard from A5.2. - (_, Entity avatarEntity, _) = SeedBinding(PARTICIPANT_A, SID_1); + (Entity avatarEntity, _) = SeedBinding(PARTICIPANT_A, SID_1); world.Remove(avatarEntity); registry.ResetCallCounters(); @@ -371,7 +371,7 @@ public void DoesNotInvokeRegistryWhenAudibleRangeAbsentPathDooms() [Test] public void IdleTickWithNoTriggersDoesNotMutateWorld() { - (_, Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); + (Entity avatarEntity, LivekitAudioSource source) = SeedBinding(PARTICIPANT_A, SID_1); system.Update(0); system.Update(0); @@ -392,7 +392,7 @@ public void DisposeDestroysAllRemainingSourcesAndClearsBindings() for (int i = 0; i < COUNT; i++) { - (_, _, LivekitAudioSource source) = SeedBinding($"wallet-{i}", SID_1); + (_, LivekitAudioSource source) = SeedBinding($"wallet-{i}", SID_1); sources.Add(source); } @@ -428,10 +428,10 @@ private static void AssertSourceTornDown(LivekitAudioSource source, string? mess Assert.That(source.gameObject.activeSelf, Is.False, message ?? "pooled source must be inactive"); } - // Slice 4: audio-source component is co-located on the avatar. There is no separate audio entity — - // the seeded "audioEntity" returned here IS the avatar (kept named distinctly to minimise churn in - // call sites that bind both locally). - private (Entity audioEntity, Entity avatarEntity, LivekitAudioSource source) SeedBinding(string walletId, string sid) + // Slice 4: audio-source component is co-located on the avatar — there is no separate audio entity. + // Callers that previously needed an "audioEntity" alias should just reuse avatarEntity; AssertCleanedUp + // takes the same entity for both the liveness check and the component-removed check. + private (Entity avatarEntity, LivekitAudioSource source) SeedBinding(string walletId, string sid) { Entity avatarEntity = CreateAvatarEntity(walletId); // Realistic state for a live audio component: streamer marker + audible range tag both present. @@ -444,7 +444,7 @@ private static void AssertSourceTornDown(LivekitAudioSource source, string? mess LivekitAudioSource source = CreateLivekitAudioSource(key); world.Add(avatarEntity, new NearbyAudioSourceComponent(key, source)); - return (avatarEntity, avatarEntity, source); + return (avatarEntity, source); } private Entity CreateAvatarEntity(string walletId) diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyLivekitBridgeSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyLivekitBridgeSystemShould.cs index a704425913..423056926f 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyLivekitBridgeSystemShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyLivekitBridgeSystemShould.cs @@ -215,11 +215,10 @@ public void T7_AvatarCurrentSidMutatesInPlaceWhenResolverFlipsSids() } [Test] - public void T8_AvatarLosesComponentWhenHasAudioStreamGoesFalseButNotDuringAllZerosWindow() + public void T8a_PreservesComponentDuringAllZerosWindow() { - // Test 8 from the spec — combined two-step assertion. - // Step A — all-zeros window: HasAudioStream=true, GetActiveSid=null → wait, do NOT drop. - // Step B — identity gone: HasAudioStream=false, GetActiveSid=null → drop with cascade. + // Test 8 (Step A) from the spec — all-zeros window: HasAudioStream=true, GetActiveSid=null. + // The bridge must wait it out; dropping here would thrash the component until the resolver self-heals. const string WALLET = "wallet-a"; Entity e = CreateAvatarEntity(WALLET); @@ -231,7 +230,7 @@ public void T8_AvatarLosesComponentWhenHasAudioStreamGoesFalseButNotDuringAllZer Assert.That(world.Has(e), Is.True, "precondition: speaking tag set"); world.Add(e, new InAudibleRangeTag { IsSuspended = false }); - // Step A — resolver enters all-zeros window (identity still indexed, no candidate emitting). + // Resolver enters the all-zeros window: identity still indexed, no candidate emitting. registry.GetActiveSid(WALLET).Returns((string?)null); registry.HasAudioStream(WALLET).Returns(true); @@ -241,8 +240,25 @@ public void T8_AvatarLosesComponentWhenHasAudioStreamGoesFalseButNotDuringAllZer "all-zeros window (HasAudioStream && active=null) must NOT drop the component"); Assert.That(world.Has(e), Is.True, "speaking tag must persist through the all-zeros window"); Assert.That(world.Has(e), Is.True, "audible-range tag must persist through the all-zeros window"); + } + + [Test] + public void T8b_DropsComponentWhenIdentityFullyGone() + { + // Test 8 (Step B) from the spec — identity disappears entirely: HasAudioStream=false → drop with cascade. + const string WALLET = "wallet-a"; + Entity e = CreateAvatarEntity(WALLET); - // Step B — identity disappears entirely. + StubStreaming(WALLET, "sid-1"); + registry.IsActiveSpeaker(WALLET).Returns(true); + + system.Update(0); + Assert.That(world.Has(e), Is.True, "precondition: component attached"); + Assert.That(world.Has(e), Is.True, "precondition: speaking tag set"); + world.Add(e, new InAudibleRangeTag { IsSuspended = false }); + + // Identity disappears entirely. + registry.GetActiveSid(WALLET).Returns((string?)null); registry.HasAudioStream(WALLET).Returns(false); system.Update(0); From 44d61b6df76a34ca3a841dc2360a2cb21fb7622a Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Thu, 21 May 2026 11:30:58 +0200 Subject: [PATCH 08/12] 2 more comments --- .../NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs index 89efe4c4ab..8c575ac0f7 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs @@ -25,6 +25,7 @@ public interface INearbyAudioStreamRegistry : IDisposable Weak GetActiveStream(StreamKey key); /// + /// Call-site discipline. Main-thread only — the multi-candidate branch performs one FFI hop per sid. /// The single active sid for an identity. Returns null if the identity has no sids. /// Single-candidate fast path: returned eagerly without consulting the frame oracle (a lone candidate is active by definition). /// Multi-candidate: picks the sid that most recently emitted a media frame; returns null if none of the candidates has @@ -36,6 +37,7 @@ public interface INearbyAudioStreamRegistry : IDisposable /// true when .sid is the resolver's current pick for .identity. /// Cleanup uses this in place of "sid disappeared from snapshot" — it also reaps demoted ghost sids that /// still exist in the registry but lost to a fresher candidate. + /// Shares the cost profile and main-thread discipline of : same resolver call underneath. /// bool IsActiveSid(StreamKey key); From add84dee3ad1a52308873f40ae4bca0157b8a522 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Thu, 21 May 2026 12:48:04 +0200 Subject: [PATCH 09/12] pointed to merged livekit and updated to new interface --- .../Rooms/Interior/InteriorVideoStreams.cs | 3 -- .../Connections/Rooms/Logs/LogVideoStreams.cs | 7 --- .../Rooms/Nulls/NullVideoStreams.cs | 3 -- .../Core/NearbyAudioStreamsRegistry.cs | 16 +++++-- .../NearbyAudioStreamRegistryShould.cs | 46 +++++++++++++------ Explorer/Packages/manifest.json | 2 +- Explorer/Packages/packages-lock.json | 4 +- 7 files changed, 48 insertions(+), 33 deletions(-) diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Interior/InteriorVideoStreams.cs b/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Interior/InteriorVideoStreams.cs index 76178c8eb0..5ace50508d 100644 --- a/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Interior/InteriorVideoStreams.cs +++ b/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Interior/InteriorVideoStreams.cs @@ -74,9 +74,6 @@ public void AssignRoom(Room room) assigned.AssignRoom(room); } - public int GetLastFrameReceivedAt(StreamKey streamKey) => - assigned.GetLastFrameReceivedAt(streamKey); - public void Assign(IAudioStreams value, out IAudioStreams? previous) { previous = assigned; diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Logs/LogVideoStreams.cs b/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Logs/LogVideoStreams.cs index fa02104196..46c9e3e6e6 100644 --- a/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Logs/LogVideoStreams.cs +++ b/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Logs/LogVideoStreams.cs @@ -71,12 +71,5 @@ public LogAudioStreams(IAudioStreams origin) : base(origin, nameof(LogAudioStrea { this.origin = origin; } - - public int GetLastFrameReceivedAt(StreamKey streamKey) - { - int tick = origin.GetLastFrameReceivedAt(streamKey); - ReportHub.Log(ReportCategory.LIVEKIT, $"{nameof(LogAudioStreams)}: {nameof(GetLastFrameReceivedAt)}: {streamKey.identity}, {streamKey.sid} -> {tick};"); - return tick; - } } } diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Nulls/NullVideoStreams.cs b/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Nulls/NullVideoStreams.cs index 43ba3092b0..34071de479 100644 --- a/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Nulls/NullVideoStreams.cs +++ b/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Nulls/NullVideoStreams.cs @@ -43,8 +43,5 @@ public class NullVideoStreams : NullStreams, IVid public class NullAudioStreams : NullStreams, IAudioStreams { public static readonly NullAudioStreams INSTANCE = new (); - - // -1 is the resolver's sentinel for "stream missing / never decoded a frame" - public int GetLastFrameReceivedAt(StreamKey streamKey) => -1; } } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs index 9b5c061644..401fe9d8c0 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs @@ -42,6 +42,10 @@ public sealed class NearbyAudioStreamsRegistry : INearbyAudioStreamRegistry { private readonly IRoom room; + // Frame-activity oracle seam: the LiveKit-side helper lives on an extension and AudioStream itself is + // FFI-bound + non-virtual, so a delegate is the only injectable shape for tests. + private readonly Func> getLastFrameReceivedAt; + // Immutability contract — see class XML. Swappable via Interlocked.Exchange / Volatile.Read. // IGNORE_LINE_WEBGL_THREAD_SAFETY_FLAG // concurrencyLevel: 1 — FFI dispatch is serial, only one writer ever; saves the per-instance lock array (default = Environment.ProcessorCount). private DCLConcurrentDictionary streamsByIdentity = NewSnapshot(); @@ -53,8 +57,12 @@ public sealed class NearbyAudioStreamsRegistry : INearbyAudioStreamRegistry public int RebuildEpoch => DCLVolatile.Read(ref rebuildEpoch); public NearbyAudioStreamsRegistry(IRoom room) + : this(room, key => room.AudioStreams.GetLastFrameReceivedAt(key)) { } + + internal NearbyAudioStreamsRegistry(IRoom room, Func> getLastFrameReceivedAt) { this.room = room; + this.getLastFrameReceivedAt = getLastFrameReceivedAt; room.ConnectionUpdated += OnConnectionUpdated; @@ -135,10 +143,12 @@ public Weak GetActiveStream(StreamKey key) => foreach (string sid in sids) { - int t = room.AudioStreams.GetLastFrameReceivedAt(new StreamKey(walletId, sid)); + Option lastFrame = getLastFrameReceivedAt(new StreamKey(walletId, sid)); + + // Option.None: AudioStream missing or never decoded a frame. + if (!lastFrame.Has) continue; - // -1 sentinel: AudioStream missing or never decoded a frame. - if (t == -1) continue; + int t = lastFrame.Value; if (bestSid is null || unchecked(t - bestTick) > 0) { diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs index fd272049ab..94eba2a8ce 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs @@ -10,6 +10,7 @@ using LiveKit.Rooms.Tracks.Hub; using NSubstitute; using NUnit.Framework; +using RichTypes; using System; using System.Collections.Generic; using System.Reflection; @@ -31,6 +32,7 @@ public class NearbyAudioStreamRegistryShould private IParticipantsHub participantsHub = null!; private FakeActiveSpeakers activeSpeakers = null!; private IAudioStreams audioStreams = null!; + private FrameOracleStub frameOracle = null!; private NearbyAudioStreamsRegistry registry = null!; [SetUp] @@ -42,11 +44,9 @@ public void SetUp() activeSpeakers = new FakeActiveSpeakers(); room.ActiveSpeakers.Returns(activeSpeakers); audioStreams = Substitute.For(); - // -1 sentinel matches production: missing stream / never decoded a frame. - audioStreams.GetLastFrameReceivedAt(default).ReturnsForAnyArgs(-1); - audioStreams.ClearReceivedCalls(); // stub-setup call counts as received; clear so DidNotReceive assertions are clean. room.AudioStreams.Returns(audioStreams); - registry = new NearbyAudioStreamsRegistry(room); + frameOracle = new FrameOracleStub(); + registry = new NearbyAudioStreamsRegistry(room, frameOracle.Get); } [TearDown] @@ -438,7 +438,7 @@ public void ReturnSingleSidAsActiveWithoutConsultingFrameOracle() RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); Assert.That(registry.GetActiveSid(WALLET_A), Is.EqualTo(SID_1)); - audioStreams.DidNotReceiveWithAnyArgs().GetLastFrameReceivedAt(default); + Assert.That(frameOracle.Calls, Is.Zero, "single-sid hot path must not consult the frame oracle"); } [Test] @@ -446,7 +446,7 @@ public void ReturnNullActiveSidWhenAllCandidatesAreGhosts() { RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); RaiseTrackSubscribed(WALLET_A, SID_2, TrackKind.KindAudio); - // Both stay at the default -1 sentinel — none have ever decoded a frame. + // Both stay at the default Option.None — none have ever decoded a frame. Assert.That(registry.GetActiveSid(WALLET_A), Is.Null); } @@ -457,8 +457,8 @@ public void PickTheOnlyCandidateWithFrameActivity() RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); RaiseTrackSubscribed(WALLET_A, SID_2, TrackKind.KindAudio); - audioStreams.GetLastFrameReceivedAt(new StreamKey(WALLET_A, SID_2)).Returns(100); - // SID_1 keeps the default -1 sentinel (ghost). + frameOracle.Set(new StreamKey(WALLET_A, SID_2), 100); + // SID_1 keeps the default Option.None (ghost). Assert.That(registry.GetActiveSid(WALLET_A), Is.EqualTo(SID_2)); } @@ -469,8 +469,8 @@ public void PickTheNewestCandidateWhenSeveralAreLive() RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); RaiseTrackSubscribed(WALLET_A, SID_2, TrackKind.KindAudio); - audioStreams.GetLastFrameReceivedAt(new StreamKey(WALLET_A, SID_1)).Returns(100); - audioStreams.GetLastFrameReceivedAt(new StreamKey(WALLET_A, SID_2)).Returns(200); + frameOracle.Set(new StreamKey(WALLET_A, SID_1), 100); + frameOracle.Set(new StreamKey(WALLET_A, SID_2), 200); Assert.That(registry.GetActiveSid(WALLET_A), Is.EqualTo(SID_2)); } @@ -482,9 +482,9 @@ public void TreatTickCounterWrapAroundAsNewerNotOlder() RaiseTrackSubscribed(WALLET_A, SID_2, TrackKind.KindAudio); // Pre-wrap (older), positive end of the range. - audioStreams.GetLastFrameReceivedAt(new StreamKey(WALLET_A, SID_1)).Returns(int.MaxValue - 50); + frameOracle.Set(new StreamKey(WALLET_A, SID_1), int.MaxValue - 50); // Post-wrap (newer in unchecked arithmetic), negative end of the range. - audioStreams.GetLastFrameReceivedAt(new StreamKey(WALLET_A, SID_2)).Returns(int.MinValue + 50); + frameOracle.Set(new StreamKey(WALLET_A, SID_2), int.MinValue + 50); Assert.That(registry.GetActiveSid(WALLET_A), Is.EqualTo(SID_2), "unchecked(SID_2_tick - SID_1_tick) == 101 > 0 — resolver must prefer the post-wrap candidate"); @@ -496,8 +496,8 @@ public void IsActiveSidTrueForWinnerFalseForGhostLoser() RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); RaiseTrackSubscribed(WALLET_A, SID_2, TrackKind.KindAudio); - audioStreams.GetLastFrameReceivedAt(new StreamKey(WALLET_A, SID_2)).Returns(500); - // SID_1 keeps -1 (ghost). + frameOracle.Set(new StreamKey(WALLET_A, SID_2), 500); + // SID_1 keeps Option.None (ghost). Assert.That(registry.IsActiveSid(new StreamKey(WALLET_A, SID_2)), Is.True); Assert.That(registry.IsActiveSid(new StreamKey(WALLET_A, SID_1)), Is.False); @@ -626,6 +626,24 @@ private static TrackPublication NewPublication(string sid, TrackKind kind, Track return publication; } + // Replaces NSubstitute stubs for the frame-activity oracle: the production seam is a + // Func> because the LiveKit helper is an extension method (not on the + // interface) and AudioStream itself is FFI-bound and non-virtual. + private sealed class FrameOracleStub + { + private readonly Dictionary values = new (); + public int Calls { get; private set; } + + public void Set(StreamKey key, int tick) => + values[key] = tick; + + public Option Get(StreamKey key) + { + Calls++; + return values.TryGetValue(key, out int v) ? Option.Some(v) : Option.None; + } + } + private sealed class FakeActiveSpeakers : IActiveSpeakers { private readonly HashSet set = new (); diff --git a/Explorer/Packages/manifest.json b/Explorer/Packages/manifest.json index c15b921945..37fa0c6416 100644 --- a/Explorer/Packages/manifest.json +++ b/Explorer/Packages/manifest.json @@ -10,7 +10,7 @@ "com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask", "com.dcl.gpui-assets": "git@github.com:decentraland/unity-explorer-packages.git?path=/GPUInstancerPro/com.dcl.gpui-assets", "com.decentraland.filebrowserpro": "git@github.com:decentraland/unity-explorer-packages.git?path=/FileBrowserPro", - "com.decentraland.livekit-sdk": "https://github.com/decentraland/client-sdk-unity.git#chore/frame-activity-oracle-on-AudioStream", + "com.decentraland.livekit-sdk": "https://github.com/decentraland/client-sdk-unity.git", "com.decentraland.renum": "https://github.com/NickKhalow/REnum.git?path=REnum", "com.decentraland.renum.sourcegen": "https://github.com/NickKhalow/REnum.git#sourcegen/1.1.4", "com.decentraland.rpc-csharp": "https://github.com/decentraland/rpc-csharp.git?path=rpc-csharp/src#f3dd251c7837cc2d844e1c07e741177a53676064", diff --git a/Explorer/Packages/packages-lock.json b/Explorer/Packages/packages-lock.json index b40a8ffafb..98aae04144 100644 --- a/Explorer/Packages/packages-lock.json +++ b/Explorer/Packages/packages-lock.json @@ -90,7 +90,7 @@ "hash": "f57b137a6bd76527ea144b7e03cd7743f1b39599" }, "com.decentraland.livekit-sdk": { - "version": "https://github.com/decentraland/client-sdk-unity.git#chore/frame-activity-oracle-on-AudioStream", + "version": "https://github.com/decentraland/client-sdk-unity.git", "depth": 0, "source": "git", "dependencies": { @@ -98,7 +98,7 @@ "com.nickkhalow.richtypes": "https://github.com/NickKhalow/RichTypesUnity.git?path=/Packages/RichTypes", "io.livekit.unity": "https://github.com/livekit/client-sdk-unity-web.git" }, - "hash": "9c06f180d260fa0905d1dcb7587e6675e674fee9" + "hash": "9caeba7fb88547062f227b2e246b18b6927e4435" }, "com.decentraland.renum": { "version": "https://github.com/NickKhalow/REnum.git?path=REnum", From 9e47387a8445e5d1c76dc488a4ebc0812ae08d8b Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Thu, 21 May 2026 12:49:34 +0200 Subject: [PATCH 10/12] updated comment --- .../NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs index 401fe9d8c0..134de00b2f 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs @@ -42,8 +42,7 @@ public sealed class NearbyAudioStreamsRegistry : INearbyAudioStreamRegistry { private readonly IRoom room; - // Frame-activity oracle seam: the LiveKit-side helper lives on an extension and AudioStream itself is - // FFI-bound + non-virtual, so a delegate is the only injectable shape for tests. + // delegate is the only injectable shape for tests. private readonly Func> getLastFrameReceivedAt; // Immutability contract — see class XML. Swappable via Interlocked.Exchange / Volatile.Read. // IGNORE_LINE_WEBGL_THREAD_SAFETY_FLAG From 2daefb10c2ccdff022f8d2d274d20201479c13f8 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Thu, 21 May 2026 13:20:07 +0200 Subject: [PATCH 11/12] changed ref to in --- .../Systems/NearbyAudioCleanupSystem.cs | 20 +++++++++---------- .../Systems/NearbyVoiceChatDebugSystem.cs | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs index a6ab0ee4e3..2088ae3915 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs @@ -83,15 +83,15 @@ protected override void OnDispose() // #1: avatar is not a streamer anymore (lost NearbyAudioStreamerComponent). [Query] [None(typeof(DeleteEntityIntention), typeof(NearbyAudioStreamerComponent))] - private void ReapOrphanedSource(Entity entity, ref NearbyAudioSourceComponent comp) => - DisposeAndRemove(entity, ref comp); + private void ReapOrphanedSource(Entity entity, in NearbyAudioSourceComponent comp) => + DisposeAndRemove(entity, in comp); // #2: avatar left audible range. [Query] [All(typeof(NearbyAudioStreamerComponent))] [None(typeof(DeleteEntityIntention), typeof(InAudibleRangeTag))] - private void ReapOutOfRangeSource(Entity entity, ref NearbyAudioSourceComponent comp) => - DisposeAndRemove(entity, ref comp); + private void ReapOutOfRangeSource(Entity entity, in NearbyAudioSourceComponent comp) => + DisposeAndRemove(entity, in comp); // #3/#4/#5: // !IsActiveSid covers sid evicted entirely (resolver picks different/null sid) @@ -99,17 +99,17 @@ private void ReapOutOfRangeSource(Entity entity, ref NearbyAudioSourceComponent [Query] [All(typeof(NearbyAudioStreamerComponent), typeof(InAudibleRangeTag))] [None(typeof(DeleteEntityIntention))] - private void ReapFilteredSource(Entity entity, ref NearbyAudioSourceComponent comp) + private void ReapFilteredSource(Entity entity, in NearbyAudioSourceComponent comp) { bool remove = userBlockingCache.UserIsBlocked(comp.Key.identity) || roomMetadataCurrentScene.IsUserBanned(comp.Key.identity) || !registry.IsActiveSid(comp.Key); if (remove) - DisposeAndRemove(entity, ref comp); + DisposeAndRemove(entity, in comp); } - private void DisposeAndRemove(Entity entity, ref NearbyAudioSourceComponent comp) + private void DisposeAndRemove(Entity entity, in NearbyAudioSourceComponent comp) { sourceFactory.Dispose(comp.LivekitAudioSource); World.Remove(entity); @@ -117,12 +117,12 @@ private void DisposeAndRemove(Entity entity, ref NearbyAudioSourceComponent comp [Query] [None(typeof(DeleteEntityIntention))] - private void DisposeAllLiveSources(Entity entity, ref NearbyAudioSourceComponent comp) => - DisposeAndRemove(entity, ref comp); + private void DisposeAllLiveSources(Entity entity, in NearbyAudioSourceComponent comp) => + DisposeAndRemove(entity, in comp); [Query] [All(typeof(DeleteEntityIntention))] - private void DisposeDyingAvatarSources(ref NearbyAudioSourceComponent comp) + private void DisposeDyingAvatarSources(in NearbyAudioSourceComponent comp) { sourceFactory.Dispose(comp.LivekitAudioSource); } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyVoiceChatDebugSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyVoiceChatDebugSystem.cs index 2f62f7907d..d8ca1d947f 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyVoiceChatDebugSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyVoiceChatDebugSystem.cs @@ -237,7 +237,7 @@ private void LogMismatchEdges(bool isMismatched, ulong audioSourceCount, ulong c [Query] [None(typeof(DeleteEntityIntention))] - private void ApplySettings(ref NearbyAudioSourceComponent nearbyAudio) + private void ApplySettings(in NearbyAudioSourceComponent nearbyAudio) { nearbyAudio.LivekitAudioSource.ApplySpatialSettings(configuration); } From d34bec97002e17865357d0000a40d44a837c39e1 Mon Sep 17 00:00:00 2001 From: Vitaly Popuzin Date: Thu, 21 May 2026 13:32:47 +0200 Subject: [PATCH 12/12] rolled back LogAudioStreams --- .../Multiplayer/Connections/Rooms/Logs/LogVideoStreams.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Logs/LogVideoStreams.cs b/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Logs/LogVideoStreams.cs index 46c9e3e6e6..3375ad7479 100644 --- a/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Logs/LogVideoStreams.cs +++ b/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Logs/LogVideoStreams.cs @@ -65,11 +65,6 @@ public LogVideoStreams(IStreams origin) : base(or public class LogAudioStreams : LogStreams, IAudioStreams { - private readonly IAudioStreams origin; - - public LogAudioStreams(IAudioStreams origin) : base(origin, nameof(LogAudioStreams)) - { - this.origin = origin; - } + public LogAudioStreams(IAudioStreams origin) : base(origin, nameof(LogAudioStreams)) { } } }