echofocus / vanta /data /augment.py
rjattt's picture
Upload folder using huggingface_hub
d67c04d verified
Raw History Blame Contribute Delete
6.2 kB
"""Recording-chain augmentation — teaches the model real-world audio.
An earlier model trained on clean LibriSpeech generalized to LibriSpeech but
failed on real phone/laptop recordings: it never saw mic coloration,
limited bandwidth, compression artifacts, or room tone. This module degrades the
synthetic mixtures to *look like* real recordings, closing that domain gap.
Two kinds of degradation, applied differently on purpose:
LINEAR (EQ tilt, band-limiting): these color the whole recording, target voice
included. We apply the SAME linear filter to the mixture AND the clean target,
so the model isn't asked to invent bandwidth the "mic" never captured — it only
has to remove the interferer + noise within the recorded band.
MIXTURE-ONLY (soft clipping, mu-law codec sim, broadband noise floor): these
simulate junk in the captured signal. Applied to the mixture only; the target
stays the clean reference, so the model learns to clean them off.
All ops are numpy/scipy so they run in DataLoader workers without ffmpeg overhead.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from scipy.signal import butter, sosfilt
EPS = 1e-8
@dataclass
class AugConfig:
enabled: bool = False
# Probability each op fires per sample.
eq_prob: float = 0.6
bandlimit_prob: float = 0.5
clip_prob: float = 0.3
mulaw_prob: float = 0.3
noise_floor_prob: float = 0.5
# Band-limit cutoff range (Hz). 3.4 kHz ~ telephone; 7.8 kHz ~ near full band.
lowpass_hz: tuple[float, float] = (3400.0, 7800.0)
# First-order spectral tilt in dB across the band (+ = brighter, - = darker).
eq_tilt_db: tuple[float, float] = (-9.0, 9.0)
# Soft-clip drive (higher = more distortion). tanh(drive * x) / tanh(drive).
clip_drive: tuple[float, float] = (2.0, 8.0)
# mu-law companding quantization levels (lower = more codec-like crunch).
mulaw_levels: tuple[int, int] = (64, 256)
# Broadband noise floor SNR (dB) relative to signal RMS.
noise_floor_snr_db: tuple[float, float] = (20.0, 40.0)
class RecordingAugment:
"""Sample a recording chain, then apply its linear/mixture-only parts."""
def __init__(self, cfg: AugConfig, sr: int):
self.cfg = cfg
self.sr = sr
# ---- parameter sampling -------------------------------------------------
def sample_params(self, rng: np.random.Generator) -> dict:
c = self.cfg
p: dict = {}
p["eq"] = (
float(rng.uniform(*c.eq_tilt_db)) if rng.random() < c.eq_prob else None
)
p["lowpass"] = (
float(rng.uniform(*c.lowpass_hz)) if rng.random() < c.bandlimit_prob else None
)
p["clip"] = (
float(rng.uniform(*c.clip_drive)) if rng.random() < c.clip_prob else None
)
p["mulaw"] = (
int(rng.integers(c.mulaw_levels[0], c.mulaw_levels[1] + 1))
if rng.random() < c.mulaw_prob else None
)
p["noise_floor"] = (
float(rng.uniform(*c.noise_floor_snr_db))
if rng.random() < c.noise_floor_prob else None
)
return p
# ---- linear ops (apply identically to mixture AND target) ---------------
def apply_linear(self, wav: np.ndarray, params: dict) -> np.ndarray:
out = wav
if params.get("eq") is not None:
out = self._spectral_tilt(out, params["eq"])
if params.get("lowpass") is not None:
out = self._lowpass(out, params["lowpass"])
return out.astype(np.float32, copy=False)
# ---- mixture-only ops (degradations to be cleaned off) ------------------
def apply_mixture_only(
self, wav: np.ndarray, params: dict, rng: np.random.Generator
) -> np.ndarray:
out = wav
if params.get("clip") is not None:
out = self._soft_clip(out, params["clip"])
if params.get("mulaw") is not None:
out = self._mulaw(out, params["mulaw"])
if params.get("noise_floor") is not None:
out = self._add_noise_floor(out, params["noise_floor"], rng)
return out.astype(np.float32, copy=False)
# ---- individual effects -------------------------------------------------
def _spectral_tilt(self, wav: np.ndarray, tilt_db: float) -> np.ndarray:
# Frequency-domain first-order tilt: linear gain ramp from -tilt/2 at DC
# to +tilt/2 at Nyquist (dB), applied to the magnitude spectrum.
n = len(wav)
if n < 8:
return wav
spec = np.fft.rfft(wav)
ramp = np.linspace(-0.5, 0.5, spec.shape[0])
gain = 10 ** ((tilt_db * ramp) / 20.0)
return np.fft.irfft(spec * gain, n=n)
def _lowpass(self, wav: np.ndarray, cutoff_hz: float) -> np.ndarray:
nyq = self.sr / 2.0
cutoff = min(max(cutoff_hz, 500.0), nyq * 0.99)
sos = butter(6, cutoff / nyq, btype="low", output="sos")
return sosfilt(sos, wav)
def _soft_clip(self, wav: np.ndarray, drive: float) -> np.ndarray:
peak = np.max(np.abs(wav)) + EPS
x = wav / peak
y = np.tanh(drive * x) / np.tanh(drive)
return y * peak
def _mulaw(self, wav: np.ndarray, levels: int) -> np.ndarray:
peak = np.max(np.abs(wav)) + EPS
x = np.clip(wav / peak, -1.0, 1.0)
mu = float(levels - 1)
comp = np.sign(x) * np.log1p(mu * np.abs(x)) / np.log1p(mu) # encode
q = np.round((comp + 1) / 2 * (levels - 1)) / (levels - 1) * 2 - 1 # quantize
dec = np.sign(q) * (1.0 / mu) * (np.power(1 + mu, np.abs(q)) - 1) # decode
return dec * peak
def _add_noise_floor(
self, wav: np.ndarray, snr_db: float, rng: np.random.Generator
) -> np.ndarray:
sig_rms = np.sqrt(np.mean(wav**2) + EPS)
noise = rng.standard_normal(len(wav)).astype(np.float32)
noise_rms = np.sqrt(np.mean(noise**2) + EPS)
target_noise_rms = sig_rms / (10 ** (snr_db / 20.0))
return wav + noise * (target_noise_rms / noise_rms)