data.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  1. """
  2. Wakeword-specific data handling and preprocessing.
  3. This module provides specialized data handling for wakeword detection tasks,
  4. including dataset creation, preprocessing, and augmentation specific to
  5. wakeword detection requirements.
  6. """
  7. import os
  8. import logging
  9. import random
  10. import numpy as np
  11. from pathlib import Path
  12. from typing import Dict, List, Tuple, Optional, Any, Union
  13. from collections import defaultdict
  14. import torch
  15. import torch.nn.functional as F
  16. from torch.utils.data import Dataset, DataLoader
  17. import torchaudio
  18. import torchaudio.transforms as T
  19. from ..data_pipeline import AudioDataset, AudioProcessor, AudioAugmentation, AudioProcessingConfig
  20. class WakewordDataset(AudioDataset):
  21. """
  22. Specialized dataset for wakeword detection.
  23. Extends the base AudioDataset with wakeword-specific functionality
  24. including class balancing, negative sample generation, and specialized
  25. augmentation strategies.
  26. """
  27. def __init__(self, audio_files: List[str], labels: List[int],
  28. class_names: Dict[int, str],
  29. audio_processor: AudioProcessor,
  30. augmentation: Optional[AudioAugmentation] = None,
  31. augmentation_config: Optional[Dict[str, float]] = None,
  32. apply_augmentation: bool = True,
  33. balance_classes: bool = True,
  34. negative_augmentation_factor: float = 2.0):
  35. """
  36. Initialize wakeword dataset.
  37. Args:
  38. audio_files: List of audio file paths
  39. labels: List of corresponding labels
  40. class_names: Mapping of class IDs to names
  41. audio_processor: Audio processor instance
  42. augmentation: Audio augmentation instance
  43. augmentation_config: Augmentation configuration
  44. apply_augmentation: Whether to apply augmentation
  45. balance_classes: Whether to balance classes using sampling
  46. negative_augmentation_factor: Extra augmentation factor for negative samples
  47. """
  48. super().__init__(
  49. audio_files, labels, audio_processor, augmentation,
  50. augmentation_config, apply_augmentation
  51. )
  52. self.class_names = class_names
  53. self.balance_classes = balance_classes
  54. self.negative_augmentation_factor = negative_augmentation_factor
  55. # Analyze class distribution
  56. self.class_distribution = self._analyze_class_distribution()
  57. # Create sampling weights for balanced training
  58. if balance_classes:
  59. self.sample_weights = self._calculate_sample_weights()
  60. else:
  61. self.sample_weights = None
  62. def _analyze_class_distribution(self) -> Dict[int, int]:
  63. """Analyze the distribution of classes in the dataset."""
  64. distribution = defaultdict(int)
  65. for label in self.labels:
  66. distribution[label] += 1
  67. return dict(distribution)
  68. def _calculate_sample_weights(self) -> torch.Tensor:
  69. """Calculate sample weights for balanced training."""
  70. class_counts = [self.class_distribution.get(i, 1) for i in range(len(self.class_names))]
  71. total_samples = sum(class_counts)
  72. # Calculate weights inversely proportional to class frequency
  73. class_weights = [total_samples / (len(self.class_names) * count) for count in class_counts]
  74. # Assign weights to each sample
  75. sample_weights = torch.zeros(len(self.labels))
  76. for i, label in enumerate(self.labels):
  77. sample_weights[i] = class_weights[label]
  78. return sample_weights
  79. def __getitem__(self, idx: int) -> Tuple[torch.Tensor, int]:
  80. """Get item with wakeword-specific processing."""
  81. audio_file = self.audio_files[idx]
  82. label = self.labels[idx]
  83. class_name = self.class_names.get(label, "unknown")
  84. try:
  85. # Load and process audio
  86. waveform, sr = self.audio_processor.load_audio(audio_file)
  87. # Convert to mono
  88. if waveform.shape[0] > 1:
  89. waveform = torch.mean(waveform, dim=0, keepdim=True)
  90. # Resample
  91. waveform = self.audio_processor.resample(waveform, sr)
  92. # Apply augmentation if enabled
  93. if (self.apply_augmentation and self.augmentation is not None
  94. and self.augmentation_config):
  95. # Apply extra augmentation for negative samples
  96. if class_name in ["negative", "background"]:
  97. # More aggressive augmentation for negative samples
  98. enhanced_config = self.augmentation_config.copy()
  99. for key in enhanced_config:
  100. if key.endswith('_factor'):
  101. enhanced_config[key] *= self.negative_augmentation_factor
  102. waveform = self.augmentation.apply_augmentations(waveform, enhanced_config)
  103. else:
  104. waveform = self.augmentation.apply_augmentations(waveform, self.augmentation_config)
  105. # Normalize length and extract features
  106. waveform = self.audio_processor.normalize_length(waveform)
  107. features = self.audio_processor.extract_features(waveform)
  108. # Ensure consistent shape
  109. if features.dim() == 3: # (1, freq, time)
  110. features = features.squeeze(0) # (freq, time)
  111. # Apply wakeword-specific feature normalization
  112. features = self._normalize_features(features)
  113. return features, label
  114. except Exception as e:
  115. logging.warning(f"Error processing {audio_file}: {str(e)}")
  116. # Return zero tensor and label on error
  117. dummy_features = torch.zeros(
  118. self.audio_processor.config.n_mels,
  119. self.audio_processor.config.target_samples // self.audio_processor.config.hop_length + 1
  120. )
  121. return self._normalize_features(dummy_features), label
  122. def _normalize_features(self, features: torch.Tensor) -> torch.Tensor:
  123. """Apply wakeword-specific feature normalization."""
  124. # Apply instance normalization to make model more robust
  125. # to volume variations and recording conditions
  126. # Calculate mean and std along time dimension
  127. mean = torch.mean(features, dim=-1, keepdim=True)
  128. std = torch.std(features, dim=-1, keepdim=True) + 1e-8
  129. # Normalize
  130. features = (features - mean) / std
  131. # Clip extreme values
  132. features = torch.clamp(features, -3.0, 3.0)
  133. return features
  134. def get_class_distribution(self) -> Dict[str, int]:
  135. """Get class distribution with class names."""
  136. return {
  137. self.class_names[class_id]: count
  138. for class_id, count in self.class_distribution.items()
  139. }
  140. class WakewordDataPreprocessor:
  141. """
  142. Specialized preprocessor for wakeword detection data.
  143. Handles the specific requirements mentioned in the project description:
  144. - Trimming wakeword files to consistent length
  145. - Splitting negative/background files into chunks
  146. - Creating balanced train/val/test splits
  147. """
  148. def __init__(self, audio_config: AudioProcessingConfig,
  149. logger: Optional[logging.Logger] = None):
  150. """Initialize wakeword data preprocessor."""
  151. self.audio_config = audio_config
  152. self.logger = logger or logging.getLogger(__name__)
  153. self.audio_processor = AudioProcessor(audio_config)
  154. def preprocess_wakeword_data(self, raw_data_dir: str, output_dir: str,
  155. wakeword_classes: Dict[int, str],
  156. max_wakeword_length: Optional[float] = None) -> str:
  157. """
  158. Preprocess raw wakeword data according to project requirements.
  159. This method implements the preprocessing pipeline described in the
  160. project description:
  161. 1. Find max wakeword length
  162. 2. Trim all files to consistent length with silence padding
  163. 3. Split negative/background files into chunks
  164. 4. Create balanced train/val/test splits
  165. Args:
  166. raw_data_dir: Directory containing raw wakeword data
  167. output_dir: Output directory for processed data
  168. wakeword_classes: Mapping of class IDs to names
  169. max_wakeword_length: Maximum wakeword length (auto-detect if None)
  170. Returns:
  171. Path to processed data directory
  172. """
  173. self.logger.info(f"Preprocessing wakeword data from {raw_data_dir}")
  174. raw_path = Path(raw_data_dir)
  175. output_path = Path(output_dir)
  176. # Step 1: Analyze wakeword lengths to determine target length
  177. if max_wakeword_length is None:
  178. max_wakeword_length = self._find_max_wakeword_length(raw_path, wakeword_classes)
  179. self.logger.info(f"Auto-detected max wakeword length: {max_wakeword_length:.2f}s")
  180. # Update audio config with detected length
  181. self.audio_config.target_length = max_wakeword_length
  182. target_samples = int(self.audio_config.sample_rate * max_wakeword_length)
  183. # Step 2: Process each class
  184. processed_files_by_class = {}
  185. for class_id, class_name in wakeword_classes.items():
  186. self.logger.info(f"Processing class: {class_name}")
  187. class_files = self._process_class_files(
  188. raw_path / class_name, class_name, target_samples
  189. )
  190. if class_files:
  191. processed_files_by_class[class_id] = class_files
  192. self.logger.info(f"Processed {len(class_files)} files for {class_name}")
  193. else:
  194. self.logger.warning(f"No files processed for class: {class_name}")
  195. # Step 3: Save processed files
  196. chunked_dir = output_path / "chunked"
  197. self._save_processed_files(processed_files_by_class, wakeword_classes, chunked_dir)
  198. # Step 4: Create train/val/test splits
  199. self._create_balanced_splits(chunked_dir, output_path, wakeword_classes)
  200. self.logger.info(f"Wakeword preprocessing completed: {output_path}")
  201. return str(output_path)
  202. def _find_max_wakeword_length(self, raw_path: Path,
  203. wakeword_classes: Dict[int, str]) -> float:
  204. """Find the maximum wakeword length after trimming silence."""
  205. max_length = 0.0
  206. # Only analyze actual wakeword classes (not negative/background)
  207. wakeword_only_classes = [name for name in wakeword_classes.values()
  208. if name not in ["negative", "background"]]
  209. for class_name in wakeword_only_classes:
  210. class_dir = raw_path / class_name
  211. if not class_dir.exists():
  212. continue
  213. # Get audio files
  214. audio_files = []
  215. for ext in ['.wav', '.mp3', '.flac']:
  216. audio_files.extend(list(class_dir.glob(f"**/*{ext}")))
  217. for audio_file in audio_files[:20]: # Sample first 20 files
  218. try:
  219. waveform, sr = self.audio_processor.load_audio(str(audio_file))
  220. # Convert to mono and resample
  221. if waveform.shape[0] > 1:
  222. waveform = torch.mean(waveform, dim=0, keepdim=True)
  223. waveform = self.audio_processor.resample(waveform, sr)
  224. # Trim silence
  225. trimmed = self.audio_processor.trim_silence(waveform, threshold=0.01)
  226. # Calculate length in seconds
  227. length_seconds = trimmed.shape[-1] / self.audio_config.sample_rate
  228. max_length = max(max_length, length_seconds)
  229. except Exception as e:
  230. self.logger.warning(f"Error analyzing {audio_file}: {str(e)}")
  231. continue
  232. # Add some padding and ensure minimum length
  233. max_length = max(max_length * 1.2, 1.0) # 20% padding, minimum 1 second
  234. return min(max_length, 3.0) # Cap at 3 seconds for practical reasons
  235. def _process_class_files(self, class_dir: Path, class_name: str,
  236. target_samples: int) -> List[Tuple[torch.Tensor, str]]:
  237. """Process all files for a specific class."""
  238. if not class_dir.exists():
  239. self.logger.warning(f"Class directory not found: {class_dir}")
  240. return []
  241. # Get audio files
  242. audio_files = []
  243. for ext in ['.wav', '.mp3', '.flac']:
  244. audio_files.extend(list(class_dir.glob(f"**/*{ext}")))
  245. processed_files = []
  246. for audio_file in audio_files:
  247. try:
  248. # Load and process audio
  249. waveform, sr = self.audio_processor.load_audio(str(audio_file))
  250. # Convert to mono and resample
  251. if waveform.shape[0] > 1:
  252. waveform = torch.mean(waveform, dim=0, keepdim=True)
  253. waveform = self.audio_processor.resample(waveform, sr)
  254. if class_name in ["negative", "background"]:
  255. # Split long files into chunks
  256. chunks = self._split_into_chunks(waveform, target_samples)
  257. for i, chunk in enumerate(chunks):
  258. filename = f"{audio_file.stem}_chunk_{i:03d}.wav"
  259. processed_files.append((chunk, filename))
  260. else:
  261. # For wakeword files: trim silence and normalize length
  262. # Trim silence first
  263. trimmed = self.audio_processor.trim_silence(waveform, threshold=0.01)
  264. # Normalize to target length with smart padding
  265. normalized = self._smart_normalize_length(trimmed, target_samples)
  266. processed_files.append((normalized, audio_file.name))
  267. except Exception as e:
  268. self.logger.error(f"Error processing {audio_file}: {str(e)}")
  269. continue
  270. return processed_files
  271. def _smart_normalize_length(self, waveform: torch.Tensor,
  272. target_samples: int) -> torch.Tensor:
  273. """
  274. Smart length normalization that preserves wakeword positioning.
  275. For wakewords, we want to:
  276. 1. Center the wakeword in the target duration
  277. 2. Add silence padding around it
  278. 3. Ensure the full wakeword is preserved
  279. """
  280. current_samples = waveform.shape[-1]
  281. if current_samples >= target_samples:
  282. # If too long, crop from center
  283. start = (current_samples - target_samples) // 2
  284. return waveform[..., start:start + target_samples]
  285. else:
  286. # If too short, pad with silence
  287. # Add random positioning to create variety
  288. max_pad_start = target_samples - current_samples
  289. pad_start = random.randint(0, max_pad_start // 2) # Bias towards center
  290. pad_end = target_samples - current_samples - pad_start
  291. return F.pad(waveform, (pad_start, pad_end))
  292. def _split_into_chunks(self, waveform: torch.Tensor,
  293. chunk_size: int, overlap_ratio: float = 0.1) -> List[torch.Tensor]:
  294. """Split long audio into overlapping chunks."""
  295. chunks = []
  296. total_samples = waveform.shape[-1]
  297. if total_samples <= chunk_size:
  298. # If shorter than chunk size, pad to chunk size
  299. padded = F.pad(waveform, (0, chunk_size - total_samples))
  300. chunks.append(padded)
  301. else:
  302. # Split into overlapping chunks
  303. step_size = int(chunk_size * (1 - overlap_ratio))
  304. start = 0
  305. while start + chunk_size <= total_samples:
  306. chunk = waveform[..., start:start + chunk_size]
  307. chunks.append(chunk)
  308. start += step_size
  309. # Add final chunk if there's remaining audio
  310. if start < total_samples and total_samples - start > chunk_size // 2:
  311. final_chunk = waveform[..., -chunk_size:]
  312. chunks.append(final_chunk)
  313. return chunks
  314. def _save_processed_files(self, processed_files_by_class: Dict[int, List[Tuple[torch.Tensor, str]]],
  315. wakeword_classes: Dict[int, str],
  316. output_dir: Path):
  317. """Save processed files to disk."""
  318. for class_id, files in processed_files_by_class.items():
  319. class_name = wakeword_classes[class_id]
  320. class_dir = output_dir / class_name
  321. class_dir.mkdir(parents=True, exist_ok=True)
  322. for waveform, filename in files:
  323. output_file = class_dir / filename
  324. torchaudio.save(
  325. str(output_file),
  326. waveform,
  327. self.audio_config.sample_rate
  328. )
  329. def _create_balanced_splits(self, chunked_dir: Path, output_dir: Path,
  330. wakeword_classes: Dict[int, str]):
  331. """Create balanced train/validation/test splits."""
  332. # Define split ratios
  333. train_ratio = 0.7
  334. val_ratio = 0.2
  335. test_ratio = 0.1
  336. # Create output directories
  337. for split in ['train', 'val', 'test']:
  338. for class_name in wakeword_classes.values():
  339. (output_dir / split / class_name).mkdir(parents=True, exist_ok=True)
  340. # Process each class
  341. for class_id, class_name in wakeword_classes.items():
  342. class_dir = chunked_dir / class_name
  343. if not class_dir.exists():
  344. continue
  345. # Get all files for this class
  346. files = list(class_dir.glob("*.wav"))
  347. if len(files) < 3:
  348. self.logger.warning(f"Too few files for {class_name} - copying to train only")
  349. # Copy all to train
  350. for file in files:
  351. import shutil
  352. shutil.copy2(file, output_dir / "train" / class_name / file.name)
  353. continue
  354. # Shuffle files
  355. random.shuffle(files)
  356. # Calculate split indices
  357. total_files = len(files)
  358. train_end = int(total_files * train_ratio)
  359. val_end = int(total_files * (train_ratio + val_ratio))
  360. # Split files
  361. train_files = files[:train_end]
  362. val_files = files[train_end:val_end]
  363. test_files = files[val_end:]
  364. # Ensure minimum samples in each split
  365. if len(val_files) == 0 and len(files) > 1:
  366. val_files = [train_files.pop()]
  367. if len(test_files) == 0 and len(files) > 2:
  368. test_files = [train_files.pop()]
  369. # Copy files to respective directories
  370. splits = [
  371. ("train", train_files),
  372. ("val", val_files),
  373. ("test", test_files)
  374. ]
  375. for split_name, split_files in splits:
  376. for file in split_files:
  377. import shutil
  378. dest_dir = output_dir / split_name / class_name
  379. shutil.copy2(file, dest_dir / file.name)
  380. self.logger.info(f"Split {class_name}: {len(train_files)} train, "
  381. f"{len(val_files)} val, {len(test_files)} test")
  382. def create_dataset_from_directory(self, data_dir: str, split: str,
  383. wakeword_classes: Dict[int, str],
  384. augmentation_config: Dict[str, float] = None,
  385. balance_classes: bool = True) -> WakewordDataset:
  386. """
  387. Create wakeword dataset from processed directory.
  388. Args:
  389. data_dir: Directory containing processed data
  390. split: Data split ('train', 'val', 'test')
  391. wakeword_classes: Mapping of class IDs to names
  392. augmentation_config: Augmentation configuration
  393. balance_classes: Whether to balance classes
  394. Returns:
  395. WakewordDataset instance
  396. """
  397. data_path = Path(data_dir) / split
  398. # Collect all files and labels
  399. audio_files = []
  400. labels = []
  401. class_name_to_id = {name: class_id for class_id, name in wakeword_classes.items()}
  402. for class_name, class_id in class_name_to_id.items():
  403. class_dir = data_path / class_name
  404. if not class_dir.exists():
  405. self.logger.warning(f"Class directory not found: {class_dir}")
  406. continue
  407. # Get all audio files
  408. for audio_file in class_dir.glob("*.wav"):
  409. audio_files.append(str(audio_file))
  410. labels.append(class_id)
  411. if not audio_files:
  412. raise ValueError(f"No audio files found in {data_path}")
  413. # Create augmentation
  414. augmentation = None
  415. if split == "train" and augmentation_config:
  416. augmentation = AudioAugmentation(self.audio_config.sample_rate)
  417. # Create dataset
  418. dataset = WakewordDataset(
  419. audio_files=audio_files,
  420. labels=labels,
  421. class_names=wakeword_classes,
  422. audio_processor=self.audio_processor,
  423. augmentation=augmentation,
  424. augmentation_config=augmentation_config,
  425. apply_augmentation=(split == "train"),
  426. balance_classes=balance_classes
  427. )
  428. self.logger.info(f"Created {split} dataset with {len(dataset)} samples")
  429. self.logger.info(f"Class distribution: {dataset.get_class_distribution()}")
  430. return dataset
  431. def create_wakeword_dataloaders(data_dir: str, wakeword_classes: Dict[int, str],
  432. audio_config: AudioProcessingConfig,
  433. batch_size: int = 32,
  434. num_workers: int = 4,
  435. augmentation_config: Dict[str, float] = None,
  436. balance_training: bool = True) -> Tuple[DataLoader, DataLoader, DataLoader]:
  437. """
  438. Create train, validation, and test data loaders for wakeword detection.
  439. Args:
  440. data_dir: Directory containing processed wakeword data
  441. wakeword_classes: Mapping of class IDs to names
  442. audio_config: Audio processing configuration
  443. batch_size: Batch size for data loaders
  444. num_workers: Number of worker processes
  445. augmentation_config: Augmentation configuration
  446. balance_training: Whether to balance training data
  447. Returns:
  448. Tuple of (train_loader, val_loader, test_loader)
  449. """
  450. preprocessor = WakewordDataPreprocessor(audio_config)
  451. # Create datasets
  452. train_dataset = preprocessor.create_dataset_from_directory(
  453. data_dir, "train", wakeword_classes, augmentation_config, balance_training
  454. )
  455. val_dataset = preprocessor.create_dataset_from_directory(
  456. data_dir, "val", wakeword_classes, None, False
  457. )
  458. test_dataset = preprocessor.create_dataset_from_directory(
  459. data_dir, "test", wakeword_classes, None, False
  460. )
  461. # Create data loaders
  462. train_loader = DataLoader(
  463. train_dataset,
  464. batch_size=batch_size,
  465. shuffle=True,
  466. num_workers=num_workers,
  467. pin_memory=True,
  468. drop_last=True
  469. )
  470. val_loader = DataLoader(
  471. val_dataset,
  472. batch_size=batch_size,
  473. shuffle=False,
  474. num_workers=num_workers,
  475. pin_memory=True
  476. )
  477. test_loader = DataLoader(
  478. test_dataset,
  479. batch_size=batch_size,
  480. shuffle=False,
  481. num_workers=num_workers,
  482. pin_memory=True
  483. )
  484. return train_loader, val_loader, test_loader