From 8af3296fee798681987e11ac21c3b7efdfd88f8c Mon Sep 17 00:00:00 2001 From: hoobio <7289249+hoobio@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:26:26 +1000 Subject: [PATCH 1/2] fix(rules): use copy-on-write store to prevent crash on rule mutation Mutators (Upsert, Delete, Reorder) previously mutated the underlying List in place. Concurrent readers iterating Rules from the routing applier or UI hit InvalidOperationException ("Collection was modified") when a rule changed mid-iteration. The new Wave Link apply path widened that race, surfacing it as a hard crash on delete. Mutators now build a fresh list, mutate the copy, and atomically swap the volatile reference. Readers see whichever list was current when they took the reference and that list is never modified again, so iteration is lock-free and safe even while mutators run. --- src/Earmark.Core/Services/RulesService.cs | 24 +++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/Earmark.Core/Services/RulesService.cs b/src/Earmark.Core/Services/RulesService.cs index 7db8eee..7938a0c 100644 --- a/src/Earmark.Core/Services/RulesService.cs +++ b/src/Earmark.Core/Services/RulesService.cs @@ -7,7 +7,11 @@ public sealed class RulesService : IRulesService, IDisposable { private readonly IRuleStore _store; private readonly SemaphoreSlim _gate = new(1, 1); - private List _rules = new(); + + // Copy-on-write: mutators publish a brand-new list via this volatile reference. Readers + // see whichever list was current when they took the reference, and that list is never + // mutated again, so iteration is safe without locks even while mutators run. + private volatile List _rules = new(); public void Dispose() => _gate.Dispose(); @@ -43,17 +47,19 @@ public async Task UpsertAsync(RoutingRule rule, CancellationToken ct = default) await _gate.WaitAsync(ct).ConfigureAwait(false); try { - var idx = _rules.FindIndex(r => r.Id == rule.Id); + var copy = new List(_rules); + var idx = copy.FindIndex(r => r.Id == rule.Id); if (idx >= 0) { - _rules[idx] = rule; + copy[idx] = rule; } else { - _rules.Add(rule); + copy.Add(rule); } - await _store.SaveAsync(_rules, ct).ConfigureAwait(false); + _rules = copy; + await _store.SaveAsync(copy, ct).ConfigureAwait(false); } finally { @@ -68,8 +74,10 @@ public async Task DeleteAsync(Guid ruleId, CancellationToken ct = default) await _gate.WaitAsync(ct).ConfigureAwait(false); try { - _rules.RemoveAll(r => r.Id == ruleId); - await _store.SaveAsync(_rules, ct).ConfigureAwait(false); + var copy = new List(_rules); + copy.RemoveAll(r => r.Id == ruleId); + _rules = copy; + await _store.SaveAsync(copy, ct).ConfigureAwait(false); } finally { @@ -100,7 +108,7 @@ public async Task ReorderAsync(IReadOnlyList orderedIds, CancellationToken // Append any rules not in orderedIds (defensive). ordered.AddRange(map.Values); _rules = ordered; - await _store.SaveAsync(_rules, ct).ConfigureAwait(false); + await _store.SaveAsync(ordered, ct).ConfigureAwait(false); } finally { From 9dfb1969c2ca5308b907666f8c8f838ce38d6115 Mon Sep 17 00:00:00 2001 From: hoobio <7289249+hoobio@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:26:57 +1000 Subject: [PATCH 2/2] feat: add Wave Link integration plus volume/mute and editor improvements Wave Link integration - Connect to Wave Link's local WebSocket via discovered port from ws-info.json (fallback range 1884-1893), authenticate via Origin: streamdeck:// header - WaveLinkService is a long-lived singleton with a 5s polling loop and immediate Closed-event detection; exposes IsEnabled, State, LastSnapshot and StateChanged/SnapshotChanged events - Settings toggle "Enable Wave Link integration" with a colored status dot (green Connected / red Unavailable / gray Off) and tooltip - Probe-WaveLink.ps1 script under scripts/ for ad-hoc protocol testing Wave Link rule actions - AddWaveLinkMixOutput: ensure matched device is on matched mix (idempotent) - RemoveWaveLinkMixOutput: ensure matched device is not on matched mix - SetWaveLinkMixOutput: exact-match mode that keeps only matched devices on the matched mix; non-matching outputs are removed in a second pass so multiple Set actions on the same mix compose correctly - New MixPattern field on RuleAction; Get-before-Set on the wire avoids redundant audio glitches Device volume/mute rule actions - SetDeviceVolume (per-action Volume in [0,1]) - MuteDevice / UnmuteDevice - IAudioEndpointService grows GetVolume/GetMuted/SetVolume/SetMuted, all idempotent (set is skipped if delta < 0.5% for volume, or already at target for mute) - Per-device first-match-wins semantics: rule list order is the priority, volume and mute are independent dimensions Editor improvements - AutoSuggestBox replaces TextBox for device and mix patterns; suggestions populated from live endpoints / Wave Link snapshot, filtered as you type, inserted as the literal name on selection - PatternMatcher.Matches adds an exact-string shortcut so literal names with regex metacharacters (parentheses, dots) just work without escaping; regex compile is a fallback - Per-action diagnostic banner extended to cover invalid regex, unmatched patterns, and Wave Link unavailability; messages echo the user pattern and list available mixes / devices for context - Volume slider visible only when the action is SetDeviceVolume Chrome layout - Pane toggle button moved into TitleBar.LeftHeader; the icon, "Earmark" title and subtitle shift right; NavigationView's built-in toggle is hidden via IsPaneToggleButtonVisible="False" --- scripts/Probe-WaveLink.ps1 | 97 +++++ src/Earmark.App/App.xaml | 3 + src/Earmark.App/Converters/Converters.cs | 50 +++ src/Earmark.App/MainWindow.xaml | 14 + src/Earmark.App/MainWindow.xaml.cs | 5 + src/Earmark.App/Services/IRoutingApplier.cs | 256 +++++++++++++ .../Services/StartupSettingsApplier.cs | 9 + src/Earmark.App/Settings/AppSettings.cs | 2 + src/Earmark.App/ViewModels/RuleRow.cs | 289 +++++++++++++-- src/Earmark.App/ViewModels/RulesViewModel.cs | 15 +- .../ViewModels/SettingsViewModel.cs | 58 ++- src/Earmark.App/Views/RulesPage.xaml | 72 +++- src/Earmark.App/Views/RulesPage.xaml.cs | 72 ++++ src/Earmark.App/Views/SettingsPage.xaml | 10 + .../AudioServiceCollectionExtensions.cs | 3 + .../Services/AudioEndpointService.cs | 77 ++++ src/Earmark.Audio/WaveLink/WaveLinkClient.cs | 290 +++++++++++++++ src/Earmark.Audio/WaveLink/WaveLinkModels.cs | 41 ++ .../WaveLink/WaveLinkPortDiscovery.cs | 41 ++ src/Earmark.Audio/WaveLink/WaveLinkService.cs | 349 ++++++++++++++++++ .../Audio/IAudioEndpointService.cs | 13 + src/Earmark.Core/Models/RoutingRule.cs | 31 ++ src/Earmark.Core/Routing/RuleMatcher.cs | 93 +++-- src/Earmark.Core/WaveLink/IWaveLinkService.cs | 45 +++ 24 files changed, 1855 insertions(+), 80 deletions(-) create mode 100644 scripts/Probe-WaveLink.ps1 create mode 100644 src/Earmark.Audio/WaveLink/WaveLinkClient.cs create mode 100644 src/Earmark.Audio/WaveLink/WaveLinkModels.cs create mode 100644 src/Earmark.Audio/WaveLink/WaveLinkPortDiscovery.cs create mode 100644 src/Earmark.Audio/WaveLink/WaveLinkService.cs create mode 100644 src/Earmark.Core/WaveLink/IWaveLinkService.cs diff --git a/scripts/Probe-WaveLink.ps1 b/scripts/Probe-WaveLink.ps1 new file mode 100644 index 0000000..aa3ac9a --- /dev/null +++ b/scripts/Probe-WaveLink.ps1 @@ -0,0 +1,97 @@ +#Requires -Version 5.1 +[CmdletBinding()] +param( + [string[]]$Methods = @('getMixes', 'getOutputDevices', 'getInputDevices', 'getChannels', 'getApplicationInfo'), + [int]$ResponseTimeoutMs = 4000 +) + +$ErrorActionPreference = 'Stop' + +$wsInfoPath = Join-Path $env:LOCALAPPDATA 'Packages\Elgato.WaveLink_g54w8ztgkx496\LocalState\ws-info.json' +if (-not (Test-Path $wsInfoPath)) { + throw "ws-info.json not found at $wsInfoPath. Is Wave Link running?" +} + +$port = (Get-Content $wsInfoPath -Raw | ConvertFrom-Json).port +Write-Verbose "Using port $port from $wsInfoPath" + +$ws = [System.Net.WebSockets.ClientWebSocket]::new() +$ws.Options.SetRequestHeader('Origin', 'streamdeck://') +$cts = [System.Threading.CancellationTokenSource]::new(5000) +$ws.ConnectAsync([Uri]"ws://127.0.0.1:$port", $cts.Token).Wait() +Write-Verbose "Connected (state: $($ws.State))" + +function Send-Json { + param($Ws, $Object) + $json = $Object | ConvertTo-Json -Depth 10 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $seg = [System.ArraySegment[byte]]::new($bytes) + $Ws.SendAsync($seg, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, [System.Threading.CancellationToken]::None).Wait() +} + +function Receive-Json { + param($Ws, [int]$TimeoutMs) + $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMs) + $sb = [System.Text.StringBuilder]::new() + $buf = [byte[]]::new(8192) + while ([DateTime]::UtcNow -lt $deadline) { + $remain = [int]($deadline - [DateTime]::UtcNow).TotalMilliseconds + if ($remain -le 0) { break } + $cts2 = [System.Threading.CancellationTokenSource]::new($remain) + try { + $task = $Ws.ReceiveAsync([System.ArraySegment[byte]]::new($buf), $cts2.Token) + $task.Wait() + $r = $task.Result + $null = $sb.Append([System.Text.Encoding]::UTF8.GetString($buf, 0, $r.Count)) + if ($r.EndOfMessage) { return $sb.ToString() } + } catch { + return $null + } + } + return $null +} + +$id = 1 +$results = [ordered]@{} + +# Drain any unsolicited push frames first +while ($null -ne (Receive-Json -Ws $ws -TimeoutMs 250)) {} + +foreach ($method in $Methods) { + $req = @{ jsonrpc = '2.0'; method = $method; id = $id } + Write-Host "--> $method (id=$id)" -ForegroundColor Cyan + Send-Json -Ws $ws -Object $req + + $resp = $null + $deadline = [DateTime]::UtcNow.AddMilliseconds($ResponseTimeoutMs) + while ([DateTime]::UtcNow -lt $deadline -and $null -eq $resp) { + $msg = Receive-Json -Ws $ws -TimeoutMs 1000 + if ($null -eq $msg) { continue } + try { + $obj = $msg | ConvertFrom-Json + if ($obj.id -eq $id) { $resp = $obj; break } + } catch { + Write-Warning "Non-JSON frame: $($msg.Substring(0, [Math]::Min(200, $msg.Length)))" + } + } + + if ($null -eq $resp) { + Write-Host " (no response in ${ResponseTimeoutMs}ms)" -ForegroundColor Yellow + } else { + $results[$method] = $resp + $resp | ConvertTo-Json -Depth 10 + } + $id++ +} + +$ws.CloseOutputAsync('NormalClosure', 'done', [System.Threading.CancellationToken]::None).Wait() + +Write-Host "`n=== Summary ===" -ForegroundColor Green +foreach ($m in $results.Keys) { + $r = $results[$m] + if ($r.error) { + Write-Host "$m -> ERROR $($r.error.code): $($r.error.message)" -ForegroundColor Red + } else { + Write-Host "$m -> ok" -ForegroundColor Green + } +} diff --git a/src/Earmark.App/App.xaml b/src/Earmark.App/App.xaml index 0340692..1e35fc6 100644 --- a/src/Earmark.App/App.xaml +++ b/src/Earmark.App/App.xaml @@ -29,6 +29,9 @@ + + + 4 diff --git a/src/Earmark.App/Converters/Converters.cs b/src/Earmark.App/Converters/Converters.cs index 02503b1..d69a161 100644 --- a/src/Earmark.App/Converters/Converters.cs +++ b/src/Earmark.App/Converters/Converters.cs @@ -1,3 +1,5 @@ +using Earmark.Core.WaveLink; + using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Data; using Microsoft.UI.Xaml.Media; @@ -72,6 +74,54 @@ public object ConvertBack(object value, Type targetType, object parameter, strin throw new NotSupportedException(); } +public sealed class WaveLinkStateBrushConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + var key = value is WaveLinkConnectionState s ? s switch + { + WaveLinkConnectionState.Connected => "SystemFillColorSuccessBrush", + WaveLinkConnectionState.Unavailable => "SystemFillColorCriticalBrush", + _ => "TextFillColorTertiaryBrush", + } : "TextFillColorTertiaryBrush"; + + if (Application.Current.Resources.TryGetValue(key, out var brush) && brush is Brush b) + { + return b; + } + + return new SolidColorBrush(Microsoft.UI.Colors.Gray); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => + throw new NotSupportedException(); +} + +public sealed class WaveLinkStateGlyphConverter : IValueConverter +{ + // Segoe Fluent Icons: CheckMark E73E, Warning E7BA, StatusCircleBlock F140. + public object Convert(object value, Type targetType, object parameter, string language) => + value is WaveLinkConnectionState s ? s switch + { + WaveLinkConnectionState.Connected => "", + WaveLinkConnectionState.Unavailable => "", + _ => "", + } : ""; + + public object ConvertBack(object value, Type targetType, object parameter, string language) => + throw new NotSupportedException(); +} + +public sealed class VolumeFloatToPercentConverter : IValueConverter +{ + // Slider values are double; the rule action stores Volume as float in [0,1]. + public object Convert(object value, Type targetType, object parameter, string language) => + value is float f ? Math.Round(f * 100.0, 0) : 0.0; + + public object ConvertBack(object value, Type targetType, object parameter, string language) => + value is double d ? (float)Math.Clamp(d / 100.0, 0.0, 1.0) : 0f; +} + public sealed class EnumToStringConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, string language) => diff --git a/src/Earmark.App/MainWindow.xaml b/src/Earmark.App/MainWindow.xaml index 70447b2..844f31e 100644 --- a/src/Earmark.App/MainWindow.xaml +++ b/src/Earmark.App/MainWindow.xaml @@ -26,6 +26,19 @@ + + + _logger; private readonly HashSet _appliedSessionKeys = new(StringComparer.OrdinalIgnoreCase); private readonly Lock _appliedGate = new(); @@ -41,6 +45,7 @@ public RoutingApplier( IAudioEndpointService endpoints, IAudioPolicyService policy, IRuleMatcher matcher, + IWaveLinkService waveLink, ILogger logger) { _rules = rules; @@ -48,6 +53,7 @@ public RoutingApplier( _endpoints = endpoints; _policy = policy; _matcher = matcher; + _waveLink = waveLink; _logger = logger; } @@ -121,7 +127,10 @@ await Task.Run(() => { ApplyDefaultDevices(); ApplyApplicationsToSessions(); + ApplyVolumeAndMuteRules(); }).ConfigureAwait(false); + + await ApplyWaveLinkRulesAsync(_cts?.Token ?? CancellationToken.None).ConfigureAwait(false); } finally { @@ -360,6 +369,253 @@ private async void OnTimerTick(object? state) } } + private void ApplyVolumeAndMuteRules() + { + // Per-device first-match-wins, symmetric with app/default-device rules. Volume and mute + // are independent dimensions, so each endpoint resolves them separately: we scan rules + // top-to-bottom and lock in the first matching SetDeviceVolume for the volume target and + // the first matching Mute/Unmute for the mute target. + var renderEndpoints = _endpoints.GetEndpoints(EndpointFlow.Render); + var captureEndpoints = _endpoints.GetEndpoints(EndpointFlow.Capture); + var allEndpoints = renderEndpoints + .Concat(captureEndpoints) + .Where(e => e.State == EndpointState.Active) + .ToList(); + if (allEndpoints.Count == 0) + { + return; + } + + foreach (var endpoint in allEndpoints) + { + float? targetVolume = null; + string? volumeRuleName = null; + bool? targetMuted = null; + string? muteRuleName = null; + + foreach (var rule in _rules.Rules) + { + if (targetVolume.HasValue && targetMuted.HasValue) break; + if (!rule.Enabled) continue; + if (!_matcher.ConditionsMet(rule, renderEndpoints)) continue; + + var ruleLabel = string.IsNullOrEmpty(rule.Name) ? rule.Id.ToString() : rule.Name; + + foreach (var action in rule.Actions) + { + if (!action.IsValid) continue; + if (action.Type is not (ActionType.SetDeviceVolume or ActionType.MuteDevice or ActionType.UnmuteDevice)) continue; + + var devRegex = TryCompile(action.DevicePattern); + if (!MatchPattern(action.DevicePattern, devRegex, endpoint.FriendlyName) && + !MatchPattern(action.DevicePattern, devRegex, endpoint.DisplayName)) continue; + + if (action.Type == ActionType.SetDeviceVolume && !targetVolume.HasValue) + { + targetVolume = action.Volume; + volumeRuleName = ruleLabel; + } + else if (action.Type is ActionType.MuteDevice or ActionType.UnmuteDevice && !targetMuted.HasValue) + { + targetMuted = action.Type == ActionType.MuteDevice; + muteRuleName = ruleLabel; + } + + if (targetVolume.HasValue && targetMuted.HasValue) break; + } + } + + if (targetVolume.HasValue) + { + var applied = _endpoints.SetVolume(endpoint.Id, targetVolume.Value); + if (applied) + { + _logger.LogInformation("Applied volume rule '{Rule}': '{Device}' -> {Volume:F2}", + volumeRuleName, endpoint.DisplayName, targetVolume.Value); + } + } + if (targetMuted.HasValue) + { + var applied = _endpoints.SetMuted(endpoint.Id, targetMuted.Value); + if (applied) + { + _logger.LogInformation("Applied {Verb} rule '{Rule}': '{Device}'", + targetMuted.Value ? "mute" : "unmute", muteRuleName, endpoint.DisplayName); + } + } + } + } + + private readonly record struct WaveLinkClaim(string TargetMixId, string RuleName); + + private async Task ApplyWaveLinkRulesAsync(CancellationToken ct) + { + var hasWaveLinkRule = false; + foreach (var rule in _rules.Rules) + { + if (!rule.Enabled) continue; + foreach (var action in rule.Actions) + { + if (action.IsValid && action.IsWaveLinkAction) + { + hasWaveLinkRule = true; + break; + } + } + if (hasWaveLinkRule) break; + } + if (!hasWaveLinkRule) + { + return; + } + + WaveLinkSnapshot? snapshot; + try + { + snapshot = await _waveLink.GetSnapshotAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { return; } + catch (Exception ex) + { + _logger.LogWarning(ex, "Wave Link: snapshot failed"); + return; + } + if (snapshot is null) + { + _logger.LogDebug("Wave Link: snapshot unavailable; skipping {Count} rules", _rules.Rules.Count); + return; + } + + var claims = BuildWaveLinkClaims(snapshot); + + foreach (var output in snapshot.OutputDevices) + { + if (ct.IsCancellationRequested) return; + if (!claims.TryGetValue(output.DeviceId, out var claim)) continue; + + if (string.Equals(output.CurrentMixId, claim.TargetMixId, StringComparison.Ordinal)) + { + _logger.LogDebug("Skip Wave Link for '{Device}': already on '{Mix}'", + output.DeviceName, ResolveMixName(snapshot.Mixes, claim.TargetMixId)); + continue; + } + + bool ok; + try + { + ok = await _waveLink.SetMixForOutputAsync(output.DeviceId, output.OutputId, claim.TargetMixId, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { return; } + + if (ok) + { + _logger.LogInformation("Applied Wave Link rule '{Rule}': '{Device}' {From} -> {To}", + claim.RuleName, + output.DeviceName, + ResolveMixName(snapshot.Mixes, output.CurrentMixId), + ResolveMixName(snapshot.Mixes, claim.TargetMixId)); + } + } + } + + private Dictionary BuildWaveLinkClaims(WaveLinkSnapshot snapshot) + { + var claims = new Dictionary(StringComparer.Ordinal); + var setOwnedMixes = new HashSet(StringComparer.Ordinal); + var renderEndpoints = _endpoints.GetEndpoints(EndpointFlow.Render); + + foreach (var rule in _rules.Rules) + { + if (!rule.Enabled) continue; + if (!_matcher.ConditionsMet(rule, renderEndpoints)) continue; + + var ruleLabel = string.IsNullOrEmpty(rule.Name) ? rule.Id.ToString() : rule.Name; + + foreach (var action in rule.Actions) + { + if (!action.IsValid || !action.IsWaveLinkAction) continue; + + var mixRegex = TryCompile(action.MixPattern); + var devRegex = TryCompile(action.DevicePattern); + // Exact-match shortcut covers the case where the pattern equals a name + // verbatim (e.g. inserted from auto-suggest), so a missing regex isn't fatal. + + WaveLinkMixInfo? matchedMix = null; + foreach (var mix in snapshot.Mixes) + { + if (MatchPattern(action.MixPattern, mixRegex, mix.Name)) + { + matchedMix = mix; + break; + } + } + if (matchedMix is null) continue; + + if (action.Type == ActionType.SetWaveLinkMixOutput) + { + setOwnedMixes.Add(matchedMix.Id); + } + + foreach (var output in snapshot.OutputDevices) + { + if (claims.ContainsKey(output.DeviceId)) continue; + var deviceMatches = MatchPattern(action.DevicePattern, devRegex, output.DeviceName); + + switch (action.Type) + { + case ActionType.AddWaveLinkMixOutput: + case ActionType.SetWaveLinkMixOutput: + if (deviceMatches) + { + claims[output.DeviceId] = new WaveLinkClaim(matchedMix.Id, ruleLabel); + } + break; + case ActionType.RemoveWaveLinkMixOutput: + if (deviceMatches && string.Equals(output.CurrentMixId, matchedMix.Id, StringComparison.Ordinal)) + { + claims[output.DeviceId] = new WaveLinkClaim(string.Empty, ruleLabel); + } + break; + } + } + } + } + + // Set's "remove non-matching" sweep: any Set-owned mix loses unclaimed devices. + foreach (var output in snapshot.OutputDevices) + { + if (claims.ContainsKey(output.DeviceId)) continue; + if (setOwnedMixes.Contains(output.CurrentMixId)) + { + claims[output.DeviceId] = new WaveLinkClaim(string.Empty, "Set rule (cleanup)"); + } + } + + return claims; + } + + private static string ResolveMixName(IReadOnlyList mixes, string mixId) + { + if (string.IsNullOrEmpty(mixId)) return "(none)"; + return mixes.FirstOrDefault(m => string.Equals(m.Id, mixId, StringComparison.Ordinal))?.Name ?? mixId; + } + + private static Regex? TryCompile(string pattern) + { + if (string.IsNullOrWhiteSpace(pattern)) return null; + try + { + return new Regex( + pattern, + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(250)); + } + catch (ArgumentException) { return null; } + } + + private static bool MatchPattern(string pattern, Regex? regex, string input) => + PatternMatcher.Matches(pattern, regex, input); + public void Dispose() { if (!_started) diff --git a/src/Earmark.App/Services/StartupSettingsApplier.cs b/src/Earmark.App/Services/StartupSettingsApplier.cs index c90320f..45c959f 100644 --- a/src/Earmark.App/Services/StartupSettingsApplier.cs +++ b/src/Earmark.App/Services/StartupSettingsApplier.cs @@ -1,5 +1,6 @@ using Earmark.App.Logging; using Earmark.App.Settings; +using Earmark.Core.WaveLink; using Microsoft.Extensions.Logging; @@ -9,16 +10,19 @@ internal sealed class StartupSettingsApplier : IDisposable { private readonly ISettingsService _settings; private readonly FileLoggerProvider _fileLogger; + private readonly IWaveLinkService _waveLink; private readonly ILogger _logger; private bool _started; public StartupSettingsApplier( ISettingsService settings, FileLoggerProvider fileLogger, + IWaveLinkService waveLink, ILogger logger) { _settings = settings; _fileLogger = fileLogger; + _waveLink = waveLink; _logger = logger; } @@ -56,6 +60,11 @@ private void Apply() _fileLogger.SetMinimumLevel(desired); _logger.LogInformation("File log level set to {Level}", desired); } + + if (_waveLink.IsEnabled != s.EnableWaveLink) + { + _waveLink.IsEnabled = s.EnableWaveLink; + } } catch (Exception ex) { diff --git a/src/Earmark.App/Settings/AppSettings.cs b/src/Earmark.App/Settings/AppSettings.cs index a95c68a..642fa7e 100644 --- a/src/Earmark.App/Settings/AppSettings.cs +++ b/src/Earmark.App/Settings/AppSettings.cs @@ -13,4 +13,6 @@ public sealed class AppSettings public bool LaunchToTray { get; set; } public bool VerboseLogging { get; set; } + + public bool EnableWaveLink { get; set; } } diff --git a/src/Earmark.App/ViewModels/RuleRow.cs b/src/Earmark.App/ViewModels/RuleRow.cs index a065948..a6ce281 100644 --- a/src/Earmark.App/ViewModels/RuleRow.cs +++ b/src/Earmark.App/ViewModels/RuleRow.cs @@ -7,6 +7,7 @@ using Earmark.Core.Audio; using Earmark.Core.Models; using Earmark.Core.Routing; +using Earmark.Core.WaveLink; namespace Earmark.App.ViewModels; @@ -55,6 +56,9 @@ public RuleRow(RoutingRule rule, Func persistAsync) [ObservableProperty] public partial string MatchSummary { get; set; } = string.Empty; + [ObservableProperty] + public partial string Warning { get; set; } = string.Empty; + public bool IsActive => Status == RuleStatus.Active; public bool IsDimmed => Status is RuleStatus.Off or RuleStatus.ConditionsNotMet or RuleStatus.Shadowed or RuleStatus.Idle or RuleStatus.Incomplete; public double CardOpacity => IsDimmed ? 0.55 : 1.0; @@ -62,6 +66,7 @@ public RuleRow(RoutingRule rule, Func persistAsync) public bool HasActions => Actions.Count > 0; public bool HasStatusMessage => !string.IsNullOrEmpty(StatusMessage); public bool HasMatchSummary => !string.IsNullOrEmpty(MatchSummary); + public bool HasWarning => !string.IsNullOrEmpty(Warning); public string DisplayName { @@ -143,7 +148,11 @@ private static void SyncList( } } - public void Recompute(IReadOnlyList sessions, IReadOnlyList endpoints) + public void Recompute( + IReadOnlyList sessions, + IReadOnlyList endpoints, + WaveLinkSnapshot? waveLinkSnapshot, + WaveLinkConnectionState waveLinkState) { foreach (var c in Conditions) { @@ -151,10 +160,24 @@ public void Recompute(IReadOnlyList sessions, IReadOnlyList a.HasDiagnostic); + Warning = first?.Diagnostic ?? string.Empty; } public void ApplyEvaluation(RuleEvaluation evaluation) @@ -168,8 +191,9 @@ private void UpdateMatchSummary() { var totalApps = Actions.Where(a => a.RequiresAppPattern).Sum(a => a.AppMatchCount); var deviceCount = Actions.Count(a => a.HasDeviceMatch); + var mixCount = Actions.Count(a => a.IsWaveLinkAction && a.HasMixMatch); - if (totalApps == 0 && deviceCount == 0) + if (totalApps == 0 && deviceCount == 0 && mixCount == 0) { MatchSummary = string.Empty; return; @@ -184,6 +208,10 @@ private void UpdateMatchSummary() { parts.Add(deviceCount == 1 ? "1 device" : $"{deviceCount} devices"); } + if (mixCount > 0) + { + parts.Add(mixCount == 1 ? "1 mix" : $"{mixCount} mixes"); + } MatchSummary = string.Join(" / ", parts); } @@ -264,6 +292,8 @@ partial void OnStatusChanged(RuleStatus value) partial void OnMatchSummaryChanged(string value) => OnPropertyChanged(nameof(HasMatchSummary)); + partial void OnWarningChanged(string value) => OnPropertyChanged(nameof(HasWarning)); + private void NotifyChildChanged() { OnPropertyChanged(nameof(DisplayName)); @@ -354,6 +384,19 @@ internal static bool MatchSafe(Regex regex, string input) return false; } } + + /// + /// Match the pattern against text with an exact-string shortcut: if the pattern verbatim + /// equals the candidate (case-insensitive), match without compiling. Otherwise fall back + /// to regex. + /// + internal static bool MatchOrExact(string pattern, string candidate) + { + if (string.IsNullOrEmpty(candidate)) return false; + if (string.Equals(pattern, candidate, StringComparison.OrdinalIgnoreCase)) return true; + if (!TryCompile(pattern, out var regex) || regex is null) return false; + return MatchSafe(regex, candidate); + } } internal interface ISyncable @@ -394,6 +437,12 @@ public ActionRow(RuleAction action, Action notifyParent) new ActionTypeOption(ActionType.SetApplicationInput, "Set input device for app"), new ActionTypeOption(ActionType.SetDefaultOutput, "Set system default output"), new ActionTypeOption(ActionType.SetDefaultInput, "Set system default input"), + new ActionTypeOption(ActionType.AddWaveLinkMixOutput, "Add device to Wave Link mix"), + new ActionTypeOption(ActionType.RemoveWaveLinkMixOutput, "Remove device from Wave Link mix"), + new ActionTypeOption(ActionType.SetWaveLinkMixOutput, "Set Wave Link mix outputs (exact)"), + new ActionTypeOption(ActionType.SetDeviceVolume, "Set device volume"), + new ActionTypeOption(ActionType.MuteDevice, "Mute device"), + new ActionTypeOption(ActionType.UnmuteDevice, "Unmute device"), }; #pragma warning disable CA1822 @@ -409,6 +458,12 @@ public ActionRow(RuleAction action, Action notifyParent) [ObservableProperty] public partial string DevicePattern { get; set; } = string.Empty; + [ObservableProperty] + public partial string MixPattern { get; set; } = string.Empty; + + [ObservableProperty] + public partial float Volume { get; set; } = 0.5f; + [ObservableProperty] public partial bool SetsDefault { get; set; } = true; @@ -424,10 +479,35 @@ public ActionRow(RuleAction action, Action notifyParent) [ObservableProperty] public partial string DeviceMatchSummary { get; set; } = string.Empty; + [ObservableProperty] + public partial string MixMatchSummary { get; set; } = string.Empty; + + [ObservableProperty] + public partial string Diagnostic { get; set; } = string.Empty; + + [ObservableProperty] + public partial bool IsDevicePatternValid { get; set; } = true; + + [ObservableProperty] + public partial bool IsMixPatternValid { get; set; } = true; + + [ObservableProperty] + public partial bool IsAppPatternValid { get; set; } = true; + + public IReadOnlyList DeviceCandidates { get; private set; } = Array.Empty(); + public IReadOnlyList MixCandidates { get; private set; } = Array.Empty(); + public bool RequiresAppPattern => Type is ActionType.SetApplicationOutput or ActionType.SetApplicationInput; public bool IsDefaultAction => Type is ActionType.SetDefaultOutput or ActionType.SetDefaultInput; + public bool IsWaveLinkAction => Type is + ActionType.AddWaveLinkMixOutput or + ActionType.RemoveWaveLinkMixOutput or + ActionType.SetWaveLinkMixOutput; + public bool RequiresVolumeSlider => Type is ActionType.SetDeviceVolume; public bool HasAppMatches => AppMatchCount > 0; public bool HasDeviceMatch => !string.IsNullOrEmpty(DeviceMatchSummary); + public bool HasMixMatch => !string.IsNullOrEmpty(MixMatchSummary); + public bool HasDiagnostic => !string.IsNullOrEmpty(Diagnostic); public string AppMatchSummary => AppMatchCount == 1 ? "1 matching app" : $"{AppMatchCount} matching apps"; public string TypeLabel => Type switch @@ -436,6 +516,12 @@ public ActionRow(RuleAction action, Action notifyParent) ActionType.SetApplicationInput => "App input", ActionType.SetDefaultOutput => "Default output", ActionType.SetDefaultInput => "Default input", + ActionType.AddWaveLinkMixOutput => "Add to mix", + ActionType.RemoveWaveLinkMixOutput => "Remove from mix", + ActionType.SetWaveLinkMixOutput => "Set mix outputs", + ActionType.SetDeviceVolume => "Set volume", + ActionType.MuteDevice => "Mute", + ActionType.UnmuteDevice => "Unmute", _ => Type.ToString(), }; @@ -456,6 +542,8 @@ public ActionTypeOption SelectedTypeOption Type = Type, AppPattern = AppPattern, DevicePattern = DevicePattern, + MixPattern = MixPattern, + Volume = Volume, SetsDefault = SetsDefault, SetsCommunications = SetsCommunications, }; @@ -469,6 +557,8 @@ public void SyncFromModel(RuleAction action) Type = action.Type; AppPattern = action.AppPattern; DevicePattern = action.DevicePattern; + MixPattern = action.MixPattern; + Volume = action.Volume; SetsDefault = action.SetsDefault; SetsCommunications = action.SetsCommunications; } @@ -478,8 +568,16 @@ public void SyncFromModel(RuleAction action) } } - public void Recompute(IReadOnlyList sessions, IReadOnlyList endpoints) + public void Recompute( + IReadOnlyList sessions, + IReadOnlyList endpoints, + WaveLinkSnapshot? waveLinkSnapshot, + WaveLinkConnectionState waveLinkState) { + IsAppPatternValid = string.IsNullOrWhiteSpace(AppPattern) || RuleRow.TryCompile(AppPattern, out _); + IsDevicePatternValid = string.IsNullOrWhiteSpace(DevicePattern) || RuleRow.TryCompile(DevicePattern, out _); + IsMixPatternValid = string.IsNullOrWhiteSpace(MixPattern) || RuleRow.TryCompile(MixPattern, out _); + if (RequiresAppPattern) { RecomputeApp(sessions); @@ -490,12 +588,163 @@ public void Recompute(IReadOnlyList sessions, IReadOnlyList o.DeviceName) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .ToArray() + ?? Array.Empty(); + + MixCandidates = waveLinkSnapshot?.Mixes + .Select(m => m.Name) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .ToArray() + ?? Array.Empty(); + } + else + { + DeviceMatchSummary = ResolveDeviceNameAnyFlow(DevicePattern, EffectiveDeviceFlow(Type), endpoints); + MixMatchSummary = string.Empty; + Diagnostic = ComputeNonWaveLinkDiagnostic(); + + DeviceCandidates = endpoints + .Where(e => e.State == EndpointState.Active && DeviceMatchesType(e.Flow, Type)) + .Select(e => e.FriendlyName) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + MixCandidates = Array.Empty(); + } + + OnPropertyChanged(nameof(DeviceCandidates)); + OnPropertyChanged(nameof(MixCandidates)); + } + + private string ComputeNonWaveLinkDiagnostic() + { + if (RequiresAppPattern && !string.IsNullOrWhiteSpace(AppPattern) && !IsAppPatternValid) + { + return $"App pattern '{AppPattern}' is not valid regex"; + } + if (!string.IsNullOrWhiteSpace(DevicePattern) && !IsDevicePatternValid) + { + return $"Device pattern '{DevicePattern}' is not valid regex"; + } + return string.Empty; + } + + private static EndpointFlow? EffectiveDeviceFlow(ActionType type) => type switch + { + ActionType.SetApplicationOutput or ActionType.SetDefaultOutput => EndpointFlow.Render, + ActionType.SetApplicationInput or ActionType.SetDefaultInput => EndpointFlow.Capture, + ActionType.SetDeviceVolume or ActionType.MuteDevice or ActionType.UnmuteDevice => null, + _ => EndpointFlow.Render, + }; + + private static bool DeviceMatchesType(EndpointFlow flow, ActionType type) + { + var required = EffectiveDeviceFlow(type); + return required is null || required == flow; + } + + private string ComputeWaveLinkDiagnostic(WaveLinkConnectionState state, WaveLinkSnapshot? snapshot) + { + if (state == WaveLinkConnectionState.Disabled) + { + return "Wave Link integration is off in Settings"; + } + if (state == WaveLinkConnectionState.Unavailable || snapshot is null) + { + return "Wave Link not connected"; + } + if (string.IsNullOrWhiteSpace(MixPattern)) + { + return "Mix pattern is empty"; + } + if (string.IsNullOrWhiteSpace(DevicePattern)) + { + return "Device pattern is empty"; + } + if (!RuleRow.TryCompile(MixPattern, out _)) + { + return $"Mix pattern '{MixPattern}' is not valid regex"; + } + if (!RuleRow.TryCompile(DevicePattern, out _)) + { + return $"Device pattern '{DevicePattern}' is not valid regex"; + } + if (!HasMixMatch) + { + var available = snapshot.Mixes.Count == 0 + ? "(no mixes)" + : string.Join(", ", snapshot.Mixes.Select(m => $"'{m.Name}'")); + return $"Mix pattern '{MixPattern}' does not match any current mix. Available: {available}"; + } + if (!HasDeviceMatch) + { + var deviceNames = snapshot.OutputDevices + .Select(d => d.DeviceName) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var available = deviceNames.Count == 0 + ? "(no devices)" + : string.Join(", ", deviceNames.Select(n => $"'{n}'")); + return $"Device pattern '{DevicePattern}' does not match any current output device. Available: {available}"; + } + return string.Empty; + } + + private static string ResolveWaveLinkMixName(string pattern, WaveLinkSnapshot? snapshot) + { + if (snapshot is null || string.IsNullOrWhiteSpace(pattern)) + { + return string.Empty; + } + + var matched = snapshot.Mixes + .Where(m => RuleRow.MatchOrExact(pattern, m.Name)) + .Select(m => m.Name) + .ToList(); + + return matched.Count switch + { + 0 => string.Empty, + 1 => matched[0], + _ => $"{matched[0]} (+{matched.Count - 1})", + }; + } + + private static string ResolveWaveLinkDeviceName(string pattern, WaveLinkSnapshot? snapshot) + { + if (snapshot is null || string.IsNullOrWhiteSpace(pattern)) + { + return string.Empty; + } + + var matched = snapshot.OutputDevices + .Where(o => RuleRow.MatchOrExact(pattern, o.DeviceName)) + .Select(o => o.DeviceName) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return matched.Count switch + { + 0 => string.Empty, + 1 => matched[0], + _ => $"{matched[0]} (+{matched.Count - 1})", + }; } private void RecomputeApp(IReadOnlyList sessions) { - if (!RuleRow.TryCompile(AppPattern, out var regex) || regex is null) + if (string.IsNullOrWhiteSpace(AppPattern)) { AppMatchCount = 0; AppMatchNames = string.Empty; @@ -506,7 +755,8 @@ private void RecomputeApp(IReadOnlyList sessions) var names = new List(); foreach (var session in sessions) { - if (!RuleRow.MatchSafe(regex, session.ProcessName) && !RuleRow.MatchSafe(regex, session.ExecutablePath)) + if (!RuleRow.MatchOrExact(AppPattern, session.ProcessName) && + !RuleRow.MatchOrExact(AppPattern, session.ExecutablePath)) { continue; } @@ -524,27 +774,16 @@ private void RecomputeApp(IReadOnlyList sessions) AppMatchNames = names.Count == 0 ? string.Empty : string.Join("\n", names); } - private static EndpointFlow EffectiveFlow(ActionType type) => type switch - { - ActionType.SetApplicationInput or ActionType.SetDefaultInput => EndpointFlow.Capture, - _ => EndpointFlow.Render, - }; - - private static string ResolveDeviceName(string pattern, EndpointFlow flow, IReadOnlyList endpoints) + private static string ResolveDeviceNameAnyFlow(string pattern, EndpointFlow? flow, IReadOnlyList endpoints) { if (string.IsNullOrWhiteSpace(pattern)) { return string.Empty; } - if (!RuleRow.TryCompile(pattern, out var regex) || regex is null) - { - return string.Empty; - } - var matched = endpoints - .Where(e => e.Flow == flow && e.State == EndpointState.Active) - .Where(e => RuleRow.MatchSafe(regex, e.FriendlyName) || RuleRow.MatchSafe(regex, e.DisplayName)) + .Where(e => e.State == EndpointState.Active && (flow is null || e.Flow == flow)) + .Where(e => RuleRow.MatchOrExact(pattern, e.FriendlyName) || RuleRow.MatchOrExact(pattern, e.DisplayName)) .Select(e => e.FriendlyName) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); @@ -564,12 +803,16 @@ partial void OnTypeChanged(ActionType value) OnPropertyChanged(nameof(TypeLabel)); OnPropertyChanged(nameof(RequiresAppPattern)); OnPropertyChanged(nameof(IsDefaultAction)); + OnPropertyChanged(nameof(IsWaveLinkAction)); + OnPropertyChanged(nameof(RequiresVolumeSlider)); OnPropertyChanged(nameof(SelectedTypeOption)); Notify(); } partial void OnAppPatternChanged(string value) => Notify(); partial void OnDevicePatternChanged(string value) => Notify(); + partial void OnMixPatternChanged(string value) => Notify(); + partial void OnVolumeChanged(float value) => Notify(); partial void OnSetsDefaultChanged(bool value) => Notify(); partial void OnSetsCommunicationsChanged(bool value) => Notify(); partial void OnAppMatchCountChanged(int value) @@ -579,6 +822,10 @@ partial void OnAppMatchCountChanged(int value) } partial void OnDeviceMatchSummaryChanged(string value) => OnPropertyChanged(nameof(HasDeviceMatch)); + partial void OnMixMatchSummaryChanged(string value) => + OnPropertyChanged(nameof(HasMixMatch)); + partial void OnDiagnosticChanged(string value) => + OnPropertyChanged(nameof(HasDiagnostic)); private void Notify() { diff --git a/src/Earmark.App/ViewModels/RulesViewModel.cs b/src/Earmark.App/ViewModels/RulesViewModel.cs index 1d3d216..bff7541 100644 --- a/src/Earmark.App/ViewModels/RulesViewModel.cs +++ b/src/Earmark.App/ViewModels/RulesViewModel.cs @@ -9,6 +9,7 @@ using Earmark.Core.Models; using Earmark.Core.Routing; using Earmark.Core.Services; +using Earmark.Core.WaveLink; namespace Earmark.App.ViewModels; @@ -21,6 +22,7 @@ public partial class RulesViewModel : ObservableObject, IDisposable private readonly IAudioSessionService _sessions; private readonly IAudioEndpointService _endpoints; private readonly IRuleEvaluator _evaluator; + private readonly IWaveLinkService _waveLink; private readonly IDispatcherQueueProvider _dispatcher; private readonly Lock _gate = new(); @@ -33,6 +35,7 @@ public RulesViewModel( IAudioSessionService sessions, IAudioEndpointService endpoints, IRuleEvaluator evaluator, + IWaveLinkService waveLink, IDispatcherQueueProvider dispatcher) { _rules = rules ?? throw new ArgumentNullException(nameof(rules)); @@ -40,6 +43,7 @@ public RulesViewModel( _sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); _endpoints = endpoints ?? throw new ArgumentNullException(nameof(endpoints)); _evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator)); + _waveLink = waveLink ?? throw new ArgumentNullException(nameof(waveLink)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); Items = new ObservableCollection(_rules.Rules.Select(BuildRow)); @@ -52,9 +56,13 @@ public RulesViewModel( _rules.RulesChanged += OnRulesChanged; _sessions.SessionsChanged += OnSessionsOrEndpointsChanged; _endpoints.EndpointsChanged += OnSessionsOrEndpointsChanged; + _waveLink.SnapshotChanged += OnWaveLinkChanged; + _waveLink.StateChanged += OnWaveLinkChanged; QueueMatchRefresh(); } + private void OnWaveLinkChanged(object? sender, EventArgs e) => QueueMatchRefresh(); + public ObservableCollection Items { get; } public bool HasItems => Items.Count > 0; @@ -196,12 +204,15 @@ private async Task RefreshMatchesAsync(CancellationToken ct) return; } + var snapshot = _waveLink.LastSnapshot; + var waveLinkState = _waveLink.State; + _dispatcher.Enqueue(() => { var liveRules = Items.Select(r => r.ToRule()).ToList(); foreach (var row in Items) { - row.Recompute(sessions, endpoints); + row.Recompute(sessions, endpoints, snapshot, waveLinkState); var rule = liveRules.First(r => r.Id == row.Id); var evaluation = _evaluator.Evaluate(rule, liveRules, sessions, endpoints); row.ApplyEvaluation(evaluation); @@ -232,6 +243,8 @@ public void Dispose() _rules.RulesChanged -= OnRulesChanged; _sessions.SessionsChanged -= OnSessionsOrEndpointsChanged; _endpoints.EndpointsChanged -= OnSessionsOrEndpointsChanged; + _waveLink.SnapshotChanged -= OnWaveLinkChanged; + _waveLink.StateChanged -= OnWaveLinkChanged; Items.CollectionChanged -= OnItemsCollectionChanged; _matchCts?.Cancel(); _matchCts?.Dispose(); diff --git a/src/Earmark.App/ViewModels/SettingsViewModel.cs b/src/Earmark.App/ViewModels/SettingsViewModel.cs index de0107b..4a588cc 100644 --- a/src/Earmark.App/ViewModels/SettingsViewModel.cs +++ b/src/Earmark.App/ViewModels/SettingsViewModel.cs @@ -1,18 +1,26 @@ using CommunityToolkit.Mvvm.ComponentModel; +using Earmark.App.Services; using Earmark.App.Settings; +using Earmark.Core.WaveLink; namespace Earmark.App.ViewModels; -public partial class SettingsViewModel : ObservableObject +public partial class SettingsViewModel : ObservableObject, IDisposable { private readonly ISettingsService _settings; + private readonly IWaveLinkService _waveLink; + private readonly IDispatcherQueueProvider _dispatcher; private bool _suppress; - public SettingsViewModel(ISettingsService settings) + public SettingsViewModel(ISettingsService settings, IWaveLinkService waveLink, IDispatcherQueueProvider dispatcher) { _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _waveLink = waveLink ?? throw new ArgumentNullException(nameof(waveLink)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _waveLink.StateChanged += OnWaveLinkStateChanged; SyncFromSettings(); + SyncFromWaveLink(); } [ObservableProperty] @@ -33,6 +41,33 @@ public SettingsViewModel(ISettingsService settings) [ObservableProperty] public partial bool VerboseLogging { get; set; } + [ObservableProperty] + public partial bool EnableWaveLink { get; set; } + + [ObservableProperty] + public partial WaveLinkConnectionState WaveLinkState { get; set; } + + public string WaveLinkStatusText => WaveLinkState switch + { + WaveLinkConnectionState.Connected => "Connected", + WaveLinkConnectionState.Unavailable => "Wave Link not running", + _ => "Off", + }; + + public string WaveLinkStatusGlyph => WaveLinkState switch + { + WaveLinkConnectionState.Connected => "", // checkmark + WaveLinkConnectionState.Unavailable => "", // warning triangle + _ => "", // cancel / dot + }; + + public string WaveLinkStatusBrushKey => WaveLinkState switch + { + WaveLinkConnectionState.Connected => "SystemFillColorSuccessBrush", + WaveLinkConnectionState.Unavailable => "SystemFillColorCautionBrush", + _ => "TextFillColorTertiaryBrush", + }; + public void SyncFromSettings() { _suppress = true; @@ -44,6 +79,7 @@ public void SyncFromSettings() CloseToTray = _settings.Current.CloseToTray; LaunchToTray = _settings.Current.LaunchToTray; VerboseLogging = _settings.Current.VerboseLogging; + EnableWaveLink = _settings.Current.EnableWaveLink; } finally { @@ -51,12 +87,25 @@ public void SyncFromSettings() } } + private void SyncFromWaveLink() => WaveLinkState = _waveLink.State; + + private void OnWaveLinkStateChanged(object? sender, EventArgs e) => + _dispatcher.Enqueue(() => WaveLinkState = _waveLink.State); + partial void OnLaunchOnStartupChanged(bool value) => Persist(s => s.LaunchOnStartup = value); partial void OnShowTrayIconChanged(bool value) => Persist(s => s.ShowTrayIcon = value); partial void OnMinimizeToTrayChanged(bool value) => Persist(s => s.MinimizeToTray = value); partial void OnCloseToTrayChanged(bool value) => Persist(s => s.CloseToTray = value); partial void OnLaunchToTrayChanged(bool value) => Persist(s => s.LaunchToTray = value); partial void OnVerboseLoggingChanged(bool value) => Persist(s => s.VerboseLogging = value); + partial void OnEnableWaveLinkChanged(bool value) => Persist(s => s.EnableWaveLink = value); + + partial void OnWaveLinkStateChanged(WaveLinkConnectionState value) + { + OnPropertyChanged(nameof(WaveLinkStatusText)); + OnPropertyChanged(nameof(WaveLinkStatusGlyph)); + OnPropertyChanged(nameof(WaveLinkStatusBrushKey)); + } private async void Persist(Action mutate) { @@ -68,4 +117,9 @@ private async void Persist(Action mutate) mutate(_settings.Current); await _settings.SaveAsync(); } + + public void Dispose() + { + _waveLink.StateChanged -= OnWaveLinkStateChanged; + } } diff --git a/src/Earmark.App/Views/RulesPage.xaml b/src/Earmark.App/Views/RulesPage.xaml index d97ba3f..cb1eaaa 100644 --- a/src/Earmark.App/Views/RulesPage.xaml +++ b/src/Earmark.App/Views/RulesPage.xaml @@ -121,8 +121,15 @@ MinWidth="0" Width="44" VerticalAlignment="Center" /> - + + + + + + + + + + + + + + + + + + @@ -292,9 +323,12 @@ - - + + - - + + + + @@ -321,6 +361,24 @@ OffContent="Off" OnContent="On" IsOn="{x:Bind SetsCommunications, Mode=TwoWay}" /> + + + + + + + diff --git a/src/Earmark.App/Views/RulesPage.xaml.cs b/src/Earmark.App/Views/RulesPage.xaml.cs index b57e733..382e0d9 100644 --- a/src/Earmark.App/Views/RulesPage.xaml.cs +++ b/src/Earmark.App/Views/RulesPage.xaml.cs @@ -103,4 +103,76 @@ private void OnRemoveConditionClicked(object sender, RoutedEventArgs e) } return null; } + + // CA1822 suppressed: XAML event hookup requires instance methods even when the body + // doesn't touch instance state. +#pragma warning disable CA1822 + private void OnDevicePatternTextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput) return; + if (sender.DataContext is not ActionRow row) return; + sender.ItemsSource = FilterCandidates(row.DeviceCandidates, sender.Text); + } + + private void OnDevicePatternGotFocus(object sender, RoutedEventArgs e) + { + if (sender is AutoSuggestBox box && box.DataContext is ActionRow row) + { + box.ItemsSource = FilterCandidates(row.DeviceCandidates, box.Text); + } + } + + private void OnDeviceSuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) + { + if (args.SelectedItem is string name) + { + // Insert the literal name. PatternMatcher.Matches treats an exact-name pattern + // as a string equality match without compiling, so no regex escaping needed. + sender.Text = name; + } + } + + private void OnMixPatternTextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput) return; + if (sender.DataContext is not ActionRow row) return; + sender.ItemsSource = FilterCandidates(row.MixCandidates, sender.Text); + } + + private void OnMixPatternGotFocus(object sender, RoutedEventArgs e) + { + if (sender is AutoSuggestBox box && box.DataContext is ActionRow row) + { + box.ItemsSource = FilterCandidates(row.MixCandidates, box.Text); + } + } + + private void OnMixSuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) + { + if (args.SelectedItem is string name) + { + sender.Text = name; + } + } +#pragma warning restore CA1822 + + private static List FilterCandidates(IReadOnlyList candidates, string? text) + { + if (string.IsNullOrEmpty(text)) + { + return candidates.Take(20).ToList(); + } + + var matches = new List(); + foreach (var candidate in candidates) + { + if (candidate.Contains(text, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(candidate); + if (matches.Count >= 20) break; + } + } + return matches; + } + } diff --git a/src/Earmark.App/Views/SettingsPage.xaml b/src/Earmark.App/Views/SettingsPage.xaml index 6a0dd55..234df3f 100644 --- a/src/Earmark.App/Views/SettingsPage.xaml +++ b/src/Earmark.App/Views/SettingsPage.xaml @@ -42,6 +42,16 @@ + + + + + + + diff --git a/src/Earmark.Audio/AudioServiceCollectionExtensions.cs b/src/Earmark.Audio/AudioServiceCollectionExtensions.cs index d13dd54..49870dd 100644 --- a/src/Earmark.Audio/AudioServiceCollectionExtensions.cs +++ b/src/Earmark.Audio/AudioServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using Earmark.Audio.Services; +using Earmark.Audio.WaveLink; using Earmark.Core.Audio; +using Earmark.Core.WaveLink; using Microsoft.Extensions.DependencyInjection; @@ -14,6 +16,7 @@ public static IServiceCollection AddEarmarkInterop(this IServiceCollection servi services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/src/Earmark.Audio/Services/AudioEndpointService.cs b/src/Earmark.Audio/Services/AudioEndpointService.cs index ca1c6e6..bdc39ab 100644 --- a/src/Earmark.Audio/Services/AudioEndpointService.cs +++ b/src/Earmark.Audio/Services/AudioEndpointService.cs @@ -52,6 +52,83 @@ public IReadOnlyList GetEndpoints(EndpointFlow flow = EndpointFlo return snap.ById.TryGetValue(id, out var endpoint) ? endpoint : null; } + public float? GetVolume(string id) + { + ArgumentException.ThrowIfNullOrEmpty(id); + try + { + using var device = _enumerator.GetDevice(id); + return device.AudioEndpointVolume.MasterVolumeLevelScalar; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "GetVolume({Id}) failed", id); + return null; + } + } + + public bool? GetMuted(string id) + { + ArgumentException.ThrowIfNullOrEmpty(id); + try + { + using var device = _enumerator.GetDevice(id); + return device.AudioEndpointVolume.Mute; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "GetMuted({Id}) failed", id); + return null; + } + } + + public bool SetVolume(string id, float level) + { + ArgumentException.ThrowIfNullOrEmpty(id); + var clamped = Math.Clamp(level, 0f, 1f); + try + { + using var device = _enumerator.GetDevice(id); + var current = device.AudioEndpointVolume.MasterVolumeLevelScalar; + if (Math.Abs(current - clamped) < 0.005f) + { + _logger.LogDebug("SetVolume({Id}) skipped: already at {Level:F2}", id, current); + return false; + } + device.AudioEndpointVolume.MasterVolumeLevelScalar = clamped; + _logger.LogInformation("SetVolume({Id}) {Old:F2} -> {New:F2}", id, current, clamped); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "SetVolume({Id}, {Level}) failed", id, clamped); + return false; + } + } + + public bool SetMuted(string id, bool muted) + { + ArgumentException.ThrowIfNullOrEmpty(id); + try + { + using var device = _enumerator.GetDevice(id); + var current = device.AudioEndpointVolume.Mute; + if (current == muted) + { + _logger.LogDebug("SetMuted({Id}) skipped: already {State}", id, muted ? "muted" : "unmuted"); + return false; + } + device.AudioEndpointVolume.Mute = muted; + _logger.LogInformation("SetMuted({Id}) -> {State}", id, muted ? "muted" : "unmuted"); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "SetMuted({Id}, {Muted}) failed", id, muted); + return false; + } + } + private void TryRebuild() { try diff --git a/src/Earmark.Audio/WaveLink/WaveLinkClient.cs b/src/Earmark.Audio/WaveLink/WaveLinkClient.cs new file mode 100644 index 0000000..55351ae --- /dev/null +++ b/src/Earmark.Audio/WaveLink/WaveLinkClient.cs @@ -0,0 +1,290 @@ +using System.Buffers; +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Text.Json; + +using Microsoft.Extensions.Logging; + +namespace Earmark.Audio.WaveLink; + +public sealed class WaveLinkClient : IAsyncDisposable +{ + private const string OriginHeader = "streamdeck://"; + + private readonly ILogger _logger; + private readonly ConcurrentDictionary> _pending = new(); + private readonly SemaphoreSlim _sendLock = new(1, 1); + private readonly CancellationTokenSource _disposalCts = new(); + + private ClientWebSocket? _socket; + private Task? _receiveLoop; + private int _nextId; + + public WaveLinkClient(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public bool IsConnected => _socket?.State == WebSocketState.Open; + + public event Action? Notification; + + public event EventHandler? Closed; + + public async Task ConnectAsync(CancellationToken ct) + { + if (IsConnected) + { + throw new InvalidOperationException("Already connected."); + } + + var port = WaveLinkPortDiscovery.TryReadPort(); + if (port is int discovered) + { + _logger.LogInformation("Wave Link: discovered port {Port} from ws-info.json", discovered); + if (await TryConnectAsync(discovered, ct).ConfigureAwait(false)) + { + return discovered; + } + } + else + { + _logger.LogInformation("Wave Link: ws-info.json missing or invalid; scanning fallback ports {Min}-{Max}", + WaveLinkPortDiscovery.FallbackPorts().First(), WaveLinkPortDiscovery.FallbackPorts().Last()); + } + + foreach (var fallback in WaveLinkPortDiscovery.FallbackPorts()) + { + if (await TryConnectAsync(fallback, ct).ConfigureAwait(false)) + { + _logger.LogInformation("Wave Link: connected via fallback port {Port}", fallback); + return fallback; + } + } + + throw new InvalidOperationException("Wave Link is not reachable. Is it running?"); + } + + private async Task TryConnectAsync(int port, CancellationToken ct) + { + var socket = new ClientWebSocket(); + socket.Options.SetRequestHeader("Origin", OriginHeader); + var sw = System.Diagnostics.Stopwatch.StartNew(); + try + { + using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + connectCts.CancelAfter(TimeSpan.FromSeconds(2)); + var uri = new Uri($"ws://127.0.0.1:{port}", UriKind.Absolute); + await socket.ConnectAsync(uri, connectCts.Token).ConfigureAwait(false); + _logger.LogInformation("Wave Link: connect to port {Port} succeeded in {Ms} ms", port, sw.ElapsedMilliseconds); + } + catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) + { + socket.Dispose(); + _logger.LogInformation("Wave Link: connect to port {Port} failed after {Ms} ms: {Type}: {Message}", + port, sw.ElapsedMilliseconds, ex.GetType().Name, ex.InnerException?.Message ?? ex.Message); + return false; + } + + _socket = socket; + var loopToken = _disposalCts.Token; + _receiveLoop = Task.Run(() => ReceiveLoopAsync(loopToken), loopToken); + return true; + } + + public async Task CallAsync(string method, object? @params = null, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(method); + var socket = _socket ?? throw new InvalidOperationException("Not connected."); + + var id = Interlocked.Increment(ref _nextId); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pending[id] = tcs; + + try + { + using var ms = new MemoryStream(); + using (var writer = new Utf8JsonWriter(ms)) + { + writer.WriteStartObject(); + writer.WriteString("jsonrpc", "2.0"); + writer.WriteString("method", method); + if (@params is not null) + { + writer.WritePropertyName("params"); + JsonSerializer.Serialize(writer, @params); + } + writer.WriteNumber("id", id); + writer.WriteEndObject(); + } + + await _sendLock.WaitAsync(ct).ConfigureAwait(false); + try + { + await socket.SendAsync(ms.GetBuffer().AsMemory(0, (int)ms.Length), WebSocketMessageType.Text, true, ct).ConfigureAwait(false); + } + finally + { + _sendLock.Release(); + } + + using var registration = ct.Register(static state => + { + ((TaskCompletionSource)state!).TrySetCanceled(); + }, tcs); + + return await tcs.Task.ConfigureAwait(false); + } + finally + { + _pending.TryRemove(id, out _); + } + } + + public async Task CallAsync(string method, object? @params = null, CancellationToken ct = default) + { + var element = await CallAsync(method, @params, ct).ConfigureAwait(false); + return element.Deserialize(); + } + + private async Task ReceiveLoopAsync(CancellationToken ct) + { + var socket = _socket!; + var buffer = new byte[16 * 1024]; + var message = new ArrayBufferWriter(16 * 1024); + + try + { + while (!ct.IsCancellationRequested && socket.State == WebSocketState.Open) + { + message.Clear(); + ValueWebSocketReceiveResult result; + do + { + result = await socket.ReceiveAsync(buffer.AsMemory(), ct).ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + { + _logger.LogInformation("Wave Link: server closed connection ({Status} {Description})", + socket.CloseStatus, socket.CloseStatusDescription); + return; + } + message.Write(buffer.AsSpan(0, result.Count)); + } while (!result.EndOfMessage); + + DispatchFrame(message.WrittenSpan); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { } + catch (WebSocketException ex) + { + _logger.LogWarning(ex, "Wave Link: WebSocket error in receive loop"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Wave Link: unexpected error in receive loop"); + } + finally + { + FailAllPending(new InvalidOperationException("Wave Link connection closed.")); + try { Closed?.Invoke(this, EventArgs.Empty); } catch { } + } + } + + private void DispatchFrame(ReadOnlySpan utf8) + { + JsonDocument doc; + try + { + doc = JsonDocument.Parse(utf8.ToArray()); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Wave Link: malformed JSON frame ({Length} bytes)", utf8.Length); + return; + } + + try + { + var root = doc.RootElement; + if (root.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.Number && idEl.TryGetInt32(out var id) && id > 0) + { + if (_pending.TryRemove(id, out var tcs)) + { + if (root.TryGetProperty("error", out var errorEl)) + { + var code = errorEl.TryGetProperty("code", out var c) ? c.GetInt32() : -1; + var msg = errorEl.TryGetProperty("message", out var m) ? m.GetString() : "(unknown)"; + tcs.TrySetException(new WaveLinkRpcException(code, msg ?? "(unknown)")); + } + else if (root.TryGetProperty("result", out var resultEl)) + { + tcs.TrySetResult(resultEl.Clone()); + } + else + { + tcs.TrySetException(new InvalidOperationException("Response had neither result nor error.")); + } + } + else + { + _logger.LogTrace("Wave Link: received response for unknown id {Id}", id); + } + } + else if (root.TryGetProperty("method", out var methodEl) && methodEl.ValueKind == JsonValueKind.String) + { + var method = methodEl.GetString()!; + if (Notification is { } handler) + { + var paramsEl = root.TryGetProperty("params", out var p) ? p.Clone() : default; + handler(method, paramsEl); + } + } + } + finally + { + doc.Dispose(); + } + } + + private void FailAllPending(Exception ex) + { + foreach (var (id, tcs) in _pending.ToArray()) + { + tcs.TrySetException(ex); + _pending.TryRemove(id, out _); + } + } + + public async ValueTask DisposeAsync() + { + await _disposalCts.CancelAsync().ConfigureAwait(false); + + var socket = _socket; + if (socket is not null) + { + try + { + if (socket.State == WebSocketState.Open) + { + using var closeCts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "shutdown", closeCts.Token).ConfigureAwait(false); + } + } + catch { } + socket.Dispose(); + } + + if (_receiveLoop is not null) + { + try { await _receiveLoop.ConfigureAwait(false); } catch { } + } + + _sendLock.Dispose(); + _disposalCts.Dispose(); + } +} + +public sealed class WaveLinkRpcException(int code, string message) : Exception($"Wave Link RPC error {code}: {message}") +{ + public int Code { get; } = code; +} diff --git a/src/Earmark.Audio/WaveLink/WaveLinkModels.cs b/src/Earmark.Audio/WaveLink/WaveLinkModels.cs new file mode 100644 index 0000000..1b47f19 --- /dev/null +++ b/src/Earmark.Audio/WaveLink/WaveLinkModels.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; + +namespace Earmark.Audio.WaveLink; + +public sealed record WaveLinkApplicationInfo( + [property: JsonPropertyName("appID")] string AppId, + [property: JsonPropertyName("operatingSystem")] string OperatingSystem, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("version")] string Version, + [property: JsonPropertyName("build")] int Build, + [property: JsonPropertyName("interfaceRevision")] int InterfaceRevision); + +public sealed record WaveLinkMix( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("level")] double Level, + [property: JsonPropertyName("isMuted")] bool IsMuted); + +public sealed record WaveLinkMixesResult( + [property: JsonPropertyName("mixes")] IReadOnlyList Mixes); + +public sealed record WaveLinkOutput( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("isMuted")] bool IsMuted, + [property: JsonPropertyName("level")] double Level, + [property: JsonPropertyName("mixId")] string MixId); + +public sealed record WaveLinkOutputDevice( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("deviceType")] string DeviceType, + [property: JsonPropertyName("outputs")] IReadOnlyList Outputs); + +public sealed record WaveLinkMainOutput( + [property: JsonPropertyName("outputDeviceId")] string OutputDeviceId, + [property: JsonPropertyName("outputId")] string OutputId); + +public sealed record WaveLinkOutputDevicesResult( + [property: JsonPropertyName("mainOutput")] WaveLinkMainOutput MainOutput, + [property: JsonPropertyName("outputDevices")] IReadOnlyList OutputDevices); diff --git a/src/Earmark.Audio/WaveLink/WaveLinkPortDiscovery.cs b/src/Earmark.Audio/WaveLink/WaveLinkPortDiscovery.cs new file mode 100644 index 0000000..aab281c --- /dev/null +++ b/src/Earmark.Audio/WaveLink/WaveLinkPortDiscovery.cs @@ -0,0 +1,41 @@ +using System.Text.Json; + +namespace Earmark.Audio.WaveLink; + +internal static class WaveLinkPortDiscovery +{ + private const string WaveLinkPackageFamily = "Elgato.WaveLink_g54w8ztgkx496"; + + public static string WsInfoFilePath { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Packages", + WaveLinkPackageFamily, + "LocalState", + "ws-info.json"); + + public static int? TryReadPort() + { + if (!File.Exists(WsInfoFilePath)) + { + return null; + } + + try + { + using var stream = File.OpenRead(WsInfoFilePath); + using var doc = JsonDocument.Parse(stream); + if (doc.RootElement.TryGetProperty("port", out var portElement) + && portElement.TryGetInt32(out var port) + && port > 0) + { + return port; + } + } + catch (IOException) { } + catch (JsonException) { } + + return null; + } + + public static IEnumerable FallbackPorts() => Enumerable.Range(1884, 10); +} diff --git a/src/Earmark.Audio/WaveLink/WaveLinkService.cs b/src/Earmark.Audio/WaveLink/WaveLinkService.cs new file mode 100644 index 0000000..c1a8aa5 --- /dev/null +++ b/src/Earmark.Audio/WaveLink/WaveLinkService.cs @@ -0,0 +1,349 @@ +using Earmark.Core.WaveLink; + +using Microsoft.Extensions.Logging; + +namespace Earmark.Audio.WaveLink; + +internal sealed class WaveLinkService : IWaveLinkService, IAsyncDisposable +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(5); + + private readonly ILogger _logger; + private readonly ILogger _clientLogger; + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly Lock _stateGate = new(); + + private WaveLinkClient? _client; + private bool _clientFailed; + private bool _disposed; + + private bool _isEnabled; + private WaveLinkConnectionState _state = WaveLinkConnectionState.Disabled; + private WaveLinkSnapshot? _lastSnapshot; + private CancellationTokenSource? _pollCts; + private Task? _pollTask; + + public WaveLinkService(ILogger logger, ILogger clientLogger) + { + _logger = logger; + _clientLogger = clientLogger; + } + + public bool IsEnabled + { + get => _isEnabled; + set + { + if (_isEnabled == value) + { + return; + } + + _isEnabled = value; + if (value) + { + _logger.LogInformation("Wave Link integration enabled"); + StartPolling(); + SetState(WaveLinkConnectionState.Unavailable); + } + else + { + _logger.LogInformation("Wave Link integration disabled"); + StopPolling(); + _ = Task.Run(async () => + { + await _gate.WaitAsync().ConfigureAwait(false); + try { await DisposeClientLockedAsync().ConfigureAwait(false); } + finally { _gate.Release(); } + }); + SetSnapshot(null); + SetState(WaveLinkConnectionState.Disabled); + } + } + } + + public WaveLinkConnectionState State + { + get { lock (_stateGate) { return _state; } } + } + + public bool IsAvailable => State == WaveLinkConnectionState.Connected; + + public WaveLinkSnapshot? LastSnapshot + { + get { lock (_stateGate) { return _lastSnapshot; } } + } + + public event EventHandler? StateChanged; + public event EventHandler? SnapshotChanged; + + public async Task GetSnapshotAsync(CancellationToken ct = default) + { + if (!_isEnabled) + { + return null; + } + + var client = await EnsureConnectedAsync(ct).ConfigureAwait(false); + if (client is null) + { + SetState(WaveLinkConnectionState.Unavailable); + return null; + } + + try + { + var mixesResult = await client.CallAsync("getMixes", null, ct).ConfigureAwait(false); + var outputsResult = await client.CallAsync("getOutputDevices", null, ct).ConfigureAwait(false); + + if (mixesResult is null || outputsResult is null) + { + return null; + } + + var mixes = mixesResult.Mixes + .Select(m => new WaveLinkMixInfo(m.Id, m.Name)) + .ToList(); + + var outputs = new List(); + foreach (var device in outputsResult.OutputDevices) + { + foreach (var output in device.Outputs) + { + outputs.Add(new WaveLinkOutputInfo( + DeviceId: device.Id, + OutputId: output.Id, + DeviceName: device.Name, + CurrentMixId: output.MixId ?? string.Empty)); + } + } + + var snapshot = new WaveLinkSnapshot(mixes, outputs); + SetSnapshot(snapshot); + SetState(WaveLinkConnectionState.Connected); + return snapshot; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _logger.LogWarning(ex, "Wave Link: getSnapshot failed"); + _clientFailed = true; + SetState(WaveLinkConnectionState.Unavailable); + return null; + } + } + + public async Task SetMixForOutputAsync(string deviceId, string outputId, string mixId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(deviceId); + ArgumentException.ThrowIfNullOrEmpty(outputId); + ArgumentNullException.ThrowIfNull(mixId); + + if (!_isEnabled) + { + return false; + } + + var client = await EnsureConnectedAsync(ct).ConfigureAwait(false); + if (client is null) + { + SetState(WaveLinkConnectionState.Unavailable); + return false; + } + + try + { + var payload = new + { + outputDevice = new + { + id = deviceId, + outputs = new[] + { + new { id = outputId, mixId }, + }, + }, + }; + await client.CallAsync("setOutputDevice", payload, ct).ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _logger.LogWarning(ex, "Wave Link: setOutputDevice({DeviceId}, {OutputId}, {MixId}) failed", + deviceId, outputId, mixId); + _clientFailed = true; + SetState(WaveLinkConnectionState.Unavailable); + return false; + } + } + + private async Task EnsureConnectedAsync(CancellationToken ct) + { + if (_disposed || !_isEnabled) + { + return null; + } + + if (_client?.IsConnected == true && !_clientFailed) + { + return _client; + } + + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + if (_disposed || !_isEnabled) + { + return null; + } + + if (_client?.IsConnected == true && !_clientFailed) + { + return _client; + } + + await DisposeClientLockedAsync().ConfigureAwait(false); + _clientFailed = false; + + var client = new WaveLinkClient(_clientLogger); + client.Closed += OnClientClosed; + try + { + await client.ConnectAsync(ct).ConfigureAwait(false); + } + catch + { + client.Closed -= OnClientClosed; + try { await client.DisposeAsync().ConfigureAwait(false); } catch { } + return null; + } + + _client = client; + return client; + } + finally + { + _gate.Release(); + } + } + + private async Task DisposeClientLockedAsync() + { + if (_client is null) return; + _client.Closed -= OnClientClosed; + try { await _client.DisposeAsync().ConfigureAwait(false); } catch { } + _client = null; + } + + private void OnClientClosed(object? sender, EventArgs e) + { + // The receive loop fires Closed when Wave Link exits, kills the service, or otherwise + // drops the connection. Flip state immediately and clear the snapshot so the UI doesn't + // wait for the next 5s poll tick. + _clientFailed = true; + if (_isEnabled) + { + SetState(WaveLinkConnectionState.Unavailable); + SetSnapshot(null); + } + } + + private void StartPolling() + { + StopPolling(); + var cts = new CancellationTokenSource(); + _pollCts = cts; + _pollTask = Task.Run(() => PollLoopAsync(cts.Token), cts.Token); + } + + private void StopPolling() + { + _pollCts?.Cancel(); + _pollCts?.Dispose(); + _pollCts = null; + _pollTask = null; + } + + private async Task PollLoopAsync(CancellationToken ct) + { + // First refresh fires immediately so the indicator updates without a 5s delay. + try + { + await GetSnapshotAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { return; } + catch { /* GetSnapshotAsync logs; ignore here */ } + + while (!ct.IsCancellationRequested) + { + try + { + await Task.Delay(PollInterval, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { return; } + + if (!_isEnabled || ct.IsCancellationRequested) + { + return; + } + + try + { + await GetSnapshotAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { return; } + catch { } + } + } + + private void SetState(WaveLinkConnectionState newState) + { + bool changed; + lock (_stateGate) + { + changed = _state != newState; + if (changed) _state = newState; + } + + if (changed) + { + StateChanged?.Invoke(this, EventArgs.Empty); + } + } + + private void SetSnapshot(WaveLinkSnapshot? snapshot) + { + bool changed; + lock (_stateGate) + { + changed = !ReferenceEquals(_lastSnapshot, snapshot); + _lastSnapshot = snapshot; + } + + if (changed) + { + SnapshotChanged?.Invoke(this, EventArgs.Empty); + } + } + + public async ValueTask DisposeAsync() + { + _disposed = true; + StopPolling(); + await _gate.WaitAsync().ConfigureAwait(false); + try + { + if (_client is not null) + { + try { await _client.DisposeAsync().ConfigureAwait(false); } catch { } + _client = null; + } + } + finally + { + _gate.Release(); + } + _gate.Dispose(); + } +} diff --git a/src/Earmark.Core/Audio/IAudioEndpointService.cs b/src/Earmark.Core/Audio/IAudioEndpointService.cs index 6b0bfee..0cb22cf 100644 --- a/src/Earmark.Core/Audio/IAudioEndpointService.cs +++ b/src/Earmark.Core/Audio/IAudioEndpointService.cs @@ -6,6 +6,19 @@ public interface IAudioEndpointService { IReadOnlyList GetEndpoints(EndpointFlow flow = EndpointFlow.Render); AudioEndpoint? GetById(string id); + + /// Returns 0-1 master volume scalar, or null if the device is unreachable. + float? GetVolume(string id); + + /// Returns mute state, or null if the device is unreachable. + bool? GetMuted(string id); + + /// Sets master volume only if it differs from by more than 0.5%. Returns true when a write happened. + bool SetVolume(string id, float level); + + /// Sets mute only if it differs. Returns true when a write happened. + bool SetMuted(string id, bool muted); + event EventHandler? EndpointsChanged; event EventHandler? DefaultsChanged; } diff --git a/src/Earmark.Core/Models/RoutingRule.cs b/src/Earmark.Core/Models/RoutingRule.cs index 0754cee..8b63a4d 100644 --- a/src/Earmark.Core/Models/RoutingRule.cs +++ b/src/Earmark.Core/Models/RoutingRule.cs @@ -31,6 +31,12 @@ public enum ActionType SetApplicationInput, SetDefaultOutput, SetDefaultInput, + AddWaveLinkMixOutput, + RemoveWaveLinkMixOutput, + SetWaveLinkMixOutput, + SetDeviceVolume, + MuteDevice, + UnmuteDevice, } public sealed class RuleCondition @@ -55,6 +61,12 @@ public sealed class RuleAction public string DevicePattern { get; set; } = string.Empty; + /// SetWaveLinkMixOutput only: regex against the Wave Link mix name. + public string MixPattern { get; set; } = string.Empty; + + /// SetDeviceVolume only: target volume in [0, 1]. + public float Volume { get; set; } = 0.5f; + /// SetDefault* only: claim the device for the system "default" (Console + Multimedia) role. public bool SetsDefault { get; set; } = true; @@ -67,11 +79,24 @@ public sealed class RuleAction [JsonIgnore] public bool IsDefaultAction => Type is ActionType.SetDefaultOutput or ActionType.SetDefaultInput; + [JsonIgnore] + public bool IsWaveLinkAction => Type is + ActionType.AddWaveLinkMixOutput or + ActionType.RemoveWaveLinkMixOutput or + ActionType.SetWaveLinkMixOutput; + + [JsonIgnore] + public bool IsVolumeAction => Type is ActionType.SetDeviceVolume; + + [JsonIgnore] + public bool IsMuteAction => Type is ActionType.MuteDevice or ActionType.UnmuteDevice; + [JsonIgnore] public EndpointFlow EffectiveFlow => Type switch { ActionType.SetApplicationOutput or ActionType.SetDefaultOutput => EndpointFlow.Render, ActionType.SetApplicationInput or ActionType.SetDefaultInput => EndpointFlow.Capture, + ActionType.AddWaveLinkMixOutput or ActionType.RemoveWaveLinkMixOutput or ActionType.SetWaveLinkMixOutput => EndpointFlow.Render, _ => EndpointFlow.Render, }; @@ -82,6 +107,12 @@ public sealed class RuleAction !string.IsNullOrWhiteSpace(AppPattern) && !string.IsNullOrWhiteSpace(DevicePattern), ActionType.SetDefaultOutput or ActionType.SetDefaultInput => !string.IsNullOrWhiteSpace(DevicePattern) && (SetsDefault || SetsCommunications), + ActionType.AddWaveLinkMixOutput or ActionType.RemoveWaveLinkMixOutput or ActionType.SetWaveLinkMixOutput => + !string.IsNullOrWhiteSpace(MixPattern) && !string.IsNullOrWhiteSpace(DevicePattern), + ActionType.SetDeviceVolume => + !string.IsNullOrWhiteSpace(DevicePattern) && Volume is >= 0f and <= 1f, + ActionType.MuteDevice or ActionType.UnmuteDevice => + !string.IsNullOrWhiteSpace(DevicePattern), _ => false, }; } diff --git a/src/Earmark.Core/Routing/RuleMatcher.cs b/src/Earmark.Core/Routing/RuleMatcher.cs index 01d82ea..64c3248 100644 --- a/src/Earmark.Core/Routing/RuleMatcher.cs +++ b/src/Earmark.Core/Routing/RuleMatcher.cs @@ -141,10 +141,7 @@ public bool ConditionsMet(RoutingRule rule, IReadOnlyList endpoin private static bool AnyEndpointMatches(string pattern, ConditionFlow flow, IReadOnlyList endpoints) { - if (!RegexCache.TryGet(pattern, out var regex) || regex is null) - { - return false; - } + var regex = TryCompile(pattern); foreach (var endpoint in endpoints) { @@ -162,7 +159,8 @@ private static bool AnyEndpointMatches(string pattern, ConditionFlow flow, IRead continue; } - if (TryMatchEndpoint(regex, endpoint)) + if (PatternMatcher.Matches(pattern, regex, endpoint.FriendlyName) || + PatternMatcher.Matches(pattern, regex, endpoint.DisplayName)) { return true; } @@ -171,69 +169,66 @@ private static bool AnyEndpointMatches(string pattern, ConditionFlow flow, IRead return false; } - private static bool TryMatchEndpoint(Regex regex, AudioEndpoint endpoint) + private static bool MatchesApp(string pattern, AudioSession session) { - try - { - return regex.IsMatch(endpoint.FriendlyName) || regex.IsMatch(endpoint.DisplayName); - } - catch (RegexMatchTimeoutException) - { - return false; - } + var regex = TryCompile(pattern); + return PatternMatcher.Matches(pattern, regex, session.ProcessName) || + PatternMatcher.Matches(pattern, regex, session.ExecutablePath); } - private static bool MatchesApp(string pattern, AudioSession session) + private static AudioEndpoint? MatchEndpoint(string pattern, EndpointFlow flow, IReadOnlyList endpoints) { - if (!RegexCache.TryGet(pattern, out var regex) || regex is null) - { - return false; - } + var regex = TryCompile(pattern); - return TryMatch(regex, session.ProcessName) || TryMatch(regex, session.ExecutablePath); + return endpoints + .Where(e => e.Flow == flow && e.State == EndpointState.Active) + .Where(e => PatternMatcher.Matches(pattern, regex, e.FriendlyName) || + PatternMatcher.Matches(pattern, regex, e.DisplayName)) + .OrderByDescending(e => e.IsDefault) + .ThenByDescending(e => e.IsDefaultCommunications) + .ThenBy(e => e.FriendlyName, StringComparer.OrdinalIgnoreCase) + .ThenBy(e => e.Id, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); } - private static bool TryMatch(Regex regex, string input) + private static Regex? TryCompile(string pattern) { - if (string.IsNullOrEmpty(input)) + return RegexCache.TryGet(pattern, out var regex) ? regex : null; + } +} + +/// +/// Pattern-against-text matching with an exact-string shortcut. If the pattern verbatim equals +/// the candidate text (case-insensitive), the match succeeds without compiling the regex; this +/// lets the UI insert literal device names (which often contain regex metacharacters) without +/// escaping them. Falls back to regex.IsMatch otherwise. +/// +public static class PatternMatcher +{ + public static bool Matches(string pattern, Regex? regex, string candidate) + { + if (string.IsNullOrEmpty(candidate)) { return false; } - try + if (string.Equals(pattern, candidate, StringComparison.OrdinalIgnoreCase)) { - return regex.IsMatch(input); + return true; } - catch (RegexMatchTimeoutException) + + if (regex is null) { return false; } - } - private static AudioEndpoint? MatchEndpoint(string pattern, EndpointFlow flow, IReadOnlyList endpoints) - { - if (!RegexCache.TryGet(pattern, out var regex) || regex is null) + try { - return null; + return regex.IsMatch(candidate); + } + catch (RegexMatchTimeoutException) + { + return false; } - - return endpoints - .Where(e => e.Flow == flow && e.State == EndpointState.Active) - .Where(e => - { - try - { - return regex.IsMatch(e.FriendlyName) || regex.IsMatch(e.DisplayName); - } - catch (RegexMatchTimeoutException) - { - return false; - } - }) - .OrderByDescending(e => e.IsDefault) - .ThenByDescending(e => e.IsDefaultCommunications) - .ThenBy(e => e.FriendlyName, StringComparer.OrdinalIgnoreCase) - .ThenBy(e => e.Id, StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(); } } diff --git a/src/Earmark.Core/WaveLink/IWaveLinkService.cs b/src/Earmark.Core/WaveLink/IWaveLinkService.cs new file mode 100644 index 0000000..36c5cab --- /dev/null +++ b/src/Earmark.Core/WaveLink/IWaveLinkService.cs @@ -0,0 +1,45 @@ +namespace Earmark.Core.WaveLink; + +public enum WaveLinkConnectionState +{ + /// Integration is turned off in settings. + Disabled, + + /// Integration is on but Wave Link isn't reachable (not running, refused, etc.). + Unavailable, + + /// Integration is on and the WS is open. + Connected, +} + +public interface IWaveLinkService +{ + bool IsEnabled { get; set; } + + WaveLinkConnectionState State { get; } + + bool IsAvailable { get; } + + /// Most recent snapshot pulled by GetSnapshotAsync. Null if integration is disabled or never succeeded. + WaveLinkSnapshot? LastSnapshot { get; } + + event EventHandler? StateChanged; + + event EventHandler? SnapshotChanged; + + Task GetSnapshotAsync(CancellationToken ct = default); + + Task SetMixForOutputAsync(string deviceId, string outputId, string mixId, CancellationToken ct = default); +} + +public sealed record WaveLinkSnapshot( + IReadOnlyList Mixes, + IReadOnlyList OutputDevices); + +public sealed record WaveLinkMixInfo(string Id, string Name); + +public sealed record WaveLinkOutputInfo( + string DeviceId, + string OutputId, + string DeviceName, + string CurrentMixId);