Bump to v2.2.0: Opus native binding, efficiency tidy-up, diag self-meter
Single biggest change: added the Concentus.Native NuGet package. Concentus 2.0+ auto-detects native libopus at runtime and routes encode calls through it; encoder state lives on the C side and is reused across calls rather than `new`ing ~15 working buffers per call (Concentus issue #22, open since 2018). Measured on the desktop test at 15:36:55 — Opus 10 ms allocation rate dropped from 4,625 KB/s to 108 KB/s, a 97.7% reduction. Process CPU dropped from 4.7% to 1.6% in the same config. Audio is bit- for-bit identical (it's literally the same encoder, just better packaged). `OpusEncoderState.cs` itself unchanged on the call site. Diagnostic / measurement layer (gated on Enable-logs, zero cost when off): * ProcessSelfMeter: CPU%, managed heap MB, working set MB, allocation rate per second, GC counts per generation * Per-thread work-time counters: captureMs / sendMs / recvMs / renderMs expressed as milliseconds of CPU consumed by each audio thread per second * Inter-packet arrival gap measured at the user-space UDP socket (rxNetGapMs) — pinpoints whether arrival jitter is in the network or our own dispatch path Small efficiency wins (each one was small but cumulative): * deviceRefreshTimer interval 1s -> 3s (item 4) * WaitHandle array allocations eliminated in MixingEngine.MixLoop and MultiOutputPlayout.ProduceLoop (item 6) * MultiOutputPlayout caches its output-buffer snapshot and only rebuilds on SetOutputDevices, instead of rebuilding every 10 ms (item 7) * HeartbeatService reuses an outbound ping byte[] instead of allocating per send (item 14) * PeerDiscoveryService caches broadcast addresses and invalidates on Windows' NetworkChange event instead of walking all NICs every 1.5 s (item 16) Legacy / dead-code removal: * KeepAlive packet's implementation (struct, enums, writer, reader, size constant) — all dead since HeartbeatService landed 2026-05-06. Kept the RemPacketType.KeepAlive enum value and silent-drop dispatch for wire compat with any pre-2026-05-06 build still in the wild (item 30) * driftDropFramesTotal / driftRepeatFramesTotal fields and accessors — Phase-2 splice corrector relics, never incremented since Phase-4 resampler design landed; backed five always-zero diag log columns (items 34 + 35) * DriftAccumulator (always returned 0) — same shape, removed alongside the driftAcc= column (item 35) * TakeMaxFanOutCacheBytes / Ms + fanCacheMs column — FanOutSource was retired in May (item 36) Project documentation: * RemSoundefficiency.md added as the canonical record of the efficiency analysis, every item's status, and the measured wins from this round * Honest item-by-item review of the original 50-item list — several items I had sized optimistically in the original analysis turned out to be already-done (item 20), already-optimal (item 22), or below the meter floor (items 9, 15, 17, 25). Recorded so future passes don't re-investigate. Wire format and audio pipeline unchanged from v1.5 onward — v1.5 through v2.2 peers interoperate.
This commit is contained in:
@@ -63,6 +63,12 @@ public sealed class HeartbeatService : IDisposable
|
||||
private CancellationTokenSource? cts;
|
||||
private Task? sendTask;
|
||||
private uint sequence;
|
||||
// Reusable outbound packet buffer for the once-per-second ping fan-out. Pre-2026-05-23
|
||||
// SendPings did `var bytes = packet.ToArray()` on every call (a 21-byte allocation +
|
||||
// GC header). Trivial in absolute terms — ~3 small allocations/sec/peer — but the
|
||||
// SendPings thread has only one writer so a single reused array is straightforward and
|
||||
// makes the pattern explicit. Item 14 of RemSoundefficiency.md.
|
||||
private readonly byte[] outboundPingBuffer = new byte[RemPacket.HeaderSize + RemPacket.HeartbeatPayloadSize];
|
||||
|
||||
/// <summary>
|
||||
/// Outbound transport for heartbeat packets. REQUIRED — without it Start() succeeds but
|
||||
@@ -253,20 +259,22 @@ public sealed class HeartbeatService : IDisposable
|
||||
foreach (var p in targets) p.FirstPingSentUtc ??= nowUtc;
|
||||
}
|
||||
|
||||
// Build packet. streamId is fixed at 0xFFFF for heartbeats so it's distinguishable
|
||||
// in any future stream-aware filter; sequence increments locally per send.
|
||||
Span<byte> packet = stackalloc byte[RemPacket.HeaderSize + RemPacket.HeartbeatPayloadSize];
|
||||
// Build packet directly into the reusable outboundPingBuffer instead of stack-
|
||||
// allocating + ToArray(). Same wire format, no per-call allocation. SendPings runs
|
||||
// exclusively on the timer task — single writer — so no lock needed around the
|
||||
// reuse. streamId is fixed at 0xFFFF for heartbeats so it's distinguishable in any
|
||||
// future stream-aware filter; sequence increments locally per send.
|
||||
var seq = Interlocked.Increment(ref sequence);
|
||||
var tickMs = monotonic.ElapsedMilliseconds;
|
||||
RemPacket.WriteHeader(packet, RemPacketType.Heartbeat, 0xFFFF, seq);
|
||||
RemPacket.WriteHeartbeatPayload(packet[RemPacket.HeaderSize..], HeartbeatKind.Ping, tickMs);
|
||||
var bytes = packet.ToArray();
|
||||
var packetSpan = outboundPingBuffer.AsSpan();
|
||||
RemPacket.WriteHeader(packetSpan, RemPacketType.Heartbeat, 0xFFFF, seq);
|
||||
RemPacket.WriteHeartbeatPayload(packetSpan[RemPacket.HeaderSize..], HeartbeatKind.Ping, tickMs);
|
||||
|
||||
foreach (var p in targets)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ok = transport(bytes, bytes.Length, p.AudioEndpoint);
|
||||
var ok = transport(outboundPingBuffer, outboundPingBuffer.Length, p.AudioEndpoint);
|
||||
onDiagnostic?.Invoke($"send seq={seq} to={p.AudioEndpoint} {(ok ? "ok" : "FAILED")}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -39,6 +39,15 @@ public sealed class PeerDiscoveryService : IDisposable
|
||||
// reference once per tick. Volatile-write semantics via the assignment under the gate are
|
||||
// sufficient because we only ever swap the reference, never mutate in place.
|
||||
private IReadOnlyList<IPAddress> unicastTargets = [];
|
||||
// Cached broadcast addresses. Item 16 of RemSoundefficiency.md — pre-2026-05-23 we
|
||||
// recomputed these every 1.5 s by walking every network interface (NetworkInterface
|
||||
// .GetAllNetworkInterfaces is a real Win32 P/Invoke), allocating a HashSet, and iterating
|
||||
// unicast addresses. Network interfaces don't change on a 1.5 s cadence; cache the
|
||||
// result and invalidate only when Windows raises the NetworkAddressChanged event.
|
||||
// Reference-swap on update so the announce loop can read it without locking.
|
||||
private volatile IPAddress[] cachedBroadcastAddresses = [];
|
||||
private int broadcastCacheDirty = 1; // 1 = needs rebuild, 0 = current. Int for Interlocked.
|
||||
private NetworkAddressChangedEventHandler? networkChangeHandler;
|
||||
|
||||
public event Action? PeersChanged;
|
||||
|
||||
@@ -70,6 +79,15 @@ public sealed class PeerDiscoveryService : IDisposable
|
||||
|
||||
announcer = new UdpClient(AddressFamily.InterNetwork) { EnableBroadcast = true };
|
||||
|
||||
// Subscribe to Windows network-change notifications so we know to rebuild the
|
||||
// broadcast-address cache. Without this we'd either have to re-walk all interfaces
|
||||
// every 1.5 s (the pre-2026-05-23 behaviour) or risk announcing on stale broadcast
|
||||
// addresses after a network change. The handler just flips the dirty flag — the
|
||||
// actual rebuild happens lazily the next time AnnounceLoop reads the cache.
|
||||
networkChangeHandler = (_, _) => Interlocked.Exchange(ref broadcastCacheDirty, 1);
|
||||
try { NetworkChange.NetworkAddressChanged += networkChangeHandler; }
|
||||
catch { /* harmless — caching just falls back to per-tick rebuild on first miss */ }
|
||||
|
||||
listenTask = Task.Run(() => ListenLoop(cts.Token));
|
||||
announceTask = Task.Run(() => AnnounceLoop(cts.Token));
|
||||
}
|
||||
@@ -105,6 +123,12 @@ public sealed class PeerDiscoveryService : IDisposable
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (networkChangeHandler is not null)
|
||||
{
|
||||
try { NetworkChange.NetworkAddressChanged -= networkChangeHandler; }
|
||||
catch { /* ignore — best-effort unsubscribe */ }
|
||||
networkChangeHandler = null;
|
||||
}
|
||||
cts?.Cancel();
|
||||
listener?.Dispose();
|
||||
announcer?.Dispose();
|
||||
@@ -225,23 +249,48 @@ public sealed class PeerDiscoveryService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<IPAddress> GetBroadcastAddresses()
|
||||
/// <summary>Returns the cached broadcast-address array, rebuilding it only if the
|
||||
/// dirty flag has been set (initial state, or by the NetworkAddressChanged event).
|
||||
/// The original implementation walked every NIC on every announcement (~40 per minute);
|
||||
/// caching turns that into a single walk per network change. Item 16 of
|
||||
/// RemSoundefficiency.md. 2026-05-23.</summary>
|
||||
private IPAddress[] GetBroadcastAddresses()
|
||||
{
|
||||
var addresses = new HashSet<IPAddress> { IPAddress.Broadcast };
|
||||
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
|
||||
// Fast path: cache is current.
|
||||
if (Volatile.Read(ref broadcastCacheDirty) == 0)
|
||||
{
|
||||
if (ni.OperationalStatus != OperationalStatus.Up || ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
||||
foreach (var unicast in ni.GetIPProperties().UnicastAddresses)
|
||||
return cachedBroadcastAddresses;
|
||||
}
|
||||
// Slow path: rebuild. Atomic CAS clears the dirty flag before the rebuild so a
|
||||
// concurrent NetworkAddressChanged event sets it again rather than racing.
|
||||
Interlocked.Exchange(ref broadcastCacheDirty, 0);
|
||||
var addresses = new HashSet<IPAddress> { IPAddress.Broadcast };
|
||||
try
|
||||
{
|
||||
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (unicast.Address.AddressFamily != AddressFamily.InterNetwork || unicast.IPv4Mask is null) continue;
|
||||
var addr = unicast.Address.GetAddressBytes();
|
||||
var mask = unicast.IPv4Mask.GetAddressBytes();
|
||||
var bcast = new byte[4];
|
||||
for (var i = 0; i < 4; i++) bcast[i] = (byte)(addr[i] | ~mask[i]);
|
||||
addresses.Add(new IPAddress(bcast));
|
||||
if (ni.OperationalStatus != OperationalStatus.Up || ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
||||
foreach (var unicast in ni.GetIPProperties().UnicastAddresses)
|
||||
{
|
||||
if (unicast.Address.AddressFamily != AddressFamily.InterNetwork || unicast.IPv4Mask is null) continue;
|
||||
var addr = unicast.Address.GetAddressBytes();
|
||||
var mask = unicast.IPv4Mask.GetAddressBytes();
|
||||
var bcast = new byte[4];
|
||||
for (var i = 0; i < 4; i++) bcast[i] = (byte)(addr[i] | ~mask[i]);
|
||||
addresses.Add(new IPAddress(bcast));
|
||||
}
|
||||
}
|
||||
}
|
||||
return addresses;
|
||||
catch
|
||||
{
|
||||
// GetAllNetworkInterfaces can throw transiently on some configurations; the
|
||||
// limited-broadcast 255.255.255.255 still reaches LAN peers on most setups, so
|
||||
// fall back to just that rather than aborting discovery.
|
||||
}
|
||||
var snapshot = new IPAddress[addresses.Count];
|
||||
addresses.CopyTo(snapshot);
|
||||
cachedBroadcastAddresses = snapshot;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private void PruneExpiredPeers()
|
||||
|
||||
@@ -52,26 +52,14 @@ public enum RemoteControlKind : byte
|
||||
SystemMuteToggle = 5,
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum KeepAliveCapabilities : byte
|
||||
{
|
||||
None = 0,
|
||||
CanSend = 1,
|
||||
CanReceive = 2,
|
||||
}
|
||||
|
||||
public enum KeepAliveKind : byte
|
||||
{
|
||||
Heartbeat = 1,
|
||||
Ack = 2,
|
||||
}
|
||||
|
||||
public readonly record struct KeepAliveInfo(
|
||||
Guid SessionId,
|
||||
KeepAliveKind Kind,
|
||||
KeepAliveCapabilities Capabilities,
|
||||
AudioTransportCodec Codec,
|
||||
long UnixTimeMilliseconds);
|
||||
// KeepAliveCapabilities / KeepAliveKind / KeepAliveInfo + the KeepAlivePayloadSize +
|
||||
// WriteKeepAlivePayload / TryReadKeepAlive methods that lived here were removed 2026-05-23.
|
||||
// They date from before HeartbeatService (which arrived 2026-05-06). After HeartbeatService
|
||||
// went in, no code in RemSound ever wrote or read a KeepAlive packet again — they were dead
|
||||
// code carried through 16 releases. RemPacketType.KeepAlive = 3 and the silent-drop dispatch
|
||||
// in AudioReceiver are RETAINED on purpose so any pre-2026-05-06 build still in the wild
|
||||
// has its packets quietly ignored rather than counted as malformed — but the unused machinery
|
||||
// to construct/parse the payload is gone.
|
||||
|
||||
/// <summary>
|
||||
/// Wire format for RemSound packets. Header is 12 bytes; body length is implied by the UDP datagram.
|
||||
@@ -95,7 +83,8 @@ public static class RemPacket
|
||||
/// before reading the Lane field; payloads shorter than that default Lane to
|
||||
/// <see cref="RenderRoute.Mixed"/>. Senders newer than 2026-05-11 always write this size.</summary>
|
||||
public const int FormatPayloadExtendedSize = 36;
|
||||
public const int KeepAlivePayloadSize = 28;
|
||||
// KeepAlivePayloadSize removed 2026-05-23 — no code reads or writes this payload any more
|
||||
// (see top-of-file comment). RemPacketType.KeepAlive itself is retained for wire safety.
|
||||
/// <summary>
|
||||
/// Heartbeat payload: 1 byte <see cref="HeartbeatKind"/> + 8 bytes originator-monotonic
|
||||
/// timestamp (Stopwatch.ElapsedMilliseconds at the time the originating Ping was sent).
|
||||
@@ -179,24 +168,8 @@ public static class RemPacket
|
||||
return FormatPayloadExtendedSize;
|
||||
}
|
||||
|
||||
public static int WriteKeepAlivePayload(Span<byte> destination, KeepAliveInfo info)
|
||||
{
|
||||
if (destination.Length < KeepAlivePayloadSize)
|
||||
{
|
||||
throw new ArgumentException("KeepAlive payload destination too small", nameof(destination));
|
||||
}
|
||||
|
||||
destination[0] = (byte)info.Kind;
|
||||
destination[1] = (byte)info.Codec;
|
||||
destination[2] = (byte)info.Capabilities;
|
||||
destination[3] = 0;
|
||||
BinaryPrimitives.WriteInt64LittleEndian(destination[4..], info.UnixTimeMilliseconds);
|
||||
if (!info.SessionId.TryWriteBytes(destination.Slice(12, 16)))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return KeepAlivePayloadSize;
|
||||
}
|
||||
// WriteKeepAlivePayload removed 2026-05-23 — dead since HeartbeatService landed
|
||||
// 2026-05-06. See top-of-file comment.
|
||||
|
||||
public static bool TryReadHeader(ReadOnlySpan<byte> packet, out RemPacketType type, out ushort streamId, out uint sequence)
|
||||
{
|
||||
@@ -305,19 +278,8 @@ public static class RemPacket
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryReadKeepAlive(ReadOnlySpan<byte> payload, out KeepAliveInfo info)
|
||||
{
|
||||
info = default;
|
||||
if (payload.Length < KeepAlivePayloadSize) return false;
|
||||
if (!Enum.IsDefined((KeepAliveKind)payload[0])) return false;
|
||||
info = new KeepAliveInfo(
|
||||
new Guid(payload.Slice(12, 16)),
|
||||
(KeepAliveKind)payload[0],
|
||||
(KeepAliveCapabilities)payload[2],
|
||||
Enum.IsDefined((AudioTransportCodec)payload[1]) ? (AudioTransportCodec)payload[1] : AudioTransportCodec.Pcm,
|
||||
BinaryPrimitives.ReadInt64LittleEndian(payload[4..]));
|
||||
return true;
|
||||
}
|
||||
// TryReadKeepAlive removed 2026-05-23 — dead since HeartbeatService landed 2026-05-06.
|
||||
// See top-of-file comment.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user