framesource 0.3.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.
- frame_processors/__init__.py +37 -0
- frame_source/__init__.py +50 -0
- framesource/__init__.py +93 -0
- framesource/_msmf_config.py +26 -0
- framesource/_version.py +24 -0
- framesource/discovery.py +109 -0
- framesource/errors.py +82 -0
- framesource/factory.py +290 -0
- framesource/processors/__init__.py +34 -0
- framesource/processors/equirectangular360_processor.py +577 -0
- framesource/processors/fisheye2equirectangular_processor.py +328 -0
- framesource/processors/frame_processor.py +30 -0
- framesource/processors/hyperspectral_processor.py +23 -0
- framesource/processors/realsense_depth_processor.py +90 -0
- framesource/py.typed +0 -0
- framesource/sources/__init__.py +40 -0
- framesource/sources/audiospectrogram_capture.py +1068 -0
- framesource/sources/basler_capture.py +477 -0
- framesource/sources/folder_capture.py +920 -0
- framesource/sources/genicam_capture.py +681 -0
- framesource/sources/huateng_capture.py +245 -0
- framesource/sources/ipcamera_capture.py +254 -0
- framesource/sources/mvsdk.py +2454 -0
- framesource/sources/realsense_capture.py +565 -0
- framesource/sources/screen_capture.py +800 -0
- framesource/sources/video_capture_base.py +560 -0
- framesource/sources/video_file_capture.py +259 -0
- framesource/sources/webcam_capture.py +511 -0
- framesource/sources/ximea_capture.py +299 -0
- framesource/threading_utils.py +790 -0
- framesource-0.3.0.dist-info/METADATA +787 -0
- framesource-0.3.0.dist-info/RECORD +37 -0
- framesource-0.3.0.dist-info/WHEEL +5 -0
- framesource-0.3.0.dist-info/licenses/LICENSE +21 -0
- framesource-0.3.0.dist-info/scm_file_list.json +276 -0
- framesource-0.3.0.dist-info/scm_version.json +8 -0
- framesource-0.3.0.dist-info/top_level.txt +3 -0
|
@@ -0,0 +1,1068 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
import warnings
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Optional, Union
|
|
6
|
+
|
|
7
|
+
import cv2
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import librosa
|
|
12
|
+
import pyaudio
|
|
13
|
+
import soundfile as sf # noqa: F401 - availability probe for the optional "audio" extra
|
|
14
|
+
|
|
15
|
+
AUDIO_AVAILABLE = True
|
|
16
|
+
except ImportError as e:
|
|
17
|
+
AUDIO_AVAILABLE = False
|
|
18
|
+
MISSING_DEPS = str(e)
|
|
19
|
+
|
|
20
|
+
from ..discovery import DeviceInfo
|
|
21
|
+
from ..errors import MissingDependencyError
|
|
22
|
+
from .video_capture_base import VideoCaptureBase
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_freq_range(value: Union[str, tuple[float, float], list]) -> tuple[float, float]:
|
|
28
|
+
"""Parse a frequency range given as ``(min, max)``, ``[min, max]`` or ``"min,max"``.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ValueError: If the value does not contain exactly two numeric entries.
|
|
32
|
+
"""
|
|
33
|
+
if isinstance(value, str):
|
|
34
|
+
parts = value.split(",")
|
|
35
|
+
elif isinstance(value, (tuple, list)):
|
|
36
|
+
parts = list(value)
|
|
37
|
+
else:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
"freq_range must be a (min_freq, max_freq) tuple/list or a "
|
|
40
|
+
f"'min,max' string, got {value!r}"
|
|
41
|
+
)
|
|
42
|
+
if len(parts) != 2:
|
|
43
|
+
raise ValueError("freq_range must contain exactly 2 values: min_freq,max_freq")
|
|
44
|
+
try:
|
|
45
|
+
return (float(parts[0]), float(parts[1]))
|
|
46
|
+
except (TypeError, ValueError) as e:
|
|
47
|
+
raise ValueError(f"freq_range values must be numeric, got {value!r}") from e
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AudioSpectrogramCapture(VideoCaptureBase):
|
|
51
|
+
"""
|
|
52
|
+
Audio spectrogram capture implementation.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
has_discovery = True
|
|
56
|
+
supports_exposure = False # No optical exposure; stubs return a no-op
|
|
57
|
+
supports_gain = False # No optical gain; stubs return a no-op
|
|
58
|
+
"""
|
|
59
|
+
Capture audio spectrograms as video frames from microphones or audio files.
|
|
60
|
+
Treats spectrograms as visual data that can be processed like regular video frames.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
source: Union[int, str, None] = None,
|
|
66
|
+
*,
|
|
67
|
+
n_mels: int = 128,
|
|
68
|
+
n_fft: int = 2048,
|
|
69
|
+
hop_length: int = 512,
|
|
70
|
+
window_duration: float = 2.0,
|
|
71
|
+
freq_range: Union[str, tuple[float, float], list] = (20, 8000),
|
|
72
|
+
sample_rate: int = 44100,
|
|
73
|
+
colormap: Optional[Union[int, str]] = None,
|
|
74
|
+
db_range: tuple[float, float] = (-80, 0),
|
|
75
|
+
frame_rate: int = 30,
|
|
76
|
+
audio_buffer_size: int = 1024,
|
|
77
|
+
contrast_method: str = "fixed",
|
|
78
|
+
adaptive_alpha: float = 0.95,
|
|
79
|
+
percentile_range: Union[tuple[float, float], list] = (5, 95),
|
|
80
|
+
gamma_correction: float = 1.0,
|
|
81
|
+
noise_floor: float = -70,
|
|
82
|
+
**kwargs,
|
|
83
|
+
):
|
|
84
|
+
"""
|
|
85
|
+
Initialize audio spectrogram capture.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
source: Audio source - microphone index (int), file path (str), or None for default mic.
|
|
89
|
+
n_mels: Number of mel bands (default: 128).
|
|
90
|
+
n_fft: FFT window size (default: 2048).
|
|
91
|
+
hop_length: Number of samples between successive frames (default: 512).
|
|
92
|
+
window_duration: Duration of audio window in seconds (default: 2.0).
|
|
93
|
+
freq_range: Frequency range as a ``(min_freq, max_freq)`` tuple/list
|
|
94
|
+
or a ``"min,max"`` string (default: ``(20, 8000)``).
|
|
95
|
+
sample_rate: Audio sample rate in Hz (default: 44100).
|
|
96
|
+
colormap: OpenCV colormap (int or name) for visualization, or None
|
|
97
|
+
for grayscale (default: None).
|
|
98
|
+
db_range: Dynamic range in dB (default: ``(-80, 0)``).
|
|
99
|
+
frame_rate: Spectrogram update rate in Hz (default: 30).
|
|
100
|
+
audio_buffer_size: Audio buffer size in samples (default: 1024).
|
|
101
|
+
contrast_method: Contrast enhancement method - ``'fixed'``,
|
|
102
|
+
``'adaptive'`` or ``'percentile'`` (default: ``'fixed'``).
|
|
103
|
+
adaptive_alpha: Smoothing factor for adaptive normalization
|
|
104
|
+
(default: 0.95).
|
|
105
|
+
percentile_range: ``(low, high)`` percentiles for percentile
|
|
106
|
+
normalization (default: ``(5, 95)``).
|
|
107
|
+
gamma_correction: Gamma correction for contrast (default: 1.0).
|
|
108
|
+
noise_floor: Noise floor in dB (default: -70).
|
|
109
|
+
**kwargs: Additional passthrough options stored on ``self.config``.
|
|
110
|
+
"""
|
|
111
|
+
if not AUDIO_AVAILABLE:
|
|
112
|
+
raise MissingDependencyError(
|
|
113
|
+
"librosa/soundfile/pyaudio", extra="audio", details=MISSING_DEPS
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Forward the promoted spectrogram options back into ``self.config`` so
|
|
117
|
+
# its contents match the historical ``**kwargs`` passthrough.
|
|
118
|
+
for _key, _val in (
|
|
119
|
+
("n_mels", n_mels),
|
|
120
|
+
("n_fft", n_fft),
|
|
121
|
+
("hop_length", hop_length),
|
|
122
|
+
("window_duration", window_duration),
|
|
123
|
+
("freq_range", freq_range),
|
|
124
|
+
("sample_rate", sample_rate),
|
|
125
|
+
("colormap", colormap),
|
|
126
|
+
("db_range", db_range),
|
|
127
|
+
("frame_rate", frame_rate),
|
|
128
|
+
("audio_buffer_size", audio_buffer_size),
|
|
129
|
+
("contrast_method", contrast_method),
|
|
130
|
+
("adaptive_alpha", adaptive_alpha),
|
|
131
|
+
("percentile_range", percentile_range),
|
|
132
|
+
("gamma_correction", gamma_correction),
|
|
133
|
+
("noise_floor", noise_floor),
|
|
134
|
+
):
|
|
135
|
+
if _val is not None:
|
|
136
|
+
kwargs[_key] = _val
|
|
137
|
+
|
|
138
|
+
super().__init__(source, **kwargs)
|
|
139
|
+
|
|
140
|
+
# Spectrogram parameters
|
|
141
|
+
self.n_mels = int(n_mels)
|
|
142
|
+
self.n_fft = int(n_fft)
|
|
143
|
+
self.hop_length = int(hop_length)
|
|
144
|
+
self.window_duration = float(window_duration)
|
|
145
|
+
self.freq_range = _parse_freq_range(freq_range)
|
|
146
|
+
self.sample_rate = int(sample_rate) # Increased to support higher frequencies
|
|
147
|
+
# Validate and set colormap
|
|
148
|
+
self.colormap = self._validate_colormap(colormap)
|
|
149
|
+
self.db_range = db_range
|
|
150
|
+
self.frame_rate = int(frame_rate)
|
|
151
|
+
self.audio_buffer_size = int(audio_buffer_size)
|
|
152
|
+
|
|
153
|
+
# Contrast enhancement parameters
|
|
154
|
+
self.contrast_method = contrast_method # 'fixed', 'adaptive', 'percentile'
|
|
155
|
+
self.adaptive_alpha = float(adaptive_alpha) # Smoothing factor for adaptive normalization
|
|
156
|
+
percentile_param = percentile_range
|
|
157
|
+
if isinstance(percentile_param, (tuple, list)) and len(percentile_param) == 2:
|
|
158
|
+
self.percentile_range = (float(percentile_param[0]), float(percentile_param[1]))
|
|
159
|
+
else:
|
|
160
|
+
self.percentile_range = (5.0, 95.0)
|
|
161
|
+
self.gamma_correction = float(gamma_correction) # Gamma correction for contrast
|
|
162
|
+
self.noise_floor = float(noise_floor) # Noise floor in dB
|
|
163
|
+
|
|
164
|
+
# Adaptive normalization state
|
|
165
|
+
self._adaptive_min = None
|
|
166
|
+
self._adaptive_max = None
|
|
167
|
+
|
|
168
|
+
# Validate and adjust sample rate based on frequency range
|
|
169
|
+
max_freq = self.freq_range[1]
|
|
170
|
+
nyquist_freq = self.sample_rate / 2
|
|
171
|
+
if max_freq > nyquist_freq:
|
|
172
|
+
# Automatically adjust sample rate to support the requested frequency range
|
|
173
|
+
required_sample_rate = int(max_freq * 2.2) # Add 10% margin above Nyquist
|
|
174
|
+
# Round to common sample rates
|
|
175
|
+
common_rates = [22050, 44100, 48000, 88200, 96000, 192000]
|
|
176
|
+
self.sample_rate = min(rate for rate in common_rates if rate >= required_sample_rate)
|
|
177
|
+
logger.warning(
|
|
178
|
+
f"Frequency range {self.freq_range} requires sample rate >= "
|
|
179
|
+
f"{required_sample_rate}Hz. Adjusted sample rate to {self.sample_rate}Hz"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
logger.info(
|
|
183
|
+
f"Sample rate: {self.sample_rate}Hz, Nyquist frequency: {self.sample_rate / 2}Hz"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Calculated parameters
|
|
187
|
+
self.window_samples = int(self.window_duration * self.sample_rate)
|
|
188
|
+
self.spectrogram_width = int(self.window_samples // self.hop_length) + 1
|
|
189
|
+
|
|
190
|
+
# Audio processing
|
|
191
|
+
self.audio_buffer = np.zeros(self.window_samples, dtype=np.float32)
|
|
192
|
+
self.mel_filter: Optional[np.ndarray] = None
|
|
193
|
+
self.pyaudio_instance = None
|
|
194
|
+
self.audio_stream = None
|
|
195
|
+
self.audio_thread = None
|
|
196
|
+
self.audio_stop_event = None
|
|
197
|
+
self.is_file_source = False
|
|
198
|
+
self.audio_data: Optional[np.ndarray] = None
|
|
199
|
+
self.audio_position = 0
|
|
200
|
+
|
|
201
|
+
# Frame buffer for consistent frame rate
|
|
202
|
+
self.current_frame = None
|
|
203
|
+
self.frame_lock = threading.Lock()
|
|
204
|
+
|
|
205
|
+
logger.info(f"AudioSpectrogramCapture initialized - Source: {source}")
|
|
206
|
+
logger.info(
|
|
207
|
+
f"Spectrogram params: n_mels={self.n_mels}, n_fft={self.n_fft}, "
|
|
208
|
+
f"window_duration={self.window_duration}s, freq_range={self.freq_range}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def _read_implementation(self) -> tuple[bool, Optional[np.ndarray]]:
|
|
212
|
+
"""
|
|
213
|
+
Read a single spectrogram frame.
|
|
214
|
+
Returns:
|
|
215
|
+
Tuple[bool, Optional[np.ndarray]]: (success, frame)
|
|
216
|
+
"""
|
|
217
|
+
if not self.is_connected:
|
|
218
|
+
return False, None
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
if self.is_file_source:
|
|
222
|
+
return self._read_file_frame()
|
|
223
|
+
else:
|
|
224
|
+
return self._read_microphone_frame()
|
|
225
|
+
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.error(f"Error reading frame: {e}")
|
|
228
|
+
return False, None
|
|
229
|
+
|
|
230
|
+
def connect(self) -> bool:
|
|
231
|
+
"""Connect to audio source."""
|
|
232
|
+
try:
|
|
233
|
+
self._setup_mel_filter()
|
|
234
|
+
|
|
235
|
+
if isinstance(self.source, str) and Path(self.source).exists():
|
|
236
|
+
return self._connect_file()
|
|
237
|
+
else:
|
|
238
|
+
return self._connect_microphone()
|
|
239
|
+
|
|
240
|
+
except Exception as e:
|
|
241
|
+
logger.error(f"Failed to connect to audio source: {e}")
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
def _setup_mel_filter(self):
|
|
245
|
+
"""Setup mel filter bank."""
|
|
246
|
+
self.mel_filter = librosa.filters.mel(
|
|
247
|
+
sr=self.sample_rate,
|
|
248
|
+
n_fft=self.n_fft,
|
|
249
|
+
n_mels=self.n_mels,
|
|
250
|
+
fmin=self.freq_range[0],
|
|
251
|
+
fmax=self.freq_range[1],
|
|
252
|
+
)
|
|
253
|
+
if self.mel_filter is not None:
|
|
254
|
+
logger.info(f"Mel filter bank created: {self.mel_filter.shape}")
|
|
255
|
+
else:
|
|
256
|
+
logger.error("Failed to create mel filter bank")
|
|
257
|
+
|
|
258
|
+
def _connect_file(self) -> bool:
|
|
259
|
+
"""Connect to audio file."""
|
|
260
|
+
try:
|
|
261
|
+
self.audio_data, sr = librosa.load(self.source, sr=self.sample_rate)
|
|
262
|
+
if sr != self.sample_rate:
|
|
263
|
+
logger.warning(f"File sample rate {sr} resampled to {self.sample_rate}")
|
|
264
|
+
|
|
265
|
+
self.is_file_source = True
|
|
266
|
+
self.audio_position = 0
|
|
267
|
+
self.is_connected = True
|
|
268
|
+
|
|
269
|
+
logger.info(
|
|
270
|
+
f"Connected to audio file: {self.source} "
|
|
271
|
+
f"(duration: {len(self.audio_data) / self.sample_rate:.2f}s)"
|
|
272
|
+
if self.audio_data is not None
|
|
273
|
+
else ""
|
|
274
|
+
)
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
except Exception as e:
|
|
278
|
+
logger.error(f"Failed to load audio file: {e}")
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
def _connect_microphone(self) -> bool:
|
|
282
|
+
"""Connect to microphone."""
|
|
283
|
+
try:
|
|
284
|
+
self.pyaudio_instance = pyaudio.PyAudio()
|
|
285
|
+
|
|
286
|
+
# Determine device index
|
|
287
|
+
device_index = None
|
|
288
|
+
if isinstance(self.source, int):
|
|
289
|
+
device_index = self.source
|
|
290
|
+
|
|
291
|
+
# Get device info
|
|
292
|
+
if device_index is not None:
|
|
293
|
+
device_info = self.pyaudio_instance.get_device_info_by_index(device_index)
|
|
294
|
+
logger.info(f"Using audio device: {device_info['name']}")
|
|
295
|
+
|
|
296
|
+
# Open audio stream
|
|
297
|
+
self.audio_stream = self.pyaudio_instance.open(
|
|
298
|
+
format=pyaudio.paFloat32,
|
|
299
|
+
channels=1,
|
|
300
|
+
rate=self.sample_rate,
|
|
301
|
+
input=True,
|
|
302
|
+
input_device_index=device_index,
|
|
303
|
+
frames_per_buffer=self.audio_buffer_size,
|
|
304
|
+
stream_callback=self._audio_callback,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
self.is_file_source = False
|
|
308
|
+
self.is_connected = True
|
|
309
|
+
|
|
310
|
+
logger.info(f"Connected to microphone (device {device_index})")
|
|
311
|
+
return True
|
|
312
|
+
|
|
313
|
+
except Exception as e:
|
|
314
|
+
logger.error(f"Failed to connect to microphone: {e}")
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
def _audio_callback(self, in_data, frame_count, time_info, status):
|
|
318
|
+
"""PyAudio callback for real-time audio capture."""
|
|
319
|
+
if status:
|
|
320
|
+
logger.warning(f"Audio callback status: {status}")
|
|
321
|
+
|
|
322
|
+
audio_chunk = np.frombuffer(in_data, dtype=np.float32)
|
|
323
|
+
|
|
324
|
+
# Shift buffer and add new data
|
|
325
|
+
shift_amount = len(audio_chunk)
|
|
326
|
+
self.audio_buffer[:-shift_amount] = self.audio_buffer[shift_amount:]
|
|
327
|
+
self.audio_buffer[-shift_amount:] = audio_chunk
|
|
328
|
+
|
|
329
|
+
return (None, pyaudio.paContinue)
|
|
330
|
+
|
|
331
|
+
def disconnect(self) -> bool:
|
|
332
|
+
"""Disconnect from audio source."""
|
|
333
|
+
try:
|
|
334
|
+
self.is_connected = False
|
|
335
|
+
|
|
336
|
+
if self.audio_stream:
|
|
337
|
+
self.audio_stream.stop_stream()
|
|
338
|
+
self.audio_stream.close()
|
|
339
|
+
self.audio_stream = None
|
|
340
|
+
|
|
341
|
+
if self.pyaudio_instance:
|
|
342
|
+
self.pyaudio_instance.terminate()
|
|
343
|
+
self.pyaudio_instance = None
|
|
344
|
+
|
|
345
|
+
logger.info("Disconnected from audio source")
|
|
346
|
+
return True
|
|
347
|
+
|
|
348
|
+
except Exception as e:
|
|
349
|
+
logger.error(f"Error disconnecting: {e}")
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
def _read_file_frame(self) -> tuple[bool, Optional[np.ndarray]]:
|
|
353
|
+
"""Read frame from audio file."""
|
|
354
|
+
if self.audio_data is None:
|
|
355
|
+
return False, None
|
|
356
|
+
|
|
357
|
+
if self.audio_position + self.window_samples > len(self.audio_data):
|
|
358
|
+
# Loop back to beginning
|
|
359
|
+
self.audio_position = 0
|
|
360
|
+
|
|
361
|
+
# Extract audio window
|
|
362
|
+
audio_window = self.audio_data[
|
|
363
|
+
self.audio_position : self.audio_position + self.window_samples
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
# Advance position
|
|
367
|
+
advance_samples = int(self.sample_rate / self.frame_rate)
|
|
368
|
+
self.audio_position += advance_samples
|
|
369
|
+
|
|
370
|
+
# Generate spectrogram
|
|
371
|
+
spectrogram = self._generate_spectrogram(audio_window)
|
|
372
|
+
return True, spectrogram
|
|
373
|
+
|
|
374
|
+
def _read_microphone_frame(self) -> tuple[bool, Optional[np.ndarray]]:
|
|
375
|
+
"""Read frame from microphone."""
|
|
376
|
+
if not self.audio_stream or not self.audio_stream.is_active():
|
|
377
|
+
return False, None
|
|
378
|
+
|
|
379
|
+
# Use current audio buffer
|
|
380
|
+
audio_window = self.audio_buffer.copy()
|
|
381
|
+
|
|
382
|
+
# Generate spectrogram
|
|
383
|
+
spectrogram = self._generate_spectrogram(audio_window)
|
|
384
|
+
return True, spectrogram
|
|
385
|
+
|
|
386
|
+
def _generate_spectrogram(self, audio_data: np.ndarray) -> np.ndarray:
|
|
387
|
+
"""Generate mel spectrogram from audio data."""
|
|
388
|
+
if self.mel_filter is None:
|
|
389
|
+
raise RuntimeError("Mel filter not initialized")
|
|
390
|
+
|
|
391
|
+
# Compute STFT
|
|
392
|
+
stft = librosa.stft(audio_data, n_fft=self.n_fft, hop_length=self.hop_length)
|
|
393
|
+
magnitude = np.abs(stft)
|
|
394
|
+
|
|
395
|
+
# Apply mel filter
|
|
396
|
+
mel_spectrogram = np.dot(self.mel_filter, magnitude)
|
|
397
|
+
|
|
398
|
+
# Convert to dB
|
|
399
|
+
mel_db = librosa.power_to_db(mel_spectrogram, ref=np.max)
|
|
400
|
+
|
|
401
|
+
# Apply noise floor
|
|
402
|
+
mel_db = np.maximum(mel_db, self.noise_floor)
|
|
403
|
+
|
|
404
|
+
# Apply contrast enhancement based on method
|
|
405
|
+
if self.contrast_method == "adaptive":
|
|
406
|
+
mel_normalized = self._adaptive_normalize(mel_db)
|
|
407
|
+
elif self.contrast_method == "percentile":
|
|
408
|
+
mel_normalized = self._percentile_normalize(mel_db)
|
|
409
|
+
else: # 'fixed'
|
|
410
|
+
mel_normalized = self._fixed_normalize(mel_db)
|
|
411
|
+
|
|
412
|
+
# Apply gamma correction for additional contrast control
|
|
413
|
+
if self.gamma_correction != 1.0:
|
|
414
|
+
mel_normalized = np.power(mel_normalized, self.gamma_correction)
|
|
415
|
+
|
|
416
|
+
# Convert to uint8
|
|
417
|
+
mel_uint8 = (np.clip(mel_normalized, 0, 1) * 255).astype(np.uint8)
|
|
418
|
+
|
|
419
|
+
# Flip vertically (high frequencies at top)
|
|
420
|
+
mel_uint8 = np.flipud(mel_uint8)
|
|
421
|
+
|
|
422
|
+
# Apply colormap if specified, otherwise return grayscale
|
|
423
|
+
if self.colormap is not None and isinstance(self.colormap, int):
|
|
424
|
+
try:
|
|
425
|
+
# Ensure mel_uint8 is a proper numpy array with correct dtype
|
|
426
|
+
if not isinstance(mel_uint8, np.ndarray):
|
|
427
|
+
logger.warning("mel_uint8 is not a numpy array, converting...")
|
|
428
|
+
mel_uint8 = np.array(mel_uint8, dtype=np.uint8)
|
|
429
|
+
|
|
430
|
+
# Ensure it's uint8
|
|
431
|
+
if mel_uint8.dtype != np.uint8:
|
|
432
|
+
mel_uint8 = mel_uint8.astype(np.uint8)
|
|
433
|
+
|
|
434
|
+
colored = cv2.applyColorMap(mel_uint8, self.colormap)
|
|
435
|
+
except Exception as e:
|
|
436
|
+
logger.warning(f"Failed to apply colormap {self.colormap}: {e}. Using grayscale.")
|
|
437
|
+
# Fallback to grayscale
|
|
438
|
+
colored = cv2.cvtColor(mel_uint8, cv2.COLOR_GRAY2BGR)
|
|
439
|
+
else:
|
|
440
|
+
# Convert grayscale to 3-channel for consistency with colored spectrograms
|
|
441
|
+
colored = cv2.cvtColor(mel_uint8, cv2.COLOR_GRAY2BGR)
|
|
442
|
+
|
|
443
|
+
return colored
|
|
444
|
+
|
|
445
|
+
def get_frame_size(self) -> Optional[tuple[int, int]]:
|
|
446
|
+
"""Get spectrogram frame dimensions (width, height)."""
|
|
447
|
+
return (self.spectrogram_width, self.n_mels)
|
|
448
|
+
|
|
449
|
+
def set_frame_size(self, width: int, height: int) -> bool:
|
|
450
|
+
"""Set spectrogram dimensions by adjusting parameters."""
|
|
451
|
+
logger.warning(
|
|
452
|
+
"Frame size for spectrograms is determined by audio parameters. "
|
|
453
|
+
"Adjust n_mels, window_duration, or hop_length instead."
|
|
454
|
+
)
|
|
455
|
+
return False
|
|
456
|
+
|
|
457
|
+
def get_fps(self) -> Optional[float]:
|
|
458
|
+
"""Get spectrogram frame rate."""
|
|
459
|
+
return self.frame_rate
|
|
460
|
+
|
|
461
|
+
def set_fps(self, fps: float) -> bool:
|
|
462
|
+
"""Set spectrogram frame rate."""
|
|
463
|
+
if fps > 0:
|
|
464
|
+
self.frame_rate = fps
|
|
465
|
+
return True
|
|
466
|
+
return False
|
|
467
|
+
|
|
468
|
+
# Audio-specific parameter methods
|
|
469
|
+
def get_n_mels(self) -> int:
|
|
470
|
+
"""Get number of mel bands."""
|
|
471
|
+
return self.n_mels
|
|
472
|
+
|
|
473
|
+
def set_n_mels(self, n_mels: int) -> bool:
|
|
474
|
+
"""Set number of mel bands (requires reconnection)."""
|
|
475
|
+
if n_mels > 0:
|
|
476
|
+
self.n_mels = n_mels
|
|
477
|
+
logger.info(f"n_mels set to {n_mels}. Reconnect to apply changes.")
|
|
478
|
+
return True
|
|
479
|
+
return False
|
|
480
|
+
|
|
481
|
+
def get_window_duration(self) -> float:
|
|
482
|
+
"""Get audio window duration."""
|
|
483
|
+
return self.window_duration
|
|
484
|
+
|
|
485
|
+
def set_window_duration(self, duration: float) -> bool:
|
|
486
|
+
"""Set audio window duration (requires reconnection)."""
|
|
487
|
+
if duration > 0:
|
|
488
|
+
self.window_duration = duration
|
|
489
|
+
self.window_samples = int(duration * self.sample_rate)
|
|
490
|
+
self.spectrogram_width = int(self.window_samples // self.hop_length) + 1
|
|
491
|
+
logger.info(f"Window duration set to {duration}s. Reconnect to apply changes.")
|
|
492
|
+
return True
|
|
493
|
+
return False
|
|
494
|
+
|
|
495
|
+
def get_freq_range(self) -> tuple[float, float]:
|
|
496
|
+
"""Get frequency range."""
|
|
497
|
+
return self.freq_range
|
|
498
|
+
|
|
499
|
+
def set_freq_range(self, min_freq: float, max_freq: float) -> bool:
|
|
500
|
+
"""Set frequency range (requires reconnection).
|
|
501
|
+
|
|
502
|
+
Automatically adjusts sample rate if needed.
|
|
503
|
+
"""
|
|
504
|
+
if 0 < min_freq < max_freq:
|
|
505
|
+
# Check if current sample rate supports the requested frequency range
|
|
506
|
+
nyquist_freq = self.sample_rate / 2
|
|
507
|
+
if max_freq > nyquist_freq:
|
|
508
|
+
# Automatically adjust sample rate to support the requested frequency range
|
|
509
|
+
required_sample_rate = int(max_freq * 2.2) # Add 10% margin above Nyquist
|
|
510
|
+
# Round to common sample rates
|
|
511
|
+
common_rates = [22050, 44100, 48000, 88200, 96000, 192000]
|
|
512
|
+
new_sample_rate = min(rate for rate in common_rates if rate >= required_sample_rate)
|
|
513
|
+
|
|
514
|
+
logger.warning(
|
|
515
|
+
f"Frequency range ({min_freq}, {max_freq}) requires sample rate >= "
|
|
516
|
+
f"{required_sample_rate}Hz. Adjusted sample rate from "
|
|
517
|
+
f"{self.sample_rate}Hz to {new_sample_rate}Hz"
|
|
518
|
+
)
|
|
519
|
+
self.sample_rate = new_sample_rate
|
|
520
|
+
|
|
521
|
+
# Recalculate window samples based on new sample rate
|
|
522
|
+
self.window_samples = int(self.window_duration * self.sample_rate)
|
|
523
|
+
self.spectrogram_width = int(self.window_samples // self.hop_length) + 1
|
|
524
|
+
|
|
525
|
+
self.freq_range = (min_freq, max_freq)
|
|
526
|
+
logger.info(
|
|
527
|
+
f"Frequency range set to {self.freq_range}. "
|
|
528
|
+
f"Sample rate: {self.sample_rate}Hz. Reconnect to apply changes."
|
|
529
|
+
)
|
|
530
|
+
return True
|
|
531
|
+
return False
|
|
532
|
+
|
|
533
|
+
def get_nyquist_frequency(self) -> float:
|
|
534
|
+
"""Get the Nyquist frequency (maximum representable frequency)."""
|
|
535
|
+
return self.sample_rate / 2.0
|
|
536
|
+
|
|
537
|
+
def get_sample_rate(self) -> int:
|
|
538
|
+
"""Get the current sample rate."""
|
|
539
|
+
return self.sample_rate
|
|
540
|
+
|
|
541
|
+
def set_sample_rate(self, sample_rate: int) -> bool:
|
|
542
|
+
"""Set sample rate (requires reconnection)."""
|
|
543
|
+
if sample_rate > 0:
|
|
544
|
+
self.sample_rate = sample_rate
|
|
545
|
+
# Recalculate dependent parameters
|
|
546
|
+
self.window_samples = int(self.window_duration * self.sample_rate)
|
|
547
|
+
self.spectrogram_width = int(self.window_samples // self.hop_length) + 1
|
|
548
|
+
logger.info(
|
|
549
|
+
f"Sample rate set to {sample_rate}Hz. "
|
|
550
|
+
f"Nyquist frequency: {self.get_nyquist_frequency()}Hz. Reconnect to apply changes."
|
|
551
|
+
)
|
|
552
|
+
return True
|
|
553
|
+
return False
|
|
554
|
+
|
|
555
|
+
def _validate_colormap(self, colormap_param) -> Optional[int]:
|
|
556
|
+
"""
|
|
557
|
+
Validate and convert colormap parameter to proper OpenCV colormap constant.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
colormap_param: Colormap parameter (int, str, or None)
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
Optional[int]: Valid OpenCV colormap constant or None
|
|
564
|
+
"""
|
|
565
|
+
if colormap_param is None or colormap_param == "":
|
|
566
|
+
return None
|
|
567
|
+
|
|
568
|
+
# If it's already an integer, validate it's a valid OpenCV colormap
|
|
569
|
+
if isinstance(colormap_param, int):
|
|
570
|
+
# OpenCV colormap constants are typically 0-21
|
|
571
|
+
if 0 <= colormap_param <= 21:
|
|
572
|
+
return colormap_param
|
|
573
|
+
else:
|
|
574
|
+
logger.warning(f"Invalid colormap integer {colormap_param}, using grayscale")
|
|
575
|
+
return None
|
|
576
|
+
|
|
577
|
+
# If it's a string, try to convert to OpenCV constant
|
|
578
|
+
if isinstance(colormap_param, str):
|
|
579
|
+
colormap_map = {
|
|
580
|
+
"AUTUMN": cv2.COLORMAP_AUTUMN,
|
|
581
|
+
"BONE": cv2.COLORMAP_BONE,
|
|
582
|
+
"JET": cv2.COLORMAP_JET,
|
|
583
|
+
"WINTER": cv2.COLORMAP_WINTER,
|
|
584
|
+
"RAINBOW": cv2.COLORMAP_RAINBOW,
|
|
585
|
+
"OCEAN": cv2.COLORMAP_OCEAN,
|
|
586
|
+
"SUMMER": cv2.COLORMAP_SUMMER,
|
|
587
|
+
"SPRING": cv2.COLORMAP_SPRING,
|
|
588
|
+
"COOL": cv2.COLORMAP_COOL,
|
|
589
|
+
"HSV": cv2.COLORMAP_HSV,
|
|
590
|
+
"PINK": cv2.COLORMAP_PINK,
|
|
591
|
+
"HOT": cv2.COLORMAP_HOT,
|
|
592
|
+
"PARULA": cv2.COLORMAP_PARULA,
|
|
593
|
+
"MAGMA": cv2.COLORMAP_MAGMA,
|
|
594
|
+
"INFERNO": cv2.COLORMAP_INFERNO,
|
|
595
|
+
"PLASMA": cv2.COLORMAP_PLASMA,
|
|
596
|
+
"VIRIDIS": cv2.COLORMAP_VIRIDIS,
|
|
597
|
+
"CIVIDIS": cv2.COLORMAP_CIVIDIS,
|
|
598
|
+
"TWILIGHT": cv2.COLORMAP_TWILIGHT,
|
|
599
|
+
"TWILIGHT_SHIFTED": cv2.COLORMAP_TWILIGHT_SHIFTED,
|
|
600
|
+
"TURBO": cv2.COLORMAP_TURBO,
|
|
601
|
+
"DEEPGREEN": cv2.COLORMAP_DEEPGREEN,
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
colormap_name = colormap_param.upper()
|
|
605
|
+
if colormap_name in colormap_map:
|
|
606
|
+
return colormap_map[colormap_name]
|
|
607
|
+
else:
|
|
608
|
+
logger.warning(f"Unknown colormap name '{colormap_param}', using grayscale")
|
|
609
|
+
return None
|
|
610
|
+
|
|
611
|
+
logger.warning(f"Invalid colormap type {type(colormap_param)}, using grayscale")
|
|
612
|
+
return None
|
|
613
|
+
|
|
614
|
+
def set_colormap(self, colormap: Optional[int]) -> bool:
|
|
615
|
+
"""
|
|
616
|
+
Set the colormap for spectrogram visualization.
|
|
617
|
+
|
|
618
|
+
Args:
|
|
619
|
+
colormap: OpenCV colormap constant (e.g., cv2.COLORMAP_VIRIDIS) or None for grayscale
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
bool: True if successful
|
|
623
|
+
"""
|
|
624
|
+
self.colormap = self._validate_colormap(colormap)
|
|
625
|
+
colormap_name = "grayscale" if self.colormap is None else f"cv2 colormap {self.colormap}"
|
|
626
|
+
logger.info(f"Colormap set to {colormap_name}")
|
|
627
|
+
return True
|
|
628
|
+
|
|
629
|
+
def get_colormap(self) -> Optional[int]:
|
|
630
|
+
"""Get the current colormap (None means grayscale)."""
|
|
631
|
+
return self.colormap
|
|
632
|
+
|
|
633
|
+
def set_contrast_method(self, method: str) -> bool:
|
|
634
|
+
"""
|
|
635
|
+
Set the contrast enhancement method.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
method: 'fixed', 'adaptive', or 'percentile'
|
|
639
|
+
|
|
640
|
+
Returns:
|
|
641
|
+
bool: True if successful
|
|
642
|
+
"""
|
|
643
|
+
if method in ["fixed", "adaptive", "percentile"]:
|
|
644
|
+
self.contrast_method = method
|
|
645
|
+
# Reset adaptive state when changing methods
|
|
646
|
+
if method == "adaptive":
|
|
647
|
+
self._adaptive_min = None
|
|
648
|
+
self._adaptive_max = None
|
|
649
|
+
logger.info(f"Contrast method set to {method}")
|
|
650
|
+
return True
|
|
651
|
+
else:
|
|
652
|
+
logger.warning(
|
|
653
|
+
f"Invalid contrast method: {method}. Use 'fixed', 'adaptive', or 'percentile'"
|
|
654
|
+
)
|
|
655
|
+
return False
|
|
656
|
+
|
|
657
|
+
def get_contrast_method(self) -> str:
|
|
658
|
+
"""Get the current contrast enhancement method."""
|
|
659
|
+
return self.contrast_method
|
|
660
|
+
|
|
661
|
+
def set_gamma_correction(self, gamma: float) -> bool:
|
|
662
|
+
"""
|
|
663
|
+
Set gamma correction value for contrast enhancement.
|
|
664
|
+
|
|
665
|
+
Args:
|
|
666
|
+
gamma: Gamma value (< 1.0 increases contrast, > 1.0 decreases contrast)
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
bool: True if successful
|
|
670
|
+
"""
|
|
671
|
+
if gamma > 0:
|
|
672
|
+
self.gamma_correction = gamma
|
|
673
|
+
logger.info(f"Gamma correction set to {gamma}")
|
|
674
|
+
return True
|
|
675
|
+
return False
|
|
676
|
+
|
|
677
|
+
def get_gamma_correction(self) -> float:
|
|
678
|
+
"""Get the current gamma correction value."""
|
|
679
|
+
return self.gamma_correction
|
|
680
|
+
|
|
681
|
+
def set_noise_floor(self, noise_floor_db: float) -> bool:
|
|
682
|
+
"""
|
|
683
|
+
Set the noise floor in dB to suppress background noise.
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
noise_floor_db: Noise floor in dB (e.g., -70)
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
bool: True if successful
|
|
690
|
+
"""
|
|
691
|
+
self.noise_floor = noise_floor_db
|
|
692
|
+
logger.info(f"Noise floor set to {noise_floor_db} dB")
|
|
693
|
+
return True
|
|
694
|
+
|
|
695
|
+
def get_noise_floor(self) -> float:
|
|
696
|
+
"""Get the current noise floor in dB."""
|
|
697
|
+
return self.noise_floor
|
|
698
|
+
|
|
699
|
+
def set_percentile_range(self, low: float, high: float) -> bool:
|
|
700
|
+
"""
|
|
701
|
+
Set the percentile range for percentile-based normalization.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
low: Lower percentile (0-100)
|
|
705
|
+
high: Upper percentile (0-100)
|
|
706
|
+
|
|
707
|
+
Returns:
|
|
708
|
+
bool: True if successful
|
|
709
|
+
"""
|
|
710
|
+
if 0 <= low < high <= 100:
|
|
711
|
+
self.percentile_range = (low, high)
|
|
712
|
+
logger.info(f"Percentile range set to {self.percentile_range}")
|
|
713
|
+
return True
|
|
714
|
+
return False
|
|
715
|
+
|
|
716
|
+
def get_percentile_range(self) -> tuple[float, float]:
|
|
717
|
+
"""Get the current percentile range."""
|
|
718
|
+
return self.percentile_range
|
|
719
|
+
|
|
720
|
+
def validate_frequency_range(self, min_freq: float, max_freq: float) -> tuple[bool, str]:
|
|
721
|
+
"""
|
|
722
|
+
Validate if the frequency range is supported by the current configuration.
|
|
723
|
+
|
|
724
|
+
Returns:
|
|
725
|
+
Tuple[bool, str]: (is_valid, message)
|
|
726
|
+
"""
|
|
727
|
+
nyquist = self.sample_rate / 2.0
|
|
728
|
+
|
|
729
|
+
if min_freq <= 0:
|
|
730
|
+
return False, f"Minimum frequency must be > 0, got {min_freq}"
|
|
731
|
+
if max_freq <= min_freq:
|
|
732
|
+
return (
|
|
733
|
+
False,
|
|
734
|
+
f"Maximum frequency must be > minimum frequency, got {max_freq} <= {min_freq}",
|
|
735
|
+
)
|
|
736
|
+
if max_freq > nyquist:
|
|
737
|
+
return (
|
|
738
|
+
False,
|
|
739
|
+
f"Maximum frequency {max_freq}Hz exceeds Nyquist limit {nyquist}Hz "
|
|
740
|
+
f"for sample rate {self.sample_rate}Hz",
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
return True, "Frequency range is valid"
|
|
744
|
+
|
|
745
|
+
# Stub methods for base class compatibility
|
|
746
|
+
def enable_auto_exposure(self, enable: bool = True) -> bool:
|
|
747
|
+
"""Not applicable for audio capture."""
|
|
748
|
+
return True
|
|
749
|
+
|
|
750
|
+
def set_exposure(self, value: float) -> bool:
|
|
751
|
+
"""Not applicable for audio capture."""
|
|
752
|
+
return True
|
|
753
|
+
|
|
754
|
+
def get_exposure(self) -> Optional[float]:
|
|
755
|
+
"""Not applicable for audio capture."""
|
|
756
|
+
return None
|
|
757
|
+
|
|
758
|
+
def set_gain(self, value: float) -> bool:
|
|
759
|
+
"""Audio gain control could be implemented here."""
|
|
760
|
+
return True
|
|
761
|
+
|
|
762
|
+
def get_gain(self) -> Optional[float]:
|
|
763
|
+
"""Audio gain control could be implemented here."""
|
|
764
|
+
return None
|
|
765
|
+
|
|
766
|
+
def _fixed_normalize(self, mel_db: np.ndarray) -> np.ndarray:
|
|
767
|
+
"""
|
|
768
|
+
Apply fixed dB range normalization.
|
|
769
|
+
|
|
770
|
+
Args:
|
|
771
|
+
mel_db: Mel spectrogram in dB
|
|
772
|
+
|
|
773
|
+
Returns:
|
|
774
|
+
np.ndarray: Normalized spectrogram [0, 1]
|
|
775
|
+
"""
|
|
776
|
+
return np.clip((mel_db - self.db_range[0]) / (self.db_range[1] - self.db_range[0]), 0, 1)
|
|
777
|
+
|
|
778
|
+
def _adaptive_normalize(self, mel_db: np.ndarray) -> np.ndarray:
|
|
779
|
+
"""
|
|
780
|
+
Apply adaptive normalization based on current frame statistics.
|
|
781
|
+
|
|
782
|
+
Args:
|
|
783
|
+
mel_db: Mel spectrogram in dB
|
|
784
|
+
|
|
785
|
+
Returns:
|
|
786
|
+
np.ndarray: Normalized spectrogram [0, 1]
|
|
787
|
+
"""
|
|
788
|
+
# Initialize adaptive min/max values
|
|
789
|
+
if self._adaptive_min is None or self._adaptive_max is None:
|
|
790
|
+
self._adaptive_min = np.min(mel_db)
|
|
791
|
+
self._adaptive_max = np.max(mel_db)
|
|
792
|
+
|
|
793
|
+
# Update adaptive min/max values using exponential moving average
|
|
794
|
+
current_min = np.min(mel_db)
|
|
795
|
+
current_max = np.max(mel_db)
|
|
796
|
+
|
|
797
|
+
self._adaptive_min = (
|
|
798
|
+
self.adaptive_alpha * self._adaptive_min + (1 - self.adaptive_alpha) * current_min
|
|
799
|
+
)
|
|
800
|
+
self._adaptive_max = (
|
|
801
|
+
self.adaptive_alpha * self._adaptive_max + (1 - self.adaptive_alpha) * current_max
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
# Ensure we don't divide by zero
|
|
805
|
+
range_val = max(self._adaptive_max - self._adaptive_min, 1e-10)
|
|
806
|
+
|
|
807
|
+
# Normalize
|
|
808
|
+
normalized = (mel_db - self._adaptive_min) / range_val
|
|
809
|
+
return np.clip(normalized, 0, 1)
|
|
810
|
+
|
|
811
|
+
def _percentile_normalize(self, mel_db: np.ndarray) -> np.ndarray:
|
|
812
|
+
"""
|
|
813
|
+
Apply percentile-based normalization for robust contrast.
|
|
814
|
+
|
|
815
|
+
Args:
|
|
816
|
+
mel_db: Mel spectrogram in dB
|
|
817
|
+
|
|
818
|
+
Returns:
|
|
819
|
+
np.ndarray: Normalized spectrogram [0, 1]
|
|
820
|
+
"""
|
|
821
|
+
# Calculate percentiles
|
|
822
|
+
low_percentile = np.percentile(mel_db, self.percentile_range[0])
|
|
823
|
+
high_percentile = np.percentile(mel_db, self.percentile_range[1])
|
|
824
|
+
|
|
825
|
+
# Ensure we don't divide by zero
|
|
826
|
+
range_val = max(high_percentile - low_percentile, 1e-10)
|
|
827
|
+
|
|
828
|
+
# Normalize
|
|
829
|
+
normalized = (mel_db - low_percentile) / range_val
|
|
830
|
+
return np.clip(normalized, 0, 1)
|
|
831
|
+
|
|
832
|
+
@classmethod
|
|
833
|
+
def discover(cls) -> list[DeviceInfo]:
|
|
834
|
+
"""
|
|
835
|
+
Discover available audio input devices (microphones).
|
|
836
|
+
|
|
837
|
+
Returns:
|
|
838
|
+
List[DeviceInfo]: Discovered input devices. Each entry behaves like
|
|
839
|
+
a dict with keys ``index``, ``name``, ``channels`` and
|
|
840
|
+
``sample_rate`` (the latter two via ``metadata``).
|
|
841
|
+
"""
|
|
842
|
+
if not AUDIO_AVAILABLE:
|
|
843
|
+
logger.warning("Audio dependencies not available. Cannot discover audio devices.")
|
|
844
|
+
return []
|
|
845
|
+
|
|
846
|
+
devices: list[DeviceInfo] = []
|
|
847
|
+
|
|
848
|
+
try:
|
|
849
|
+
p = pyaudio.PyAudio()
|
|
850
|
+
|
|
851
|
+
# Get device count
|
|
852
|
+
device_count = p.get_device_count()
|
|
853
|
+
|
|
854
|
+
for i in range(device_count):
|
|
855
|
+
try:
|
|
856
|
+
device_info = p.get_device_info_by_index(i)
|
|
857
|
+
|
|
858
|
+
# Only include input devices (microphones)
|
|
859
|
+
max_input_channels = device_info.get("maxInputChannels", 0)
|
|
860
|
+
if isinstance(max_input_channels, (int, float)) and max_input_channels > 0:
|
|
861
|
+
device_data = DeviceInfo(
|
|
862
|
+
device_id=str(i),
|
|
863
|
+
index=i,
|
|
864
|
+
name=device_info["name"],
|
|
865
|
+
driver="pyaudio",
|
|
866
|
+
id_stable=False,
|
|
867
|
+
metadata={
|
|
868
|
+
"channels": device_info["maxInputChannels"],
|
|
869
|
+
"sample_rate": device_info["defaultSampleRate"],
|
|
870
|
+
},
|
|
871
|
+
)
|
|
872
|
+
devices.append(device_data)
|
|
873
|
+
logger.info(f"Found audio input device: {device_data}")
|
|
874
|
+
|
|
875
|
+
except Exception as e:
|
|
876
|
+
logger.warning(f"Could not get info for audio device {i}: {e}")
|
|
877
|
+
continue
|
|
878
|
+
|
|
879
|
+
p.terminate()
|
|
880
|
+
|
|
881
|
+
except Exception as e:
|
|
882
|
+
logger.error(f"Error discovering audio devices: {e}")
|
|
883
|
+
|
|
884
|
+
return devices
|
|
885
|
+
|
|
886
|
+
@classmethod
|
|
887
|
+
def get_config_schema(cls) -> dict[str, Any]:
|
|
888
|
+
"""Get configuration schema for audio spectrogram capture"""
|
|
889
|
+
warnings.warn(
|
|
890
|
+
"get_config_schema() is deprecated and will be removed in a future release; "
|
|
891
|
+
"UI form schemas belong in the consuming application.",
|
|
892
|
+
DeprecationWarning,
|
|
893
|
+
stacklevel=2,
|
|
894
|
+
)
|
|
895
|
+
return {
|
|
896
|
+
"title": "Audio Spectrogram Configuration",
|
|
897
|
+
"description": "Configure audio spectrogram capture from microphones or audio files",
|
|
898
|
+
"fields": [
|
|
899
|
+
{
|
|
900
|
+
"name": "source",
|
|
901
|
+
"label": "Audio Source",
|
|
902
|
+
"type": "text",
|
|
903
|
+
"placeholder": "0 or /path/to/audio.wav",
|
|
904
|
+
"description": "Microphone index (0, 1, 2...) or path to audio file",
|
|
905
|
+
"required": False,
|
|
906
|
+
"default": 0,
|
|
907
|
+
},
|
|
908
|
+
{
|
|
909
|
+
"name": "n_mels",
|
|
910
|
+
"label": "Mel Bands",
|
|
911
|
+
"type": "number",
|
|
912
|
+
"min": 32,
|
|
913
|
+
"max": 256,
|
|
914
|
+
"placeholder": "128",
|
|
915
|
+
"description": "Number of mel frequency bands in spectrogram",
|
|
916
|
+
"required": False,
|
|
917
|
+
"default": 128,
|
|
918
|
+
},
|
|
919
|
+
{
|
|
920
|
+
"name": "n_fft",
|
|
921
|
+
"label": "FFT Window Size",
|
|
922
|
+
"type": "select",
|
|
923
|
+
"options": [
|
|
924
|
+
{"value": 512, "label": "512"},
|
|
925
|
+
{"value": 1024, "label": "1024"},
|
|
926
|
+
{"value": 2048, "label": "2048"},
|
|
927
|
+
{"value": 4096, "label": "4096"},
|
|
928
|
+
],
|
|
929
|
+
"description": "FFT window size for frequency analysis",
|
|
930
|
+
"required": False,
|
|
931
|
+
"default": 2048,
|
|
932
|
+
},
|
|
933
|
+
{
|
|
934
|
+
"name": "hop_length",
|
|
935
|
+
"label": "Hop Length",
|
|
936
|
+
"type": "number",
|
|
937
|
+
"min": 128,
|
|
938
|
+
"max": 2048,
|
|
939
|
+
"placeholder": "512",
|
|
940
|
+
"description": "Number of samples between successive frames",
|
|
941
|
+
"required": False,
|
|
942
|
+
"default": 512,
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
"name": "window_duration",
|
|
946
|
+
"label": "Window Duration (s)",
|
|
947
|
+
"type": "number",
|
|
948
|
+
"min": 0.5,
|
|
949
|
+
"max": 10.0,
|
|
950
|
+
"step": 0.1,
|
|
951
|
+
"placeholder": "2.0",
|
|
952
|
+
"description": "Duration of audio window in seconds",
|
|
953
|
+
"required": False,
|
|
954
|
+
"default": 2.0,
|
|
955
|
+
},
|
|
956
|
+
{
|
|
957
|
+
"name": "sample_rate",
|
|
958
|
+
"label": "Sample Rate (Hz)",
|
|
959
|
+
"type": "select",
|
|
960
|
+
"options": [
|
|
961
|
+
{"value": 22050, "label": "22050"},
|
|
962
|
+
{"value": 44100, "label": "44100"},
|
|
963
|
+
{"value": 48000, "label": "48000"},
|
|
964
|
+
{"value": 96000, "label": "96000"},
|
|
965
|
+
],
|
|
966
|
+
"description": "Audio sample rate in Hz",
|
|
967
|
+
"required": False,
|
|
968
|
+
"default": 44100,
|
|
969
|
+
},
|
|
970
|
+
{
|
|
971
|
+
"name": "freq_range",
|
|
972
|
+
"label": "Frequency Range (Hz)",
|
|
973
|
+
"type": "text",
|
|
974
|
+
"placeholder": "20,8000",
|
|
975
|
+
"description": 'Frequency range as "min,max" (e.g., "20,8000")',
|
|
976
|
+
"required": False,
|
|
977
|
+
"default": "20,8000",
|
|
978
|
+
},
|
|
979
|
+
{
|
|
980
|
+
"name": "frame_rate",
|
|
981
|
+
"label": "Frame Rate (FPS)",
|
|
982
|
+
"type": "number",
|
|
983
|
+
"min": 1,
|
|
984
|
+
"max": 60,
|
|
985
|
+
"placeholder": "30",
|
|
986
|
+
"description": "Spectrogram update rate in frames per second",
|
|
987
|
+
"required": False,
|
|
988
|
+
"default": 30,
|
|
989
|
+
},
|
|
990
|
+
{
|
|
991
|
+
"name": "colormap",
|
|
992
|
+
"label": "Color Map",
|
|
993
|
+
"type": "select",
|
|
994
|
+
"options": [
|
|
995
|
+
{"value": "", "label": "Grayscale"},
|
|
996
|
+
{"value": "JET", "label": "Jet"},
|
|
997
|
+
{"value": "HOT", "label": "Hot"},
|
|
998
|
+
{"value": "VIRIDIS", "label": "Viridis"},
|
|
999
|
+
{"value": "PLASMA", "label": "Plasma"},
|
|
1000
|
+
{"value": "INFERNO", "label": "Inferno"},
|
|
1001
|
+
{"value": "MAGMA", "label": "Magma"},
|
|
1002
|
+
{"value": "TURBO", "label": "Turbo"},
|
|
1003
|
+
{"value": "RAINBOW", "label": "Rainbow"},
|
|
1004
|
+
{"value": "OCEAN", "label": "Ocean"},
|
|
1005
|
+
{"value": "COOL", "label": "Cool"},
|
|
1006
|
+
{"value": "SPRING", "label": "Spring"},
|
|
1007
|
+
{"value": "SUMMER", "label": "Summer"},
|
|
1008
|
+
{"value": "AUTUMN", "label": "Autumn"},
|
|
1009
|
+
{"value": "WINTER", "label": "Winter"},
|
|
1010
|
+
],
|
|
1011
|
+
"description": "Color mapping for spectrogram visualization",
|
|
1012
|
+
"required": False,
|
|
1013
|
+
"default": "",
|
|
1014
|
+
},
|
|
1015
|
+
{
|
|
1016
|
+
"name": "contrast_method",
|
|
1017
|
+
"label": "Contrast Method",
|
|
1018
|
+
"type": "select",
|
|
1019
|
+
"options": [
|
|
1020
|
+
{"value": "fixed", "label": "Fixed Range"},
|
|
1021
|
+
{"value": "adaptive", "label": "Adaptive"},
|
|
1022
|
+
{"value": "percentile", "label": "Percentile"},
|
|
1023
|
+
],
|
|
1024
|
+
"description": "Method for contrast enhancement",
|
|
1025
|
+
"required": False,
|
|
1026
|
+
"default": "fixed",
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
"name": "gamma_correction",
|
|
1030
|
+
"label": "Gamma Correction",
|
|
1031
|
+
"type": "number",
|
|
1032
|
+
"min": 0.1,
|
|
1033
|
+
"max": 3.0,
|
|
1034
|
+
"step": 0.1,
|
|
1035
|
+
"placeholder": "1.0",
|
|
1036
|
+
"description": "Gamma correction for contrast (< 1.0 increases contrast)",
|
|
1037
|
+
"required": False,
|
|
1038
|
+
"default": 1.0,
|
|
1039
|
+
},
|
|
1040
|
+
{
|
|
1041
|
+
"name": "noise_floor",
|
|
1042
|
+
"label": "Noise Floor (dB)",
|
|
1043
|
+
"type": "number",
|
|
1044
|
+
"min": -100,
|
|
1045
|
+
"max": -20,
|
|
1046
|
+
"placeholder": "-70",
|
|
1047
|
+
"description": "Noise floor in dB to suppress background noise",
|
|
1048
|
+
"required": False,
|
|
1049
|
+
"default": -70,
|
|
1050
|
+
},
|
|
1051
|
+
],
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
if __name__ == "__main__":
|
|
1056
|
+
import threading
|
|
1057
|
+
|
|
1058
|
+
import cv2
|
|
1059
|
+
|
|
1060
|
+
# Example usage: Capture spectrograms from multiple audio sources (microphones or files)
|
|
1061
|
+
|
|
1062
|
+
devices = AudioSpectrogramCapture.discover()
|
|
1063
|
+
print(f"Discovered {len(devices)} audio input devices:")
|
|
1064
|
+
for device in devices:
|
|
1065
|
+
print(
|
|
1066
|
+
f" - {device['name']} (Index: {device['index']}, "
|
|
1067
|
+
f"Channels: {device['channels']}, Sample Rate: {device['sample_rate']})"
|
|
1068
|
+
)
|