From 52d25eed9559169928e7c654f05a8f52075f455d Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sat, 20 Jun 2026 20:21:59 +0200 Subject: [PATCH] feat(gp): extract GP chord-diagram fingerings + GP8 chord names (E3) 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; 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) --- lib/gp2rs.py | 55 ++++++++++++++++++++--- lib/gp2rs_gpx.py | 97 ++++++++++++++++++++++++++++++++++++++++- tests/test_gp2rs.py | 81 ++++++++++++++++++++++++++++++++++ tests/test_gp2rs_gpx.py | 85 ++++++++++++++++++++++++++++++++++++ 4 files changed, 312 insertions(+), 6 deletions(-) diff --git a/lib/gp2rs.py b/lib/gp2rs.py index f9d9768f..dbfe1bff 100644 --- a/lib/gp2rs.py +++ b/lib/gp2rs.py @@ -460,6 +460,37 @@ def _gp_string_to_rs(gp_string: int, num_strings: int) -> int: 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: """Detect whether a GP track is a bass. @@ -828,17 +859,31 @@ def convert_track( fret_key = tuple(frets) 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) chord_templates.append(ChordTemplate( - name=chord_name, + name="", frets=list(frets), fingers=[-1] * width, )) 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( time=t, diff --git a/lib/gp2rs_gpx.py b/lib/gp2rs_gpx.py index d21f2aa3..9588b357 100644 --- a/lib/gp2rs_gpx.py +++ b/lib/gp2rs_gpx.py @@ -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) +# GPIF chord-diagram 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 ```` with + per-string ```` plus + ````. Diagram string + indices share the positional space of note ``String`` indices, so they go + through the same pitch-rank transform; ```` 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]: """Flatten a drumKit ``InstrumentSet``'s articulations into a list of GM ``OutputMidiNumber``s, positionally indexed to match a note's @@ -1277,6 +1364,10 @@ def convert_file( rs_chords: list[RsChord] = [] chord_templates: list[ChordTemplate] = [] 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] = [] sections: list[RsSection] = [] section_counts: dict[str, int] = {} @@ -1533,8 +1624,12 @@ def convert_file( fkey = tuple(frets_t) if fkey not in chord_template_map: chord_template_map[fkey] = len(chord_templates) + _diag = chord_diagram_map.get(fkey) 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( time=t, diff --git a/tests/test_gp2rs.py b/tests/test_gp2rs.py index 8724d646..5e7e62ce 100644 --- a/tests/test_gp2rs.py +++ b/tests/test_gp2rs.py @@ -1100,3 +1100,84 @@ def test_tie_not_extended_across_repeat_boundary(): assert sustain == pytest.approx(0.5, abs=0.01), ( 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" diff --git a/tests/test_gp2rs_gpx.py b/tests/test_gp2rs_gpx.py index 2bb81f88..a0038471 100644 --- a/tests/test_gp2rs_gpx.py +++ b/tests/test_gp2rs_gpx.py @@ -684,3 +684,88 @@ def test_note_vibrato_ignores_whammy_trembar_property(): '') tp = {p.get('name'): p for p in n.findall('.//Property')} 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 +# 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""" + + TA + + Lead Guitar + 40 45 50 55 59 64 + {diagram_block} + + + 0 + 0 + 0 + 0 1 + + + 0 + 3 + + 1 + 2 + + Quarter + +""" + + +_DIAGRAM_BLOCK = """ + + + + + + + + + + + + +""" + + +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"