# TeamsISO v1.0 — Implementation Spec

**Status:** Draft, ready for plan-writing
**Date:** 2026-05-07
**Owner:** Zac Gaetano (Wild Dragon LLC)
**Source design doc:** `TeamsISO Design Document.docx` v0.1 DRAFT (May 2026)

This spec turns the source design document into an implementable plan for the v1.0 release. The product vision, problem statement, and feature matrix in the source document remain authoritative; this spec adds the architectural and operational decisions needed to start building.

## 1. Scope

v1.0 ships the feature set in §6 of the source document, exactly as written:

- NDI participant discovery (auto)
- Per-participant ISO NDI output
- Global framerate lock (23.976 / 24 / 25 / 29.97 / 30 / 50 / 59.94 / 60 fps)
- Global resolution normalize (720p / 1080p / 4K)
- Custom output stream naming
- Isolated audio per ISO with mixed-audio fallback
- Screen share as ISO output

Deferred to v1.5: per-stream framerate override, thumbnail previews, GPU-accelerated scaling.
Deferred to v2.0: multi-machine cluster coordination, OSC/WebSocket control API.

Out of scope for v1.0: automatic peer discovery between TeamsISO instances, audio resampling, code signing of the installer.

## 2. Architecture

**Pattern:** engine/UI separation from day one. The NDI engine is a class library with no UI dependency; the WPF app is a thin host that binds to the engine through a typed C# API. v2.0's control APIs and multi-machine coordinator drop in cleanly because the boundary already exists.

**Solution layout:**

- `TeamsISO.Engine` — class library. Discovery, receive, frame processing, send, configuration, logging abstraction. Exposes `IIsoController` and observable streams. Owns all threading.
- `TeamsISO.Engine.NdiInterop` — internal P/Invoke shim for `NDIlib_*` and libyuv. Kept separate so the rest of the engine speaks managed types and unit tests can fake the interop surface.
- `TeamsISO.App` — WPF + MVVM host. Instantiates the engine, binds view models to engine observables, persists window layout. Zero NDI knowledge.
- `TeamsISO.Engine.Tests` — xUnit unit tests against `FakeNdiInterop`. Pure managed.
- `TeamsISO.Engine.IntegrationTests` — xUnit integration tests against the real NDI runtime. Tagged `[Trait("requires", "ndi")]`.
- `TeamsISO.Installer` — WiX v5 project producing the MSI.

**Engine ↔ App contract:** `IIsoController` exposes `IObservable<IReadOnlyList<Participant>>`, `IObservable<IsoHealthStats>` per output, `IObservable<EngineAlert>`, and async command methods (`EnableIsoAsync`, `SetTargetFramerate`, `SetCustomName`, `SetGlobalSettings`, etc.). All commands are cancellable.

## 3. Domain model

Defined in `TeamsISO.Engine.Domain`. All types are immutable records unless noted.

- **`NdiSource`** — raw discovery record. `string FullName`, parsed `MachineName`, `Kind` (`Participant | ActiveSpeaker | Audio | ScreenShare`), `DisplayName` (null for non-participant kinds).
- **`Participant`** — operator-facing identity. `Guid Id` (engine-assigned, stable across rename heuristic), `string DisplayName` (last seen), `NdiSource? CurrentSource`, `DateTimeOffset FirstSeen / LastSeen`. Mutable via the engine; observable.
- **`IsoAssignment`** — operator's intent. `Guid ParticipantId`, `bool IsEnabled`, `string? CustomOutputName`. Persisted to `config.json`. Reserves room for v1.5 per-stream overrides.
- **`IsoOutput`** — runtime state. `Guid ParticipantId`, `string EffectiveOutputName`, `IsoHealthStats Stats`, `IsoState State` (`Idle | Receiving | Sending | NoSignal | Error`).
- **`FrameProcessingSettings`** — `TargetFramerate`, `TargetResolution`, `AspectMode` (`Pillarbox | Letterbox | Stretch`), `AudioMode` (`Isolated | Mixed | Auto`).
- **`IsoHealthStats`** — `FramesIn`, `FramesOut`, `FramesDropped`, `FramesDuplicated`, `LastFrameAt`, `IncomingFps`, `IncomingResolution`.
- **`EngineConfig`** — root persisted record: `FrameProcessingSettings Global`, `IReadOnlyList<IsoAssignment> Assignments`. Stored at `%APPDATA%\TeamsISO\config.json`.
- **`EngineAlert`** — discriminated union: `NdiRuntimeMismatch | OutputNameCollision | PipelineError | ConfigSaveFailed`.

