Catch a per-app send from its very start (instant capture-on-open)

Fixes the bug where a ticked app that launched AFTER the profile loaded was
never captured: the 3s reconcile only redrew the list and only ran while you
were looking at it, so it never restarted the audio capture.

Now:
- AudioSessionStartWatcher (RemSound.Sender): hooks the Windows audio-session-
  created notification on the default render device, firing the instant an app
  opens an audio session (i.e. about to make its first sound). Re-points on a
  default-device change. Best-effort; falls back to the poll.
- RefreshSendAppCapture: re-applies the send sources whenever the ticked apps'
  running process ids change, driven by both the session watcher (instant) and
  the reconcile poll (backstop) — and the poll now runs regardless of which tab
  is showing, so a remembered app is caught even when you're not on the list.
- Profile.RememberedSendApplications added (persisted; the two-list UI that
  uses it lands next).
- Self-test 'Send-app capture change-detection': the pure signature is stable
  while nothing changes and flips when a ticked app opens/closes. Gate 36/36.

Live behaviour (real apps + the session notification) needs on-machine testing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-15 19:54:27 +01:00
co-authored by Claude Opus 4.8
parent 5129aa2a39
commit 1e5030b08f
4 changed files with 197 additions and 3 deletions
+67 -3
View File
@@ -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");
}
/// <summary>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".</summary>
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();
}
/// <summary>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.</summary>
internal static string ComputeSendAppPidSignature(IEnumerable<string> checkedNames, Func<string, IReadOnlyList<int>> pidsFor)
=> string.Join("|", checkedNames
.SelectMany(name => pidsFor(name).Select(pid => $"{name}:{pid}"))
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase));
/// <summary>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.</summary>
private void OnAppSessionStarted(int pid)
{
try { if (!IsDisposed) BeginInvoke((Action)RefreshSendAppCapture); } catch { /* form gone */ }
}
/// <summary>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.</summary>
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;
}
/// <summary>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 */ }
+33
View File
@@ -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)";
}
/// <summary>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.</summary>
private static string? SendAppCaptureChangeDetection()
{
var pids = new Dictionary<string, IReadOnlyList<int>>(StringComparer.OrdinalIgnoreCase)
{
["vlc"] = new[] { 100 },
["firefox"] = Array.Empty<int>(), // remembered but not running yet
};
IReadOnlyList<int> Lookup(string n) => pids.TryGetValue(n, out var v) ? v : Array.Empty<int>();
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<int>();
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,
+7
View File
@@ -87,6 +87,13 @@ public sealed class Profile
/// list (remembered) and start being captured again the moment they reappear.</summary>
public List<string> SelectedSendApplications { get; set; } = [];
/// <summary>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 <see cref="SelectedSendApplications"/>); the rest
/// stay remembered so they can be re-ticked later. Lower-case process names, no path/extension.</summary>
public List<string> RememberedSendApplications { get; set; } = [];
// === Connectivity & transport ===
public int AudioPort { get; set; } = 47830;
public int CodecRaw { get; set; } = (int)AudioTransportCodec.Pcm;
@@ -0,0 +1,90 @@
using NAudio.CoreAudioApi;
using NAudio.CoreAudioApi.Interfaces;
namespace RemSound.Sender;
/// <summary>
/// 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
/// <see cref="AudioSessionManager.OnSessionCreated"/>.
///
/// <para>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
/// (<see cref="Rehook"/>) when the default render device changes — reuse the app's existing device-change
/// notifier rather than adding another watcher.</para>
/// </summary>
public sealed class AudioSessionStartWatcher : IDisposable
{
private readonly Action<int> onSessionStartedPid;
private readonly Action<string>? log;
private readonly object gate = new();
private MMDevice? device;
private AudioSessionManager? manager;
private bool disposed;
public AudioSessionStartWatcher(Action<int> onSessionStartedPid, Action<string>? log = null)
{
this.onSessionStartedPid = onSessionStartedPid;
this.log = log;
Rehook();
}
/// <summary>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.</summary>
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();
}
}
}