Bump to v2.2.0: Opus native binding, efficiency tidy-up, diag self-meter
Single biggest change: added the Concentus.Native NuGet package. Concentus 2.0+ auto-detects native libopus at runtime and routes encode calls through it; encoder state lives on the C side and is reused across calls rather than `new`ing ~15 working buffers per call (Concentus issue #22, open since 2018). Measured on the desktop test at 15:36:55 — Opus 10 ms allocation rate dropped from 4,625 KB/s to 108 KB/s, a 97.7% reduction. Process CPU dropped from 4.7% to 1.6% in the same config. Audio is bit- for-bit identical (it's literally the same encoder, just better packaged). `OpusEncoderState.cs` itself unchanged on the call site. Diagnostic / measurement layer (gated on Enable-logs, zero cost when off): * ProcessSelfMeter: CPU%, managed heap MB, working set MB, allocation rate per second, GC counts per generation * Per-thread work-time counters: captureMs / sendMs / recvMs / renderMs expressed as milliseconds of CPU consumed by each audio thread per second * Inter-packet arrival gap measured at the user-space UDP socket (rxNetGapMs) — pinpoints whether arrival jitter is in the network or our own dispatch path Small efficiency wins (each one was small but cumulative): * deviceRefreshTimer interval 1s -> 3s (item 4) * WaitHandle array allocations eliminated in MixingEngine.MixLoop and MultiOutputPlayout.ProduceLoop (item 6) * MultiOutputPlayout caches its output-buffer snapshot and only rebuilds on SetOutputDevices, instead of rebuilding every 10 ms (item 7) * HeartbeatService reuses an outbound ping byte[] instead of allocating per send (item 14) * PeerDiscoveryService caches broadcast addresses and invalidates on Windows' NetworkChange event instead of walking all NICs every 1.5 s (item 16) Legacy / dead-code removal: * KeepAlive packet's implementation (struct, enums, writer, reader, size constant) — all dead since HeartbeatService landed 2026-05-06. Kept the RemPacketType.KeepAlive enum value and silent-drop dispatch for wire compat with any pre-2026-05-06 build still in the wild (item 30) * driftDropFramesTotal / driftRepeatFramesTotal fields and accessors — Phase-2 splice corrector relics, never incremented since Phase-4 resampler design landed; backed five always-zero diag log columns (items 34 + 35) * DriftAccumulator (always returned 0) — same shape, removed alongside the driftAcc= column (item 35) * TakeMaxFanOutCacheBytes / Ms + fanCacheMs column — FanOutSource was retired in May (item 36) Project documentation: * RemSoundefficiency.md added as the canonical record of the efficiency analysis, every item's status, and the measured wins from this round * Honest item-by-item review of the original 50-item list — several items I had sized optimistically in the original analysis turned out to be already-done (item 20), already-optimal (item 22), or below the meter floor (items 9, 15, 17, 25). Recorded so future passes don't re-investigate. Wire format and audio pipeline unchanged from v1.5 onward — v1.5 through v2.2 peers interoperate.
This commit is contained in:
@@ -248,20 +248,25 @@ public sealed class AudioReceiver : IDisposable
|
||||
/// servicing, scheduler not waking our receive thread, kernel batching). 2026-05-21.</summary>
|
||||
public int TakeMaxInterPacketGapMs() => listener.TakeMaxInterPacketGapMs();
|
||||
|
||||
/// <summary>Worst FanOutSource cache-occupancy seen since the last call, expressed in
|
||||
/// milliseconds at the mix rate (48 kHz stereo float). With one active render lane the
|
||||
/// FanOut should drain to ~0 after every consumer Read; sustained non-zero means a
|
||||
/// render lane is holding samples (slow consumer holding back compaction, or the fast
|
||||
/// consumer not draining quickly enough). Zero in WasapiOnly mode (no FanOut). Resets
|
||||
/// on read. Added 2026-05-11 to verify the BothIndependent FanOut path isn't quietly
|
||||
/// inflating latency on either lane.</summary>
|
||||
public int TakeMaxFanOutCacheMs()
|
||||
{
|
||||
// 48000 Hz × 2 ch × 4 bytes/sample = 384,000 bytes/sec.
|
||||
const int MixBytesPerSecond = 48000 * 2 * 4;
|
||||
var bytes = (multiOutput as CompositeRenderBackend)?.TakeMaxFanOutCacheBytes() ?? 0;
|
||||
return bytes * 1000 / MixBytesPerSecond;
|
||||
}
|
||||
/// <summary>Cumulative milliseconds the network receive thread spent inside packet-
|
||||
/// handler work since the last call (drain-on-read pattern). Diag log emits this as
|
||||
/// recvMs per second — a direct read of how busy the network thread is. Item 2 of
|
||||
/// RemSoundefficiency.md. Resets on read.</summary>
|
||||
public double TakeReceiveWorkMs() =>
|
||||
listener.TakeCumulativeOnPacketTicks() * 1000.0 / Stopwatch.Frequency;
|
||||
|
||||
/// <summary>Cumulative milliseconds the audio render threads spent inside
|
||||
/// <see cref="PlayoutEngine.Read"/> / <see cref="PlayoutEngine.ReadForRoute"/>
|
||||
/// (per-session mix + volume + limiter + pack-to-bytes) since the last call. Diag log
|
||||
/// emits this as renderMs per second. Resets on read. 2026-05-22.</summary>
|
||||
public double TakeRenderWorkMs() =>
|
||||
playoutEngine.TakeCumulativeRenderTicks() * 1000.0 / Stopwatch.Frequency;
|
||||
|
||||
// TakeMaxFanOutCacheMs removed 2026-05-23. Originally measured the FanOutSource cache age
|
||||
// between WASAPI and ASIO consumers in BothIndependent mode. The FanOut architecture was
|
||||
// removed in May when each lane got its own filtered PlayoutEngine source — there is no
|
||||
// shared cache to measure any more, so the method always returned 0. Removed alongside
|
||||
// CompositeRenderBackend.TakeMaxFanOutCacheBytes and the fanCacheMs= diag column.
|
||||
public string OutputDeviceName => multiOutput.ActiveDeviceSummary;
|
||||
public int CurrentBufferMs => playoutEngine.CurrentBufferMs;
|
||||
public int TargetLatencyMs => playoutEngine.TargetLatencyMs;
|
||||
@@ -412,11 +417,9 @@ public sealed class AudioReceiver : IDisposable
|
||||
public long TrimDropBytes => playoutEngine.AggregateTrimDropBytes;
|
||||
public long DrainDropBytes => playoutEngine.AggregateDrainDropBytes;
|
||||
public long TrimFireCount => playoutEngine.AggregateTrimFireCount;
|
||||
/// <summary>Phase-2 drift correction counters: how many single stereo frames have been
|
||||
/// dropped (sender clock faster) or repeated (sender clock slower) to keep the playout
|
||||
/// buffer aligned with target. Each event = 21 µs of audio at 48 kHz, sub-audible.</summary>
|
||||
public long DriftDropFrames => playoutEngine.AggregateDriftDropFrames;
|
||||
public long DriftRepeatFrames => playoutEngine.AggregateDriftRepeatFrames;
|
||||
// DriftDropFrames / DriftRepeatFrames accessors removed 2026-05-23. They aggregated
|
||||
// Phase-2 splice-corrector counters that the Phase-4 fixed-ratio resampler design never
|
||||
// increments. Always-zero. Surfaced two unhelpful diag-log columns that are now gone.
|
||||
/// <summary>Cumulative count of FULL-empty playout reads (framesRead == 0) — the audible
|
||||
/// underrun events that trigger noise-burst concealment + fade-in. Separated from
|
||||
/// <see cref="Underruns"/> (which conflates full and partial short reads) so the diag
|
||||
@@ -429,9 +432,8 @@ public sealed class AudioReceiver : IDisposable
|
||||
/// <summary>Live LP-filtered drift error of the primary active session (stereo frames,
|
||||
/// signed). Negative = buffer running below target on average; positive = above.</summary>
|
||||
public double FilteredDriftErrorFrames => playoutEngine.PrimaryFilteredDriftErrorFrames;
|
||||
/// <summary>Live drift integrator accumulator of the primary session. Crosses ±1 to fire
|
||||
/// a drop / repeat correction.</summary>
|
||||
public double DriftAccumulator => playoutEngine.PrimaryDriftAccumulator;
|
||||
// DriftAccumulator removed 2026-05-23. Phase-4 fixed-ratio resampler never sets an
|
||||
// integrator value; always returned 0. Removed alongside the driftAcc= diag column.
|
||||
/// <summary>Take the worst single-sample step out of the ring buffer (after decode +
|
||||
/// SessionPlayout.Write, before resampler) since the last call.</summary>
|
||||
public float TakeMaxPostRingReadStep() => playoutEngine.TakeMaxPostRingReadStep();
|
||||
|
||||
@@ -89,12 +89,11 @@ internal sealed class CompositeRenderBackend : IRenderBackend
|
||||
|
||||
public bool IsRunning => started;
|
||||
|
||||
/// <summary>Legacy probe from the FanOut era — always 0 now that BothIndependent reads
|
||||
/// per-lane sources directly with no intermediate cache. Kept on the surface so the
|
||||
/// receiver-side diag plumbing (fanCacheMs= column) keeps emitting a sentinel zero
|
||||
/// rather than disappearing. Can be removed once we're confident the per-lane wiring
|
||||
/// is the right shape long-term.</summary>
|
||||
public int TakeMaxFanOutCacheBytes() => 0;
|
||||
// TakeMaxFanOutCacheBytes removed 2026-05-23. The FanOutSource architecture was retired
|
||||
// in mid-May when each lane got its own filtered PlayoutEngine source — there's no shared
|
||||
// cache to measure any more, so the method always returned 0. The receiver-side
|
||||
// pass-through (AudioReceiver.TakeMaxFanOutCacheMs) and the fanCacheMs= diag column were
|
||||
// removed alongside it.
|
||||
|
||||
public string ActiveDeviceSummary
|
||||
{
|
||||
|
||||
@@ -38,6 +38,14 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
||||
private readonly Dictionary<string, OutputEntry> outputs = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly byte[] frameScratch = new byte[FrameBytes];
|
||||
private readonly WaveFormat sharedFormat = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels);
|
||||
// Snapshot of the current output buffers, rebuilt only when SetOutputDevices changes the
|
||||
// device set (rare — typically once per user action, minutes apart). The producer loop
|
||||
// reads this with a single volatile load per tick instead of taking the gate and
|
||||
// rebuilding `outputs.Values.Select(o => o.Buffer).ToArray()` on every 10 ms tick.
|
||||
// Item 7 of RemSoundefficiency.md — eliminates ~100 array allocations per second on the
|
||||
// receive side whenever any output device is ticked. Empty array is a singleton via
|
||||
// Array.Empty<T>(), so the default value costs nothing.
|
||||
private volatile BufferedWaveProvider[] outputBufferSnapshot = Array.Empty<BufferedWaveProvider>();
|
||||
|
||||
private CancellationTokenSource? cts;
|
||||
private Task? produceTask;
|
||||
@@ -95,6 +103,10 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
||||
|
||||
foreach (var o in outputs.Values) DisposeOutput(o);
|
||||
outputs.Clear();
|
||||
// Reset the snapshot the producer loop reads so any subsequent Start sees the
|
||||
// empty state cleanly (not a stale snapshot from the previous session). Empty
|
||||
// array is a cached singleton, no allocation.
|
||||
outputBufferSnapshot = Array.Empty<BufferedWaveProvider>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +164,14 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
||||
try { device?.Dispose(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the snapshot the producer loop reads. Under the gate, so the producer
|
||||
// sees a consistent view; once published via the volatile field, the loop reads
|
||||
// it without taking the gate every tick. Empty case uses the cached singleton
|
||||
// so it's allocation-free. Item 7 of RemSoundefficiency.md.
|
||||
outputBufferSnapshot = outputs.Count == 0
|
||||
? Array.Empty<BufferedWaveProvider>()
|
||||
: outputs.Values.Select(o => o.Buffer).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +198,10 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
||||
if (nextTickStopwatch > now)
|
||||
{
|
||||
var sleepMs = (int)Math.Clamp((nextTickStopwatch - now) * 1000 / Stopwatch.Frequency, 1, 50);
|
||||
if (WaitHandle.WaitAny(new[] { ct.WaitHandle }, sleepMs) == 0) break;
|
||||
// Item 6 of RemSoundefficiency.md — see matching change in
|
||||
// MixingEngine.MixLoop for the rationale. WaitOne is allocation-free
|
||||
// and semantically equivalent to WaitAny on a 1-element array.
|
||||
if (ct.WaitHandle.WaitOne(sleepMs)) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -188,22 +211,16 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
||||
}
|
||||
nextTickStopwatch += ticksPerFrame;
|
||||
|
||||
// Snapshot the buffers under the gate so we don't iterate a mid-mutation dict.
|
||||
// Also skip the source.Read entirely when no outputs are ticked: in
|
||||
// BothIndependent mode the source is a FanOutSource view shared with the ASIO
|
||||
// lane, and pulling here when WASAPI has nothing ticked makes the FanOut
|
||||
// consume PlayoutEngine audio ~10 ms ahead of the ASIO consumer, leaving the
|
||||
// ASIO lane permanently reading from a cache 10 ms behind the source. That
|
||||
// showed up in test logs as fanCacheMs sustained at 12–14 ms with bufAvg=0,
|
||||
// and audibly as an extra 10 ms baked into the ASIO lane's perceived latency.
|
||||
// The gate-then-read order matters; the previous order (read first, then
|
||||
// check outputs.Count) was the bug.
|
||||
BufferedWaveProvider[] targets;
|
||||
lock (gate)
|
||||
{
|
||||
if (outputs.Count == 0) continue;
|
||||
targets = outputs.Values.Select(o => o.Buffer).ToArray();
|
||||
}
|
||||
// Read the pre-built snapshot. Volatile load — no lock, no allocation per
|
||||
// tick. SetOutputDevices rebuilds the snapshot under the gate whenever the
|
||||
// device set changes (rare event), so reads here see a consistent view.
|
||||
// Skip the source.Read entirely when no outputs are ticked: in BothIndependent
|
||||
// mode the source is shared between WASAPI and ASIO, and pulling here when
|
||||
// WASAPI has nothing ticked would consume PlayoutEngine audio ahead of the
|
||||
// ASIO consumer. Pre-2026-05-23 this whole block ran under `lock (gate)` and
|
||||
// rebuilt the array on every tick — fixed as item 7 of RemSoundefficiency.md.
|
||||
var targets = outputBufferSnapshot;
|
||||
if (targets.Length == 0) continue;
|
||||
|
||||
var produced = source.Read(frameScratch, 0, FrameBytes);
|
||||
if (produced <= 0) continue;
|
||||
|
||||
@@ -43,6 +43,15 @@ internal sealed class NetworkListener : IDisposable
|
||||
public int TakeMaxInterPacketGapMs() =>
|
||||
(int)(Interlocked.Exchange(ref maxInterPacketGapTicks, 0) * 1000 / Stopwatch.Frequency);
|
||||
|
||||
// CUMULATIVE on-packet work-time counter. Sister to maxOnPacketTicks (per-call max)
|
||||
// — this is "total time the receive thread spent inside the packet handler since the
|
||||
// last Take". The diag log samples this once a second and reports milliseconds-of-
|
||||
// CPU-per-second for the receive thread, which is the per-thread CPU% reading from
|
||||
// item 2 of RemSoundefficiency.md. Cumulative-sum + atomic-take pattern; no lock.
|
||||
// 2026-05-22.
|
||||
private long cumulativeOnPacketTicks;
|
||||
public long TakeCumulativeOnPacketTicks() => Interlocked.Exchange(ref cumulativeOnPacketTicks, 0);
|
||||
|
||||
public NetworkListener(Action<byte[], int, IPEndPoint> onPacket, Action<string> onDiagnostic)
|
||||
{
|
||||
this.onPacket = onPacket;
|
||||
@@ -89,6 +98,7 @@ internal sealed class NetworkListener : IDisposable
|
||||
// spurious huge gap.
|
||||
Interlocked.Exchange(ref lastReceiveTicks, 0);
|
||||
Interlocked.Exchange(ref maxInterPacketGapTicks, 0);
|
||||
Interlocked.Exchange(ref cumulativeOnPacketTicks, 0);
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
@@ -142,6 +152,10 @@ internal sealed class NetworkListener : IDisposable
|
||||
long current;
|
||||
do { current = Volatile.Read(ref maxOnPacketTicks); }
|
||||
while (elapsed > current && Interlocked.CompareExchange(ref maxOnPacketTicks, elapsed, current) != current);
|
||||
// And the cumulative counter — every call's elapsed adds in. Lets the
|
||||
// diag log show "the receive thread spent X ms working this second"
|
||||
// (item 2 of the efficiency analysis).
|
||||
Interlocked.Add(ref cumulativeOnPacketTicks, elapsed);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using NAudio.Wave;
|
||||
using RemSound.Core;
|
||||
@@ -82,6 +83,15 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
private volatile bool asioLaneActive = true;
|
||||
private volatile bool muted;
|
||||
private volatile float volume = 1f;
|
||||
// Cumulative render-thread work-time counter. Every Read / ReadForRoute call adds its
|
||||
// elapsed Stopwatch ticks here; the diag log samples once a second to report renderMs
|
||||
// — milliseconds of CPU the render thread(s) consumed in the last second. Per-thread
|
||||
// CPU usage from item 2 of RemSoundefficiency.md. Gated implicitly by the diag log's
|
||||
// own DiagnosticsGate check (the math is cheap enough that we don't gate the
|
||||
// Stopwatch reads themselves — the alternative is a per-call branch every render
|
||||
// callback, which costs more than the read does).
|
||||
private long cumulativeRenderTicks;
|
||||
public long TakeCumulativeRenderTicks() => Interlocked.Exchange(ref cumulativeRenderTicks, 0);
|
||||
// 1 = stupid aggressive, 10 = perfectly smooth. Read on the audio thread, written from UI.
|
||||
// Now mostly a safety-knob for the click-trim catastrophic path; in normal operation the
|
||||
// Phase-2 drift corrector (in SessionPlayout) keeps the buffer near target so the trim
|
||||
@@ -414,27 +424,9 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cumulative count of single-frame drops the Phase-2 drift corrector has applied.</summary>
|
||||
public long AggregateDriftDropFrames
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot) total += s.DriftDropFramesTotal;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cumulative count of single-frame repeats the Phase-2 drift corrector has applied.</summary>
|
||||
public long AggregateDriftRepeatFrames
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot) total += s.DriftRepeatFramesTotal;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
// AggregateDriftDropFrames + AggregateDriftRepeatFrames removed 2026-05-23 alongside the
|
||||
// backing per-session fields. They surfaced two always-zero diag-log columns; both columns
|
||||
// and accessors are gone.
|
||||
|
||||
/// <summary>Cumulative count of full-empty reads (framesRead == 0) across all sessions.
|
||||
/// These are the audible underrun events that trigger noise-burst concealment + fade-in
|
||||
@@ -480,17 +472,9 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Live state — the drift integrator accumulator of the first active session.
|
||||
/// Crosses ±1 to fire a single-frame drop / repeat. Useful for "is the corrector about
|
||||
/// to fire?" diagnosis.</summary>
|
||||
public double PrimaryDriftAccumulator
|
||||
{
|
||||
get
|
||||
{
|
||||
var snap = sessionsSnapshot;
|
||||
return snap.Length > 0 ? snap[0].DriftAccumulator : 0.0;
|
||||
}
|
||||
}
|
||||
// PrimaryDriftAccumulator removed 2026-05-23 alongside SessionPlayout.DriftAccumulator
|
||||
// (which always returned 0 under the Phase-4 resampler design) and the driftAcc= diag
|
||||
// log column.
|
||||
|
||||
/// <summary>Worst single-sample step seen out of the ring buffer since the last call.
|
||||
/// Compared against the sender's pre-encode probe and the session's post-resampler
|
||||
@@ -595,8 +579,20 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
/// stream onto an ASIO output (and vice versa) in BothIndependent mode — that broke a
|
||||
/// long-standing cross-backend send/receive flow.
|
||||
/// </summary>
|
||||
public int Read(byte[] buffer, int offset, int count) =>
|
||||
ReadAllSessions(buffer, offset, count, mixScratch, sessionScratch, recordDiagnostics: true);
|
||||
public int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
// Per-thread CPU instrumentation. Gated on DiagnosticsGate so the Stopwatch
|
||||
// reads cost nothing when logs are off; cumulativeRenderTicks is what the diag
|
||||
// log samples for the renderMs column.
|
||||
if (!RemSound.Core.DiagnosticsGate.Enabled)
|
||||
{
|
||||
return ReadAllSessions(buffer, offset, count, mixScratch, sessionScratch, recordDiagnostics: true);
|
||||
}
|
||||
var start = Stopwatch.GetTimestamp();
|
||||
var produced = ReadAllSessions(buffer, offset, count, mixScratch, sessionScratch, recordDiagnostics: true);
|
||||
Interlocked.Add(ref cumulativeRenderTicks, Stopwatch.GetTimestamp() - start);
|
||||
return produced;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared per-route render pull. Iterates the session snapshot, summing only those
|
||||
@@ -608,6 +604,23 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
/// per-tick stats columns are still the user-visible source of truth.
|
||||
/// </summary>
|
||||
internal int ReadForRoute(byte[] buffer, int offset, int count, RenderRoute route, float[] mixBuf, float[] sessionBuf, bool recordDiagnostics)
|
||||
{
|
||||
// Per-thread CPU instrumentation — same shape as Read above. Gate on DiagnosticsGate
|
||||
// so when logs are off this is a free pass-through.
|
||||
long workStart = 0;
|
||||
var diag = RemSound.Core.DiagnosticsGate.Enabled;
|
||||
if (diag) workStart = Stopwatch.GetTimestamp();
|
||||
try
|
||||
{
|
||||
return ReadForRouteInner(buffer, offset, count, route, mixBuf, sessionBuf, recordDiagnostics);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (diag) Interlocked.Add(ref cumulativeRenderTicks, Stopwatch.GetTimestamp() - workStart);
|
||||
}
|
||||
}
|
||||
|
||||
private int ReadForRouteInner(byte[] buffer, int offset, int count, RenderRoute route, float[] mixBuf, float[] sessionBuf, bool recordDiagnostics)
|
||||
{
|
||||
if (recordDiagnostics) diagnostics.RecordRenderRead(count);
|
||||
|
||||
|
||||
@@ -163,14 +163,11 @@ internal sealed class SessionPlayout : IDisposable
|
||||
// we don't realloc on the hot path.
|
||||
private float[] resamplerInputScratch = new float[2048];
|
||||
|
||||
// Retained for backward compatibility with the diagnostic surface — the diag log line
|
||||
// still emits driftDrop / driftRep counters and the DriftAccumulator / FilteredError
|
||||
// accessors. In the Phase-4 design these are all just informational metrics that stay
|
||||
// at zero / track the same buffer-vs-target offset, but old log parsers don't break.
|
||||
// Explicit zero init so the compiler doesn't flag them as never-assigned when the
|
||||
// Phase-4 design no longer increments them anywhere.
|
||||
private long driftDropFramesTotal = 0;
|
||||
private long driftRepeatFramesTotal = 0;
|
||||
// driftDropFramesTotal + driftRepeatFramesTotal fields removed 2026-05-23. They were
|
||||
// Phase-2/3 splice-corrector counters that the Phase-4 fixed-ratio resampler design
|
||||
// never incremented; they sat at zero and fed dead diag-log columns that have also been
|
||||
// removed. The current corrector's "where is the buffer" signal is filteredErrorFrames
|
||||
// (below) — that one IS still active and IS still surfaced via FilteredDriftErrorFrames.
|
||||
// Live state for the diag log — the current buffer-level offset from target, low-pass
|
||||
// filtered. Lets the diag line continue to surface "where the buffer is sitting".
|
||||
// Updated each Read; no longer drives any correction logic itself.
|
||||
@@ -204,12 +201,9 @@ internal sealed class SessionPlayout : IDisposable
|
||||
private const double DriftFilterTimeConstantSec = 2.0;
|
||||
// Number of stereo frames each side of a splice point that get blended when a drop or
|
||||
// repeat fires. Cosine crossfade over this window smooths the discontinuity into an audio
|
||||
// Public accessors for the diag log. Drop / repeat counters are retained for the diag
|
||||
// surface (the Phase-4 resampler doesn't increment them, so they stay flat at the
|
||||
// last value from any pre-Phase-4 fallback path — informationally that's "the splice
|
||||
// path didn't fire", which is what we want to see now).
|
||||
public long DriftDropFramesTotal => Interlocked.Read(ref driftDropFramesTotal);
|
||||
public long DriftRepeatFramesTotal => Interlocked.Read(ref driftRepeatFramesTotal);
|
||||
// DriftDropFramesTotal / DriftRepeatFramesTotal accessors removed 2026-05-23 alongside
|
||||
// their backing fields — they only ever surfaced two always-zero columns in the diag log,
|
||||
// and the columns have been removed too.
|
||||
/// <summary>Diagnostic accessor — current smoothed sender-rate-ratio applied to the
|
||||
/// resampler. 1.0 = no resampling (matched clocks). Values like 1.0002 = sender running
|
||||
/// 200 ppm faster than receiver; 0.9998 = 200 ppm slower.</summary>
|
||||
@@ -245,11 +239,9 @@ internal sealed class SessionPlayout : IDisposable
|
||||
/// running above target on average (sender clock faster); negative = buffer below
|
||||
/// target. Magnitude shows how off-target the buffer's average position is right now.</summary>
|
||||
public double FilteredDriftErrorFrames => filteredErrorFrames;
|
||||
/// <summary>Legacy diag accessor — the Phase-2 / Phase-3 integrator accumulator is no
|
||||
/// longer used in the Phase-4 resampler design. Always returns 0. Kept on the surface
|
||||
/// so MainForm's existing diag log line still compiles; can be removed once the diag
|
||||
/// columns are pruned.</summary>
|
||||
public double DriftAccumulator => 0.0;
|
||||
// DriftAccumulator accessor removed 2026-05-23. The Phase-4 fixed-ratio resampler design
|
||||
// never sets an integrator accumulator value; the property always returned 0. Removed
|
||||
// along with the driftAcc= diag column.
|
||||
|
||||
public IPEndPoint Endpoint { get; }
|
||||
/// <summary>The stream ID this session was opened for. Sessions are keyed by
|
||||
|
||||
Reference in New Issue
Block a user