From 16bd875baef48ac94d8fe25f64c459ba59f931fe Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Wed, 22 Jul 2026 08:40:59 -0500 Subject: [PATCH 1/2] fix(editor): auto anchors look ahead and stop thrashing on lone notes The auto fret-hand anchor generator (`_compute_anchors`) was a single forward-greedy pass: fixed width 4, no look-ahead, relocating one fret below any note outside the current window. It over-split positions it didn't need to (frets 3, 1, 5 -> two anchors instead of one at fret 1) and thrashed on a single high grace note, emitting an up-then-back pair of shifts for one reached note. Auto anchors feed position-suggest, chord-grip, fingering and the stretch/legato lints, so weak anchors quietly degraded all of them. It now grows each run to the longest span of consecutive note-groups that still fits one window before choosing the fret, and tolerates a lone out-of-window group whose next note returns to the window as a reach rather than a position change (a real >=2-note excursion still moves). Chord and coincident notes are grouped by time, so the hand can no longer relocate mid-chord. The {time, fret, width} shape and inclusive-width semantics are unchanged. Pure and deterministic; covered by tests/test_anchor_compute.py. Full pytest green (401 passed); lint clean; the only JS failures are the two known-on-main ones (mixer_meter_teardown, song_fit). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017xGPjDBF8NTwTK7VQvizix Signed-off-by: ChrisBeWithYou --- CHANGELOG.md | 16 ++++ routes.py | 134 +++++++++++++++++++++------ tests/test_anchor_compute.py | 173 +++++++++++++++++++++++++++++++++++ 3 files changed, 297 insertions(+), 26 deletions(-) create mode 100644 tests/test_anchor_compute.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 70462d6..aeda13f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 existing behavior, so a bare scroll can never edit. Every other profile is untouched. ### Changed +- **The auto fret-hand anchor engine now looks ahead and stops thrashing on lone + notes.** `_compute_anchors` was a single forward-greedy pass — fixed width 4, + no look-ahead, relocating one fret below any note that fell outside the current + window — so it split positions it didn't need to (frets 3, 1, 5 became two + anchors instead of one at fret 1) and *thrashed* on a single high grace note, + emitting an up-then-back pair of shifts for one reached note. It now grows each + run to the longest span of consecutive note-groups that still fits one window + before choosing the fret (so 3, 1, 5 is one anchor at fret 1), and tolerates a + lone out-of-window group whose next note returns to the window as a *reach* + rather than a position change (a real ≥2-note excursion still moves). Chord + notes and coincident notes are now grouped by time so the hand can no longer + "relocate" in the middle of a chord. Auto anchors feed position-suggest, + chord-grip, fingering and the stretch/legato lints, so all of them get steadier + positions; the `{time, fret, width}` shape and inclusive-`width` semantics are + unchanged. Pure and covered by `tests/test_anchor_compute.py`. + - **Redesigned the Tracks-view region blocks as glass banners.** A region now reads as a proper titled object: a translucent kind-colour title band across the top (blue master / teal stem / green guitar / orange bass / purple drums / ice diff --git a/routes.py b/routes.py index a3f0f65..d49597f 100644 --- a/routes.py +++ b/routes.py @@ -2746,34 +2746,116 @@ def _valid_handshape_dicts(seq): return out -def _compute_anchors(notes, chords): - """Auto-generate anchors from note fret positions.""" - all_fretted = [] - for n in notes: - if n["fret"] > 0: - all_fretted.append((n["time"], n["fret"])) - for ch in chords: - for cn in ch.get("notes", []): - if cn["fret"] > 0: - all_fretted.append((cn["time"], cn["fret"])) - - all_fretted.sort(key=lambda x: x[0]) - - if not all_fretted: - return [{"time": 0.0, "fret": 1, "width": 4}] +# Fret-hand anchor generation ------------------------------------------------ +# +# An anchor {time, fret, width} places the fret hand's index at `fret`, +# reaching notes across the INCLUSIVE span [fret, fret + width]. `width` is +# the playable span (max fret minus min fret the hand covers) — the same +# width semantics the stretch lint (`span > width`) and the highway read, +# NOT a half-open count. +_ANCHOR_WIDTH = 4 + + +def _fretted_groups(notes, chords): + """Simultaneous fretted-note groups as (time, lo, hi) tuples, time-sorted. + + Open strings (fret <= 0) need no hand position and are skipped. Notes and + chord-notes that sound at one time collapse into ONE group: the hand has + to span them together, so the anchor window must contain the whole + [lo, hi] at once — unlike the old flat pass, which walked chord notes + individually and could "relocate" the hand in the middle of a chord. + Coincident single notes merge the same way. + """ + groups = {} # rounded time -> [lo, hi] - anchors = [{ - "time": 0.0, - "fret": max(1, all_fretted[0][1] - 1), - "width": 4, - }] + def _add(t, raw_fret): + fret = _safe_int(raw_fret, 0) + if fret <= 0: + return + key = round(_safe_float(t, 0.0), 6) + g = groups.get(key) + if g is None: + groups[key] = [fret, fret] + else: + g[0] = min(g[0], fret) + g[1] = max(g[1], fret) + + for n in notes or []: + _add(n.get("time"), n.get("fret")) + for ch in chords or []: + ct = ch.get("time") + for cn in ch.get("notes", []) or []: + _add(cn.get("time", ct), cn.get("fret")) + + return [(t, lo, hi) for t, (lo, hi) in sorted(groups.items())] + + +def _compute_anchors(notes, chords, width=_ANCHOR_WIDTH): + """Auto-generate fret-hand anchors from note positions. + + Picks a MINIMAL sequence of hand positions (each shift is one anchor) + covering the fretted notes. Two properties the old single forward-greedy + pass lacked: + + * Look-ahead placement — each run is grown to the longest span of + consecutive note-groups that still fits ONE width-`width` window + *before* its fret is chosen, so e.g. frets 3, 1, 5 resolve to a + single anchor at fret 1 instead of relocating on the way down. + * Outlier tolerance (anti-thrash) — a lone note-group that jumps + outside the current window but is immediately followed by a return + to it is charted as a *reach*, not a position change, so a single + high grace note no longer forces two spurious shifts (up, then back). + A sustained excursion (>= 2 groups) is still a real move. + + Pure and deterministic — unit-tested directly. `width` is a parameter for + tests only; both callers use the default. + """ + groups = _fretted_groups(notes, chords) + if not groups: + return [{"time": 0.0, "fret": 1, "width": width}] + + def _place(lo, hi): + # Keep one comfort fret below the run's lowest note when the run does + # not already fill the full width, but never let the window slip below + # the top note (fret >= hi - width) or below fret 1. + return max(1, max(hi - width, lo - 1)) + + anchors = [] + i, ng = 0, len(groups) + while i < ng: + start_time, win_lo, win_hi = groups[i] + j = i + 1 + while j < ng: + _, glo, ghi = groups[j] + new_lo, new_hi = min(win_lo, glo), max(win_hi, ghi) + if new_hi - new_lo <= width: + win_lo, win_hi = new_lo, new_hi + j += 1 + continue + # groups[j] breaks the window. Absorb it as a single-group reach + # iff its own notes fit a hand-span (not a wide chord no position + # could hold) AND there is positive evidence of a return: the very + # next group is back inside the current window. A breaking group + # with nothing (or another out-of-window group) after it is a + # genuine position change, not a lone reach, so it starts a new + # run — that lone forward move is not thrash. + reachable = (ghi - glo) <= width + nxt = groups[j + 1] if j + 1 < ng else None + returns = nxt is not None and win_lo <= nxt[1] and nxt[2] <= win_hi + if reachable and returns: + j += 1 + continue + break - for t, fret in all_fretted: - a = anchors[-1] - if fret < a["fret"] or fret > a["fret"] + a["width"]: - new_fret = max(1, fret - 1) - if new_fret != a["fret"]: - anchors.append({"time": t, "fret": new_fret, "width": 4}) + fret = _place(win_lo, win_hi) + # The first anchor always covers t=0 so the hand has a position from + # the start; a shift landing on the previous anchor's fret is not a + # real move (both runs fit that one window) and is dropped. + if not anchors: + anchors.append({"time": 0.0, "fret": fret, "width": width}) + elif anchors[-1]["fret"] != fret: + anchors.append({"time": start_time, "fret": fret, "width": width}) + i = j return anchors diff --git a/tests/test_anchor_compute.py b/tests/test_anchor_compute.py new file mode 100644 index 0000000..ff85bec --- /dev/null +++ b/tests/test_anchor_compute.py @@ -0,0 +1,173 @@ +"""Unit tests for the fret-hand anchor engine (`_compute_anchors`). + +The rewrite added look-ahead run placement and lone-outlier tolerance to the +old single forward-greedy pass. These tests pin the two headline behaviours +(both of which the old engine got wrong) plus the structural invariants the +downstream consumers — position suggest, chord grip, the stretch/legato +lints, the anchor-resolve sweep — rely on. +""" + +from routes import _compute_anchors, _fretted_groups + + +def note(t, fret): + return {"time": float(t), "fret": fret, "string": 0} + + +def chord(t, frets): + return {"time": float(t), "notes": [{"time": float(t), "fret": f} for f in frets]} + + +def seq(frets): + """One single note per integer beat, in the given fret order.""" + return [note(i, f) for i, f in enumerate(frets)] + + +def frets_of(anchors): + return [a["fret"] for a in anchors] + + +# --- degenerate inputs ------------------------------------------------------ + +def test_no_notes_returns_single_default_anchor(): + assert _compute_anchors([], []) == [{"time": 0.0, "fret": 1, "width": 4}] + + +def test_only_open_strings_returns_single_default_anchor(): + got = _compute_anchors([note(0.0, 0), note(1.0, -1)], []) + assert got == [{"time": 0.0, "fret": 1, "width": 4}] + + +# --- placement -------------------------------------------------------------- + +def test_single_note_keeps_one_comfort_fret_below(): + # fret 5 alone -> index sits at 4 (5 - 1), window [4, 8] + assert _compute_anchors([note(1.0, 5)], []) == [ + {"time": 0.0, "fret": 4, "width": 4}] + + +def test_single_high_note_is_fret_11(): + # Pins the value test_xml_export's comment references: lone fret-12 -> 11. + assert frets_of(_compute_anchors([note(1.0, 12)], [])) == [11] + + +def test_first_anchor_always_covers_time_zero(): + assert _compute_anchors([note(9.0, 7)], [])[0]["time"] == 0.0 + + +def test_low_note_clamps_to_fret_one(): + assert frets_of(_compute_anchors([note(0.0, 1)], [])) == [1] + + +def test_full_width_run_cannot_slip_below_top_note(): + # frets 3..7 exactly fill the width -> anchor must stay at 3 (not 2), or + # the window [2, 6] would drop the top note 7. + assert frets_of(_compute_anchors(seq([3, 5, 7]), [])) == [3] + + +# --- look-ahead (the headline win) ----------------------------------------- + +def test_lookahead_folds_3_1_5_into_one_anchor(): + # min 1, max 5, span 4 == width -> a single hand position at fret 1. + # The old forward greedy anchored on the 3, then relocated on the 1. + got = _compute_anchors(seq([3, 1, 5]), []) + assert len(got) == 1 + assert got[0]["fret"] == 1 + + +# --- anti-thrash outlier tolerance ----------------------------------------- + +def test_lone_high_outlier_is_a_reach_not_two_shifts(): + # 3 3 3 [12] 3 3 3 -> ONE anchor; the old greedy thrashed (3 -> 11 -> 3). + got = _compute_anchors(seq([3, 3, 3, 12, 3, 3, 3]), []) + assert len(got) == 1 + assert got[0]["fret"] == 2 + + +def test_trailing_outlier_is_a_real_final_position(): + # No evidence of return (nothing follows) -> the final high note is a + # genuine move, so it gets its own anchor rather than a phantom reach. + got = _compute_anchors(seq([3, 3, 3, 12]), []) + assert frets_of(got) == [2, 11] + + +def test_two_separated_blips_both_tolerated(): + got = _compute_anchors(seq([3, 20, 3, 20, 3]), []) + assert len(got) == 1 + + +def test_sustained_excursion_is_a_real_move(): + # Two notes at 12 -> a genuine trip up and back: three anchors. + got = _compute_anchors(seq([3, 3, 12, 12, 3, 3]), []) + assert frets_of(got) == [2, 11, 2] + + +def test_consecutive_outlier_then_non_return_breaks(): + # 3 [12 15] 3 -> the 12/15 pair is a real high region, not a lone reach. + got = _compute_anchors(seq([3, 12, 15, 3]), []) + assert len(got) == 3 + assert frets_of(got)[0] == 2 and frets_of(got)[-1] == 2 + + +# --- long scale genuinely shifts ------------------------------------------- + +def test_ascending_scale_shifts_positions_monotonically(): + got = _compute_anchors(seq(list(range(1, 13))), []) + fr = frets_of(got) + assert len(fr) > 1 + assert fr == sorted(fr) # never moves backwards + assert all(a != b for a, b in zip(fr, fr[1:])) # no redundant repeats + + +# --- chords are one group (no mid-chord relocation) ------------------------ + +def test_wide_chord_is_a_single_group(): + # A chord spanning 3..12 must not thrash inside itself: one anchor. + assert len(_compute_anchors([], [chord(1.0, [3, 5, 12])])) == 1 + + +def test_chord_and_coincident_note_group_by_time(): + groups = _fretted_groups([note(1.0, 3)], [chord(1.0, [5, 7])]) + assert groups == [(1.0, 3, 7)] + + +# --- structural invariants + robustness ------------------------------------ + +def test_output_is_time_sorted_positive_and_deduped(): + got = _compute_anchors(seq([5, 9, 2, 14, 3, 7, 11, 1]), []) + times = [a["time"] for a in got] + assert times == sorted(times) + assert all(a["fret"] >= 1 for a in got) + assert all(a["width"] == 4 for a in got) + assert all(a["fret"] != b["fret"] for a, b in zip(got, got[1:])) + + +def test_deterministic(): + data = seq([4, 8, 3, 12, 6, 2]) + assert _compute_anchors(data, []) == _compute_anchors(data, []) + + +def test_robust_to_missing_and_float_fields(): + notes = [{"time": 0.0}, {"time": 1.0, "fret": 5.0}] # missing fret; float fret + chords = [{"time": 2.0, "notes": [{"fret": 7}]}] # chord note lacks time + got = _compute_anchors(notes, chords) + assert got + assert all(a["fret"] >= 1 for a in got) + + +def test_every_in_range_note_lies_in_its_active_anchor_window(): + # A dense passage with no lone outliers: every note must sit inside the + # active anchor's inclusive [fret, fret + width] window. + frets = [2, 3, 4, 5, 6, 5, 4, 3, 7, 8, 9, 8, 7] + anchors = _compute_anchors(seq(frets), []) + + def active(t): + cur = anchors[0] + for a in anchors: + if a["time"] <= t + 1e-9: + cur = a + return cur + + for i, f in enumerate(frets): + a = active(float(i)) + assert a["fret"] <= f <= a["fret"] + a["width"], (i, f, a) From 089eb9ebdfa53d95fd46b98c8764d9e0958b4554 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Wed, 22 Jul 2026 17:52:14 -0500 Subject: [PATCH 2/2] Use effective window for anchor returns --- routes.py | 7 ++++++- tests/test_anchor_compute.py | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/routes.py b/routes.py index d49597f..a3a7863 100644 --- a/routes.py +++ b/routes.py @@ -2841,7 +2841,12 @@ def _place(lo, hi): # run — that lone forward move is not thrash. reachable = (ghi - glo) <= width nxt = groups[j + 1] if j + 1 < ng else None - returns = nxt is not None and win_lo <= nxt[1] and nxt[2] <= win_hi + # Compare the return against the effective placed window, which + # includes the comfort margin around the raw run bounds. + tentative_fret = _place(win_lo, win_hi) + returns = (nxt is not None + and tentative_fret <= nxt[1] + and nxt[2] <= tentative_fret + width) if reachable and returns: j += 1 continue diff --git a/tests/test_anchor_compute.py b/tests/test_anchor_compute.py index ff85bec..a125b79 100644 --- a/tests/test_anchor_compute.py +++ b/tests/test_anchor_compute.py @@ -84,6 +84,14 @@ def test_lone_high_outlier_is_a_reach_not_two_shifts(): assert got[0]["fret"] == 2 +def test_outlier_return_uses_the_effective_comfort_window(): + # The raw run spans only fret 3, but _place gives it window [2, 6]. A + # return at fret 4 is inside that real window and must not cause 2→11→3 + # thrash around the lone fret-12 reach. + got = _compute_anchors(seq([3, 3, 3, 12, 4, 4, 4]), []) + assert frets_of(got) == [2] + + def test_trailing_outlier_is_a_real_final_position(): # No evidence of return (nothing follows) -> the final high note is a # genuine move, so it gets its own anchor rather than a phantom reach.