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.
Files changed (41) hide show
  1. capturekit/__init__.py +164 -0
  2. capturekit/_backends/__init__.py +0 -0
  3. capturekit/_backends/base.py +89 -0
  4. capturekit/_backends/linux/__init__.py +1 -0
  5. capturekit/_backends/linux/_xlib.py +655 -0
  6. capturekit/_backends/linux/api.py +160 -0
  7. capturekit/_backends/linux/pulse.py +464 -0
  8. capturekit/_backends/linux/x11.py +720 -0
  9. capturekit/_backends/windows/__init__.py +0 -0
  10. capturekit/_backends/windows/_com.py +350 -0
  11. capturekit/_backends/windows/_d3d11.py +307 -0
  12. capturekit/_backends/windows/_win32.py +420 -0
  13. capturekit/_backends/windows/_winrt.py +162 -0
  14. capturekit/_backends/windows/api.py +214 -0
  15. capturekit/_backends/windows/cursor.py +66 -0
  16. capturekit/_backends/windows/dxgi_duplication.py +310 -0
  17. capturekit/_backends/windows/wasapi.py +570 -0
  18. capturekit/_backends/windows/wgc.py +398 -0
  19. capturekit/_pipeline/__init__.py +1 -0
  20. capturekit/_pipeline/aligner.py +177 -0
  21. capturekit/_pipeline/clock.py +47 -0
  22. capturekit/_pipeline/frame_queue.py +120 -0
  23. capturekit/_pipeline/mixer.py +80 -0
  24. capturekit/_pipeline/timeline.py +133 -0
  25. capturekit/_platform.py +24 -0
  26. capturekit/_version.py +1 -0
  27. capturekit/_worker.py +100 -0
  28. capturekit/capabilities.py +79 -0
  29. capturekit/capture.py +141 -0
  30. capturekit/config.py +85 -0
  31. capturekit/encoding/__init__.py +5 -0
  32. capturekit/encoding/ffmpeg.py +356 -0
  33. capturekit/encoding/pipes.py +212 -0
  34. capturekit/errors.py +109 -0
  35. capturekit/py.typed +0 -0
  36. capturekit/recording.py +940 -0
  37. capturekit/sources.py +124 -0
  38. capturekit/types.py +211 -0
  39. capturekit-0.1.0.dist-info/METADATA +112 -0
  40. capturekit-0.1.0.dist-info/RECORD +41 -0
  41. 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})")