diff --git a/src/TeamsISO.Engine/Discovery/NdiSourceParser.cs b/src/TeamsISO.Engine/Discovery/NdiSourceParser.cs
new file mode 100644
index 0000000..565ac84
--- /dev/null
+++ b/src/TeamsISO.Engine/Discovery/NdiSourceParser.cs
@@ -0,0 +1,70 @@
+using TeamsISO.Engine.Domain;
+
+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
+/// "MACHINE (Teams Audio)" — audio-only mix
+/// "MACHINE (Teams Screen Share)" — screen share
+///
+public static class NdiSourceParser
+{
+ public static NdiSource? Parse(string fullName)
+ {
+ if (string.IsNullOrWhiteSpace(fullName))
+ return null;
+
+ // Find the last '(' so machine names can themselves contain parens.
+ var openParen = fullName.LastIndexOf('(');
+ if (openParen <= 0 || !fullName.EndsWith(')'))
+ {
+ // 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 machine = fullName[..openParen].TrimEnd();
+ if (machine.Length == 0)
+ return null;
+
+ var inner = fullName.Substring(openParen + 1, fullName.Length - openParen - 2).Trim();
+
+ if (!inner.StartsWith("Teams", StringComparison.Ordinal))
+ return null;
+
+ if (inner == "Teams")
+ 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))
+ {
+ var display = inner[prefix.Length..].Trim();
+ if (display.Length == 0)
+ return null;
+ return new NdiSource(fullName, machine, NdiSourceKind.Participant, display);
+ }
+
+ return null;
+ }
+}
diff --git a/src/TeamsISO.Engine/Domain/NdiSource.cs b/src/TeamsISO.Engine/Domain/NdiSource.cs
new file mode 100644
index 0000000..2394db0
--- /dev/null
+++ b/src/TeamsISO.Engine/Domain/NdiSource.cs
@@ -0,0 +1,10 @@
+namespace TeamsISO.Engine.Domain;
+
+///
+/// Raw discovery record parsed from an NDI source string emitted by Microsoft Teams.
+///
+public sealed record NdiSource(
+ string FullName,
+ string MachineName,
+ NdiSourceKind Kind,
+ string? DisplayName);
diff --git a/src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs b/src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs
new file mode 100644
index 0000000..8268d06
--- /dev/null
+++ b/src/tests/TeamsISO.Engine.Tests/Domain/NdiSourceParserTests.cs
@@ -0,0 +1,46 @@
+using TeamsISO.Engine.Discovery;
+using TeamsISO.Engine.Domain;
+
+namespace TeamsISO.Engine.Tests.Domain;
+
+public class NdiSourceParserTests
+{
+ [Theory]
+ [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)")]
+ public void Parse_Participant_ExtractsMachineAndDisplayName(
+ string fullName, string expectedMachine, NdiSourceKind expectedKind, string expectedDisplay)
+ {
+ var result = NdiSourceParser.Parse(fullName);
+
+ result.Should().NotBeNull();
+ result!.MachineName.Should().Be(expectedMachine);
+ result.Kind.Should().Be(expectedKind);
+ result.DisplayName.Should().Be(expectedDisplay);
+ result.FullName.Should().Be(fullName);
+ }
+
+ [Theory]
+ [InlineData("HOST (Teams)", NdiSourceKind.ActiveSpeaker)]
+ [InlineData("HOST (Teams Audio)", NdiSourceKind.Audio)]
+ [InlineData("HOST (Teams Screen Share)", NdiSourceKind.ScreenShare)]
+ public void Parse_NonParticipantKinds_ClassifyCorrectly(string fullName, NdiSourceKind expectedKind)
+ {
+ var result = NdiSourceParser.Parse(fullName);
+
+ result.Should().NotBeNull();
+ result!.Kind.Should().Be(expectedKind);
+ result.DisplayName.Should().BeNull();
+ }
+
+ [Theory]
+ [InlineData("Plain NDI Source")]
+ [InlineData("HOST (Some Other Software)")]
+ [InlineData("(Teams - No Machine)")]
+ public void Parse_NonTeamsSource_ReturnsNull(string fullName)
+ {
+ var result = NdiSourceParser.Parse(fullName);
+ result.Should().BeNull();
+ }
+}