Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions SteelSeriesAPI.Explorer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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." +
Expand Down
3 changes: 3 additions & 0 deletions SteelSeriesAPI.Sample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")}");
Expand Down
13 changes: 13 additions & 0 deletions SteelSeriesAPI.Tests/RedirectionsManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
38 changes: 38 additions & 0 deletions SteelSeriesAPI.Tests/SonarEventListenerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
6 changes: 6 additions & 0 deletions SteelSeriesAPI/.editorconfig
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion SteelSeriesAPI/Core/ServerDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public async Task<Uri> 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)
{
Expand Down
25 changes: 14 additions & 11 deletions SteelSeriesAPI/Core/SonarHttpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ public SonarHttpClient(ServerDiscovery discovery, ILogger? logger = null)
/// <param name="ct">A token to cancel the operation.</param>
public async Task<JsonDocument> 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);
}
}


Expand All @@ -43,39 +46,39 @@ public async Task<JsonDocument> GetAsync(string route, CancellationToken ct = de
/// <param name="ct">A token to cancel the operation.</param>
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<HttpResponseMessage> 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)
{
// Transport-level failure (connection refused, reset...):
// 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);
Expand All @@ -90,11 +93,11 @@ private async Task<Uri> 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
{
Expand Down
8 changes: 4 additions & 4 deletions SteelSeriesAPI/Sonar/Events/Listener/DebouncedRefresher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -65,8 +65,8 @@ internal void Schedule(CancellationToken ct)
/// <summary>Runs the refresh immediately, serialized with any scheduled refresh.</summary>
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(); }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public sealed partial class SonarEventListener
/// <summary>Fetches the selected configs, diffs them against the baseline, and raises granular events.</summary>
private async Task RefreshSelectedConfigsAsync(CancellationToken ct)
{
var selected = await _configs.GetSelectedAsync(ct);
var selected = await _configs.GetSelectedAsync(ct).ConfigureAwait(false);

if (_selectedConfigsBaseline is { } baseline)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ public sealed partial class SonarEventListener
/// <summary>
/// Raised when Sonar broadcasts a redirection invalidation, without details.
/// Most consumers should prefer the granular events: <see cref="ClassicDeviceChanged"/>,
/// <see cref="MixDeviceChanged"/>, <see cref="MixChannelToggled"/> and <see cref="StreamMonitoringChanged"/>.
/// <see cref="MixDeviceChanged"/>, <see cref="MixChannelToggled"/>, <see cref="StreamMonitoringChanged"/>
/// and <see cref="MicDeviceChanged"/>.
/// </summary>
public event EventHandler? RedirectionsInvalidated;

Expand All @@ -31,6 +32,9 @@ public sealed partial class SonarEventListener
/// <summary>Raised when stream monitoring ("hear what the audience hears") is toggled.</summary>
public event EventHandler<StreamMonitoringChange>? StreamMonitoringChanged;

/// <summary>Raised when the streamer-mode mic passthrough is captured from a different device.</summary>
public event EventHandler<MicDeviceChange>? MicDeviceChanged;

/// <summary>The full redirection state used as a diffing baseline.</summary>
internal sealed record RedirectionsSnapshot(
IReadOnlyList<ClassicRedirection> Classic,
Expand All @@ -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)
{
Expand All @@ -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)
Expand All @@ -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
{
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
10 changes: 8 additions & 2 deletions SteelSeriesAPI/Sonar/Events/RedirectionChanges.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,21 @@ public sealed record MixChannelToggle(Mix Mix, Channel Channel, bool IsEnabled);
/// <param name="IsEnabled">Whether stream monitoring is enabled now.</param>
public sealed record StreamMonitoringChange(bool IsEnabled);

/// <summary>The streamer-mode mic passthrough was routed to a different capture device.</summary>
/// <param name="PreviousDeviceId">The device the mic was captured from before.</param>
/// <param name="NewDeviceId">The device the mic is captured from now.</param>
public sealed record MicDeviceChange(string PreviousDeviceId, string NewDeviceId);

/// <summary>Everything that changed between two redirection snapshots.</summary>
public sealed record RedirectionDiff(
IReadOnlyList<ClassicDeviceChange> ClassicDeviceChanges,
IReadOnlyList<MixDeviceChange> MixDeviceChanges,
IReadOnlyList<MixChannelToggle> MixChannelToggles,
StreamMonitoringChange? MonitoringChange)
StreamMonitoringChange? MonitoringChange,
MicDeviceChange? MicDeviceChange)
{
/// <summary>True when nothing actually changed between the two snapshots.</summary>
public bool IsEmpty =>
ClassicDeviceChanges.Count == 0 && MixDeviceChanges.Count == 0 &&
MixChannelToggles.Count == 0 && MonitoringChange is null;
MixChannelToggles.Count == 0 && MonitoringChange is null && MicDeviceChange is null;
Comment on lines 33 to +44
}
Loading
Loading