feat(ndi): plumb NDI groups (discovery + output) through the engine

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.
This commit is contained in:
Zac Gaetano 2026-05-07 23:48:49 -04:00
parent fa8d2a8fad
commit 909237f454
13 changed files with 235 additions and 23 deletions

View file

@ -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<NdiNative.FindCreateSettings>());
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<string> 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 ?? "<default>"}'.");
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);
}
}

View file

@ -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;
}
/// <summary>
/// Mirrors <c>NDIlib_find_create_t</c>. Used to constrain which NDI groups a
/// finder will discover sources from. Passing NULL/<c>IntPtr.Zero</c> for the
/// settings pointer (or a struct with all fields zero) yields default behavior:
/// <c>show_local_sources=true</c>, groups=<c>"Public"</c>, no extra IPs.
/// </summary>
[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
{

View file

@ -17,6 +17,9 @@ public interface IIsoController : IAsyncDisposable
/// <summary>Current global processing settings.</summary>
FrameProcessingSettings GlobalSettings { get; }
/// <summary>Current NDI group settings (discovery + output groups).</summary>
NdiGroupSettings GroupSettings { get; }
/// <summary>Starts discovery and supervises the runtime probe. Returns once startup is complete.</summary>
Task StartAsync(CancellationToken cancellationToken);
@ -31,4 +34,10 @@ public interface IIsoController : IAsyncDisposable
/// <summary>Updates global processing settings and persists them. Currently does not restart running pipelines (Phase C wires that).</summary>
Task SetGlobalSettingsAsync(FrameProcessingSettings settings, CancellationToken cancellationToken);
/// <summary>
/// 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.
/// </summary>
Task SetGroupSettingsAsync(NdiGroupSettings groupSettings, CancellationToken cancellationToken);
}

View file

@ -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<IReadOnlyList<Participant>> Participants => _participants.AsObservable();
public IObservable<EngineAlert> 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<DiscoveryEvent>();
_discovery = new NdiDiscoveryService(
interop, _discoveryChannel.Writer,
loggerFactory.CreateLogger<NdiDiscoveryService>());
loggerFactory.CreateLogger<NdiDiscoveryService>(),
_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);
}
/// <summary>
/// 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.
/// </summary>
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<IsoAssignment> 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)
{

View file

@ -20,12 +20,13 @@ public sealed class NdiDiscoveryService
public NdiDiscoveryService(
INdiInterop interop,
ChannelWriter<DiscoveryEvent> writer,
ILogger<NdiDiscoveryService> logger)
ILogger<NdiDiscoveryService> logger,
string? discoveryGroups = null)
{
_interop = interop;
_writer = writer;
_logger = logger;
_finder = interop.CreateFinder();
_finder = interop.CreateFinder(discoveryGroups);
}
/// <summary>

View file

@ -2,8 +2,12 @@ namespace TeamsISO.Engine.Domain;
public sealed record EngineConfig(
FrameProcessingSettings Global,
IReadOnlyList<IsoAssignment> Assignments)
IReadOnlyList<IsoAssignment> Assignments,
NdiGroupSettings? NdiGroups = null)
{
public static readonly EngineConfig Default =
new(FrameProcessingSettings.Default, Array.Empty<IsoAssignment>());
new(FrameProcessingSettings.Default, Array.Empty<IsoAssignment>(), NdiGroupSettings.Default);
/// <summary>Returns <see cref="NdiGroups"/> or <see cref="NdiGroupSettings.Default"/> when null (legacy configs).</summary>
public NdiGroupSettings GroupsOrDefault => NdiGroups ?? NdiGroupSettings.Default;
}

View file

