Spaces:
Running
Running
File size: 26,392 Bytes
be60cf4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 | """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
|