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,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}")
|