From 4df1f78e2b2f0fd3a752d5f84e5ab9daf613d5a3 Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Sun, 30 Aug 2026 17:07:20 +0200 Subject: [PATCH] CA2007 + set streamer mic device --- SteelSeriesAPI.Explorer/Program.cs | 19 ++++++++++ SteelSeriesAPI.Sample/Program.cs | 3 ++ .../RedirectionsManagerTests.cs | 13 +++++++ .../SonarEventListenerTests.cs | 38 +++++++++++++++++++ SteelSeriesAPI/.editorconfig | 6 +++ SteelSeriesAPI/Core/ServerDiscovery.cs | 2 +- SteelSeriesAPI/Core/SonarHttpClient.cs | 25 ++++++------ .../Events/Listener/DebouncedRefresher.cs | 8 ++-- .../Listener/SonarEventListener.Configs.cs | 2 +- .../SonarEventListener.Redirections.cs | 25 ++++++++---- .../Listener/SonarEventListener.Volumes.cs | 10 ++--- .../Sonar/Events/RedirectionChanges.cs | 10 ++++- .../Sonar/Events/SonarEventListener.cs | 14 +++---- .../Sonar/Managers/AppRoutingManager.cs | 6 +-- .../Sonar/Managers/AudioDeviceManager.cs | 4 +- .../Sonar/Managers/ChatMixManager.cs | 2 +- .../Sonar/Managers/ConfigManager.cs | 8 ++-- .../Sonar/Managers/IRedirectionsManager.cs | 7 ++++ SteelSeriesAPI/Sonar/Managers/ModeManager.cs | 8 ++-- .../Sonar/Managers/RedirectionsManager.cs | 13 +++++-- .../Sonar/Managers/VolumeSettingsManager.cs | 4 +- SteelSeriesAPI/Sonar/SonarRoutes.cs | 4 ++ SteelSeriesAPI/SteelSeriesAPI.csproj | 2 +- 23 files changed, 175 insertions(+), 58 deletions(-) create mode 100644 SteelSeriesAPI/.editorconfig diff --git a/SteelSeriesAPI.Explorer/Program.cs b/SteelSeriesAPI.Explorer/Program.cs index 2f5f922..c66d472 100644 --- a/SteelSeriesAPI.Explorer/Program.cs +++ b/SteelSeriesAPI.Explorer/Program.cs @@ -350,6 +350,25 @@ await Step("Mix channel toggle round-trip", async () => await sonar.Redirections.SetMixChannelEnabledAsync(Mix.Personal, Channel.Media, before); }); + await Step("Streamer mic device round-trip", async () => + { + var state = await sonar.Redirections.GetStreamRedirectionsAsync(); + var mic = state.Mic ?? throw new SkipException("mic redirection absent from response"); + + var captures = (await sonar.Devices.GetAllAsync(AudioDataFlow.Capture)) + .Where(d => !d.IsSonarVirtual).ToList(); + var other = captures.FirstOrDefault(d => d.Id != mic.DeviceId) + ?? throw new SkipException("only one capture device available"); + + await sonar.Redirections.SetMicDeviceAsync(other.Id); + await Task.Delay(150); + var after = await sonar.Redirections.GetStreamRedirectionsAsync(); + if (after.Mic?.DeviceId != other.Id) + throw new Exception($"read back {after.Mic?.DeviceId}, expected {other.Id}"); + + await sonar.Redirections.SetMicDeviceAsync(mic.DeviceId); + }); + await Step($"Restore initial mode ({initialMode})", () => sonar.Mode.SetAsync(initialMode)); Console.WriteLine($"\n{pass} passed, {fail} failed, {skip} skipped." + diff --git a/SteelSeriesAPI.Sample/Program.cs b/SteelSeriesAPI.Sample/Program.cs index bfd0fcc..3d8d5a7 100644 --- a/SteelSeriesAPI.Sample/Program.cs +++ b/SteelSeriesAPI.Sample/Program.cs @@ -167,6 +167,9 @@ private static void SubscribeToEvents(SonarClient sonar) sonar.Events.MixDeviceChanged += (_, e) => Console.WriteLine($"[Redirections] {e.Mix} mix routed to {e.NewDeviceId}"); + + sonar.Events.MicDeviceChanged += (_, e) => + Console.WriteLine($"[Redirections] Mic passthrough routed to {e.NewDeviceId}"); sonar.Events.MixChannelToggled += (_, e) => Console.WriteLine($"[Redirections] {e.Channel} on {e.Mix} mix: {(e.IsEnabled ? "enabled" : "disabled")}"); diff --git a/SteelSeriesAPI.Tests/RedirectionsManagerTests.cs b/SteelSeriesAPI.Tests/RedirectionsManagerTests.cs index 6a913df..f317134 100644 --- a/SteelSeriesAPI.Tests/RedirectionsManagerTests.cs +++ b/SteelSeriesAPI.Tests/RedirectionsManagerTests.cs @@ -123,6 +123,19 @@ public async Task SetMixChannelEnabledAsync_UsesJsonVocabulary() Assert.Single(transport.PutRoutes)); } + [Fact] + public async Task SetMicDeviceAsync_UsesTheMicStreamRedirectionRoute() + { + var transport = new FakeTransport(); + var manager = new RedirectionsManager(transport); + + await manager.SetMicDeviceAsync("{0.0.1}.{abc}"); + + string route = Assert.Single(transport.PutRoutes); + Assert.StartsWith("streamRedirections/mic/deviceId/", route); + Assert.DoesNotContain("{", route); // braces escaped + } + [Fact] public async Task SetClassicDeviceAsync_UsesShortVocabularyAndEscapesDeviceId() { diff --git a/SteelSeriesAPI.Tests/SonarEventListenerTests.cs b/SteelSeriesAPI.Tests/SonarEventListenerTests.cs index 95fc6c0..4259a47 100644 --- a/SteelSeriesAPI.Tests/SonarEventListenerTests.cs +++ b/SteelSeriesAPI.Tests/SonarEventListenerTests.cs @@ -231,4 +231,42 @@ public void Diff_ChannelAbsentFromPrevious_IsSkipped() Assert.Empty(SonarEventListener.Diff(previous, current, Mode.Classic)); } + + // ---------------------------------------------------------------- + // Redirections diff + // ---------------------------------------------------------------- + + [Fact] + public void DiffRedirections_MicDeviceChange_IsDetected() + { + var previous = new SonarEventListener.RedirectionsSnapshot( + [], + new StreamRedirections(null, null, new MicRedirection("{cap-1}", true)), + MonitoringEnabled: false); + var current = previous with + { + Stream = new StreamRedirections(null, null, new MicRedirection("{cap-2}", true)), + }; + + var diff = SonarEventListener.DiffRedirections(previous, current); + + Assert.NotNull(diff.MicDeviceChange); + Assert.Equal("{cap-1}", diff.MicDeviceChange!.PreviousDeviceId); + Assert.Equal("{cap-2}", diff.MicDeviceChange.NewDeviceId); + Assert.False(diff.IsEmpty); + } + + [Fact] + public void DiffRedirections_MicAbsentOrUnchanged_YieldsNoMicChange() + { + var withMic = new SonarEventListener.RedirectionsSnapshot( + [], + new StreamRedirections(null, null, new MicRedirection("{cap-1}", true)), + MonitoringEnabled: false); + var withoutMic = withMic with { Stream = new StreamRedirections(null, null, null) }; + + Assert.Null(SonarEventListener.DiffRedirections(withMic, withMic).MicDeviceChange); + Assert.Null(SonarEventListener.DiffRedirections(withoutMic, withMic).MicDeviceChange); + Assert.Null(SonarEventListener.DiffRedirections(withMic, withoutMic).MicDeviceChange); + } } \ No newline at end of file diff --git a/SteelSeriesAPI/.editorconfig b/SteelSeriesAPI/.editorconfig new file mode 100644 index 0000000..196a138 --- /dev/null +++ b/SteelSeriesAPI/.editorconfig @@ -0,0 +1,6 @@ +# Library-only analyzer rules (does not apply to Sample/Explorer/Tests). + +[*.cs] +# CA2007: a library must not capture the caller's synchronization context when awaiting. +# Every await in this project must use ConfigureAwait(false). +dotnet_diagnostic.CA2007.severity = error diff --git a/SteelSeriesAPI/Core/ServerDiscovery.cs b/SteelSeriesAPI/Core/ServerDiscovery.cs index 85c6083..43f4657 100644 --- a/SteelSeriesAPI/Core/ServerDiscovery.cs +++ b/SteelSeriesAPI/Core/ServerDiscovery.cs @@ -57,7 +57,7 @@ public async Task DiscoverSonarAddressAsync(CancellationToken ct = default) string json; try { - json = await _ggClient.GetStringAsync($"https://{ggAddress}/subApps", ct); + json = await _ggClient.GetStringAsync($"https://{ggAddress}/subApps", ct).ConfigureAwait(false); } catch (HttpRequestException ex) { diff --git a/SteelSeriesAPI/Core/SonarHttpClient.cs b/SteelSeriesAPI/Core/SonarHttpClient.cs index d0747d0..7e3d653 100644 --- a/SteelSeriesAPI/Core/SonarHttpClient.cs +++ b/SteelSeriesAPI/Core/SonarHttpClient.cs @@ -32,9 +32,12 @@ public SonarHttpClient(ServerDiscovery discovery, ILogger? logger = null) /// A token to cancel the operation. public async Task GetAsync(string route, CancellationToken ct = default) { - using var response = await SendAsync(HttpMethod.Get, route, ct); - await using var stream = await response.Content.ReadAsStreamAsync(ct); - return await JsonDocument.ParseAsync(stream, cancellationToken: ct); + using var response = await SendAsync(HttpMethod.Get, route, ct).ConfigureAwait(false); + var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + return await JsonDocument.ParseAsync(stream, cancellationToken: ct).ConfigureAwait(false); + } } @@ -43,19 +46,19 @@ public async Task GetAsync(string route, CancellationToken ct = de /// A token to cancel the operation. public async Task PutAsync(string route, CancellationToken ct = default) { - using var _ = await SendAsync(HttpMethod.Put, route, ct); + using var _ = await SendAsync(HttpMethod.Put, route, ct).ConfigureAwait(false); } private async Task SendAsync( HttpMethod method, string route, CancellationToken ct, bool isRetry = false) { - Uri baseAddress = await GetBaseAddressAsync(ct); + Uri baseAddress = await GetBaseAddressAsync(ct).ConfigureAwait(false); var request = new HttpRequestMessage(method, new Uri(baseAddress, route)); HttpResponseMessage response; try { - response = await _http.SendAsync(request, ct); + response = await _http.SendAsync(request, ct).ConfigureAwait(false); } catch (HttpRequestException ex) when (!isRetry) { @@ -63,19 +66,19 @@ private async Task SendAsync( // GG may have restarted on a new port. Rediscover once, retry once. _logger.LogInformation(ex, "Request to {Route} failed, rediscovering Sonar address", route); InvalidateAddress(); - return await SendAsync(method, route, ct, isRetry: true); + return await SendAsync(method, route, ct, isRetry: true).ConfigureAwait(false); } catch (TaskCanceledException ex) when (!ct.IsCancellationRequested && !isRetry) { // Timeout (not a caller cancellation): treat as a transport failure. _logger.LogInformation(ex, "Request to {Route} timed out, rediscovering Sonar address", route); InvalidateAddress(); - return await SendAsync(method, route, ct, isRetry: true); + return await SendAsync(method, route, ct, isRetry: true).ConfigureAwait(false); } if (!response.IsSuccessStatusCode) { - string body = await response.Content.ReadAsStringAsync(ct); + string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (body.Contains("Cannot be called in current mode", StringComparison.OrdinalIgnoreCase)) throw new SonarWrongModeException(route); @@ -90,11 +93,11 @@ private async Task GetBaseAddressAsync(CancellationToken ct) { if (_baseAddress is not null) return _baseAddress; - await _discoveryLock.WaitAsync(ct); + await _discoveryLock.WaitAsync(ct).ConfigureAwait(false); try { // Another caller may have resolved it while we waited. - return _baseAddress ??= await _discovery.DiscoverSonarAddressAsync(ct); + return _baseAddress ??= await _discovery.DiscoverSonarAddressAsync(ct).ConfigureAwait(false); } finally { diff --git a/SteelSeriesAPI/Sonar/Events/Listener/DebouncedRefresher.cs b/SteelSeriesAPI/Sonar/Events/Listener/DebouncedRefresher.cs index 9ccfa69..95245ef 100644 --- a/SteelSeriesAPI/Sonar/Events/Listener/DebouncedRefresher.cs +++ b/SteelSeriesAPI/Sonar/Events/Listener/DebouncedRefresher.cs @@ -41,14 +41,14 @@ internal void Schedule(CancellationToken ct) { try { - await Task.Delay(DebounceDelay, ct); + await Task.Delay(DebounceDelay, ct).ConfigureAwait(false); if (version != _version) { _logger.LogDebug("{Name} refresh #{Version} superseded", _name, version); return; } - await RunNowAsync(ct); + await RunNowAsync(ct).ConfigureAwait(false); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -65,8 +65,8 @@ internal void Schedule(CancellationToken ct) /// Runs the refresh immediately, serialized with any scheduled refresh. internal async Task RunNowAsync(CancellationToken ct) { - await _lock.WaitAsync(ct); - try { await _refresh(ct); } + await _lock.WaitAsync(ct).ConfigureAwait(false); + try { await _refresh(ct).ConfigureAwait(false); } finally { _lock.Release(); } } diff --git a/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Configs.cs b/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Configs.cs index 48a9447..cf85208 100644 --- a/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Configs.cs +++ b/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Configs.cs @@ -24,7 +24,7 @@ public sealed partial class SonarEventListener /// Fetches the selected configs, diffs them against the baseline, and raises granular events. private async Task RefreshSelectedConfigsAsync(CancellationToken ct) { - var selected = await _configs.GetSelectedAsync(ct); + var selected = await _configs.GetSelectedAsync(ct).ConfigureAwait(false); if (_selectedConfigsBaseline is { } baseline) { diff --git a/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Redirections.cs b/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Redirections.cs index eca5647..93d28cf 100644 --- a/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Redirections.cs +++ b/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Redirections.cs @@ -15,7 +15,8 @@ public sealed partial class SonarEventListener /// /// Raised when Sonar broadcasts a redirection invalidation, without details. /// Most consumers should prefer the granular events: , - /// , and . + /// , , + /// and . /// public event EventHandler? RedirectionsInvalidated; @@ -31,6 +32,9 @@ public sealed partial class SonarEventListener /// Raised when stream monitoring ("hear what the audience hears") is toggled. public event EventHandler? StreamMonitoringChanged; + /// Raised when the streamer-mode mic passthrough is captured from a different device. + public event EventHandler? MicDeviceChanged; + /// The full redirection state used as a diffing baseline. internal sealed record RedirectionsSnapshot( IReadOnlyList Classic, @@ -41,9 +45,9 @@ internal sealed record RedirectionsSnapshot( private async Task RefreshRedirectionsAsync(CancellationToken ct) { var snapshot = new RedirectionsSnapshot( - await _redirections.GetClassicRedirectionsAsync(ct), - await _redirections.GetStreamRedirectionsAsync(ct), - await _redirections.GetStreamMonitoringEnabledAsync(ct)); + await _redirections.GetClassicRedirectionsAsync(ct).ConfigureAwait(false), + await _redirections.GetStreamRedirectionsAsync(ct).ConfigureAwait(false), + await _redirections.GetStreamMonitoringEnabledAsync(ct).ConfigureAwait(false)); if (_redirectionsBaseline is { } baseline) { @@ -52,9 +56,9 @@ await _redirections.GetStreamRedirectionsAsync(ct), if (!diff.IsEmpty) { _logger.LogDebug( - "Redirection changes detected: {Classic} classic, {MixDev} mix devices, {Toggles} toggles, monitoring changed: {Mon}", + "Redirection changes detected: {Classic} classic, {MixDev} mix devices, {Toggles} toggles, monitoring changed: {Mon}, mic changed: {Mic}", diff.ClassicDeviceChanges.Count, diff.MixDeviceChanges.Count, - diff.MixChannelToggles.Count, diff.MonitoringChange is not null); + diff.MixChannelToggles.Count, diff.MonitoringChange is not null, diff.MicDeviceChange is not null); } foreach (var change in diff.ClassicDeviceChanges) @@ -65,6 +69,8 @@ await _redirections.GetStreamRedirectionsAsync(ct), RaiseSafely(() => MixChannelToggled?.Invoke(this, change)); if (diff.MonitoringChange is { } monitoring) RaiseSafely(() => StreamMonitoringChanged?.Invoke(this, monitoring)); + if (diff.MicDeviceChange is { } micChange) + RaiseSafely(() => MicDeviceChanged?.Invoke(this, micChange)); } else { @@ -108,6 +114,11 @@ void DiffMix(MixRedirection? prev, MixRedirection? cur) ? new StreamMonitoringChange(current.MonitoringEnabled) : null; - return new RedirectionDiff(classicChanges, mixDeviceChanges, mixToggles, monitoring); + MicDeviceChange? micChange = + previous.Stream.Mic is { } prevMic && current.Stream.Mic is { } curMic && prevMic.DeviceId != curMic.DeviceId + ? new MicDeviceChange(prevMic.DeviceId, curMic.DeviceId) + : null; + + return new RedirectionDiff(classicChanges, mixDeviceChanges, mixToggles, monitoring, micChange); } } \ No newline at end of file diff --git a/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Volumes.cs b/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Volumes.cs index ad77496..c449613 100644 --- a/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Volumes.cs +++ b/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Volumes.cs @@ -38,18 +38,18 @@ private async Task RunPollingAsync(TimeSpan interval, CancellationToken ct) while (!ct.IsCancellationRequested) { - try { await Task.Delay(interval, ct); } + try { await Task.Delay(interval, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } try { - Mode mode = await modeManager.GetAsync(ct); + Mode mode = await modeManager.GetAsync(ct).ConfigureAwait(false); string route = mode == Mode.Streamer ? SonarRoutes.StreamerVolumes : SonarRoutes.ClassicVolumes; - using var doc = await _httpClient.GetAsync(route, ct); + using var doc = await _httpClient.GetAsync(route, ct).ConfigureAwait(false); var snapshot = ParseVolumeSnapshot(doc.RootElement); if (baselineMode is not null && baselineMode != mode) @@ -69,8 +69,8 @@ private async Task RunPollingAsync(TimeSpan interval, CancellationToken ct) baseline = snapshot; baselineMode = mode; - await _redirectionsRefresher.RunNowAsync(ct); - await _configsRefresher.RunNowAsync(ct); + await _redirectionsRefresher.RunNowAsync(ct).ConfigureAwait(false); + await _configsRefresher.RunNowAsync(ct).ConfigureAwait(false); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception ex) diff --git a/SteelSeriesAPI/Sonar/Events/RedirectionChanges.cs b/SteelSeriesAPI/Sonar/Events/RedirectionChanges.cs index 2cc1e3b..cf194d3 100644 --- a/SteelSeriesAPI/Sonar/Events/RedirectionChanges.cs +++ b/SteelSeriesAPI/Sonar/Events/RedirectionChanges.cs @@ -25,15 +25,21 @@ public sealed record MixChannelToggle(Mix Mix, Channel Channel, bool IsEnabled); /// Whether stream monitoring is enabled now. public sealed record StreamMonitoringChange(bool IsEnabled); +/// The streamer-mode mic passthrough was routed to a different capture device. +/// The device the mic was captured from before. +/// The device the mic is captured from now. +public sealed record MicDeviceChange(string PreviousDeviceId, string NewDeviceId); + /// Everything that changed between two redirection snapshots. public sealed record RedirectionDiff( IReadOnlyList ClassicDeviceChanges, IReadOnlyList MixDeviceChanges, IReadOnlyList MixChannelToggles, - StreamMonitoringChange? MonitoringChange) + StreamMonitoringChange? MonitoringChange, + MicDeviceChange? MicDeviceChange) { /// True when nothing actually changed between the two snapshots. public bool IsEmpty => ClassicDeviceChanges.Count == 0 && MixDeviceChanges.Count == 0 && - MixChannelToggles.Count == 0 && MonitoringChange is null; + MixChannelToggles.Count == 0 && MonitoringChange is null && MicDeviceChange is null; } \ No newline at end of file diff --git a/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs b/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs index 62e5ec5..994258f 100644 --- a/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs +++ b/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs @@ -120,8 +120,8 @@ public async Task StopAsync() { if (_cts is null || _runLoop is null) return; - await _cts.CancelAsync(); - try { await Task.WhenAll(_runLoop, _pollLoop ?? Task.CompletedTask); } + await _cts.CancelAsync().ConfigureAwait(false); + try { await Task.WhenAll(_runLoop, _pollLoop ?? Task.CompletedTask).ConfigureAwait(false); } catch (OperationCanceledException) { /* expected */ } _cts.Dispose(); @@ -140,11 +140,11 @@ private async Task RunAsync(CancellationToken ct) { try { - Uri http = await _httpClient.GetServerAddressAsync(ct); + Uri http = await _httpClient.GetServerAddressAsync(ct).ConfigureAwait(false); Uri wsUri = new UriBuilder(http) { Scheme = "ws", Path = SocketPath }.Uri; using var ws = new ClientWebSocket(); - await ws.ConnectAsync(wsUri, ct); + await ws.ConnectAsync(wsUri, ct).ConfigureAwait(false); _logger.LogDebug("Connected to Sonar event stream at {Uri}", wsUri); backoff = TimeSpan.FromSeconds(1); // reset on success @@ -157,7 +157,7 @@ private async Task RunAsync(CancellationToken ct) _redirectionsRefresher.Schedule(ct); _configsRefresher.Schedule(ct); - await ReceiveLoopAsync(ws, ct); + await ReceiveLoopAsync(ws, ct).ConfigureAwait(false); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -177,7 +177,7 @@ private async Task RunAsync(CancellationToken ct) // GG may have restarted on a new port: force a fresh discovery on next attempt. _httpClient.InvalidateAddress(); - try { await Task.Delay(backoff, ct); } + try { await Task.Delay(backoff, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, 30)); @@ -192,7 +192,7 @@ private async Task ReceiveLoopAsync(ClientWebSocket ws, CancellationToken ct) while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested) { - var result = await ws.ReceiveAsync(buffer, ct); + var result = await ws.ReceiveAsync(buffer, ct).ConfigureAwait(false); if (result.MessageType == WebSocketMessageType.Close) return; message.Write(buffer, 0, result.Count); diff --git a/SteelSeriesAPI/Sonar/Managers/AppRoutingManager.cs b/SteelSeriesAPI/Sonar/Managers/AppRoutingManager.cs index 26f9c0c..b145594 100644 --- a/SteelSeriesAPI/Sonar/Managers/AppRoutingManager.cs +++ b/SteelSeriesAPI/Sonar/Managers/AppRoutingManager.cs @@ -15,7 +15,7 @@ internal sealed class AppRoutingManager : IAppRoutingManager /// public async Task> GetRoutingsAsync(CancellationToken ct = default) { - using var doc = await _transport.GetAsync(SonarRoutes.AudioDeviceRouting, ct); + using var doc = await _transport.GetAsync(SonarRoutes.AudioDeviceRouting, ct).ConfigureAwait(false); return ParseRoutings(doc.RootElement); } @@ -23,7 +23,7 @@ public async Task> GetRoutingsAsync(CancellationTok public async Task RouteAppAsync(int processId, Channel channel, CancellationToken ct = default) { // Device ids are regenerated by GG updates: resolve the channel's device at call time. - var routings = await GetRoutingsAsync(ct); + var routings = await GetRoutingsAsync(ct).ConfigureAwait(false); var target = routings.FirstOrDefault(r => r.Channel == channel && r.DataFlow == AudioDataFlow.Render); @@ -32,7 +32,7 @@ public async Task RouteAppAsync(int processId, Channel channel, CancellationToke throw new SonarResponseException( $"No render device found for channel '{channel}' in the routing state."); - await RouteAppAsync(processId, target.DeviceId, AudioDataFlow.Render, ct); + await RouteAppAsync(processId, target.DeviceId, AudioDataFlow.Render, ct).ConfigureAwait(false); } /// diff --git a/SteelSeriesAPI/Sonar/Managers/AudioDeviceManager.cs b/SteelSeriesAPI/Sonar/Managers/AudioDeviceManager.cs index 6a41090..fda20cd 100644 --- a/SteelSeriesAPI/Sonar/Managers/AudioDeviceManager.cs +++ b/SteelSeriesAPI/Sonar/Managers/AudioDeviceManager.cs @@ -15,7 +15,7 @@ internal sealed class AudioDeviceManager : IAudioDeviceManager /// public async Task> GetAllAsync(CancellationToken ct = default) { - using var doc = await _transport.GetAsync(SonarRoutes.AudioDevices, ct); + using var doc = await _transport.GetAsync(SonarRoutes.AudioDevices, ct).ConfigureAwait(false); return ParseDevices(doc.RootElement); } @@ -23,7 +23,7 @@ public async Task> GetAllAsync(CancellationToken ct = public async Task> GetAllAsync( AudioDataFlow dataFlow, bool includeSonarVirtual = false, CancellationToken ct = default) { - var all = await GetAllAsync(ct); + var all = await GetAllAsync(ct).ConfigureAwait(false); return all .Where(d => d.DataFlow == dataFlow && (includeSonarVirtual || !d.IsSonarVirtual)) .ToList(); diff --git a/SteelSeriesAPI/Sonar/Managers/ChatMixManager.cs b/SteelSeriesAPI/Sonar/Managers/ChatMixManager.cs index cfad07a..ec83592 100644 --- a/SteelSeriesAPI/Sonar/Managers/ChatMixManager.cs +++ b/SteelSeriesAPI/Sonar/Managers/ChatMixManager.cs @@ -14,7 +14,7 @@ internal sealed class ChatMixManager : IChatMixManager /// public async Task GetAsync(CancellationToken ct = default) { - using var doc = await _transport.GetAsync(SonarRoutes.GetChatMix, ct); + using var doc = await _transport.GetAsync(SonarRoutes.GetChatMix, ct).ConfigureAwait(false); var root = doc.RootElement; double balance = root.TryGetProperty("balance", out var b) && diff --git a/SteelSeriesAPI/Sonar/Managers/ConfigManager.cs b/SteelSeriesAPI/Sonar/Managers/ConfigManager.cs index 37b44a3..4a66901 100644 --- a/SteelSeriesAPI/Sonar/Managers/ConfigManager.cs +++ b/SteelSeriesAPI/Sonar/Managers/ConfigManager.cs @@ -15,28 +15,28 @@ internal sealed class ConfigManager : IConfigManager /// public async Task> GetAllAsync(CancellationToken ct = default) { - using var doc = await _transport.GetAsync(SonarRoutes.Configs, ct); + using var doc = await _transport.GetAsync(SonarRoutes.Configs, ct).ConfigureAwait(false); return ParseConfigList(doc.RootElement); } /// public async Task> GetAllAsync(Channel channel, CancellationToken ct = default) { - var all = await GetAllAsync(ct); + var all = await GetAllAsync(ct).ConfigureAwait(false); return all.Where(c => c.Channel == channel).ToList(); } /// public async Task> GetSelectedAsync(CancellationToken ct = default) { - using var doc = await _transport.GetAsync(SonarRoutes.SelectedConfigs, ct); + using var doc = await _transport.GetAsync(SonarRoutes.SelectedConfigs, ct).ConfigureAwait(false); return ParseConfigList(doc.RootElement).ToDictionary(c => c.Channel); } /// public async Task GetSelectedAsync(Channel channel, CancellationToken ct = default) { - var selected = await GetSelectedAsync(ct); + var selected = await GetSelectedAsync(ct).ConfigureAwait(false); return selected.GetValueOrDefault(channel); } diff --git a/SteelSeriesAPI/Sonar/Managers/IRedirectionsManager.cs b/SteelSeriesAPI/Sonar/Managers/IRedirectionsManager.cs index fdcfcdc..9149d85 100644 --- a/SteelSeriesAPI/Sonar/Managers/IRedirectionsManager.cs +++ b/SteelSeriesAPI/Sonar/Managers/IRedirectionsManager.cs @@ -23,6 +23,13 @@ public interface IRedirectionsManager /// Routes a streamer-mode mix to a different output device. Task SetMixDeviceAsync(Mix mix, string deviceId, CancellationToken ct = default); + /// + /// Routes the streamer-mode mic passthrough to a different capture device. + /// + /// The id of the capture device to capture the mic from. + /// A token to cancel the operation. + Task SetMicDeviceAsync(string deviceId, CancellationToken ct = default); + /// Enables or disables a channel on a streamer-mode mix (the per-channel mix toggles). Task SetMixChannelEnabledAsync(Mix mix, Channel channel, bool enabled, CancellationToken ct = default); diff --git a/SteelSeriesAPI/Sonar/Managers/ModeManager.cs b/SteelSeriesAPI/Sonar/Managers/ModeManager.cs index 4ecef34..faafcb6 100644 --- a/SteelSeriesAPI/Sonar/Managers/ModeManager.cs +++ b/SteelSeriesAPI/Sonar/Managers/ModeManager.cs @@ -14,7 +14,7 @@ internal sealed class ModeManager : IModeManager /// public async Task GetAsync(CancellationToken ct = default) { - using var doc = await _transport.GetAsync(SonarRoutes.GetMode, ct); + using var doc = await _transport.GetAsync(SonarRoutes.GetMode, ct).ConfigureAwait(false); string? raw = doc.RootElement.ValueKind == JsonValueKind.String ? doc.RootElement.GetString() @@ -27,14 +27,14 @@ public async Task GetAsync(CancellationToken ct = default) /// public async Task SetAsync(Mode mode, CancellationToken ct = default) { - await _transport.PutAsync(SonarRoutes.SetMode(mode), ct); + await _transport.PutAsync(SonarRoutes.SetMode(mode), ct).ConfigureAwait(false); // Mode switching takes ~400-600ms in practice (measured 2026-08-07). // Poll every 100ms with a generous 5s budget: succeeds as soon as confirmed. for (int attempt = 0; attempt < 50; attempt++) { - if (await GetAsync(ct) == mode) return; - await Task.Delay(100, ct); + if (await GetAsync(ct).ConfigureAwait(false) == mode) return; + await Task.Delay(100, ct).ConfigureAwait(false); } throw new SonarResponseException( diff --git a/SteelSeriesAPI/Sonar/Managers/RedirectionsManager.cs b/SteelSeriesAPI/Sonar/Managers/RedirectionsManager.cs index f398f0d..99c9a6e 100644 --- a/SteelSeriesAPI/Sonar/Managers/RedirectionsManager.cs +++ b/SteelSeriesAPI/Sonar/Managers/RedirectionsManager.cs @@ -15,7 +15,7 @@ internal sealed class RedirectionsManager : IRedirectionsManager /// public async Task> GetClassicRedirectionsAsync(CancellationToken ct = default) { - using var doc = await _transport.GetAsync(SonarRoutes.ClassicRedirections, ct); + using var doc = await _transport.GetAsync(SonarRoutes.ClassicRedirections, ct).ConfigureAwait(false); return ParseClassicRedirections(doc.RootElement); } @@ -29,7 +29,7 @@ public Task SetClassicDeviceAsync(Channel channel, string deviceId, Cancellation /// public async Task GetStreamRedirectionsAsync(CancellationToken ct = default) { - using var doc = await _transport.GetAsync(SonarRoutes.StreamRedirections, ct); + using var doc = await _transport.GetAsync(SonarRoutes.StreamRedirections, ct).ConfigureAwait(false); return ParseStreamRedirections(doc.RootElement); } @@ -40,6 +40,13 @@ public Task SetMixDeviceAsync(Mix mix, string deviceId, CancellationToken ct = d return _transport.PutAsync(SonarRoutes.SetStreamRedirectionDevice(mix, deviceId), ct); } + /// + public Task SetMicDeviceAsync(string deviceId, CancellationToken ct = default) + { + ValidateDeviceId(deviceId); + return _transport.PutAsync(SonarRoutes.SetStreamRedirectionMicDevice(deviceId), ct); + } + /// public Task SetMixChannelEnabledAsync(Mix mix, Channel channel, bool enabled, CancellationToken ct = default) { @@ -52,7 +59,7 @@ public Task SetMixChannelEnabledAsync(Mix mix, Channel channel, bool enabled, Ca /// public async Task GetStreamMonitoringEnabledAsync(CancellationToken ct = default) { - using var doc = await _transport.GetAsync(SonarRoutes.StreamMonitoringEnabled, ct); + using var doc = await _transport.GetAsync(SonarRoutes.StreamMonitoringEnabled, ct).ConfigureAwait(false); return doc.RootElement.ValueKind == JsonValueKind.True; } diff --git a/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs b/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs index 5448069..8926762 100644 --- a/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs +++ b/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs @@ -15,7 +15,7 @@ internal sealed class VolumeSettingsManager : IVolumeSettingsManager /// public async Task GetAsync(Channel channel, CancellationToken ct = default) { - using var doc = await _transport.GetAsync(SonarRoutes.ClassicVolumes, ct); + using var doc = await _transport.GetAsync(SonarRoutes.ClassicVolumes, ct).ConfigureAwait(false); // Master lives under "masters", other channels under "devices/{key}". JsonElement node = channel == Channel.Master @@ -28,7 +28,7 @@ public async Task GetAsync(Channel channel, CancellationToken ct /// public async Task GetAsync(Channel channel, Mix mix, CancellationToken ct = default) { - using var doc = await _transport.GetAsync(SonarRoutes.StreamerVolumes, ct); + using var doc = await _transport.GetAsync(SonarRoutes.StreamerVolumes, ct).ConfigureAwait(false); JsonElement node = channel == Channel.Master ? doc.RootElement.Dig("masters", "stream", mix.ToJsonKey()) diff --git a/SteelSeriesAPI/Sonar/SonarRoutes.cs b/SteelSeriesAPI/Sonar/SonarRoutes.cs index 4f7ed21..c07ac03 100644 --- a/SteelSeriesAPI/Sonar/SonarRoutes.cs +++ b/SteelSeriesAPI/Sonar/SonarRoutes.cs @@ -75,6 +75,10 @@ internal static string SetClassicRedirectionDevice(Channel channel, string devic internal static string SetStreamRedirectionDevice(Mix mix, string deviceId) => $"streamRedirections/{mix.ToRouteKey()}/deviceId/{Uri.EscapeDataString(deviceId)}"; + // The mic passthrough is a streamRedirection entry with id "mic", same route shape as the mixes. + internal static string SetStreamRedirectionMicDevice(string deviceId) => + $"streamRedirections/mic/deviceId/{Uri.EscapeDataString(deviceId)}"; + internal static string SetMixChannelEnabled(Mix mix, Channel channel, bool enabled) => $"streamRedirections/{mix.ToRouteKey()}/redirections/{channel.ToJsonKey()}/isEnabled/{Bool(enabled)}"; diff --git a/SteelSeriesAPI/SteelSeriesAPI.csproj b/SteelSeriesAPI/SteelSeriesAPI.csproj index a247f46..76e2ec0 100644 --- a/SteelSeriesAPI/SteelSeriesAPI.csproj +++ b/SteelSeriesAPI/SteelSeriesAPI.csproj @@ -11,7 +11,7 @@ Steelseries-NET-API - 2.0.0-alpha.1 + 2.0.0-alpha.2 DataNext SteelSeries-NET-API Unofficial .NET library to control SteelSeries GG Sonar: volumes, mutes, mixer mode, chat mix, audio configs, device redirections, app routing, and real-time change events. No admin rights required. Not affiliated with SteelSeries.