Compare commits

..

No commits in common. "fa8d2a8fadf0236d5577b7b1eea4e6b7f6b7c721" and "d90ebb826f7849173f49e027852532d59c651611" have entirely different histories.

3 changed files with 39 additions and 122 deletions

View file

@ -1,5 +1,4 @@
using System.Reactive.Linq; using System.Reactive.Linq;
using System.Runtime.Versioning;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TeamsISO.Engine.Controller; using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain; using TeamsISO.Engine.Domain;
@ -23,16 +22,12 @@ using SysConsole = System.Console;
/// Usage: /// Usage:
/// teamsiso-console # discover only — print participants as they appear/leave /// teamsiso-console # discover only — print participants as they appear/leave
/// teamsiso-console --enable-all # auto-enable an ISO for every discovered participant /// teamsiso-console --enable-all # auto-enable an ISO for every discovered participant
/// teamsiso-console --list-sources # diagnostic: print every raw NDI source string visible
/// on the network for ~5s, then exit. Useful for debugging
/// why expected Teams sources aren't being classified.
/// </summary> /// </summary>
public static class Program public static class Program
{ {
public static async Task<int> Main(string[] args) public static async Task<int> Main(string[] args)
{ {
var enableAll = args.Contains("--enable-all", StringComparer.OrdinalIgnoreCase); var enableAll = args.Contains("--enable-all", StringComparer.OrdinalIgnoreCase);
var listSources = args.Contains("--list-sources", StringComparer.OrdinalIgnoreCase);
using var loggerFactory = EngineLogging.CreateConsole(LogLevel.Information); using var loggerFactory = EngineLogging.CreateConsole(LogLevel.Information);
var logger = loggerFactory.CreateLogger("TeamsISO.Console"); var logger = loggerFactory.CreateLogger("TeamsISO.Console");
@ -53,11 +48,6 @@ public static class Program
return 2; return 2;
} }
if (listSources)
{
return await RunListSourcesAsync(interop, logger);
}
var configPath = Path.Combine( var configPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"TeamsISO", "config.json"); "TeamsISO", "config.json");
@ -135,35 +125,4 @@ public static class Program
logger.LogInformation("Engine stopped."); logger.LogInformation("Engine stopped.");
return 0; return 0;
} }
/// <summary>
/// Diagnostic mode: enumerates every raw NDI source string visible to the local
/// NDI finder for ~5 seconds, prints each unique one, then exits. Bypasses the
/// <see cref="Discovery.NdiSourceParser"/> entirely, so it surfaces sources that
/// the parser would otherwise reject — useful for confirming the on-the-wire
/// format Teams (or any other producer) is actually emitting.
/// </summary>
[SupportedOSPlatform("windows")]
private static async Task<int> RunListSourcesAsync(NdiInteropPInvoke interop, ILogger logger)
{
const int pollMs = 500;
const int totalMs = 5000;
logger.LogInformation("Listing raw NDI sources for {TotalMs} ms (polling every {PollMs} ms)…", totalMs, pollMs);
using var finder = interop.CreateFinder();
var seen = new SortedSet<string>(StringComparer.Ordinal);
var elapsed = 0;
while (elapsed < totalMs)
{
await Task.Delay(pollMs);
elapsed += pollMs;
foreach (var s in interop.GetCurrentSources(finder))
{
if (seen.Add(s))
logger.LogInformation(" + {Source}", s);
}
}
logger.LogInformation("Done. {Count} unique source(s) seen in {TotalMs} ms.", seen.Count, totalMs);
interop.Dispose();
return 0;
}
} }

View file

