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 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 */ }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user