mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 20:31:21 +00:00
* 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>
2087 lines
87 KiB
Python
2087 lines
87 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:.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.05–0.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
|