From 12f8f8667b2762ffa61d2f7bc6b692d930ff7d69 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Tue, 8 Sep 2026 12:38:45 +0100 Subject: [PATCH] Mixer: keep app identities and routing overrides consistent Use the owning PipeWire client for application metadata missing from playback nodes. Normalize executable identities consistently at discovery, restore and app commands. Prefer an existing canonical override over a stale alias so a duplicate entry cannot move a game back to System after restart. Add captured-graph and override migration regressions. --- docs/api.md | 7 + docs/manual.md | 7 + src/OpenXLR.Core/Mixing/Mixer.cs | 10 +- src/OpenXLR.Core/Mixing/PipeWireAdapter.cs | 36 ++++- src/OpenXLR.Core/Mixing/StreamMatcher.cs | 26 +++- src/OpenXLR.Tests/AppIdentityTests.cs | 147 +++++++++++++++++++++ 6 files changed, 219 insertions(+), 14 deletions(-) create mode 100644 src/OpenXLR.Tests/AppIdentityTests.cs diff --git a/docs/api.md b/docs/api.md index a73791d..c753439 100644 --- a/docs/api.md +++ b/docs/api.md @@ -102,6 +102,13 @@ a bare `error` message, so an editor can wait for the acknowledgement: | `resetDevice` | none | write the firmware defaults back to a device without settings memory and forget its last settings (an error until the daemon has seen the device connect after a power cycle once); on the Wave XLR Pro, which keeps its own settings, write OpenXLR's baseline instead: gain 30 dB on both inputs, every processing stage and phantom off, headphones and aux level at half, the crossfade fully on PC, routing untouched, refused while the gain lock is on. The capabilities say `builtInDefaults` when a model has a baseline | | `getDiagnostics` | none | vendor block dump for bug reports | +Application identities use playback-node metadata, falling back to the +owning PipeWire client's application name and process binary when absent. +Windows executable names normalize to the same key as their Wine/Proton +client, for example `Balatro.exe` becomes `balatro`. `assignApp` and +`forgetApp` also accept those legacy executable-name identities. When +loading conflicting old and normalized overrides, the normalized key wins. + Insert definitions optionally carry `nativeHost: true` to select the native LV2 helper for that insert. Missing or false keeps PipeWire filter-chain, even when the helper is installed. Unsupported native selections are rejected. diff --git a/docs/manual.md b/docs/manual.md index 5fd4cf2..f32b717 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -154,6 +154,13 @@ software mix instead, with a few milliseconds of delay. The APPLICATIONS card lists every app that is currently registered with PipeWire as an audio client; a green light means it is playing. +An app's playback streams and audio client share the same identity even +when PipeWire puts the process name only on the client. Windows executable +names such as `Balatro.exe` use the same normalized key as their Wine or +Proton client, so the app keeps one entry and one channel assignment. +When older settings contain both that key and a stale executable-name +alias, the existing normalized assignment takes precedence. + 1. Change the channel in the dropdown next to the app. The move happens immediately and is remembered for that app. The channels also appear as playback devices in your desktop's audio applet (KDE's, for one), diff --git a/src/OpenXLR.Core/Mixing/Mixer.cs b/src/OpenXLR.Core/Mixing/Mixer.cs index 6c2d0a0..01a5e92 100644 --- a/src/OpenXLR.Core/Mixing/Mixer.cs +++ b/src/OpenXLR.Core/Mixing/Mixer.cs @@ -904,8 +904,8 @@ public void ApplySettings(MixerSettings s) foreach (string cell in s.ChannelMuted) if (_cells.Contains(cell)) _muted.Add(cell); - foreach ((string identity, string channelId) in s.AppOverrides) - Matcher.SetOverride(StreamMatcher.MigrateIdentity(Sanitize(identity)), _config.ResolveApplicationChannel(channelId)); + foreach ((string identity, string channelId) in StreamMatcher.MigrateOverrides(s.AppOverrides)) + Matcher.SetOverride(identity, _config.ResolveApplicationChannel(channelId)); // Remembered apps come back inactive until a stream appears. // Identities saved before the "(deleted)" fix are migrated here so @@ -915,7 +915,9 @@ public void ApplySettings(MixerSettings s) string identity = StreamMatcher.MigrateIdentity(Sanitize(app.Identity)); if (PipeWireAdapter.IsPlumbingIdentity(identity)) continue; // pre-filter leftovers if (!_apps.ContainsKey(identity)) - _apps[identity] = new StreamAssignment(0, 0, Sanitize(app.Label), identity, _config.ResolveApplicationChannel(app.ChannelId)) { Active = false, Running = false }; + _apps[identity] = new StreamAssignment(0, 0, Sanitize(app.Label), identity, + Matcher.Overrides.TryGetValue(identity, out string? pinned) + ? pinned : _config.ResolveApplicationChannel(app.ChannelId)) { Active = false, Running = false }; } static string Sanitize(string v) => v.EndsWith(" (deleted)", StringComparison.Ordinal) ? v[..^10] : v; @@ -1596,6 +1598,7 @@ public void ForgetApp(string identity) { lock (_gate) { + identity = StreamMatcher.MigrateIdentity(identity); _apps.Remove(identity); Matcher.RemoveOverride(identity); } @@ -1610,6 +1613,7 @@ public void AssignApp(string identity, string channelId, string? label = null) lock (_gate) { if (string.IsNullOrWhiteSpace(identity)) return; + identity = StreamMatcher.MigrateIdentity(identity); if (channelId == StreamMatcher.Ignore) { // Stop managing the app: remember the choice, hand its live diff --git a/src/OpenXLR.Core/Mixing/PipeWireAdapter.cs b/src/OpenXLR.Core/Mixing/PipeWireAdapter.cs index dbb7202..4a66d6c 100644 --- a/src/OpenXLR.Core/Mixing/PipeWireAdapter.cs +++ b/src/OpenXLR.Core/Mixing/PipeWireAdapter.cs @@ -1209,10 +1209,11 @@ public static bool IsPlumbingIdentity(string identity) /// not. Browsers, chat apps and players connect as clients the moment they /// initialise audio, so this is "audio-capable and running". /// - public IReadOnlyList ListClients() + public IReadOnlyList ListClients() => ListClients(DumpJson()); + + internal static IReadOnlyList ListClients(byte[] json) { var found = new List(); - byte[] json = DumpJson(); JsonDocument doc; try { doc = PipeWireSnapshot.Parse(json); } catch (JsonException) { return found; } @@ -1251,15 +1252,29 @@ bool Listed(string[] list, string? v) => v is not null && Array.Exists(list, e = /// with the identity fields the matcher needs. OpenXLR's own loopbacks are /// excluded: they are plumbing, not applications. /// - public IReadOnlyList ListStreams() + public IReadOnlyList ListStreams() => ListStreams(DumpJson()); + + internal static IReadOnlyList ListStreams(byte[] json) { var found = new List(); - byte[] json = DumpJson(); JsonDocument doc; try { doc = PipeWireSnapshot.Parse(json); } catch (JsonException) { return found; } using (doc) { + // Native PipeWire streams can leave process metadata on their + // owning client. Read it from this same snapshot, regardless of + // whether the client appears before or after its playback node. + var clients = new Dictionary(); + foreach (JsonElement o in doc.RootElement.EnumerateArray()) + if (o.TryGetProperty("type", out JsonElement type) && + type.GetString() == "PipeWire:Interface:Client" && + o.TryGetProperty("id", out JsonElement id) && id.ValueKind == JsonValueKind.Number && + id.TryGetInt32(out int clientId) && + o.TryGetProperty("info", out JsonElement info) && info.ValueKind == JsonValueKind.Object && + info.TryGetProperty("props", out JsonElement props)) + clients[clientId] = props; + foreach (JsonElement o in doc.RootElement.EnumerateArray()) { if (!o.TryGetProperty("type", out JsonElement t) || @@ -1289,12 +1304,17 @@ public IReadOnlyList ListStreams() os.TryGetInt32(out int sv) ? sv : o.GetProperty("id").GetInt32(); // A binary replaced on disk while running (updates) reports // as "name (deleted)"; strip it or the app splits identities. - string? binary = Str(props, "application.process.binary"); + JsonElement client = default; + if (props.TryGetProperty("client.id", out JsonElement owner) && owner.ValueKind == JsonValueKind.Number && + owner.TryGetInt32(out int ownerId)) + clients.TryGetValue(ownerId, out client); + string? AppProperty(string key) => Str(props, key) ?? Str(client, key); + string? binary = AppProperty("application.process.binary"); if (binary is not null && binary.EndsWith(" (deleted)", StringComparison.Ordinal)) binary = binary[..^10]; found.Add(new AudioStream( o.GetProperty("id").GetInt32(), - Str(props, "application.name"), + AppProperty("application.name"), binary, Str(props, "media.name")) { Serial = serial }); } @@ -1302,7 +1322,9 @@ public IReadOnlyList ListStreams() return found; static string? Str(JsonElement props, string key) - => props.TryGetProperty(key, out JsonElement v) ? v.GetString() : null; + => props.ValueKind == JsonValueKind.Object && props.TryGetProperty(key, out JsonElement v) && + v.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(v.GetString()) + ? v.GetString() : null; } /// All audio nodes as (id, node.name, media.class). diff --git a/src/OpenXLR.Core/Mixing/StreamMatcher.cs b/src/OpenXLR.Core/Mixing/StreamMatcher.cs index 206713a..b27f3e4 100644 --- a/src/OpenXLR.Core/Mixing/StreamMatcher.cs +++ b/src/OpenXLR.Core/Mixing/StreamMatcher.cs @@ -90,7 +90,8 @@ public string Match(AudioStream stream) // Wine and Proton report a shared binary, so treat them as games rather // than letting them fall through to System with every other unknown app. - if (IsWineLike(stream.Binary) || IsWineLike(stream.AppName)) return "game"; + if (IsWineLike(stream.Binary) || IsWineLike(stream.AppName) || + IsWindowsExecutable(stream.Binary) || IsWindowsExecutable(stream.AppName)) return "game"; return _fallbackChannel; } @@ -98,6 +99,9 @@ public string Match(AudioStream stream) private static bool IsWineLike(string? s) => s is not null && WineLike.Any(w => s.Contains(w, StringComparison.OrdinalIgnoreCase)); + internal static bool IsWindowsExecutable(string? name) + => name?.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) == true; + /// /// A Windows game's stable key. The same game surfaces under several /// spellings of its own name ("Cyberpunk 2077", "Cyberpunk2077.exe"), so @@ -119,9 +123,20 @@ public static string GameIdentity(string name) /// through unchanged. /// public static string MigrateIdentity(string identity) - => identity.Contains(' ') || identity.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) - ? GameIdentity(identity) - : identity; + { + if (identity.EndsWith(" (deleted)", StringComparison.Ordinal)) identity = identity[..^10]; + return identity.Contains(' ') || IsWindowsExecutable(identity) ? GameIdentity(identity) : identity; + } + + /// Prefer an existing canonical choice over a stale alias. + internal static IReadOnlyDictionary MigrateOverrides(IReadOnlyDictionary overrides) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach ((string identity, string channel) in overrides.OrderBy(pair => + string.Equals(pair.Key, MigrateIdentity(pair.Key), StringComparison.OrdinalIgnoreCase) ? 0 : 1)) + result.TryAdd(MigrateIdentity(identity), channel); + return result; + } } /// One application playback stream in the graph. @@ -148,6 +163,9 @@ public string Identity // person both are "Steam", so they share one identity and one // channel assignment. if (bin.Equals("steamwebhelper", StringComparison.OrdinalIgnoreCase)) return "steam"; + // The executable can be reported directly, or as the app name + // while the playback node omits its runtime binary entirely. + if (StreamMatcher.IsWindowsExecutable(bin)) return StreamMatcher.GameIdentity(bin); bool shared = bin.Contains("wine", StringComparison.OrdinalIgnoreCase) || bin.Contains("proton", StringComparison.OrdinalIgnoreCase); if (!shared) return bin; diff --git a/src/OpenXLR.Tests/AppIdentityTests.cs b/src/OpenXLR.Tests/AppIdentityTests.cs new file mode 100644 index 0000000..80036d8 --- /dev/null +++ b/src/OpenXLR.Tests/AppIdentityTests.cs @@ -0,0 +1,147 @@ +using System.Text; +using OpenXLR.Core.Mixing; + +namespace OpenXLR.Tests; + +public sealed class AppIdentityTests +{ + // Reduced from the live Balatro graph: the process binary belongs to + // the client, while the playback node only carries the Windows app name. + private static byte[] BalatroGraph => Encoding.UTF8.GetBytes(""" + [ + {"id":649,"type":"PipeWire:Interface:Node","info":{"props":{ + "client.id":650,"application.name":"Balatro.exe", + "media.class":"Stream/Output/Audio","media.name":"Balatro.exe", + "object.serial":43258 + }}}, + {"id":650,"type":"PipeWire:Interface:Client","info":{"props":{ + "application.name":"Balatro.exe","application.process.binary":"wine64-preloader" + }}} + ] + """); + + [Fact] + public void PlaybackAndClientShareOneIdentityAndSavedRoute() + { + AudioStream client = Assert.Single(PipeWireAdapter.ListClients(BalatroGraph)); + AudioStream stream = Assert.Single(PipeWireAdapter.ListStreams(BalatroGraph)); + var matcher = new StreamMatcher(); + matcher.SetOverride(client.Identity, "music"); + + Assert.Equal("balatro", client.Identity); + Assert.Equal(client.Identity, stream.Identity); + Assert.Equal("music", matcher.Match(stream)); + Assert.Equal("game", new StreamMatcher().Match(stream)); + Assert.Equal(43258, stream.Serial); + } + + [Theory] + [InlineData(null)] + [InlineData("Balatro.exe")] + [InlineData("wine64-preloader")] + public void WindowsIdentityMatchesItsPersistedKey(string? binary) + { + var stream = new AudioStream(649, "Balatro.exe", binary, "playback"); + Assert.Equal("balatro", stream.Identity); + Assert.Equal(stream.Identity, StreamMatcher.MigrateIdentity(stream.Identity)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CanonicalOverrideWinsOverStaleAliasRegardlessOfFileOrder(bool reverse) + { + KeyValuePair[] entries = + [new("balatro", "game"), new("Balatro.exe", "system")]; + var saved = (reverse ? entries.Reverse() : entries).ToDictionary(pair => pair.Key, pair => pair.Value); + IReadOnlyDictionary migrated = StreamMatcher.MigrateOverrides(saved); + + Assert.Equal("game", Assert.Single(migrated).Value); + Assert.Equal("balatro", Assert.Single(migrated).Key); + var matcher = new StreamMatcher(); + foreach ((string identity, string channel) in migrated) matcher.SetOverride(identity, channel); + Assert.Equal("game", matcher.Match(Assert.Single(PipeWireAdapter.ListStreams(BalatroGraph)))); + } + + [Fact] + public void LegacyOnlyOverridesAndNotManagedChoicesSurviveMigration() + { + var saved = new Dictionary + { + ["Balatro.exe"] = StreamMatcher.Ignore, + ["spotify (deleted)"] = "music", + }; + var migrated = StreamMatcher.MigrateOverrides(saved); + Assert.Equal(StreamMatcher.Ignore, migrated["balatro"]); + Assert.Equal("music", migrated["spotify"]); + Assert.Equal(2, migrated.Count); + } + + [Fact] + public void ExplicitNodeMetadataWinsOverClientMetadata() + { + byte[] json = Encoding.UTF8.GetBytes(Encoding.UTF8.GetString(BalatroGraph) + .Replace("\"client.id\":650,", "\"client.id\":650,\"application.process.binary\":\"spotify\",")); + AudioStream stream = Assert.Single(PipeWireAdapter.ListStreams(json)); + Assert.Equal("spotify", stream.Identity); + Assert.Equal("music", new StreamMatcher().Match(stream)); + } + + [Fact] + public void MissingClientStillUsesTheWindowsGameKey() + { + byte[] json = Encoding.UTF8.GetBytes(Encoding.UTF8.GetString(BalatroGraph).Replace("\"client.id\":650", "\"client.id\":999")); + AudioStream stream = Assert.Single(PipeWireAdapter.ListStreams(json)); + Assert.Equal("balatro", stream.Identity); + Assert.Equal("game", new StreamMatcher().Match(stream)); + } + + [Fact] + public void SharedAppLabelsDoNotMergeDifferentNativeBinaries() + { + byte[] json = Encoding.UTF8.GetBytes(""" + [ + {"id":1,"type":"PipeWire:Interface:Client","info":{"props":{ + "application.name":"Chromium","application.process.binary":"vesktop (deleted)" + }}}, + {"id":2,"type":"PipeWire:Interface:Client","info":{"props":{ + "application.name":"Chromium","application.process.binary":"chromium" + }}}, + {"id":3,"type":"PipeWire:Interface:Node","info":{"props":{ + "client.id":1,"media.class":"Stream/Output/Audio" + }}}, + {"id":4,"type":"PipeWire:Interface:Node","info":{"props":{ + "client.id":2,"media.class":"Stream/Output/Audio" + }}} + ] + """); + IReadOnlyList streams = PipeWireAdapter.ListStreams(json); + Assert.Equal(["vesktop", "chromium"], streams.Select(stream => stream.Identity)); + Assert.Equal(["voicechat", "browser"], streams.Select(new StreamMatcher().Match)); + Assert.Equal("Vesktop", streams[0].Label); + } + + [Fact] + public void WindowsGamesRemainDistinctUnderTheSameRuntime() + { + Assert.NotEqual(new AudioStream(1, "Balatro.exe", "wine64-preloader", null).Identity, + new AudioStream(2, "Other Game.exe", "wine64-preloader", null).Identity); + } + + [Fact] + public void AssigningAndForgettingAnExecutableAliasUseTheCanonicalApp() + { + using var mixer = new Mixer(); + mixer.AssignApp("balatro", "game", "Balatro"); + mixer.AssignApp("Balatro.exe", "music", "Balatro"); + + MixerSettings settings = mixer.ExportSettings(); + Assert.Equal("balatro", Assert.Single(settings.KnownApps).Identity); + Assert.Equal("music", settings.AppOverrides["balatro"]); + Assert.Single(settings.AppOverrides); + + mixer.ForgetApp("Balatro.exe"); + Assert.Empty(mixer.ExportSettings().KnownApps); + Assert.Empty(mixer.Matcher.Overrides); + } +}