diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Interior/InteriorVideoStreams.cs b/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Interior/InteriorVideoStreams.cs index 98ba7f7b816..5ace50508de 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 diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Logs/LogVideoStreams.cs b/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Logs/LogVideoStreams.cs index ed8d566f711..3375ad7479a 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,6 @@ public LogVideoStreams(IStreams origin) : base(or public class LogAudioStreams : LogStreams, IAudioStreams { - public LogAudioStreams(IStreams origin) : base(origin, nameof(LogVideoStreams)) { } + public LogAudioStreams(IAudioStreams origin) : base(origin, nameof(LogAudioStreams)) { } } } diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Nulls/NullVideoStreams.cs b/Explorer/Assets/DCL/Multiplayer/Connections/Rooms/Nulls/NullVideoStreams.cs index 96d51aa56a7..34071de479f 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 diff --git a/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs b/Explorer/Assets/DCL/PluginSystem/Global/VoiceChatPlugin.cs index a2580784aa4..5f7bdc4570f 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/Components/NearbyAudioSourceComponent.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Components/NearbyAudioSourceComponent.cs index c6ef1668b0c..c8d9d1cf0fe 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/Components/NearbyAudioStreamerComponent.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Components/NearbyAudioStreamerComponent.cs index 7065048eda6..9955f3c4648 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 b59e89d14aa..8c575ac0f76 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/INearbyAudioStreamRegistry.cs @@ -18,21 +18,28 @@ 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); + /// + /// 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 + /// ever emitted a frame — a transient "not-yet-decided" window that the bridge re-polls next tick and self-heals. + /// + string? GetActiveSid(string walletId); + + /// + /// 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); /// /// 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 2acd8c0206d..134de00b2f1 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Core/NearbyAudioStreamsRegistry.cs @@ -35,13 +35,16 @@ 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 { private readonly IRoom room; + // 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 +56,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; @@ -119,19 +126,43 @@ 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) + public string? GetActiveSid(string walletId) { - if (!DCLVolatile.Read(ref streamsByIdentity).TryGetValue(key.identity, out string[]? sids)) - return true; + 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) + { + Option lastFrame = getLastFrameReceivedAt(new StreamKey(walletId, sid)); - return Array.IndexOf(sids, key.sid) < 0; + // Option.None: AudioStream missing or never decoded a frame. + if (!lastFrame.Has) continue; + + int t = lastFrame.Value; + + if (bestSid is null || unchecked(t - bestTick) > 0) + { + bestTick = t; + bestSid = sid; + } + } + + return bestSid; + } + + public bool IsActiveSid(StreamKey key) + { + string? active = GetActiveSid(key.identity); + return active is not null && string.Equals(active, key.sid, StringComparison.Ordinal); } private void OnConnectionUpdated(IRoom _, ConnectionUpdate update, LKDisconnectReason? __) @@ -216,10 +247,9 @@ 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. - // Single-writer assumption (serial FFI dispatch) — no CAS retry needed. + // 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). 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 c83c2288eeb..fff67e3caf3 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 iterates the snapshotted sids on the entity itself and materializes an audio-source entity per (walletId, sid) pair that does not yet have one. - /// Throttled to a fixed budget per frame so a crowd ramp-up does not spike a single tick. + /// 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))] [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; @@ -61,8 +55,8 @@ protected override void Update(float t) } [Query] - [None(typeof(DeleteEntityIntention))] - [All(typeof(AvatarBase), typeof(NearbyAudioStreamerComponent), typeof(InAudibleRangeTag))] + [None(typeof(NearbyAudioSourceComponent), typeof(DeleteEntityIntention))] + [All(typeof(AvatarBase), typeof(InAudibleRangeTag))] private void CreateAndBindAudioSourcesToStreamers(Entity avatarEntity, in Profile profile, in NearbyAudioStreamerComponent nearby) { string walletId = profile.UserId; @@ -71,24 +65,13 @@ 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); - - if (!bindings.Contains(key)) - { - Weak stream = registry.GetActiveStream(key); + var key = new StreamKey(walletId, nearby.CurrentSid); + Weak stream = registry.GetActiveStream(key); - // Track was unsubscribed between collection (snapshot read) and resolve (GetActiveStream); skip to avoid a one-frame ghost source. - if (!stream.Resource.Has) continue; - - LivekitAudioSource source = sourceFactory.Create(key, stream); - - World.Create(new NearbyAudioSourceComponent(key, avatarEntity, source)); - bindings.Add(key); - } + 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 f22c6abf996..2088ae39154 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioCleanupSystem.cs @@ -8,38 +8,29 @@ using ECS.Abstract; using ECS.Groups; using ECS.LifeCycle.Components; -using LiveKit.Rooms.Streaming; -using System.Collections.Generic; 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 (stream gone) — registry no longer reports the bound (walletId, sid). - /// 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)] public partial class NearbyAudioCleanupSystem : BaseUnityLoopSystem { - private static readonly QueryDescription LIVE_AUDIO_QUERY = - new QueryDescription().WithAll().WithNone(); - private readonly INearbyAudioStreamRegistry registry; - private readonly HashSet bindings; private readonly IUserBlockingCache userBlockingCache; private readonly NearbyVoiceChatStateModel stateModel; private readonly INearbyAudioSourceFactory sourceFactory; @@ -48,10 +39,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; @@ -71,43 +61,68 @@ 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 wipe every live source; per-entity detection is skipped in either case. if (stateModel.IsListeningDisabled || deviceChanged) - World.Add(in LIVE_AUDIO_QUERY); + DisposeAllLiveSourcesQuery(World); else - FlagDoomedAudioEntitiesQuery(World); + { + ReapOrphanedSourceQuery(World); // #1: streamer marker gone + ReapOutOfRangeSourceQuery(World); // #2: audible-range tag gone + ReapFilteredSourceQuery(World); // #3-4-5 sid demoted / user blocked or banned by the scene + } - TearDownMarkedAudioEntitiesQuery(World); + DisposeDyingAvatarSourcesQuery(World); // #7: avatars marked for deletion } protected override void OnDispose() { - DisposeAllAudioSourcesQuery(World); - bindings.Clear(); + DisposeAllLiveSourcesQuery(World); + DisposeDyingAvatarSourcesQuery(World); } + // #1: avatar is not a streamer anymore (lost NearbyAudioStreamerComponent). + [Query] + [None(typeof(DeleteEntityIntention), typeof(NearbyAudioStreamerComponent))] + 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, in NearbyAudioSourceComponent comp) => + DisposeAndRemove(entity, in comp); + + // #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 FlagDoomedAudioEntities(Entity audioEntity, ref NearbyAudioSourceComponent comp) + private void ReapFilteredSource(Entity entity, in NearbyAudioSourceComponent comp) { - Entity avatar = comp.AvatarEntity; + bool remove = userBlockingCache.UserIsBlocked(comp.Key.identity) + || roomMetadataCurrentScene.IsUserBanned(comp.Key.identity) + || !registry.IsActiveSid(comp.Key); - // Component absence ≠ avatar gone. Component absence ≠ specific sid gone either. Both fallbacks must remain. - 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)) - World.Add(audioEntity); + if (remove) + DisposeAndRemove(entity, in comp); } - [Query] - [All(typeof(DeleteEntityIntention))] - private void TearDownMarkedAudioEntities(ref NearbyAudioSourceComponent comp) + private void DisposeAndRemove(Entity entity, in NearbyAudioSourceComponent comp) { sourceFactory.Dispose(comp.LivekitAudioSource); - bindings.Remove(comp.Key); + World.Remove(entity); } [Query] - private void DisposeAllAudioSources(ref NearbyAudioSourceComponent comp) + [None(typeof(DeleteEntityIntention))] + private void DisposeAllLiveSources(Entity entity, in NearbyAudioSourceComponent comp) => + DisposeAndRemove(entity, in comp); + + [Query] + [All(typeof(DeleteEntityIntention))] + private void DisposeDyingAvatarSources(in NearbyAudioSourceComponent comp) { sourceFactory.Dispose(comp.LivekitAudioSource); } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioPositionSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioPositionSystem.cs index bd66bcc5a42..ad40d116e00 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioPositionSystem.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyAudioPositionSystem.cs @@ -6,14 +6,12 @@ using ECS.LifeCycle.Components; using LiveKit.Rooms.Streaming.Audio; using Unity.Mathematics; -using Unity.Profiling; using UnityEngine; 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. /// [UpdateInGroup(typeof(NearbyVoiceChatGroup))] [UpdateAfter(typeof(NearbyAudioBindingSystem))] @@ -32,36 +30,41 @@ internal NearbyAudioPositionSystem(World world, NearbyMuteService muteService, N protected override void Update(float t) { - SyncPositionsAndSpatialAnglesQuery(World, listenerState.ListenerTransform, listenerState.PlayerHeadPosition); + StopPlayingOutOfRangeSourcesQuery(World); + SpatializeActiveSourceQuery(World, listenerState.ListenerTransform, listenerState.PlayerHeadPosition); } [Query] - [None(typeof(DeleteEntityIntention))] - private void SyncPositionsAndSpatialAngles([Data] Transform listenerTransform, [Data] Vector3 playerHeadPos, ref NearbyAudioSourceComponent nearbyAudio) + [All(typeof(NearbyAudioStreamerComponent))] + [None(typeof(InAudibleRangeTag), typeof(DeleteEntityIntention))] + private void StopPlayingOutOfRangeSources(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; + StopIfPlaying(ref nearbyAudio); + } - // Per-frame idempotent inactive-state application — self-healing - Entity avatar = nearbyAudio.AvatarEntity; - bool inactive = !World.TryGet(avatar, out InAudibleRangeTag rangeTag) || rangeTag.IsSuspended; + [Query] + [All(typeof(NearbyAudioStreamerComponent))] + [None(typeof(DeleteEntityIntention))] + private void SpatializeActiveSource([Data] Transform listenerTransform, [Data] Vector3 playerHeadPos, in InAudibleRangeTag rangeTag, in AvatarBase avatarBase, ref NearbyAudioSourceComponent nearbyAudio) + { + 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 remoteAvatarHeadPos = avatarBase.HeadAnchorPoint.position; Vector3 sourcePos = listenerTransform.position + (remoteAvatarHeadPos - playerHeadPos); if ((sourcePos - nearbyAudio.LastWrittenPos).sqrMagnitude > POSITION_EPSILON_SQR) @@ -77,17 +80,15 @@ private void SyncPositionsAndSpatialAngles([Data] Transform listenerTransform, [ 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; - } + UpdateMute(ref nearbyAudio, src); + } + + 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) @@ -101,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 7e391845246..1b437cda1c1 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,9 @@ 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)); + string? activeSid = registry.GetActiveSid(walletId); + if (activeSid != null) + World.Add(entity, new NearbyAudioStreamerComponent(activeSid)); } [Query] @@ -63,12 +63,13 @@ 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). + 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; + // Cleanup reaps the old (walletId, oldSid) entity. + if (!string.Equals(nearby.CurrentSid, activeSid, System.StringComparison.Ordinal)) + nearby.CurrentSid = activeSid; } [Query] @@ -77,7 +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) || registry.GetAudioSidsArray(userId) != null) 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/Systems/NearbyVoiceChatDebugSystem.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Systems/NearbyVoiceChatDebugSystem.cs index 9eb6719f773..d8ca1d947f4 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); @@ -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); } diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudibleRangeMarkerSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudibleRangeMarkerSystemShould.cs index 5993f8ac628..5af8a6b5e7c 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 16ae7413b61..f4b612feb50 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioBindingSystemShould.cs @@ -24,22 +24,21 @@ 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 { - 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)!; private FakeStreamRegistry registry; - private HashSet bindings; private IUserBlockingCache userBlockingCache; private NearbyVoiceChatStateModel stateModel; @@ -53,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() @@ -70,14 +68,13 @@ protected override void OnTearDown() gameObjects.Clear(); - bindings.Clear(); stateModel.Dispose(); EcsTestsUtils.TearDownFeaturesRegistry(); } [Test] - public void SingleAvatarSingleStreamCreatesOneEntity() + public void SingleAvatarSingleStreamAddsComponentOnAvatar() { const string WALLET = "wallet-alice"; Entity avatarEntity = CreateStreamingAvatar(WALLET, "sid-1"); @@ -87,31 +84,21 @@ 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); } - [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"); @@ -162,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] @@ -256,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 ─ @@ -277,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] @@ -288,14 +270,14 @@ 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); 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] @@ -305,7 +287,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 +305,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); @@ -331,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] @@ -356,24 +337,24 @@ 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()).ReturnsForAnyArgs(false); + 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"; @@ -384,11 +365,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()); } finally { @@ -422,10 +401,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 +449,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,7 +458,9 @@ public Weak GetActiveStream(StreamKey key) : Weak.Null; } - public bool IsStreamGone(StreamKey key) => !streamsByKey.ContainsKey(key); + public string? GetActiveSid(string walletId) => null; + + 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 dd90357357f..a14fafa8947 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioCleanupSystemShould.cs @@ -22,16 +22,17 @@ 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. - /// - disposes any survivors and clears bindings. + /// - 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. + /// They do NOT assert avatar-entity destruction (out of scope). + /// - disposes any survivors. /// public class NearbyAudioCleanupSystemShould : UnitySystemTestBase { @@ -45,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!; @@ -58,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() @@ -73,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); @@ -81,28 +79,24 @@ 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 but does NOT World.Remove the component — the dying avatar + // will take it down on physical destruction. + AssertSourceTornDown(source); } // ── Trigger #2: stream gone ───────────────────────────────── @@ -110,24 +104,24 @@ public void AvatarWithDeleteEntityIntentionCausesCleanup() [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 ──────────────────────────── @@ -135,51 +129,52 @@ 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 ────────────────────────────── [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(bindings, Is.Empty); - foreach ((Entity audioEntity, LivekitAudioSource source, _) in seeded) + Assert.That(world.CountEntities(in LIVE_AUDIO_QUERY), Is.EqualTo(0), "all audio components must be removed"); + 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(); @@ -187,10 +182,10 @@ public void DisabledStateTearsDownAllAudioEntities() system.Update(0); 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 +195,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 +223,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); } } @@ -242,71 +239,77 @@ 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] - 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); } + // 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 FlagsAudioEntityWhenOneOfNSidsGoneButMarkerPresent() + public void GhostSidLosingResolverPickCausesCleanup() { - // 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. - 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); - LivekitAudioSource source2 = CreateLivekitAudioSource(key2); - 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); + // 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 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). + 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(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(); 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"); - Assert.That(source2 == null, Is.False); + AssertCleanedUp(avatarEntity, source, PARTICIPANT_A, SID_1); } [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(world.Has(avatarEntity), Is.True, "Bridge's [None] filter prevents component removal on a doomed avatar"); } @@ -315,15 +318,15 @@ 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. - (_, Entity avatarEntity, _) = SeedBinding(PARTICIPANT_A, SID_1); + // If the marker-absence clause fires, registry.IsActiveSid must NOT be invoked. + (Entity avatarEntity, _) = SeedBinding(PARTICIPANT_A, SID_1); world.Remove(avatarEntity); registry.ResetCallCounters(); 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"); @@ -337,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] @@ -350,14 +353,14 @@ 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(); 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"); @@ -368,15 +371,14 @@ 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)); } @@ -390,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); } @@ -399,18 +401,19 @@ public void DisposeDestroysAllRemainingSourcesAndClearsBindings() foreach (LivekitAudioSource source in sources) AssertSourceTornDown(source); - - Assert.That(bindings, Is.Empty); } // ── 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. 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"); } // A2 made source teardown reference-stable: the pool keeps the GO alive after Dispose. Both @@ -425,25 +428,23 @@ private static void AssertSourceTornDown(LivekitAudioSource source, string? mess Assert.That(source.gameObject.activeSelf, Is.False, message ?? "pooled source must be inactive"); } - 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); - // 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 - // (IsStreamGone / UserIsBlocked / lifecycle), not the marker-absence shortcuts by accident. - world.Add(avatarEntity, new NearbyAudioStreamerComponent(new[] { sid })); + // 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)); - bindings.Add(key); + world.Add(avatarEntity, new NearbyAudioSourceComponent(key, source)); - return (audioEntity, avatarEntity, source); + return (avatarEntity, source); } private Entity CreateAvatarEntity(string walletId) @@ -475,61 +476,79 @@ 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(StreamKey key) { - IsStreamGoneCallCount++; - return !sidsByIdentity.TryGetValue(key.identity, out HashSet? sids) || !sids.Contains(key.sid); + IsActiveSidCallCount++; + 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/NearbyAudioPositionSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioPositionSystemShould.cs index 4a2da4c627d..2a6c8420ab7 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/EditMode/NearbyAudioStreamRegistryShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs index c57f06c0d93..94eba2a8ce5 100644 --- a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs +++ b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyAudioStreamRegistryShould.cs @@ -5,10 +5,12 @@ 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; using NUnit.Framework; +using RichTypes; using System; using System.Collections.Generic; using System.Reflection; @@ -29,6 +31,8 @@ public class NearbyAudioStreamRegistryShould private IRoom room = null!; private IParticipantsHub participantsHub = null!; private FakeActiveSpeakers activeSpeakers = null!; + private IAudioStreams audioStreams = null!; + private FrameOracleStub frameOracle = null!; private NearbyAudioStreamsRegistry registry = null!; [SetUp] @@ -39,7 +43,10 @@ public void SetUp() room.Participants.Returns(participantsHub); activeSpeakers = new FakeActiveSpeakers(); room.ActiveSpeakers.Returns(activeSpeakers); - registry = new NearbyAudioStreamsRegistry(room); + audioStreams = Substitute.For(); + room.AudioStreams.Returns(audioStreams); + frameOracle = new FrameOracleStub(); + registry = new NearbyAudioStreamsRegistry(room, frameOracle.Get); } [TearDown] @@ -153,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] @@ -337,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++; @@ -373,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] @@ -397,57 +403,110 @@ 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 HasAudioStreamReturnsFalseAfterLastSidUnsubscribed() + { + RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); + RaiseTrackUnsubscribed(WALLET_A, SID_1); + + Assert.That(registry.HasAudioStream(WALLET_A), Is.False); + Assert.That(registry.GetActiveSid(WALLET_A), Is.Null); + } + + // ── 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)); + Assert.That(frameOracle.Calls, Is.Zero, "single-sid hot path must not consult the frame oracle"); + } [Test] - public void SubscribingThenUnsubscribingSameSidProducesDistinctArrayReferences() + public void ReturnNullActiveSidWhenAllCandidatesAreGhosts() { RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); - string[] ref1 = registry.GetAudioSidsArray(WALLET_A)!; + RaiseTrackSubscribed(WALLET_A, SID_2, TrackKind.KindAudio); + // Both stay at the default Option.None — 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); - string[] ref2 = registry.GetAudioSidsArray(WALLET_A)!; - RaiseTrackUnsubscribed(WALLET_A, SID_1); - string[] ref3 = registry.GetAudioSidsArray(WALLET_A)!; + frameOracle.Set(new StreamKey(WALLET_A, SID_2), 100); + // SID_1 keeps the default Option.None (ghost). - 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"); + Assert.That(registry.GetActiveSid(WALLET_A), Is.EqualTo(SID_2)); } [Test] - public void SameSidSetObservedTwiceReturnsSameReference() + public void PickTheNewestCandidateWhenSeveralAreLive() { RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); + RaiseTrackSubscribed(WALLET_A, SID_2, TrackKind.KindAudio); - string[]? a = registry.GetAudioSidsArray(WALLET_A); - string[]? b = registry.GetAudioSidsArray(WALLET_A); + frameOracle.Set(new StreamKey(WALLET_A, SID_1), 100); + frameOracle.Set(new StreamKey(WALLET_A, SID_2), 200); - 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"); + Assert.That(registry.GetActiveSid(WALLET_A), Is.EqualTo(SID_2)); } [Test] - public void IsStreamGoneReturnsTrueAfterLastSidUnsubscribed() + public void TreatTickCounterWrapAroundAsNewerNotOlder() { RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); - RaiseTrackUnsubscribed(WALLET_A, SID_1); + RaiseTrackSubscribed(WALLET_A, SID_2, TrackKind.KindAudio); - Assert.That(registry.IsStreamGone(new StreamKey(WALLET_A, SID_1)), Is.True); - Assert.That(registry.HasAudioStream(WALLET_A), Is.False); + // Pre-wrap (older), positive end of the range. + frameOracle.Set(new StreamKey(WALLET_A, SID_1), int.MaxValue - 50); + // Post-wrap (newer in unchecked arithmetic), negative end of the range. + 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"); + } + + [Test] + public void IsActiveSidTrueForWinnerFalseForGhostLoser() + { + RaiseTrackSubscribed(WALLET_A, SID_1, TrackKind.KindAudio); + RaiseTrackSubscribed(WALLET_A, SID_2, TrackKind.KindAudio); + + 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); + } + + [Test] + public void IsActiveSidFalseWhenWalletHasNoSids() + { + Assert.That(registry.IsActiveSid(new StreamKey("0xUNKNOWN", SID_1)), Is.False); } [Test] @@ -474,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(new StreamKey(walletId, sid)); private void RaiseTrackSubscribed(string identity, string sid, TrackKind kind, TrackSource source = TrackSource.SourceMicrophone) { @@ -565,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/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyLivekitBridgeSystemShould.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/EditMode/NearbyLivekitBridgeSystemShould.cs index b17bcc5628b..423056926f1 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,148 @@ 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(world.Get(e).CurrentSid, Is.EqualTo("sid-1"), + "stable resolver → CurrentSid must remain unchanged across ticks"); + } + + [Test] + 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); + + // 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(world.Has(e), Is.False, "precondition: no component while resolver returns null"); + + // Resolver: sid-42 on tick 2. + registry.GetActiveSid(WALLET).Returns(SID_42); + registry.HasAudioStream(WALLET).Returns(true); + + system.Update(0); + + 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 T8a_PreservesComponentDuringAllZerosWindow() + { + // 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); + + 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 }); + + // Resolver enters the 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(ReferenceEquals(world.Get(e).StreamSidsSnapshot, sids), Is.True, - "stable registry → SidsSnapshot reference must remain unchanged across ticks"); + 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"); } [Test] - public void UpdateStreamingRefreshesReferenceWhenRegistryArrayChanged() + 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); - string[] firstRef = StubStreaming(WALLET, "sid-1"); + + StubStreaming(WALLET, "sid-1"); + registry.IsActiveSpeaker(WALLET).Returns(true); system.Update(0); - Assert.That(ReferenceEquals(world.Get(e).StreamSidsSnapshot, firstRef), Is.True); + 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 }); - // 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); + // Identity disappears entirely. + registry.GetActiveSid(WALLET).Returns((string?)null); + registry.HasAudioStream(WALLET).Returns(false); 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.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 +299,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 +316,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 +331,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 +356,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 +406,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 +443,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 609ac07ae11..e40b4c3e66a 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 ────────────────────────── @@ -313,32 +310,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,26 +328,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) => + activeSidByIdentity.TryGetValue(walletId, out string? sid) ? sid : null; + + 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 1006da238a4..c8d23bf6b49 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); @@ -353,7 +350,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 @@ -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) { @@ -522,34 +519,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,21 +541,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) => + activeSidByIdentity.TryGetValue(walletId, out string? sid) ? sid : null; + + public bool IsActiveSid(StreamKey key) => + activeSidByIdentity.TryGetValue(key.identity, out string? active) && active == key.sid; public bool IsActiveSpeaker(string walletId) => activeSpeakers.Contains(walletId); diff --git a/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioPositionHotPathPerformanceTest.cs b/Explorer/Assets/DCL/VoiceChat/NearbyVoiceChat/Tests/PerformanceTests/NearbyAudioPositionHotPathPerformanceTest.cs index 8a78cc2d33b..ddbe67b638b 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 6cd8b271425..67966164ea5 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) diff --git a/Explorer/Packages/packages-lock.json b/Explorer/Packages/packages-lock.json index ce28bcdda9c..98aae04144d 100644 --- a/Explorer/Packages/packages-lock.json +++ b/Explorer/Packages/packages-lock.json @@ -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": "9caeba7fb88547062f227b2e246b18b6927e4435" }, "com.decentraland.renum": { "version": "https://github.com/NickKhalow/REnum.git?path=REnum",