feat(engine+console): SMPTE test-pattern generator + --test-pattern flag
This commit is contained in:
parent
7c7520e2be
commit
832aad6a14
3 changed files with 223 additions and 0 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
71
src/TeamsISO.Engine/Pipeline/TestPatternGenerator.cs
Normal file
71
src/TeamsISO.Engine/Pipeline/TestPatternGenerator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue