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
+36 -8
View File
@@ -3626,7 +3626,15 @@ public sealed class MainForm : Form
if (!connected) return;
var all = allSendEndpoints;
// Endpoints the heartbeat currently considers long-dead, keyed by "ip:port".
// Decide which selected peers to actually TRANSMIT audio to. We arm only peers that are
// genuinely reachable — anyone whose heartbeat has been Unreachable for longer than
// AudioPruneUnreachableAfter is dropped from the send set, so RemSound never streams audio
// into a dead address. The heartbeat keeps pinging EVERY selected peer regardless (it runs
// off SetTrackedPeers, not this set), so the instant a peer comes back it is re-armed.
//
// Carve-out: a peer we are actively RECEIVING audio from stays armed even if its heartbeat
// reads Unreachable — that covers an asymmetric path where audio flows but the heartbeat
// round-trip doesn't, so a working stream is never cut.
HashSet<string>? dead = null;
if (all.Length > 0 && heartbeatService is { } hb)
{
@@ -3634,7 +3642,8 @@ public sealed class MainForm : Form
{
if (ph.State == PeerHealthState.Unreachable
&& ph.AgeOfLastPong is { } age
&& age > AudioPruneUnreachableAfter)
&& age > AudioPruneUnreachableAfter
&& !receiver.IsAudioFlowingFrom(ph.AudioEndpoint.Address, TimeSpan.FromSeconds(3)))
{
(dead ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase))
.Add($"{ph.AudioEndpoint.Address}:{ph.AudioEndpoint.Port}");
@@ -3646,17 +3655,24 @@ public sealed class MainForm : Form
? all
: all.Where(ep => !dead.Contains($"{ep.Address}:{ep.Port}")).ToArray();
// Safety net: never silence EVERY peer through pruning. If the whole set looks dead (a
// total network drop), keep sending to all — the wasted SendTo per dead endpoint is
// cheaper than masking a global outage or missing the recovery.
if (armed.Length == 0) armed = all;
// There used to be a "never silence EVERY peer" safety net here that re-armed the whole
// set when pruning would leave nobody. Removed 2026-06-12: when NO peer is reachable we
// must send NOTHING, not blast audio at every dead address. Otherwise someone connected to
// a single peer that goes offline keeps uploading the full stream into the void — exactly
// a-singer's issue #8 ("Not connected to any peer ... sending 51.1 kB/s ... sent 2116.5 MB"
// after the only receiver was switched off hours earlier). The heartbeat still probes all
// peers, so the moment one answers again it is re-armed and audio resumes on its own.
var signature = string.Join("|", armed.Select(ep => $"{ep.Address}:{ep.Port}").OrderBy(s => s, StringComparer.OrdinalIgnoreCase));
if (signature == activeAudioReceiverSignature) return;
activeAudioReceiverSignature = signature;
sender.SetReceivers(armed);
var pruned = all.Length - armed.Length;
if (pruned > 0)
if (armed.Length == 0)
{
logFile.Event($"audio receivers updated: 0 active — no reachable peer, not sending (heartbeat still probing {all.Length})");
}
else if (pruned > 0)
{
logFile.Event($"audio receivers updated: {armed.Length} active, {pruned} pruned (unreachable >{AudioPruneUnreachableAfter.TotalSeconds:0}s); heartbeat still probing all");
}
@@ -5443,6 +5459,7 @@ public sealed class MainForm : Form
var stepRawCapWB = sender.TakeMaxSenderRawCaptureStepWithinBuffer();
var stepRawCap = stepRawCapXB > stepRawCapWB ? stepRawCapXB : stepRawCapWB;
var capPeak = sender.TakeMaxSenderPreEncodePeak();
var sndAudFr = sender.TakeSenderAudioFramesSent();
var clippedNow = sender.ClippedSampleCount;
var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow;
var stepPostDecXB = receiver.TakeMaxPostDecodeStepCrossBuffer();
@@ -5478,7 +5495,7 @@ public sealed class MainForm : Form
$"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " +
$"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " +
$"filtErr={filteredErrorFrames:0.0}f " +
$"capPeak={capPeak:0.000} stepRawCap={stepRawCap:0.000} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepPostDec={stepPostDec:0.000} stepPostRing={stepPostRing:0.000} stepPostRsm={stepPostRsm:0.000} " +
$"capPeak={capPeak:0.000} sndAudFrΔ={sndAudFr} stepRawCap={stepRawCap:0.000} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepPostDec={stepPostDec:0.000} stepPostRing={stepPostRing:0.000} stepPostRsm={stepPostRsm:0.000} " +
$"stepRawCapXB={stepRawCapXB:0.000} stepRawCapWB={stepRawCapWB:0.000} " +
$"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB:0.000} " +
$"stepPreEncAsiXB={stepPreEncAsiXB:0.000} stepPreEncAsiWB={stepPreEncAsiWB:0.000} " +
@@ -5539,8 +5556,15 @@ public sealed class MainForm : Form
var selfMeter = processSelfMeter.Take();
var captureMs = sender.TakeCaptureWorkMs();
var sendMs = sender.TakeSendWorkMs();
// capPeak = loudest sample reaching the encoder; sndAudFrΔ = audio frames that
// actually left the socket this second. Together they split "mic captured but
// nothing sent" (drop at encode/encrypt) from "mic captured and sent" — the
// measurement the "mic only works in ASIO" report needs, now on the talker side too.
var capPeak = sender.TakeMaxSenderPreEncodePeak();
var sndAudFr = sender.TakeSenderAudioFramesSent();
logFile.Event(
$"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} " +
$"capPeak={capPeak:0.000} sndAudFrΔ={sndAudFr} " +
$"stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepRawCap={stepRawCap:0.000} " +
$"stepRawCapXB={stepRawCapXB:0.000} stepRawCapWB={stepRawCapWB:0.000} " +
$"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB:0.000} " +
@@ -6430,6 +6454,10 @@ public sealed class MainForm : Form
public const string ProfileSwitch = "profile-switch";
public const string ProfileMenuOpen = "profile-menu-open";
public const string Update = "update";
// Startup cue is special: it plays from Program.cs before any profile loads, so its
// enable flag and custom-path live machine-wide in AppConfig, not the per-profile
// settings store. This id is still used by the Preferences cue list for display/keying.
public const string Startup = "startup";
}
/// <summary>Load one cue sound. Resolution order: