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 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-26 22:51:21 +01:00
co-authored by Claude Fable 5
parent 1b7fb47cd3
commit 7d886ea910
5 changed files with 108 additions and 67 deletions
+57
View File
@@ -0,0 +1,57 @@
using RemSound.Core;
using RemSound.Sender;
namespace RemSound.App;
/// <summary>
/// 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.
/// </summary>
internal static class CaptureSpecBuilder
{
/// <summary>Devices mode. <paramref name="followedDefaultId"/> 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.</summary>
internal static List<CaptureSourceSpec> BuildOutputSpecs(
IEnumerable<(string Id, string Name)> outputs, out string? followedDefaultId)
{
var specs = new List<CaptureSourceSpec>();
var added = new HashSet<string>(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;
}
/// <summary>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.</summary>
internal static List<CaptureSourceSpec> BuildApplicationSpecs(IEnumerable<string> appNames)
{
var specs = new List<CaptureSourceSpec>();
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;
}
}
+10 -37
View File
@@ -5942,44 +5942,17 @@ public sealed class MainForm : Form
// Build the unified spec list from all three send-side lists. The CompositeCaptureBackend // 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 // 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. // (sent to AsioCaptureBackend). Both run in parallel and their outputs are summed.
var specs = new List<CaptureSourceSpec>(); // The WASAPI outputs-or-apps portion is the SHARED builder (CaptureSpecBuilder) — the same code
var addedLoopbackIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); // the service assembles its specs with, so the two can no longer drift apart.
string? followedDefaultLoopback = null;
var appsMode = ProcessLoopbackCapture.IsSupported && sendModeList.SelectedIndex == SendModeApplicationsIndex; var appsMode = ProcessLoopbackCapture.IsSupported && sendModeList.SelectedIndex == SendModeApplicationsIndex;
if (!appsMode) string? followedDefaultLoopback = null;
{ var specs = appsMode
// Devices mode (classic): loopback-capture whole output devices from the ticked list. ? CaptureSpecBuilder.BuildApplicationSpecs(CheckedSendApplicationNames())
foreach (var item in sendOutputDevicesList.CheckedItems.OfType<AudioDeviceChoice>()) : CaptureSpecBuilder.BuildOutputSpecs(
{ sendOutputDevicesList.CheckedItems.OfType<AudioDeviceChoice>()
if (item.IsDefaultFollower) .Where(c => c.DeviceId is not null)
{ .Select(c => (c.DeviceId!, c.Name)),
// Loopback-capture whatever Windows currently uses as the default OUTPUT, and follow it. out followedDefaultLoopback);
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));
}
}
}
lastFollowedDefaultLoopbackId = followedDefaultLoopback; lastFollowedDefaultLoopbackId = followedDefaultLoopback;
var addedInputIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var addedInputIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
string? followedDefaultInput = null; string? followedDefaultInput = null;
+10 -1
View File
@@ -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"); 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), 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"); "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";
} }
/// <summary>The encoder-boundary clamp is now ONE shared rule (SampleClamp) used by all three /// <summary>The encoder-boundary clamp is now ONE shared rule (SampleClamp) used by all three
+8 -29
View File
@@ -557,35 +557,14 @@ public sealed class ServiceSendHost : IDisposable
var specs = new List<CaptureSourceSpec>(); var specs = new List<CaptureSourceSpec>();
var appsMode = ProcessLoopbackCapture.IsSupported var appsMode = ProcessLoopbackCapture.IsSupported
&& string.Equals(p.WasapiSendMode, "applications", StringComparison.OrdinalIgnoreCase); && string.Equals(p.WasapiSendMode, "applications", StringComparison.OrdinalIgnoreCase);
if (appsMode) // 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
// Specific applications only — matches the main app. The old "send all applications" // only (the "send all" path was removed from both sides; the stale profile flag is ignored);
// path (SendAllApplications) was removed from both the app and the service; the profile // devices mode resolves the "Use Windows default" sentinel to the live default on every
// flag is ignored here so a stale profile can't resurrect whole-system capture. // ApplyProfile, which a default-device change re-triggers via the notifier — so it FOLLOWS.
foreach (var name in p.SelectedSendApplications.Distinct(StringComparer.OrdinalIgnoreCase)) specs.AddRange(appsMode
foreach (var pid in AudioAppEnumerator.PidsForProcessName(name)) ? CaptureSpecBuilder.BuildApplicationSpecs(p.SelectedSendApplications)
specs.Add(new CaptureSourceSpec(ProcessLoopbackId.Format(pid), CaptureKind.ProcessLoopback, name)); : CaptureSpecBuilder.BuildOutputSpecs(p.SelectedWasapiSendOutputs.Distinct().Select(id => (id, id)), out _));
}
else
{
var addedOutputs = new HashSet<string>(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));
}
}
}
// The service never sends WASAPI inputs (mics/line-ins) — outputs and specific apps only. Any // 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. // SelectedWasapiSendInputs left in an old profile is deliberately ignored here.
return specs; return specs;
+23
View File
@@ -0,0 +1,23 @@
namespace RemSound.Core;
/// <summary>
/// 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.
/// </summary>
public static class SampleClamp
{
public static long ClampBuffer(Span<float> 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;
}
}