From 909237f45437885c7b85336f2beca1597f85ab98 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Thu, 7 May 2026 23:48:49 -0400 Subject: [PATCH] feat(ndi): plumb NDI groups (discovery + output) through the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an NdiGroupSettings record carrying optional comma-separated NDI group lists for the finder and the senders. Extends INdiInterop.CreateFinder / CreateSender with optional groups arguments and populates NDIlib_find_create_t.p_groups and NDIlib_send_create_t.p_groups via P/Invoke. IsoController reads the settings on construction, threads DiscoveryGroups into NdiDiscoveryService and OutputGroups into IsoPipelineConfig, and exposes SetGroupSettingsAsync for runtime updates (group changes apply on next process restart so live pipelines aren't orphaned). This unblocks the 'transcoder' topology where Teams broadcasts NDI on a private group (e.g. teamsiso-input) and TeamsISO re-emits clean normalized streams on Public — keeping raw, wrong-framerate Teams sources off the production network. EngineConfig schema is JSON-back-compat: existing config.json files (no NdiGroups field) deserialize with NdiGroups=null and load as NdiGroupSettings.Default. UI surface for these settings comes in a follow-up. Tests: 72/72 passing (was 69) — added IsoController coverage that group settings are read from ConfigStore on startup, passed to the finder, threaded into per-pipeline config, and round-trip through SetGroupSettingsAsync/Save/Load. --- .../NdiInteropPInvoke.cs | 60 ++++++++++++++++--- src/TeamsISO.Engine.NdiInterop/NdiNative.cs | 16 ++++- .../Controller/IIsoController.cs | 9 +++ .../Controller/IsoController.cs | 34 ++++++++++- .../Discovery/NdiDiscoveryService.cs | 5 +- src/TeamsISO.Engine/Domain/EngineConfig.cs | 8 ++- .../Domain/NdiGroupSettings.cs | 27 +++++++++ src/TeamsISO.Engine/Interop/INdiInterop.cs | 22 ++++++- src/TeamsISO.Engine/Pipeline/IsoPipeline.cs | 3 +- .../Pipeline/IsoPipelineConfig.cs | 7 +++ src/TeamsISO.Engine/Pipeline/NdiSender.cs | 5 +- .../Controller/IsoControllerTests.cs | 48 +++++++++++++++ .../Fakes/FakeNdiInterop.cs | 14 ++++- 13 files changed, 235 insertions(+), 23 deletions(-) create mode 100644 src/TeamsISO.Engine/Domain/NdiGroupSettings.cs diff --git a/src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs b/src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs index 1c9727f..b75d98f 100644 --- a/src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs +++ b/src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs @@ -37,12 +37,48 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable // ---- Discovery ---- - public NdiFindHandle CreateFinder() + public NdiFindHandle CreateFinder(string? groups = null) { - var native = NdiNative.FindCreateV2(IntPtr.Zero); - if (native == IntPtr.Zero) - throw new InvalidOperationException("NDIlib_find_create_v2 returned null."); - return new NdiPInvokeFindHandle(native); + // Empty/whitespace -> default (Public). Otherwise allocate a UTF-8 buffer + // for the comma-separated group list and pin a settings struct around it. + var trimmed = string.IsNullOrWhiteSpace(groups) ? null : groups!.Trim(); + if (trimmed is null) + { + var nativeDefault = NdiNative.FindCreateV2(IntPtr.Zero); + if (nativeDefault == IntPtr.Zero) + throw new InvalidOperationException("NDIlib_find_create_v2 returned null."); + return new NdiPInvokeFindHandle(nativeDefault); + } + + var groupsUtf8 = Marshal.StringToHGlobalAnsi(trimmed); + try + { + var settings = new NdiNative.FindCreateSettings + { + show_local_sources = true, + p_groups = groupsUtf8, + p_extra_ips = IntPtr.Zero, + }; + var settingsPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); + try + { + Marshal.StructureToPtr(settings, settingsPtr, false); + var native = NdiNative.FindCreateV2(settingsPtr); + if (native == IntPtr.Zero) + throw new InvalidOperationException( + $"NDIlib_find_create_v2 returned null for groups='{trimmed}'."); + _logger.LogInformation("NDI finder created with groups: {Groups}", trimmed); + return new NdiPInvokeFindHandle(native); + } + finally + { + Marshal.FreeHGlobal(settingsPtr); + } + } + finally + { + Marshal.FreeHGlobal(groupsUtf8); + } } public IReadOnlyList GetCurrentSources(NdiFindHandle finder) @@ -150,26 +186,34 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable // ---- Send ---- - public NdiSenderHandle CreateSender(string outputName) + public NdiSenderHandle CreateSender(string outputName, string? groups = null) { + var trimmedGroups = string.IsNullOrWhiteSpace(groups) ? null : groups!.Trim(); var nameUtf8 = Marshal.StringToHGlobalAnsi(outputName); + var groupsUtf8 = trimmedGroups is null + ? IntPtr.Zero + : Marshal.StringToHGlobalAnsi(trimmedGroups); try { var settings = new NdiNative.SendCreateSettings { p_ndi_name = nameUtf8, - p_groups = IntPtr.Zero, + p_groups = groupsUtf8, clock_video = true, clock_audio = false }; var native = NdiNative.SendCreate(ref settings); if (native == IntPtr.Zero) - throw new InvalidOperationException($"NDIlib_send_create returned null for output '{outputName}'."); + throw new InvalidOperationException( + $"NDIlib_send_create returned null for output '{outputName}' on groups '{trimmedGroups ?? ""}'."); + if (trimmedGroups is not null) + _logger.LogInformation("NDI sender '{Output}' created on groups: {Groups}", outputName, trimmedGroups); return new NdiPInvokeSenderHandle(native, outputName); } finally { Marshal.FreeHGlobal(nameUtf8); + if (groupsUtf8 != IntPtr.Zero) Marshal.FreeHGlobal(groupsUtf8); } } diff --git a/src/TeamsISO.Engine.NdiInterop/NdiNative.cs b/src/TeamsISO.Engine.NdiInterop/NdiNative.cs index a3d66b2..02e3c46 100644 --- a/src/TeamsISO.Engine.NdiInterop/NdiNative.cs +++ b/src/TeamsISO.Engine.NdiInterop/NdiNative.cs @@ -147,11 +147,25 @@ internal static class NdiNative public struct SendCreateSettings { public IntPtr p_ndi_name; // const char* - public IntPtr p_groups; // const char* + public IntPtr p_groups; // const char* (UTF-8, comma-separated; NULL = "Public") [MarshalAs(UnmanagedType.U1)] public bool clock_video; [MarshalAs(UnmanagedType.U1)] public bool clock_audio; } + /// + /// Mirrors NDIlib_find_create_t. Used to constrain which NDI groups a + /// finder will discover sources from. Passing NULL/IntPtr.Zero for the + /// settings pointer (or a struct with all fields zero) yields default behavior: + /// show_local_sources=true, groups="Public", no extra IPs. + /// + [StructLayout(LayoutKind.Sequential)] + public struct FindCreateSettings + { + [MarshalAs(UnmanagedType.U1)] public bool show_local_sources; + public IntPtr p_groups; // const char* (UTF-8, comma-separated; NULL = "Public") + public IntPtr p_extra_ips; // const char* (UTF-8, comma-separated; NULL = none) + } + [StructLayout(LayoutKind.Sequential)] public struct VideoFrameV2 { diff --git a/src/TeamsISO.Engine/Controller/IIsoController.cs b/src/TeamsISO.Engine/Controller/IIsoController.cs index 384f976..5c16d31 100644 --- a/src/TeamsISO.Engine/Controller/IIsoController.cs +++ b/src/TeamsISO.Engine/Controller/IIsoController.cs @@ -17,6 +17,9 @@ public interface IIsoController : IAsyncDisposable /// Current global processing settings. FrameProcessingSettings GlobalSettings { get; } + /// Current NDI group settings (discovery + output groups). + NdiGroupSettings GroupSettings { get; } + /// Starts discovery and supervises the runtime probe. Returns once startup is complete. Task StartAsync(CancellationToken cancellationToken); @@ -31,4 +34,10 @@ public interface IIsoController : IAsyncDisposable /// Updates global processing settings and persists them. Currently does not restart running pipelines (Phase C wires that). Task SetGlobalSettingsAsync(FrameProcessingSettings settings, CancellationToken cancellationToken); + + /// + /// Updates the NDI group configuration and persists it. Group changes apply on next process + /// restart — rebuilding finder/sender handles mid-flight would orphan running pipelines. + /// + Task SetGroupSettingsAsync(NdiGroupSettings groupSettings, CancellationToken cancellationToken); } diff --git a/src/TeamsISO.Engine/Controller/IsoController.cs b/src/TeamsISO.Engine/Controller/IsoController.cs index be26051..c530599 100644 --- a/src/TeamsISO.Engine/Controller/IsoController.cs +++ b/src/TeamsISO.Engine/Controller/IsoController.cs @@ -35,6 +35,7 @@ public sealed class IsoController : IIsoController private readonly object _gate = new(); private FrameProcessingSettings _settings; + private NdiGroupSettings _groupSettings; private CancellationTokenSource? _cts; private Task? _discoveryTask; private Task? _eventPumpTask; @@ -42,6 +43,7 @@ public sealed class IsoController : IIsoController public IObservable> Participants => _participants.AsObservable(); public IObservable Alerts => _alerts.AsObservable(); public FrameProcessingSettings GlobalSettings { get { lock (_gate) return _settings; } } + public NdiGroupSettings GroupSettings { get { lock (_gate) return _groupSettings; } } public IsoController( INdiInterop interop, @@ -63,12 +65,14 @@ public sealed class IsoController : IIsoController var loaded = configStore.Load(); _settings = loaded.Global; + _groupSettings = loaded.GroupsOrDefault; _tracker = new ParticipantTracker(_renameWindow, clock ?? (() => DateTimeOffset.UtcNow)); _discoveryChannel = Channel.CreateUnbounded(); _discovery = new NdiDiscoveryService( interop, _discoveryChannel.Writer, - loggerFactory.CreateLogger()); + loggerFactory.CreateLogger(), + _groupSettings.DiscoveryGroups); } public async Task StartAsync(CancellationToken cancellationToken) @@ -113,7 +117,17 @@ public sealed class IsoController : IIsoController throw new InvalidOperationException($"Participant {participantId} not currently visible on the network."); var output = customName ?? DefaultOutputName(participantId); - var config = new IsoPipelineConfig(participantId, p.CurrentSource.FullName, output, _settings); + string? outputGroups; + FrameProcessingSettings settingsSnapshot; + lock (_gate) + { + outputGroups = _groupSettings.OutputGroups; + settingsSnapshot = _settings; + } + var config = new IsoPipelineConfig(participantId, p.CurrentSource.FullName, output, settingsSnapshot) + { + OutputGroups = outputGroups, + }; var pipeline = _pipelineFactory(config); lock (_gate) _pipelines[participantId] = pipeline; @@ -139,19 +153,33 @@ public sealed class IsoController : IIsoController return PersistAssignmentsAsync(cancellationToken); } + /// + /// Updates the NDI group configuration. Note: existing finder/sender handles aren't + /// rebuilt — group changes take effect on the next process restart, since rebuilding + /// the live finder mid-flight would orphan in-flight participants. The settings + /// panel surfaces this caveat to the user. + /// + public Task SetGroupSettingsAsync(NdiGroupSettings groupSettings, CancellationToken cancellationToken) + { + lock (_gate) _groupSettings = groupSettings; + return PersistAssignmentsAsync(cancellationToken); + } + private Task PersistAssignmentsAsync(CancellationToken cancellationToken) { try { FrameProcessingSettings settings; + NdiGroupSettings groupSettings; IReadOnlyList assignments; lock (_gate) { settings = _settings; + groupSettings = _groupSettings; assignments = _pipelines.Keys.Select(id => new IsoAssignment(id, IsEnabled: true, CustomOutputName: null)).ToArray(); } - _configStore.Save(new EngineConfig(settings, assignments)); + _configStore.Save(new EngineConfig(settings, assignments, groupSettings)); } catch (Exception ex) { diff --git a/src/TeamsISO.Engine/Discovery/NdiDiscoveryService.cs b/src/TeamsISO.Engine/Discovery/NdiDiscoveryService.cs index 9031ca6..37e8804 100644 --- a/src/TeamsISO.Engine/Discovery/NdiDiscoveryService.cs +++ b/src/TeamsISO.Engine/Discovery/NdiDiscoveryService.cs @@ -20,12 +20,13 @@ public sealed class NdiDiscoveryService public NdiDiscoveryService( INdiInterop interop, ChannelWriter writer, - ILogger logger) + ILogger logger, + string? discoveryGroups = null) { _interop = interop; _writer = writer; _logger = logger; - _finder = interop.CreateFinder(); + _finder = interop.CreateFinder(discoveryGroups); } /// diff --git a/src/TeamsISO.Engine/Domain/EngineConfig.cs b/src/TeamsISO.Engine/Domain/EngineConfig.cs index 6b37927..eb2a09c 100644 --- a/src/TeamsISO.Engine/Domain/EngineConfig.cs +++ b/src/TeamsISO.Engine/Domain/EngineConfig.cs @@ -2,8 +2,12 @@ namespace TeamsISO.Engine.Domain; public sealed record EngineConfig( FrameProcessingSettings Global, - IReadOnlyList Assignments) + IReadOnlyList Assignments, + NdiGroupSettings? NdiGroups = null) { public static readonly EngineConfig Default = - new(FrameProcessingSettings.Default, Array.Empty()); + new(FrameProcessingSettings.Default, Array.Empty(), NdiGroupSettings.Default); + + /// Returns or when null (legacy configs). + public NdiGroupSettings GroupsOrDefault => NdiGroups ?? NdiGroupSettings.Default; } diff --git a/src/TeamsISO.Engine/Domain/NdiGroupSettings.cs b/src/TeamsISO.Engine/Domain/NdiGroupSettings.cs new file mode 100644 index 0000000..3ae230a --- /dev/null +++ b/src/TeamsISO.Engine/Domain/NdiGroupSettings.cs @@ -0,0 +1,27 @@ +namespace TeamsISO.Engine.Domain; + +/// +/// Network-layer NDI configuration. Lets the operator place TeamsISO inside an NDI +/// "transcoder" topology where the upstream (Teams) outputs are confined to a private +/// group so they don't pollute the production network, while TeamsISO's own normalized +/// outputs broadcast on the standard "Public" group consumed by the switcher. +/// +/// Both fields are comma-separated lists of NDI group names. Whitespace around commas +/// is tolerated. null or whitespace means "use the NDI default", which is the +/// implicit "Public" group. +/// +/// +/// Groups the engine's finder subscribes to when enumerating sources. Set this to +/// the private group your Teams machine is configured to broadcast on (e.g. +/// "teamsiso-input") when you want to hide raw Teams outputs from the rest of +/// the network. +/// +/// +/// Groups TeamsISO's own ISO senders broadcast on. Leave at the default for the +/// normal case where downstream switchers receive over the standard +/// "Public" group; override only for split-audience setups. +/// +public sealed record NdiGroupSettings(string? DiscoveryGroups, string? OutputGroups) +{ + public static readonly NdiGroupSettings Default = new(null, null); +} diff --git a/src/TeamsISO.Engine/Interop/INdiInterop.cs b/src/TeamsISO.Engine/Interop/INdiInterop.cs index f78f5fd..a0da0e6 100644 --- a/src/TeamsISO.Engine/Interop/INdiInterop.cs +++ b/src/TeamsISO.Engine/Interop/INdiInterop.cs @@ -9,7 +9,15 @@ namespace TeamsISO.Engine.Interop; public interface INdiInterop { // ----- Discovery ----- - NdiFindHandle CreateFinder(); + + /// + /// Creates an NDI finder that observes sources advertised on the given groups. + /// + /// + /// Comma-separated list of NDI group names to subscribe to. null or empty + /// uses the NDI default ("Public"). Whitespace around commas is tolerated. + /// + NdiFindHandle CreateFinder(string? groups = null); /// Snapshots the currently-known sources visible to the finder. IReadOnlyList GetCurrentSources(NdiFindHandle finder); @@ -24,7 +32,17 @@ public interface INdiInterop RawFrame? CaptureFrame(NdiReceiverHandle receiver, int timeoutMs); // ----- Send ----- - NdiSenderHandle CreateSender(string outputName); + + /// + /// Creates an NDI sender broadcasting on the given groups. + /// + /// The NDI source name segment (the part inside parens). + /// + /// Comma-separated list of NDI group names to broadcast on. null or empty + /// uses the NDI default ("Public"). + /// + NdiSenderHandle CreateSender(string outputName, string? groups = null); + void SendFrame(NdiSenderHandle sender, ProcessedFrame frame); // ----- Runtime probe ----- diff --git a/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs b/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs index 511fb21..cb7f385 100644 --- a/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs +++ b/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs @@ -164,7 +164,8 @@ public sealed class IsoPipeline : IAsyncDisposable using var receiver = new NdiReceiver( interop, config.SourceName, rawChannel.Writer, loggerFactory.CreateLogger()); using var sender = new NdiSender( - interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger()); + interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger(), + config.OutputGroups); var processor = new FrameProcessor( config.Settings, scaler, new SolidFrameRenderer(), diff --git a/src/TeamsISO.Engine/Pipeline/IsoPipelineConfig.cs b/src/TeamsISO.Engine/Pipeline/IsoPipelineConfig.cs index 272e57c..b4a1212 100644 --- a/src/TeamsISO.Engine/Pipeline/IsoPipelineConfig.cs +++ b/src/TeamsISO.Engine/Pipeline/IsoPipelineConfig.cs @@ -20,4 +20,11 @@ public sealed record IsoPipelineConfig( /// Bounded processed-frame channel capacity. public int ProcessedChannelCapacity { get; init; } = 2; + + /// + /// NDI groups (comma-separated) the output sender broadcasts on. null means + /// "use the NDI default" (Public). Set per pipeline so the controller can drive it + /// from the global NdiGroupSettings without changing this record's identity contract. + /// + public string? OutputGroups { get; init; } } diff --git a/src/TeamsISO.Engine/Pipeline/NdiSender.cs b/src/TeamsISO.Engine/Pipeline/NdiSender.cs index f828f94..97d463d 100644 --- a/src/TeamsISO.Engine/Pipeline/NdiSender.cs +++ b/src/TeamsISO.Engine/Pipeline/NdiSender.cs @@ -20,13 +20,14 @@ public sealed class NdiSender : IDisposable INdiInterop interop, string outputName, ChannelReader input, - ILogger logger) + ILogger logger, + string? outputGroups = null) { _interop = interop; _outputName = outputName; _input = input; _logger = logger; - _handle = interop.CreateSender(outputName); + _handle = interop.CreateSender(outputName, outputGroups); } public long FramesSent => Interlocked.Read(ref _framesSent); diff --git a/src/tests/TeamsISO.Engine.Tests/Controller/IsoControllerTests.cs b/src/tests/TeamsISO.Engine.Tests/Controller/IsoControllerTests.cs index cd2d165..5d90f28 100644 --- a/src/tests/TeamsISO.Engine.Tests/Controller/IsoControllerTests.cs +++ b/src/tests/TeamsISO.Engine.Tests/Controller/IsoControllerTests.cs @@ -122,6 +122,54 @@ public class IsoControllerTests : IDisposable _store.Load().Global.Should().Be(newSettings); } + [Fact] + public async Task Constructor_PassesDiscoveryGroupsToFinder() + { + // Pre-populate the config store with a discovery-groups setting; the controller + // reads it on construction and feeds it into the finder. + _store.Save(new EngineConfig( + FrameProcessingSettings.Default, + Array.Empty(), + new NdiGroupSettings(DiscoveryGroups: "teamsiso-input", OutputGroups: null))); + + await using var controller = NewController(); + + _interop.LastFinderGroups.Should().Be("teamsiso-input"); + controller.GroupSettings.DiscoveryGroups.Should().Be("teamsiso-input"); + } + + [Fact] + public async Task EnableIsoAsync_PassesOutputGroupsToPipelineConfig() + { + _store.Save(new EngineConfig( + FrameProcessingSettings.Default, + Array.Empty(), + new NdiGroupSettings(DiscoveryGroups: null, OutputGroups: "Public,producers"))); + + await using var controller = NewController(); + await controller.StartAsync(CancellationToken.None); + + _interop.Sources.Add("PC1 (Teams - Jane)"); + var pid = await WaitForFirstParticipantAsync(controller); + + await controller.EnableIsoAsync(pid, customName: null, CancellationToken.None); + + _factoryCalls.Should().HaveCount(1); + _factoryCalls[0].OutputGroups.Should().Be("Public,producers"); + } + + [Fact] + public async Task SetGroupSettingsAsync_PersistsToConfigStore() + { + await using var controller = NewController(); + var newGroups = new NdiGroupSettings(DiscoveryGroups: "teamsiso-input", OutputGroups: "Public"); + + await controller.SetGroupSettingsAsync(newGroups, CancellationToken.None); + + controller.GroupSettings.Should().Be(newGroups); + _store.Load().GroupsOrDefault.Should().Be(newGroups); + } + [Fact] public async Task StartAsync_RuntimeProbeMismatch_RaisesAlert() { diff --git a/src/tests/TeamsISO.Engine.Tests/Fakes/FakeNdiInterop.cs b/src/tests/TeamsISO.Engine.Tests/Fakes/FakeNdiInterop.cs index 35ae09c..c38e2e1 100644 --- a/src/tests/TeamsISO.Engine.Tests/Fakes/FakeNdiInterop.cs +++ b/src/tests/TeamsISO.Engine.Tests/Fakes/FakeNdiInterop.cs @@ -17,7 +17,16 @@ public sealed class FakeNdiInterop : INdiInterop public Dictionary ReceiverCreatedCount { get; } = new(); public Dictionary SenderCreatedCount { get; } = new(); - public NdiFindHandle CreateFinder() => new FakeFindHandle(); + /// Last groups string seen by ; null = default Public. + public string? LastFinderGroups { get; private set; } + /// Per-output groups string seen by ; null = default Public. + public Dictionary SenderGroups { get; } = new(); + + public NdiFindHandle CreateFinder(string? groups = null) + { + LastFinderGroups = groups; + return new FakeFindHandle(); + } public IReadOnlyList GetCurrentSources(NdiFindHandle finder) => Sources.ToArray(); public NdiReceiverHandle CreateReceiver(string sourceFullName) @@ -35,9 +44,10 @@ public sealed class FakeNdiInterop : INdiInterop return null; // simulate timeout } - public NdiSenderHandle CreateSender(string outputName) + public NdiSenderHandle CreateSender(string outputName, string? groups = null) { SenderCreatedCount[outputName] = SenderCreatedCount.GetValueOrDefault(outputName) + 1; + SenderGroups[outputName] = groups; SentFrames.GetOrAdd(outputName, _ => new List()); return new FakeSenderHandle(outputName); }