Bump to v2.1.0: UPnP, read-only profile lock, sleep/hibernate audio fix

Headline features:
* Automatic router port opening (UPnP / NAT-PMP / PCP). Opt-in via
  Preferences; surfaces external address + carrier-grade NAT detection.
* Lock profile (read-only). New File-menu tick that makes a profile
  load-only — session changes don't persist, no save prompt on close.
  Unblocks unattended shutdowns (NVDA gone, remote dropped, hibernate)
  where the existing save prompt could deadlock.
* Check for updates on startup (default on) + brief countdown notice
  before silent updates install, so a launch-time update doesn't make
  the app silently vanish.
* "Cue sounds" -> "Audio cue sounds" label clarification.

Bug fixes:
* No sound after the computer wakes from sleep. PowerResumeHandler
  rebuilds the audio backend automatically on resume; brief
  "Reconnecting to audio driver" splash during the rebuild.
* Receiver audio silent after waking from hibernate. RefreshAudioDeviceLists
  now treats a transient ASIO probe failure (returns -1/-1 because the
  driver is mid-teardown / mid-reinit) as "retry next tick" instead of
  clearing the user's tick selection.

Diagnostic-only changes (gated on the existing Enable-logs checkbox,
zero cost when off):
* AudioStepProbe split into cross-buffer vs within-buffer maxes so log
  inspection can tell a real-content sharp transient apart from a
  pipeline-boundary glitch. Plumbed through every probe owner.
* New rxNetGapMs + gc0/gc1/gc2 delta columns in the diag log to split
  receive-side jitter into network-layer vs managed-runtime causes.

Files touched: RELEASE_NOTES.md + readme.html + 24 source files across
RemSound.Core / RemSound.Sender / RemSound.Receiver / RemSound.App.
Three new app files: PowerResumeHandler, RouterPortMapper,
UpdateInstallNoticeDialog.

