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
+35 -7
View File
@@ -199,16 +199,41 @@ public sealed class RemSoundSettingsStore
Save(s);
}
public int LoadOpusFrameMilliseconds(int defaultValue = 10) =>
Try(() => Load()?.OpusFrameMilliseconds is int v && (v == 10 || v == 20) ? v : (int?)null) ?? defaultValue;
/// <summary>Loads the Opus frame size in samples-per-channel at 48 kHz. Default 480 = 10 ms.
/// Migration path: profiles written by v2.x stored milliseconds (5/10/20) in the same JSON
/// field; values &lt; 120 are interpreted as legacy ms and converted (×48 → samples). The
/// ranges don't overlap (max legitimate ms = 60, min legitimate samples = 120), so the
/// disambiguation is unambiguous.</summary>
public int LoadOpusFrameSamplesPerChannel(int defaultValue = 480) =>
Try(() => Load()?.OpusFrameSamplesPerChannel is int v ? NormalizeOpusFrameSamples(v) : (int?)null) ?? defaultValue;
public void SaveOpusFrameMilliseconds(int value)
/// <summary>Saves the Opus frame size in samples-per-channel at 48 kHz. Accepts the four
/// standard-Opus RESTRICTED_LOWDELAY values (120/240/480/960); anything else collapses to
/// 480 (= 10 ms), the safe default.</summary>
public void SaveOpusFrameSamplesPerChannel(int value)
{
var s = Load() ?? new Settings();
s.OpusFrameMilliseconds = value == 20 ? 20 : 10;
s.OpusFrameSamplesPerChannel = value switch
{
960 => 960, // 20 ms
480 => 480, // 10 ms
240 => 240, // 5 ms — not exposed in the dropdown but reachable via Tight rate
120 => 120, // 2.5 ms experimental
_ => 480,
};
Save(s);
}
/// <summary>Disambiguates a persisted Opus frame-size value between the legacy v2.x
/// integer-milliseconds storage (5/10/20) and the v3.x samples-per-channel storage
/// (120/240/480/960). Values &lt; 120 are legacy ms; ≥ 120 are samples. See
/// <see cref="LoadOpusFrameSamplesPerChannel"/>.</summary>
private static int NormalizeOpusFrameSamples(int persisted)
{
if (persisted < 120) return persisted * 48; // legacy ms → samples at 48 kHz
return persisted;
}
public bool LoadContinuousAutoTuneEnabled(bool defaultValue = false) =>
Try(() => Load()?.ContinuousAutoTuneEnabled) ?? defaultValue;
@@ -486,7 +511,7 @@ public sealed class RemSoundSettingsStore
AcceptRemoteVolumeCommands = profile.AcceptRemoteVolumeCommands,
MaxLatencyMs = profile.MaxLatencyMs,
Codec = profile.Codec,
OpusFrameMilliseconds = profile.OpusFrameMilliseconds,
OpusFrameSamplesPerChannel = profile.OpusFrameSamplesPerChannel,
ContinuousAutoTuneEnabled = profile.ContinuousAutoTuneEnabled,
ContinuousAutoTuneIntervalSec = profile.ContinuousAutoTuneIntervalSec,
MaxLatencyMsAsio = profile.MaxLatencyMsAsio,
@@ -533,7 +558,7 @@ public sealed class RemSoundSettingsStore
if (s.AcceptRemoteVolumeCommands is bool arvc) profile.AcceptRemoteVolumeCommands = arvc;
if (s.MaxLatencyMs is int ml) profile.MaxLatencyMs = ml;
if (s.Codec is AudioTransportCodec c) profile.Codec = c;
if (s.OpusFrameMilliseconds is int op) profile.OpusFrameMilliseconds = op;
if (s.OpusFrameSamplesPerChannel is int op) profile.OpusFrameSamplesPerChannel = op;
if (s.ContinuousAutoTuneEnabled is bool cae) profile.ContinuousAutoTuneEnabled = cae;
if (s.ContinuousAutoTuneIntervalSec is int cai) profile.ContinuousAutoTuneIntervalSec = cai;
if (s.MaxLatencyMsAsio is int mla) profile.MaxLatencyMsAsio = mla;
@@ -592,7 +617,10 @@ public sealed class RemSoundSettingsStore
public bool? AcceptRemoteVolumeCommands { get; set; }
public int? MaxLatencyMs { get; set; }
public AudioTransportCodec? Codec { get; set; }
public int? OpusFrameMilliseconds { get; set; }
// Renamed 2026-05-23 (v3.0). Was OpusFrameMilliseconds; value semantic shifted to
// samples-per-channel at 48 kHz. The cache is in-memory only — no JSON migration is
// needed here; on-disk migration happens in Profile via [JsonPropertyName].
public int? OpusFrameSamplesPerChannel { get; set; }
public bool? ContinuousAutoTuneEnabled { get; set; }
public int? ContinuousAutoTuneIntervalSec { get; set; }
// Per-route latency settings used in AudioMode.BothIndependent only. MaxLatencyMs