Per-app send fixes + apps-mode UI rework (no send-all) + truly-global remembered lists

Per-app capture (the "foobar alone = no sound" report):
- ProcessLoopbackCapture: take ActivateAudioInterfaceAsync's out operation as a raw
  IntPtr (released after completion) instead of a typed interface. The eager RCW cast
  of the not-yet-realised operation object threw InvalidCastException / E_NOINTERFACE
  and killed EVERY specific-app capture at start.
- CompositeCaptureBackend: single IsPushEligible authority shared by Start,
  UpdateSources and the coalesced rebuild. The update paths were missing the
  ProcessLoopback exclusion, so switching whole-device -> one app kept the push
  backend and fed it "proc:<pid>" (GetDevice ArgumentException).
- PushModeWasapiBackend: loud backstop rejecting process-loopback specs.
- Self-test: lifecycle test now FAILS on an activation error (it previously passed
  green with the feature completely dead) + pure routing-rule checks.

Apps-mode UI (Ed 2026-07-16): the "Send all applications" checkbox is GONE from the
main window - applications mode always means picking specific apps; whole-system
audio is devices mode's job. Active list = running apps + any ticked app that is not
running ("(not running)" so it can be unticked); Remembered list = global address
book minus whatever is ticked. In-place list reconcile (no Clear+rebuild) kills the
NVDA double-read of the toggled row. Profile.SendAllApplications stays for the
SERVICE (deliberate divergence - a headless lock-screen sender wants system audio).

Remembered lists now genuinely machine-wide (AppConfig-backed): the settings store is
an intra-process cache, so remembered applications were forgotten on every exit and
remembered peers were per-profile in practice. Both books moved to AppConfig; legacy
per-profile peers are unioned in on profile load; profile save snapshots the global
book back for old-build compat. Cross-instance persistence pinned by self-test.

