diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 1b1ac1c..844c785 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -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)"; } + /// 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. + 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"; + } + /// 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), diff --git a/src/RemSound.App/ServiceSendHost.cs b/src/RemSound.App/ServiceSendHost.cs index 443e615..96ea59f 100644 --- a/src/RemSound.App/ServiceSendHost.cs +++ b/src/RemSound.App/ServiceSendHost.cs @@ -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; + /// 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 + /// times — and never again once real audio has been heard. + 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); }