feat(engine): surface Teams Active Speaker as a routable participant
Some checks failed
CI / build-and-test (push) Failing after 28s

ParticipantTracker now accepts NdiSourceKind.ActiveSpeaker (Teams' auto-mix output — legacy 'MACHINE (Teams)' or current 'MACHINE (MS Teams - Active Speaker)') and surfaces it as a synthetic row in the participant list with the display name 'Active Speaker'. The operator can route it to its own normalized ISO via the same toggle every other participant uses, so vMix / OBS / Ross can subscribe to a single clean active-speaker feed.

Stable Id: derived from SHA1 of 'auto-mix:<machine>' formatted as a v5 GUID, so a discovery cycle that re-adds the source doesn't duplicate the row and the operator's ISO assignment stays bound across the rename window.

Tests: 78/78 unit (was 76) — added ParticipantTrackerTests.ActiveSpeaker_AppearsAsSyntheticAutoMixParticipant + ActiveSpeaker_ReAddOnSameMachine_PreservesId. Existing NonParticipantSources_AreIgnored still passes (only ActiveSpeaker is opted in; ScreenShare and Audio are still ignored).
This commit is contained in:
Zac Gaetano 2026-05-09 09:25:45 -04:00
parent ab072979d8
commit 778e5163e9
2 changed files with 87 additions and 0 deletions

View file

@ -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));
}
/// <summary>
/// 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:&lt;machine&gt;" so the row's identity is
/// stable across discovery churn.
/// </summary>
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));
}
/// <summary>
/// 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).
/// </summary>
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();

View file

@ -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:<machine>") 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);
}
}