From ca124540a7d20e944fa05736b8b1ce9571c203fc Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Thu, 7 May 2026 23:33:43 -0400 Subject: [PATCH] fix(parser): accept 'MS Teams' brand prefix from current Teams NDI broadcasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new Microsoft Teams desktop client (observed against a live meeting on Teams 26106.1906.4665.7308) emits NDI source strings of the form WOOGLIN (MS Teams - Brendon Power) WOOGLIN (MS Teams - (Local)) WOOGLIN (MS Teams - Active Speaker) rather than the legacy 'MACHINE (Teams - ...)' shape NdiSourceParser was written to. As a result every Teams source was rejected and TeamsISO showed zero participants in real meetings. Refactor the parser to recognize 'Teams', 'MS Teams', and (defensively) 'Microsoft Teams' as brand prefixes — longest first so 'MS Teams' isn't shadowed. Also recognize reserved suffix tokens after a dash ('Active Speaker' / 'Audio' / 'Audio Mix' / 'Screen Share') so the new active-speaker output is correctly classified as ActiveSpeaker rather than misread as a participant named 'Active Speaker'. Tests: kept all legacy cases, added MS Teams + Microsoft Teams variants and the new dash-prefixed reserved-suffix cases. 69/69 unit tests passing; verified end-to-end against a live Teams meeting where TeamsISO.exe now shows '(Local)' and 'Brendon Power' in the Participants DataGrid. --- .../Discovery/NdiSourceParser.cs | 108 +++++++++++------- .../Domain/NdiSourceParserTests.cs | 12 ++ 2 files changed, 81 insertions(+), 39 deletions(-) diff --git a/src/TeamsISO.Engine/Discovery/NdiSourceParser.cs b/src/TeamsISO.Engine/Discovery/NdiSourceParser.cs index 565ac84..dbf2a24 100644 --- a/src/TeamsISO.Engine/Discovery/NdiSourceParser.cs +++ b/src/TeamsISO.Engine/Discovery/NdiSourceParser.cs @@ -5,64 +5,94 @@ namespace TeamsISO.Engine.Discovery; /// /// Parses NDI source strings emitted by Microsoft Teams. /// -/// Examples Teams emits: -/// "MACHINE (Teams - Display Name)" -/// "MACHINE (Teams)" — auto-mixed active speaker +/// Teams has shipped two on-the-wire naming conventions for its NDI broadcast +/// outputs. Both are recognized. +/// +/// Legacy (older Teams desktop): +/// "MACHINE (Teams)" — auto-mixed active-speaker output /// "MACHINE (Teams Audio)" — audio-only mix /// "MACHINE (Teams Screen Share)" — screen share +/// "MACHINE (Teams - Display Name)" — per-participant +/// +/// Current (the new Microsoft Teams desktop client, observed 2026): +/// "MACHINE (MS Teams - Active Speaker)" — auto-mixed active-speaker output +/// "MACHINE (MS Teams - Audio Mix)" — audio-only mix +/// "MACHINE (MS Teams - Screen Share)" — screen share +/// "MACHINE (MS Teams - (Local))" — the local user's own preview +/// "MACHINE (MS Teams - Display Name)" — per-participant +/// +/// "Microsoft Teams" is also accepted as a defensive future-proof brand prefix. /// public static class NdiSourceParser { + /// + /// Brand prefixes we recognize, ordered LONGEST FIRST so that "MS Teams" + /// is matched before the shorter "Teams" substring it contains. + /// + private static readonly string[] BrandPrefixes = { "Microsoft Teams", "MS Teams", "Teams" }; + + /// + /// Reserved suffix tokens that classify a source as a non-participant kind. + /// Includes both legacy spellings ("Audio") and current ("Audio Mix"). + /// + private static readonly Dictionary ReservedSuffixes = new(StringComparer.Ordinal) + { + ["Active Speaker"] = NdiSourceKind.ActiveSpeaker, + ["Audio"] = NdiSourceKind.Audio, + ["Audio Mix"] = NdiSourceKind.Audio, + ["Screen Share"] = NdiSourceKind.ScreenShare, + }; + public static NdiSource? Parse(string fullName) { - if (string.IsNullOrWhiteSpace(fullName)) - return null; + if (string.IsNullOrWhiteSpace(fullName)) return null; + if (!fullName.EndsWith(')')) return null; - // Find the last '(' so machine names can themselves contain parens. - var openParen = fullName.LastIndexOf('('); - if (openParen <= 0 || !fullName.EndsWith(')')) + // Locate " (" by scanning for " (BRAND" with the longest + // brand first. Anchoring this way (rather than to the last '(') means + // machine names that themselves contain parens won't truncate the boundary, + // and display names that contain parens (e.g. "(Local)") survive intact + // because we extract them by string length, not by re-scanning for ')'. + var openParen = -1; + string? brand = null; + foreach (var candidate in BrandPrefixes) { - // Try outer parens for cases like "MACHINE (Teams - Smith, Bob (PM))" - // by walking from the first '(' that opens a "Teams" inner. - var firstParen = fullName.IndexOf(" (Teams", StringComparison.Ordinal); - if (firstParen <= 0) - return null; - openParen = firstParen + 1; - if (!fullName.EndsWith(')')) - return null; - } - else - { - // Re-anchor to the first " (Teams" if present, so display names containing - // their own parens (e.g. "Smith, Bob (PM)") don't get truncated. - var firstParen = fullName.IndexOf(" (Teams", StringComparison.Ordinal); - if (firstParen > 0) - openParen = firstParen + 1; + var needle = " (" + candidate; + var i = fullName.IndexOf(needle, StringComparison.Ordinal); + if (i > 0) + { + openParen = i + 1; + brand = candidate; + break; + } } + if (openParen < 0 || brand is null) return null; var machine = fullName[..openParen].TrimEnd(); - if (machine.Length == 0) - return null; + if (machine.Length == 0) return null; + // inner is the text between '(' and the trailing ')'. var inner = fullName.Substring(openParen + 1, fullName.Length - openParen - 2).Trim(); + if (!inner.StartsWith(brand, StringComparison.Ordinal)) return null; - if (!inner.StartsWith("Teams", StringComparison.Ordinal)) - return null; + var rest = inner[brand.Length..].TrimStart(); - if (inner == "Teams") + // Legacy "MACHINE (Teams)" — bare brand, no suffix → active speaker. + if (rest.Length == 0) return new NdiSource(fullName, machine, NdiSourceKind.ActiveSpeaker, DisplayName: null); - if (inner == "Teams Audio") - return new NdiSource(fullName, machine, NdiSourceKind.Audio, DisplayName: null); - if (inner == "Teams Screen Share") - return new NdiSource(fullName, machine, NdiSourceKind.ScreenShare, DisplayName: null); - const string prefix = "Teams - "; - if (inner.StartsWith(prefix, StringComparison.Ordinal)) + // Legacy: brand followed by a reserved word with no dash, e.g. "Teams Audio". + if (ReservedSuffixes.TryGetValue(rest, out var legacyKind)) + return new NdiSource(fullName, machine, legacyKind, DisplayName: null); + + // Current: brand followed by " - ". + if (rest.StartsWith('-')) { - var display = inner[prefix.Length..].Trim(); - if (display.Length == 0) - return null; - return new NdiSource(fullName, machine, NdiSourceKind.Participant, display); + var afterDash = rest[1..].TrimStart(); + if (afterDash.Length == 0) return null; + if (ReservedSuffixes.TryGetValue(afterDash, out var taggedKind)) + return new NdiSource(fullName, machine, taggedKind, DisplayName: null); + return new NdiSource(fullName, machine, NdiSourceKind.Participant, afterDash); } return null; diff --git a/src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs b/src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs index 8268d06..d9f21f3 100644 --- a/src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs +++ b/src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs @@ -6,9 +6,16 @@ namespace TeamsISO.Engine.Tests.Domain; public class NdiSourceParserTests { [Theory] + // ----- Legacy "Teams" brand (older Teams desktop) ----- [InlineData("WORKSTATION-01 (Teams - Jane Doe)", "WORKSTATION-01", NdiSourceKind.Participant, "Jane Doe")] [InlineData("PROD-PC (Teams - Élise O'Connor)", "PROD-PC", NdiSourceKind.Participant, "Élise O'Connor")] [InlineData("HOST (Teams - Smith, Bob (PM))", "HOST", NdiSourceKind.Participant, "Smith, Bob (PM)")] + // ----- Current "MS Teams" brand (the new Teams desktop client) ----- + [InlineData("WOOGLIN (MS Teams - Brendon Power)", "WOOGLIN", NdiSourceKind.Participant, "Brendon Power")] + [InlineData("WOOGLIN (MS Teams - (Local))", "WOOGLIN", NdiSourceKind.Participant, "(Local)")] + [InlineData("LAB-PC (MS Teams - Smith, Bob (PM))", "LAB-PC", NdiSourceKind.Participant, "Smith, Bob (PM)")] + // ----- Defensive future-proof "Microsoft Teams" brand ----- + [InlineData("WORKSTATION (Microsoft Teams - Alex Rivera)", "WORKSTATION", NdiSourceKind.Participant, "Alex Rivera")] public void Parse_Participant_ExtractsMachineAndDisplayName( string fullName, string expectedMachine, NdiSourceKind expectedKind, string expectedDisplay) { @@ -22,9 +29,14 @@ public class NdiSourceParserTests } [Theory] + // ----- Legacy "Teams" forms ----- [InlineData("HOST (Teams)", NdiSourceKind.ActiveSpeaker)] [InlineData("HOST (Teams Audio)", NdiSourceKind.Audio)] [InlineData("HOST (Teams Screen Share)", NdiSourceKind.ScreenShare)] + // ----- Current "MS Teams" forms (dash-prefixed reserved suffix) ----- + [InlineData("WOOGLIN (MS Teams - Active Speaker)", NdiSourceKind.ActiveSpeaker)] + [InlineData("WOOGLIN (MS Teams - Audio Mix)", NdiSourceKind.Audio)] + [InlineData("WOOGLIN (MS Teams - Screen Share)", NdiSourceKind.ScreenShare)] public void Parse_NonParticipantKinds_ClassifyCorrectly(string fullName, NdiSourceKind expectedKind) { var result = NdiSourceParser.Parse(fullName);