data_pipeline.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936
  1. """
  2. Data pipeline management and preprocessing for ML trainers.
  3. This module provides comprehensive data handling including audio preprocessing,
  4. data augmentation, dataset creation, and batch loading with support for
  5. various audio formats and training 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, Union, Callable, Any
  13. from dataclasses import dataclass
  14. import warnings
  15. import torch
  16. import torch.nn as nn
  17. import torch.nn.functional as F
  18. from torch.utils.data import Dataset, DataLoader, random_split, WeightedRandomSampler
  19. import torchaudio
  20. import torchaudio.transforms as T
  21. from sklearn.model_selection import train_test_split
  22. # Suppress torchaudio warnings
  23. warnings.filterwarnings("ignore", category=UserWarning, module="torchaudio")
  24. @dataclass
  25. class AudioProcessingConfig:
  26. """Configuration for audio processing."""
  27. sample_rate: int = 16000
  28. target_length: float = 1.5 # seconds
  29. n_mels: int = 40
  30. n_fft: int = 512
  31. hop_length: int = 160 # 10ms at 16kHz
  32. win_length: int = 400 # 25ms at 16kHz
  33. fmin: float = 80.0
  34. fmax: float = 8000.0
  35. power: float = 2.0
  36. normalized: bool = True
  37. feature_type: str = "mel_spectrogram" # mel_spectrogram, mfcc, raw
  38. @property
  39. def target_samples(self) -> int:
  40. """Get target number of samples."""
  41. return int(self.sample_rate * self.target_length)
  42. class AudioProcessor:
  43. """Audio processing utilities for the training pipeline."""
  44. def __init__(self, config: AudioProcessingConfig, device: str = "cpu"):
  45. """
  46. Initialize audio processor.
  47. Args:
  48. config: Audio processing configuration
  49. device: Device for processing ('cpu', 'cuda')
  50. """
  51. self.config = config
  52. self.device = device
  53. # Initialize transforms
  54. self.mel_spectrogram = T.MelSpectrogram(
  55. sample_rate=config.sample_rate,
  56. n_fft=config.n_fft,
  57. hop_length=config.hop_length,
  58. win_length=config.win_length,
  59. n_mels=config.n_mels,
  60. fmin=config.fmin,
  61. fmax=config.fmax,
  62. power=config.power,
  63. normalized=config.normalized
  64. ).to(device)
  65. self.mfcc = T.MFCC(
  66. sample_rate=config.sample_rate,
  67. n_mfcc=config.n_mels,
  68. melkwargs={
  69. 'n_fft': config.n_fft,
  70. 'hop_length': config.hop_length,
  71. 'win_length': config.win_length,
  72. 'n_mels': config.n_mels,
  73. 'fmin': config.fmin,
  74. 'fmax': config.fmax
  75. }
  76. ).to(device)
  77. self.amplitude_to_db = T.AmplitudeToDB().to(device)
  78. def load_audio(self, filepath: str) -> Tuple[torch.Tensor, int]:
  79. """
  80. Load audio file and return waveform and sample rate.
  81. Args:
  82. filepath: Path to audio file
  83. Returns:
  84. Tuple of (waveform, sample_rate)
  85. """
  86. try:
  87. waveform, sr = torchaudio.load(filepath)
  88. return waveform, sr
  89. except Exception as e:
  90. raise ValueError(f"Error loading audio file {filepath}: {str(e)}")
  91. def resample(self, waveform: torch.Tensor, orig_sr: int) -> torch.Tensor:
  92. """
  93. Resample audio to target sample rate.
  94. Args:
  95. waveform: Input waveform
  96. orig_sr: Original sample rate
  97. Returns:
  98. Resampled waveform
  99. """
  100. if orig_sr != self.config.sample_rate:
  101. resampler = T.Resample(orig_sr, self.config.sample_rate)
  102. waveform = resampler(waveform)
  103. return waveform
  104. def normalize_length(self, waveform: torch.Tensor,
  105. method: str = "pad_trim") -> torch.Tensor:
  106. """
  107. Normalize audio length to target duration.
  108. Args:
  109. waveform: Input waveform
  110. method: Method for length normalization ('pad_trim', 'stretch', 'random_crop')
  111. Returns:
  112. Length-normalized waveform
  113. """
  114. target_samples = self.config.target_samples
  115. current_samples = waveform.shape[-1]
  116. if method == "pad_trim":
  117. if current_samples > target_samples:
  118. # Trim from center
  119. start = (current_samples - target_samples) // 2
  120. waveform = waveform[..., start:start + target_samples]
  121. elif current_samples < target_samples:
  122. # Pad with zeros
  123. pad_amount = target_samples - current_samples
  124. pad_left = pad_amount // 2
  125. pad_right = pad_amount - pad_left
  126. waveform = F.pad(waveform, (pad_left, pad_right))
  127. elif method == "random_crop":
  128. if current_samples > target_samples:
  129. # Random crop
  130. start = random.randint(0, current_samples - target_samples)
  131. waveform = waveform[..., start:start + target_samples]
  132. elif current_samples < target_samples:
  133. # Pad with zeros
  134. pad_amount = target_samples - current_samples
  135. waveform = F.pad(waveform, (0, pad_amount))
  136. elif method == "stretch":
  137. # Time-stretch to exact length (may change pitch)
  138. if current_samples != target_samples:
  139. stretch_factor = target_samples / current_samples
  140. # Simple linear interpolation for stretching
  141. indices = torch.linspace(0, current_samples - 1, target_samples)
  142. waveform = torch.nn.functional.interpolate(
  143. waveform.unsqueeze(0),
  144. size=target_samples,
  145. mode='linear',
  146. align_corners=True
  147. ).squeeze(0)
  148. return waveform
  149. def extract_features(self, waveform: torch.Tensor) -> torch.Tensor:
  150. """
  151. Extract features from waveform.
  152. Args:
  153. waveform: Input waveform
  154. Returns:
  155. Feature tensor
  156. """
  157. waveform = waveform.to(self.device)
  158. if self.config.feature_type == "mel_spectrogram":
  159. features = self.mel_spectrogram(waveform)
  160. features = self.amplitude_to_db(features)
  161. elif self.config.feature_type == "mfcc":
  162. features = self.mfcc(waveform)
  163. elif self.config.feature_type == "raw":
  164. features = waveform
  165. else:
  166. raise ValueError(f"Unknown feature type: {self.config.feature_type}")
  167. return features
  168. def process_audio(self, filepath: str,
  169. normalize_method: str = "pad_trim") -> torch.Tensor:
  170. """
  171. Complete audio processing pipeline.
  172. Args:
  173. filepath: Path to audio file
  174. normalize_method: Method for length normalization
  175. Returns:
  176. Processed feature tensor
  177. """
  178. # Load audio
  179. waveform, sr = self.load_audio(filepath)
  180. # Convert to mono if stereo
  181. if waveform.shape[0] > 1:
  182. waveform = torch.mean(waveform, dim=0, keepdim=True)
  183. # Resample
  184. waveform = self.resample(waveform, sr)
  185. # Normalize length
  186. waveform = self.normalize_length(waveform, normalize_method)
  187. # Extract features
  188. features = self.extract_features(waveform)
  189. return features
  190. def trim_silence(self, waveform: torch.Tensor,
  191. threshold: float = 0.01) -> torch.Tensor:
  192. """
  193. Trim silence from beginning and end of audio.
  194. Args:
  195. waveform: Input waveform
  196. threshold: Silence threshold (relative to max amplitude)
  197. Returns:
  198. Trimmed waveform
  199. """
  200. # Calculate energy
  201. energy = torch.abs(waveform)
  202. max_energy = torch.max(energy)
  203. # Find non-silent regions
  204. non_silent = energy > (threshold * max_energy)
  205. if torch.any(non_silent):
  206. # Find first and last non-silent samples
  207. non_silent_indices = torch.where(non_silent.squeeze())[0]
  208. start_idx = non_silent_indices[0].item()
  209. end_idx = non_silent_indices[-1].item() + 1
  210. return waveform[..., start_idx:end_idx]
  211. else:
  212. # If all silent, return original
  213. return waveform
  214. class AudioAugmentation:
  215. """Audio data augmentation utilities."""
  216. def __init__(self, sample_rate: int = 16000, device: str = "cpu"):
  217. """Initialize audio augmentation."""
  218. self.sample_rate = sample_rate
  219. self.device = device
  220. def add_noise(self, waveform: torch.Tensor,
  221. noise_factor: float = 0.1) -> torch.Tensor:
  222. """Add random noise to audio."""
  223. noise = torch.randn_like(waveform) * noise_factor
  224. return waveform + noise
  225. def time_shift(self, waveform: torch.Tensor,
  226. shift_factor: float = 0.1) -> torch.Tensor:
  227. """Apply random time shift."""
  228. shift_amount = int(waveform.shape[-1] * shift_factor * (2 * random.random() - 1))
  229. return torch.roll(waveform, shift_amount, dims=-1)
  230. def speed_change(self, waveform: torch.Tensor,
  231. speed_factor: float = 0.1) -> torch.Tensor:
  232. """Change playback speed (affects both tempo and pitch)."""
  233. speed_change = 1.0 + speed_factor * (2 * random.random() - 1)
  234. # Simple resampling for speed change
  235. new_length = int(waveform.shape[-1] / speed_change)
  236. indices = torch.linspace(0, waveform.shape[-1] - 1, new_length)
  237. # Linear interpolation
  238. waveform_resampled = torch.nn.functional.interpolate(
  239. waveform.unsqueeze(0),
  240. size=new_length,
  241. mode='linear',
  242. align_corners=True
  243. ).squeeze(0)
  244. return waveform_resampled
  245. def pitch_shift(self, waveform: torch.Tensor,
  246. pitch_factor: float = 0.1) -> torch.Tensor:
  247. """Apply pitch shift (simplified version)."""
  248. # This is a simplified pitch shift - in practice, you'd use more sophisticated methods
  249. pitch_change = 1.0 + pitch_factor * (2 * random.random() - 1)
  250. # Create a simple frequency domain shift
  251. fft = torch.fft.fft(waveform)
  252. shifted_fft = torch.zeros_like(fft)
  253. n = fft.shape[-1]
  254. shift_bins = int(n * (pitch_change - 1.0))
  255. if shift_bins > 0:
  256. shifted_fft[..., shift_bins:] = fft[..., :-shift_bins]
  257. elif shift_bins < 0:
  258. shifted_fft[..., :shift_bins] = fft[..., -shift_bins:]
  259. else:
  260. shifted_fft = fft
  261. return torch.fft.ifft(shifted_fft).real
  262. def volume_change(self, waveform: torch.Tensor,
  263. volume_factor: float = 0.2) -> torch.Tensor:
  264. """Apply random volume change."""
  265. volume_change = 1.0 + volume_factor * (2 * random.random() - 1)
  266. return waveform * volume_change
  267. def apply_augmentations(self, waveform: torch.Tensor,
  268. augmentation_config: Dict[str, float]) -> torch.Tensor:
  269. """Apply multiple augmentations based on configuration."""
  270. augmented = waveform.clone()
  271. if augmentation_config.get('noise_factor', 0) > 0:
  272. if random.random() < 0.5: # 50% chance
  273. augmented = self.add_noise(augmented, augmentation_config['noise_factor'])
  274. if augmentation_config.get('speed_factor', 0) > 0:
  275. if random.random() < 0.3: # 30% chance
  276. augmented = self.speed_change(augmented, augmentation_config['speed_factor'])
  277. if augmentation_config.get('pitch_factor', 0) > 0:
  278. if random.random() < 0.3: # 30% chance
  279. augmented = self.pitch_shift(augmented, augmentation_config['pitch_factor'])
  280. if augmentation_config.get('volume_factor', 0) > 0:
  281. if random.random() < 0.4: # 40% chance
  282. augmented = self.volume_change(augmented, augmentation_config['volume_factor'])
  283. if augmentation_config.get('time_shift_factor', 0) > 0:
  284. if random.random() < 0.3: # 30% chance
  285. augmented = self.time_shift(augmented, augmentation_config['time_shift_factor'])
  286. return augmented
  287. class AudioDataset(Dataset):
  288. """Generic audio dataset for training."""
  289. def __init__(self, audio_files: List[str], labels: List[int],
  290. audio_processor: AudioProcessor,
  291. augmentation: Optional[AudioAugmentation] = None,
  292. augmentation_config: Optional[Dict[str, float]] = None,
  293. apply_augmentation: bool = True):
  294. """
  295. Initialize audio dataset.
  296. Args:
  297. audio_files: List of audio file paths
  298. labels: List of corresponding labels
  299. audio_processor: Audio processor instance
  300. augmentation: Audio augmentation instance
  301. augmentation_config: Augmentation configuration
  302. apply_augmentation: Whether to apply augmentation during training
  303. """
  304. self.audio_files = audio_files
  305. self.labels = labels
  306. self.audio_processor = audio_processor
  307. self.augmentation = augmentation
  308. self.augmentation_config = augmentation_config or {}
  309. self.apply_augmentation = apply_augmentation
  310. assert len(audio_files) == len(labels), "Audio files and labels must have same length"
  311. def __len__(self) -> int:
  312. return len(self.audio_files)
  313. def __getitem__(self, idx: int) -> Tuple[torch.Tensor, int]:
  314. """Get item from dataset."""
  315. audio_file = self.audio_files[idx]
  316. label = self.labels[idx]
  317. try:
  318. # Load and process audio
  319. waveform, sr = self.audio_processor.load_audio(audio_file)
  320. # Convert to mono
  321. if waveform.shape[0] > 1:
  322. waveform = torch.mean(waveform, dim=0, keepdim=True)
  323. # Resample
  324. waveform = self.audio_processor.resample(waveform, sr)
  325. # Apply augmentation if enabled and available
  326. if (self.apply_augmentation and self.augmentation is not None
  327. and self.augmentation_config):
  328. waveform = self.augmentation.apply_augmentations(
  329. waveform, self.augmentation_config
  330. )
  331. # Normalize length and extract features
  332. waveform = self.audio_processor.normalize_length(waveform)
  333. features = self.audio_processor.extract_features(waveform)
  334. # Ensure consistent shape
  335. if features.dim() == 3: # (1, freq, time)
  336. features = features.squeeze(0) # (freq, time)
  337. return features, label
  338. except Exception as e:
  339. # Return zero tensor and label on error
  340. logging.warning(f"Error processing {audio_file}: {str(e)}")
  341. dummy_features = torch.zeros(
  342. self.audio_processor.config.n_mels,
  343. self.audio_processor.config.target_samples // self.audio_processor.config.hop_length + 1
  344. )
  345. return dummy_features, label
  346. class DatasetBuilder:
  347. """Builder for creating training datasets from file structures."""
  348. def __init__(self, logger: Optional[logging.Logger] = None):
  349. """Initialize dataset builder."""
  350. self.logger = logger or logging.getLogger(__name__)
  351. def scan_directory_structure(self, data_dir: str,
  352. supported_extensions: List[str] = None) -> Dict[str, List[str]]:
  353. """
  354. Scan directory structure and organize files by class.
  355. Args:
  356. data_dir: Root data directory
  357. supported_extensions: List of supported file extensions
  358. Returns:
  359. Dictionary mapping class names to file lists
  360. """
  361. if supported_extensions is None:
  362. supported_extensions = ['.wav', '.mp3', '.flac', '.m4a']
  363. data_dir = Path(data_dir)
  364. if not data_dir.exists():
  365. raise FileNotFoundError(f"Data directory not found: {data_dir}")
  366. class_files = {}
  367. # For each subdirectory (class)
  368. for class_dir in data_dir.iterdir():
  369. if class_dir.is_dir():
  370. class_name = class_dir.name
  371. files = []
  372. # Find all audio files in class directory
  373. for ext in supported_extensions:
  374. files.extend(list(class_dir.glob(f"**/*{ext}")))
  375. if files:
  376. class_files[class_name] = [str(f) for f in files]
  377. self.logger.info(f"Found {len(files)} files for class '{class_name}'")
  378. return class_files
  379. def create_wakeword_dataset(self, data_dir: str,
  380. audio_processor: AudioProcessor,
  381. wakeword_classes: Dict[int, str],
  382. val_split: float = 0.2,
  383. test_split: float = 0.1,
  384. augmentation_config: Dict[str, float] = None) -> Tuple[AudioDataset, AudioDataset, AudioDataset]:
  385. """
  386. Create wakeword detection dataset.
  387. Args:
  388. data_dir: Data directory containing wakeword audio files
  389. audio_processor: Audio processor instance
  390. wakeword_classes: Mapping of class IDs to class names
  391. val_split: Validation split ratio
  392. test_split: Test split ratio
  393. augmentation_config: Augmentation configuration
  394. Returns:
  395. Tuple of (train_dataset, val_dataset, test_dataset)
  396. """
  397. # Scan directory structure
  398. class_files = self.scan_directory_structure(data_dir)
  399. # Map class names to IDs
  400. class_name_to_id = {name: class_id for class_id, name in wakeword_classes.items()}
  401. all_files = []
  402. all_labels = []
  403. for class_name, files in class_files.items():
  404. if class_name in class_name_to_id:
  405. class_id = class_name_to_id[class_name]
  406. all_files.extend(files)
  407. all_labels.extend([class_id] * len(files))
  408. self.logger.info(f"Added {len(files)} files for class '{class_name}' (ID: {class_id})")
  409. else:
  410. self.logger.warning(f"Unknown class '{class_name}' - skipping")
  411. if not all_files:
  412. raise ValueError("No valid audio files found")
  413. # Split data
  414. train_files, test_files, train_labels, test_labels = train_test_split(
  415. all_files, all_labels, test_size=test_split, random_state=42, stratify=all_labels
  416. )
  417. train_files, val_files, train_labels, val_labels = train_test_split(
  418. train_files, train_labels, test_size=val_split/(1-test_split),
  419. random_state=42, stratify=train_labels
  420. )
  421. # Create augmentation
  422. augmentation = AudioAugmentation(audio_processor.config.sample_rate)
  423. # Create datasets
  424. train_dataset = AudioDataset(
  425. train_files, train_labels, audio_processor,
  426. augmentation, augmentation_config, apply_augmentation=True
  427. )
  428. val_dataset = AudioDataset(
  429. val_files, val_labels, audio_processor,
  430. None, None, apply_augmentation=False
  431. )
  432. test_dataset = AudioDataset(
  433. test_files, test_labels, audio_processor,
  434. None, None, apply_augmentation=False
  435. )
  436. self.logger.info(f"Created datasets - Train: {len(train_dataset)}, "
  437. f"Val: {len(val_dataset)}, Test: {len(test_dataset)}")
  438. return train_dataset, val_dataset, test_dataset
  439. def create_voice_recognition_dataset(self, data_dir: str,
  440. audio_processor: AudioProcessor,
  441. speaker_mapping: Dict[str, str],
  442. val_split: float = 0.2,
  443. test_split: float = 0.1,
  444. augmentation_config: Dict[str, float] = None) -> Tuple[AudioDataset, AudioDataset, AudioDataset, Dict[str, str]]:
  445. """
  446. Create voice recognition dataset.
  447. Args:
  448. data_dir: Data directory containing speaker audio files
  449. audio_processor: Audio processor instance
  450. speaker_mapping: Mapping of directory names to speaker names
  451. val_split: Validation split ratio
  452. test_split: Test split ratio
  453. augmentation_config: Augmentation configuration
  454. Returns:
  455. Tuple of (train_dataset, val_dataset, test_dataset, final_speaker_mapping)
  456. """
  457. # Scan directory structure
  458. class_files = self.scan_directory_structure(data_dir)
  459. # Create speaker ID mapping
  460. speaker_id_mapping = {}
  461. all_files = []
  462. all_labels = []
  463. for idx, (dir_name, files) in enumerate(class_files.items()):
  464. speaker_name = speaker_mapping.get(dir_name, dir_name)
  465. speaker_id = f"speaker{str(idx).zfill(3)}"
  466. speaker_id_mapping[speaker_id] = speaker_name
  467. all_files.extend(files)
  468. all_labels.extend([idx] * len(files))
  469. self.logger.info(f"Added {len(files)} files for speaker '{speaker_name}' "
  470. f"(ID: {speaker_id}, Label: {idx})")
  471. if not all_files:
  472. raise ValueError("No valid audio files found")
  473. # Split data
  474. train_files, test_files, train_labels, test_labels = train_test_split(
  475. all_files, all_labels, test_size=test_split, random_state=42, stratify=all_labels
  476. )
  477. train_files, val_files, train_labels, val_labels = train_test_split(
  478. train_files, train_labels, test_size=val_split/(1-test_split),
  479. random_state=42, stratify=train_labels
  480. )
  481. # Create augmentation
  482. augmentation = AudioAugmentation(audio_processor.config.sample_rate)
  483. # Create datasets
  484. train_dataset = AudioDataset(
  485. train_files, train_labels, audio_processor,
  486. augmentation, augmentation_config, apply_augmentation=True
  487. )
  488. val_dataset = AudioDataset(
  489. val_files, val_labels, audio_processor,
  490. None, None, apply_augmentation=False
  491. )
  492. test_dataset = AudioDataset(
  493. test_files, test_labels, audio_processor,
  494. None, None, apply_augmentation=False
  495. )
  496. self.logger.info(f"Created voice recognition datasets - Train: {len(train_dataset)}, "
  497. f"Val: {len(val_dataset)}, Test: {len(test_dataset)}")
  498. return train_dataset, val_dataset, test_dataset, speaker_id_mapping
  499. class DataLoaderFactory:
  500. """Factory for creating optimized data loaders."""
  501. @staticmethod
  502. def create_dataloader(dataset: Dataset,
  503. batch_size: int,
  504. shuffle: bool = True,
  505. num_workers: int = 4,
  506. pin_memory: bool = True,
  507. drop_last: bool = False,
  508. balanced_sampling: bool = False) -> DataLoader:
  509. """
  510. Create optimized data loader.
  511. Args:
  512. dataset: Dataset to load from
  513. batch_size: Batch size
  514. shuffle: Whether to shuffle data
  515. num_workers: Number of worker processes
  516. pin_memory: Whether to pin memory
  517. drop_last: Whether to drop last incomplete batch
  518. balanced_sampling: Whether to use balanced sampling for imbalanced datasets
  519. Returns:
  520. Configured DataLoader
  521. """
  522. sampler = None
  523. if balanced_sampling and hasattr(dataset, 'labels'):
  524. # Create weighted sampler for balanced sampling
  525. labels = dataset.labels
  526. class_counts = torch.bincount(torch.tensor(labels))
  527. class_weights = 1.0 / class_counts.float()
  528. sample_weights = class_weights[labels]
  529. sampler = WeightedRandomSampler(
  530. weights=sample_weights,
  531. num_samples=len(dataset),
  532. replacement=True
  533. )
  534. shuffle = False # Don't shuffle when using sampler
  535. return DataLoader(
  536. dataset=dataset,
  537. batch_size=batch_size,
  538. shuffle=shuffle,
  539. sampler=sampler,
  540. num_workers=num_workers,
  541. pin_memory=pin_memory,
  542. drop_last=drop_last,
  543. persistent_workers=num_workers > 0
  544. )
  545. class DataPreprocessor:
  546. """Comprehensive data preprocessing pipeline."""
  547. def __init__(self, audio_config: AudioProcessingConfig,
  548. logger: Optional[logging.Logger] = None):
  549. """Initialize data preprocessor."""
  550. self.audio_config = audio_config
  551. self.logger = logger or logging.getLogger(__name__)
  552. self.audio_processor = AudioProcessor(audio_config)
  553. self.dataset_builder = DatasetBuilder(logger)
  554. def preprocess_wakeword_data(self, raw_data_dir: str,
  555. output_dir: str,
  556. wakeword_classes: Dict[int, str]) -> str:
  557. """
  558. Preprocess raw wakeword data.
  559. This method handles the requirements from the project description:
  560. 1. Trim audio files to consistent length
  561. 2. Split negative/background files into chunks
  562. 3. Sort into train/val/test directories
  563. Args:
  564. raw_data_dir: Directory containing raw audio files
  565. output_dir: Output directory for processed data
  566. wakeword_classes: Mapping of class IDs to names
  567. Returns:
  568. Path to processed data directory
  569. """
  570. raw_data_path = Path(raw_data_dir)
  571. output_path = Path(output_dir)
  572. self.logger.info(f"Preprocessing wakeword data from {raw_data_dir}")
  573. # Process each class
  574. for class_id, class_name in wakeword_classes.items():
  575. class_raw_dir = raw_data_path / class_name
  576. if not class_raw_dir.exists():
  577. self.logger.warning(f"Class directory not found: {class_raw_dir}")
  578. continue
  579. self.logger.info(f"Processing class: {class_name}")
  580. # Get all audio files
  581. audio_files = []
  582. for ext in ['.wav', '.mp3', '.flac']:
  583. audio_files.extend(list(class_raw_dir.glob(f"**/*{ext}")))
  584. if not audio_files:
  585. self.logger.warning(f"No audio files found for class: {class_name}")
  586. continue
  587. processed_files = []
  588. for audio_file in audio_files:
  589. try:
  590. # Load and process audio
  591. waveform, sr = self.audio_processor.load_audio(str(audio_file))
  592. # Convert to mono
  593. if waveform.shape[0] > 1:
  594. waveform = torch.mean(waveform, dim=0, keepdim=True)
  595. # Resample
  596. waveform = self.audio_processor.resample(waveform, sr)
  597. if class_name in ['negative', 'background']:
  598. # Split long files into chunks
  599. chunks = self._split_into_chunks(waveform, self.audio_config.target_samples)
  600. for i, chunk in enumerate(chunks):
  601. chunk_filename = f"{audio_file.stem}_chunk_{i:03d}.wav"
  602. processed_files.append((chunk, chunk_filename))
  603. else:
  604. # For wakeword files, trim silence and normalize length
  605. waveform = self.audio_processor.trim_silence(waveform)
  606. waveform = self.audio_processor.normalize_length(waveform)
  607. processed_files.append((waveform, audio_file.name))
  608. except Exception as e:
  609. self.logger.error(f"Error processing {audio_file}: {str(e)}")
  610. continue
  611. # Save processed files
  612. class_output_dir = output_path / "chunked" / class_name
  613. class_output_dir.mkdir(parents=True, exist_ok=True)
  614. for waveform, filename in processed_files:
  615. output_file = class_output_dir / filename
  616. torchaudio.save(str(output_file), waveform, self.audio_config.sample_rate)
  617. self.logger.info(f"Processed {len(processed_files)} files for class: {class_name}")
  618. # Split into train/val/test
  619. self._split_processed_data(output_path / "chunked", output_path)
  620. return str(output_path)
  621. def preprocess_voice_recognition_data(self, raw_data_dir: str,
  622. output_dir: str,
  623. target_chunk_length: float = None) -> str:
  624. """
  625. Preprocess raw voice recognition data.
  626. Args:
  627. raw_data_dir: Directory containing raw speaker audio files
  628. output_dir: Output directory for processed data
  629. target_chunk_length: Target chunk length (uses wakeword length if None)
  630. Returns:
  631. Path to processed data directory
  632. """
  633. if target_chunk_length is None:
  634. target_chunk_length = self.audio_config.target_length
  635. raw_data_path = Path(raw_data_dir)
  636. output_path = Path(output_dir)
  637. self.logger.info(f"Preprocessing voice recognition data from {raw_data_dir}")
  638. # Process each speaker directory
  639. for speaker_dir in raw_data_path.iterdir():
  640. if not speaker_dir.is_dir():
  641. continue
  642. speaker_name = speaker_dir.name
  643. self.logger.info(f"Processing speaker: {speaker_name}")
  644. # Get all audio files
  645. audio_files = []
  646. for ext in ['.wav', '.mp3', '.flac']:
  647. audio_files.extend(list(speaker_dir.glob(f"**/*{ext}")))
  648. if not audio_files:
  649. self.logger.warning(f"No audio files found for speaker: {speaker_name}")
  650. continue
  651. processed_files = []
  652. target_samples = int(self.audio_config.sample_rate * target_chunk_length)
  653. for audio_file in audio_files:
  654. try:
  655. # Load and process audio
  656. waveform, sr = self.audio_processor.load_audio(str(audio_file))
  657. # Convert to mono
  658. if waveform.shape[0] > 1:
  659. waveform = torch.mean(waveform, dim=0, keepdim=True)
  660. # Resample
  661. waveform = self.audio_processor.resample(waveform, sr)
  662. # Trim silence
  663. waveform = self.audio_processor.trim_silence(waveform)
  664. # Split into chunks
  665. chunks = self._split_into_chunks(waveform, target_samples)
  666. for i, chunk in enumerate(chunks):
  667. chunk_filename = f"{audio_file.stem}_chunk_{i:03d}.wav"
  668. processed_files.append((chunk, chunk_filename))
  669. except Exception as e:
  670. self.logger.error(f"Error processing {audio_file}: {str(e)}")
  671. continue
  672. # Save processed files
  673. speaker_output_dir = output_path / "chunked" / speaker_name
  674. speaker_output_dir.mkdir(parents=True, exist_ok=True)
  675. for waveform, filename in processed_files:
  676. output_file = speaker_output_dir / filename
  677. torchaudio.save(str(output_file), waveform, self.audio_config.sample_rate)
  678. self.logger.info(f"Processed {len(processed_files)} files for speaker: {speaker_name}")
  679. # Split into train/val/test
  680. self._split_processed_data(output_path / "chunked", output_path)
  681. return str(output_path)
  682. def _split_into_chunks(self, waveform: torch.Tensor,
  683. chunk_size: int,
  684. overlap: float = 0.0) -> List[torch.Tensor]:
  685. """Split waveform into chunks of specified size."""
  686. chunks = []
  687. total_samples = waveform.shape[-1]
  688. if total_samples <= chunk_size:
  689. # If shorter than chunk size, pad and return
  690. padded = F.pad(waveform, (0, chunk_size - total_samples))
  691. chunks.append(padded)
  692. else:
  693. # Split into overlapping chunks
  694. step_size = int(chunk_size * (1 - overlap))
  695. start = 0
  696. while start + chunk_size <= total_samples:
  697. chunk = waveform[..., start:start + chunk_size]
  698. chunks.append(chunk)
  699. start += step_size
  700. # Add final chunk if there's remaining audio
  701. if start < total_samples:
  702. final_chunk = waveform[..., -chunk_size:]
  703. chunks.append(final_chunk)
  704. return chunks
  705. def _split_processed_data(self, chunked_dir: Path, output_dir: Path):
  706. """Split processed data into train/val/test directories."""
  707. train_dir = output_dir / "train"
  708. val_dir = output_dir / "val"
  709. test_dir = output_dir / "test"
  710. # Create directories
  711. for split_dir in [train_dir, val_dir, test_dir]:
  712. split_dir.mkdir(parents=True, exist_ok=True)
  713. # Process each class/speaker
  714. for class_dir in chunked_dir.iterdir():
  715. if not class_dir.is_dir():
  716. continue
  717. class_name = class_dir.name
  718. # Get all files
  719. files = list(class_dir.glob("*.wav"))
  720. if len(files) < 3:
  721. self.logger.warning(f"Too few files for class {class_name} - skipping split")
  722. continue
  723. # Split files
  724. random.shuffle(files)
  725. total_files = len(files)
  726. test_count = max(1, int(total_files * 0.1))
  727. val_count = max(1, int(total_files * 0.2))
  728. train_count = total_files - test_count - val_count
  729. test_files = files[:test_count]
  730. val_files = files[test_count:test_count + val_count]
  731. train_files = files[test_count + val_count:]
  732. # Copy files to appropriate directories
  733. for split_name, split_files, split_dir in [
  734. ("train", train_files, train_dir),
  735. ("val", val_files, val_dir),
  736. ("test", test_files, test_dir)
  737. ]:
  738. class_split_dir = split_dir / class_name
  739. class_split_dir.mkdir(exist_ok=True)
  740. for file in split_files:
  741. import shutil
  742. shutil.copy2(file, class_split_dir / file.name)
  743. self.logger.info(f"Split {class_name}: {len(train_files)} train, "
  744. f"{len(val_files)} val, {len(test_files)} test")