@ -0,0 +1,27 @@
namespace TeamsISO.Engine.Domain;
/// <summary>
/// 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 <c>"Public"</c> group consumed by the switcher.
///
/// Both fields are comma-separated lists of NDI group names. Whitespace around commas
/// is tolerated. <c>null</c> or whitespace means "use the NDI default", which is the
/// implicit <c>"Public"</c> group.
/// </summary>
/// <param name="DiscoveryGroups">
/// 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.
/// <c>"teamsiso-input"</c>) when you want to hide raw Teams outputs from the rest of
/// the network.
/// </param>
/// <param name="OutputGroups">
/// Groups TeamsISO's own ISO senders broadcast on. Leave at the default for the
/// normal case where downstream switchers receive over the standard
/// <c>"Public"</c> group; override only for split-audience setups.
/// </param>
public sealed record NdiGroupSettings(string? DiscoveryGroups, string? OutputGroups)
{
public static readonly NdiGroupSettings Default = new(null, null);
}

View file

@ -9,7 +9,15 @@ namespace TeamsISO.Engine.Interop;
public interface INdiInterop
{
// ----- Discovery -----
NdiFindHandle CreateFinder();
/// <summary>
/// Creates an NDI finder that observes sources advertised on the given groups.
/// </summary>
/// <param name="groups">
/// Comma-separated list of NDI group names to subscribe to. <c>null</c> or empty
/// uses the NDI default (<c>"Public"</c>). Whitespace around commas is tolerated.
/// </param>
NdiFindHandle CreateFinder(string? groups = null);
/// <summary>Snapshots the currently-known sources visible to the finder.</summary>
IReadOnlyList<string> GetCurrentSources(NdiFindHandle finder);
@ -24,7 +32,17 @@ public interface INdiInterop
RawFrame? CaptureFrame(NdiReceiverHandle receiver, int timeoutMs);
// ----- Send -----
NdiSenderHandle CreateSender(string outputName);
/// <summary>
/// Creates an NDI sender broadcasting on the given groups.
/// </summary>
/// <param name="outputName">The NDI source name segment (the part inside parens).</param>
/// <param name="groups">
/// Comma-separated list of NDI group names to broadcast on. <c>null</c> or empty
/// uses the NDI default (<c>"Public"</c>).
/// </param>
NdiSenderHandle CreateSender(string outputName, string? groups = null);
void SendFrame(NdiSenderHandle sender, ProcessedFrame frame);
// ----- Runtime probe -----

View file

@ -164,7 +164,8 @@ public sealed class IsoPipeline : IAsyncDisposable
using var receiver = new NdiReceiver(
interop, config.SourceName, rawChannel.Writer, loggerFactory.CreateLogger<NdiReceiver>());
using var sender = new NdiSender(
interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger<NdiSender>());
interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger<NdiSender>(),
config.OutputGroups);
var processor = new FrameProcessor(
config.Settings, scaler, new SolidFrameRenderer(),

View file

@ -20,4 +20,11 @@ public sealed record IsoPipelineConfig(
/// <summary>Bounded processed-frame channel capacity.</summary>
public int ProcessedChannelCapacity { get; init; } = 2;
/// <summary>
/// NDI groups (comma-separated) the output sender broadcasts on. <c>null</c> 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.
/// </summary>
public string? OutputGroups { get; init; }
}

View file

@ -20,13 +20,14 @@ public sealed class NdiSender : IDisposable
INdiInterop interop,
string outputName,
ChannelReader<ProcessedFrame> input,
ILogger<NdiSender> logger)
ILogger<NdiSender> 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);

View file

@ -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<IsoAssignment>(),
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<IsoAssignment>(),
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()
{

View file

@ -17,7 +17,16 @@ public sealed class FakeNdiInterop : INdiInterop
public Dictionary<string, int> ReceiverCreatedCount { get; } = new();
public Dictionary<string, int> SenderCreatedCount { get; } = new();
public NdiFindHandle CreateFinder() => new FakeFindHandle();
/// <summary>Last <c>groups</c> string seen by <see cref="CreateFinder"/>; null = default Public.</summary>
public string? LastFinderGroups { get; private set; }
/// <summary>Per-output <c>groups</c> string seen by <see cref="CreateSender"/>; null = default Public.</summary>
public Dictionary<string, string?> SenderGroups { get; } = new();
public NdiFindHandle CreateFinder(string? groups = null)
{
LastFinderGroups = groups;
return new FakeFindHandle();
}
public IReadOnlyList<string> 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<ProcessedFrame>());
return new FakeSenderHandle(outputName);
}