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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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