Issue #23: boot lock-screen silent-capture self-heal (re-open ladder)
Corrected diagnosis (Ed: the machine's OWN speakers audibly play the Windows tune and NVDA at the boot lock screen — the endpoint is NOT silent): a loopback capture attached in the first seconds of boot can land on an audio-engine mix the logon-session audio path was never wired into. Callbacks flow (fed by our own silence keepalive) but carry none of the audio that is audibly playing, and Windows fires no device event about it — the previously-shipped device-change reopen never triggers. Signing in re-plumbs the session audio into the graph, which is why sound starts instantly with zero change on our side; a capture opened after sign-out (graph fully live) works at the lock screen, matching Jonathan's reports exactly. Fix: while sending, the 15s capture pulse now drives a self-heal — if the capture has heard only silence since it opened (pre-encode peak < 0.001 on every pulse), or its callbacks freeze, tear down and re-open the capture so it re-attaches to the live graph. Capped at 3 attempts per sending stint; the first real audio ends the ladder so a quiet-but-healthy capture is never churned. Ladder refills on Resume() and on power resume (wake re-plumbs the graph like boot). Every re-open is logged with the attempt count, so Jonathan's next log shows either "re-open recovered audio" (fixed) or three silent re-opens (deeper Windows routing issue, and we know exactly where we stand). Decision core (ShouldReopenSilentCapture) is pure and pinned by a new self-test. Gate: 39/39. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1003bbfdf2
commit
c72169c7f5
@@ -68,6 +68,7 @@ internal static class SelfTest
|
||||
RunStep(results, "Service send host (headless stream + yield)", ServiceSendHostStream);
|
||||
RunStep(results, "Service network presence (reachable + shell teardown)", ServiceNetworkPresenceReachable);
|
||||
RunStep(results, "Service reachability-gated sending (drop dead peers, re-arm recovered)", ServiceReachabilityGating);
|
||||
RunStep(results, "Service silent-capture self-heal (issue #23 boot re-open ladder)", ServiceSilentCaptureSelfHeal);
|
||||
RunStep(results, "Send-app capture change-detection (catch an app the instant it opens)", SendAppCaptureChangeDetection);
|
||||
RunStep(results, "Remembered applications list is global + clearable", RememberedApplicationsGlobal);
|
||||
RunStep(results, "Send-app lists semantics (ticked → Active, out of Remembered)", SendAppListSemantics);
|
||||
@@ -1547,6 +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
|
||||
/// 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>
|
||||
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),
|
||||
"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),
|
||||
"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";
|
||||
}
|
||||
|
||||
/// <summary>The fix for "a saved app that launches later never gets captured": the send engine
|
||||
/// re-applies capture whenever the ticked apps' running process ids change. This tests the pure
|
||||
/// change-detector that drives it — the signature is stable while nothing changes (so we don't churn),
|
||||
|
||||
@@ -72,26 +72,59 @@ public sealed class ServiceSendHost : IDisposable
|
||||
private bool loggedFirstCallback;
|
||||
private bool loggedZeroCallbacks;
|
||||
private long lastCapturePulseTick;
|
||||
private long pulsePrevCallbacks = -1;
|
||||
// Issue #23 self-heal state. Boot fingerprint (Jonathan's logs + reports): the machine's own
|
||||
// speakers PLAY the Windows tune and NVDA at the boot lock screen, the service's loopback capture
|
||||
// opens fine and callbacks flow — yet the mix it taps carries none of that audio, and peers hear
|
||||
// nothing. Signing in fixes it INSTANTLY with zero change on our side; a capture opened after a
|
||||
// sign-out (audio graph fully live) works at the lock screen. Conclusion: a capture attached in the
|
||||
// first seconds of boot can land on the engine before the logon-session audio path is wired into
|
||||
// it, and Windows fires no device event to tell us — so we RE-OPEN it ourselves. Ladder: while
|
||||
// sending, if the capture has been silent since it opened (or its callbacks freeze), re-open the
|
||||
// capture, at most MaxSilentReopens times per sending stint. The first real audio (peak ≥ 0.001)
|
||||
// ends the ladder for the stint, so a quiet-but-healthy capture is left alone after that; a stint
|
||||
// that IS genuinely silent throughout just gets 3 logged, inaudible re-opens.
|
||||
private bool everHeardAudio;
|
||||
private int silentReopenAttempts;
|
||||
private const int MaxSilentReopens = 3;
|
||||
|
||||
// How often the periodic capture pulse is written while the service is sending. 15s keeps a
|
||||
// boot-to-login window (typically 30s+) covered by at least two readings without bloating the log.
|
||||
private const int CapturePulseIntervalMs = 15_000;
|
||||
|
||||
/// <summary>Pure decision core of the issue-#23 boot self-heal, split out so the self-test can pin
|
||||
/// it: re-open when the capture stalled or has never heard audio this stint, but never more than
|
||||
/// <see cref="MaxSilentReopens"/> times — and never again once real audio has been heard.</summary>
|
||||
internal static bool ShouldReopenSilentCapture(bool stalled, bool everHeardAudio, int attemptsSoFar) =>
|
||||
(stalled || !everHeardAudio) && attemptsSoFar < MaxSilentReopens;
|
||||
|
||||
private void WatchCaptureHealth()
|
||||
{
|
||||
// Jonathan's follow-up log proved callbacks CAN flow at the lock screen while peers still hear
|
||||
// nothing — callbacks alone can't distinguish real sound from the silence keepalive feeding back.
|
||||
// So while sending, also pulse the loudest pre-encode sample + frames actually sent every 15s:
|
||||
// peak≈0.000 pre-login flipping to real values at login = the captured endpoint mix is genuinely
|
||||
// silent on the lock screen (device/session routing), NOT a service pipeline fault.
|
||||
// Callbacks alone can't distinguish real sound from our own silence keepalive feeding back, so
|
||||
// the pulse also samples the loudest pre-encode sample + frames actually handed to the wire.
|
||||
var now = Environment.TickCount64;
|
||||
if (now - lastCapturePulseTick >= CapturePulseIntervalMs)
|
||||
{
|
||||
lastCapturePulseTick = now;
|
||||
var callbacks = sender.CaptureCallbacks;
|
||||
var peak = sender.TakeMaxSenderPreEncodePeak();
|
||||
var frames = sender.TakeSenderAudioFramesSent();
|
||||
log?.Invoke($"service: capture pulse — callbacks={sender.CaptureCallbacks} bytes={sender.CaptureBytes} peak={peak:F3} framesSent={frames}"
|
||||
if (peak >= 0.001f) everHeardAudio = true;
|
||||
log?.Invoke($"service: capture pulse — callbacks={callbacks} bytes={sender.CaptureBytes} peak={peak:F3} framesSent={frames}"
|
||||
+ (peak < 0.001f ? " (capturing SILENCE — nothing audible in the endpoint mix)" : ""));
|
||||
|
||||
var stalled = pulsePrevCallbacks >= 0 && callbacks == pulsePrevCallbacks;
|
||||
pulsePrevCallbacks = callbacks;
|
||||
if (ShouldReopenSilentCapture(stalled, everHeardAudio, silentReopenAttempts))
|
||||
{
|
||||
silentReopenAttempts++;
|
||||
log?.Invoke($"service: capture {(stalled ? "STALLED — callbacks frozen" : "has heard only silence since it opened")} "
|
||||
+ $"while the endpoint may be audibly playing — re-opening capture to re-attach to the live audio graph "
|
||||
+ $"(attempt {silentReopenAttempts}/{MaxSilentReopens}, issue #23 boot self-heal)");
|
||||
var profile = loadProfile();
|
||||
if (profile is not null) { Suspend(); ApplyProfile(profile); }
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (loggedFirstCallback) return;
|
||||
@@ -160,6 +193,10 @@ public sealed class ServiceSendHost : IDisposable
|
||||
loggedFirstCallback = false;
|
||||
loggedZeroCallbacks = false;
|
||||
lastCapturePulseTick = Environment.TickCount64; // first pulse lands one interval after start
|
||||
pulsePrevCallbacks = -1; // fresh capture instance — the counter restarts, don't misread it as a stall
|
||||
// everHeardAudio / silentReopenAttempts deliberately NOT reset here: the silent-capture
|
||||
// self-heal calls straight back into ApplyProfile, so resetting them here would make the
|
||||
// re-open ladder infinite. They reset per sending STINT in Resume() (and on power resume).
|
||||
// Come up on the network too, so the peers can discover and connect to us — not just receive a
|
||||
// blind push. Same well-known audio port and the same components the interactive app uses.
|
||||
presence.Start(RemPacket.DefaultPort, endpoints);
|
||||
@@ -190,6 +227,9 @@ public sealed class ServiceSendHost : IDisposable
|
||||
{
|
||||
var profile = loadProfile();
|
||||
if (profile is null) { log?.Invoke("service: no service profile configured — staying idle"); return false; }
|
||||
// Fresh sending stint — refill the silent-capture self-heal ladder (issue #23).
|
||||
everHeardAudio = false;
|
||||
silentReopenAttempts = 0;
|
||||
return ApplyProfile(profile);
|
||||
}
|
||||
|
||||
@@ -295,6 +335,9 @@ public sealed class ServiceSendHost : IDisposable
|
||||
var profile = loadProfile();
|
||||
if (profile is null) return;
|
||||
log?.Invoke("service: re-opening capture after power resume");
|
||||
// Wake-from-sleep re-plumbs the audio graph much like boot does — refill the self-heal ladder.
|
||||
everHeardAudio = false;
|
||||
silentReopenAttempts = 0;
|
||||
Suspend();
|
||||
ApplyProfile(profile);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user