Spaces:
Running on Zero
Running on Zero
| """ | |
| Classical acoustic feature extraction for the energy/silence baseline | |
| (EXP-001) and the lightweight classifier baseline (EXP-002/EXP-003). | |
| Implemented with numpy + scipy only (no librosa dependency) so this module | |
| can be developed and unit-tested in this sandbox, which has numpy/scipy but | |
| not librosa/soundfile/torch available and no network access to install | |
| them. If librosa is available in the actual training environment it is a | |
| fine drop-in replacement for `mfcc()` — the manual mel-filterbank + DCT | |
| implementation here exists specifically so this module doesn't have a hard | |
| librosa dependency, not because librosa's MFCC is worse. | |
| All functions operate on a 1D float32 numpy array of mono audio samples at | |
| a known sample rate. Nothing in this file reads audio from disk or the | |
| network — see audio_io.py for that. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass, asdict | |
| import numpy as np | |
| from scipy.fftpack import dct | |
| EPS = 1e-10 | |
| # --------------------------------------------------------------------------- | |
| # Frame-level primitives | |
| # --------------------------------------------------------------------------- | |
| def frame_signal(audio: np.ndarray, frame_len: int, hop_len: int) -> np.ndarray: | |
| """Split audio into overlapping frames. Returns shape (n_frames, frame_len). | |
| Frames past the end are dropped (no zero-padding) — for feature | |
| extraction we don't want to invent energy from padding. | |
| """ | |
| n = len(audio) | |
| if n < frame_len: | |
| return np.empty((0, frame_len), dtype=audio.dtype) | |
| n_frames = 1 + (n - frame_len) // hop_len | |
| idx = np.arange(frame_len)[None, :] + hop_len * np.arange(n_frames)[:, None] | |
| return audio[idx] | |
| def rms_per_frame(frames: np.ndarray) -> np.ndarray: | |
| return np.sqrt(np.mean(frames.astype(np.float64) ** 2, axis=1) + EPS) | |
| def zcr_per_frame(frames: np.ndarray) -> np.ndarray: | |
| signs = np.sign(frames) | |
| signs[signs == 0] = 1 | |
| return np.mean(np.abs(np.diff(signs, axis=1)) > 0, axis=1) | |
| # --------------------------------------------------------------------------- | |
| # Spectral features | |
| # --------------------------------------------------------------------------- | |
| def _power_spectrum(frames: np.ndarray, sr: int) -> tuple[np.ndarray, np.ndarray]: | |
| n_fft = frames.shape[1] | |
| window = np.hanning(n_fft) | |
| windowed = frames * window[None, :] | |
| spec = np.fft.rfft(windowed, axis=1) | |
| power = np.abs(spec) ** 2 | |
| freqs = np.fft.rfftfreq(n_fft, d=1.0 / sr) | |
| return power, freqs | |
| def spectral_centroid(frames: np.ndarray, sr: int) -> np.ndarray: | |
| power, freqs = _power_spectrum(frames, sr) | |
| total = power.sum(axis=1) + EPS | |
| return (power * freqs[None, :]).sum(axis=1) / total | |
| def spectral_bandwidth(frames: np.ndarray, sr: int) -> np.ndarray: | |
| power, freqs = _power_spectrum(frames, sr) | |
| total = power.sum(axis=1) + EPS | |
| centroid = (power * freqs[None, :]).sum(axis=1) / total | |
| diff_sq = (freqs[None, :] - centroid[:, None]) ** 2 | |
| return np.sqrt((power * diff_sq).sum(axis=1) / total) | |
| def spectral_rolloff(frames: np.ndarray, sr: int, roll_percent: float = 0.85) -> np.ndarray: | |
| power, freqs = _power_spectrum(frames, sr) | |
| cumsum = np.cumsum(power, axis=1) | |
| total = cumsum[:, -1:] + EPS | |
| threshold = roll_percent * total | |
| idx = (cumsum >= threshold).argmax(axis=1) | |
| return freqs[idx] | |
| def mel_filterbank(sr: int, n_fft: int, n_mels: int = 26, fmin: float = 0.0, fmax: float | None = None) -> np.ndarray: | |
| """Standard triangular mel filterbank, computed with numpy only.""" | |
| fmax = fmax or sr / 2 | |
| def hz_to_mel(f): | |
| return 2595.0 * np.log10(1.0 + f / 700.0) | |
| def mel_to_hz(m): | |
| return 700.0 * (10 ** (m / 2595.0) - 1.0) | |
| mel_min, mel_max = hz_to_mel(fmin), hz_to_mel(fmax) | |
| mel_points = np.linspace(mel_min, mel_max, n_mels + 2) | |
| hz_points = mel_to_hz(mel_points) | |
| bin_points = np.floor((n_fft + 1) * hz_points / sr).astype(int) | |
| fbank = np.zeros((n_mels, n_fft // 2 + 1)) | |
| for m in range(1, n_mels + 1): | |
| left, center, right = bin_points[m - 1], bin_points[m], bin_points[m + 1] | |
| if center == left: | |
| center += 1 | |
| if right == center: | |
| right += 1 | |
| for k in range(left, min(center, fbank.shape[1])): | |
| fbank[m - 1, k] = (k - left) / (center - left) | |
| for k in range(center, min(right, fbank.shape[1])): | |
| fbank[m - 1, k] = (right - k) / (right - center) | |
| return fbank | |
| def mfcc(frames: np.ndarray, sr: int, n_mfcc: int = 13, n_mels: int = 26) -> np.ndarray: | |
| """Return shape (n_frames, n_mfcc). Manual implementation — see module | |
| docstring for why this doesn't depend on librosa. | |
| """ | |
| power, _ = _power_spectrum(frames, sr) | |
| fbank = mel_filterbank(sr, frames.shape[1], n_mels=n_mels) | |
| mel_energy = power @ fbank.T | |
| log_mel = np.log(mel_energy + EPS) | |
| coeffs = dct(log_mel, type=2, axis=1, norm="ortho")[:, :n_mfcc] | |
| return coeffs | |
| # --------------------------------------------------------------------------- | |
| # Silence / energy-trend features (used directly by the EXP-001 baseline) | |
| # --------------------------------------------------------------------------- | |
| def trailing_silence_duration( | |
| audio: np.ndarray, | |
| sr: int, | |
| frame_len_ms: float = 20.0, | |
| hop_len_ms: float = 10.0, | |
| silence_rms_threshold: float = 0.01, | |
| ) -> float: | |
| """Duration in seconds of trailing silence at the end of the clip, | |
| using a fixed RMS threshold. This is the "classic VAD-style endpointing" | |
| signal used by EXP-001. The threshold is a tunable hyperparameter — | |
| see scripts for the tuning procedure (must be tuned on dev/val split | |
| only, never on the held-out test set, per project rules). | |
| """ | |
| frame_len = max(1, int(sr * frame_len_ms / 1000)) | |
| hop_len = max(1, int(sr * hop_len_ms / 1000)) | |
| frames = frame_signal(audio, frame_len, hop_len) | |
| if len(frames) == 0: | |
| return len(audio) / sr # whole clip shorter than one frame — treat as silence-length itself | |
| rms = rms_per_frame(frames) | |
| is_silent = rms < silence_rms_threshold | |
| # count trailing silent frames | |
| n_trailing = 0 | |
| for v in is_silent[::-1]: | |
| if v: | |
| n_trailing += 1 | |
| else: | |
| break | |
| return n_trailing * hop_len_ms / 1000.0 | |
| def energy_slope(rms: np.ndarray, n_recent: int = 5) -> float: | |
| """Linear-fit slope of RMS energy over the last `n_recent` frames. | |
| Negative slope = energy decaying (consistent with trailing off). | |
| Returns 0.0 if too few frames. | |
| """ | |
| if len(rms) < 2: | |
| return 0.0 | |
| recent = rms[-n_recent:] | |
| if len(recent) < 2: | |
| return 0.0 | |
| x = np.arange(len(recent)) | |
| slope, _ = np.polyfit(x, recent, 1) | |
| return float(slope) | |
| # --------------------------------------------------------------------------- | |
| # Full feature vector for the classifier baseline (EXP-002 / EXP-003) | |
| # --------------------------------------------------------------------------- | |
| class FeatureConfig: | |
| sr: int = 16_000 | |
| frame_len_ms: float = 25.0 | |
| hop_len_ms: float = 10.0 | |
| n_mfcc: int = 13 | |
| n_mels: int = 26 | |
| silence_rms_threshold: float = 0.01 | |
| # Recent-window sizes for temporal features (EXP-003 in this phase's | |
| # experiment list — "is the tail of the audio more predictive than the | |
| # whole-clip statistics?"). Windows shorter than one frame or longer | |
| # than the clip are silently skipped per-clip (documented, not hidden). | |
| recent_windows_ms: tuple = (100, 250, 500, 750, 1000) | |
| def _summary_stats(x: np.ndarray, prefix: str) -> dict: | |
| if len(x) == 0: | |
| return {f"{prefix}_mean": 0.0, f"{prefix}_std": 0.0, f"{prefix}_min": 0.0, f"{prefix}_max": 0.0} | |
| return { | |
| f"{prefix}_mean": float(np.mean(x)), | |
| f"{prefix}_std": float(np.std(x)), | |
| f"{prefix}_min": float(np.min(x)), | |
| f"{prefix}_max": float(np.max(x)), | |
| } | |
| def extract_global_features(audio: np.ndarray, cfg: FeatureConfig = FeatureConfig()) -> dict: | |
| """Features computed over the entire clip — the "global statistics" | |
| half of EXP-002/EXP-003's comparison against recent-window features. | |
| """ | |
| sr = cfg.sr | |
| frame_len = max(1, int(sr * cfg.frame_len_ms / 1000)) | |
| hop_len = max(1, int(sr * cfg.hop_len_ms / 1000)) | |
| frames = frame_signal(audio, frame_len, hop_len) | |
| feats: dict = {"duration_sec": len(audio) / sr} | |
| if len(frames) == 0: | |
| # Clip shorter than one frame: return zeroed features rather than | |
| # crashing, but flag it so downstream code can filter/inspect these. | |
| feats["too_short_for_framing"] = True | |
| return feats | |
| feats["too_short_for_framing"] = False | |
| rms = rms_per_frame(frames) | |
| zcr = zcr_per_frame(frames) | |
| centroid = spectral_centroid(frames, sr) | |
| bandwidth = spectral_bandwidth(frames, sr) | |
| rolloff = spectral_rolloff(frames, sr) | |
| coeffs = mfcc(frames, sr, n_mfcc=cfg.n_mfcc, n_mels=cfg.n_mels) | |
| feats.update(_summary_stats(rms, "rms")) | |
| feats.update(_summary_stats(zcr, "zcr")) | |
| feats.update(_summary_stats(centroid, "centroid")) | |
| feats.update(_summary_stats(bandwidth, "bandwidth")) | |
| feats.update(_summary_stats(rolloff, "rolloff")) | |
| for i in range(coeffs.shape[1]): | |
| feats.update(_summary_stats(coeffs[:, i], f"mfcc{i}")) | |
| feats["energy_slope"] = energy_slope(rms) | |
| feats["trailing_silence_sec"] = trailing_silence_duration( | |
| audio, sr, cfg.frame_len_ms, cfg.hop_len_ms, cfg.silence_rms_threshold | |
| ) | |
| return feats | |
| def extract_recent_window_features(audio: np.ndarray, cfg: FeatureConfig = FeatureConfig()) -> dict: | |
| """Features computed over only the last N ms of audio, for each window | |
| size in cfg.recent_windows_ms. This directly supports the Phase 2 | |
| research question: does the tail of the clip predict the label better | |
| than whole-clip statistics? Windows longer than the available audio are | |
| skipped (recorded as NaN) rather than padded with fabricated silence, | |
| so downstream analysis can see exactly which clips had enough audio for | |
| each window size. | |
| """ | |
| sr = cfg.sr | |
| feats: dict = {} | |
| for w_ms in cfg.recent_windows_ms: | |
| w_samples = int(sr * w_ms / 1000) | |
| key_prefix = f"tail{w_ms}ms" | |
| if len(audio) < w_samples: | |
| feats[f"{key_prefix}_available"] = False | |
| feats[f"{key_prefix}_rms_mean"] = float("nan") | |
| continue | |
| window = audio[-w_samples:] | |
| frame_len = max(1, int(sr * cfg.frame_len_ms / 1000)) | |
| hop_len = max(1, int(sr * cfg.hop_len_ms / 1000)) | |
| frames = frame_signal(window, min(frame_len, w_samples), max(1, min(hop_len, w_samples))) | |
| feats[f"{key_prefix}_available"] = True | |
| if len(frames) == 0: | |
| rms_val = rms_per_frame(window.reshape(1, -1)) | |
| else: | |
| rms_val = rms_per_frame(frames) | |
| feats[f"{key_prefix}_rms_mean"] = float(np.mean(rms_val)) | |
| feats[f"{key_prefix}_rms_slope"] = energy_slope(rms_val, n_recent=len(rms_val)) | |
| return feats | |
| def extract_features(audio: np.ndarray, cfg: FeatureConfig = FeatureConfig(), include_recent_windows: bool = True) -> dict: | |
| feats = extract_global_features(audio, cfg) | |
| if include_recent_windows: | |
| feats.update(extract_recent_window_features(audio, cfg)) | |
| return feats | |