File size: 23,937 Bytes
6462856
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6c8504
 
 
c8e9dc2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6c8504
 
 
 
 
 
 
 
 
 
6462856
 
 
 
 
 
 
 
6e754f2
c6c8504
6462856
 
 
 
 
 
 
 
 
 
6e754f2
d06996d
 
 
 
 
 
 
 
 
 
 
c6c8504
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6e754f2
 
 
d06996d
c6c8504
d06996d
 
 
 
 
 
 
6e754f2
6462856
 
 
c8e9dc2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6462856
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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]