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:
@@ -49,6 +49,14 @@ public sealed class AppConfig
|
||||
/// itself is the user-visible confirmation, so a follow-up popup is redundant.</summary>
|
||||
public bool SaveProfileConfirmationSuppressed { get; set; }
|
||||
|
||||
/// <summary>True if the user has ticked "do not show me this message again" on the
|
||||
/// "this profile is read-only, save was skipped" popup. Once ticked, Ctrl+S / File → Save
|
||||
/// on a read-only profile silently does nothing instead of explaining why — the user
|
||||
/// has acknowledged that they know it's a no-op. Machine-local (not per-profile) so the
|
||||
/// preference sticks across profile switches; the prompt itself is the same wording on
|
||||
/// any read-only profile so a single dismissal applies everywhere. 2026-05-22.</summary>
|
||||
public bool SaveOnReadOnlyMessageSuppressed { get; set; }
|
||||
|
||||
/// <summary>If true, RemSound minimises to the system tray immediately after the main
|
||||
/// window finishes loading. Lets the user "boot up the machine and have RemSound
|
||||
/// already running quietly". Default false.</summary>
|
||||
@@ -83,6 +91,22 @@ public sealed class AppConfig
|
||||
/// RemSound. Default false — the user gets a confirmation dialog before each install.</summary>
|
||||
public bool SilentlyInstallUpdates { get; set; }
|
||||
|
||||
/// <summary>If true (the default), RemSound runs an update check shortly after launch in
|
||||
/// addition to whatever <see cref="UpdateCheckFrequency"/> drives in the background. The
|
||||
/// startup check is what catches users who quit and re-open the app within the polling
|
||||
/// interval — without it they could miss an update for hours. Set to false to disable the
|
||||
/// startup check; the periodic timer (if set) still runs.</summary>
|
||||
public bool CheckForUpdatesOnStartup { get; set; } = true;
|
||||
|
||||
/// <summary>If true, RemSound tries to open the audio port (UDP 47830) on the local router
|
||||
/// using UPnP / NAT-PMP / PCP, so peers on the public internet can reach this machine
|
||||
/// without manual port forwarding. Default false — the toggle opt-in only, because some
|
||||
/// networks (corporate, hostile shared) shouldn't have apps poking the router. When
|
||||
/// successful, RemSound surfaces the external address in the Preferences dialog so the
|
||||
/// user knows what to give peers. Falls back gracefully when the router doesn't support
|
||||
/// UPnP — RemSound just doesn't open anything.</summary>
|
||||
public bool UpnpEnabled { get; set; }
|
||||
|
||||
/// <summary>UTC timestamp of the last successful update check. Used by the background
|
||||
/// update timer to space out polls across launches — if you set the frequency to
|
||||
/// "every 24 hours" and re-launch the app three times that day, it still hits the API
|
||||
|
||||
@@ -6,11 +6,29 @@ namespace RemSound.Core;
|
||||
/// between consecutive samples of the same channel; a typical "click" in real audio shows
|
||||
/// up as a step well above what naturally occurs in music or speech content.
|
||||
///
|
||||
/// Each probe holds the maximum step observed across all calls to <see cref="ScanStereo"/>
|
||||
/// since the last <see cref="TakeMax"/>. The diag log polls TakeMax once per second to
|
||||
/// emit the worst step at that pipeline stage. Comparing the max across stages — sender
|
||||
/// pre-encode, receiver post-decode, receiver post-ring-read, receiver post-resampler,
|
||||
/// final output — reveals which stage introduces the click.
|
||||
/// 2026-05-21: split the max into TWO independent counters so we can tell the difference
|
||||
/// between a sharp transient inside a buffer (natural-looking real audio content) and a
|
||||
/// discontinuity at the buffer / packet boundary (samples that are not adjacent in time —
|
||||
/// i.e. something we lost, duplicated, or mis-stitched in the pipeline). The plain
|
||||
/// <see cref="TakeMax"/> still returns the larger of the two for back-compat, but the
|
||||
/// <see cref="TakeMaxCrossBuffer"/> / <see cref="TakeMaxWithinBuffer"/> pair lets the diag
|
||||
/// logger emit both, so a click event in the log clearly says which side it's on:
|
||||
///
|
||||
/// * <c>stepXB</c> — first sample of a new delivered buffer vs the last sample of the
|
||||
/// previous one (i.e. the cross-buffer carry). A non-zero value here means our chain
|
||||
/// received samples that aren't contiguous with what came before — driver bug, library
|
||||
/// misalignment, or a sample drop/duplicate at a boundary. The buffer in question is
|
||||
/// whatever the caller passes to <see cref="ScanStereo"/> / <see cref="ScanInterleavedChannel"/>:
|
||||
/// ASIO/WASAPI capture callback in the raw probes, an OnMixedSamples callback in the
|
||||
/// pre-encode probe, a PCM packet in the post-decode probe, and so on.
|
||||
/// * <c>stepWB</c> — two samples that ARE adjacent in the same delivered buffer. A
|
||||
/// non-zero value here is just real audio content with a sharp edge; loud transients,
|
||||
/// percussion hits, the start of a syllable.
|
||||
///
|
||||
/// Each probe holds the maximum step observed since the last drain. The diag log polls the
|
||||
/// drains once per second to emit the worst step at that pipeline stage. Comparing the max
|
||||
/// across stages — sender pre-encode, receiver post-decode, receiver post-ring-read,
|
||||
/// receiver post-resampler, final output — reveals which stage introduces the click.
|
||||
///
|
||||
/// Thread model: writes are lock-free CAS-update of a long-encoded float bit pattern (so
|
||||
/// one probe can be hit from multiple threads if needed). Read-and-reset is also atomic.
|
||||
@@ -20,7 +38,11 @@ namespace RemSound.Core;
|
||||
/// </summary>
|
||||
public sealed class AudioStepProbe
|
||||
{
|
||||
private long maxStepBits;
|
||||
// Two separate maxes — see class comment. Both are float bits packed into a long so the
|
||||
// CAS-update loop can flip them atomically from any thread. Encoded as Int32 -> long
|
||||
// because BitConverter.SingleToInt32Bits is the cheapest float<->int32 bridge.
|
||||
private long maxCrossBufferStepBits;
|
||||
private long maxWithinBufferStepBits;
|
||||
// Remember the last sample on each channel so the next scan can compute the cross-buffer
|
||||
// step. Without this we'd miss any discontinuity at the buffer boundary (the most
|
||||
// suspicious place — that's where copies, format conversions and resampler hand-offs
|
||||
@@ -40,7 +62,8 @@ public sealed class AudioStepProbe
|
||||
if (!DiagnosticsGate.Enabled) return;
|
||||
if (interleavedFloats.IsEmpty) return;
|
||||
if (channelCount <= 0 || channelIndex < 0 || channelIndex >= channelCount) return;
|
||||
var max = ReadMax();
|
||||
var maxCross = ReadMax(ref maxCrossBufferStepBits);
|
||||
var maxWithin = ReadMax(ref maxWithinBufferStepBits);
|
||||
// Use lastL as the cross-buffer carry for single-channel scans. (We don't need a
|
||||
// separate "lastSingle" — every probe is consumed by exactly one caller at a time, so
|
||||
// reusing the field is fine. The cross-buffer step is what matters for buffer-boundary
|
||||
@@ -51,15 +74,35 @@ public sealed class AudioStepProbe
|
||||
for (var i = 0; i < samples; i++)
|
||||
{
|
||||
var s = interleavedFloats[i * channelCount + channelIndex];
|
||||
if (i == 0 && !seedFromPrev) prev = s;
|
||||
var step = s - prev;
|
||||
if (step < 0f) step = -step;
|
||||
if (step > max) max = step;
|
||||
if (i == 0)
|
||||
{
|
||||
// First sample of this buffer. If we have a carry from the previous scan,
|
||||
// compare it to that — that comparison IS the cross-buffer boundary step.
|
||||
// If we don't (first ever call), seed prev with this sample so the inner-loop
|
||||
// step calc starting at i=1 has a sensible reference.
|
||||
if (seedFromPrev)
|
||||
{
|
||||
var step = s - prev;
|
||||
if (step < 0f) step = -step;
|
||||
if (step > maxCross) maxCross = step;
|
||||
}
|
||||
else
|
||||
{
|
||||
prev = s;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var step = s - prev;
|
||||
if (step < 0f) step = -step;
|
||||
if (step > maxWithin) maxWithin = step;
|
||||
}
|
||||
prev = s;
|
||||
}
|
||||
lastL = prev;
|
||||
hasLast = true;
|
||||
WriteMaxIfGreater(max);
|
||||
WriteMaxIfGreater(ref maxCrossBufferStepBits, maxCross);
|
||||
WriteMaxIfGreater(ref maxWithinBufferStepBits, maxWithin);
|
||||
}
|
||||
|
||||
/// <summary>Scan an interleaved stereo float span and update the max step. Cheap; safe
|
||||
@@ -68,62 +111,97 @@ public sealed class AudioStepProbe
|
||||
{
|
||||
if (!DiagnosticsGate.Enabled) return;
|
||||
if (stereoFloats.IsEmpty) return;
|
||||
var max = ReadMax();
|
||||
var maxCross = ReadMax(ref maxCrossBufferStepBits);
|
||||
var maxWithin = ReadMax(ref maxWithinBufferStepBits);
|
||||
var prevL = lastL;
|
||||
var prevR = lastR;
|
||||
var seedFromPrev = hasLast;
|
||||
// Pair walk. For samples after the first, compare to the previous sample of the
|
||||
// same channel from THIS buffer. For the first pair, compare to the saved
|
||||
// last-sample-from-the-previous-buffer if available.
|
||||
// same channel from THIS buffer (within-buffer). For the first pair, compare to the
|
||||
// saved last-sample-from-the-previous-buffer if available (cross-buffer).
|
||||
for (var i = 0; i + 1 < stereoFloats.Length; i += 2)
|
||||
{
|
||||
var l = stereoFloats[i];
|
||||
var r = stereoFloats[i + 1];
|
||||
float stepL, stepR;
|
||||
if (i == 0)
|
||||
{
|
||||
if (!seedFromPrev) { prevL = l; prevR = r; }
|
||||
stepL = l - prevL;
|
||||
stepR = r - prevR;
|
||||
if (seedFromPrev)
|
||||
{
|
||||
var stepL = l - prevL;
|
||||
var stepR = r - prevR;
|
||||
var absL = stepL < 0f ? -stepL : stepL;
|
||||
var absR = stepR < 0f ? -stepR : stepR;
|
||||
if (absL > maxCross) maxCross = absL;
|
||||
if (absR > maxCross) maxCross = absR;
|
||||
}
|
||||
// If we don't have a carry, simply skip the comparison — the next iteration's
|
||||
// within-buffer step (i=2 vs i=0) will be the first real measurement.
|
||||
}
|
||||
else
|
||||
{
|
||||
stepL = l - stereoFloats[i - 2];
|
||||
stepR = r - stereoFloats[i - 1];
|
||||
var stepL = l - stereoFloats[i - 2];
|
||||
var stepR = r - stereoFloats[i - 1];
|
||||
var absL = stepL < 0f ? -stepL : stepL;
|
||||
var absR = stepR < 0f ? -stepR : stepR;
|
||||
if (absL > maxWithin) maxWithin = absL;
|
||||
if (absR > maxWithin) maxWithin = absR;
|
||||
}
|
||||
var absL = stepL < 0f ? -stepL : stepL;
|
||||
var absR = stepR < 0f ? -stepR : stepR;
|
||||
if (absL > max) max = absL;
|
||||
if (absR > max) max = absR;
|
||||
}
|
||||
// Save the last sample of this buffer for the next scan.
|
||||
var lastIdx = stereoFloats.Length - 2;
|
||||
lastL = stereoFloats[lastIdx];
|
||||
lastR = stereoFloats[lastIdx + 1];
|
||||
hasLast = true;
|
||||
WriteMaxIfGreater(max);
|
||||
WriteMaxIfGreater(ref maxCrossBufferStepBits, maxCross);
|
||||
WriteMaxIfGreater(ref maxWithinBufferStepBits, maxWithin);
|
||||
}
|
||||
|
||||
/// <summary>Atomic snapshot of the current max + reset to zero. Returns the value as
|
||||
/// a float in the same units as the input (i.e. 0.5 = a 0.5-magnitude single-sample
|
||||
/// step, which is a 6 dB jump and definitely audible).</summary>
|
||||
/// <summary>Atomic snapshot of the current maxes + reset to zero. Returns the larger of
|
||||
/// the cross-buffer and within-buffer maxes — preserves the pre-2026-05-21 semantics for
|
||||
/// callers that just want "the worst step we saw at this stage". For the cross/within
|
||||
/// split, use <see cref="TakeMaxCrossBuffer"/> + <see cref="TakeMaxWithinBuffer"/>
|
||||
/// instead; calling either of those drains its own counter independently of this one,
|
||||
/// so a caller wanting the split must NOT also call <c>TakeMax</c> in the same
|
||||
/// 1-second window.</summary>
|
||||
public float TakeMax()
|
||||
{
|
||||
var bits = Interlocked.Exchange(ref maxStepBits, 0);
|
||||
var c = TakeMaxCrossBuffer();
|
||||
var w = TakeMaxWithinBuffer();
|
||||
return c > w ? c : w;
|
||||
}
|
||||
|
||||
/// <summary>Atomic snapshot + reset of the cross-buffer max. A non-zero value here means
|
||||
/// the first sample of some delivered buffer did NOT continue smoothly from the last
|
||||
/// sample of the previous buffer — i.e. samples not adjacent in time. Strong signal that
|
||||
/// the pipeline lost, duplicated, or mis-stitched a buffer boundary.</summary>
|
||||
public float TakeMaxCrossBuffer()
|
||||
{
|
||||
var bits = Interlocked.Exchange(ref maxCrossBufferStepBits, 0);
|
||||
return BitConverter.Int32BitsToSingle((int)bits);
|
||||
}
|
||||
|
||||
private float ReadMax() => BitConverter.Int32BitsToSingle((int)Volatile.Read(ref maxStepBits));
|
||||
/// <summary>Atomic snapshot + reset of the within-buffer max. A non-zero value here just
|
||||
/// means there was a sharp transient INSIDE a delivered buffer — almost always real audio
|
||||
/// content (percussion hit, syllable onset, etc.). Useful as the "this is just music"
|
||||
/// baseline against which the cross-buffer max is interpreted.</summary>
|
||||
public float TakeMaxWithinBuffer()
|
||||
{
|
||||
var bits = Interlocked.Exchange(ref maxWithinBufferStepBits, 0);
|
||||
return BitConverter.Int32BitsToSingle((int)bits);
|
||||
}
|
||||
|
||||
private void WriteMaxIfGreater(float candidate)
|
||||
private static float ReadMax(ref long field) =>
|
||||
BitConverter.Int32BitsToSingle((int)Volatile.Read(ref field));
|
||||
|
||||
private static void WriteMaxIfGreater(ref long field, float candidate)
|
||||
{
|
||||
var candidateBits = (long)BitConverter.SingleToInt32Bits(candidate);
|
||||
long current;
|
||||
do
|
||||
{
|
||||
current = Volatile.Read(ref maxStepBits);
|
||||
current = Volatile.Read(ref field);
|
||||
var currentValue = BitConverter.Int32BitsToSingle((int)current);
|
||||
if (candidate <= currentValue) return;
|
||||
} while (Interlocked.CompareExchange(ref maxStepBits, candidateBits, current) != current);
|
||||
} while (Interlocked.CompareExchange(ref field, candidateBits, current) != current);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,19 @@ public sealed class Profile
|
||||
/// <summary>Display title and filename stem (sanitised). Required.</summary>
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
/// <summary>If true, this profile is loaded for use but the app never writes the user's
|
||||
/// in-session changes back to disk: Ctrl+S / File → Save politely refuses (with a "use
|
||||
/// Save As instead" message), and FormClosing skips its usual "save changes?" prompt
|
||||
/// entirely. Whatever the user fiddled with this session is kept in memory until the
|
||||
/// app closes and then discarded; the file on disk stays exactly as it was. Off by
|
||||
/// default. Toggled per-profile via File → Lock profile (read-only). Use case: a
|
||||
/// "default" profile you want to live in and toggle send/receive on without the close
|
||||
/// prompt blocking shutdown — important for users who can't reach the prompt because
|
||||
/// they're remote, or because the screen reader has crashed, or because the laptop is
|
||||
/// hibernating. The flag is the *only* property the lock-toggle writes back to disk;
|
||||
/// any other in-session edits stay session-only. 2026-05-22.</summary>
|
||||
public bool ReadOnly { get; set; }
|
||||
|
||||
// === Main form: send / receive ===
|
||||
public bool ReceiveAudioOn { get; set; }
|
||||
public bool SendAudioOn { get; set; }
|
||||
|
||||
@@ -67,6 +67,29 @@ public sealed class ProfileStore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns whether the profile with the given title has its ReadOnly flag set
|
||||
/// on disk, without doing a full <see cref="Load"/>. Used by the startup profile picker
|
||||
/// to label locked profiles in the list ("Title (read-only)") so the user knows what
|
||||
/// they're picking. Returns false on any error — the picker treats unreadable profiles
|
||||
/// as not-read-only, which is the safer default (the worst case is the user gets the
|
||||
/// normal save-prompt behaviour on close, which is what they're already used to).</summary>
|
||||
public bool IsProfileReadOnly(string title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title)) return false;
|
||||
var path = PathFor(title);
|
||||
if (!File.Exists(path)) return false;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
var profile = JsonSerializer.Deserialize<Profile>(json);
|
||||
return profile?.ReadOnly ?? false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Loads a profile by title. Returns null if the file is missing or
|
||||
/// unreadable. Malformed JSON is treated as "not found" rather than throwing —
|
||||
/// the caller can surface a diagnostic and fall back to a blank template.</summary>
|
||||
|
||||
Reference in New Issue
Block a user