Repository navigation
Expand file tree
/
Copy pathLinuxAudioService.Cache.cs
More file actions
497 lines (421 loc) · 18.2 KB
/
Copy pathLinuxAudioService.Cache.cs
File metadata and controls
497 lines (421 loc) · 18.2 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
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
using System.Diagnostics;
using System.Globalization;
using System.Text.RegularExpressions;
namespace LoupixDeck.Plugin.Audio;
/// <summary>
/// The pactl event monitor and the caches it keeps fresh.
/// <para>
/// Every pactl call is a process. Without caching, a dial tick cost a GetVolume and a SetVolume
/// (two processes), the devices folder cost 2 + 2N processes every 750 ms, and each strip bar ran
/// its own <c>pactl subscribe</c> that re-read volume and mute on every app-stream change. One
/// shared <c>pactl subscribe</c> process now tells which device or stream changed, so volume, mute,
/// the endpoint lists and the sink-input list are answered from memory until that device reports a
/// change. While the monitor is not running (pactl missing, process died), cached values expire
/// after <see cref="UnmonitoredLifetimeMs"/> instead.
/// </para>
/// </summary>
public sealed partial class LinuxAudioService : IDisposable
{
/// <summary>How long a cached answer is trusted while no event monitor is running.</summary>
private const long UnmonitoredLifetimeMs = 500;
/// <summary>Minimum pause before a died monitor is started again.</summary>
private const long MonitorRestartDelayMs = 5000;
// "Event 'change' on sink #42", "Event 'remove' on sink-input #7", "Event 'change' on server"
private static readonly Regex EventLine =
new(@"^Event '(\w+)' on ([\w-]+)(?: #(\d+))?", RegexOptions.Compiled);
private readonly Lock _cacheLock = new();
private Process? _monitor;
private CancellationTokenSource? _monitorCts;
// Not long.MinValue: TickCount64 - long.MinValue overflows to a negative number, which reads
// as "started just now" and kept the first monitor from ever starting.
private long _monitorStartedAt = -MonitorRestartDelayMs;
private bool _disposed;
/// <summary>Bumped on every event, so a read that raced an event does not store a stale answer.</summary>
private long _generation;
private readonly Dictionary<string, CachedLevel> _levels = new(StringComparer.Ordinal);
private readonly Dictionary<AudioEndpointKind, CachedEndpoints> _endpoints = [];
private readonly Dictionary<AudioEndpointKind, Dictionary<int, string>> _namesByIndex = [];
private CachedSinkInputs? _sinkInputs;
private readonly List<Listener> _listeners = [];
private readonly DeviceChangeNotifier _deviceChanges = new();
/// <summary>A device's level. <paramref name="PendingEchoes"/> counts writes of this service
/// whose change event has not arrived yet; see <see cref="StoreLevel"/>.</summary>
private sealed record CachedLevel(float Volume, bool Muted, long Stamp, int PendingEchoes = 0);
/// <summary>How long after its own write a change event still counts as that write's echo.</summary>
private const long EchoWindowMs = 1000;
private sealed record CachedEndpoints(IReadOnlyList<AudioEndpointInfo> List, string DefaultName, long Stamp);
private sealed record CachedSinkInputs(IReadOnlyList<SinkInput> List, long Stamp);
/// <summary>
/// True once the monitor has had time to connect. <c>pactl subscribe</c> needs a moment after
/// the process starts; a change in that gap would never be reported, so until then cached
/// answers still expire as if no monitor ran.
/// </summary>
private bool MonitorRunning =>
_monitor != null && Environment.TickCount64 - _monitorStartedAt >= UnmonitoredLifetimeMs;
private bool IsFresh(long stamp) =>
MonitorRunning || Environment.TickCount64 - stamp < UnmonitoredLifetimeMs;
// --- endpoint volume and mute -----------------------------------------
/// <summary>"sink:NAME" / "source:NAME" — the same id whether the caller passed a prefix or not.</summary>
private static string LevelKey(AudioEndpointKind kind, string name) => $"{Noun(kind)}:{name}";
private static string Noun(AudioEndpointKind kind) => kind == AudioEndpointKind.Render ? "sink" : "source";
private CachedLevel Level(string endpointId)
{
EnsureMonitor();
(AudioEndpointKind kind, string name) = SplitId(endpointId);
string key = LevelKey(kind, name);
long generation;
lock (_cacheLock)
{
if (_levels.TryGetValue(key, out CachedLevel? cached) && IsFresh(cached.Stamp)) return cached;
generation = _generation;
}
string noun = Noun(kind);
bool ok = TryRunPactl(out string volumeOutput, $"get-{noun}-volume", name);
ok &= TryRunPactl(out string muteOutput, $"get-{noun}-mute", name);
// e.g. "Volume: front-left: 45875 / 70% / -9.62 dB, front-right: 45875 / 70% ..."
Match volume = Regex.Match(volumeOutput, @"(\d+)%");
CachedLevel level = new(
volume.Success
? Math.Clamp(int.Parse(volume.Groups[1].Value, CultureInfo.InvariantCulture) / 100f, 0f, 1f)
: 0f,
// "Mute: yes" / "Mute: no"
muteOutput.Trim().EndsWith("yes", StringComparison.OrdinalIgnoreCase),
Environment.TickCount64);
// A failed read is answered but not kept: with a running monitor nothing would replace it.
lock (_cacheLock)
{
if (ok && generation == _generation) _levels[key] = level;
}
return level;
}
/// <summary>
/// Records a value this service just wrote, so the next read needs no process. The write comes
/// back as a change event; that echo is expected and keeps the entry, otherwise every dial tick
/// would pay for a re-read of the value it has just set.
/// </summary>
private void StoreLevel(string endpointId, float? volume, bool? muted)
{
(AudioEndpointKind kind, string name) = SplitId(endpointId);
string key = LevelKey(kind, name);
lock (_cacheLock)
{
// Only a known entry is updated: half a level (volume without mute) is not a level.
if (!_levels.TryGetValue(key, out CachedLevel? cached)) return;
// PulseAudio posts no change event for a write that changes nothing (a dial turned
// further at 100 %), so such a write must not wait for an echo: it would swallow the
// next outside change as its own.
if ((volume == null || Math.Abs(volume.Value - cached.Volume) < 0.005f) &&
(muted == null || muted.Value == cached.Muted))
{
return;
}
_levels[key] = cached with
{
Volume = volume ?? cached.Volume,
Muted = muted ?? cached.Muted,
Stamp = Environment.TickCount64,
PendingEchoes = cached.PendingEchoes + 1
};
}
}
// --- endpoint lists ---------------------------------------------------
private CachedEndpoints Endpoints(AudioEndpointKind kind)
{
EnsureMonitor();
long generation;
lock (_cacheLock)
{
if (_endpoints.TryGetValue(kind, out CachedEndpoints? cached) && IsFresh(cached.Stamp)) return cached;
generation = _generation;
}
// A failed default lookup reads as "no default"; the list is still answered but not kept,
// or every @default binding would do nothing until the next device event.
bool defaultOk = TryRunPactl(out string defaultOutput, $"get-default-{Noun(kind)}");
string defaultName = defaultOutput.Trim();
// A pactl call that failed or timed out (a stuck PulseAudio socket, say) reads as "no
// devices", which would empty every menu and folder for one refresh. Keep the last list.
if (!TryRunPactl(out string listOutput, "list", kind == AudioEndpointKind.Render ? "sinks" : "sources"))
{
lock (_cacheLock)
{
return _endpoints.TryGetValue(kind, out CachedEndpoints? last)
? last
: new CachedEndpoints([], defaultName, long.MinValue);
}
}
CachedEndpoints endpoints = new(ParseEndpoints(listOutput, defaultName, kind), defaultName,
Environment.TickCount64);
lock (_cacheLock)
{
if (defaultOk && generation == _generation) _endpoints[kind] = endpoints;
}
return endpoints;
}
// --- sink inputs ------------------------------------------------------
private IReadOnlyList<SinkInput> SinkInputs()
{
EnsureMonitor();
long generation;
lock (_cacheLock)
{
if (_sinkInputs != null && IsFresh(_sinkInputs.Stamp)) return _sinkInputs.List;
generation = _generation;
}
bool ok = TryRunPactl(out string output, "list", "sink-inputs");
IReadOnlyList<SinkInput> inputs = ParseSinkInputs(output);
lock (_cacheLock)
{
if (ok && generation == _generation) _sinkInputs = new CachedSinkInputs(inputs, Environment.TickCount64);
}
return inputs;
}
/// <summary>Drops the sink-input list after this service changed a stream.</summary>
private void InvalidateSinkInputs()
{
lock (_cacheLock)
{
_sinkInputs = null;
_generation++;
}
}
// --- change listeners ---------------------------------------------------
/// <summary>One <see cref="IAudioService.SubscribeVolumeChanges"/> registration.</summary>
private sealed class Listener(LinuxAudioService owner, string key, Action<float, bool> onChange) : IDisposable
{
public string Key { get; } = key;
public Action<float, bool> OnChange { get; } = onChange;
public void Dispose()
{
lock (owner._cacheLock) owner._listeners.Remove(this);
}
}
private IDisposable AddListener(string endpointId, Action<float, bool> onChange)
{
EnsureMonitor();
(AudioEndpointKind kind, string name) = SplitId(endpointId);
Listener listener = new(this, LevelKey(kind, name), onChange);
lock (_cacheLock) _listeners.Add(listener);
return listener;
}
public IDisposable SubscribeDeviceChanges(Action onChange)
{
// The events come from the monitor, so a subscriber needs it running even before
// anything has read a volume.
EnsureMonitor();
return _deviceChanges.Subscribe(onChange);
}
// --- the monitor ------------------------------------------------------
/// <summary>Starts the shared <c>pactl subscribe</c> process unless it runs, or died too recently.</summary>
private void EnsureMonitor()
{
lock (_cacheLock)
{
if (_disposed || _monitor != null) return;
if (Environment.TickCount64 - _monitorStartedAt < MonitorRestartDelayMs) return;
_monitorStartedAt = Environment.TickCount64;
Process process;
try
{
process = Process.Start(PactlStartInfo("subscribe"))!;
}
catch
{
return;
}
CancellationTokenSource cts = new();
_monitor = process;
_monitorCts = cts;
// stderr is never read; draining it keeps a chatty pactl from blocking on a full pipe.
_ = process.StandardError.ReadToEndAsync(cts.Token);
_ = Task.Run(() => ReadEvents(process, cts.Token));
}
}
private async Task ReadEvents(Process process, CancellationToken token)
{
try
{
StreamReader reader = process.StandardOutput;
while (!token.IsCancellationRequested)
{
string? line = await reader.ReadLineAsync(token).ConfigureAwait(false);
if (line == null) break;
HandleEvent(line);
}
}
catch
{
// Cancelled on dispose, or the process went away.
}
finally
{
StopMonitor(process);
}
}
/// <summary>Forgets a monitor process. Everything cached on its word is dropped too, since
/// events may have been missed; the next query starts a new monitor.</summary>
private void StopMonitor(Process process)
{
CancellationTokenSource? cts = null;
lock (_cacheLock)
{
if (!ReferenceEquals(_monitor, process)) return;
_monitor = null;
cts = _monitorCts;
_monitorCts = null;
ClearCaches();
}
// Changes may have gone unreported while the monitor was dying; whoever shows a device
// re-reads it, which also starts the next monitor.
_deviceChanges.Raise();
try { cts?.Cancel(); } catch { /* ignore */ }
try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch { /* ignore */ }
process.Dispose();
cts?.Dispose();
}
public void RefreshDevices()
{
lock (_cacheLock) ClearCaches();
}
private void ClearCaches()
{
_levels.Clear();
_endpoints.Clear();
_namesByIndex.Clear();
_sinkInputs = null;
_generation++;
}
private void HandleEvent(string line)
{
Match match = EventLine.Match(line);
if (!match.Success) return;
string type = match.Groups[1].Value;
string facility = match.Groups[2].Value;
switch (facility)
{
case "sink-input":
InvalidateSinkInputs();
return;
case "server":
// The default sink or source moved: every list's IsDefault may be wrong now.
lock (_cacheLock)
{
_endpoints.Clear();
_generation++;
}
_deviceChanges.Raise();
return;
case "sink":
case "source":
break;
default:
// card, client, module, sample-cache, source-output: nothing cached depends on them.
return;
}
AudioEndpointKind kind = facility == "sink" ? AudioEndpointKind.Render : AudioEndpointKind.Capture;
if (!int.TryParse(match.Groups[3].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int index))
return;
if (type == "new")
{
// A device came: the list and the index map are out of date.
lock (_cacheLock)
{
_endpoints.Remove(kind);
_namesByIndex.Remove(kind);
_generation++;
}
_deviceChanges.Raise();
return;
}
// Looked up before a removal clears the map, so a removed device still has its name here.
string? name = NameOfIndex(kind, index);
if (type == "remove") _deviceChanges.Raise();
List<Listener> notify;
CachedLevel? echo = null;
lock (_cacheLock)
{
_generation++;
if (type == "remove")
{
_endpoints.Remove(kind);
_namesByIndex.Remove(kind);
}
if (name == null)
{
// Unknown device: drop every level of that kind rather than keep a stale one.
string prefix = Noun(kind) + ":";
foreach (string key in _levels.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).ToList())
_levels.Remove(key);
return;
}
string levelKey = LevelKey(kind, name);
if (type == "remove")
{
_levels.Remove(levelKey);
return;
}
if (_levels.TryGetValue(levelKey, out CachedLevel? cached) && cached.PendingEchoes > 0 &&
Environment.TickCount64 - cached.Stamp < EchoWindowMs)
{
// Our own write coming back: the cached value already is the new one.
echo = cached with { PendingEchoes = cached.PendingEchoes - 1 };
_levels[levelKey] = echo;
}
else
{
_levels.Remove(levelKey);
}
// Only the listeners of exactly this device: a change on an app stream or on another
// device is not theirs to re-read.
notify = _listeners.Where(l => string.Equals(l.Key, levelKey, StringComparison.Ordinal)).ToList();
}
if (notify.Count == 0) return;
// One read serves every listener of the device: the first fills the cache.
CachedLevel level;
try { level = echo ?? Level(LevelKey(kind, name)); }
catch { return; }
foreach (Listener listener in notify)
{
try { listener.OnChange(level.Volume, level.Muted); }
catch { /* a failing listener must not stop the monitor */ }
}
}
/// <summary>The name of the sink or source with that index, from <c>pactl list short</c>,
/// re-read when the index is not known yet.</summary>
private string? NameOfIndex(AudioEndpointKind kind, int index)
{
lock (_cacheLock)
{
if (_namesByIndex.TryGetValue(kind, out Dictionary<int, string>? known) &&
known.TryGetValue(index, out string? name))
{
return name;
}
}
// "47\talsa_output.pci-0000_00_1f.3.analog-stereo\tmodule-alsa-card.c\ts16le 2ch 48000Hz\tRUNNING"
Dictionary<int, string> names = [];
string list = RunPactl("list", "short", kind == AudioEndpointKind.Render ? "sinks" : "sources");
foreach (string row in list.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
string[] columns = row.Split('\t');
if (columns.Length >= 2 &&
int.TryParse(columns[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int rowIndex))
{
names[rowIndex] = columns[1].Trim();
}
}
lock (_cacheLock) _namesByIndex[kind] = names;
return names.GetValueOrDefault(index);
}
/// <summary>Stops the event monitor. Called by the plugin on shutdown.</summary>
public void Dispose()
{
Process? monitor;
lock (_cacheLock)
{
_disposed = true;
monitor = _monitor;
_listeners.Clear();
}
if (monitor != null) StopMonitor(monitor);
}
}