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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<CaptureSourceSpec>();
|
||||
var addedLoopbackIds = new HashSet<string>(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<AudioDeviceChoice>())
|
||||
{
|
||||
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<AudioDeviceChoice>()
|
||||
.Where(c => c.DeviceId is not null)
|
||||
.Select(c => (c.DeviceId!, c.Name)),
|
||||
out followedDefaultLoopback);
|
||||
lastFollowedDefaultLoopbackId = followedDefaultLoopback;
|
||||
var addedInputIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
string? followedDefaultInput = null;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/// <summary>The encoder-boundary clamp is now ONE shared rule (SampleClamp) used by all three
|
||||
|
||||
@@ -557,35 +557,14 @@ public sealed class ServiceSendHost : IDisposable
|
||||
var specs = new List<CaptureSourceSpec>();
|
||||
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<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));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user