"""Convert Guitar Pro files (.gp5/.gp4/.gp3) to arrangement XML.""" import json import logging import re import xml.etree.ElementTree as ET from xml.dom import minidom from dataclasses import dataclass, field from pathlib import Path import guitarpro log = logging.getLogger("feedBack.lib.gp2rs") _YEAR_RE = re.compile(r"\b(1[89]\d{2}|20\d{2})\b") def _extract_year(song: guitarpro.Song) -> str: """Pull a 4-digit year out of GP metadata. GP files have no dedicated year field; the year usually appears inside the copyright string (e.g. "1998 Goat Head Music, WB Music Corp, USA"). the converter requires to parse as Int32, so we extract just the digits and fall back to empty (which the converter treats as no year) when nothing matches. """ for field_val in (getattr(song, "copyright", None), getattr(song, "subtitle", None)): if not field_val: continue m = _YEAR_RE.search(str(field_val)) if m: return m.group(1) return "" # Standard tuning MIDI values, GP string order (1 = highest, high → low). # Guitar: E4 B3 G3 D3 A2 E2 [B1 F#1] — extends to 7/8 strings by adding low B/F#. # Bass: C3 G2 D2 A1 E1 B0 — 6-string bass high→low. A 4-string bass is the # middle four (G2 D2 A1 E1); a 5-string adds either the high C (full # table including index 0) or the low B (slice ending at index 5). # We keep the tables at the maximum we support; _standard_tuning_for slices # to the actual string count. STANDARD_TUNING_GUITAR = [64, 59, 55, 50, 45, 40, 35, 30] STANDARD_TUNING_BASS = [48, 43, 38, 33, 28, 23] GP_TICKS_PER_QUARTER = 960 @dataclass class TempoEvent: tick: int tempo: float # BPM @dataclass class RsNote: time: float string: int fret: int sustain: float = 0.0 bend: float = 0.0 bend_intent: int = 0 bend_values: list | None = None slide_to: int = -1 slide_unpitch_to: int = -1 hammer_on: bool = False pull_off: bool = False harmonic: bool = False harmonic_pinch: bool = False palm_mute: bool = False mute: bool = False vibrato: bool = False accent: bool = False 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 class RsChord: time: float template_idx: int notes: list[RsNote] = field(default_factory=list) @dataclass class RsAnchor: time: float fret: int width: int = 4 @dataclass class RsBeat: time: float measure: int # -1 for non-downbeats @dataclass class RsSection: name: str time: float number: int = 1 @dataclass class ChordTemplate: name: str frets: list[int] # per string, -1 = unused fingers: list[int] # per string, -1 = unused @dataclass class PlaybackEntry: """One scheduled play of one source measure. The converter walks the GP playback graph (repeat brackets, voltas, D.S./D.C./Coda/Fine) and emits a ``PlaybackEntry`` for every source measure in its played order. ``mh_index`` indexes back into ``song.measureHeaders`` and ``track.measures``; ``pass_index`` is 0 for the first time through, 1 for the second, etc. inside a repeat block (0 elsewhere). The two ``*_secs`` fields together let consumers shift each authored event into post-expansion (output) time: output_time = (authored_secs - mh_authored_start_secs) + output_start_secs + audio_offset ``duration_secs`` is the same value the schedule used when stepping ``output_start_secs`` forward, so consumers (e.g. ``song_length`` derivation) can compute ``output_end = output_start_secs + duration_secs`` without redoing the time-signature math the schedule deliberately avoids for irregular / pickup measures. """ mh_index: int pass_index: int output_start_secs: float mh_authored_start_secs: float duration_secs: float def _build_tempo_map(song: guitarpro.Song) -> list[TempoEvent]: """Build a list of (tick, tempo) events from the song.""" events = [TempoEvent(tick=0, tempo=float(song.tempo))] for track in song.tracks: for measure in track.measures: for voice in measure.voices: for beat in voice.beats: if beat.effect and beat.effect.mixTableChange: mtc = beat.effect.mixTableChange if mtc.tempo and mtc.tempo.value > 0: events.append(TempoEvent( tick=beat.start, tempo=float(mtc.tempo.value) )) events.sort(key=lambda e: e.tick) # Deduplicate by tick seen = set() unique = [] for e in events: if e.tick not in seen: seen.add(e.tick) unique.append(e) return unique def _tick_to_seconds(tick: int, tempo_map: list[TempoEvent]) -> float: """Convert a GP tick position to seconds using the tempo map.""" seconds = 0.0 prev_tick = 0 prev_tempo = tempo_map[0].tempo for event in tempo_map: if event.tick >= tick: break # Accumulate time from prev_tick to event.tick at prev_tempo dt = (event.tick - prev_tick) / GP_TICKS_PER_QUARTER * (60.0 / prev_tempo) seconds += dt prev_tick = event.tick prev_tempo = event.tempo # Remaining ticks from last tempo event to target tick dt = (tick - prev_tick) / GP_TICKS_PER_QUARTER * (60.0 / prev_tempo) seconds += dt return seconds def _duration_to_seconds(duration: guitarpro.Duration, tempo: float) -> float: """Convert a GP Duration to seconds at a given tempo.""" # duration.value: 1=whole, 2=half, 4=quarter, 8=eighth, etc. beats = 4.0 / duration.value if duration.isDotted: beats *= 1.5 if duration.tuplet.enters > 0 and duration.tuplet.times > 0: beats *= duration.tuplet.times / duration.tuplet.enters return beats * (60.0 / tempo) # pyguitarpro models bend-point x-positions on 0..BendEffect.maxPosition (12) # across the note's duration; y-values are half-quarter-tone units where 12 = 6 # semitones, so semitones = value / 2.0 (matches the scalar `bend` derivation). _GP_BEND_MAX_POSITION = 12 def _bend_intent_from_values(values: list[float]) -> int: """Classify a bend gesture (§6.2.1) from its time-ordered semitone values: 0 up, 1 release, 2 pre-bend, 3 pre-bend-and-release, 4 round-trip.""" if not values: return 0 eps = 0.05 first, last, peak = values[0], values[-1], max(values) if first > eps: if last <= eps: return 3 # pre-bent, then released to pitch if last < first - eps: return 1 # held bend let down return 2 # pre-bend held if peak > eps and last <= eps: return 4 # bend up and back down return 0 # plain bend up def _gp_bend_shape(bend, duration_secs: float): """From a pyguitarpro ``BendEffect``, return ``(peak, intent, curve)``. ``peak`` is the bend's peak in semitones (the scalar ``bn``); ``intent`` is the §6.2.1 ``bt`` code; ``curve`` is the time-stamped ``bnv`` list (``[{t: seconds-from-onset, v: semitones}]``) or ``None`` when there's no usable shape (no points, or a zero-length note collapsing every point to ``t=0``).""" pts = sorted(bend.points or [], key=lambda p: p.position) if not pts: return 0.0, 0, None values = [round(p.value / 2.0, 1) for p in pts] peak = round(max(values), 1) intent = _bend_intent_from_values(values) curve = None if duration_secs > 0 and len(pts) >= 2: curve = [ {"t": round(duration_secs * (p.position / _GP_BEND_MAX_POSITION), 3), "v": v} for p, v in zip(pts, values) ] return peak, intent, curve def _bend_shape_xml_attrs(n: "RsNote") -> dict: """Optional bend-shape XML attributes for a /, default- omitted: `bendIntent` only when non-zero, `bendValues` (a JSON-encoded [{t,v}] curve) only when present. `_parse_note` (lib/song.py) reads these back so a GP-imported bend curve survives import → wire → highway.""" attrs: dict = {} if n.bend_intent: attrs["bendIntent"] = str(int(n.bend_intent)) if n.bend_values: attrs["bendValues"] = json.dumps(n.bend_values, separators=(",", ":")) return attrs def _finger_xml_attrs(n: "RsNote") -> dict: """Optional teaching-mark XML attribute for a /: `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 for event in tempo_map: if event.tick > tick: break result = event.tempo return result def _measure_duration_secs( mh: guitarpro.MeasureHeader, tempo_map: list[TempoEvent] ) -> float: """Duration of one measure in seconds at its authored tempo curve. Uses the same source-tick tempo map as :func:`_tick_to_seconds` so a tempo change *inside* a measure is integrated correctly. """ ts = mh.timeSignature # numerator beats, each beat is (4 / denominator.value) quarter notes. quarter_notes = ts.numerator * (4.0 / ts.denominator.value) end_tick = mh.start + int(round(quarter_notes * GP_TICKS_PER_QUARTER)) return _tick_to_seconds(end_tick, tempo_map) - _tick_to_seconds(mh.start, tempo_map) def _measure_beat_tick(mh: guitarpro.MeasureHeader, beat_index: int) -> int: """Return the authored tick for a time-signature beat subdivision.""" ts = mh.timeSignature quarter_notes = beat_index * (4.0 / ts.denominator.value) return mh.start + int(round(quarter_notes * GP_TICKS_PER_QUARTER)) # Names that may appear in MeasureHeader.fromDirection (jump *sources*). _DA_CAPO_NAMES = frozenset({ "Da Capo", "Da Capo al Coda", "Da Capo al Double Coda", "Da Capo al Fine", }) _DA_SEGNO_SEGNO_NAMES = frozenset({ "Da Segno Segno", "Da Segno Segno al Coda", "Da Segno Segno al Double Coda", "Da Segno Segno al Fine", }) _DA_SEGNO_NAMES = frozenset({ "Da Segno", "Da Segno al Coda", "Da Segno al Double Coda", "Da Segno al Fine", }) _DA_CODA_NAMES = frozenset({"Da Coda", "Da Double Coda"}) _JUMP_BACK_NAMES = _DA_CAPO_NAMES | _DA_SEGNO_NAMES | _DA_SEGNO_SEGNO_NAMES def _build_playback_schedule( song: guitarpro.Song, tempo_map: list[TempoEvent], expand_repeats: bool = True, ) -> list[PlaybackEntry]: """Walk the GP playback graph and emit one :class:`PlaybackEntry` per played measure. Honors three nested kinds of non-linear playback when ``expand_repeats`` is true: 1. **D.S. / D.C. / Coda / Fine** (``MeasureHeader.fromDirection`` and ``.direction``) — outermost; can teleport the cursor to a Segno, Coda, or song start, and arm an "al Fine" / "al Coda" stop or redirect that fires on the next pass. Checked after every emitted measure, including measures inside a repeat bracket. 2. **Repeat brackets** (``isRepeatOpen`` / ``repeatClose`` with the ``repeatAlternative`` volta bitmask) — when an open is encountered the walker remembers (open, close, passes) and, after each emitted measure inside the block, decides whether to loop back to the open for the next pass. 3. **Linear walk** — fall-through; emit one entry per measure. Conventionally, repeat brackets in a section that has been *jumped back to* via D.S./D.C. play **once** ("second time, don't repeat"). We implement that by gating layer 2 on ``not jumped_back``. Malformed inputs (orphan opens, unresolved D.S. targets, etc.) emit a warning and fall through to a linear walk for the affected section. Setting ``expand_repeats=False`` produces a one-entry-per-measure schedule with monotonically accumulating ``output_start_secs`` — i.e. the same chart the converter produced before this feature existed. """ headers = list(song.measureHeaders) if not headers: return [] # Pre-compute authored start for every header. Prefer the next # header's start as this header's end: a final 3/4 measure in a 4/4 # piece, an anacrusis at the front, or any partial / mid-song # time-signature change all carry their *real* duration in the tick # delta between adjacent headers. Time-signature arithmetic is only # used as a fall-back for the very last measure (which has no # successor to diff against). authored_starts = [_tick_to_seconds(mh.start, tempo_map) for mh in headers] durations: list[float] = [] for idx, mh in enumerate(headers): if idx + 1 < len(headers): durations.append(authored_starts[idx + 1] - authored_starts[idx]) else: durations.append(_measure_duration_secs(mh, tempo_map)) # Pre-scan direction targets (Segno / Coda / Fine markers). First # occurrence wins; later duplicates only get a warning. targets: dict[str, int] = {} for i, mh in enumerate(headers): if mh.direction: nm = mh.direction.name if nm in targets: log.warning( "gp2rs: duplicate direction target %r at measure %d " "(first occurrence at %d will be used)", nm, i, targets[nm], ) else: targets[nm] = i schedule: list[PlaybackEntry] = [] output_t = 0.0 def emit(idx: int, pass_index: int) -> None: nonlocal output_t schedule.append(PlaybackEntry( mh_index=idx, pass_index=pass_index, output_start_secs=output_t, mh_authored_start_secs=authored_starts[idx], duration_secs=durations[idx], )) output_t += durations[idx] if not expand_repeats: for i in range(len(headers)): emit(i, 0) return schedule def find_repeat_close(start: int) -> int | None: """Index of the first measure ≥ start whose ``repeatClose`` is set. pyguitarpro encodes ``repeatClose == -1`` for "no close marker" and ``repeatClose == N`` (with ``N >= 0``) for a closing marker that replays the bracket ``N`` additional times. We accept any non- negative value as a close so a file authored with ``repeatClose == 0`` (a "decorative" close that doesn't actually loop) doesn't get mis-treated as an orphan open. """ for j in range(start, len(headers)): if headers[j].repeatClose >= 0: return j return None # Walker state. Repeats and directions interleave: a D.C./D.S. inside # a repeat block must fire as soon as its measure is emitted, so we # process repeats as a single-loop "loop back at end of pass" rather # than a nested sub-loop. This makes every emitted measure flow # through the same direction/jump checks below. jumped_back = False # True after the first D.S./D.C./D.S.S. fires stop_at: str | None = None # None | "fine" | "coda" | "double_coda" in_repeat = False repeat_open = -1 repeat_close = -1 total_passes = 1 pass_idx = 0 def end_of_pass(curr_i: int) -> tuple[int, bool]: """Compute the next index after the current pass through the repeat block ends. Returns ``(next_i, still_in_repeat)``.""" nonlocal pass_idx, in_repeat # Did we just emit / skip past the close measure? if not in_repeat or curr_i <= repeat_close: return curr_i, in_repeat pass_idx += 1 if pass_idx >= total_passes: in_repeat = False return repeat_close + 1, False return repeat_open, True i = 0 while i < len(headers): mh = headers[i] # --- (1) Detect entry into a new repeat block --- if not in_repeat and mh.isRepeatOpen and not jumped_back: j = find_repeat_close(i) if j is None: log.warning( "gp2rs: repeat-open at measure %d has no matching close; " "playing the rest of the song linearly", i, ) else: in_repeat = True repeat_open = i repeat_close = j total_passes = max(1, headers[j].repeatClose + 1) pass_idx = 0 # --- (2) Volta skip --- if in_repeat: ra = mh.repeatAlternative if ra and not (ra & (1 << pass_idx)): i += 1 i, _ = end_of_pass(i) continue # --- (3) "al Coda" / "al Double Coda" redirect (before emit) --- if stop_at in ("coda", "double_coda") and mh.fromDirection \ and mh.fromDirection.name in _DA_CODA_NAMES: want = "Double Coda" if mh.fromDirection.name == "Da Double Coda" \ else "Coda" target = targets.get(want) if target is None: log.warning( "gp2rs: %s redirect at measure %d but no %s target found", mh.fromDirection.name, i, want, ) stop_at = None else: stop_at = None in_repeat = False i = target continue # --- (4) Emit the measure --- emit(i, pass_idx if in_repeat else 0) # --- (5) Fine stop --- if stop_at == "fine" and mh.direction and mh.direction.name == "Fine": return schedule # --- (6) D.S./D.C./D.S.S. jump (fires once, ever) --- if not jumped_back and mh.fromDirection \ and mh.fromDirection.name in _JUMP_BACK_NAMES: name = mh.fromDirection.name if name in _DA_CAPO_NAMES: target_idx: int | None = 0 elif name in _DA_SEGNO_SEGNO_NAMES: target_idx = targets.get("Segno Segno") else: target_idx = targets.get("Segno") if target_idx is None: log.warning( "gp2rs: %s at measure %d has no matching target; " "continuing linearly", name, i, ) else: if "al Fine" in name: stop_at = "fine" elif "al Double Coda" in name: stop_at = "double_coda" elif "al Coda" in name: stop_at = "coda" jumped_back = True in_repeat = False # leave any current repeat block i = target_idx continue # --- (7) Advance, looping back on repeat-pass end --- i += 1 i, _ = end_of_pass(i) return schedule def _gp_string_to_rs(gp_string: int, num_strings: int) -> int: """Convert GP string number (1=high) to RS string index (0=low).""" 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. 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 _chord_diagram_frets(chord, num_strings: int, width: int) -> list[int]: """RS-string-ordered absolute frets of the chord DIAGRAM voicing, padded to ``width`` with -1. Used to confirm the diagram describes the voicing actually played before enriching a template — mirrors the GP8 exact fret-pattern guard. pyguitarpro stores absolute frets in ``chord.strings`` (``firstFret`` is display-only), so the result compares directly against the played ``frets``.""" out = [-1] * width strings = getattr(chord, "strings", None) or [] for i, fret in enumerate(strings): if fret is None or fret < 0: continue rs = _gp_string_to_rs(i + 1, num_strings) if 0 <= rs < width: out[rs] = fret return out def _is_bass_track(track: guitarpro.Track) -> bool: """Detect whether a GP track is a bass. Trusts a GM MIDI program in the Bass family (32-39) when present, but does not trust an explicit *non-bass* program: GP files frequently ship bass tracks with mis-set channels (acoustic-guitar 24, piano 0, etc.), so we always fall back to the highest string's pitch when the program isn't in the bass range. Bass tops out around C3 (MIDI 48 on a 6-string bass); guitar's highest string is E4 (MIDI 64) or D4 (MIDI 62) even for detuned 7/8-string charts, so a `max ≤ 48` cut cleanly separates them. """ if hasattr(track, "channel") and track.channel: instrument = getattr(track.channel, "instrument", -1) if 32 <= instrument <= 39: return True if not track.strings: return False return max(s.value for s in track.strings) <= 48 def _standard_tuning_for(num: int, is_bass: bool, top_midi: int | None = None) -> list[int]: """Return a high→low standard tuning of length `num` for the given role. 5-string bass has two common standards: high-C (`C G D A E`, top MIDI 48) and low-B (`G D A E B`, top MIDI 43). When `top_midi` is provided and `num == 5`, pick whichever standard the actual top string is closer to — the midpoint between MIDI 43 and 48 is 45.5, so `top_midi >= 46` selects the high-C variant. With no hint we default to the more common low-B layout. """ if is_bass: if num == 5 and top_midi is not None and top_midi >= 46: # High-C 5-string: drop the lowest string from the 6-string table. return STANDARD_TUNING_BASS[:5] if num <= 5: # 4-string and low-B 5-string: skip the high C from the 6-string table. return STANDARD_TUNING_BASS[1:1 + num] if num <= len(STANDARD_TUNING_BASS): return STANDARD_TUNING_BASS[:num] # >6-string bass is pathological but theoretically possible — # pad with descending fourths so the returned list always has # `num` entries (otherwise `_compute_tuning` silently leaves # offsets at 0 for the missing slots). extra = [STANDARD_TUNING_BASS[-1] - 5 * (i + 1) for i in range(num - len(STANDARD_TUNING_BASS))] return STANDARD_TUNING_BASS + extra # Guitar extends downward (low B/F#) — slice from the top. if num <= len(STANDARD_TUNING_GUITAR): return STANDARD_TUNING_GUITAR[:num] # Pathological GP files with >8 strings: pad by continuing in fourths. extra = [STANDARD_TUNING_GUITAR[-1] - 5 * (i + 1) for i in range(num - len(STANDARD_TUNING_GUITAR))] return STANDARD_TUNING_GUITAR + extra def _compute_tuning(track: guitarpro.Track) -> list[int]: """Compute RS tuning offsets (semitones from standard) from GP string MIDI values.""" num = len(track.strings) # Top GP string is `number == 1`; fall back to None when strings is # empty so `_standard_tuning_for` uses the default low-B 5-string layout. top_midi = next((s.value for s in track.strings if s.number == 1), None) standard = _standard_tuning_for(num, _is_bass_track(track), top_midi=top_midi) # GP strings are ordered high to low (string 1 = highest). # RS tuning is ordered low to high (index 0 = lowest). offsets = [0] * num for gp_str in track.strings: idx = gp_str.number - 1 if idx < 0 or idx >= len(standard): continue # defensive; shouldn't happen now that standard tracks num rs_idx = _gp_string_to_rs(gp_str.number, num) offsets[rs_idx] = gp_str.value - standard[idx] return offsets def convert_track( song: guitarpro.Song, track_index: int, audio_offset: float = 0.0, arrangement_name: str = "", force_standard_tuning: bool = False, *, expand_repeats: bool = True, ) -> str: """Convert a GP track to arrangement XML string. Args: song: Parsed Guitar Pro song track_index: Which track to convert (0-based) audio_offset: Seconds to add to all times (for sync with audio) arrangement_name: "Lead", "Rhythm", "Bass", etc. force_standard_tuning: If True, set tuning to E standard (frets unchanged) expand_repeats: When true (default), replay repeated bars and follow D.S./D.C./Coda/Fine jumps so the chart matches an audio file that plays the song as performed. When false, every measure is emitted once in authored order — equivalent to the pre-expansion behavior. Returns: XML string of the chart arrangement """ track = song.tracks[track_index] num_strings = len(track.strings) is_bass = _is_bass_track(track) tempo_map = _build_tempo_map(song) schedule = _build_playback_schedule(song, tempo_map, expand_repeats) headers = song.measureHeaders if force_standard_tuning: tuning = [0] * num_strings else: tuning = _compute_tuning(track) if not arrangement_name: name = track.name.strip() low = name.lower() if is_bass or "bass" in low: arrangement_name = "Bass" elif "rhythm" in low or "rhy" in low: arrangement_name = "Rhythm" else: arrangement_name = "Lead" # ── Collect beats (ebeats) ──────────────────────────────────────────── # Iterate the schedule rather than song.measureHeaders directly so each # replayed pass of a repeat block emits its own downbeats / subdivisions # at the correct *output* time. beats = [] for entry in schedule: mh = headers[entry.mh_index] beats.append(RsBeat( time=entry.output_start_secs + audio_offset, measure=mh.number, )) # Subdivisions within the measure — same authored offsets, shifted # into output time by the entry's output_start. num_beats_in_measure = mh.timeSignature.numerator for b in range(1, num_beats_in_measure): sub_tick = _measure_beat_tick(mh, b) sub_offset_in_measure = _tick_to_seconds(sub_tick, tempo_map) \ - entry.mh_authored_start_secs beats.append(RsBeat( time=entry.output_start_secs + sub_offset_in_measure + audio_offset, measure=-1, )) beats.sort(key=lambda b: b.time) # ── Collect sections from markers ───────────────────────────────────── # One marker per scheduled appearance: a "verse" marker inside a ×2 # repeat block emits "verse #1" on pass 0 and "verse #2" on pass 1. sections = [] section_counts: dict[str, int] = {} for entry in schedule: mh = headers[entry.mh_index] if mh.marker and mh.marker.title: name = mh.marker.title.strip().lower().replace(" ", "") section_counts[name] = section_counts.get(name, 0) + 1 sections.append(RsSection( name=name, time=entry.output_start_secs + audio_offset, number=section_counts[name], )) if not sections: # Default: one section for the whole song sections.append(RsSection(name="default", time=audio_offset, number=1)) # ── Collect notes and chords ────────────────────────────────────────── rs_notes = [] rs_chords = [] chord_templates: list[ChordTemplate] = [] chord_template_map: dict[tuple, int] = {} # fret tuple → index last_note_per_string: dict[int, RsNote] = {} # for tie sustain extension pending_slides: list = [] # (RsNote, rs_string, kind) — resolved post-loop _prev_mh_index: int = -1 # sentinel: no previous entry for entry in schedule: # Clear the tie-tracking state on backward jumps in the playback # schedule (repeat loopbacks, D.S., D.C.). A tie at the start of # a repeated section must not extend the last note from the previous # pass through that section. Forward skips (volta alternatives, # al-Coda redirects) are *not* cleared because consecutive schedule # entries that jump forward are still adjacent in the output audio, # so a tie crossing such a boundary is semantically valid. if _prev_mh_index != -1 and entry.mh_index <= _prev_mh_index: last_note_per_string.clear() _prev_mh_index = entry.mh_index measure = track.measures[entry.mh_index] for voice in measure.voices: for beat in voice.beats: if not beat.notes: continue authored_beat_secs = _tick_to_seconds(beat.start, tempo_map) t = (authored_beat_secs - entry.mh_authored_start_secs) \ + entry.output_start_secs + audio_offset tempo = _tempo_at_tick(beat.start, tempo_map) dur = _duration_to_seconds(beat.duration, tempo) beat_notes = [] for note in beat.notes: if note.type == guitarpro.NoteType.rest: continue rs_str = _gp_string_to_rs(note.string, num_strings) if note.type == guitarpro.NoteType.tie: prev = last_note_per_string.get(rs_str) if prev is not None and prev.time < t: prev.sustain = max(prev.sustain, (t + dur) - prev.time) continue fret = note.value if note.type == guitarpro.NoteType.dead: fret = max(fret, 0) rn = RsNote( time=t, string=rs_str, fret=fret, sustain=dur if dur > 0.2 else 0.0, mute=note.type == guitarpro.NoteType.dead, ) # Techniques eff = note.effect if eff.bend and eff.bend.points: # `bn` is the peak; `bnv`/`bt` describe the shape over # time (§6.2.1). semitones = value / 2 (maxValue 12 = 6 # semitones); the old /100.0 made every bend round to 0. peak, intent, curve = _gp_bend_shape(eff.bend, dur) rn.bend = peak rn.bend_intent = intent rn.bend_values = curve if eff.hammer: # HO vs PO from pitch direction off the prior note on the # string (descending = pull-off). last_note_per_string is # still the previous note here (updated after append). _prevn = last_note_per_string.get(rs_str) if _prevn is not None and _prevn.fret > fret: rn.pull_off = True else: rn.hammer_on = True if eff.slides: for slide in eff.slides: if slide in ( guitarpro.SlideType.shiftSlideTo, guitarpro.SlideType.legatoSlideTo, ): # Pitched slide to the next note on this string; # slide_to (the target fret) is filled in by the # post-pass below once all notes are known. rn.link_next = True pending_slides.append((rn, rs_str, "pitched")) elif slide == guitarpro.SlideType.outDownwards: pending_slides.append((rn, rs_str, "down")) elif slide == guitarpro.SlideType.outUpwards: pending_slides.append((rn, rs_str, "up")) if getattr(eff, "letRing", False): rn.link_next = True if eff.harmonic: if isinstance(eff.harmonic, guitarpro.PinchHarmonic): rn.harmonic_pinch = True else: rn.harmonic = True if eff.palmMute: rn.palm_mute = True if eff.accentuatedNote or eff.heavyAccentuatedNote: rn.accent = True if eff.ghostNote: rn.mute = True if getattr(eff, "vibrato", False): rn.vibrato = True 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 # the peak amount (value->semitones, same /2 scale as bends, # capped at ±12). Don't clobber a real slide on the note. # Skip when the note already has a slide: a real slide's # slide_to is still -1 here (filled by the post-pass below), # so guarding only on rn.slide_to would let a note carry both # a whammy slide_unpitch_to AND a slide_to (conflicting). _tb = getattr(beat.effect, "tremoloBar", None) if (_tb and _tb.points and not eff.slides and rn.slide_to < 0 and rn.slide_unpitch_to < 0): _peak = max(_tb.points, key=lambda p: abs(p.value)).value if _peak: _semis = max(-12, min(12, round(_peak / 2.0))) if _semis: rn.slide_unpitch_to = max(0, rn.fret + _semis) # Grace note (lead-in / flam). GP stores it as an effect on # the MAIN note, so the beat loop never emits it. Emit it as # a separate short note just before the main one, carrying # its transition into the main note (slide / hammer-pull / # bend). Same string as the main note. _gr = getattr(eff, "grace", None) if _gr is not None: _glead = (4.0 / max(getattr(_gr, "duration", 32) or 32, 1)) \ * (60.0 / tempo) if dur > 0: _glead = min(_glead, dur * 0.5) _gnote = RsNote( time=max(0.0, t - _glead), string=rs_str, fret=max(0, getattr(_gr, "fret", 0)), sustain=0.0, mute=bool(getattr(_gr, "isDead", False)), ) _gt = _gr.transition if _gt == guitarpro.GraceEffectTransition.slide: _gnote.slide_to = rn.fret # NB: do NOT set link_next here. link_next tells the # highway to suppress the target note's gem (so a # normal slide visually connects), but a grace note # is an ornament before the principal note — the main # note IS re-struck and must keep its gem. Setting it # hid the main note and made the grace look unrendered. # The grace note is zero-sustain, but the highway only # draws a slide trail when sus > 0 — so the slide would # be invisible. Sustain it across the lead-in gap # (grace at t-_glead, main at t) so the slide renders # from the grace into the still-visible main note. if _glead > 0: _gnote.sustain = _glead elif _gt == guitarpro.GraceEffectTransition.bend: _gnote.bend = 1.0 elif _gt == guitarpro.GraceEffectTransition.hammer: # hammer-on / pull-off from the grace into the main if rn.fret >= _gnote.fret: rn.hammer_on = True else: rn.pull_off = True rs_notes.append(_gnote) beat_notes.append(rn) existing = last_note_per_string.get(rs_str) if existing is None or rn.time >= existing.time: last_note_per_string[rs_str] = rn if not beat_notes: continue if len(beat_notes) == 1: rs_notes.append(beat_notes[0]) else: # Chord: create/reuse a chord template. Size the # voicing to the highest string actually used (with # a floor of 6 to keep RS schema slots populated) # — on a 7/8-string track, a plain 6-string voicing # should not inflate every chord template to length # 7/8 with trailing `-1`s that round-trip back as # spurious wide templates. used = max((n.string for n in beat_notes if 0 <= n.string < num_strings), default=-1) width = max(6, used + 1) frets = [-1] * width for n in beat_notes: if 0 <= n.string < width: frets[n.string] = n.fret fret_key = tuple(frets) if fret_key not in chord_template_map: idx = len(chord_templates) chord_templates.append(ChordTemplate( name="", frets=list(frets), fingers=[-1] * width, )) chord_template_map[fret_key] = idx else: idx = chord_template_map[fret_key] # Enrich the template from the GP chord diagram attached to # this beat — but ONLY when the diagram describes the voicing # actually played (same width-normalized fret pattern). A # mismatched chord label/diagram would otherwise mis-name / # finger the played template, and the back-fill would spread # it to other strums of the same played pattern. Mirrors the # GP8 exact fret-pattern guard. # # Name and fingers back-fill INDEPENDENTLY: a name-only first # annotation must not block a later beat that carries fingers # (and vice versa). Back-fill any still-blank field so the # data attaches regardless of which strum carries it. if beat.effect and beat.effect.chord: gpc = beat.effect.chord # Compare over the FULL string span (played width vs the # track's string count) so a diagram that frets an # extended string the played voicing doesn't use counts # as a mismatch instead of being silently trimmed. _w = max(len(frets), num_strings) _played = frets + [-1] * (_w - len(frets)) if _chord_diagram_frets(gpc, num_strings, _w) == _played: ct = chord_templates[idx] if not ct.name and gpc.name: ct.name = gpc.name if all(f < 0 for f in ct.fingers): fingers = _chord_fingers(gpc, frets, num_strings) if any(f >= 0 for f in fingers): ct.fingers = fingers rs_chords.append(RsChord( time=t, template_idx=chord_template_map[fret_key], notes=beat_notes, )) rs_notes.sort(key=lambda n: n.time) rs_chords.sort(key=lambda c: c.time) # Resolve slide targets now that every note on each string is known. A # pitched slide takes its target fret from the NEXT note on the same # string; out-slides are unpitched (approximate ±5 frets). if pending_slides: _by_string: dict = {} for _n in rs_notes: _by_string.setdefault(_n.string, []).append(_n) for _c in rs_chords: for _n in _c.notes: _by_string.setdefault(_n.string, []).append(_n) for _lst in _by_string.values(): _lst.sort(key=lambda n: n.time) for _rn, _sstr, _kind in pending_slides: if _kind == "pitched": _seq = _by_string.get(_sstr, []) _nxt = next((x for x in _seq if x.time > _rn.time), None) if _nxt is not None and _nxt.fret != _rn.fret: _rn.slide_to = _nxt.fret elif _kind == "down": _rn.slide_unpitch_to = max(1, _rn.fret - 5) elif _kind == "up": _rn.slide_unpitch_to = _rn.fret + 5 # ── Compute anchors ─────────────────────────────────────────────────── # Exclude open strings (fret 0) — they span the full highway and # shouldn't cause the fret range to shift anchors = [] all_timed_frets = [(n.time, n.fret) for n in rs_notes if n.fret > 0] for c in rs_chords: for cn in c.notes: if cn.fret > 0: all_timed_frets.append((cn.time, cn.fret)) all_timed_frets.sort() # Always start with an anchor at the beginning first_fret = all_timed_frets[0][1] if all_timed_frets else 1 anchors.append(RsAnchor(time=audio_offset, fret=max(1, first_fret - 1), width=4)) for t, fret in all_timed_frets: anchor_lo = anchors[-1].fret anchor_hi = anchor_lo + anchors[-1].width if fret < anchor_lo or fret > anchor_hi: new_fret = max(1, fret - 1) if new_fret != anchors[-1].fret: anchors.append(RsAnchor(time=t, fret=new_fret, width=4)) # ── Compute song length ─────────────────────────────────────────────── # End of the final scheduled measure in *output* time. After expansion # this can be substantially longer than `_tick_to_seconds(last_mh.start # + measure_len)` would yield on the authored timeline. if schedule: last_entry = schedule[-1] song_length = ( last_entry.output_start_secs + last_entry.duration_secs + audio_offset ) else: song_length = audio_offset # ── Build XML ───────────────────────────────────────────────────────── return _build_xml( title=song.title or "Untitled", artist=song.artist or "Unknown", album=song.album or "", year=_extract_year(song), arrangement=arrangement_name, tuning=tuning, num_strings=num_strings, song_length=song_length, audio_offset=audio_offset, beats=beats, sections=sections, notes=rs_notes, chords=rs_chords, chord_templates=chord_templates, anchors=anchors, tempo=song.tempo, ) def _build_xml( title, artist, album, year, arrangement, tuning, num_strings, song_length, audio_offset, beats, sections, notes, chords, chord_templates, anchors, tempo, ) -> str: root = ET.Element("song", version="7") ET.SubElement(root, "title").text = title ET.SubElement(root, "arrangement").text = arrangement ET.SubElement(root, "offset").text = f"{audio_offset:.3f}" ET.SubElement(root, "songLength").text = f"{song_length:.3f}" ET.SubElement(root, "startBeat").text = f"{beats[0].time:.3f}" if beats else "0.000" ET.SubElement(root, "averageTempo").text = str(tempo) ET.SubElement(root, "artistName").text = artist ET.SubElement(root, "albumName").text = album ET.SubElement(root, "albumYear").text = year # Tuning. RS2014 schema names 6 string slots; we always emit those # for compatibility, and emit additional string6+ attributes (up to # `len(tuning)-1`) for 7+ string arrangements. FeedBack parses # them; the format ignores them. # # `stringCount` records the AUTHORITATIVE string count (== len(tuning)), # because the 6-slot padding above erases the 4-vs-5-vs-6-string # distinction for standard tunings (a 4-string bass, 5-string bass and # 6-string guitar are otherwise byte-identical, all string0..5 = 0). # parse_arrangement trims `tuning` back to this on read so downstream # string-count derivation (song.arrangement_string_count, the editor's # _stringCountFor) sees the real width instead of guessing. RS2014 and # any other consumer simply ignore the unknown attribute. tuning_el = ET.SubElement(root, "tuning") tuning_el.set("stringCount", str(len(tuning))) for i in range(max(6, len(tuning))): tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0)) ET.SubElement(root, "capo").text = "0" # Ebeats ebeats = ET.SubElement(root, "ebeats", count=str(len(beats))) for b in beats: ET.SubElement(ebeats, "ebeat", time=f"{b.time:.3f}", measure=str(b.measure)) # Sections sections_el = ET.SubElement(root, "sections", count=str(len(sections))) for s in sections: ET.SubElement(sections_el, "section", name=s.name, number=str(s.number), startTime=f"{s.time:.3f}") # Phrases — one per section phrases_el = ET.SubElement(root, "phrases", count=str(len(sections))) for i, s in enumerate(sections): ET.SubElement(phrases_el, "phrase", disparity="0", ignore="0", maxDifficulty="0", name=s.name, solo="0") phrase_iters = ET.SubElement(root, "phraseIterations", count=str(len(sections))) for i, s in enumerate(sections): ET.SubElement(phrase_iters, "phraseIteration", time=f"{s.time:.3f}", phraseId=str(i)) # Chord templates. RS schema names fret0..fret5; emit extra slots # per-template only when that specific chord actually has 7+ string # data, so a single 7-string chord doesn't inflate every other # 6-string template with synthetic `-1` slots on round-trip. ct_el = ET.SubElement(root, "chordTemplates", count=str(len(chord_templates))) for ct in chord_templates: width = max(6, len(ct.frets), len(ct.fingers)) attrs = {"chordName": ct.name} for i in range(width): attrs[f"fret{i}"] = str(ct.frets[i] if i < len(ct.frets) else -1) attrs[f"finger{i}"] = str(ct.fingers[i] if i < len(ct.fingers) else -1) ET.SubElement(ct_el, "chordTemplate", **attrs) # Single difficulty level with all notes levels_el = ET.SubElement(root, "levels", count="1") level = ET.SubElement(levels_el, "level", difficulty="0") # Notes notes_el = ET.SubElement(level, "notes", count=str(len(notes))) for n in notes: attrs = { "time": f"{n.time:.3f}", "string": str(n.string), "fret": str(n.fret), "sustain": f"{n.sustain:.3f}", "bend": f"{n.bend:.1f}" if n.bend else "0", "hammerOn": "1" if n.hammer_on else "0", "pullOff": "1" if n.pull_off else "0", "slideTo": str(n.slide_to), "slideUnpitchTo": str(n.slide_unpitch_to), "harmonic": "1" if n.harmonic else "0", "harmonicPinch": "1" if n.harmonic_pinch else "0", "palmMute": "1" if n.palm_mute else "0", "mute": "1" if n.mute else "0", "vibrato": "1" if n.vibrato else "0", "tremolo": "1" if n.tremolo else "0", "accent": "1" if n.accent else "0", "linkNext": "1" if n.link_next else "0", "tap": "1" if n.tap else "0", "ignore": "0", } attrs.update(_bend_shape_xml_attrs(n)) attrs.update(_finger_xml_attrs(n)) ET.SubElement(notes_el, "note", **attrs) # Chords chords_el = ET.SubElement(level, "chords", count=str(len(chords))) for ch in chords: chord_el = ET.SubElement(chords_el, "chord", time=f"{ch.time:.3f}", chordId=str(ch.template_idx), highDensity="0", strum="down") for cn in ch.notes: cn_attrs = { "time": f"{cn.time:.3f}", "string": str(cn.string), "fret": str(cn.fret), "sustain": f"{cn.sustain:.3f}", "bend": f"{cn.bend:.1f}" if cn.bend else "0", "hammerOn": "1" if cn.hammer_on else "0", "pullOff": "1" if cn.pull_off else "0", "slideTo": str(cn.slide_to), "slideUnpitchTo": str(cn.slide_unpitch_to), "harmonic": "1" if cn.harmonic else "0", "harmonicPinch": "1" if cn.harmonic_pinch else "0", "palmMute": "1" if cn.palm_mute else "0", "mute": "1" if cn.mute else "0", "vibrato": "1" if cn.vibrato else "0", "tremolo": "1" if cn.tremolo else "0", "accent": "1" if cn.accent else "0", "linkNext": "1" if cn.link_next else "0", "tap": "1" if cn.tap else "0", "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 anchors_el = ET.SubElement(level, "anchors", count=str(len(anchors))) for a in anchors: ET.SubElement(anchors_el, "anchor", time=f"{a.time:.3f}", fret=str(a.fret), width=str(a.width)) # Hand shapes (empty for now) ET.SubElement(level, "handShapes", count="0") # Pretty print xml_str = ET.tostring(root, encoding="unicode") dom = minidom.parseString(xml_str) return dom.toprettyxml(indent=" ", encoding=None) PIANO_INSTRUMENTS = set(range(0, 8)) # MIDI instruments 0-7 = piano family KEYS_INSTRUMENTS = PIANO_INSTRUMENTS | set(range(16, 24)) | {80, 81, 82, 83} # + organs + synth leads KEYS_NAME_KEYWORDS = {"piano", "keys", "keyboard", "synth", "organ", "rhodes", "wurlitzer", "clav", "epiano"} # GM drum mapping: MIDI note -> drum piece name GM_DRUM_MAP = { 35: "Kick", 36: "Kick", 38: "Snare", 40: "Snare", 42: "HiHat", 44: "HiHat", 46: "HiHat", 48: "Tom1", 50: "Tom1", 45: "Tom2", 47: "Tom2", 41: "Tom3", 43: "Tom3", 49: "Crash", 57: "Crash", 51: "Ride", 59: "Ride", } DRUMS_NAME_KEYWORDS = {"drums", "drum", "percussion", "drum kit", "drumkit"} def is_piano_track(track: guitarpro.Track) -> bool: """Detect if a GP track is a piano/keyboard instrument.""" if track.isPercussionTrack: return False # Check MIDI instrument if hasattr(track, 'channel') and track.channel: inst = getattr(track.channel, 'instrument', -1) if inst in KEYS_INSTRUMENTS: return True # Check name name_low = track.name.lower() if any(kw in name_low for kw in KEYS_NAME_KEYWORDS): return True return False def is_drum_track(track: guitarpro.Track) -> bool: """Detect if a GP track is a percussion/drum track.""" if track.isPercussionTrack: return True # Check MIDI channel 10 (index 9) if hasattr(track, 'channel') and track.channel: ch = getattr(track.channel, 'channel', -1) if ch == 9: # MIDI channel 10 (0-indexed) return True # Check name name_low = track.name.lower() if any(kw in name_low for kw in DRUMS_NAME_KEYWORDS): return True return False def list_tracks(gp_path: str) -> list[dict]: """List all tracks in a Guitar Pro file with basic info.""" if Path(gp_path).suffix.lower() in ('.gpx', '.gp'): from gp2rs_gpx import list_tracks as _gpx_list_tracks return _gpx_list_tracks(gp_path) song = guitarpro.parse(gp_path) tracks = [] for i, track in enumerate(song.tracks): note_count = 0 for measure in track.measures: for voice in measure.voices: for beat in voice.beats: note_count += len(beat.notes) instrument = -1 if hasattr(track, 'channel') and track.channel: instrument = getattr(track.channel, 'instrument', -1) tracks.append({ "index": i, "name": track.name, "strings": len(track.strings), "is_percussion": track.isPercussionTrack, "is_piano": is_piano_track(track), "is_drums": is_drum_track(track), "is_bass": _is_bass_track(track), "instrument": instrument, "notes": note_count, }) return tracks def auto_select_tracks(gp_path: str) -> tuple[list[int], dict[int, str]]: """Auto-select guitar/bass/keys tracks and assign the standard arrangement names. Includes piano/keyboard tracks as "Keys" arrangements alongside guitar and bass tracks. Returns: (track_indices, name_map) — indices to include and their arrangement names """ tracks = list_tracks(gp_path) guitar_keywords = {"guitar", "gtr", "lead", "rhythm", "rhy", "solo", "clean", "distort", "acoustic", "elec"} bass_keywords = {"bass"} skip_keywords = {"string", "choir", "brass", "brite", "flute", "violin", "cello", "horn"} selected = [] for t in tracks: if t["notes"] == 0: continue # Drum/percussion tracks → Drums if t["is_drums"]: selected.append((t["index"], "drums")) continue # Piano/keyboard tracks → Keys if t["is_piano"]: selected.append((t["index"], "keys")) continue name_low = t["name"].lower() # Bass detection: trust GM instrument / pitch-based check, which covers # 4-, 5- and 6-string basses. if t["is_bass"]: selected.append((t["index"], "bass")) continue # Check name for skip keywords if any(kw in name_low for kw in skip_keywords): continue # Check name for guitar/bass keywords if any(kw in name_low for kw in bass_keywords): selected.append((t["index"], "bass")) elif any(kw in name_low for kw in guitar_keywords): selected.append((t["index"], "guitar")) elif 6 <= t["strings"] <= 8: # Generic 6/7/8-string, assume guitar (extended-range). selected.append((t["index"], "guitar")) if not selected: # Fallback: take all non-percussion non-empty tracks for t in tracks: if not t["is_percussion"] and t["notes"] > 0: role = "bass" if t["is_bass"] else "guitar" selected.append((t["index"], role)) # Assign the standard arrangement names: Lead, Rhythm, Combo, Bass, Keys, Drums track_indices = [] name_map = {} lead_count = 0 rhythm_count = 0 bass_count = 0 keys_count = 0 drums_count = 0 for idx, role in selected: track_indices.append(idx) if role == "drums": drums_count += 1 name_map[idx] = "Drums" if drums_count == 1 else f"Drums {drums_count}" elif role == "keys": keys_count += 1 name_map[idx] = "Keys" if keys_count == 1 else f"Keys {keys_count}" elif role == "bass": bass_count += 1 name_map[idx] = "Bass" if bass_count == 1 else f"Bass {bass_count}" elif lead_count == 0: lead_count += 1 name_map[idx] = "Lead" else: rhythm_count += 1 name_map[idx] = "Rhythm" if rhythm_count == 1 else f"Combo" return track_indices, name_map def convert_piano_track( song: guitarpro.Song, track_index: int, audio_offset: float = 0.0, arrangement_name: str = "Keys", *, expand_repeats: bool = True, ) -> str: """Convert a GP piano/keyboard track to arrangement XML using MIDI encoding. Encodes MIDI notes into the string+fret format: string = midi_note // 24 fret = midi_note % 24 This gives a range of 0-143, covering the full piano range within the 6-string x 24-fret structure. The piano highway plugin decodes back via: midi = string * 24 + fret. Honors GP repeat brackets and D.S./D.C./Coda/Fine jumps when ``expand_repeats`` is true — see :func:`_build_playback_schedule`. """ track = song.tracks[track_index] tempo_map = _build_tempo_map(song) schedule = _build_playback_schedule(song, tempo_map, expand_repeats) headers = song.measureHeaders # ── Collect beats ──────────────────────────────────────────────── beats = [] for entry in schedule: mh = headers[entry.mh_index] beats.append(RsBeat( time=entry.output_start_secs + audio_offset, measure=mh.number, )) num_beats_in_measure = mh.timeSignature.numerator for b in range(1, num_beats_in_measure): sub_tick = _measure_beat_tick(mh, b) sub_offset_in_measure = _tick_to_seconds(sub_tick, tempo_map) \ - entry.mh_authored_start_secs beats.append(RsBeat( time=entry.output_start_secs + sub_offset_in_measure + audio_offset, measure=-1, )) beats.sort(key=lambda b: b.time) # ── Collect sections from markers ──────────────────────────────── sections = [] section_counts: dict[str, int] = {} for entry in schedule: mh = headers[entry.mh_index] if mh.marker and mh.marker.title: name = mh.marker.title.strip().lower().replace(" ", "") section_counts[name] = section_counts.get(name, 0) + 1 sections.append(RsSection( name=name, time=entry.output_start_secs + audio_offset, number=section_counts[name], )) if not sections: sections.append(RsSection(name="default", time=audio_offset, number=1)) # ── Collect notes ──────────────────────────────────────────────── rs_notes = [] rs_chords = [] chord_templates: list[ChordTemplate] = [] chord_template_map: dict[tuple, int] = {} last_note_per_pitch: dict[tuple[int, int], RsNote] = {} # (rs_string, rs_fret) → note, for tie sustain extension _prev_mh_index: int = -1 # sentinel: no previous entry for entry in schedule: # Clear the tie-tracking state on backward jumps in the playback # schedule (repeat loopbacks, D.S., D.C.). A tie at the start of # a repeated section must not extend the last note from the previous # pass through that section. Forward skips (volta alternatives, # al-Coda redirects) are *not* cleared because consecutive schedule # entries that jump forward are still adjacent in the output audio, # so a tie crossing such a boundary is semantically valid. if _prev_mh_index != -1 and entry.mh_index <= _prev_mh_index: last_note_per_pitch.clear() _prev_mh_index = entry.mh_index measure = track.measures[entry.mh_index] for voice in measure.voices: for beat in voice.beats: if not beat.notes: continue authored_beat_secs = _tick_to_seconds(beat.start, tempo_map) t = (authored_beat_secs - entry.mh_authored_start_secs) \ + entry.output_start_secs + audio_offset tempo = _tempo_at_tick(beat.start, tempo_map) dur = _duration_to_seconds(beat.duration, tempo) beat_notes = [] for note in beat.notes: if note.type == guitarpro.NoteType.rest: continue # Get MIDI note value from the GP note # In GP, note.value is the fret, and the string tuning # gives the base MIDI value gp_str_idx = note.string # 1-based in GP if 1 <= gp_str_idx <= len(track.strings): base_midi = track.strings[gp_str_idx - 1].value else: base_midi = 60 # fallback to middle C midi_note = base_midi + note.value # Encode into the string+fret rs_string = midi_note // 24 rs_fret = midi_note % 24 if note.type == guitarpro.NoteType.tie: prev = last_note_per_pitch.get((rs_string, rs_fret)) if prev is not None and prev.time < t: prev.sustain = max(prev.sustain, (t + dur) - prev.time) continue rn = RsNote( time=t, string=rs_string, fret=rs_fret, sustain=dur if dur > 0.15 else 0.0, mute=note.type == guitarpro.NoteType.dead, ) # Accent from velocity eff = note.effect if eff.accentuatedNote or eff.heavyAccentuatedNote: rn.accent = True beat_notes.append(rn) pitch_key = (rs_string, rs_fret) existing = last_note_per_pitch.get(pitch_key) if existing is None or rn.time >= existing.time: last_note_per_pitch[pitch_key] = rn if not beat_notes: continue if len(beat_notes) == 1: rs_notes.append(beat_notes[0]) else: # Piano chord: create template from MIDI-encoded positions frets = [-1] * 6 for n in beat_notes: if 0 <= n.string < 6: frets[n.string] = n.fret fret_key = tuple(frets) if fret_key not in chord_template_map: 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, frets=list(frets), fingers=[-1] * 6, )) chord_template_map[fret_key] = idx rs_chords.append(RsChord( time=t, template_idx=chord_template_map[fret_key], notes=beat_notes, )) rs_notes.sort(key=lambda n: n.time) rs_chords.sort(key=lambda c: c.time) # ── Anchors (simplified for piano — just cover the range) ──────── anchors = [RsAnchor(time=audio_offset, fret=1, width=24)] # ── Song length ────────────────────────────────────────────────── if schedule: last_entry = schedule[-1] song_length = ( last_entry.output_start_secs + last_entry.duration_secs + audio_offset ) else: song_length = audio_offset # ── Build XML ──────────────────────────────────────────────────── # Use all-zero tuning (piano has no tuning concept) return _build_xml( title=song.title or "Untitled", artist=song.artist or "Unknown", album=song.album or "", year=_extract_year(song), arrangement=arrangement_name, tuning=[0] * 6, num_strings=6, song_length=song_length, audio_offset=audio_offset, beats=beats, sections=sections, notes=rs_notes, chords=rs_chords, chord_templates=chord_templates, anchors=anchors, tempo=song.tempo, ) def convert_drum_track( song: guitarpro.Song, track_index: int, audio_offset: float = 0.0, arrangement_name: str = "Drums", *, expand_repeats: bool = True, ) -> str: """Convert a GP drum/percussion track to arrangement XML using MIDI encoding. Encodes MIDI drum note numbers into the string+fret format: string = midi_note // 24 fret = midi_note % 24 The drum highway plugin decodes back via: midi = string * 24 + fret and maps to the appropriate drum lane (kick, snare, hi-hat, etc.). Honors GP repeat brackets and D.S./D.C./Coda/Fine jumps when ``expand_repeats`` is true — see :func:`_build_playback_schedule`. """ track = song.tracks[track_index] tempo_map = _build_tempo_map(song) schedule = _build_playback_schedule(song, tempo_map, expand_repeats) headers = song.measureHeaders # ── Collect beats ──────────────────────────────────────────────── beats = [] for entry in schedule: mh = headers[entry.mh_index] beats.append(RsBeat( time=entry.output_start_secs + audio_offset, measure=mh.number, )) num_beats_in_measure = mh.timeSignature.numerator for b in range(1, num_beats_in_measure): sub_tick = _measure_beat_tick(mh, b) sub_offset_in_measure = _tick_to_seconds(sub_tick, tempo_map) \ - entry.mh_authored_start_secs beats.append(RsBeat( time=entry.output_start_secs + sub_offset_in_measure + audio_offset, measure=-1, )) beats.sort(key=lambda b: b.time) # ── Collect sections from markers ──────────────────────────────── sections = [] section_counts: dict[str, int] = {} for entry in schedule: mh = headers[entry.mh_index] if mh.marker and mh.marker.title: name = mh.marker.title.strip().lower().replace(" ", "") section_counts[name] = section_counts.get(name, 0) + 1 sections.append(RsSection( name=name, time=entry.output_start_secs + audio_offset, number=section_counts[name], )) if not sections: sections.append(RsSection(name="default", time=audio_offset, number=1)) # ── Collect drum notes ─────────────────────────────────────────── rs_notes = [] rs_chords = [] chord_templates: list[ChordTemplate] = [] chord_template_map: dict[tuple, int] = {} for entry in schedule: measure = track.measures[entry.mh_index] for voice in measure.voices: for beat in voice.beats: if not beat.notes: continue authored_beat_secs = _tick_to_seconds(beat.start, tempo_map) t = (authored_beat_secs - entry.mh_authored_start_secs) \ + entry.output_start_secs + audio_offset beat_notes = [] for note in beat.notes: if note.type == guitarpro.NoteType.rest: continue # For percussion tracks, the MIDI note comes from the # string tuning value (each "string" = a drum piece). # note.value is the fret (usually 0 for drums). gp_str_idx = note.string # 1-based if 1 <= gp_str_idx <= len(track.strings): midi_note = track.strings[gp_str_idx - 1].value + note.value else: midi_note = note.value if midi_note not in GM_DRUM_MAP: continue # Skip unknown percussion sounds # Encode into the string+fret rs_string = midi_note // 24 rs_fret = midi_note % 24 rn = RsNote( time=t, string=rs_string, fret=rs_fret, sustain=0.0, # Drums have no sustain ) # Accent from velocity/effect eff = note.effect if eff.accentuatedNote or eff.heavyAccentuatedNote: rn.accent = True # Ghost notes: mark as mute (low velocity) if eff.ghostNote: rn.mute = True beat_notes.append(rn) if not beat_notes: continue if len(beat_notes) == 1: rs_notes.append(beat_notes[0]) else: # Multiple drum hits at same time → chord frets = [-1] * 6 for n in beat_notes: if 0 <= n.string < 6: frets[n.string] = n.fret fret_key = tuple(frets) if fret_key not in chord_template_map: idx = len(chord_templates) chord_templates.append(ChordTemplate( name="", frets=list(frets), fingers=[-1] * 6, )) chord_template_map[fret_key] = idx rs_chords.append(RsChord( time=t, template_idx=chord_template_map[fret_key], notes=beat_notes, )) rs_notes.sort(key=lambda n: n.time) rs_chords.sort(key=lambda c: c.time) # ── Anchors (simplified for drums) ─────────────────────────────── anchors = [RsAnchor(time=audio_offset, fret=1, width=24)] # ── Song length ────────────────────────────────────────────────── if schedule: last_entry = schedule[-1] song_length = ( last_entry.output_start_secs + last_entry.duration_secs + audio_offset ) else: song_length = audio_offset # ── Build XML ──────────────────────────────────────────────────── return _build_xml( title=song.title or "Untitled", artist=song.artist or "Unknown", album=song.album or "", year=_extract_year(song), arrangement=arrangement_name, tuning=[0] * 6, num_strings=6, song_length=song_length, audio_offset=audio_offset, beats=beats, sections=sections, notes=rs_notes, chords=rs_chords, chord_templates=chord_templates, anchors=anchors, tempo=song.tempo, ) def convert_drum_track_to_drumtab( song: guitarpro.Song, track_index: int, audio_offset: float = 0.0, arrangement_name: str = "Drums", *, expand_repeats: bool = True, out_unmapped: dict[int, dict] | None = None, ) -> dict: """Convert a GP drum/percussion track to a `drum_tab.json` dict. Returns the payload documented in `docs/sloppak-spec.md` §5.3: {"version": 1, "name": str, "kit": [{"id": piece, "name": label}, ...], "hits": [{"t": float, "p": piece, "v": int, "g"?: bool, "f"?: bool, "k"?: float}, ...]} Velocity is preserved verbatim (pyguitarpro uses MIDI 1-127). Ghost notes are surfaced as `g: true` (not as a velocity penalty), flams as `f: true` via `NoteEffect.isGrace`. Hi-hat openness is derived from the MIDI note number (42 closed / 46 open / 44 pedal) since GP stores those on distinct drum strings. Unknown percussion sounds (cowbell, tambourine etc.) are skipped — round-tripping them would require teaching `lib/drums.py` first. Callers can pass an empty dict as ``out_unmapped`` to receive a per-MIDI record of every skipped note (``{midi: {"count": int, "times": [...]}}``, times capped at 100 samples per note) so they can surface a warning or offer a manual mapping UI. Honours GP repeat brackets and D.S./D.C./Coda/Fine jumps when ``expand_repeats`` is true — same `_build_playback_schedule` machinery used by the guitar/bass/keys/legacy-drum-XML converters above. """ # Imported lazily so an environment without lib/drums.py (older worktree # checkout) still loads gp2rs successfully. import drums as drums_mod track = song.tracks[track_index] tempo_map = _build_tempo_map(song) schedule = _build_playback_schedule(song, tempo_map, expand_repeats) hits: list[dict] = [] pieces_seen: dict[str, str] = {} # piece-id → display name for entry in schedule: measure = track.measures[entry.mh_index] for voice in measure.voices: for beat in voice.beats: if not beat.notes: continue authored_beat_secs = _tick_to_seconds(beat.start, tempo_map) t = ( (authored_beat_secs - entry.mh_authored_start_secs) + entry.output_start_secs + audio_offset ) for note in beat.notes: if note.type == guitarpro.NoteType.rest: continue # Percussion tracks: MIDI note is the string's tuning # value (each "string" pins a drum piece) + fret offset. gp_str_idx = note.string if 1 <= gp_str_idx <= len(track.strings): midi_note = track.strings[gp_str_idx - 1].value + note.value else: midi_note = note.value piece = drums_mod.midi_to_piece(midi_note) if piece is None: # Unmapped percussion sound. Record it for the # optional out-parameter so the caller can surface # a "these notes were dropped" warning to the user # (with the option to map them by hand). The # default path is still to skip silently for # backward compatibility with callers that don't # opt in. if out_unmapped is not None: # NB: do NOT shadow the outer `entry` loop # variable from `for entry in schedule:`. unmapped_rec = out_unmapped.setdefault( int(midi_note), {"count": 0, "times": []}) unmapped_rec["count"] += 1 if len(unmapped_rec["times"]) < 100: unmapped_rec["times"].append(round(t, 3)) continue hit: dict = {"t": round(t, 3), "p": piece} # Velocity: GP stores 1-127 MIDI velocity directly; default # is 95 (Velocities.default). Pass through verbatim, # clamping defensively so a corrupt file can't poison the # wire format. vel = int(getattr(note, "velocity", 0) or 0) if 1 <= vel <= 127: hit["v"] = vel eff = note.effect # Ghost — explicit flag on the GP effect. Accent flag is # already reflected in the higher velocity, so we don't # need a separate `ac` field on the hit. if getattr(eff, "ghostNote", False): hit["g"] = True # Flam / grace note — pyguitarpro models grace notes as a # GraceEffect dangling off NoteEffect; `isGrace` is the # convenience boolean. Drum charts use grace almost # exclusively for flams, so map directly. if getattr(eff, "isGrace", False): hit["f"] = True # Cymbal choke — GP doesn't have a first-class field for # it, but staccato on a cymbal piece is the closest # idiomatic encoding. Treat it as a short choke tail # (~80 ms) so the highway can render the fade-out. if ( drums_mod.piece_category(piece) == "cymbal" and getattr(eff, "staccato", False) ): hit["k"] = 0.08 hits.append(hit) if piece not in pieces_seen: # Title-case piece-id for the kit legend name; user- # facing labels are overridden at the lane-config # level anyway. pieces_seen[piece] = piece.replace("_", " ").title() hits.sort(key=lambda h: h["t"]) # Times for unmapped notes were collected in beat-iteration order; # multi-voice measures can produce out-of-order beats, so sort each # entry's `times` list chronologically before returning to the caller. if out_unmapped is not None: for _rec in out_unmapped.values(): _rec["times"].sort() return { "version": drums_mod.SCHEMA_VERSION, "name": arrangement_name, "kit": [{"id": pid, "name": name} for pid, name in pieces_seen.items()], "hits": hits, } def convert_file( gp_path: str, output_dir: str, track_indices: list[int] | None = None, audio_offset: float = 0.0, arrangement_names: dict[int, str] | None = None, force_standard_tuning: bool = False, *, expand_repeats: bool = True, ) -> list[str]: """Convert a GP file to arrangement XMLs. Args: gp_path: Path to .gp5/.gp4/.gp3 file output_dir: Directory to write XML files track_indices: Which tracks to convert (None = auto-select) audio_offset: Seconds to add for audio sync arrangement_names: Override arrangement names {track_idx: name} force_standard_tuning: Force E standard tuning (frets unchanged) expand_repeats: When true (default), the converter walks the GP playback graph — replaying repeat brackets, honoring volta (1st/2nd-ending) markers, and following D.S./D.C./Coda/Fine jumps — so the emitted arrangement matches a linear audio file playing the song as performed. Pass false to recover the legacy as-written behavior (each measure emitted exactly once). Returns: List of output XML file paths """ if Path(gp_path).suffix.lower() in ('.gpx', '.gp'): from gp2rs_gpx import convert_file as _gpx_convert_file return _gpx_convert_file( gp_path, output_dir, track_indices, audio_offset, arrangement_names, force_standard_tuning, expand_repeats=expand_repeats, ) song = guitarpro.parse(gp_path) out = Path(output_dir) out.mkdir(parents=True, exist_ok=True) if track_indices is None: # Auto-select: include all tracks that auto_select_tracks would pick track_indices, auto_names = auto_select_tracks(gp_path) if not arrangement_names: arrangement_names = auto_names names = arrangement_names or {} output_files = [] # Counts of auto-named guitar/bass arrangements so multiple guitars get # distinct RS roles (Lead, Rhythm, Combo, …) instead of all "Lead". _role_counts: dict[str, int] = {} for idx in track_indices: track = song.tracks[idx] arr_name = names.get(idx, "") # Route drum/percussion tracks through drum converter if is_drum_track(track) or (arr_name and arr_name.lower().startswith("drums")): xml_str = convert_drum_track( song, idx, audio_offset, arr_name or "Drums", expand_repeats=expand_repeats, ) # Route piano/keyboard tracks through the MIDI-encoding converter elif is_piano_track(track) or (arr_name and arr_name.lower().startswith("keys")): xml_str = convert_piano_track( song, idx, audio_offset, arr_name or "Keys", expand_repeats=expand_repeats, ) else: # Assign a distinct RS role when the caller didn't name this # guitar/bass track, so multiple guitars don't all default to # "Lead" inside convert_track. if not arr_name: low = track.name.lower() if _is_bass_track(track) or "bass" in low: _bc = _role_counts.get("bass", 0) _role_counts["bass"] = _bc + 1 arr_name = "Bass" if _bc == 0 else f"Bass {_bc + 1}" else: _gc = _role_counts.get("guitar", 0) _role_counts["guitar"] = _gc + 1 _roles = ("Lead", "Rhythm", "Combo") arr_name = _roles[_gc] if _gc < len(_roles) else f"Combo {_gc - 1}" xml_str = convert_track( song, idx, audio_offset, arr_name, force_standard_tuning, expand_repeats=expand_repeats, ) safe_name = track.name.strip().replace(" ", "_").replace("/", "_") filename = f"{safe_name}_{arr_name or 'arr'}.xml" filepath = out / filename filepath.write_text(xml_str) output_files.append(str(filepath)) return output_files