Parallel code review of this session's changes surfaced several real bugs; fixed the substantive ones (judgment/cleanup calls held for Ed): - HIGH Clearing "remembered peers" was resurrected on the next launch: the per-profile -> global migration re-ran every startup and re-unioned the profile file's stale copy. Added a one-time AppConfig.RememberedPeersMigrated marker so migration runs once and a cleared list stays cleared. Self-test pins the clear-then-reload scenario. - MED PushModeWasapiBackend.Start rethrew on a device-open failure; nothing up the stack wraps it, so device churn (a push-eligible single WASAPI source unplugged mid-open) could crash the app. Now logs and stays stopped like MixingEngine/ASIO; the device watcher / self-heal re-open when a device returns. - MED Service self-heal: (a) the re-open "no send sources" path left PerformanceMode ON and presence up while streaming nothing - now releases cleanly; (b) the 3-attempt ladder never refunded, so 3 hiccups over a days-long stint meant permanent silence - now refunds when real audio is heard, and resets on a device hot-plug. - MED ApplyProfile resolved peers (DNS) and enumerated devices INSIDE the gate lock - a boot-time DNS hang as SYSTEM stalled Suspend()/yield/self-heal. Moved outside the lock. - LOW AudioSessionStartWatcher leaked the AudioSessionManager on every Rehook (the WASAPI handle-leak fingerprint) - now disposed. New lifecycle self-test. - LOW stale docstrings (send-all master toggle; ServiceUpdate in-place scheme; Profile .SendAllApplications "neither reads nor writes"). Gate: 42/42 (added peers-migration + session-watcher-lifecycle tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
666 lines
39 KiB
C#
666 lines
39 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using NAudio.CoreAudioApi;
|
|
using RemSound.Core;
|
|
using RemSound.Sender;
|
|
|
|
namespace RemSound.App;
|
|
|
|
/// <summary>
|
|
/// The headless engine behind the RemSound Windows service: it loads the designated send-only profile
|
|
/// and streams it to the profile's peers, with no window, tray, hotkeys or screen reader. It YIELDS to
|
|
/// the interactive app — while a normal RemSound is open it suspends (stops capturing, drops the send),
|
|
/// resuming when the app closes or crashes (see <see cref="InteractivePresence"/>).
|
|
///
|
|
/// <para>Send-only and WASAPI-only by design: ASIO can't run in a service, and receive is impossible on
|
|
/// Windows 11 with no user logged in, so neither is attempted. v1 sends directly to the profile's
|
|
/// configured peer addresses (LAN / port-forwarded / reachable hosts); NAT hole-punching and relay
|
|
/// discovery are the interactive app's job, not the service's.</para>
|
|
///
|
|
/// <para>Structured so the mechanism is unit-testable without a real service: <see cref="ApplyProfile"/>,
|
|
/// <see cref="Suspend"/> and <see cref="Resume"/> are driven directly by the self-tests, and
|
|
/// <see cref="RunLoop"/> wires them to the interactive-presence token.</para>
|
|
/// </summary>
|
|
public sealed class ServiceSendHost : IDisposable
|
|
{
|
|
private readonly Func<Profile?> loadProfile;
|
|
private readonly Action<string>? log;
|
|
private readonly AudioSender sender = new();
|
|
// Network reachability: discovery + listener + heartbeat, so a peer can actually FIND and CONNECT to
|
|
// the service (the bare sender only ever pushed blindly to fixed addresses). Brought up alongside the
|
|
// sender while we're streaming, and fully torn down to a shell whenever we yield to the interactive app.
|
|
private readonly ServiceNetworkPresence presence;
|
|
private readonly object gate = new();
|
|
private bool running; // the engine is actively sending
|
|
private bool disposed;
|
|
|
|
// Event-driven device-set watcher (the same mechanism the main window uses): it fires ONLY when a
|
|
// device is added/removed/changes state or the default changes — no background polling, nothing that
|
|
// builds up. While the service should be sending, that's our cue to (re)open capture: it covers the
|
|
// audio stack finishing coming up at boot, a device being plugged/unplugged, and the audio service
|
|
// restarting. Debounced because one hot-plug fires several notifications in quick succession.
|
|
private AudioDeviceChangeNotifier? deviceNotifier;
|
|
private volatile bool wantSending; // true while the app is absent and we intend to stream
|
|
private long lastDeviceChangeTick;
|
|
|
|
// Reachability-gated sending (issues #8 / #15): stream ONLY to peers the heartbeat can reach, and drop
|
|
// any that stay unreachable — never blast audio into a dead address forever. The heartbeat keeps
|
|
// probing the FULL set, so a peer that comes back is re-armed on the next refresh. Mirrors the app's
|
|
// RefreshAudioReceivers. Send-only, so there's no "actively receiving" carve-out.
|
|
private static readonly TimeSpan PruneUnreachableAfter = TimeSpan.FromSeconds(30);
|
|
private IPEndPoint[] allEndpoints = [];
|
|
private string? armedSignature;
|
|
|
|
/// <param name="loadProfile">Supplies the current service profile (re-read on each resume so edits
|
|
/// are picked up). Returns null if none is configured.</param>
|
|
/// <param name="log">Optional diagnostic sink.</param>
|
|
public ServiceSendHost(Func<Profile?> loadProfile, Action<string>? log = null)
|
|
{
|
|
this.loadProfile = loadProfile;
|
|
this.log = log;
|
|
presence = new ServiceNetworkPresence(sender, log);
|
|
// Wire the audio engine's own diagnostics into the service log — capture opens/failures, backend
|
|
// switches, the silence keepalive, composite mode. Without this the service log is blind below
|
|
// "streaming N sources" (issue #23 taught us that the hard way).
|
|
sender.Diagnostic = msg => log?.Invoke($"sender: {msg}");
|
|
}
|
|
|
|
// 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
|
|
// 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 bool loggedFirstCallback;
|
|
private bool loggedZeroCallbacks;
|
|
private long lastCapturePulseTick;
|
|
private long lastCallbacks = -1;
|
|
private long lastCallbacksChangeTick;
|
|
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;
|
|
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 = [];
|
|
// 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"
|
|
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
|
|
/// it: re-open when the capture is provably deaf (the endpoint meter shows the device audibly
|
|
/// playing while the capture has heard nothing since it opened) or its callbacks froze — but never
|
|
/// more than <see cref="MaxReopens"/> times a stint, and never for deafness once real audio has
|
|
/// 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()
|
|
{
|
|
var now = Environment.TickCount64;
|
|
|
|
// What is the CAPTURE hearing? (Resets on read — this watch is the only reader; the pulse log
|
|
// uses the accumulated maxima.) Callbacks alone can't distinguish real sound from our own
|
|
// silence keepalive feeding back, which is why the peak matters.
|
|
var peak = sender.TakeMaxSenderPreEncodePeak();
|
|
if (peak > pulsePeakMax) pulsePeakMax = peak;
|
|
// Real audio proves the capture works — refund the self-heal ladder so a LATER hiccup (hours or
|
|
// days into an always-on stream) gets fresh re-open attempts instead of finding the budget spent.
|
|
if (peak >= SilentPeak) { everHeardAudio = true; deafSinceTick = 0; reopenAttempts = 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)
|
|
{
|
|
try { var v = dev.AudioMeterInformation.MasterPeakValue; if (v > meter) meter = v; }
|
|
catch { /* endpoint invalidated mid-read — the device watcher / next reopen re-resolves */ }
|
|
}
|
|
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))
|
|
{
|
|
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;
|
|
}
|
|
|
|
// 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 (callbacks > 0)
|
|
{
|
|
loggedFirstCallback = true;
|
|
log?.Invoke($"service: first capture callback received — audio is flowing ({sender.CaptureBytes} bytes so far)");
|
|
return;
|
|
}
|
|
if (!loggedZeroCallbacks && now - captureWatchStartTick > 10_000)
|
|
{
|
|
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)");
|
|
}
|
|
}
|
|
|
|
/// <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>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.</summary>
|
|
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"));
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
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)
|
|
{
|
|
// The source went away (e.g. the only loopback device was unplugged). Release the whole
|
|
// send stack — presence, meter readers, the session watcher AND the perf-mode overrides
|
|
// — instead of sitting "running" with High priority / EcoQoS-off held while streaming
|
|
// nothing. The device-change watcher re-opens (via ApplyProfile) when a device returns.
|
|
log?.Invoke("service: re-open found no send sources — releasing until a device returns");
|
|
try { presence.Stop(); } catch { }
|
|
SwapMeterDevices(Array.Empty<CaptureSourceSpec>());
|
|
try { sessionKick?.Dispose(); } catch { } sessionKick = null;
|
|
try { PerformanceMode.Apply(false, msg => log?.Invoke($"service: {msg}")); } catch { }
|
|
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}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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
|
|
/// writes, readable by the SYSTEM service account. Re-read on each resume so edits are picked up.</summary>
|
|
public static ServiceSendHost FromConfig(Action<string>? log = null) => new(ServiceStore.LoadProfile, log);
|
|
|
|
public bool IsSending { get { lock (gate) return running; } }
|
|
|
|
/// <summary>Test seam: is the network presence (discovery + listener + heartbeat) currently up? Tracks
|
|
/// sending — up while streaming, torn down to a shell while yielded to the interactive app.</summary>
|
|
internal bool IsNetworkPresenceUpForTest => presence.IsUp;
|
|
|
|
/// <summary>Test seam: the crypto material the host pushed to the sender + the codec/frame it set, so
|
|
/// a self-test can prove the service configures the sender exactly like the main app.</summary>
|
|
internal (byte[]? Key, byte[]? Fingerprint, AudioTransportCodec Codec, int Frame) SenderConfigForTest =>
|
|
(sender.AudioKey, sender.AudioFingerprint, sender.Codec, sender.OpusFrameSamplesPerChannel);
|
|
|
|
/// <summary>Builds the send sources, peer endpoints and encryption key from a profile and starts the
|
|
/// sender. Idempotent-ish: call <see cref="Suspend"/> before re-applying a different profile. Returns
|
|
/// false (and stays stopped) if the profile has nothing to send or no reachable peers.</summary>
|
|
public bool ApplyProfile(Profile profile)
|
|
{
|
|
// Resolve sources + peer addresses OUTSIDE the lock. BuildEndpoints does DNS (Dns.GetHostAddresses)
|
|
// and BuildSendSpecs enumerates devices — either can block for seconds at boot as SYSTEM before the
|
|
// network/audio stack is fully up. Doing that while holding `gate` would stall Suspend() (yielding
|
|
// to the interactive app), the RunLoop tick and the self-heal for the whole timeout.
|
|
if (disposed) return false;
|
|
var specs = BuildSendSpecs(profile);
|
|
var endpoints = BuildEndpoints(profile);
|
|
lock (gate)
|
|
{
|
|
if (disposed) return false;
|
|
if (specs.Count == 0) { log?.Invoke("service: profile has no WASAPI send sources — nothing to stream"); return false; }
|
|
if (endpoints.Count == 0) { log?.Invoke("service: profile has no reachable peers — nothing to stream to"); return false; }
|
|
|
|
// Encryption: derive BOTH the key AND the fingerprint from the plain password, exactly like
|
|
// MainForm.RecomputeAudioCrypto. The peer verifies the fingerprint before accepting a stream —
|
|
// sending the key without it would get the service's audio rejected at the far end.
|
|
var plainPassword = string.IsNullOrEmpty(profile.Password) ? "" : RemSoundCrypto.Deobfuscate(profile.Password);
|
|
sender.AudioKey = string.IsNullOrEmpty(plainPassword) ? null : RemSoundCrypto.DeriveKey(plainPassword);
|
|
sender.AudioFingerprint = string.IsNullOrEmpty(plainPassword) ? null : RemSoundCrypto.Fingerprint(plainPassword);
|
|
// The service's audio transport is FIXED to the known-good live-jamming config, regardless of
|
|
// what the profile carries (the config dialog no longer exposes these — Ed, 2026-07-17). Raw
|
|
// PCM sounded hideous over the service; Opus at the 2.5 ms live frame with Small packets and
|
|
// lock-to-clock is what sounds right. Forcing it here means a stale or hand-edited profile can
|
|
// never put the service back on a bad codec.
|
|
const AudioTransportCodec serviceCodec = AudioTransportCodec.Opus;
|
|
const int serviceOpusFrameSamples = 120; // 2.5 ms at 48 kHz
|
|
const SendRate serviceSendRate = SendRate.Tight; // "Small" packets
|
|
sender.ConfigureCodec(serviceCodec, MainForm.EffectiveOpusFrameSamples(serviceCodec, serviceOpusFrameSamples, serviceSendRate));
|
|
sender.SetSendRate(serviceSendRate);
|
|
sender.SetTightLatency(true); // lock to audio clock — always on
|
|
// Arm the full set to begin with (nothing is known-dead yet); RefreshSendArming then prunes any
|
|
// peer the heartbeat can't reach and re-arms it when it recovers.
|
|
allEndpoints = endpoints.ToArray();
|
|
armedSignature = null;
|
|
sender.SetReceivers(endpoints);
|
|
sender.Configure(specs);
|
|
// A device-open failure at Start must NOT throw out of the service loop. With lock-to-clock
|
|
// always on, the WASAPI lane uses push-mode, which opens the endpoint synchronously and throws
|
|
// (e.g. ArgumentException) if the device id is invalid or the device has vanished. Swallow it:
|
|
// presence still comes up, and the device-change watcher / self-heal re-open the capture when a
|
|
// good device appears. Without this guard a disappeared device would crash the whole service.
|
|
try { sender.Start(); }
|
|
catch (Exception ex) { log?.Invoke($"service: capture start failed ({ex.GetType().Name}: {ex.Message}) — presence up; will re-open when a device is available"); }
|
|
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;
|
|
lastCapturePulseTick = Environment.TickCount64; // first pulse lands one interval after start
|
|
// Fresh capture instance — restart the per-capture watch state so a new stream isn't
|
|
// misread as stalled or deaf on its very first ticks.
|
|
lastCallbacks = -1;
|
|
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
|
|
// blind push. Same well-known audio port and the same components the interactive app uses.
|
|
presence.Start(RemPacket.DefaultPort, endpoints);
|
|
running = true;
|
|
// Un-throttle the process while streaming. A headless Windows SERVICE is treated by the OS as a
|
|
// background process and gets aggressively downclocked (EcoQoS), migrated onto efficiency cores
|
|
// and given a coarse scheduler quantum — which starves the send loop and makes the stream
|
|
// CRACKLE even though capture and the network look clean. The interactive app only does this on
|
|
// the user's opt-in "high priority" toggle, but the service has no foreground/battery use case
|
|
// (it exists solely to stream), so it always engages while sending. Idempotent; released in
|
|
// Suspend. This is the fix for "the service sounds hideous vs the main app".
|
|
try { PerformanceMode.Apply(true, msg => log?.Invoke($"service: {msg}")); } catch { /* best-effort */ }
|
|
log?.Invoke($"service: streaming \"{profile.Title}\" — {specs.Count} source(s) to {endpoints.Count} peer(s)");
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>Stops sending and releases capture. Safe to call when already stopped.</summary>
|
|
public void Suspend()
|
|
{
|
|
lock (gate)
|
|
{
|
|
if (!running) return;
|
|
// Vacate the network FIRST (stop announcing, unbind the port, stop the heartbeat) so the
|
|
// 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 { sender.Stop(); } catch (Exception ex) { log?.Invoke($"service: stop error {ex.GetType().Name}: {ex.Message}"); }
|
|
SwapMeterDevices(Array.Empty<CaptureSourceSpec>()); // release the endpoint-meter readers
|
|
try { sessionKick?.Dispose(); } catch { /* ignore */ }
|
|
sessionKick = null;
|
|
try { PerformanceMode.Apply(false, msg => log?.Invoke($"service: {msg}")); } catch { /* best-effort */ }
|
|
running = false;
|
|
log?.Invoke("service: suspended (interactive app present)");
|
|
}
|
|
}
|
|
|
|
/// <summary>Re-reads the current service profile and starts sending. Used when the interactive app
|
|
/// goes away, so any edits it made are picked up.</summary>
|
|
public bool Resume()
|
|
{
|
|
var profile = loadProfile();
|
|
if (profile is null) { log?.Invoke("service: no service profile configured — staying idle"); return false; }
|
|
// Fresh sending stint — refill the deaf-capture self-heal ladder (issue #23).
|
|
everHeardAudio = false;
|
|
reopenAttempts = 0;
|
|
return ApplyProfile(profile);
|
|
}
|
|
|
|
/// <summary>Pure, testable: which endpoints to actively stream to — the full set minus any peer the
|
|
/// heartbeat reports as continuously unreachable for longer than <paramref name="pruneAfter"/>. A peer
|
|
/// that's reachable, or still within the grace window, stays armed. Mirrors the app's RefreshAudioReceivers.</summary>
|
|
internal static IPEndPoint[] ComputeArmedEndpoints(IReadOnlyList<IPEndPoint> all, IReadOnlyList<PeerHealth> health, TimeSpan pruneAfter)
|
|
{
|
|
HashSet<string>? dead = null;
|
|
foreach (var ph in health)
|
|
{
|
|
if (ph.State == PeerHealthState.Unreachable && ph.AgeOfLastPong is { } age && age > pruneAfter)
|
|
(dead ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase)).Add($"{ph.AudioEndpoint.Address}:{ph.AudioEndpoint.Port}");
|
|
}
|
|
return dead is null ? all.ToArray() : all.Where(ep => !dead.Contains($"{ep.Address}:{ep.Port}")).ToArray();
|
|
}
|
|
|
|
/// <summary>Re-arm the sender to only the reachable peers, using the heartbeat health. Cheap and
|
|
/// idempotent — only touches the sender when the armed set actually changes. Called on the service's
|
|
/// existing poll tick while streaming, so there's no extra timer.</summary>
|
|
private void RefreshSendArming()
|
|
{
|
|
lock (gate)
|
|
{
|
|
if (!running || allEndpoints.Length == 0) return;
|
|
var armed = ComputeArmedEndpoints(allEndpoints, presence.PeerHealthSnapshot(), PruneUnreachableAfter);
|
|
var sig = string.Join("|", armed.Select(ep => $"{ep.Address}:{ep.Port}").OrderBy(s => s, StringComparer.OrdinalIgnoreCase));
|
|
if (sig == armedSignature) return;
|
|
armedSignature = sig;
|
|
sender.SetReceivers(armed);
|
|
log?.Invoke(armed.Length == 0
|
|
? $"service: 0 reachable peers — holding audio (heartbeat still probing {allEndpoints.Length})"
|
|
: $"service: streaming to {armed.Length}/{allEndpoints.Length} reachable peer(s)");
|
|
}
|
|
}
|
|
|
|
/// <summary>The service's main loop: watch the interactive-presence token and hand the send back and
|
|
/// 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
|
|
/// cancelled (service stop).</summary>
|
|
// 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);
|
|
|
|
/// <summary>Test seam: run the loop against a caller-supplied presence-token name so a test can't
|
|
/// collide with a real running app on the production token.</summary>
|
|
internal void RunLoopWithToken(CancellationToken ct, string tokenName, int pollMs, int resumeSettleMs)
|
|
=> RunLoopCore(ct, () => InteractivePresence.IsInteractiveAppRunning(tokenName), pollMs, resumeSettleMs);
|
|
|
|
private void RunLoopCore(CancellationToken ct, Func<bool> isAppPresent, int pollMs, int resumeSettleMs)
|
|
{
|
|
// Register the event-driven device watcher for the life of the loop. Its callback re-opens
|
|
// capture when the device set changes, so we never poll for device readiness.
|
|
try { deviceNotifier ??= new AudioDeviceChangeNotifier(OnDeviceSetChanged); }
|
|
catch (Exception ex) { log?.Invoke($"service: device-change watcher unavailable ({ex.GetType().Name}) — relying on app-transition re-opens"); }
|
|
|
|
var appWasPresent = true; // force an initial evaluation
|
|
var absentSince = Environment.TickCount64;
|
|
var triedThisAbsence = false;
|
|
var haveSeenApp = false; // the settle delay only matters AFTER a real app yield
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
var appPresent = isAppPresent();
|
|
if (appPresent)
|
|
{
|
|
haveSeenApp = true;
|
|
wantSending = false;
|
|
if (IsSending) Suspend();
|
|
absentSince = long.MaxValue;
|
|
triedThisAbsence = false;
|
|
}
|
|
else
|
|
{
|
|
if (appWasPresent) { absentSince = Environment.TickCount64; triedThisAbsence = false; } // app just left
|
|
// The resume-settle delay exists to stop rapid app open/close from thrashing the engine —
|
|
// but ONLY once the app has actually been present. At BOOT the app has never run, so waiting
|
|
// it out is pure dead time in which the Windows startup sound plays uncaptured. Start
|
|
// immediately on the first-ever stint; apply the settle only on later app→absent transitions.
|
|
if (!haveSeenApp || Environment.TickCount64 - absentSince >= resumeSettleMs)
|
|
{
|
|
wantSending = true;
|
|
// One start attempt per absence. If capture isn't ready yet (audio stack still coming
|
|
// up at boot, device absent), the device-change watcher re-opens it the moment a device
|
|
// appears — no per-tick retry loop that would keep churning in the background.
|
|
if (!triedThisAbsence && !IsSending) { Resume(); triedThisAbsence = true; }
|
|
}
|
|
}
|
|
// While streaming, re-arm to only the reachable peers (drop dead ones, pick up recovered ones)
|
|
// and watch capture health (issue #23: open-but-starved loopback). Piggybacks this existing
|
|
// tick — no extra timer, no background pile-up.
|
|
if (IsSending)
|
|
{
|
|
RefreshSendArming();
|
|
WatchCaptureHealth();
|
|
}
|
|
appWasPresent = appPresent;
|
|
ct.WaitHandle.WaitOne(pollMs);
|
|
}
|
|
wantSending = false;
|
|
Suspend();
|
|
}
|
|
|
|
/// <summary>Force a re-open of capture if we intend to send — called after a power resume, when the
|
|
/// audio devices have re-initialised and the current capture may be dead. The device-change watcher
|
|
/// usually catches this too, but a resume doesn't always fire an endpoint change, so we re-open
|
|
/// explicitly to be safe.</summary>
|
|
public void ReopenAfterResume()
|
|
{
|
|
if (!wantSending || disposed) return;
|
|
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;
|
|
reopenAttempts = 0;
|
|
Suspend();
|
|
ApplyProfile(profile);
|
|
}
|
|
|
|
/// <summary>Device-set change callback (COM thread). While we intend to send, (re)open capture — this
|
|
/// is the event that fires when the audio stack finishes coming up at boot, a device is plugged or
|
|
/// unplugged, or the audio service restarts. Debounced: a single hot-plug fires several notifications.</summary>
|
|
private void OnDeviceSetChanged()
|
|
{
|
|
if (!wantSending || disposed) return;
|
|
var now = Environment.TickCount64;
|
|
lock (gate)
|
|
{
|
|
if (now - lastDeviceChangeTick < 750) return; // coalesce the burst
|
|
lastDeviceChangeTick = now;
|
|
}
|
|
var profile = loadProfile();
|
|
if (profile is null) return;
|
|
// A device hot-plug re-plumbs the audio graph much like a power resume or a fresh boot — refill the
|
|
// self-heal ladder so a brand-new device that comes up momentarily deaf still gets its re-opens.
|
|
everHeardAudio = false;
|
|
reopenAttempts = 0;
|
|
Suspend();
|
|
ApplyProfile(profile);
|
|
}
|
|
|
|
// WASAPI-only send specs from a profile. Mirrors the app's applications-vs-devices logic but never
|
|
// touches ASIO (the service can't). Applications mode needs Windows 10 19041+.
|
|
internal static List<CaptureSourceSpec> BuildSendSpecs(Profile p)
|
|
{
|
|
var specs = new List<CaptureSourceSpec>();
|
|
var appsMode = ProcessLoopbackCapture.IsSupported
|
|
&& string.Equals(p.WasapiSendMode, "applications", StringComparison.OrdinalIgnoreCase);
|
|
if (appsMode)
|
|
{
|
|
if (p.SendAllApplications)
|
|
{
|
|
var def = ResolveDefaultRenderId();
|
|
if (def is not null) specs.Add(new CaptureSourceSpec(def, CaptureKind.Loopback, "All applications (system audio)"));
|
|
}
|
|
else
|
|
{
|
|
foreach (var name in p.SelectedSendApplications.Distinct(StringComparer.OrdinalIgnoreCase))
|
|
foreach (var pid in AudioAppEnumerator.PidsForProcessName(name))
|
|
specs.Add(new CaptureSourceSpec(ProcessLoopbackId.Format(pid), CaptureKind.ProcessLoopback, name));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
foreach (var id in p.SelectedWasapiSendOutputs.Distinct())
|
|
specs.Add(new CaptureSourceSpec(id, CaptureKind.Loopback, id));
|
|
}
|
|
foreach (var id in p.SelectedWasapiSendInputs.Distinct())
|
|
specs.Add(new CaptureSourceSpec(id, CaptureKind.Input, id));
|
|
return specs;
|
|
}
|
|
|
|
// Resolve the profile's configured peers to audio endpoints. v1: direct addresses only.
|
|
internal static List<IPEndPoint> BuildEndpoints(Profile p)
|
|
{
|
|
var entries = p.SelectedConnectedPeers.Count > 0 ? p.SelectedConnectedPeers : p.RememberedPeers;
|
|
var result = new List<IPEndPoint>();
|
|
var seen = new HashSet<string>();
|
|
foreach (var entry in entries.Where(e => !string.IsNullOrWhiteSpace(e)).Distinct())
|
|
{
|
|
var (host, port) = SplitHostPort(entry);
|
|
IPAddress? addr;
|
|
if (!IPAddress.TryParse(host, out addr))
|
|
{
|
|
try
|
|
{
|
|
var found = Dns.GetHostAddresses(host);
|
|
addr = found.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) ?? found.FirstOrDefault();
|
|
}
|
|
catch { addr = null; }
|
|
}
|
|
if (addr is null) continue;
|
|
// Send to the peer's audio port: an explicit "host:port" wins, else the standard peer port —
|
|
// the same default the main app's manual-peer path uses (NOT the local listen port).
|
|
var ep = new IPEndPoint(addr, port ?? RemPacket.DefaultPeerDialPort);
|
|
if (seen.Add($"{ep.Address}:{ep.Port}")) result.Add(ep);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Minimal "host[:port]" parser (self-contained so the host doesn't depend on the WinForms UI).
|
|
internal static (string host, int? port) SplitHostPort(string text)
|
|
{
|
|
text = text.Trim();
|
|
var colon = text.LastIndexOf(':');
|
|
if (colon <= 0 || colon == text.Length - 1) return (text, null);
|
|
var host = text[..colon];
|
|
if (host.Contains(':')) return (text, null); // looks like an IPv6 literal — treat whole as host
|
|
return int.TryParse(text[(colon + 1)..], out var port) && port is >= 1 and <= 65535 ? (host, port) : (text, null);
|
|
}
|
|
|
|
private static string? ResolveDefaultRenderId()
|
|
{
|
|
try
|
|
{
|
|
using var en = new MMDeviceEnumerator();
|
|
if (!en.HasDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia)) return null;
|
|
using var d = en.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
|
|
return d.ID;
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
lock (gate)
|
|
{
|
|
if (disposed) return;
|
|
disposed = true;
|
|
}
|
|
wantSending = false;
|
|
try { deviceNotifier?.Dispose(); } catch { } deviceNotifier = null;
|
|
try { sessionKick?.Dispose(); } catch { } sessionKick = null;
|
|
SwapMeterDevices(Array.Empty<CaptureSourceSpec>());
|
|
try { PerformanceMode.Apply(false); } catch { }
|
|
try { presence.Dispose(); } catch { }
|
|
try { sender.Stop(); } catch { }
|
|
try { sender.Dispose(); } catch { }
|
|
}
|
|
}
|