JacobLinCool's picture
Restore selectable legacy model generations
be60cf4
Raw
History Blame Contribute Delete
26.4 kB
"""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)
@torch.no_grad()
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
@torch.no_grad()
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),
}
@torch.no_grad()
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