feat(engine): surface Teams Active Speaker as a routable participant
Some checks failed
CI / build-and-test (push) Failing after 28s
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:
parent
ab072979d8
commit
778e5163e9
2 changed files with 87 additions and 0 deletions
|
|
@ -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:<machine>" 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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue