diff --git a/src/TeamsISO.App/TeamsISO.App.csproj b/src/TeamsISO.App/TeamsISO.App.csproj
index aa79754..bca05e1 100644
--- a/src/TeamsISO.App/TeamsISO.App.csproj
+++ b/src/TeamsISO.App/TeamsISO.App.csproj
@@ -4,10 +4,22 @@
WinExe
net8.0-windows
true
+
+ true
TeamsISO.App
TeamsISO
true
Assets\teamsiso.ico
+
+ true
@@ -15,6 +27,18 @@
+
+
+
+ <_Parameter1>TeamsISO.App.Tests
+
+
+
diff --git a/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs b/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs
index 7409dde..ce70d30 100644
--- a/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs
+++ b/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs
@@ -1,5 +1,9 @@
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
+using TeamsISO.Engine.Pipeline;
using IsoHealthStats = TeamsISO.Engine.Domain.IsoHealthStats;
namespace TeamsISO.App.ViewModels;
@@ -16,12 +20,193 @@ public sealed class ParticipantViewModel : ObservableObject
private bool _isProcessing;
private string _customName;
+ /// Thumbnail dimensions. 16:9 ratio matches the typical Teams output aspect.
+ private const int ThumbnailWidth = 160;
+ private const int ThumbnailHeight = 90;
+
+ ///
+ /// Live preview of the most recent processed frame, scaled to ×
+ /// . Updated by on the UI
+ /// thread, called from MainViewModel's stats tick. Null until the first frame arrives.
+ ///
+ public WriteableBitmap? Thumbnail
+ {
+ get => _thumbnail;
+ private set
+ {
+ if (SetField(ref _thumbnail, value))
+ OnPropertyChanged(nameof(HasThumbnail));
+ }
+ }
+ private WriteableBitmap? _thumbnail;
+
+ /// True when is non-null. Bound to Visibility in XAML.
+ public bool HasThumbnail => _thumbnail is not null;
+
public ParticipantViewModel(IIsoController controller, Participant participant)
{
_controller = controller;
_participant = participant;
_customName = string.Empty;
ToggleIsoCommand = new AsyncRelayCommand(ToggleIsoAsync, () => !_isProcessing);
+ CopySourceNameCommand = new RelayCommand(() =>
+ {
+ try
+ {
+ var src = _participant.CurrentSource?.FullName;
+ if (!string.IsNullOrEmpty(src))
+ System.Windows.Clipboard.SetText(src);
+ }
+ catch
+ {
+ // Clipboard occasionally errors when something else has it locked;
+ // best-effort, no user-visible failure.
+ }
+ });
+
+ OpenPreviewCommand = new RelayCommand(() =>
+ {
+ // Non-modal — operator can open multiple previews at once.
+ // Owner is the main window so the preview centers nicely and
+ // closes cleanly when the host exits.
+ var preview = new PreviewWindow(_controller, Id, DisplayName);
+ preview.Show();
+ });
+
+ RestartIsoCommand = new AsyncRelayCommand(RestartIsoAsync,
+ () => _isEnabled && !_isProcessing);
+ }
+
+ ///
+ /// Disable + re-enable this pipeline. Brief delay between for the engine
+ /// to fully tear down before we ask for a fresh sender. The processing
+ /// flag suppresses the toggle button + restart action while in flight.
+ ///
+ private async Task RestartIsoAsync()
+ {
+ if (!IsEnabled) return;
+ IsProcessing = true;
+ try
+ {
+ await _controller.DisableIsoAsync(Id, CancellationToken.None);
+ // Short delay so any in-flight NDI sender disposal completes before
+ // we ask CreateSender for the same name. Empirically 250ms is plenty.
+ await Task.Delay(250);
+ var resolvedName = string.IsNullOrWhiteSpace(_customName)
+ ? Services.OutputNameTemplate.Render(
+ Services.OutputNameTemplate.Get(),
+ Id,
+ DisplayName)
+ : _customName;
+ bool? recordOverride = _recordToDisk ? null : false;
+ await _controller.EnableIsoAsync(Id, resolvedName, recordOverride, CancellationToken.None);
+ // IsEnabled is already true (we never set it false); re-fire the
+ // change notification so any UI bindings sensitive to a transition
+ // observe the restart.
+ OnPropertyChanged(nameof(IsEnabled));
+ }
+ finally
+ {
+ IsProcessing = false;
+ }
+ }
+
+ ///
+ /// Refresh the preview thumbnail from the engine's most recent processed frame.
+ /// Must be called on the UI thread (MainViewModel's stats DispatcherTimer is fine).
+ /// Allocates the WriteableBitmap lazily on the first call so we don't pay for it
+ /// on participants that never have an ISO enabled. Skips work if the engine has
+ /// no frame yet (no pipeline, or pipeline still warming up).
+ ///
+ public void UpdateThumbnail(ProcessedFrame? frame)
+ {
+ if (frame is null || frame.Pixels.IsEmpty)
+ {
+ // Don't clear a previously-rendered thumbnail on transient null reads —
+ // a brief gap between frames shouldn't visibly blank the preview. The
+ // Thumbnail is only set to null when the pipeline genuinely stops, which
+ // we observe by IsEnabled flipping false elsewhere.
+ return;
+ }
+
+ // Defense in depth: if the engine ever hands us a frame whose pixel buffer
+ // doesn't match the declared dimensions (would imply an engine bug), don't
+ // crash the UI on IndexOutOfRangeException — silently skip this update and
+ // wait for a sane frame.
+ var expectedBytes = frame.Width * frame.Height * 4;
+ if (frame.Width <= 0 || frame.Height <= 0 || frame.Pixels.Length < expectedBytes)
+ return;
+
+ if (_thumbnail is null)
+ {
+ // 96 DPI matches the WPF default; PixelFormats.Bgra32 matches the
+ // engine's BGRA pixel layout so the WritePixels call is a memcpy.
+ // The setter fires PropertyChanged for both Thumbnail and HasThumbnail
+ // so the DataGrid's Visibility bindings flip in the same change cycle.
+ Thumbnail = new WriteableBitmap(
+ ThumbnailWidth, ThumbnailHeight, 96, 96, PixelFormats.Bgra32, null);
+ }
+
+ var thumb = _thumbnail!;
+ thumb.Lock();
+ try
+ {
+ ScaleNearestNeighborBgra(
+ src: frame.Pixels.Span,
+ srcW: frame.Width,
+ srcH: frame.Height,
+ dst: thumb.BackBuffer,
+ dstStride: thumb.BackBufferStride,
+ dstW: ThumbnailWidth,
+ dstH: ThumbnailHeight);
+ thumb.AddDirtyRect(new Int32Rect(0, 0, ThumbnailWidth, ThumbnailHeight));
+ }
+ finally
+ {
+ thumb.Unlock();
+ }
+ }
+
+ ///
+ /// Inline nearest-neighbor scaler — copies one downsampled BGRA frame into the
+ /// WriteableBitmap's back buffer. We don't reuse
+ /// because it allocates a managed buffer per scale; here we want to write
+ /// directly into the WriteableBitmap's pinned native memory to avoid a copy.
+ ///
+ /// The arithmetic is the same: for each destination pixel, compute the source
+ /// pixel via integer ratio and copy 4 bytes. At 1Hz × 10 thumbnails that's
+ /// ~144,000 pixel reads per second — negligible CPU.
+ ///
+ private static void ScaleNearestNeighborBgra(
+ ReadOnlySpan src, int srcW, int srcH,
+ IntPtr dst, int dstStride, int dstW, int dstH)
+ {
+ // Pre-compute the x-ratio table once per call so the inner loop is just two
+ // multiplies and a memcpy. Java-style fixed-point would be faster but for
+ // 160×90 the overhead is irrelevant.
+ Span srcXFor = stackalloc int[dstW];
+ for (var x = 0; x < dstW; x++) srcXFor[x] = x * srcW / dstW;
+
+ unsafe
+ {
+ var dstPtr = (byte*)dst;
+ var srcStride = srcW * 4;
+ for (var y = 0; y < dstH; y++)
+ {
+ var srcY = y * srcH / dstH;
+ var srcRow = srcY * srcStride;
+ var dstRow = y * dstStride;
+ for (var x = 0; x < dstW; x++)
+ {
+ var srcOff = srcRow + srcXFor[x] * 4;
+ var dstOff = dstRow + x * 4;
+ dstPtr[dstOff + 0] = src[srcOff + 0];
+ dstPtr[dstOff + 1] = src[srcOff + 1];
+ dstPtr[dstOff + 2] = src[srcOff + 2];
+ dstPtr[dstOff + 3] = src[srcOff + 3];
+ }
+ }
+ }
}
public Guid Id => _participant.Id;
@@ -36,6 +221,20 @@ public sealed class ParticipantViewModel : ObservableObject
set => SetField(ref _isEnabled, value);
}
+ ///
+ /// When true (default), the operator wants this participant's ISO recorded
+ /// when the global recording toggle is on. When false, this participant is
+ /// opted out of recording even with global on. The flag is read at the
+ /// EnableIsoAsync call so changing it after enabling has no effect on a
+ /// running pipeline; operator must disable + re-enable to apply.
+ ///
+ public bool RecordToDisk
+ {
+ get => _recordToDisk;
+ set => SetField(ref _recordToDisk, value);
+ }
+ private bool _recordToDisk = true;
+
private long _framesIn;
private long _framesOut;
private long _framesDropped;
@@ -53,6 +252,29 @@ public sealed class ParticipantViewModel : ObservableObject
private string _stateLabel = "—";
private string _stateColor = "Wd.Text.Tertiary";
+ private double _peakAudioLevel;
+ private double _displayedAudioLevel;
+ private DateTimeOffset _lastPeakAt;
+
+ ///
+ /// Smoothed audio level for display (0.0 to 1.0). Decays toward 0 between
+ /// peak updates so the VU bar feels alive even when audio is sparse. The
+ /// raw peak from the engine arrives at the 1Hz stats poll; we interpolate
+ /// down between polls in the property getter (technically a slight
+ /// abstraction leak but simpler than wiring another timer).
+ ///
+ public double DisplayedAudioLevel
+ {
+ get => _displayedAudioLevel;
+ private set => SetField(ref _displayedAudioLevel, value);
+ }
+
+ ///
+ /// VU-bar fill width as a 0-100 number, suitable for a Width binding on
+ /// a fixed-size 100-px-wide indicator. Returns the displayed (decayed)
+ /// audio level scaled to [0, 100]; 0 when no recent audio has been seen.
+ ///
+ public double AudioLevelWidthPercent => Math.Clamp(_displayedAudioLevel * 100, 0, 100);
/// Human-readable pipeline state ("Receiving", "Error", "—").
public string StateLabel { get => _stateLabel; set => SetField(ref _stateLabel, value); }
@@ -73,6 +295,26 @@ public sealed class ParticipantViewModel : ObservableObject
/// Updates the live stats display from a controller-side snapshot.
public void UpdateStats(IsoHealthStats stats)
{
+ // Audio level: take the new peak when it's higher than what we're
+ // currently displaying (instant attack), otherwise decay toward zero
+ // (slow release). 0.7 multiplier per 1Hz tick = ~half-life of ~1.6s,
+ // which feels like a real VU meter. When the engine starts feeding
+ // real PeakAudioLevel values, this code starts working without
+ // further changes.
+ if (stats.PeakAudioLevel > _displayedAudioLevel)
+ {
+ _displayedAudioLevel = stats.PeakAudioLevel;
+ _lastPeakAt = DateTimeOffset.UtcNow;
+ }
+ else
+ {
+ _displayedAudioLevel *= 0.7;
+ if (_displayedAudioLevel < 0.01) _displayedAudioLevel = 0;
+ }
+ _peakAudioLevel = stats.PeakAudioLevel;
+ OnPropertyChanged(nameof(DisplayedAudioLevel));
+ OnPropertyChanged(nameof(AudioLevelWidthPercent));
+
FramesIn = stats.FramesIn;
FramesOut = stats.FramesOut;
FramesDropped = stats.FramesDropped;
@@ -111,6 +353,19 @@ public sealed class ParticipantViewModel : ObservableObject
public AsyncRelayCommand ToggleIsoCommand { get; }
+ /// Copy the participant's NDI source FullName to the clipboard. Useful for pasting into Studio Monitor.
+ public RelayCommand CopySourceNameCommand { get; }
+
+ /// Open a non-modal floating preview window for this participant. Multi-monitor friendly.
+ public RelayCommand OpenPreviewCommand { get; }
+
+ ///
+ /// Restart the pipeline for this participant: disable, brief pause, re-enable.
+ /// Useful when a single feed flakes (drops climb, framerate jitters) without
+ /// affecting other ISOs. No-op when the pipeline isn't currently enabled.
+ ///
+ public AsyncRelayCommand RestartIsoCommand { get; }
+
/// Refreshes the underlying participant data (called when the controller emits an updated list).
public void Update(Participant updated)
{
@@ -133,9 +388,25 @@ public sealed class ParticipantViewModel : ObservableObject
}
else
{
+ // Resolve the output name: explicit per-participant CustomName
+ // wins; otherwise expand the operator's template (defaults to
+ // "TEAMSISO_{guid}" which matches the engine's old hard-coded
+ // behavior). Passing the rendered name to EnableIsoAsync as
+ // customName overrides the engine's DefaultOutputName path.
+ var resolvedName = string.IsNullOrWhiteSpace(_customName)
+ ? Services.OutputNameTemplate.Render(
+ Services.OutputNameTemplate.Get(),
+ Id,
+ DisplayName)
+ : _customName;
+ // Per-participant recording opt-out: when RecordToDisk is false,
+ // pass a false override so the engine doesn't attach a recorder
+ // even if global recording is on.
+ bool? recordOverride = _recordToDisk ? null : false;
await _controller.EnableIsoAsync(
Id,
- string.IsNullOrWhiteSpace(_customName) ? null : _customName,
+ resolvedName,
+ recordOverride,
CancellationToken.None);
IsEnabled = true;
}
diff --git a/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs b/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs
index a6f4c1a..b57ce6e 100644
--- a/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs
+++ b/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs
@@ -1,3 +1,4 @@
+using System.IO;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using TeamsISO.Engine.Domain;
@@ -35,6 +36,21 @@ public sealed class IsoPipeline : IAsyncDisposable
private int _lastHeight;
private DateTimeOffset? _lastReceivedAt;
+ // Most recent ProcessedFrame, for the UI thumbnail. We hold a reference (not a
+ // copy) — the FrameProcessor allocates new arrays per frame so the captured
+ // ReadOnlyMemory stays valid until GC reclaims it. UI consumers read
+ // this lazily at ~1Hz; transient null reads (between supervisor restarts)
+ // are handled at the call site.
+ private ProcessedFrame? _latestProcessedFrame;
+
+ ///
+ /// The most recent emitted by the inner pipeline,
+ /// or null if no frame has been processed yet (or the pipeline has stopped).
+ /// Safe to read from any thread; reading the reference is atomic on .NET, and
+ /// ProcessedFrame itself is immutable once constructed.
+ ///
+ public ProcessedFrame? LatestProcessedFrame => Volatile.Read(ref _latestProcessedFrame);
+
// 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
@@ -191,6 +207,7 @@ public sealed class IsoPipeline : IAsyncDisposable
Volatile.Write(ref _liveReceiver, null);
Volatile.Write(ref _liveSender, null);
Volatile.Write(ref _liveProcessor, null);
+ Volatile.Write(ref _latestProcessedFrame, null);
ResetFrameTimestamps();
},
onFrame: frame =>
@@ -203,6 +220,13 @@ public sealed class IsoPipeline : IAsyncDisposable
var nowTicks = DateTimeOffset.UtcNow.UtcTicks;
_lastReceivedAt = new DateTimeOffset(nowTicks, TimeSpan.Zero);
RecordFrameTimestamp(nowTicks);
+ },
+ onProcessedFrame: frame =>
+ {
+ // Hold the most recent processed frame for the UI preview thumbnail.
+ // We replace the reference atomically; the previous frame becomes GC-eligible
+ // once the renderer's last copy is done with it.
+ Volatile.Write(ref _latestProcessedFrame, frame);
});
}
@@ -302,7 +326,8 @@ public sealed class IsoPipeline : IAsyncDisposable
CancellationToken ct,
Action? onLive = null,
Action? onClear = null,
- Action? onFrame = null)
+ Action? onFrame = null,
+ Action? onProcessedFrame = null)
{
var rawChannel = Channel.CreateBounded(new BoundedChannelOptions(config.RawChannelCapacity)
{
@@ -323,6 +348,29 @@ public sealed class IsoPipeline : IAsyncDisposable
? rawChannel.Writer
: new TappedChannelWriter(rawChannel.Writer, onFrame);
+ // Tap the processed-frame stream for the optional recorder. We open the
+ // recorder lazily on the first frame so we know the actual output
+ // dimensions (FrameProcessingSettings carries an enum, not concrete pixel
+ // counts; the FrameProcessor resolves the enum to width/height when it
+ // first scales, and that's what gets emitted in the ProcessedFrame).
+ ChannelWriter processedWriter = config.Recorder is null
+ ? processedChannel.Writer
+ : new RecordingChannelWriter(
+ processedChannel.Writer,
+ config.Recorder,
+ config.RecordingOutputDirectory ?? Path.GetTempPath(),
+ config.RecorderDisplayName ?? config.OutputName,
+ config.Settings.FramerateHz);
+
+ // Tap again so the host can read the most recent processed frame for the
+ // UI preview thumbnail. The tap fires AFTER the recorder write, so a
+ // recorder failure doesn't suppress the preview update; both observers
+ // are independent.
+ if (onProcessedFrame is not null)
+ {
+ processedWriter = new TappedChannelWriter(processedWriter, onProcessedFrame);
+ }
+
using var receiver = new NdiReceiver(
interop, config.SourceName, rawWriter, loggerFactory.CreateLogger());
using var sender = new NdiSender(
@@ -331,7 +379,7 @@ public sealed class IsoPipeline : IAsyncDisposable
var processor = new FrameProcessor(
config.Settings, scaler, new SolidFrameRenderer(),
- frameClock, rawChannel.Reader, processedChannel.Writer,
+ frameClock, rawChannel.Reader, processedWriter,
config.SlateThreshold, loggerFactory.CreateLogger());
onLive?.Invoke(receiver, sender, processor);
@@ -348,6 +396,9 @@ public sealed class IsoPipeline : IAsyncDisposable
{
rawChannel.Writer.TryComplete();
processedChannel.Writer.TryComplete();
+ // Recorder owns its own writer task with bounded queue; closing here
+ // flushes pending frames and finalizes manifest.json + convert.cmd.
+ try { config.Recorder?.Close(); } catch { /* defensive */ }
onClear?.Invoke();
}
}
@@ -372,6 +423,70 @@ public sealed class IsoPipeline : IAsyncDisposable
public override bool TryComplete(Exception? error = null) => _inner.TryComplete(error);
}
+ ///
+ /// Channel-writer wrapper that feeds every successfully-written
+ /// to an . Opens the
+ /// recorder lazily on the first frame (so we know the actual width/height —
+ /// the FrameProcessor resolves the resolution enum to concrete dimensions
+ /// only after the first scale, and that's what shows up in the ProcessedFrame).
+ ///
+ private sealed class RecordingChannelWriter : ChannelWriter
+ {
+ private readonly ChannelWriter _inner;
+ private readonly IRecorderSink _recorder;
+ private readonly string _outputDirectory;
+ private readonly string _displayName;
+ private readonly double _fps;
+ private bool _opened;
+
+ public RecordingChannelWriter(
+ ChannelWriter inner,
+ IRecorderSink recorder,
+ string outputDirectory,
+ string displayName,
+ double fps)
+ {
+ _inner = inner;
+ _recorder = recorder;
+ _outputDirectory = outputDirectory;
+ _displayName = displayName;
+ _fps = fps;
+ }
+
+ public override bool TryWrite(ProcessedFrame item)
+ {
+ if (!_inner.TryWrite(item)) return false;
+ // Lazy-open after the first successful write so we have concrete
+ // dimensions. We deliberately try-catch here: a recorder failure
+ // (disk full, permission denied) must NOT prevent the live ISO from
+ // continuing — the user's downstream switcher is the production
+ // surface, the recording is the archive copy.
+ try
+ {
+ if (!_opened)
+ {
+ _recorder.Open(_displayName, _outputDirectory, item.Width, item.Height, _fps);
+ _opened = true;
+ }
+ _recorder.WriteFrame(item);
+ }
+ catch
+ {
+ // defensive: recorder errors never propagate to the live path
+ }
+ return true;
+ }
+
+ public override ValueTask WaitToWriteAsync(CancellationToken ct = default)
+ => _inner.WaitToWriteAsync(ct);
+
+ public override bool TryComplete(Exception? error = null)
+ {
+ try { _recorder.Close(); } catch { /* defensive */ }
+ return _inner.TryComplete(error);
+ }
+ }
+
private static async Task ProcessorLoopAsync(FrameProcessor processor, IFrameClock clock, CancellationToken ct)
{
while (!ct.IsCancellationRequested)