From 7d886ea91051077935e757d628e9b07f9eb70e2c Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:51:21 +0100 Subject: [PATCH] Peer extraction stage 2: ONE send-spec builder for app + service (CaptureSpecBuilder) The WASAPI outputs-or-apps spec assembly - the exact logic the standing 'service must mirror the app's send behaviour' rule exists for - lived as two hand-mirrored copies in MainForm.ApplySendSources and ServiceSendHost.BuildSendSpecs. Now ONE builder: - Devices mode: loopback specs, 'Use Windows default' sentinel resolved to the LIVE default render endpoint, duplicates collapsed, resolved-default reported via out param (drives the app's re-route-on-default-change detection). - Applications mode: one process-loopback spec per running PID per chosen app name. The app layers WASAPI inputs + ASIO pairs on top; the service adds neither (by design). Both callers rewired; behaviour identical by construction and pinned three ways: the service parity test, the follower test (now also pinning the out param, follower+ explicit-device dedup to one spec, and not-running apps contributing nothing), and the main-window profile round-trip. Gate 61/61. Co-Authored-By: Claude Fable 5 --- src/RemSound.App/CaptureSpecBuilder.cs | 57 ++++++++++++++++++++++++++ src/RemSound.App/MainForm.cs | 47 +++++---------------- src/RemSound.App/SelfTest.cs | 11 ++++- src/RemSound.App/ServiceSendHost.cs | 37 ++++------------- src/RemSound.Core/SampleClamp.cs | 23 +++++++++++ 5 files changed, 108 insertions(+), 67 deletions(-) create mode 100644 src/RemSound.App/CaptureSpecBuilder.cs create mode 100644 src/RemSound.Core/SampleClamp.cs diff --git a/src/RemSound.App/CaptureSpecBuilder.cs b/src/RemSound.App/CaptureSpecBuilder.cs new file mode 100644 index 0000000..e3b1c81 --- /dev/null +++ b/src/RemSound.App/CaptureSpecBuilder.cs @@ -0,0 +1,57 @@ +using RemSound.Core; +using RemSound.Sender; + +namespace RemSound.App; + +/// +/// The WASAPI send-spec assembly shared by the main window and the send-only service — the very rule +/// the standing "service must mirror the app's send behaviour" applies to, which previously existed as +/// two hand-mirrored copies (MainForm.ApplySendSources and ServiceSendHost.BuildSendSpecs). Devices +/// mode: loopback specs with the "Use Windows default" sentinel resolved to the LIVE default render +/// endpoint and duplicates collapsed. Applications mode: one process-loopback spec per running PID of +/// each chosen app name (child processes ride along via the include-tree capture). The app layers its +/// WASAPI inputs and ASIO pairs on top; the service deliberately adds neither. +/// +internal static class CaptureSpecBuilder +{ + /// Devices mode. reports the endpoint the follower + /// resolved to (null when not following, or no default exists) — the app compares it on device + /// changes to know when the Windows default moved and a re-route is due. + internal static List BuildOutputSpecs( + IEnumerable<(string Id, string Name)> outputs, out string? followedDefaultId) + { + var specs = new List(); + var added = new HashSet(StringComparer.OrdinalIgnoreCase); + followedDefaultId = null; + foreach (var (id, name) in outputs) + { + if (AudioDefaultFollower.IsLoopbackSend(id)) + { + var def = AudioDefaultFollower.ResolveDefaultRenderId(); + followedDefaultId = def; + if (def is not null && added.Add(def)) + specs.Add(new CaptureSourceSpec(def, CaptureKind.Loopback, "Windows default audio device")); + } + else if (added.Add(id)) + { + specs.Add(new CaptureSourceSpec(id, CaptureKind.Loopback, name)); + } + } + return specs; + } + + /// Applications mode. Apps not currently running contribute nothing until they reappear — + /// the reconcile timer and the session-start watcher keep re-applying, so they're caught on open. + internal static List BuildApplicationSpecs(IEnumerable appNames) + { + var specs = new List(); + foreach (var name in appNames.Distinct(StringComparer.OrdinalIgnoreCase)) + { + foreach (var pid in AudioAppEnumerator.PidsForProcessName(name)) + { + specs.Add(new CaptureSourceSpec(ProcessLoopbackId.Format(pid), CaptureKind.ProcessLoopback, name)); + } + } + return specs; + } +} diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 7bdae8e..c2cf803 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -5942,44 +5942,17 @@ public sealed class MainForm : Form // Build the unified spec list from all three send-side lists. The CompositeCaptureBackend // splits this set internally into WASAPI specs (sent to MixingEngine) and ASIO specs // (sent to AsioCaptureBackend). Both run in parallel and their outputs are summed. - var specs = new List(); - var addedLoopbackIds = new HashSet(StringComparer.OrdinalIgnoreCase); - string? followedDefaultLoopback = null; + // The WASAPI outputs-or-apps portion is the SHARED builder (CaptureSpecBuilder) — the same code + // the service assembles its specs with, so the two can no longer drift apart. var appsMode = ProcessLoopbackCapture.IsSupported && sendModeList.SelectedIndex == SendModeApplicationsIndex; - if (!appsMode) - { - // Devices mode (classic): loopback-capture whole output devices from the ticked list. - foreach (var item in sendOutputDevicesList.CheckedItems.OfType()) - { - if (item.IsDefaultFollower) - { - // Loopback-capture whatever Windows currently uses as the default OUTPUT, and follow it. - followedDefaultLoopback = ResolveDefaultDeviceId(NAudio.CoreAudioApi.DataFlow.Render); - if (!string.IsNullOrEmpty(followedDefaultLoopback) && addedLoopbackIds.Add(followedDefaultLoopback)) - { - specs.Add(new CaptureSourceSpec(followedDefaultLoopback, CaptureKind.Loopback, "Windows default audio device")); - } - } - else if (item.DeviceId is { } id && addedLoopbackIds.Add(id)) - { - specs.Add(new CaptureSourceSpec(id, CaptureKind.Loopback, item.Name)); - } - } - } - else - { - // Applications mode: one process-loopback spec per running process of each ticked app name. - // Apps not currently running contribute nothing until they reappear (the reconcile timer and - // the session-start watcher keep the list fresh and re-apply). Child processes are captured - // too (the process-loopback include-tree mode), so a browser's audio renderers are covered. - foreach (var name in CheckedSendApplicationNames()) - { - foreach (var pid in AudioAppEnumerator.PidsForProcessName(name)) - { - specs.Add(new CaptureSourceSpec(ProcessLoopbackId.Format(pid), CaptureKind.ProcessLoopback, name)); - } - } - } + string? followedDefaultLoopback = null; + var specs = appsMode + ? CaptureSpecBuilder.BuildApplicationSpecs(CheckedSendApplicationNames()) + : CaptureSpecBuilder.BuildOutputSpecs( + sendOutputDevicesList.CheckedItems.OfType() + .Where(c => c.DeviceId is not null) + .Select(c => (c.DeviceId!, c.Name)), + out followedDefaultLoopback); lastFollowedDefaultLoopbackId = followedDefaultLoopback; var addedInputIds = new HashSet(StringComparer.OrdinalIgnoreCase); string? followedDefaultInput = null; diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 47e50b9..9a6ede7 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -962,7 +962,16 @@ internal static class SelfTest if (expected is null) return Skip("no Windows default output device on this box to resolve the follower against"); Check(specs.Any(s => s.Kind == CaptureKind.Loopback && s.DeviceId == expected), "the follower must resolve to a loopback spec on the current Windows default output"); - return "follower flagged + sentinel shared with the app; service resolves it to the live default render endpoint"; + + // Shared-builder details: the resolved default reports via the out param (drives the app's + // re-route detection), and a follower + the SAME device ticked explicitly collapse to one spec. + var built = CaptureSpecBuilder.BuildOutputSpecs( + new[] { (AudioDefaultFollower.LoopbackSendId, "follow"), (expected, "explicit") }, out var followed); + Check(followed == expected, "the resolved default must be reported via the out param"); + Check(built.Count == 1, "the follower and the same explicit device must collapse to ONE spec"); + Check(CaptureSpecBuilder.BuildApplicationSpecs(new[] { "zzremsound_no_such_process" }).Count == 0, + "an app that isn't running must contribute no specs (it's caught on open by the watcher)"); + return "sentinel shared + resolved to the live default; builder dedups and reports the followed id"; } /// The encoder-boundary clamp is now ONE shared rule (SampleClamp) used by all three diff --git a/src/RemSound.App/ServiceSendHost.cs b/src/RemSound.App/ServiceSendHost.cs index c0dc6e5..7886d26 100644 --- a/src/RemSound.App/ServiceSendHost.cs +++ b/src/RemSound.App/ServiceSendHost.cs @@ -557,35 +557,14 @@ public sealed class ServiceSendHost : IDisposable var specs = new List(); var appsMode = ProcessLoopbackCapture.IsSupported && string.Equals(p.WasapiSendMode, "applications", StringComparison.OrdinalIgnoreCase); - if (appsMode) - { - // Specific applications only — matches the main app. The old "send all applications" - // path (SendAllApplications) was removed from both the app and the service; the profile - // flag is ignored here so a stale profile can't resurrect whole-system capture. - foreach (var name in p.SelectedSendApplications.Distinct(StringComparer.OrdinalIgnoreCase)) - foreach (var pid in AudioAppEnumerator.PidsForProcessName(name)) - specs.Add(new CaptureSourceSpec(ProcessLoopbackId.Format(pid), CaptureKind.ProcessLoopback, name)); - } - else - { - var addedOutputs = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var id in p.SelectedWasapiSendOutputs.Distinct()) - { - if (AudioDefaultFollower.IsLoopbackSend(id)) - { - // "Use Windows default output" — resolve to the live default render endpoint. Re-run - // on every ApplyProfile (which a default-device change triggers via the notifier), so - // the service FOLLOWS the default. Same sentinel + resolver the main app uses. - var def = AudioDefaultFollower.ResolveDefaultRenderId(); - if (def is not null && addedOutputs.Add(def)) - specs.Add(new CaptureSourceSpec(def, CaptureKind.Loopback, "Windows default audio device")); - } - else if (addedOutputs.Add(id)) - { - specs.Add(new CaptureSourceSpec(id, CaptureKind.Loopback, id)); - } - } - } + // Both branches are the SHARED builder (CaptureSpecBuilder) — the same code the main window + // assembles its specs with, so the two can no longer drift apart. Apps mode = specific apps + // only (the "send all" path was removed from both sides; the stale profile flag is ignored); + // devices mode resolves the "Use Windows default" sentinel to the live default on every + // ApplyProfile, which a default-device change re-triggers via the notifier — so it FOLLOWS. + specs.AddRange(appsMode + ? CaptureSpecBuilder.BuildApplicationSpecs(p.SelectedSendApplications) + : CaptureSpecBuilder.BuildOutputSpecs(p.SelectedWasapiSendOutputs.Distinct().Select(id => (id, id)), out _)); // The service never sends WASAPI inputs (mics/line-ins) — outputs and specific apps only. Any // SelectedWasapiSendInputs left in an old profile is deliberately ignored here. return specs; diff --git a/src/RemSound.Core/SampleClamp.cs b/src/RemSound.Core/SampleClamp.cs new file mode 100644 index 0000000..a451976 --- /dev/null +++ b/src/RemSound.Core/SampleClamp.cs @@ -0,0 +1,23 @@ +namespace RemSound.Core; + +/// +/// The encoder-boundary hard clamp, shared by all three capture paths (mix engine, ASIO backend, +/// push-mode WASAPI backend) — which used to carry three private copies of it. Clamps every sample to +/// [−1, +1] and returns how many were clipped, so callers batch ONE Interlocked.Add per buffer instead +/// of per-sample interlocked increments on the real-time path (the ASIO copy did up to four per frame). +/// Exactly ±1 is NOT clipping — only samples beyond the range count. +/// +public static class SampleClamp +{ + public static long ClampBuffer(Span samples) + { + long clipped = 0; + for (var i = 0; i < samples.Length; i++) + { + var v = samples[i]; + if (v > 1f) { samples[i] = 1f; clipped++; } + else if (v < -1f) { samples[i] = -1f; clipped++; } + } + return clipped; + } +}