mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 04:41:23 +00:00
Four tester-reported GP-import issues, all in the converter/parse layer: * String count (bugs 2 & 4): <tuning> is padded to 6 slots, so a 4-string bass, 5-string bass and 6-string guitar were byte-identical and the real count was lost — a 5-string bass played on 4 strings and a 4-string bass showed a phantom B in the editor. Record the authoritative count in a new <tuning stringCount=N> attribute (gp2rs._build_xml) and trim the padded tail back to it on read (song.parse_arrangement). All consumers already trust a non-6 tuning length (arrangement_string_count, the editor's _stringCountFor and build-time _normalize_tuning_to_count), so this fixes the create-mode preview AND the built sloppak with no consumer changes. * Lead/Rhythm reversed (bug 3): guitar arrangements were named by appearance order (first guitar -> Lead), swapping roles for files that list Rhythm before Lead. Honor 'lead'/'rhythm' in the GP track name; unhinted tracks keep positional fallback. Applied to both convert_file's fallback (the editor's track_indices-without-names path) and _auto_select_gpx, with cross-role dedup so name-based and positional labels can't collide. * Preview note count (bug 1): the importer's per-track count included tie-continuation notes, which are folded into the previous note's sustain and never become separate RS notes (260 shown vs 241 imported). Exclude tie destinations so the preview matches the imported result. Adds regression tests for all three. Bug 5 (no stems from synced audio) is environment-dependent (best-effort demucs backend) and not addressed here. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2056 lines
85 KiB
Python
2056 lines
85 KiB
Python
"""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:.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
|