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