feat: app icon, FPS, drops counter, --version, About dialog, Stop Teams toggle

Six related polish items, all building on tonight's groundwork.

1. App icon: teamsiso.ico generated from dragon-mark.png at 7 sizes (16-256), wired as ApplicationIcon in the WPF csproj, MainWindow.Icon, AboutWindow.Icon, and ARPPRODUCTICON in the WiX MSI. Taskbar / window / Add-Remove-Programs all show the dragon mark now.

2. Running incoming FPS: ring buffer of last 30 frame timestamps in IsoPipeline; ComputeFps() returns moving-average rate. Surfaced on IsoHealthStats.IncomingFps and shown in the Source column of the participants DataGrid as 'WxH · 59.94 fps'. Resets cleanly on every supervisor restart.

3. Drops counter: FrameProcessor.Stats already aggregated FramesDropped (closest-frame strategy when the receiver outpaces the processor) and FramesDuplicated; just plumbed _liveProcessor through IsoPipeline so GetStats() can read them. Exposed in the Live column under the in/out counters as a coral-tinted 'drop N'.

4. Console --version flag: prints engine version (with embedded git SHA), .NET version, OS, NDI runtime banner, expected prefix, exit-code legend, plus a wilddragon.net link. Useful for support tickets.

5. About dialog: chromeless modal with the dragon mark + version / .NET / OS / NDI runtime fields and a link to wilddragon.net. Triggered by clicking the rail logo.

6. Teams launcher Stop toggle: TeamsLauncher gains IsRunning() and StopAll(). The rail's Teams button now toggles — if Teams is up, ask to close all Teams windows via WM_CLOSE; otherwise launch as before. Confirms before stopping so we don't kill the user's call mid-transition.

Tests: 74/74 unit + 9/9 NDI integration green throughout. MSI builds clean and now embeds the dragon icon for ARP.
This commit is contained in:
Zac Gaetano 2026-05-08 13:50:19 -04:00
parent 01ef4250d7
commit e8f52a3153
12 changed files with 522 additions and 32 deletions

View file

@ -58,6 +58,15 @@
<!-- ARPNOMODIFY is set by WixUI_InstallDir; don't redeclare. -->
<Property Id="ARPNOREPAIR" Value="1" />
<!--
ARP icon — references the same .ico the WPF host uses. WiX requires the
icon resource to live next to the wxs OR be reachable at build time;
we point at the published copy under src/TeamsISO.App/Assets so the icon
embedded in the MSI matches the icon in the running exe.
-->
<Icon Id="TeamsISOIcon" SourceFile="$(var.AssetsDir)teamsiso.ico" />
<Property Id="ARPPRODUCTICON" Value="TeamsISOIcon" />
<!--
NDI Runtime detection. We check for NDI_RUNTIME_DIR_V6 in the system
environment block. Missing → warn during install, don't block. The

View file

@ -18,7 +18,7 @@
<PublishDir>$(MSBuildThisFileDirectory)..\publish\TeamsISO\</PublishDir>
<!-- Pass MSBuild values into WiX preprocessor. -->
<DefineConstants>PublishDir=$(PublishDir)</DefineConstants>
<DefineConstants>PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\TeamsISO.App\Assets\</DefineConstants>
<!-- Code-signing hook (set externally for release builds; left empty for dev). -->
<SignOutput Condition=" '$(SignOutput)' == '' ">false</SignOutput>

View file

