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