v3.9.1: plain/WASAPI streams now play on an ASIO-mode receiver (silent-mic fix)

Receiver: a Mixed (plain) session is rendered on an active lane in BothIndependent mode instead of being skipped, so a WASAPI-only sender is no longer silent to a receiver that has an ASIO driver selected. Also port the per-session buffer depth-drain (stops the receive jitter buffer bloating).

Sender: add sndAudFr meter (audio frames actually sent) to localise capture vs send.

App: startup sound cue (machine-wide, Preferences); stop sending audio when no peer is reachable (issue #8). Version 3.9.1.

Server (relay): fix updater version-compare for multi-dot tags, guard the main loop against crashes, reject spoofed BYE from a mismatched endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-06-12 19:00:33 +01:00
co-authored by Claude Opus 4.8
parent 04b17ff1ab
commit 84c4a47411
15 changed files with 360 additions and 148 deletions
+4 -3
View File
@@ -336,9 +336,10 @@ internal sealed class MultiOutputPlayout : IRenderBackend
private const double DriftRatioMin = 0.95;
private const double DriftRatioMax = 1.05;
// Feedback: steer the buffer toward a cushion. Pure rate-matching holds the buffer wherever
// the start-up transient left it (~50 ms and climbing in the field) SessionPlayout gets
// away without this because it ARMS at target and has a click-trim net; the device buffer
// has neither, so it needs an explicit depth term. The correction is tiny (≤0.3 % rate,
// the start-up transient left it (~50 ms and climbing in the field). SessionPlayout was
// originally thought to get away without this (it ARMS at target and has a click-trim net),
// but the same standing-bloat showed up on its ring over a long WASAPI session, so as of
// 2026-06-12 it carries the identical depth term. The correction is tiny (≤0.3 % rate,
// spread over seconds): a sub-audible pitch nudge, never a click.
//
// The cushion is sized to the CARD, not hardcoded. A device can't hold a buffer below one
+15 -3
View File
@@ -668,12 +668,24 @@ internal sealed class PlayoutEngine : IWaveProvider
foreach (var session in snap)
{
var matchesOwnLane = session.Route == route;
// Orphan = session tagged for the OTHER non-Mixed lane, and that lane has no
// active output. (Mixed-tagged sessions never appear in BothIndependent.)
// Orphan = session tagged for the OTHER non-Mixed lane whose lane has no active
// output — fall it through onto whichever lane IS being read so it stays audible
// (2026-05-15).
var isOrphanFromOtherLane = !matchesOwnLane
&& session.Route != RenderRoute.Mixed
&& !otherLaneActive;
if (!matchesOwnLane && !isOrphanFromOtherLane) continue;
// A Mixed (plain) session belongs to NO lane — it's what a classic WASAPI-only sender
// announces. In BothIndependent mode the lane-filtered reads would otherwise SKIP it
// (it matches neither lane, and the orphan clause above excludes Mixed), so a plain
// stream sent to an ASIO-mode receiver was decoded but never rendered — heard as total
// silence while its session ring filled and overflowed. 2026-06-12 fix: render a Mixed
// session on exactly ONE active lane — the WASAPI lane by preference, falling back to
// the ASIO lane only when no WASAPI output is active — so a plain stream always plays,
// in any mode, honouring the "every send/receive combination interoperates" contract.
// (A read on route==WasapiLane implies that lane is active, so this never double-plays.)
var playMixedHere = session.Route == RenderRoute.Mixed
&& (route == RenderRoute.WasapiLane || !wasapiLaneActive);
if (!matchesOwnLane && !isOrphanFromOtherLane && !playMixedHere) continue;
aggregateBufferedBytes += session.BufferedBytes;
var produced = session.ReadFloats(sessionBuf.AsSpan(0, outFloats), outFrames, routeTargetMs, routeMaxMs, smoothness);
if (produced <= 0) continue;
+40 -10
View File
@@ -199,6 +199,20 @@ internal sealed class SessionPlayout : IDisposable
// Low-pass filter time constant for the buffer-level-error display in the diag log.
// Doesn't affect any correction logic in Phase 4 — purely informational.
private const double DriftFilterTimeConstantSec = 2.0;
// === Depth feedback (2026-06-12) — drain a standing buffer back to target ===
// The clock-ratio resampler above is a pure rate-MATCHER: it equalises long-run sender-write
// and receiver-output rates, which holds the ring at whatever depth it currently sits. On the
// WASAPI render path (a Stopwatch-paced producer loop drains the ring, not a hardware clock) a
// stall-then-burst can leave the ring standing ~100 ms deep and the rate-matcher then freezes
// it there for the whole session — receive latency ratchets up overnight and only ever resets
// on a reconnect or a glitchy click-trim. Andre's 2026-06-11 overnight WASAPI log showed
// exactly this sawtooth. The per-device WASAPI corrector (MultiOutputPlayout.DriftResampling-
// Provider) already carries this depth term; this ports the same term onto the per-sender ring.
// depthError > 0 (too deep) biases the rate UP so the resampler pulls more input per output and
// drains faster; clamped to ±MaxDepthBias and spread over DepthCorrectionSec → a sub-audible
// ≤0.3 % pitch nudge, never a click.
private const double DepthCorrectionSec = 15.0;
private const double MaxDepthBias = 0.003;
// Number of stereo frames each side of a splice point that get blended when a drop or
// repeat fires. Cosine crossfade over this window smooths the discontinuity into an audio
// DriftDropFramesTotal / DriftRepeatFramesTotal accessors removed 2026-05-23 alongside
@@ -541,7 +555,7 @@ internal sealed class SessionPlayout : IDisposable
}
prevDriftSampleTicks = nowTicks;
UpdateDriftResamplerRateIfDue(nowTicks);
UpdateDriftResamplerRateIfDue(nowTicks, targetLatencyMs);
// Read through the resampler and apply concealment on full underruns.
ReadThroughResampler(output, outFrames);
@@ -554,7 +568,7 @@ internal sealed class SessionPlayout : IDisposable
/// into the live ratio, and push it to the resampler. Called from the audio thread
/// on every ReadFloats. No-op if the window hasn't elapsed yet.
/// </summary>
private void UpdateDriftResamplerRateIfDue(long nowTicks)
private void UpdateDriftResamplerRateIfDue(long nowTicks, int targetLatencyMs)
{
if (resamplerWindowStartTicks == 0)
{
@@ -577,8 +591,10 @@ internal sealed class SessionPlayout : IDisposable
if (bytesOutputInWindow > 0 && bytesWrittenInWindow > 0)
{
// ratio = bytes_sender_produced / bytes_receiver_consumed over the window.
// Above 1.0 = sender clock faster than receiver. Below 1.0 = sender slower.
// Feed-forward: ratio = bytes_sender_produced / bytes_receiver_consumed over the
// window. Above 1.0 = sender clock faster than receiver; below = slower. This is the
// true crystal difference, independent of the resampler rate we apply, so it cleanly
// cancels steady-state drift and the depth feedback below doesn't have to fight it.
// For Ed's hardware (sender slower than receiver) this should settle ~0.9998.
var measuredRatio = (double)bytesWrittenInWindow / bytesOutputInWindow;
if (measuredRatio >= DriftRatioMin && measuredRatio <= DriftRatioMax)
@@ -594,18 +610,32 @@ internal sealed class SessionPlayout : IDisposable
// Subsequent — smooth so a one-window outlier doesn't yank the rate.
smoothedRateRatio = (1.0 - DriftRatioSmoothingNew) * smoothedRateRatio + DriftRatioSmoothingNew * measuredRatio;
}
// Push to the resampler. SetRates(input_rate, output_rate). Input rate
// = measured sender rate; output rate = the receiver's nominal MixSampleRate.
// The resampler now stretches or compresses incoming audio by the ppm
// necessary to keep the playout ring buffer level constant.
driftResampler.SetRates(MixSampleRate * smoothedRateRatio, MixSampleRate);
Interlocked.Increment(ref resamplerUpdatesTotal);
}
// If the measured ratio is outside the sanity window (>5 % off), reject it.
// That happens transiently during session arming, slider raises, or sender
// start-of-stream bursts. Keep the previous ratio rather than yanking.
}
// Feedback: nudge the ring back toward the user's target depth. Without this the
// resampler is a pure rate-matcher and a standing (bloated) buffer never drains — see the
// DepthCorrectionSec / MaxDepthBias note above. depthError > 0 (too deep) biases the
// applied rate UP so the resampler pulls more input per output and drains the buffer
// faster; < 0 biases down to refill. Clamped + spread over DepthCorrectionSec so it's a
// gentle, inaudible pitch trim, not a per-sample discontinuity. No-op until the
// feed-forward has a valid measurement (smoothedRateRatio is meaningless before then).
if (resamplerActivelyTracking)
{
var depthFrames = playout.BufferedBytes / MixBytesPerFrame;
var targetFrames = targetLatencyMs * MixSampleRate / 1000;
var depthError = depthFrames - targetFrames;
var depthCorrection = Math.Clamp(
depthError / (DepthCorrectionSec * MixSampleRate),
-MaxDepthBias, MaxDepthBias);
var appliedRatio = smoothedRateRatio + depthCorrection;
driftResampler.SetRates(MixSampleRate * appliedRatio, MixSampleRate);
Interlocked.Increment(ref resamplerUpdatesTotal);
}
// Anchor the next window.
resamplerWindowStartTicks = nowTicks;
resamplerWindowStartBytesWritten = bytesWrittenNow;