Lock-screen service: spike + headless send host + app-yield coordination
Local checkpoint - NOT for public release. First increment of the send-only Windows service feature (design in memory/project_remsound_service). THE SPIKE PASSED: the send engine runs fully headless (no window, no message pump) and streams, proven by a real self-test — the one genuine unknown that gated the whole feature. Also proves the app-yield model end to end. What's in this increment (all headless, all tested, 19/19 gate): - AppConfig: ServiceProfileName + ServiceLoggingEnabled (machine-wide). - InteractivePresence (Core): the cross-session app-yield token. App holds a Global\ mutex for its lifetime; the service checks it and yields while an interactive app is present, resuming when it closes OR crashes (OS frees the mutex). Name-parameterised internal seams for isolated testing. - ServiceSendHost (App): loads a send-only profile and streams it to its peers, WASAPI-only, no ASIO/receive. ApplyProfile/Suspend/Resume + a RunLoop that drives them from the presence token with a settle delay. v1 sends to direct peer addresses (LAN/port-forwarded); NAT/relay discovery stays the app's job. - Program.cs: the interactive app now acquires the presence token at startup so a future service yields to it. - Tests: "Service app-yield token" (held=present, released=absent) and "Service send host (headless stream + yield)" — streams a captured device to a local receiver over loopback, verifies start/suspend/resume, then drives the full RunLoop against the token (held=suspended, released=resumes-and-flows). Still to come (later increments): the --run-service entry + Windows-service registration, the Service menu, the 3-tab config dialog, updater integration, docs. None user-facing yet, so nothing deployed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8f61eb800d
commit
e648bee531
@@ -113,7 +113,7 @@ public sealed class MainForm : Form
|
||||
// programmatically repopulate them (mode switch, profile apply, reconcile) so those handlers
|
||||
// don't fire MarkProfileDirty or trigger re-entrant rebuilds on our own writes.
|
||||
private bool suppressSendAppEvents;
|
||||
// The two sendModeList rows, in order. Index 0 = whole sound devices (classic), 1 = applications.
|
||||
// The two sendModeList rows, in order. Index 0 = whole audio devices (classic), 1 = applications.
|
||||
private const int SendModeDevicesIndex = 0;
|
||||
private const int SendModeApplicationsIndex = 1;
|
||||
// ASIO-side lists. Always present in the form but hidden when ASIO is disabled. The two
|
||||
@@ -3299,7 +3299,7 @@ public sealed class MainForm : Form
|
||||
sendCheckboxPanel.Controls.Add(sendMyAudioCheckbox);
|
||||
panel.Controls.Add(sendCheckboxPanel, 1, 6);
|
||||
|
||||
// Row 7: "how to send WASAPI audio" chooser — sound devices (the classic loopback list) or
|
||||
// Row 7: "how to send WASAPI audio" chooser — audio devices (the classic loopback list) or
|
||||
// specific applications. Sits right after "Send my audio". Built manually (like the ASIO
|
||||
// driver row) so we keep the label reference to collapse the whole row on old Windows.
|
||||
BuildSendModeRow(panel, 7);
|
||||
@@ -3338,7 +3338,7 @@ public sealed class MainForm : Form
|
||||
sendModeLabel.Click += (_, _) => FocusControl(sendModeList);
|
||||
sendModeList.AccessibleName = "How to send WASAPI audio (Alt+6)";
|
||||
sendModeList.Items.Clear();
|
||||
sendModeList.Items.Add("Send whole sound devices"); // SendModeDevicesIndex
|
||||
sendModeList.Items.Add("Send whole audio devices"); // SendModeDevicesIndex
|
||||
sendModeList.Items.Add("Send specific applications"); // SendModeApplicationsIndex
|
||||
sendModeList.SelectedIndex = SendModeDevicesIndex;
|
||||
panel.Controls.Add(sendModeLabel, 0, row);
|
||||
|
||||
@@ -206,6 +206,13 @@ internal static class Program
|
||||
instance.StartActivationListener();
|
||||
instance.ActivateRequested += () => activeMainForm?.RestoreFromTray();
|
||||
|
||||
// Hold the interactive-presence token for this copy's whole lifetime, so the send-only
|
||||
// lock-screen service (if installed) yields to us — it suspends its own sending while an
|
||||
// interactive RemSound is open. Windows releases the token automatically when this process
|
||||
// exits or crashes, so the service resumes on its own. Best-effort: null if it couldn't be
|
||||
// taken, in which case we simply run without announcing our presence.
|
||||
using var presenceHold = InteractivePresence.AcquireHold();
|
||||
|
||||
// Best-effort: clear leftover update temp stages (and any relics of the old batch updater).
|
||||
// We hold the single-instance lock here, so only the live copy does this — no sibling race.
|
||||
RemSoundUpdater.CleanUpUpdateStages();
|
||||
|
||||
@@ -62,6 +62,8 @@ internal static class SelfTest
|
||||
RunStep(results, "Per-application send enumeration", AppSendEnumeration);
|
||||
RunStep(results, "Per-application capture lifecycle", AppSendCaptureLifecycle);
|
||||
RunStep(results, "Lifecycle churn (modes, sources, pan/EQ, send/receive)", LifecycleChurn);
|
||||
RunStep(results, "Service app-yield token", ServiceInteractivePresence);
|
||||
RunStep(results, "Service send host (headless stream + yield)", ServiceSendHostStream);
|
||||
RunStep(results, "v5 settings and shaping round-trip", V5ConfigRoundTrip);
|
||||
RunStep(results, "Profile save and reload", ProfileRoundTrip);
|
||||
RunStep(results, "What's-new update marker", WhatsNewMarkerRoundTrip);
|
||||
@@ -455,6 +457,129 @@ internal static class SelfTest
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
/// <summary>The lock-screen service's app-yield token: while a hold is active the service must see an
|
||||
/// interactive app present; once released (or on crash — the OS frees the mutex) it must see none.
|
||||
/// Uses a unique token name so the test is immune to a real RemSound running alongside the gate.</summary>
|
||||
private static string? ServiceInteractivePresence()
|
||||
{
|
||||
var name = @"Global\RemSound.Interactive.selftest." + Guid.NewGuid().ToString("N");
|
||||
Check(!InteractivePresence.IsInteractiveAppRunning(name), "no app should be seen before any hold");
|
||||
using (var hold = InteractivePresence.AcquireHold(name))
|
||||
{
|
||||
Check(hold is not null, "AcquireHold should succeed");
|
||||
Check(InteractivePresence.IsInteractiveAppRunning(name), "an app must be seen while the hold is active");
|
||||
// A second, independent check must also see it (the service polls repeatedly).
|
||||
Check(InteractivePresence.IsInteractiveAppRunning(name), "repeated checks must stay consistent while held");
|
||||
}
|
||||
var released = false;
|
||||
for (var i = 0; i < 40 && !released; i++)
|
||||
{
|
||||
if (!InteractivePresence.IsInteractiveAppRunning(name)) released = true; else Thread.Sleep(25);
|
||||
}
|
||||
Check(released, "no app should be seen after the hold is released");
|
||||
return "held → present; released → absent";
|
||||
}
|
||||
|
||||
/// <summary>End-to-end proof of the send-only service host, headless (no window, no message pump):
|
||||
/// a temp send-only profile streams a captured device to a local receiver over loopback. Drives the
|
||||
/// real yield mechanism — ApplyProfile streams, Suspend stops, Resume re-reads and streams again —
|
||||
/// and then the RunLoop against the presence token: holding the token suspends the host, releasing it
|
||||
/// resumes. SKIPs on a box with no capturable output device.</summary>
|
||||
private static string? ServiceSendHostStream()
|
||||
{
|
||||
const int port = 47846;
|
||||
string? deviceId;
|
||||
try { deviceId = AudioDeviceCatalog.LoadOutputs().FirstOrDefault(o => o.DeviceId is not null)?.DeviceId; }
|
||||
catch (Exception ex) { return Skip("could not enumerate outputs: " + ex.Message); }
|
||||
if (deviceId is null) return Skip("no usable output device to capture from");
|
||||
|
||||
// Unit-level checks first (no hardware): spec + endpoint building from a profile.
|
||||
var probe = new Profile { WasapiSendMode = "devices" };
|
||||
probe.SelectedWasapiSendOutputs.Add("dev-a");
|
||||
probe.SelectedConnectedPeers.Add("127.0.0.1:47846");
|
||||
probe.SelectedConnectedPeers.Add("bad::garbage::host");
|
||||
Check(ServiceSendHost.BuildSendSpecs(probe).Any(s => s.DeviceId == "dev-a" && s.Kind == CaptureKind.Loopback),
|
||||
"a WASAPI send output must become a loopback spec");
|
||||
var eps = ServiceSendHost.BuildEndpoints(probe);
|
||||
Check(eps.Any(e => e.Address.ToString() == "127.0.0.1" && e.Port == 47846), "a host:port peer must resolve to an endpoint");
|
||||
|
||||
using var receiver = new AudioReceiver();
|
||||
try { receiver.Start(port); }
|
||||
catch (Exception ex) { return Skip($"could not bind test port {port}: {ex.Message}"); }
|
||||
receiver.SetOutputDevices(Array.Empty<string>()); // decode only — never make a sound
|
||||
|
||||
var profile = new Profile
|
||||
{
|
||||
Title = "selftest-service",
|
||||
WasapiSendMode = "devices",
|
||||
Codec = AudioTransportCodec.Pcm,
|
||||
};
|
||||
profile.SelectedWasapiSendOutputs.Add(deviceId);
|
||||
profile.SelectedConnectedPeers.Add($"127.0.0.1:{port}");
|
||||
|
||||
using var host = new ServiceSendHost(() => profile);
|
||||
|
||||
Check(host.ApplyProfile(profile), "ApplyProfile should start streaming");
|
||||
Check(host.IsSending, "host should report sending after ApplyProfile");
|
||||
Thread.Sleep(500);
|
||||
var afterStart = receiver.PacketsReceived;
|
||||
Check(afterStart > 0, $"packets must flow from the service host (got {afterStart})");
|
||||
|
||||
host.Suspend();
|
||||
Check(!host.IsSending, "host should report not sending after Suspend");
|
||||
Thread.Sleep(200);
|
||||
var atSuspend = receiver.PacketsReceived;
|
||||
Thread.Sleep(400);
|
||||
Check(receiver.PacketsReceived == atSuspend, "no packets must flow while suspended");
|
||||
|
||||
Check(host.Resume(), "Resume should restart streaming");
|
||||
Thread.Sleep(500);
|
||||
Check(receiver.PacketsReceived > atSuspend, "packets must flow again after Resume");
|
||||
|
||||
// Now the full RunLoop + presence token, with a unique token so a real app can't interfere.
|
||||
host.Suspend();
|
||||
var tokenName = @"Global\RemSound.Interactive.selftest." + Guid.NewGuid().ToString("N");
|
||||
var loopResult = RunLoopYieldCheck(host, receiver, tokenName);
|
||||
Check(loopResult is null, loopResult ?? "");
|
||||
|
||||
return $"streamed headless; start/suspend/resume verified; {afterStart} pkts; yield loop ok";
|
||||
}
|
||||
|
||||
// Drives ServiceSendHost.RunLoop against a presence token (unique name via a tiny shim): with the
|
||||
// token held the host must stay suspended; released, it must resume and packets must flow.
|
||||
private static string? RunLoopYieldCheck(ServiceSendHost host, AudioReceiver receiver, string tokenName)
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
// Hold the token BEFORE the loop starts so the host yields from the outset.
|
||||
var hold = InteractivePresence.AcquireHold(tokenName);
|
||||
if (hold is null) return "could not acquire the presence token for the yield check";
|
||||
var loop = new Thread(() => host.RunLoopWithToken(cts.Token, tokenName, pollMs: 100, resumeSettleMs: 200)) { IsBackground = true };
|
||||
loop.Start();
|
||||
try
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
if (host.IsSending) return "host must stay suspended while the interactive token is held";
|
||||
var held = receiver.PacketsReceived;
|
||||
Thread.Sleep(300);
|
||||
if (receiver.PacketsReceived != held) return "no packets must flow while the token is held";
|
||||
|
||||
hold.Dispose(); hold = null; // app "closes" — host should resume after the settle
|
||||
var resumed = false;
|
||||
for (var i = 0; i < 40 && !resumed; i++) { Thread.Sleep(50); if (host.IsSending) resumed = true; }
|
||||
if (!resumed) return "host must resume after the token is released";
|
||||
var before = receiver.PacketsReceived;
|
||||
Thread.Sleep(400);
|
||||
if (receiver.PacketsReceived <= before) return "packets must flow after the host resumes";
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
cts.Cancel();
|
||||
loop.Join(2000);
|
||||
hold?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The v5 machine-wide settings and per-peer shaping survive a JSON save/reload: new
|
||||
/// AppConfig defaults, the named-peers book, the main tab order, per-peer shaping with parametric
|
||||
/// bands, and the new recording default. All in-memory — the real config/profiles aren't touched.</summary>
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using NAudio.CoreAudioApi;
|
||||
using RemSound.Core;
|
||||
using RemSound.Sender;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// The headless engine behind the RemSound Windows service: it loads the designated send-only profile
|
||||
/// and streams it to the profile's peers, with no window, tray, hotkeys or screen reader. It YIELDS to
|
||||
/// the interactive app — while a normal RemSound is open it suspends (stops capturing, drops the send),
|
||||
/// resuming when the app closes or crashes (see <see cref="InteractivePresence"/>).
|
||||
///
|
||||
/// <para>Send-only and WASAPI-only by design: ASIO can't run in a service, and receive is impossible on
|
||||
/// Windows 11 with no user logged in, so neither is attempted. v1 sends directly to the profile's
|
||||
/// configured peer addresses (LAN / port-forwarded / reachable hosts); NAT hole-punching and relay
|
||||
/// discovery are the interactive app's job, not the service's.</para>
|
||||
///
|
||||
/// <para>Structured so the mechanism is unit-testable without a real service: <see cref="ApplyProfile"/>,
|
||||
/// <see cref="Suspend"/> and <see cref="Resume"/> are driven directly by the self-tests, and
|
||||
/// <see cref="RunLoop"/> wires them to the interactive-presence token.</para>
|
||||
/// </summary>
|
||||
public sealed class ServiceSendHost : IDisposable
|
||||
{
|
||||
private readonly Func<Profile?> loadProfile;
|
||||
private readonly Action<string>? log;
|
||||
private readonly AudioSender sender = new();
|
||||
private readonly object gate = new();
|
||||
private bool running; // the engine is actively sending
|
||||
private bool disposed;
|
||||
|
||||
/// <param name="loadProfile">Supplies the current service profile (re-read on each resume so edits
|
||||
/// are picked up). Returns null if none is configured.</param>
|
||||
/// <param name="log">Optional diagnostic sink.</param>
|
||||
public ServiceSendHost(Func<Profile?> loadProfile, Action<string>? log = null)
|
||||
{
|
||||
this.loadProfile = loadProfile;
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
/// <summary>Convenience factory for the real service: loads the profile named by
|
||||
/// <see cref="AppConfig.ServiceProfileName"/> from the given profiles folder each time it's asked.</summary>
|
||||
public static ServiceSendHost FromConfig(Action<string>? log = null) => new(() =>
|
||||
{
|
||||
var cfg = AppConfig.Load();
|
||||
if (string.IsNullOrWhiteSpace(cfg.ServiceProfileName) || string.IsNullOrWhiteSpace(cfg.ProfilesDirectory)) return null;
|
||||
try { return new ProfileStore(cfg.ProfilesDirectory).Load(cfg.ServiceProfileName!); }
|
||||
catch { return null; }
|
||||
}, log);
|
||||
|
||||
public bool IsSending { get { lock (gate) return running; } }
|
||||
|
||||
/// <summary>Builds the send sources, peer endpoints and encryption key from a profile and starts the
|
||||
/// sender. Idempotent-ish: call <see cref="Suspend"/> before re-applying a different profile. Returns
|
||||
/// false (and stays stopped) if the profile has nothing to send or no reachable peers.</summary>
|
||||
public bool ApplyProfile(Profile profile)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed) return false;
|
||||
var specs = BuildSendSpecs(profile);
|
||||
var endpoints = BuildEndpoints(profile);
|
||||
if (specs.Count == 0) { log?.Invoke("service: profile has no WASAPI send sources — nothing to stream"); return false; }
|
||||
if (endpoints.Count == 0) { log?.Invoke("service: profile has no reachable peers — nothing to stream to"); return false; }
|
||||
|
||||
sender.AudioKey = string.IsNullOrEmpty(profile.Password)
|
||||
? null
|
||||
: RemSoundCrypto.DeriveKey(RemSoundCrypto.Deobfuscate(profile.Password));
|
||||
sender.ConfigureCodec(profile.Codec, profile.OpusFrameSamplesPerChannel);
|
||||
sender.SetSendRate(profile.SendRate);
|
||||
sender.SetTightLatency(profile.TightLatencyMode);
|
||||
sender.SetReceivers(endpoints);
|
||||
sender.Configure(specs);
|
||||
sender.Start();
|
||||
running = true;
|
||||
log?.Invoke($"service: streaming \"{profile.Title}\" — {specs.Count} source(s) to {endpoints.Count} peer(s)");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stops sending and releases capture. Safe to call when already stopped.</summary>
|
||||
public void Suspend()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!running) return;
|
||||
try { sender.Stop(); } catch (Exception ex) { log?.Invoke($"service: stop error {ex.GetType().Name}: {ex.Message}"); }
|
||||
running = false;
|
||||
log?.Invoke("service: suspended (interactive app present)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the current service profile and starts sending. Used when the interactive app
|
||||
/// goes away, so any edits it made are picked up.</summary>
|
||||
public bool Resume()
|
||||
{
|
||||
var profile = loadProfile();
|
||||
if (profile is null) { log?.Invoke("service: no service profile configured — staying idle"); return false; }
|
||||
return ApplyProfile(profile);
|
||||
}
|
||||
|
||||
/// <summary>The service's main loop: watch the interactive-presence token and hand the send back and
|
||||
/// forth. Starts sending immediately if no app is present. A short settle delay before resuming
|
||||
/// stops rapid app open/close from thrashing the engine. Returns when <paramref name="ct"/> is
|
||||
/// cancelled (service stop).</summary>
|
||||
public void RunLoop(CancellationToken ct, int pollMs = 1000, int resumeSettleMs = 2000)
|
||||
=> RunLoopCore(ct, InteractivePresence.IsInteractiveAppRunning, pollMs, resumeSettleMs);
|
||||
|
||||
/// <summary>Test seam: run the loop against a caller-supplied presence-token name so a test can't
|
||||
/// collide with a real running app on the production token.</summary>
|
||||
internal void RunLoopWithToken(CancellationToken ct, string tokenName, int pollMs, int resumeSettleMs)
|
||||
=> RunLoopCore(ct, () => InteractivePresence.IsInteractiveAppRunning(tokenName), pollMs, resumeSettleMs);
|
||||
|
||||
private void RunLoopCore(CancellationToken ct, Func<bool> isAppPresent, int pollMs, int resumeSettleMs)
|
||||
{
|
||||
var appWasPresent = true; // force an initial evaluation
|
||||
var absentSince = Environment.TickCount64;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var appPresent = isAppPresent();
|
||||
if (appPresent)
|
||||
{
|
||||
if (IsSending) Suspend();
|
||||
absentSince = long.MaxValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (appWasPresent) absentSince = Environment.TickCount64; // app just left — start the settle timer
|
||||
if (!IsSending && Environment.TickCount64 - absentSince >= resumeSettleMs)
|
||||
Resume();
|
||||
}
|
||||
appWasPresent = appPresent;
|
||||
ct.WaitHandle.WaitOne(pollMs);
|
||||
}
|
||||
Suspend();
|
||||
}
|
||||
|
||||
// WASAPI-only send specs from a profile. Mirrors the app's applications-vs-devices logic but never
|
||||
// touches ASIO (the service can't). Applications mode needs Windows 10 19041+.
|
||||
internal static List<CaptureSourceSpec> BuildSendSpecs(Profile p)
|
||||
{
|
||||
var specs = new List<CaptureSourceSpec>();
|
||||
var appsMode = ProcessLoopbackCapture.IsSupported
|
||||
&& string.Equals(p.WasapiSendMode, "applications", StringComparison.OrdinalIgnoreCase);
|
||||
if (appsMode)
|
||||
{
|
||||
if (p.SendAllApplications)
|
||||
{
|
||||
var def = ResolveDefaultRenderId();
|
||||
if (def is not null) specs.Add(new CaptureSourceSpec(def, CaptureKind.Loopback, "All applications (system audio)"));
|
||||
}
|
||||
else
|
||||
{
|
||||
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
|
||||
{
|
||||
foreach (var id in p.SelectedWasapiSendOutputs.Distinct())
|
||||
specs.Add(new CaptureSourceSpec(id, CaptureKind.Loopback, id));
|
||||
}
|
||||
foreach (var id in p.SelectedWasapiSendInputs.Distinct())
|
||||
specs.Add(new CaptureSourceSpec(id, CaptureKind.Input, id));
|
||||
return specs;
|
||||
}
|
||||
|
||||
// Resolve the profile's configured peers to audio endpoints. v1: direct addresses only.
|
||||
internal static List<IPEndPoint> BuildEndpoints(Profile p)
|
||||
{
|
||||
var entries = p.SelectedConnectedPeers.Count > 0 ? p.SelectedConnectedPeers : p.RememberedPeers;
|
||||
var result = new List<IPEndPoint>();
|
||||
var seen = new HashSet<string>();
|
||||
foreach (var entry in entries.Where(e => !string.IsNullOrWhiteSpace(e)).Distinct())
|
||||
{
|
||||
var (host, port) = SplitHostPort(entry);
|
||||
IPAddress? addr;
|
||||
if (!IPAddress.TryParse(host, out addr))
|
||||
{
|
||||
try
|
||||
{
|
||||
var found = Dns.GetHostAddresses(host);
|
||||
addr = found.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) ?? found.FirstOrDefault();
|
||||
}
|
||||
catch { addr = null; }
|
||||
}
|
||||
if (addr is null) continue;
|
||||
var ep = new IPEndPoint(addr, port ?? p.AudioPort);
|
||||
if (seen.Add($"{ep.Address}:{ep.Port}")) result.Add(ep);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Minimal "host[:port]" parser (self-contained so the host doesn't depend on the WinForms UI).
|
||||
internal static (string host, int? port) SplitHostPort(string text)
|
||||
{
|
||||
text = text.Trim();
|
||||
var colon = text.LastIndexOf(':');
|
||||
if (colon <= 0 || colon == text.Length - 1) return (text, null);
|
||||
var host = text[..colon];
|
||||
if (host.Contains(':')) return (text, null); // looks like an IPv6 literal — treat whole as host
|
||||
return int.TryParse(text[(colon + 1)..], out var port) && port is >= 1 and <= 65535 ? (host, port) : (text, null);
|
||||
}
|
||||
|
||||
private static string? ResolveDefaultRenderId()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var en = new MMDeviceEnumerator();
|
||||
if (!en.HasDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia)) return null;
|
||||
using var d = en.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
|
||||
return d.ID;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
}
|
||||
try { sender.Stop(); } catch { }
|
||||
try { sender.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user