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
@@ -722,19 +722,26 @@ public sealed class MainForm : Form
|
||||
maxLatencyBox.AccessibleName = "Audio latency in milliseconds (Alt+L)";
|
||||
|
||||
// --- Populate static choices ---
|
||||
// Order: PCM first (LAN), Opus 20 ms (higher quality, more robust to loss), Opus 10 ms
|
||||
// (lower latency at the cost of slightly less audio quality and loss tolerance). Labels
|
||||
// intentionally avoid all numbers and ms jargon — the slider is the only place ms
|
||||
// should appear in the UI.
|
||||
// Three transport choices, ordered most-tolerant-of-bad-networks to most-demanding:
|
||||
// * PCM 48K 24-bit — uncompressed, ~2.3 Mbps
|
||||
// * Opus broadcast quality — 20 ms frame (960 samples/ch at 48 kHz), loss tolerant
|
||||
// * Opus live latency — 2.5 ms frame (120 samples/ch at 48 kHz), 8× the packet
|
||||
// rate of broadcast quality, for jamming / live monitoring
|
||||
// The 10 ms middle option (480 samples/ch) that lived here in v2.x has been retired —
|
||||
// it sat between the other two without a clear use case (saved only 5 ms over 20 ms
|
||||
// and gave up loss tolerance for no clearly audible win). Frame size on the wire is
|
||||
// samples-per-channel at 48 kHz (v3.0 unit). Labels avoid numbers and ms jargon per
|
||||
// the manual's "use case in words" convention; the per-peer status line surfaces the
|
||||
// actual ms figure for users who want to verify.
|
||||
codecBox.Items.AddRange(new object[]
|
||||
{
|
||||
new CodecChoice("PCM 48K 24 bit for very fast connections", AudioTransportCodec.Pcm, 0),
|
||||
new CodecChoice("Opus high quality for fast connections", AudioTransportCodec.Opus, 20),
|
||||
new CodecChoice("Opus lower quality for slower connections", AudioTransportCodec.Opus, 10),
|
||||
new CodecChoice("PCM 48K 24 bit — uncompressed", AudioTransportCodec.Pcm, 0),
|
||||
new CodecChoice("Opus, broadcast quality — loss tolerant", AudioTransportCodec.Opus, 960),
|
||||
new CodecChoice("Opus, live latency — for jamming and monitoring", AudioTransportCodec.Opus, 120),
|
||||
});
|
||||
codecBox.SelectedIndex = ResolveCodecIndex(settings.LoadCodec(), settings.LoadOpusFrameMilliseconds());
|
||||
codecBox.SelectedIndex = ResolveCodecIndex(settings.LoadCodec(), settings.LoadOpusFrameSamplesPerChannel());
|
||||
var initialCodec = (CodecChoice)codecBox.SelectedItem!;
|
||||
sender.ConfigureCodec(initialCodec.Codec, EffectiveOpusFrameMs(initialCodec.Codec, initialCodec.OpusFrameMs, settings.LoadSendRate()));
|
||||
sender.ConfigureCodec(initialCodec.Codec, EffectiveOpusFrameSamples(initialCodec.Codec, initialCodec.OpusFrameSamples, settings.LoadSendRate()));
|
||||
sender.SetSendRate(settings.LoadSendRate());
|
||||
|
||||
// Relay-mode plumbing. The sender's UDP socket is always-receiving from form construction
|
||||
@@ -859,10 +866,10 @@ public sealed class MainForm : Form
|
||||
if (codecBox.SelectedItem is CodecChoice item)
|
||||
{
|
||||
settings.SaveCodec(item.Codec);
|
||||
if (item.Codec == AudioTransportCodec.Opus) settings.SaveOpusFrameMilliseconds(item.OpusFrameMs);
|
||||
var effectiveFrameMs = EffectiveOpusFrameMs(item.Codec, item.OpusFrameMs, settings.LoadSendRate());
|
||||
sender.ConfigureCodec(item.Codec, effectiveFrameMs);
|
||||
logFile.Event($"codec changed to {item.Codec}{(item.Codec == AudioTransportCodec.Opus ? $" {effectiveFrameMs}ms" : "")}");
|
||||
if (item.Codec == AudioTransportCodec.Opus) settings.SaveOpusFrameSamplesPerChannel(item.OpusFrameSamples);
|
||||
var effectiveSamples = EffectiveOpusFrameSamples(item.Codec, item.OpusFrameSamples, settings.LoadSendRate());
|
||||
sender.ConfigureCodec(item.Codec, effectiveSamples);
|
||||
logFile.Event($"codec changed to {item.Codec}{(item.Codec == AudioTransportCodec.Opus ? $" {effectiveSamples / 48.0:0.##}ms" : "")}");
|
||||
MarkProfileDirty();
|
||||
}
|
||||
};
|
||||
@@ -1572,50 +1579,70 @@ public sealed class MainForm : Form
|
||||
|
||||
/// <summary>Ctrl+S / File → Save behaviour: if a profile is currently loaded, overwrite
|
||||
/// it; if we're on the blank template (no current profile), fall through to Save as.
|
||||
/// Read-only profiles refuse here with a hint pointing at Save As — that's the whole
|
||||
/// point of read-only mode, so silently ignoring Ctrl+S would be more confusing than
|
||||
/// a one-time message explaining why nothing happened. The message is suppressible
|
||||
/// via the "Do not show again" tick (same pattern as the Save-success popup).</summary>
|
||||
///
|
||||
/// Read-only profiles: the lock suppresses the automatic "you have unsaved changes"
|
||||
/// prompt on close / profile switch (the user has declared "anything I changed this
|
||||
/// session is throwaway"), but it does NOT block explicit Ctrl+S / File → Save — if the
|
||||
/// user asks to save on purpose, the save goes through. First time they do this we show
|
||||
/// a one-time warning explaining the situation, with a "Do not show again" tick so the
|
||||
/// warning self-suppresses for power users. Changed 2026-05-23 from the v2.x hard-block
|
||||
/// behaviour after Ed's feedback that the lock should protect against accident, not
|
||||
/// against intent.</summary>
|
||||
private void SaveOrSaveAs()
|
||||
{
|
||||
if (currentProfileReadOnly)
|
||||
{
|
||||
if (!AppConfig.Load().SaveOnReadOnlyMessageSuppressed)
|
||||
if (!AppConfig.Load().SaveOnReadOnlyWarningSuppressed)
|
||||
{
|
||||
ShowSaveBlockedByReadOnlyDialog();
|
||||
if (!ShowSaveOnReadOnlyWarningDialog()) return;
|
||||
}
|
||||
// Read-only profiles always have a title — read-only is meaningless on the blank
|
||||
// template — so we go straight to UpdateExistingProfile without the
|
||||
// string-null-check that the unlocked path needs.
|
||||
UpdateExistingProfile();
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrEmpty(currentProfileTitle)) SaveProfileAs();
|
||||
else UpdateExistingProfile();
|
||||
}
|
||||
|
||||
/// <summary>Native TaskDialog explaining why Ctrl+S / File → Save did nothing on a
|
||||
/// read-only profile. Verification checkbox lets the user suppress future occurrences;
|
||||
/// same shape as <see cref="ShowSaveConfirmationDialog"/>. NVDA reads the heading +
|
||||
/// body + checkbox as part of the normal tab order. 2026-05-22.</summary>
|
||||
private void ShowSaveBlockedByReadOnlyDialog()
|
||||
/// <summary>Native TaskDialog warning the user that they're about to overwrite a profile
|
||||
/// marked read-only. Returns true if the user confirmed the save, false if they
|
||||
/// cancelled. Verification checkbox lets the user suppress future occurrences via
|
||||
/// <see cref="AppConfig.SaveOnReadOnlyWarningSuppressed"/>; same shape as
|
||||
/// <see cref="ShowSaveConfirmationDialog"/>. NVDA reads the heading + body + checkbox
|
||||
/// as part of the normal tab order. 2026-05-23 (rewrite of the v2.x hard-block dialog).
|
||||
/// </summary>
|
||||
private bool ShowSaveOnReadOnlyWarningDialog()
|
||||
{
|
||||
var verification = new TaskDialogVerificationCheckBox("Do not show me this message again");
|
||||
var saveButton = new TaskDialogButton("Save anyway");
|
||||
var cancelButton = new TaskDialogButton("Cancel") { AllowCloseDialog = true };
|
||||
var page = new TaskDialogPage
|
||||
{
|
||||
Caption = AppName,
|
||||
Heading = "This profile is read-only",
|
||||
Text = "This profile is locked, so Save was skipped. Use File → Save as... to save your changes to a new profile, or untick File → Lock profile (read-only) to unlock this one.",
|
||||
Icon = TaskDialogIcon.Information,
|
||||
Heading = "Saving onto a read-only profile",
|
||||
Text = "You're about to save changes onto a profile that's marked as read-only. "
|
||||
+ "RemSound allows this because you asked to save on purpose — the lock only "
|
||||
+ "stops the automatic \"save your changes?\" prompt; it doesn't stop you "
|
||||
+ "saving when you mean to.\n\n"
|
||||
+ "Click Save anyway to overwrite this profile, or Cancel and use "
|
||||
+ "File → Save as... if you'd rather save your changes to a new profile.",
|
||||
Icon = TaskDialogIcon.Warning,
|
||||
Verification = verification,
|
||||
Buttons = { TaskDialogButton.OK },
|
||||
DefaultButton = TaskDialogButton.OK,
|
||||
Buttons = { saveButton, cancelButton },
|
||||
DefaultButton = cancelButton,
|
||||
AllowCancel = true,
|
||||
};
|
||||
TaskDialog.ShowDialog(this, page);
|
||||
var clicked = TaskDialog.ShowDialog(this, page);
|
||||
if (verification.Checked)
|
||||
{
|
||||
var cfg = AppConfig.Load();
|
||||
cfg.SaveOnReadOnlyMessageSuppressed = true;
|
||||
cfg.SaveOnReadOnlyWarningSuppressed = true;
|
||||
try { cfg.Save(); } catch { /* harmless — preference just won't persist */ }
|
||||
AppendLogEntry("save-blocked-by-read-only message suppressed by user");
|
||||
AppendLogEntry("save-on-read-only warning suppressed by user");
|
||||
}
|
||||
return clicked == saveButton;
|
||||
}
|
||||
|
||||
/// <summary>Rename the currently-active profile JSON on disk. No-op on the blank
|
||||
@@ -1877,7 +1904,13 @@ public sealed class MainForm : Form
|
||||
/// untouched.</summary>
|
||||
private async Task InstallUpdateAsync(UpdateInfo info)
|
||||
{
|
||||
var ok = await updater.DownloadAndStageInstallAsync(info).ConfigureAwait(true);
|
||||
// Pass the currently-loaded profile title so the updater drops a resume-after-update
|
||||
// sentinel; the relaunched RemSound.exe will pick this up in Program.Main and silently
|
||||
// re-open the same profile, skipping the picker. Without this, a silent or
|
||||
// mid-session update would drop the session AND leave the user back at the picker —
|
||||
// the session never resumes by itself. Null/empty title (blank template, no profile
|
||||
// saved yet) skips the sentinel and the relaunch falls through to normal startup.
|
||||
var ok = await updater.DownloadAndStageInstallAsync(info, currentProfileTitle).ConfigureAwait(true);
|
||||
if (!ok)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
@@ -2658,7 +2691,7 @@ public sealed class MainForm : Form
|
||||
}
|
||||
}
|
||||
var sendingNow = connected && IsSendEnabled && sender.IsRunning;
|
||||
var codecLabel = FormatCodecLabel(sender.Codec, sender.OpusFrameMilliseconds);
|
||||
var codecLabel = FormatCodecLabel(sender.Codec, sender.OpusFrameSamplesPerChannel);
|
||||
|
||||
for (int i = 0; i < connectedPeersList.Items.Count; i++)
|
||||
{
|
||||
@@ -5272,7 +5305,8 @@ public sealed class MainForm : Form
|
||||
var rate = settings.LoadSendRate();
|
||||
if (item.Codec == AudioTransportCodec.Opus)
|
||||
{
|
||||
return EffectiveOpusFrameMs(item.Codec, item.OpusFrameMs, rate) / 2.0;
|
||||
// EffectiveOpusFrameSamples is samples-per-channel at 48 kHz; ÷ 48 → ms, ÷ 2 → half-frame.
|
||||
return EffectiveOpusFrameSamples(item.Codec, item.OpusFrameSamples, rate) / 96.0;
|
||||
}
|
||||
// PCM
|
||||
if (settings.LoadTightLatencyMode() && settings.LoadAudioMode() == AudioMode.AsioOnly)
|
||||
@@ -5304,28 +5338,29 @@ public sealed class MainForm : Form
|
||||
private double RenderBufferEstimateMs() => 10;
|
||||
|
||||
/// <summary>
|
||||
/// Translates a codec choice + the user's Send Rate into the effective Opus frame size.
|
||||
/// PCM frame size is set separately in AudioSender.SetSendRate (it's a sample-count, not
|
||||
/// a milliseconds value). Standard returns the codec's natural frame; Tight halves it
|
||||
/// (Opus 20 → 10, Opus 10 → 5, PCM frame size handled in AudioSender). Opus codec accepts
|
||||
/// 2.5/5/10/20/40/60 ms — never goes below 5 here so we don't need sub-millisecond Opus.
|
||||
/// Translates a codec choice + the user's Send Rate into the effective Opus frame size in
|
||||
/// samples-per-channel at 48 kHz. PCM frame size is set separately in AudioSender.SetSendRate.
|
||||
/// Standard returns the codec's natural frame; Tight halves it (Opus 960 → 480 → 240 → 120
|
||||
/// floored). Floor is 120 samples = 2.5 ms = standard libopus's RESTRICTED_LOWDELAY minimum.
|
||||
/// </summary>
|
||||
private static int EffectiveOpusFrameMs(AudioTransportCodec codec, int opusFrameMs, SendRate rate)
|
||||
private static int EffectiveOpusFrameSamples(AudioTransportCodec codec, int opusFrameSamples, SendRate rate)
|
||||
{
|
||||
if (codec != AudioTransportCodec.Opus) return opusFrameMs;
|
||||
return rate == SendRate.Tight ? Math.Max(5, opusFrameMs / 2) : opusFrameMs;
|
||||
if (codec != AudioTransportCodec.Opus) return opusFrameSamples;
|
||||
return rate == SendRate.Tight ? Math.Max(120, opusFrameSamples / 2) : opusFrameSamples;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Short codec label for the per-peer line in the connectivity dialog. e.g. "PCM",
|
||||
/// "Opus 10ms", "Opus 20ms". Uses the same EffectiveOpusFrameMs the encoder uses so the
|
||||
/// label reflects the actually-encoded frame size, not the codec menu choice.
|
||||
/// "Opus 10ms", "Opus 20ms", "Opus 2.5ms". Input is samples-per-channel at 48 kHz; the
|
||||
/// label derives ms from samples / 48 with up to one decimal place. Uses the same
|
||||
/// EffectiveOpusFrameSamples the encoder uses so the label reflects the actually-encoded
|
||||
/// frame size, not the codec menu choice.
|
||||
/// </summary>
|
||||
private static string FormatCodecLabel(AudioTransportCodec codec, int opusFrameMs)
|
||||
private static string FormatCodecLabel(AudioTransportCodec codec, int opusFrameSamples)
|
||||
{
|
||||
return codec switch
|
||||
{
|
||||
AudioTransportCodec.Opus => $"Opus {Math.Max(1, opusFrameMs)}ms",
|
||||
AudioTransportCodec.Opus => $"Opus {Math.Max(1, opusFrameSamples) / 48.0:0.##}ms",
|
||||
AudioTransportCodec.Pcm => "PCM",
|
||||
_ => codec.ToString(),
|
||||
};
|
||||
@@ -5342,8 +5377,9 @@ public sealed class MainForm : Form
|
||||
{
|
||||
if (codecBox.SelectedItem is CodecChoice item && item.Codec == AudioTransportCodec.Opus)
|
||||
{
|
||||
sender.ConfigureCodec(item.Codec, EffectiveOpusFrameMs(item.Codec, item.OpusFrameMs, rate));
|
||||
logFile.Event($"send rate changed to {rate} → Opus frame {EffectiveOpusFrameMs(item.Codec, item.OpusFrameMs, rate)}ms");
|
||||
var effectiveSamples = EffectiveOpusFrameSamples(item.Codec, item.OpusFrameSamples, rate);
|
||||
sender.ConfigureCodec(item.Codec, effectiveSamples);
|
||||
logFile.Event($"send rate changed to {rate} → Opus frame {effectiveSamples / 48.0:0.##}ms");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -5351,10 +5387,21 @@ public sealed class MainForm : Form
|
||||
}
|
||||
}
|
||||
|
||||
private static int ResolveCodecIndex(AudioTransportCodec codec, int opusFrameMs)
|
||||
private static int ResolveCodecIndex(AudioTransportCodec codec, int opusFrameSamples)
|
||||
{
|
||||
if (codec == AudioTransportCodec.Pcm) return 0;
|
||||
return opusFrameMs == 20 ? 1 : 2; // Opus 20 = index 1, Opus 10 (default) = index 2
|
||||
// Opus 120 (2.5 ms — live latency) = index 2. Anything else (including the retired
|
||||
// 10 ms middle (480) and the never-exposed 5 ms (240)) collapses to index 1
|
||||
// (broadcast quality / 20 ms), the safer default — losing a little latency is the
|
||||
// less surprising outcome on upgrade than losing loss tolerance. v2.x profiles that
|
||||
// saved OpusFrameMilliseconds=10 (which the settings store migrates to 480 samples
|
||||
// via the <120 sentinel) land here on the broadcast side; users who specifically
|
||||
// want low latency re-pick "live latency" from the dropdown.
|
||||
return opusFrameSamples switch
|
||||
{
|
||||
120 => 2,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ===================== Auto-tune =====================
|
||||
|
||||
Reference in New Issue
Block a user