Spaces:
Running on Zero
Running on Zero
File size: 11,340 Bytes
875e4af | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 | """
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)
# ---------------------------------------------------------------------------
@dataclass
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
|