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,160 @@
1
+ """Linux backend facade: X11/Xvfb video, PulseAudio/PipeWire audio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import platform
7
+ from collections.abc import Iterator
8
+ from types import ModuleType
9
+
10
+ from ...capabilities import (
11
+ Capabilities,
12
+ Capability,
13
+ experimental,
14
+ supported,
15
+ unavailable,
16
+ unsupported,
17
+ )
18
+ from ...errors import CapabilityUnavailableError, TargetNotFoundError
19
+ from ...sources import (
20
+ AudioSourceSpec,
21
+ MonitorBackend,
22
+ MonitorSource,
23
+ WindowSource,
24
+ )
25
+ from ...types import AudioDevice, AudioSession, Monitor, Window
26
+ from ..base import AudioBackend, VideoBackend
27
+
28
+ PLATFORM = "linux"
29
+
30
+
31
+ @contextlib.contextmanager
32
+ def thread_context() -> Iterator[None]:
33
+ # Each backend opens its own X connection on its own thread.
34
+ yield
35
+
36
+
37
+ def _x11() -> ModuleType:
38
+ from . import x11
39
+
40
+ return x11
41
+
42
+
43
+ def list_monitors(display: str | None = None) -> list[Monitor]:
44
+ result: list[Monitor] = _x11().list_monitors(display)
45
+ return result
46
+
47
+
48
+ def list_windows(display: str | None = None, include_untitled: bool = False) -> list[Window]:
49
+ result: list[Window] = _x11().list_windows(display, include_untitled)
50
+ return result
51
+
52
+
53
+ def get_window(handle: int, display: str | None = None) -> Window:
54
+ result: Window = _x11().get_window(handle, display)
55
+ return result
56
+
57
+
58
+ def resolve_monitor(spec: Monitor | int | None, display: str | None) -> Monitor:
59
+ if isinstance(spec, Monitor):
60
+ display = spec.display if display is None else display
61
+ monitors = list_monitors(display)
62
+ if spec is None:
63
+ return next((m for m in monitors if m.is_primary), monitors[0])
64
+ if isinstance(spec, int):
65
+ if not 0 <= spec < len(monitors):
66
+ raise TargetNotFoundError(f"monitor index {spec} out of range (0..{len(monitors) - 1})")
67
+ return monitors[spec]
68
+ for m in monitors:
69
+ if m.id == spec.id:
70
+ return m
71
+ raise TargetNotFoundError(f"monitor {spec.id} not found on {display}")
72
+
73
+
74
+ def open_video(source: MonitorSource | WindowSource) -> VideoBackend:
75
+ x11 = _x11()
76
+ if isinstance(source, WindowSource):
77
+ display = source.display
78
+ if display is None and isinstance(source.window, Window):
79
+ display = source.window.display
80
+ name, _explicit = x11.resolve_display(display)
81
+ backend: VideoBackend = x11.CompositeWindowBackend(
82
+ source.handle, source.region, source.client_area_only, name
83
+ )
84
+ return backend
85
+ if source.backend not in (MonitorBackend.AUTO, MonitorBackend.X11):
86
+ raise CapabilityUnavailableError(f"monitor backend {source.backend.value} is Windows-only")
87
+ monitor = resolve_monitor(source.monitor, source.display)
88
+ mon_backend: VideoBackend = x11.X11MonitorBackend(monitor, source.region, monitor.display)
89
+ return mon_backend
90
+
91
+
92
+ def _pulse() -> ModuleType:
93
+ from . import pulse
94
+
95
+ return pulse
96
+
97
+
98
+ def list_audio_devices() -> list[AudioDevice]:
99
+ result: list[AudioDevice] = _pulse().list_devices()
100
+ return result
101
+
102
+
103
+ def list_audio_sessions() -> list[AudioSession]:
104
+ result: list[AudioSession] = _pulse().list_sessions()
105
+ return result
106
+
107
+
108
+ def open_audio(spec: AudioSourceSpec, sample_rate: int, channels: int = 2) -> AudioBackend:
109
+ backend: AudioBackend = _pulse().open_audio(spec, sample_rate, channels)
110
+ return backend
111
+
112
+
113
+ def capabilities(display: str | None = None) -> Capabilities:
114
+ from ...errors import CaptureError
115
+
116
+ notes: list[str] = []
117
+ server = None
118
+ try:
119
+ x11 = _x11()
120
+ with x11.open_display(display) as d:
121
+ server = x11.server_kind(d)
122
+ shm = d.has_extension("MIT-SHM")
123
+ composite = d.has_extension("Composite")
124
+ xwayland = server == "xwayland"
125
+ except CaptureError as exc:
126
+ reason = str(exc)
127
+ monitor: Capability = unavailable(reason)
128
+ window: Capability = unavailable(reason)
129
+ else:
130
+ monitor = (
131
+ unsupported("XWayland root does not contain the Wayland desktop")
132
+ if xwayland
133
+ else supported("MIT-SHM" if shm else "XGetImage (no MIT-SHM)")
134
+ )
135
+ window = (
136
+ supported("XComposite automatic redirection")
137
+ if composite
138
+ else unavailable("X server lacks the Composite extension")
139
+ )
140
+ if xwayland and composite:
141
+ window = experimental("X11 clients on XWayland via XComposite")
142
+ audio = _pulse().audio_capabilities()
143
+ return Capabilities(
144
+ platform=PLATFORM,
145
+ os_version=platform.release(),
146
+ display_server=server,
147
+ monitor_capture=monitor,
148
+ window_capture=window,
149
+ window_capture_borderless=supported("no capture border on X11"),
150
+ window_capture_minimized=unsupported(
151
+ "unmapped/iconified windows have no Composite pixmap; the last frame is held"
152
+ ),
153
+ cursor_capture=unsupported("cursor capture on X11 is not implemented yet"),
154
+ microphone_audio=audio["microphone"],
155
+ system_audio=audio["system"],
156
+ process_audio=audio["process"],
157
+ audio_session_enumeration=audio["sessions"],
158
+ recording=supported("FFmpeg subprocess (requires an FFmpeg executable)"),
159
+ notes=tuple(notes),
160
+ )
@@ -0,0 +1,464 @@
1
+ """Linux audio through the PulseAudio protocol (PulseAudio or pipewire-pulse).
2
+
3
+ * Enumeration: ``pactl -f json`` (PulseAudio >= 16 / pipewire-pulse).
4
+ * Microphone / system audio: one ``parec`` per source (``<sink>.monitor``
5
+ for system audio).
6
+ * Application audio: one ``parec --monitor-stream=<sink-input>`` per stream
7
+ of the target process tree. This records only that stream and does not
8
+ change the user's routing; streams that appear later are attached, ended
9
+ streams are dropped. Per-process capture never falls back to the sink
10
+ monitor.
11
+
12
+ All streams are float32 / 48 kHz / stereo; the server resamples.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import functools
18
+ import json
19
+ import logging
20
+ import os
21
+ import shutil
22
+ import subprocess
23
+ import threading
24
+ from collections import deque
25
+ from collections.abc import Callable
26
+ from typing import Any
27
+
28
+ import numpy as np
29
+
30
+ from ..._pipeline.clock import NS_PER_S, now_ns
31
+ from ...capabilities import Capability, supported, unavailable
32
+ from ...errors import (
33
+ AudioDeviceDisconnectedError,
34
+ AudioDeviceError,
35
+ CapabilityUnavailableError,
36
+ TargetNotFoundError,
37
+ )
38
+ from ...sources import (
39
+ AudioSourceSpec,
40
+ MicrophoneSource,
41
+ ProcessAudioSource,
42
+ SystemAudioSource,
43
+ audio_source_label,
44
+ )
45
+ from ...types import AudioDevice, AudioDeviceKind, AudioSession
46
+ from ..base import AudioCallback, AudioPacket
47
+
48
+ log = logging.getLogger("capturekit.linux.pulse")
49
+
50
+ CHUNK_FRAMES = 480 # 10 ms at 48 kHz
51
+ LATENCY_MS = 20
52
+ RESCAN_S = 0.5
53
+
54
+
55
+ def _tool(name: str) -> str:
56
+ path = shutil.which(name)
57
+ if path is None:
58
+ raise CapabilityUnavailableError(
59
+ f"{name!r} not found: install pulseaudio-utils (works with PulseAudio and PipeWire)"
60
+ )
61
+ return path
62
+
63
+
64
+ def _pactl(*args: str) -> Any:
65
+ res = subprocess.run(
66
+ [_tool("pactl"), "-f", "json", *args],
67
+ capture_output=True,
68
+ text=True,
69
+ timeout=10,
70
+ check=False,
71
+ )
72
+ if res.returncode != 0:
73
+ raise AudioDeviceError(f"pactl {' '.join(args)} failed: {res.stderr.strip()}")
74
+ return json.loads(res.stdout or "null")
75
+
76
+
77
+ def _pactl_text(*args: str) -> str:
78
+ res = subprocess.run(
79
+ [_tool("pactl"), *args], capture_output=True, text=True, timeout=10, check=False
80
+ )
81
+ if res.returncode != 0:
82
+ raise AudioDeviceError(f"pactl {' '.join(args)} failed: {res.stderr.strip()}")
83
+ return res.stdout.strip()
84
+
85
+
86
+ def audio_capabilities() -> dict[str, Capability]:
87
+ try:
88
+ _pactl_text("info")
89
+ _tool("parec")
90
+ except (
91
+ CapabilityUnavailableError,
92
+ AudioDeviceError,
93
+ OSError,
94
+ subprocess.TimeoutExpired,
95
+ ) as exc:
96
+ cap = unavailable(f"no reachable PulseAudio/PipeWire server: {exc}")
97
+ return dict.fromkeys(("microphone", "system", "process", "sessions"), cap)
98
+ return {
99
+ "microphone": supported("parec on the source"),
100
+ "system": supported("parec on <default sink>.monitor"),
101
+ "process": supported("parec --monitor-stream per sink-input of the process tree"),
102
+ "sessions": supported("pactl sink-inputs with application.process.id"),
103
+ }
104
+
105
+
106
+ def list_devices() -> list[AudioDevice]:
107
+ default_sink = _pactl_text("get-default-sink")
108
+ default_source = _pactl_text("get-default-source")
109
+ out: list[AudioDevice] = []
110
+ for s in _pactl("list", "sources") or []:
111
+ name = s.get("name", "")
112
+ if s.get("monitor_of_sink") not in (None, "n/a") or name.endswith(".monitor"):
113
+ continue # monitors are reached through SystemAudioSource
114
+ out.append(_device(s, AudioDeviceKind.INPUT, name == default_source))
115
+ for s in _pactl("list", "sinks") or []:
116
+ out.append(_device(s, AudioDeviceKind.OUTPUT, s.get("name") == default_sink))
117
+ return out
118
+
119
+
120
+ def _device(s: dict[str, Any], kind: AudioDeviceKind, default: bool) -> AudioDevice:
121
+ spec = str(s.get("sample_specification", ""))
122
+ rate = next((int(p[:-2]) for p in spec.split() if p.endswith("Hz") and p[:-2].isdigit()), None)
123
+ ch = next((int(p[:-2]) for p in spec.split() if p.endswith("ch") and p[:-2].isdigit()), None)
124
+ return AudioDevice(
125
+ id=s.get("name", ""),
126
+ name=s.get("description") or s.get("name", ""),
127
+ kind=kind,
128
+ is_default=default,
129
+ channels=ch,
130
+ sample_rate=rate,
131
+ backend="pulse",
132
+ )
133
+
134
+
135
+ def _sink_inputs() -> list[dict[str, Any]]:
136
+ data = _pactl("list", "sink-inputs") or []
137
+ return [d for d in data if isinstance(d, dict)]
138
+
139
+
140
+ def _sink_names() -> dict[int, str]:
141
+ return {int(s["index"]): str(s["name"]) for s in _pactl("list", "short", "sinks") or []}
142
+
143
+
144
+ def list_sessions() -> list[AudioSession]:
145
+ sinks = _sink_names()
146
+ out = []
147
+ for si in _sink_inputs():
148
+ props = si.get("properties", {})
149
+ pid = props.get("application.process.id")
150
+ out.append(
151
+ AudioSession(
152
+ pid=int(pid) if pid and str(pid).isdigit() else None,
153
+ process_name=props.get("application.process.binary"),
154
+ display_name=props.get("application.name") or props.get("media.name") or "",
155
+ device_id=sinks.get(int(si.get("sink", -1))),
156
+ is_active=not si.get("corked", False),
157
+ stream_id=str(si.get("index")),
158
+ backend="pulse",
159
+ )
160
+ )
161
+ return out
162
+
163
+
164
+ def process_tree(pid: int) -> set[int]:
165
+ parents: dict[int, int] = {}
166
+ for entry in os.listdir("/proc"):
167
+ if not entry.isdigit():
168
+ continue
169
+ try:
170
+ with open(f"/proc/{entry}/stat", encoding="utf-8") as f:
171
+ stat = f.read()
172
+ # field 4 (ppid) follows the parenthesised command name
173
+ parents[int(entry)] = int(stat.rsplit(")", 1)[1].split()[1])
174
+ except (OSError, IndexError, ValueError):
175
+ continue
176
+ tree = {pid}
177
+ changed = True
178
+ while changed:
179
+ changed = False
180
+ for child, parent in parents.items():
181
+ if parent in tree and child not in tree:
182
+ tree.add(child)
183
+ changed = True
184
+ return tree
185
+
186
+
187
+ # --- parec streams ---------------------------------------------------------------
188
+
189
+
190
+ class ParecStream:
191
+ """One ``parec`` process; delivers float32 stereo chunks to a callback."""
192
+
193
+ def __init__(
194
+ self,
195
+ args: list[str],
196
+ channels: int,
197
+ on_chunk: Callable[[np.ndarray, int], None],
198
+ on_end: Callable[[ParecStream, int | None], None],
199
+ ) -> None:
200
+ self.args = args
201
+ self.channels = channels
202
+ self._on_chunk = on_chunk
203
+ self._on_end = on_end
204
+ self.proc = subprocess.Popen(
205
+ args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL
206
+ )
207
+ self._stopping = False
208
+ self._thread = threading.Thread(target=self._run, name="capturekit-parec", daemon=True)
209
+ self._thread.start()
210
+
211
+ def _run(self) -> None:
212
+ assert self.proc.stdout is not None
213
+ nbytes = CHUNK_FRAMES * self.channels * 4
214
+ while True:
215
+ data = self.proc.stdout.read(nbytes)
216
+ if not data:
217
+ break
218
+ n = len(data) // (4 * self.channels)
219
+ if n == 0:
220
+ continue
221
+ arr = (
222
+ np.frombuffer(data[: n * 4 * self.channels], dtype=np.float32)
223
+ .reshape(n, self.channels)
224
+ .copy()
225
+ )
226
+ # A pipe carries no timestamps: estimate capture time of the first
227
+ # sample from the read time, chunk length and requested latency.
228
+ ts = now_ns() - n * NS_PER_S // 48_000 - LATENCY_MS * 1_000_000
229
+ self._on_chunk(arr, ts)
230
+ rc = self.proc.wait()
231
+ if not self._stopping:
232
+ self._on_end(self, rc)
233
+
234
+ def stop(self) -> None:
235
+ self._stopping = True
236
+ if self.proc.poll() is None:
237
+ self.proc.terminate()
238
+ try:
239
+ self.proc.wait(2)
240
+ except subprocess.TimeoutExpired:
241
+ self.proc.kill()
242
+ self._thread.join(3)
243
+
244
+ @property
245
+ def stderr(self) -> str:
246
+ if self.proc.stderr is None or self.proc.poll() is None:
247
+ return ""
248
+ return self.proc.stderr.read().decode("utf-8", "replace").strip()
249
+
250
+
251
+ def _parec_args(rate: int, channels: int, name: str) -> list[str]:
252
+ return [
253
+ _tool("parec"), "--raw", "--format=float32le", f"--rate={rate}",
254
+ f"--channels={channels}", f"--latency-msec={LATENCY_MS}",
255
+ f"--client-name=capturekit-{name}",
256
+ # PipeWire: never re-link to another node (e.g. the microphone) when
257
+ # the target disappears.
258
+ "--property=node.dont-reconnect=true",
259
+ ] # fmt: skip
260
+
261
+
262
+ class PulseDeviceCapture:
263
+ """Microphone or system audio (sink monitor)."""
264
+
265
+ def __init__(
266
+ self, kind: str, device: str | None, sample_rate: int, channels: int, name: str
267
+ ) -> None:
268
+ self.kind = kind
269
+ self.device = device
270
+ self.sample_rate = sample_rate
271
+ self.channels = channels
272
+ self.name = name
273
+ self.device_name = ""
274
+ self._stream: ParecStream | None = None
275
+
276
+ def start(self, on_packet: AudioCallback, on_error: Callable[[BaseException], None]) -> None:
277
+ if self.kind == "system":
278
+ sink = self.device or _pactl_text("get-default-sink")
279
+ source = sink if sink.endswith(".monitor") else f"{sink}.monitor"
280
+ else:
281
+ source = self.device or _pactl_text("get-default-source")
282
+ names = {s.get("name") for s in _pactl("list", "short", "sources") or []}
283
+ if source not in names:
284
+ raise TargetNotFoundError(f"audio source {source!r} not found")
285
+ self.device_name = source
286
+ args = [*_parec_args(self.sample_rate, self.channels, self.kind), f"--device={source}"]
287
+
288
+ def ended(stream: ParecStream, rc: int | None) -> None:
289
+ on_error(
290
+ AudioDeviceDisconnectedError(
291
+ f"recording from {source!r} ended (parec exit {rc}): {stream.stderr}"
292
+ )
293
+ )
294
+
295
+ self._stream = ParecStream(
296
+ args, self.channels, lambda a, ts: on_packet(AudioPacket(a, ts)), ended
297
+ )
298
+
299
+ def stop(self) -> None:
300
+ if self._stream is not None:
301
+ self._stream.stop()
302
+ self._stream = None
303
+
304
+
305
+ class PulseProcessCapture:
306
+ """Audio of a process tree: every matching sink-input, mixed, followed live."""
307
+
308
+ def __init__(
309
+ self,
310
+ pid: int,
311
+ include_children: bool,
312
+ stream_ids: tuple[str, ...],
313
+ sample_rate: int,
314
+ channels: int,
315
+ name: str,
316
+ ) -> None:
317
+ self.pid = pid
318
+ self.include_children = include_children
319
+ self.stream_ids = stream_ids
320
+ self.sample_rate = sample_rate
321
+ self.channels = channels
322
+ self.name = name
323
+ self.device_name = f"process {pid}"
324
+ self._streams: dict[int, ParecStream] = {}
325
+ self._fifos: dict[int, deque[np.ndarray]] = {}
326
+ self._lock = threading.Lock()
327
+ self._stop = threading.Event()
328
+ self._threads: list[threading.Thread] = []
329
+ self.streams_seen: set[int] = set()
330
+
331
+ def _matching_inputs(self) -> list[int]:
332
+ if self.stream_ids:
333
+ wanted = {int(s) for s in self.stream_ids}
334
+ return [int(si["index"]) for si in _sink_inputs() if int(si["index"]) in wanted]
335
+ pids = process_tree(self.pid) if self.include_children else {self.pid}
336
+ out = []
337
+ for si in _sink_inputs():
338
+ p = si.get("properties", {}).get("application.process.id")
339
+ if p and str(p).isdigit() and int(p) in pids:
340
+ out.append(int(si["index"]))
341
+ return out
342
+
343
+ def start(self, on_packet: AudioCallback, on_error: Callable[[BaseException], None]) -> None:
344
+ if not os.path.exists(f"/proc/{self.pid}"):
345
+ raise TargetNotFoundError(f"process {self.pid} does not exist")
346
+ _tool("parec")
347
+ self._on_packet = on_packet
348
+ self._on_error = on_error
349
+ self._rescan()
350
+ for target in (self._scan_loop, self._mix_loop):
351
+ t = threading.Thread(target=target, name="capturekit-pulse-process", daemon=True)
352
+ t.start()
353
+ self._threads.append(t)
354
+
355
+ def _rescan(self) -> None:
356
+ try:
357
+ current = set(self._matching_inputs())
358
+ except (AudioDeviceError, OSError, ValueError, subprocess.TimeoutExpired) as exc:
359
+ log.warning("sink-input scan failed: %s", exc)
360
+ return
361
+ with self._lock:
362
+ for idx in current - set(self._streams):
363
+ args = [
364
+ *_parec_args(self.sample_rate, self.channels, f"stream{idx}"),
365
+ f"--monitor-stream={idx}",
366
+ ]
367
+ fifo: deque[np.ndarray] = deque()
368
+ self._fifos[idx] = fifo
369
+ self._streams[idx] = ParecStream(
370
+ args,
371
+ self.channels,
372
+ functools.partial(self._push_chunk, fifo),
373
+ functools.partial(self._stream_ended, idx),
374
+ )
375
+ self.streams_seen.add(idx)
376
+ log.debug("attached sink-input %d", idx)
377
+
378
+ def _push_chunk(self, fifo: deque[np.ndarray], a: np.ndarray, _ts: int) -> None:
379
+ self._push(fifo, a)
380
+
381
+ def _stream_ended(self, idx: int, _stream: ParecStream, _rc: int | None) -> None:
382
+ self._ended(idx)
383
+
384
+ def _push(self, fifo: deque[np.ndarray], a: np.ndarray) -> None:
385
+ with self._lock:
386
+ fifo.append(a)
387
+ # Bound the backlog (300 ms): drop the oldest audio.
388
+ while sum(len(x) for x in fifo) > self.sample_rate * 3 // 10:
389
+ fifo.popleft()
390
+
391
+ def _ended(self, idx: int) -> None:
392
+ with self._lock:
393
+ self._streams.pop(idx, None)
394
+ log.debug("sink-input %d ended", idx)
395
+
396
+ def _scan_loop(self) -> None:
397
+ while not self._stop.wait(RESCAN_S):
398
+ self._rescan()
399
+
400
+ def _pop(self, fifo: deque[np.ndarray], n: int) -> np.ndarray:
401
+ out = np.zeros((n, self.channels), np.float32)
402
+ filled = 0
403
+ while filled < n and fifo:
404
+ blk = fifo[0]
405
+ take = min(n - filled, len(blk))
406
+ out[filled : filled + take] = blk[:take]
407
+ filled += take
408
+ if take == len(blk):
409
+ fifo.popleft()
410
+ else:
411
+ fifo[0] = blk[take:]
412
+ return out
413
+
414
+ def _mix_loop(self) -> None:
415
+ """Clock-driven mixer: emits exactly real-time worth of samples, so a
416
+ silent or absent process yields silence, never other audio."""
417
+ delay_ns = 60_000_000 # jitter buffer for the parec pipes
418
+ t0 = now_ns() + delay_ns
419
+ emitted = 0
420
+ while not self._stop.wait(0.01):
421
+ due = (now_ns() - t0) * self.sample_rate // NS_PER_S - emitted
422
+ if due <= 0:
423
+ continue
424
+ with self._lock:
425
+ fifos = [f for i, f in self._fifos.items() if i in self._streams or f]
426
+ mix = np.zeros((due, self.channels), np.float32)
427
+ for f in fifos:
428
+ mix += self._pop(f, due)
429
+ for i in [i for i, f in self._fifos.items() if i not in self._streams and not f]:
430
+ del self._fifos[i]
431
+ np.clip(mix, -1.0, 1.0, out=mix)
432
+ ts = t0 - delay_ns + emitted * NS_PER_S // self.sample_rate
433
+ emitted += due
434
+ try:
435
+ self._on_packet(AudioPacket(mix, ts))
436
+ except Exception as exc:
437
+ self._on_error(exc)
438
+ return
439
+
440
+ def stop(self) -> None:
441
+ self._stop.set()
442
+ for t in self._threads:
443
+ t.join(3)
444
+ with self._lock:
445
+ streams = list(self._streams.values())
446
+ self._streams.clear()
447
+ for s in streams:
448
+ s.stop()
449
+
450
+
451
+ def open_audio(spec: AudioSourceSpec, sample_rate: int, channels: int) -> Any:
452
+ name = audio_source_label(spec)
453
+ dev = None
454
+ if isinstance(spec, (MicrophoneSource, SystemAudioSource)) and spec.device is not None:
455
+ dev = spec.device if isinstance(spec.device, str) else spec.device.id
456
+ if isinstance(spec, MicrophoneSource):
457
+ return PulseDeviceCapture("microphone", dev, sample_rate, channels, name)
458
+ if isinstance(spec, SystemAudioSource):
459
+ return PulseDeviceCapture("system", dev, sample_rate, channels, name)
460
+ if isinstance(spec, ProcessAudioSource):
461
+ return PulseProcessCapture(
462
+ spec.pid, spec.include_children, spec.stream_ids, sample_rate, channels, name
463
+ )
464
+ raise TypeError(f"unknown audio source {spec!r}")