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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions docs/manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
10 changes: 7 additions & 3 deletions src/OpenXLR.Core/Mixing/Mixer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -1596,6 +1598,7 @@ public void ForgetApp(string identity)
{
lock (_gate)
{
identity = StreamMatcher.MigrateIdentity(identity);
_apps.Remove(identity);
Matcher.RemoveOverride(identity);
}
Expand All @@ -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
Expand Down
36 changes: 29 additions & 7 deletions src/OpenXLR.Core/Mixing/PipeWireAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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".
/// </summary>
public IReadOnlyList<AudioStream> ListClients()
public IReadOnlyList<AudioStream> ListClients() => ListClients(DumpJson());

internal static IReadOnlyList<AudioStream> ListClients(byte[] json)
{
var found = new List<AudioStream>();
byte[] json = DumpJson();
JsonDocument doc;
try { doc = PipeWireSnapshot.Parse(json); }
catch (JsonException) { return found; }
Expand Down Expand Up @@ -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.
/// </summary>
public IReadOnlyList<AudioStream> ListStreams()
public IReadOnlyList<AudioStream> ListStreams() => ListStreams(DumpJson());

internal static IReadOnlyList<AudioStream> ListStreams(byte[] json)
{
var found = new List<AudioStream>();
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<int, JsonElement>();
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) ||
Expand Down Expand Up @@ -1289,20 +1304,27 @@ public IReadOnlyList<AudioStream> 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 });
}
}
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;
}

/// <summary>All audio nodes as (id, node.name, media.class).</summary>
Expand Down
26 changes: 22 additions & 4 deletions src/OpenXLR.Core/Mixing/StreamMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,18 @@ 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;
}

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;

/// <summary>
/// A Windows game's stable key. The same game surfaces under several
/// spellings of its own name ("Cyberpunk 2077", "Cyberpunk2077.exe"), so
Expand All @@ -119,9 +123,20 @@ public static string GameIdentity(string name)
/// through unchanged.
/// </summary>
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;
}

/// <summary>Prefer an existing canonical choice over a stale alias.</summary>
internal static IReadOnlyDictionary<string, string> MigrateOverrides(IReadOnlyDictionary<string, string> overrides)
{
var result = new Dictionary<string, string>(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;
}
}

/// <summary>One application playback stream in the graph.</summary>
Expand All @@ -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;
Expand Down
147 changes: 147 additions & 0 deletions src/OpenXLR.Tests/AppIdentityTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, string>[] entries =
[new("balatro", "game"), new("Balatro.exe", "system")];
var saved = (reverse ? entries.Reverse() : entries).ToDictionary(pair => pair.Key, pair => pair.Value);
IReadOnlyDictionary<string, string> 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<string, string>
{
["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<AudioStream> 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);
}
}