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);
+ }
}