mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
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:
co-authored by
Claude Opus 4.8
parent
1183f100ee
commit
c557742174
+50
-5
@@ -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,
|
||||
|
||||
+96
-1
@@ -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 <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]:
|
||||
"""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,
|
||||
|
||||
Reference in New Issue
Block a user