Per-Participant NDI ISO Controller for Microsoft Teams. Receives Teams NDI streams, normalizes framerate/resolution, and re-emits clean ISO outputs for live production switchers (vMix, OBS, Ross). Wild Dragon LLC.
Find a file
Zac Gaetano 84861dafa5
Some checks failed
CI / build-and-test (push) Failing after 30s
test: integration — App+MainWindow STA smoke, control-surface live VM, theme XAML load
Punch-list items 26 + 27 — three integration tests that need a live
WPF Application + STA dispatcher, sharing one WpfHostFixture so
Application is created exactly once for the suite (it's
one-per-AppDomain and any second `new Application()` throws).

* src/tests/TeamsISO.App.Tests/Integration/WpfHostFixture.cs (new)
  — long-lived STA thread that hosts a single Application instance
  and a Dispatcher; tests marshal work onto it via Run<T>() /
  Run(Action). WpfHostCollection wraps it as an
  ICollectionFixture so xUnit injects the shared fixture into
  any test class that opts in.
* src/tests/TeamsISO.App.Tests/Integration/IntegrationTests.cs
  (new) — single test class carrying all three cases:
  - AppStartup_FullChain_Constructs_WithoutThrowing — pre-loads
    Theme.Dark.xaml + WildDragonTheme.xaml via pack URIs, calls
    ThemeManager.Apply(), constructs MainViewModel with the stub
    controller, constructs MainWindow with the VM as DataContext,
    and asserts the Wd.Canvas brush key resolves on the live
    window. All DependencyObject access happens inside a single
    Dispatcher.Invoke so we never marshal a DO reference across
    threads (WPF's VerifyAccess would throw).
  - ControlSurface_GetParticipants_ReturnsLiveViewModelState —
    boots a ControlSurfaceServer on an ephemeral port against
    a real MainViewModel; publishes a synthetic participant
    through the stub controller's observable; drains the
    dispatcher to ApplicationIdle so the Background-priority
    add lands before the REST call; asserts the JSON includes
    Alice. Complements branch-9 route-smoke tests (which used a
    null view-model) by exercising the dispatcher-marshalling
    path.
  - ThemeXaml_DarkAndLight_BothLoadWithDistinctWdCanvas — loads
    both theme files directly via pack URIs and asserts the two
    canvas brushes are the documented #0A0A0A and #FAFAFB. Doesn't
    test ThemeManager.SwapColorDictionary against Application.
    Resources (the swap STATE test was flaky under xUnit's
    parallel-collection model — Application.Resources is
    process-wide and sibling tests' mutations made the read
    non-deterministic). The unit-layer ThemeManagerTests already
    cover the swap state machine against stubbed seams; this
    integration test guards that the real XAML files load and
    produce the documented colours.

Production code change to support both tests AND a longstanding
correctness issue:
* ThemeManager.SwapColorDictionary now constructs its replacement
  ResourceDictionary with a `pack://application:,,,/TeamsISO;component/Themes/…`
  absolute URI instead of the relative `/Themes/…` form. The
  relative form resolves against Application.Current's base URI —
  which is the entry assembly in production (TeamsISO) but the
  test assembly in xUnit. The pack URI is unambiguous in both
  contexts. Production behaviour is identical (still resolves to
  the same XAML files in the App assembly).

Notes-state collection: NotesServiceTests + OscBridgeDispatchTests
now share a NotesStateCollection xUnit collection because both
mutate the static NotesService.DirectoryOverride; without the
collection xUnit's parallel-collection scheduling let one class's
ctor clobber the override mid-test.

Xunit.StaFact 1.1.11 package added to the test csproj — primary
use was the early WpfFact-based iteration of these tests, kept
because Xunit.StaFact provides the [WpfFact] alternative if a
future test wants per-test STA without sharing the fixture.

Final test totals: 56 → 131 in App.Tests; 103 → 106 in
Engine.Tests. 237 tests pass. Build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:34:09 -04:00
.forgejo/workflows ci: optional MSI + exe code-signing in release.yml 2026-05-10 09:41:28 -04:00
docs chore(docs): reconcile to WPF-only after WinUI 3 was abandoned 2026-05-15 19:16:20 -04:00
installer feat: app icon, FPS, drops counter, --version, About dialog, Stop Teams toggle 2026-05-08 13:50:19 -04:00
src test: integration — App+MainWindow STA smoke, control-surface live VM, theme XAML load 2026-05-15 21:34:09 -04:00
.editorconfig chore: scaffold repo conventions and global build props 2026-05-07 15:07:53 +00:00
.gitignore chore(docs): reconcile to WPF-only after WinUI 3 was abandoned 2026-05-15 19:16:20 -04:00
build-and-test.ps1 chore: sweep orphaned files (UpdateChecker, UpdateBanner, TeamsControlBridge, helper scripts) 2026-05-10 09:42:29 -04:00
CHANGELOG.md chore(docs): reconcile to WPF-only after WinUI 3 was abandoned 2026-05-15 19:16:20 -04:00
commit-and-push.ps1 chore: trim stale batch-commit script + drop SmokeTest placeholder 2026-05-15 20:16:14 -04:00
coverlet.runsettings ci: enforce 80% line coverage gate on TeamsISO.Engine 2026-05-07 15:16:11 +00:00
DESIGN.md chore(docs): reconcile to WPF-only after WinUI 3 was abandoned 2026-05-15 19:16:20 -04:00
Directory.Build.props chore: scaffold repo conventions and global build props 2026-05-07 15:07:53 +00:00
NEXT_STEPS.md chore(docs): reconcile to WPF-only after WinUI 3 was abandoned 2026-05-15 19:16:20 -04:00
PRODUCT.md feat(wpf): v2 'Studio Terminal' shell - theme system, header, transport strip, drawer 2026-05-14 12:46:24 -04:00
README.md chore(docs): reconcile to WPF-only after WinUI 3 was abandoned 2026-05-15 19:16:20 -04:00
TeamsISO.Linux.slnf feat(console): add TeamsISO.Console headless smoke runner 2026-05-07 15:37:44 +00:00
TeamsISO.sln feat(wpf): rollback to WPF host, axe recording, fix settings pane 2026-05-14 06:02:40 -04:00
TeamsISO.Windows.slnf chore(docs): reconcile to WPF-only after WinUI 3 was abandoned 2026-05-15 19:16:20 -04:00

TeamsISO

Per-Participant NDI ISO Controller for Microsoft Teams.

TeamsISO sits between Microsoft Teams' raw NDI broadcast output and a live-production environment. It receives each participant's NDI stream, normalizes framerate / resolution / aspect / audio per a configured target, and re-emits clean, individually-addressable NDI sources for ingestion into a switcher (vMix, OBS, Ross, hardware capture).

What it does

  • Discovers participants as Teams broadcasts each one over NDI, surfacing the operator-friendly display name (handles current "MS Teams - Name" format and the legacy "(Teams) Name" format).
  • Normalizes feeds to a consistent framerate, resolution, aspect mode, and audio routing — so the downstream switcher gets predictable inputs regardless of what each participant's webcam is doing.
  • Routes per-participant as separate NDI sources with a configurable output-name template (TEAMSISO_{name}, {guid}, {machine}, {timestamp} tokens).
  • Records each ISO to disk simultaneously — raw BGRA + sidecar manifest.json
    • ffmpeg convert.cmd — so post-production gets a clean per-guest archive.
  • Embeds Teams orchestration: launch and stop Teams from the rail, hide Teams' UI windows during a show, drive in-call controls (mute, camera, share, leave, raise hand) via UIAutomation.
  • Operator presets save the current per-participant ISO assignment and custom output names, applicable on next launch automatically.
  • Live preview thumbnails per participant in the participants table, plus pop-out floating preview windows (right-click → Open preview…) for multi-monitor monitoring.
  • External control surface — REST + WebSocket on 127.0.0.1:9755 and OSC on UDP 127.0.0.1:9000 for Bitfocus Companion / Stream Deck / TouchOSC integration. Self-contained HTML control panel at /ui for phone-as-controller.
  • Crash diagnostics wired to a rolling daily Serilog file sink under %LOCALAPPDATA%\TeamsISO\Logs\.
  • Update check against forge.wilddragon.net's release API — manual or silent on launch (throttled to 24h).
  • Diagnostic bundle export zips logs + config + presets for bug reports.

Status

Pre-1.0. The May 2026 batch is feature-complete; v1.0 cut is gated on code-signing the MSI and a smoke pass against a real Teams meeting. See CHANGELOG.md for the [Unreleased] entry.

The May 2026 ground-up redesign — the v2 "Studio Terminal" shell — has landed on the WPF host (src/TeamsISO.App/). A WinUI 3 replatform was explored in early May 2026 and abandoned (activation blockers + redundant work given the redesign is purely XAML / view-layer); the brief lives at docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md, and the abandoned migration plan + bootstrap probe are archived under docs/archive/.

Build

Requires .NET 8 SDK on Windows. WPF is the only host:

  • src/TeamsISO.App — WPF, net8.0-windows, the shipping build

Build from the solution filter:

dotnet restore TeamsISO.Windows.slnf
dotnet build TeamsISO.Windows.slnf -c Release
dotnet test TeamsISO.Windows.slnf --filter "Category!=ndi&requires!=ndi"

The shipped helper scripts in the repo root automate this:

pwsh -File .\build-and-test.ps1
pwsh -File .\commit-and-push.ps1

Documentation

Keyboard shortcuts

Key Action
F1 Open help / cheat sheet
Ctrl + K Open the command palette (also Ctrl + P)
Ctrl + T Toggle theme (dark ↔ light)
Ctrl + M Drop a timestamped marker into every active recording
Ctrl + Shift + S Stop every running ISO (emergency)
Ctrl + R Refresh NDI discovery (rebuild finder)
19 / NumPad 19 Toggle the Nth visible participant's ISO

File locations

Path Contents
%APPDATA%\TeamsISO\config.json Engine settings (framerate, NDI groups, etc.)
%LOCALAPPDATA%\TeamsISO\presets.json Saved operator presets + auto-apply preference
%LOCALAPPDATA%\TeamsISO\Logs\ Rolling daily diagnostic logs
%LOCALAPPDATA%\TeamsISO\Notes\ Per-day show-notes markdown files
%USERPROFILE%\Videos\TeamsISO\<date>\ Default recording output
%APPDATA%\NDI\ndi-config.v1.json NDI Access Manager group routing

License

Proprietary, © Wild Dragon LLC 2026.