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
+15
View File
@@ -56,6 +56,13 @@ class Note:
strum_group: int = -1 strum_group: int = -1
scale_degree: int = -1 scale_degree: int = -1
ignore: bool = False 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 @dataclass
@@ -272,6 +279,10 @@ def note_to_wire(n: Note) -> dict:
out["ch"] = n.strum_group out["ch"] = n.strum_group
if n.scale_degree != -1: if n.scale_degree != -1:
out["sd"] = n.scale_degree 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 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), strum_group=_wire_int_optional(d.get("ch"), -1),
scale_degree=_wire_int_optional(d.get("sd"), -1), scale_degree=_wire_int_optional(d.get("sd"), -1),
ignore=bool(d.get("ig", False)), 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,
) )
+28
View File
@@ -249,6 +249,34 @@ def test_note_teaching_marks_tolerate_malformed_optional_ints():
assert n.scale_degree == -1 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) ────────────────────────── # ── Scale-degree derivation helpers (§6.2.2 / §7.7) ──────────────────────────
@pytest.mark.parametrize("key,pc", [ @pytest.mark.parametrize("key,pc", [