diff --git a/src/RemSound.App/ServiceSendHost.cs b/src/RemSound.App/ServiceSendHost.cs
index da5c548..94872db 100644
--- a/src/RemSound.App/ServiceSendHost.cs
+++ b/src/RemSound.App/ServiceSendHost.cs
@@ -30,6 +30,15 @@ public sealed class ServiceSendHost : IDisposable
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;
+
/// Supplies the current service profile (re-read on each resume so edits
/// are picked up). Returns null if none is configured.
/// Optional diagnostic sink.
@@ -112,61 +121,63 @@ public sealed class ServiceSendHost : IDisposable
internal void RunLoopWithToken(CancellationToken ct, string tokenName, int pollMs, int resumeSettleMs)
=> RunLoopCore(ct, () => InteractivePresence.IsInteractiveAppRunning(tokenName), pollMs, resumeSettleMs);
- private void RunLoopCore(CancellationToken ct, Func isAppPresent, int pollMs, int resumeSettleMs, int healthMs = 5000)
+ private void RunLoopCore(CancellationToken ct, Func 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;
- long healthBaseline = -1; // packet count at the last health check (-1 = not yet baselined)
- var lastHealthTick = Environment.TickCount64;
+ var triedThisAbsence = false;
while (!ct.IsCancellationRequested)
{
var appPresent = isAppPresent();
if (appPresent)
{
+ wantSending = false;
if (IsSending) Suspend();
absentSince = long.MaxValue;
- healthBaseline = -1;
+ triedThisAbsence = false;
}
else
{
- if (appWasPresent) absentSince = Environment.TickCount64; // app just left — start the settle timer
+ if (appWasPresent) { absentSince = Environment.TickCount64; triedThisAbsence = false; } // app just left
if (Environment.TickCount64 - absentSince >= resumeSettleMs)
{
- if (!IsSending)
- {
- // Keep trying to (re)start every poll until it succeeds — covers a boot where the
- // audio stack / a device isn't ready yet, and a device that returns later.
- Resume();
- healthBaseline = -1;
- lastHealthTick = Environment.TickCount64;
- }
- else if (Environment.TickCount64 - lastHealthTick >= healthMs)
- {
- // Self-heal: we THINK we're sending, but if no packets have flowed since the last
- // check then no capture is actually running (devices weren't ready when we started,
- // a device dropped, or the audio service restarted). Re-open the capture.
- lastHealthTick = Environment.TickCount64;
- var packets = sender.PacketsSent;
- if (healthBaseline >= 0 && packets == healthBaseline)
- {
- log?.Invoke("service: no audio flowing while sending — re-opening capture");
- Suspend();
- Resume();
- healthBaseline = -1;
- }
- else
- {
- healthBaseline = packets;
- }
- }
+ 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; }
}
}
appWasPresent = appPresent;
ct.WaitHandle.WaitOne(pollMs);
}
+ wantSending = false;
Suspend();
}
+ /// 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.
+ 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;
+ 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 BuildSendSpecs(Profile p)
@@ -254,6 +265,8 @@ public sealed class ServiceSendHost : IDisposable
if (disposed) return;
disposed = true;
}
+ wantSending = false;
+ try { deviceNotifier?.Dispose(); } catch { } deviceNotifier = null;
try { sender.Stop(); } catch { }
try { sender.Dispose(); } catch { }
}