data.py 24 KB

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