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); }