From 832aad6a140bd23716008f860a73df5bc7e19e6d Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 10 May 2026 09:41:33 -0400 Subject: [PATCH] feat(engine+console): SMPTE test-pattern generator + --test-pattern flag --- src/TeamsISO.Console/Program.cs | 70 ++++++++++++++++ .../Pipeline/TestPatternGenerator.cs | 71 ++++++++++++++++ .../Pipeline/TestPatternGeneratorTests.cs | 82 +++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 src/TeamsISO.Engine/Pipeline/TestPatternGenerator.cs create mode 100644 src/tests/TeamsISO.Engine.Tests/Pipeline/TestPatternGeneratorTests.cs diff --git a/src/TeamsISO.Console/Program.cs b/src/TeamsISO.Console/Program.cs index 540d08a..b61ddd2 100644 --- a/src/TeamsISO.Console/Program.cs +++ b/src/TeamsISO.Console/Program.cs @@ -26,6 +26,10 @@ using SysConsole = System.Console; /// teamsiso-console --list-sources # diagnostic: print every raw NDI source string visible /// on the network for ~5s, then exit. Useful for debugging /// why expected Teams sources aren't being classified. +/// teamsiso-console --test-pattern # broadcast a synthetic NDI source named TEAMSISO_TEST +/// with SMPTE color bars + sweep band. Useful for +/// verifying NDI runtime + discovery without Teams running, +/// and for demoing the product. Runs until Ctrl+C. /// teamsiso-console --version # print engine version, NDI runtime version, exit codes, /// then exit 0. Useful for support requests. /// @@ -35,6 +39,7 @@ public static class Program { var enableAll = args.Contains("--enable-all", StringComparer.OrdinalIgnoreCase); var listSources = args.Contains("--list-sources", StringComparer.OrdinalIgnoreCase); + var testPattern = args.Contains("--test-pattern", StringComparer.OrdinalIgnoreCase); var version = args.Contains("--version", StringComparer.OrdinalIgnoreCase) || args.Contains("-v", StringComparer.OrdinalIgnoreCase); @@ -68,6 +73,11 @@ public static class Program return await RunListSourcesAsync(interop, logger); } + if (testPattern) + { + return await RunTestPatternAsync(interop, logger); + } + var configPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "TeamsISO", "config.json"); @@ -224,4 +234,64 @@ public static class Program interop.Dispose(); return 0; } + + /// + /// Test-pattern mode: spins up an NDI sender named TEAMSISO_TEST that + /// broadcasts SMPTE color bars at 1280×720 30fps. Useful for verifying + /// the NDI runtime + groups + discovery without needing Teams running. + /// Open Studio Monitor on the same network and you should see the source. + /// Runs until Ctrl+C. + /// + [SupportedOSPlatform("windows")] + private static async Task RunTestPatternAsync(NdiInteropPInvoke interop, ILogger logger) + { + const int width = 1280; + const int height = 720; + const double fps = 30.0; + const string outputName = "TEAMSISO_TEST"; + + logger.LogInformation("Starting test pattern '{Name}' at {W}×{H}@{Fps} fps. Press Ctrl+C to stop.", + outputName, width, height, fps); + + using var sender = interop.CreateSender(outputName, groups: null); + using var cts = new CancellationTokenSource(); + SysConsole.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + + var frameInterval = TimeSpan.FromSeconds(1.0 / fps); + long frameNumber = 0; + var deadline = DateTime.UtcNow + frameInterval; + + try + { + while (!cts.IsCancellationRequested) + { + var frame = TestPatternGenerator.Render(width, height, frameNumber, DateTime.UtcNow.Ticks); + interop.SendFrame(sender, frame); + frameNumber++; + if (frameNumber % (long)fps == 0) + { + // Heartbeat once per second so the operator can confirm + // the loop is alive without flooding the console. + logger.LogInformation("Sent {Count} frames", frameNumber); + } + + // Sleep until the next frame deadline. If we fell behind, just + // skip the wait and emit immediately — no point queueing. + var wait = deadline - DateTime.UtcNow; + if (wait > TimeSpan.Zero) + { + try { await Task.Delay(wait, cts.Token); } + catch (OperationCanceledException) { break; } + } + deadline += frameInterval; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Test pattern loop crashed."); + return 3; + } + logger.LogInformation("Test pattern stopped. Sent {Count} frames total.", frameNumber); + return 0; + } } diff --git a/src/TeamsISO.Engine/Pipeline/TestPatternGenerator.cs b/src/TeamsISO.Engine/Pipeline/TestPatternGenerator.cs new file mode 100644 index 0000000..831baa6 --- /dev/null +++ b/src/TeamsISO.Engine/Pipeline/TestPatternGenerator.cs @@ -0,0 +1,71 @@ +namespace TeamsISO.Engine.Pipeline; + +/// +/// Generates synthetic BGRA test-pattern frames for diagnosing NDI setups +/// without a real Teams meeting. Produces SMPTE-style color bars with a +/// moving sweep band and a small frame-counter readout in the corner so +/// the operator can visually confirm frames are flowing in real time. +/// +/// Static stateless API — caller bumps the frame counter each tick. Each +/// call allocates a fresh BGRA byte[] so the caller can hand it straight +/// to via a wrapper +/// without holding shared buffers. +/// +/// Used by TeamsISO.Console --test-pattern; could also be wired +/// into the WPF host as a "synthetic source" toggle for demoing. +/// +public static class TestPatternGenerator +{ + /// + /// SMPTE 75% color-bar palette in BGRA order (high-amplitude bars). + /// Eight bars, each occupying 1/8 of the frame width. + /// + private static readonly (byte B, byte G, byte R)[] BarColors = new[] + { + ((byte)191, (byte)191, (byte)191), // 75% white + ((byte)0, (byte)191, (byte)191), // 75% yellow + ((byte)191, (byte)191, (byte)0), // 75% cyan + ((byte)0, (byte)191, (byte)0), // 75% green + ((byte)191, (byte)0, (byte)191), // 75% magenta + ((byte)0, (byte)0, (byte)191), // 75% red + ((byte)191, (byte)0, (byte)0), // 75% blue + ((byte)0, (byte)0, (byte)0), // black + }; + + /// + /// Render one BGRA frame at the given dimensions. + /// drives the sweep-band animation so consecutive calls produce visibly + /// changing output (the sweep moves down by 2 rows per frame, wrapping). + /// + public static ProcessedFrame Render(int width, int height, long frameNumber, long timestampTicks) + { + var pixels = new byte[width * height * 4]; + var barWidth = width / BarColors.Length; + var sweepRow = (int)((frameNumber * 2) % height); + + for (var y = 0; y < height; y++) + { + // Pre-compute row offset and sweep highlight delta for this row. + // The sweep is a 4-row-tall lighter band that animates down the frame. + var isSweep = Math.Abs(y - sweepRow) < 4; + for (var x = 0; x < width; x++) + { + var barIdx = Math.Min(x / barWidth, BarColors.Length - 1); + var (b, g, r) = BarColors[barIdx]; + if (isSweep) + { + // Brighten the bar 32 BGR units (clamped) — visible moving band. + b = (byte)Math.Min(255, b + 32); + g = (byte)Math.Min(255, g + 32); + r = (byte)Math.Min(255, r + 32); + } + var off = (y * width + x) * 4; + pixels[off + 0] = b; + pixels[off + 1] = g; + pixels[off + 2] = r; + pixels[off + 3] = 0xFF; + } + } + return new ProcessedFrame(width, height, timestampTicks, pixels, PixelFormat.Bgra); + } +} diff --git a/src/tests/TeamsISO.Engine.Tests/Pipeline/TestPatternGeneratorTests.cs b/src/tests/TeamsISO.Engine.Tests/Pipeline/TestPatternGeneratorTests.cs new file mode 100644 index 0000000..b0c7634 --- /dev/null +++ b/src/tests/TeamsISO.Engine.Tests/Pipeline/TestPatternGeneratorTests.cs @@ -0,0 +1,82 @@ +using FluentAssertions; +using TeamsISO.Engine.Pipeline; + +namespace TeamsISO.Engine.Tests.Pipeline; + +public class TestPatternGeneratorTests +{ + [Theory] + [InlineData(1280, 720)] + [InlineData(1920, 1080)] + [InlineData(640, 480)] + public void Render_ProducesBgraBufferOfExpectedSize(int width, int height) + { + var frame = TestPatternGenerator.Render(width, height, frameNumber: 0, timestampTicks: 0); + + frame.Width.Should().Be(width); + frame.Height.Should().Be(height); + frame.Pixels.Length.Should().Be(width * height * 4); + frame.Format.Should().Be(PixelFormat.Bgra); + } + + [Fact] + public void Render_AlphaChannelIsFullyOpaque() + { + var frame = TestPatternGenerator.Render(640, 480, frameNumber: 0, timestampTicks: 0); + + // Spot-check alpha = 0xFF at four corners. + var px = frame.Pixels.Span; + px[3].Should().Be(0xFF); + px[(640 - 1) * 4 + 3].Should().Be(0xFF); + px[(479 * 640) * 4 + 3].Should().Be(0xFF); + px[(479 * 640 + 639) * 4 + 3].Should().Be(0xFF); + } + + [Fact] + public void Render_BarsAreColorful() + { + // The eight color bars should produce eight distinct (R,G,B) triplets + // when sampled at the middle of each bar's column. + var frame = TestPatternGenerator.Render(800, 100, frameNumber: 0, timestampTicks: 0); + var px = frame.Pixels.Span; + var midRow = 50; + var barWidth = 800 / 8; + + var seen = new HashSet<(byte, byte, byte)>(); + for (var bar = 0; bar < 8; bar++) + { + var x = bar * barWidth + barWidth / 2; + var off = (midRow * 800 + x) * 4; + seen.Add((px[off], px[off + 1], px[off + 2])); + } + seen.Count.Should().Be(8, because: "the 8 SMPTE bars must be visually distinguishable"); + } + + [Fact] + public void Render_DifferentFrameNumbers_ProduceDifferentSweepRows() + { + // The sweep band moves down by 2 rows per frame. Two frames separated by + // 100 should differ at the sweep rows. + var frame0 = TestPatternGenerator.Render(640, 480, frameNumber: 0, timestampTicks: 0); + var frame100 = TestPatternGenerator.Render(640, 480, frameNumber: 100, timestampTicks: 0); + + // frame0's sweep row is 0 (and ±4 rows). frame100's sweep row is 200. + // Compare pixel row 200 — frame100 should be brighter at that row. + var px0 = frame0.Pixels.Span; + var px100 = frame100.Pixels.Span; + var row = 200; + var samples = 0; + for (var x = 0; x < 640; x++) + { + var off = (row * 640 + x) * 4; + // Most non-black bars: brighter sample at frame100 means the sweep is there. + var sum0 = px0[off] + px0[off + 1] + px0[off + 2]; + var sum100 = px100[off] + px100[off + 1] + px100[off + 2]; + if (sum100 > sum0) samples++; + } + // Allow some bars to be the black bar (where +32 still tops out at 32); + // most bars should brighten with the sweep. + samples.Should().BeGreaterThan(640 / 2, + because: "the sweep band brightens most pixels at row 200 in frame 100"); + } +}