From 00fce2772dde8c6db5372366da36a21c8e6ae4de Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sun, 19 Jul 2026 00:39:27 -0500 Subject: [PATCH] feat(song): per-note keys hand assignment (`hand`) on the Note wire (#990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor's keys LH/RH hand arc needs a per-note hand assignment ('lh'/'rh', from a MusicXML grand staff import today, hand-editable later) to survive a sloppak save → reload: the editor already emits it on the wire, but note_from_wire dropped unknown keys, so the field died on every reopen. - Note gains `hand: str | None = None` (None = unassigned; the heuristic hand split keeps owning unassigned notes). Distinct from `right_hand` (the bass plucking finger) — hence the spelled-out `hand` wire key, since `rh` is taken. - note_to_wire emits it default-omitted and validates on emit; older readers ignore it (feedpak: unknown note keys are permitted). - note_from_wire decodes it as a strict enum — anything but 'lh'/'rh' (junk, wrong case, bools) falls back to unassigned rather than poisoning downstream hand-split / hands-separate practice logic. Groundwork consumers land separately: notation_lift.split_hands respecting per-note overrides, and the editor's hand surface. Editor counterpart: feedBack-plugin-editor #299. Tests: three new wire round-trip tests in tests/test_song.py (literal key, default-omitted, junk rejection both directions), matching the teaching-marks test style. Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q Signed-off-by: ChrisBeWithYou Co-authored-by: ChrisBeWithYou Co-authored-by: Claude Opus 4.8 --- lib/song.py | 15 +++++++++++++++ tests/test_song.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/lib/song.py b/lib/song.py index c993c71..fd6f09d 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 30b2a54..e92f634 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", [