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.
Files changed (37) hide show
  1. frame_processors/__init__.py +37 -0
  2. frame_source/__init__.py +50 -0
  3. framesource/__init__.py +93 -0
  4. framesource/_msmf_config.py +26 -0
  5. framesource/_version.py +24 -0
  6. framesource/discovery.py +109 -0
  7. framesource/errors.py +82 -0
  8. framesource/factory.py +290 -0
  9. framesource/processors/__init__.py +34 -0
  10. framesource/processors/equirectangular360_processor.py +577 -0
  11. framesource/processors/fisheye2equirectangular_processor.py +328 -0
  12. framesource/processors/frame_processor.py +30 -0
  13. framesource/processors/hyperspectral_processor.py +23 -0
  14. framesource/processors/realsense_depth_processor.py +90 -0
  15. framesource/py.typed +0 -0
  16. framesource/sources/__init__.py +40 -0
  17. framesource/sources/audiospectrogram_capture.py +1068 -0
  18. framesource/sources/basler_capture.py +477 -0
  19. framesource/sources/folder_capture.py +920 -0
  20. framesource/sources/genicam_capture.py +681 -0
  21. framesource/sources/huateng_capture.py +245 -0
  22. framesource/sources/ipcamera_capture.py +254 -0
  23. framesource/sources/mvsdk.py +2454 -0
  24. framesource/sources/realsense_capture.py +565 -0
  25. framesource/sources/screen_capture.py +800 -0
  26. framesource/sources/video_capture_base.py +560 -0
  27. framesource/sources/video_file_capture.py +259 -0
  28. framesource/sources/webcam_capture.py +511 -0
  29. framesource/sources/ximea_capture.py +299 -0
  30. framesource/threading_utils.py +790 -0
  31. framesource-0.3.0.dist-info/METADATA +787 -0
  32. framesource-0.3.0.dist-info/RECORD +37 -0
  33. framesource-0.3.0.dist-info/WHEEL +5 -0
  34. framesource-0.3.0.dist-info/licenses/LICENSE +21 -0
  35. framesource-0.3.0.dist-info/scm_file_list.json +276 -0
  36. framesource-0.3.0.dist-info/scm_version.json +8 -0
  37. 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
+ )