heart-sound-classifier / src /phase2_preprocess.py
Nishchal
Heart Sound CNN Classifier — EfficientNet-B0, 81% accuracy, Gradio app
13e4427
Raw
History Blame Contribute Delete
9.03 kB
"""
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()