diff --git a/src/RemSound.App/ServiceSendHost.cs b/src/RemSound.App/ServiceSendHost.cs index 1b7a13f..10baa96 100644 --- a/src/RemSound.App/ServiceSendHost.cs +++ b/src/RemSound.App/ServiceSendHost.cs @@ -89,7 +89,7 @@ public sealed class ServiceSendHost : IDisposable private long lastCapturePulseTick; private long lastCallbacks = -1; private long lastCallbacksChangeTick; - private bool everHeardAudio; + private volatile bool everHeardAudio; // read from the session-kick COM path as well as the loop private long deafSinceTick; // when continuous meter-audible + capture-silent began; 0 = not deaf private int reopenAttempts; private long lastReopenTick; @@ -99,6 +99,14 @@ public sealed class ServiceSendHost : IDisposable // Meter readers for the captured loopback endpoints — swapped on every (re)apply, read lock-free // on the watch tick (a torn read against a just-disposed device is caught per-device). private volatile MMDevice[] meterDevices = []; + // INSTANT trigger (no polling): Windows raises a session-created notification the moment an app sets + // up an audio session on the device — BEFORE its first sound plays. At the boot lock screen that's + // LogonUI / NVDA arriving. If a new session appears while this capture has never heard audio, we + // re-open right then, so the re-attached capture is listening from the first note. The meter + // watchdog below stays as the backstop in case this notification doesn't cross sessions at the + // lock screen. Our own silence keepalive also creates a session — filtered by process id, or it + // would kick an endless reopen loop. + private AudioSessionStartWatcher? sessionKick; private const int MaxReopens = 3; // per sending stint; refilled on Resume/power-resume private const float AudiblePeak = 0.003f; // endpoint meter level considered "audibly playing" @@ -158,15 +166,11 @@ public sealed class ServiceSendHost : IDisposable var stalled = now - lastCallbacksChangeTick > StallMsToReopen; var deaf = deafSinceTick != 0 && now - deafSinceTick >= DeafMsToReopen; - if (ShouldReopenCapture(deaf, everHeardAudio, stalled, reopenAttempts) && now - lastReopenTick >= ReopenSpacingMs) + if (ShouldReopenCapture(deaf, everHeardAudio, stalled, reopenAttempts)) { - reopenAttempts++; - lastReopenTick = now; - deafSinceTick = 0; - log?.Invoke($"service: capture {(stalled ? "STALLED — callbacks frozen" : $"is DEAF — endpoint meter reads {meter:F3} (audible) but the capture has heard nothing since it opened")} " - + $"— re-opening capture to re-attach to the live audio graph (attempt {reopenAttempts}/{MaxReopens}, issue #23 boot self-heal)"); - var profile = loadProfile(); - if (profile is not null) { Suspend(); ApplyProfile(profile); } + ReopenCapture(stalled + ? "capture STALLED — callbacks frozen" + : $"capture is DEAF — endpoint meter reads {meter:F3} (audible) but the capture has heard nothing since it opened"); return; } @@ -218,6 +222,60 @@ public sealed class ServiceSendHost : IDisposable foreach (var d in old) { try { d.Dispose(); } catch { /* ignore */ } } } + /// Session-created notification (COM thread) — the INSTANT path of the issue-#23 self-heal. + /// An app just set up an audio session on the default render device, i.e. sound is about to start. + /// If this capture has never heard audio, re-open it right now so the re-attached capture is + /// listening from the first note (at boot: the Windows tune / NVDA at the logon screen). Hops to the + /// thread pool — never tear down audio objects from inside an audio notification callback. + private void OnAudioSessionCreated(int pid) + { + if (pid == Environment.ProcessId) return; // our own silence keepalive — reacting would loop forever + if (everHeardAudio || !wantSending || disposed) return; + Task.Run(() => ReopenCapture($"new audio session (pid {pid}) appeared while the capture has heard nothing — sound is starting")); + } + + /// Tear down and re-open JUST the audio capture (sender stop → rebuild specs → start), + /// leaving the network presence up so peers see no discovery blip. The shared exit of both + /// self-heal paths (instant session-kick + meter watchdog); rate-limited and capped here so the + /// two paths can't stack re-opens. + private void ReopenCapture(string reason) + { + lock (gate) + { + if (!running || disposed) return; + var now = Environment.TickCount64; + if (reopenAttempts >= MaxReopens || now - lastReopenTick < ReopenSpacingMs) return; + reopenAttempts++; + lastReopenTick = now; + deafSinceTick = 0; + log?.Invoke($"service: {reason} — re-opening capture to re-attach to the live audio graph " + + $"(attempt {reopenAttempts}/{MaxReopens}, issue #23 boot self-heal)"); + var profile = loadProfile(); + if (profile is null) return; + try + { + sender.Stop(); + var specs = BuildSendSpecs(profile); // re-resolve (the default device may have moved) + if (specs.Count == 0) { log?.Invoke("service: re-open found no send sources — capture left stopped"); running = false; return; } + sender.Configure(specs); + sender.Start(); + SwapMeterDevices(specs); + captureWatchStartTick = Environment.TickCount64; + loggedFirstCallback = false; + loggedZeroCallbacks = false; + lastCallbacks = -1; + lastCallbacksChangeTick = Environment.TickCount64; + pulsePeakMax = 0f; + pulseMeterMax = 0f; + pulseFramesSent = 0; + } + catch (Exception ex) + { + log?.Invoke($"service: capture re-open failed: {ex.GetType().Name}: {ex.Message}"); + } + } + } + /// Convenience factory for the real service: loads the profile from the machine-wide /// (ProgramData) each time it's asked — the same file the config dialog /// writes, readable by the SYSTEM service account. Re-read on each resume so edits are picked up. @@ -267,6 +325,10 @@ public sealed class ServiceSendHost : IDisposable sender.Configure(specs); sender.Start(); SwapMeterDevices(specs); // endpoint-meter readers for the deaf-capture detector + // Instant self-heal trigger: session-created notification on the default render device. + // Recreated per apply so it re-points at the current default after a device change. + try { sessionKick?.Dispose(); } catch { /* ignore */ } + sessionKick = new AudioSessionStartWatcher(OnAudioSessionCreated, msg => log?.Invoke($"service: {msg}")); captureWatchStartTick = Environment.TickCount64; loggedFirstCallback = false; loggedZeroCallbacks = false; @@ -302,6 +364,8 @@ public sealed class ServiceSendHost : IDisposable try { presence.Stop(); } catch (Exception ex) { log?.Invoke($"service: presence stop error {ex.GetType().Name}: {ex.Message}"); } try { sender.Stop(); } catch (Exception ex) { log?.Invoke($"service: stop error {ex.GetType().Name}: {ex.Message}"); } SwapMeterDevices(Array.Empty()); // release the endpoint-meter readers + try { sessionKick?.Dispose(); } catch { /* ignore */ } + sessionKick = null; running = false; log?.Invoke("service: suspended (interactive app present)"); } @@ -541,6 +605,8 @@ public sealed class ServiceSendHost : IDisposable } wantSending = false; try { deviceNotifier?.Dispose(); } catch { } deviceNotifier = null; + try { sessionKick?.Dispose(); } catch { } sessionKick = null; + SwapMeterDevices(Array.Empty()); try { presence.Dispose(); } catch { } try { sender.Stop(); } catch { } try { sender.Dispose(); } catch { }