@ -0,0 +1,171 @@
<Window x:Class="TeamsISO.App.AboutWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="About TeamsISO"
Icon="/Assets/teamsiso.ico"
Width="460" Height="500"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
ResizeMode="NoResize"
Background="{DynamicResource Wd.Canvas}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="ClearType">
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome
CaptionHeight="32"
ResizeBorderThickness="0"
CornerRadius="0"
GlassFrameThickness="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Caption -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="About TeamsISO"
Style="{StaticResource Wd.Text.Caption}"
Margin="20,12,0,0"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.CaptionClose}"
Click="OnClose"
shell:WindowChrome.IsHitTestVisibleInChrome="True">
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Width="10" Height="10"
Stretch="None"/>
</Button>
</Grid>
<!-- Body -->
<StackPanel Grid.Row="1"
Margin="32,16,32,16"
VerticalAlignment="Top">
<Image Source="/Assets/dragon-mark.png"
Width="80" Height="80"
HorizontalAlignment="Center"
Margin="0,0,0,16"
RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock Text="TeamsISO"
Style="{StaticResource Wd.Text.Title}"
FontSize="28"
HorizontalAlignment="Center"/>
<TextBlock Text="Per-participant NDI ISO Controller for Microsoft Teams"
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
HorizontalAlignment="Center"
TextAlignment="Center"
Margin="0,4,0,0"
TextWrapping="Wrap"/>
<Border Style="{StaticResource Wd.Card}"
Margin="0,20,0,0"
Padding="16">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Version"
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,0,16,4"/>
<TextBlock Grid.Row="0" Grid.Column="1"
x:Name="VersionText"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Primary}"
TextWrapping="Wrap"
Margin="0,0,0,4"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text=".NET"
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,0,16,4"/>
<TextBlock Grid.Row="1" Grid.Column="1"
x:Name="RuntimeText"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Primary}"
Margin="0,0,0,4"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="OS"
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,0,16,4"/>
<TextBlock Grid.Row="2" Grid.Column="1"
x:Name="OsText"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Primary}"
Margin="0,0,0,4"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="NDI runtime"
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,0,16,0"/>
<TextBlock Grid.Row="3" Grid.Column="1"
x:Name="NdiText"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Primary}"
TextWrapping="Wrap"/>
</Grid>
</Border>
</StackPanel>
<!-- Footer -->
<Border Grid.Row="2"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="0,1,0,0"
Padding="20,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Style="{StaticResource Wd.Text.Mono}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
VerticalAlignment="Center">
<Hyperlink x:Name="WebsiteLink"
Foreground="{DynamicResource Wd.Accent.Cyan}"
TextDecorations="None"
Click="OnWebsiteClick">
wilddragon.net
</Hyperlink>
<Run Text=" · © Wild Dragon LLC"/>
</TextBlock>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.Ghost}"
Content="Close"
Click="OnClose"
MinWidth="80"/>
</Grid>
</Border>
</Grid>
</Border>
</Window>

View file