**Participant identity across rename / disconnect.** Teams source strings change when a participant renames. Engine policy: if a source disappears and within 5 seconds a new participant source with the same `MachineName` appears, the engine transfers the existing `Participant.Id` (and any `IsoAssignment` bound to it) to the new source. The UI shows a brief rename toast. Operators can opt out per-meeting in settings.

## 4. Components

Eight subsystems inside `TeamsISO.Engine`. Each has one responsibility.

**`NdiDiscoveryService`** — owns one `NDIlib_find_create_v2` instance on a long-running background thread. Polls every ~500 ms, diffs the source list, classifies each source, pushes `DiscoveryEvent` (`Added | Removed | Renamed`) onto a `Channel<DiscoveryEvent>`.

**`ParticipantTracker`** — consumes `DiscoveryEvent`s, applies the rename heuristic, maintains the canonical `IObservable<IReadOnlyList<Participant>>`. Stateful, pure-managed, unit-testable without NDI.

**`IsoPipeline`** — per-ISO unit. Owns one receiver, one frame processor, one sender, all health stats. Lifecycle methods `Start`, `Stop`. Created by `IsoPipelineFactory` when the operator enables an ISO.

**`NdiReceiver`** — wraps `NDIlib_recv_create_v3`. Dedicated thread loops on `NDIlib_recv_capture_v3`. Pushes captured frames into a bounded `Channel<RawFrame>` (capacity 4, drop-oldest under backpressure). Records dropped-frame count.

**`FrameProcessor`** — driven by `PeriodicTimer` at the target framerate. At each tick: read newest frame from the channel non-blocking; if available, scale via libyuv to target resolution + aspect mode, recalculate timecodes, hand to sender; if unavailable, re-emit `lastFrame`; if `lastFrame` is older than 2.5 s, emit a no-signal slate (`SolidFrameRenderer`, mid-grey).

**`NdiSender`** — wraps `NDIlib_send_create`. Dedicated thread sends video on its tick and audio passthrough on its own queue. Audio mode `Auto` probes for isolated audio at startup and falls back to mixed if unavailable.

**`IsoController`** — top of engine. Holds the pipeline dictionary, the `ParticipantTracker`, the `ConfigStore`. Exposes the `IIsoController` API. Translates "operator enabled this participant" into pipeline creation and start.

**`ConfigStore`** — load/save `EngineConfig` to `%APPDATA%\TeamsISO\config.json`. Atomic writes via temp file + rename.

**Logging:** Serilog file sink at `%APPDATA%\TeamsISO\logs\teamsiso-{Date}.log`, 14-day retention, structured. Engine code logs through `ILogger<T>` from `Microsoft.Extensions.Logging`.

## 5. Data flow and threading

Per ISO:

```
NDI source on LAN
    │
    ▼
[Capture thread]                 (1 dedicated thread)
  NDIlib_recv_capture_v3, blocking loop
    │
    ▼  Channel<RawFrame> (capacity 4, drop-oldest)
    │
[Processor tick]                 (PeriodicTimer on ThreadPool, target framerate)
  pick newest frame → libyuv scale/aspect → retimecode
    │
    ▼  ProcessedFrame
    │
[Send thread]                    (1 dedicated thread)
  NDIlib_send_send_video_v2 + audio
    │
    ▼
ISO output on LAN
```

