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