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,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