Repository navigation
Expand file tree
/
Copy pathLinuxAudioService.cs
More file actions
174 lines (149 loc) · 7.1 KB
/
Copy pathLinuxAudioService.cs
File metadata and controls
174 lines (149 loc) · 7.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
using LoupixDeck.PluginSdk;
using System.Globalization;
using System.Text.RegularExpressions;
namespace LoupixDeck.Plugin.Audio;
/// <summary>
/// Linux implementation backed by <c>pactl</c>. Works on PulseAudio and on PipeWire
/// systems via the pipewire-pulse compatibility layer.
/// </summary>
public sealed partial class LinuxAudioService : IAudioService
{
private static readonly ToolProbe HasPactl = new("pactl", "--version");
private static readonly ToolProbe HasPaplay = new("paplay", "--version");
private static readonly ToolProbe HasFfplay = new("ffplay", "-version");
private static readonly ToolProbe HasMpv = new("mpv", "--version");
private static readonly ToolProbe HasFfmpeg = new("ffmpeg", "-version");
public bool IsSupported => HasPactl.Value;
/// <summary>
/// The tools this backend shells out to. Probes again on every call rather than trusting the
/// first answer, so a package installed while LoupixDeck is running reads as met on the next
/// check. Note that commands are only registered at load: after installing pactl the plugin
/// still needs a restart to offer them, which the hint says.
/// </summary>
public IReadOnlyList<PluginRequirement> GetRequirements()
{
bool pactl = HasPactl.Refresh();
bool paplay = HasPaplay.Refresh();
bool mpv = HasMpv.Refresh();
bool ffplay = HasFfplay.Refresh();
HasFfmpeg.Refresh();
return
[
new PluginRequirement
{
Id = "pactl",
Name = "pactl (pulseaudio-utils)",
IsMet = pactl,
Message = "pactl is missing, so the Audio plugin has no commands.",
InstallHint = "Install the package pulseaudio-utils (Arch Linux: libpulse), then restart LoupixDeck."
},
new PluginRequirement
{
Id = "sound-player",
Name = "Sound player (paplay, mpv or ffplay)",
IsMet = paplay || mpv || ffplay,
Message = "No sound player was found, so sounds cannot be played.",
InstallHint = "Install pulseaudio-utils (paplay) or mpv. For mp3 and m4a files mpv, "
+ "or ffmpeg together with paplay, is needed."
}
];
}
public IReadOnlyList<AudioEndpointInfo> GetEndpoints(AudioEndpointKind kind) =>
IsSupported ? Endpoints(kind).List : [];
public string? GetDefaultEndpointId(AudioEndpointKind kind)
{
if (!IsSupported) return null;
string name = Endpoints(kind).DefaultName;
if (string.IsNullOrEmpty(name)) return null;
// Same "sink:NAME" / "source:NAME" encoding GetEndpoints reports, so the id is
// interchangeable with a bound one.
return LevelKey(kind, name);
}
public float GetVolume(string endpointId) => IsSupported ? Level(endpointId).Volume : 0f;
public void SetVolume(string endpointId, float scalar01)
{
if (!IsSupported) return;
var (kind, name) = SplitId(endpointId);
float clamped = Math.Clamp(scalar01, 0f, 1f);
var pct = (int)Math.Round(clamped * 100f);
if (TryRunPactl(out _, $"set-{Noun(kind)}-volume", name, $"{pct.ToString(CultureInfo.InvariantCulture)}%"))
StoreLevel(endpointId, pct / 100f, null);
}
public bool GetMute(string endpointId) => IsSupported && Level(endpointId).Muted;
public void SetMute(string endpointId, bool muted)
{
if (!IsSupported) return;
var (kind, name) = SplitId(endpointId);
if (TryRunPactl(out _, $"set-{Noun(kind)}-mute", name, muted ? "1" : "0"))
StoreLevel(endpointId, null, muted);
}
/// <summary>
/// Registers with the shared event monitor. The callback runs only for a change on exactly
/// this device, not for app streams or other devices, which used to cost two pactl processes
/// per subscriber on every stream change.
/// </summary>
public IDisposable SubscribeVolumeChanges(string endpointId, Action<float, bool> onChange)
{
ArgumentNullException.ThrowIfNull(onChange);
return IsSupported ? AddListener(endpointId, onChange) : EmptyDisposable.Instance;
}
public bool SetDefaultEndpoint(string endpointId)
{
if (!IsSupported) return false;
(AudioEndpointKind kind, string name) = SplitId(endpointId);
string noun = kind == AudioEndpointKind.Render ? "sink" : "source";
RunPactl($"set-default-{noun}", name);
// pactl leaves already-running streams on the old device, which reads as "nothing
// happened" to the user. Move them across too.
if (kind == AudioEndpointKind.Render)
{
foreach (SinkInput input in SinkInputs())
RunPactl("move-sink-input", input.Index.ToString(CultureInfo.InvariantCulture), name);
InvalidateSinkInputs();
}
// The server event that follows would clear it too, but a menu opened right now must
// already show the new default.
lock (_cacheLock)
{
_endpoints.Remove(kind);
_generation++;
}
string current = RunPactl($"get-default-{noun}").Trim();
return string.Equals(current, name, StringComparison.Ordinal);
}
// --- helpers ---------------------------------------------------------
private static (AudioEndpointKind Kind, string Name) SplitId(string id)
{
// IDs are encoded as "sink:NAME" / "source:NAME" so the kind is recoverable.
var idx = id.IndexOf(':');
if (idx < 0) return (AudioEndpointKind.Render, id);
var kind = id[..idx] == "source" ? AudioEndpointKind.Capture : AudioEndpointKind.Render;
return (kind, id[(idx + 1)..]);
}
private static IReadOnlyList<AudioEndpointInfo> ParseEndpoints(
string pactlList, string defaultName, AudioEndpointKind kind)
{
var prefix = kind == AudioEndpointKind.Render ? "sink" : "source";
var result = new List<AudioEndpointInfo>();
// Blocks separated by blank lines; each block has "Name: ..." and "Description: ..."
foreach (var block in pactlList.Split("\n\n", StringSplitOptions.RemoveEmptyEntries))
{
var name = Regex.Match(block, @"^\s*Name:\s*(.+)$", RegexOptions.Multiline).Groups[1].Value.Trim();
var desc = Regex.Match(block, @"^\s*Description:\s*(.+)$", RegexOptions.Multiline).Groups[1].Value.Trim();
if (string.IsNullOrEmpty(name)) continue;
// Filter out monitor sources (capture mirrors of sinks) — noise for the user.
if (kind == AudioEndpointKind.Capture && name.EndsWith(".monitor", StringComparison.Ordinal))
continue;
result.Add(new AudioEndpointInfo(
Id: $"{prefix}:{name}",
FriendlyName: string.IsNullOrEmpty(desc) ? name : desc,
IsDefault: name == defaultName));
}
return result;
}
private sealed class EmptyDisposable : IDisposable
{
public static readonly EmptyDisposable Instance = new();
public void Dispose() { }
}
}