diff --git a/src/TeamsISO.Engine/Discovery/ParticipantTracker.cs b/src/TeamsISO.Engine/Discovery/ParticipantTracker.cs
index df8b8b4..ca23971 100644
--- a/src/TeamsISO.Engine/Discovery/ParticipantTracker.cs
+++ b/src/TeamsISO.Engine/Discovery/ParticipantTracker.cs
@@ -38,7 +38,7 @@ public sealed class ParticipantTracker
HandleRemoved(r.Source);
break;
case DiscoveryEvent.Removed r when r.Source.Kind == NdiSourceKind.ActiveSpeaker:
- HandleRemoved(r.Source);
+ HandleAutoMixRemoved(r.Source);
break;
}
}
@@ -125,6 +125,23 @@ public sealed class ParticipantTracker
_recentlyRemoved.Add(new RecentlyRemoved(existing.Id, source.MachineName, now));
}
+ ///
+ /// Removes the synthetic auto-mix row's CurrentSource. Crucially does NOT add to
+ /// — the auto-mix row's identity is already stable
+ /// via the deterministic v5 GUID, so re-add restores it without needing the
+ /// rename-window heuristic, and we must not let an active-speaker disappearance
+ /// poison the rename matcher for a Participant joining the same machine within
+ /// the window.
+ ///
+ private void HandleAutoMixRemoved(NdiSource source)
+ {
+ var stableId = DeterministicGuid("auto-mix:" + source.MachineName);
+ var existing = _participants.FirstOrDefault(p => p.Id == stableId);
+ if (existing is null) return;
+ existing.CurrentSource = null;
+ existing.LastSeen = _now();
+ }
+
private void PruneRecentlyRemoved(DateTimeOffset now)
{
_recentlyRemoved.RemoveAll(rr => now - rr.RemovedAt > _renameWindow);
diff --git a/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs b/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs
index 76e43bd..a6f4c1a 100644
--- a/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs
+++ b/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs
@@ -45,7 +45,15 @@ public sealed class IsoPipeline : IAsyncDisposable
private readonly object _frameTimesGate = new();
public Guid ParticipantId { get; }
- public IsoState State { get; private set; } = IsoState.Idle;
+
+ // Backing field for State, accessed via Volatile.Read/Write so the supervisor
+ // loop's writes are observed promptly by the UI thread's stats poll.
+ private int _state = (int)IsoState.Idle;
+ public IsoState State
+ {
+ get => (IsoState)Volatile.Read(ref _state);
+ private set => Volatile.Write(ref _state, (int)value);
+ }
public int ConsecutiveFailures => _consecutiveFailures;
///
diff --git a/src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs b/src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs
index c125685..385e4df 100644
--- a/src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs
+++ b/src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs
@@ -135,4 +135,32 @@ public class ParticipantTrackerTests
tracker.Participants.Should().HaveCount(1);
tracker.Participants[0].Id.Should().Be(firstId);
}
+
+ [Fact]
+ public void ActiveSpeakerRemove_DoesNotPoisonRenameWindowForLaterParticipant()
+ {
+ // Regression: the rename-window heuristic matches by MachineName alone, so a
+ // disappearing ActiveSpeaker source on a machine could cause the next
+ // Participant joining that same machine to inherit the auto-mix's GUID, AND
+ // the auto-mix row would be renamed to the participant's display name.
+ // HandleAutoMixRemoved deliberately skips _recentlyRemoved.
+ var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => T0);
+ var autoMix = new NdiSource("WOOGLIN (MS Teams - Active Speaker)", "WOOGLIN", NdiSourceKind.ActiveSpeaker, null);
+ var jane = new NdiSource("WOOGLIN (MS Teams - Jane)", "WOOGLIN", NdiSourceKind.Participant, "Jane");
+
+ tracker.Apply(new DiscoveryEvent.Added(autoMix));
+ var autoMixId = tracker.Participants[0].Id;
+ tracker.Apply(new DiscoveryEvent.Removed(autoMix));
+ tracker.Apply(new DiscoveryEvent.Added(jane));
+
+ // Two distinct rows: the auto-mix (offline, no source) and Jane (a brand-new participant).
+ tracker.Participants.Should().HaveCount(2);
+ var jp = tracker.Participants.Single(p => p.DisplayName == "Jane");
+ jp.Id.Should().NotBe(autoMixId,
+ because: "Jane is a fresh Participant, not a renamed auto-mix");
+ var asRow = tracker.Participants.Single(p => p.DisplayName == "Active Speaker");
+ asRow.Id.Should().Be(autoMixId);
+ asRow.CurrentSource.Should().BeNull(
+ because: "the auto-mix source went away and hasn't been re-added");
+ }
}