feat(gp): extract GP chord-diagram fingerings + GP8 chord names (E3) (#522)

GP imports previously landed chord templates with blank fingerings (GP5 +
GP8) and blank names (GP8), so the editor/highway had nothing to show even
though E0 preserves and E1 authors that data. E3 extracts the real chord
diagrams so imports arrive rich, keyed on the same fret-pattern join key the
editor (E0) and GP5 already use.

GP8 (lib/gp2rs_gpx.py): parse the per-track GPIF DiagramCollection
(Item @name + Diagram Fret/Fingering) into a fret-pattern -> {name, fingers}
map and enrich matching played voicings at the template build site. Diagram
string indices share the note String index space, so they go through the same
pitch-rank transform; <Fret fret> is treated as absolute.

GP5 (lib/gp2rs.py): pyguitarpro exposes the voicing on beat.effect.chord
(.strings indexed 0=highest string, .fingerings the parallel Fingering enum,
already RS finger ints). New _chord_fingers maps them to RS string order; the
template enrich now back-fills any still-blank template so the annotated chord
attaches even when an earlier unannotated strum of the same voicing created it.

Finger encoding (none/open=-1, thumb=0, index=1, middle=2, ring=3, pinky=4)
matches the editor E1 + RS serializer. Only enriches on exact fret-pattern
match; diagram-less charts import identically (blank). Verified end-to-end
against real files (GP8_Test.gp, joplin-janis-piece_of_my_heart.gp4).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-20 20:44:45 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 1183f100ee
commit c557742174
4 changed files with 312 additions and 6 deletions
+50 -5
View File
@@ -460,6 +460,37 @@ def _gp_string_to_rs(gp_string: int, num_strings: int) -> int:
return num_strings - gp_string return num_strings - gp_string
def _chord_fingers(chord, frets: list[int], num_strings: int) -> list[int]:
"""Per-string fingering for a chord template, in RS string order.
pyguitarpro exposes the chord-diagram voicing on ``beat.effect.chord``:
``chord.strings`` is a per-string fret list indexed 0 = highest string
(GP string 1), -1 = unplayed; ``chord.fingerings`` is the parallel list
of :class:`guitarpro.Fingering` enums (``open=-1, thumb=0, index=1,
middle=2, annular=3, little=4`` — already the RS finger integers). The
fingerings list may carry one trailing extra entry, so we only read the
first ``len(strings)`` of it.
Returns a list the same width as ``frets`` (RS string index 0 = low).
Only strings that are actually played in this template (``frets[rs] >= 0``)
get a finger; everything else stays -1. A chord without a populated
voicing yields all -1, so diagram-less charts are unchanged.
"""
fingers = [-1] * len(frets)
strings = getattr(chord, "strings", None) or []
fingerings = getattr(chord, "fingerings", None) or []
for i, fret in enumerate(strings):
if fret is None or fret < 0:
continue # string not part of the voicing
rs = _gp_string_to_rs(i + 1, num_strings)
if not (0 <= rs < len(frets)) or frets[rs] < 0:
continue
if i < len(fingerings):
val = getattr(fingerings[i], "value", fingerings[i])
fingers[rs] = val if isinstance(val, int) else -1
return fingers
def _is_bass_track(track: guitarpro.Track) -> bool: def _is_bass_track(track: guitarpro.Track) -> bool:
"""Detect whether a GP track is a bass. """Detect whether a GP track is a bass.
@@ -828,17 +859,31 @@ def convert_track(
fret_key = tuple(frets) fret_key = tuple(frets)
if fret_key not in chord_template_map: if fret_key not in chord_template_map:
# Try to get chord name from GP
chord_name = ""
if beat.effect and beat.effect.chord:
chord_name = beat.effect.chord.name or ""
idx = len(chord_templates) idx = len(chord_templates)
chord_templates.append(ChordTemplate( chord_templates.append(ChordTemplate(
name=chord_name, name="",
frets=list(frets), frets=list(frets),
fingers=[-1] * width, fingers=[-1] * width,
)) ))
chord_template_map[fret_key] = idx chord_template_map[fret_key] = idx
else:
idx = chord_template_map[fret_key]
# Enrich the template with the GP chord diagram (name +
# per-string fingering) attached to this beat. Back-fill
# any still-blank template so the data attaches regardless
# of whether the annotated beat is the one that first
# created the template (a plain earlier strum of the same
# voicing must not shadow it). First annotation wins.
if beat.effect and beat.effect.chord:
ct = chord_templates[idx]
if not ct.name and all(f < 0 for f in ct.fingers):
name = beat.effect.chord.name or ""
fingers = _chord_fingers(
beat.effect.chord, frets, num_strings)
if name or any(f >= 0 for f in fingers):
ct.name = name
ct.fingers = fingers
rs_chords.append(RsChord( rs_chords.append(RsChord(
time=t, time=t,
+96 -1
View File
@@ -445,6 +445,93 @@ def _gp6_element_variation_to_midi(element: int, variation: int) -> int | None:
return _ART_TO_MIDI.get(art_id, art_id) return _ART_TO_MIDI.get(art_id, art_id)
# GPIF chord-diagram <Position finger="..."> names → RS finger integers,
# matching the editor (E1) + gp2rs/pyguitarpro convention:
# open/unused = -1, thumb = 0, index = 1, middle = 2, ring = 3, pinky = 4.
_GPIF_FINGER_MAP = {
'none': -1, 'open': -1, '': -1,
'thumb': 0,
'index': 1,
'middle': 2,
'ring': 3, 'annular': 3,
'pinky': 4, 'little': 4,
}
def _rs_string_order(string_pitches: list[int]) -> dict[int, int]:
"""Map each GPIF string index → RS string index (0 = lowest pitch).
Mirrors the per-note transform in ``convert_file`` (sort GPIF string
indices by open pitch ascending, tiebreak on index, use the rank), so a
chord diagram's string indices land on the same RS strings as the played
notes regardless of format direction (GP6 .gpx high→low, GP8 .gp low→high).
"""
order = sorted(range(len(string_pitches)),
key=lambda i: (string_pitches[i], i))
return {gp: rs for rs, gp in enumerate(order)}
def _parse_chord_diagrams(track_el, string_pitches: list[int]) -> dict:
"""Map fret-pattern tuple → ``{'name', 'fingers'}`` from a track's diagrams.
GP7/GP8 GPIF stores authored chord diagrams per track under
``Properties/Property[@name="DiagramCollection"]/Items/Item``. Each Item
carries the chord name (its ``name`` attribute) and a ``<Diagram>`` with
per-string ``<Fret string=.. fret=..>`` plus
``<Fingering><Position finger=.. string=..></Fingering>``. Diagram string
indices share the positional space of note ``String`` indices, so they go
through the same pitch-rank transform; ``<Fret fret>`` is the absolute fret
(``baseFret`` is display-only and not applied).
Keying by fret pattern (width-normalised to ≥6, exactly like the template
build site) keeps the join key consistent with GP5 + the editor's
preserve-by-fret-key (E0). Returns ``{}`` when there are no diagrams or no
string tuning (orientation/width would be undefined).
"""
diagrams: dict[tuple, dict] = {}
if track_el is None or not string_pitches:
return diagrams
gp_to_rs = _rs_string_order(string_pitches)
for item in track_el.findall(
'.//Property[@name="DiagramCollection"]/Items/Item'):
diag = item.find('Diagram')
if diag is None:
continue
rs_frets: dict[int, int] = {}
for fr in diag.findall('Fret'):
try:
gp = int(fr.get('string'))
fret = int(fr.get('fret'))
except (TypeError, ValueError):
continue
if fret < 0:
continue
rs = gp_to_rs.get(gp)
if rs is not None:
rs_frets[rs] = fret
if not rs_frets:
continue
width = max(6, max(rs_frets) + 1)
frets = [-1] * width
fingers = [-1] * width
for rs, fret in rs_frets.items():
frets[rs] = fret
for pos in diag.findall('Fingering/Position'):
try:
gp = int(pos.get('string'))
except (TypeError, ValueError):
continue
rs = gp_to_rs.get(gp)
if rs is None or not (0 <= rs < width) or frets[rs] < 0:
continue
fname = (pos.get('finger') or '').strip().lower()
fingers[rs] = _GPIF_FINGER_MAP.get(fname, -1)
# First diagram wins for a given voicing (stable, deterministic).
diagrams.setdefault(tuple(frets),
{'name': item.get('name', '') or '', 'fingers': fingers})
return diagrams
def _gpx_percussion_midis(track_el) -> list[int]: def _gpx_percussion_midis(track_el) -> list[int]:
"""Flatten a drumKit ``InstrumentSet``'s articulations into a list of GM """Flatten a drumKit ``InstrumentSet``'s articulations into a list of GM
``OutputMidiNumber``s, positionally indexed to match a note's ``OutputMidiNumber``s, positionally indexed to match a note's
@@ -1277,6 +1364,10 @@ def convert_file(
rs_chords: list[RsChord] = [] rs_chords: list[RsChord] = []
chord_templates: list[ChordTemplate] = [] chord_templates: list[ChordTemplate] = []
chord_template_map: dict[tuple, int] = {} chord_template_map: dict[tuple, int] = {}
# Authored chord diagrams (name + per-string fingering) for this track,
# keyed by fret pattern so they enrich matching played voicings.
chord_diagram_map = _parse_chord_diagrams(
track.get('_el'), track['string_pitches'])
beats_out: list[RsBeat] = [] beats_out: list[RsBeat] = []
sections: list[RsSection] = [] sections: list[RsSection] = []
section_counts: dict[str, int] = {} section_counts: dict[str, int] = {}
@@ -1533,8 +1624,12 @@ def convert_file(
fkey = tuple(frets_t) fkey = tuple(frets_t)
if fkey not in chord_template_map: if fkey not in chord_template_map:
chord_template_map[fkey] = len(chord_templates) chord_template_map[fkey] = len(chord_templates)
_diag = chord_diagram_map.get(fkey)
chord_templates.append(ChordTemplate( chord_templates.append(ChordTemplate(
name='', frets=list(frets_t), fingers=[-1] * width, name=(_diag['name'] if _diag else ''),
frets=list(frets_t),
fingers=(list(_diag['fingers']) if _diag
else [-1] * width),
)) ))
rs_chords.append(RsChord( rs_chords.append(RsChord(
time=t, time=t,
+81
View File
@@ -1100,3 +1100,84 @@ def test_tie_not_extended_across_repeat_boundary():
assert sustain == pytest.approx(0.5, abs=0.01), ( assert sustain == pytest.approx(0.5, abs=0.01), (
f"sustain should be ~0.5 s (one quarter note), got {sustain:.3f}" f"sustain should be ~0.5 s (one quarter note), got {sustain:.3f}"
) )
# ── convert_track: GP5 chord-diagram fingering extraction (E3) ───────────────
# pyguitarpro exposes the chord-diagram voicing on beat.effect.chord:
# .strings is per-string frets indexed 0 = highest string, .fingerings is the
# parallel Fingering enum list (open=-1, thumb=0, index=1, middle=2, ring=3,
# pinky=4 — already the RS finger integers). A chord beat carrying this data
# must import with per-string fingers; a chord beat without it stays all -1.
def _ct_chord(name, strings, fingerings):
return SimpleNamespace(
name=name, strings=list(strings),
fingerings=list(fingerings), length=len(strings),
)
def test_chord_diagram_fingers_extracted():
# Two-note voicing on high e (fret 3) + B (fret 2). chord.strings is
# indexed 0 = highest string, so strings[0] = high e, strings[1] = B.
note_e = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3) # high e
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2) # B
beat = _ct_beat(tick=0, dur_value=4, notes=[note_e, note_b])
beat.effect.chord = _ct_chord(
"Gtest",
strings=[3, 2, -1, -1, -1, -1],
fingerings=[
guitarpro.Fingering.middle, # high e -> 2
guitarpro.Fingering.index, # B -> 1
],
)
xml_str = convert_track(_ct_song([beat]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
ct = root.find(".//chordTemplates/chordTemplate")
assert ct is not None
assert ct.get("chordName") == "Gtest"
# _gp_string_to_rs(1, 6) = 5 (high e), _gp_string_to_rs(2, 6) = 4 (B).
assert ct.get("fret5") == "3" and ct.get("finger5") == "2"
assert ct.get("fret4") == "2" and ct.get("finger4") == "1"
assert [ct.get(f"finger{i}") for i in range(0, 4)] == ["-1"] * 4
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.
note_e = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3)
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)
beat = _ct_beat(tick=0, dur_value=4, notes=[note_e, note_b]) # chord=None
xml_str = convert_track(_ct_song([beat]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
ct = root.find(".//chordTemplates/chordTemplate")
assert ct is not None
assert ct.get("chordName") == ""
assert [ct.get(f"finger{i}") for i in range(6)] == ["-1"] * 6
def test_chord_diagram_backfills_template_first_strummed_unannotated():
# The annotated chord must enrich its voicing even when an earlier,
# unannotated beat of the SAME fret pattern created the template first.
plain = _ct_beat(
tick=0, dur_value=4,
notes=[_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3),
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)],
) # chord=None, creates the blank template
annotated = _ct_beat(
tick=GP_TICKS_PER_QUARTER, dur_value=4,
notes=[_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3),
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)],
)
annotated.effect.chord = _ct_chord(
"Gtest", strings=[3, 2, -1, -1, -1, -1],
fingerings=[guitarpro.Fingering.middle, guitarpro.Fingering.index],
)
xml_str = convert_track(_ct_song([plain, annotated]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
cts = root.findall(".//chordTemplates/chordTemplate")
assert len(cts) == 1, "same voicing must dedup to one template"
assert cts[0].get("chordName") == "Gtest"
assert cts[0].get("finger5") == "2" and cts[0].get("finger4") == "1"
+85
View File
@@ -684,3 +684,88 @@ def test_note_vibrato_ignores_whammy_trembar_property():
'</Properties></Note>') '</Properties></Note>')
tp = {p.get('name'): p for p in n.findall('.//Property')} tp = {p.get('name'): p for p in n.findall('.//Property')}
assert _note_has_vibrato(n, tp) is False assert _note_has_vibrato(n, tp) is False
# ── 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
# <Diagram> with per-string fret + finger. A played voicing matching that
# fret pattern must import with the diagram's name + fingers; a chart without
# a DiagramCollection must import with blank name + all-(-1) fingers.
def _gpif_chord_diagram(diagram_block: str) -> str:
# A two-note chord (low E fret 3 + A fret 2) on a low->high tuned guitar.
return f"""
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
<Tracks>
<Track id="0"><Name>Lead Guitar</Name>
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property>
{diagram_block}
</Track>
</Tracks>
<MasterBars><MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar></MasterBars>
<Bars><Bar id="0"><Voices>0</Voices></Bar></Bars>
<Voices><Voice id="0"><Beats>0</Beats></Voice></Voices>
<Beats><Beat id="0"><Rhythm ref="r0"/><Notes>0 1</Notes></Beat></Beats>
<Notes>
<Note id="0">
<Property name="String"><String>0</String></Property>
<Property name="Fret"><Fret>3</Fret></Property></Note>
<Note id="1">
<Property name="String"><String>1</String></Property>
<Property name="Fret"><Fret>2</Fret></Property></Note>
</Notes>
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
</GPIF>
"""
_DIAGRAM_BLOCK = """
<Property name="DiagramCollection"><Items>
<Item id="1" name="G5">
<Diagram stringCount="6" fretCount="5" baseFret="0">
<Fret string="0" fret="3"/>
<Fret string="1" fret="2"/>
<Fingering>
<Position finger="Middle" fret="3" string="0"/>
<Position finger="Index" fret="2" string="1"/>
</Fingering>
</Diagram>
</Item>
</Items></Property>
"""
def _convert_first_chord_template(monkeypatch, tmp_path, gpif):
monkeypatch.setattr(gp2rs_gpx, "_load_gpif", lambda _p: ET.fromstring(gpif))
out_files = convert_file(
"dummy.gp", str(tmp_path),
track_indices=[0], arrangement_names={0: "Lead"},
)
root = ET.parse(out_files[0]).getroot()
cts = root.findall(".//chordTemplates/chordTemplate")
assert len(cts) == 1
return cts[0]
def test_convert_file_gp8_chord_diagram_enriches_template(tmp_path, monkeypatch):
ct = _convert_first_chord_template(
monkeypatch, tmp_path, _gpif_chord_diagram(_DIAGRAM_BLOCK))
# Diagram name + per-string fingering land on the matching voicing.
assert ct.get("chordName") == "G5"
# RS string 0 = low E (fret 3, Middle=2), string 1 = A (fret 2, Index=1).
assert ct.get("fret0") == "3" and ct.get("finger0") == "2"
assert ct.get("fret1") == "2" and ct.get("finger1") == "1"
# Unplayed strings stay -1 for both fret and finger.
assert [ct.get(f"finger{i}") for i in range(2, 6)] == ["-1"] * 4
def test_convert_file_gp8_no_diagram_leaves_template_blank(tmp_path, monkeypatch):
# Same chart, no DiagramCollection -> identical import to before E3.
ct = _convert_first_chord_template(
monkeypatch, tmp_path, _gpif_chord_diagram(""))
assert ct.get("chordName") == ""
assert [ct.get(f"finger{i}") for i in range(6)] == ["-1"] * 6
# Fret pattern itself is unchanged (the join key still works).
assert ct.get("fret0") == "3" and ct.get("fret1") == "2"