mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 19:59:35 +00:00
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:
co-authored by
ChrisBeWithYou
Claude Opus 4.8
parent
1745b13ba7
commit
00fce2772d
+15
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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", [
|
||||||
|
|||||||
Reference in New Issue
Block a user