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();
+ }
+ }
+}