-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathDuckingEngine.cs
More file actions
331 lines (277 loc) · 11.4 KB
/
Copy pathDuckingEngine.cs
File metadata and controls
331 lines (277 loc) · 11.4 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
using NAudio.CoreAudioApi;
namespace AmpUp;
// DuckingConfig and DuckingRule are now in AmpUp.Core.Models
public class DuckingEngine : IDisposable
{
private readonly MMDeviceEnumerator _enumerator = new();
private readonly object _lock = new();
// Per-rule state
private readonly Dictionary<int, RuleState> _ruleStates = new();
private bool _disposed;
private class SessionInfo
{
public string ProcessName { get; set; } = "";
public float OriginalVolume { get; set; }
public float CurrentDuckedVolume { get; set; }
public bool IsDucked { get; set; }
}
private class RuleState
{
public bool TriggerActive { get; set; }
// processName (lowercase) -> session info for currently tracked targets
public Dictionary<string, SessionInfo> TrackedSessions { get; set; } = new();
// Timestamps for fade tracking
public DateTime FadeStartTime { get; set; } = DateTime.MinValue;
public FadeDirection FadeDir { get; set; } = FadeDirection.None;
public int FadeMs { get; set; }
// Volume at fade start (per-session stored in SessionInfo)
public bool FadeComplete { get; set; } = true;
}
private enum FadeDirection { None, FadeOut, FadeIn }
// PID -> lowercase process name cache. Process.GetProcessById snapshots the
// entire system process table per call, so resolve each PID once and reuse.
// Guarded by _lock (only touched from Poll/EnumerateSessions).
private readonly Dictionary<int, string> _pidNameCache = new();
public void Poll(DuckingConfig config)
{
if (!config.Enabled || config.Rules.Count == 0) return;
lock (_lock)
{
if (_disposed) return;
Dictionary<string, (AudioSessionControl Session, float Peak)>? sessions = null;
try
{
// Enumerate sessions once per Poll call — shared by the trigger
// checks AND the fade steps (no re-enumeration mid-cycle)
sessions = EnumerateSessions();
for (int i = 0; i < config.Rules.Count; i++)
{
var rule = config.Rules[i];
if (string.IsNullOrWhiteSpace(rule.TriggerApp)) continue;
if (!_ruleStates.TryGetValue(i, out var state))
{
state = new RuleState();
_ruleStates[i] = state;
}
PollRule(rule, state, sessions);
}
}
catch (Exception ex)
{
Logger.Log($"DuckingEngine.Poll error: {ex.Message}");
}
finally
{
// Session wrappers are re-created fresh each poll and nothing holds
// them across polls (RuleState stores names + volumes only), so
// dispose every wrapper from this cycle.
if (sessions != null)
foreach (var kv in sessions)
try { kv.Value.Session.Dispose(); } catch { }
}
}
}
private void PollRule(DuckingRule rule, RuleState state, Dictionary<string, (AudioSessionControl Session, float Peak)> sessions)
{
string trigger = rule.TriggerApp.ToLowerInvariant();
// Check if trigger app is active and has audio above threshold
bool triggerNowActive = false;
foreach (var kv in sessions)
{
if (kv.Key.Contains(trigger))
{
if (kv.Value.Peak >= rule.ActivationThreshold)
{
triggerNowActive = true;
break;
}
}
}
bool wasActive = state.TriggerActive;
if (triggerNowActive && !wasActive)
{
// Trigger just became active — start fade-out on targets
state.TriggerActive = true;
CaptureTargetVolumes(rule, state, sessions);
BeginFade(state, FadeDirection.FadeOut, rule.FadeOutMs);
Logger.Log($"DuckingEngine: trigger '{rule.TriggerApp}' active — ducking {state.TrackedSessions.Count} session(s) by {rule.DuckPercent}%");
}
else if (!triggerNowActive && wasActive)
{
// Trigger just stopped — start fade-in (restore)
state.TriggerActive = false;
BeginFade(state, FadeDirection.FadeIn, rule.FadeInMs);
Logger.Log($"DuckingEngine: trigger '{rule.TriggerApp}' stopped — restoring volumes");
}
// Advance any in-progress fade
if (!state.FadeComplete)
{
ApplyFadeStep(rule, state, sessions);
}
}
private void CaptureTargetVolumes(DuckingRule rule, RuleState state, Dictionary<string, (AudioSessionControl Session, float Peak)> sessions)
{
string trigger = rule.TriggerApp.ToLowerInvariant();
// Remove stale sessions that are no longer present
var toRemove = state.TrackedSessions.Keys
.Where(k => !sessions.ContainsKey(k))
.ToList();
foreach (var k in toRemove)
state.TrackedSessions.Remove(k);
bool duckAll = rule.TargetApps.Count == 0;
foreach (var kv in sessions)
{
string procName = kv.Key;
// Never duck the trigger app itself
if (procName.Contains(trigger)) continue;
// If TargetApps specified, only duck matching apps
if (!duckAll)
{
bool matched = rule.TargetApps.Any(t => procName.Contains(t.ToLowerInvariant()));
if (!matched) continue;
}
// Skip system sounds (PID 0 sessions — already filtered in EnumerateSessions)
float currentVol;
try { currentVol = kv.Value.Session.SimpleAudioVolume.Volume; }
catch { continue; }
if (!state.TrackedSessions.TryGetValue(procName, out var info))
{
info = new SessionInfo { ProcessName = procName };
state.TrackedSessions[procName] = info;
}
// Only re-capture original if not currently ducked
if (!info.IsDucked)
info.OriginalVolume = currentVol;
info.CurrentDuckedVolume = currentVol; // starting point for fade
}
}
private static void BeginFade(RuleState state, FadeDirection dir, int durationMs)
{
state.FadeDir = dir;
state.FadeMs = Math.Max(1, durationMs);
state.FadeStartTime = DateTime.UtcNow;
state.FadeComplete = false;
// Snapshot current volume as fade-start for each session
foreach (var info in state.TrackedSessions.Values)
{
// CurrentDuckedVolume holds the current real volume at fade start
// (already set in CaptureTargetVolumes or previous fade step)
}
}
private void ApplyFadeStep(DuckingRule rule, RuleState state,
Dictionary<string, (AudioSessionControl Session, float Peak)> liveSessions)
{
double elapsed = (DateTime.UtcNow - state.FadeStartTime).TotalMilliseconds;
float t = Math.Clamp((float)(elapsed / state.FadeMs), 0f, 1f);
bool allDone = true;
foreach (var info in state.TrackedSessions.Values)
{
if (!liveSessions.TryGetValue(info.ProcessName, out var live))
{
// Session disappeared — mark as not ducked so it's not restored
info.IsDucked = false;
continue;
}
float targetVol;
float startVol;
if (state.FadeDir == FadeDirection.FadeOut)
{
// Fade from original down to ducked level
float duckedTarget = info.OriginalVolume * (1f - rule.DuckPercent / 100f);
startVol = info.OriginalVolume;
targetVol = duckedTarget;
info.IsDucked = true;
}
else
{
// Fade from current (ducked) back up to original
startVol = info.CurrentDuckedVolume;
targetVol = info.OriginalVolume;
}
float newVol = Lerp(startVol, targetVol, t);
try
{
live.Session.SimpleAudioVolume.Volume = Math.Clamp(newVol, 0f, 1f);
info.CurrentDuckedVolume = newVol;
}
catch { }
if (t < 1f) allDone = false;
}
if (t >= 1f || allDone)
{
state.FadeComplete = true;
if (state.FadeDir == FadeDirection.FadeIn)
{
// Fully restored — clear tracking so fresh capture next time
foreach (var info in state.TrackedSessions.Values)
info.IsDucked = false;
}
}
}
/// <summary>
/// Enumerate all non-system audio sessions on the default render endpoint.
/// Returns processName (lowercase) -> (session, peak level).
/// </summary>
private Dictionary<string, (AudioSessionControl Session, float Peak)> EnumerateSessions()
{
var result = new Dictionary<string, (AudioSessionControl, float)>();
var seenPids = new HashSet<int>();
var device = _enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
using var dev = device;
var mgr = dev.AudioSessionManager;
var sessions = mgr.Sessions;
for (int i = 0; i < sessions.Count; i++)
{
var s = sessions[i];
bool stored = false;
try
{
var pid = (int)s.GetProcessID;
if (pid == 0) continue; // skip system sounds
seenPids.Add(pid);
// Resolve names through the cache — GetProcessById snapshots the
// whole process table, so only hit it for PIDs we haven't seen
if (!_pidNameCache.TryGetValue(pid, out var name))
{
using var proc = System.Diagnostics.Process.GetProcessById(pid);
name = proc.ProcessName.ToLowerInvariant();
_pidNameCache[pid] = name;
}
float peak = 0f;
try { peak = s.AudioMeterInformation.MasterPeakValue; } catch { }
if (!result.ContainsKey(name))
{
result[name] = (s, peak);
stored = true;
}
}
catch { }
finally
{
// Wrapper not kept (system sounds, dead process, duplicate name) —
// dispose it rather than leaving it for the finalizer queue
if (!stored)
try { s.Dispose(); } catch { }
}
}
// Evict cached names for PIDs that no longer have sessions
if (_pidNameCache.Count > 0)
{
List<int>? stale = null;
foreach (var p in _pidNameCache.Keys)
if (!seenPids.Contains(p)) (stale ??= new List<int>()).Add(p);
if (stale != null)
foreach (var p in stale) _pidNameCache.Remove(p);
}
return result;
}
private static float Lerp(float a, float b, float t) => a + (b - a) * t;
public void Dispose()
{
lock (_lock)
{
_disposed = true;
}
_enumerator.Dispose();
}
}