feat(engine+console): SMPTE test-pattern generator + --test-pattern flag

This commit is contained in:
Zac Gaetano 2026-05-10 09:41:33 -04:00
parent 7c7520e2be
commit 832aad6a14
3 changed files with 223 additions and 0 deletions

View file

@ -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.
/// </summary>
@ -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;
}
/// <summary>
/// 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.
/// </summary>
[SupportedOSPlatform("windows")]
private static async Task<int> 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;
}
}

View file

@ -0,0 +1,71 @@
namespace TeamsISO.Engine.Pipeline;
/// <summary>
/// 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 <see cref="NdiSender"/> via a <see cref="ProcessedFrame"/> wrapper
/// without holding shared buffers.
///
/// Used by <c>TeamsISO.Console --test-pattern</c>; could also be wired
/// into the WPF host as a "synthetic source" toggle for demoing.
/// </summary>
public static class TestPatternGenerator
{
/// <summary>
/// SMPTE 75% color-bar palette in BGRA order (high-amplitude bars).
/// Eight bars, each occupying 1/8 of the frame width.
/// </summary>
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
};
/// <summary>
/// Render one BGRA frame at the given dimensions. <paramref name="frameNumber"/>
/// drives the sweep-band animation so consecutive calls produce visibly
/// changing output (the sweep moves down by 2 rows per frame, wrapping).
/// </summary>
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);
}
}

View file

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