Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
139 changes: 113 additions & 26 deletions routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2746,34 +2746,121 @@ 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
# 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
break
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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

Expand Down
181 changes: 181 additions & 0 deletions tests/test_anchor_compute.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""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_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.
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)
Loading