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:
co-authored by
Claude Opus 4.8
parent
dd70613017
commit
946e6f4be4
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user