Bump to v3.0.0: Opus live latency, codec rename, profile-resume after update, save-through-lock

* New "Opus, live latency" codec mode: 2.5 ms frames (120 samples/ch at 48 kHz)
  via the float-input encode path. End-to-end codec delay drops to ~5 ms (vs
  ~12.5 ms at standard 10 ms Opus). Test on LAN: 400 pps/lane, zero missed /
  reordered / duplicate packets, ~15 ms one-way saved end-to-end.

* Wire-format change: AudioFormatInfo.FrameDurationMilliseconds renamed to
  FrameSamplesPerChannel (int sample-count at announced sample rate). Removes
  the lossy 48000*ms/1000 conversion that couldn't represent 2.5 ms. v3 <-> v3
  exact; v3 <-> v2 still passes audio (Opus decoder is self-describing from
  packet TOC) but v2 side over-sizes its buffer wildly. v2.x profiles auto-
  migrate via <120 sentinel rule in RemSoundSettingsStore (anything below 120
  is treated as legacy ms and multiplied by 48). Profile JSON key kept as
  OpusFrameMilliseconds via [JsonPropertyName] so old profile files still load.

* Codec dropdown rebuilt with use-case names: "PCM 48K 24 bit - uncompressed",
  "Opus, broadcast quality - loss tolerant", "Opus, live latency - for jamming
  and monitoring". Middle 10 ms option retired; saved 480-sample profiles
  collapse to broadcast quality (safer-side default).

* Profile auto-resume after self-update: RemSoundUpdater writes a one-shot
  _resume-after-update.txt sentinel containing the active profile title before
  exit; Program.Main reads + deletes it on next start and silently loads that
  profile, skipping the picker. Helper batch's robocopy /XF excludes the
  sentinel and the failure-branch cleans it up if the install aborts. Falls
  through to normal startup behaviour (StartWithProfileTitle or picker) if the
  sentinel is missing, empty, or names a profile that no longer exists.

* Read-only profile saves now go through on explicit Ctrl+S / File -> Save
  with a one-time TaskDialog warning ("Save anyway" / "Cancel" + Do-not-show-
  again). Lock continues to suppress the automatic unsaved-changes prompt on
  close / profile switch (its main job). AppConfig.SaveOnReadOnlyMessageSuppressed
  renamed to SaveOnReadOnlyWarningSuppressed; v2.x suppression flag is silently
  discarded since the behaviour changed and the user needs to see the warning
  once on each machine.

* Manual (readme.html) updated: codec table rewritten with the three new
  choices and corrected bandwidth figures, send-rate description updated, new
  sections "The same profile picks up automatically after an update" and
  "Saving on purpose while a profile is locked".

* Subsumes the never-separately-released v2.2 work: native Opus encoder
  (~97% less per-second memory churn on Opus send path via Concentus.Native),
  efficiency tidy-ups (item 4 ASIO probe rate, item 6 WaitHandle, item 7
  snapshot cache, items 14/16 heartbeat + discovery), legacy cleanup
  (items 30/34/35/36: KeepAlive infrastructure, drift drop/repeat/accumulator
  fields, fan-out cache stat). New diagnostic columns cpu/memMB/wsMB/
  allocKBps/captureMs/sendMs/recvMs/renderMs gated on Enable-logs.

