audio_features.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. """
  2. Audio feature extraction for wakeword detection.
  3. This module provides efficient feature extraction from raw audio data,
  4. specifically optimized for wakeword detection using Log-Mel spectrograms.
  5. """
  6. import math
  7. import warnings
  8. from dataclasses import dataclass
  9. from typing import Tuple, Optional, Union
  10. import torch
  11. import torch.nn.functional as F
  12. import torchaudio
  13. import torchaudio.functional as AF
  14. import torchaudio.transforms as AT
  15. import numpy as np
  16. @dataclass
  17. class SpectrogramConfig:
  18. """Configuration for spectrogram generation."""
  19. # Audio parameters
  20. sample_rate: int = 16000
  21. n_fft: int = 512
  22. hop_length: int = 160 # 10ms at 16kHz
  23. win_length: int = 400 # 25ms at 16kHz
  24. window_fn: str = "hann"
  25. # Mel filterbank parameters
  26. n_mels: int = 40
  27. f_min: float = 80.0
  28. f_max: Optional[float] = None # Will default to sample_rate/2
  29. # Normalization and preprocessing
  30. power: float = 2.0
  31. normalized: bool = True
  32. center: bool = True
  33. pad_mode: str = "constant"
  34. # Log transformation
  35. log_offset: float = 1e-8
  36. # Output parameters
  37. time_frames: int = 151 # Target number of time frames
  38. def __post_init__(self):
  39. """Validate and set derived parameters."""
  40. if self.f_max is None:
  41. self.f_max = self.sample_rate / 2.0
  42. # Calculate expected number of frames for validation
  43. # This helps ensure consistent input sizes to the model
  44. self.expected_frames = self.time_frames
  45. class AudioFeatureExtractor:
  46. """
  47. Efficient audio feature extraction for wakeword detection.
  48. This class provides optimized Log-Mel spectrogram extraction with
  49. proper normalization and windowing for real-time inference.
  50. """
  51. def __init__(self, config: SpectrogramConfig, device: Optional[torch.device] = None):
  52. """
  53. Initialize the feature extractor.
  54. Args:
  55. config: Spectrogram configuration
  56. device: PyTorch device for computation
  57. """
  58. self.config = config
  59. self.device = device or torch.device("cpu")
  60. # Pre-compute mel filterbank for efficiency
  61. self.mel_scale = AT.MelScale(
  62. n_mels=config.n_mels,
  63. sample_rate=config.sample_rate,
  64. f_min=config.f_min,
  65. f_max=config.f_max,
  66. n_stft=config.n_fft // 2 + 1,
  67. norm="slaney",
  68. mel_scale="htk"
  69. ).to(self.device)
  70. # Pre-compute window function
  71. if config.window_fn == "hann":
  72. self.window = torch.hann_window(config.win_length).to(self.device)
  73. elif config.window_fn == "hamming":
  74. self.window = torch.hamming_window(config.win_length).to(self.device)
  75. elif config.window_fn == "blackman":
  76. self.window = torch.blackman_window(config.win_length).to(self.device)
  77. else:
  78. raise ValueError(f"Unsupported window function: {config.window_fn}")
  79. # Cache for efficiency metrics
  80. self._cache_stats = {
  81. "total_extractions": 0,
  82. "total_time_ms": 0.0,
  83. "avg_time_ms": 0.0
  84. }
  85. def extract_features(self, waveform: torch.Tensor,
  86. normalize_audio: bool = True) -> torch.Tensor:
  87. """
  88. Extract Log-Mel spectrogram features from audio waveform.
  89. Args:
  90. waveform: Input audio tensor of shape (batch_size, samples) or (samples,)
  91. normalize_audio: Whether to normalize audio amplitude
  92. Returns:
  93. Log-Mel spectrogram tensor of shape (batch_size, n_mels, time_frames)
  94. """
  95. start_time = torch.cuda.Event(enable_timing=True) if self.device.type == "cuda" else None
  96. # Ensure proper tensor format
  97. if waveform.dim() == 1:
  98. waveform = waveform.unsqueeze(0) # Add batch dimension
  99. waveform = waveform.to(self.device)
  100. # Audio preprocessing
  101. if normalize_audio:
  102. waveform = self._normalize_audio(waveform)
  103. # Apply pre-emphasis filter (optional but common for speech)
  104. waveform = self._apply_preemphasis(waveform, alpha=0.97)
  105. # Compute STFT
  106. stft = torch.stft(
  107. waveform.squeeze(0) if waveform.shape[0] == 1 else waveform,
  108. n_fft=self.config.n_fft,
  109. hop_length=self.config.hop_length,
  110. win_length=self.config.win_length,
  111. window=self.window,
  112. center=self.config.center,
  113. pad_mode=self.config.pad_mode,
  114. normalized=self.config.normalized,
  115. onesided=True,
  116. return_complex=True
  117. )
  118. # Compute power spectrogram
  119. power_spec = stft.abs().pow(self.config.power)
  120. # Apply mel filterbank
  121. mel_spec = self.mel_scale(power_spec)
  122. # Apply log transformation
  123. log_mel_spec = torch.log(mel_spec + self.config.log_offset)
  124. # Ensure correct shape and size
  125. log_mel_spec = self._ensure_correct_frames(log_mel_spec)
  126. # Add batch dimension if needed
  127. if log_mel_spec.dim() == 2:
  128. log_mel_spec = log_mel_spec.unsqueeze(0)
  129. # Update performance stats
  130. if start_time and self.device.type == "cuda":
  131. end_time = torch.cuda.Event(enable_timing=True)
  132. end_time.record()
  133. torch.cuda.synchronize()
  134. elapsed_ms = start_time.elapsed_time(end_time)
  135. self._update_stats(elapsed_ms)
  136. return log_mel_spec
  137. def extract_features_streaming(self, audio_chunk: torch.Tensor,
  138. previous_context: Optional[torch.Tensor] = None) -> Tuple[torch.Tensor, torch.Tensor]:
  139. """
  140. Extract features for streaming audio with context management.
  141. Args:
  142. audio_chunk: Current audio chunk
  143. previous_context: Previous audio context for overlap processing
  144. Returns:
  145. Tuple of (features, new_context) for next iteration
  146. """
  147. # Combine with previous context if available
  148. if previous_context is not None:
  149. combined_audio = torch.cat([previous_context, audio_chunk], dim=-1)
  150. else:
  151. combined_audio = audio_chunk
  152. # Extract features
  153. features = self.extract_features(combined_audio, normalize_audio=True)
  154. # Calculate context for next iteration (50% overlap)
  155. context_samples = len(audio_chunk) // 2
  156. new_context = combined_audio[..., -context_samples:] if len(combined_audio) > context_samples else combined_audio
  157. return features, new_context
  158. def _normalize_audio(self, waveform: torch.Tensor) -> torch.Tensor:
  159. """Normalize audio waveform."""
  160. # RMS normalization
  161. rms = torch.sqrt(torch.mean(waveform ** 2, dim=-1, keepdim=True))
  162. # Avoid division by zero
  163. rms = torch.clamp(rms, min=1e-8)
  164. normalized = waveform / rms
  165. # Scale to reasonable range
  166. normalized = normalized * 0.1
  167. return normalized
  168. def _apply_preemphasis(self, waveform: torch.Tensor, alpha: float = 0.97) -> torch.Tensor:
  169. """Apply pre-emphasis filter to enhance high frequencies."""
  170. return torch.cat([
  171. waveform[..., :1],
  172. waveform[..., 1:] - alpha * waveform[..., :-1]
  173. ], dim=-1)
  174. def _ensure_correct_frames(self, mel_spec: torch.Tensor) -> torch.Tensor:
  175. """Ensure spectrogram has the correct number of time frames."""
  176. current_frames = mel_spec.shape[-1]
  177. target_frames = self.config.time_frames
  178. if current_frames == target_frames:
  179. return mel_spec
  180. elif current_frames > target_frames:
  181. # Truncate from center
  182. start_idx = (current_frames - target_frames) // 2
  183. return mel_spec[..., start_idx:start_idx + target_frames]
  184. else:
  185. # Pad to reach target frames
  186. pad_total = target_frames - current_frames
  187. pad_left = pad_total // 2
  188. pad_right = pad_total - pad_left
  189. return F.pad(mel_spec, (pad_left, pad_right), mode='constant', value=0)
  190. def _update_stats(self, elapsed_ms: float):
  191. """Update performance statistics."""
  192. self._cache_stats["total_extractions"] += 1
  193. self._cache_stats["total_time_ms"] += elapsed_ms
  194. self._cache_stats["avg_time_ms"] = (
  195. self._cache_stats["total_time_ms"] / self._cache_stats["total_extractions"]
  196. )
  197. def get_performance_stats(self) -> dict:
  198. """Get performance statistics."""
  199. return self._cache_stats.copy()
  200. def reset_stats(self):
  201. """Reset performance statistics."""
  202. self._cache_stats = {
  203. "total_extractions": 0,
  204. "total_time_ms": 0.0,
  205. "avg_time_ms": 0.0
  206. }
  207. def calculate_audio_duration(self, n_samples: int) -> float:
  208. """Calculate audio duration in seconds from number of samples."""
  209. return n_samples / self.config.sample_rate
  210. def calculate_required_samples(self, duration_seconds: float) -> int:
  211. """Calculate number of samples needed for given duration."""
  212. return int(duration_seconds * self.config.sample_rate)
  213. def validate_audio_format(self, waveform: torch.Tensor) -> bool:
  214. """
  215. Validate that audio format matches expected configuration.
  216. Args:
  217. waveform: Input audio tensor
  218. Returns:
  219. True if format is valid, False otherwise
  220. """
  221. if waveform.dim() not in [1, 2]:
  222. return False
  223. if waveform.dim() == 2 and waveform.shape[0] > 1:
  224. # Multi-channel audio - should be mono for wakeword detection
  225. warnings.warn("Multi-channel audio detected. Consider converting to mono.")
  226. return False
  227. return True
  228. class BatchAudioFeatureExtractor(AudioFeatureExtractor):
  229. """
  230. Batch-optimized feature extractor for processing multiple audio samples.
  231. Useful for training and batch inference scenarios.
  232. """
  233. def __init__(self, config: SpectrogramConfig, device: Optional[torch.device] = None):
  234. super().__init__(config, device)
  235. self.batch_size_limit = 32 # Prevent OOM for large batches
  236. def extract_features_batch(self, waveforms: torch.Tensor,
  237. lengths: Optional[torch.Tensor] = None) -> torch.Tensor:
  238. """
  239. Extract features from a batch of waveforms.
  240. Args:
  241. waveforms: Batch of waveforms (batch_size, max_length)
  242. lengths: Actual lengths of each waveform (optional)
  243. Returns:
  244. Batch of Log-Mel spectrograms (batch_size, n_mels, time_frames)
  245. """
  246. batch_size = waveforms.shape[0]
  247. if batch_size > self.batch_size_limit:
  248. # Process in chunks to avoid OOM
  249. all_features = []
  250. for i in range(0, batch_size, self.batch_size_limit):
  251. chunk = waveforms[i:i + self.batch_size_limit]
  252. chunk_lengths = lengths[i:i + self.batch_size_limit] if lengths is not None else None
  253. chunk_features = self._process_batch_chunk(chunk, chunk_lengths)
  254. all_features.append(chunk_features)
  255. return torch.cat(all_features, dim=0)
  256. else:
  257. return self._process_batch_chunk(waveforms, lengths)
  258. def _process_batch_chunk(self, waveforms: torch.Tensor,
  259. lengths: Optional[torch.Tensor] = None) -> torch.Tensor:
  260. """Process a chunk of the batch."""
  261. features_list = []
  262. for i in range(waveforms.shape[0]):
  263. waveform = waveforms[i]
  264. # Trim to actual length if provided
  265. if lengths is not None:
  266. actual_length = int(lengths[i].item())
  267. waveform = waveform[:actual_length]
  268. # Extract features for this sample
  269. features = self.extract_features(waveform.unsqueeze(0), normalize_audio=True)
  270. features_list.append(features.squeeze(0))
  271. return torch.stack(features_list, dim=0)
  272. def create_feature_extractor(config: Optional[SpectrogramConfig] = None,
  273. device: Optional[torch.device] = None,
  274. batch_mode: bool = False) -> AudioFeatureExtractor:
  275. """
  276. Factory function to create feature extractors.
  277. Args:
  278. config: Spectrogram configuration (uses default if None)
  279. device: PyTorch device
  280. batch_mode: Whether to create batch-optimized extractor
  281. Returns:
  282. Feature extractor instance
  283. """
  284. if config is None:
  285. config = SpectrogramConfig()
  286. if batch_mode:
  287. return BatchAudioFeatureExtractor(config, device)
  288. else:
  289. return AudioFeatureExtractor(config, device)
  290. def validate_spectrogram_config(config: SpectrogramConfig) -> bool:
  291. """
  292. Validate spectrogram configuration parameters.
  293. Args:
  294. config: Configuration to validate
  295. Returns:
  296. True if valid, False otherwise
  297. """
  298. try:
  299. # Check basic constraints
  300. if config.sample_rate <= 0:
  301. return False
  302. if config.n_fft <= 0 or config.n_fft > config.sample_rate:
  303. return False
  304. if config.hop_length <= 0 or config.hop_length > config.n_fft:
  305. return False
  306. if config.win_length <= 0 or config.win_length > config.n_fft:
  307. return False
  308. if config.n_mels <= 0:
  309. return False
  310. if config.f_min < 0:
  311. return False
  312. if config.f_max is not None and config.f_max <= config.f_min:
  313. return False
  314. if config.time_frames <= 0:
  315. return False
  316. return True
  317. except Exception:
  318. return False