214 lines
15 KiB
Markdown
214 lines
15 KiB
Markdown
|
|
# 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.
|