Audio peak: high-water mark across UI poll interval
Some checks failed
CI / build-and-test (push) Failing after 31s
Some checks failed
CI / build-and-test (push) Failing after 31s
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.
This commit is contained in:
parent
dc25fe1eef
commit
2c607a70ff
4 changed files with 145 additions and 26 deletions
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public double PeekAudioPeak()
|
||||
{
|
||||
var bits = Volatile.Read(ref _audioPeakBits);
|
||||
return BitConverter.Int64BitsToDouble(bits);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -72,14 +90,28 @@ public sealed class NdiReceiver : IDisposable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures one audio frame (or returns on timeout) and updates
|
||||
/// <see cref="LatestAudioPeak"/>. Test seam mirroring <see cref="CaptureOnce"/>.
|
||||
/// Captures one audio frame (or returns on timeout) and atomically
|
||||
/// updates the high-water peak. Test seam mirroring <see cref="CaptureOnce"/>.
|
||||
/// The CAS loop is needed because two writers (this method, called from
|
||||
/// the audio capture thread) and one reader (<see cref="ConsumeAudioPeak"/>,
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ public sealed class FakeNdiInterop : INdiInterop
|
|||
public List<string> Sources { get; } = new();
|
||||
public ConcurrentDictionary<string, ConcurrentQueue<RawFrame>> ReceiverFrames { get; } = new();
|
||||
public ConcurrentDictionary<string, List<ProcessedFrame>> SentFrames { get; } = new();
|
||||
/// <summary>Optional per-source audio peak queue. Each <see cref="CaptureAudioPeak"/> dequeues one entry; null if empty (matches the production "timeout" behavior).</summary>
|
||||
public ConcurrentDictionary<string, ConcurrentQueue<double>> ReceiverAudioPeaks { get; } = new();
|
||||
public string RuntimeVersion { get; set; } = "6.0.0";
|
||||
public Dictionary<string, int> ReceiverCreatedCount { get; } = new();
|
||||
public Dictionary<string, int> 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;
|
||||
|
|
|
|||
|
|
@ -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<RawFrame>();
|
||||
var receiver = new NdiReceiver(interop, Source, output.Writer, NullLogger<NdiReceiver>.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<double>())
|
||||
.Enqueue(0.42);
|
||||
|
||||
var output = Channel.CreateUnbounded<RawFrame>();
|
||||
var receiver = new NdiReceiver(interop, Source, output.Writer, NullLogger<NdiReceiver>.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<double>());
|
||||
q.Enqueue(0.3);
|
||||
q.Enqueue(0.8);
|
||||
q.Enqueue(0.2);
|
||||
|
||||
var output = Channel.CreateUnbounded<RawFrame>();
|
||||
var receiver = new NdiReceiver(interop, Source, output.Writer, NullLogger<NdiReceiver>.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<double>())
|
||||
.Enqueue(0.5);
|
||||
|
||||
var output = Channel.CreateUnbounded<RawFrame>();
|
||||
var receiver = new NdiReceiver(interop, Source, output.Writer, NullLogger<NdiReceiver>.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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue