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
+27
View File
@@ -1220,6 +1220,33 @@ def test_chord_diagram_fingers_extracted():
assert [ct.get(f"finger{i}") for i in range(0, 4)] == ["-1"] * 4
def test_single_note_left_hand_finger_imports_as_fg():
"""A GP single note's leftHandFinger imports as the `fg` teaching mark and
survives convert_track XML → _parse_note → note_to_wire (§6.2.2)."""
from song import _parse_note, note_to_wire
note = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=5)
note.effect.leftHandFinger = guitarpro.Fingering.middle # -> 2
beat = _ct_beat(tick=0, dur_value=4, notes=[note])
root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314
xn = root.findall(".//notes/note")[0]
assert xn.get("fretFinger") == "2"
assert note_to_wire(_parse_note(xn))["fg"] == 2
def test_single_note_open_finger_omits_fg():
"""Open/unset leftHandFinger leaves fg unset — no fabricated finger."""
from song import _parse_note, note_to_wire
note = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=5)
note.effect.leftHandFinger = guitarpro.Fingering.open # -1 -> unset
beat = _ct_beat(tick=0, dur_value=4, notes=[note])
root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314
xn = root.findall(".//notes/note")[0]
assert xn.get("fretFinger") is None
assert "fg" not in note_to_wire(_parse_note(xn))
def test_chord_without_diagram_has_blank_fingers():
# A plain two-note chord (effect.chord is None) is unchanged: blank name,
# all-(-1) fingers — no regression for diagram-less charts.
+27
View File
@@ -32,6 +32,7 @@ from gp2rs_gpx import (
_inject_tones,
_resolve_pending_slides,
_gpx_bend_shape,
_gpif_left_fingering,
)
from gp2rs import RsNote
@@ -731,6 +732,32 @@ def test_note_vibrato_ignores_whammy_trembar_property():
assert _note_has_vibrato(n, tp) is False
# ── _gpif_left_fingering (GP7/GP8 per-note fret-hand finger -> fg) ───────────
# GPIF stores a single note's fret-hand finger as a direct <LeftFingering>
# child of <Note> (NOT a <Property>), with classical p-i-m-a-c letter codes —
# verified against real GP8 exports (Open / I / M observed). Maps to the same
# RS finger integers as the chord-diagram path (§6.2.2). Teaching mark only.
@pytest.mark.parametrize("code, expected", [
("Open", -1), ("P", 0), ("I", 1), ("M", 2), ("A", 3), ("C", 4),
("i", 1), ("m", 2), # case-insensitive
("index", 1), ("ring", 3), # word forms also accepted
])
def test_gpif_left_fingering_letter_codes(code, expected):
n = ET.fromstring(f'<Note id="1"><LeftFingering>{code}</LeftFingering>'
'<Properties></Properties></Note>')
assert _gpif_left_fingering(n) == expected
def test_gpif_left_fingering_absent_or_unknown_is_unset():
# No <LeftFingering> child, or an unrecognised value -> -1 (never fabricate).
assert _gpif_left_fingering(ET.fromstring('<Note id="1"/>')) == -1
assert _gpif_left_fingering(
ET.fromstring('<Note id="1"><LeftFingering>Z</LeftFingering></Note>')) == -1
assert _gpif_left_fingering(
ET.fromstring('<Note id="1"><LeftFingering></LeftFingering></Note>')) == -1
# ── convert_file: GP8 chord-diagram name + fingering extraction (E3) ─────────
# GP7/GP8 GPIF carries authored chord diagrams under a track's
# Property[@name="DiagramCollection"]. Each Item gives the chord name and a
+107
View File
@@ -20,10 +20,14 @@ from song import (
chord_to_wire,
sanitize_tempos,
compute_smart_names,
base_open_string_midis,
key_to_tonic_pc,
note_from_wire,
note_to_wire,
note_pitch_midi,
phrase_from_wire,
phrase_to_wire,
scale_degree_for_pitch,
)
@@ -204,6 +208,109 @@ def test_note_bend_shape_omitted_when_default():
assert decoded.bend_values is None
# ── Teaching marks (§6.2.2) ──────────────────────────────────────────────────
def test_note_teaching_marks_round_trip():
"""fg/ch/sd survive the wire under their literal keys.
Pin the public wire keys explicitly (cross-language sloppak readers key
off the literal strings), like the rh/pkd test above.
"""
n = Note(
time=0.0, string=0, fret=0,
fret_finger=2, strum_group=5, scale_degree=7,
)
wire = note_to_wire(n)
assert wire["fg"] == 2
assert wire["ch"] == 5
assert wire["sd"] == 7
assert note_from_wire(wire) == n
def test_note_teaching_marks_omitted_when_default():
"""fg/ch/sd are default-omitted (-1) and decode back to -1."""
wire = note_to_wire(Note(time=0.0, string=0, fret=0))
for omitted in ("fg", "ch", "sd"):
assert omitted not in wire, f"{omitted!r} should be default-omitted"
decoded = note_from_wire(wire)
assert decoded.fret_finger == -1
assert decoded.strum_group == -1
assert decoded.scale_degree == -1
def test_note_teaching_marks_tolerate_malformed_optional_ints():
"""fg/ch/sd survive null / empty / non-numeric wire values."""
for bad in (None, "", " ", "x", "inf"):
n = note_from_wire({"t": 0.0, "s": 0, "f": 0,
"fg": bad, "ch": bad, "sd": bad})
assert n.fret_finger == -1
assert n.strum_group == -1
assert n.scale_degree == -1
# ── Scale-degree derivation helpers (§6.2.2 / §7.7) ──────────────────────────
@pytest.mark.parametrize("key,pc", [
("C", 0), ("c", 0),
("E", 4), ("Em", 4), ("E minor", 4),
("G", 7), ("G major", 7), ("Gmaj", 7),
("A#m", 10), ("Bb", 10), # enharmonic — same pitch class
("F#", 6), ("F#m", 6),
("Cb", 11), ("B#", 0), # accidentals wrap mod 12
])
def test_key_to_tonic_pc_parses_key_names(key, pc):
assert key_to_tonic_pc(key) == pc
@pytest.mark.parametrize("bad", [None, "", " ", "H", "xyz", "7", 5])
def test_key_to_tonic_pc_rejects_unparseable(bad):
assert key_to_tonic_pc(bad) is None
def test_scale_degree_for_pitch_standard_tuning_key_of_e():
"""Tonic E (pc 4), standard tuning: low-E open -> tonic, A-string fret 2 -> fifth."""
tonic = key_to_tonic_pc("E")
assert tonic == 4
low_e_open = 40 # E2
a_string_fret2 = 45 + 2 # A2 + 2 = B2
assert scale_degree_for_pitch(low_e_open, tonic) == 0 # tonic
assert scale_degree_for_pitch(a_string_fret2, tonic) == 7 # perfect fifth
assert scale_degree_for_pitch(40 + 3, tonic) == 3 # G2 -> minor third
def test_note_pitch_midi_standard_tuning_offsets():
"""`arr.tuning` holds OFFSETS from standard (0 = standard), padded to 6 on
RS-XML; pitch = base + offset + capo + fret. Standard guitar: low-E open ->
40 (E2), A-string fret 2 -> 47 (B2)."""
arr = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0])
assert note_pitch_midi(arr, Note(time=0, string=0, fret=0)) == 40 # low E open
assert note_pitch_midi(arr, Note(time=0, string=1, fret=2)) == 47 # A + 2 = B
# Drop-D (low string offset -2): low-E string open sounds D2 = 38.
drop_d = Arrangement(name="Lead", tuning=[-2, 0, 0, 0, 0, 0])
assert note_pitch_midi(drop_d, Note(time=0, string=0, fret=0)) == 38
# Capo 2 raises every sounding pitch by 2 semitones.
capo2 = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0], capo=2)
assert note_pitch_midi(capo2, Note(time=0, string=0, fret=0)) == 42
def test_note_pitch_midi_bass_uses_bass_base():
"""A 4-string arrangement named 'Bass' uses the bass base (low E1 = 28),
not the guitar base (40)."""
bass = Arrangement(name="Bass", tuning=[0, 0, 0, 0])
assert note_pitch_midi(bass, Note(time=0, string=0, fret=0)) == 28
def test_note_pitch_midi_out_of_range_string_is_none():
arr = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0])
assert note_pitch_midi(arr, Note(time=0, string=9, fret=0)) is None
def test_base_open_string_midis_bass_vs_guitar():
assert base_open_string_midis(6, False)[0] == 40 # guitar low E
assert base_open_string_midis(4, True)[0] == 28 # bass low E
assert base_open_string_midis(4, False)[0] == 40 # 4-string guitar voicing
def test_note_bend_values_rounded_on_wire():
"""`bnv` rounds `t` to 3 and `v` to 1, matching the scalar `bn` precision."""
n = Note(