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,398 @@
1
+ """Windows Graphics Capture (WGC) through the raw WinRT ABI.
2
+
3
+ Window capture targets the HWND itself via
4
+ ``IGraphicsCaptureItemInterop::CreateForWindow``: frames contain the
5
+ window's own composition surface, so overlapping windows never appear.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import threading
12
+ from ctypes import Structure, byref, c_int, c_int32, c_int64, c_long, c_ubyte, c_void_p
13
+
14
+ import numpy as np
15
+
16
+ from ..._pipeline.clock import hns_to_ns, now_ns
17
+ from ...errors import (
18
+ AccessDeniedError,
19
+ CapabilityUnavailableError,
20
+ NativeError,
21
+ TargetNotFoundError,
22
+ WindowClosedError,
23
+ )
24
+ from ...types import Monitor, Rect, validate_region
25
+ from ..base import AcquiredFrame, Buffer, VideoBackendInfo, blit_clear, crop_box
26
+ from . import _d3d11, _win32
27
+ from ._com import GUID, ComPtr, check_hr
28
+ from ._winrt import (
29
+ EventDelegate,
30
+ EventRegistration,
31
+ activation_factory,
32
+ close_winrt,
33
+ typed_event_handler_iid,
34
+ )
35
+
36
+ log = logging.getLogger("capturekit.windows.wgc")
37
+
38
+ IID_IGraphicsCaptureItemInterop = GUID.from_str("3628E81B-3CAC-4C60-B7F4-23CE0E0C3356")
39
+ IID_IGraphicsCaptureItem = GUID.from_str("79C3F95B-31F7-4EC2-A464-632EF5D30760")
40
+ IID_IDirect3D11CaptureFramePoolStatics2 = GUID.from_str("589B103F-6BBC-5DF5-A991-02E28B3B66D5")
41
+ IID_IGraphicsCaptureSession2 = GUID.from_str("2C39AE40-7D2E-5044-804E-8B6799D4CF9E")
42
+ IID_IGraphicsCaptureSession3 = GUID.from_str("F2CDD966-22AE-5EA1-9596-3A289344C3BE")
43
+ IID_IGraphicsCaptureSessionStatics = GUID.from_str("2224A540-5974-49AA-B232-0882536F4CB5")
44
+ IID_IDirect3DDxgiInterfaceAccess = GUID.from_str("A9B3D012-3DF2-4EE3-B8D1-8695F457D3C1")
45
+
46
+ FRAME_ARRIVED_IID = typed_event_handler_iid(
47
+ "Windows.Graphics.Capture.Direct3D11CaptureFramePool", "24eb6d22-1975-422e-82e7-780dbd8ddf24"
48
+ )
49
+ ITEM_CLOSED_IID = typed_event_handler_iid(
50
+ "Windows.Graphics.Capture.GraphicsCaptureItem", "79c3f95b-31f7-4ec2-a464-632ef5d30760"
51
+ )
52
+
53
+ # vtable slots (IInspectable occupies 0..5)
54
+ _INTEROP_CreateForWindow = 3
55
+ _INTEROP_CreateForMonitor = 4
56
+ _ITEM_get_Size = 7
57
+ _ITEM_add_Closed = 8
58
+ _ITEM_remove_Closed = 9
59
+ _POOLSTATICS2_CreateFreeThreaded = 6
60
+ _POOL_Recreate = 6
61
+ _POOL_TryGetNextFrame = 7
62
+ _POOL_add_FrameArrived = 8
63
+ _POOL_remove_FrameArrived = 9
64
+ _POOL_CreateCaptureSession = 10
65
+ _SESSION_StartCapture = 6
66
+ _SESSION2_put_IsCursorCaptureEnabled = 7
67
+ _SESSION3_put_IsBorderRequired = 7
68
+ _FRAME_get_Surface = 6
69
+ _FRAME_get_SystemRelativeTime = 7
70
+ _FRAME_get_ContentSize = 8
71
+ _SESSIONSTATICS_IsSupported = 6
72
+ _DXGIACCESS_GetInterface = 3
73
+
74
+ E_ACCESSDENIED = -2147024891
75
+ E_INVALIDARG = -2147024809
76
+
77
+ PIXEL_FORMAT_B8G8R8A8 = _d3d11.DXGI_FORMAT_B8G8R8A8_UNORM
78
+ POOL_BUFFERS = 2
79
+ REFRESH_AFTER_RESIZE_NS = 150_000_000
80
+
81
+
82
+ class SizeInt32(Structure):
83
+ _fields_ = [("Width", c_int32), ("Height", c_int32)]
84
+
85
+
86
+ def is_supported() -> bool:
87
+ """``GraphicsCaptureSession.IsSupported()`` (False if WinRT type missing)."""
88
+ try:
89
+ statics = activation_factory(
90
+ "Windows.Graphics.Capture.GraphicsCaptureSession", IID_IGraphicsCaptureSessionStatics
91
+ )
92
+ except NativeError:
93
+ return False
94
+ with statics:
95
+ val = c_ubyte()
96
+ statics.hcall(_SESSIONSTATICS_IsSupported, (c_void_p,), byref(val), what="IsSupported")
97
+ return bool(val.value)
98
+
99
+
100
+ class WgcBackend:
101
+ """WGC capture of a window (``hwnd``) or a monitor (``monitor``)."""
102
+
103
+ def __init__(
104
+ self,
105
+ *,
106
+ hwnd: int | None = None,
107
+ monitor: Monitor | None = None,
108
+ region: Rect | None = None,
109
+ client_area_only: bool = False,
110
+ include_cursor: bool = False,
111
+ border: bool | None = None,
112
+ ) -> None:
113
+ if (hwnd is None) == (monitor is None):
114
+ raise ValueError("exactly one of hwnd or monitor is required")
115
+ self.hwnd = hwnd
116
+ self.monitor = monitor
117
+ self.region = region
118
+ self.client_area_only = client_area_only
119
+ self.include_cursor = include_cursor
120
+ self.border = border
121
+ self._device: ComPtr | None = None
122
+ self._context: ComPtr | None = None
123
+ self._rt_device: ComPtr | None = None
124
+ self._item: ComPtr | None = None
125
+ self._pool: ComPtr | None = None
126
+ self._session: ComPtr | None = None
127
+ self._reader: _d3d11.StagingReader | None = None
128
+ self._frame_reg: EventRegistration | None = None
129
+ self._closed_reg: EventRegistration | None = None
130
+ self._frame_event = threading.Event()
131
+ self._closed = threading.Event()
132
+ self._pool_size = (0, 0)
133
+ self._out_size = (0, 0)
134
+ self.border_applied: bool | None = None
135
+ self.cursor_applied: bool | None = None
136
+ self.resize_count = 0
137
+ self.session_restarts = 0
138
+ self._refresh_deadline = 0
139
+ self.info = VideoBackendInfo("wgc-window" if hwnd is not None else "wgc-monitor", (0, 0))
140
+
141
+ # -- lifecycle ---------------------------------------------------------
142
+ def start(self) -> None:
143
+ try:
144
+ self._start()
145
+ except BaseException:
146
+ self.close()
147
+ raise
148
+
149
+ def _start(self) -> None:
150
+ if self.hwnd is not None:
151
+ if not _win32.is_window(self.hwnd):
152
+ raise TargetNotFoundError(f"window {self.hwnd:#x} does not exist")
153
+ if _win32.display_affinity(self.hwnd) != 0:
154
+ log.warning(
155
+ "window %#x excludes itself from capture (display affinity); "
156
+ "frames will be blank — capturekit does not bypass this",
157
+ self.hwnd,
158
+ )
159
+ if not is_supported():
160
+ raise CapabilityUnavailableError("Windows Graphics Capture is not supported here")
161
+ self._device, self._context = _d3d11.create_device()
162
+ self._rt_device = _d3d11.create_winrt_device(self._device)
163
+ self._reader = _d3d11.StagingReader(self._device, self._context)
164
+ self._item = self._create_item()
165
+
166
+ size = SizeInt32()
167
+ self._item.hcall(_ITEM_get_Size, (c_void_p,), byref(size), what="GraphicsCaptureItem.Size")
168
+ if size.Width <= 0 or size.Height <= 0:
169
+ raise CapabilityUnavailableError(
170
+ "capture item has zero size (window minimized at start or not renderable)"
171
+ )
172
+ self._open_session((size.Width, size.Height))
173
+ closed_delegate = EventDelegate(ITEM_CLOSED_IID, lambda s, a: self._on_closed())
174
+ self._closed_reg = EventRegistration.add(
175
+ self._item, _ITEM_add_Closed, _ITEM_remove_Closed, closed_delegate, "Closed"
176
+ )
177
+ base = self._base_rect(self._pool_size)
178
+ if self.region is not None:
179
+ validate_region(self.region, base.size)
180
+ self._out_size = crop_box(base, self.region).size
181
+ self.info.initial_size = self._out_size
182
+ self.info.details.update(border=self.border_applied, cursor=self.cursor_applied)
183
+
184
+ def _open_session(self, size: tuple[int, int]) -> None:
185
+ """Create frame pool + capture session for ``size`` and start capturing."""
186
+ assert self._rt_device is not None and self._item is not None
187
+ self._pool_size = size
188
+ with activation_factory(
189
+ "Windows.Graphics.Capture.Direct3D11CaptureFramePool",
190
+ IID_IDirect3D11CaptureFramePoolStatics2,
191
+ ) as statics:
192
+ self._pool = statics.out_ptr(
193
+ _POOLSTATICS2_CreateFreeThreaded, (c_void_p, c_int, c_int32, SizeInt32),
194
+ self._rt_device.as_param, PIXEL_FORMAT_B8G8R8A8, POOL_BUFFERS, SizeInt32(*size),
195
+ what="Direct3D11CaptureFramePool.CreateFreeThreaded", name="FramePool",
196
+ ) # fmt: skip
197
+ self._session = self._pool.out_ptr(
198
+ _POOL_CreateCaptureSession, (c_void_p,), self._item.as_param,
199
+ what="CreateCaptureSession", name="GraphicsCaptureSession",
200
+ ) # fmt: skip
201
+ self._configure_session()
202
+ frame_delegate = EventDelegate(FRAME_ARRIVED_IID, lambda s, a: self._frame_event.set())
203
+ self._frame_reg = EventRegistration.add(
204
+ self._pool, _POOL_add_FrameArrived, _POOL_remove_FrameArrived, frame_delegate,
205
+ "FrameArrived",
206
+ ) # fmt: skip
207
+ self._session.hcall(_SESSION_StartCapture, (), what="StartCapture")
208
+
209
+ def _close_session(self) -> None:
210
+ if self._frame_reg is not None:
211
+ try:
212
+ self._frame_reg.remove()
213
+ except NativeError:
214
+ pass
215
+ self._frame_reg = None
216
+ close_winrt(self._session)
217
+ self._session = None
218
+ close_winrt(self._pool)
219
+ self._pool = None
220
+
221
+ def _create_item(self) -> ComPtr:
222
+ with activation_factory(
223
+ "Windows.Graphics.Capture.GraphicsCaptureItem", IID_IGraphicsCaptureItemInterop
224
+ ) as interop:
225
+ out = c_void_p()
226
+ if self.hwnd is not None:
227
+ hr = interop.call(
228
+ _INTEROP_CreateForWindow, c_long, (c_void_p, c_void_p, c_void_p),
229
+ c_void_p(self.hwnd), byref(IID_IGraphicsCaptureItem), byref(out),
230
+ ) # fmt: skip
231
+ what = f"CreateForWindow({self.hwnd:#x})"
232
+ else:
233
+ assert self.monitor is not None
234
+ hr = interop.call(
235
+ _INTEROP_CreateForMonitor, c_long, (c_void_p, c_void_p, c_void_p),
236
+ c_void_p(self.monitor.native_handle), byref(IID_IGraphicsCaptureItem),
237
+ byref(out),
238
+ ) # fmt: skip
239
+ what = f"CreateForMonitor({self.monitor.id})"
240
+ if hr == E_ACCESSDENIED:
241
+ raise AccessDeniedError(f"{what}: access denied")
242
+ if hr == E_INVALIDARG:
243
+ raise CapabilityUnavailableError(
244
+ f"{what}: target cannot be captured (invalid, non-top-level or system window)"
245
+ )
246
+ check_hr(hr, what)
247
+ return ComPtr(out.value, "GraphicsCaptureItem")
248
+
249
+ def _configure_session(self) -> None:
250
+ assert self._session is not None
251
+ s2 = self._session.try_query(IID_IGraphicsCaptureSession2, "IGraphicsCaptureSession2")
252
+ if s2 is not None:
253
+ with s2:
254
+ hr = s2.call(
255
+ _SESSION2_put_IsCursorCaptureEnabled,
256
+ c_long,
257
+ (c_ubyte,),
258
+ 1 if self.include_cursor else 0,
259
+ )
260
+ self.cursor_applied = hr >= 0 and self.include_cursor
261
+ if self.border is not None:
262
+ s3 = self._session.try_query(IID_IGraphicsCaptureSession3, "IGraphicsCaptureSession3")
263
+ if s3 is None:
264
+ log.info("IsBorderRequired not available on this Windows build")
265
+ self.border_applied = None
266
+ else:
267
+ with s3:
268
+ hr = s3.call(
269
+ _SESSION3_put_IsBorderRequired, c_long, (c_ubyte,), 1 if self.border else 0
270
+ )
271
+ self.border_applied = self.border if hr >= 0 else None
272
+ if hr < 0:
273
+ log.info(
274
+ "IsBorderRequired=%s rejected (0x%08X)", self.border, hr & 0xFFFFFFFF
275
+ )
276
+
277
+ def _on_closed(self) -> None:
278
+ self._closed.set()
279
+ self._frame_event.set()
280
+
281
+ def close(self) -> None:
282
+ self._close_session()
283
+ if self._closed_reg is not None:
284
+ try:
285
+ self._closed_reg.remove()
286
+ except NativeError:
287
+ pass
288
+ self._closed_reg = None
289
+ if self._reader is not None:
290
+ self._reader.release()
291
+ self._reader = None
292
+ for name in ("_item", "_rt_device", "_context", "_device"):
293
+ p = getattr(self, name)
294
+ if p is not None:
295
+ p.release()
296
+ setattr(self, name, None)
297
+
298
+ # -- capture -----------------------------------------------------------
299
+ @property
300
+ def target_closed(self) -> bool:
301
+ return self._closed.is_set() or (self.hwnd is not None and not _win32.is_window(self.hwnd))
302
+
303
+ def _base_rect(self, content: tuple[int, int]) -> Rect:
304
+ full = Rect(0, 0, *content)
305
+ if self.hwnd is not None and self.client_area_only:
306
+ return _win32.client_rect_in_window(self.hwnd).intersect(full)
307
+ return full
308
+
309
+ def _next_frame(self) -> ComPtr | None:
310
+ """Drain the pool, keeping only the newest frame."""
311
+ assert self._pool is not None
312
+ latest: ComPtr | None = None
313
+ while True:
314
+ out = c_void_p()
315
+ try:
316
+ self._pool.hcall(
317
+ _POOL_TryGetNextFrame, (c_void_p,), byref(out), what="TryGetNextFrame"
318
+ )
319
+ except BaseException:
320
+ close_winrt(latest) # do not starve the 2-buffer pool
321
+ raise
322
+ if not out.value:
323
+ return latest
324
+ if latest is not None:
325
+ close_winrt(latest)
326
+ latest = ComPtr(out.value, "Direct3D11CaptureFrame")
327
+
328
+ def acquire(self, out: Buffer | None, timeout_s: float) -> AcquiredFrame | None:
329
+ if self.target_closed:
330
+ raise WindowClosedError(f"capture target {self.hwnd or self.monitor} was closed")
331
+ self._frame_event.clear()
332
+ frame = self._next_frame()
333
+ if frame is None and timeout_s > 0:
334
+ self._frame_event.wait(timeout_s)
335
+ if self.target_closed:
336
+ raise WindowClosedError(f"capture target {self.hwnd or self.monitor} was closed")
337
+ frame = self._next_frame()
338
+ if frame is None:
339
+ if self._refresh_deadline and now_ns() > self._refresh_deadline:
340
+ # Resized window has not presented a full-size frame (static
341
+ # content): a new session always delivers an initial frame.
342
+ self._refresh_deadline = 0
343
+ self.session_restarts += 1
344
+ self._close_session()
345
+ self._open_session(self._pool_size)
346
+ return None
347
+ try:
348
+ return self._process(frame, out)
349
+ finally:
350
+ close_winrt(frame)
351
+
352
+ def _process(self, frame: ComPtr, out: Buffer | None) -> AcquiredFrame:
353
+ assert self._reader is not None and self._pool is not None
354
+ ts = c_int64()
355
+ frame.hcall(
356
+ _FRAME_get_SystemRelativeTime, (c_void_p,), byref(ts), what="SystemRelativeTime"
357
+ )
358
+ cs = SizeInt32()
359
+ frame.hcall(_FRAME_get_ContentSize, (c_void_p,), byref(cs), what="ContentSize")
360
+ content = (max(0, cs.Width), max(0, cs.Height))
361
+
362
+ surface = frame.out_ptr(_FRAME_get_Surface, (), what="Frame.Surface", name="Surface")
363
+ with surface, surface.query(IID_IDirect3DDxgiInterfaceAccess, "DxgiAccess") as access:
364
+ tex = access.out_ptr(
365
+ _DXGIACCESS_GetInterface, (c_void_p,), byref(_d3d11.IID_ID3D11Texture2D),
366
+ what="GetInterface(ID3D11Texture2D)", name="ID3D11Texture2D",
367
+ ) # fmt: skip
368
+ with tex:
369
+ desc = _d3d11.texture_desc(tex)
370
+ visible = (min(content[0], desc.Width), min(content[1], desc.Height))
371
+ box = crop_box(self._base_rect(visible), self.region)
372
+ if out is None:
373
+ w, h = box.size if not box.is_empty() else self._out_size
374
+ dest = np.empty((max(1, h), max(1, w), 4), dtype=np.uint8)
375
+ else:
376
+ dest = out
377
+ if box.is_empty():
378
+ dest[...] = 0
379
+ else:
380
+ self._reader.copy_box(tex, box.x, box.y, box.width, box.height)
381
+ self._reader.read_into(dest)
382
+ blit_clear(dest, min(box.width, dest.shape[1]), min(box.height, dest.shape[0]))
383
+
384
+ if content != self._pool_size and content[0] > 0 and content[1] > 0:
385
+ # Window resized: new frames need a pool matching the new size.
386
+ assert self._rt_device is not None
387
+ self._pool.hcall(
388
+ _POOL_Recreate, (c_void_p, c_int, c_int32, SizeInt32),
389
+ self._rt_device.as_param, PIXEL_FORMAT_B8G8R8A8, POOL_BUFFERS,
390
+ SizeInt32(*content), what="FramePool.Recreate",
391
+ ) # fmt: skip
392
+ self._pool_size = content
393
+ self.resize_count += 1
394
+ self._refresh_deadline = now_ns() + REFRESH_AFTER_RESIZE_NS
395
+ elif (desc.Width, desc.Height) == content:
396
+ self._refresh_deadline = 0
397
+ stamp = hns_to_ns(ts.value) if ts.value > 0 else now_ns()
398
+ return AcquiredFrame(dest, stamp, content)
@@ -0,0 +1 @@
1
+ """Platform-independent timing, synchronisation and buffering primitives."""
@@ -0,0 +1,177 @@
1
+ """Audio alignment to the media timeline: gap filling, overlap trimming and
2
+ gentle drift correction. See the av-sync skill for the model.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+
9
+ import numpy as np
10
+ import numpy.typing as npt
11
+
12
+ from .clock import NS_PER_S
13
+ from .timeline import Timeline
14
+
15
+ Samples = npt.NDArray[np.float32]
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class AlignerStats:
20
+ packets: int = 0
21
+ packets_dropped_paused: int = 0
22
+ samples_in: int = 0
23
+ samples_out: int = 0
24
+ silence_inserted: int = 0
25
+ samples_trimmed: int = 0
26
+ drift_corrected: int = 0 # net samples added (+) / removed (-) by resampling
27
+ discontinuities: int = 0
28
+ last_error_samples: float = 0.0
29
+
30
+
31
+ class AudioAligner:
32
+ """Converts timestamped packets into a gapless sample stream.
33
+
34
+ ``push`` / ``heartbeat`` / ``finish`` return the blocks to emit, in order.
35
+ One aligner per source; not thread-safe (owned by the source's thread).
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ timeline: Timeline,
41
+ sample_rate: int = 48_000,
42
+ channels: int = 2,
43
+ *,
44
+ gap_ms: float = 20.0,
45
+ overlap_ms: float = 20.0,
46
+ deadband_ms: float = 2.0,
47
+ max_ratio: float = 0.002,
48
+ smoothing: float = 0.05,
49
+ ) -> None:
50
+ self.timeline = timeline
51
+ self.rate = sample_rate
52
+ self.channels = channels
53
+ self.written = 0
54
+ self._gap = int(sample_rate * gap_ms / 1000)
55
+ self._overlap = int(sample_rate * overlap_ms / 1000)
56
+ self._deadband = sample_rate * deadband_ms / 1000
57
+ self._max_ratio = max_ratio
58
+ self._alpha = smoothing
59
+ self._err = 0.0
60
+ self._have_err = False
61
+ self._synced = False
62
+ self._generation = 0
63
+ self.stats = AlignerStats()
64
+
65
+ def _pos(self, media_ns: int) -> int:
66
+ return media_ns * self.rate // NS_PER_S
67
+
68
+ def _silence(self, n: int) -> Samples:
69
+ self.stats.silence_inserted += n
70
+ return np.zeros((n, self.channels), dtype=np.float32)
71
+
72
+ def push(self, data: Samples, timestamp_ns: int, discontinuity: bool = False) -> list[Samples]:
73
+ st = self.stats
74
+ st.packets += 1
75
+ n = int(data.shape[0])
76
+ st.samples_in += n
77
+ if discontinuity:
78
+ st.discontinuities += 1
79
+ media = self.timeline.media_ns(timestamp_ns)
80
+ if media is None or n == 0:
81
+ st.packets_dropped_paused += 1
82
+ return []
83
+ gen = self.timeline.generation
84
+ if gen != self._generation:
85
+ # New segment (start or resume): re-anchor exactly instead of
86
+ # treating the pause boundary as clock drift.
87
+ self._generation = gen
88
+ self._synced = False
89
+ self._have_err = False
90
+ expected = self._pos(media)
91
+ err = expected - self.written
92
+ out: list[Samples] = []
93
+ if not self._synced:
94
+ # First packet: align exactly (any offset is start-up latency, not drift).
95
+ self._synced = True
96
+ if err > 0:
97
+ out.append(self._silence(err))
98
+ self.written += err
99
+ err = 0
100
+ elif err < 0:
101
+ trim = -err
102
+ st.samples_trimmed += min(trim, n)
103
+ if trim >= n:
104
+ self._synced = False
105
+ return out
106
+ data = data[trim:]
107
+ n -= trim
108
+ err = 0
109
+ if err > self._gap:
110
+ out.append(self._silence(err))
111
+ self.written += err
112
+ self._have_err = False
113
+ elif -err > self._overlap:
114
+ trim = -err
115
+ st.samples_trimmed += min(trim, n)
116
+ if trim >= n:
117
+ return out
118
+ data = data[trim:]
119
+ n -= trim
120
+ self._have_err = False
121
+ else:
122
+ # Small error: smooth it (timestamps jitter ~1 ms) and nudge the
123
+ # stream length by resampling at most max_ratio per packet.
124
+ if not self._have_err:
125
+ self._err = float(err)
126
+ self._have_err = True
127
+ else:
128
+ self._err += self._alpha * (err - self._err)
129
+ st.last_error_samples = self._err
130
+ if abs(self._err) > self._deadband:
131
+ limit = max(1, int(n * self._max_ratio))
132
+ corr = int(max(-limit, min(limit, round(self._err * 0.1))))
133
+ if corr:
134
+ data = _resample(data, n + corr)
135
+ st.drift_corrected += corr
136
+ self._err -= corr
137
+ n += corr
138
+ out.append(np.ascontiguousarray(data, dtype=np.float32))
139
+ self.written += n
140
+ st.samples_out = self.written
141
+ return out
142
+
143
+ def heartbeat(self, wall_now_ns: int, margin_ns: int = 60_000_000) -> list[Samples]:
144
+ """Fill silence up to ``now - margin`` when no packets arrive
145
+ (endpoint loopback produces no packets while nothing plays)."""
146
+ if not self.timeline.started:
147
+ return []
148
+ target = self._pos(max(0, self.timeline.media_now(wall_now_ns) - margin_ns))
149
+ missing = target - self.written
150
+ if missing <= 0:
151
+ return []
152
+ self.written += missing
153
+ self.stats.samples_out = self.written
154
+ self._have_err = False
155
+ return [self._silence(missing)]
156
+
157
+ def finish(self, media_end_ns: int) -> list[Samples]:
158
+ """Pad with silence so the stream ends exactly at ``media_end_ns``."""
159
+ target = self._pos(media_end_ns)
160
+ missing = target - self.written
161
+ if missing <= 0:
162
+ return []
163
+ self.written += missing
164
+ self.stats.samples_out = self.written
165
+ return [self._silence(missing)]
166
+
167
+
168
+ def _resample(data: Samples, m: int) -> Samples:
169
+ n = data.shape[0]
170
+ if n < 2 or m < 1:
171
+ return data
172
+ x_new = np.linspace(0.0, n - 1, m)
173
+ x_old = np.arange(n, dtype=np.float64)
174
+ out = np.empty((m, data.shape[1]), dtype=np.float32)
175
+ for ch in range(data.shape[1]):
176
+ out[:, ch] = np.interp(x_new, x_old, data[:, ch])
177
+ return out
@@ -0,0 +1,47 @@
1
+ """The single capturekit clock.
2
+
3
+ Windows: ``time.perf_counter_ns`` is QueryPerformanceCounter scaled to ns with
4
+ no offset (verified, see native-api-research skill), so QPC-based timestamps
5
+ from WGC, DXGI and WASAPI convert without an offset.
6
+ Linux: CLOCK_MONOTONIC, which PulseAudio/PipeWire and X server-side
7
+ timestamps can be related to.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ import threading
14
+ import time
15
+
16
+ if sys.platform == "win32":
17
+ now_ns = time.perf_counter_ns
18
+ else:
19
+ now_ns = time.monotonic_ns
20
+
21
+ NS_PER_S = 1_000_000_000
22
+
23
+
24
+ def hns_to_ns(hundred_ns: int) -> int:
25
+ """Convert 100-ns QPC units (WASAPI u64QPCPosition, WinRT TimeSpan)."""
26
+ return hundred_ns * 100
27
+
28
+
29
+ def qpc_ticks_to_ns(ticks: int, frequency: int) -> int:
30
+ return ticks * NS_PER_S // frequency
31
+
32
+
33
+ def sleep_until(deadline_ns: int, stop: threading.Event | None = None) -> bool:
34
+ """Sleep until the absolute ``deadline_ns``.
35
+
36
+ Returns ``False`` if ``stop`` was set while waiting. Wakes at most every
37
+ 50 ms to observe ``stop``. On Python >= 3.11 Windows, ``time.sleep`` uses a
38
+ high-resolution waitable timer, so the residual error is typically below
39
+ 1 ms; callers measure and report actual jitter instead of assuming it.
40
+ """
41
+ while True:
42
+ if stop is not None and stop.is_set():
43
+ return False
44
+ remaining = deadline_ns - now_ns()
45
+ if remaining <= 0:
46
+ return True
47
+ time.sleep(min(remaining, 50_000_000) / NS_PER_S)