System-wide threads at 3 active ISOs: 3 capture + 3 send (dedicated, blocking-friendly), 1 discovery, 1 participant-tracker async loop on ThreadPool, 1 UI dispatcher, processor work on ThreadPool. Approximately 9 dedicated threads plus ThreadPool work — within budget for the recommended hardware.

**Why dedicated threads for capture and send:** NDI capture and send calls block. Mixing them onto the .NET ThreadPool risks starving worker threads. Processing is short-lived per frame and fits the ThreadPool model.

**Frame timing strategy (closest-frame):** simple, deterministic, works across all supported framerates without interpolation. Frame duplication = re-send `lastFrame`. After 2.5 s of no incoming frames, slate.

**Audio:** v1.0 forwards audio passthrough on its own NDI queue, no resampling. Isolated audio is forwarded as-is when available; mixed audio is forwarded on the active-speaker stream only as fallback.

**Cancellation:** every loop respects a per-ISO `CancellationToken`. Stopping an ISO triggers cancellation, joins capture and send threads (1 s timeout), disposes NDI handles.

## 6. Error handling and recovery

**Pipeline isolation.** Each `IsoPipeline` runs independently. One pipeline failing never affects others.

**Per-pipeline failure recovery.** Unhandled exception → pipeline transitions to `Error`, releases NDI handles, logs with full context, auto-restarts after 1 s. Exponential backoff: 1, 2, 4, 8, 16 s, capped at 30 s. After 5 consecutive failures, stays `Error` and waits for operator action. Participant remains visible in the UI list so the operator can re-enable manually.

**Source disconnect (expected, not error).** Pipeline transitions to `NoSignal` after 2.5 s, keeps the assignment bound, keeps emitting the slate. If the source returns within 60 s, reconnects automatically. After 60 s the pipeline stops the sender to free NDI bandwidth; reconnects when the source reappears.

**NDI runtime version mismatch.** Detected at startup by `NdiRuntimeProbe`. Surfaces `EngineAlert.NdiRuntimeMismatch`. UI shows a banner with instructions to re-download Teams' NDI binaries (per source doc §7.2). Engine still attempts to run — it's a warning, not a hard fail.

**Output name collision on the LAN.** Logged and surfaced as `EngineAlert.OutputNameCollision`. v1.0 does not auto-rename; the operator picks unique names.

**Startup preflight.** Run before the UI accepts commands:

