Release v4.7: keep trying your configured peer after a reboot (#15)

Removes the heartbeat "adopt a live address" feature (added v1.6). On a LAN with
more than one RemSound machine it could latch a receiver onto an unrelated sender
that happened to be pinging the audio port — then never recover to the real peer,
needing a manual restart (the singer's #15, with log). The feature guessed peer
identity from an untracked ping source with no way to verify it was the same peer;
on the stable VPN/LAN addresses RemSound is actually used with, it only ever caused
harm, since same-address reconnect already works via the continuous heartbeat.
Removed TryAdoptLiveHeartbeatAddress + IsPrivateLanAddress (MainForm) and
GetUntrackedPingSources + recentPingSources (HeartbeatService); the identity-safe
discovery-based following (by verified peer ID) stays. Manual troubleshooting entry
rewritten to match.

Also bundles the held changes since v4.6: status reads line-by-line with GB totals
and double-press-to-copy, CPU/memory in the status, the Install Scripts folder, and
the what's-new-after-failed-update fix. Version 4.7; About + RELEASE_NOTES updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-06-24 22:41:43 +01:00
co-authored by Claude Opus 4.8
parent 843be9b7fb
commit 64d5cb5a86
7 changed files with 58 additions and 101 deletions
+1 -1
View File
@@ -1243,7 +1243,7 @@ Most likely your ASIO latency target is below the network's real-world jitter le
### A peer rebooted or changed address and the sound didn't come back
RemSound follows a peer to its new address on its own. If the address you connected to stops responding but the same peer is still reaching you from a different address on your network — because they rebooted onto a new address, for example — RemSound re-points to the live address within a few seconds and the sound resumes without you doing anything. If it doesn't recover, the peer is genuinely unreachable (off, asleep, or a firewall is blocking the new path).
RemSound keeps trying the address you connected to, so when a peer comes back at the same address — the usual case after a reboot — the sound returns on its own within a few seconds, with nothing for you to do. If the peer comes back at a _different_ address (a new DHCP lease, say), reconnect to it: pick it again from **Discovered peers** , or enter its new address with **Add peer by IP** (Connectivity tab, Alt+A). If the sound still doesn't return, the peer is genuinely unreachable off, asleep, or a firewall is blocking the path.
### One side says “unreachable” even though sound is flowing
+25 -9
View File
@@ -1,29 +1,45 @@
# RemSound v4.6
# RemSound v4.7
Follow your Windows default audio device — for both the sound you receive and the mic you send.
Sound comes back on its own after a reboot — plus a few screen-reader and status improvements.
## Use the Windows default audio device
## Sound returns by itself after a reboot
Both WASAPI device lists now have a new entry at the very top — **"Use Windows default audio device, follows Windows changes"** — in the outputs for received sound, and in the inputs to send.
If RemSound started up before your network or VPN was ready, it could lock onto the wrong machine on your network and sit silent until you closed and reopened it. RemSound now keeps trying the peer you actually chose and connects the moment it answers — no manual reconnect needed.
Tick it and RemSound uses whatever Windows currently treats as the default (the default speakers for received sound, the default microphone for sending), and — the useful part — it **follows that default automatically**. Make a headset your default and RemSound switches to it on its own; unplug it and it moves back, all without touching the list.
(With thanks to the singer who reported this and captured the log that pinned it down.)
It works alongside the specific devices: tick the default entry *and* particular cards, and the sound plays out of (or is captured from) all of them at once. When you first turn it on, RemSound offers to untick the other devices so you use only the default — with a "Don't ask me this again" option. If you hide that question and later want it back, use **Options → Reset the default audio device prompt**.
## Hearing the status, improved
The **Speak the RemSound status information** hotkey (for screen-reader users) now:
- reads the status **a line at a time** — peers, ping, uptime, rates, totals — so each part lands as its own short phrase;
- shows big data totals in **gigabytes** once they pass a gigabyte;
- copies the whole status to the **clipboard** on a quick **double press**, so you can paste it to someone.
The status line now also shows **how much CPU and memory RemSound itself is using**.
## If RemSound won't start: Install Scripts
RemSound needs the .NET 10 desktop runtime. On the rare machine that doesn't have it, RemSound can't start to install it itself — so there's now an **Install Scripts** folder next to the program containing a one-click installer (a `.cmd` and a `.ps1`) that fetches and installs the runtime for you.
## Other fixes
- The **"what's new"** notes no longer appear a second time after an update that didn't finish.
## Compatibility
**v4.6 talks to v3.3 through v4.5 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.7 talks to v3.3 through v4.6 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
1. Download `RemSound-v4.6.zip` from this release.
1. Download `RemSound-v4.7.zip` from this release.
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.
4. Run `RemSound.exe`.
## Upgrading
**From v3.6 or newer:** Help → Check for updates installs v4.6 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.7 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.
+1 -1
View File
@@ -1292,7 +1292,7 @@ RemSound.exe --connect 192.168.1.42
</ul>
<h3>A peer rebooted or changed address and the sound didn't come back</h3>
<p>RemSound follows a peer to its new address on its own. If the address you connected to stops responding but the same peer is still reaching you from a different address on your network &mdash; because they rebooted onto a new address, for example &mdash; RemSound re-points to the live address within a few seconds and the sound resumes without you doing anything. If it doesn't recover, the peer is genuinely unreachable (off, asleep, or a firewall is blocking the new path).</p>
<p>RemSound keeps trying the address you connected to, so when a peer comes back at the same address &mdash; the usual case after a reboot &mdash; the sound returns on its own within a few seconds, with nothing for you to do. If the peer comes back at a <em>different</em> address (a new DHCP lease, say), reconnect to it: pick it again from <strong>Discovered peers</strong>, or enter its new address with <strong>Add peer by IP</strong> (Connectivity tab, Alt+A). If the sound still doesn't return, the peer is genuinely unreachable &mdash; off, asleep, or a firewall is blocking the path.</p>
<h3>One side says &ldquo;unreachable&rdquo; even though sound is flowing</h3>
<p>The health check-ins use the same channel as the audio, so if the sound gets through, the check-ins should too. If one side shows &ldquo;unreachable&rdquo; while the sound plays fine, make sure both computers are running the same version of RemSound &mdash; an older version on either end can speak a slightly different check-in language.</p>
+29
View File
@@ -20,6 +20,35 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v4.7
Sound now comes back on its own after a reboot.
If RemSound started before your network or VPN
was up, it could latch onto a different machine
on your network and stay silent until you closed
and reopened it. It now keeps trying the peer you
chose and connects the moment it answers no
manual reconnect. (Thank you to the singer who
reported this and sent the log that pinned it
down.)
A few improvements for screen-reader users. The
"Speak the RemSound status information" hotkey
now reads the status a line at a time, shows big
data totals in gigabytes, and a quick double
press copies the status to the clipboard so you
can share it. The status also now includes how
much CPU and memory RemSound itself is using.
If RemSound ever won't start because the .NET
runtime is missing, there's a new "Install
Scripts" folder next to the program with a
one-click installer for it.
And the "what's new" notes no longer pop up a
second time after an update that didn't finish.
RemSound v4.6
Follow your Windows default audio device.
+1 -57
View File
@@ -322,9 +322,6 @@ public sealed class MainForm : Form
private int continuousTuneIntervalSec = 5;
private long lastObservedUnderrunCount;
private HeartbeatService? heartbeatService;
// Last time TryAdoptLiveHeartbeatAddress re-pointed the sender at a peer's live address.
// Gives a fresh endpoint time to prove healthy before another swap can fire (anti-thrash).
private DateTime lastAddressAdoptionUtc = DateTime.MinValue;
// Tracks whether each peer was last considered CONNECTED, for the connect/disconnect cues.
// "Connected" now means audio is actually flowing OR the heartbeat is healthy — not the
// heartbeat alone (see DetectAndAnnouncePeerHealthTransitions). The bool, rather than the
@@ -1201,7 +1198,6 @@ public sealed class MainForm : Form
UpdateStatus();
SnapshotLogIfDue();
EnsureRequestedAudioRunning();
TryAdoptLiveHeartbeatAddress();
// Refresh the Connectivity tab's peer lists from the same 1 Hz tick — replaces
// the dialog's old 1.5 s dedicated refresh timer. Each Sync* helper short-circuits
// when its signature is unchanged so NVDA isn't spammed with re-announcements.
@@ -5277,7 +5273,7 @@ public sealed class MainForm : Form
// heard as heavy crackle (Tech Singer's Win7-over-VPN report, 2026-05-31). Keeping the
// endpoint pinned while it's still passing heartbeats stops the thrash. A genuine move
// (DHCP renewal, Wi-Fi switch) makes the old endpoint go unreachable first, at which
// point the guard lets the move through; TryAdoptLiveHeartbeatAddress backs it up.
// point the guard lets the move through.
foreach (var (id, oldEndpoint) in selectedPeerEndpoints.ToList())
{
if (!knownPeers.TryGetValue(id, out var peer)) continue;
@@ -5366,58 +5362,6 @@ public sealed class MainForm : Form
/// messier multi-peer case is left for the user to sort out by hand. Runs once per second
/// from the status ticker. 2026-05-15.
/// </summary>
private void TryAdoptLiveHeartbeatAddress()
{
if (heartbeatService is null || !connected) return;
// Cooldown: adoption re-points the sender; give a freshly-adopted endpoint time to
// prove healthy (or fail) before another swap can fire.
if (DateTime.UtcNow - lastAddressAdoptionUtc < TimeSpan.FromSeconds(10)) return;
var unreachable = heartbeatService.GetAllPeerHealth()
.Where(h => h.State == PeerHealthState.Unreachable)
.ToList();
if (unreachable.Count != 1) return; // 0 = nothing wrong; 2+ = ambiguous
var liveSources = heartbeatService.GetUntrackedPingSources();
if (liveSources.Count != 1) return; // 0 = no candidate; 2+ = ambiguous
var deadEp = unreachable[0].AudioEndpoint;
var liveAddr = liveSources[0];
if (liveAddr.Equals(deadEp.Address)) return; // same machine — nothing to adopt
if (!IsPrivateLanAddress(liveAddr)) return; // never adopt a public / relay source
// Find the selected-peer entry whose endpoint is the dead one.
var match = selectedPeerEndpoints
.FirstOrDefault(kv => kv.Value.Address.Equals(deadEp.Address) && kv.Value.Port == deadEp.Port);
if (match.Key == Guid.Empty) return;
// Reuse the dead endpoint's port — a peer that moved on the LAN keeps its audio port.
var newEp = new IPEndPoint(liveAddr, deadEp.Port);
selectedPeerEndpoints[match.Key] = newEp;
var label = selectedPeerLabels.GetValueOrDefault(match.Key, deadEp.Address.ToString());
logFile.Event($"heartbeat: adopted live address for \"{label}\": {deadEp} unreachable, peer is pinging from {newEp}");
lastAddressAdoptionUtc = DateTime.UtcNow;
// ApplyAudioRuntime re-points BOTH the audio sender (SetReceivers) and heartbeat
// tracking (SetTrackedPeers); PushAllowedReceiveSenders re-points the receiver
// allow-list; PushDiscoveryUnicastHints feeds the new address to discovery too.
ApplyAudioRuntime();
PushAllowedReceiveSenders();
PushDiscoveryUnicastHints();
}
/// <summary>True if <paramref name="addr"/> is an IPv4 RFC1918 private-range address
/// (10/8, 172.16/12, 192.168/16). Gates stale-address adoption so a relay's public
/// source address can never be mistaken for a peer that moved on the LAN.</summary>
private static bool IsPrivateLanAddress(IPAddress addr)
{
if (addr.AddressFamily != AddressFamily.InterNetwork) return false;
var b = addr.GetAddressBytes();
return b[0] == 10
|| (b[0] == 172 && b[1] >= 16 && b[1] <= 31)
|| (b[0] == 192 && b[1] == 168);
}
/// <summary>
/// Wipes the rolling max-gap window and pushes <see cref="lastSourceChangeUtc"/> forward,
/// so the next continuous auto-tune tick has nothing to react to. Called whenever a user
+1 -1
View File
@@ -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.6</Version>
<Version>4.7</Version>
</PropertyGroup>
<ItemGroup>
-32
View File
@@ -52,13 +52,6 @@ public sealed class HeartbeatService : IDisposable
private readonly object gate = new();
private readonly Dictionary<string, PeerState> peers = new(StringComparer.OrdinalIgnoreCase);
private readonly Stopwatch monotonic = Stopwatch.StartNew();
// Source addresses of recently-received heartbeat Pings, keyed by IP only (the ping's
// source port is the peer's ephemeral / NAT port, never the audio port — same reasoning
// as the pong IP-only match in HandlePacket). MainForm reads this via
// GetUntrackedPingSources to recover from a stale-address situation: a peer we can't
// reach at its resolved (e.g. stale-DNS) address but which is pinging us from its real
// address. See TryAdoptLiveHeartbeatAddress in MainForm. 2026-05-15.
private readonly Dictionary<IPAddress, DateTime> recentPingSources = new();
private CancellationTokenSource? cts;
private Task? sendTask;
@@ -166,28 +159,6 @@ public sealed class HeartbeatService : IDisposable
}
}
/// <summary>
/// Addresses that have sent us a heartbeat Ping within the last <see cref="UnreachableWindow"/>
/// and are NOT currently tracked peers. Used by MainForm's stale-address recovery: when a
/// tracked peer has gone Unreachable but some other address is actively pinging us, that
/// address is very likely the same peer at its real location (DNS handed us a stale IP).
/// Keyed by IP only — heartbeat ping source ports are ephemeral and carry no peer identity.
/// </summary>
public IReadOnlyList<IPAddress> GetUntrackedPingSources()
{
lock (gate)
{
var cutoff = DateTime.UtcNow - UnreachableWindow;
// Prune entries older than the window while we're holding the lock.
foreach (var stale in recentPingSources.Where(kv => kv.Value < cutoff).Select(kv => kv.Key).ToList())
{
recentPingSources.Remove(stale);
}
var trackedAddrs = peers.Values.Select(p => p.AudioEndpoint.Address).ToHashSet();
return recentPingSources.Keys.Where(a => !trackedAddrs.Contains(a)).ToList();
}
}
/// <summary>
/// One-line summary suitable for the snapshot log column or status label.
/// "no peers" / "192.168.1.5: 24ms" / "192.168.1.5: 24ms, 192.168.1.6: unreachable 7s".
@@ -316,9 +287,6 @@ public sealed class HeartbeatService : IDisposable
if (kind == HeartbeatKind.Ping)
{
onDiagnostic?.Invoke($"recv ping from={remote}");
// Record the source so MainForm can spot a peer that's pinging us from an
// address we're not tracking (stale-DNS / DHCP-move recovery).
lock (gate) { recentPingSources[remote.Address] = DateTime.UtcNow; }
// Echo the originator's timestamp back to them as a Pong. Reply target is the
// remote source endpoint (whatever socket the ping came in on, that's where to