Bidi-Score / post_process.py
projectlosangeles's picture
Create post_process.py
5297a2a verified
Raw
History Blame Contribute Delete
11.2 kB
# post_process.py
## Vibe-coded with Z AI
### Project Los Angeles
### Tegridy Code 2026
### License CC BY-NC-SA 4.0
import math
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _ioi_mode(onsets, res_ms=5.0):
"""Most common inter-onset interval (binned), ignoring <40 ms gaps."""
us = sorted(set(onsets))
iois = [us[i + 1] - us[i] for i in range(len(us) - 1) if us[i + 1] - us[i] > 1e-6]
if not iois:
return None
bins = {}
for d in iois:
b = int(round(d / res_ms)) * res_ms
if b >= 40.0:
bins[b] = bins.get(b, 0) + 1
if not bins:
return None
return max(bins, key=bins.get)
def _detect_beat_period_ms(onsets, res_ms=5.0, lo_bpm=50.0, hi_bpm=210.0):
"""
Estimate the quarter-note period (ms) from absolute note-onset times
using the autocorrelation of an onset impulse train. Peak is searched
inside the 70-180 BPM preferred range and parabolically refined.
"""
if len(onsets) < 2:
return 500.0
t_max = max(onsets)
if t_max <= 0:
return 500.0
n = int(t_max / res_ms) + 2
env = [0.0] * n
for t in onsets:
idx = int(round(t / res_ms))
if 0 <= idx < n:
env[idx] += 1.0
lag_min = max(1, int(round((60000.0 / hi_bpm) / res_ms)))
lag_max = max(lag_min + 1, int(round((60000.0 / lo_bpm) / res_ms)))
acf = [0.0] * (lag_max + 2)
for lag in range(lag_min, lag_max + 1):
s = 0.0
for i in range(n - lag):
s += env[i] * env[i + lag]
acf[lag] = s
# preferred tempo window: 70-180 BPM -> 333-858 ms
pref_lo = max(lag_min, int(round((60000.0 / 180.0) / res_ms)))
pref_hi = min(lag_max, int(round((60000.0 / 70.0) / res_ms)))
best_lag, best_val = None, -1.0
for lag in range(pref_lo, pref_hi + 1):
if acf[lag] > best_val:
best_val, best_lag = acf[lag], lag
if best_lag is None or best_val <= 0: # fallback: global max
for lag in range(lag_min, lag_max + 1):
if acf[lag] > best_val:
best_val, best_lag = acf[lag], lag
if best_lag is None:
return 500.0
# parabolic interpolation around the peak
y0 = acf[best_lag - 1] if best_lag - 1 >= 0 else acf[best_lag]
y1 = acf[best_lag]
y2 = acf[best_lag + 1] if best_lag + 1 < len(acf) else acf[best_lag]
denom = (y0 - 2 * y1 + y2)
offset = 0.0
if abs(denom) > 1e-12:
offset = max(-0.5, min(0.5, 0.5 * (y0 - y2) / denom))
beat = (best_lag + offset) * res_ms
# light octave correction toward the preferred range
while beat < 333.0:
beat *= 2.0
while beat > 858.0:
beat /= 2.0
return beat
def _level_of(g, spacings_asc):
"""Coarsest grid value v whose spacing divides g (within tol)."""
for v, s in spacings_asc: # coarse (small v) first
r = g / s
if abs(r - round(r)) * s < 1e-3 + 1e-3 * s:
return v
return spacings_asc[-1][0]
def _duration_level(dur, spacings_asc):
"""Grid value v whose spacing makes the note last ~1-4 units."""
best_v, best_err = None, None
for v, s in spacings_asc:
if s <= 0:
continue
units = dur / s
k = max(1, min(4, int(round(units))))
err = abs(dur - k * s) / max(dur, s)
if best_err is None or err < best_err:
best_err, best_v = err, v
return best_v
def _level_distance(lv, v_star, spacings_asc):
vs = [v for v, _ in spacings_asc]
try:
return abs(vs.index(lv) - vs.index(v_star))
except ValueError:
return 99
def _snap_one(t, finest, spacings_asc, back_window, forward_bonus,
duration_hint, dur=None):
"""Forward-biased nearest-grid snap for a single (chord-reference) onset."""
if finest <= 0:
return t
k0 = math.floor(t / finest + 1e-9)
g0 = k0 * finest
g1 = (k0 + 1) * finest
if abs(t - g0) < 1e-6: # already on grid
return g0
d_bwd = t - g0 # > 0
d_fwd = g1 - t # > 0
v_star = None
if duration_hint and dur is not None and dur > 0:
v_star = _duration_level(dur, spacings_asc)
best, best_score = None, None
for g, d, tag in ((g0, d_bwd, 'back'), (g1, d_fwd, 'fwd')):
if tag == 'back' and d > back_window: # backward only when mild
continue
score = -d
if tag == 'fwd':
score += forward_bonus * finest # forward preferred on ties
if v_star is not None:
lv = _level_of(g, spacings_asc)
if lv == v_star:
score += 0.30 * finest
elif _level_distance(lv, v_star, spacings_asc) == 1:
score += 0.10 * finest
if best_score is None or score > best_score + 1e-9:
best_score, best = score, g
if best is None: # fallback (shouldn't happen)
best = g0 if d_bwd <= d_fwd else g1
return best
# --------------------------------------------------------------------------- #
# Main function
# --------------------------------------------------------------------------- #
def snap_score_to_grid(
score,
grid_values,
beat_ms=None,
tempo_bpm=None,
chord_tol_ms=None,
back_factor=0.40,
max_backward_ms=45.0,
forward_bonus=0.10,
duration_hint=True,
preserve_order=True,
resort=True,
verbose=False,
):
"""
Snap MIDI.py note onsets (millisecond, single flat track) to a musical grid.
score flat list of MIDI.py events in ms absolute time
(midi2ms_score output, ticks removed). Note events:
['note', start_ms, dur_ms, channel, note, velocity, extra]
The 7th element (index 6) is a no-op and is passed through.
Non-note events are preserved unchanged.
grid_values note-value denominators, e.g. [1,2,4,8,16,32]
(1=whole, 2=half, 4=quarter, 8=eighth, 16=sixteenth,
32=thirty-second). Spacing for value v = (4*beat_ms)/v.
beat_ms quarter-note period in ms (auto-detected if None).
tempo_bpm overrides beat_ms (beat_ms = 60000/tempo_bpm).
chord_tol_ms max onset spread to group asynchronous chord notes
(default min(45.0, finest*0.5)).
back_factor backward snaps allowed only within back_factor*finest
of a grid line (default 0.40).
max_backward_ms absolute cap on any backward shift (default 45.0).
forward_bonus fraction of finest added to forward candidate score
so forward wins near-ties (default 0.10).
duration_hint use note durations to bias toward the correct grid level.
preserve_order enforce non-decreasing snapped onsets.
resort re-sort output events by time.
verbose print detected tempo + statistics.
Returns: new score (same structure), onsets snapped, durations + 7th
element preserved.
"""
if score is None:
return []
# tolerate a leading ticks integer, just in case it wasn't stripped
leading_ticks = None
events = score
if len(events) > 0 and isinstance(events[0], int) and not isinstance(events[0], bool):
leading_ticks = events[0]
events = events[1:]
# collect note events
note_idxs = [i for i, e in enumerate(events)
if isinstance(e, list) and len(e) >= 6 and e[0] == 'note']
if not note_idxs:
return list(score)
notes = [events[i] for i in note_idxs]
onsets = [float(n[1]) for n in notes]
durs = [float(n[2]) if len(n) > 2 else 0.0 for n in notes]
# ---- 1. beat period -------------------------------------------------- #
if tempo_bpm is not None:
beat = 60000.0 / float(tempo_bpm)
elif beat_ms is not None:
beat = float(beat_ms)
else:
beat = _detect_beat_period_ms(onsets)
# ---- 2. grid --------------------------------------------------------- #
gvs = sorted(set(int(v) for v in grid_values if int(v) > 0))
whole = 4.0 * beat
spacings_asc = [(v, whole / v) for v in gvs] # coarse (small v) first
spacings_by_v = dict(spacings_asc)
finest = spacings_by_v[gvs[-1]]
if finest <= 0:
finest = beat
if chord_tol_ms is None:
chord_tol_ms = min(45.0, finest * 0.5)
back_window = min(max_backward_ms, finest * back_factor)
# ---- 3. chord grouping (async onsets -> one reference) --------------- #
order = sorted(range(len(notes)), key=lambda k: (onsets[k], k))
groups, cur = [], [order[0]]
for k in order[1:]:
if onsets[k] - onsets[cur[0]] <= chord_tol_ms:
cur.append(k)
else:
groups.append(cur); cur = [k]
groups.append(cur)
group_ref = []
for g in groups:
gs = sorted(onsets[k] for k in g)
group_ref.append(gs[len(gs) // 2]) # median, robust
# ---- 4. snap each group reference ------------------------------------ #
snapped = [None] * len(notes)
prev_snapped = -float('inf')
n_back = n_fwd = n_exact = 0
for gi, g in enumerate(groups):
t = group_ref[gi]
# duration hint = longest note in the chord (defines its rhythmic level)
gdur = max((durs[k] for k in g), default=None)
st = _snap_one(t, finest, spacings_asc, back_window,
forward_bonus, duration_hint, dur=gdur)
if preserve_order and st < prev_snapped:
st = prev_snapped # never slip backwards
if abs(st - t) < 1e-6:
n_exact += 1
elif st < t:
n_back += 1
else:
n_fwd += 1
group_ref[gi] = st
prev_snapped = st
for k in g:
snapped[k] = st
# ---- 5. rebuild events ---------------------------------------------- #
new_events = [list(e) if isinstance(e, list) else e for e in events]
for pos, nk in enumerate(note_idxs):
new_events[nk][1] = int(round(snapped[pos])) # onset only
# duration (idx 2), channel/note/velocity, and the 7th element (idx 6)
# are left untouched.
if resort:
def time_key(e):
if isinstance(e, list) and len(e) >= 2:
return e[1]
return 0
indexed = list(enumerate(new_events))
indexed.sort(key=lambda p: (time_key(p[1]), p[0]))
new_events = [e for _, e in indexed]
if verbose:
print(f"[snap_score_to_grid] beat={beat:.2f} ms "
f"({60000.0/beat:.1f} BPM), finest grid={finest:.2f} ms, "
f"chord_tol={chord_tol_ms:.2f} ms, back_window={back_window:.2f} ms")
print(f"[snap_score_to_grid] snaps: exact={n_exact}, "
f"forward={n_fwd}, backward={n_back}, groups={len(groups)}")
if leading_ticks is not None:
return [leading_ticks] + new_events
return new_events