proc-tap 0.2.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.
- proc_tap-0.2.0.dist-info/METADATA +444 -0
- proc_tap-0.2.0.dist-info/RECORD +15 -0
- proc_tap-0.2.0.dist-info/WHEEL +5 -0
- proc_tap-0.2.0.dist-info/licenses/LICENSE +21 -0
- proc_tap-0.2.0.dist-info/top_level.txt +1 -0
- proctap/__init__.py +13 -0
- proctap/_native.pyi +83 -0
- proctap/backends/__init__.py +71 -0
- proctap/backends/base.py +77 -0
- proctap/backends/linux.py +521 -0
- proctap/backends/macos.py +373 -0
- proctap/backends/windows.py +85 -0
- proctap/contrib/__init__.py +17 -0
- proctap/contrib/discord_source.py +381 -0
- proctap/core.py +222 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""
|
|
2
|
+
macOS audio capture backend using Core Audio Process Tap API.
|
|
3
|
+
|
|
4
|
+
This module provides process-specific audio capture on macOS 14.4+ using
|
|
5
|
+
the Core Audio Process Tap API via a Swift CLI helper process.
|
|
6
|
+
|
|
7
|
+
Requirements:
|
|
8
|
+
- macOS 14.4 (Sonoma) or later
|
|
9
|
+
- Audio capture permission (NSAudioCaptureUsageDescription)
|
|
10
|
+
- Swift CLI helper binary (proctap-macos)
|
|
11
|
+
|
|
12
|
+
STATUS: Implemented for macOS 14.4+
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Optional
|
|
18
|
+
import logging
|
|
19
|
+
import subprocess
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import platform
|
|
23
|
+
import queue
|
|
24
|
+
import threading
|
|
25
|
+
import struct
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from .base import AudioBackend
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_macos_version() -> tuple[int, int, int]:
|
|
34
|
+
"""
|
|
35
|
+
Get macOS version as tuple (major, minor, patch).
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Tuple of (major, minor, patch) version numbers
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
(14, 4, 0) for macOS 14.4.0 Sonoma
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
version_str = platform.mac_ver()[0]
|
|
45
|
+
parts = version_str.split('.')
|
|
46
|
+
major = int(parts[0]) if len(parts) > 0 else 0
|
|
47
|
+
minor = int(parts[1]) if len(parts) > 1 else 0
|
|
48
|
+
patch = int(parts[2]) if len(parts) > 2 else 0
|
|
49
|
+
return (major, minor, patch)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
logger.warning(f"Failed to parse macOS version: {e}")
|
|
52
|
+
return (0, 0, 0)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def supports_process_tap() -> bool:
|
|
56
|
+
"""
|
|
57
|
+
Check if the current macOS version supports Process Tap API.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
True if macOS 14.4+, False otherwise
|
|
61
|
+
"""
|
|
62
|
+
major, minor, _ = get_macos_version()
|
|
63
|
+
return major > 14 or (major == 14 and minor >= 4)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def find_helper_binary() -> Optional[Path]:
|
|
67
|
+
"""
|
|
68
|
+
Find the proctap-macos Swift CLI helper binary.
|
|
69
|
+
|
|
70
|
+
Searches in the following locations:
|
|
71
|
+
1. Same directory as this module
|
|
72
|
+
2. Package data directory
|
|
73
|
+
3. System PATH
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Path to helper binary, or None if not found
|
|
77
|
+
"""
|
|
78
|
+
# Try relative to this module
|
|
79
|
+
module_dir = Path(__file__).parent
|
|
80
|
+
candidates = [
|
|
81
|
+
module_dir / "proctap-macos",
|
|
82
|
+
module_dir.parent / "bin" / "proctap-macos",
|
|
83
|
+
module_dir.parent.parent / "bin" / "proctap-macos",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
for candidate in candidates:
|
|
87
|
+
if candidate.exists() and os.access(candidate, os.X_OK):
|
|
88
|
+
logger.debug(f"Found helper binary: {candidate}")
|
|
89
|
+
return candidate
|
|
90
|
+
|
|
91
|
+
# Try PATH
|
|
92
|
+
import shutil
|
|
93
|
+
path_binary = shutil.which("proctap-macos")
|
|
94
|
+
if path_binary:
|
|
95
|
+
logger.debug(f"Found helper binary in PATH: {path_binary}")
|
|
96
|
+
return Path(path_binary)
|
|
97
|
+
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class MacOSBackend(AudioBackend):
|
|
102
|
+
"""
|
|
103
|
+
macOS implementation using Core Audio Process Tap API.
|
|
104
|
+
|
|
105
|
+
This backend uses a Swift CLI helper (proctap-macos) that interfaces with
|
|
106
|
+
Core Audio Process Tap API (macOS 14.4+) to capture audio from a specific
|
|
107
|
+
process.
|
|
108
|
+
|
|
109
|
+
The helper outputs raw PCM audio to stdout, which is read by this Python backend.
|
|
110
|
+
|
|
111
|
+
Requirements:
|
|
112
|
+
- macOS 14.4 (Sonoma) or later
|
|
113
|
+
- Swift CLI helper binary (proctap-macos)
|
|
114
|
+
- Audio capture permission
|
|
115
|
+
|
|
116
|
+
Limitations:
|
|
117
|
+
- Requires macOS 14.4+
|
|
118
|
+
- Target process must be actively playing audio
|
|
119
|
+
- Requires user permission for audio capture
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
pid: int,
|
|
125
|
+
sample_rate: int = 48000,
|
|
126
|
+
channels: int = 2,
|
|
127
|
+
sample_width: int = 2,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""
|
|
130
|
+
Initialize macOS backend.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
pid: Process ID to capture audio from
|
|
134
|
+
sample_rate: Sample rate in Hz (default: 48000)
|
|
135
|
+
channels: Number of channels (default: 2 for stereo)
|
|
136
|
+
sample_width: Bytes per sample (default: 2 for 16-bit)
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
RuntimeError: If macOS version < 14.4 or helper binary not found
|
|
140
|
+
"""
|
|
141
|
+
super().__init__(pid)
|
|
142
|
+
|
|
143
|
+
# Check macOS version
|
|
144
|
+
if not supports_process_tap():
|
|
145
|
+
major, minor, patch = get_macos_version()
|
|
146
|
+
raise RuntimeError(
|
|
147
|
+
f"macOS {major}.{minor}.{patch} does not support Process Tap API. "
|
|
148
|
+
"Requires macOS 14.4 (Sonoma) or later."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Find helper binary
|
|
152
|
+
self._helper_path = find_helper_binary()
|
|
153
|
+
if self._helper_path is None:
|
|
154
|
+
raise RuntimeError(
|
|
155
|
+
"Swift CLI helper 'proctap-macos' not found. "
|
|
156
|
+
"Please ensure the helper binary is installed and in PATH, "
|
|
157
|
+
"or located in the package directory."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
self._sample_rate = sample_rate
|
|
161
|
+
self._channels = channels
|
|
162
|
+
self._sample_width = sample_width
|
|
163
|
+
self._bits_per_sample = sample_width * 8
|
|
164
|
+
|
|
165
|
+
self._process: Optional[subprocess.Popen] = None
|
|
166
|
+
self._audio_queue: queue.Queue[bytes] = queue.Queue(maxsize=100)
|
|
167
|
+
self._reader_thread: Optional[threading.Thread] = None
|
|
168
|
+
self._stop_event = threading.Event()
|
|
169
|
+
self._is_running = False
|
|
170
|
+
|
|
171
|
+
logger.info(
|
|
172
|
+
f"Initialized MacOSBackend for PID {pid} "
|
|
173
|
+
f"({sample_rate}Hz, {channels}ch, {self._bits_per_sample}bit)"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def start(self) -> None:
|
|
177
|
+
"""
|
|
178
|
+
Start audio capture from the target process.
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
RuntimeError: If capture fails to start
|
|
182
|
+
"""
|
|
183
|
+
if self._is_running:
|
|
184
|
+
logger.warning("Audio capture is already running")
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
# Build command to launch Swift helper
|
|
189
|
+
cmd = [
|
|
190
|
+
str(self._helper_path),
|
|
191
|
+
"--pid", str(self._pid),
|
|
192
|
+
"--sample-rate", str(self._sample_rate),
|
|
193
|
+
"--channels", str(self._channels),
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
logger.debug(f"Launching helper: {' '.join(cmd)}")
|
|
197
|
+
|
|
198
|
+
# Start helper process
|
|
199
|
+
self._process = subprocess.Popen(
|
|
200
|
+
cmd,
|
|
201
|
+
stdout=subprocess.PIPE,
|
|
202
|
+
stderr=subprocess.PIPE,
|
|
203
|
+
bufsize=0, # Unbuffered
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Start reader thread
|
|
207
|
+
self._stop_event.clear()
|
|
208
|
+
self._reader_thread = threading.Thread(
|
|
209
|
+
target=self._reader_worker,
|
|
210
|
+
daemon=True
|
|
211
|
+
)
|
|
212
|
+
self._reader_thread.start()
|
|
213
|
+
|
|
214
|
+
self._is_running = True
|
|
215
|
+
logger.info(f"Started audio capture for PID {self._pid}")
|
|
216
|
+
|
|
217
|
+
except Exception as e:
|
|
218
|
+
self._is_running = False
|
|
219
|
+
if self._process:
|
|
220
|
+
self._process.terminate()
|
|
221
|
+
self._process = None
|
|
222
|
+
raise RuntimeError(f"Failed to start audio capture: {e}") from e
|
|
223
|
+
|
|
224
|
+
def stop(self) -> None:
|
|
225
|
+
"""Stop audio capture."""
|
|
226
|
+
if not self._is_running:
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
self._stop_event.set()
|
|
230
|
+
self._is_running = False
|
|
231
|
+
|
|
232
|
+
# Wait for reader thread
|
|
233
|
+
if self._reader_thread and self._reader_thread.is_alive():
|
|
234
|
+
self._reader_thread.join(timeout=2.0)
|
|
235
|
+
|
|
236
|
+
# Terminate helper process
|
|
237
|
+
if self._process:
|
|
238
|
+
try:
|
|
239
|
+
self._process.terminate()
|
|
240
|
+
self._process.wait(timeout=1.0)
|
|
241
|
+
except subprocess.TimeoutExpired:
|
|
242
|
+
self._process.kill()
|
|
243
|
+
self._process.wait()
|
|
244
|
+
finally:
|
|
245
|
+
self._process = None
|
|
246
|
+
|
|
247
|
+
logger.info("Stopped audio capture")
|
|
248
|
+
|
|
249
|
+
def read(self) -> Optional[bytes]:
|
|
250
|
+
"""
|
|
251
|
+
Read audio data from the capture buffer.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
PCM audio data as bytes, or None if no data is available
|
|
255
|
+
"""
|
|
256
|
+
if not self._is_running:
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
return self._audio_queue.get(timeout=0.1)
|
|
261
|
+
except queue.Empty:
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
def get_format(self) -> dict[str, int]:
|
|
265
|
+
"""
|
|
266
|
+
Get audio format information.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Dictionary with 'sample_rate', 'channels', 'bits_per_sample'
|
|
270
|
+
"""
|
|
271
|
+
return {
|
|
272
|
+
'sample_rate': self._sample_rate,
|
|
273
|
+
'channels': self._channels,
|
|
274
|
+
'bits_per_sample': self._bits_per_sample,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
def close(self) -> None:
|
|
278
|
+
"""Clean up resources."""
|
|
279
|
+
self.stop()
|
|
280
|
+
|
|
281
|
+
def __del__(self) -> None:
|
|
282
|
+
"""Destructor to ensure cleanup."""
|
|
283
|
+
try:
|
|
284
|
+
self.close()
|
|
285
|
+
except:
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
def _reader_worker(self) -> None:
|
|
289
|
+
"""
|
|
290
|
+
Worker thread that reads PCM data from helper process stdout.
|
|
291
|
+
|
|
292
|
+
Reads audio in chunks and puts them into the queue.
|
|
293
|
+
"""
|
|
294
|
+
if not self._process or not self._process.stdout:
|
|
295
|
+
logger.error("Helper process not started")
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
# Calculate chunk size (10ms of audio)
|
|
300
|
+
chunk_frames = int(self._sample_rate * 0.01) # 10ms
|
|
301
|
+
chunk_bytes = chunk_frames * self._channels * self._sample_width
|
|
302
|
+
|
|
303
|
+
logger.debug(f"Reader worker started (chunk size: {chunk_bytes} bytes)")
|
|
304
|
+
|
|
305
|
+
while not self._stop_event.is_set():
|
|
306
|
+
try:
|
|
307
|
+
# Read chunk from stdout
|
|
308
|
+
chunk = self._process.stdout.read(chunk_bytes)
|
|
309
|
+
|
|
310
|
+
if not chunk:
|
|
311
|
+
# EOF or process terminated
|
|
312
|
+
logger.debug("Helper process output ended")
|
|
313
|
+
break
|
|
314
|
+
|
|
315
|
+
if len(chunk) == chunk_bytes:
|
|
316
|
+
# Put chunk in queue
|
|
317
|
+
try:
|
|
318
|
+
self._audio_queue.put_nowait(chunk)
|
|
319
|
+
except queue.Full:
|
|
320
|
+
# Drop old frames if queue is full
|
|
321
|
+
try:
|
|
322
|
+
self._audio_queue.get_nowait()
|
|
323
|
+
self._audio_queue.put_nowait(chunk)
|
|
324
|
+
except:
|
|
325
|
+
pass
|
|
326
|
+
|
|
327
|
+
except Exception as e:
|
|
328
|
+
if not self._stop_event.is_set():
|
|
329
|
+
logger.error(f"Error reading audio data: {e}")
|
|
330
|
+
break
|
|
331
|
+
|
|
332
|
+
logger.debug("Reader worker stopped")
|
|
333
|
+
|
|
334
|
+
except Exception as e:
|
|
335
|
+
logger.error(f"Reader worker error: {e}")
|
|
336
|
+
|
|
337
|
+
finally:
|
|
338
|
+
# Check for errors from helper process
|
|
339
|
+
if self._process and self._process.poll() is not None:
|
|
340
|
+
stderr_output = self._process.stderr.read().decode('utf-8', errors='ignore')
|
|
341
|
+
if stderr_output:
|
|
342
|
+
logger.error(f"Helper process error: {stderr_output}")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# Development notes:
|
|
346
|
+
#
|
|
347
|
+
# This implementation uses a Swift CLI helper binary that wraps Core Audio Process Tap API.
|
|
348
|
+
# The helper is a separate executable built with SwiftPM.
|
|
349
|
+
#
|
|
350
|
+
# Swift helper responsibilities:
|
|
351
|
+
# - Use kAudioHardwarePropertyTranslatePIDToProcessObject to get process object
|
|
352
|
+
# - Create CATapDescription with target process
|
|
353
|
+
# - Call AudioHardwareCreateProcessTap to create tap
|
|
354
|
+
# - Set up audio output format (16-bit PCM)
|
|
355
|
+
# - Stream PCM to stdout in continuous chunks
|
|
356
|
+
#
|
|
357
|
+
# Python backend responsibilities:
|
|
358
|
+
# - Launch helper process with appropriate arguments
|
|
359
|
+
# - Read PCM from helper's stdout
|
|
360
|
+
# - Buffer audio in queue for consumption
|
|
361
|
+
# - Handle process lifecycle and cleanup
|
|
362
|
+
#
|
|
363
|
+
# Advantages of this approach:
|
|
364
|
+
# - Clean separation of Swift/ObjC code from Python
|
|
365
|
+
# - No need for PyObjC or ctypes complexity
|
|
366
|
+
# - Helper can be code-signed independently
|
|
367
|
+
# - Easy to test Swift code separately
|
|
368
|
+
#
|
|
369
|
+
# Future improvements:
|
|
370
|
+
# - Add support for dynamic format negotiation
|
|
371
|
+
# - Implement error recovery and reconnection
|
|
372
|
+
# - Add metrics and monitoring
|
|
373
|
+
# - Optimize buffer management
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Windows audio capture backend using WASAPI Process Loopback.
|
|
3
|
+
|
|
4
|
+
This backend wraps the native C++ extension (_native) for Windows-specific
|
|
5
|
+
process audio capture functionality.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
from .base import AudioBackend
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WindowsBackend(AudioBackend):
|
|
19
|
+
"""
|
|
20
|
+
Windows implementation using WASAPI Process Loopback.
|
|
21
|
+
|
|
22
|
+
Requires:
|
|
23
|
+
- Windows 10 20H1 or later
|
|
24
|
+
- C++ native extension (_native)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, pid: int) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Initialize Windows backend.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
pid: Process ID to capture audio from
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ImportError: If the native extension cannot be imported
|
|
36
|
+
"""
|
|
37
|
+
super().__init__(pid)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
from .._native import ProcessLoopback # type: ignore[attr-defined]
|
|
41
|
+
self._native = ProcessLoopback(pid)
|
|
42
|
+
logger.debug(f"Initialized Windows WASAPI backend for PID {pid}")
|
|
43
|
+
except ImportError as e:
|
|
44
|
+
raise ImportError(
|
|
45
|
+
"Native extension (_native) could not be imported. "
|
|
46
|
+
"Please build the extension with: pip install -e .\n"
|
|
47
|
+
f"Original error: {e}"
|
|
48
|
+
) from e
|
|
49
|
+
|
|
50
|
+
def start(self) -> None:
|
|
51
|
+
"""Start WASAPI audio capture."""
|
|
52
|
+
self._native.start()
|
|
53
|
+
logger.debug(f"Started audio capture for PID {self._pid}")
|
|
54
|
+
|
|
55
|
+
def stop(self) -> None:
|
|
56
|
+
"""Stop WASAPI audio capture."""
|
|
57
|
+
try:
|
|
58
|
+
self._native.stop()
|
|
59
|
+
logger.debug(f"Stopped audio capture for PID {self._pid}")
|
|
60
|
+
except Exception as e:
|
|
61
|
+
logger.error(f"Error stopping capture: {e}")
|
|
62
|
+
|
|
63
|
+
def read(self) -> Optional[bytes]:
|
|
64
|
+
"""
|
|
65
|
+
Read audio data from WASAPI capture buffer.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
PCM audio data as bytes, or empty bytes if no data available
|
|
69
|
+
"""
|
|
70
|
+
return self._native.read()
|
|
71
|
+
|
|
72
|
+
def get_format(self) -> dict[str, int]:
|
|
73
|
+
"""
|
|
74
|
+
Get audio format from native backend.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Dictionary with 'sample_rate', 'channels', 'bits_per_sample'
|
|
78
|
+
|
|
79
|
+
Note:
|
|
80
|
+
The Windows native backend uses a fixed format:
|
|
81
|
+
- 44100 Hz sample rate
|
|
82
|
+
- 2 channels (stereo)
|
|
83
|
+
- 16 bits per sample
|
|
84
|
+
"""
|
|
85
|
+
return self._native.get_format()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Contrib modules for ProcessAudioTap.
|
|
3
|
+
|
|
4
|
+
This package contains optional integrations and utilities that extend
|
|
5
|
+
ProcessAudioTap's functionality.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__all__: list[str] = []
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from .discord_source import ProcessAudioSource
|
|
14
|
+
__all__.append("ProcessAudioSource")
|
|
15
|
+
except ImportError:
|
|
16
|
+
# discord.py is not installed
|
|
17
|
+
pass
|