- NDI runtime present and queryable
- Smoke test: create + destroy one `NDIlib_send_create` instance
- Config file readable; corrupt or missing → fall back to defaults and log
- libyuv DLL loadable
- Write access to `%APPDATA%\TeamsISO\`

A failing preflight surfaces a single error dialog with a copyable diagnostic string; the app does not enter the main UI.

**Engine alert channel.** `IObservable<EngineAlert>` exposes structured alerts to the UI for banner display and to the log for ops.

## 7. Testing

**Three layers, three test projects.**

**Unit (`TeamsISO.Engine.Tests`)** — pure managed, no NDI runtime, fast (<1 s). Covers:

- `ParticipantTracker` rename heuristic (synthetic event streams).
- `FrameProcessor` timing logic against fake clock and fake interop. Asserts: 30 fps target / 24 fps incoming yields 30 frames/s with appropriate duplication; 60 fps target / 30 fps incoming doubles each frame; 2.5 s of silence triggers slate.
- `IsoPipeline` lifecycle (start → run → stop → restart on simulated fault, with backoff schedule asserted).
- `ConfigStore` round-trip (missing → defaults; save → reload identical; corrupt JSON → defaults + log).
- `NdiSourceParser` against a corpus of real Teams source strings (participant, active speaker, audio, screen share, multi-word names with parens, unicode).

**Integration (`TeamsISO.Engine.IntegrationTests`)** — Windows-only, real NDI runtime. Tagged `[Trait("requires", "ndi")]`.

- Spin up a NewTek NDI Test Pattern source as a synthetic participant; route through `IsoPipeline`; receive on a second NDI receiver; assert output stream existence, naming, framerate (measured over 5 s), resolution.
- Source disappear / reappear: stop the test pattern source mid-stream, assert pipeline transitions through `NoSignal`, restart the source, assert pipeline resumes.
- Output name collision: spin two pipelines with the same name, assert `EngineAlert.OutputNameCollision`.

**Manual / live test playbook (`docs/test-playbook.md`)** — checklist for verifying against real Teams meetings before each release.

**TDD discipline.** Every behavior in the engine starts as a failing unit test against fakes. NDI interop has an `INdiInterop` interface; production wires `NdiInteropPInvoke`, tests wire `FakeNdiInterop`.

**Coverage target.** 80% line coverage on `TeamsISO.Engine`, excluding the P/Invoke shim. Enforced in CI.

## 8. Build, packaging, distribution

**Source repo.** `forge.wilddragon.net/zgaetano/teamsiso`. Default branch `main`. Trunk-based with feature branches; PR review for engine-touching changes.

**Build.** MSBuild via `dotnet build` and `dotnet publish`. Solution targets `net8.0-windows` with `TargetPlatformVersion=10.0.19041.0`. `TeamsISO.App` publishes self-contained, single-file, ReadyToRun:

```
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:PublishReadyToRun=true
```

**CI.** Forgejo Actions (GitHub-Actions-compatible). Two pipelines:

- `ci.yml` — every push and PR. Builds, runs unit tests, enforces coverage threshold, lints (treat-warnings-as-errors). Linux runner. Integration tests skip cleanly because `requires=ndi` is absent.
- `release.yml` — on tag push (`v*`). Windows runner with NDI runtime preinstalled. Builds release, runs unit + integration, builds WiX installer, attaches `.msi` to a Forgejo release.

**Versioning.** SemVer in `Directory.Build.props`. Flows to assembly metadata and installer. Tag `v1.0.0` triggers the release pipeline.

**Installer (WiX v5).** Produces `TeamsISO-x.y.z.msi`. Behavior:

- Detects NDI runtime via registry probe; if absent or older, prompts the operator to download from `ndi.video/tools/`. The runtime is not bundled — NDI's redistribution license requires user consent.
- Installs to `%ProgramFiles%\TeamsISO\`.
- Creates Start Menu shortcut, optional desktop shortcut.
- `%APPDATA%\TeamsISO\` is created on first run, not at install (per-user data, per-machine MSI).
- Adds Add/Remove Programs entry.

**NDI redistribution.** Per NDI SDK License v5 the runtime is not bundled. Detection is by registry key. Mismatches show a dialog with the official download link. Captured open task: legal review of NDI SDK License v5 before public v1.0 release.

**Distribution.** v1.0 ships as MSI from Forgejo releases. No auto-update in v1.0. The About dialog shows the current version and links to the Forgejo releases page.

## 9. Open tasks blocking v1.0 release

- Legal review of NDI SDK License v5 (per source doc §7.3) — required before public release; not required for development.
- Confirmation that the Microsoft Teams tenant has the admin policy enabling NDI broadcast (the relevant Teams meeting-policy setting; current name varies by Teams admin center version — verified against the live tenant during development).
- Selection of code-signing approach for v1.0 vs. v1.5 (currently deferred).

## 10. Out of scope for v1.0 (deferred)

- Per-stream framerate override (v1.5)
- Thumbnail previews (v1.5)
- GPU-accelerated frame scaling (v1.5)
- Multi-machine cluster auto-coordination (v2.0)
- OSC / WebSocket control API (v2.0)
- Code signing of the installer
- Auto-update
- Audio resampling

## 11. Glossary

- **NDI** — Network Device Interface (Vizrt/NewTek). LAN video transport protocol used by Teams' broadcast mode.
- **ISO** — In live production, an "isolated" feed of a single source, separate from the program mix. ZoomISO and TeamsISO produce per-participant ISO feeds.
- **Active speaker** — Teams' auto-mixed feed that follows whoever is talking. A separate NDI source from individual participant streams.
- **Slate** — a static frame (typically a solid color or "no signal" graphic) emitted when the source has stopped delivering frames.
