diff --git a/docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md b/docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md new file mode 100644 index 0000000..89f55e1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-07-teamsiso-v1-design.md @@ -0,0 +1,213 @@ +# 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>`, `IObservable` per output, `IObservable`, 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 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`. + +**`ParticipantTracker`** — consumes `DiscoveryEvent`s, applies the rename heuristic, maintains the canonical `IObservable>`. 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` (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` 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 (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` 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.