Spaces:
Running
Running
| """Precise global beat-grid estimation from the beat head's activations. | |
| Why this exists: the beat head predicts beat/downbeat activations on the | |
| encoder's 4-frame (~46.4 ms) grid. Raw peak picking + median(diff) is far too | |
| imprecise for barline anchoring — each peak carries +-23 ms bin-quantization | |
| noise, a single-peak anchor offsets the whole chart by 1-3 TJA slots, and a | |
| 0.1% period error drifts ~200 ms over a three-minute song. That is exactly the | |
| "notes are not on the barline" failure mode. | |
| Fix: a TJA needs one rigid grid anyway (constant BPM + one OFFSET), so we | |
| estimate (period, phase) GLOBALLY from every peak in the song: | |
| 1. activation curves over OVERLAPPING windows (hop = WINDOW/2), using BOTH | |
| channels (ch0 = beat, ch1 = downbeat) — ~5x more evidence than | |
| downbeats-only; | |
| 2. sub-bin peak times via parabolic interpolation (46 ms bins -> ~5-10 ms); | |
| 3. robust periodic regression t_k ~= phase + k*period with integer-index | |
| assignment and outlier rejection, initialized at bar level and refined at | |
| beat level (precision grows ~1/(N*span): 100+ bars pins BPM to <0.01); | |
| 4. integer / half-integer BPM snap, accepted only when the residual does not | |
| degrade (community charts are almost always integer BPM); | |
| 5. bar phase chosen by downbeat-activation voting over the meter offsets. | |
| Residual statistics are returned so callers can fall back (no barline | |
| anchoring) when the song does not have a constant tempo. | |
| """ | |
| import numpy as np | |
| import torch | |
| from .vocab import FPS, WINDOW | |
| BIN = 4.0 / FPS # activation bin length in seconds (~46.4 ms) | |
| def beat_activations(model, mel, device="cuda", hop=WINDOW // 2): | |
| """Averaged beat/downbeat activation curves over overlapping windows. | |
| mel: (n_mels, T). Returns (acc, bin_s): acc is (2, ceil(T/factor)) with | |
| row 0 = beat, row 1 = downbeat; bin_s is the bin length in seconds. | |
| The head resolution is auto-detected: the classic linear head runs at | |
| encoder rate (4-frame bins, ~46 ms), the hi-res head at frame rate | |
| (~11.6 ms bins). | |
| """ | |
| from .generate import _autocast | |
| if isinstance(mel, torch.Tensor): | |
| mel = mel.numpy() | |
| T = mel.shape[1] | |
| L = WINDOW // 4 | |
| acc = cnt = None | |
| factor = 4 | |
| for st in range(0, max(T - hop, 1), hop): | |
| w = torch.from_numpy(mel[:, st : st + WINDOW].astype(np.float32)) | |
| if w.shape[1] < WINDOW: | |
| w = torch.nn.functional.pad(w, (0, WINDOW - w.shape[1]), | |
| value=float(np.log(1e-5))) | |
| if w.shape[0] < model.in_ch: # dual/slot models: blank grid channels | |
| w = torch.cat([w, torch.full((model.in_ch - w.shape[0], WINDOW), -1.0)]) | |
| with _autocast(device): | |
| mem = model.encode(w[None].to(device)) | |
| pr = torch.sigmoid(model.beat(mem[:, -L:]).float())[0].cpu().numpy().T | |
| if acc is None: | |
| factor = WINDOW // pr.shape[1] | |
| nbin = (T + factor - 1) // factor | |
| acc = np.zeros((2, nbin)) | |
| cnt = np.zeros(nbin) | |
| b0 = st // factor | |
| n = min(pr.shape[1], nbin - b0) # skip bins past the real song end | |
| if n > 0: | |
| acc[:, b0 : b0 + n] += pr[:, :n] | |
| cnt[b0 : b0 + n] += 1 | |
| if acc is None: | |
| return np.zeros((2, 1)), factor / FPS | |
| return acc / np.maximum(cnt, 1), factor / FPS | |
| def _refined_peaks(act, height, min_dist_s, bin_s=BIN): | |
| """Peak times (seconds) with sub-bin parabolic interpolation.""" | |
| from scipy.signal import find_peaks | |
| idx, _ = find_peaks(act, height=height, distance=max(2, int(min_dist_s / bin_s))) | |
| out = [] | |
| for i in idx: | |
| dx = 0.0 | |
| if 0 < i < len(act) - 1: | |
| d = act[i - 1] - 2 * act[i] + act[i + 1] | |
| if d < -1e-9: | |
| dx = float(np.clip(0.5 * (act[i - 1] - act[i + 1]) / d, -0.5, 0.5)) | |
| # +0.5: training targets bin FLOOR(t*FPS/div), so a peak at bin i | |
| # means the event sits at the bin CENTER — without this everything | |
| # reads one half-bin early | |
| out.append((i + dx + 0.5) * bin_s) | |
| return np.asarray(out) | |
| def _fit_periodic(t, period, phase, reject=0.16, iters=6): | |
| """Robust regression t_i ~= phase + k_i*period with unknown integer k_i. | |
| Returns (period, phase, inlier_mask, rms_seconds).""" | |
| t = np.asarray(t, float) | |
| keep = np.ones(len(t), bool) | |
| for _ in range(iters): | |
| k = np.round((t - phase) / period) | |
| r = t - (phase + k * period) | |
| keep = np.abs(r) < reject * period | |
| if keep.sum() < 4: | |
| return period, phase, keep, float("inf") | |
| kk, tt = k[keep], t[keep] | |
| km = kk.mean() | |
| denom = float(((kk - km) ** 2).sum()) | |
| if denom < 1e-9: | |
| break | |
| period = float(((kk - km) * (tt - tt.mean())).sum() / denom) | |
| phase = float(tt.mean() - period * km) | |
| r = t - (phase + np.round((t - phase) / period) * period) | |
| rms = float(np.sqrt(np.mean(r[keep] ** 2))) | |
| return period, phase, keep, rms | |
| def _refit_phase(t, period, phase, reject=0.22, iters=3): | |
| """Phase-only refit for a FIXED period (used by the BPM-snap step).""" | |
| t = np.asarray(t, float) | |
| keep = np.ones(len(t), bool) | |
| for _ in range(iters): | |
| k = np.round((t - phase) / period) | |
| r = t - (phase + k * period) | |
| keep = np.abs(r) < reject * period | |
| if keep.sum() < 4: | |
| return phase, keep, float("inf") | |
| phase += float(np.median(r[keep])) | |
| r = t - (phase + np.round((t - phase) / period) * period) | |
| rms = float(np.sqrt(np.mean(r[keep] ** 2))) | |
| return phase, keep, rms | |
| def fit_grid(model, mel, device="cuda", meter=None): | |
| """Estimate a rigid (BPM, barline phase) grid for the whole song. | |
| Returns a dict with bpm / beat / bar / phase (a barline time), synthesized | |
| downbeats+beats covering the song, and fit diagnostics (inlier_frac, | |
| rms_ms, ok). Returns None when there are not enough peaks to fit. | |
| """ | |
| if isinstance(mel, torch.Tensor): | |
| mel_np = mel.numpy() | |
| else: | |
| mel_np = mel | |
| act, bin_s = beat_activations(model, mel_np, device) | |
| dur = mel_np.shape[1] / FPS | |
| t_d = _refined_peaks(act[1], height=0.35, min_dist_s=0.9, bin_s=bin_s) | |
| t_b = _refined_peaks(act[0], height=0.35, min_dist_s=0.22, bin_s=bin_s) | |
| if len(t_d) < 4: | |
| return None | |
| # 1) bar-level robust fit (several phase inits; first peak may be spurious) | |
| p0 = float(np.median(np.diff(t_d))) | |
| fits = [_fit_periodic(t_d, p0, float(ph)) for ph in t_d[:3]] | |
| p_bar0, phi_d, keep_d, rms_d = min(fits, key=lambda f: f[3]) | |
| # 2) meter from the bar/beat period ratio; taiko is almost always 4/4 | |
| if meter is None: | |
| meter = 4 | |
| if len(t_b) >= 8: | |
| m = p_bar0 / max(float(np.median(np.diff(t_b))), 1e-6) | |
| if abs(m - 3) < 0.25: | |
| meter = 3 | |
| # 3) beat-level refinement with ALL peaks (downbeats are beats too) | |
| t_all = np.concatenate([t_b, t_d]) if len(t_b) else t_d | |
| p_beat, phi_beat, keep_a, rms_a = _fit_periodic( | |
| t_all, p_bar0 / meter, phi_d, reject=0.22) | |
| if not np.isfinite(rms_a): | |
| p_beat, phi_beat, rms_a = p_bar0 / meter, phi_d, rms_d | |
| # 4) integer / half-integer BPM snap + octave normalization: prefer the | |
| # common-range notation (a 120 song fit at 60 plays identically but | |
| # notates ugly), accept only if the residual holds up | |
| bpm = 60.0 / p_beat | |
| cands = [round(bpm), round(bpm * 2) / 2] | |
| if bpm < 80: # octave normalization: prefer common-range notation | |
| cands = [round(bpm * 2), round(bpm * 2) * 1.0] + cands | |
| elif bpm > 210: | |
| cands = [round(bpm / 2)] + cands | |
| # rank: in [80, 210] first, integers before halves, keep insertion order | |
| ranked = sorted(dict.fromkeys(cands), | |
| key=lambda c: (not (80 <= c <= 210), abs(c - round(c)) > 0, | |
| cands.index(c))) | |
| best = (rms_a, bpm, p_beat, phi_beat) | |
| for cand in ranked: | |
| if not 40 <= cand <= 320 or not (0.4 <= cand / max(bpm, 1e-6) <= 2.5): | |
| continue | |
| pb = 60.0 / cand | |
| ph, _, rms = _refit_phase(t_all, pb, phi_beat) | |
| if rms <= best[0] + 0.0015: # snap unless it costs >1.5 ms rms | |
| best = (rms, float(cand), pb, ph) | |
| break | |
| rms_a, bpm, p_beat, phi_beat = best | |
| p_bar = meter * p_beat | |
| # 5) bar phase: among the `meter` beat offsets, pick the one whose barline | |
| # comb collects the most downbeat activation | |
| nb = act.shape[1] | |
| def _comb_score(ph): | |
| ts = np.arange(ph if ph >= 0 else ph + p_bar, dur, p_bar) | |
| ix = np.clip(np.round(ts / bin_s).astype(int), 0, nb - 1) | |
| return float(act[1][ix].sum()) | |
| base = phi_beat + np.round((phi_d - phi_beat) / p_beat) * p_beat | |
| offs = [(base + j * p_beat - p_bar * np.floor((base + j * p_beat) / p_bar)) | |
| for j in range(meter)] | |
| phase = max(offs, key=_comb_score) | |
| k_max = int(np.floor((dur - phase) / p_bar)) + 1 | |
| downbeats = phase + np.arange(0, max(k_max, 1)) * p_bar | |
| downbeats = downbeats[(downbeats >= 0) & (downbeats < dur)] | |
| beats = phase + np.arange(0, int((dur - phase) / p_beat) + 1) * p_beat | |
| beats = beats[(beats >= 0) & (beats < dur)] | |
| inlier = float(keep_a.mean()) if len(keep_a) else 0.0 | |
| rms_ms = rms_a * 1000.0 | |
| return { | |
| "bpm": float(bpm), "beat": float(p_beat), "bar": float(p_bar), | |
| "meter": int(meter), "phase": float(phase), | |
| "downbeats": downbeats, "beats": beats, | |
| "n_db_peaks": int(len(t_d)), "n_beat_peaks": int(len(t_b)), | |
| "inlier_frac": inlier, "rms_ms": float(rms_ms), | |
| "db_peaks": t_d, # raw refined peaks, for diagnostics/eval | |
| "ok": bool(inlier >= 0.6 and rms_ms <= 30.0 and len(t_d) >= 8), | |
| } | |
| def fit_grid_fixed_bpm(model, mel, bpm, device="cuda", meter=4): | |
| """Grid fit with a USER-SUPPLIED BPM: the period is trusted, only the | |
| phase is estimated (a far easier problem than the free fit — this often | |
| unlocks the slot-exact path on songs whose free fit fails on octave or | |
| period confusion). Returns a grid dict or None.""" | |
| if isinstance(mel, torch.Tensor): | |
| mel = mel.numpy() | |
| act, bin_s = beat_activations(model, mel, device) | |
| dur = mel.shape[1] / FPS | |
| p_beat = 60.0 / float(bpm) | |
| p_bar = meter * p_beat | |
| t_d = _refined_peaks(act[1], height=0.30, min_dist_s=0.5, bin_s=bin_s) | |
| t_b = _refined_peaks(act[0], height=0.30, min_dist_s=0.22, bin_s=bin_s) | |
| t_all = np.concatenate([t_b, t_d]) if len(t_b) else t_d | |
| if len(t_all) < 8: | |
| return None | |
| # beat-level phase (dense evidence), then bar phase by activation voting | |
| ph, keep, rms = _refit_phase(t_all, p_beat, float(np.median(t_all[:5])), iters=5) | |
| if not np.isfinite(rms): | |
| return None | |
| nb = act.shape[1] | |
| def _score(o): | |
| ts = np.arange(o - np.floor(o / p_bar) * p_bar, dur, p_bar) | |
| ix = np.clip(np.round(ts / bin_s).astype(int), 0, nb - 1) | |
| return float(act[1][ix].sum()) | |
| phase = max((ph + j * p_beat for j in range(meter)), key=_score) | |
| phase -= np.floor(phase / p_bar) * p_bar | |
| downbeats = phase + np.arange(0, int((dur - phase) / p_bar) + 1) * p_bar | |
| downbeats = downbeats[(downbeats >= 0) & (downbeats < dur)] | |
| beats = phase + np.arange(0, int((dur - phase) / p_beat) + 1) * p_beat | |
| beats = beats[(beats >= 0) & (beats < dur)] | |
| inlier = float(keep.mean()) if len(keep) else 0.0 | |
| return { | |
| "bpm": float(bpm), "beat": p_beat, "bar": p_bar, "meter": meter, | |
| "phase": float(phase), "downbeats": downbeats, "beats": beats, | |
| "n_db_peaks": int(len(t_d)), "n_beat_peaks": int(len(t_b)), | |
| "inlier_frac": inlier, "rms_ms": float(rms * 1000), | |
| "db_peaks": t_d, "fixed_bpm": True, | |
| # period is user-trusted, so the gate only needs the PHASE to be sane | |
| "ok": bool(inlier >= 0.5 and rms * 1000 <= 40.0), | |
| } | |
| # --- adaptive piecewise-constant tempo fit ------------------------------------ | |
| # The BPM census over all 1155 songs: 34% are multi-BPM; among them the main | |
| # tempo covers a median 91% of measures, contiguous-segment count is <=4 for | |
| # 82% of songs but p99 = 29 (chained ritardando). So segmentation must be | |
| # ADAPTIVE (change-point DP with a per-segment penalty), not a fixed K. | |
| def _seg_fit(t, reject=0.16): | |
| """Fit t_k ~= phase + k*period over ONE segment of consecutive downbeat | |
| peaks (missing peaks allowed: k advances by round(gap/period)). | |
| Returns (period, phase, sse_seconds^2, n_inlier).""" | |
| t = np.asarray(t, float) | |
| if len(t) < 2: | |
| return None | |
| d = np.diff(t) | |
| p0 = float(np.median(d)) | |
| if p0 <= 0: | |
| return None | |
| k = np.concatenate([[0], np.cumsum(np.maximum(1, np.round(d / p0)))]) | |
| for _ in range(3): | |
| km, tm = k.mean(), t.mean() | |
| denom = float(((k - km) ** 2).sum()) | |
| if denom < 1e-9: | |
| return None | |
| p = float(((k - km) * (t - tm)).sum() / denom) | |
| if p <= 0: | |
| return None | |
| phi = float(tm - p * km) | |
| r = t - (phi + k * p) | |
| k = k + np.round(-r / p) # re-assign indices after refinement | |
| r = t - (phi + k * p) | |
| keep = np.abs(r) < reject * p | |
| sse = float((r[keep] ** 2).sum()) + float((~keep).sum()) * (reject * p) ** 2 | |
| return p, phi, sse, int(keep.sum()) | |
| def fit_grid_piecewise(model, mel, device="cuda", dev_thresh=0.18, | |
| min_run=3, seg_rms_ms=25.0): | |
| """Main-grid + spliced exceptions (v2 after the DP version over-segmented). | |
| The BPM census says multi-BPM songs are ~91% main-tempo, so: fit the rigid | |
| grid first (full-song precision, outliers rejected), then find CONTIGUOUS | |
| runs of downbeat peaks that consistently deviate from it, refit each run | |
| locally, and splice. Segment count stays small by construction and the | |
| main grid keeps its precision. Returns None if the main fit fails. | |
| """ | |
| if isinstance(mel, torch.Tensor): | |
| mel = mel.numpy() | |
| g = fit_grid(model, mel, device=device) | |
| if g is None: | |
| return None | |
| t_d = g["db_peaks"] | |
| if len(t_d) < 8: | |
| return None | |
| if not g["ok"]: | |
| # the single rigid grid failed: true multi-tempo song. Try every | |
| # coarse split point, fit both halves rigidly, keep the best pair | |
| # (recursion depth 1 -> up to 2 segments; census: covers most cases). | |
| best = None | |
| for cut in range(6, len(t_d) - 6, 3): | |
| fa = _seg_fit(t_d[:cut]) | |
| fb = _seg_fit(t_d[cut:]) | |
| if fa is None or fb is None: | |
| continue | |
| rms = np.sqrt((fa[2] + fb[2]) / len(t_d)) | |
| if best is None or rms < best[0]: | |
| best = (rms, cut, fa, fb) | |
| if best is not None and best[0] * 1000 <= 30.0: | |
| _, cut, (pa, fia, _, na), (pb, fib, _, nb2) = best | |
| dur = mel.shape[1] / FPS | |
| t_mid = 0.5 * (t_d[cut - 1] + t_d[cut]) | |
| da = fia + np.arange(0, int(np.ceil((t_mid - fia) / pa)) + 1) * pa | |
| da = da[(da >= 0) & (da < t_mid)] | |
| db2 = fib + np.arange(0, int(np.ceil((dur - fib) / pb)) + 1) * pb | |
| db2 = db2[(db2 >= t_mid) & (db2 < dur)] | |
| downbeats = np.sort(np.concatenate([da, db2])) | |
| inl = (na + nb2) / len(t_d) | |
| return {**g, "downbeats": downbeats, | |
| "bpm": 240.0 / pa, "beat": 60.0 / (240.0 / pa) if pa else g["beat"], | |
| "bar": pa, "phase": float(downbeats[0]) if len(downbeats) else g["phase"], | |
| "segments": [(float(fia), 240.0 / pa), (float(t_mid), 240.0 / pb)], | |
| "piecewise": True, "n_segments": 2, | |
| "inlier_frac": float(inl), "rms_ms": float(best[0] * 1000), | |
| "ok": bool(inl >= 0.7)} | |
| return {**g, "piecewise": False, "n_segments": 1, | |
| "segments": [(float(g["phase"]), float(g["bpm"]))]} | |
| bar = g["bar"] | |
| phi = g["phase"] | |
| dur = mel.shape[1] / FPS | |
| r = t_d - (phi + np.round((t_d - phi) / bar) * bar) | |
| bad = np.abs(r) > dev_thresh * bar | |
| # contiguous deviant runs of >= min_run peaks | |
| runs = [] | |
| i = 0 | |
| while i < len(t_d): | |
| if bad[i]: | |
| j = i | |
| while j + 1 < len(t_d) and bad[j + 1]: | |
| j += 1 | |
| if j - i + 1 >= min_run: | |
| runs.append((i, j)) | |
| i = j + 1 | |
| else: | |
| i += 1 | |
| segments = [(float(phi), float(g["bpm"]))] | |
| if not runs: # effectively constant: return the rigid fit unchanged | |
| return {**g, "piecewise": False, "n_segments": 1, | |
| "segments": segments} | |
| spliced = [] | |
| n_bad_fixed = 0 | |
| for i, j in runs: | |
| f = _seg_fit(t_d[i : j + 1]) | |
| if f is None: | |
| continue | |
| p_loc, phi_loc, sse, ninl = f | |
| rms = np.sqrt(sse / max(j - i + 1, 1)) * 1000 | |
| if rms > seg_rms_ms or not (0.8 <= p_loc <= 8.0): | |
| continue # local section too messy: leave it to the main grid | |
| bpm_loc = 240.0 / p_loc | |
| if abs(bpm_loc - round(bpm_loc)) < 0.35: | |
| p2 = 240.0 / round(bpm_loc) | |
| rr = t_d[i:j+1] - (phi_loc + np.round((t_d[i:j+1] - phi_loc) / p2) * p2) | |
| if float(np.sqrt(np.mean(rr ** 2))) * 1000 <= rms * 1.3 + 4: | |
| p_loc = p2 | |
| phi_loc = phi_loc + float(np.median(rr)) | |
| bpm_loc = float(round(bpm_loc)) | |
| t_lo = t_d[i] - 0.25 * p_loc | |
| t_hi = (t_d[j] + p_loc) if j + 1 >= len(t_d) else t_d[j + 1] - 0.25 * bar | |
| spliced.append((t_lo, t_hi, p_loc, phi_loc, bpm_loc)) | |
| n_bad_fixed += j - i + 1 | |
| if not spliced: # nothing splice-worthy: keep the rigid fit verbatim | |
| return {**g, "piecewise": False, "n_segments": 1, "segments": segments} | |
| # main-grid barlines outside spliced spans + local barlines inside | |
| main_db = phi + np.arange(0, int(np.ceil((dur - phi) / bar)) + 1) * bar | |
| main_db = main_db[(main_db >= 0) & (main_db < dur)] | |
| keep = np.ones(len(main_db), bool) | |
| downbeats = [] | |
| for t_lo, t_hi, p_loc, phi_loc, bpm_loc in spliced: | |
| keep &= ~((main_db >= t_lo) & (main_db < t_hi)) | |
| loc = phi_loc + np.arange(0, int(np.ceil((t_hi - phi_loc) / p_loc)) + 1) * p_loc | |
| loc = loc[(loc >= max(t_lo, 0)) & (loc < min(t_hi, dur))] | |
| downbeats.append(loc) | |
| segments.append((float(max(t_lo, 0.0)), float(bpm_loc))) | |
| downbeats.append(main_db[keep]) | |
| downbeats = np.concatenate(downbeats) | |
| downbeats = np.sort(downbeats) | |
| dk = np.concatenate([[True], np.diff(downbeats) > 0.3]) | |
| downbeats = downbeats[dk] | |
| # residual after splicing (all peaks vs nearest final barline) | |
| err = np.array([np.min(np.abs(downbeats - t)) for t in t_d]) | |
| inlier = float(np.mean(err < dev_thresh * bar)) | |
| rms_ms = float(np.sqrt(np.mean(np.minimum(err, dev_thresh * bar) ** 2)) * 1000) | |
| return { | |
| **g, | |
| "downbeats": downbeats, "segments": sorted(segments), | |
| "piecewise": bool(spliced), "n_segments": 1 + len(spliced), | |
| "inlier_frac": inlier, "rms_ms": rms_ms, | |
| # splices only ADD local corrections to an already-vetted main grid — | |
| # never downgrade the rigid fit's verdict | |
| "ok": bool(g["ok"] or (inlier >= 0.75 and rms_ms <= 35.0)), | |
| } | |
| # --- onset evidence: metrically weighted slot-comb refinement ----------------- | |
| # Audio onsets (spectral flux) are ~4x sharper in time than the beat head's | |
| # 46 ms bins and 10-20x denser than downbeats. They cannot determine the | |
| # metrical LEVEL (which line is beat 1) — but given the beat head's fit as an | |
| # anchor, they lock (period, phase) far more precisely, and they can rescue | |
| # songs whose beat-head activations are confused but whose percussion is clean. | |
| # The comb is the TJA slot lattice (beat/24) with metrical-hierarchy weights: | |
| # occupancy concentrates on strong positions, which is what makes a 16 ms | |
| # lattice identifiable at all (a flat comb that fine would fit noise). | |
| _W24 = np.full(24, 0.05) | |
| _W24[0] = 1.0 # beat | |
| _W24[12] = 0.55 # 8th | |
| _W24[[6, 18]] = 0.30 # 16ths | |
| _W24[[8, 16]] = 0.22 # 8th triplets | |
| _W24[[3, 9, 15, 21]] = 0.10 # 32nds | |
| _W24[[4, 20]] = 0.10 # 16th triplets | |
| def onset_peaks(mel, max_n=3000): | |
| """Spectral-flux onset times (seconds) with sub-frame refinement.""" | |
| from scipy.signal import find_peaks | |
| flux = np.maximum(0, np.diff(mel.astype(np.float32), axis=1)).sum(0) | |
| flux = np.concatenate([[0.0], flux]) | |
| med = np.median(flux) | |
| idx, props = find_peaks(flux, height=med * 1.5, distance=max(2, int(0.035 * FPS))) | |
| if len(idx) > max_n: # keep the strongest | |
| keep = np.argsort(props["peak_heights"])[-max_n:] | |
| idx = np.sort(idx[keep]) | |
| out = [] | |
| for i in idx: | |
| dx = 0.0 | |
| if 0 < i < len(flux) - 1: | |
| d = flux[i - 1] - 2 * flux[i] + flux[i + 1] | |
| if d < -1e-9: | |
| dx = float(np.clip(0.5 * (flux[i - 1] - flux[i + 1]) / d, -0.5, 0.5)) | |
| out.append((i + dx) / FPS) | |
| return np.asarray(out) | |
| def comb_score(onsets, period_beat, phase, sig=0.012): | |
| """Sum over onsets of hierarchy-weight x Gaussian(dist to nearest slot).""" | |
| step = period_beat / 24.0 | |
| x = (onsets - phase) / step | |
| k = np.round(x) | |
| dist = (x - k) * step | |
| w = _W24[(k.astype(int)) % 24] | |
| return float(np.sum(w * np.exp(-0.5 * (dist / sig) ** 2))) | |
| def onset_polish(grid, mel, span_bpm=0.6, span_ms=30, n_p=25, n_f=31): | |
| """Local (period, phase) refinement of a trusted fit on onset evidence. | |
| Returns an updated grid dict (or the original if no improvement).""" | |
| ons = onset_peaks(mel) | |
| if len(ons) < 30 or grid is None: | |
| return grid | |
| p0, f0 = grid["beat"], grid["phase"] | |
| base = comb_score(ons, p0, f0) | |
| best = (base, p0, f0) | |
| for p in np.linspace(60.0 / (grid["bpm"] + span_bpm), 60.0 / (grid["bpm"] - span_bpm), n_p): | |
| for f in f0 + np.linspace(-span_ms / 1000, span_ms / 1000, n_f): | |
| s = comb_score(ons, p, f) | |
| if s > best[0]: | |
| best = (s, float(p), float(f)) | |
| s1, p1, f1 = best | |
| if s1 <= base * 1.02: # <2% gain: keep the beat-head fit | |
| return grid | |
| out = dict(grid) | |
| meter = grid.get("meter", 4) | |
| out["beat"], out["bpm"], out["bar"] = p1, 60.0 / p1, meter * p1 | |
| # keep the bar phase on the SAME barline, re-expressed on the new beat grid | |
| out["phase"] = f1 + round((grid["phase"] - f1) / p1) * p1 | |
| dur = mel.shape[1] / FPS | |
| ph, bar = out["phase"], out["bar"] | |
| ph -= np.floor(ph / bar) * bar | |
| out["downbeats"] = np.arange(ph, dur, bar) | |
| out["beats"] = np.arange(ph - np.floor(ph / p1) * p1, dur, p1) | |
| out["onset_gain"] = s1 / max(comb_score(ons, p0, f0), 1e-9) | |
| return out | |
| def onset_rescue(mel, act=None, bpm_range=(65, 210)): | |
| """Grid estimation from onsets alone (+ optional downbeat activation for | |
| the bar phase) — for songs where the beat-head fit failed. Returns a grid | |
| dict with ok=True only when the comb evidence is decisive.""" | |
| ons = onset_peaks(mel) | |
| if len(ons) < 40: | |
| return None | |
| dur = mel.shape[1] / FPS | |
| # tempo candidates from the IOI histogram (mode + octaves) | |
| iois = np.diff(ons) | |
| iois = iois[(iois > 0.08) & (iois < 2.0)] | |
| if len(iois) < 20: | |
| return None | |
| hist, edges = np.histogram(iois, bins=192, range=(0.08, 2.0)) | |
| cand_p = [] | |
| for i in np.argsort(hist)[-6:]: | |
| c = 0.5 * (edges[i] + edges[i + 1]) | |
| for mult in (0.5, 1.0, 2.0, 4.0): | |
| p = c * mult | |
| if 60.0 / bpm_range[1] <= p <= 60.0 / bpm_range[0]: | |
| cand_p.append(p) | |
| best = (0.0, None, None) | |
| for p in sorted(set(np.round(cand_p, 4))): | |
| for f in np.arange(0.0, p, p / 48): | |
| s = comb_score(ons, p, f, sig=0.015) | |
| if s > best[0]: | |
| best = (s, float(p), float(f)) | |
| if best[1] is None: | |
| return None | |
| # fine polish around the winner | |
| g0 = {"beat": best[1], "bpm": 60.0 / best[1], "phase": best[2], "meter": 4, | |
| "bar": 4 * best[1]} | |
| # integer-BPM snap when it costs <2% of the comb score | |
| bpm = g0["bpm"] | |
| for candb in (round(bpm), round(bpm * 2) / 2): | |
| if abs(candb - bpm) < 0.6 and candb > 0: | |
| pb = 60.0 / candb | |
| fb = max(np.arange(0.0, pb, pb / 96), | |
| key=lambda f: comb_score(ons, pb, f)) | |
| if comb_score(ons, pb, fb) >= 0.98 * best[0]: | |
| g0.update(beat=pb, bpm=float(candb), phase=float(fb), bar=4 * pb) | |
| break | |
| # bar phase: downbeat-activation voting among the 4 beat offsets | |
| ph = g0["phase"] | |
| if act is not None: | |
| if isinstance(act, tuple): | |
| act, _bin = act | |
| else: | |
| _bin = BIN | |
| nb = act.shape[1] | |
| def _sc(o): | |
| ts = np.arange(o - np.floor(o / g0["bar"]) * g0["bar"], dur, g0["bar"]) | |
| ix = np.clip(np.round(ts / _bin).astype(int), 0, nb - 1) | |
| return float(act[1][ix].sum()) | |
| ph = max((g0["phase"] + jj * g0["beat"] for jj in range(4)), key=_sc) | |
| ph -= np.floor(ph / g0["bar"]) * g0["bar"] | |
| # decisiveness gate: winner must clearly beat the runner-up octave | |
| alt = comb_score(ons, g0["beat"] * 2, ph) + comb_score(ons, g0["beat"] / 2, ph) | |
| score = comb_score(ons, g0["beat"], ph) | |
| ok = bool(score > 0.25 * len(ons) and score > 0.75 * alt) | |
| return {"bpm": g0["bpm"], "beat": g0["beat"], "bar": g0["bar"], "meter": 4, | |
| "phase": float(ph), | |
| "downbeats": np.arange(ph, dur, g0["bar"]), | |
| "beats": np.arange(ph - np.floor(ph / g0["beat"]) * g0["beat"], dur, g0["beat"]), | |
| "n_db_peaks": 0, "n_beat_peaks": int(len(ons)), | |
| "inlier_frac": float(score / max(len(ons), 1)), "rms_ms": -1.0, | |
| "db_peaks": np.asarray([]), "ok": ok, "rescued": True} | |
| def debias_to_grid(times, phase, step): | |
| """Remove the generator's systematic latency: median signed offset of | |
| `times` to the nearest grid subdivision. Returns (shifted_times, offset). | |
| Relative timing (groove, tuplets) is untouched — this is a global shift.""" | |
| t = np.asarray(times, float) | |
| if len(t) == 0: | |
| return t, 0.0 | |
| r = (t - phase + step / 2) % step - step / 2 | |
| off = float(np.median(r)) | |
| return t - off, off | |