diff --git a/lib/song.py b/lib/song.py index c993c71b..fd6f09d1 100644 --- a/lib/song.py +++ b/lib/song.py @@ -56,6 +56,13 @@ class Note: strum_group: int = -1 scale_degree: int = -1 ignore: bool = False + # Keys hand assignment ('lh'/'rh', None = unassigned) — authored per-note, + # e.g. from a MusicXML grand staff import in the editor. Lets the notation + # hand split and hands-separate practice honor the author instead of the + # mean-pitch heuristic. Distinct from `right_hand` (the bass plucking + # finger); spelled-out `hand` on the wire because `rh` is taken. + # Default-omitted on the wire; older readers ignore it. + hand: str | None = None @dataclass @@ -272,6 +279,10 @@ def note_to_wire(n: Note) -> dict: out["ch"] = n.strum_group if n.scale_degree != -1: out["sd"] = n.scale_degree + # Keys hand assignment — default-omitted; validated on emit so a + # directly-constructed Note can't put junk ('LH', True, …) on the wire. + if n.hand in ("lh", "rh"): + out["hand"] = n.hand return out @@ -532,6 +543,10 @@ def note_from_wire(d: dict, time: float | None = None) -> Note: strum_group=_wire_int_optional(d.get("ch"), -1), scale_degree=_wire_int_optional(d.get("sd"), -1), ignore=bool(d.get("ig", False)), + # Keys hand assignment — strict enum decode: anything but 'lh'/'rh' + # (junk, wrong case, bools) falls back to unassigned rather than + # poisoning downstream hand-split/practice logic. + hand=d.get("hand") if d.get("hand") in ("lh", "rh") else None, ) diff --git a/tests/test_song.py b/tests/test_song.py index 30b2a546..e92f6348 100644 --- a/tests/test_song.py +++ b/tests/test_song.py @@ -249,6 +249,34 @@ def test_note_teaching_marks_tolerate_malformed_optional_ints(): assert n.scale_degree == -1 +# ── Keys hand assignment ───────────────────────────────────────────────────── + +def test_note_hand_round_trips_under_literal_key(): + """The keys hand assignment survives the wire as the literal `hand` key + (spelled out — `rh` is taken by right_hand, the bass plucking finger).""" + for hand in ("lh", "rh"): + n = Note(time=0.0, string=2, fret=12, hand=hand) + wire = note_to_wire(n) + assert wire["hand"] == hand + assert note_from_wire(wire) == n + + +def test_note_hand_omitted_when_unassigned(): + wire = note_to_wire(Note(time=0.0, string=0, fret=0)) + assert "hand" not in wire + assert note_from_wire(wire).hand is None + + +def test_note_hand_junk_never_emitted_and_decodes_to_unassigned(): + """Emit side validates ('LH', True, … stay off the wire); decode side is a + strict enum so a hand-edited pack can't poison hand-split logic.""" + for junk in ("LH", "left", "", True, 1, ["lh"]): + assert "hand" not in note_to_wire( + Note(time=0.0, string=0, fret=0, hand=junk)) + assert note_from_wire( + {"t": 0.0, "s": 0, "f": 0, "hand": junk}).hand is None + + # ── Scale-degree derivation helpers (§6.2.2 / §7.7) ────────────────────────── @pytest.mark.parametrize("key,pc", [