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