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
@@ -372,6 +372,24 @@ internal sealed class AudioRecorder : IDisposable
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>How many frames <see cref="DrainOneDirection"/> would produce for one direction right
|
||||
/// now, WITHOUT consuming anything — the lane-merge min (or the single active lane), capped at
|
||||
/// <paramref name="maxFrames"/>. Used by the Both path to drain the sent and received directions by
|
||||
/// the SAME amount so neither ring is over-consumed and the two stay sample-aligned. Read heads are
|
||||
/// owned by this (writer) thread so a plain read is fine; the caller passes Volatile.Read snapshots
|
||||
/// of the write heads, which the audio threads advance.</summary>
|
||||
private static int DirectionAvailFrames(long wasapiWrite, long wasapiRead, long asioWrite, long asioRead, int maxFrames)
|
||||
{
|
||||
var w = (int)((wasapiWrite - wasapiRead) / MixChannels);
|
||||
var a = (int)((asioWrite - asioRead) / MixChannels);
|
||||
int avail;
|
||||
if (w > 0 && a > 0) avail = Math.Min(w, a);
|
||||
else if (w > 0) avail = w;
|
||||
else if (a > 0) avail = a;
|
||||
else avail = 0;
|
||||
return Math.Min(avail, maxFrames);
|
||||
}
|
||||
|
||||
private void Process()
|
||||
{
|
||||
int framesThisCall;
|
||||
@@ -410,18 +428,37 @@ internal sealed class AudioRecorder : IDisposable
|
||||
// received scratch we'll grow as needed.
|
||||
EnsureScratchSize(DrainChunkMaxFrames * MixChannels);
|
||||
EnsureSecondaryScratchSize(DrainChunkMaxFrames * MixChannels);
|
||||
var sentFrames = DrainOneDirection(
|
||||
sentWasapiRing, ref sentWasapiWriteHead, ref sentWasapiReadHead,
|
||||
sentAsioRing, ref sentAsioWriteHead, ref sentAsioReadHead,
|
||||
mixScratch, mixScratchAux, DrainChunkMaxFrames);
|
||||
EnsureRecvDirectionScratchSize(DrainChunkMaxFrames * MixChannels);
|
||||
var recvFrames = DrainOneDirection(
|
||||
receivedWasapiRing, ref receivedWasapiWriteHead, ref receivedWasapiReadHead,
|
||||
receivedAsioRing, ref receivedAsioWriteHead, ref receivedAsioReadHead,
|
||||
recvDirectionScratch, mixScratchAux, DrainChunkMaxFrames);
|
||||
if (sentFrames > 0 && recvFrames > 0)
|
||||
|
||||
// Peek how much each direction can supply WITHOUT consuming, so when both carry audio we
|
||||
// drain them by the SAME amount and keep them sample-aligned. Draining each independently
|
||||
// (the old code) advanced the faster direction's ring head past frames we then never
|
||||
// wrote — a silent, continuous loss + progressive drift on any real two-way session,
|
||||
// which is exactly what Both mode exists to capture. Now the surplus genuinely stays in
|
||||
// its ring for the next pass.
|
||||
var sentAvail = DirectionAvailFrames(
|
||||
Volatile.Read(ref sentWasapiWriteHead), sentWasapiReadHead,
|
||||
Volatile.Read(ref sentAsioWriteHead), sentAsioReadHead, DrainChunkMaxFrames);
|
||||
var recvAvail = DirectionAvailFrames(
|
||||
Volatile.Read(ref receivedWasapiWriteHead), receivedWasapiReadHead,
|
||||
Volatile.Read(ref receivedAsioWriteHead), receivedAsioReadHead, DrainChunkMaxFrames);
|
||||
|
||||
if (sentAvail > 0 && recvAvail > 0)
|
||||
{
|
||||
framesThisCall = Math.Min(sentFrames, recvFrames);
|
||||
// Both directions have data — take the same count from each. Because write heads only
|
||||
// advance (producers add) and this is the sole consumer, each drain returns exactly
|
||||
// `take`, so the two stay aligned and nothing is over-consumed.
|
||||
var take = Math.Min(sentAvail, recvAvail);
|
||||
var got1 = DrainOneDirection(
|
||||
sentWasapiRing, ref sentWasapiWriteHead, ref sentWasapiReadHead,
|
||||
sentAsioRing, ref sentAsioWriteHead, ref sentAsioReadHead,
|
||||
mixScratch, mixScratchAux, take);
|
||||
var got2 = DrainOneDirection(
|
||||
receivedWasapiRing, ref receivedWasapiWriteHead, ref receivedWasapiReadHead,
|
||||
receivedAsioRing, ref receivedAsioWriteHead, ref receivedAsioReadHead,
|
||||
recvDirectionScratch, mixScratchAux, take);
|
||||
framesThisCall = Math.Min(got1, got2); // defensive; both equal `take` in practice
|
||||
if (framesThisCall <= 0) return;
|
||||
var dst = mixScratch.AsSpan(0, framesThisCall * MixChannels);
|
||||
var aux = recvDirectionScratch.AsSpan(0, framesThisCall * MixChannels);
|
||||
for (var i = 0; i < dst.Length; i++)
|
||||
@@ -431,20 +468,26 @@ internal sealed class AudioRecorder : IDisposable
|
||||
else if (s < -1f) s = -1f + MathF.Tanh(-1f - s);
|
||||
dst[i] = s;
|
||||
}
|
||||
// Any leftover frames in the direction that produced MORE this tick stay
|
||||
// in their rings for the next iteration — they're not lost, just deferred.
|
||||
// We can't write them now without un-syncing the two directions.
|
||||
}
|
||||
else if (sentFrames > 0)
|
||||
else if (sentAvail > 0)
|
||||
{
|
||||
framesThisCall = sentFrames;
|
||||
// mixScratch already contains the sent direction's audio — emit as-is.
|
||||
// Only the sent direction has audio right now — record it solo (no over-consume,
|
||||
// nothing to align against). mixScratch already holds it.
|
||||
framesThisCall = DrainOneDirection(
|
||||
sentWasapiRing, ref sentWasapiWriteHead, ref sentWasapiReadHead,
|
||||
sentAsioRing, ref sentAsioWriteHead, ref sentAsioReadHead,
|
||||
mixScratch, mixScratchAux, DrainChunkMaxFrames);
|
||||
if (framesThisCall <= 0) return;
|
||||
}
|
||||
else if (recvFrames > 0)
|
||||
else if (recvAvail > 0)
|
||||
{
|
||||
framesThisCall = recvFrames;
|
||||
// The recv-direction audio lives in recvDirectionScratch; copy into
|
||||
// mixScratch so EmitMixBuffer (which reads from mixScratch) sees it.
|
||||
framesThisCall = DrainOneDirection(
|
||||
receivedWasapiRing, ref receivedWasapiWriteHead, ref receivedWasapiReadHead,
|
||||
receivedAsioRing, ref receivedAsioWriteHead, ref receivedAsioReadHead,
|
||||
recvDirectionScratch, mixScratchAux, DrainChunkMaxFrames);
|
||||
if (framesThisCall <= 0) return;
|
||||
// The recv-direction audio lives in recvDirectionScratch; copy into mixScratch so
|
||||
// EmitMixBuffer (which reads from mixScratch) sees it.
|
||||
var len = framesThisCall * MixChannels;
|
||||
recvDirectionScratch.AsSpan(0, len).CopyTo(mixScratch.AsSpan(0, len));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user