using System.Runtime.InteropServices; using System.Runtime.Versioning; using Microsoft.Extensions.Logging; using TeamsISO.Engine.Interop; using TeamsISO.Engine.Pipeline; namespace TeamsISO.Engine.NdiInterop; /// /// Production implementation backed by the NDI 6 SDK. /// Initializes the NDI runtime on construction and tears it down on dispose. /// All frame buffers crossing the managed boundary are copied so the engine never holds /// a reference to NDI-owned memory after the call returns. /// [SupportedOSPlatform("windows")] public sealed class NdiInteropPInvoke : INdiInterop, IDisposable { private readonly ILogger _logger; private bool _initialized; private bool _disposed; public NdiInteropPInvoke(ILogger logger) { _logger = logger; if (!NdiNative.Initialize()) throw new InvalidOperationException( "NDI runtime failed to initialize. Ensure the NDI Runtime is installed and " + "Processing.NDI.Lib.x64.dll is on the application's DLL search path."); _initialized = true; } public string GetRuntimeVersion() { var ptr = NdiNative.Version(); return Marshal.PtrToStringAnsi(ptr) ?? string.Empty; } // ---- Discovery ---- public NdiFindHandle CreateFinder(string? groups = null) { // Empty/whitespace -> default (Public). Otherwise allocate a UTF-8 buffer // for the comma-separated group list and pin a settings struct around it. // // Memory ownership: the NDI SDK's NDIlib_find_create_v2 (and _send_create, // _recv_create_v3) copy the strings out of the settings struct synchronously // before returning — they don't retain pointers into our buffers. This is the // same lifetime contract CreateReceiver / CreateSender below have relied on // since Phase B-2; if it ever turns out to be wrong, those will fail too. The // loopback discovery integration test would catch a regression here. 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) { var pInvokeFinder = (NdiPInvokeFindHandle)finder; var ptr = NdiNative.FindGetCurrentSources(pInvokeFinder.Native, out var count); if (ptr == IntPtr.Zero || count == 0) return Array.Empty(); // The native call returns a pointer to an array of NDIlib_source_t. // Each entry is sizeof(IntPtr)*2 bytes (p_ndi_name + p_url_address). var sourceSize = Marshal.SizeOf(); var result = new string[count]; for (var i = 0; i < count; i++) { var sourcePtr = IntPtr.Add(ptr, i * sourceSize); var src = Marshal.PtrToStructure(sourcePtr); result[i] = Marshal.PtrToStringAnsi(src.p_ndi_name) ?? string.Empty; } return result; } // ---- Receive ---- public NdiReceiverHandle CreateReceiver(string sourceFullName) { var nameUtf8 = Marshal.StringToHGlobalAnsi(sourceFullName); var recvNameUtf8 = Marshal.StringToHGlobalAnsi("TeamsISO"); try { var settings = new NdiNative.RecvCreateV3Settings { source_to_connect_to = new NdiNative.Source { p_ndi_name = nameUtf8, p_url_address = IntPtr.Zero }, color_format = NdiNative.RecvColorFormat.BgrxBgra, bandwidth = NdiNative.RecvBandwidth.Highest, allow_video_fields = false, p_ndi_recv_name = recvNameUtf8 }; var native = NdiNative.RecvCreateV3(ref settings); if (native == IntPtr.Zero) throw new InvalidOperationException($"NDIlib_recv_create_v3 returned null for source '{sourceFullName}'."); return new NdiPInvokeReceiverHandle(native, sourceFullName); } finally { Marshal.FreeHGlobal(nameUtf8); Marshal.FreeHGlobal(recvNameUtf8); } } /// /// Pulls one audio frame and returns its peak amplitude in [0,1], or null /// if the timeout elapsed without an audio frame arriving. Uses the same /// underlying NDIlib_recv_capture_v3 the video path does, but binds the /// audio output slot only — the receiver's internal queue serves video /// and audio independently, so this can be polled from a separate thread /// without contending with the video capture loop. /// public double? CaptureAudioPeak(NdiReceiverHandle receiver, int timeoutMs) { var pInvokeReceiver = (NdiPInvokeReceiverHandle)receiver; var frameType = NdiNative.RecvCaptureV3Audio( pInvokeReceiver.Native, IntPtr.Zero, out var nativeAudio, IntPtr.Zero, (uint)Math.Max(0, timeoutMs)); if (frameType != NdiNative.FrameType.Audio || nativeAudio.p_data == IntPtr.Zero) { // Free defensively on the off-chance an audio struct was partially // populated despite the wrong frame-type return — the SDK's free // is a no-op on a zero pointer. if (nativeAudio.p_data != IntPtr.Zero) NdiNative.RecvFreeAudioV3(pInvokeReceiver.Native, ref nativeAudio); return null; } try { // Total bytes for the entire frame's audio buffer. For FLTP that's // no_channels * channel_stride_in_bytes. The struct's union slot // exposed as channel_stride_in_bytes is the per-channel stride // when FourCC=FLTp; total samples across all channels is // no_channels * no_samples and we walk every sample for the peak. var totalBytes = nativeAudio.no_channels * nativeAudio.channel_stride_in_bytes; if (totalBytes <= 0 || nativeAudio.no_samples <= 0) return 0.0; var managed = new byte[totalBytes]; Marshal.Copy(nativeAudio.p_data, managed, 0, totalBytes); var totalSamples = nativeAudio.no_channels * nativeAudio.no_samples; return TeamsISO.Engine.Pipeline.AudioPeakComputer.ComputePeak( managed, nativeAudio.FourCC, totalSamples); } finally { NdiNative.RecvFreeAudioV3(pInvokeReceiver.Native, ref nativeAudio); } } public RawFrame? CaptureFrame(NdiReceiverHandle receiver, int timeoutMs) { var pInvokeReceiver = (NdiPInvokeReceiverHandle)receiver; var frameType = NdiNative.RecvCaptureV3( pInvokeReceiver.Native, out var nativeFrame, IntPtr.Zero, IntPtr.Zero, (uint)Math.Max(0, timeoutMs)); if (frameType != NdiNative.FrameType.Video || nativeFrame.p_data == IntPtr.Zero) { // Even non-video frame types may need to be released; recv_free_video_v2 is safe on zeroed structs. if (nativeFrame.p_data != IntPtr.Zero) NdiNative.RecvFreeVideoV2(pInvokeReceiver.Native, ref nativeFrame); return null; } try { var pixelFormat = nativeFrame.FourCC switch { NdiNative.FourCC.BGRA or NdiNative.FourCC.BGRX => PixelFormat.Bgra, NdiNative.FourCC.UYVY => PixelFormat.Uyvy, NdiNative.FourCC.RGBA => PixelFormat.Rgba, _ => PixelFormat.Bgra }; // Compute total byte length. line_stride_in_bytes is the union slot for video frames. var byteLength = nativeFrame.line_stride_in_bytes * nativeFrame.yres; if (byteLength <= 0) { _logger.LogWarning("NDI video frame had non-positive byte length ({Length}); skipping.", byteLength); return null; } var managed = new byte[byteLength]; Marshal.Copy(nativeFrame.p_data, managed, 0, byteLength); return new RawFrame( Width: nativeFrame.xres, Height: nativeFrame.yres, TimestampTicks: nativeFrame.timestamp, Pixels: managed, Format: pixelFormat); } finally { NdiNative.RecvFreeVideoV2(pInvokeReceiver.Native, ref nativeFrame); } } // ---- Send ---- 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 = 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}' 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); } } public void SendFrame(NdiSenderHandle sender, ProcessedFrame frame) { var pInvokeSender = (NdiPInvokeSenderHandle)sender; var fourcc = frame.Format switch { PixelFormat.Bgra => NdiNative.FourCC.BGRA, PixelFormat.Rgba => NdiNative.FourCC.RGBA, PixelFormat.Uyvy => NdiNative.FourCC.UYVY, _ => NdiNative.FourCC.BGRA }; // Pin the managed buffer so the native call can read it directly. var pixels = frame.Pixels.ToArray(); var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned); try { var nativeFrame = new NdiNative.VideoFrameV2 { xres = frame.Width, yres = frame.Height, FourCC = fourcc, frame_rate_N = 60000, frame_rate_D = 1001, // 59.94 default; controller-supplied later picture_aspect_ratio = 0.0f, // 0 = derive from xres/yres frame_format_type = NdiNative.FrameFormatType.Progressive, timecode = NdiTimecodeSynthesize, // synthesize p_data = handle.AddrOfPinnedObject(), line_stride_in_bytes = frame.Width * BytesPerPixel(frame.Format), p_metadata = IntPtr.Zero, timestamp = frame.TimestampTicks }; NdiNative.SendSendVideoV2(pInvokeSender.Native, ref nativeFrame); } finally { handle.Free(); } } private const long NdiTimecodeSynthesize = unchecked((long)0x8000000000000000UL); // NDIlib_send_timecode_synthesize private static int BytesPerPixel(PixelFormat fmt) => fmt switch { PixelFormat.Bgra or PixelFormat.Rgba => 4, PixelFormat.Uyvy => 2, _ => 4 }; public void Dispose() { if (_disposed) return; if (_initialized) { NdiNative.Destroy(); _initialized = false; } _disposed = true; } }