@ -0,0 +1,71 @@
using System.Diagnostics;
using System.Reflection;
using System.Runtime.Versioning;
using System.Windows;
using System.Windows.Navigation;
using TeamsISO.Engine.NdiInterop;
namespace TeamsISO.App;
/// <summary>
/// Modal "About" dialog. Surfaces enough info that a user filing a support ticket
/// can paste version + NDI runtime + OS in a single screenshot.
/// </summary>
public partial class AboutWindow : Window
{
public AboutWindow()
{
InitializeComponent();
PopulateText();
}
private void PopulateText()
{
var asm = typeof(App).Assembly;
var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? asm.GetName().Version?.ToString()
?? "unknown";
VersionText.Text = info;
RuntimeText.Text = $".NET {Environment.Version}";
OsText.Text = Environment.OSVersion.ToString();
NdiText.Text = TryGetNdiVersion();
}
[SupportedOSPlatform("windows")]
private static string TryGetNdiVersion()
{
try
{
using var interop = new NdiInteropPInvoke(
Microsoft.Extensions.Logging.Abstractions.NullLogger<NdiInteropPInvoke>.Instance);
return interop.GetRuntimeVersion();
}
catch (Exception ex)
{
return $"not initialized ({ex.Message})";
}
}
private void OnClose(object sender, RoutedEventArgs e) => Close();
/// <summary>
/// Open the company site in the default browser. We intentionally use the
/// shell's URL handler rather than a tab inside the app — this is a
/// "tell me more" link, not a workflow.
/// </summary>
private void OnWebsiteClick(object sender, RoutedEventArgs e)
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = "https://wilddragon.net",
UseShellExecute = true,
});
}
catch
{
// best-effort; if shell launch fails the click is a no-op
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View file

@ -5,6 +5,7 @@
xmlns:conv="clr-namespace:TeamsISO.App.Converters"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="TeamsISO"
Icon="/Assets/teamsiso.ico"
Height="780" Width="1280"
MinHeight="640" MinWidth="1080"
Background="{DynamicResource Wd.Canvas}"
@ -52,14 +53,20 @@
BorderThickness="0,0,1,0">
<DockPanel LastChildFill="False">
<!-- Wild Dragon mark — real logo from wilddragon.net -->
<Image DockPanel.Dock="Top"
Source="/Assets/dragon-mark.png"
Width="40" Height="40"
Margin="0,18,0,4"
<!-- Wild Dragon mark — real logo from wilddragon.net. Clickable: opens About. -->
<Button DockPanel.Dock="Top"
Style="{StaticResource Wd.Button.RailIcon}"
Width="48" Height="56"
Margin="0,12,0,4"
Click="OnAboutClick"
ToolTip="About TeamsISO">
<StackPanel>
<Image Source="/Assets/dragon-mark.png"
Width="32" Height="32"
HorizontalAlignment="Center"
RenderOptions.BitmapScalingMode="HighQuality"
ToolTip="Wild Dragon"/>
RenderOptions.BitmapScalingMode="HighQuality"/>
</StackPanel>
</Button>
<TextBlock DockPanel.Dock="Top"
Text="Wild Dragon"
@ -67,7 +74,7 @@
Foreground="{DynamicResource Wd.Accent.Cyan}"
HorizontalAlignment="Center"
FontSize="9"
Margin="0,0,0,16"/>
Margin="0,0,0,12"/>
<!-- Divider -->
<Border DockPanel.Dock="Top"
@ -349,29 +356,34 @@
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding SourceMachine}"
Style="{StaticResource Wd.Text.Mono}"/>
<TextBlock Text="{Binding IncomingResolution}"
Style="{StaticResource Wd.Text.Mono}"
<TextBlock Style="{StaticResource Wd.Text.Mono}"
FontSize="10"
Foreground="{DynamicResource Wd.Text.Tertiary}"/>
Foreground="{DynamicResource Wd.Text.Tertiary}">
<Run Text="{Binding IncomingResolution, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding IncomingFps, Mode=OneWay}"/>
</TextBlock>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Live" Width="100">
<DataGridTemplateColumn Header="Live" Width="120">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel VerticalAlignment="Center">
<TextBlock Style="{StaticResource Wd.Text.Mono}"
FontSize="11">
<Run Text="↓ "/>
<Run Text="{Binding FramesIn}"/>
<Run Text="{Binding FramesIn, Mode=OneWay}"/>
<Run Text=" ↑ "/>
<Run Text="{Binding FramesOut, Mode=OneWay}"/>
</TextBlock>
<TextBlock Style="{StaticResource Wd.Text.Mono}"
FontSize="10"
Foreground="{DynamicResource Wd.Text.Tertiary}">
<Run Text=" "/>
<Run Text="{Binding FramesOut}"/>
Foreground="{DynamicResource Wd.Accent.Coral}">
<Run Text="drop "/>
<Run Text="{Binding FramesDropped, Mode=OneWay}"/>
</TextBlock>
</StackPanel>
</DataTemplate>

View file

