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/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 { 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);