- 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:
co-authored by
Claude Opus 4.8
parent
5abbeefe29
commit
034a7de632
@@ -20,6 +20,36 @@ internal sealed class AboutDialog : Form
|
||||
/// updates" path.</summary>
|
||||
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
|
||||
|
||||
A couple of small fixes for screen-reader users. When
|
||||
|
||||
@@ -1265,13 +1265,21 @@ public sealed class MainForm : Form
|
||||
// missing (deferred so it lands after the window is fully up and can surface).
|
||||
BeginInvoke(CheckForMissingEnabledCues);
|
||||
|
||||
// Honour AppConfig.StartMinimised — drop straight to the tray after the
|
||||
// window finishes loading. Wrapped in 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 minimizeThisInstance = AppConfig.Load().StartMinimised || startNextInstanceMinimized;
|
||||
// "Start minimised" is a COLD-BOOT preference: drop straight to the tray when the app
|
||||
// first launches. It must NOT apply when the user deliberately creates a new profile or
|
||||
// switches profiles — those relaunch the window through the Program.cs loop. Those paths
|
||||
// already set startNextInstanceMinimized to "stay in the tray only if we were already
|
||||
// there", which is the right intent; but ORing in the global StartMinimised used to
|
||||
// override that and hide the window on every new-profile / switch, which read to the user
|
||||
// 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;
|
||||
if (minimizeThisInstance)
|
||||
{
|
||||
@@ -1783,6 +1791,12 @@ public sealed class MainForm : Form
|
||||
// "loading audio driver" splash in that case.
|
||||
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
|
||||
/// 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>
|
||||
@@ -5062,43 +5076,51 @@ public sealed class MainForm : Form
|
||||
/// </summary>
|
||||
private void PushDiscoveryUnicastHints()
|
||||
{
|
||||
var hints = new HashSet<IPAddress>();
|
||||
|
||||
// Manual peers store IPEndPoint already.
|
||||
foreach (var peer in manualPeers.Values)
|
||||
// Snapshot the UI-thread-owned inputs HERE, then resolve hostnames OFF the UI thread.
|
||||
//
|
||||
// The comment that used to live here claimed Dns.GetHostAddresses "returns near-instantly".
|
||||
// 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);
|
||||
}
|
||||
// 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 1–10 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 (IPAddress.TryParse(entry, out var direct))
|
||||
var hints = new HashSet<IPAddress>(seedAddresses);
|
||||
foreach (var entry in rememberedEntries)
|
||||
{
|
||||
hints.Add(direct);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
foreach (var addr in Dns.GetHostAddresses(entry))
|
||||
if (string.IsNullOrWhiteSpace(entry)) continue;
|
||||
if (IPAddress.TryParse(entry, out var direct))
|
||||
{
|
||||
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
hints.Add(direct);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
foreach (var addr in Dns.GetHostAddresses(entry))
|
||||
{
|
||||
hints.Add(addr);
|
||||
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
{
|
||||
hints.Add(addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Not resolvable right now — skip; re-pushed next time this method runs.
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Hostname not resolvable right now — skip silently. Will retry next time
|
||||
// PushDiscoveryUnicastHints is called.
|
||||
}
|
||||
}
|
||||
|
||||
discovery.SetUnicastPeerAddresses(hints);
|
||||
discovery.SetUnicastPeerAddresses(hints);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
tag_name on the latest GitHub release; bump it on every public release. The
|
||||
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
|
||||
is what the About dialog and the updater both read. -->
|
||||
<Version>4.1</Version>
|
||||
<Version>4.2</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<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 ~16–31 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 25–34 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.
|
||||
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 ~16–31 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 nextTickStopwatch = Stopwatch.GetTimestamp() + ticksPerFrame;
|
||||
|
||||
@@ -311,6 +311,11 @@ internal sealed class MixingEngine : ICaptureBackend
|
||||
{
|
||||
// Pro Audio scheduling category if available; falls back gracefully if MMCSS isn't accessible.
|
||||
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 nextTickStopwatch = Stopwatch.GetTimestamp() + ticksPerFrame;
|
||||
|
||||
Reference in New Issue
Block a user