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;
+ }
+}