diff --git a/installer/Package.wxs b/installer/Package.wxs
index b1b7233..79de7cd 100644
--- a/installer/Package.wxs
+++ b/installer/Package.wxs
@@ -58,6 +58,15 @@
+
+
+
+
- PublishDir=$(PublishDir)
+ PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\TeamsISO.App\Assets\
false
diff --git a/src/TeamsISO.App/AboutWindow.xaml b/src/TeamsISO.App/AboutWindow.xaml
new file mode 100644
index 0000000..38edd77
--- /dev/null
+++ b/src/TeamsISO.App/AboutWindow.xaml
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ wilddragon.net
+
+
+
+
+
+
+
+
+
diff --git a/src/TeamsISO.App/AboutWindow.xaml.cs b/src/TeamsISO.App/AboutWindow.xaml.cs
new file mode 100644
index 0000000..b8735c9
--- /dev/null
+++ b/src/TeamsISO.App/AboutWindow.xaml.cs
@@ -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;
+
+///
+/// Modal "About" dialog. Surfaces enough info that a user filing a support ticket
+/// can paste version + NDI runtime + OS in a single screenshot.
+///
+public partial class AboutWindow : Window
+{
+ public AboutWindow()
+ {
+ InitializeComponent();
+ PopulateText();
+ }
+
+ private void PopulateText()
+ {
+ var asm = typeof(App).Assembly;
+ var info = asm.GetCustomAttribute()?.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.Instance);
+ return interop.GetRuntimeVersion();
+ }
+ catch (Exception ex)
+ {
+ return $"not initialized ({ex.Message})";
+ }
+ }
+
+ private void OnClose(object sender, RoutedEventArgs e) => Close();
+
+ ///
+ /// 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.
+ ///
+ 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
+ }
+ }
+}
diff --git a/src/TeamsISO.App/Assets/teamsiso.ico b/src/TeamsISO.App/Assets/teamsiso.ico
new file mode 100644
index 0000000..b7be1cf
Binary files /dev/null and b/src/TeamsISO.App/Assets/teamsiso.ico differ
diff --git a/src/TeamsISO.App/MainWindow.xaml b/src/TeamsISO.App/MainWindow.xaml
index eab047a..64d8634 100644
--- a/src/TeamsISO.App/MainWindow.xaml
+++ b/src/TeamsISO.App/MainWindow.xaml
@@ -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">
-
-
+
+
+ Margin="0,0,0,12"/>
-
+ Foreground="{DynamicResource Wd.Text.Tertiary}">
+
+
+
+
-
+
-
+
+
+
-
-
+ Foreground="{DynamicResource Wd.Accent.Coral}">
+
+
diff --git a/src/TeamsISO.App/MainWindow.xaml.cs b/src/TeamsISO.App/MainWindow.xaml.cs
index a2c9030..e6c739d 100644
--- a/src/TeamsISO.App/MainWindow.xaml.cs
+++ b/src/TeamsISO.App/MainWindow.xaml.cs
@@ -29,13 +29,43 @@ public partial class MainWindow : Window
/// Custom close button.
private void OnClose(object sender, RoutedEventArgs e) => Close();
+ /// Opens the About dialog — version, NDI runtime, build SHA.
+ private void OnAboutClick(object sender, RoutedEventArgs e)
+ {
+ var about = new AboutWindow { Owner = this };
+ about.ShowDialog();
+ }
+
///
- /// 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).
///
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(
diff --git a/src/TeamsISO.App/Services/TeamsLauncher.cs b/src/TeamsISO.App/Services/TeamsLauncher.cs
index 5f6be4b..d32b1ad 100644
--- a/src/TeamsISO.App/Services/TeamsLauncher.cs
+++ b/src/TeamsISO.App/Services/TeamsLauncher.cs
@@ -22,6 +22,24 @@ namespace TeamsISO.App.Services;
///
public static class TeamsLauncher
{
+ ///
+ /// 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.
+ ///
+ private static readonly string[] TeamsProcessNames =
+ {
+ "ms-teams", // new MSTeams binary basename
+ "msteams", // alternate basename observed on some installs
+ "Teams", // classic Teams desktop client
+ };
+
+ ///
+ /// 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".
+ ///
+ public static bool IsRunning() =>
+ TeamsProcessNames.Any(n => Process.GetProcessesByName(n).Length > 0);
+
///
/// 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;
}
+ ///
+ /// Asks every running Teams process to close gracefully via WM_CLOSE
+ /// (CloseMainWindow). Returns the count of processes that exited cleanly within
+ /// . 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.
+ ///
+ 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
diff --git a/src/TeamsISO.App/TeamsISO.App.csproj b/src/TeamsISO.App/TeamsISO.App.csproj
index d4a1c29..9b9eab7 100644
--- a/src/TeamsISO.App/TeamsISO.App.csproj
+++ b/src/TeamsISO.App/TeamsISO.App.csproj
@@ -7,7 +7,7 @@
TeamsISO.App
TeamsISO
true
-
+ Assets\teamsiso.ico
@@ -19,6 +19,7 @@
+
diff --git a/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs b/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs
index 9a1d67a..a4d1d5e 100644
--- a/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs
+++ b/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs
@@ -38,7 +38,9 @@ public sealed class ParticipantViewModel : ObservableObject
private long _framesIn;
private long _framesOut;
+ private long _framesDropped;
private string _incomingResolution = "—";
+ private string _incomingFps = "—";
/// Number of frames the receiver has captured so far.
public long FramesIn { get => _framesIn; set => SetField(ref _framesIn, value); }
@@ -46,17 +48,31 @@ public sealed class ParticipantViewModel : ObservableObject
/// Number of frames the sender has emitted so far.
public long FramesOut { get => _framesOut; set => SetField(ref _framesOut, value); }
+ /// Frames dropped by the closest-frame strategy when the receiver outpaces the processor.
+ public long FramesDropped { get => _framesDropped; set => SetField(ref _framesDropped, value); }
+
/// Source resolution as "WxH", or em-dash when no frames have been seen yet.
public string IncomingResolution { get => _incomingResolution; set => SetField(ref _incomingResolution, value); }
+ ///
+ /// 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.
+ ///
+ public string IncomingFps { get => _incomingFps; set => SetField(ref _incomingFps, value); }
+
/// Updates the live stats display from a controller-side snapshot.
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
diff --git a/src/TeamsISO.Console/Program.cs b/src/TeamsISO.Console/Program.cs
index 0732991..540d08a 100644
--- a/src/TeamsISO.Console/Program.cs
+++ b/src/TeamsISO.Console/Program.cs
@@ -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.
///
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;
}
+ ///
+ /// Prints version + diagnostic info. Always exits 0; the strings are intended to
+ /// be pasted into a support ticket.
+ ///
+ 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()
+ .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());
+ 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;
+ }
+
///
/// 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
diff --git a/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs b/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs
index 27471f8..252c2fc 100644
--- a/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs
+++ b/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs
@@ -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);
}
+ ///
+ /// 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).
+ ///
+ 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;
+ }
+ }
+
///
/// 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? onLive = null,
+ Action? onLive = null,
Action? onClear = null,
Action? onFrame = null)
{
@@ -249,13 +318,13 @@ public sealed class IsoPipeline : IAsyncDisposable
interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger(),
config.OutputGroups);
- onLive?.Invoke(receiver, sender);
-
var processor = new FrameProcessor(
config.Settings, scaler, new SolidFrameRenderer(),
frameClock, rawChannel.Reader, processedChannel.Writer,
config.SlateThreshold, loggerFactory.CreateLogger());
+ onLive?.Invoke(receiver, sender, processor);
+
var receiverTask = receiver.RunAsync(ct);
var senderTask = sender.RunAsync(ct);
var processorTask = ProcessorLoopAsync(processor, frameClock, ct);