@ -5,94 +5,64 @@ namespace TeamsISO.Engine.Discovery;
/// <summary> /// <summary>
/// Parses NDI source strings emitted by Microsoft Teams. /// Parses NDI source strings emitted by Microsoft Teams.
/// ///
/// Teams has shipped two on-the-wire naming conventions for its NDI broadcast /// Examples Teams emits:
/// outputs. Both are recognized. /// "MACHINE (Teams - Display Name)"
/// /// "MACHINE (Teams)" — auto-mixed active speaker
/// Legacy (older Teams desktop):
/// "MACHINE (Teams)" — auto-mixed active-speaker output
/// "MACHINE (Teams Audio)" — audio-only mix /// "MACHINE (Teams Audio)" — audio-only mix
/// "MACHINE (Teams Screen Share)" — screen share /// "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.
/// </summary> /// </summary>
public static class NdiSourceParser public static class NdiSourceParser
{ {
/// <summary>
/// Brand prefixes we recognize, ordered LONGEST FIRST so that "MS Teams"
/// is matched before the shorter "Teams" substring it contains.
/// </summary>
private static readonly string[] BrandPrefixes = { "Microsoft Teams", "MS Teams", "Teams" };
/// <summary>
/// Reserved suffix tokens that classify a source as a non-participant kind.
/// Includes both legacy spellings ("Audio") and current ("Audio Mix").
/// </summary>
private static readonly Dictionary<string, NdiSourceKind> 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) public static NdiSource? Parse(string fullName)
{ {
if (string.IsNullOrWhiteSpace(fullName)) return null; if (string.IsNullOrWhiteSpace(fullName))
if (!fullName.EndsWith(')')) return null; return null;
// Locate "<MACHINE> (<BRAND>" by scanning for " (BRAND" with the longest // Find the last '(' so machine names can themselves contain parens.
// brand first. Anchoring this way (rather than to the last '(') means var openParen = fullName.LastIndexOf('(');
// machine names that themselves contain parens won't truncate the boundary, if (openParen <= 0 || !fullName.EndsWith(')'))
// 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)
{ {
var needle = " (" + candidate; // Try outer parens for cases like "MACHINE (Teams - Smith, Bob (PM))"
var i = fullName.IndexOf(needle, StringComparison.Ordinal); // by walking from the first '(' that opens a "Teams" inner.
if (i > 0) var firstParen = fullName.IndexOf(" (Teams", StringComparison.Ordinal);
{ if (firstParen <= 0)
openParen = i + 1; return null;
brand = candidate; openParen = firstParen + 1;
break; 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;
} }
if (openParen < 0 || brand is null) return null;
var machine = fullName[..openParen].TrimEnd(); 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(); var inner = fullName.Substring(openParen + 1, fullName.Length - openParen - 2).Trim();
if (!inner.StartsWith(brand, StringComparison.Ordinal)) return null;
var rest = inner[brand.Length..].TrimStart(); if (!inner.StartsWith("Teams", StringComparison.Ordinal))
return null;
// Legacy "MACHINE (Teams)" — bare brand, no suffix → active speaker. if (inner == "Teams")
if (rest.Length == 0)
return new NdiSource(fullName, machine, NdiSourceKind.ActiveSpeaker, DisplayName: null); 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);
// Legacy: brand followed by a reserved word with no dash, e.g. "Teams Audio". const string prefix = "Teams - ";
if (ReservedSuffixes.TryGetValue(rest, out var legacyKind)) if (inner.StartsWith(prefix, StringComparison.Ordinal))
return new NdiSource(fullName, machine, legacyKind, DisplayName: null);
// Current: brand followed by " - <suffix>".
if (rest.StartsWith('-'))
{ {
var afterDash = rest[1..].TrimStart(); var display = inner[prefix.Length..].Trim();
if (afterDash.Length == 0) return null; if (display.Length == 0)
if (ReservedSuffixes.TryGetValue(afterDash, out var taggedKind)) return null;
return new NdiSource(fullName, machine, taggedKind, DisplayName: null); return new NdiSource(fullName, machine, NdiSourceKind.Participant, display);
return new NdiSource(fullName, machine, NdiSourceKind.Participant, afterDash);
} }
return null; return null;

View file

@ -6,16 +6,9 @@ namespace TeamsISO.Engine.Tests.Domain;
public class NdiSourceParserTests public class NdiSourceParserTests
{ {
[Theory] [Theory]
// ----- Legacy "Teams" brand (older Teams desktop) -----
[InlineData("WORKSTATION-01 (Teams - Jane Doe)", "WORKSTATION-01", NdiSourceKind.Participant, "Jane Doe")] [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("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)")] [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( public void Parse_Participant_ExtractsMachineAndDisplayName(
string fullName, string expectedMachine, NdiSourceKind expectedKind, string expectedDisplay) string fullName, string expectedMachine, NdiSourceKind expectedKind, string expectedDisplay)
{ {
@ -29,14 +22,9 @@ public class NdiSourceParserTests
} }
[Theory] [Theory]
// ----- Legacy "Teams" forms -----
[InlineData("HOST (Teams)", NdiSourceKind.ActiveSpeaker)] [InlineData("HOST (Teams)", NdiSourceKind.ActiveSpeaker)]
[InlineData("HOST (Teams Audio)", NdiSourceKind.Audio)] [InlineData("HOST (Teams Audio)", NdiSourceKind.Audio)]
[InlineData("HOST (Teams Screen Share)", NdiSourceKind.ScreenShare)] [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) public void Parse_NonParticipantKinds_ClassifyCorrectly(string fullName, NdiSourceKind expectedKind)
{ {
var result = NdiSourceParser.Parse(fullName); var result = NdiSourceParser.Parse(fullName);