feat(song): per-note keys hand assignment (hand) on the Note wire (#990)

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 <chris@rifflarr.local>
Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ChrisBeWithYou
2026-07-19 00:39:27 -05:00
committed by GitHub
co-authored by ChrisBeWithYou Claude Opus 4.8
parent 1745b13ba7
commit 00fce2772d
2 changed files with 43 additions and 0 deletions
+28
View File
@@ -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", [