diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index e6e8fa4..a3f404b 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -109,6 +109,12 @@ public sealed class MainForm : Form private readonly Label sendAppsStatusLabel = new() { AutoSize = true, Text = "No application selected." }; private MnemonicLabel? sendAppsLabel; private System.Windows.Forms.Timer? sendAppsReconcileTimer; + // Instant capture-on-start: fires the moment a ticked app opens an audio session, so we begin + // capturing it right at the start (the poll below is only a backstop). Live only while sending + // specific apps. lastSendAppPidSignature debounces the re-apply so we only reconfigure the engine + // when the ticked apps' actual process ids change (an app opened or closed). + private RemSound.Sender.AudioSessionStartWatcher? sessionStartWatcher; + private string? lastSendAppPidSignature; // Guards the sendModeList / sendAllApplicationsCheckbox / sendAppsList handlers while we // programmatically repopulate them (mode switch, profile apply, reconcile) so those handlers // don't fire MarkProfileDirty or trigger re-entrant rebuilds on our own writes. @@ -1409,6 +1415,7 @@ public sealed class MainForm : Form updateCheckTimer.Stop(); updateCheckTimer.Dispose(); asioDriverChangeDebounce.Stop(); asioDriverChangeDebounce.Dispose(); try { sendAppsReconcileTimer?.Stop(); sendAppsReconcileTimer?.Dispose(); } catch { } + DisposeSessionStartWatcher(); try { processSelfMeter.Dispose(); } catch { } try { deviceChangeNotifier?.Dispose(); } catch { } try { powerResumeHandler?.Dispose(); } catch { } @@ -3614,8 +3621,12 @@ public sealed class MainForm : Form sendAppsReconcileTimer = new System.Windows.Forms.Timer { Interval = 3000 }; sendAppsReconcileTimer.Tick += (_, _) => { - if (sendModeList.SelectedIndex == SendModeApplicationsIndex && sendAppsList.Visible) - ReconcileSendAppsList(); + if (sendModeList.SelectedIndex != SendModeApplicationsIndex) return; + // Backstop for the instant watcher: catch an app opening/closing even when this tab isn't + // showing, so a remembered app still starts being captured. The list redraw only matters when + // the list is actually on screen. + RefreshSendAppCapture(); + if (sendAppsList.Visible) ReconcileSendAppsList(); }; ApplySendModeVisibility(); @@ -3657,14 +3668,18 @@ public sealed class MainForm : Form if (sendAppsLabel is not null) sendAppsLabel.Visible = showAppList; SetRowControlVisible(sendAppsList, showAppList); - if (appsMode) + // Only the specific-apps case needs the reconcile poll + the instant session-start watcher (in + // "send all applications" mode we just loopback the whole default device, no per-app tracking). + if (appsMode && !sendAllApplicationsCheckbox.Checked) { if (sendAppsList.Items.Count == 0) ReconcileSendAppsList(); sendAppsReconcileTimer?.Start(); + EnsureSessionStartWatcher(); } else { sendAppsReconcileTimer?.Stop(); + DisposeSessionStartWatcher(); } } @@ -3712,6 +3727,52 @@ public sealed class MainForm : Form UpdateCheckedListStatus(sendAppsList, sendAppsStatusLabel, "application"); } + /// Re-resolve the ticked apps' current process ids and, if they changed (an app opened or + /// closed), re-apply the send sources so a remembered app STARTS being captured the instant it opens + /// (or stops when it closes). Cheap: only touches the engine when the resolved PID set actually + /// changes. Runs regardless of which tab is showing, so a remembered app is caught even when you're not + /// looking at the list — this is the fix for "a saved app that launches later never gets captured". + private void RefreshSendAppCapture() + { + if (!connected || suppressSendAppEvents) return; + if (!ProcessLoopbackCapture.IsSupported) return; + if (sendModeList.SelectedIndex != SendModeApplicationsIndex || sendAllApplicationsCheckbox.Checked) return; + var sig = ComputeSendAppPidSignature(CheckedSendApplicationNames(), AudioAppEnumerator.PidsForProcessName); + if (sig == lastSendAppPidSignature) return; + lastSendAppPidSignature = sig; + ApplyAudioRuntime(); + } + + /// Pure, testable: a stable signature of the ticked apps' current process ids. Changes exactly + /// when a ticked app opens or closes a process — which is when the send capture needs re-applying. + internal static string ComputeSendAppPidSignature(IEnumerable checkedNames, Func> pidsFor) + => string.Join("|", checkedNames + .SelectMany(name => pidsFor(name).Select(pid => $"{name}:{pid}")) + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase)); + + /// Session-start callback (COM thread): an app just opened an audio session. Marshal to the UI + /// thread and re-check our ticked apps — RefreshSendAppCapture no-ops if the new session isn't one of + /// ours, and begins capturing immediately if it is. Best-effort. + private void OnAppSessionStarted(int pid) + { + try { if (!IsDisposed) BeginInvoke((Action)RefreshSendAppCapture); } catch { /* form gone */ } + } + + /// Stand up the session-start watcher while we're sending specific applications, so a remembered + /// app is captured the instant it starts. No-op if already running or process-loopback isn't supported. + private void EnsureSessionStartWatcher() + { + if (sessionStartWatcher is not null || !ProcessLoopbackCapture.IsSupported) return; + try { sessionStartWatcher = new RemSound.Sender.AudioSessionStartWatcher(OnAppSessionStarted, m => logFile.Event($"session-start: {m}")); } + catch (Exception ex) { logFile.Event($"session-start watcher unavailable: {ex.GetType().Name}: {ex.Message}"); } + } + + private void DisposeSessionStartWatcher() + { + try { sessionStartWatcher?.Dispose(); } catch { } + sessionStartWatcher = null; + } + /// Restores the WASAPI send mode, the "Send all applications" master toggle and the ticked /// app names from a loaded profile. Remembered apps that aren't running right now are seeded into the /// list (ticked, marked "not running") so they resume capture the moment they reappear. On Windows @@ -5807,6 +5868,9 @@ public sealed class MainForm : Form if (IsDisposed) return; deviceRefreshTimer.Stop(); deviceRefreshTimer.Start(); + // The default render device may have changed — re-point the session-start watcher at it so + // it keeps hearing new app sessions on whatever device apps now play to. + sessionStartWatcher?.Rehook(); })); } catch { /* handle gone / form closing — nothing to refresh */ } diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index dce7e8a..c2c24f4 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -68,6 +68,7 @@ internal static class SelfTest RunStep(results, "Service send host (headless stream + yield)", ServiceSendHostStream); RunStep(results, "Service network presence (reachable + shell teardown)", ServiceNetworkPresenceReachable); RunStep(results, "Service reachability-gated sending (drop dead peers, re-arm recovered)", ServiceReachabilityGating); + RunStep(results, "Send-app capture change-detection (catch an app the instant it opens)", SendAppCaptureChangeDetection); RunStep(results, "Service registration args", ServiceRegistrationArgs); RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine); RunStep(results, "Recording split tracks (per-peer + own)", RecordingSplitTracks); @@ -1518,6 +1519,38 @@ internal static class SelfTest return "reachable armed; long-unreachable dropped; grace-window kept; recovery re-arms (issues #8/#15)"; } + /// The fix for "a saved app that launches later never gets captured": the send engine + /// re-applies capture whenever the ticked apps' running process ids change. This tests the pure + /// change-detector that drives it — the signature is stable while nothing changes (so we don't churn), + /// and changes the moment a ticked app opens or closes a process. + private static string? SendAppCaptureChangeDetection() + { + var pids = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["vlc"] = new[] { 100 }, + ["firefox"] = Array.Empty(), // remembered but not running yet + }; + IReadOnlyList Lookup(string n) => pids.TryGetValue(n, out var v) ? v : Array.Empty(); + var names = new[] { "vlc", "firefox" }; + + var s1 = MainForm.ComputeSendAppPidSignature(names, Lookup); + Check(MainForm.ComputeSendAppPidSignature(names, Lookup) == s1, "the signature must be stable while nothing changes (no needless re-apply)"); + + // firefox launches → a new process id appears → the signature must change (triggers capture). + pids["firefox"] = new[] { 200 }; + var s2 = MainForm.ComputeSendAppPidSignature(names, Lookup); + Check(s2 != s1, "a ticked app opening must change the signature (so capture starts for it)"); + + // firefox closes again → back to the original signature. + pids["firefox"] = Array.Empty(); + Check(MainForm.ComputeSendAppPidSignature(names, Lookup) == s1, "the app closing must return the signature (so its capture is dropped)"); + + // A second instance of a ticked app (another PID) also changes it. + pids["vlc"] = new[] { 100, 101 }; + Check(MainForm.ComputeSendAppPidSignature(names, Lookup) != s1, "a second process of a ticked app must change the signature too"); + return "signature stable when unchanged; changes when a ticked app opens/closes (drives instant capture)"; + } + private static int FreeUdpPort() { using var s = new System.Net.Sockets.Socket(System.Net.Sockets.AddressFamily.InterNetwork, diff --git a/src/RemSound.Core/Profile.cs b/src/RemSound.Core/Profile.cs index b3604e5..cfb97a1 100644 --- a/src/RemSound.Core/Profile.cs +++ b/src/RemSound.Core/Profile.cs @@ -87,6 +87,13 @@ public sealed class Profile /// list (remembered) and start being captured again the moment they reappear. public List SelectedSendApplications { get; set; } = []; + /// The profile's REMEMBERED application names — apps the user has set this profile up to send, + /// whether or not they're running right now (the "Remembered applications" list, mirroring remembered + /// peers but profile-scoped). Includes apps that were added by name while closed. A subset of these is + /// ticked/active at any time (that active subset is ); the rest + /// stay remembered so they can be re-ticked later. Lower-case process names, no path/extension. + public List RememberedSendApplications { get; set; } = []; + // === Connectivity & transport === public int AudioPort { get; set; } = 47830; public int CodecRaw { get; set; } = (int)AudioTransportCodec.Pcm; diff --git a/src/RemSound.Sender/AudioSessionStartWatcher.cs b/src/RemSound.Sender/AudioSessionStartWatcher.cs new file mode 100644 index 0000000..6ab8ac8 --- /dev/null +++ b/src/RemSound.Sender/AudioSessionStartWatcher.cs @@ -0,0 +1,90 @@ +using NAudio.CoreAudioApi; +using NAudio.CoreAudioApi.Interfaces; + +namespace RemSound.Sender; + +/// +/// Fires the instant an application starts a new audio session on the default render device — i.e. the +/// moment an app is about to make its first sound. That lets "send specific applications" begin capturing +/// a remembered app right at its START, instead of only noticing it on the next poll (by which time the +/// first sound is already gone). Uses the Windows audio-session-created notification via NAudio's +/// . +/// +/// Best-effort and self-contained: any failure just means we fall back to the poll. The callback +/// arrives on a COM thread with the new session's process id; the caller marshals to the UI thread and +/// decides whether that process is one of the apps it wants. Re-point it at the current default device +/// () when the default render device changes — reuse the app's existing device-change +/// notifier rather than adding another watcher. +/// +public sealed class AudioSessionStartWatcher : IDisposable +{ + private readonly Action onSessionStartedPid; + private readonly Action? log; + private readonly object gate = new(); + private MMDevice? device; + private AudioSessionManager? manager; + private bool disposed; + + public AudioSessionStartWatcher(Action onSessionStartedPid, Action? log = null) + { + this.onSessionStartedPid = onSessionStartedPid; + this.log = log; + Rehook(); + } + + /// Point the watcher at the CURRENT default render device. Call on a default-device change so + /// we keep hearing new sessions on whatever device apps are actually playing to. Never throws. + public void Rehook() + { + lock (gate) + { + if (disposed) return; + UnhookLocked(); + try + { + var en = new MMDeviceEnumerator(); + if (!en.HasDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia)) { en.Dispose(); return; } + device = en.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); + en.Dispose(); + manager = device.AudioSessionManager; + manager.RefreshSessions(); // required before OnSessionCreated fires (Windows quirk) + manager.OnSessionCreated += HandleSessionCreated; + } + catch (Exception ex) + { + log?.Invoke($"session-start watcher hook failed: {ex.GetType().Name}: {ex.Message}"); + UnhookLocked(); + } + } + } + + private void HandleSessionCreated(object? sender, IAudioSessionControl newSession) + { + // COM thread. Pull the PID and hand it off; the caller decides if it cares and marshals threads. + try + { + using var ctl = new AudioSessionControl(newSession); + var pid = (int)ctl.GetProcessID; + if (pid > 0) onSessionStartedPid(pid); + } + catch { /* a session we can't read — ignore */ } + } + + private void UnhookLocked() + { + try { if (manager is not null) manager.OnSessionCreated -= HandleSessionCreated; } catch { } + manager = null; + try { device?.Dispose(); } catch { } + device = null; + } + + public void Dispose() + { + lock (gate) + { + if (disposed) return; + disposed = true; + UnhookLocked(); + } + } +}