second-ear / ear /dsp.py
sevahu97's picture
Upload folder using huggingface_hub
5459c43 verified
Raw
History Blame Contribute Delete
14.6 kB
"""Measurement layer.
Pure numpy/scipy. No librosa β€” import cost and CPU headroom matter on a
2-vCPU Space where this runs several times a second on a live stream.
Everything is measured at 48 kHz. Input at any rate is resampled once on
the way in, so the K-weighting biquads (which are rate-specific) stay valid
and every metric is comparable across sources.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field, asdict
from typing import Optional
import numpy as np
from scipy.signal import lfilter, resample_poly, get_window
SR = 48_000
# Band edges in Hz. Seven bands, chosen to match how engineers actually talk
# about a mix rather than to divide the spectrum evenly.
BANDS: dict[str, tuple[float, float]] = {
"sub": (20.0, 60.0),
"bass": (60.0, 120.0),
"lowmid": (120.0, 350.0),
"mid": (350.0, 1500.0),
"himid": (1500.0, 4000.0),
"presence": (4000.0, 8000.0),
"air": (8000.0, 16000.0),
}
BAND_ORDER = list(BANDS.keys())
BAND_LABELS = {
"sub": "Sub 20–60",
"bass": "Bass 60–120",
"lowmid": "Low mid 120–350",
"mid": "Mid 350–1.5k",
"himid": "Hi mid 1.5–4k",
"presence": "Presence 4–8k",
"air": "Air 8–16k",
}
# ITU-R BS.1770-4 K-weighting, 48 kHz.
_K1_B = np.array([1.53512485958697, -2.69169618940638, 1.19839281085285])
_K1_A = np.array([1.0, -1.69065929318241, 0.73248077421585])
_K2_B = np.array([1.0, -2.0, 1.0])
_K2_A = np.array([1.0, -1.99004745483398, 0.99007225036621])
_KRUMHANSL_MAJOR = np.array(
[6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]
)
_KRUMHANSL_MINOR = np.array(
[6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]
)
_NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
_EPS = 1e-12
def _db(x: float) -> float:
return 20.0 * math.log10(max(float(x), _EPS))
def _pdb(p: float) -> float:
"""Power (already squared) to dB."""
return 10.0 * math.log10(max(float(p), _EPS))
# --------------------------------------------------------------------------
# input conditioning
# --------------------------------------------------------------------------
def to_float_stereo(sr: int, data: np.ndarray) -> np.ndarray:
"""Normalise any Gradio audio payload to float32 (n, 2) at 48 kHz."""
x = np.asarray(data)
if x.dtype.kind in "iu":
info = np.iinfo(x.dtype)
x = x.astype(np.float32) / max(abs(info.min), info.max)
else:
x = x.astype(np.float32, copy=False)
if x.ndim == 1:
x = x[:, None]
if x.shape[1] > 2: # some capture paths hand back (channels, n)
if x.shape[0] <= 2:
x = x.T
else:
x = x[:, :2]
if x.shape[1] == 1:
x = np.repeat(x, 2, axis=1)
if sr != SR and x.shape[0] > 0:
g = math.gcd(int(sr), SR)
x = resample_poly(x, SR // g, int(sr) // g, axis=0).astype(np.float32)
return np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
# --------------------------------------------------------------------------
# loudness
# --------------------------------------------------------------------------
def _k_weight(x: np.ndarray) -> np.ndarray:
y = lfilter(_K1_B, _K1_A, x, axis=0)
return lfilter(_K2_B, _K2_A, y, axis=0)
def _block_loudness(xk: np.ndarray, win: int, hop: int) -> np.ndarray:
"""Per-block BS.1770 loudness in LKFS for a K-weighted signal."""
n = xk.shape[0]
if n < win:
if n == 0:
return np.array([])
mean_sq = np.mean(xk**2, axis=0).sum()
return np.array([-0.691 + _pdb(mean_sq)])
starts = np.arange(0, n - win + 1, hop)
out = np.empty(len(starts), dtype=np.float64)
for i, s in enumerate(starts):
blk = xk[s : s + win]
out[i] = -0.691 + _pdb(np.mean(blk**2, axis=0).sum())
return out
def loudness(x: np.ndarray) -> dict:
"""Integrated / short-term / range loudness, gated per BS.1770-4."""
xk = _k_weight(x)
blocks = _block_loudness(xk, int(0.400 * SR), int(0.100 * SR))
short = _block_loudness(xk, int(3.0 * SR), int(1.0 * SR))
lufs_i = float("-inf")
if blocks.size:
above_abs = blocks[blocks > -70.0]
if above_abs.size:
mean_pow = np.mean(10 ** (above_abs / 10.0))
rel_gate = 10.0 * math.log10(max(mean_pow, _EPS)) - 10.0
kept = above_abs[above_abs > rel_gate]
pool = kept if kept.size else above_abs
lufs_i = float(10.0 * math.log10(max(np.mean(10 ** (pool / 10.0)), _EPS)))
lra = 0.0
if short.size >= 3:
valid = short[short > -70.0]
if valid.size >= 3:
lra = float(np.percentile(valid, 95) - np.percentile(valid, 10))
return {
"lufs_i": lufs_i,
"lufs_s": float(short[-1]) if short.size else float("-inf"),
"lra": lra,
"short_term": short,
}
def true_peak_db(x: np.ndarray) -> float:
"""dBTP via 4x oversampling (BS.1770 minimum)."""
if x.shape[0] < 8:
return _db(np.max(np.abs(x)) if x.size else 0.0)
up = resample_poly(x, 4, 1, axis=0)
return _db(float(np.max(np.abs(up))))
# --------------------------------------------------------------------------
# spectrum
# --------------------------------------------------------------------------
def spectrum(mono: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Averaged power spectrum via Welch-style overlapping Hann frames."""
n = mono.shape[0]
nfft = 8192 if n >= 8192 else 1 << max(8, int(math.log2(max(n, 256))))
if n < nfft:
mono = np.pad(mono, (0, nfft - n))
n = nfft
win = get_window("hann", nfft, fftbins=True)
hop = nfft // 2
acc = np.zeros(nfft // 2 + 1)
count = 0
for s in range(0, n - nfft + 1, hop):
frame = mono[s : s + nfft] * win
acc += np.abs(np.fft.rfft(frame)) ** 2
count += 1
if count:
acc /= count
freqs = np.fft.rfftfreq(nfft, 1.0 / SR)
return freqs, acc
def band_energies(freqs: np.ndarray, power: np.ndarray) -> dict[str, float]:
"""Band power as dB relative to total 20 Hz–20 kHz power."""
full = (freqs >= 20.0) & (freqs <= 20000.0)
total = float(power[full].sum())
out = {}
for name, (lo, hi) in BANDS.items():
sel = (freqs >= lo) & (freqs < hi)
out[name] = _pdb(float(power[sel].sum()) / max(total, _EPS))
return out
def spectral_shape(freqs: np.ndarray, power: np.ndarray) -> dict:
sel = (freqs >= 40.0) & (freqs <= 16000.0)
f, p = freqs[sel], power[sel]
if not f.size or p.sum() <= _EPS:
return {"centroid": 0.0, "tilt": 0.0, "rolloff85": 0.0, "flatness": 0.0}
centroid = float((f * p).sum() / p.sum())
# dB/octave tilt: least-squares fit of level against log2(frequency).
logf = np.log2(f)
logp = 10.0 * np.log10(np.maximum(p, _EPS))
tilt = float(np.polyfit(logf, logp, 1)[0])
csum = np.cumsum(p)
rolloff = float(f[min(int(np.searchsorted(csum, 0.85 * csum[-1])), f.size - 1)])
gmean = float(np.exp(np.mean(np.log(np.maximum(p, _EPS)))))
flatness = gmean / float(np.mean(p) + _EPS)
return {
"centroid": centroid,
"tilt": tilt,
"rolloff85": rolloff,
"flatness": float(flatness),
}
# --------------------------------------------------------------------------
# stereo
# --------------------------------------------------------------------------
def stereo_image(x: np.ndarray) -> dict:
left, right = x[:, 0], x[:, 1]
denom = math.sqrt(float(np.mean(left**2)) * float(np.mean(right**2))) + _EPS
corr = float(np.mean(left * right) / denom)
mid = (left + right) * 0.5
side = (left - right) * 0.5
mid_p = float(np.mean(mid**2))
side_p = float(np.mean(side**2))
width = _pdb(side_p / max(mid_p, _EPS))
# Mono-fold penalty: how much level is lost summing to mono.
stereo_p = float(np.mean(x**2))
mono_loss = _pdb(mid_p / max(stereo_p, _EPS))
# Low-frequency correlation is the one that actually costs you on a
# club system, so it gets measured separately.
sub_corr = corr
if x.shape[0] > 4096:
nfft = 4096
w = get_window("hann", nfft)
bins = np.fft.rfftfreq(nfft, 1.0 / SR)
m = (bins >= 20) & (bins < 120)
limit = min(x.shape[0] - nfft, 24 * nfft)
acc_l = acc_r = acc_lr = 0.0
for s in range(0, max(limit, 1), nfft // 2):
fl = np.fft.rfft(left[s : s + nfft] * w)
fr = np.fft.rfft(right[s : s + nfft] * w)
acc_l += float(np.sum(np.abs(fl[m]) ** 2))
acc_r += float(np.sum(np.abs(fr[m]) ** 2))
acc_lr += float(np.real(np.sum(fl[m] * np.conj(fr[m]))))
d = math.sqrt(acc_l * acc_r) + _EPS
sub_corr = float(acc_lr / d)
return {
"correlation": corr,
"sub_correlation": sub_corr,
"width_db": width,
"mono_loss_db": mono_loss,
}
# --------------------------------------------------------------------------
# rhythm + pitch
# --------------------------------------------------------------------------
def onset_envelope(mono: np.ndarray) -> tuple[np.ndarray, float]:
nfft, hop = 2048, 512
if mono.shape[0] < nfft * 4:
return np.zeros(0), SR / hop
win = get_window("hann", nfft)
n_frames = 1 + (mono.shape[0] - nfft) // hop
mags = np.empty((n_frames, nfft // 2 + 1), dtype=np.float32)
for i in range(n_frames):
s = i * hop
mags[i] = np.abs(np.fft.rfft(mono[s : s + nfft] * win))
logm = np.log1p(mags * 100.0)
flux = np.maximum(np.diff(logm, axis=0), 0.0).sum(axis=1)
if flux.size and flux.max() > 0:
flux = flux / flux.max()
return flux, SR / hop
def tempo_from_onsets(flux: np.ndarray, fps: float) -> dict:
if flux.size < 64:
return {"bpm": 0.0, "confidence": 0.0, "onset_rate": 0.0}
env = flux - flux.mean()
ac = np.correlate(env, env, mode="full")[env.size - 1 :]
if ac[0] > 0:
ac = ac / ac[0]
lag_min = max(int(fps * 60.0 / 200.0), 2)
lag_max = min(int(fps * 60.0 / 60.0), ac.size - 1)
if lag_max <= lag_min:
return {"bpm": 0.0, "confidence": 0.0, "onset_rate": 0.0}
window = ac[lag_min:lag_max]
best = int(np.argmax(window)) + lag_min
conf = float(max(window.max(), 0.0))
bpm = 60.0 * fps / best
# Octave correction β€” autocorrelation happily locks onto half or double.
while bpm < 70.0:
bpm *= 2.0
while bpm > 190.0:
bpm /= 2.0
thresh = flux.mean() + flux.std()
peaks = (flux[1:-1] > thresh) & (flux[1:-1] > flux[:-2]) & (flux[1:-1] >= flux[2:])
onset_rate = int(np.sum(peaks)) / (flux.size / fps) if flux.size else 0.0
return {"bpm": float(bpm), "confidence": conf, "onset_rate": float(onset_rate)}
def key_estimate(freqs: np.ndarray, power: np.ndarray) -> dict:
sel = (freqs >= 55.0) & (freqs <= 2200.0)
f, p = freqs[sel], power[sel]
if not f.size or p.sum() <= _EPS:
return {"key": "β€”", "confidence": 0.0}
midi = 69.0 + 12.0 * np.log2(f / 440.0)
pc = np.mod(np.round(midi).astype(int), 12)
chroma = np.zeros(12)
np.add.at(chroma, pc, np.sqrt(p))
if chroma.sum() <= _EPS:
return {"key": "β€”", "confidence": 0.0}
chroma = chroma / chroma.sum()
scored: list[tuple[float, str]] = []
for root in range(12):
rotated = np.roll(chroma, -root)
for profile, quality in ((_KRUMHANSL_MAJOR, ""), (_KRUMHANSL_MINOR, "m")):
prof = profile / profile.sum()
if np.std(rotated) < _EPS:
continue
score = float(np.corrcoef(rotated, prof)[0, 1])
scored.append((score, f"{_NOTE_NAMES[root]}{quality}"))
if not scored:
return {"key": "β€”", "confidence": 0.0}
scored.sort(reverse=True)
margin = scored[0][0] - (scored[1][0] if len(scored) > 1 else 0.0)
return {"key": scored[0][1], "confidence": float(max(0.0, margin))}
# --------------------------------------------------------------------------
# top-level report
# --------------------------------------------------------------------------
@dataclass
class Report:
duration: float = 0.0
lufs_i: float = float("-inf")
lufs_s: float = float("-inf")
lra: float = 0.0
true_peak: float = -120.0
sample_peak: float = -120.0
rms: float = -120.0
crest: float = 0.0
psr: float = 0.0
bands: dict = field(default_factory=dict)
ratios: dict = field(default_factory=dict)
shape: dict = field(default_factory=dict)
stereo: dict = field(default_factory=dict)
rhythm: dict = field(default_factory=dict)
key: dict = field(default_factory=dict)
def to_dict(self) -> dict:
d = asdict(self)
for k, v in list(d.items()):
if isinstance(v, float) and math.isinf(v):
d[k] = -120.0
return d
def analyze(sr: int, data: np.ndarray, *, fast: bool = False) -> Optional[Report]:
"""Full measurement pass. `fast=True` skips tempo/key for the live loop."""
x = to_float_stereo(sr, data)
if x.shape[0] < SR // 20:
return None
mono = x.mean(axis=1)
rms = float(np.sqrt(np.mean(mono**2)))
sample_peak = float(np.max(np.abs(x)))
loud = loudness(x)
freqs, power = spectrum(mono)
bands = band_energies(freqs, power)
rep = Report(
duration=x.shape[0] / SR,
lufs_i=loud["lufs_i"],
lufs_s=loud["lufs_s"],
lra=loud["lra"],
true_peak=true_peak_db(x) if not fast else _db(sample_peak),
sample_peak=_db(sample_peak),
rms=_db(rms),
crest=_db(sample_peak) - _db(rms),
bands=bands,
shape=spectral_shape(freqs, power),
stereo=stereo_image(x),
)
# Peak-to-short-term-loudness ratio: the "is it still punchy" number.
if loud["short_term"].size and math.isfinite(rep.true_peak):
rep.psr = float(rep.true_peak - float(loud["short_term"][-1]))
rep.ratios = {
"sub_vs_bass": bands["sub"] - bands["bass"],
"mud": bands["lowmid"] - bands["mid"],
"harsh": bands["himid"] - bands["mid"],
"air": bands["air"] - bands["mid"],
"tilt_low_high": (bands["sub"] + bands["bass"]) - (bands["presence"] + bands["air"]),
}
if not fast:
flux, fps = onset_envelope(mono)
rep.rhythm = tempo_from_onsets(flux, fps)
rep.key = key_estimate(freqs, power)
else:
rep.rhythm = {"bpm": 0.0, "confidence": 0.0, "onset_rate": 0.0}
rep.key = {"key": "β€”", "confidence": 0.0}
return rep