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
proctap/backends/base.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Abstract base class for platform-specific audio capture backends.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AudioBackend(ABC):
|
|
12
|
+
"""
|
|
13
|
+
Abstract base class for audio capture backends.
|
|
14
|
+
|
|
15
|
+
Each platform-specific backend must implement these methods to provide
|
|
16
|
+
process-specific audio capture functionality.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, pid: int) -> None:
|
|
20
|
+
"""
|
|
21
|
+
Initialize the backend for a specific process.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
pid: Process ID to capture audio from
|
|
25
|
+
"""
|
|
26
|
+
self._pid = pid
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def pid(self) -> int:
|
|
30
|
+
"""Get the target process ID."""
|
|
31
|
+
return self._pid
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def start(self) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Start audio capture from the target process.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
RuntimeError: If capture fails to start
|
|
40
|
+
"""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def stop(self) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Stop audio capture.
|
|
47
|
+
|
|
48
|
+
Should be safe to call multiple times.
|
|
49
|
+
"""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def read(self) -> Optional[bytes]:
|
|
54
|
+
"""
|
|
55
|
+
Read audio data from the capture buffer.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
PCM audio data as bytes, or None if no data is available
|
|
59
|
+
|
|
60
|
+
Note:
|
|
61
|
+
This method should not block for extended periods.
|
|
62
|
+
Return None quickly if no data is available.
|
|
63
|
+
"""
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def get_format(self) -> dict[str, int]:
|
|
68
|
+
"""
|
|
69
|
+
Get audio format information.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Dictionary with keys:
|
|
73
|
+
- 'sample_rate': Sample rate in Hz (e.g., 44100)
|
|
74
|
+
- 'channels': Number of channels (e.g., 2 for stereo)
|
|
75
|
+
- 'bits_per_sample': Bits per sample (e.g., 16)
|
|
76
|
+
"""
|
|
77
|
+
pass
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Linux audio capture backend.
|
|
3
|
+
|
|
4
|
+
This module provides process-specific audio capture on Linux using PulseAudio
|
|
5
|
+
or PipeWire (via PulseAudio compatibility layer).
|
|
6
|
+
|
|
7
|
+
STATUS: Experimental - PulseAudio support implemented
|
|
8
|
+
NOTE: Requires pulsectl library (pip install pulsectl)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Optional, Callable
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
import logging
|
|
16
|
+
import queue
|
|
17
|
+
import threading
|
|
18
|
+
|
|
19
|
+
from .base import AudioBackend
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# Type alias for audio callback
|
|
24
|
+
AudioCallback = Callable[[bytes, int], None]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LinuxAudioStrategy(ABC):
|
|
28
|
+
"""
|
|
29
|
+
Abstract base class for Linux audio capture strategies.
|
|
30
|
+
|
|
31
|
+
Allows switching between PulseAudio and PipeWire implementations.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def connect(self) -> None:
|
|
36
|
+
"""Connect to the audio server."""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def find_process_stream(self, pid: int) -> bool:
|
|
41
|
+
"""
|
|
42
|
+
Find audio stream for the target process.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
pid: Process ID to find
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
True if stream found, False otherwise
|
|
49
|
+
"""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def start_capture(self) -> None:
|
|
54
|
+
"""Start capturing audio from the target stream."""
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def stop_capture(self) -> None:
|
|
59
|
+
"""Stop capturing audio."""
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def read_audio(self, timeout: float = 0.1) -> Optional[bytes]:
|
|
64
|
+
"""
|
|
65
|
+
Read audio data from capture buffer.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
timeout: Maximum time to wait for data
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
PCM audio data as bytes, or None if no data available
|
|
72
|
+
"""
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def close(self) -> None:
|
|
77
|
+
"""Clean up resources."""
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def get_format(self) -> dict[str, int]:
|
|
82
|
+
"""
|
|
83
|
+
Get audio format information.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Dictionary with 'sample_rate', 'channels', 'bits_per_sample'
|
|
87
|
+
"""
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PulseAudioStrategy(LinuxAudioStrategy):
|
|
92
|
+
"""
|
|
93
|
+
PulseAudio-based audio capture strategy.
|
|
94
|
+
|
|
95
|
+
Uses pulsectl library to interact with PulseAudio server.
|
|
96
|
+
Works on systems with PulseAudio or PipeWire (via pulseaudio-compat layer).
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
pid: int,
|
|
102
|
+
sample_rate: int = 44100,
|
|
103
|
+
channels: int = 2,
|
|
104
|
+
sample_width: int = 2,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Initialize PulseAudio strategy.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
pid: Target process ID
|
|
111
|
+
sample_rate: Sample rate in Hz (default: 44100)
|
|
112
|
+
channels: Number of channels (default: 2 for stereo)
|
|
113
|
+
sample_width: Bytes per sample (default: 2 for 16-bit)
|
|
114
|
+
"""
|
|
115
|
+
self._pid = pid
|
|
116
|
+
self._sample_rate = sample_rate
|
|
117
|
+
self._channels = channels
|
|
118
|
+
self._sample_width = sample_width
|
|
119
|
+
self._bits_per_sample = sample_width * 8
|
|
120
|
+
|
|
121
|
+
self._pulse = None
|
|
122
|
+
self._sink_input_index = None
|
|
123
|
+
self._loopback_module_index = None
|
|
124
|
+
self._capture_stream = None
|
|
125
|
+
self._audio_queue: queue.Queue[bytes] = queue.Queue(maxsize=100)
|
|
126
|
+
self._capture_thread: Optional[threading.Thread] = None
|
|
127
|
+
self._stop_event = threading.Event()
|
|
128
|
+
|
|
129
|
+
# Try to import pulsectl
|
|
130
|
+
try:
|
|
131
|
+
import pulsectl
|
|
132
|
+
self._pulsectl = pulsectl
|
|
133
|
+
except ImportError as e:
|
|
134
|
+
raise RuntimeError(
|
|
135
|
+
"pulsectl library is required for Linux audio capture. "
|
|
136
|
+
"Install it with: pip install pulsectl"
|
|
137
|
+
) from e
|
|
138
|
+
|
|
139
|
+
def connect(self) -> None:
|
|
140
|
+
"""Connect to PulseAudio server."""
|
|
141
|
+
try:
|
|
142
|
+
self._pulse = self._pulsectl.Pulse('proctap')
|
|
143
|
+
logger.info("Connected to PulseAudio server")
|
|
144
|
+
except Exception as e:
|
|
145
|
+
raise RuntimeError(
|
|
146
|
+
f"Failed to connect to PulseAudio server: {e}. "
|
|
147
|
+
"Make sure PulseAudio or PipeWire (with pulseaudio-compat) is running."
|
|
148
|
+
) from e
|
|
149
|
+
|
|
150
|
+
def find_process_stream(self, pid: int) -> bool:
|
|
151
|
+
"""
|
|
152
|
+
Find sink-input for the target process.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
pid: Process ID to find
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
True if stream found, False otherwise
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
RuntimeError: If not connected to PulseAudio
|
|
162
|
+
"""
|
|
163
|
+
if self._pulse is None:
|
|
164
|
+
raise RuntimeError("Not connected to PulseAudio. Call connect() first.")
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
sink_inputs = self._pulse.sink_input_list()
|
|
168
|
+
logger.debug(f"Found {len(sink_inputs)} sink inputs")
|
|
169
|
+
|
|
170
|
+
for sink_input in sink_inputs:
|
|
171
|
+
# Check application.process.id property
|
|
172
|
+
process_id_str = sink_input.proplist.get('application.process.id')
|
|
173
|
+
if process_id_str and process_id_str == str(pid):
|
|
174
|
+
self._sink_input_index = sink_input.index
|
|
175
|
+
logger.info(
|
|
176
|
+
f"Found sink-input #{sink_input.index} for PID {pid}: "
|
|
177
|
+
f"{sink_input.proplist.get('application.name', 'Unknown')}"
|
|
178
|
+
)
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
logger.warning(f"No audio stream found for PID {pid}")
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
except Exception as e:
|
|
185
|
+
logger.error(f"Error finding process stream: {e}")
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
def start_capture(self) -> None:
|
|
189
|
+
"""
|
|
190
|
+
Start capturing audio from the target stream.
|
|
191
|
+
|
|
192
|
+
Creates a loopback module to capture audio from the sink-input.
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
RuntimeError: If sink-input not found or capture fails to start
|
|
196
|
+
"""
|
|
197
|
+
if self._sink_input_index is None:
|
|
198
|
+
raise RuntimeError(
|
|
199
|
+
"No sink-input found. Call find_process_stream() first."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
# Get sink-input details
|
|
204
|
+
sink_input = self._pulse.sink_input_info(self._sink_input_index)
|
|
205
|
+
sink_index = sink_input.sink
|
|
206
|
+
|
|
207
|
+
# Create monitor source name
|
|
208
|
+
sink_info = self._pulse.sink_info(sink_index)
|
|
209
|
+
monitor_source = sink_info.monitor_source_name
|
|
210
|
+
|
|
211
|
+
# Load module-loopback to capture from this specific sink-input
|
|
212
|
+
# Note: This creates a loopback from the entire sink, not per-app
|
|
213
|
+
# A better approach would use module-remap-source, but that's more complex
|
|
214
|
+
|
|
215
|
+
logger.info(f"Creating loopback from sink {sink_index} (monitor: {monitor_source})")
|
|
216
|
+
|
|
217
|
+
# For now, we'll use a simpler approach: create a simple recorder
|
|
218
|
+
# that reads from the monitor source and filters by checking timestamps
|
|
219
|
+
# TODO: Implement proper per-sink-input capture using module-remap-source
|
|
220
|
+
|
|
221
|
+
# Start capture thread
|
|
222
|
+
self._stop_event.clear()
|
|
223
|
+
self._capture_thread = threading.Thread(
|
|
224
|
+
target=self._capture_worker,
|
|
225
|
+
args=(monitor_source,),
|
|
226
|
+
daemon=True
|
|
227
|
+
)
|
|
228
|
+
self._capture_thread.start()
|
|
229
|
+
|
|
230
|
+
logger.info("Audio capture started")
|
|
231
|
+
|
|
232
|
+
except Exception as e:
|
|
233
|
+
raise RuntimeError(f"Failed to start audio capture: {e}") from e
|
|
234
|
+
|
|
235
|
+
def _capture_worker(self, source_name: str) -> None:
|
|
236
|
+
"""
|
|
237
|
+
Worker thread that captures audio from PulseAudio.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
source_name: Name of the source to capture from
|
|
241
|
+
"""
|
|
242
|
+
try:
|
|
243
|
+
# Create a simple recorder using pulsectl
|
|
244
|
+
# Note: This is a simplified implementation
|
|
245
|
+
# For production, we'd need more sophisticated stream handling
|
|
246
|
+
|
|
247
|
+
import subprocess
|
|
248
|
+
|
|
249
|
+
# Use parec (PulseAudio recorder) to capture raw PCM
|
|
250
|
+
cmd = [
|
|
251
|
+
'parec',
|
|
252
|
+
'--device', source_name,
|
|
253
|
+
'--rate', str(self._sample_rate),
|
|
254
|
+
'--channels', str(self._channels),
|
|
255
|
+
'--format', 's16le', # 16-bit signed little-endian
|
|
256
|
+
'--raw'
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
logger.debug(f"Starting parec: {' '.join(cmd)}")
|
|
260
|
+
|
|
261
|
+
proc = subprocess.Popen(
|
|
262
|
+
cmd,
|
|
263
|
+
stdout=subprocess.PIPE,
|
|
264
|
+
stderr=subprocess.PIPE,
|
|
265
|
+
bufsize=0
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Read in chunks (10ms of audio)
|
|
269
|
+
chunk_frames = int(self._sample_rate * 0.01) # 10ms
|
|
270
|
+
chunk_bytes = chunk_frames * self._channels * self._sample_width
|
|
271
|
+
|
|
272
|
+
while not self._stop_event.is_set():
|
|
273
|
+
try:
|
|
274
|
+
chunk = proc.stdout.read(chunk_bytes)
|
|
275
|
+
if not chunk:
|
|
276
|
+
break
|
|
277
|
+
|
|
278
|
+
if len(chunk) == chunk_bytes:
|
|
279
|
+
try:
|
|
280
|
+
self._audio_queue.put_nowait(chunk)
|
|
281
|
+
except queue.Full:
|
|
282
|
+
# Drop old frames if queue is full
|
|
283
|
+
try:
|
|
284
|
+
self._audio_queue.get_nowait()
|
|
285
|
+
self._audio_queue.put_nowait(chunk)
|
|
286
|
+
except:
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
except Exception as e:
|
|
290
|
+
logger.error(f"Error reading audio: {e}")
|
|
291
|
+
break
|
|
292
|
+
|
|
293
|
+
# Clean up
|
|
294
|
+
proc.terminate()
|
|
295
|
+
try:
|
|
296
|
+
proc.wait(timeout=1.0)
|
|
297
|
+
except subprocess.TimeoutExpired:
|
|
298
|
+
proc.kill()
|
|
299
|
+
|
|
300
|
+
logger.debug("Capture worker stopped")
|
|
301
|
+
|
|
302
|
+
except Exception as e:
|
|
303
|
+
logger.error(f"Capture worker error: {e}")
|
|
304
|
+
|
|
305
|
+
def stop_capture(self) -> None:
|
|
306
|
+
"""Stop capturing audio."""
|
|
307
|
+
self._stop_event.set()
|
|
308
|
+
|
|
309
|
+
if self._capture_thread and self._capture_thread.is_alive():
|
|
310
|
+
self._capture_thread.join(timeout=2.0)
|
|
311
|
+
|
|
312
|
+
# Unload loopback module if it exists
|
|
313
|
+
if self._pulse and self._loopback_module_index is not None:
|
|
314
|
+
try:
|
|
315
|
+
self._pulse.module_unload(self._loopback_module_index)
|
|
316
|
+
logger.debug(f"Unloaded loopback module #{self._loopback_module_index}")
|
|
317
|
+
except Exception as e:
|
|
318
|
+
logger.warning(f"Failed to unload loopback module: {e}")
|
|
319
|
+
finally:
|
|
320
|
+
self._loopback_module_index = None
|
|
321
|
+
|
|
322
|
+
logger.info("Audio capture stopped")
|
|
323
|
+
|
|
324
|
+
def read_audio(self, timeout: float = 0.1) -> Optional[bytes]:
|
|
325
|
+
"""
|
|
326
|
+
Read audio data from capture buffer.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
timeout: Maximum time to wait for data
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
PCM audio data as bytes, or None if no data available
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
return self._audio_queue.get(timeout=timeout)
|
|
336
|
+
except queue.Empty:
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
def close(self) -> None:
|
|
340
|
+
"""Clean up resources."""
|
|
341
|
+
self.stop_capture()
|
|
342
|
+
|
|
343
|
+
if self._pulse:
|
|
344
|
+
self._pulse.close()
|
|
345
|
+
self._pulse = None
|
|
346
|
+
logger.debug("Closed PulseAudio connection")
|
|
347
|
+
|
|
348
|
+
def get_format(self) -> dict[str, int]:
|
|
349
|
+
"""Get audio format information."""
|
|
350
|
+
return {
|
|
351
|
+
'sample_rate': self._sample_rate,
|
|
352
|
+
'channels': self._channels,
|
|
353
|
+
'bits_per_sample': self._bits_per_sample,
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class LinuxBackend(AudioBackend):
|
|
358
|
+
"""
|
|
359
|
+
Linux implementation for process-specific audio capture.
|
|
360
|
+
|
|
361
|
+
⚠️ EXPERIMENTAL: This backend uses PulseAudio and is in early testing phase.
|
|
362
|
+
|
|
363
|
+
Supports:
|
|
364
|
+
- PulseAudio (via pulsectl library)
|
|
365
|
+
- PipeWire (via PulseAudio compatibility layer)
|
|
366
|
+
|
|
367
|
+
Requirements:
|
|
368
|
+
- Linux with PulseAudio or PipeWire
|
|
369
|
+
- pulsectl library: pip install pulsectl
|
|
370
|
+
- parec command (usually in pulseaudio-utils package)
|
|
371
|
+
|
|
372
|
+
Limitations:
|
|
373
|
+
- Currently captures from the entire sink monitor (not per-application isolation)
|
|
374
|
+
- Requires the target process to be actively playing audio
|
|
375
|
+
- May capture audio from other applications on the same sink
|
|
376
|
+
|
|
377
|
+
TODO: Implement proper per-application isolation using module-remap-source
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
def __init__(
|
|
381
|
+
self,
|
|
382
|
+
pid: int,
|
|
383
|
+
sample_rate: int = 44100,
|
|
384
|
+
channels: int = 2,
|
|
385
|
+
sample_width: int = 2,
|
|
386
|
+
engine: str = "auto",
|
|
387
|
+
) -> None:
|
|
388
|
+
"""
|
|
389
|
+
Initialize Linux backend.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
pid: Process ID to capture audio from
|
|
393
|
+
sample_rate: Sample rate in Hz (default: 44100)
|
|
394
|
+
channels: Number of channels (default: 2 for stereo)
|
|
395
|
+
sample_width: Bytes per sample (default: 2 for 16-bit)
|
|
396
|
+
engine: Audio engine to use: "auto", "pulse", or "pipewire"
|
|
397
|
+
(default: "auto" - currently uses PulseAudio)
|
|
398
|
+
"""
|
|
399
|
+
super().__init__(pid)
|
|
400
|
+
|
|
401
|
+
self._sample_rate = sample_rate
|
|
402
|
+
self._channels = channels
|
|
403
|
+
self._sample_width = sample_width
|
|
404
|
+
self._engine = engine
|
|
405
|
+
self._is_running = False
|
|
406
|
+
|
|
407
|
+
# Select strategy based on engine parameter
|
|
408
|
+
if engine in ("auto", "pulse"):
|
|
409
|
+
self._strategy: Optional[LinuxAudioStrategy] = PulseAudioStrategy(
|
|
410
|
+
pid=pid,
|
|
411
|
+
sample_rate=sample_rate,
|
|
412
|
+
channels=channels,
|
|
413
|
+
sample_width=sample_width,
|
|
414
|
+
)
|
|
415
|
+
logger.info(f"Initialized LinuxBackend for PID {pid} (engine: PulseAudio)")
|
|
416
|
+
elif engine == "pipewire":
|
|
417
|
+
# Future: PipeWireStrategy implementation
|
|
418
|
+
raise NotImplementedError(
|
|
419
|
+
"Native PipeWire backend is not yet implemented. "
|
|
420
|
+
"Use engine='auto' or 'pulse' to use PulseAudio compatibility layer."
|
|
421
|
+
)
|
|
422
|
+
else:
|
|
423
|
+
raise ValueError(f"Unknown engine: {engine}. Use 'auto', 'pulse', or 'pipewire'")
|
|
424
|
+
|
|
425
|
+
def start(self) -> None:
|
|
426
|
+
"""
|
|
427
|
+
Start audio capture from the target process.
|
|
428
|
+
|
|
429
|
+
Raises:
|
|
430
|
+
RuntimeError: If capture fails to start
|
|
431
|
+
"""
|
|
432
|
+
if self._is_running:
|
|
433
|
+
logger.warning("Audio capture is already running")
|
|
434
|
+
return
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
# Connect to audio server
|
|
438
|
+
self._strategy.connect()
|
|
439
|
+
|
|
440
|
+
# Find process stream
|
|
441
|
+
if not self._strategy.find_process_stream(self._pid):
|
|
442
|
+
raise RuntimeError(
|
|
443
|
+
f"No audio stream found for PID {self._pid}. "
|
|
444
|
+
"Make sure the process is actively playing audio."
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# Start capture
|
|
448
|
+
self._strategy.start_capture()
|
|
449
|
+
self._is_running = True
|
|
450
|
+
|
|
451
|
+
logger.info(f"Started audio capture for PID {self._pid}")
|
|
452
|
+
|
|
453
|
+
except Exception as e:
|
|
454
|
+
self._is_running = False
|
|
455
|
+
raise RuntimeError(f"Failed to start audio capture: {e}") from e
|
|
456
|
+
|
|
457
|
+
def stop(self) -> None:
|
|
458
|
+
"""Stop audio capture."""
|
|
459
|
+
if not self._is_running:
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
self._strategy.stop_capture()
|
|
464
|
+
self._is_running = False
|
|
465
|
+
logger.info("Stopped audio capture")
|
|
466
|
+
except Exception as e:
|
|
467
|
+
logger.error(f"Error stopping capture: {e}")
|
|
468
|
+
|
|
469
|
+
def read(self) -> Optional[bytes]:
|
|
470
|
+
"""
|
|
471
|
+
Read audio data from the capture buffer.
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
PCM audio data as bytes, or None if no data is available
|
|
475
|
+
"""
|
|
476
|
+
if not self._is_running:
|
|
477
|
+
return None
|
|
478
|
+
|
|
479
|
+
return self._strategy.read_audio(timeout=0.1)
|
|
480
|
+
|
|
481
|
+
def get_format(self) -> dict[str, int]:
|
|
482
|
+
"""
|
|
483
|
+
Get audio format information.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
Dictionary with 'sample_rate', 'channels', 'bits_per_sample'
|
|
487
|
+
"""
|
|
488
|
+
return self._strategy.get_format()
|
|
489
|
+
|
|
490
|
+
def close(self) -> None:
|
|
491
|
+
"""Clean up resources."""
|
|
492
|
+
self.stop()
|
|
493
|
+
if self._strategy:
|
|
494
|
+
self._strategy.close()
|
|
495
|
+
|
|
496
|
+
def __del__(self) -> None:
|
|
497
|
+
"""Destructor to ensure cleanup."""
|
|
498
|
+
try:
|
|
499
|
+
self.close()
|
|
500
|
+
except:
|
|
501
|
+
pass
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# Development notes:
|
|
505
|
+
#
|
|
506
|
+
# Current implementation limitations:
|
|
507
|
+
# - Uses `parec` to capture from monitor source (entire sink, not per-app)
|
|
508
|
+
# - This means it may capture audio from other apps using the same sink
|
|
509
|
+
# - Proper per-app isolation requires module-remap-source or similar
|
|
510
|
+
#
|
|
511
|
+
# Future improvements:
|
|
512
|
+
# 1. Implement proper per-sink-input capture using module-remap-source
|
|
513
|
+
# 2. Add native PipeWire support (PipeWireStrategy class)
|
|
514
|
+
# 3. Improve error handling for edge cases
|
|
515
|
+
# 4. Add support for dynamic format negotiation
|
|
516
|
+
# 5. Optimize buffer management for lower latency
|
|
517
|
+
#
|
|
518
|
+
# References:
|
|
519
|
+
# - PulseAudio module-remap-source: https://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/Modules/#module-remap-source
|
|
520
|
+
# - pulsectl documentation: https://github.com/mk-fg/python-pulse-control
|
|
521
|
+
# - PipeWire PulseAudio compatibility: https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Config-PulseAudio
|