Issue #23: deaf-capture detector via endpoint meter — react in ~1s, not 15s

The 15s silence-pulse trigger was far too slow (Ed: the boot tune would be over
before the first check) and silence alone was always a weak signal — a quiet
machine and a deaf capture look identical from inside the stream.

New detector: read the endpoint's OWN output meter (IAudioMeterInformation
.MasterPeakValue) every watch tick, independently of our capture stream, and
compare it with what the capture is hearing. Device audibly playing (meter >=
0.003) while the capture has heard only silence since it opened = the capture is
provably DEAF -> re-open it immediately so it re-attaches to the live audio graph.

- Service loop tick 1000ms -> 500ms; deafness threshold is TIME-based (450ms of
  continuous divergence) so reaction lands ~1s after the first audible sound —
  fast enough that the boot tune itself comes through — and a fast test cadence
  can't trip it (a healthy capture hears real sound within ~200ms).
- Zero churn risk: a quiet machine reads quiet on BOTH sides, so healthy captures
  never re-open (the old design would have re-opened 3x on any quiet stint).
- Frozen callbacks (2s+) still re-open regardless of loudness.
- Ladder: max 3 re-opens per stint, 2s spacing, ends at the first real audio
  heard; refilled on Resume and power resume.
- Meter readers swapped per (re)apply, disposed on suspend; per-device catch
  absorbs a disposed/invalidated endpoint mid-read.
- 15s pulse is now purely diagnostic and logs capPeak + meterPeak maxima with an
  explicit "(DEVICE AUDIBLE BUT CAPTURE SILENT)" flag.
- Decision core (ShouldReopenCapture) pure + pinned by updated self-test.

Gate: 39/39.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-16 23:21:54 +01:00
co-authored by Claude Opus 4.8
parent c72169c7f5
commit 950a61cbf3
2 changed files with 156 additions and 64 deletions
+14 -12
View File
@@ -1548,23 +1548,25 @@ internal static class SelfTest
return "reachable armed; long-unreachable dropped; grace-window kept; recovery re-arms (issues #8/#15)";
}
/// <summary>Issue #23 boot self-heal decision core: a capture that has heard only silence since it
/// opened (or whose callbacks froze) gets re-opened, capped at 3 attempts per stint, and the ladder
/// ends for good once real audio has been heard. The scenario: at the boot lock screen the machine's
/// <summary>Issue #23 boot self-heal decision core. Scenario: at the boot lock screen the machine's
/// speakers audibly play (Windows tune, NVDA) but a capture attached in the first seconds of boot
/// taps an engine mix the logon-session audio was never wired into — re-attaching lands on the live
/// graph.</summary>
/// taps an engine mix the logon-session audio was never wired into — the endpoint's own METER shows
/// sound while the capture hears none, which proves the capture deaf, and a re-open re-attaches it
/// to the live graph. Quiet machines read quiet on both sides, so healthy captures never re-open;
/// frozen callbacks re-open regardless; the ladder is capped and ends once real audio is heard.</summary>
private static string? ServiceSilentCaptureSelfHeal()
{
Check(ServiceSendHost.ShouldReopenSilentCapture(stalled: false, everHeardAudio: false, attemptsSoFar: 0),
"a capture that has never heard audio must be re-opened");
Check(ServiceSendHost.ShouldReopenSilentCapture(stalled: true, everHeardAudio: true, attemptsSoFar: 0),
Check(ServiceSendHost.ShouldReopenCapture(captureDeaf: true, everHeardAudio: false, stalled: false, attemptsSoFar: 0),
"a provably deaf capture (device audible, capture silent since open) must be re-opened");
Check(ServiceSendHost.ShouldReopenCapture(captureDeaf: false, everHeardAudio: true, stalled: true, attemptsSoFar: 0),
"a stalled capture must be re-opened even after audio has been heard");
Check(!ServiceSendHost.ShouldReopenSilentCapture(stalled: false, everHeardAudio: true, attemptsSoFar: 0),
"a healthy capture that has heard real audio must be left alone");
Check(!ServiceSendHost.ShouldReopenSilentCapture(stalled: false, everHeardAudio: false, attemptsSoFar: 3),
Check(!ServiceSendHost.ShouldReopenCapture(captureDeaf: false, everHeardAudio: false, stalled: false, attemptsSoFar: 0),
"a quiet machine (silent on both sides) must never trigger a re-open");
Check(!ServiceSendHost.ShouldReopenCapture(captureDeaf: true, everHeardAudio: true, stalled: false, attemptsSoFar: 0),
"once real audio has been heard, later silence must not re-open a healthy capture");
Check(!ServiceSendHost.ShouldReopenCapture(captureDeaf: true, everHeardAudio: false, stalled: false, attemptsSoFar: 3),
"the re-open ladder must stop at the attempt cap");
return "silent-since-open and stalled captures re-open; heard-audio healthy captures don't; capped at 3";
return "deaf and stalled captures re-open; quiet or heard-audio captures don't; capped at 3";
}
/// <summary>The fix for "a saved app that launches later never gets captured": the send engine