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
+12 -12
View File
@@ -14,7 +14,7 @@ namespace RemSound.Sender;
///
/// Threading: the hot-path methods (<see cref="OnMixedSamples"/> and below) are called from
/// the capture engine's callback thread. Each lane has exactly one such thread feeding it.
/// Cross-thread state read from AudioSender (codec, mute, opusFrameMs, etc.) goes through
/// Cross-thread state read from AudioSender (codec, mute, opusFrameSamples, etc.) goes through
/// volatile fields on the owner. Configuration mutations (<see cref="ConfigureCodec"/>,
/// <see cref="OnPcmFrameSizeChanged"/>) come from the UI thread; they take the same
/// configGate that AudioSender does to serialise streamId rotation against in-flight
@@ -77,11 +77,11 @@ internal sealed class SenderLane
public ushort StreamId => streamId;
public SenderLane(AudioSender owner, int initialOpusFrameMs, int opusBitrate)
public SenderLane(AudioSender owner, int initialOpusFrameSamplesPerChannel, int opusBitrate)
{
this.owner = owner;
this.opusBitrate = opusBitrate;
opusEncoder = new OpusEncoderState(initialOpusFrameMs, opusBitrate);
opusEncoder = new OpusEncoderState(initialOpusFrameSamplesPerChannel, opusBitrate);
opusFrameStereoSamples = opusEncoder.FrameSizePerChannel * MixChannels;
streamId = NewStreamId();
}
@@ -121,11 +121,11 @@ internal sealed class SenderLane
/// format), rebuilds the Opus encoder if Opus is in play, and zeroes the accumulator so
/// any half-filled frame from the previous format doesn't leak into the new one.
/// </summary>
public void OnCodecChanged(AudioTransportCodec newCodec, int opusFrameMs)
public void OnCodecChanged(AudioTransportCodec newCodec, int opusFrameSamplesPerChannel)
{
if (newCodec == AudioTransportCodec.Opus)
{
opusEncoder = new OpusEncoderState(opusFrameMs, opusBitrate);
opusEncoder = new OpusEncoderState(opusFrameSamplesPerChannel, opusBitrate);
opusFrameStereoSamples = opusEncoder.FrameSizePerChannel * MixChannels;
}
streamId = NewStreamId();
@@ -287,19 +287,19 @@ internal sealed class SenderLane
if (DateTime.UtcNow - lastFormatPacketUtc < TimeSpan.FromMilliseconds(FormatResendIntervalMs)) return;
lastFormatPacketUtc = DateTime.UtcNow;
// PCM FrameDurationMilliseconds: receiver only uses this for buffer sizing and
// diagnostics, not for decode. Round 2.5 ms up to ≥1 to keep the wire field integer.
var pcmFrameMs = owner.PcmFrameSamplesPerChannel * 1000 / MixSampleRate;
if (pcmFrameMs < 1) pcmFrameMs = 1;
// Wire field FrameSamplesPerChannel: receiver uses this for buffer sizing and the
// decoder hot path. PCM passes through the sender's own sample-count directly; Opus
// uses whatever the encoder is configured for. v3.0 wire format — see
// AudioFormatInfo doc comment for the semantic-shift rationale.
var codec = owner.Codec;
var opusFrameMs = owner.OpusFrameMilliseconds;
var opusFrameSamples = owner.OpusFrameSamplesPerChannel;
// Pass this lane's current Route as the Lane field. In classic-mode senders this is
// Mixed and the receiver routes the session to its legacy mix bus; in BothIndependent
// senders this is WasapiLane or AsioLane and the receiver routes to the matching
// per-route IWaveProvider surface.
var format = codec == AudioTransportCodec.Opus
? new AudioFormatInfo(48000, 2, 16, 1, 4, 192_000, (int)AudioTransportCodec.Opus, opusFrameMs, route)
: new AudioFormatInfo(48000, 2, 24, 1, 6, 288_000, (int)AudioTransportCodec.Pcm, pcmFrameMs, route);
? new AudioFormatInfo(48000, 2, 16, 1, 4, 192_000, (int)AudioTransportCodec.Opus, opusFrameSamples, route)
: new AudioFormatInfo(48000, 2, 24, 1, 6, 288_000, (int)AudioTransportCodec.Pcm, owner.PcmFrameSamplesPerChannel, route);
// Allocate the extended (36-byte) format payload — see RemPacket.FormatPayloadExtendedSize
// for the backward-compat contract. Old receivers parse the first 32 bytes and ignore