#!/usr/bin/env python3 """ Train an audio embedding classifier on recorded samples. Uses wav2vec2 to extract embeddings, then trains a simple classifier. Usage: uv run python train_classifier.py """ import pickle from pathlib import Path import numpy as np import torch from scipy.io import wavfile from torchaudio.pipelines import WAV2VEC2_BASE RECORDINGS_DIR = Path(__file__).parent / "recordings" MODEL_PATH = Path(__file__).parent / "classifier.pkl" SAMPLE_RATE = 16000 # Categories (excluding wake_word which is handled separately) CATEGORIES = [ "pressure_below_15", "pressure_15_to_25", "pressure_25_to_35", "pressure_above_35", "drop_l", "drop_p", "drop_d", "both_eyes", ] def load_audio(path: Path) -> torch.Tensor: """Load and preprocess audio file.""" sr, data = wavfile.read(path) # Convert to float32 if data.dtype == np.int16: data = data.astype(np.float32) / 32768.0 elif data.dtype == np.int32: data = data.astype(np.float32) / 2147483648.0 elif data.dtype != np.float32: data = data.astype(np.float32) # Convert to mono if stereo if len(data.shape) > 1: data = data.mean(axis=1) # Resample if needed if sr != SAMPLE_RATE: from scipy import signal num_samples = int(len(data) * SAMPLE_RATE / sr) data = signal.resample(data, num_samples) # Convert to torch tensor with batch dimension waveform = torch.from_numpy(data).unsqueeze(0) return waveform def extract_embedding(waveform: torch.Tensor, model, device: str) -> np.ndarray: """Extract embedding from audio using wav2vec2.""" with torch.no_grad(): waveform = waveform.to(device) features, _ = model.extract_features(waveform) # Use the last layer's features, averaged over time embedding = features[-1].mean(dim=1).cpu().numpy() return embedding.flatten() def load_samples(): """Load all training samples.""" samples = [] labels = [] for category in CATEGORIES: category_dir = RECORDINGS_DIR / category if not category_dir.exists(): print(f"Warning: {category_dir} does not exist") continue wav_files = list(category_dir.glob("*.wav")) print(f" {category}: {len(wav_files)} samples") for wav_file in wav_files: samples.append(wav_file) labels.append(category) return samples, labels def main(): print("=== Training Audio Classifier ===\n") # Check for samples if not RECORDINGS_DIR.exists(): print(f"Error: {RECORDINGS_DIR} does not exist") print("Run ./collect_samples.sh first to record training samples") return print("Loading samples...") sample_paths, labels = load_samples() if len(sample_paths) == 0: print("\nNo samples found. Run ./collect_samples.sh first.") return if len(set(labels)) < 2: print(f"\nNeed samples from at least 2 categories. Found: {set(labels)}") return print(f"\nTotal: {len(sample_paths)} samples across {len(set(labels))} categories") # Check minimum samples per category from collections import Counter counts = Counter(labels) min_samples = min(counts.values()) if min_samples < 3: print(f"\nWarning: Some categories have < 3 samples. More samples = better accuracy.") # Load model device = "cuda" if torch.cuda.is_available() else "cpu" print(f"\nLoading wav2vec2 on {device}...") bundle = WAV2VEC2_BASE model = bundle.get_model().to(device) model.eval() # Extract embeddings print("Extracting embeddings...") embeddings = [] valid_labels = [] for i, (path, label) in enumerate(zip(sample_paths, labels)): try: waveform = load_audio(path) embedding = extract_embedding(waveform, model, device) embeddings.append(embedding) valid_labels.append(label) print(f" [{i+1}/{len(sample_paths)}] {path.name}") except Exception as e: print(f" [{i+1}/{len(sample_paths)}] {path.name} - ERROR: {e}") if len(embeddings) < 2: print("\nNot enough valid samples to train.") return X = np.array(embeddings) y = np.array(valid_labels) print(f"\nEmbedding shape: {X.shape}") # Train classifier print("\nTraining classifier...") from sklearn.preprocessing import LabelEncoder from sklearn.svm import SVC from sklearn.model_selection import cross_val_score label_encoder = LabelEncoder() y_encoded = label_encoder.fit_transform(y) # Use SVM with probability estimates classifier = SVC(kernel='rbf', probability=True, C=1.0) # Cross-validation if enough samples if len(X) >= 4: n_splits = min(5, len(X)) scores = cross_val_score(classifier, X, y_encoded, cv=n_splits) print(f"Cross-validation accuracy: {scores.mean():.2%} (+/- {scores.std()*2:.2%})") # Train on all data classifier.fit(X, y_encoded) # Save model model_data = { 'classifier': classifier, 'label_encoder': label_encoder, 'categories': CATEGORIES, } with open(MODEL_PATH, 'wb') as f: pickle.dump(model_data, f) print(f"\nClassifier saved to: {MODEL_PATH}") print(f"Categories: {list(label_encoder.classes_)}") print("\nDone! The listener will now use this classifier.") if __name__ == "__main__": main()