-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathProcessLoopbackCaptureService.cs
More file actions
248 lines (219 loc) · 10.1 KB
/
Copy pathProcessLoopbackCaptureService.cs
File metadata and controls
248 lines (219 loc) · 10.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
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
using System.Buffers;
using System.Runtime.InteropServices;
using NAudio.CoreAudioApi;
using NAudio.CoreAudioApi.Interfaces;
using NAudio.Wave;
using IAudioCaptureClient = MultiAudioRouter.Core.ProcessLoopbackInterop.IAudioCaptureClient;
using AudioClientBufferFlags = MultiAudioRouter.Core.ProcessLoopbackInterop.AudioClientBufferFlags;
namespace MultiAudioRouter.Core;
/// <summary>
/// 指定 PID のプロセス(ツリー)を対象に WASAPI Process Loopback でキャプチャする。
/// Windows 10 build 20348 / Windows 11 以降が必要。
/// </summary>
public sealed class ProcessLoopbackCaptureService : ICaptureSource
{
private readonly uint _pid;
private readonly bool _excludeMode;
private readonly WaveFormat _format;
private IAudioClient? _audioClient;
private IAudioCaptureClient? _captureClient;
private Thread? _captureThread;
private CancellationTokenSource? _cts;
private ManualResetEventSlim? _bufferReady; // GC で SafeWaitHandle が閉じられないようフィールド保持
private bool _disposed;
private bool _running;
// 診断用カウンタ (各 5s スナップショットで Interlocked.Exchange で吸い出す)
public long SilentPacketCount;
public long DiscontinuityPacketCount;
public long PaddedBytes;
public long RealBytes;
public WaveFormat? WaveFormat => _format;
public event EventHandler<WaveInEventArgs>? DataAvailable;
public event EventHandler<StoppedEventArgs>? RecordingStopped;
/// <summary>
/// PID とツリーモード (false=Include / true=Exclude) を指定。
/// format は明示指定推奨 (process loopback はデバイスに紐づかないので MixFormat 取得不可)。
/// 既定: 48kHz / 2ch / IEEE Float 32bit。
/// </summary>
public ProcessLoopbackCaptureService(uint pid, bool excludeMode = false, WaveFormat? format = null)
{
_pid = pid;
_excludeMode = excludeMode;
_format = format ?? WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
}
public void Start()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_running) throw new InvalidOperationException("既にキャプチャ中です。");
_bufferReady = new ManualResetEventSlim(false);
_cts = new CancellationTokenSource();
// STA UI スレッドから呼ばれた場合の COM apartment 不整合を避けるため、
// 全ての COM 操作 (Activate / Initialize / GetService / Start / drain) を
// MTA な専用スレッド上で完結させる。
var ready = new ManualResetEventSlim(false);
Exception? initFailure = null;
var token = _cts.Token;
_captureThread = new Thread(() =>
{
try
{
_audioClient = ProcessLoopbackInterop.ActivateProcessLoopbackAudioClient(_pid, _excludeMode);
long hnsBufferDuration = 100 * 10000; // 100ms
var hr = _audioClient.Initialize(
AudioClientShareMode.Shared,
AudioClientStreamFlags.Loopback | AudioClientStreamFlags.EventCallback,
hnsBufferDuration,
hnsBufferDuration,
_format,
Guid.Empty);
if (hr != 0)
throw Marshal.GetExceptionForHR(hr) ?? new InvalidOperationException($"IAudioClient.Initialize HRESULT 0x{hr:X8}");
var hEvent = _bufferReady!.WaitHandle.SafeWaitHandle.DangerousGetHandle();
_audioClient.SetEventHandle(hEvent);
var captureGuid = ProcessLoopbackInterop.IID_IAudioCaptureClient;
var serviceHr = _audioClient.GetService(captureGuid, out var captureObj);
if (serviceHr != 0)
throw Marshal.GetExceptionForHR(serviceHr) ?? new InvalidOperationException($"GetService HRESULT 0x{serviceHr:X8}");
_captureClient = (IAudioCaptureClient)captureObj;
_audioClient.Start();
_running = true;
}
catch (Exception ex)
{
initFailure = ex;
ready.Set();
return;
}
ready.Set();
CaptureLoop(_bufferReady, token);
})
{
IsBackground = true,
Name = "ProcessLoopbackCapture",
};
_captureThread.SetApartmentState(ApartmentState.MTA);
_captureThread.Start();
// 初期化完了を待つ (失敗なら例外を呼び出し元に伝播)
ready.Wait(TimeSpan.FromSeconds(10));
if (initFailure is not null)
throw initFailure;
}
private void CaptureLoop(ManualResetEventSlim? bufferReady, CancellationToken token)
{
Exception? failure = null;
try
{
var bytesPerFrame = _format.Channels * (_format.BitsPerSample / 8);
var bytesPerSec = (long)_format.AverageBytesPerSecond;
var startUtc = DateTime.UtcNow;
long bytesEmitted = 0;
// 配信が壁時計に対して遅れていたら silence を埋めて連続化させる閾値。
// padding は data loss の代償行為で本物の音抜けを増やすため、極稀ケースのみ拾う。
// (実質無効化: 5 秒以上の途絶でのみ発動)
var padThreshold = bytesPerSec * 5000 / 1000; // 5000ms
while (!token.IsCancellationRequested)
{
// EVENTCALLBACK が機能していない場合の保険として、Wait が timeout でも
// パケットチェックは行う。10ms 周期で頻繁に drain (100ms buffer に対し十分な余裕)。
bufferReady?.Wait(10, token);
bufferReady?.Reset();
// 全パケットを drain
while (true)
{
var hr = _captureClient!.GetNextPacketSize(out var packetFrames);
if (hr < 0) throw Marshal.GetExceptionForHR(hr) ?? new InvalidOperationException($"GetNextPacketSize HRESULT 0x{hr:X8}");
if (packetFrames == 0) break;
hr = _captureClient.GetBuffer(out var bufPtr, out var framesAvailable, out var flags, out _, out _);
if (hr < 0) throw Marshal.GetExceptionForHR(hr) ?? new InvalidOperationException($"GetBuffer HRESULT 0x{hr:X8}");
if (framesAvailable > 0)
{
var byteCount = framesAvailable * bytesPerFrame;
var isSilent = (flags & AudioClientBufferFlags.Silent) != 0;
if (isSilent) Interlocked.Increment(ref SilentPacketCount);
if ((flags & AudioClientBufferFlags.DataDiscontinuity) != 0)
Interlocked.Increment(ref DiscontinuityPacketCount);
var managed = ArrayPool<byte>.Shared.Rent(byteCount);
try
{
if (isSilent)
Array.Clear(managed, 0, byteCount);
else
Marshal.Copy(bufPtr, managed, 0, byteCount);
DataAvailable?.Invoke(this, new WaveInEventArgs(managed, byteCount));
bytesEmitted += byteCount;
Interlocked.Add(ref RealBytes, byteCount);
}
finally
{
ArrayPool<byte>.Shared.Return(managed);
}
}
_captureClient.ReleaseBuffer(framesAvailable);
}
// 壁時計ベースの silence パディング:
// 配信が長時間途絶えた場合のみ発動 (300ms 以上の本物の沈黙)。
// 細かいクロック差で発火させると padding 由来の無音が混ざって逆に音抜けに聞こえる。
var elapsedMs = (DateTime.UtcNow - startUtc).TotalMilliseconds;
var expectedBytes = (long)(elapsedMs * bytesPerSec / 1000.0);
var deficit = expectedBytes - bytesEmitted;
if (deficit > padThreshold)
{
var padBytes = (int)Math.Min(deficit, bytesPerSec / 10);
padBytes -= padBytes % bytesPerFrame;
if (padBytes > 0)
{
var pad = ArrayPool<byte>.Shared.Rent(padBytes);
try
{
Array.Clear(pad, 0, padBytes);
DataAvailable?.Invoke(this, new WaveInEventArgs(pad, padBytes));
bytesEmitted += padBytes;
Interlocked.Add(ref PaddedBytes, padBytes);
}
finally
{
ArrayPool<byte>.Shared.Return(pad);
}
}
}
}
}
catch (OperationCanceledException) { /* 正常停止 */ }
catch (Exception ex)
{
failure = ex;
}
finally
{
try { _audioClient?.Stop(); } catch { }
RecordingStopped?.Invoke(this, new StoppedEventArgs(failure));
}
}
public void Stop()
{
if (!_running) return;
_running = false;
_cts?.Cancel();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Stop();
try { _captureThread?.Join(TimeSpan.FromSeconds(2)); } catch { }
if (_audioClient is not null)
{
try { Marshal.ReleaseComObject(_audioClient); } catch { }
_audioClient = null;
}
if (_captureClient is not null)
{
try { Marshal.ReleaseComObject(_captureClient); } catch { }
_captureClient = null;
}
_bufferReady?.Dispose();
_bufferReady = null;
_cts?.Dispose();
_cts = null;
}
}