@ -29,13 +29,43 @@ public partial class MainWindow : Window
/// <summary>Custom close button.</summary>
private void OnClose(object sender, RoutedEventArgs e) => Close();
/// <summary>Opens the About dialog — version, NDI runtime, build SHA.</summary>
private void OnAboutClick(object sender, RoutedEventArgs e)
{
var about = new AboutWindow { Owner = this };
about.ShowDialog();
}
/// <summary>
/// First step toward the Embedded-Teams roadmap (Phase E.1) — launches the MS
/// Teams desktop client as a subprocess so the operator doesn't have to switch
/// apps to start a meeting.
/// Toggle behavior: if Teams is already running, ask to stop it; otherwise
/// launch via TeamsLauncher's fallback chain. First step toward the
/// Embedded-Teams roadmap (Phase E.1).
/// </summary>
private void OnLaunchTeamsClick(object sender, RoutedEventArgs e)
{
if (TeamsLauncher.IsRunning())
{
var confirm = MessageBox.Show(
"Microsoft Teams is currently running.\n\nClose all Teams windows now?",
"TeamsISO — Stop Teams",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.Yes) return;
var asked = TeamsLauncher.StopAll();
if (TeamsLauncher.IsRunning())
{
MessageBox.Show(
asked == 0
? "No Teams windows responded to close."
: $"Sent close to {asked} Teams window(s); some may still be exiting.",
"TeamsISO — Stop Teams",
MessageBoxButton.OK,
MessageBoxImage.Information);
}
return;
}
if (!TeamsLauncher.TryLaunch(out var error))
{
MessageBox.Show(

View file

@ -22,6 +22,24 @@ namespace TeamsISO.App.Services;
/// </summary>
public static class TeamsLauncher
{
/// <summary>
/// Heuristic process-name candidates we'll consider as "the Teams process" when
/// the rail toggle wants to find a running instance. New MSTeams comes first.
/// </summary>
private static readonly string[] TeamsProcessNames =
{
"ms-teams", // new MSTeams binary basename
"msteams", // alternate basename observed on some installs
"Teams", // classic Teams desktop client
};
/// <summary>
/// True if any process matching the known Teams binary basenames is running.
/// Used by the rail to decide whether to show "Launch Teams" vs "Stop Teams".
/// </summary>
public static bool IsRunning() =>
TeamsProcessNames.Any(n => Process.GetProcessesByName(n).Length > 0);
/// <summary>
/// Launches Teams. Returns true if a launch was started successfully (the
/// process may take a few seconds to actually appear). False if every
@ -69,6 +87,41 @@ public static class TeamsLauncher
return false;
}
/// <summary>
/// Asks every running Teams process to close gracefully via WM_CLOSE
/// (CloseMainWindow). Returns the count of processes that exited cleanly within
/// <paramref name="gracePeriod"/>. Stragglers are NOT force-killed — Teams' own
/// "are you sure" prompt may legitimately keep a process alive briefly, and we
/// don't want to nuke the user's call mid-transition.
/// </summary>
public static int StopAll(TimeSpan? gracePeriod = null)
{
var grace = gracePeriod ?? TimeSpan.FromSeconds(3);
var deadline = DateTime.UtcNow + grace;
var asked = 0;
foreach (var name in TeamsProcessNames)
{
foreach (var p in Process.GetProcessesByName(name))
{
try
{
if (p.HasExited) { p.Dispose(); continue; }
if (p.MainWindowHandle != IntPtr.Zero)
{
p.CloseMainWindow();
asked++;
}
}
catch { /* defensive: process may have died between enumeration and signal */ }
finally { p.Dispose(); }
}
}
// Best-effort wait so the rail can flip its icon promptly.
while (DateTime.UtcNow < deadline && IsRunning())
Thread.Sleep(150);
return asked;
}
private static bool TryStart(string target, bool useShell)
{
try

View file

@ -7,7 +7,7 @@
<RootNamespace>TeamsISO.App</RootNamespace>
<AssemblyName>TeamsISO</AssemblyName>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<ApplicationIcon></ApplicationIcon>
<ApplicationIcon>Assets\teamsiso.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
@ -19,6 +19,7 @@
<ItemGroup>
<Resource Include="Assets\dragon-mark.png" />
<Resource Include="Assets\wild-dragon-wordmark.png" />
<Resource Include="Assets\teamsiso.ico" />
</ItemGroup>
</Project>

View file

@ -38,7 +38,9 @@ public sealed class ParticipantViewModel : ObservableObject
private long _framesIn;
private long _framesOut;
private long _framesDropped;
private string _incomingResolution = "—";
private string _incomingFps = "—";
/// <summary>Number of frames the receiver has captured so far.</summary>
public long FramesIn { get => _framesIn; set => SetField(ref _framesIn, value); }
@ -46,17 +48,31 @@ public sealed class ParticipantViewModel : ObservableObject
/// <summary>Number of frames the sender has emitted so far.</summary>
public long FramesOut { get => _framesOut; set => SetField(ref _framesOut, value); }
/// <summary>Frames dropped by the closest-frame strategy when the receiver outpaces the processor.</summary>
public long FramesDropped { get => _framesDropped; set => SetField(ref _framesDropped, value); }
/// <summary>Source resolution as "WxH", or em-dash when no frames have been seen yet.</summary>
public string IncomingResolution { get => _incomingResolution; set => SetField(ref _incomingResolution, value); }
/// <summary>
/// Live incoming framerate as "59.94 fps", or em-dash when fewer than 2 frames
/// have been observed since the pipeline started. Computed in the engine via a
/// 30-frame moving window.
/// </summary>
public string IncomingFps { get => _incomingFps; set => SetField(ref _incomingFps, value); }
/// <summary>Updates the live stats display from a controller-side snapshot.</summary>
public void UpdateStats(IsoHealthStats stats)
{
FramesIn = stats.FramesIn;
FramesOut = stats.FramesOut;
FramesDropped = stats.FramesDropped;
IncomingResolution = stats.IncomingWidth > 0 && stats.IncomingHeight > 0
? $"{stats.IncomingWidth}×{stats.IncomingHeight}"
: "—";
IncomingFps = stats.IncomingFps > 0
? $"{stats.IncomingFps:0.0} fps"
: "—";
}
public bool IsProcessing

View file

@ -26,6 +26,8 @@ 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 --version # print engine version, NDI runtime version, exit codes,
/// then exit 0. Useful for support requests.
/// </summary>
public static class Program
{
@ -33,6 +35,14 @@ public static class Program
{
var enableAll = args.Contains("--enable-all", StringComparer.OrdinalIgnoreCase);
var listSources = args.Contains("--list-sources", StringComparer.OrdinalIgnoreCase);
var version = args.Contains("--version", StringComparer.OrdinalIgnoreCase)
|| args.Contains("-v", StringComparer.OrdinalIgnoreCase);
if (version)
{
return PrintVersion();
}
using var loggerFactory = EngineLogging.CreateConsole(LogLevel.Information);
var logger = loggerFactory.CreateLogger("TeamsISO.Console");
@ -136,6 +146,54 @@ public static class Program
return 0;
}
/// <summary>
/// Prints version + diagnostic info. Always exits 0; the strings are intended to
/// be pasted into a support ticket.
/// </summary>
private static int PrintVersion()
{
var asm = typeof(Program).Assembly;
var asmVersion = asm.GetName().Version?.ToString() ?? "unknown";
var info = asm.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false)
.OfType<System.Reflection.AssemblyInformationalVersionAttribute>()
.FirstOrDefault()?.InformationalVersion ?? asmVersion;
SysConsole.WriteLine($"TeamsISO Console");
SysConsole.WriteLine($" Version: {info}");
SysConsole.WriteLine($" Engine: {asmVersion} (TeamsISO.Engine + TeamsISO.Engine.NdiInterop)");
SysConsole.WriteLine($" Runtime: .NET {Environment.Version}");
SysConsole.WriteLine($" OS: {Environment.OSVersion}");
SysConsole.WriteLine();
if (OperatingSystem.IsWindows())
{
try
{
using var lf = EngineLogging.CreateConsole(LogLevel.Warning);
using var interop = new NdiInteropPInvoke(lf.CreateLogger<NdiInteropPInvoke>());
SysConsole.WriteLine($" NDI runtime: {interop.GetRuntimeVersion()}");
SysConsole.WriteLine($" Expects prefix: {NdiVersion.ExpectedRuntimeVersionPrefix}");
}
catch (Exception ex)
{
SysConsole.WriteLine($" NDI runtime: NOT INITIALIZED ({ex.Message})");
}
}
else
{
SysConsole.WriteLine($" NDI runtime: requires Windows");
}
SysConsole.WriteLine();
SysConsole.WriteLine($"Exit codes:");
SysConsole.WriteLine($" 0 clean exit");
SysConsole.WriteLine($" 1 not running on Windows");
SysConsole.WriteLine($" 2 NDI runtime initialization failed (install from https://ndi.video/tools/)");
SysConsole.WriteLine();
SysConsole.WriteLine($"Wild Dragon LLC · https://wilddragon.net");
return 0;
}
/// <summary>
/// Diagnostic mode: enumerates every raw NDI source string visible to the local
/// NDI finder for ~5 seconds, prints each unique one, then exits. Bypasses the

View file

@ -20,10 +20,12 @@ public sealed class IsoPipeline : IAsyncDisposable
private Task? _supervisorTask;
private int _consecutiveFailures;
// Refs to the currently-live receiver and sender, set by the inner loop on each
// restart. Reads via Volatile.Read are safe from any thread (UI's stats poll).
// Refs to the currently-live receiver, sender, and frame processor, set by the
// inner loop on each restart. Reads via Volatile.Read are safe from any thread
// (UI's stats poll).
private NdiReceiver? _liveReceiver;
private NdiSender? _liveSender;
private FrameProcessor? _liveProcessor;
// Last-frame metadata, snapshotted out of the RawFrame on capture so we don't
// hold a reference to the frame's pixel buffer past its useful life. Two ints
@ -33,6 +35,15 @@ public sealed class IsoPipeline : IAsyncDisposable
private int _lastHeight;
private DateTimeOffset? _lastReceivedAt;
// Ring buffer of the last 30 incoming-frame timestamps for live fps display.
// Updated on the receiver's capture thread (single writer) and read by the UI
// poll thread (single reader); we use a lock for the snapshot path because
// an array-of-ticks can't be torn-read atomically.
private readonly long[] _frameTimes = new long[30];
private int _frameTimesHead;
private int _frameTimesCount;
private readonly object _frameTimesGate = new();
public Guid ParticipantId { get; }
public IsoState State { get; private set; } = IsoState.Idle;
public int ConsecutiveFailures => _consecutiveFailures;
@ -47,6 +58,7 @@ public sealed class IsoPipeline : IAsyncDisposable
{
var receiver = Volatile.Read(ref _liveReceiver);
var sender = Volatile.Read(ref _liveSender);
var processor = Volatile.Read(ref _liveProcessor);
var w = Volatile.Read(ref _lastWidth);
var h = Volatile.Read(ref _lastHeight);
var lastAt = _lastReceivedAt;
@ -54,17 +66,68 @@ public sealed class IsoPipeline : IAsyncDisposable
if (receiver is null || sender is null)
return Domain.IsoHealthStats.Empty;
// FrameProcessor.Stats already aggregates FramesDropped (older frames dropped
// by the closest-frame strategy when the input channel had backlog) and
// FramesDuplicated (last-frame re-emits when no new frame arrived this tick).
var procStats = processor?.Stats;
return new Domain.IsoHealthStats(
FramesIn: receiver.FramesCaptured,
FramesOut: sender.FramesSent,
FramesDropped: 0, // FrameProcessor currently doesn't surface drops; wire later
FramesDuplicated: 0, // same — last-frame re-emits aren't counted yet
FramesDropped: procStats?.FramesDropped ?? 0,
FramesDuplicated: procStats?.FramesDuplicated ?? 0,
LastFrameAt: lastAt,
IncomingFps: 0, // running rate computation is a follow-up
IncomingFps: ComputeFps(),
IncomingWidth: w,
IncomingHeight: h);
}
/// <summary>
/// Computes a moving-average incoming framerate from the last N frame timestamps.
/// Rate = (count - 1) / (newest - oldest). Returns 0 if fewer than 2 frames are
/// recorded or if the window is degenerate (clock skew, all-equal timestamps).
/// </summary>
private double ComputeFps()
{
long oldest, newest;
int count;
lock (_frameTimesGate)
{
count = _frameTimesCount;
if (count < 2) return 0;
// Oldest is at the slot AFTER head when buffer is full; otherwise at index 0.
var oldestIdx = count < _frameTimes.Length
? 0
: _frameTimesHead;
var newestIdx = (_frameTimesHead - 1 + _frameTimes.Length) % _frameTimes.Length;
oldest = _frameTimes[oldestIdx];
newest = _frameTimes[newestIdx];
}
var deltaTicks = newest - oldest;
if (deltaTicks <= 0) return 0;
var seconds = deltaTicks / (double)TimeSpan.TicksPerSecond;
return (count - 1) / seconds;
}
private void RecordFrameTimestamp(long ticks)
{
lock (_frameTimesGate)
{
_frameTimes[_frameTimesHead] = ticks;
_frameTimesHead = (_frameTimesHead + 1) % _frameTimes.Length;
if (_frameTimesCount < _frameTimes.Length) _frameTimesCount++;
}
}
private void ResetFrameTimestamps()
{
lock (_frameTimesGate)
{
_frameTimesHead = 0;
_frameTimesCount = 0;
}
}
/// <summary>
/// Test ctor. The caller supplies the inner runner directly so failures and lifetimes
/// can be controlled from a unit test.
@ -105,15 +168,19 @@ public sealed class IsoPipeline : IAsyncDisposable
{
_runInner = ct => RunInnerPipelineAsync(
config, interop, scaler, frameClock, loggerFactory, ct,
onLive: (recv, send) =>
onLive: (recv, send, proc) =>
{
Volatile.Write(ref _liveReceiver, recv);
Volatile.Write(ref _liveSender, send);
Volatile.Write(ref _liveProcessor, proc);
ResetFrameTimestamps(); // fresh window on every supervisor restart
},
onClear: () =>
{
Volatile.Write(ref _liveReceiver, null);
Volatile.Write(ref _liveSender, null);
Volatile.Write(ref _liveProcessor, null);
ResetFrameTimestamps();
},
onFrame: frame =>
{
@ -122,7 +189,9 @@ public sealed class IsoPipeline : IAsyncDisposable
// late stats read can never resurrect a dropped frame's pixel buffer.
Volatile.Write(ref _lastWidth, frame.Width);
Volatile.Write(ref _lastHeight, frame.Height);
_lastReceivedAt = DateTimeOffset.UtcNow;
var nowTicks = DateTimeOffset.UtcNow.UtcTicks;
_lastReceivedAt = new DateTimeOffset(nowTicks, TimeSpan.Zero);
RecordFrameTimestamp(nowTicks);
});
}
@ -220,7 +289,7 @@ public sealed class IsoPipeline : IAsyncDisposable
IFrameClock frameClock,
ILoggerFactory loggerFactory,
CancellationToken ct,
Action<NdiReceiver, NdiSender>? onLive = null,
Action<NdiReceiver, NdiSender, FrameProcessor>? onLive = null,
Action? onClear = null,
Action<RawFrame>? onFrame = null)
{
@ -249,13 +318,13 @@ public sealed class IsoPipeline : IAsyncDisposable
interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger<NdiSender>(),
config.OutputGroups);
onLive?.Invoke(receiver, sender);
var processor = new FrameProcessor(
config.Settings, scaler, new SolidFrameRenderer(),
frameClock, rawChannel.Reader, processedChannel.Writer,
config.SlateThreshold, loggerFactory.CreateLogger<FrameProcessor>());
onLive?.Invoke(receiver, sender, processor);
var receiverTask = receiver.RunAsync(ct);
var senderTask = sender.RunAsync(ct);
var processorTask = ProcessorLoopAsync(processor, frameClock, ct);