Wire format and audio pipeline unchanged from v1.5 onward — v1.5
through v2.1 peers interoperate.
This commit is contained in:
Ednunp
2026-05-22 23:06:07 +01:00
parent 00cb4deef1
commit 79b28b6c02
27 changed files with 1980 additions and 125 deletions
+45 -1
View File
@@ -241,6 +241,13 @@ public sealed class AudioReceiver : IDisposable
/// contributions. Resets on read.</summary>
public int TakeMaxOnPacketMs() => listener.TakeMaxOnPacketMs();
/// <summary>Worst inter-packet arrival gap (ms) at the user-space UDP socket since the
/// last call. Resets on read. Compared with the sender's per-callback gap on the other
/// machine, this localises a stall: if the sender's send-callback gap is small but this
/// is large, the OS/network between sender and receiver delayed delivery (NIC IRQ
/// servicing, scheduler not waking our receive thread, kernel batching). 2026-05-21.</summary>
public int TakeMaxInterPacketGapMs() => listener.TakeMaxInterPacketGapMs();
/// <summary>Worst FanOutSource cache-occupancy seen since the last call, expressed in
/// milliseconds at the mix rate (48 kHz stereo float). With one active render lane the
/// FanOut should drain to ~0 after every consumer Read; sustained non-zero means a
@@ -299,7 +306,8 @@ public sealed class AudioReceiver : IDisposable
/// <summary>Take the worst post-decode single-sample step magnitude across all active
/// stream sessions since the last call, resetting each session's probe. Used by the
/// diag log to pinpoint where in the pipeline audio discontinuities are being
/// introduced.</summary>
/// introduced. Returns max-of-(cross, within); for the split values use the XB/WB
/// methods below and do NOT also call this in the same drain window.</summary>
public float TakeMaxPostDecodeStep()
{
lock (sessionsLock)
@@ -314,6 +322,38 @@ public sealed class AudioReceiver : IDisposable
}
}
/// <summary>Cross-buffer (packet-boundary) max post-decode step across all sessions.
/// Drains each session's cross-buffer counter. 2026-05-21 addition for the click hunt.</summary>
public float TakeMaxPostDecodeStepCrossBuffer()
{
lock (sessionsLock)
{
var max = 0f;
foreach (var s in sessions.Values)
{
var v = s.TakeMaxPostDecodeStepCrossBuffer();
if (v > max) max = v;
}
return max;
}
}
/// <summary>Within-buffer (in-packet content) max post-decode step across all sessions.
/// Drains each session's within-buffer counter. 2026-05-21 addition for the click hunt.</summary>
public float TakeMaxPostDecodeStepWithinBuffer()
{
lock (sessionsLock)
{
var max = 0f;
foreach (var s in sessions.Values)
{
var v = s.TakeMaxPostDecodeStepWithinBuffer();
if (v > max) max = v;
}
return max;
}
}
public long PcmFrameDiscardedPartials
{
get
@@ -395,8 +435,12 @@ public sealed class AudioReceiver : IDisposable
/// <summary>Take the worst single-sample step out of the ring buffer (after decode +
/// SessionPlayout.Write, before resampler) since the last call.</summary>
public float TakeMaxPostRingReadStep() => playoutEngine.TakeMaxPostRingReadStep();
public float TakeMaxPostRingReadStepCrossBuffer() => playoutEngine.TakeMaxPostRingReadStepCrossBuffer();
public float TakeMaxPostRingReadStepWithinBuffer() => playoutEngine.TakeMaxPostRingReadStepWithinBuffer();
/// <summary>Take the worst single-sample step out of the resampler since the last call.</summary>
public float TakeMaxPostResamplerStep() => playoutEngine.TakeMaxPostResamplerStep();
public float TakeMaxPostResamplerStepCrossBuffer() => playoutEngine.TakeMaxPostResamplerStepCrossBuffer();
public float TakeMaxPostResamplerStepWithinBuffer() => playoutEngine.TakeMaxPostResamplerStepWithinBuffer();
/// <summary>RingbufferOverflowDropBytes = AggregateDrops minus the deliberate trim+drain
/// causes. Whatever's left was the producer-side overflow (Write into a full buffer) or
/// the catastrophic-cap trim from NoteFramesQueued. Both indicate "we genuinely couldn't
+33 -1
View File
@@ -31,6 +31,18 @@ internal sealed class NetworkListener : IDisposable
public int TakeMaxOnPacketMs() =>
(int)(Interlocked.Exchange(ref maxOnPacketTicks, 0) * 1000 / Stopwatch.Frequency);
// Inter-packet arrival gap at the user-space socket. Measures the elapsed time between
// consecutive `ReceiveFrom` returns. This is the diagnostic that splits "the sender
// stalled" from "the network jittered" from "the OS sat on packets before delivering
// them to our process" — by comparing this with the sender's per-callback gap on the
// other machine, we can tell which side introduced the arrival gap that triggered a
// concealment-fire / underrun. The probe is gated on DiagnosticsGate.Enabled exactly
// like the OnPacket timer above; pays nothing when logs are off. 2026-05-21.
private long maxInterPacketGapTicks;
private long lastReceiveTicks;
public int TakeMaxInterPacketGapMs() =>
(int)(Interlocked.Exchange(ref maxInterPacketGapTicks, 0) * 1000 / Stopwatch.Frequency);
public NetworkListener(Action<byte[], int, IPEndPoint> onPacket, Action<string> onDiagnostic)
{
this.onPacket = onPacket;
@@ -72,6 +84,11 @@ internal sealed class NetworkListener : IDisposable
thread = null;
cts?.Dispose();
cts = null;
// Reset the inter-packet timestamp so a Restart doesn't measure the long pause
// between the previous session's last packet and the new session's first as a
// spurious huge gap.
Interlocked.Exchange(ref lastReceiveTicks, 0);
Interlocked.Exchange(ref maxInterPacketGapTicks, 0);
}
public void Dispose() => Stop();
@@ -104,7 +121,22 @@ internal sealed class NetworkListener : IDisposable
// per packet for a number nobody is going to log.
if (RemSound.Core.DiagnosticsGate.Enabled)
{
var dispatchStart = Stopwatch.GetTimestamp();
var nowTicks = Stopwatch.GetTimestamp();
// Inter-packet arrival gap. First packet seeds lastReceiveTicks without
// recording a gap (no previous to compare to). Subsequent packets compute
// elapsed-since-previous-ReceiveFrom-returned. The window includes our
// onPacket processing, but that's typically sub-millisecond — so a spike
// here points at the OS/network layer below us, not at our dispatch work.
// Our work shows up separately in maxOnPacketTicks.
var prevReceiveTicks = Interlocked.Exchange(ref lastReceiveTicks, nowTicks);
if (prevReceiveTicks != 0)
{
var gap = nowTicks - prevReceiveTicks;
long curGap;
do { curGap = Volatile.Read(ref maxInterPacketGapTicks); }
while (gap > curGap && Interlocked.CompareExchange(ref maxInterPacketGapTicks, gap, curGap) != curGap);
}
var dispatchStart = nowTicks;
onPacket(buffer, received, remote);
var elapsed = Stopwatch.GetTimestamp() - dispatchStart;
long current;
+57 -2
View File
@@ -495,7 +495,9 @@ internal sealed class PlayoutEngine : IWaveProvider
/// <summary>Worst single-sample step seen out of the ring buffer since the last call.
/// Compared against the sender's pre-encode probe and the session's post-resampler
/// probe, this locates where in the pipeline an audio discontinuity was introduced.
/// Takes the max across all sessions and resets each.</summary>
/// Takes the max across all sessions and resets each. Returns max-of-(cross, within);
/// for the split, use the XB/WB variants and do NOT also call this in the same drain
/// window.</summary>
public float TakeMaxPostRingReadStep()
{
var snap = sessionsSnapshot;
@@ -508,9 +510,36 @@ internal sealed class PlayoutEngine : IWaveProvider
return max;
}
/// <summary>Cross-buffer (read-boundary) max post-ring-read step across all sessions.</summary>
public float TakeMaxPostRingReadStepCrossBuffer()
{
var snap = sessionsSnapshot;
var max = 0f;
foreach (var s in snap)
{
var v = s.TakeMaxPostRingReadStepCrossBuffer();
if (v > max) max = v;
}
return max;
}
/// <summary>Within-buffer max post-ring-read step across all sessions.</summary>
public float TakeMaxPostRingReadStepWithinBuffer()
{
var snap = sessionsSnapshot;
var max = 0f;
foreach (var s in snap)
{
var v = s.TakeMaxPostRingReadStepWithinBuffer();
if (v > max) max = v;
}
return max;
}
/// <summary>Worst single-sample step in the resampler output since the last call.
/// Significantly larger than <see cref="TakeMaxPostRingReadStep"/> would point the
/// finger at the resampler integration.</summary>
/// finger at the resampler integration. Returns max-of-(cross, within); use the XB/WB
/// variants for the split.</summary>
public float TakeMaxPostResamplerStep()
{
var snap = sessionsSnapshot;
@@ -523,6 +552,32 @@ internal sealed class PlayoutEngine : IWaveProvider
return max;
}
/// <summary>Cross-buffer max post-resampler step across all sessions.</summary>
public float TakeMaxPostResamplerStepCrossBuffer()
{
var snap = sessionsSnapshot;
var max = 0f;
foreach (var s in snap)
{
var v = s.TakeMaxPostResamplerStepCrossBuffer();
if (v > max) max = v;
}
return max;
}
/// <summary>Within-buffer max post-resampler step across all sessions.</summary>
public float TakeMaxPostResamplerStepWithinBuffer()
{
var snap = sessionsSnapshot;
var max = 0f;
foreach (var s in snap)
{
var v = s.TakeMaxPostResamplerStepWithinBuffer();
if (v > max) max = v;
}
return max;
}
// === WASAPI render thread ===
/// <summary>
+4
View File
@@ -226,6 +226,10 @@ internal sealed class SessionPlayout : IDisposable
private readonly AudioStepProbe postResamplerStepProbe = new();
public float TakeMaxPostRingReadStep() => postRingReadStepProbe.TakeMax();
public float TakeMaxPostResamplerStep() => postResamplerStepProbe.TakeMax();
public float TakeMaxPostRingReadStepCrossBuffer() => postRingReadStepProbe.TakeMaxCrossBuffer();
public float TakeMaxPostRingReadStepWithinBuffer() => postRingReadStepProbe.TakeMaxWithinBuffer();
public float TakeMaxPostResamplerStepCrossBuffer() => postResamplerStepProbe.TakeMaxCrossBuffer();
public float TakeMaxPostResamplerStepWithinBuffer() => postResamplerStepProbe.TakeMaxWithinBuffer();
// Concealment vs partial-read counters split from the legacy "Underruns" — that one
// increments on ANY short read at the AudioRingBuffer level (whether framesRead==0
+2
View File
@@ -53,6 +53,8 @@ internal sealed class StreamSession : IDisposable
// samples a moment later (after riding through the ring buffer).
private readonly AudioStepProbe postDecodeStepProbe = new();
public float TakeMaxPostDecodeStep() => postDecodeStepProbe.TakeMax();
public float TakeMaxPostDecodeStepCrossBuffer() => postDecodeStepProbe.TakeMaxCrossBuffer();
public float TakeMaxPostDecodeStepWithinBuffer() => postDecodeStepProbe.TakeMaxWithinBuffer();
// === Wire-level sequence tracking (Phase 5, 2026-05-14) ===
// Every audio packet carries a wire sequence number that monotonically increases per