Spaces:
Sleeping
Sleeping
| """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 | |
| # -------------------------------------------------------------------------- | |
| 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 | |