Bump to v1.2.0: recording, sound cues, drift compensation, diagnostics

New user-facing features:

* Recording. Dedicated Record menu (Alt+O — moved from Alt+R to
  avoid clashing with the Receive audio checkbox), Start/Stop on
  Ctrl+R, settings dialog, per-profile source / format / bit-depth /
  channel-mode / folder. Three source modes (received only, sent
  only, both). Files are crash-resilient — a process crash
  mid-recording leaves a playable file containing everything up to
  the last header refresh (~5 seconds).

* Four output formats, all functional:
    - WAV: 16/24-bit PCM or 32-bit float, custom writer with
      periodic RIFF re-patching.
    - MP3: LAME 128–320 kbps CBR (via NAudio.Lame).
    - OGG-Opus: 96–256 kbps VBR (via Concentus.Oggfile, reusing the
      Concentus encoder from the wire path).
    - FLAC: 16/24-bit lossless (via CUETools.Codecs.FLAKE — pure
      managed, no native DLL).

* Recording start/stop sound cues. record start.wav and
  record stop.wav play around the recording transition. Played via
  System.Media.SoundPlayer to the default Windows output, separate
  from the recording pipeline so a normal recording does not contain
  the cue.

* Per-cue Preferences. The old single "Mute connect/disconnect
  sounds" checkbox is replaced by a CheckedListBox: Connect /
  Disconnect / Recording start / Recording stop. Old profiles with
  the legacy MuteConnectionCues=true are honoured on first load via
  a migration path in the new Load* helpers.

* Receiver-side drift compensation switched from discrete
  single-frame splices to a continuous WdlResampler at a smoothed
  rate ratio. SessionPlayout.cs rewrite.

Diagnostics (only active with Enable logs ticked):

* Per-stage discontinuity probes — sender raw capture (per backend,
  PushModeWasapi + Asio both wired), sender pre-encode (now per
  lane in BothIndependent, fixing a cross-stream artefact), receiver
  post-decode, post-ring, post-resampler.

* Wire-level packet sequence tracking on each PCM stream — in-order
  / missed / reordered / duplicated counts in the diag log.

* Clipped-sample delta in the diag log.

* New AudioStepProbe in RemSound.Core with per-channel scan helper.

UI changes:

* Record menu uses Alt+O (Rec&ord). Inside the menu, item mnemonics
  unchanged (S / T / O / C).

* Auto-tune interval combo label is mode-aware: "Auto-tune latency
  interval" in classic modes, "Auto-tune interval — WASAPI and ASIO"
  in BothIndependent. The combo's Enabled state now follows EITHER
  lane's auto-tune checkbox (was only the WASAPI one — bug).

