The audio capture loop runs at ~50Hz publishing every buffer's peak via overwrite; the UI stats poll reads at 1Hz. With overwrite semantics the UI sees one of every ~50 audio frames per second — loud transients between reads were invisible to the VU meter.
New design: NdiReceiver maintains an atomic high-water mark, max-updated on each audio frame via CompareExchange CAS loop. IsoPipeline.GetStats now calls ConsumeAudioPeak() which atomically reads + resets to 0, so the next UI tick reflects the loudest sample seen in the next 1s window.
Added PeekAudioPeak() for non-consuming reads (e.g. external diagnostics dashboards that poll faster than the UI).
FakeNdiInterop gained a ReceiverAudioPeaks queue + CaptureAudioPeak override so tests can drive the audio path. 4 new tests in NdiReceiverTests cover: empty case, single-frame consume+reset, max-hold across 3 frames, no-frame leaves high-water mark untouched. 104 + 46 + 9 = 159/159 passing.
The DataGrid's per-row audio level bar (in the Live column) was inert because IsoHealthStats.PeakAudioLevel always returned 0.0. Engine work needed: capture NDI audio frames, compute peak amplitude, publish through the existing stats path.
Engine:
- AudioPeakComputer (new): max-abs computation across NDI's FLTP / FLT / PCM s16 sample formats. Pure managed code, fully unit-tested (14 cases — clamping behaviour, edge cases like short.MinValue overflow, totalSamples-vs-buffer mismatch defenses).
- INdiInterop.CaptureAudioPeak (new, default-implemented): polls one audio frame, returns peak in [0,1] or null on timeout. FakeNdiInterop inherits the no-op default; production NdiInteropPInvoke overrides with real FLTP decode through a sibling RecvCaptureV3Audio import + RecvFreeAudioV3.
- NdiNative: AudioFrameV3 struct + audio-only RecvCaptureV3 binding + FreeAudioV3.
- NdiReceiver: spins up a sibling audio-capture loop alongside the existing video loop on the same lifetime. Audio failures are caught + logged but never re-thrown (a misbehaving audio path must never tear down the live video pipeline). Latest peak published via Volatile<long> (BitConverter int64 bits) so UI reads are torn-free across threads.
- IsoPipeline.GetStats: surfaces NdiReceiver.LatestAudioPeak as IsoHealthStats.PeakAudioLevel.
UI:
- ParticipantViewModel.OnStatsTick already had the decay logic (max-of-new-or-decayed-old, 0.7 multiplier) waiting for real values. No UI changes needed.
Tests: 14 new + 141 existing = 155/155 passing. 0 warnings, 0 errors.
Two real concerns from the code review on ab07297..b266623:
1. ActiveSpeaker removal poisoned the rename-window heuristic. ParticipantTracker.HandleRemoved appends to _recentlyRemoved keyed by MachineName alone; the next Participant Add on the same machine consulted that list with no kind discrimination, so an active-speaker disappearance immediately followed by a participant joining (very common: Teams renames its outputs as participants enter/leave) would cause the new participant to inherit the auto-mix's deterministic v5 GUID. New HandleAutoMixRemoved deliberately skips _recentlyRemoved — the auto-mix row's identity is already stable via the deterministic Id, so re-add restores it without the rename window.
2. IsoPipeline.State writes were not synchronized. Supervisor loop sets State on its own thread; UI thread reads from GetStats. Without volatility, the JIT could cache the field in a register and the UI would stay stuck on Receiving even after Error. Backing field is now an int read/written via Volatile.Read/Volatile.Write, matching the pattern already used for _liveReceiver / _liveSender / _liveProcessor.
Tests: 79/79 (was 78) — added ParticipantTrackerTests.ActiveSpeakerRemove_DoesNotPoisonRenameWindowForLaterParticipant which would have caught (1).
ParticipantTracker now accepts NdiSourceKind.ActiveSpeaker (Teams' auto-mix output — legacy 'MACHINE (Teams)' or current 'MACHINE (MS Teams - Active Speaker)') and surfaces it as a synthetic row in the participant list with the display name 'Active Speaker'. The operator can route it to its own normalized ISO via the same toggle every other participant uses, so vMix / OBS / Ross can subscribe to a single clean active-speaker feed.
Stable Id: derived from SHA1 of 'auto-mix:<machine>' formatted as a v5 GUID, so a discovery cycle that re-adds the source doesn't duplicate the row and the operator's ISO assignment stays bound across the rename window.
Tests: 78/78 unit (was 76) — added ParticipantTrackerTests.ActiveSpeaker_AppearsAsSyntheticAutoMixParticipant + ActiveSpeaker_ReAddOnSameMachine_PreservesId. Existing NonParticipantSources_AreIgnored still passes (only ActiveSpeaker is opted in; ScreenShare and Audio are still ignored).
Four polish items + a test pass.
1. Inter Variable (rsms/inter v3.19, OFL) is bundled at Assets/Fonts/Inter.ttf (~800 KB) and registered as a WPF Resource. WildDragonTheme.xaml's Wd.Font.Sans now points at pack://application:,,,/Assets/Fonts/#Inter so the typography matches wilddragon.net regardless of whether the user has Inter installed system-wide. Falls back to Segoe UI Variable Display if the resource is missing.
2. 'Stop all ISOs' button at the right of the participants header. Bound to a new MainViewModel.StopAllIsosCommand that snapshots the enabled list, awaits DisableIsoAsync sequentially, and silently swallows per-pipeline failures (best-effort emergency stop). CanExecute gates on whether any ISO is currently enabled.
3. WindowStateStore service persists the main window's Left/Top/Width/Height/State to %LOCALAPPDATA%\\TeamsISO\\window.json on close and restores it on SourceInitialized. Multi-monitor friendly: a saved position with no corner inside any virtual screen is rejected so a disconnected monitor doesn't strand the window off-screen.
4. Two new unit tests cover FrameProcessor's drops + duplicates accounting. 76/76 unit tests pass (was 74).
Adds Serilog.Sinks.File to TeamsISO.Engine and a new EngineLogging.CreateDefault() factory that writes to BOTH the existing console sink and a rolling daily file at %LOCALAPPDATA%\\TeamsISO\\Logs\\teamsiso<date>.log. The WPF host (TeamsISO.exe is a WinExe with no console attached at runtime) now uses CreateDefault so support has something to ask for when users file an issue. The Console build keeps using CreateConsole — stdout is the right surface there and shell redirection beats a competing on-disk sink.
Files roll daily, cap at 10 MB before mid-day rollover, and only the most recent 14 are retained. Disk flush interval is 250 ms so a tail -f from another tool sees lines promptly. Path is announced via the first log line on every startup.
Two unit tests gate the wiring: AllLoggers_WriteToFile (verifies both typed and named CreateLogger() reach the file) and LogsAtBelowMinimumLevel_AreSuppressed (regression guard for level filtering). 74/74 unit tests pass (was 72).
Also adds a startup breadcrumb log line in App.OnStartup carrying the build version + PID so we can correlate a user's log file with a specific commit.
Adds an NdiGroupSettings record carrying optional comma-separated NDI group lists for the finder and the senders. Extends INdiInterop.CreateFinder / CreateSender with optional groups arguments and populates NDIlib_find_create_t.p_groups and NDIlib_send_create_t.p_groups via P/Invoke. IsoController reads the settings on construction, threads DiscoveryGroups into NdiDiscoveryService and OutputGroups into IsoPipelineConfig, and exposes SetGroupSettingsAsync for runtime updates (group changes apply on next process restart so live pipelines aren't orphaned).
This unblocks the 'transcoder' topology where Teams broadcasts NDI on a private group (e.g. teamsiso-input) and TeamsISO re-emits clean normalized streams on Public — keeping raw, wrong-framerate Teams sources off the production network.
EngineConfig schema is JSON-back-compat: existing config.json files (no NdiGroups field) deserialize with NdiGroups=null and load as NdiGroupSettings.Default. UI surface for these settings comes in a follow-up.
Tests: 72/72 passing (was 69) — added IsoController coverage that group settings are read from ConfigStore on startup, passed to the finder, threaded into per-pipeline config, and round-trip through SetGroupSettingsAsync/Save/Load.
The new Microsoft Teams desktop client (observed against a live meeting on Teams 26106.1906.4665.7308) emits NDI source strings of the form
WOOGLIN (MS Teams - Brendon Power)
WOOGLIN (MS Teams - (Local))
WOOGLIN (MS Teams - Active Speaker)
rather than the legacy 'MACHINE (Teams - ...)' shape NdiSourceParser was written to. As a result every Teams source was rejected and TeamsISO showed zero participants in real meetings.
Refactor the parser to recognize 'Teams', 'MS Teams', and (defensively) 'Microsoft Teams' as brand prefixes — longest first so 'MS Teams' isn't shadowed. Also recognize reserved suffix tokens after a dash ('Active Speaker' / 'Audio' / 'Audio Mix' / 'Screen Share') so the new active-speaker output is correctly classified as ActiveSpeaker rather than misread as a participant named 'Active Speaker'.
Tests: kept all legacy cases, added MS Teams + Microsoft Teams variants and the new dash-prefixed reserved-suffix cases. 69/69 unit tests passing; verified end-to-end against a live Teams meeting where TeamsISO.exe now shows '(Local)' and 'Brendon Power' in the Participants DataGrid.