diff --git a/src/TeamsISO.Engine/Discovery/ParticipantTracker.cs b/src/TeamsISO.Engine/Discovery/ParticipantTracker.cs index 3b94340..df8b8b4 100644 --- a/src/TeamsISO.Engine/Discovery/ParticipantTracker.cs +++ b/src/TeamsISO.Engine/Discovery/ParticipantTracker.cs @@ -31,9 +31,15 @@ public sealed class ParticipantTracker case DiscoveryEvent.Added a when a.Source.Kind == NdiSourceKind.Participant: HandleAdded(a.Source); break; + case DiscoveryEvent.Added a when a.Source.Kind == NdiSourceKind.ActiveSpeaker: + HandleAutoMixAdded(a.Source); + break; case DiscoveryEvent.Removed r when r.Source.Kind == NdiSourceKind.Participant: HandleRemoved(r.Source); break; + case DiscoveryEvent.Removed r when r.Source.Kind == NdiSourceKind.ActiveSpeaker: + HandleRemoved(r.Source); + break; } } @@ -61,6 +67,52 @@ public sealed class ParticipantTracker LastSeen: now)); } + /// + /// Surfaces Teams' auto-mix output (the "Active Speaker" feed in current Teams, + /// or the legacy bare "(Teams)" source) as a special row in the participant list + /// with the synthetic display name "Active Speaker". The operator can then route + /// it to its own normalized ISO via the same enable toggle every other participant + /// uses. Id is deterministic from "auto-mix:<machine>" so the row's identity is + /// stable across discovery churn. + /// + private void HandleAutoMixAdded(NdiSource source) + { + var now = _now(); + PruneRecentlyRemoved(now); + + var stableId = DeterministicGuid("auto-mix:" + source.MachineName); + var existing = _participants.FirstOrDefault(p => p.Id == stableId); + if (existing is not null) + { + existing.CurrentSource = source; + existing.LastSeen = now; + return; + } + + _participants.Add(new MutableParticipant( + Id: stableId, + DisplayName: "Active Speaker", + CurrentSource: source, + FirstSeen: now, + LastSeen: now)); + } + + /// + /// Stable Guid derived from a string. Used to give the auto-mix synthetic + /// participant a consistent Id across discovery cycles (so re-add doesn't + /// duplicate the row, and the operator's ISO assignment persists). + /// + private static Guid DeterministicGuid(string input) + { + var hash = System.Security.Cryptography.SHA1.HashData(System.Text.Encoding.UTF8.GetBytes(input)); + var bytes = new byte[16]; + Buffer.BlockCopy(hash, 0, bytes, 0, 16); + // Set the version (5) and variant bits per RFC 4122 so the value is a valid v5 GUID. + bytes[6] = (byte)((bytes[6] & 0x0F) | 0x50); + bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80); + return new Guid(bytes); + } + private void HandleRemoved(NdiSource source) { var now = _now(); diff --git a/src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs b/src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs index 0892fab..c125685 100644 --- a/src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs +++ b/src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs @@ -100,4 +100,39 @@ public class ParticipantTrackerTests tracker.Participants.Should().BeEmpty(); } + + [Fact] + public void ActiveSpeaker_AppearsAsSyntheticAutoMixParticipant() + { + // The ActiveSpeaker kind (legacy "MACHINE (Teams)" or current + // "MACHINE (MS Teams - Active Speaker)") is now surfaced as a row in the + // participant list with a synthetic display name "Active Speaker", so the + // operator can route it to its own normalized ISO via the same toggle as + // any participant. + var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => T0); + var autoMix = new NdiSource("WOOGLIN (MS Teams - Active Speaker)", "WOOGLIN", NdiSourceKind.ActiveSpeaker, null); + + tracker.Apply(new DiscoveryEvent.Added(autoMix)); + + tracker.Participants.Should().HaveCount(1); + tracker.Participants[0].DisplayName.Should().Be("Active Speaker"); + tracker.Participants[0].CurrentSource!.FullName.Should().Be(autoMix.FullName); + } + + [Fact] + public void ActiveSpeaker_ReAddOnSameMachine_PreservesId() + { + // The synthetic auto-mix participant must use a deterministic Id (derived + // from "auto-mix:") so a discovery cycle that re-adds the source + // doesn't duplicate the row and the operator's ISO assignment stays bound. + var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => T0); + var autoMix = new NdiSource("WOOGLIN (MS Teams - Active Speaker)", "WOOGLIN", NdiSourceKind.ActiveSpeaker, null); + + tracker.Apply(new DiscoveryEvent.Added(autoMix)); + var firstId = tracker.Participants[0].Id; + tracker.Apply(new DiscoveryEvent.Added(autoMix)); + + tracker.Participants.Should().HaveCount(1); + tracker.Participants[0].Id.Should().Be(firstId); + } }