Service: offer "Use Windows default output" (Christopher's request), reusing the app's follower
The service profile could only pick named output devices, so it couldn't "just send
whatever this machine plays and follow the Windows default". The main app already had
exactly that (its DefaultLoopbackSendFollower + ResolveDefaultDeviceId) - so rather
than invent a parallel mechanism (which would silently diverge), this pulls the shared
piece out and has the service reuse it.
- New AudioDefaultFollower: one home for the loopback-send sentinel
("__use-default-loopback-send__"), the follower list entry, and the default-endpoint
resolver. MainForm now references it (its DefaultLoopbackSendFollower and
ResolveDefaultDeviceId delegate to it) so there is a single definition.
- Service config dialog: the Audio send tab lists "Use Windows default audio device,
follows Windows changes" as the first output choice. Ticking it persists the same
sentinel the app uses.
- ServiceSendHost.BuildSendSpecs resolves that sentinel to the LIVE default render
endpoint (with de-dup against explicitly-ticked devices), never passing the raw
sentinel through. Because the service re-applies its profile on every device-change
notification - and OnDefaultDeviceChanged is one of them - it FOLLOWS the default:
change Windows' default output and the service switches to it within a beat.
- Manual: documents the new option in the service section.
Test: new "Default-output follower" self-test - follower is flagged + shares the app's
sentinel, and the service resolves it to the current Windows default render endpoint
(never leaks the raw sentinel into a capture spec). Gate 44/44.
The ASIO "Rea" devices Ed noticed are real registry drivers (Realtek ASIO + REAPER's
ReaRoute), not injected dummies - we only ever list HKLM\SOFTWARE\ASIO. No code change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
43e249e90e
commit
4a86a82ec8
+1
-1
@@ -1413,7 +1413,7 @@ RemSound.exe --connect 192.168.1.42
|
||||
<h3>Setting it up</h3>
|
||||
<p>Everything lives in the <strong>Service</strong> menu on the menu bar:</p>
|
||||
<ol>
|
||||
<li><strong>Configure service profile…</strong> — opens a small window with two tabs (Connectivity and Audio send) where you choose who to send to (plus a password) and what to send. There is no “send my audio” switch because the service always sends, and there is no audio-quality tab to fiddle with: the service always uses the settings that work best for live streaming (the Opus live-latency codec, small packets, locked to the audio clock), so it just sounds right. This is a separate profile from your normal ones and does not appear in the usual profile list. The <strong>Additional options</strong> button lets you turn the connect/disconnect sounds and the service's own log on or off.</li>
|
||||
<li><strong>Configure service profile…</strong> — opens a small window with two tabs (Connectivity and Audio send) where you choose who to send to (plus a password) and what to send. On the Audio send tab, the first output choice is <strong>Use Windows default audio device, follows Windows changes</strong> — tick that to send whatever this machine is currently playing and keep following the Windows default if it later changes, rather than pinning one named card. (You can still pick specific devices, or a specific application, exactly as in the normal app.) There is no “send my audio” switch because the service always sends, and there is no audio-quality tab to fiddle with: the service always uses the settings that work best for live streaming (the Opus live-latency codec, small packets, locked to the audio clock), so it just sounds right. This is a separate profile from your normal ones and does not appear in the usual profile list. The <strong>Additional options</strong> button lets you turn the connect/disconnect sounds and the service's own log on or off.</li>
|
||||
<li><strong>Install service</strong> — registers it with Windows so it starts automatically at every boot. Windows asks for administrator permission (one prompt). Do this once. (When you first install RemSound on a PC, the installer also offers to set the service up for you, so you may have done this already.)</li>
|
||||
<li><strong>Start service</strong> / <strong>Stop service</strong> — run or halt it now without waiting for a reboot.</li>
|
||||
<li><strong>Uninstall service</strong> — removes it entirely.</li>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using NAudio.CoreAudioApi;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// The "Use Windows default audio device, follows Windows changes" choice, shared by the main window
|
||||
/// and the lock-screen service so both offer — and resolve — it identically. It is NOT a real endpoint:
|
||||
/// it's a sentinel that both persist in <c>SelectedWasapiSendOutputs</c>, and that the send-spec
|
||||
/// builders resolve to the CURRENT default render endpoint each time capture is (re)built. When Windows'
|
||||
/// default output changes, the device-change notifier drives a rebuild and the new default is picked up.
|
||||
///
|
||||
/// <para>Kept in one place because the service must track the main app: the main window already had this
|
||||
/// (its <c>DefaultLoopbackSendFollower</c> + <c>ResolveDefaultDeviceId</c>); the service reuses the very
|
||||
/// same sentinel and resolver rather than inventing a parallel one that would diverge.</para>
|
||||
/// </summary>
|
||||
internal static class AudioDefaultFollower
|
||||
{
|
||||
/// <summary>Sentinel device id for "follow the Windows default OUTPUT (loopback send)". Namespaced so
|
||||
/// it can never collide with a real WASAPI endpoint id (which look like "{0.0.0.00000000}.{guid}").</summary>
|
||||
internal const string LoopbackSendId = "__use-default-loopback-send__";
|
||||
|
||||
internal static bool IsLoopbackSend(string? deviceId) =>
|
||||
string.Equals(deviceId, LoopbackSendId, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>The follower list entry for the "WASAPI outputs to send" list. A fresh instance each call
|
||||
/// (list controls take ownership of their items).</summary>
|
||||
internal static AudioDeviceChoice LoopbackSendChoice() =>
|
||||
new("Use Windows default audio device, follows Windows changes", LoopbackSendId, CaptureKind.Loopback)
|
||||
{ IsDefaultFollower = true };
|
||||
|
||||
/// <summary>The current Windows default OUTPUT (render) endpoint id, or null if none. Convenience for
|
||||
/// callers that only follow the default output and shouldn't have to name a NAudio DataFlow.</summary>
|
||||
internal static string? ResolveDefaultRenderId() => ResolveDefaultDeviceId(DataFlow.Render);
|
||||
|
||||
/// <summary>The current Windows default endpoint id for the given direction, or null if there isn't
|
||||
/// one (or it can't be read). Render = default speakers/output; Capture = default mic/input.</summary>
|
||||
internal static string? ResolveDefaultDeviceId(DataFlow flow)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var enumerator = new MMDeviceEnumerator();
|
||||
if (!enumerator.HasDefaultAudioEndpoint(flow, Role.Multimedia)) return null;
|
||||
using var device = enumerator.GetDefaultAudioEndpoint(flow, Role.Multimedia);
|
||||
return device.ID;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
@@ -526,8 +526,9 @@ public sealed class MainForm : Form
|
||||
// "Use Windows default" for the SEND side's "WASAPI audio outputs to send" (system-audio/loopback)
|
||||
// list: loopback-capture whatever Windows currently uses as the default OUTPUT and follow it. Its own
|
||||
// sentinel + persisted flag, distinct from the receive-output follower (which plays TO the default).
|
||||
private static readonly AudioDeviceChoice DefaultLoopbackSendFollower =
|
||||
new("Use Windows default audio device, follows Windows changes", "__use-default-loopback-send__", CaptureKind.Loopback) { IsDefaultFollower = true };
|
||||
// The loopback-send follower is shared with the service (AudioDefaultFollower) so both offer and
|
||||
// resolve the exact same sentinel; the receive-output and send-input followers above stay local.
|
||||
private static readonly AudioDeviceChoice DefaultLoopbackSendFollower = AudioDefaultFollower.LoopbackSendChoice();
|
||||
// The Windows-default device id we last routed to while following, per direction. A default-device
|
||||
// change doesn't change the device SET, so the list-sync wouldn't catch it — we compare against
|
||||
// these to spot it and re-route (see ReapplyIfFollowedDefaultChanged).
|
||||
@@ -6479,17 +6480,10 @@ public sealed class MainForm : Form
|
||||
|
||||
/// <summary>The id of the current Windows default endpoint for the given direction, or null if
|
||||
/// there isn't one / it can't be read. Best-effort.</summary>
|
||||
private static string? ResolveDefaultDeviceId(NAudio.CoreAudioApi.DataFlow flow)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var enumerator = new NAudio.CoreAudioApi.MMDeviceEnumerator();
|
||||
if (!enumerator.HasDefaultAudioEndpoint(flow, NAudio.CoreAudioApi.Role.Multimedia)) return null;
|
||||
using var device = enumerator.GetDefaultAudioEndpoint(flow, NAudio.CoreAudioApi.Role.Multimedia);
|
||||
return device.ID;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
// Shared with the service via AudioDefaultFollower so "the current Windows default" means the same
|
||||
// thing in both. Thin delegate kept so the existing call sites don't all have to change.
|
||||
private static string? ResolveDefaultDeviceId(NAudio.CoreAudioApi.DataFlow flow) =>
|
||||
AudioDefaultFollower.ResolveDefaultDeviceId(flow);
|
||||
|
||||
private static bool IsFollowerChecked(CheckedListBox list) =>
|
||||
list.CheckedItems.OfType<AudioDeviceChoice>().Any(c => c.IsDefaultFollower);
|
||||
|
||||
@@ -64,6 +64,7 @@ internal static class SelfTest
|
||||
RunStep(results, "Lifecycle churn (modes, sources, pan/EQ, send/receive)", LifecycleChurn);
|
||||
RunStep(results, "Service app-yield token", ServiceInteractivePresence);
|
||||
RunStep(results, "Service sender parity (crypto + Opus frame)", ServiceSenderParity);
|
||||
RunStep(results, "Default-output follower (service follows Windows default)", DefaultOutputFollower);
|
||||
RunStep(results, "Service profile isolation (location + hidden from pickers)", ServiceProfileIsolation);
|
||||
RunStep(results, "Service send host (headless stream + yield)", ServiceSendHostStream);
|
||||
RunStep(results, "Service network presence (reachable + shell teardown)", ServiceNetworkPresenceReachable);
|
||||
@@ -882,6 +883,29 @@ internal static class SelfTest
|
||||
/// AND the fingerprint from the password (a missing fingerprint gets the encrypted stream rejected at
|
||||
/// the peer), and apply the send-rate-adjusted Opus frame (the "Small" rate halves it). Guards the
|
||||
/// divergences found auditing the service against the main app.</summary>
|
||||
/// <summary>The "Use Windows default output" follower (Christopher's request) must be the SAME shared
|
||||
/// sentinel + resolver the main app uses, and the service must resolve it to the LIVE default render
|
||||
/// endpoint — never pass the raw sentinel through as a device id.</summary>
|
||||
private static string? DefaultOutputFollower()
|
||||
{
|
||||
var choice = AudioDefaultFollower.LoopbackSendChoice();
|
||||
Check(choice.IsDefaultFollower, "the default-output follower must be flagged IsDefaultFollower");
|
||||
Check(AudioDefaultFollower.IsLoopbackSend(choice.DeviceId), "the follower's id must be the loopback-send sentinel");
|
||||
Check(!AudioDefaultFollower.IsLoopbackSend("{0.0.0.00000000}.{abc}"), "a real endpoint id must not be taken for the follower sentinel");
|
||||
|
||||
var p = new Profile { WasapiSendMode = "devices" };
|
||||
p.SelectedWasapiSendOutputs.Add(AudioDefaultFollower.LoopbackSendId);
|
||||
var specs = ServiceSendHost.BuildSendSpecs(p);
|
||||
Check(!specs.Any(s => AudioDefaultFollower.IsLoopbackSend(s.DeviceId)),
|
||||
"the raw follower sentinel must never reach a capture spec — it must be resolved first");
|
||||
|
||||
var expected = AudioDefaultFollower.ResolveDefaultRenderId();
|
||||
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";
|
||||
}
|
||||
|
||||
private static string? ServiceSenderParity()
|
||||
{
|
||||
// The profile deliberately carries the WRONG audio transport (raw PCM, broadcast frame, Standard
|
||||
|
||||
@@ -197,7 +197,12 @@ internal sealed class ServiceProfileDialog : Form
|
||||
suppressAppEvents = true;
|
||||
try
|
||||
{
|
||||
PopulateDeviceList(outputsList, AudioDeviceCatalog.LoadOutputs(), working.SelectedWasapiSendOutputs);
|
||||
// Offer "Use Windows default output" as the first output choice (Christopher's request):
|
||||
// send whatever this machine currently plays, following the Windows default. Same shared
|
||||
// follower + sentinel the main window uses, so a ticked follower persists and resolves identically.
|
||||
var outputChoices = new List<AudioDeviceChoice> { AudioDefaultFollower.LoopbackSendChoice() };
|
||||
outputChoices.AddRange(AudioDeviceCatalog.LoadOutputs());
|
||||
PopulateDeviceList(outputsList, outputChoices, working.SelectedWasapiSendOutputs);
|
||||
PopulateDeviceList(inputsList, AudioDeviceCatalog.LoadInputs(), working.SelectedWasapiSendInputs);
|
||||
|
||||
var appsMode = ProcessLoopbackCapture.IsSupported
|
||||
|
||||
@@ -582,8 +582,23 @@ public sealed class ServiceSendHost : IDisposable
|
||||
}
|
||||
else
|
||||
{
|
||||
var addedOutputs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var id in p.SelectedWasapiSendOutputs.Distinct())
|
||||
specs.Add(new CaptureSourceSpec(id, CaptureKind.Loopback, id));
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach (var id in p.SelectedWasapiSendInputs.Distinct())
|
||||
specs.Add(new CaptureSourceSpec(id, CaptureKind.Input, id));
|
||||
|
||||
Reference in New Issue
Block a user