Release v4.2: fix connect-freeze (#10) and new-profile minimize (#12); smoother WASAPI timing

- Connect no longer freezes: PushDiscoveryUnicastHints resolved remembered
  hostnames with synchronous Dns.GetHostAddresses on the UI thread, blocking the
  whole window for the DNS timeout on an unresolvable name. A screen-reader user
  experiences that as the entire machine locking up. Resolution now runs off the
  UI thread. Same class of bug as the v3.0.1 UPnP-on-the-UI-thread hang. (#10)

- New profile / profile switch no longer hides the window: OnShown ORed the
  global StartMinimised into every instance, so creating a new profile while
  Start minimised was on dropped the window to the tray and looked like a crash.
  StartMinimised now applies only to a genuine cold launch; relaunches honour the
  explicit per-instance flag. (#12)

- Smoother WASAPI audio: the receive producer loop and sender mix loop pace
  themselves with WaitHandle.WaitOne, bound by the system timer (~15.6ms default).
  Without a fine timer the 10ms feed slips to ~16-31ms and delivers audio in
  chunky bursts (the desktop-render chunkiness behind Andre's dropouts/lag). New
  SystemTimerResolution holds a 1ms timer whenever a stream is live, independent
  of the opt-in Priority mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-06-15 09:21:04 +01:00
co-authored by Claude Opus 4.8
parent 5abbeefe29
commit 034a7de632
7 changed files with 165 additions and 47 deletions
+13 -9
View File
@@ -1,29 +1,33 @@
# RemSound v4.1 # RemSound v4.2
A small follow-up to v4.0, with two fixes for screen-reader users. Three fixes — a freeze when connecting, a new-profile glitch that looked like a crash, and smoother audio on WASAPI.
## Starting in the tray is silent ## Connecting no longer risks a freeze
When RemSound starts straight into the notification area (**Start minimised**, or `--minimized`), it no longer plays the "minimise" cue. Booting into the tray isn't you choosing to hide the window, so it shouldn't sound the cue — only a genuine minimise does. If you had a peer saved by **name** (rather than a numeric address) and that name couldn't be looked up quickly — an offline peer, or a VPN name while the VPN was down — RemSound used to stall for a few seconds while it waited on the lookup. Because a screen reader waits on the program it's reading, that stall could feel like the whole computer locking up: speech going quiet, keys not responding, then everything coming back a moment later. The name lookup now runs in the background, so connecting — and reconnecting — stays responsive no matter what's in your saved-peers list.
## Bringing the window back announces itself ## Creating a new profile no longer hides the window
When you restore RemSound from the tray, it now lands focus on a control on whichever tab you'd left showing, so your screen reader announces the window instead of surfacing silently. Creating a new profile (**Ctrl+N**), or switching profiles, while you had **Start minimised** turned on would drop the freshly-loaded window straight to the tray — so the new profile looked like it had crashed. "Start minimised" is meant for when RemSound first launches, not for something you did on purpose. Now a new profile (or a switch) comes up the normal way, or stays in the tray only if that's where you already were.
## Smoother audio on WASAPI
RemSound now keeps Windows' timing fine while it's streaming. Without that, Windows can be lazy about waking the audio engine on time, so audio gets delivered in lumpy bursts instead of even steps — which on some machines showed up as breakup, or as latency that crept up over a long session. This fine timing was previously only switched on by **Priority mode**; now the audio path asks for it on its own whenever you're streaming, so you get the smoother delivery without needing that toggle on. (Priority mode's other, heavier options are unchanged and still optional.)
## Compatibility ## Compatibility
**v4.1 talks to v3.3 through v4.0 with no trouble** — the over-the-network format is unchanged, so you don't have to update both ends at once. (Everyone still needs **v3.3 or newer**, where end-to-end encryption came in.) **v4.2 talks to v3.3 through v4.1 with no trouble** — the over-the-network format is unchanged, so you don't have to update both ends at once. (Everyone still needs **v3.3 or newer**, where end-to-end encryption came in.)
## Install ## Install
1. Download `RemSound-v4.1.zip` from this release. 1. Download `RemSound-v4.2.zip` from this release.
2. Close RemSound. 2. Close RemSound.
3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it won't touch your settings, profiles, logs or recordings. 3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it won't touch your settings, profiles, logs or recordings.
4. Run `RemSound.exe`. 4. Run `RemSound.exe`.
## Upgrading ## Upgrading
**From v3.6 or newer:** Help → Check for updates installs v4.1 with the in-app updater — and if it can't finish, it puts your old version back exactly as it was. **From v3.6 or newer:** Help → Check for updates installs v4.2 with the in-app updater — and if it can't finish, it puts your old version back exactly as it was.
**From v1.9v3.5:** Check for updates works, but uses your current version's older updater for this one hop. If auto-update has been failing on your machine, install by hand using the steps above. **From v1.9v3.5:** Check for updates works, but uses your current version's older updater for this one hop. If auto-update has been failing on your machine, install by hand using the steps above.
+30
View File
@@ -20,6 +20,36 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary> /// updates" path.</summary>
private const string ReleaseNotes = private const string ReleaseNotes =
""" """
RemSound v4.2
Three fixes, one of them a real annoyance gone.
Connecting to someone no longer risks a freeze.
If you had a peer saved by name and that name
couldn't be looked up quickly, RemSound used to
stall for a few seconds while it waited and
because your screen reader waits on RemSound, the
whole computer could seem to lock up. That lookup
now happens out of the way, so connecting stays
responsive.
Creating a new profile (Ctrl+N), or switching
profiles, no longer drops the window to the tray
when you have "start minimised" turned on.
Starting minimised is meant for when RemSound
first launches, not for something you did on
purpose so a new profile now comes up the normal
way (or stays in the tray only if that's where you
already were), instead of vanishing and looking
like a crash.
And WASAPI audio is smoother. RemSound now keeps
Windows' timing fine while it's streaming, so audio
moves in even steps instead of arriving in clumps.
On machines where playback was breaking up or
running laggy on WASAPI, this should help and you
no longer need Priority mode switched on to get it.
RemSound v4.1 RemSound v4.1
A couple of small fixes for screen-reader users. When A couple of small fixes for screen-reader users. When
+43 -21
View File
@@ -1265,13 +1265,21 @@ public sealed class MainForm : Form
// missing (deferred so it lands after the window is fully up and can surface). // missing (deferred so it lands after the window is fully up and can surface).
BeginInvoke(CheckForMissingEnabledCues); BeginInvoke(CheckForMissingEnabledCues);
// Honour AppConfig.StartMinimised drop straight to the tray after the // "Start minimised" is a COLD-BOOT preference: drop straight to the tray when the app
// window finishes loading. Wrapped in BeginInvoke so the minimise happens // first launches. It must NOT apply when the user deliberately creates a new profile or
// *after* Shown completes (otherwise the form-show + form-hide collide and // switches profiles — those relaunch the window through the Program.cs loop. Those paths
// some virtual-machine drivers throw a redraw exception). The pending-profile // already set startNextInstanceMinimized to "stay in the tray only if we were already
// apply path above is unaffected — settings/devices/peers are already wired // there", which is the right intent; but ORing in the global StartMinimised used to
// up before we hide the window. // override that and hide the window on every new-profile / switch, which read to the user
var minimizeThisInstance = AppConfig.Load().StartMinimised || startNextInstanceMinimized; // as a crash (issue #12). So gate StartMinimised to the genuine first launch, and let
// relaunches honour only the explicit per-instance flag.
// BeginInvoke so the minimise happens *after* Shown completes (otherwise the form-show +
// form-hide collide and 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.
var coldStart = isFirstLaunch;
isFirstLaunch = false;
var minimizeThisInstance = startNextInstanceMinimized || (coldStart && AppConfig.Load().StartMinimised);
startNextInstanceMinimized = false; startNextInstanceMinimized = false;
if (minimizeThisInstance) if (minimizeThisInstance)
{ {
@@ -1783,6 +1791,12 @@ public sealed class MainForm : Form
// "loading audio driver" splash in that case. // "loading audio driver" splash in that case.
internal static bool startNextInstanceMinimized; internal static bool startNextInstanceMinimized;
// True until the first MainForm of the process has shown its window. Distinguishes a genuine
// cold launch (where the "Start minimised" preference applies) from an in-session new-profile or
// profile-switch relaunch (where it must NOT — those would otherwise hide the window and look
// like a crash, issue #12). Flipped false in the first OnShown.
private static bool isFirstLaunch = true;
/// <summary>Switch to the profile at <paramref name="path"/> via the same close-and-relaunch /// <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 /// 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> /// recents list by the next MainForm constructor when it sees the loaded path.</summary>
@@ -5062,18 +5076,27 @@ public sealed class MainForm : Form
/// </summary> /// </summary>
private void PushDiscoveryUnicastHints() private void PushDiscoveryUnicastHints()
{ {
var hints = new HashSet<IPAddress>(); // Snapshot the UI-thread-owned inputs HERE, then resolve hostnames OFF the UI thread.
//
// Manual peers store IPEndPoint already. // The comment that used to live here claimed Dns.GetHostAddresses "returns near-instantly".
foreach (var peer in manualPeers.Values) // It does for a parsed IP or an already-cached name — but for a remembered HOSTNAME that
// can't currently resolve (an offline peer, or a Tailscale/WireGuard name while the VPN is
// down) it BLOCKS for the system DNS timeout, seconds per entry. This method runs on the UI
// thread on every connect / disconnect / add-peer (it's how discovery learns its VPN unicast
// targets), so that block froze the whole window for a few seconds — which a screen-reader
// user experiences as the entire machine locking up (issue #10). Same class of bug as the
// v3.0.1 UPnP-on-the-UI-thread hang, in a newer feature.
//
// SetUnicastPeerAddresses just swaps a snapshot reference and fires an announcement, and is
// already called from the discovery receive loop's own thread, so it's safe to call from a
// background thread here. The hints are advisory and re-pushed frequently, so a slightly
// stale result from an overlapping resolution is harmless.
var seedAddresses = manualPeers.Values.Select(p => p.Address).ToList();
var rememberedEntries = settings.LoadRememberedPeers().ToList();
Task.Run(() =>
{ {
hints.Add(peer.Address); var hints = new HashSet<IPAddress>(seedAddresses);
} foreach (var entry in rememberedEntries)
// Remembered peers are stored as string entries (IP or hostname). Try to parse as IP;
// for hostnames try a quick non-blocking DNS lookup. We do this synchronously here
// because the remembered list is small (typically 110 entries) and Dns.GetHostAddresses
// returns near-instantly for either a parsed IP or a cached hostname.
foreach (var entry in settings.LoadRememberedPeers())
{ {
if (string.IsNullOrWhiteSpace(entry)) continue; if (string.IsNullOrWhiteSpace(entry)) continue;
if (IPAddress.TryParse(entry, out var direct)) if (IPAddress.TryParse(entry, out var direct))
@@ -5093,12 +5116,11 @@ public sealed class MainForm : Form
} }
catch catch
{ {
// Hostname not resolvable right now — skip silently. Will retry next time // Not resolvable right now — skip; re-pushed next time this method runs.
// PushDiscoveryUnicastHints is called.
} }
} }
discovery.SetUnicastPeerAddresses(hints); discovery.SetUnicastPeerAddresses(hints);
});
} }
+1 -1
View File
@@ -14,7 +14,7 @@
tag_name on the latest GitHub release; bump it on every public release. The tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. --> is what the About dialog and the updater both read. -->
<Version>4.1</Version> <Version>4.2</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -0,0 +1,50 @@
using System.Runtime.InteropServices;
namespace RemSound.Core;
/// <summary>
/// Holds the Windows system timer resolution at a fine grain (1 ms by default) for the lifetime of
/// this scope, via timeBeginPeriod / timeEndPeriod (winmm).
///
/// Why the audio engine needs this on its own, independent of the opt-in "Priority mode": a
/// real-time loop that paces itself with a Thread / WaitHandle wait is only as precise as the
/// system timer, whose DEFAULT resolution is ~15.6 ms. Windows wakes a waiting thread on the next
/// coarse tick, so a loop asking to run every 10 ms actually slips to ~16 ms — or ~31 ms when it
/// misses a tick. On a machine where nothing else happens to be holding a fine timer, that lumpy
/// wake-up made the receive producer feed audio to the output devices in chunky ~1631 ms bursts
/// instead of smooth 10 ms frames — the desktop-render chunkiness behind Andre's overnight dropouts
/// and lag (diagnosed 2026-06-15: render-callback gaps clustered at 2534 ms ≈ 2× a 15.6 ms tick).
/// Priority mode already requested 1 ms via the same call, but it's off by default and the audio
/// path must not depend on a user toggle to deliver smoothly; the producer / mix loops hold this
/// scope directly whenever a stream is live.
///
/// timeBeginPeriod is process-global and reference-counted by Windows, so multiple scopes (and a
/// concurrent Priority-mode request) nest safely — each Begin needs its own matching End. The cost
/// of a fine timer is a small increase in power draw, acceptable while audio is actively streaming.
/// Best-effort: a failed request just leaves the default resolution; nothing here throws into the
/// audio path.
/// </summary>
public sealed class SystemTimerResolution : IDisposable
{
private readonly uint periodMs;
private readonly bool acquired;
public SystemTimerResolution(uint periodMs = 1)
{
this.periodMs = periodMs;
try { acquired = timeBeginPeriod(periodMs) == 0; } // TIMERR_NOERROR == 0
catch { acquired = false; }
}
public void Dispose()
{
if (!acquired) return;
try { timeEndPeriod(periodMs); } catch { /* leave it — process exit reclaims it */ }
}
[DllImport("winmm.dll")]
private static extern uint timeBeginPeriod(uint uMilliseconds);
[DllImport("winmm.dll")]
private static extern uint timeEndPeriod(uint uMilliseconds);
}
@@ -239,6 +239,13 @@ internal sealed class MultiOutputPlayout : IRenderBackend
{ {
// Pro Audio MMCSS for the producer thread — it's the one feeding all WASAPI outputs. // Pro Audio MMCSS for the producer thread — it's the one feeding all WASAPI outputs.
using var threadBoost = new WindowsAudioThreadBoost("Pro Audio"); using var threadBoost = new WindowsAudioThreadBoost("Pro Audio");
// Hold the system timer at 1 ms for as long as we're producing. This loop paces itself with
// WaitHandle.WaitOne below, whose granularity is the system timer — coarse (~15.6 ms) by
// default, which slips the 10 ms feed to ~1631 ms and delivers audio to the outputs in
// chunky bursts (the receive-render chunkiness behind Andre's dropouts/lag). The MMCSS boost
// above raises priority but NOT timer granularity. Deliberately NOT gated behind the opt-in
// Priority mode — the audio path must run smoothly without a user toggle. 2026-06-15.
using var timerResolution = new SystemTimerResolution(1);
var ticksPerFrame = Stopwatch.Frequency * FrameMs / 1000; var ticksPerFrame = Stopwatch.Frequency * FrameMs / 1000;
var nextTickStopwatch = Stopwatch.GetTimestamp() + ticksPerFrame; var nextTickStopwatch = Stopwatch.GetTimestamp() + ticksPerFrame;
+5
View File
@@ -311,6 +311,11 @@ internal sealed class MixingEngine : ICaptureBackend
{ {
// Pro Audio scheduling category if available; falls back gracefully if MMCSS isn't accessible. // Pro Audio scheduling category if available; falls back gracefully if MMCSS isn't accessible.
using var threadBoost = new WindowsAudioThreadBoost("Pro Audio"); using var threadBoost = new WindowsAudioThreadBoost("Pro Audio");
// Hold the system timer at 1 ms while mixing. Like the receive producer loop, this paces
// itself with WaitHandle.WaitOne, whose granularity is the (coarse, ~15.6 ms by default)
// system timer; without a fine timer the mix tick slips and the capture path turns lumpy.
// Not gated behind Priority mode — see SystemTimerResolution. 2026-06-15.
using var timerResolution = new SystemTimerResolution(1);
var ticksPerFrame = Stopwatch.Frequency * MixTickMs / 1000; var ticksPerFrame = Stopwatch.Frequency * MixTickMs / 1000;
var nextTickStopwatch = Stopwatch.GetTimestamp() + ticksPerFrame; var nextTickStopwatch = Stopwatch.GetTimestamp() + ticksPerFrame;