v5.2: stability and polish — deep-audit bug fixes + install-flow fixes
Verified findings from a multi-dimension code audit, plus the two install-flow bugs: - Fix Opus encoder use-after-free on a codec/rate change while streaming (guard swap vs encode). - Fix "both" single-file recording dropping audio + drifting (drain both directions in lockstep). - Fix broken clip counter, UPnP teardown on exit, auto-update-restart foreground grant, and a malformed-Opus-format packet orphaning a playout session forever. - Post-install relaunch now respects start-minimised; uninstall is path-aware so it won't clear a different copy's run-at-startup. - Perf/hygiene: cache AppConfig off UI hot paths, fold per-peer EQ+gain into one pass, deterministic disposal (tray menu, timers, COM shortcut, Process handles, process meter), ring-buffer overflow guard, receiver session-lock fix, remote-control allow-list moved onto the UI thread. - Remove dead code (two IsAsioBackend, SessionPlayout.Reset, IsSameEndpoint, RemSoundUpdater IDisposable); several stale-doc fixes. Deferred (not in this release): drift-estimator tweak, peer-discovery pruning, uninstall retry-loop, encryption nonce. Wire format unchanged (interops v3.3-v5.1). Version -> 5.2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7038fef67c
commit
2fb9274a95
@@ -176,8 +176,6 @@ public sealed class AudioReceiver : IDisposable
|
||||
if (wasRunning) multiOutput.Start();
|
||||
}
|
||||
|
||||
public bool IsAsioBackend => multiOutput is CompositeRenderBackend;
|
||||
|
||||
/// <summary>Sets the Buffer-smoothness knob (1 = aggressive — clicks the buffer back
|
||||
/// to target on any drift, holds the user's latency tightly; 10 = smooth — no clicks
|
||||
/// but the queue can creep up under jitter or sustained clock drift). Knob drives a
|
||||
@@ -691,8 +689,14 @@ public sealed class AudioReceiver : IDisposable
|
||||
Interlocked.Exchange(ref bytesReceived, 0);
|
||||
Interlocked.Exchange(ref packetsDropped, 0);
|
||||
|
||||
// Tear down any sessions left over from a previous Start (in case Stop wasn't called).
|
||||
DisposeAllSessionsLocked();
|
||||
// Tear down any sessions left over from a previous Start (in case Stop wasn't called). Under
|
||||
// sessionsLock to match the method's "Locked" contract and its other two callers — the counter
|
||||
// getters / prune run on the App's snapshot-tick thread and touch `sessions` under this lock, so
|
||||
// clearing it bare would be an unsynchronised mutation of the non-thread-safe dictionary.
|
||||
lock (sessionsLock)
|
||||
{
|
||||
DisposeAllSessionsLocked();
|
||||
}
|
||||
playoutEngine.ResetAll();
|
||||
|
||||
listener.Start(udpPort);
|
||||
@@ -1073,7 +1077,21 @@ public sealed class AudioReceiver : IDisposable
|
||||
isFormatChange = true;
|
||||
}
|
||||
|
||||
newSession = new StreamSession(remote, streamId, format, sp, diagnostics, _ => sp.NoteFramesQueued(playoutEngine.TargetLatencyMs), decryptor);
|
||||
try
|
||||
{
|
||||
newSession = new StreamSession(remote, streamId, format, sp, diagnostics, _ => sp.NoteFramesQueued(playoutEngine.TargetLatencyMs), decryptor);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The StreamSession ctor failed — most realistically because a corrupt/hostile Format
|
||||
// announced an Opus sample rate/channel count the decoder rejects. We already registered
|
||||
// the SessionPlayout in PlayoutEngine via GetOrCreateSession above; nothing was added to
|
||||
// `sessions`, so PruneIdleSessions would never reap it and it would linger forever, summed
|
||||
// on every render callback and poisoning the auto-tune's underrun stats. Reap it here,
|
||||
// then let the exception propagate so the packet handler still logs it.
|
||||
playoutEngine.RemoveSession(remote, streamId);
|
||||
throw;
|
||||
}
|
||||
sessions[key] = newSession;
|
||||
|
||||
// Same-lane streamId rotation: drop other sessions from this peer that share the
|
||||
|
||||
@@ -10,9 +10,12 @@ namespace RemSound.Receiver;
|
||||
/// Hands raw packets (byte buffer + length + remote endpoint) up to a callback supplied by the
|
||||
/// owner — has no idea what's inside the packets.
|
||||
///
|
||||
/// Allocation-free in steady state: one fixed receive buffer reused across calls,
|
||||
/// <see cref="Socket.ReceiveFrom"/> with <see cref="SocketAddress"/> avoids the per-call
|
||||
/// IPEndPoint boxing that <see cref="UdpClient.ReceiveAsync"/> incurred.
|
||||
/// The receive buffer is a single fixed array reused across calls. NOTE: the current
|
||||
/// <see cref="Socket.ReceiveFrom(byte[], int, int, SocketFlags, ref EndPoint)"/> overload still
|
||||
/// allocates a SocketAddress + a fresh IPEndPoint per datagram — tiny (tens of bytes) and dwarfed
|
||||
/// by decode/mix work, but not literally allocation-free. A future optimisation could switch to the
|
||||
/// Span/SocketAddress overload with a cached SocketAddress and only materialise an IPEndPoint when a
|
||||
/// new session actually opens.
|
||||
/// </summary>
|
||||
internal sealed class NetworkListener : IDisposable
|
||||
{
|
||||
|
||||
@@ -110,20 +110,41 @@ public sealed class PeerDspChain
|
||||
int n = left.Length;
|
||||
if (n > 0)
|
||||
{
|
||||
for (int f = 0; f < frames; f++)
|
||||
// Fold the post-EQ gain into the EQ loop's final store, so a peer with both EQ and non-unity
|
||||
// gain walks the block ONCE on the render thread instead of twice (branch hoisted out of the
|
||||
// per-frame loop). n==0 + gain-only keeps its own single pass below.
|
||||
if (hasGain)
|
||||
{
|
||||
float sl = output[2 * f];
|
||||
float sr = output[2 * f + 1];
|
||||
for (int b = 0; b < n; b++)
|
||||
for (int f = 0; f < frames; f++)
|
||||
{
|
||||
sl = left[b].Transform(sl);
|
||||
sr = right[b].Transform(sr);
|
||||
float sl = output[2 * f];
|
||||
float sr = output[2 * f + 1];
|
||||
for (int b = 0; b < n; b++)
|
||||
{
|
||||
sl = left[b].Transform(sl);
|
||||
sr = right[b].Transform(sr);
|
||||
}
|
||||
output[2 * f] = sl * gainL;
|
||||
output[2 * f + 1] = sr * gainR;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int f = 0; f < frames; f++)
|
||||
{
|
||||
float sl = output[2 * f];
|
||||
float sr = output[2 * f + 1];
|
||||
for (int b = 0; b < n; b++)
|
||||
{
|
||||
sl = left[b].Transform(sl);
|
||||
sr = right[b].Transform(sr);
|
||||
}
|
||||
output[2 * f] = sl;
|
||||
output[2 * f + 1] = sr;
|
||||
}
|
||||
output[2 * f] = sl;
|
||||
output[2 * f + 1] = sr;
|
||||
}
|
||||
}
|
||||
if (hasGain)
|
||||
else if (hasGain)
|
||||
{
|
||||
for (int f = 0; f < frames; f++)
|
||||
{
|
||||
|
||||
@@ -657,15 +657,15 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
|
||||
/// <summary>
|
||||
/// Render-side audio pull. Iterates every session regardless of lane tag and sums them
|
||||
/// into one mixed bus. This is what every render backend (WasapiOnly, AsioOnly, classic
|
||||
/// Both via the tee, and BothIndependent via the tee) reads from, so a user can pick any
|
||||
/// output device for any received audio — independently of which capture technology the
|
||||
/// sender used. Per-lane latency targets are still honoured: each session reads its own
|
||||
/// route's TargetMs / MaxMs via <see cref="LatencyFor"/>, so the WASAPI-captured stream
|
||||
/// can buffer at one latency and the ASIO-captured stream at another within the same
|
||||
/// output mix. The lane-specific <see cref="WasapiLaneOutput"/> / <see cref="AsioLaneOutput"/>
|
||||
/// surfaces are kept around for future per-route routing options but are not used by the
|
||||
/// default render path (see <c>CompositeRenderBackend</c>). 2026-05-11 revision: previous
|
||||
/// into one mixed bus. This is what the WasapiOnly / AsioOnly render path reads from, so a
|
||||
/// user can pick any output device for any received audio — independently of which capture
|
||||
/// technology the sender used. Per-lane latency targets are still honoured: each session
|
||||
/// reads its own route's TargetMs / MaxMs via <see cref="LatencyFor"/>, so the WASAPI-captured
|
||||
/// stream can buffer at one latency and the ASIO-captured stream at another within the same
|
||||
/// output mix. In BothIndependent mode this all-sessions pull is NOT used: each lane reads its
|
||||
/// own filtered surface (<see cref="WasapiLaneOutput"/> / <see cref="AsioLaneOutput"/>) directly
|
||||
/// — see <c>CompositeRenderBackend</c> — so those surfaces are ACTIVE render sources, not
|
||||
/// future-only. 2026-05-11 revision: previous
|
||||
/// implementation filtered by route, which made it impossible to route a WASAPI-captured
|
||||
/// stream onto an ASIO output (and vice versa) in BothIndependent mode — that broke a
|
||||
/// long-standing cross-backend send/receive flow.
|
||||
|
||||
@@ -435,36 +435,6 @@ internal sealed class SessionPlayout : IDisposable
|
||||
drainRequested = true;
|
||||
}
|
||||
|
||||
/// <summary>Reset the buffer and per-session state. Used at start/stop. Arming will rebuild
|
||||
/// from the next packets that arrive.</summary>
|
||||
public void Reset()
|
||||
{
|
||||
playout.Reset();
|
||||
playbackArmed = false;
|
||||
largestWriteMs = 0;
|
||||
inUnderrunConcealment = false;
|
||||
consecutiveEmptyReads = 0;
|
||||
lastConcealSampleL = 0f;
|
||||
lastConcealSampleR = 0f;
|
||||
filteredErrorFrames = 0;
|
||||
prevDriftSampleTicks = 0;
|
||||
trimGlideTargetMs = 0;
|
||||
prevTrimGlideTicks = 0;
|
||||
// Phase-4 drift resampler state. Reset counters and window state. Reset() on the
|
||||
// resampler clears its internal filter delay line so a fresh session doesn't
|
||||
// inherit phase from a prior one. SetRates back to 1:1 — we'll re-measure drift
|
||||
// from scratch.
|
||||
bytesWrittenForDriftEst = 0;
|
||||
bytesReadOutputForDriftEst = 0;
|
||||
resamplerWindowStartTicks = 0;
|
||||
resamplerWindowStartBytesWritten = 0;
|
||||
resamplerWindowStartBytesOutput = 0;
|
||||
smoothedRateRatio = 1.0;
|
||||
resamplerActivelyTracking = false;
|
||||
driftResampler.Reset();
|
||||
driftResampler.SetRates(MixSampleRate, MixSampleRate);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// AudioRingBuffer is managed; nothing to free explicitly. Method present for symmetry
|
||||
|
||||
@@ -114,8 +114,6 @@ internal sealed class StreamSession : IDisposable
|
||||
&& Format.Channels == format.Channels
|
||||
&& Format.FrameSamplesPerChannel == format.FrameSamplesPerChannel;
|
||||
|
||||
public bool IsSameEndpoint(IPEndPoint endpoint) => Endpoint.Equals(endpoint);
|
||||
|
||||
public bool HandleAudioPayload(uint sequence, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
diagnostics.RecordPacketArrived();
|
||||
|
||||
Reference in New Issue
Block a user