v3.4 refinements: WASAPI drift correction, startup-dialog sequencing, quick-switch polish, docs

Builds on the v3.4 freeze (dd70613) with the fixes and tuning from live testing,
plus the v3.4 documentation pass.

Audio (receiver):
- Per-device WASAPI drift correction in MultiOutputPlayout. A pull-side resampler
  (mirroring SessionPlayout's proven corrector) holds each output device's buffer at
  a fixed low depth, cancelling the slow clock drift that made WASAPI peers "lag
  apart" over long sessions. Feed-forward clock-ratio measurement plus a gentle
  depth-restoring term; the first measurement window is discarded because WASAPI
  start-up priming poisons it. ASIO already self-corrected; this brings WASAPI level.
- Output device buffer requested at 5 ms (WASAPI clamps it up to the device's minimum
  period, ~10 ms) instead of 15 ms, since the corrector keeps it fed — a free saving.

UI / accessibility (MainForm, Program):
- Startup notices (what's-new About box, Realtek warning) now run one at a time via a
  single sequence instead of separate BeginInvokes, so they no longer stack into
  nested modals that couldn't be closed. The loading splash is skipped for a
  tray-bound quick switch.
- Quick profile switch keeps RemSound in the tray if it was there, and plays the
  switch cue immediately on click.
- Profile-switch cue now plays on click for every switch path (recent menu, quick
  switch, File > Open) and no longer on a fresh start into the first profile. It was
  also previously dead on the rebuilt form (pendingProfile was nulled first).
- Realtek ASIO toggle's accessible name now reads "Enable"/"Disable" to match the
  visible text, instead of "Toggle" (screen reader read the wrong word).

Docs (plain English):
- RELEASE_NOTES.md: v3.4 entry.
- About dialog: v3.4 "what's new".
- readme.html (the canonical bundled manual): quick switch, the hotkey read-outs, the
  new profile-menu-open cue, Realtek auto-detect/disable, and the config-folder path.
- MANUAL.md regenerated from readme.html via sync-manual.py so the two stay in sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-06-08 15:31:32 +01:00
co-authored by Claude Opus 4.8
parent dd70613017
commit 946e6f4be4
7 changed files with 434 additions and 77 deletions
+251 -20
View File
@@ -1,5 +1,7 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using NAudio.CoreAudioApi;
using NAudio.Dsp;
using NAudio.Wave;
using RemSound.Core;
@@ -15,7 +17,20 @@ namespace RemSound.Receiver;
/// - With multiple WasapiOuts, each render thread would call Read independently and only one
/// output would get each frame; the others would starve.
/// - The producer loop runs at the canonical 48 kHz / 10 ms cadence, decoupled from any one
/// device's clock. Per-device drift is absorbed by the BufferedWaveProvider's headroom.
/// device's clock.
///
/// Per-device drift correction (2026-06-08): the producer feeds every device's buffer at the
/// receiver's Stopwatch clock, but each WASAPI device drains at its OWN crystal. Left alone, the
/// two clocks diverge by tens-to-hundreds of ppm and the buffer slowly fills (device slower) or
/// empties (device faster) — Andre's "desktop and laptop drift apart over time on WASAPI". Each
/// device is wrapped in a <see cref="DriftResamplingProvider"/> that sits on the PULL side
/// (between the buffer and the WasapiOut) and continuously stretches/compresses by the measured
/// clock ratio, holding the buffer level steady. This mirrors the proven per-sender corrector in
/// <see cref="SessionPlayout"/> exactly: resampler on the consumer side, output-driven, slow
/// rate-ratio measurement over a multi-second window. Crucially the producer still writes the RAW
/// mix into each buffer (no resampling on the input), so the buffer keeps its natural cushion —
/// the resampler only adjusts the rate at which the device drains it. An earlier attempt that
/// resampled on the PRODUCER side drained the cushion to zero and crackled; this does not.
///
/// Output-device set is diffed on <see cref="SetOutputDevices"/>: existing devices stay live,
/// removed ones are stopped, new ones are opened. No audio interruption to the unchanged ones.
@@ -38,14 +53,15 @@ 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>();
// Snapshot of the current per-device drift providers, 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 the list on every 10 ms tick. Item 7 of RemSoundefficiency.md — eliminates
// ~100 array allocations per second on the receive side. Empty array is a singleton via
// Array.Empty<T>(), so the default value costs nothing. We feed each provider (which writes
// the raw mix into its buffer AND counts the bytes for the drift measurement) rather than
// touching the BufferedWaveProvider directly.
private volatile DriftResamplingProvider[] outputSnapshot = Array.Empty<DriftResamplingProvider>();
private CancellationTokenSource? cts;
private Task? produceTask;
@@ -106,7 +122,7 @@ internal sealed class MultiOutputPlayout : IRenderBackend
// 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>();
outputSnapshot = Array.Empty<DriftResamplingProvider>();
}
}
@@ -151,10 +167,21 @@ internal sealed class MultiOutputPlayout : IRenderBackend
DiscardOnBufferOverflow = true,
BufferDuration = TimeSpan.FromMilliseconds(OutputBufferMs),
};
wasapi = new WasapiOut(device, AudioClientShareMode.Shared, useEventSync: true, latency: 15);
wasapi.Init(buffer);
// Per-device drift corrector sits between the buffer and the WasapiOut. The
// device pulls THROUGH it; it pulls the matching amount from `buffer` and
// resamples by the measured clock ratio. The producer writes the raw mix
// into `buffer` via drift.Feed (which also counts bytes for the measurement).
var drift = new DriftResamplingProvider(buffer, name,
msg => onDiagnostic?.Invoke($"drift: {msg}"));
// Output device buffer. Request 5 ms; shared-mode WASAPI clamps it up to the
// device's minimum period (~10 ms on tested hardware, 2026-06-08) — but ~10 ms
// is still ~5 ms tighter than the old 15 ms, a free latency win. The per-device
// drift corrector keeps this buffer fed from its held ~12 ms cushion, so the
// smaller endpoint reserve doesn't risk underruns even on a flaky onboard device.
wasapi = new WasapiOut(device, AudioClientShareMode.Shared, useEventSync: true, latency: 5);
wasapi.Init(drift);
wasapi.Play();
outputs[id] = new OutputEntry { Device = device, Output = wasapi, Buffer = buffer, Name = name };
outputs[id] = new OutputEntry { Device = device, Output = wasapi, Buffer = buffer, Drift = drift, Name = name };
onDiagnostic?.Invoke($"output added: \"{name}\"");
}
catch (Exception ex)
@@ -169,9 +196,9 @@ internal sealed class MultiOutputPlayout : IRenderBackend
// 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();
outputSnapshot = outputs.Count == 0
? Array.Empty<DriftResamplingProvider>()
: outputs.Values.Select(o => o.Drift).ToArray();
}
}
@@ -219,16 +246,19 @@ internal sealed class MultiOutputPlayout : IRenderBackend
// 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;
var targets = outputSnapshot;
if (targets.Length == 0) continue;
var produced = source.Read(frameScratch, 0, FrameBytes);
if (produced <= 0) continue;
foreach (var buffer in targets)
// Feed the RAW mix into each device's buffer (no resampling here — the buffer
// keeps its natural cushion). Feed also counts the bytes for that device's
// drift measurement. Per-output failure shouldn't kill the loop.
foreach (var drift in targets)
{
try { buffer.AddSamples(frameScratch, 0, produced); }
catch { /* per-output failure shouldn't kill the loop */ }
try { drift.Feed(frameScratch, 0, produced); }
catch { /* ignore */ }
}
}
catch (OperationCanceledException) { break; }
@@ -245,6 +275,207 @@ internal sealed class MultiOutputPlayout : IRenderBackend
public required MMDevice Device { get; init; }
public required WasapiOut Output { get; init; }
public required BufferedWaveProvider Buffer { get; init; }
public required DriftResamplingProvider Drift { get; init; }
public required string Name { get; init; }
}
/// <summary>
/// Sits between a device's <see cref="BufferedWaveProvider"/> and its <see cref="WasapiOut"/>.
/// The WasapiOut render thread pulls from THIS (not the buffer directly); we pull the matching
/// amount of audio from the buffer and run it through a continuous fixed-ratio resampler whose
/// rate is the measured (producer-feed ÷ device-drain) clock ratio over a multi-second window.
/// That holds the buffer level steady against per-device clock drift.
///
/// This deliberately mirrors <see cref="SessionPlayout"/>'s Phase-4 corrector: output-driven
/// (ResamplePrepare asks how many input frames it needs for N output frames), linear-interp
/// mode, 10 s window, 70/30 smoothing, ±5 % sanity clamp. Resampling on the PULL side keeps
/// the producer's raw feed (and therefore the buffer's natural cushion) intact — the earlier
/// producer-side attempt resampled the input and starved the cushion to zero.
///
/// Threading: <see cref="Feed"/> runs on the producer thread; <see cref="Read"/> (and the
/// resampler + rate update) run on the WasapiOut render thread. The only shared state is
/// <c>producerFedBytes</c>, guarded by Interlocked. The resampler itself is touched only on
/// the render thread.
/// </summary>
private sealed class DriftResamplingProvider : IWaveProvider
{
// Mirror SessionPlayout's proven constants for the clock-ratio feed-forward.
private const double DriftMeasurementWindowSec = 10.0;
private const double DriftRatioSmoothingNew = 0.30;
private const double DriftRatioMin = 0.95;
private const double DriftRatioMax = 1.05;
// Feedback: steer the buffer toward a known, low cushion. Pure rate-matching holds the
// buffer wherever the start-up transient left it (~50 ms and climbing in the field) —
// SessionPlayout gets away without this because it ARMS at target and has a click-trim
// net; the device buffer has neither, so it needs an explicit depth term. The correction
// is tiny (≤0.3 % rate, spread over seconds): a sub-audible pitch nudge, never a click.
private const int TargetDepthMs = 12; // operating depth we hold the buffer at
private const double DepthCorrectionSec = 15.0; // correct a depth error over ~this long
private const double MaxDepthBias = 0.003; // cap the depth nudge at 0.3 % rate
private readonly BufferedWaveProvider buffer;
private readonly string name;
private readonly Action<string>? onDiagnostic;
private readonly WdlResampler resampler;
public WaveFormat WaveFormat => buffer.WaveFormat;
// Drift measurement. producerFedBytes is incremented by the producer thread in Feed;
// deviceDrainedBytes is incremented by the render thread (us) in Read. Their ratio over
// a multi-second window is the receiver-feed-rate ÷ device-drain-rate — exactly the
// ratio the resampler needs to hold the buffer level.
private long producerFedBytes; // Interlocked (producer writes, render reads)
private long deviceDrainedBytes; // render thread only
private long windowStartTicks;
private long windowStartFed;
private long windowStartDrained;
private double smoothedRatio = 1.0;
private bool tracking;
private bool firstWindowDone;
// Scratch — grown lazily, persists across calls so the hot path doesn't allocate.
private byte[] inputBytes = new byte[16384];
private float[] outputScratch = new float[4096];
public DriftResamplingProvider(BufferedWaveProvider buffer, string name, Action<string>? onDiagnostic)
{
this.buffer = buffer;
this.name = name;
this.onDiagnostic = onDiagnostic;
// interp=true, filtercnt=0 → linear-interpolation mode, plenty for sub-1000-ppm
// corrections. SetFeedMode(false) = output-driven. Start at 1:1; the first window's
// measurement replaces it.
resampler = new WdlResampler();
resampler.SetMode(interp: true, filtercnt: 0, sinc: false);
resampler.SetFeedMode(false);
resampler.SetRates(MixSampleRate, MixSampleRate);
}
/// <summary>Producer thread: write the raw mix into the device buffer and count the
/// bytes for the drift measurement. No resampling here — the buffer keeps its cushion.</summary>
public void Feed(byte[] data, int offset, int count)
{
buffer.AddSamples(data, offset, count);
Interlocked.Add(ref producerFedBytes, count);
}
/// <summary>Render thread: the WasapiOut pulls <paramref name="count"/> bytes. We pull
/// the resampler's required input from the buffer and produce exactly that many output
/// bytes (zero-padding any shortfall, so WASAPI never sees a short read).</summary>
public int Read(byte[] outBuffer, int offset, int count)
{
var outFrames = count / MixBytesPerFrame;
if (outFrames <= 0) return 0;
UpdateRatioIfDue();
var inputFramesNeeded = resampler.ResamplePrepare(outFrames, MixChannels, out var inBuf, out var inBufOff);
if (inputFramesNeeded > 0)
{
var inputFloats = inputFramesNeeded * MixChannels;
var inputByteCount = inputFloats * sizeof(float);
if (inputBytes.Length < inputByteCount) inputBytes = new byte[inputByteCount];
// BufferedWaveProvider has ReadFully=true, so this returns inputByteCount,
// zero-padding if the buffer is momentarily short (a brief underrun produces
// silence, not a glitch — same as the pre-corrector behaviour).
var got = buffer.Read(inputBytes, 0, inputByteCount);
var gotFloats = got / sizeof(float);
MemoryMarshal.Cast<byte, float>(inputBytes.AsSpan(0, got)).CopyTo(inBuf.AsSpan(inBufOff, gotFloats));
if (gotFloats < inputFloats) inBuf.AsSpan(inBufOff + gotFloats, inputFloats - gotFloats).Clear();
}
var outFloats = outFrames * MixChannels;
if (outputScratch.Length < outFloats) outputScratch = new float[outFloats];
var produced = resampler.ResampleOut(outputScratch, 0, inputFramesNeeded, outFrames, MixChannels);
var producedFloats = produced * MixChannels;
var outSpan = MemoryMarshal.Cast<byte, float>(outBuffer.AsSpan(offset, count));
var copy = Math.Min(producedFloats, outSpan.Length);
for (var i = 0; i < copy; i++)
{
var v = outputScratch[i];
// Safety clamp — a NaN or out-of-range sample would otherwise be a loud pop.
if (v > 1f) v = 1f;
else if (v < -1f) v = -1f;
else if (float.IsNaN(v)) v = 0f;
outSpan[i] = v;
}
// Zero-fill any shortfall (startup priming of the resampler delay line, mainly).
if (copy < outSpan.Length) outSpan.Slice(copy).Clear();
// Count the device's consumption (always the full requested amount — WASAPI took
// `count` bytes regardless of how much real audio backed it). Matches SessionPlayout.
deviceDrainedBytes += count;
return count;
}
/// <summary>If the measurement window has elapsed, recompute the clock ratio (feed-forward)
/// and the depth-restoring nudge (feedback), combine them, and push to the resampler.
/// Render thread only.</summary>
private void UpdateRatioIfDue()
{
var now = Stopwatch.GetTimestamp();
if (windowStartTicks == 0)
{
windowStartTicks = now;
windowStartFed = Interlocked.Read(ref producerFedBytes);
windowStartDrained = deviceDrainedBytes;
return;
}
var elapsedSec = (now - windowStartTicks) / (double)Stopwatch.Frequency;
if (elapsedSec < DriftMeasurementWindowSec) return;
var fedNow = Interlocked.Read(ref producerFedBytes);
var fedDelta = fedNow - windowStartFed;
var drainedDelta = deviceDrainedBytes - windowStartDrained;
// Re-anchor immediately so every early return below still advances the window cleanly.
windowStartTicks = now;
windowStartFed = fedNow;
windowStartDrained = deviceDrainedBytes;
// Discard the FIRST completed window. WASAPI primes its endpoint buffer at start-up,
// which inflates the device-drain count for that window and reads as a large bogus
// ppm (1199 ppm observed) that shoves the buffer off target. Start measuring from
// the next window, by which point start-up is done.
if (!firstWindowDone) { firstWindowDone = true; return; }
if (fedDelta <= 0 || drainedDelta <= 0) return;
// Feed-forward: the true crystal ratio (system feed ÷ device drain). Independent of
// the resampler rate we apply, so it's a clean measurement of the clock difference.
// Cancels steady-state drift so the feedback term doesn't have to fight a constant.
var measured = (double)fedDelta / drainedDelta;
if (measured >= DriftRatioMin && measured <= DriftRatioMax)
{
smoothedRatio = tracking
? (1.0 - DriftRatioSmoothingNew) * smoothedRatio + DriftRatioSmoothingNew * measured
: measured;
tracking = true;
}
if (!tracking) return; // nothing valid measured yet — don't touch the rate.
// Feedback: nudge the buffer toward TargetDepthMs. depthError > 0 = too deep → bias
// the rate UP so the resampler pulls more per output and drains the buffer faster;
// < 0 = too shallow → bias down. Clamped + spread over DepthCorrectionSec so it's a
// gentle, inaudible pitch trim, not a per-sample discontinuity.
var depthFrames = buffer.BufferedBytes / MixBytesPerFrame;
var targetFrames = TargetDepthMs * MixSampleRate / 1000;
var depthError = depthFrames - targetFrames;
var depthCorrection = Math.Clamp(
depthError / (DepthCorrectionSec * MixSampleRate),
-MaxDepthBias, MaxDepthBias);
var appliedRatio = smoothedRatio + depthCorrection;
resampler.SetRates(MixSampleRate * appliedRatio, MixSampleRate);
var depthMs = buffer.BufferedBytes / MixBytesPerFrame * 1000 / MixSampleRate;
var clockPpm = (smoothedRatio - 1.0) * 1_000_000.0;
var corrPpm = depthCorrection * 1_000_000.0;
onDiagnostic?.Invoke(
$"\"{name}\": clock={smoothedRatio:F6} ({clockPpm:+0;-0}ppm) depthMs={depthMs} " +
$"target={TargetDepthMs} corr={corrPpm:+0;-0}ppm applied={appliedRatio:F6}");
}
}
}