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
@@ -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