capturekit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- capturekit/__init__.py +164 -0
- capturekit/_backends/__init__.py +0 -0
- capturekit/_backends/base.py +89 -0
- capturekit/_backends/linux/__init__.py +1 -0
- capturekit/_backends/linux/_xlib.py +655 -0
- capturekit/_backends/linux/api.py +160 -0
- capturekit/_backends/linux/pulse.py +464 -0
- capturekit/_backends/linux/x11.py +720 -0
- capturekit/_backends/windows/__init__.py +0 -0
- capturekit/_backends/windows/_com.py +350 -0
- capturekit/_backends/windows/_d3d11.py +307 -0
- capturekit/_backends/windows/_win32.py +420 -0
- capturekit/_backends/windows/_winrt.py +162 -0
- capturekit/_backends/windows/api.py +214 -0
- capturekit/_backends/windows/cursor.py +66 -0
- capturekit/_backends/windows/dxgi_duplication.py +310 -0
- capturekit/_backends/windows/wasapi.py +570 -0
- capturekit/_backends/windows/wgc.py +398 -0
- capturekit/_pipeline/__init__.py +1 -0
- capturekit/_pipeline/aligner.py +177 -0
- capturekit/_pipeline/clock.py +47 -0
- capturekit/_pipeline/frame_queue.py +120 -0
- capturekit/_pipeline/mixer.py +80 -0
- capturekit/_pipeline/timeline.py +133 -0
- capturekit/_platform.py +24 -0
- capturekit/_version.py +1 -0
- capturekit/_worker.py +100 -0
- capturekit/capabilities.py +79 -0
- capturekit/capture.py +141 -0
- capturekit/config.py +85 -0
- capturekit/encoding/__init__.py +5 -0
- capturekit/encoding/ffmpeg.py +356 -0
- capturekit/encoding/pipes.py +212 -0
- capturekit/errors.py +109 -0
- capturekit/py.typed +0 -0
- capturekit/recording.py +940 -0
- capturekit/sources.py +124 -0
- capturekit/types.py +211 -0
- capturekit-0.1.0.dist-info/METADATA +112 -0
- capturekit-0.1.0.dist-info/RECORD +41 -0
- capturekit-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""WASAPI capture: microphones, endpoint loopback and process loopback.
|
|
2
|
+
|
|
3
|
+
All streams are opened in shared mode as float32 / 48 kHz / stereo with
|
|
4
|
+
``AUTOCONVERTPCM`` so the audio engine performs format conversion and
|
|
5
|
+
resampling (no Python resampling except drift correction).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ctypes
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from ctypes import POINTER, Structure, byref, c_int, c_long, c_uint, c_uint32, c_uint64, c_void_p
|
|
16
|
+
from ctypes import wintypes as wt
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from ..._pipeline.clock import NS_PER_S, hns_to_ns, now_ns
|
|
21
|
+
from ...errors import (
|
|
22
|
+
AudioDeviceDisconnectedError,
|
|
23
|
+
AudioDeviceError,
|
|
24
|
+
CapabilityUnavailableError,
|
|
25
|
+
NativeError,
|
|
26
|
+
TargetNotFoundError,
|
|
27
|
+
)
|
|
28
|
+
from ...types import AudioDevice, AudioDeviceKind, AudioSession
|
|
29
|
+
from ..base import AudioCallback, AudioPacket
|
|
30
|
+
from . import _win32
|
|
31
|
+
from ._com import (
|
|
32
|
+
GUID,
|
|
33
|
+
ComApartment,
|
|
34
|
+
ComObject,
|
|
35
|
+
ComPtr,
|
|
36
|
+
check_hr,
|
|
37
|
+
co_create_instance,
|
|
38
|
+
co_task_mem_free,
|
|
39
|
+
hr_u32,
|
|
40
|
+
prop_variant_clear,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
log = logging.getLogger("capturekit.windows.wasapi")
|
|
44
|
+
|
|
45
|
+
CLSID_MMDeviceEnumerator = GUID.from_str("BCDE0395-E52F-467C-8E3D-C4579291692E")
|
|
46
|
+
IID_IMMDeviceEnumerator = GUID.from_str("A95664D2-9614-4F35-A746-DE8DB63617E6")
|
|
47
|
+
IID_IAudioClient = GUID.from_str("1CB9AD4C-DBFA-4c32-B178-C2F568A703B2")
|
|
48
|
+
IID_IAudioCaptureClient = GUID.from_str("C8ADBD64-E71E-48a0-A4DE-185C395CD317")
|
|
49
|
+
IID_IAudioSessionManager2 = GUID.from_str("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F")
|
|
50
|
+
IID_IAudioSessionControl2 = GUID.from_str("bfb7ff88-7239-4fc9-8fa2-07c950be9c6d")
|
|
51
|
+
IID_IActivateAudioInterfaceCompletionHandler = GUID.from_str("41D949AB-9862-444A-80F6-C261334DA5EB")
|
|
52
|
+
PKEY_Device_FriendlyName_fmtid = GUID.from_str("a45c254e-df1c-4efd-8020-67d146a850e0")
|
|
53
|
+
|
|
54
|
+
eRender, eCapture = 0, 1
|
|
55
|
+
eConsole = 0
|
|
56
|
+
DEVICE_STATE_ACTIVE = 1
|
|
57
|
+
CLSCTX_ALL = 0x17
|
|
58
|
+
STGM_READ = 0
|
|
59
|
+
AUDCLNT_SHAREMODE_SHARED = 0
|
|
60
|
+
AUDCLNT_STREAMFLAGS_LOOPBACK = 0x00020000
|
|
61
|
+
AUDCLNT_STREAMFLAGS_EVENTCALLBACK = 0x00040000
|
|
62
|
+
AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM = 0x80000000
|
|
63
|
+
AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY = 0x08000000
|
|
64
|
+
AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY = 0x1
|
|
65
|
+
AUDCLNT_BUFFERFLAGS_SILENT = 0x2
|
|
66
|
+
AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR = 0x4
|
|
67
|
+
AUDCLNT_E_DEVICE_INVALIDATED = -2004287484 # 0x88890004
|
|
68
|
+
AUDCLNT_E_SERVICE_NOT_RUNNING = -2004287472 # 0x88890010
|
|
69
|
+
E_NOTFOUND = -2147023728 # 0x80070490
|
|
70
|
+
WAVE_FORMAT_IEEE_FLOAT = 3
|
|
71
|
+
VT_LPWSTR = 31
|
|
72
|
+
VT_BLOB = 65
|
|
73
|
+
VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK = "VAD\\Process_Loopback"
|
|
74
|
+
AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK = 1
|
|
75
|
+
PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE = 0
|
|
76
|
+
PROCESS_LOOPBACK_MIN_BUILD = 20348
|
|
77
|
+
BUFFER_HNS = 2_000_000 # 200 ms engine buffer
|
|
78
|
+
|
|
79
|
+
# IAudioClient
|
|
80
|
+
_AC_Initialize = 3
|
|
81
|
+
_AC_Start = 10
|
|
82
|
+
_AC_Stop = 11
|
|
83
|
+
_AC_SetEventHandle = 13
|
|
84
|
+
_AC_GetService = 14
|
|
85
|
+
# IAudioCaptureClient
|
|
86
|
+
_CC_GetBuffer = 3
|
|
87
|
+
_CC_ReleaseBuffer = 4
|
|
88
|
+
_CC_GetNextPacketSize = 5
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class WAVEFORMATEX(Structure):
|
|
92
|
+
_pack_ = 1
|
|
93
|
+
_fields_ = [
|
|
94
|
+
("wFormatTag", wt.WORD),
|
|
95
|
+
("nChannels", wt.WORD),
|
|
96
|
+
("nSamplesPerSec", wt.DWORD),
|
|
97
|
+
("nAvgBytesPerSec", wt.DWORD),
|
|
98
|
+
("nBlockAlign", wt.WORD),
|
|
99
|
+
("wBitsPerSample", wt.WORD),
|
|
100
|
+
("cbSize", wt.WORD),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PROPERTYKEY(Structure):
|
|
105
|
+
_fields_ = [("fmtid", GUID), ("pid", wt.DWORD)]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class PROPVARIANT(Structure):
|
|
109
|
+
_fields_ = [
|
|
110
|
+
("vt", wt.WORD),
|
|
111
|
+
("r1", wt.WORD),
|
|
112
|
+
("r2", wt.WORD),
|
|
113
|
+
("r3", wt.WORD),
|
|
114
|
+
("val", c_void_p), # pwszVal / blob cbSize (low 4 bytes)
|
|
115
|
+
("ptr2", c_void_p), # blob pBlobData
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class AUDIOCLIENT_ACTIVATION_PARAMS(Structure):
|
|
120
|
+
_fields_ = [
|
|
121
|
+
("ActivationType", c_int),
|
|
122
|
+
("TargetProcessId", wt.DWORD),
|
|
123
|
+
("ProcessLoopbackMode", c_int),
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
128
|
+
_k32.CreateEventW.argtypes = (c_void_p, wt.BOOL, wt.BOOL, wt.LPCWSTR)
|
|
129
|
+
_k32.CreateEventW.restype = wt.HANDLE
|
|
130
|
+
_k32.WaitForSingleObject.argtypes = (wt.HANDLE, wt.DWORD)
|
|
131
|
+
_k32.WaitForSingleObject.restype = wt.DWORD
|
|
132
|
+
_k32.CloseHandle.argtypes = (wt.HANDLE,)
|
|
133
|
+
_mmdevapi = ctypes.WinDLL("Mmdevapi")
|
|
134
|
+
_mmdevapi.ActivateAudioInterfaceAsync.argtypes = (
|
|
135
|
+
wt.LPCWSTR,
|
|
136
|
+
c_void_p,
|
|
137
|
+
c_void_p,
|
|
138
|
+
c_void_p,
|
|
139
|
+
POINTER(c_void_p),
|
|
140
|
+
)
|
|
141
|
+
_mmdevapi.ActivateAudioInterfaceAsync.restype = c_long
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def windows_build() -> int:
|
|
145
|
+
return int(sys.getwindowsversion().build)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def process_loopback_supported() -> bool:
|
|
149
|
+
return windows_build() >= PROCESS_LOOPBACK_MIN_BUILD
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def float_format(rate: int, channels: int) -> WAVEFORMATEX:
|
|
153
|
+
return WAVEFORMATEX(
|
|
154
|
+
WAVE_FORMAT_IEEE_FLOAT, channels, rate, rate * channels * 4, channels * 4, 32, 0
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# --- enumeration -------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _enumerator() -> ComPtr:
|
|
162
|
+
return co_create_instance(
|
|
163
|
+
CLSID_MMDeviceEnumerator, IID_IMMDeviceEnumerator, "IMMDeviceEnumerator"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _device_id(dev: ComPtr) -> str:
|
|
168
|
+
out = c_void_p()
|
|
169
|
+
dev.hcall(5, (c_void_p,), byref(out), what="IMMDevice::GetId")
|
|
170
|
+
try:
|
|
171
|
+
return ctypes.wstring_at(out.value) if out.value else ""
|
|
172
|
+
finally:
|
|
173
|
+
co_task_mem_free(out.value)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _friendly_name(dev: ComPtr) -> str:
|
|
177
|
+
store = dev.out_ptr(4, (c_uint,), STGM_READ, what="OpenPropertyStore", name="IPropertyStore")
|
|
178
|
+
with store:
|
|
179
|
+
key = PROPERTYKEY(PKEY_Device_FriendlyName_fmtid, 14)
|
|
180
|
+
pv = PROPVARIANT()
|
|
181
|
+
store.hcall(5, (c_void_p, c_void_p), byref(key), byref(pv), what="GetValue")
|
|
182
|
+
try:
|
|
183
|
+
return ctypes.wstring_at(pv.val) if pv.vt == VT_LPWSTR and pv.val else ""
|
|
184
|
+
finally:
|
|
185
|
+
prop_variant_clear(pv)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _default_id(enum: ComPtr, flow: int) -> str | None:
|
|
189
|
+
out = c_void_p()
|
|
190
|
+
hr = enum.call(4, c_long, (c_int, c_int, c_void_p), flow, eConsole, byref(out))
|
|
191
|
+
if hr == E_NOTFOUND or not out.value:
|
|
192
|
+
return None
|
|
193
|
+
check_hr(hr, "GetDefaultAudioEndpoint")
|
|
194
|
+
with ComPtr(out.value, "IMMDevice") as dev:
|
|
195
|
+
return _device_id(dev)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _devices(enum: ComPtr, flow: int) -> list[ComPtr]:
|
|
199
|
+
coll = enum.out_ptr(
|
|
200
|
+
3,
|
|
201
|
+
(c_int, c_uint),
|
|
202
|
+
flow,
|
|
203
|
+
DEVICE_STATE_ACTIVE,
|
|
204
|
+
what="EnumAudioEndpoints",
|
|
205
|
+
name="IMMDeviceCollection",
|
|
206
|
+
)
|
|
207
|
+
with coll:
|
|
208
|
+
count = c_uint()
|
|
209
|
+
coll.hcall(3, (c_void_p,), byref(count), what="GetCount")
|
|
210
|
+
return [
|
|
211
|
+
coll.out_ptr(4, (c_uint,), i, what="Item", name="IMMDevice") for i in range(count.value)
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def list_devices() -> list[AudioDevice]:
|
|
216
|
+
result: list[AudioDevice] = []
|
|
217
|
+
with ComApartment(), _enumerator() as enum:
|
|
218
|
+
for flow, kind in ((eCapture, AudioDeviceKind.INPUT), (eRender, AudioDeviceKind.OUTPUT)):
|
|
219
|
+
default = _default_id(enum, flow)
|
|
220
|
+
for dev in _devices(enum, flow):
|
|
221
|
+
with dev:
|
|
222
|
+
did = _device_id(dev)
|
|
223
|
+
result.append(
|
|
224
|
+
AudioDevice(
|
|
225
|
+
did, _friendly_name(dev), kind, did == default, backend="wasapi"
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
return result
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def list_sessions() -> list[AudioSession]:
|
|
232
|
+
"""Audio sessions on all active render endpoints with their owning PIDs."""
|
|
233
|
+
sessions: list[AudioSession] = []
|
|
234
|
+
with ComApartment(), _enumerator() as enum:
|
|
235
|
+
for dev in _devices(enum, eRender):
|
|
236
|
+
with dev:
|
|
237
|
+
did = _device_id(dev)
|
|
238
|
+
mgr = dev.out_ptr(
|
|
239
|
+
3,
|
|
240
|
+
(c_void_p, c_uint, c_void_p),
|
|
241
|
+
byref(IID_IAudioSessionManager2),
|
|
242
|
+
CLSCTX_ALL,
|
|
243
|
+
None,
|
|
244
|
+
what="Activate(IAudioSessionManager2)",
|
|
245
|
+
name="IAudioSessionManager2",
|
|
246
|
+
)
|
|
247
|
+
with mgr:
|
|
248
|
+
sen = mgr.out_ptr(
|
|
249
|
+
5, (), what="GetSessionEnumerator", name="IAudioSessionEnumerator"
|
|
250
|
+
)
|
|
251
|
+
with sen:
|
|
252
|
+
count = c_int()
|
|
253
|
+
sen.hcall(3, (c_void_p,), byref(count), what="GetCount")
|
|
254
|
+
for i in range(count.value):
|
|
255
|
+
ctl = sen.out_ptr(
|
|
256
|
+
4, (c_int,), i, what="GetSession", name="IAudioSessionControl"
|
|
257
|
+
)
|
|
258
|
+
with ctl:
|
|
259
|
+
s = _session_info(ctl, did)
|
|
260
|
+
if s is not None:
|
|
261
|
+
sessions.append(s)
|
|
262
|
+
return sessions
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _session_info(ctl: ComPtr, device_id: str) -> AudioSession | None:
|
|
266
|
+
ctl2 = ctl.try_query(IID_IAudioSessionControl2, "IAudioSessionControl2")
|
|
267
|
+
if ctl2 is None:
|
|
268
|
+
return None
|
|
269
|
+
with ctl2:
|
|
270
|
+
pid = wt.DWORD()
|
|
271
|
+
ctl2.call(14, c_long, (c_void_p,), byref(pid)) # GetProcessId (S_FALSE: multi-process)
|
|
272
|
+
state = c_int()
|
|
273
|
+
ctl2.hcall(3, (c_void_p,), byref(state), what="GetState")
|
|
274
|
+
name_ptr = c_void_p()
|
|
275
|
+
ctl2.call(4, c_long, (c_void_p,), byref(name_ptr))
|
|
276
|
+
display = ctypes.wstring_at(name_ptr.value) if name_ptr.value else ""
|
|
277
|
+
co_task_mem_free(name_ptr.value)
|
|
278
|
+
is_system = ctl2.call(15, c_long, ()) == 0 # IsSystemSoundsSession: S_OK = yes
|
|
279
|
+
p = int(pid.value) or None
|
|
280
|
+
pname = _win32.process_name(p)
|
|
281
|
+
return AudioSession(
|
|
282
|
+
pid=p,
|
|
283
|
+
process_name=pname,
|
|
284
|
+
display_name=display or ("System sounds" if is_system else (pname or "")),
|
|
285
|
+
device_id=device_id,
|
|
286
|
+
is_active=state.value == 1,
|
|
287
|
+
backend="wasapi",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# --- capture -----------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class _CompletionHandler(ComObject):
|
|
295
|
+
_methods_ = (("ActivateCompleted", c_long, (c_void_p,)),)
|
|
296
|
+
|
|
297
|
+
def __init__(self) -> None:
|
|
298
|
+
super().__init__((IID_IActivateAudioInterfaceCompletionHandler,))
|
|
299
|
+
self.done = threading.Event()
|
|
300
|
+
|
|
301
|
+
def ActivateCompleted(self, _op: int) -> None:
|
|
302
|
+
self.done.set()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _activate_process_loopback(pid: int, include_tree: bool) -> ComPtr:
|
|
306
|
+
params = AUDIOCLIENT_ACTIVATION_PARAMS(
|
|
307
|
+
AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK,
|
|
308
|
+
pid,
|
|
309
|
+
PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE if include_tree else 0,
|
|
310
|
+
)
|
|
311
|
+
if not include_tree:
|
|
312
|
+
# Mode 1 (EXCLUDE_TARGET_PROCESS_TREE) means "everything except" —
|
|
313
|
+
# it is NOT "target without children", so it is never used here.
|
|
314
|
+
raise CapabilityUnavailableError(
|
|
315
|
+
"WASAPI process loopback always includes child processes; "
|
|
316
|
+
"include_children=False is not available on Windows"
|
|
317
|
+
)
|
|
318
|
+
pv = PROPVARIANT()
|
|
319
|
+
pv.vt = VT_BLOB
|
|
320
|
+
pv.val = ctypes.sizeof(params)
|
|
321
|
+
pv.ptr2 = ctypes.addressof(params)
|
|
322
|
+
handler = _CompletionHandler()
|
|
323
|
+
op = c_void_p()
|
|
324
|
+
try:
|
|
325
|
+
hr = _mmdevapi.ActivateAudioInterfaceAsync(
|
|
326
|
+
VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK, byref(IID_IAudioClient), byref(pv),
|
|
327
|
+
c_void_p(handler.ptr), byref(op),
|
|
328
|
+
) # fmt: skip
|
|
329
|
+
check_hr(hr, "ActivateAudioInterfaceAsync(process loopback)")
|
|
330
|
+
with ComPtr(op.value, "IActivateAudioInterfaceAsyncOperation") as operation:
|
|
331
|
+
if not handler.done.wait(10):
|
|
332
|
+
raise AudioDeviceError("process loopback activation timed out")
|
|
333
|
+
act_hr = c_long()
|
|
334
|
+
client = c_void_p()
|
|
335
|
+
operation.hcall(
|
|
336
|
+
3, (c_void_p, c_void_p), byref(act_hr), byref(client), what="GetActivateResult"
|
|
337
|
+
)
|
|
338
|
+
check_hr(act_hr.value, f"process loopback activation for PID {pid}")
|
|
339
|
+
return ComPtr(client.value, "IAudioClient(process loopback)")
|
|
340
|
+
finally:
|
|
341
|
+
handler.release()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class WasapiCapture:
|
|
345
|
+
"""One WASAPI capture stream running on its own MTA thread."""
|
|
346
|
+
|
|
347
|
+
def __init__(
|
|
348
|
+
self,
|
|
349
|
+
kind: str,
|
|
350
|
+
*,
|
|
351
|
+
device_id: str | None = None,
|
|
352
|
+
pid: int | None = None,
|
|
353
|
+
include_tree: bool = True,
|
|
354
|
+
sample_rate: int = 48_000,
|
|
355
|
+
channels: int = 2,
|
|
356
|
+
name: str = "",
|
|
357
|
+
) -> None:
|
|
358
|
+
if kind not in ("microphone", "system", "process"):
|
|
359
|
+
raise ValueError(kind)
|
|
360
|
+
self.kind = kind
|
|
361
|
+
self.device_id = device_id
|
|
362
|
+
self.pid = pid
|
|
363
|
+
self.include_tree = include_tree
|
|
364
|
+
self.sample_rate = sample_rate
|
|
365
|
+
self.channels = channels
|
|
366
|
+
self.name = name or kind
|
|
367
|
+
self._thread: threading.Thread | None = None
|
|
368
|
+
self._stop = threading.Event()
|
|
369
|
+
self._ready = threading.Event()
|
|
370
|
+
self._init_error: BaseException | None = None
|
|
371
|
+
self.device_name = ""
|
|
372
|
+
self.packets = 0
|
|
373
|
+
self.glitches = 0
|
|
374
|
+
|
|
375
|
+
def _validate(self) -> None:
|
|
376
|
+
if self.kind == "process":
|
|
377
|
+
if not process_loopback_supported():
|
|
378
|
+
raise CapabilityUnavailableError(
|
|
379
|
+
f"process audio capture needs Windows build >= {PROCESS_LOOPBACK_MIN_BUILD} "
|
|
380
|
+
f"(this is {windows_build()}); refusing to fall back to system audio"
|
|
381
|
+
)
|
|
382
|
+
if not self.pid or not _pid_exists(self.pid):
|
|
383
|
+
raise TargetNotFoundError(f"process {self.pid} does not exist")
|
|
384
|
+
|
|
385
|
+
def start(self, on_packet: AudioCallback, on_error: Callable[[BaseException], None]) -> None:
|
|
386
|
+
self._validate()
|
|
387
|
+
self._thread = threading.Thread(
|
|
388
|
+
target=self._run, args=(on_packet, on_error), name=f"capturekit-audio-{self.name}",
|
|
389
|
+
daemon=True,
|
|
390
|
+
) # fmt: skip
|
|
391
|
+
self._thread.start()
|
|
392
|
+
self._ready.wait(15)
|
|
393
|
+
if self._init_error is not None:
|
|
394
|
+
self._thread.join(5)
|
|
395
|
+
raise self._init_error
|
|
396
|
+
if not self._ready.is_set():
|
|
397
|
+
self._stop.set()
|
|
398
|
+
raise AudioDeviceError(f"audio source {self.name!r} did not start")
|
|
399
|
+
|
|
400
|
+
def stop(self) -> None:
|
|
401
|
+
self._stop.set()
|
|
402
|
+
if self._thread is not None:
|
|
403
|
+
self._thread.join(5)
|
|
404
|
+
if self._thread.is_alive():
|
|
405
|
+
log.warning("audio thread %r did not stop within 5 s", self.name)
|
|
406
|
+
return # keep the reference; it is a daemon thread
|
|
407
|
+
self._thread = None
|
|
408
|
+
|
|
409
|
+
# -- thread ------------------------------------------------------------
|
|
410
|
+
def _open_client(self) -> ComPtr:
|
|
411
|
+
if self.kind == "process":
|
|
412
|
+
assert self.pid is not None
|
|
413
|
+
self.device_name = f"process {self.pid} ({_win32.process_name(self.pid)})"
|
|
414
|
+
return _activate_process_loopback(self.pid, self.include_tree)
|
|
415
|
+
flow = eCapture if self.kind == "microphone" else eRender
|
|
416
|
+
with _enumerator() as enum:
|
|
417
|
+
out = c_void_p()
|
|
418
|
+
if self.device_id:
|
|
419
|
+
hr = enum.call(5, c_long, (wt.LPCWSTR, c_void_p), self.device_id, byref(out))
|
|
420
|
+
if hr < 0:
|
|
421
|
+
raise TargetNotFoundError(f"audio device {self.device_id!r} not found")
|
|
422
|
+
else:
|
|
423
|
+
hr = enum.call(4, c_long, (c_int, c_int, c_void_p), flow, eConsole, byref(out))
|
|
424
|
+
if hr == E_NOTFOUND or not out.value:
|
|
425
|
+
raise TargetNotFoundError(
|
|
426
|
+
f"no default {'input' if flow == eCapture else 'output'} audio device"
|
|
427
|
+
)
|
|
428
|
+
check_hr(hr, "GetDefaultAudioEndpoint")
|
|
429
|
+
with ComPtr(out.value, "IMMDevice") as dev:
|
|
430
|
+
self.device_name = _friendly_name(dev)
|
|
431
|
+
return dev.out_ptr(
|
|
432
|
+
3,
|
|
433
|
+
(c_void_p, c_uint, c_void_p),
|
|
434
|
+
byref(IID_IAudioClient),
|
|
435
|
+
CLSCTX_ALL,
|
|
436
|
+
None,
|
|
437
|
+
what="Activate(IAudioClient)",
|
|
438
|
+
name="IAudioClient",
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def _open_stream(self) -> tuple[ComPtr, ComPtr, int]:
|
|
442
|
+
client = self._open_client()
|
|
443
|
+
capture: ComPtr | None = None
|
|
444
|
+
event = None
|
|
445
|
+
try:
|
|
446
|
+
flags = (
|
|
447
|
+
AUDCLNT_STREAMFLAGS_EVENTCALLBACK
|
|
448
|
+
| AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
|
|
449
|
+
| AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY
|
|
450
|
+
)
|
|
451
|
+
if self.kind != "microphone":
|
|
452
|
+
flags |= AUDCLNT_STREAMFLAGS_LOOPBACK
|
|
453
|
+
fmt = float_format(self.sample_rate, self.channels)
|
|
454
|
+
client.hcall(
|
|
455
|
+
_AC_Initialize,
|
|
456
|
+
(c_int, c_uint, ctypes.c_int64, ctypes.c_int64, c_void_p, c_void_p),
|
|
457
|
+
AUDCLNT_SHAREMODE_SHARED,
|
|
458
|
+
flags,
|
|
459
|
+
BUFFER_HNS,
|
|
460
|
+
0,
|
|
461
|
+
byref(fmt),
|
|
462
|
+
None,
|
|
463
|
+
what=f"IAudioClient::Initialize({self.name})",
|
|
464
|
+
)
|
|
465
|
+
event = _k32.CreateEventW(None, False, False, None)
|
|
466
|
+
client.hcall(_AC_SetEventHandle, (wt.HANDLE,), event, what="SetEventHandle")
|
|
467
|
+
capture = client.out_ptr(
|
|
468
|
+
_AC_GetService,
|
|
469
|
+
(c_void_p,),
|
|
470
|
+
byref(IID_IAudioCaptureClient),
|
|
471
|
+
what="GetService(IAudioCaptureClient)",
|
|
472
|
+
name="IAudioCaptureClient",
|
|
473
|
+
)
|
|
474
|
+
client.hcall(_AC_Start, (), what="IAudioClient::Start")
|
|
475
|
+
return client, capture, event
|
|
476
|
+
except BaseException:
|
|
477
|
+
if capture is not None:
|
|
478
|
+
capture.release()
|
|
479
|
+
client.release()
|
|
480
|
+
if event:
|
|
481
|
+
_k32.CloseHandle(event)
|
|
482
|
+
raise
|
|
483
|
+
|
|
484
|
+
def _run(self, on_packet: AudioCallback, on_error: Callable[[BaseException], None]) -> None:
|
|
485
|
+
with ComApartment():
|
|
486
|
+
try:
|
|
487
|
+
client, capture, event = self._open_stream()
|
|
488
|
+
except NativeError as exc:
|
|
489
|
+
self._init_error = _translate(exc, self.name)
|
|
490
|
+
self._ready.set()
|
|
491
|
+
return
|
|
492
|
+
except BaseException as exc:
|
|
493
|
+
self._init_error = exc
|
|
494
|
+
self._ready.set()
|
|
495
|
+
return
|
|
496
|
+
self._ready.set()
|
|
497
|
+
try:
|
|
498
|
+
self._loop(capture, event, on_packet)
|
|
499
|
+
except NativeError as exc:
|
|
500
|
+
on_error(_translate(exc, self.name))
|
|
501
|
+
except BaseException as exc:
|
|
502
|
+
on_error(exc)
|
|
503
|
+
finally:
|
|
504
|
+
client.call(_AC_Stop, c_long, ())
|
|
505
|
+
capture.release()
|
|
506
|
+
client.release()
|
|
507
|
+
_k32.CloseHandle(event)
|
|
508
|
+
|
|
509
|
+
def _loop(self, capture: ComPtr, event: int, on_packet: AudioCallback) -> None:
|
|
510
|
+
data = c_void_p()
|
|
511
|
+
frames = c_uint32()
|
|
512
|
+
flags = wt.DWORD()
|
|
513
|
+
devpos = c_uint64()
|
|
514
|
+
qpcpos = c_uint64()
|
|
515
|
+
size = c_uint32()
|
|
516
|
+
bpf = 4 * self.channels
|
|
517
|
+
get_next = capture.method(_CC_GetNextPacketSize, c_long, (c_void_p,))
|
|
518
|
+
get_buf = capture.method(
|
|
519
|
+
_CC_GetBuffer, c_long, (c_void_p, c_void_p, c_void_p, c_void_p, c_void_p)
|
|
520
|
+
)
|
|
521
|
+
rel_buf = capture.method(_CC_ReleaseBuffer, c_long, (c_uint32,))
|
|
522
|
+
ptr = capture.ptr
|
|
523
|
+
while not self._stop.is_set():
|
|
524
|
+
_k32.WaitForSingleObject(event, 50)
|
|
525
|
+
while True:
|
|
526
|
+
hr = get_next(ptr, byref(size))
|
|
527
|
+
if hr < 0:
|
|
528
|
+
raise NativeError("GetNextPacketSize", hr)
|
|
529
|
+
if size.value == 0:
|
|
530
|
+
break
|
|
531
|
+
hr = get_buf(
|
|
532
|
+
ptr, byref(data), byref(frames), byref(flags), byref(devpos), byref(qpcpos)
|
|
533
|
+
)
|
|
534
|
+
if hr < 0:
|
|
535
|
+
raise NativeError("GetBuffer", hr)
|
|
536
|
+
n = frames.value
|
|
537
|
+
f = flags.value
|
|
538
|
+
if f & AUDCLNT_BUFFERFLAGS_SILENT or not data.value:
|
|
539
|
+
arr = np.zeros((n, self.channels), dtype=np.float32)
|
|
540
|
+
else:
|
|
541
|
+
raw = (ctypes.c_ubyte * (n * bpf)).from_address(data.value)
|
|
542
|
+
arr = np.frombuffer(raw, dtype=np.float32).reshape(n, self.channels).copy()
|
|
543
|
+
rel_buf(ptr, n)
|
|
544
|
+
if qpcpos.value and not f & AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR:
|
|
545
|
+
ts = hns_to_ns(qpcpos.value)
|
|
546
|
+
else:
|
|
547
|
+
ts = now_ns() - n * NS_PER_S // self.sample_rate
|
|
548
|
+
disc = bool(f & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY)
|
|
549
|
+
if disc:
|
|
550
|
+
self.glitches += 1
|
|
551
|
+
self.packets += 1
|
|
552
|
+
if n:
|
|
553
|
+
on_packet(AudioPacket(arr, ts, disc))
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _pid_exists(pid: int) -> bool:
|
|
557
|
+
h = _win32.kernel32.OpenProcess(_win32.PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
558
|
+
if h:
|
|
559
|
+
_win32.kernel32.CloseHandle(h)
|
|
560
|
+
return True
|
|
561
|
+
return ctypes.get_last_error() == 5 # access denied → exists
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _translate(exc: NativeError, name: str) -> BaseException:
|
|
565
|
+
code = exc.code or 0
|
|
566
|
+
if code == AUDCLNT_E_DEVICE_INVALIDATED:
|
|
567
|
+
return AudioDeviceDisconnectedError(f"audio device for {name!r} was removed/invalidated")
|
|
568
|
+
if code == AUDCLNT_E_SERVICE_NOT_RUNNING:
|
|
569
|
+
return AudioDeviceError("Windows Audio service is not running")
|
|
570
|
+
return AudioDeviceError(f"{name}: {exc} (0x{hr_u32(code):08X})")
|