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:
co-authored by
Claude Opus 4.8
parent
5129aa2a39
commit
1e5030b08f
@@ -109,6 +109,12 @@ public sealed class MainForm : Form
|
|||||||
private readonly Label sendAppsStatusLabel = new() { AutoSize = true, Text = "No application selected." };
|
private readonly Label sendAppsStatusLabel = new() { AutoSize = true, Text = "No application selected." };
|
||||||
private MnemonicLabel? sendAppsLabel;
|
private MnemonicLabel? sendAppsLabel;
|
||||||
private System.Windows.Forms.Timer? sendAppsReconcileTimer;
|
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
|
// Guards the sendModeList / sendAllApplicationsCheckbox / sendAppsList handlers while we
|
||||||
// programmatically repopulate them (mode switch, profile apply, reconcile) so those handlers
|
// programmatically repopulate them (mode switch, profile apply, reconcile) so those handlers
|
||||||
// don't fire MarkProfileDirty or trigger re-entrant rebuilds on our own writes.
|
// 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();
|
updateCheckTimer.Stop(); updateCheckTimer.Dispose();
|
||||||
asioDriverChangeDebounce.Stop(); asioDriverChangeDebounce.Dispose();
|
asioDriverChangeDebounce.Stop(); asioDriverChangeDebounce.Dispose();
|
||||||
try { sendAppsReconcileTimer?.Stop(); sendAppsReconcileTimer?.Dispose(); } catch { }
|
try { sendAppsReconcileTimer?.Stop(); sendAppsReconcileTimer?.Dispose(); } catch { }
|
||||||
|
DisposeSessionStartWatcher();
|
||||||
try { processSelfMeter.Dispose(); } catch { }
|
try { processSelfMeter.Dispose(); } catch { }
|
||||||
try { deviceChangeNotifier?.Dispose(); } catch { }
|
try { deviceChangeNotifier?.Dispose(); } catch { }
|
||||||
try { powerResumeHandler?.Dispose(); } catch { }
|
try { powerResumeHandler?.Dispose(); } catch { }
|
||||||
@@ -3614,8 +3621,12 @@ public sealed class MainForm : Form
|
|||||||
sendAppsReconcileTimer = new System.Windows.Forms.Timer { Interval = 3000 };
|
sendAppsReconcileTimer = new System.Windows.Forms.Timer { Interval = 3000 };
|
||||||
sendAppsReconcileTimer.Tick += (_, _) =>
|
sendAppsReconcileTimer.Tick += (_, _) =>
|
||||||
{
|
{
|
||||||
if (sendModeList.SelectedIndex == SendModeApplicationsIndex && sendAppsList.Visible)
|
if (sendModeList.SelectedIndex != SendModeApplicationsIndex) return;
|
||||||
ReconcileSendAppsList();
|
// 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();
|
ApplySendModeVisibility();
|
||||||
@@ -3657,14 +3668,18 @@ public sealed class MainForm : Form
|
|||||||
if (sendAppsLabel is not null) sendAppsLabel.Visible = showAppList;
|
if (sendAppsLabel is not null) sendAppsLabel.Visible = showAppList;
|
||||||
SetRowControlVisible(sendAppsList, 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();
|
if (sendAppsList.Items.Count == 0) ReconcileSendAppsList();
|
||||||
sendAppsReconcileTimer?.Start();
|
sendAppsReconcileTimer?.Start();
|
||||||
|
EnsureSessionStartWatcher();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
sendAppsReconcileTimer?.Stop();
|
sendAppsReconcileTimer?.Stop();
|
||||||
|
DisposeSessionStartWatcher();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3712,6 +3727,52 @@ public sealed class MainForm : Form
|
|||||||
UpdateCheckedListStatus(sendAppsList, sendAppsStatusLabel, "application");
|
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
|
/// <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
|
/// 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
|
/// 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;
|
if (IsDisposed) return;
|
||||||
deviceRefreshTimer.Stop();
|
deviceRefreshTimer.Stop();
|
||||||
deviceRefreshTimer.Start();
|
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 */ }
|
catch { /* handle gone / form closing — nothing to refresh */ }
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ internal static class SelfTest
|
|||||||
RunStep(results, "Service send host (headless stream + yield)", ServiceSendHostStream);
|
RunStep(results, "Service send host (headless stream + yield)", ServiceSendHostStream);
|
||||||
RunStep(results, "Service network presence (reachable + shell teardown)", ServiceNetworkPresenceReachable);
|
RunStep(results, "Service network presence (reachable + shell teardown)", ServiceNetworkPresenceReachable);
|
||||||
RunStep(results, "Service reachability-gated sending (drop dead peers, re-arm recovered)", ServiceReachabilityGating);
|
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, "Service registration args", ServiceRegistrationArgs);
|
||||||
RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine);
|
RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine);
|
||||||
RunStep(results, "Recording split tracks (per-peer + own)", RecordingSplitTracks);
|
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)";
|
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()
|
private static int FreeUdpPort()
|
||||||
{
|
{
|
||||||
using var s = new System.Net.Sockets.Socket(System.Net.Sockets.AddressFamily.InterNetwork,
|
using var s = new System.Net.Sockets.Socket(System.Net.Sockets.AddressFamily.InterNetwork,
|
||||||
|
|||||||
@@ -87,6 +87,13 @@ public sealed class Profile
|
|||||||
/// list (remembered) and start being captured again the moment they reappear.</summary>
|
/// list (remembered) and start being captured again the moment they reappear.</summary>
|
||||||
public List<string> SelectedSendApplications { get; set; } = [];
|
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 ===
|
// === Connectivity & transport ===
|
||||||
public int AudioPort { get; set; } = 47830;
|
public int AudioPort { get; set; } = 47830;
|
||||||
public int CodecRaw { get; set; } = (int)AudioTransportCodec.Pcm;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user