bigears / soloscribe /quantize.py
DGring's picture
Upload folder using huggingface_hub
c8e9dc2 verified
Raw History Blame Contribute Delete
23.9 kB
"""Beat tracking and rhythm quantization.
The jazz-critical decision lives here: swung eighths land near the 2/3 point
of the beat, and quantizing them to a straight grid produces garbage. The
convention real transcribers use — and what this module does — is to NOTATE
straight eighths and mark the piece "swing feel" (GP5 TripletFeel.eighth),
reserving actual triplet notation for genuine triplet figures (which betray
themselves by an onset near the 1/3 point).
Each beat independently chooses its subdivision template; a per-beat penalty
keeps the notation as simple as the evidence allows.
"""
from __future__ import annotations
import bisect
import numpy as np
from .model import BeatGrid, NoteEvent, QNote, Score, TICKS_PER_QUARTER
_SUBDIVS = np.array([0.0, 0.25, 1 / 3, 0.5, 2 / 3, 0.75, 1.0])
def _grid_fit_seconds(times: list[float], ons: np.ndarray) -> float:
"""Mean seconds-distance from each onset to its nearest legal subdivision
of a (possibly drifting) beat grid."""
if len(times) < 2 or len(ons) == 0:
return float("inf")
ts = np.asarray(times)
total = 0.0
for t in ons:
i = int(np.clip(np.searchsorted(ts, t) - 1, 0, len(ts) - 2))
span = ts[i + 1] - ts[i]
if span <= 0:
return float("inf")
frac = (t - ts[i]) / span
total += float(np.abs(_SUBDIVS - frac).min()) * span
return total / len(ons)
def _subdiv_fit(ons: np.ndarray, period: float) -> tuple[float, float]:
"""(mean distance to nearest legal subdivision, best phase) for a period."""
cands = np.arange(0.0, period, 0.002)
fr = ((ons[None, :] - cands[:, None]) / period) % 1.0
d = np.abs(fr[:, :, None] - _SUBDIVS[None, None, :]).min(axis=2)
sums = d.mean(axis=1)
i = int(np.argmin(sums))
return float(sums[i]), float(cands[i])
def build_beat_grid(
y: np.ndarray,
sr: int,
*,
bpm: float | None = None,
downbeat: float | None = None,
beats_per_bar: int = 4,
cover_until: float | None = None,
onsets: list[float] | None = None,
durations: list[float] | None = None,
) -> BeatGrid:
"""Track beats in the audio; extrapolate so the grid covers the whole clip."""
import librosa
duration = len(y) / sr
# A caller who supplies BOTH tempo and downbeat has asserted ground
# truth — honor it with an exact metronomic grid. Tracking would only
# re-introduce phase errors (a syncopated line pulls the tracker toward
# the offbeats; comb-energy correction is circular for the same reason).
# A user-supplied tempo WITHOUT a downbeat: trust their period, measure
# the phase from the played notes themselves. The tracker can phase-lock
# off the grid even with a strong tempo prior — measured on the blues
# fixture at bpm=92/no-downbeat: 27 of 27 score onsets landed half a cell
# off the audio and coverage read 0.407 (verdict low) on a correct
# transcription. The estimator must be SUBDIVISION-AWARE: a circular mean
# of onset positions is degenerate for straight eighths (positions {0, ½}
# are antipodal) and biased for swing (mass at ⅔ drags the mean off the
# beat) — an earlier circular-mean version produced a spurious pickup bar
# and tie-split drift for exactly that reason. Minimizing each onset's
# distance to its NEAREST legal grid position is phase estimation with
# the same model the quantizer uses.
# NO tempo given at all: estimate period AND phase jointly from the
# played notes. The audio tracker alone half-timed a real jazz clip
# (54.98 against a true 110, GuitarSet bench). No single statistic
# settles the tempo OCTAVE — measured failures of each candidate:
# fractional fit cost favors halving (same ms jitter shrinks as a
# fraction of a longer beat); seconds cost favors doubling (finer grids
# fit anything); onbeat×support favors doubling on eighths-dominated
# lines (every note becomes a beat, support barely drops); and the
# interior-population band alone is defeated by lines carrying two
# metrical levels (blues 8ths+16ths read as 184's 8ths). What finally
# separates the octaves everywhere measured is DURATIONS — notes last a
# characteristic fraction of the true beat (band 0.22–0.80 of a period,
# median) — used as a cull alongside the interior band, with
# onbeat×support−4·seconds_cost scoring the survivors. Measured, cold:
# blues 91.9/92, bebop 150.2/150, funk 104.2/104, real jazz 112.3/110.
if (bpm is None and downbeat is None
and onsets is not None and len(onsets) >= 8):
import librosa as _lr
ons = np.asarray(sorted(onsets))
t0 = float(np.atleast_1d(_lr.feature.tempo(y=y, sr=sr))[0]) or 120.0
# Two opposed witnesses break the octave degeneracy that a fit cost
# cannot (fractional cost favors halving; seconds cost favors
# doubling, since a finer grid fits anything better):
# on-beat fraction — halve the tempo and half the true beats
# stop being beats, so it collapses;
# beat support — double the tempo and phantom beats appear
# that carry no notes, so it collapses.
best = None # (score, bpm, period, phase)
for mult in (0.5, 1.0, 2.0):
base = t0 * mult
if not 40 <= base <= 240:
continue
for cand_bpm in np.arange(base * 0.92, base * 1.08, base * 0.004):
p_ = 60.0 / cand_bpm
frac_cost, phi = _subdiv_fit(ons, p_)
sec_cost = frac_cost * p_
rel = ((ons - phi) / p_) % 1.0
dist_s = np.minimum(rel, 1.0 - rel) * p_
onbeat = float(np.mean(dist_s < 0.045))
# The share of notes living at interior subdivisions is the
# octave's fingerprint: real lines put SOME notes off the
# beat (offbeat eighths, syncopation) but never nearly all.
# Doubled tempo empties the interior (everything lands on a
# "beat"); halved tempo overloads it. Candidates outside the
# band are culled rather than merely penalized — the score
# below rewards density and would otherwise still pick them.
interior = 1.0 - onbeat
if not (0.08 <= interior <= 0.75):
continue
# Durations are the octave's second witness, independent of
# onset positions: notes last a characteristic fraction of
# the true beat. Doubled tempo makes typical notes "longer
# than a beat"; halved makes them slivers.
if durations is not None and len(durations) >= 8:
med_beats = float(np.median(durations)) / p_
if not (0.22 <= med_beats <= 0.80):
continue
beats_n = max(1, int((ons[-1] - phi) / p_) + 1)
idx = np.unique(np.round((ons - phi) / p_).astype(int))
support = float(len(idx[(idx >= 0) & (idx < beats_n)]) / beats_n)
prior = 1.0 if 60 <= cand_bpm <= 190 else 0.85
score = (onbeat * support * prior) - 4.0 * sec_cost
if best is None or score > best[0]:
best = (score, cand_bpm, p_, phi)
if best is not None:
_, _, period_fit, phase_fit = best
bpm = 60.0 / period_fit
downbeat = phase_fit + np.floor(
(float(ons[0]) - phase_fit) / period_fit + 0.30) * period_fit
if downbeat < -period_fit * 0.25:
downbeat += period_fit
# falls through to the metronomic path below
if (bpm is not None and bpm > 0 and downbeat is None
and onsets is not None and len(onsets) >= 4):
period = 60.0 / float(bpm)
ons = np.asarray(sorted(onsets))
_, phase = _subdiv_fit(ons, period)
# Bar 1 starts with the music: the beat at-or-just-before the first
# onset is the downbeat (an onset within 30% of a beat after it
# counts as ON it, so no artificial pickup bar appears).
first = float(ons[0])
downbeat = phase + np.floor((first - phase) / period + 0.30) * period
if downbeat < -period * 0.25:
downbeat += period
if bpm is not None and bpm > 0 and downbeat is not None:
period = 60.0 / float(bpm)
horizon = max(duration, cover_until or 0.0)
# Real players drift; a metronomic grid at even the RIGHT tempo pays
# for that in coverage (measured: real jazz at a correctly fitted
# 112 bpm still scored 0.574 against its own audio). So when onsets
# are available, also build a tracker-constrained-to-this-tempo grid
# and let the onsets arbitrate in seconds. The tracker must win by a
# clear margin: unconstrained preference for it is exactly the
# phase-lock failure the metronomic paths exist to prevent.
if onsets is not None and len(onsets) >= 8 and duration > 4.0:
try:
_, fr_tr = librosa.beat.beat_track(
y=y, sr=sr, start_bpm=float(bpm), tightness=400, trim=False)
t_tr = [float(x) for x in librosa.frames_to_time(fr_tr, sr=sr)]
if len(t_tr) >= 4:
p_tr = float(np.median(np.diff(t_tr)))
while t_tr[0] - p_tr > -p_tr * 0.5:
t_tr.insert(0, t_tr[0] - p_tr)
if t_tr[0] <= 0:
break
while t_tr[-1] < horizon:
t_tr.append(t_tr[-1] + p_tr)
ons_a = np.asarray(sorted(onsets))
# The tracker's beats live in the onset-DETECTOR's time
# convention (~30 ms late of the transcriber's event
# clock — the same measured gap the demo calibration
# handles). The metronomic grid is phase-fitted to the
# events; the tracked grid gets the same alignment:
# keep its drift shape, slide the whole grid by the
# constant that best fits the events.
deltas = np.arange(-0.06, 0.0605, 0.004)
best_d = min(deltas, key=lambda d: _grid_fit_seconds(
[t + d for t in t_tr], ons_a))
t_tr = [t + float(best_d) for t in t_tr]
metro = []
tm = float(downbeat)
while tm - period >= -period * 0.5:
tm -= period
while tm < horizon + period:
metro.append(tm)
tm += period
c_metro = _grid_fit_seconds(metro, ons_a)
c_track = _grid_fit_seconds(t_tr, ons_a)
def _onbeat_frac(times_):
ts_ = np.asarray(times_)
n = 0
for t in ons_a:
i = int(np.clip(np.searchsorted(ts_, t) - 1, 0, len(ts_) - 2))
span = ts_[i + 1] - ts_[i]
fr_ = (t - ts_[i]) / span
if min(fr_, 1 - fr_) * span < 0.045:
n += 1
return n / max(1, len(ons_a))
# Arbitrate on ON-BEAT AGREEMENT first, cost second.
# Subdivision-fit cost WRAPS: a drifting metronomic
# grid's accumulated error cycles onto other legal
# positions, so its cost saturates (~30 ms) while notes
# land on wrong beat-labels — measured on a real rock
# clip where the "cheaper" metronomic grid scored
# coverage 0.495 against the tracker's correct read.
# On-beat fraction does not wrap, and the tracker's one
# failure mode (offbeat phase-lock) shows up in it as a
# collapse — so it guards both directions at once.
ob_t, ob_m = _onbeat_frac(t_tr), _onbeat_frac(metro)
take_tracked = (
0.85 < p_tr / period < 1.18
and (ob_t > ob_m + 0.03
or (ob_t >= ob_m - 0.02 and c_track < c_metro * 0.92))
)
if take_tracked:
first = int(np.argmin(
[abs(t - float(ons_a[0])) + (0.3 * p_tr if t > ons_a[0] + 0.3 * p_tr else 0)
for t in t_tr]))
while first > 0 and t_tr[first] > ons_a[0] + 0.30 * p_tr:
first -= 1
return BeatGrid(
beat_times=t_tr,
beats_per_bar=beats_per_bar,
first_downbeat=first,
bpm_nominal=60.0 / p_tr,
)
except Exception:
pass # arbitration is an enhancement; metronomic is the floor
t0 = float(downbeat)
while t0 - period >= -period * 0.5:
t0 -= period
times = []
t = t0
while t < horizon + period:
times.append(t)
t += period
first = int(np.argmin([abs(t - downbeat) for t in times]))
return BeatGrid(
beat_times=times,
beats_per_bar=beats_per_bar,
first_downbeat=first,
bpm_nominal=float(bpm),
)
if bpm is not None and bpm > 0:
tempo_prior = float(bpm)
tightness = 400 # trust the user's tempo; track drift only
else:
tempo_prior = 120.0
tightness = 100
tempo, frames = librosa.beat.beat_track(
y=y, sr=sr, start_bpm=tempo_prior, tightness=tightness, trim=False
)
times = librosa.frames_to_time(frames, sr=sr)
tempo = float(np.atleast_1d(tempo)[0])
if len(times) < 4:
# Degenerate tracking (very short or arrhythmic clip): fall back to a
# metronomic grid at the prior tempo.
period = 60.0 / (bpm or tempo or 120.0)
times = np.arange(0.0, duration + period, period)
times = list(map(float, times))
period = float(np.median(np.diff(times))) if len(times) > 1 else 60.0 / tempo_prior
# If the user gave a BPM and the tracker landed on a related-but-wrong
# metrical level (half/double), rescale toward the user's intent.
if bpm and period > 0:
ratio = (60.0 / bpm) / period
if 1.7 < ratio < 2.3: # tracker found half-time → insert midpoints
mids = [(a + b) / 2 for a, b in zip(times, times[1:])]
times = sorted(times + mids)
period /= 2
elif 0.42 < ratio < 0.6: # tracker found double-time → take every other
times = times[::2]
period *= 2
# Constant phase correction: the tracker can lock onto offbeat energy
# (syncopated 16th lines drag it ~0.2 beats early). Keep its drift, but
# slide the whole grid to the phase that maximizes onset-envelope energy
# at the beat times. Found by the ground-truth E2E harness (funk lick:
# 14/16 beats off by > an eighth before this correction).
if len(times) > 3:
env = librosa.onset.onset_strength(y=y, sr=sr)
env_t = librosa.times_like(env, sr=sr)
arr = np.asarray(times)
def _energy(delta: float) -> float:
shifted = arr + delta
ok = (shifted >= env_t[0]) & (shifted <= env_t[-1])
if not ok.any():
return -1.0
return float(np.mean(np.interp(shifted[ok], env_t, env)))
deltas = np.linspace(-0.35 * period, 0.35 * period, 29)
best = max(deltas, key=_energy)
if _energy(best) > _energy(0.0) * 1.02: # only move on clear evidence
times = [t + float(best) for t in arr]
# A user-supplied downbeat is ground truth: shift the grid so the nearest
# tracked beat lands exactly on it.
if downbeat is not None and times:
nearest = min(times, key=lambda t: abs(t - downbeat))
if abs(nearest - downbeat) <= period * 0.5:
times = [t + (downbeat - nearest) for t in times]
# Extrapolate to cover [0, duration] (and any caller-requested horizon).
horizon = max(duration, cover_until or 0.0)
while times[0] - period > -period * 0.5:
times.insert(0, times[0] - period)
if times[0] <= 0:
break
while times[-1] < horizon:
times.append(times[-1] + period)
first_downbeat = 0
if downbeat is not None:
first_downbeat = int(np.argmin([abs(t - downbeat) for t in times]))
bpm_nominal = 60.0 / period if period > 0 else (bpm or 120.0)
return BeatGrid(
beat_times=times,
beats_per_bar=beats_per_bar,
first_downbeat=first_downbeat,
bpm_nominal=float(bpm or bpm_nominal),
)
def _beat_position(grid_times: list[float], t: float) -> tuple[int, float]:
"""(beat_index, fractional position within that beat) for time t."""
i = bisect.bisect_right(grid_times, t) - 1
i = max(0, min(i, len(grid_times) - 2))
span = grid_times[i + 1] - grid_times[i]
frac = (t - grid_times[i]) / span if span > 0 else 0.0
return i, min(max(frac, 0.0), 1.499) # allow slight spill past the beat
# Subdivision templates: (name, true_positions, notated_positions, penalty)
_TEMPLATES: list[tuple[str, tuple[float, ...], tuple[float, ...], float]] = [
("straight8", (0.0, 0.5), (0.0, 0.5), 0.00),
("sixteenth", (0.0, 0.25, 0.5, 0.75), (0.0, 0.25, 0.5, 0.75), 0.030),
("triplet", (0.0, 1 / 3, 2 / 3), (0.0, 1 / 3, 2 / 3), 0.035),
("swing8", (0.0, 2 / 3), (0.0, 0.5), 0.015),
]
def detect_swing(events: list[NoteEvent], grid: BeatGrid) -> tuple[bool, float]:
"""Do offbeat eighths sit nearer 2/3 than 1/2? Returns (swing, median_pos).
Beats carrying sixteenth-position evidence (onsets near 0.25/0.75) are
excluded — a straight 16th line's 0.75 onsets would otherwise drag the
median into the swing band and stamp a funk line "swing feel". The band
is also closed below 0.75, where the fourth sixteenth lives.
"""
by_beat: dict[int, list[float]] = {}
for ev in events:
bi, frac = _beat_position(grid.beat_times, ev.start)
by_beat.setdefault(bi, []).append(frac)
offbeats = []
for fracs in by_beat.values():
sixteenthish = any(
0.21 <= f <= 0.29 or 0.71 <= f <= 0.79 for f in fracs
)
if sixteenthish:
continue
offbeats.extend(f for f in fracs if 0.40 <= f <= 0.80)
if len(offbeats) < 4:
return False, 0.5
med = float(np.median(offbeats))
return 0.58 <= med < 0.75, med
def quantize(
events: list[NoteEvent],
grid: BeatGrid,
*,
swing: str = "auto",
key: str = "C",
chords: list[str | None] | None = None,
title: str = "",
min_cell: int = TICKS_PER_QUARTER // 4,
) -> Score:
"""Snap transcribed events onto the notation grid → Score (unfretted)."""
tpq = TICKS_PER_QUARTER
if swing == "auto":
swing_on, _ = detect_swing(events, grid)
else:
swing_on = swing == "on"
times = grid.beat_times
# Choose one subdivision template per beat from the onsets it contains.
by_beat: dict[int, list[float]] = {}
for ev in events:
bi, frac = _beat_position(times, ev.start)
by_beat.setdefault(bi, []).append(frac)
beat_template: dict[int, tuple] = {}
for bi, fracs in by_beat.items():
best, best_cost = None, float("inf")
for name, true_pos, notated, penalty in _TEMPLATES:
if name == "swing8" and not swing_on:
continue
if name == "triplet" and swing_on:
# In swing, only genuine triplets (evidence: an onset near 1/3)
# earn triplet notation; otherwise swung pairs win.
if not any(0.20 <= f <= 0.46 for f in fracs):
continue
# Absolute (not squared) misfit: squared distances between grid
# points are tiny and would let complexity penalties overpower
# genuine evidence (a real triplet would flatten to eighths).
cost = penalty + sum(
min(abs(f - p) for p in true_pos + (1.0,)) for f in fracs
) / len(fracs)
if cost < best_cost:
best, best_cost = (name, true_pos, notated), cost
beat_template[bi] = best
def snap(t: float, is_offset: bool = False) -> int:
"""Time → global notated tick (relative to first downbeat's beat)."""
bi, frac = _beat_position(times, t)
tmpl = beat_template.get(bi)
if tmpl is None:
name, true_pos, notated = "straight8", (0.0, 0.5), (0.0, 0.5)
else:
name, true_pos, notated = tmpl
candidates = list(zip(true_pos, notated)) + [(1.0, 1.0)]
j = int(np.argmin([abs(frac - tp) for tp, _ in candidates]))
notated_frac = candidates[j][1]
return round(((bi - grid.first_downbeat) + notated_frac) * tpq)
qnotes: list[QNote] = []
for ev in sorted(events, key=lambda e: (e.start, e.pitch)):
onset = snap(ev.start)
offset = snap(ev.end, is_offset=True)
if offset <= onset:
offset = onset + min_cell
# Keep triplet-grid durations on the triplet lattice.
dur = offset - onset
qnotes.append(
QNote(
onset=onset,
duration=dur,
pitch=ev.pitch,
velocity=ev.velocity,
vibrato=ev.vibrato,
bend=list(ev.bend),
confidence=ev.confidence,
)
)
# Shift any pre-downbeat content into a pickup bar rather than negative
# ticks. The tempo map below must shift identically or audit resynthesis
# lands notes at the wrong clock times.
shift_ticks = 0
if qnotes:
min_onset = min(q.onset for q in qnotes)
if min_onset < 0:
tpb = tpq * grid.beats_per_bar
shift_ticks = ((-min_onset + tpb - 1) // tpb) * tpb
for q in qnotes:
q.onset += shift_ticks
# Monophonic overlap trim (simultaneous onsets = deliberate double-stops).
qnotes.sort(key=lambda q: (q.onset, q.pitch))
for i, q in enumerate(qnotes):
for later in qnotes[i + 1:]:
if later.onset == q.onset:
continue
if later.onset >= q.offset:
break
q.duration = max(min_cell, later.onset - q.onset)
break
period = float(np.median(np.diff(times))) if len(times) > 1 else 60.0 / grid.bpm_nominal
tempo_bpm = 60.0 / period if period > 0 else grid.bpm_nominal
# Tempo map: one anchor per tracked beat → audit resynthesis follows the
# band's actual drift, not the nominal tempo.
tempo_map = [
(round((i - grid.first_downbeat) * tpq) + shift_ticks, t)
for i, t in enumerate(times)
]
return Score(
qnotes=qnotes,
beats_per_bar=grid.beats_per_bar,
tempo_bpm=tempo_bpm,
triplet_feel=swing_on,
key=key,
tempo_map=tempo_map,
chords=list(chords) if chords else [],
title=title,
)
def parse_chords(spec: str | None) -> list[str | None]:
"""'Gm7|C7|Fmaj7' or newline-separated → one entry per bar."""
if not spec:
return []
parts = [p.strip() for p in spec.replace("\n", "|").split("|")]
return [p if p else None for p in parts]