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.
@@ -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