feat(core): teaching marks fg/ch/sd — wire + GP import + sd derivation (§6.2.2) (#536)

Add the three OPTIONAL per-note feedpak 1.5.0 teaching marks — fg (fret-hand
finger), ch (strum-group key), sd (scale degree) — to the Note model and wire
format, mirroring the bend-shape work (#531). These are DISPLAY/TEACHING ONLY:
nothing in the scoring / note-verification path reads them.

- lib/song.py: Note.fret_finger / strum_group / scale_degree, default-omitted
  on the wire (fg/ch/sd) and decoded via _wire_int_optional; _parse_note reads
  the GP-written fretFinger XML attr. Pure helpers key_to_tonic_pc (§7.7 key
  name -> tonic pitch class) + scale_degree_for_pitch, plus base_open_string_midis
  / pitch_from_base / note_pitch_midi (tuning offsets + capo + fret -> MIDI,
  mirroring app.js _TUNING_BASE_MIDI).
- lib/gp2rs.py: GP5 note.effect.leftHandFinger -> fg (RsNote field + fretFinger
  XML attr), reusing the chord Fingering value convention.
- lib/gp2rs_gpx.py: GP8/GPIF per-note <LeftFingering> (p-i-m-a-c letter codes,
  verified against real GP8 exports) -> fg.
- server.py highway_ws: derive sd for notes + chord notes from the active
  keys.json key + sounding pitch when the author didn't author one (author value
  wins); base hoisted out of the per-note loop.

Tests: round-trip + omit-when-default + malformed-tolerance for fg/ch/sd;
key_to_tonic_pc + scale_degree_for_pitch + note_pitch_midi (standard/drop-D/
capo/bass) units; GP5 leftHandFinger and GP8 <LeftFingering> import.

Part of got-feedback/feedback#334

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-21 07:57:50 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent a858617d71
commit 6ee5da3d8b
7 changed files with 399 additions and 2 deletions
+33
View File
@@ -72,6 +72,9 @@ class RsNote:
tremolo: bool = False
tap: bool = False
link_next: bool = False
# Teaching mark (§6.2.2): fret-hand finger (-1 unset, 0 thumb..4 pinky).
# Display only — never used for grading.
fret_finger: int = -1
@dataclass
@@ -255,6 +258,16 @@ def _bend_shape_xml_attrs(n: "RsNote") -> dict:
return attrs
def _finger_xml_attrs(n: "RsNote") -> dict:
"""Optional teaching-mark XML attribute for a <note>/<chordNote>: `fretFinger`
only when set (!= -1). `_parse_note` (lib/song.py) reads it back so a
GP-imported fret-hand finger survives import → wire → highway. Display only;
never used for grading (§6.2.2)."""
if getattr(n, "fret_finger", -1) != -1:
return {"fretFinger": str(int(n.fret_finger))}
return {}
def _tempo_at_tick(tick: int, tempo_map: list[TempoEvent]) -> float:
"""Get the tempo at a given tick."""
result = tempo_map[0].tempo
@@ -524,6 +537,19 @@ def _gp_string_to_rs(gp_string: int, num_strings: int) -> int:
return num_strings - gp_string
def _gp_finger_to_rs(fingering) -> int:
"""Coerce a pyguitarpro ``Fingering`` enum to an RS fret-hand finger int.
Fingering values are ``unknown=-2, open=-1, thumb=0, index=1, middle=2,
annular=3, little=4`` — already the RS finger integers for 0..4. Anything
open/unknown/out-of-range collapses to ``-1`` (unset), so we never invent a
finger. Teaching mark only (§6.2.2); never used for grading."""
val = getattr(fingering, "value", fingering)
if not isinstance(val, int) or val < 0 or val > 4:
return -1
return val
def _chord_fingers(chord, frets: list[int], num_strings: int) -> list[int]:
"""Per-string fingering for a chord template, in RS string order.
@@ -853,6 +879,11 @@ def convert_track(
if eff.tremoloPicking:
rn.tremolo = True
# Fret-hand fingering -> fg teaching mark (§6.2.2). Same
# Fingering enum + value convention as the chord path.
rn.fret_finger = _gp_finger_to_rs(
getattr(eff, "leftHandFinger", None))
# Whammy / tremolo bar (beat-level dive/raise). RS has no
# whammy attribute, so approximate the pitch movement as an
# unpitched slide: a dive slides down, a raise slides up, by
@@ -1164,6 +1195,7 @@ def _build_xml(
"ignore": "0",
}
attrs.update(_bend_shape_xml_attrs(n))
attrs.update(_finger_xml_attrs(n))
ET.SubElement(notes_el, "note", **attrs)
# Chords
@@ -1196,6 +1228,7 @@ def _build_xml(
"ignore": "0",
}
cn_attrs.update(_bend_shape_xml_attrs(cn))
cn_attrs.update(_finger_xml_attrs(cn))
ET.SubElement(chord_el, "chordNote", **cn_attrs)
# Anchors