Spaces:
Sleeping
Sleeping
| """ | |
| Phase 2 — Audio Preprocessing | |
| Bandpass filter, wavelet denoising, normalization, silence trimming, segmentation. | |
| """ | |
| import logging | |
| from pathlib import Path | |
| from typing import Optional | |
| import numpy as np | |
| import pandas as pd | |
| import librosa | |
| import soundfile as sf | |
| from scipy.signal import butter, sosfiltfilt | |
| import pywt | |
| from src.config import ( | |
| SAMPLE_RATE, BANDPASS_LOW, BANDPASS_HIGH, BANDPASS_ORDER, | |
| WAVELET_NAME, WAVELET_LEVEL, | |
| SEGMENT_DURATION, SEGMENT_SAMPLES, SEGMENT_HOP, | |
| SEGMENTS_DIR, OUTPUT_DIR, LABEL_MAP, | |
| ) | |
| logger = logging.getLogger(__name__) | |
| def load_wav(wav_path: str, target_sr: int = SAMPLE_RATE) -> np.ndarray: | |
| """Load a .wav file and resample if needed. | |
| Args: | |
| wav_path: Path to .wav file. | |
| target_sr: Target sample rate. | |
| Returns: | |
| 1D numpy array of audio samples (float32). | |
| Raises: | |
| FileNotFoundError: If file doesn't exist. | |
| RuntimeError: If file can't be read. | |
| """ | |
| path = Path(wav_path) | |
| if not path.exists(): | |
| raise FileNotFoundError(f"WAV file not found: {wav_path}") | |
| try: | |
| audio, sr = librosa.load(str(wav_path), sr=target_sr, mono=True) | |
| except Exception as e: | |
| raise RuntimeError(f"Failed to load {wav_path}: {e}") from e | |
| return audio.astype(np.float32) | |
| def bandpass_filter( | |
| audio: np.ndarray, | |
| low: int = BANDPASS_LOW, | |
| high: int = BANDPASS_HIGH, | |
| sr: int = SAMPLE_RATE, | |
| order: int = BANDPASS_ORDER, | |
| ) -> np.ndarray: | |
| """Apply a Butterworth bandpass filter to isolate heart sounds. | |
| Args: | |
| audio: 1D audio signal. | |
| low: Low cutoff frequency (Hz). | |
| high: High cutoff frequency (Hz). | |
| sr: Sample rate (Hz). | |
| order: Filter order. | |
| Returns: | |
| Filtered audio signal (same length). | |
| """ | |
| if len(audio) == 0: | |
| return audio | |
| nyquist = sr / 2.0 | |
| low_norm = low / nyquist | |
| high_norm = high / nyquist | |
| # Clamp to valid range | |
| low_norm = max(low_norm, 1e-5) | |
| high_norm = min(high_norm, 1.0 - 1e-5) | |
| sos = butter(order, [low_norm, high_norm], btype="band", output="sos") | |
| filtered = sosfiltfilt(sos, audio).astype(np.float32) | |
| return filtered | |
| def wavelet_denoise( | |
| audio: np.ndarray, | |
| wavelet: str = WAVELET_NAME, | |
| level: int = WAVELET_LEVEL, | |
| ) -> np.ndarray: | |
| """Remove high-frequency noise using wavelet soft thresholding. | |
| Args: | |
| audio: 1D audio signal. | |
| wavelet: Wavelet family name. | |
| level: Decomposition level. | |
| Returns: | |
| Denoised audio signal. | |
| """ | |
| if len(audio) == 0: | |
| return audio | |
| # Decompose | |
| coeffs = pywt.wavedec(audio, wavelet, level=level) | |
| # Estimate noise from finest detail coefficients | |
| sigma = np.median(np.abs(coeffs[-1])) / 0.6745 | |
| threshold = sigma * np.sqrt(2 * np.log(len(audio))) | |
| # Soft-threshold detail coefficients (keep approximation intact) | |
| denoised_coeffs = [coeffs[0]] # approximation — untouched | |
| for detail in coeffs[1:]: | |
| denoised_coeffs.append(pywt.threshold(detail, threshold, mode="soft")) | |
| # Reconstruct | |
| reconstructed = pywt.waverec(denoised_coeffs, wavelet) | |
| # waverec can add one extra sample due to rounding | |
| reconstructed = reconstructed[: len(audio)] | |
| return reconstructed.astype(np.float32) | |
| def normalize_amplitude(audio: np.ndarray) -> np.ndarray: | |
| """Normalize audio to [-1, 1] range by peak amplitude. | |
| Args: | |
| audio: 1D audio signal. | |
| Returns: | |
| Normalized audio. Returns zeros if input is silent. | |
| """ | |
| peak = np.max(np.abs(audio)) | |
| if peak < 1e-10: | |
| return np.zeros_like(audio) | |
| return (audio / peak).astype(np.float32) | |
| def trim_silence( | |
| audio: np.ndarray, | |
| top_db: float = 25.0, | |
| sr: int = SAMPLE_RATE, | |
| ) -> np.ndarray: | |
| """Trim leading/trailing silence based on energy threshold. | |
| Args: | |
| audio: 1D audio signal. | |
| top_db: Threshold in dB below peak to consider as silence. | |
| sr: Sample rate. | |
| Returns: | |
| Trimmed audio. Returns original if trimming would remove everything. | |
| """ | |
| if len(audio) == 0: | |
| return audio | |
| trimmed, _ = librosa.effects.trim(audio, top_db=top_db) | |
| # Safety: don't return empty audio | |
| if len(trimmed) < sr: # Less than 1 second | |
| return audio | |
| return trimmed | |
| def preprocess_audio(audio: np.ndarray, sr: int = SAMPLE_RATE) -> np.ndarray: | |
| """Full preprocessing pipeline for a single audio recording. | |
| Pipeline: bandpass → wavelet denoise → normalize → trim silence. | |
| Args: | |
| audio: Raw 1D audio signal. | |
| sr: Sample rate. | |
| Returns: | |
| Preprocessed audio signal. | |
| """ | |
| audio = bandpass_filter(audio, sr=sr) | |
| audio = wavelet_denoise(audio) | |
| audio = normalize_amplitude(audio) | |
| audio = trim_silence(audio, sr=sr) | |
| return audio | |
| def segment_audio( | |
| audio: np.ndarray, | |
| segment_samples: int = SEGMENT_SAMPLES, | |
| hop_samples: int = SEGMENT_HOP, | |
| ) -> list[np.ndarray]: | |
| """Cut audio into fixed-length overlapping windows. | |
| Args: | |
| audio: 1D preprocessed audio. | |
| segment_samples: Samples per segment (default: 3s * 2000Hz = 6000). | |
| hop_samples: Hop between segment starts (default: 50% overlap = 3000). | |
| Returns: | |
| List of segments. Each is zero-padded if needed. | |
| """ | |
| if len(audio) == 0: | |
| return [] | |
| segments = [] | |
| start = 0 | |
| while start < len(audio): | |
| end = start + segment_samples | |
| segment = audio[start:end] | |
| # Zero-pad the last segment if shorter | |
| if len(segment) < segment_samples: | |
| padded = np.zeros(segment_samples, dtype=np.float32) | |
| padded[: len(segment)] = segment | |
| segment = padded | |
| segments.append(segment) | |
| start += hop_samples | |
| return segments | |
| def process_single_recording( | |
| wav_path: str, | |
| label: int, | |
| filename: str, | |
| output_dir: Path, | |
| ) -> list[dict]: | |
| """Process one recording: load → preprocess → segment → save. | |
| Args: | |
| wav_path: Path to .wav file. | |
| label: Original label (-1 or 1). | |
| filename: Recording filename (e.g., 'a0001'). | |
| output_dir: Directory to save segments. | |
| Returns: | |
| List of dicts with segment metadata. | |
| """ | |
| class_idx = LABEL_MAP[label] | |
| audio = load_wav(wav_path) | |
| audio = preprocess_audio(audio) | |
| segments = segment_audio(audio) | |
| metadata = [] | |
| for i, seg in enumerate(segments): | |
| seg_name = f"{filename}_seg{i:04d}" | |
| seg_path = output_dir / f"{seg_name}.npy" | |
| np.save(str(seg_path), seg) | |
| metadata.append({ | |
| "segment_name": seg_name, | |
| "source_file": filename, | |
| "segment_idx": i, | |
| "class_idx": class_idx, | |
| "label": label, | |
| "segment_path": str(seg_path), | |
| }) | |
| return metadata | |
| def run_phase2(master_csv: Optional[str] = None) -> pd.DataFrame: | |
| """Execute the full Phase 2 preprocessing pipeline. | |
| Args: | |
| master_csv: Path to master_labels.csv. Auto-detected if None. | |
| Returns: | |
| DataFrame of all segment metadata. | |
| """ | |
| logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") | |
| logger.info("=" * 60) | |
| logger.info("PHASE 2 — Audio Preprocessing") | |
| logger.info("=" * 60) | |
| # Load master labels | |
| if master_csv is None: | |
| master_csv = str(OUTPUT_DIR / "master_labels.csv") | |
| master = pd.read_csv(master_csv) | |
| logger.info("Loaded %d recordings from master labels", len(master)) | |
| # Filter to readable files only | |
| if "readable" in master.columns: | |
| master = master[master["readable"] == True].reset_index(drop=True) | |
| logger.info("Readable recordings: %d", len(master)) | |
| # Create output directory | |
| SEGMENTS_DIR.mkdir(parents=True, exist_ok=True) | |
| all_metadata = [] | |
| total = len(master) | |
| for idx, row in master.iterrows(): | |
| try: | |
| meta = process_single_recording( | |
| wav_path=row["wav_path"], | |
| label=row["label"], | |
| filename=row["filename"], | |
| output_dir=SEGMENTS_DIR, | |
| ) | |
| all_metadata.extend(meta) | |
| except Exception as e: | |
| logger.warning("Error processing %s: %s", row["filename"], e) | |
| if (idx + 1) % 200 == 0 or idx == total - 1: | |
| logger.info("Processed %d/%d recordings → %d segments so far", | |
| idx + 1, total, len(all_metadata)) | |
| # Save segment metadata | |
| seg_df = pd.DataFrame(all_metadata) | |
| seg_csv = OUTPUT_DIR / "segments_metadata.csv" | |
| seg_df.to_csv(seg_csv, index=False) | |
| logger.info("Saved %d segments metadata to %s", len(seg_df), seg_csv) | |
| # Print stats | |
| normal = (seg_df["class_idx"] == 0).sum() | |
| abnormal = (seg_df["class_idx"] == 1).sum() | |
| logger.info("Segments: %d normal / %d abnormal / %d total", | |
| normal, abnormal, len(seg_df)) | |
| return seg_df | |
| if __name__ == "__main__": | |
| run_phase2() | |