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
+35
View File
@@ -20,6 +20,41 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v3.4
Quick profile switch: a new global hotkey pops up a
list of all your profiles from anywhere even when
RemSound is in the system tray. Arrow to one, press
Enter, and it switches straight away. It marks the
profile you're on, plays a sound as the list opens
and again when you switch, and stays in the tray if
that's where it was. Unset by default; give it a key
under Options Keyboard shortcuts.
A safety net for the Realtek ASIO driver, which leaks
Windows resources and can make audio unstable: RemSound
now spots it on startup and offers, just once, to
disable it. Re-enable or disable it any time from the
Options menu.
Your screen reader now reads out global hotkeys when
you move over the menu item or control they're tied to,
so you can learn your shortcuts just by arrowing around
no need to open the shortcuts dialog.
Smoother long sessions on WASAPI: two machines' sound
clocks drift apart by a hair over time, which slowly
added delay. RemSound now corrects that continuously,
so a WASAPI link stays as tight after three hours as it
was at the start. (ASIO already kept itself in step.)
Also: a new "profile menu open" cue; the profile-switch
cue now plays the instant you switch, and no longer on
a fresh start; faster reaction when you plug or unplug a
device; and your settings now tuck into a "config"
folder, moved there automatically the first time you
run this version.
RemSound v3.3
Your audio is now encrypted, end to end, so you no
+63 -23
View File
@@ -1180,17 +1180,10 @@ public sealed class MainForm : Form
// blank-template case (no pendingProfile) we schedule it here.
if (pendingProfile is null) ScheduleBaselineCapture();
ApplyPendingProfileToControls();
// Profile-switch cue (2026-05-28): fires once after the profile finishes loading
// into the UI. Covers BOTH startup (user picks a profile from the picker) and
// mid-session switch (user picks a different profile from the menu — Program.Main
// re-creates MainForm under the new profile). Skipped when the user is on the
// blank template, where there's no profile to announce. Honours the per-profile
// EnableProfileSwitchCue flag set in Preferences.
if (pendingProfile is not null
&& settings.LoadEnableProfileSwitchCue())
{
profileSwitchSound?.Play();
}
// The profile-switch cue is played ON CLICK by the switch entry points (Recent menu,
// quick switch, File open) — NOT here. A fresh launch into the first profile must stay
// silent: hearing the switch cue and then the connect cue at startup is confusing
// (Ed, 2026-06-08). So the rebuilt form never replays it.
// Show/hide the Update vs Save-as buttons based on whether we're on a loaded
// profile or the blank template.
UpdateProfileButtonsVisibility();
@@ -1210,7 +1203,9 @@ public sealed class MainForm : Form
// some virtual-machine drivers throw a redraw exception). The pending-profile
// apply path above is unaffected — settings/devices/peers are already wired
// up before we hide the window.
if (AppConfig.Load().StartMinimised)
var minimizeThisInstance = AppConfig.Load().StartMinimised || startNextInstanceMinimized;
startNextInstanceMinimized = false;
if (minimizeThisInstance)
{
BeginInvoke(() => trayController.Minimize());
}
@@ -1260,14 +1255,14 @@ public sealed class MainForm : Form
});
}
// If the user opted in, show the About box once on the first launch after an update
// installed, so they see what's new. BeginInvoke so it opens after Shown completes.
BeginInvoke(new Action(MaybeShowWhatsNewAfterUpdate));
// Offer once to disable a handle-leaking Realtek ASIO driver if one is installed.
// BeginInvoke so the TaskDialog opens after Shown completes (and after the what's-new
// box, if that fired).
BeginInvoke(new Action(MaybeWarnAboutRealtekAsio));
// Post-launch notices, shown ONE AT A TIME via a single BeginInvoke that runs them in
// sequence — NOT one BeginInvoke per notice. Separate BeginInvokes NEST: the second
// dialog opens inside the first's modal message loop, the two stack on top of each
// other, and that nesting tangles their modal state so the boxes stop closing cleanly
// (the bug where the what's-new About box wouldn't close after the Realtek warning).
// RunStartupNotices shows each notice, waits for the user to close it, THEN shows the
// next — every one modal to the main window, never nested.
BeginInvoke(new Action(RunStartupNotices));
};
statusTimer.Start();
@@ -1292,6 +1287,21 @@ public sealed class MainForm : Form
deviceRefreshTimer.Start();
}
/// <summary>
/// Runs the post-launch notices one at a time — each ShowDialog blocks until the user closes it,
/// so the next never opens on top of a still-open one. Order: the what's-new About box (after an
/// update), then the Realtek-ASIO compatibility warning. The config-migration notice is handled
/// separately in Program.Main (shown before the profile picker), so it's already outside this
/// sequence and can't stack with these.
/// </summary>
private void RunStartupNotices()
{
if (IsDisposed) return;
MaybeShowWhatsNewAfterUpdate();
if (IsDisposed) return;
MaybeWarnAboutRealtekAsio();
}
/// <summary>If the user opted in (<see cref="AppConfig.ShowWhatsNewAfterUpdate"/>) and the
/// running version changed since the last launch we recorded, open the About box once so
/// they see what changed in the update just installed. Always records the current version
@@ -1573,7 +1583,9 @@ public sealed class MainForm : Form
ToolStripMenuItem? realtekToggle = null;
if (realtekAsioDriverNames.Count > 0)
{
realtekToggle = new ToolStripMenuItem { AccessibleName = "Toggle Realtek ASIO driver in RemSound" };
// AccessibleName is set (alongside Text) by UpdateRealtekAsioMenuItemText so the screen
// reader hears "Enable"/"Disable", matching what's shown — never "Toggle".
realtekToggle = new ToolStripMenuItem();
realtekToggle.Click += (_, _) => ToggleRealtekAsio();
realtekAsioToggleItem = realtekToggle;
UpdateRealtekAsioMenuItemText();
@@ -1689,6 +1701,12 @@ public sealed class MainForm : Form
}
}
// Carried across the close-and-relaunch profile switch (static so the NEXT MainForm instance,
// built by Program.Main after this one closes, can read it). A switch done while RemSound was
// in the tray should land back in the tray; internal so Program.Main can also skip the
// "loading audio driver" splash in that case.
internal static bool startNextInstanceMinimized;
/// <summary>Switch to the profile at <paramref name="path"/> via the same close-and-relaunch
/// flow OpenProfileFromPicker uses. The active profile gets pushed to the front of the
/// recents list by the next MainForm constructor when it sees the loaded path.</summary>
@@ -1709,8 +1727,20 @@ public sealed class MainForm : Form
}
var title = Path.GetFileNameWithoutExtension(path);
if (string.IsNullOrEmpty(title)) return;
// Play the switch cue NOW, on click, for immediate feedback — CuePlayer.Play is fire-and-
// forget on its own thread + device, so it survives the form rebuild that follows. Covers
// BOTH the Recent-profiles menu and the quick-switch popup (both route through here). The
// rebuilt form deliberately does NOT replay it, so startup into the first profile is silent.
if (settings.LoadEnableProfileSwitchCue())
{
profileSwitchSound?.Play();
}
NextProfilePathToLoad = path;
NextProfileTitleToLoad = title;
// If the switch was triggered while the window was minimised / in the tray (the quick-
// switch hotkey can fire from anywhere), keep the rebuilt instance in the tray too rather
// than popping the window up in front of whatever the user is doing.
startNextInstanceMinimized = !Visible || WindowState == FormWindowState.Minimized;
AppendLogEntry($"profile switch via Recent profiles: \"{title}\" from {path}");
Close();
}
@@ -1748,8 +1778,8 @@ public sealed class MainForm : Form
var chosen = QuickProfileSwitchDialog.Show(entries);
if (!string.IsNullOrEmpty(chosen))
{
// No-ops if it's already the current profile; otherwise reloads into the chosen one,
// which plays the profile-switch cue on the relaunch.
// SwitchToRecentProfile plays the switch cue on click, keeps the window in the
// tray if it was there, and no-ops if the chosen profile is already current.
SwitchToRecentProfile(chosen);
}
}
@@ -1897,6 +1927,11 @@ public sealed class MainForm : Form
var picked = Path.GetFileNameWithoutExtension(pickedPath);
if (string.IsNullOrEmpty(picked)) return;
if (string.Equals(pickedPath, currentProfilePath, StringComparison.OrdinalIgnoreCase)) return; // already loaded
// Switch cue on click (same rationale as SwitchToRecentProfile).
if (settings.LoadEnableProfileSwitchCue())
{
profileSwitchSound?.Play();
}
// Always pass the full path through. Program.cs deserialises directly from this
// path, so profiles saved outside the active BaseDirectory still load correctly.
NextProfilePathToLoad = pickedPath;
@@ -3856,9 +3891,14 @@ public sealed class MainForm : Form
{
if (realtekAsioToggleItem is null || realtekAsioDriverNames.Count == 0) return;
var anyDisabled = realtekAsioDriverNames.Exists(d => disabledAsioDrivers.Contains(d));
// Set BOTH the visible Text (with the mnemonic) and the AccessibleName (no mnemonic) to the
// same Enable/Disable wording, so the screen reader reads exactly what's shown — never "Toggle".
realtekAsioToggleItem.Text = anyDisabled
? "&Enable Realtek ASIO driver in RemSound"
: "&Disable Realtek ASIO driver in RemSound";
realtekAsioToggleItem.AccessibleName = anyDisabled
? "Enable Realtek ASIO driver in RemSound"
: "Disable Realtek ASIO driver in RemSound";
}
private void RemoveDisabledDriverFromPicker(string driver)
+5 -1
View File
@@ -186,7 +186,11 @@ internal static class Program
// MainForm construction. Show a "Loading audio driver" splash — on its own
// thread, so it stays painted while this thread is busy — so startup doesn't
// look hung. No-op for WASAPI-only profiles (construction is near-instant).
var splash = AsioLoadingSplash.StartIfNeeded(profile);
// Skip the loading splash when this rebuild is a quick-profile-switch that's
// staying in the tray — popping a splash up in front of the user's current app
// defeats the point of keeping RemSound minimised, and the switch cue already
// gave them feedback. Normal launches and visible switches still show it.
var splash = MainForm.startNextInstanceMinimized ? null : AsioLoadingSplash.StartIfNeeded(profile);
using var form = new MainForm(store, profile, title, nextPath);
// Expose the live window to the single-instance activation callback (a second
// copy choosing "switch to the running copy" signals us to surface this form).
+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}");
}
}
}