Wire format and audio pipeline unchanged from v1.1 — v1.1 and v1.2
peers interoperate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-05-15 12:40:01 +01:00
co-authored by Claude Opus 4.7
parent a0fe8070ed
commit c59e413c1f
27 changed files with 3348 additions and 350 deletions
+116
View File
@@ -133,6 +133,19 @@ public sealed class AudioReceiver : IDisposable
public void SetConcealmentArtifact(ConcealmentArtifact artifact) =>
playoutEngine.SetConcealmentArtifact(artifact);
/// <summary>
/// Optional callback invoked when the engine produces fully-processed mixed received
/// audio (volume / mute / limiter all applied). Span is 48 kHz interleaved stereo
/// float, lives on the render thread — copy or consume quickly. Used by the recorder
/// to capture "what we heard". Setter mirrors directly onto <see cref="PlayoutEngine"/>;
/// null clears the tap.
/// </summary>
public Action<ReadOnlyMemory<float>>? OnReceivedSamples
{
get => playoutEngine.OnReceivedSamples;
set => playoutEngine.OnReceivedSamples = value;
}
/// <summary>
/// Sets the allow-list of sender endpoints whose audio will be rendered. Pass an empty set
/// to block all (the user has selected no peers); pass null to disable filtering and accept
@@ -268,6 +281,24 @@ public sealed class AudioReceiver : IDisposable
}
}
/// <summary>Take the worst post-decode single-sample step magnitude across all active
/// stream sessions since the last call, resetting each session's probe. Used by the
/// diag log to pinpoint where in the pipeline audio discontinuities are being
/// introduced.</summary>
public float TakeMaxPostDecodeStep()
{
lock (sessionsLock)
{
var max = 0f;
foreach (var s in sessions.Values)
{
var v = s.TakeMaxPostDecodeStep();
if (v > max) max = v;
}
return max;
}
}
public long PcmFrameDiscardedPartials
{
get
@@ -331,6 +362,26 @@ public sealed class AudioReceiver : IDisposable
/// 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;
/// <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
/// log can show "real underruns this second" distinct from "partial near-misses".</summary>
public long ConcealmentFires => playoutEngine.AggregateConcealmentFires;
/// <summary>Cumulative count of sub-frame partial reads (0 &lt; framesRead &lt; requested).
/// Inaudible since the 2026-05-14 concealment fix but tracked so we can see clock
/// in-phase patterns.</summary>
public long ShortReadFires => playoutEngine.AggregateShortReadFires;
/// <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;
/// <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();
/// <summary>Take the worst single-sample step out of the resampler since the last call.</summary>
public float TakeMaxPostResamplerStep() => playoutEngine.TakeMaxPostResamplerStep();
/// <summary>RingbufferOverflowDropBytes = AggregateDrops minus the deliberate trim+drain
/// causes. Whatever's left was the producer-side overflow (Write into a full buffer) or
/// the catastrophic-cap trim from NoteFramesQueued. Both indicate "we genuinely couldn't
@@ -371,6 +422,71 @@ public sealed class AudioReceiver : IDisposable
}
}
// === Wire-level packet sequence diagnostics ===
// Each audio packet carries a per-session sequence number from the sender. Tracking it
// at receipt tells us whether the network or NIC stack between sender and receiver is
// reordering, dropping, or duplicating packets — any of which would manifest as audible
// pops on the PCM path. On a healthy LAN all four counters should grow as
// WireInOrder == packets, all others == 0. A non-zero Missed / Reordered / Duplicated
// points straight at transport pathology and rules out codec / playout / hardware as
// pop sources.
/// <summary>Cumulative count of audio packets that arrived with the expected wire sequence.</summary>
public long WireInOrderCount
{
get
{
long total = 0;
lock (sessionsLock)
{
foreach (var s in sessions.Values) total += s.WireInOrderCount;
}
return total;
}
}
/// <summary>Cumulative count of packets that the wire claims went missing (forward gaps).</summary>
public long WireMissedCount
{
get
{
long total = 0;
lock (sessionsLock)
{
foreach (var s in sessions.Values) total += s.WireMissedCount;
}
return total;
}
}
/// <summary>Cumulative count of packets that arrived out-of-order (later sequence first, then earlier).</summary>
public long WireReorderedCount
{
get
{
long total = 0;
lock (sessionsLock)
{
foreach (var s in sessions.Values) total += s.WireReorderedCount;
}
return total;
}
}
/// <summary>Cumulative count of duplicate-sequence packets (the same wire seq delivered twice).</summary>
public long WireDuplicatedCount
{
get
{
long total = 0;
lock (sessionsLock)
{
foreach (var s in sessions.Values) total += s.WireDuplicatedCount;
}
return total;
}
}
public float Volume { get => playoutEngine.Volume; set => playoutEngine.Volume = value; }
public bool IsMuted { get => playoutEngine.IsMuted; set => playoutEngine.IsMuted = value; }
+128 -1
View File
@@ -159,6 +159,25 @@ internal sealed class PlayoutEngine : IWaveProvider
asioLaneOutput = new LaneOutput(this, RenderRoute.AsioLane);
}
/// <summary>
/// Optional callback invoked every time the engine produces a buffer of mixed received
/// audio (after volume/mute/limiter, before pack-to-bytes). The span is 48 kHz
/// interleaved stereo float and lives on the render thread; copy or process quickly.
/// Used by the recorder to capture "what we heard". Wired in App; null = no tap.
///
/// In BothIndependent the callback fires once per lane Read (so the recorder sees two
/// streams paced at the per-lane render rates) — the recorder consolidates them on its
/// background thread. In classic modes only the main Read path fires.
/// </summary>
public Action<ReadOnlyMemory<float>>? OnReceivedSamples { get; set; }
private void DispatchReceivedSamples(ReadOnlyMemory<float> samples)
{
var cb = OnReceivedSamples;
if (cb is null) return;
try { cb(samples); } catch { /* recorder failure isolated from audio path */ }
}
/// <summary>
/// IWaveProvider surface for sessions tagged <see cref="RenderRoute.WasapiLane"/>. Only
/// used in BothIndependent mode where the WASAPI render backend reads its own lane
@@ -378,6 +397,93 @@ internal sealed class PlayoutEngine : IWaveProvider
}
}
/// <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
/// on the next read. Split out from <see cref="AggregateUnderruns"/> (which conflates
/// full and partial short reads) so diagnostic code can tell "the buffer fully emptied"
/// from "the buffer was a frame or two short of the request".</summary>
public long AggregateConcealmentFires
{
get
{
long total = 0;
foreach (var s in sessionsSnapshot) total += s.ConcealmentFiresTotal;
return total;
}
}
/// <summary>Cumulative count of sub-frame partial reads (0 &lt; framesRead &lt; requested)
/// across all sessions. Since the 2026-05-14 concealment fix these are no longer audible
/// events — the ring buffer's zero-fill tail is left in place rather than running cosine
/// fade-in on the next read — but counting them is useful for diagnosis. A high rate
/// indicates sender/receiver clocks are running so close in-phase that the ring buffer
/// occasionally returns N-1 of N requested frames.</summary>
public long AggregateShortReadFires
{
get
{
long total = 0;
foreach (var s in sessionsSnapshot) total += s.PartialReadFiresTotal;
return total;
}
}
/// <summary>Live state — the LP-filtered drift error in stereo frames of the FIRST
/// active session in the snapshot. Most setups have exactly one session, in which case
/// this is exactly that session's value. Positive = buffer running above target on
/// average; negative = buffer below target. 0 if no sessions exist.</summary>
public double PrimaryFilteredDriftErrorFrames
{
get
{
var snap = sessionsSnapshot;
return snap.Length > 0 ? snap[0].FilteredDriftErrorFrames : 0.0;
}
}
/// <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;
}
}
/// <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
/// probe, this locates where in the pipeline an audio discontinuity was introduced.
/// Takes the max across all sessions and resets each.</summary>
public float TakeMaxPostRingReadStep()
{
var snap = sessionsSnapshot;
var max = 0f;
foreach (var s in snap)
{
var v = s.TakeMaxPostRingReadStep();
if (v > max) max = v;
}
return max;
}
/// <summary>Worst single-sample step in the resampler output since the last call.
/// Significantly larger than <see cref="TakeMaxPostRingReadStep"/> would point the
/// finger at the resampler integration.</summary>
public float TakeMaxPostResamplerStep()
{
var snap = sessionsSnapshot;
var max = 0f;
foreach (var s in snap)
{
var v = s.TakeMaxPostResamplerStep();
if (v > max) max = v;
}
return max;
}
// === WASAPI render thread ===
/// <summary>
@@ -478,6 +584,13 @@ internal sealed class PlayoutEngine : IWaveProvider
if (recordDiagnostics) diagnostics.RecordOutputSampleSteps(mixBuf.AsSpan(0, outFloats));
// Recording tap (per-lane). Mix is fully processed at this point — volume, mute and
// limiter have all been applied — so the recorder sees exactly what the user is
// about to hear from this lane. The tap fires regardless of whether the lane has an
// attached output device; that way a recording set to "received only" still captures
// audio in setups where the user has no WASAPI outputs ticked.
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats));
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
return count;
}
@@ -546,6 +659,13 @@ internal sealed class PlayoutEngine : IWaveProvider
}
if (recordDiagnostics) diagnostics.RecordOutputSampleSteps(mixBuf.AsSpan(0, outFloats));
// Recording tap (all-sessions path, classic modes / WasapiOnly). Same point in the
// pipeline as the lane-routed tap above — fully processed mix, just before the
// pack-to-bytes step. The recorder gets a clean copy of what the user is about
// to hear.
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats));
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
return count;
}
@@ -606,6 +726,13 @@ internal sealed class PlayoutEngine : IWaveProvider
}
public int Read(byte[] buffer, int offset, int count) =>
owner.ReadForRoute(buffer, offset, count, route, MixScratch, SessionScratch, recordDiagnostics: false);
// recordDiagnostics: true so the diag log line in MainForm gets buffer-level
// and render-read samples in BothIndependent mode. Originally false to avoid
// double-counting when both lanes ran concurrently, but in practice only one
// lane has sessions at a time (the user's chosen capture path) — the other
// lane's Read returns zero-mix and contributes nothing meaningful to the diag
// numbers. Without this the diag line never fires in BothIndependent setups,
// which is the user's normal mode of operation. 2026-05-14.
owner.ReadForRoute(buffer, offset, count, route, MixScratch, SessionScratch, recordDiagnostics: true);
}
}
+385 -298
View File
@@ -1,5 +1,7 @@
using System.Diagnostics;
using System.Net;
using System.Runtime.InteropServices;
using NAudio.Dsp;
using RemSound.Core;
namespace RemSound.Receiver;
@@ -27,9 +29,6 @@ internal sealed class SessionPlayout : IDisposable
private const int MixBytesPerSecond = MixSampleRate * MixBytesPerFrame;
private readonly AudioRingBuffer playout;
// Scratch buffer used by the drift-correction crossfade path. Sized as needed inside
// ReadFloats; persistent here so we don't reallocate per call.
private float[] driftScratch = new float[8192];
private volatile bool playbackArmed;
private volatile bool drainRequested;
@@ -90,88 +89,163 @@ internal sealed class SessionPlayout : IDisposable
// gets a different sequence — but we don't care about reproducibility, just character.
private readonly Random concealRng = new(Random.Shared.Next());
// === Drift correction (Phase 2, 2026-05-06) ===
// Continuous low-rate clock-drift correction. The receiver and sender each have their own
// audio crystal; over time their rates differ by a few-tens-of-ppm (typical for cheap USB
// audio). Without correction, the playout buffer slowly drifts up (sender faster) or down
// (sender slower) and eventually clicks via either overflow or underrun.
// === Drift correction (Phase 4, 2026-05-14) — fixed-ratio resampler ===
//
// The previous design corrected via a continuously-modulated WdlResampler — which produced
// sample-level corruption and was the source of all the per-sample artefacts we hunted for
// weeks (see analysis 2026-05-06). The replacement is the Jamulus / Mumble pattern:
// **integrate the buffer-level error over time and discretely drop or repeat ONE STEREO
// FRAME at a time when the integrator signals sustained drift.** A single-frame drop or
// repeat at 48 kHz is 21 µs of audio — below the threshold of audibility on any normal
// content, especially when timed by an integrator that fires only on sustained drift, not
// on packet-arrival jitter.
// Replaces the Phase-2/3 discrete-splice drift corrector. Sender and receiver each have
// their own free-running audio crystal and the two rates differ by a few-tens to a few-
// hundreds of ppm. Without compensation the receive ring buffer slowly drifts up
// (sender faster) or down (sender slower) and eventually clicks via either overflow or
// underrun.
//
// Mechanism per Read:
// 1. Sample the current buffer level vs target.
// 2. Integrate (buffer_level_error_frames * dt_sec * DriftGain) into driftAccumulator.
// 3. If accumulator >= 1, drop one frame from the head of the playout buffer
// (sender faster — we've consumed less than it produced; speed up consumption by
// one frame). Decrement accumulator.
// 4. If accumulator <= -1, queue a "repeat one frame" for the next Read (sender slower —
// stall consumption by one frame). Increment accumulator.
// Discrete single-frame drop / repeat splices — even with cosine crossfade and adaptive-
// gain integrator scheduling — are audible at any drift rate above roughly 1 correction
// per second on tonal content. Ed reported them as a continuous train of "tiny pops" all
// through this session. The instrumentation added 2026-05-14 confirmed the corrector
// was firing at 4-13 repeats/sec for his Audient EVO4 ↔ EVO8 setup (~150-200 ppm drift).
//
// Behaviour by drift rate:
// - 0 ppm (perfectly matched clocks): error stays near 0, accumulator stays near 0,
// no corrections fire. Silent.
// - 50 ppm drift (typical USB crystal mismatch = ~5 frames/sec on 48 kHz): accumulator
// grows to ±1 every ~4 seconds; one frame correction every ~4 seconds. 21 µs of audio
// dropped or repeated every ~4 seconds. Inaudible.
// - Higher transient drift (e.g. system load briefly): integrator catches up within
// seconds, brief burst of corrections, then settles. Still inaudible.
// The fixed-ratio resampler approach is what every serious networked-audio implementation
// (Jamulus, SonoBus, Dante) uses for the same problem:
// 1. Measure the sender's effective sample rate by comparing bytes written to the ring
// vs bytes output to the audio device over a long window (multi-second).
// 2. Configure a WdlResampler with input rate = measured-sender-rate and output rate =
// MixSampleRate. The resampler continuously stretches or compresses the incoming
// stream by the necessary ppm.
// 3. Update the resampler's input rate ONLY every DriftMeasurementWindowSec, smoothed
// heavily. The earlier doomed attempt (May) modulated rates per-sample based on
// instantaneous buffer level; that caused phase discontinuities and sample-level
// artefacts. Slow updates avoid that entirely.
//
// The existing click-trim block above is kept as a safety net for catastrophic conditions
// (large step changes that the slow integrator can't keep up with). At normal drift rates
// the integrator never lets the buffer reach the click-trim threshold, so the trim should
// effectively never fire in steady-state operation.
private double driftAccumulatorFrames;
// Pitch shift introduced by a fixed-ratio resampler at, say, 200 ppm is 0.02 % — far
// below the ~5 % human pitch-discrimination threshold and even below tuning precision.
// Genuinely inaudible.
//
// Safety net: the legacy click-trim block above stays in place and fires only at
// catastrophic buffer levels (target + ~23 ms or 1 second worst-case cap). The discrete
// splice corrector (drop / repeat with crossfade) is GONE — the resampler handles
// steady-state drift smoothly. If the resampler somehow can't keep up (transient
// catastrophe), the click-trim safety net fires once.
//
// The resampler. Output-driven (we want N output frames; ask the resampler how many
// input frames it needs and feed those in from the ring buffer). interp=true with
// filtercnt=0 selects WdlResampler's low-cost linear-interpolation mode — plenty good
// enough for the ppm-scale rate corrections we apply. Higher filter modes would add
// CPU cost for a sample quality difference well below audibility at these tiny ratios.
// SetRates is called periodically from the audio thread (the only thread that touches
// this resampler) so we don't need any cross-thread synchronisation around it.
private readonly WdlResampler driftResampler;
// Drift measurement counters. bytesWrittenForDriftEst is incremented by the producer
// thread on every Write; bytesReadOutputForDriftEst is incremented by the consumer
// thread (us) on every successful ReadFloats. Their ratio over a multi-second window
// is the sender's effective rate divided by the receiver's nominal rate — exactly the
// ratio the resampler needs.
private long bytesWrittenForDriftEst;
private long bytesReadOutputForDriftEst;
// Window state. windowStartTicks = when the current measurement window started, ticks
// 0 means "not yet armed for measurement". The values at window start are snapshotted
// so we can compute the delta cleanly even if the counters wrap (long is 63-bit so wrap
// is ~190 years at 48 kHz stereo float, but the math is still cleaner with snapshots).
private long resamplerWindowStartTicks;
private long resamplerWindowStartBytesWritten;
private long resamplerWindowStartBytesOutput;
// Smoothed ratio currently applied to the resampler (1.0 = no resampling). Smoothing
// is "first measurement = the measurement; subsequent = 70 % previous + 30 % new" so
// a one-window outlier doesn't yank the rate. Settles within a few windows to the
// true drift.
private double smoothedRateRatio = 1.0;
private bool resamplerActivelyTracking;
private long resamplerUpdatesTotal;
// Scratch buffer for reading from the ring buffer in float form. Sized lazily based on
// the largest input-frames request the resampler asks for; persists across calls so
// 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;
// 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.
private double filteredErrorFrames;
private long prevDriftSampleTicks;
private int pendingRepeatFrames;
private long driftDropFramesTotal;
private long driftRepeatFramesTotal;
// Integrator gain. Lowered 2026-05-06 (10×) after an empirical test where the previous
// gain (0.05) produced ~10 corrections per second on the user's hardware (two free-running
// USB audio crystals with combined drift around 200 ppm = 10 frames/sec). Even with
// single-frame corrections, 10 clicks/sec was audible. Lowering the gain alone trades
// click rate for buffer drift; combined with the crossfade-on-splice change, each
// correction is also significantly less audible per event.
//
// At 0.005, sustained 1-frame error reaches accumulator = 1 in ~200 seconds. For 200 ppm
// drift (10 frames/sec error growth), the integrator catches up at ~2 corrections/sec
// steady-state — which combined with crossfaded splices should push perceived click rate
// toward inaudible.
//
// 2026-05-06 (later): added adaptive gain scaling. The base gain above is fine for steady-
// state clock-drift compensation but pathologically slow when the buffer is far from
// target — e.g. after a slider raise the buffer sits below target and drift correction
// takes minutes to fill it. Empirically observed in user testing as "every session sounds
// different": the buffer wandered for tens of seconds at whatever level the initial
// arming chaos left it at. Now the effective gain scales linearly with absolute error
// beyond the small-error band, capped, so:
// * |error| <= DriftSmallErrorFrames: gain = DriftGain (today's behaviour, gentle)
// * |error| > DriftSmallErrorFrames: gain = DriftGain × min(|error|/small, maxScale)
// At 50 frames (~1 ms) the gain is 1×; at 1000 frames (~21 ms) it's 20× capped, giving
// a fill rate of ~100 frames/sec — a 20 ms slider raise converges in ~10 seconds with
// a barely-audible 0.2% rate offset during the fill.
private const double DriftGain = 0.005;
// Below this absolute error, gain stays at the steady-state baseline. ~1 ms at 48 kHz.
private const double DriftSmallErrorFrames = 50;
// Cap on adaptive-gain scale, so even huge errors don't produce an audible time-stretch
// (200/sec frame edits = 0.42% rate change, edge of noticeable on tonal content).
private const double DriftMaxGainScale = 20.0;
// Drift-measurement window for the fixed-ratio resampler. After this many seconds of
// sustained streaming, we compute (bytes_written / bytes_output) over the window and
// smooth-update the resampler's input rate. Long enough that brief network jitter or
// GC pauses don't bias the measurement; short enough to track temperature-induced
// crystal-rate changes (USB audio clocks can drift several ppm over minutes as the
// device warms up). 10 sec is the canonical Jamulus / SonoBus value.
private const double DriftMeasurementWindowSec = 10.0;
// First-window length. Same as DriftMeasurementWindowSec for simplicity; could be
// shortened to engage compensation faster after session start at the cost of a noisier
// initial measurement.
private const double DriftFirstWindowSec = 10.0;
// Ratio smoothing weight. New measurement = 30 %; previous smoothed = 70 %. Tunes how
// quickly the rate tracks vs how stable it is. The first measurement after session
// start uses 100 % new (no previous value to weight).
private const double DriftRatioSmoothingNew = 0.30;
// Sanity-range clamp on the measured ratio. Real clock differences between USB audio
// crystals are sub-1000 ppm (0.1 %); anything beyond ±5 % indicates a measurement
// artefact (a buffer-fill burst, a transient, or a counter wrap). Reject those and
// keep the previous ratio.
private const double DriftRatioMin = 0.95;
private const double DriftRatioMax = 1.05;
// Low-pass filter time constant for the buffer-level-error display in the diag log.
// Doesn't affect any correction logic in Phase 4 — purely informational.
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
// characteristic that's much harder to perceive as a click. 8 frames = 167 µs at 48 kHz —
// shorter than a typical impulse response, so the smear doesn't blur transients audibly.
private const int DriftCrossfadeFrames = 8;
// Pending corrections (sample-aligned single-frame edits at the next Read).
private int pendingDropFrames;
// Public accessors for the diag log.
// 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);
/// <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>
public double DriftResamplerRatio => smoothedRateRatio;
/// <summary>Number of times the resampler rate has been updated since session start.</summary>
public long DriftResamplerUpdates => Interlocked.Read(ref resamplerUpdatesTotal);
// Per-stage discontinuity probes — the receiver-side instrumentation that, combined
// with the sender's pre-encode probe and the StreamSession's post-decode probe,
// localises exactly where in the pipeline a click is introduced. PostRingRead is
// what came out of the ring buffer (after wire+decode+ring). PostResampler is what
// came out of the resampler (after rate compensation).
private readonly AudioStepProbe postRingReadStepProbe = new();
private readonly AudioStepProbe postResamplerStepProbe = new();
public float TakeMaxPostRingReadStep() => postRingReadStepProbe.TakeMax();
public float TakeMaxPostResamplerStep() => postResamplerStepProbe.TakeMax();
// Concealment vs partial-read counters split from the legacy "Underruns" — that one
// increments on ANY short read at the AudioRingBuffer level (whether framesRead==0
// or framesRead<requested). For diagnosis we care about the split: full-empty reads
// (concealmentFiresTotal) are audible events that trigger noise-burst + fade-in;
// sub-frame partial reads (partialReadFiresTotal) used to be audible too but are
// now silent after the 2026-05-14 fix that stops concealment from engaging on partials.
private long concealmentFiresTotal;
private long partialReadFiresTotal;
public long ConcealmentFiresTotal => Interlocked.Read(ref concealmentFiresTotal);
public long PartialReadFiresTotal => Interlocked.Read(ref partialReadFiresTotal);
/// <summary>Live state — the LP-filtered drift error in stereo frames. Positive = buffer
/// 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;
public IPEndPoint Endpoint { get; }
/// <summary>The stream ID this session was opened for. Sessions are keyed by
@@ -216,6 +290,18 @@ internal sealed class SessionPlayout : IDisposable
Endpoint = endpoint;
StreamId = streamId;
playout = new AudioRingBuffer(capacityBytes);
// Resampler init. interp=true, filtercnt=0 picks WdlResampler's linear-interpolation
// mode — perfectly adequate for the sub-1000-ppm rate corrections we apply (the
// higher-cost sinc modes would buy theoretical quality wins below human audibility).
// sinc=false confirms we're not using the sinc-table mode. Output-driven feed: each
// ResamplePrepare call asks the resampler "how many input frames do you need for N
// output frames" and we satisfy from the ring buffer. SetRates(in, out) starts at
// 1:1; we update with measured drift after the first window completes.
driftResampler = new WdlResampler();
driftResampler.SetMode(interp: true, filtercnt: 0, sinc: false);
driftResampler.SetFeedMode(false);
driftResampler.SetRates(MixSampleRate, MixSampleRate);
}
public void Write(ReadOnlySpan<byte> source)
@@ -223,6 +309,10 @@ internal sealed class SessionPlayout : IDisposable
var ms = source.Length * 1000 / MixBytesPerSecond;
if (ms > largestWriteMs) largestWriteMs = ms;
playout.Write(source);
// Track bytes written for the drift-resampler measurement window. Producer thread
// updates this; consumer thread (audio thread in ReadFloats) reads it via
// Interlocked.Read when sampling the window. Cumulative since session start.
Interlocked.Add(ref bytesWrittenForDriftEst, source.Length);
LastWriteUtc = DateTime.UtcNow;
}
@@ -276,10 +366,21 @@ internal sealed class SessionPlayout : IDisposable
consecutiveEmptyReads = 0;
lastConcealSampleL = 0f;
lastConcealSampleR = 0f;
driftAccumulatorFrames = 0;
filteredErrorFrames = 0;
prevDriftSampleTicks = 0;
pendingDropFrames = 0;
pendingRepeatFrames = 0;
// Phase-4 drift resampler state. Reset counters and window state. Reset() on the
// resampler clears its internal filter delay line so a fresh session doesn't
// inherit phase from a prior one. SetRates back to 1:1 — we'll re-measure drift
// from scratch.
bytesWrittenForDriftEst = 0;
bytesReadOutputForDriftEst = 0;
resamplerWindowStartTicks = 0;
resamplerWindowStartBytesWritten = 0;
resamplerWindowStartBytesOutput = 0;
smoothedRateRatio = 1.0;
resamplerActivelyTracking = false;
driftResampler.Reset();
driftResampler.SetRates(MixSampleRate, MixSampleRate);
}
public void Dispose()
@@ -424,269 +525,255 @@ internal sealed class SessionPlayout : IDisposable
}
}
// === Drift correction (Phase 2) ===
// === Drift compensation (Phase 4) — fixed-ratio resampler ===
//
// Continuously integrate buffer-level error and drop / repeat single frames at low
// rate to keep buffer aligned with target despite clock-drift between sender and
// receiver crystals. Replaces the continuous adaptive resampling that produced
// sample-level artefacts (analysed 2026-05-06). See the field-block comment above
// for the design rationale.
// 1. Update the LP-filtered buffer-level error for the diag log (informational only).
// 2. Update the resampler's rate ratio if the measurement window has elapsed.
// 3. Read through the resampler into the caller's output span.
//
// SAMPLE-RATE MISMATCH (future): the direct read below requires input PCM to already
// be at MixSampleRate (48 kHz). When endpoints have mismatched device rates (e.g.
// one machine at 44.1 kHz), the sender's MixingEngine still resamples to 48 kHz on
// the capture side so the wire format is consistent — but if a future change emits
// at the source's native rate, we'd need a FIXED-ratio resampler here (input_rate /
// 48000, computed once, never modulated). The continuous-modulation pattern was the
// bug; a fixed ratio is fine.
var driftTicks = Stopwatch.GetTimestamp();
// The buffer-level error LP filter no longer drives any correction — that job is
// now the resampler's. It's kept purely as a diag display so the log shows where
// the buffer is sitting.
var nowTicks = Stopwatch.GetTimestamp();
var driftTargetBytes = MillisecondsToBytes(targetLatencyMs);
if (prevDriftSampleTicks != 0)
{
var dtSec = (driftTicks - prevDriftSampleTicks) / (double)Stopwatch.Frequency;
var dtSec = (nowTicks - prevDriftSampleTicks) / (double)Stopwatch.Frequency;
var errorFrames = ((double)playout.BufferedBytes - driftTargetBytes) / MixBytesPerFrame;
// Adaptive gain: baseline at small errors (gentle steady-state compensation for
// clock drift) but accelerated at large errors (fast convergence after a slider
// raise or initial arming overshoot). Without this, the buffer can sit at any
// level between 0 and target+jitter for tens of seconds — making sessions feel
// randomly different. With this, the buffer reliably reaches target within a few
// seconds of any disturbance.
var absErrorFrames = errorFrames < 0 ? -errorFrames : errorFrames;
var gainScale = absErrorFrames <= DriftSmallErrorFrames
? 1.0
: Math.Min(absErrorFrames / DriftSmallErrorFrames, DriftMaxGainScale);
driftAccumulatorFrames += errorFrames * dtSec * DriftGain * gainScale;
// Clamp to prevent runaway in pathological conditions (e.g. session pause).
if (driftAccumulatorFrames > 100.0) driftAccumulatorFrames = 100.0;
else if (driftAccumulatorFrames < -100.0) driftAccumulatorFrames = -100.0;
var filterAlpha = dtSec / (DriftFilterTimeConstantSec + dtSec);
filteredErrorFrames = (1.0 - filterAlpha) * filteredErrorFrames + filterAlpha * errorFrames;
}
prevDriftSampleTicks = driftTicks;
prevDriftSampleTicks = nowTicks;
// Queue at most one correction per Read so corrections spread evenly rather than burst.
if (driftAccumulatorFrames >= 1.0)
{
pendingDropFrames++;
driftAccumulatorFrames -= 1.0;
}
else if (driftAccumulatorFrames <= -1.0)
{
pendingRepeatFrames++;
driftAccumulatorFrames += 1.0;
}
UpdateDriftResamplerRateIfDue(nowTicks);
// === Read with optional crossfaded drop / repeat ===
//
// The trick to audibly-clean drift correction: don't perform the splice as a hard
// cut. Read one extra frame (drop) or one fewer frame (repeat) from the buffer, then
// CROSSFADE around the splice point over DriftCrossfadeFrames samples. The cosine
// window blends the audio either side of the splice into a smooth smear instead of
// a discontinuity. At 8 frames (~167 µs at 48 kHz) the smear is much shorter than
// any audible transient and far less perceptible than the original sample-level
// discontinuity.
//
// Splice position: middle of the output buffer. Could choose a low-amplitude moment
// for further inaudibility (PSOLA-style) but middle-of-buffer is good enough on
// typical content and keeps the code simple.
var dropThisCall = pendingDropFrames > 0 && outFrames > DriftCrossfadeFrames * 2 ? 1 : 0;
var repeatThisCall = pendingRepeatFrames > 0 && outFrames > DriftCrossfadeFrames * 2 ? 1 : 0;
// Don't try to do both in the same Read; they'd cancel anyway.
if (dropThisCall > 0 && repeatThisCall > 0) { dropThisCall = 0; repeatThisCall = 0; }
if (dropThisCall > 0)
{
// Read outFrames + 1 frames into the output span by reading the first half,
// skipping the splice with crossfade, then reading the second half. We need
// a small extra-sample scratch for the splice. Reuse driftScratch as
// temp storage (it's already managed and grows with outFrames).
var extraFloats = (outFrames + 1) * MixChannels;
if (driftScratch.Length < extraFloats)
{
driftScratch = new float[extraFloats];
}
var temp = driftScratch.AsSpan(0, extraFloats);
ReadInputWithConcealment(temp);
// Crossfade the splice. Splice position = midpoint of the output frame.
// Result: outFrames samples where one is "elided" via a cosine cross-blend.
ApplyDropCrossfade(temp, output, outFrames);
pendingDropFrames--;
Interlocked.Increment(ref driftDropFramesTotal);
}
else if (repeatThisCall > 0)
{
// Read outFrames - 1 frames into temp, then expand to outFrames via a crossfaded
// insertion at the splice point.
var shortFloats = (outFrames - 1) * MixChannels;
if (driftScratch.Length < shortFloats)
{
driftScratch = new float[shortFloats];
}
var temp = driftScratch.AsSpan(0, shortFloats);
ReadInputWithConcealment(temp);
ApplyRepeatCrossfade(temp, output, outFrames);
pendingRepeatFrames--;
Interlocked.Increment(ref driftRepeatFramesTotal);
}
else
{
ReadInputWithConcealment(output);
}
// Read through the resampler and apply concealment on full underruns.
ReadThroughResampler(output, outFrames);
return outFrames;
}
/// <summary>Drop-mode crossfade: temp has (outFrames + 1) frames, output gets outFrames
/// frames with one elided at the splice via a cosine blend across DriftCrossfadeFrames
/// samples on each side.</summary>
private static void ApplyDropCrossfade(ReadOnlySpan<float> temp, Span<float> output, int outFrames)
/// <summary>
/// If the current drift-measurement window has expired, compute the new sender-to-
/// receiver rate ratio from the bytes-written and bytes-output counters, smooth it
/// into the live ratio, and push it to the resampler. Called from the audio thread
/// on every ReadFloats. No-op if the window hasn't elapsed yet.
/// </summary>
private void UpdateDriftResamplerRateIfDue(long nowTicks)
{
// Splice at midpoint of output frames. The "skipped" sample in temp lives at index
// spliceIdx; either side of it gets cross-blended.
var spliceIdx = outFrames / 2;
var window = DriftCrossfadeFrames;
var halfWindow = window / 2;
// Pre-window: copy temp[0..spliceIdx-halfWindow] verbatim.
var preEnd = spliceIdx - halfWindow;
if (preEnd > 0)
if (resamplerWindowStartTicks == 0)
{
temp.Slice(0, preEnd * MixChannels).CopyTo(output);
// First call — anchor the measurement window. Defer the first rate update by
// DriftFirstWindowSec so we get a stable initial measurement rather than one
// based on the first few writes (which can be bursty during session arming).
resamplerWindowStartTicks = nowTicks;
resamplerWindowStartBytesWritten = Interlocked.Read(ref bytesWrittenForDriftEst);
resamplerWindowStartBytesOutput = bytesReadOutputForDriftEst;
return;
}
// Window: cosine crossfade. As we walk through `window` output frames, blend from
// temp[preEnd + k] (the "before-skip" sample) toward temp[preEnd + 1 + k] (the
// "after-skip" sample). The blend mixes consecutive temp positions so the splice
// is spread out smoothly.
for (var k = 0; k < window; k++)
var windowDuration = resamplerActivelyTracking ? DriftMeasurementWindowSec : DriftFirstWindowSec;
var elapsedSec = (nowTicks - resamplerWindowStartTicks) / (double)Stopwatch.Frequency;
if (elapsedSec < windowDuration) return;
var bytesWrittenNow = Interlocked.Read(ref bytesWrittenForDriftEst);
var bytesWrittenInWindow = bytesWrittenNow - resamplerWindowStartBytesWritten;
var bytesOutputInWindow = bytesReadOutputForDriftEst - resamplerWindowStartBytesOutput;
if (bytesOutputInWindow > 0 && bytesWrittenInWindow > 0)
{
var t = (k + 1) / (double)(window + 1);
// Cosine-shaped smooth fade from 0 to 1 across the window.
var fadeIn = (float)((1.0 - Math.Cos(Math.PI * t)) * 0.5);
var fadeOut = 1f - fadeIn;
var beforeIdx = (preEnd + k) * MixChannels;
var afterIdx = (preEnd + 1 + k) * MixChannels;
var dstIdx = (preEnd + k) * MixChannels;
output[dstIdx] = temp[beforeIdx] * fadeOut + temp[afterIdx] * fadeIn;
output[dstIdx + 1] = temp[beforeIdx + 1] * fadeOut + temp[afterIdx + 1] * fadeIn;
// ratio = bytes_sender_produced / bytes_receiver_consumed over the window.
// Above 1.0 = sender clock faster than receiver. Below 1.0 = sender slower.
// For Ed's hardware (sender slower than receiver) this should settle ~0.9998.
var measuredRatio = (double)bytesWrittenInWindow / bytesOutputInWindow;
if (measuredRatio >= DriftRatioMin && measuredRatio <= DriftRatioMax)
{
if (!resamplerActivelyTracking)
{
// First measurement — use directly. No previous value to weight.
smoothedRateRatio = measuredRatio;
resamplerActivelyTracking = true;
}
else
{
// Subsequent — smooth so a one-window outlier doesn't yank the rate.
smoothedRateRatio = (1.0 - DriftRatioSmoothingNew) * smoothedRateRatio + DriftRatioSmoothingNew * measuredRatio;
}
// Push to the resampler. SetRates(input_rate, output_rate). Input rate
// = measured sender rate; output rate = the receiver's nominal MixSampleRate.
// The resampler now stretches or compresses incoming audio by the ppm
// necessary to keep the playout ring buffer level constant.
driftResampler.SetRates(MixSampleRate * smoothedRateRatio, MixSampleRate);
Interlocked.Increment(ref resamplerUpdatesTotal);
}
// If the measured ratio is outside the sanity window (>5 % off), reject it.
// That happens transiently during session arming, slider raises, or sender
// start-of-stream bursts. Keep the previous ratio rather than yanking.
}
// Post-window: copy temp[spliceIdx+halfWindow+1..outFrames+1] to output[spliceIdx+halfWindow..outFrames].
// The "+1" on the source side is the elision: we skip one frame from temp.
var postStartTemp = spliceIdx + halfWindow + 1;
var postStartOut = spliceIdx + halfWindow;
var postLen = outFrames - postStartOut;
if (postLen > 0)
{
temp.Slice(postStartTemp * MixChannels, postLen * MixChannels)
.CopyTo(output.Slice(postStartOut * MixChannels));
}
}
/// <summary>Repeat-mode crossfade: temp has (outFrames - 1) frames, output gets outFrames
/// with one synthesised at the splice via a cosine blend that "stretches" temp by one
/// frame.</summary>
private static void ApplyRepeatCrossfade(ReadOnlySpan<float> temp, Span<float> output, int outFrames)
{
var spliceIdx = outFrames / 2;
var window = DriftCrossfadeFrames;
var halfWindow = window / 2;
// Pre-window: copy temp[0..spliceIdx-halfWindow] verbatim.
var preEnd = spliceIdx - halfWindow;
if (preEnd > 0)
{
temp.Slice(0, preEnd * MixChannels).CopyTo(output);
}
// Window of (window + 1) output frames mapped to (window) temp frames. Cosine
// crossfade synthesizes the extra frame: each output sample in the window is a
// blend of two adjacent temp samples, with the blend weight progressing slower than
// the index, effectively inserting a "smoothed" extra sample.
for (var k = 0; k <= window; k++)
{
var t = k / (double)(window + 1);
var fadeIn = (float)((1.0 - Math.Cos(Math.PI * t)) * 0.5);
var fadeOut = 1f - fadeIn;
// Map output index -> temp position: output[preEnd+k] takes from temp[preEnd+k-1] and temp[preEnd+k].
// For k=0 we use temp[preEnd] alone; for k=window we use temp[preEnd+window-1] alone.
var leftTempIdx = Math.Max(0, preEnd + k - 1) * MixChannels;
var rightTempIdx = Math.Min(temp.Length / MixChannels - 1, preEnd + k) * MixChannels;
var dstIdx = (preEnd + k) * MixChannels;
output[dstIdx] = temp[leftTempIdx] * fadeOut + temp[rightTempIdx] * fadeIn;
output[dstIdx + 1] = temp[leftTempIdx + 1] * fadeOut + temp[rightTempIdx + 1] * fadeIn;
}
// Post-window: copy temp[spliceIdx+halfWindow..outFrames-1] to output[spliceIdx+halfWindow+1..outFrames].
var postStartTemp = spliceIdx + halfWindow;
var postStartOut = spliceIdx + halfWindow + 1;
var postLen = outFrames - postStartOut;
if (postLen > 0)
{
temp.Slice(postStartTemp * MixChannels, postLen * MixChannels)
.CopyTo(output.Slice(postStartOut * MixChannels));
}
// Anchor the next window.
resamplerWindowStartTicks = nowTicks;
resamplerWindowStartBytesWritten = bytesWrittenNow;
resamplerWindowStartBytesOutput = bytesReadOutputForDriftEst;
}
/// <summary>
/// Wraps <see cref="AudioRingBuffer.ReadFloats"/> with packet-loss-style concealment.
/// On a short read, replaces the silence-filled tail with a brief synthesised burst
/// (character chosen by <see cref="SetConcealmentArtifact"/>) decaying to zero. On the
/// next full read after a gap, applies a matching fade-in so the resumed audio doesn't
/// start with a hard discontinuity. The result is a smooth attack-and-release at the
/// edges of any gap — the human ear is much more forgiving of "dipped briefly then came
/// back" than of "instant click into silence and instant click back".
///
/// Stereo-only (matches the rest of the audio path). Output flows through the mix bus
/// and limiter as usual.
/// Resampler-backed read. Asks the resampler how many input frames it needs to
/// produce <paramref name="outFrames"/> output frames at the current rate ratio,
/// reads that many from the playout ring, runs ResampleOut, and copies the result
/// into <paramref name="output"/> with a safety clamp to [-1, 1]. Handles full-empty
/// underruns with the existing concealment fade-out / fade-in machinery.
/// </summary>
private void ReadInputWithConcealment(Span<float> inSpan)
private void ReadThroughResampler(Span<float> output, int outFrames)
{
var requestedFloats = inSpan.Length;
var floatsRead = playout.ReadFloats(inSpan);
var requestedFrames = requestedFloats / MixChannels;
var framesRead = floatsRead / MixChannels;
var outFloats = outFrames * MixChannels;
var inputFramesNeeded = driftResampler.ResamplePrepare(outFrames, MixChannels, out var inBuf, out var inBufOff);
lastInputFramesAvailable = inputFramesNeeded;
if (inputFramesNeeded <= 0)
{
// Resampler doesn't need any input this call (its internal filter delay line
// has enough). Just produce output from buffered state.
ResampleOutAndCopy(output, outFrames);
bytesReadOutputForDriftEst += outFloats * sizeof(float);
return;
}
var inputFloatsNeeded = inputFramesNeeded * MixChannels;
// Grow our scratch buffer if a larger request than ever before. After the first few
// reads at session start, this stops being a fresh allocation.
if (resamplerInputScratch.Length < inputFloatsNeeded)
{
resamplerInputScratch = new float[inputFloatsNeeded];
}
var ringBytes = MemoryMarshal.AsBytes(resamplerInputScratch.AsSpan(0, inputFloatsNeeded));
var bytesGot = playout.Read(ringBytes);
var floatsGot = bytesGot / sizeof(float);
var framesGot = floatsGot / MixChannels;
// Pipeline-stage probe — scan what we got out of the ring buffer BEFORE the
// resampler touches it. If this shows large steps, the artefact is being
// introduced somewhere between the sender and here (wire, decode, ring buffer).
// If this is clean but the post-resampler probe shows large steps, the resampler
// is the source.
if (floatsGot > 0)
{
postRingReadStepProbe.ScanStereo(resamplerInputScratch.AsSpan(0, floatsGot));
}
// Copy whatever we got into the resampler's input buffer. AudioRingBuffer.Read
// already zero-fills the tail of a short read, but we copy via the float view so
// the resampler sees consistent float samples regardless of read shortfall.
resamplerInputScratch.AsSpan(0, floatsGot).CopyTo(inBuf.AsSpan(inBufOff));
if (floatsGot < inputFloatsNeeded)
{
inBuf.AsSpan(inBufOff + floatsGot, inputFloatsNeeded - floatsGot).Clear();
}
// Diagnostic split — distinguish full-empty reads from partial short reads. Only
// full-empty (framesGot == 0) triggers audible concealment treatment. Partial
// reads happen when the ring has fewer than inputFramesNeeded frames but more
// than zero; the zero-padded tail just produces silence at the resampler output
// for that fraction.
var artifact = (ConcealmentArtifact)concealmentArtifactRaw;
if (framesGot == 0)
{
Interlocked.Increment(ref concealmentFiresTotal);
consecutiveEmptyReads++;
}
else if (framesGot < inputFramesNeeded)
{
Interlocked.Increment(ref partialReadFiresTotal);
consecutiveEmptyReads = 0;
}
else
{
consecutiveEmptyReads = 0;
}
// Run the resampler. Output goes into outputScratch (the resampler needs a float[]
// not a Span<float>); we then copy with clamp to the caller's span.
ResampleOutAndCopy(output, outFrames);
bytesReadOutputForDriftEst += outFloats * sizeof(float);
if (framesRead < requestedFrames)
// Concealment overlay on full-empty reads. The resampler will have produced
// mostly-silence output for this call (we zero-padded its input); replace the
// head of that silence with the chosen artifact so the user hears the "something
// went wrong" cue rather than dead air, with a cosine fade-in on the next read
// when real audio resumes.
if (framesGot == 0)
{
// Don't synthesise concealment forever during a sustained empty-buffer state — the
// sender has probably gone away. After N consecutive empty reads we just leave the
// buffer's hard-zero in place; result is true silence rather than a "shshshsh"
// tremolo as the noise/cosine artifact retriggers each render callback.
consecutiveEmptyReads = framesRead == 0 ? consecutiveEmptyReads + 1 : 0;
if (consecutiveEmptyReads <= ConcealmentMaxConsecutiveEmpties)
{
// AudioRingBuffer silence-filled inSpan[floatsRead..] with zero. Replace the
// head of that silence with the chosen artifact, then leave the rest at zero.
var silenceFrameStart = framesRead;
var silenceFrameCount = requestedFrames - framesRead;
ApplyFadeOut(inSpan, silenceFrameStart, silenceFrameCount, artifact);
ApplyFadeOut(output, startFrame: 0, outFrames, artifact);
}
inUnderrunConcealment = true;
}
else if (inUnderrunConcealment)
{
// First full read after a gap. Fade the new audio in from zero so we don't
// instantly jump back to whatever the new audio's amplitude is.
ApplyFadeIn(inSpan, requestedFrames, artifact);
ApplyFadeIn(output, outFrames, artifact);
inUnderrunConcealment = false;
consecutiveEmptyReads = 0;
}
else
{
consecutiveEmptyReads = 0;
}
// Remember the last real sample for the next fade-out. Use the last frame of actual
// ring data, not anything we just synthesised. (Only meaningful if we read at least
// one real frame this call — i.e. framesRead > 0.)
if (framesRead > 0)
// Track the last real sample for the next fade-out.
if (framesGot > 0)
{
var lastIdx = (framesRead - 1) * MixChannels;
lastConcealSampleL = inSpan[lastIdx];
lastConcealSampleR = inSpan[lastIdx + 1];
var lastIdx = (framesGot - 1) * MixChannels;
lastConcealSampleL = resamplerInputScratch[lastIdx];
lastConcealSampleR = resamplerInputScratch[lastIdx + 1];
}
}
/// <summary>Run the resampler with already-supplied input and copy the result to the
/// caller's span, clamping samples to [-1, 1] as a safety against any pathological
/// resampler output. If the resampler returns fewer than requested output frames, the
/// tail is zero-filled.</summary>
private void ResampleOutAndCopy(Span<float> output, int outFrames)
{
var outFloats = outFrames * MixChannels;
if (resamplerOutputScratch.Length < outFloats)
{
resamplerOutputScratch = new float[outFloats];
}
// ResampleOut consumes the input we wrote into the buffer obtained from
// ResamplePrepare, plus any state it holds internally, and produces up to outFrames
// output frames. Returns the actual count.
var produced = driftResampler.ResampleOut(resamplerOutputScratch, 0, GetLastInputFramesAvailable(), outFrames, MixChannels);
var producedFloats = produced * MixChannels;
// Pipeline-stage probe — scan the resampler output before we clamp or copy. If this
// shows steps that don't appear in the post-ring-read probe, the resampler itself
// is the source.
if (producedFloats > 0)
{
postResamplerStepProbe.ScanStereo(resamplerOutputScratch.AsSpan(0, producedFloats));
}
for (var i = 0; i < producedFloats; i++)
{
var v = resamplerOutputScratch[i];
// Safety clamp. Real resampler output should never escape [-1, 1] from in-range
// input, but a single bad sample / NaN would otherwise produce a loud audible
// pop. Clamping is cheap insurance.
if (v > 1f) v = 1f;
else if (v < -1f) v = -1f;
else if (float.IsNaN(v)) v = 0f;
output[i] = v;
}
// Zero-fill if the resampler didn't produce as many frames as we asked for. Should
// only happen in pathological cases (just after session start with empty filter
// delay line, or after a Reset).
if (produced < outFrames)
{
output.Slice(producedFloats, (outFrames - produced) * MixChannels).Clear();
}
}
// Resampler scratch — output buffer (input scratch is the resamplerInputScratch field).
private float[] resamplerOutputScratch = new float[2048];
// How many input frames we just supplied to the resampler this call. Used by
// ResampleOutAndCopy to call ResampleOut with the right input count. Updated by
// ReadThroughResampler before the ResampleOutAndCopy call.
private int lastInputFramesAvailable;
private int GetLastInputFramesAvailable() => lastInputFramesAvailable;
/// <summary>Synthesises the fade-out burst for the chosen artifact into the silence
/// region starting at <paramref name="startFrame"/>. Click variant leaves the buffer's
/// hard-zero in place.</summary>
+94
View File
@@ -39,6 +39,39 @@ internal sealed class StreamSession : IDisposable
/// <summary>For PCM streams: number of partially-assembled frames discarded mid-flight.</summary>
public long PcmFrameDiscardedPartials => pcmAssembler.DiscardedPartialCount;
// Post-decode discontinuity probe. Scans the float buffer right after Int24LEToFloat
// (PCM) or short-to-float (Opus) so we can compare to the sender's pre-encode probe and
// detect any wire-level or decode-level artefacts. Same buffer is then handed to the
// session playout, so the post-ring-read probe in SessionPlayout sees the exact same
// samples a moment later (after riding through the ring buffer).
private readonly AudioStepProbe postDecodeStepProbe = new();
public float TakeMaxPostDecodeStep() => postDecodeStepProbe.TakeMax();
// === Wire-level sequence tracking (Phase 5, 2026-05-14) ===
// Every audio packet carries a wire sequence number that monotonically increases per
// session (audioSequence in SenderLane). The Opus path uses this for FEC recovery. The
// PCM path historically ignored it entirely. Now we track it to detect:
// * MISSING packets — sequence > expected (gap > 1 frames)
// * REORDERED packets — sequence < expected (a packet arrived after a later one)
// * DUPLICATE packets — sequence == previous (same packet delivered twice)
// * IN-ORDER packets — sequence == expected
//
// Any of MISSING / REORDERED / DUPLICATE on a healthy LAN would point straight at a
// transport-level issue (NIC offload bug, switch buffer overflow, RSS hash collision
// causing packets to take different queues). MISSING on PCM = silent audio drop at
// the packet boundary = audible click. REORDERED = the receiver processes audio in
// the wrong order = audible click. DUPLICATE = same audio played twice in a row =
// audible click.
private uint? expectedNextWireSequence;
private long wireInOrderTotal;
private long wireMissedTotal; // sum of missing-packet counts (sequence > expected by N → +N)
private long wireReorderedTotal; // count of times a sequence < expected arrived
private long wireDuplicatedTotal; // count of times a sequence == previous arrived
public long WireInOrderCount => Interlocked.Read(ref wireInOrderTotal);
public long WireMissedCount => Interlocked.Read(ref wireMissedTotal);
public long WireReorderedCount => Interlocked.Read(ref wireReorderedTotal);
public long WireDuplicatedCount => Interlocked.Read(ref wireDuplicatedTotal);
public StreamSession(
IPEndPoint endpoint,
ushort streamId,
@@ -74,6 +107,7 @@ internal sealed class StreamSession : IDisposable
public bool HandleAudioPayload(uint sequence, ReadOnlySpan<byte> payload)
{
diagnostics.RecordPacketArrived();
TrackWireSequence(sequence);
return Codec switch
{
AudioTransportCodec.Pcm => HandlePcm(payload),
@@ -82,6 +116,60 @@ internal sealed class StreamSession : IDisposable
};
}
/// <summary>
/// Classify each arriving packet against the expected next wire sequence:
/// IN-ORDER (== expected), MISSING (> expected, diff sample frames), REORDERED (< expected
/// but within a small sane window), DUPLICATE (== previous). On the very first packet we
/// just seed expected and bail. On a wild jump (huge gap) we treat it as a re-sync rather
/// than logging hundreds of thousands of "missing" packets — this can happen if the sender
/// restarts mid-session or a router drops a long burst.
/// All counters use Interlocked because the readers are on the UI thread.
/// </summary>
private void TrackWireSequence(uint sequence)
{
if (expectedNextWireSequence is not uint expected)
{
expectedNextWireSequence = sequence + 1U;
Interlocked.Increment(ref wireInOrderTotal);
return;
}
if (sequence == expected)
{
Interlocked.Increment(ref wireInOrderTotal);
expectedNextWireSequence = sequence + 1U;
return;
}
// Treat the gap as an unsigned forward gap. If it's small-ish (< 1M packets, well over
// 10 minutes of audio at our packet rates) treat as forward MISSING. If it's huge,
// assume sequence ran backwards (reorder or restart).
uint forwardGap = sequence - expected;
if (forwardGap < 1_000_000U)
{
// Forward jump → forwardGap packets we never saw at the expected slot.
Interlocked.Add(ref wireMissedTotal, forwardGap);
expectedNextWireSequence = sequence + 1U;
}
else
{
// Backward jump. Distance behind expected:
uint backwardDistance = expected - sequence;
if (backwardDistance == 1U)
{
// sequence == previous (the one just before expected) → duplicate.
Interlocked.Increment(ref wireDuplicatedTotal);
}
else
{
// Out-of-order arrival from further back.
Interlocked.Increment(ref wireReorderedTotal);
}
// Do NOT roll expectedNextWireSequence backwards — that would re-count the
// already-missing packets when the originally-expected packet arrives.
}
}
public void Dispose() { /* IOpusDecoder has no Dispose; nothing else to free */ }
// === PCM ===
@@ -106,6 +194,12 @@ internal sealed class StreamSession : IDisposable
var floatSpan = MemoryMarshal.Cast<byte, float>(floatScratch);
PcmPack.Int24LEToFloat(assembled, floatSpan);
// Discontinuity probe — what does the audio look like right after we decode it?
// Compared to the sender's pre-encode probe, a higher value here would mean the
// wire codec roundtrip introduced steps. Same probe is also useful as a baseline
// for the post-ring-read probe in SessionPlayout.
postDecodeStepProbe.ScanStereo(floatSpan);
sessionPlayout.Write(floatScratch);
onFramesQueued(sampleCount / Format.Channels);
return true;