From 2c607a70ffa30425c21367fc719f69b0b2b45584 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 10 May 2026 14:04:04 -0400 Subject: [PATCH] Audio peak: high-water mark across UI poll interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/TeamsISO.Engine/Pipeline/IsoPipeline.cs | 8 +- src/TeamsISO.Engine/Pipeline/NdiReceiver.cs | 78 +++++++++++++------ .../Fakes/FakeNdiInterop.cs | 10 +++ .../Pipeline/NdiReceiverTests.cs | 75 ++++++++++++++++++ 4 files changed, 145 insertions(+), 26 deletions(-) diff --git a/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs b/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs index ac67a35..3bd064d 100644 --- a/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs +++ b/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs @@ -106,9 +106,11 @@ public sealed class IsoPipeline : IAsyncDisposable IncomingHeight: h) { State = State, - // Peak is published by NdiReceiver's audio loop; 0.0 means - // silence, no audio yet, or the sender is video-only. - PeakAudioLevel = receiver.LatestAudioPeak, + // Peak is the high-water mark since the last GetStats() call — + // ConsumeAudioPeak resets it to 0 atomically, so the next read + // reflects only what arrived in the next polling interval. + // 0.0 means silence, no audio yet, or the sender is video-only. + PeakAudioLevel = receiver.ConsumeAudioPeak(), }; } diff --git a/src/TeamsISO.Engine/Pipeline/NdiReceiver.cs b/src/TeamsISO.Engine/Pipeline/NdiReceiver.cs index 6f8404c..9997ded 100644 --- a/src/TeamsISO.Engine/Pipeline/NdiReceiver.cs +++ b/src/TeamsISO.Engine/Pipeline/NdiReceiver.cs @@ -18,18 +18,21 @@ public sealed class NdiReceiver : IDisposable private readonly NdiReceiverHandle _handle; private long _framesCaptured; - // Most recent audio peak, in [0, 1]. Updated by the audio capture loop; - // read by IsoPipeline.GetStats on the UI poll thread. We use a long - // holding the IEEE 754 double bits + Volatile read/write so reads are - // atomic across threads (a double on x86 can tear; long is always atomic - // when aligned, which the runtime guarantees for fields). + // Audio peak high-water mark since the last read, in [0, 1]. // - // Decay rationale: an audio frame arrives every ~20ms (~50Hz at 48kHz - // with 1024-sample blocks). The UI polls at 1Hz; without decay the bar - // would freeze at the loudest sample seen in the most recent buffer. - // We let the receiver keep the live max and let the UI apply visual - // decay on its end so the engine stays simple — see ParticipantViewModel. - private long _lastAudioPeakBits; + // Why a high-water mark and not a "latest peak": + // - Audio frames arrive at ~50Hz (1024-sample blocks @ 48kHz). + // - UI stats polling reads at 1Hz. + // If we only published the most recent buffer's peak, the UI would see + // exactly one of every ~50 audio frames per second — loud transients + // between reads would be invisible. By keeping the running max and + // consuming it on read, the UI sees the true peak across the entire + // polling interval, which is the actual behavior of a peak VU meter. + // + // Stored as the IEEE 754 long bits of a double so we can atomically + // CompareExchange-update without a struct lock. Volatile reads suffice + // for the consume side because we follow with an Exchange to 0. + private long _audioPeakBits; public NdiReceiver( INdiInterop interop, @@ -47,17 +50,32 @@ public sealed class NdiReceiver : IDisposable public long FramesCaptured => Interlocked.Read(ref _framesCaptured); /// - /// Most recent audio peak amplitude, in [0.0, 1.0]. Returns 0 when no - /// audio frame has been processed yet (silent source, video-only sender, - /// or audio loop hasn't started). Safe to call from any thread. + /// Reads the peak audio amplitude (in [0.0, 1.0]) seen since the last + /// call and resets the high-water mark to zero. The reset semantics are + /// what makes this a true peak meter — between two consecutive reads + /// the caller sees the loudest sample that crossed the receiver, not + /// just whatever the latest buffer happened to contain. + /// + /// Returns 0 if no audio has been received since the last read (silent + /// source, video-only sender, audio loop hasn't started, or the source + /// genuinely went quiet). Safe to call from any thread; reset is atomic. /// - public double LatestAudioPeak + public double ConsumeAudioPeak() { - get - { - var bits = Volatile.Read(ref _lastAudioPeakBits); - return BitConverter.Int64BitsToDouble(bits); - } + var bits = Interlocked.Exchange(ref _audioPeakBits, 0L); + return BitConverter.Int64BitsToDouble(bits); + } + + /// + /// Non-consuming peek at the current peak high-water mark. Useful for + /// observability paths that need to read the value without affecting the + /// max-since-last-read behavior — for example, an external diagnostics + /// dashboard that polls more often than the UI. + /// + public double PeekAudioPeak() + { + var bits = Volatile.Read(ref _audioPeakBits); + return BitConverter.Int64BitsToDouble(bits); } /// @@ -72,14 +90,28 @@ public sealed class NdiReceiver : IDisposable } /// - /// Captures one audio frame (or returns on timeout) and updates - /// . Test seam mirroring . + /// Captures one audio frame (or returns on timeout) and atomically + /// updates the high-water peak. Test seam mirroring . + /// The CAS loop is needed because two writers (this method, called from + /// the audio capture thread) and one reader (, + /// called from the UI poll thread) compete for the same field — a plain + /// Volatile.Write would lose updates if Consume fires between our read + /// and write. /// public void CaptureAudioOnce(int timeoutMs) { var peak = _interop.CaptureAudioPeak(_handle, timeoutMs); if (peak is null) return; - Volatile.Write(ref _lastAudioPeakBits, BitConverter.DoubleToInt64Bits(peak.Value)); + var newBits = BitConverter.DoubleToInt64Bits(peak.Value); + long current; + do + { + current = Volatile.Read(ref _audioPeakBits); + // If our peak is no louder than what's already there, leave it — + // somebody else (the audio thread itself, on a previous frame) + // already published a higher max. + if (peak.Value <= BitConverter.Int64BitsToDouble(current)) return; + } while (Interlocked.CompareExchange(ref _audioPeakBits, newBits, current) != current); } /// diff --git a/src/tests/TeamsISO.Engine.Tests/Fakes/FakeNdiInterop.cs b/src/tests/TeamsISO.Engine.Tests/Fakes/FakeNdiInterop.cs index c38e2e1..394745c 100644 --- a/src/tests/TeamsISO.Engine.Tests/Fakes/FakeNdiInterop.cs +++ b/src/tests/TeamsISO.Engine.Tests/Fakes/FakeNdiInterop.cs @@ -13,6 +13,8 @@ public sealed class FakeNdiInterop : INdiInterop public List Sources { get; } = new(); public ConcurrentDictionary> ReceiverFrames { get; } = new(); public ConcurrentDictionary> SentFrames { get; } = new(); + /// Optional per-source audio peak queue. Each dequeues one entry; null if empty (matches the production "timeout" behavior). + public ConcurrentDictionary> ReceiverAudioPeaks { get; } = new(); public string RuntimeVersion { get; set; } = "6.0.0"; public Dictionary ReceiverCreatedCount { get; } = new(); public Dictionary SenderCreatedCount { get; } = new(); @@ -44,6 +46,14 @@ public sealed class FakeNdiInterop : INdiInterop return null; // simulate timeout } + public double? CaptureAudioPeak(NdiReceiverHandle receiver, int timeoutMs) + { + var key = ((FakeReceiverHandle)receiver).Source; + if (ReceiverAudioPeaks.TryGetValue(key, out var q) && q.TryDequeue(out var peak)) + return peak; + return null; // no audio queued — simulate timeout + } + public NdiSenderHandle CreateSender(string outputName, string? groups = null) { SenderCreatedCount[outputName] = SenderCreatedCount.GetValueOrDefault(outputName) + 1; diff --git a/src/tests/TeamsISO.Engine.Tests/Pipeline/NdiReceiverTests.cs b/src/tests/TeamsISO.Engine.Tests/Pipeline/NdiReceiverTests.cs index 1d93422..3741280 100644 --- a/src/tests/TeamsISO.Engine.Tests/Pipeline/NdiReceiverTests.cs +++ b/src/tests/TeamsISO.Engine.Tests/Pipeline/NdiReceiverTests.cs @@ -59,4 +59,79 @@ public class NdiReceiverTests // Receiver was created exactly once interop.ReceiverCreatedCount[Source].Should().Be(1); } + + [Fact] + public void ConsumeAudioPeak_NoFramesProcessed_ReturnsZero() + { + var interop = new FakeNdiInterop(); + var output = Channel.CreateUnbounded(); + var receiver = new NdiReceiver(interop, Source, output.Writer, NullLogger.Instance); + + receiver.ConsumeAudioPeak().Should().Be(0.0); + } + + [Fact] + public void CaptureAudioOnce_PublishesPeak_ConsumeReturnsAndResets() + { + var interop = new FakeNdiInterop(); + interop.ReceiverAudioPeaks.GetOrAdd(Source, _ => new System.Collections.Concurrent.ConcurrentQueue()) + .Enqueue(0.42); + + var output = Channel.CreateUnbounded(); + var receiver = new NdiReceiver(interop, Source, output.Writer, NullLogger.Instance); + + receiver.CaptureAudioOnce(timeoutMs: 100); + + // Peek doesn't reset + receiver.PeekAudioPeak().Should().BeApproximately(0.42, precision: 0.0001); + + // First Consume returns the peak + receiver.ConsumeAudioPeak().Should().BeApproximately(0.42, precision: 0.0001); + + // Second Consume returns 0 — Consume has reset semantics + receiver.ConsumeAudioPeak().Should().Be(0.0); + } + + [Fact] + public void CaptureAudioOnce_KeepsHighWaterMarkAcrossMultipleFrames() + { + // Three frames with peaks 0.3, 0.8, 0.2. Without reset, the receiver + // must report the loudest (0.8) — that's the whole point of a peak + // meter. The "latest peak" naive overwrite would lose the 0.8 if a + // quieter 0.2 frame followed it. + var interop = new FakeNdiInterop(); + var q = interop.ReceiverAudioPeaks.GetOrAdd(Source, _ => new System.Collections.Concurrent.ConcurrentQueue()); + q.Enqueue(0.3); + q.Enqueue(0.8); + q.Enqueue(0.2); + + var output = Channel.CreateUnbounded(); + var receiver = new NdiReceiver(interop, Source, output.Writer, NullLogger.Instance); + + receiver.CaptureAudioOnce(timeoutMs: 100); + receiver.CaptureAudioOnce(timeoutMs: 100); + receiver.CaptureAudioOnce(timeoutMs: 100); + + receiver.ConsumeAudioPeak().Should().BeApproximately(0.8, precision: 0.0001); + } + + [Fact] + public void CaptureAudioOnce_NoFrameAvailable_LeavesHighWaterMarkUntouched() + { + // Establish a peak, then call CaptureAudioOnce when the fake has no + // queued frames. The high-water mark must NOT be reset just because + // a polling tick saw nothing — that would defeat the peak-hold + // semantic between consumer reads. + var interop = new FakeNdiInterop(); + interop.ReceiverAudioPeaks.GetOrAdd(Source, _ => new System.Collections.Concurrent.ConcurrentQueue()) + .Enqueue(0.5); + + var output = Channel.CreateUnbounded(); + var receiver = new NdiReceiver(interop, Source, output.Writer, NullLogger.Instance); + + receiver.CaptureAudioOnce(timeoutMs: 100); // peak now 0.5 + receiver.CaptureAudioOnce(timeoutMs: 100); // no frame; should be no-op + + receiver.PeekAudioPeak().Should().BeApproximately(0.5, precision: 0.0001); + } }