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:
co-authored by
Claude Opus 4.8
parent
c72169c7f5
commit
950a61cbf3
@@ -1548,23 +1548,25 @@ internal static class SelfTest
|
|||||||
return "reachable armed; long-unreachable dropped; grace-window kept; recovery re-arms (issues #8/#15)";
|
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
|
/// <summary>Issue #23 boot self-heal decision core. Scenario: at the boot lock screen the machine's
|
||||||
/// 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
|
/// 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
|
/// taps an engine mix the logon-session audio was never wired into — the endpoint's own METER shows
|
||||||
/// graph.</summary>
|
/// 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()
|
private static string? ServiceSilentCaptureSelfHeal()
|
||||||
{
|
{
|
||||||
Check(ServiceSendHost.ShouldReopenSilentCapture(stalled: false, everHeardAudio: false, attemptsSoFar: 0),
|
Check(ServiceSendHost.ShouldReopenCapture(captureDeaf: true, everHeardAudio: false, stalled: false, attemptsSoFar: 0),
|
||||||
"a capture that has never heard audio must be re-opened");
|
"a provably deaf capture (device audible, capture silent since open) must be re-opened");
|
||||||
Check(ServiceSendHost.ShouldReopenSilentCapture(stalled: true, everHeardAudio: true, attemptsSoFar: 0),
|
Check(ServiceSendHost.ShouldReopenCapture(captureDeaf: false, everHeardAudio: true, stalled: true, attemptsSoFar: 0),
|
||||||
"a stalled capture must be re-opened even after audio has been heard");
|
"a stalled capture must be re-opened even after audio has been heard");
|
||||||
Check(!ServiceSendHost.ShouldReopenSilentCapture(stalled: false, everHeardAudio: true, attemptsSoFar: 0),
|
Check(!ServiceSendHost.ShouldReopenCapture(captureDeaf: false, everHeardAudio: false, stalled: false, attemptsSoFar: 0),
|
||||||
"a healthy capture that has heard real audio must be left alone");
|
"a quiet machine (silent on both sides) must never trigger a re-open");
|
||||||
Check(!ServiceSendHost.ShouldReopenSilentCapture(stalled: false, everHeardAudio: false, attemptsSoFar: 3),
|
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");
|
"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
|
/// <summary>The fix for "a saved app that launches later never gets captured": the send engine
|
||||||
|
|||||||
@@ -68,79 +68,156 @@ public sealed class ServiceSendHost : IDisposable
|
|||||||
// Capture-health watch (issue #23): the lock-screen bug is "capture open but ZERO buffers ever arrive".
|
// Capture-health watch (issue #23): the lock-screen bug is "capture open but ZERO buffers ever arrive".
|
||||||
// Log the first callback when audio genuinely flows, and an explicit zero-callbacks line after 10s so
|
// Log the first callback when audio genuinely flows, and an explicit zero-callbacks line after 10s so
|
||||||
// the log names the fault instead of just going quiet.
|
// the log names the fault instead of just going quiet.
|
||||||
|
// ---- Capture-health watch + boot self-heal (issue #23) --------------------------------------
|
||||||
|
// Boot fingerprint (Jonathan's logs + Ed): at the boot lock screen the machine's OWN speakers play
|
||||||
|
// the Windows tune and NVDA, the loopback capture opens fine in session 0 and callbacks flow — yet
|
||||||
|
// the mix it taps carries none of that audio and peers hear nothing. Signing in fixes it INSTANTLY
|
||||||
|
// with no change on our side; a capture opened after sign-out works fine at the lock screen. So: a
|
||||||
|
// capture attached in the first seconds of boot can land on an engine mix the logon-session audio
|
||||||
|
// path was never wired into, and Windows raises no device event about it.
|
||||||
|
//
|
||||||
|
// THE DETECTOR: the endpoint's own output METER (IAudioMeterInformation.MasterPeakValue) is read
|
||||||
|
// straight from the device, independently of our capture stream. Every watch tick (~0.5s) we
|
||||||
|
// compare it with what the capture is hearing. Device audibly playing + capture silent since it
|
||||||
|
// opened = the capture is provably DEAF → re-open it immediately (a fresh attach lands on the live
|
||||||
|
// graph) — fast enough that the boot tune itself comes through. A quiet machine reads quiet on
|
||||||
|
// BOTH sides, so nothing ever fires on healthy captures; the first real audio the capture hears
|
||||||
|
// ends the ladder for the stint. Frozen callbacks (2s+) also re-open, deafness aside.
|
||||||
private long captureWatchStartTick;
|
private long captureWatchStartTick;
|
||||||
private bool loggedFirstCallback;
|
private bool loggedFirstCallback;
|
||||||
private bool loggedZeroCallbacks;
|
private bool loggedZeroCallbacks;
|
||||||
private long lastCapturePulseTick;
|
private long lastCapturePulseTick;
|
||||||
private long pulsePrevCallbacks = -1;
|
private long lastCallbacks = -1;
|
||||||
// Issue #23 self-heal state. Boot fingerprint (Jonathan's logs + reports): the machine's own
|
private long lastCallbacksChangeTick;
|
||||||
// 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 bool everHeardAudio;
|
||||||
private int silentReopenAttempts;
|
private long deafSinceTick; // when continuous meter-audible + capture-silent began; 0 = not deaf
|
||||||
private const int MaxSilentReopens = 3;
|
private int reopenAttempts;
|
||||||
|
private long lastReopenTick;
|
||||||
|
private float pulsePeakMax; // loudest capture sample since the last pulse log line
|
||||||
|
private float pulseMeterMax; // loudest endpoint-meter reading since the last pulse log line
|
||||||
|
private long pulseFramesSent;
|
||||||
|
// 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 = [];
|
||||||
|
|
||||||
// How often the periodic capture pulse is written while the service is sending. 15s keeps a
|
private const int MaxReopens = 3; // per sending stint; refilled on Resume/power-resume
|
||||||
// boot-to-login window (typically 30s+) covered by at least two readings without bloating the log.
|
private const float AudiblePeak = 0.003f; // endpoint meter level considered "audibly playing"
|
||||||
private const int CapturePulseIntervalMs = 15_000;
|
private const float SilentPeak = 0.001f; // capture level below this counts as silence
|
||||||
|
// Time-based (not tick-based) so the reaction doesn't depend on the loop cadence and a fast test
|
||||||
|
// cadence can't trip it: a healthy capture hears real sound within ~200ms of it starting, so 450ms
|
||||||
|
// of CONTINUOUS device-audible-but-capture-silent can only mean a genuinely deaf capture. At the
|
||||||
|
// 500ms service tick this re-opens on the second deaf reading — about 1s after the sound starts.
|
||||||
|
private const int DeafMsToReopen = 450;
|
||||||
|
private const int StallMsToReopen = 2_000; // healthy callbacks tick every few ms
|
||||||
|
private const int ReopenSpacingMs = 2_000; // don't thrash between attempts
|
||||||
|
private const int CapturePulseIntervalMs = 15_000; // diagnostic log cadence (NOT the trigger)
|
||||||
|
|
||||||
/// <summary>Pure decision core of the issue-#23 boot self-heal, split out so the self-test can pin
|
/// <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
|
/// it: re-open when the capture is provably deaf (the endpoint meter shows the device audibly
|
||||||
/// <see cref="MaxSilentReopens"/> times — and never again once real audio has been heard.</summary>
|
/// playing while the capture has heard nothing since it opened) or its callbacks froze — but never
|
||||||
internal static bool ShouldReopenSilentCapture(bool stalled, bool everHeardAudio, int attemptsSoFar) =>
|
/// more than <see cref="MaxReopens"/> times a stint, and never for deafness once real audio has
|
||||||
(stalled || !everHeardAudio) && attemptsSoFar < MaxSilentReopens;
|
/// been heard (after that, silence is just silence).</summary>
|
||||||
|
internal static bool ShouldReopenCapture(bool captureDeaf, bool everHeardAudio, bool stalled, int attemptsSoFar) =>
|
||||||
|
(stalled || (captureDeaf && !everHeardAudio)) && attemptsSoFar < MaxReopens;
|
||||||
|
|
||||||
private void WatchCaptureHealth()
|
private void WatchCaptureHealth()
|
||||||
{
|
{
|
||||||
// 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;
|
var now = Environment.TickCount64;
|
||||||
if (now - lastCapturePulseTick >= CapturePulseIntervalMs)
|
|
||||||
{
|
|
||||||
lastCapturePulseTick = now;
|
|
||||||
var callbacks = sender.CaptureCallbacks;
|
|
||||||
var peak = sender.TakeMaxSenderPreEncodePeak();
|
|
||||||
var frames = sender.TakeSenderAudioFramesSent();
|
|
||||||
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;
|
// What is the CAPTURE hearing? (Resets on read — this watch is the only reader; the pulse log
|
||||||
pulsePrevCallbacks = callbacks;
|
// uses the accumulated maxima.) Callbacks alone can't distinguish real sound from our own
|
||||||
if (ShouldReopenSilentCapture(stalled, everHeardAudio, silentReopenAttempts))
|
// silence keepalive feeding back, which is why the peak matters.
|
||||||
|
var peak = sender.TakeMaxSenderPreEncodePeak();
|
||||||
|
if (peak > pulsePeakMax) pulsePeakMax = peak;
|
||||||
|
if (peak >= SilentPeak) { everHeardAudio = true; deafSinceTick = 0; }
|
||||||
|
pulseFramesSent += sender.TakeSenderAudioFramesSent();
|
||||||
|
|
||||||
|
// What is the DEVICE playing? The endpoint's own meter, independent of our capture stream.
|
||||||
|
var meter = 0f;
|
||||||
|
foreach (var dev in meterDevices)
|
||||||
{
|
{
|
||||||
silentReopenAttempts++;
|
try { var v = dev.AudioMeterInformation.MasterPeakValue; if (v > meter) meter = v; }
|
||||||
log?.Invoke($"service: capture {(stalled ? "STALLED — callbacks frozen" : "has heard only silence since it opened")} "
|
catch { /* endpoint invalidated mid-read — the device watcher / next reopen re-resolves */ }
|
||||||
+ $"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)");
|
if (meter > pulseMeterMax) pulseMeterMax = meter;
|
||||||
|
|
||||||
|
// Deafness evidence: device audibly playing while the capture stays silent-since-open. Track
|
||||||
|
// WHEN the continuous deaf state began; any tick that breaks it resets the clock.
|
||||||
|
if (meter >= AudiblePeak && peak < SilentPeak && !everHeardAudio)
|
||||||
|
{
|
||||||
|
if (deafSinceTick == 0) deafSinceTick = now;
|
||||||
|
}
|
||||||
|
else if (meter < AudiblePeak)
|
||||||
|
{
|
||||||
|
deafSinceTick = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback stall: frozen for 2s while sending = the stream is broken regardless of loudness.
|
||||||
|
var callbacks = sender.CaptureCallbacks;
|
||||||
|
if (callbacks != lastCallbacks) { lastCallbacks = callbacks; lastCallbacksChangeTick = now; }
|
||||||
|
var stalled = now - lastCallbacksChangeTick > StallMsToReopen;
|
||||||
|
|
||||||
|
var deaf = deafSinceTick != 0 && now - deafSinceTick >= DeafMsToReopen;
|
||||||
|
if (ShouldReopenCapture(deaf, everHeardAudio, stalled, reopenAttempts) && now - lastReopenTick >= ReopenSpacingMs)
|
||||||
|
{
|
||||||
|
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();
|
var profile = loadProfile();
|
||||||
if (profile is not null) { Suspend(); ApplyProfile(profile); }
|
if (profile is not null) { Suspend(); ApplyProfile(profile); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Periodic diagnostic pulse (accumulated maxima since the previous line).
|
||||||
|
if (now - lastCapturePulseTick >= CapturePulseIntervalMs)
|
||||||
|
{
|
||||||
|
lastCapturePulseTick = now;
|
||||||
|
log?.Invoke($"service: capture pulse — callbacks={callbacks} bytes={sender.CaptureBytes} capPeak={pulsePeakMax:F3} meterPeak={pulseMeterMax:F3} framesSent={pulseFramesSent}"
|
||||||
|
+ (pulsePeakMax < SilentPeak && pulseMeterMax >= AudiblePeak ? " (DEVICE AUDIBLE BUT CAPTURE SILENT)" : "")
|
||||||
|
+ (pulsePeakMax < SilentPeak && pulseMeterMax < AudiblePeak ? " (machine quiet — both sides silent)" : ""));
|
||||||
|
pulsePeakMax = 0f;
|
||||||
|
pulseMeterMax = 0f;
|
||||||
|
pulseFramesSent = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loggedFirstCallback) return;
|
if (loggedFirstCallback) return;
|
||||||
if (sender.CaptureCallbacks > 0)
|
if (callbacks > 0)
|
||||||
{
|
{
|
||||||
loggedFirstCallback = true;
|
loggedFirstCallback = true;
|
||||||
log?.Invoke($"service: first capture callback received — audio is flowing ({sender.CaptureBytes} bytes so far)");
|
log?.Invoke($"service: first capture callback received — audio is flowing ({sender.CaptureBytes} bytes so far)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!loggedZeroCallbacks && Environment.TickCount64 - captureWatchStartTick > 10_000)
|
if (!loggedZeroCallbacks && now - captureWatchStartTick > 10_000)
|
||||||
{
|
{
|
||||||
loggedZeroCallbacks = true;
|
loggedZeroCallbacks = true;
|
||||||
log?.Invoke("service: capture is OPEN but has delivered ZERO audio callbacks after 10s — the audio engine is not feeding the loopback; peers hear nothing (issue #23 signature)");
|
log?.Invoke("service: capture is OPEN but has delivered ZERO audio callbacks after 10s — the audio engine is not feeding the loopback; peers hear nothing (issue #23 signature)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Replace the endpoint-meter readers with ones for the given specs' loopback devices
|
||||||
|
/// (empty to clear). Old readers are disposed after the swap; a watch tick racing the swap reads
|
||||||
|
/// the old snapshot and its per-device catch absorbs a disposed COM object.</summary>
|
||||||
|
private void SwapMeterDevices(IReadOnlyList<CaptureSourceSpec> specs)
|
||||||
|
{
|
||||||
|
var old = meterDevices;
|
||||||
|
var fresh = new List<MMDevice>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var enumerator = new MMDeviceEnumerator();
|
||||||
|
foreach (var id in specs.Where(s => s.Kind == CaptureKind.Loopback && !string.IsNullOrEmpty(s.DeviceId))
|
||||||
|
.Select(s => s.DeviceId).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
try { fresh.Add(enumerator.GetDevice(id)); }
|
||||||
|
catch { /* device gone — opening the capture itself will have failed and logged too */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* enumerator unavailable — the watch runs meter-blind; stall detection still works */ }
|
||||||
|
meterDevices = fresh.ToArray();
|
||||||
|
foreach (var d in old) { try { d.Dispose(); } catch { /* ignore */ } }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Convenience factory for the real service: loads the profile from the machine-wide
|
/// <summary>Convenience factory for the real service: loads the profile from the machine-wide
|
||||||
/// <see cref="ServiceStore"/> (ProgramData) each time it's asked — the same file the config dialog
|
/// <see cref="ServiceStore"/> (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.</summary>
|
/// writes, readable by the SYSTEM service account. Re-read on each resume so edits are picked up.</summary>
|
||||||
@@ -189,14 +266,22 @@ public sealed class ServiceSendHost : IDisposable
|
|||||||
sender.SetReceivers(endpoints);
|
sender.SetReceivers(endpoints);
|
||||||
sender.Configure(specs);
|
sender.Configure(specs);
|
||||||
sender.Start();
|
sender.Start();
|
||||||
|
SwapMeterDevices(specs); // endpoint-meter readers for the deaf-capture detector
|
||||||
captureWatchStartTick = Environment.TickCount64;
|
captureWatchStartTick = Environment.TickCount64;
|
||||||
loggedFirstCallback = false;
|
loggedFirstCallback = false;
|
||||||
loggedZeroCallbacks = false;
|
loggedZeroCallbacks = false;
|
||||||
lastCapturePulseTick = Environment.TickCount64; // first pulse lands one interval after start
|
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
|
// Fresh capture instance — restart the per-capture watch state so a new stream isn't
|
||||||
// everHeardAudio / silentReopenAttempts deliberately NOT reset here: the silent-capture
|
// misread as stalled or deaf on its very first ticks.
|
||||||
// self-heal calls straight back into ApplyProfile, so resetting them here would make the
|
lastCallbacks = -1;
|
||||||
// re-open ladder infinite. They reset per sending STINT in Resume() (and on power resume).
|
lastCallbacksChangeTick = Environment.TickCount64;
|
||||||
|
deafSinceTick = 0;
|
||||||
|
pulsePeakMax = 0f;
|
||||||
|
pulseMeterMax = 0f;
|
||||||
|
pulseFramesSent = 0;
|
||||||
|
// everHeardAudio / reopenAttempts deliberately NOT reset here: the 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
|
// 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.
|
// blind push. Same well-known audio port and the same components the interactive app uses.
|
||||||
presence.Start(RemPacket.DefaultPort, endpoints);
|
presence.Start(RemPacket.DefaultPort, endpoints);
|
||||||
@@ -216,6 +301,7 @@ public sealed class ServiceSendHost : IDisposable
|
|||||||
// interactive app can take it over cleanly, then stop the audio send.
|
// interactive app can take it over cleanly, then stop the audio send.
|
||||||
try { presence.Stop(); } catch (Exception ex) { log?.Invoke($"service: presence stop error {ex.GetType().Name}: {ex.Message}"); }
|
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}"); }
|
try { sender.Stop(); } catch (Exception ex) { log?.Invoke($"service: stop error {ex.GetType().Name}: {ex.Message}"); }
|
||||||
|
SwapMeterDevices(Array.Empty<CaptureSourceSpec>()); // release the endpoint-meter readers
|
||||||
running = false;
|
running = false;
|
||||||
log?.Invoke("service: suspended (interactive app present)");
|
log?.Invoke("service: suspended (interactive app present)");
|
||||||
}
|
}
|
||||||
@@ -227,9 +313,9 @@ public sealed class ServiceSendHost : IDisposable
|
|||||||
{
|
{
|
||||||
var profile = loadProfile();
|
var profile = loadProfile();
|
||||||
if (profile is null) { log?.Invoke("service: no service profile configured — staying idle"); return false; }
|
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).
|
// Fresh sending stint — refill the deaf-capture self-heal ladder (issue #23).
|
||||||
everHeardAudio = false;
|
everHeardAudio = false;
|
||||||
silentReopenAttempts = 0;
|
reopenAttempts = 0;
|
||||||
return ApplyProfile(profile);
|
return ApplyProfile(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +356,11 @@ public sealed class ServiceSendHost : IDisposable
|
|||||||
/// forth. Starts sending immediately if no app is present. A short settle delay before resuming
|
/// forth. Starts sending immediately if no app is present. A short settle delay before resuming
|
||||||
/// stops rapid app open/close from thrashing the engine. Returns when <paramref name="ct"/> is
|
/// stops rapid app open/close from thrashing the engine. Returns when <paramref name="ct"/> is
|
||||||
/// cancelled (service stop).</summary>
|
/// cancelled (service stop).</summary>
|
||||||
public void RunLoop(CancellationToken ct, int pollMs = 1000, int resumeSettleMs = 2000)
|
// 500ms tick (was 1000): the capture-health watch rides this loop, and the deaf-capture detector
|
||||||
|
// must react while the boot tune is still PLAYING — two deaf ticks at 500ms = re-open within ~1s of
|
||||||
|
// the first audible sound. The per-tick work is trivial (a mutex probe, a few counter reads and one
|
||||||
|
// meter read per captured device), so the faster cadence costs nothing measurable.
|
||||||
|
public void RunLoop(CancellationToken ct, int pollMs = 500, int resumeSettleMs = 2000)
|
||||||
=> RunLoopCore(ct, InteractivePresence.IsInteractiveAppRunning, pollMs, resumeSettleMs);
|
=> RunLoopCore(ct, InteractivePresence.IsInteractiveAppRunning, pollMs, resumeSettleMs);
|
||||||
|
|
||||||
/// <summary>Test seam: run the loop against a caller-supplied presence-token name so a test can't
|
/// <summary>Test seam: run the loop against a caller-supplied presence-token name so a test can't
|
||||||
@@ -337,7 +427,7 @@ public sealed class ServiceSendHost : IDisposable
|
|||||||
log?.Invoke("service: re-opening capture after power resume");
|
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.
|
// Wake-from-sleep re-plumbs the audio graph much like boot does — refill the self-heal ladder.
|
||||||
everHeardAudio = false;
|
everHeardAudio = false;
|
||||||
silentReopenAttempts = 0;
|
reopenAttempts = 0;
|
||||||
Suspend();
|
Suspend();
|
||||||
ApplyProfile(profile);
|
ApplyProfile(profile);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user