Service (issue #23): 15s capture pulse (callbacks/bytes/pre-encode peak/frames sent)
while sending - distinguishes "endpoint mix is genuinely silent at the lock screen"
from a pipeline fault, which callbacks alone cannot.

Gate: 38/38.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-16 22:50:38 +01:00
co-authored by Claude Opus 4.8
parent 92ad77ff52
commit 1003bbfdf2
10 changed files with 337 additions and 150 deletions
+45 -15
View File
@@ -206,38 +206,44 @@ public sealed class RemSoundSettingsStore
Save(s);
}
// Remembered peers + remembered applications are MACHINE-WIDE, backed by AppConfig (the same
// persistent per-machine file the global hotkeys use) — NOT the in-memory settings cache. They used
// to go through the cache: peers survived only because each profile's JSON carried a copy (making
// them per-profile in practice, against Ed's "both lists are global" ask), and applications didn't
// survive an app exit AT ALL — the cache is intra-process only. 2026-07-16 fix.
public IReadOnlyList<string> LoadRememberedPeers() =>
Try(() => Load()?.RememberedPeers?
Try(() => AppConfig.Load().RememberedPeers?
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList())
?? [];
public void SaveRememberedPeers(IEnumerable<string> peers)
{
var s = Load() ?? new Settings();
s.RememberedPeers = peers
var c = AppConfig.Load();
c.RememberedPeers = peers
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
Save(s);
try { c.Save(); } catch { /* best-effort, like the app's other AppConfig writes */ }
}
/// <summary>GLOBAL remembered application names (lower-case process names) — the shared "apps I send"
/// list, machine-wide like <see cref="LoadRememberedPeers"/>, not per-profile.</summary>
public IReadOnlyList<string> LoadRememberedApplications() =>
Try(() => Load()?.RememberedApplications?
Try(() => AppConfig.Load().RememberedApplications?
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList())
?? [];
public void SaveRememberedApplications(IEnumerable<string> apps)
{
var s = Load() ?? new Settings();
s.RememberedApplications = apps
var c = AppConfig.Load();
c.RememberedApplications = apps
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim().ToLowerInvariant())
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
Save(s);
try { c.Save(); } catch { /* best-effort, like the app's other AppConfig writes */ }
}
// LoggingEnabled lives in AppConfig now — it's a machine-local debug knob, not a
@@ -564,7 +570,8 @@ public sealed class RemSoundSettingsStore
ContinuousAutoTuneIntervalSec = profile.ContinuousAutoTuneIntervalSec,
MaxLatencyMsAsio = profile.MaxLatencyMsAsio,
ContinuousAutoTuneAsioEnabled = profile.ContinuousAutoTuneAsioEnabled,
RememberedPeers = profile.RememberedPeers is null ? null : new List<string>(profile.RememberedPeers),
// RememberedPeers no longer rides the cache — it's machine-wide in AppConfig now; the
// profile's legacy copy is unioned in below (migration) instead of replacing anything.
AsioDriverName = profile.AsioDriverName,
// Profile.AudioModeRaw and Profile.BothModeWarningSuppressed are no longer carried
// through the settings cache. Both fields are retired (2026-05-07 / 2026-05-11);
@@ -590,6 +597,28 @@ public sealed class RemSoundSettingsStore
CustomCuePaths = profile.CustomCuePaths is null ? new() : new Dictionary<string, string>(profile.CustomCuePaths),
RecordingSettings = profile.RecordingSettings?.Clone() ?? new RecordingSettings(),
};
MigrateRememberedPeersToGlobal(profile);
}
/// <summary>Migration for the peers list going machine-wide (2026-07-16): profiles written by older
/// builds carry their own remembered-peers list, so the first time each one is opened its entries
/// are UNIONED into the AppConfig book — nothing is lost, nothing is overwritten. Once the sets
/// match this is a no-op (no file write).</summary>
private static void MigrateRememberedPeersToGlobal(Profile profile)
{
if (profile.RememberedPeers is not { Count: > 0 } legacy) return;
try
{
var c = AppConfig.Load();
var current = c.RememberedPeers ?? [];
var merged = current
.Concat(legacy.Where(static v => !string.IsNullOrWhiteSpace(v)).Select(static v => v.Trim()))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (merged.Count == current.Count) return; // nothing new — skip the write
c.RememberedPeers = merged;
c.Save();
}
catch { /* best-effort, like the app's other AppConfig writes */ }
}
/// <summary>Copies the current in-memory settings cache into a Profile. Note: this only
@@ -609,7 +638,10 @@ public sealed class RemSoundSettingsStore
if (s.ContinuousAutoTuneIntervalSec is int cai) profile.ContinuousAutoTuneIntervalSec = cai;
if (s.MaxLatencyMsAsio is int mla) profile.MaxLatencyMsAsio = mla;
if (s.ContinuousAutoTuneAsioEnabled is bool cata) profile.ContinuousAutoTuneAsioEnabled = cata;
if (s.RememberedPeers is { } rp) profile.RememberedPeers = new List<string>(rp);
// Write the CURRENT machine-wide peers book into the profile on save. The global AppConfig copy
// is the authority now; this snapshot just keeps older builds (which read the profile's list)
// working against the same profile file, and makes the load-time migration union a no-op.
profile.RememberedPeers = new List<string>(LoadRememberedPeers());
profile.AsioDriverName = s.AsioDriverName;
// AudioMode and BothModeWarningSuppressed are not copied — both Profile fields were
// retired in the 2026-05-11 cleanup. Mode is derived from AsioDriverName and the
@@ -656,11 +688,9 @@ public sealed class RemSoundSettingsStore
// the user opt either lane in or out independently.
public int? MaxLatencyMsAsio { get; set; }
public bool? ContinuousAutoTuneAsioEnabled { get; set; }
public List<string>? RememberedPeers { get; set; }
// GLOBAL (machine-wide) remembered application names, like RememberedPeers — deliberately NOT
// per-profile and NOT copied into profile files (see ExportProfile/ImportProfile, which don't
// touch it), so one shared "apps I send" address book serves every profile. 2026-07-15.
public List<string>? RememberedApplications { get; set; }
// RememberedPeers / RememberedApplications retired from this cache 2026-07-16 — both books are
// machine-wide in AppConfig now (this cache is intra-process only, so applications were being
// forgotten on every exit and peers were per-profile in practice).
public string? AsioDriverName { get; set; }
// AudioMode and BothModeWarningSuppressed both retired from this cache. Mode is
// derived from AsioDriverName via LoadAudioMode; the Both-mode warning popup is gone.