From 4d49d5554ebecd4b2404947fed58691fb946d733 Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:27:35 +0100 Subject: [PATCH] =?UTF-8?q?Issue=20#23:=20instant=20session-kick=20self-he?= =?UTF-8?q?al=20=E2=80=94=20re-attach=20BEFORE=20the=20first=20note,=20no?= =?UTF-8?q?=20polling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ed: ~1s was still too slow; the capture must hear the boot audio from the instant it starts — and no hot check-check-check loop. Event-driven instant path: hook the Windows session-created notification on the default render device (the same AudioSessionStartWatcher that catches a per-app send from its very start). An app setting up an audio session fires it BEFORE its first sound plays — at the boot lock screen that's LogonUI / the Windows tune / NVDA arriving. New session + this capture has never heard audio -> re-open right then, so the re-attached capture is listening from the first note. Zero polling. - CRITICAL filter: our own silence keepalive also creates a session on that device — reacting to our own pid would re-open in an endless loop. Filtered. - Re-open is now capture-only (sender stop -> rebuild specs -> start): network presence stays up, peers see no discovery blip. Shared by both self-heal paths (instant session-kick + the 500ms meter watchdog, which stays as the backstop in case the session notification doesn't cross sessions pre-login) and rate-limited + capped in ONE place so the two paths can't stack re-opens. - Callback hops to the thread pool — never tears down audio objects from inside an audio notification. - Watcher recreated per apply (re-points at the current default device), disposed on suspend/dispose. Gate: 39/39. Co-Authored-By: Claude Opus 4.8 --- src/RemSound.App/ServiceSendHost.cs | 84 +++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 9 deletions(-) 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 { }