Some checks failed
CI / build-and-test (push) Failing after 29s
The DataGrid's per-row audio level bar (in the Live column) was inert because IsoHealthStats.PeakAudioLevel always returned 0.0. Engine work needed: capture NDI audio frames, compute peak amplitude, publish through the existing stats path. Engine: - AudioPeakComputer (new): max-abs computation across NDI's FLTP / FLT / PCM s16 sample formats. Pure managed code, fully unit-tested (14 cases — clamping behaviour, edge cases like short.MinValue overflow, totalSamples-vs-buffer mismatch defenses). - INdiInterop.CaptureAudioPeak (new, default-implemented): polls one audio frame, returns peak in [0,1] or null on timeout. FakeNdiInterop inherits the no-op default; production NdiInteropPInvoke overrides with real FLTP decode through a sibling RecvCaptureV3Audio import + RecvFreeAudioV3. - NdiNative: AudioFrameV3 struct + audio-only RecvCaptureV3 binding + FreeAudioV3. - NdiReceiver: spins up a sibling audio-capture loop alongside the existing video loop on the same lifetime. Audio failures are caught + logged but never re-thrown (a misbehaving audio path must never tear down the live video pipeline). Latest peak published via Volatile<long> (BitConverter int64 bits) so UI reads are torn-free across threads. - IsoPipeline.GetStats: surfaces NdiReceiver.LatestAudioPeak as IsoHealthStats.PeakAudioLevel. UI: - ParticipantViewModel.OnStatsTick already had the decay logic (max-of-new-or-decayed-old, 0.7 multiplier) waiting for real values. No UI changes needed. Tests: 14 new + 141 existing = 155/155 passing. 0 warnings, 0 errors.
338 lines
13 KiB
C#
338 lines
13 KiB
C#
using System.Runtime.InteropServices;
|
|
using System.Runtime.Versioning;
|
|
using Microsoft.Extensions.Logging;
|
|
using TeamsISO.Engine.Interop;
|
|
using TeamsISO.Engine.Pipeline;
|
|
|
|
namespace TeamsISO.Engine.NdiInterop;
|
|
|
|
/// <summary>
|
|
/// Production <see cref="INdiInterop"/> 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.
|
|
/// </summary>
|
|
[SupportedOSPlatform("windows")]
|
|
public sealed class NdiInteropPInvoke : INdiInterop, IDisposable
|
|
{
|
|
private readonly ILogger<NdiInteropPInvoke> _logger;
|
|
private bool _initialized;
|
|
private bool _disposed;
|
|
|
|
public NdiInteropPInvoke(ILogger<NdiInteropPInvoke> 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<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)
|
|
{
|
|
var pInvokeFinder = (NdiPInvokeFindHandle)finder;
|
|
var ptr = NdiNative.FindGetCurrentSources(pInvokeFinder.Native, out var count);
|
|
if (ptr == IntPtr.Zero || count == 0) return Array.Empty<string>();
|
|
|
|
// 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<NdiNative.Source>();
|
|
var result = new string[count];
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
var sourcePtr = IntPtr.Add(ptr, i * sourceSize);
|
|
var src = Marshal.PtrToStructure<NdiNative.Source>(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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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 ?? "<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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|