feedBack/lib/gp2rs.py
ChrisBeWithYou f1bae9774c
fix(gp2rs): write beat times at 6-decimal precision so imported tempo matches Guitar Pro (#819)
* fix(gp2rs): write beat times at 6-decimal precision

The editor/timeline derives per-bar BPM from beat spans
(bpm = beats*60/span), which amplifies rounding: at millisecond
(3-decimal) precision a constant-tempo GP import (e.g. 140 BPM) shows a
spurious per-bar "tempo drift" of ~0.05-0.7 BPM because most bar lengths
don't land on a ms boundary (worse for fast/odd meters). gp2rs computes
these beat times exactly from the GP tempo map, so the only precision
loss is the ebeat/startBeat format string. Writing them at 6 decimals
(microseconds) makes the derived tempo match GP's authored value.

Verified on GP5 imports (Highway to Hell 116, Equivalence 140, Living
After Midnight 138): the derived per-bar BPM collapses from two drifting
values to the single authored constant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013JgxKh99UAeQqmhzSc73tv
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* test(gp2rs): compare ebeat times by value, not string

The 6-decimal beat-time write makes _assert_ebeats' exact-string compare fail
("0.500" vs "0.500000"). These tests only assert spacing, so parse both sides
to float — precision-agnostic, no need to rewrite every parametrized list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-10 13:12:10 +02:00

2087 lines
87 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 <albumYear> 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 <note>/<chordNote>, 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 <note>/<chordNote>: `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:.6f}" if beats else "0.000000"
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 — write beat times at MICROSECOND (6-decimal) precision, not
# millisecond (3-decimal). The editor/timeline DERIVES per-bar BPM from beat
# spans (bpm = beats·60/span), which amplifies any rounding: at 3 decimals a
# constant-tempo GP (e.g. 140) shows a spurious ±0.050.7 BPM per-bar drift
# (worse for fast/odd meters) because most bar lengths don't land on a ms
# boundary. gp2rs computes these times exactly from the GP tempo map, so the
# only loss is this format string — 6 decimals makes the derived tempo match
# GP's authored value. (Everything else stays at :.3f; only beats drive tempo.)
ebeats = ET.SubElement(root, "ebeats", count=str(len(beats)))
for b in beats:
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.6f}", 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": [...],
"velocities": [...]}}``, times/velocities index-aligned and capped at
100 samples per note — velocities carry the source notes' real dynamics)
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": [], "velocities": []})
unmapped_rec["count"] += 1
if len(unmapped_rec["times"]) < 100:
unmapped_rec["times"].append(round(t, 3))
# Index-aligned with times: the note's real
# dynamics (same 1-127 gate as mapped hits,
# falling back to the 100 import default) so
# a hand-mapping UI doesn't flatten them.
_uv = int(getattr(note, "velocity", 0) or 0)
unmapped_rec["velocities"].append(
_uv if 1 <= _uv <= 127 else 100)
continue
hit: dict = {"t": round(t, 3), "p": piece}
# Velocity: GP stores 1-127 MIDI velocity directly. Note
# this is GP's *authoring* default (95, Velocities.default)
# — unrelated to the drumtab render default of 100
# (DEFAULT_VELOCITY, lib/drums.py:179), which only applies
# when `v` is omitted from a hit. Pass the GP value 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.
# Velocities are index-aligned with times, so they must sort in
# LOCKSTEP — sorting times alone would silently reassign dynamics.
if out_unmapped is not None:
for _rec in out_unmapped.values():
_vels = _rec.get("velocities")
if _vels and len(_vels) == len(_rec["times"]):
_pairs = sorted(zip(_rec["times"], _vels))
_rec["times"] = [p[0] for p in _pairs]
_rec["velocities"] = [p[1] for p in _pairs]
else:
# Belt-and-suspenders: times & velocities are always appended
# together under the same `len(times) < 100` guard above, so
# in practice the lengths can't diverge. Kept as a defensive
# fallback, not a real divergence case.
_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