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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user