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:
co-authored by
Claude Opus 4.7
parent
6d6d6897e4
commit
af0e7c3fff
@@ -50,12 +50,18 @@ public sealed class AppConfig
|
||||
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; }
|
||||
/// "you are saving onto a read-only profile" warning. Once ticked, Ctrl+S / File → Save
|
||||
/// on a read-only profile saves silently through the lock instead of warning first.
|
||||
/// 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-23 — semantic shift from v2.x: in v2.x the read-only lock
|
||||
/// hard-blocked explicit saves and this flag suppressed the explanatory "save was skipped"
|
||||
/// popup. In v3.0 the lock only suppresses the automatic "you have unsaved changes" prompt
|
||||
/// on close / profile switch; explicit Ctrl+S / File → Save now goes through with a
|
||||
/// one-time warning gated by this flag. The JSON key was renamed alongside the semantic
|
||||
/// change so users upgrading from v2.x see the new warning at least once — a v2.x
|
||||
/// suppression flag is no longer applicable and is silently discarded on load.</summary>
|
||||
public bool SaveOnReadOnlyWarningSuppressed { 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
|
||||
|
||||
@@ -6,6 +6,18 @@ namespace RemSound.Core;
|
||||
/// the semantics. The field is wire-backward-compatible: old receivers parse the first 32
|
||||
/// bytes of the format payload and ignore the extra; new receivers reading a 32-byte
|
||||
/// payload from an old sender default Lane to <see cref="RenderRoute.Mixed"/>.
|
||||
///
|
||||
/// 2026-05-23 — <c>FrameDurationMilliseconds</c> renamed to <c>FrameSamplesPerChannel</c>
|
||||
/// (v3.0 wire-format change). Previously held an int millisecond value; now holds the exact
|
||||
/// sample-count per channel at the announced <see cref="SampleRate"/>. Conversion is
|
||||
/// <c>ms = FrameSamplesPerChannel * 1000 / SampleRate</c> for display. The change exists
|
||||
/// because Opus's 2.5 ms RESTRICTED_LOWDELAY frame (= 120 samples at 48 kHz) can't be
|
||||
/// expressed cleanly in integer milliseconds; using sample-count also removes a lossy
|
||||
/// conversion that the encoder/decoder pipeline previously did at every announcement.
|
||||
/// v2.x receivers reading this field will misinterpret the value as milliseconds and
|
||||
/// over-size their internal buffers; the actual decode still works because the Opus
|
||||
/// decoder is self-describing from the packet TOC byte. Within-major-version (v3.x ↔ v3.x)
|
||||
/// the field is unambiguous.
|
||||
/// </summary>
|
||||
public sealed record AudioFormatInfo(
|
||||
int SampleRate,
|
||||
@@ -15,9 +27,15 @@ public sealed record AudioFormatInfo(
|
||||
int BlockAlign,
|
||||
int AverageBytesPerSecond,
|
||||
int Codec = (int)AudioTransportCodec.Pcm,
|
||||
int FrameDurationMilliseconds = 10,
|
||||
int FrameSamplesPerChannel = 480,
|
||||
RenderRoute Lane = RenderRoute.Mixed)
|
||||
{
|
||||
/// <summary>Human-friendly frame duration in milliseconds, derived from
|
||||
/// <see cref="FrameSamplesPerChannel"/> and <see cref="SampleRate"/>. May be a fraction
|
||||
/// (2.5 ms at 48 kHz / 120 samples). For string formatting only — the encoder/decoder
|
||||
/// hot path uses <see cref="FrameSamplesPerChannel"/> directly.</summary>
|
||||
public double FrameDurationMs => SampleRate > 0 ? FrameSamplesPerChannel * 1000.0 / SampleRate : 0;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var encodingName = Encoding switch
|
||||
@@ -28,7 +46,7 @@ public sealed record AudioFormatInfo(
|
||||
};
|
||||
var codecName = (AudioTransportCodec)Codec switch
|
||||
{
|
||||
AudioTransportCodec.Opus => $" over Opus ({FrameDurationMilliseconds} ms)",
|
||||
AudioTransportCodec.Opus => $" over Opus ({FrameDurationMs:0.##} ms)",
|
||||
_ => ""
|
||||
};
|
||||
var laneName = Lane == RenderRoute.Mixed ? "" : $" [{Lane}]";
|
||||
|
||||
@@ -62,7 +62,17 @@ public sealed class Profile
|
||||
// === Connectivity & transport ===
|
||||
public int AudioPort { get; set; } = 47830;
|
||||
public int CodecRaw { get; set; } = (int)AudioTransportCodec.Pcm;
|
||||
public int OpusFrameMilliseconds { get; set; } = 10;
|
||||
/// <summary>Opus frame size in samples-per-channel at 48 kHz. 120 = 2.5 ms, 240 = 5 ms,
|
||||
/// 480 = 10 ms (default), 960 = 20 ms. Renamed from <c>OpusFrameMilliseconds</c> in the
|
||||
/// v3.0 wire-format refactor (2026-05-23). The JSON key is kept as
|
||||
/// <c>OpusFrameMilliseconds</c> for back-compat with v2.x profile files; on read,
|
||||
/// <see cref="RemSoundSettingsStore.LoadOpusFrameSamplesPerChannel"/> disambiguates the
|
||||
/// legacy integer-ms encoding (5/10/20) from the new sample-count encoding
|
||||
/// (120/240/480/960) using a sentinel: any persisted value < 120 is treated as ms and
|
||||
/// multiplied by 48. v2.x readers loading a v3.x profile see e.g. 480 and clamp it to
|
||||
/// their accept-list (which only knows 10 or 20), defaulting silently to 10 ms.</summary>
|
||||
[JsonPropertyName("OpusFrameMilliseconds")]
|
||||
public int OpusFrameSamplesPerChannel { get; set; } = 480;
|
||||
public int SendRateRaw { get; set; } = (int)SendRate.Standard;
|
||||
public bool TightLatencyMode { get; set; }
|
||||
/// <summary>True if this profile asks Windows to keep the RemSound process in
|
||||
|
||||
@@ -157,7 +157,10 @@ public static class RemPacket
|
||||
BinaryPrimitives.WriteInt32LittleEndian(destination[16..], format.BlockAlign);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(destination[20..], format.AverageBytesPerSecond);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(destination[24..], format.Codec);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(destination[28..], format.FrameDurationMilliseconds);
|
||||
// Wire field at offset 28: sample-count per channel at the announced sample rate
|
||||
// (formerly milliseconds, renamed 2026-05-23 — see AudioFormatInfo doc comment for
|
||||
// the v3.0 wire-format change).
|
||||
BinaryPrimitives.WriteInt32LittleEndian(destination[28..], format.FrameSamplesPerChannel);
|
||||
// Extension: 1 byte Lane + 3 reserved-zero bytes. Zero-fill the reserved slot so a
|
||||
// future receiver doesn't accidentally read stale stack data if WriteFormatPayload
|
||||
// is called on an uninitialised buffer.
|
||||
|
||||
@@ -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 < 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 < 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
|
||||
|
||||
Reference in New Issue
Block a user