About dialog updated with v3.0 block at top; v2.2 block retained for the
subsumed work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-05-23 23:31:55 +01:00
co-authored by Claude Opus 4.7
parent 6d6d6897e4
commit af0e7c3fff
19 changed files with 559 additions and 166 deletions
+23 -15
View File
@@ -87,15 +87,18 @@ public sealed class AudioSender : IDisposable
private readonly Stopwatch uptime = new();
private volatile AudioTransportCodec codec = AudioTransportCodec.Pcm;
private volatile int opusFrameMs = 10; // only meaningful when codec == Opus
// Opus frame size in samples-per-channel at 48 kHz. Default 480 = 10 ms. Renamed from
// opusFrameMs 2026-05-23 (v3.0 wire-format refactor) so the 2.5 ms RESTRICTED_LOWDELAY
// mode (= 120 samples) can be expressed cleanly. Only meaningful when codec == Opus.
private volatile int opusFrameSamples = 480;
private volatile bool muted;
private IPEndPoint[] receivers = [];
private long packetsSent;
private long bytesSent;
// Internal accessor so SenderLane can read tight-latency without exposing the field
// publicly. Codec, OpusFrameMilliseconds and IsMuted are already exposed publicly below
// and re-used directly by the lane.
// publicly. Codec, OpusFrameSamplesPerChannel and IsMuted are already exposed publicly
// below and re-used directly by the lane.
internal bool IsTightLatencyEnabled => tightLatencyEnabled;
// Hot-path timing instrumentation. Both lanes update these on every emit; the SNAP
@@ -253,8 +256,8 @@ public sealed class AudioSender : IDisposable
// construction time), the lines are silently dropped, which is acceptable for a
// success/no-op outcome. On failure the socket keeps working without prioritisation.
networkPriority.TryAttach(udp.Client, msg => diagnostic?.Invoke(msg));
defaultLane = new SenderLane(this, opusFrameMs, OpusBitrateLan);
asioLane = new SenderLane(this, opusFrameMs, OpusBitrateLan);
defaultLane = new SenderLane(this, opusFrameSamples, OpusBitrateLan);
asioLane = new SenderLane(this, opusFrameSamples, OpusBitrateLan);
// WasapiOnly at startup — no ASIO needed yet, so persistentAsio stays null.
currentAudioMode = AudioMode.WasapiOnly;
currentAsioDriverName = null;
@@ -388,9 +391,10 @@ public sealed class AudioSender : IDisposable
public bool IsAsioBackend => engine is CompositeCaptureBackend;
/// <summary>Updates the PCM frame size based on the user's "Send rate" choice. For Opus,
/// frame size is set via <see cref="ConfigureCodec"/>'s opusFrameMs parameter (the App
/// halves it when SendRate is Tight). On a frame-size change, resets the accumulator and
/// stream id so the receiver opens a fresh session at the new format.</summary>
/// frame size is set via <see cref="ConfigureCodec"/>'s opusFrameSamplesPerChannel
/// parameter (the App halves it when SendRate is Tight). On a frame-size change, resets
/// the accumulator and stream id so the receiver opens a fresh session at the new format.
/// </summary>
public void SetSendRate(SendRate rate)
{
lock (configGate)
@@ -444,7 +448,10 @@ public sealed class AudioSender : IDisposable
public string? CaptureFormatDescription => engine.FirstCaptureFormatDescription;
public string? LastCaptureError => engine.FirstCaptureLastError;
public AudioTransportCodec Codec => codec;
public int OpusFrameMilliseconds => opusFrameMs;
/// <summary>Opus frame size in samples-per-channel at 48 kHz. 120 = 2.5 ms, 240 = 5 ms,
/// 480 = 10 ms, 960 = 20 ms. Renamed from OpusFrameMilliseconds in the v3.0 wire-format
/// refactor (see <see cref="AudioFormatInfo"/>).</summary>
public int OpusFrameSamplesPerChannel => opusFrameSamples;
/// <summary>
/// Atomically set the codec and (for Opus) the frame size. Resets stream identity and the
@@ -452,22 +459,23 @@ public sealed class AudioSender : IDisposable
/// parameters are taken together because changing only one would briefly send malformed
/// frames at the encoder boundary.
/// </summary>
public void ConfigureCodec(AudioTransportCodec newCodec, int newOpusFrameMs = 10)
public void ConfigureCodec(AudioTransportCodec newCodec, int newOpusFrameSamplesPerChannel = 480)
{
var clampedFrameMs = Math.Clamp(newOpusFrameMs, 5, 60);
if (codec == newCodec && (newCodec != AudioTransportCodec.Opus || opusFrameMs == clampedFrameMs))
// Clamp to the legal Opus range at 48 kHz: 120 (2.5 ms) to 2880 (60 ms).
var clampedSamples = Math.Clamp(newOpusFrameSamplesPerChannel, 120, 2880);
if (codec == newCodec && (newCodec != AudioTransportCodec.Opus || opusFrameSamples == clampedSamples))
{
return;
}
lock (configGate)
{
codec = newCodec;
opusFrameMs = clampedFrameMs;
opusFrameSamples = clampedSamples;
// Rebuild both lanes' encoders + rotate their streamIds. Same idle-lane rationale
// as SetSendRate — harmless when the asio lane has no producer; necessary when it
// does (BothIndependent).
defaultLane.OnCodecChanged(newCodec, clampedFrameMs);
asioLane.OnCodecChanged(newCodec, clampedFrameMs);
defaultLane.OnCodecChanged(newCodec, clampedSamples);
asioLane.OnCodecChanged(newCodec, clampedSamples);
}
}