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,381 @@
1
+ """
2
+ Discord AudioSource implementation for proctap.
3
+
4
+ Streams process audio to Discord voice channels with automatic format conversion
5
+ and resampling to Discord's required format (48kHz, 16-bit PCM, stereo).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import struct
12
+ import threading
13
+ import time
14
+ from collections import deque
15
+ from typing import Optional
16
+
17
+ import numpy as np
18
+
19
+ try:
20
+ import discord
21
+ except ImportError as e:
22
+ raise ImportError(
23
+ "discord.py is required for ProcessAudioSource. "
24
+ "Install with: pip install discord.py"
25
+ ) from e
26
+
27
+ from ..core import ProcessAudioTap
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Discord audio constants
32
+ DISCORD_SAMPLE_RATE = 48000 # Hz
33
+ DISCORD_CHANNELS = 2 # Stereo
34
+ DISCORD_SAMPLE_SIZE = 2 # 16-bit = 2 bytes
35
+ DISCORD_FRAME_DURATION_MS = 20 # ms
36
+ DISCORD_SAMPLES_PER_FRAME = int(DISCORD_SAMPLE_RATE * DISCORD_FRAME_DURATION_MS / 1000)
37
+ DISCORD_FRAME_SIZE = DISCORD_SAMPLES_PER_FRAME * DISCORD_CHANNELS * DISCORD_SAMPLE_SIZE # 3840 bytes
38
+
39
+
40
+ class ProcessAudioSource(discord.AudioSource):
41
+ """
42
+ Discord AudioSource that captures audio from a specific process.
43
+
44
+ This class streams audio from a target process to Discord voice channels,
45
+ automatically handling format detection, conversion, and resampling.
46
+
47
+ Args:
48
+ pid: Process ID to capture audio from
49
+ gain: Audio gain multiplier (default: 1.0)
50
+ max_queue_frames: Maximum frames to buffer (default: 50)
51
+
52
+ Example:
53
+ ```python
54
+ import discord
55
+ from processaudiotap.contrib import ProcessAudioSource
56
+
57
+ # In your Discord bot
58
+ voice_client = await channel.connect()
59
+ source = ProcessAudioSource(pid=12345, gain=1.2)
60
+ voice_client.play(source)
61
+ ```
62
+
63
+ Note:
64
+ - Automatically resamples from native 44.1kHz to Discord's 48kHz
65
+ - Handles both 32-bit float and 16-bit PCM input formats
66
+ - Runs capture in a separate thread for minimal latency
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ pid: int,
72
+ gain: float = 1.0,
73
+ max_queue_frames: int = 50,
74
+ ) -> None:
75
+ self.pid = pid
76
+ self.gain = gain
77
+ self.max_queue_frames = max_queue_frames
78
+
79
+ self._tap: Optional[ProcessAudioTap] = None
80
+ self._capture_thread: Optional[threading.Thread] = None
81
+ self._stop_event = threading.Event()
82
+
83
+ # Audio queue and buffer
84
+ self._audio_queue: deque[bytes] = deque(maxlen=max_queue_frames)
85
+ self._queue_lock = threading.Lock()
86
+ self._buffer = bytearray()
87
+
88
+ # Format detection
89
+ self._source_format: Optional[dict[str, int]] = None
90
+ self._is_float32 = False
91
+ self._format_detected = False
92
+
93
+ # Statistics
94
+ self._frames_dropped = 0
95
+ self._frames_served = 0
96
+
97
+ logger.info(f"ProcessAudioSource created for PID {pid} (gain={gain})")
98
+
99
+ def start(self) -> None:
100
+ """Start audio capture from the target process."""
101
+ if self._capture_thread is not None:
102
+ logger.warning("Audio capture already started")
103
+ return
104
+
105
+ logger.info(f"Starting audio capture for PID {self.pid}")
106
+
107
+ # Create ProcessAudioTap
108
+ self._tap = ProcessAudioTap(pid=self.pid)
109
+ self._tap.start()
110
+
111
+ # Get source format
112
+ self._source_format = self._tap.get_format()
113
+ logger.info(f"Source format: {self._source_format}")
114
+
115
+ # Start capture thread
116
+ self._stop_event.clear()
117
+ self._capture_thread = threading.Thread(
118
+ target=self._capture_loop,
119
+ daemon=True,
120
+ name=f"ProcessAudioSource-{self.pid}"
121
+ )
122
+ self._capture_thread.start()
123
+
124
+ logger.info("Audio capture started")
125
+
126
+ def stop(self) -> None:
127
+ """Stop audio capture and release resources."""
128
+ if self._capture_thread is None:
129
+ return
130
+
131
+ logger.info("Stopping audio capture...")
132
+ self._stop_event.set()
133
+
134
+ if self._capture_thread.is_alive():
135
+ self._capture_thread.join(timeout=2.0)
136
+ self._capture_thread = None
137
+
138
+ if self._tap is not None:
139
+ try:
140
+ self._tap.close()
141
+ except Exception:
142
+ logger.exception("Error closing ProcessAudioTap")
143
+ finally:
144
+ self._tap = None
145
+
146
+ logger.info(
147
+ f"Audio capture stopped. Stats: served={self._frames_served}, dropped={self._frames_dropped}"
148
+ )
149
+
150
+ def _capture_loop(self) -> None:
151
+ """
152
+ Capture loop running in separate thread.
153
+ Continuously reads audio from ProcessAudioTap and queues converted frames.
154
+ """
155
+ logger.debug("Capture loop started")
156
+
157
+ while not self._stop_event.is_set():
158
+ try:
159
+ # Read audio with timeout
160
+ chunk = self._tap.read(timeout=0.5)
161
+
162
+ if chunk is None or len(chunk) == 0:
163
+ continue
164
+
165
+ # Detect format on first chunk
166
+ if not self._format_detected:
167
+ self._detect_format(chunk)
168
+
169
+ # Convert and resample audio
170
+ converted = self._convert_audio(chunk)
171
+
172
+ if converted:
173
+ with self._queue_lock:
174
+ try:
175
+ self._audio_queue.append(converted)
176
+ except IndexError:
177
+ # Queue full, frame dropped
178
+ self._frames_dropped += 1
179
+
180
+ except Exception:
181
+ logger.exception("Error in capture loop")
182
+ time.sleep(0.1) # Prevent busy loop on error
183
+
184
+ logger.debug("Capture loop ended")
185
+
186
+ def _detect_format(self, chunk: bytes) -> None:
187
+ """
188
+ Detect if audio data is 32-bit float or 16-bit PCM.
189
+
190
+ WASAPI may return 32-bit float despite requesting 16-bit PCM.
191
+ """
192
+ if self._format_detected:
193
+ return
194
+
195
+ if len(chunk) < 8:
196
+ return # Not enough data to detect
197
+
198
+ # Try to interpret as 32-bit float
199
+ try:
200
+ sample_count = len(chunk) // 4
201
+ floats = struct.unpack(f"{sample_count}f", chunk[:sample_count * 4])
202
+
203
+ # Check if values are in typical float range [-1.0, 1.0]
204
+ max_abs = max(abs(f) for f in floats[:min(100, len(floats))])
205
+
206
+ if 0.0 < max_abs <= 2.0: # Likely float32
207
+ self._is_float32 = True
208
+ logger.info("Detected 32-bit float PCM format")
209
+ else:
210
+ self._is_float32 = False
211
+ logger.info("Detected 16-bit PCM format")
212
+
213
+ except struct.error:
214
+ self._is_float32 = False
215
+ logger.info("Defaulting to 16-bit PCM format")
216
+
217
+ self._format_detected = True
218
+
219
+ def _convert_audio(self, chunk: bytes) -> Optional[bytes]:
220
+ """
221
+ Convert audio to Discord format (48kHz, 16-bit PCM, stereo).
222
+
223
+ Args:
224
+ chunk: Raw audio data from ProcessAudioTap
225
+
226
+ Returns:
227
+ Converted audio data, or None if conversion failed
228
+ """
229
+ if not self._source_format:
230
+ return None
231
+
232
+ try:
233
+ # Parse audio data
234
+ if self._is_float32:
235
+ # 32-bit float PCM
236
+ sample_count = len(chunk) // 4
237
+ audio_data = np.frombuffer(chunk, dtype=np.float32, count=sample_count)
238
+
239
+ # Check for NaN/Inf
240
+ if np.any(~np.isfinite(audio_data)):
241
+ logger.warning("NaN/Inf detected in audio data, skipping frame")
242
+ return None
243
+
244
+ # Convert float32 [-1.0, 1.0] to int16
245
+ audio_data = np.clip(audio_data * 32767.0 * self.gain, -32768, 32767)
246
+ audio_data = audio_data.astype(np.int16)
247
+ else:
248
+ # 16-bit PCM
249
+ sample_count = len(chunk) // 2
250
+ audio_data = np.frombuffer(chunk, dtype=np.int16, count=sample_count)
251
+
252
+ # Apply gain
253
+ if self.gain != 1.0:
254
+ audio_data = audio_data.astype(np.float32)
255
+ audio_data = np.clip(audio_data * self.gain, -32768, 32767)
256
+ audio_data = audio_data.astype(np.int16)
257
+
258
+ # Reshape to (frames, channels)
259
+ channels = self._source_format["channels"]
260
+ frames = len(audio_data) // channels
261
+
262
+ if frames == 0:
263
+ return None
264
+
265
+ audio_data = audio_data[:frames * channels].reshape(frames, channels)
266
+
267
+ # Handle mono to stereo conversion
268
+ if channels == 1:
269
+ audio_data = np.repeat(audio_data, 2, axis=1)
270
+
271
+ # Resample if necessary
272
+ source_rate = self._source_format["sample_rate"]
273
+ if source_rate != DISCORD_SAMPLE_RATE:
274
+ audio_data = self._resample(audio_data, source_rate, DISCORD_SAMPLE_RATE)
275
+
276
+ # Convert to bytes
277
+ return audio_data.tobytes()
278
+
279
+ except Exception:
280
+ logger.exception("Error converting audio")
281
+ return None
282
+
283
+ def _resample(
284
+ self,
285
+ audio: np.ndarray,
286
+ source_rate: int,
287
+ target_rate: int
288
+ ) -> np.ndarray:
289
+ """
290
+ Resample audio using linear interpolation.
291
+
292
+ Args:
293
+ audio: Audio data shaped (frames, channels)
294
+ source_rate: Source sample rate
295
+ target_rate: Target sample rate
296
+
297
+ Returns:
298
+ Resampled audio data
299
+ """
300
+ if source_rate == target_rate:
301
+ return audio
302
+
303
+ # Calculate resampling ratio
304
+ ratio = target_rate / source_rate
305
+ source_frames = len(audio)
306
+ target_frames = int(source_frames * ratio)
307
+
308
+ # Linear interpolation for each channel
309
+ source_indices = np.arange(source_frames)
310
+ target_indices = np.linspace(0, source_frames - 1, target_frames)
311
+
312
+ resampled = np.empty((target_frames, audio.shape[1]), dtype=audio.dtype)
313
+
314
+ for ch in range(audio.shape[1]):
315
+ resampled[:, ch] = np.interp(target_indices, source_indices, audio[:, ch])
316
+
317
+ return resampled
318
+
319
+ def read(self) -> bytes:
320
+ """
321
+ Read one Discord audio frame (20ms @ 48kHz = 3840 bytes).
322
+
323
+ This method is called by discord.py's voice client.
324
+
325
+ Returns:
326
+ 3840 bytes of 16-bit PCM stereo audio, or silence if no data available
327
+ """
328
+ # Accumulate data until we have a full Discord frame
329
+ while len(self._buffer) < DISCORD_FRAME_SIZE:
330
+ with self._queue_lock:
331
+ if not self._audio_queue:
332
+ # No data available, return silence
333
+ silence = b"\x00" * DISCORD_FRAME_SIZE
334
+ return silence
335
+
336
+ chunk = self._audio_queue.popleft()
337
+ self._buffer.extend(chunk)
338
+
339
+ # Extract one Discord frame
340
+ frame = bytes(self._buffer[:DISCORD_FRAME_SIZE])
341
+ del self._buffer[:DISCORD_FRAME_SIZE]
342
+
343
+ self._frames_served += 1
344
+ return frame
345
+
346
+ def is_opus(self) -> bool:
347
+ """
348
+ Indicate whether this source provides Opus-encoded audio.
349
+
350
+ Returns:
351
+ False (this source provides raw PCM)
352
+ """
353
+ return False
354
+
355
+ def cleanup(self) -> None:
356
+ """
357
+ Cleanup resources when discord.py is done with this source.
358
+
359
+ This is called automatically by discord.py.
360
+ """
361
+ self.stop()
362
+
363
+ @property
364
+ def stats(self) -> dict[str, int]:
365
+ """
366
+ Get capture statistics.
367
+
368
+ Returns:
369
+ Dictionary with keys:
370
+ - 'frames_served': Number of frames successfully served
371
+ - 'frames_dropped': Number of frames dropped due to queue overflow
372
+ - 'queue_size': Current number of frames in queue
373
+ """
374
+ with self._queue_lock:
375
+ queue_size = len(self._audio_queue)
376
+
377
+ return {
378
+ "frames_served": self._frames_served,
379
+ "frames_dropped": self._frames_dropped,
380
+ "queue_size": queue_size,
381
+ }
proctap/core.py ADDED
@@ -0,0 +1,222 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Callable, Optional, AsyncIterator
5
+ import threading
6
+ import queue
7
+ import asyncio
8
+ import logging
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # -------------------------------
13
+ # Backend import (platform-specific)
14
+ # -------------------------------
15
+
16
+ from .backends import get_backend
17
+ from .backends.base import AudioBackend
18
+
19
+ AudioCallback = Callable[[bytes, int], None] # (pcm_bytes, num_frames)
20
+
21
+
22
+ @dataclass
23
+ class StreamConfig:
24
+ sample_rate: int = 44100 # Hz
25
+ channels: int = 2
26
+ # NOTE:
27
+ # 現状 backend 側でバッファサイズは制御していないので
28
+ # frames_per_buffer は「論理的なサイズ」として扱うだけ。
29
+ frames_per_buffer: int = 480 # 10ms @ 48kHz
30
+
31
+
32
+ class ProcessAudioTap:
33
+ """
34
+ High-level API for process-specific audio capture.
35
+
36
+ Supports multiple platforms:
37
+ - Windows: WASAPI Process Loopback (fully implemented)
38
+ - Linux: PulseAudio/PipeWire (under development)
39
+ - macOS: Core Audio (planned, not yet implemented)
40
+
41
+ Usage:
42
+ - Callback mode: start(on_data=callback)
43
+ - Async mode: async for chunk in tap.iter_chunks()
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ pid: int,
49
+ config: StreamConfig | None = None,
50
+ on_data: Optional[AudioCallback] = None,
51
+ ) -> None:
52
+ self._pid = pid
53
+ self._cfg = config or StreamConfig()
54
+ self._on_data = on_data
55
+
56
+ # Get platform-specific backend
57
+ # Pass config to backend (Windows uses fixed format, Linux uses config)
58
+ self._backend: AudioBackend = get_backend(
59
+ pid=pid,
60
+ sample_rate=self._cfg.sample_rate,
61
+ channels=self._cfg.channels,
62
+ sample_width=2, # 16-bit = 2 bytes
63
+ )
64
+ logger.debug(f"Using backend: {type(self._backend).__name__}")
65
+
66
+ self._thread: Optional[threading.Thread] = None
67
+ self._stop_event = threading.Event()
68
+ self._async_queue: "queue.Queue[bytes | None]" = queue.Queue()
69
+
70
+ # --- public API -----------------------------------------------------
71
+
72
+ def start(self) -> None:
73
+ if self._thread is not None:
74
+ # すでに start 済みなら何もしない
75
+ return
76
+
77
+ # Start platform-specific backend
78
+ self._backend.start()
79
+
80
+ self._stop_event.clear()
81
+ self._thread = threading.Thread(target=self._worker, daemon=True)
82
+ self._thread.start()
83
+
84
+ def stop(self) -> None:
85
+ self._stop_event.set()
86
+
87
+ if self._thread is not None:
88
+ self._thread.join(timeout=1.0)
89
+ self._thread = None
90
+
91
+ try:
92
+ self._backend.stop()
93
+ except Exception:
94
+ logger.exception("Error while stopping capture")
95
+
96
+ def close(self) -> None:
97
+ self.stop()
98
+
99
+ def __enter__(self) -> "ProcessAudioTap":
100
+ self.start()
101
+ return self
102
+
103
+ def __exit__(self, exc_type, exc, tb) -> None:
104
+ self.close()
105
+
106
+ # --- properties -----------------------------------------------------
107
+
108
+ @property
109
+ def is_running(self) -> bool:
110
+ """Check if audio capture is currently running."""
111
+ return self._thread is not None and self._thread.is_alive()
112
+
113
+ @property
114
+ def pid(self) -> int:
115
+ """Get the target process ID."""
116
+ return self._pid
117
+
118
+ @property
119
+ def config(self) -> StreamConfig:
120
+ """Get the stream configuration (note: does not affect native backend)."""
121
+ return self._cfg
122
+
123
+ # --- utility methods ------------------------------------------------
124
+
125
+ def set_callback(self, callback: Optional[AudioCallback]) -> None:
126
+ """
127
+ Change the audio data callback.
128
+
129
+ Args:
130
+ callback: New callback function, or None to remove callback
131
+ """
132
+ self._on_data = callback
133
+
134
+ def get_format(self) -> dict[str, int]:
135
+ """
136
+ Get audio format information from the native backend.
137
+
138
+ Returns:
139
+ Dictionary with keys:
140
+ - 'sample_rate': Sample rate in Hz (e.g., 44100)
141
+ - 'channels': Number of channels (e.g., 2 for stereo)
142
+ - 'bits_per_sample': Bits per sample (e.g., 16)
143
+ """
144
+ return self._backend.get_format()
145
+
146
+ def read(self, timeout: float = 1.0) -> Optional[bytes]:
147
+ """
148
+ Synchronous API: Read one audio chunk (blocking).
149
+
150
+ Args:
151
+ timeout: Maximum time to wait for data in seconds
152
+
153
+ Returns:
154
+ PCM audio data as bytes, or None if timeout or no data
155
+
156
+ Note:
157
+ This is a simple synchronous alternative to the async API.
158
+ The capture must be started first with start().
159
+ """
160
+ if not self.is_running:
161
+ raise RuntimeError("Capture is not running. Call start() first.")
162
+
163
+ try:
164
+ return self._async_queue.get(timeout=timeout)
165
+ except queue.Empty:
166
+ return None
167
+
168
+ # --- async interface ------------------------------------------------
169
+
170
+ async def iter_chunks(self) -> AsyncIterator[bytes]:
171
+ """
172
+ Async generator that yields PCM chunks as bytes.
173
+ """
174
+ loop = asyncio.get_running_loop()
175
+
176
+ while True:
177
+ chunk = await loop.run_in_executor(None, self._async_queue.get)
178
+ if chunk is None: # sentinel
179
+ break
180
+ yield chunk
181
+
182
+ # --- worker thread --------------------------------------------------
183
+
184
+ def _worker(self) -> None:
185
+ """
186
+ Loop:
187
+ data = backend.read()
188
+ -> callback
189
+ -> async_queue
190
+ """
191
+ while not self._stop_event.is_set():
192
+ try:
193
+ data = self._backend.read()
194
+ except Exception:
195
+ logger.exception("Error reading data from backend")
196
+ continue
197
+
198
+ if not data:
199
+ # パケットがまだ無いケース。ここで sleep 入れるかは後で調整。
200
+ continue
201
+
202
+ # callback
203
+ if self._on_data is not None:
204
+ try:
205
+ # frames 数は backend から直接取れないので、とりあえず -1 を渡す。
206
+ # TODO: _backend.get_format() を見て frame 数を計算する改善余地あり。
207
+ self._on_data(data, -1)
208
+ except Exception:
209
+ logger.exception("Error in audio callback")
210
+
211
+ # async queue
212
+ try:
213
+ self._async_queue.put_nowait(data)
214
+ except queue.Full:
215
+ # リアルタイム性重視なので捨てる
216
+ pass
217
+
218
+ # 終了シグナル
219
+ try:
220
+ self._async_queue.put_nowait(None)
221
+ except queue.Full:
222
+ pass