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
@@ -20,6 +20,81 @@ internal sealed class AboutDialog : Form
|
||||
/// updates" path.</summary>
|
||||
private const string ReleaseNotes =
|
||||
"""
|
||||
RemSound v3.0
|
||||
|
||||
A big release with two things you'll actually notice:
|
||||
|
||||
1) A new "live latency" Opus mode. RemSound now sends
|
||||
sound in tiny 2.5-millisecond chunks instead of the
|
||||
usual 10 or 20 ms. End-to-end delay drops to about
|
||||
5 ms of codec delay — close to PCM — perfect for
|
||||
playing along with someone in real time. Uses a bit
|
||||
more network bandwidth than the regular Opus mode
|
||||
but still a fraction of PCM. Best on a clean wired
|
||||
network. Pick it from the codec list as "Opus, live
|
||||
latency — for jamming and monitoring".
|
||||
|
||||
2) After RemSound updates itself, it now reopens on
|
||||
the same profile you were using. So if a silent
|
||||
update fires mid-session, the session drops briefly
|
||||
while the new files swap in, then RemSound reopens
|
||||
with the same devices, peers and settings — you
|
||||
don't see the profile picker, and you don't have to
|
||||
be at the computer when it happens. The next time
|
||||
you launch RemSound yourself, your normal startup
|
||||
choice applies as before.
|
||||
|
||||
Other changes:
|
||||
* The codec list is now three choices, with clearer
|
||||
names: PCM 48K 24 bit (uncompressed); Opus,
|
||||
broadcast quality (loss tolerant); Opus, live
|
||||
latency (the new low-latency mode). The old
|
||||
"Opus lower quality (10 ms)" middle option has
|
||||
been retired — it sat between the other two
|
||||
without a clear reason to pick it. If your saved
|
||||
profile was using it, RemSound silently picks
|
||||
broadcast quality for you — slightly more delay,
|
||||
more loss tolerance.
|
||||
* Save on a locked (read-only) profile now goes
|
||||
through when you ask on purpose. The lock still
|
||||
suppresses the automatic "save your changes?"
|
||||
prompt on close (its main job), but if you press
|
||||
Save deliberately, a one-time warning explains
|
||||
what's about to happen and lets you confirm or
|
||||
cancel. Once you tick "do not show again", future
|
||||
deliberate saves on a locked profile go through
|
||||
silently. Save as... is unchanged — it always
|
||||
works.
|
||||
|
||||
Also includes everything from the never-separately-
|
||||
released v2.2 work: a native Opus encoder that puts
|
||||
much less load on Windows' memory manager (about 97 %
|
||||
less per-second memory churn while sending Opus),
|
||||
various small CPU and memory tidy-ups, and new
|
||||
diagnostic log columns (cpu, memMB, wsMB, allocKBps,
|
||||
captureMs / sendMs / recvMs / renderMs).
|
||||
|
||||
Compatibility note — please read:
|
||||
|
||||
v3.0 changes how RemSound describes audio frame sizes
|
||||
to other RemSound machines on the wire. Two v3.0
|
||||
machines talk to each other perfectly. A v3.0 machine
|
||||
talking to a v2.x machine will still pass audio, but
|
||||
the v2.x side will build up too much buffer and
|
||||
latency will be very high. To avoid this, update BOTH
|
||||
machines to v3.0. The auto-updater on v1.9 and later
|
||||
will handle the upgrade for you, but the timing
|
||||
matters: if one machine updates before the other,
|
||||
expect a short period of high latency until the
|
||||
second machine catches up.
|
||||
|
||||
If you had previously ticked "do not show me this
|
||||
message again" on the v2.x "save was blocked on a
|
||||
read-only profile" dialog, that suppression doesn't
|
||||
carry over — you'll see the new one-time warning once
|
||||
per machine. That's deliberate; the behaviour changed
|
||||
and you need to know.
|
||||
|
||||
RemSound v2.2
|
||||
|
||||
A maintenance release that makes RemSound use less of
|
||||
|
||||
@@ -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 =====================
|
||||
|
||||
@@ -16,9 +16,11 @@ internal sealed class LiveCheckedListBox : CheckedListBox
|
||||
|
||||
/// <summary>
|
||||
/// User-facing choice that maps a friendly label to a codec + Opus frame size pair. The frame
|
||||
/// size is only meaningful when Codec == Opus; for PCM it's ignored.
|
||||
/// size is in samples-per-channel at 48 kHz (v3.0 wire-format unit — see
|
||||
/// <see cref="AudioFormatInfo"/>). 120 = 2.5 ms, 240 = 5 ms, 480 = 10 ms, 960 = 20 ms.
|
||||
/// Only meaningful when Codec == Opus; for PCM it's ignored.
|
||||
/// </summary>
|
||||
internal sealed record CodecChoice(string Label, AudioTransportCodec Codec, int OpusFrameMs)
|
||||
internal sealed record CodecChoice(string Label, AudioTransportCodec Codec, int OpusFrameSamples)
|
||||
{
|
||||
public override string ToString() => Label;
|
||||
}
|
||||
|
||||
+45
-10
@@ -35,18 +35,53 @@ internal static class Program
|
||||
|
||||
Profile? profile;
|
||||
string? title;
|
||||
// Auto-load shortcut: if AppConfig.StartWithProfileTitle is set and the named
|
||||
// profile actually exists in the current store, skip the picker entirely and
|
||||
// load that profile directly. This is what the Startup behaviour dialog's
|
||||
// "Start with a specific profile" toggle drives. Combined with the Windows
|
||||
// auto-start registry entry and the StartMinimised flag, it lets the user
|
||||
// boot a machine and have RemSound up and streaming with no clicks. Falls
|
||||
// through to the normal picker if the configured profile no longer exists
|
||||
// (deleted since it was selected, or the profiles folder changed) so the user
|
||||
// isn't stuck.
|
||||
// Auto-load shortcut: two paths.
|
||||
//
|
||||
// (a) Resume-after-update sentinel — a one-shot file written by the updater
|
||||
// just before it relaunches RemSound.exe (see RemSoundUpdater
|
||||
// ResumeProfileSentinelName). Holds the title of whichever profile was
|
||||
// loaded at the moment the update fired. If present, we load that profile
|
||||
// silently and delete the sentinel — so a silent or mid-session update
|
||||
// restores the same session the user was running, without dropping them
|
||||
// at the picker. This takes precedence over StartWithProfileTitle because
|
||||
// a mid-session update may have moved the user away from their configured
|
||||
// startup profile.
|
||||
//
|
||||
// (b) AppConfig.StartWithProfileTitle — the persistent "Start with a specific
|
||||
// profile" preference set via the Startup behaviour dialog. Loaded if (a)
|
||||
// didn't fire. Combined with the Windows auto-start registry entry and the
|
||||
// StartMinimised flag, it lets the user boot a machine and have RemSound
|
||||
// up and streaming with no clicks.
|
||||
//
|
||||
// Either path falls through to the normal picker if the named profile no longer
|
||||
// exists (deleted since it was selected, or the profiles folder changed) so the
|
||||
// user isn't stuck.
|
||||
Profile? autoLoaded = null;
|
||||
string? autoLoadedTitle = null;
|
||||
if (!string.IsNullOrWhiteSpace(appConfig.StartWithProfileTitle))
|
||||
|
||||
var resumeSentinelPath = Path.Combine(AppContext.BaseDirectory, RemSoundUpdater.ResumeProfileSentinelName);
|
||||
string? resumeTitle = null;
|
||||
if (File.Exists(resumeSentinelPath))
|
||||
{
|
||||
try { resumeTitle = File.ReadAllText(resumeSentinelPath).Trim(); }
|
||||
catch { resumeTitle = null; }
|
||||
// Delete the sentinel unconditionally — it's a one-shot. If the load fails
|
||||
// below, the user gets the picker on this launch and a normal start next
|
||||
// time, rather than the sentinel re-firing on every relaunch forever.
|
||||
try { File.Delete(resumeSentinelPath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resumeTitle))
|
||||
{
|
||||
try
|
||||
{
|
||||
autoLoaded = store.Load(resumeTitle!);
|
||||
if (autoLoaded is not null) autoLoadedTitle = resumeTitle;
|
||||
}
|
||||
catch { /* fall through to StartWithProfileTitle / picker */ }
|
||||
}
|
||||
|
||||
if (autoLoaded is null && !string.IsNullOrWhiteSpace(appConfig.StartWithProfileTitle))
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
tag_name on the latest GitHub release; bump it on every public release. The
|
||||
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
|
||||
is what the About dialog and the updater both read. -->
|
||||
<Version>2.2.0</Version>
|
||||
<Version>3.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -134,12 +134,29 @@ internal sealed class RemSoundUpdater : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Filename of the one-shot "after the update restart, silently load this profile"
|
||||
/// sentinel. Written next to RemSound.exe by <see cref="DownloadAndStageInstallAsync"/>
|
||||
/// when the caller supplies a non-empty <c>activeProfileTitle</c>; read and deleted by
|
||||
/// <c>Program.Main</c> on the next startup. Lives in the install directory (not %TEMP%
|
||||
/// or %APPDATA%) because the helper batch's robocopy step needs to know to skip it —
|
||||
/// see the <c>/XF</c> list in <see cref="BuildInstallScript"/>.</summary>
|
||||
public const string ResumeProfileSentinelName = "_resume-after-update.txt";
|
||||
|
||||
/// <summary>Download the update ZIP, stage it next to RemSound.exe, spawn the detached
|
||||
/// install helper, and ask the App to exit so the helper can take over. Returns true if
|
||||
/// the helper was launched (caller should Application.Exit immediately afterwards);
|
||||
/// false on any failure earlier in the pipeline. A false return leaves the running
|
||||
/// instance untouched.</summary>
|
||||
public async Task<bool> DownloadAndStageInstallAsync(UpdateInfo info, CancellationToken token = default)
|
||||
/// instance untouched.
|
||||
///
|
||||
/// <paramref name="activeProfileTitle"/> — when non-empty, a one-shot sentinel file
|
||||
/// <see cref="ResumeProfileSentinelName"/> is written next to RemSound.exe just before
|
||||
/// the helper is launched. On the next startup, Program.Main reads it, loads that profile
|
||||
/// silently (skipping the picker), and deletes the sentinel. This makes a silent / manual
|
||||
/// update behave like the session never ended — the user is back in the same profile
|
||||
/// they were running, without having to remember which one it was. When null/empty, no
|
||||
/// sentinel is written and the post-update launch uses whatever startup behaviour
|
||||
/// AppConfig has configured.</summary>
|
||||
public async Task<bool> DownloadAndStageInstallAsync(UpdateInfo info, string? activeProfileTitle = null, CancellationToken token = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -147,13 +164,17 @@ internal sealed class RemSoundUpdater : IDisposable
|
||||
var stagingDir = Path.Combine(baseDir, "_update");
|
||||
var zipPath = Path.Combine(Path.GetTempPath(), $"RemSound-update-{info.Tag}.zip");
|
||||
var batchPath = Path.Combine(baseDir, "_apply-update.cmd");
|
||||
var resumeSentinelPath = Path.Combine(baseDir, ResumeProfileSentinelName);
|
||||
|
||||
// Tidy any leftover from a previous failed attempt before we start. Also clear
|
||||
// the failure marker — the new attempt starts clean and only re-creates the
|
||||
// marker if THIS run fails.
|
||||
// marker if THIS run fails. The resume sentinel from a previous run (if any) is
|
||||
// also cleared here; if the caller supplies a profile title, the new sentinel is
|
||||
// written below after staging succeeds.
|
||||
TryDelete(zipPath);
|
||||
TryDeleteDirectory(stagingDir);
|
||||
TryDelete(Path.Combine(baseDir, "update-failed.txt"));
|
||||
TryDelete(resumeSentinelPath);
|
||||
|
||||
Log?.Invoke($"updater: downloading {info.DownloadUrl}");
|
||||
await using (var src = await http.GetStreamAsync(info.DownloadUrl, token).ConfigureAwait(false))
|
||||
@@ -174,6 +195,25 @@ internal sealed class RemSoundUpdater : IDisposable
|
||||
Log?.Invoke($"updater: writing install helper {batchPath}");
|
||||
File.WriteAllText(batchPath, BuildInstallScript(stagingRoot, baseDir));
|
||||
|
||||
// Write the resume-after-update sentinel so the post-restart launch loads the
|
||||
// same profile silently (no picker, no missed session). Only when the caller
|
||||
// supplied a title — blank-template sessions and explicit "no profile yet" cases
|
||||
// fall through to the normal startup logic.
|
||||
if (!string.IsNullOrWhiteSpace(activeProfileTitle))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(resumeSentinelPath, activeProfileTitle);
|
||||
Log?.Invoke($"updater: wrote resume sentinel for profile '{activeProfileTitle}'");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Sentinel is best-effort. If we can't write it (disk full, ACL change),
|
||||
// the update still proceeds; the user gets the picker on relaunch.
|
||||
Log?.Invoke($"updater: could not write resume sentinel: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var pid = System.Environment.ProcessId;
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
@@ -265,7 +305,7 @@ internal sealed class RemSoundUpdater : IDisposable
|
||||
rem startup settings) and their data folders (logs / profiles / recordings). An update
|
||||
rem replaces APP files only. build-release.ps1 already keeps those out of the release
|
||||
rem zip; this is the second line of defence so a bad zip still can't clobber them.
|
||||
robocopy "{stagingArg}" "{installArg}" /E /IS /IT /NFL /NDL /NJH /NJS /R:60 /W:1 /XF _apply-update.cmd /XF _update-helper.log /XF update-failed.txt /XF remsound.config.json /XD logs profiles recordings _update /LOG+:"%LOG%"
|
||||
robocopy "{stagingArg}" "{installArg}" /E /IS /IT /NFL /NDL /NJH /NJS /R:60 /W:1 /XF _apply-update.cmd /XF _update-helper.log /XF update-failed.txt /XF remsound.config.json /XF {ResumeProfileSentinelName} /XD logs profiles recordings _update /LOG+:"%LOG%"
|
||||
set "ROBO_EXIT=%ERRORLEVEL%"
|
||||
echo %DATE% %TIME% robocopy exit=%ROBO_EXIT% >> "%LOG%"
|
||||
|
||||
@@ -294,6 +334,10 @@ internal sealed class RemSoundUpdater : IDisposable
|
||||
echo. >> "%MARKER%"
|
||||
echo Once RemSound has updated successfully you can delete this file. >> "%MARKER%"
|
||||
echo Technical details for support are in _update-helper.log in this folder. >> "%MARKER%"
|
||||
rem Drop the resume-after-update sentinel on failure too — there's no restart
|
||||
rem happening, so a stale sentinel would mis-direct the user's next manual launch
|
||||
rem into auto-loading a profile they may have moved on from in the meantime.
|
||||
del "{installArg}\{ResumeProfileSentinelName}" 2>nul
|
||||
echo %DATE% %TIME% FAILURE: robocopy exit=%ROBO_EXIT%, update folder kept, NOT restarting RemSound >> "%LOG%"
|
||||
del "%~f0"
|
||||
exit /b %ROBO_EXIT%
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -274,7 +274,9 @@ public sealed class AudioReceiver : IDisposable
|
||||
/// <summary>
|
||||
/// Frame duration of the most-recently-active stream (10 ms PCM, 20 ms Opus). null when no
|
||||
/// stream is active. With multiple senders this picks the largest frame duration as the
|
||||
/// codec floor — most conservative for the auto-tune.
|
||||
/// codec floor — most conservative for the auto-tune. Returns ms (rounded up to the next
|
||||
/// integer if the underlying sample-count yields a fractional duration, e.g. 2.5 ms → 3),
|
||||
/// so the auto-tune always overestimates rather than underestimates the codec floor.
|
||||
/// </summary>
|
||||
public int? ActiveStreamFrameMs
|
||||
{
|
||||
@@ -283,12 +285,20 @@ public sealed class AudioReceiver : IDisposable
|
||||
lock (sessionsLock)
|
||||
{
|
||||
if (sessions.Count == 0) return null;
|
||||
var maxFrame = 0;
|
||||
var maxSamples = 0;
|
||||
var sampleRate = 48000;
|
||||
foreach (var s in sessions.Values)
|
||||
{
|
||||
if (s.Format.FrameDurationMilliseconds > maxFrame) maxFrame = s.Format.FrameDurationMilliseconds;
|
||||
if (s.Format.FrameSamplesPerChannel > maxSamples)
|
||||
{
|
||||
maxSamples = s.Format.FrameSamplesPerChannel;
|
||||
sampleRate = s.Format.SampleRate > 0 ? s.Format.SampleRate : 48000;
|
||||
}
|
||||
}
|
||||
return maxFrame;
|
||||
// Round up so a 2.5 ms frame reports as 3 ms — the auto-tune treats this as
|
||||
// a floor, and overestimating by half a millisecond is safer than rounding
|
||||
// down to 2 ms and pushing the buffer below the real codec frame size.
|
||||
return (maxSamples * 1000 + sampleRate - 1) / sampleRate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ internal sealed class StreamSession : IDisposable
|
||||
&& Format.Codec == format.Codec
|
||||
&& Format.SampleRate == format.SampleRate
|
||||
&& Format.Channels == format.Channels
|
||||
&& Format.FrameDurationMilliseconds == format.FrameDurationMilliseconds;
|
||||
&& Format.FrameSamplesPerChannel == format.FrameSamplesPerChannel;
|
||||
|
||||
public bool IsSameEndpoint(IPEndPoint endpoint) => Endpoint.Equals(endpoint);
|
||||
|
||||
@@ -220,7 +220,11 @@ internal sealed class StreamSession : IDisposable
|
||||
{
|
||||
if (opusDecoder is null) return false;
|
||||
|
||||
var frameSize = Math.Max(1, Format.SampleRate * Math.Max(5, Format.FrameDurationMilliseconds) / 1000);
|
||||
// Frame size in samples-per-channel comes directly off the wire in v3.0+ (was
|
||||
// SampleRate × ms / 1000 in v2.x). Floor at 120 = 2.5 ms = standard libopus
|
||||
// RESTRICTED_LOWDELAY minimum, so a malformed format packet with a tiny value can't
|
||||
// size the scratch buffer below the encoder's minimum frame size.
|
||||
var frameSize = Math.Max(120, Format.FrameSamplesPerChannel);
|
||||
var totalShorts = frameSize * Format.Channels;
|
||||
Span<short> shortScratch = totalShorts <= 4096 ? stackalloc short[totalShorts] : new short[totalShorts];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,22 @@ namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a Concentus Opus encoder configured for real-time low-latency 48 kHz stereo audio.
|
||||
/// Frame size is selectable at construction (10 ms or 20 ms). Receiver auto-handles whatever
|
||||
/// frame size the sender announces in the format packet — no coordination required.
|
||||
/// Frame size is selectable at construction as samples-per-channel. Receiver auto-handles
|
||||
/// whatever frame size the sender announces in the format packet — no coordination required.
|
||||
///
|
||||
/// 2026-05-23 — switched from the <c>Encode(ReadOnlySpan<short>...)</c> overload to the
|
||||
/// float overload after the first allocation-rate measurement (Part C, item 51 of
|
||||
/// RemSoundefficiency.md). The float overload skips one internal float→short→float round trip
|
||||
/// inside Concentus (CELT runs in float natively in RESTRICTED_LOWDELAY mode), and lets us
|
||||
/// drop our own per-sample Math.Clamp + cast loop — Concentus' float overload does its own
|
||||
/// out-of-range clipping per its XML docs. Same encoder configuration, same bitrate, same
|
||||
/// frame size, same audio output bit-for-bit.
|
||||
/// 2026-05-23 (a) — switched from the <c>Encode(ReadOnlySpan<short>...)</c> overload to
|
||||
/// the float overload after the first allocation-rate measurement (Part C, item 51 of
|
||||
/// RemSoundefficiency.md). The float overload skips one internal float→short→float round
|
||||
/// trip inside Concentus (CELT runs in float natively in RESTRICTED_LOWDELAY mode), and
|
||||
/// lets us drop our own per-sample Math.Clamp + cast loop — Concentus' float overload does
|
||||
/// its own out-of-range clipping per its XML docs. Same encoder configuration, same bitrate,
|
||||
/// same audio output bit-for-bit.
|
||||
///
|
||||
/// 2026-05-23 (b) — constructor parameter switched from milliseconds to samples-per-channel
|
||||
/// as part of the v3.0 wire-format refactor. Lets us express the 2.5 ms (= 120 samples)
|
||||
/// experimental low-latency mode cleanly without floating-point ms, and removes the
|
||||
/// <c>48000 * ms / 1000</c> conversion (which lost precision below 1 ms boundaries). Encoder
|
||||
/// configuration is otherwise identical.
|
||||
/// </summary>
|
||||
internal sealed class OpusEncoderState : IDisposable
|
||||
{
|
||||
@@ -24,16 +30,15 @@ internal sealed class OpusEncoderState : IDisposable
|
||||
private readonly IOpusEncoder encoder;
|
||||
private readonly byte[] packetScratch = new byte[PacketBufferBytes];
|
||||
|
||||
public int FrameMilliseconds { get; }
|
||||
public int FrameSizePerChannel { get; }
|
||||
|
||||
public OpusEncoderState(int frameMilliseconds, int bitrate)
|
||||
public OpusEncoderState(int frameSamplesPerChannel, int bitrate)
|
||||
{
|
||||
// RESTRICTED_LOWDELAY supports 2.5/5/10/20 ms frames. 10 ms = lowest practical latency,
|
||||
// 20 ms = same bitrate but more robust to packet loss (each lost packet is half the audio
|
||||
// share). We expose 10 and 20 as the user-selectable choices.
|
||||
FrameMilliseconds = Math.Clamp(frameMilliseconds, 5, 60);
|
||||
FrameSizePerChannel = 48000 * FrameMilliseconds / 1000;
|
||||
// RESTRICTED_LOWDELAY supports 2.5/5/10/20 ms frames at 48 kHz = 120/240/480/960
|
||||
// samples-per-channel. 120 (2.5 ms) is the lowest standard-Opus frame size. We clamp
|
||||
// to the legal Opus range; the UI never offers a value outside it but a corrupt
|
||||
// setting can't crash the encoder constructor.
|
||||
FrameSizePerChannel = Math.Clamp(frameSamplesPerChannel, 120, 2880);
|
||||
|
||||
encoder = OpusCodecFactory.CreateEncoder(48000, Channels, OpusApplication.OPUS_APPLICATION_RESTRICTED_LOWDELAY, TextWriter.Null);
|
||||
encoder.Bitrate = bitrate;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user