mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-25 06:11:36 +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>
2448 lines
110 KiB
Python
2448 lines
110 KiB
Python
"""
|
||
lib/gp2rs_gpx.py — Guitar Pro 6 (.gpx) support shim for gp2rs.
|
||
|
||
Drop this file into feedBack/lib/ alongside gp2rs.py.
|
||
No third-party dependencies — pure Python stdlib only.
|
||
|
||
Public API mirrors the two functions that the editor plugin calls:
|
||
list_tracks(gp_path) -> list[dict]
|
||
convert_file(gp_path, ...) -> list[str]
|
||
|
||
Both are called transparently by gp2rs.py when the file extension is .gpx.
|
||
Do not call this module directly; use gp2rs.list_tracks / gp2rs.convert_file.
|
||
"""
|
||
|
||
import logging
|
||
import re
|
||
import struct
|
||
import xml.etree.ElementTree as ET
|
||
from pathlib import Path
|
||
|
||
from safepath import safe_join
|
||
|
||
_log = logging.getLogger("feedBack.lib.gp2rs_gpx")
|
||
|
||
|
||
def _safe_filename_stem(name: str) -> str:
|
||
"""Filesystem-safe stem from an untrusted (GPX-supplied) track name.
|
||
|
||
Track names come from an arbitrary user-uploaded file, so they may contain
|
||
path separators (``/`` or ``\\``), ``..``, colons, etc. Collapse anything
|
||
outside ``[A-Za-z0-9._-]`` to ``_`` and strip leading/trailing dots/dashes
|
||
so the result can't traverse out of the output directory on any platform.
|
||
"""
|
||
s = re.sub(r'[^A-Za-z0-9._-]+', '_', name or '').strip('._-')
|
||
return s or 'track'
|
||
|
||
# Hard cap on the BCFZ-declared decompressed size. The size comes from a
|
||
# 32-bit field in an attacker-controllable upload; without a cap a crafted
|
||
# file could declare a multi-GB target and exhaust memory. Real GPX payloads
|
||
# are small (the November Rain example expands to ~1.4 MB); 64 MB is generous.
|
||
_GPX_MAX_DECOMPRESSED = 64 * 1024 * 1024
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# GPX container parsing (BCFZ/BCFS)
|
||
# Identical to gpx_parser.py — duplicated here so lib/ is self-contained.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _decompress_bcfz(raw: bytes) -> bytes:
|
||
if raw[:4] != b'BCFZ':
|
||
raise ValueError(f"Expected BCFZ magic, got {raw[:4]!r}")
|
||
src = raw[4:]
|
||
n = len(src)
|
||
pos = 0; current_byte = 0; bit_pos = 8
|
||
|
||
def _read_bit():
|
||
nonlocal pos, current_byte, bit_pos
|
||
if bit_pos >= 8:
|
||
if pos >= n: raise EOFError()
|
||
current_byte = src[pos]; pos += 1; bit_pos = 0
|
||
val = (current_byte >> (7 - bit_pos)) & 1; bit_pos += 1; return val
|
||
|
||
def _rb(count):
|
||
v = 0
|
||
for i in range(count - 1, -1, -1): v |= _read_bit() << i
|
||
return v
|
||
|
||
def _rr(count):
|
||
v = 0
|
||
for i in range(count): v |= _read_bit() << i
|
||
return v
|
||
|
||
lb = bytes(_rb(8) & 0xFF for _ in range(4))
|
||
expected = struct.unpack_from('<I', lb)[0]
|
||
if expected > _GPX_MAX_DECOMPRESSED:
|
||
raise ValueError(
|
||
f"GPX declares implausible decompressed size {expected} bytes "
|
||
f"(> {_GPX_MAX_DECOMPRESSED} cap); refusing to decompress"
|
||
)
|
||
out = bytearray()
|
||
try:
|
||
while len(out) < expected:
|
||
if _rb(1):
|
||
ws = _rb(4); off = _rr(ws); sz = _rr(ws)
|
||
sp = len(out) - off
|
||
for i in range(min(off, sz)): out.append(out[sp + i])
|
||
else:
|
||
sz = _rr(2)
|
||
for _ in range(sz): out.append(_rb(8) & 0xFF)
|
||
except EOFError:
|
||
pass
|
||
return bytes(out)
|
||
|
||
|
||
def _parse_bcfs(bcfs: bytes) -> dict:
|
||
if bcfs[:4] != b'BCFS':
|
||
raise ValueError(f"Expected BCFS magic, got {bcfs[:4]!r}")
|
||
HDR = 4; data = bcfs; SECTOR = 0x1000
|
||
# A file can't reference more sectors than the container physically holds;
|
||
# cap the sector-pointer walk so a malformed/crafted chain that never
|
||
# yields a 0 terminator can't loop unbounded and exhaust memory.
|
||
max_sectors = (len(data) // SECTOR) + 1
|
||
|
||
def _gi(off):
|
||
# Bounds-check every 4-byte read so a truncated/crafted file raises a
|
||
# clean ValueError instead of leaking struct.error / IndexError.
|
||
if off < 0 or HDR + off + 4 > len(data):
|
||
raise ValueError("GPX BCFS read past end of container (malformed file)")
|
||
return struct.unpack_from('<I', data, HDR + off)[0]
|
||
def _gs(off, ml):
|
||
base = HDR + off; end = base
|
||
limit = min(base + ml, len(data))
|
||
while end < limit and data[end] != 0: end += 1
|
||
return data[base:end].decode('utf-8', errors='replace')
|
||
|
||
files: dict = {}
|
||
offset = SECTOR
|
||
while HDR + offset + 4 <= len(data):
|
||
if _gi(offset) == 2:
|
||
fn = _gs(offset + 0x04, 127); fs = _gi(offset + 0x8C)
|
||
po = offset + 0x94; sc = 0; fb = bytearray()
|
||
while sc <= max_sectors:
|
||
s = _gi(po + 4 * sc); sc += 1
|
||
if s == 0: break
|
||
so = s * SECTOR
|
||
if HDR + so + SECTOR > len(data):
|
||
raise ValueError("GPX BCFS sector pointer out of range (malformed file)")
|
||
fb.extend(data[HDR + so: HDR + so + SECTOR])
|
||
else:
|
||
raise ValueError("GPX BCFS sector chain too long (malformed file)")
|
||
files[fn] = bytes(fb[:fs])
|
||
offset += SECTOR
|
||
return files
|
||
|
||
|
||
def _load_gpif(gp_path: str) -> ET.Element:
|
||
"""Load and parse score.gpif from a .gpx (GP6) or .gp (GP7/GP8) file.
|
||
|
||
GP6 (.gpx): custom BCFZ/BCFS binary container holding score.gpif.
|
||
GP7/GP8 (.gp): standard ZIP archive holding Content/score.gpif.
|
||
Both formats use the same GPIF XML schema inside, so a single parser
|
||
handles all versions once the outer container is unpacked.
|
||
"""
|
||
with open(gp_path, 'rb') as fh:
|
||
raw = fh.read()
|
||
|
||
# GP7/GP8: ZIP container (PK magic)
|
||
if raw[:2] == b'PK':
|
||
import zipfile
|
||
import io as _io
|
||
with zipfile.ZipFile(_io.BytesIO(raw)) as zf:
|
||
if 'Content/score.gpif' not in zf.namelist():
|
||
raise ValueError("Content/score.gpif not found in GP7/GP8 ZIP container")
|
||
return ET.fromstring(zf.read('Content/score.gpif'))
|
||
|
||
# GP6 (.gpx): BCFZ compressed or raw BCFS
|
||
if raw[:4] == b'BCFZ':
|
||
bcfs = _decompress_bcfz(raw)
|
||
elif raw[:4] == b'BCFS':
|
||
bcfs = raw
|
||
else:
|
||
raise ValueError(
|
||
f"Unrecognised Guitar Pro container (magic: {raw[:4]!r}). "
|
||
f"Supported: .gpx (GP6, BCFZ/BCFS) and .gp (GP7/GP8, ZIP)."
|
||
)
|
||
fs = _parse_bcfs(bcfs)
|
||
if 'score.gpif' not in fs:
|
||
raise ValueError("score.gpif not found in GPX container")
|
||
return ET.fromstring(fs['score.gpif'])
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# GPIF helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# GPX NoteValue string -> quarter-note multiplier
|
||
_NOTE_VALUE_QN = {
|
||
'Whole': 4.0, 'Half': 2.0, 'Quarter': 1.0, 'Eighth': 0.5,
|
||
'16th': 0.25, '32nd': 0.125, '64th': 0.0625, '128th': 0.03125,
|
||
}
|
||
|
||
# Diatonic step index (0=C) -> semitone offset
|
||
_STEP_TO_SEMI = [0, 2, 4, 5, 7, 9, 11] # C D E F G A B
|
||
|
||
|
||
def _gpif_tempo(root: ET.Element) -> float:
|
||
"""Return the first Tempo automation value from MasterTrack.
|
||
|
||
GPX stores tempo as e.g. "72 2" where the second token is an internal
|
||
interpolation flag. Only the first token is the BPM value.
|
||
"""
|
||
mt = root.find('MasterTrack')
|
||
if mt is not None:
|
||
for auto in mt.findall('.//Automations/*'):
|
||
if auto.findtext('Type') == 'Tempo':
|
||
raw = (auto.findtext('Value') or '').strip()
|
||
try:
|
||
return float(raw.split()[0])
|
||
except (ValueError, TypeError, IndexError):
|
||
pass
|
||
return 120.0
|
||
|
||
|
||
def _build_tempo_map(root: ET.Element) -> list[tuple[int, float]]:
|
||
"""
|
||
Build a bar-indexed tempo map from all Tempo automations on MasterTrack.
|
||
|
||
Returns a list of (bar_index, bpm) sorted by bar_index. The map is used
|
||
by convert_file() to step through bars at the correct tempo, which is
|
||
critical for songs with multiple tempo changes (e.g. Bohemian Rhapsody
|
||
goes from 72 -> 144 -> gradual slowdown to 32 BPM).
|
||
|
||
GPX tempo Value format: "72 2" — first token = BPM, second = interpolation flag.
|
||
"""
|
||
events: list[tuple[int, float]] = []
|
||
mt = root.find('MasterTrack')
|
||
if mt is not None:
|
||
for auto in mt.findall('.//Automations/*'):
|
||
if auto.findtext('Type') == 'Tempo':
|
||
try:
|
||
bar = int(auto.findtext('Bar') or 0)
|
||
raw = (auto.findtext('Value') or '').strip()
|
||
bpm = float(raw.split()[0])
|
||
events.append((bar, bpm))
|
||
except (ValueError, TypeError, IndexError):
|
||
pass
|
||
events.sort(key=lambda e: e[0])
|
||
if not events:
|
||
events = [(0, 120.0)]
|
||
return events
|
||
|
||
|
||
def _gpif_tracks(root: ET.Element) -> list[dict]:
|
||
"""Return a list of raw track dicts from the GPIF Tracks element."""
|
||
# Lookups for per-track note counting. MasterBar/Bars lists one bar id per
|
||
# track in raw Tracks order, so the enumerate index below (which counts
|
||
# skipped pseudo-tracks) is the correct bar-lookup index — same mapping
|
||
# convert_file uses via filtered_to_raw.
|
||
_masterbars = list(root.find('MasterBars') or [])
|
||
_bars_by_id = {b.get('id'): b for b in (root.find('Bars') or [])}
|
||
_voices_by_id = {v.get('id'): v for v in (root.find('Voices') or [])}
|
||
_beats_by_id = {b.get('id'): b for b in (root.find('Beats') or [])}
|
||
_notes_by_id = {n.get('id'): n for n in (root.find('Notes') or [])}
|
||
|
||
def _note_count_for_raw(raw_idx: int) -> int:
|
||
# Count of notes that ACTUALLY become RS notes for the track. This is
|
||
# the single source of truth: list_tracks surfaces it as the 'notes'
|
||
# field (the importer's per-track preview count) and _auto_select_gpx
|
||
# uses (count == 0) to skip empty tracks — so the graph is walked once
|
||
# here, not again in list_tracks.
|
||
#
|
||
# Tie-DESTINATION notes are excluded: a tied note is folded into the
|
||
# previous note as extended sustain (see the `_note_is_tie` skips in
|
||
# convert_file), so it never becomes a separate RS note. Counting them
|
||
# made the preview overstate the result (e.g. 260 shown, 241 imported);
|
||
# excluding them makes the preview match what the user actually gets.
|
||
n = 0
|
||
for mb in _masterbars:
|
||
bar_ids = mb.findtext('Bars', '').split()
|
||
if raw_idx >= len(bar_ids):
|
||
continue
|
||
bar = _bars_by_id.get(bar_ids[raw_idx])
|
||
if bar is None:
|
||
continue
|
||
for vid in bar.findtext('Voices', '').split():
|
||
if vid == '-1':
|
||
continue
|
||
voice = _voices_by_id.get(vid)
|
||
if voice is None:
|
||
continue
|
||
for bid in voice.findtext('Beats', '').split():
|
||
beat = _beats_by_id.get(bid)
|
||
if beat is None:
|
||
continue
|
||
for nid in beat.findtext('Notes', '').split():
|
||
note_el = _notes_by_id.get(nid)
|
||
if note_el is not None and _note_is_tie(note_el):
|
||
continue
|
||
n += 1
|
||
return n
|
||
|
||
result = []
|
||
for raw_idx, t in enumerate(root.find('Tracks') or []):
|
||
name = (t.findtext('Name') or '').strip()
|
||
if name.startswith('@$') and name.endswith('$@'):
|
||
continue # GP internal pseudo-tracks (raw_idx still advances)
|
||
|
||
gm = t.find('GeneralMidi')
|
||
midi_program = 0
|
||
midi_channel = 0
|
||
is_drums = False
|
||
if gm is not None:
|
||
# GP6 (.gpx): <GeneralMidi table="Percussion"><Program>...</Program>
|
||
try: midi_program = int(gm.findtext('Program') or 0)
|
||
except (ValueError, TypeError): pass
|
||
try:
|
||
ch = int(gm.findtext('PrimaryChannel') or 0)
|
||
midi_channel = ch
|
||
if ch == 9: is_drums = True
|
||
except (ValueError, TypeError): pass
|
||
if gm.get('table') == 'Percussion':
|
||
is_drums = True
|
||
else:
|
||
# GP7/GP8 (.gp): <InstrumentSet><Type>drumKit</Type>
|
||
# and <MidiConnection><PrimaryChannel>9</PrimaryChannel>
|
||
inst_set = t.find('InstrumentSet')
|
||
if inst_set is not None:
|
||
inst_type = (inst_set.findtext('Type') or '').lower()
|
||
if inst_type == 'drumkit':
|
||
is_drums = True
|
||
midi_conn = t.find('MidiConnection')
|
||
if midi_conn is not None:
|
||
try:
|
||
ch_text = (midi_conn.findtext('PrimaryChannel') or '').strip()
|
||
if ch_text:
|
||
ch = int(ch_text)
|
||
midi_channel = ch
|
||
if ch == 9:
|
||
is_drums = True
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
# String tuning
|
||
string_pitches: list[int] = []
|
||
for prop in t.findall('.//Property'):
|
||
if prop.get('name') == 'Tuning':
|
||
pe = prop.find('Pitches')
|
||
if pe is not None and pe.text:
|
||
try:
|
||
string_pitches = [int(p) for p in pe.text.split()]
|
||
except ValueError:
|
||
pass
|
||
|
||
result.append({
|
||
'_el': t,
|
||
'id': t.get('id', ''),
|
||
'name': name,
|
||
'string_pitches': string_pitches,
|
||
'is_drums': is_drums,
|
||
'midi_program': midi_program,
|
||
'midi_channel': midi_channel,
|
||
'note_count': _note_count_for_raw(raw_idx),
|
||
})
|
||
return result
|
||
|
||
|
||
def _beat_dur_secs(beat_el: ET.Element, rhythms_dict: dict, tempo_bpm: float) -> float:
|
||
"""Return the duration of a beat in seconds."""
|
||
rref = beat_el.find('Rhythm')
|
||
dur_qn = 0.25
|
||
if rref is not None:
|
||
rhythm = rhythms_dict.get(rref.get('ref', ''))
|
||
if rhythm is not None:
|
||
nv = rhythm.findtext('NoteValue', 'Quarter')
|
||
dur_qn = _NOTE_VALUE_QN.get(nv, 0.25)
|
||
if rhythm.find('AugmentationDot') is not None:
|
||
dur_qn *= 1.5
|
||
# Tuplets
|
||
tuplet = rhythm.find('PrimaryTuplet')
|
||
if tuplet is not None:
|
||
try:
|
||
num = int(tuplet.get('num', 1))
|
||
den = int(tuplet.get('den', 1))
|
||
if num and den:
|
||
dur_qn *= den / num
|
||
except (TypeError, ValueError):
|
||
pass
|
||
return dur_qn * (60.0 / tempo_bpm)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Drum encoding tables — ported from alphaTab PercussionMapper (MIT licensed)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# GP6 Element+Variation -> articulation ID
|
||
# _GP6_EV[element][variation] = articulation_id
|
||
# Source: alphaTab PercussionMapper._gp6ElementAndVariationToArticulation
|
||
_GP6_EV: list[list[int]] = [
|
||
[35, 35, 35], # [0] Kick (hit, -, -)
|
||
[38, 91, 37], # [1] Snare (hit, rim shot, side stick)
|
||
[99, 100, 99], # [2] Cowbell low (hit, tip, -)
|
||
[56, 100, 56], # [3] Cowbell medium (hit, tip, -)
|
||
[102, 103, 102], # [4] Cowbell high (hit, tip, -)
|
||
[43, 43, 43], # [5] Tom very low (hit, -, -)
|
||
[45, 45, 45], # [6] Tom low (hit, -, -)
|
||
[47, 47, 47], # [7] Tom medium (hit, -, -)
|
||
[48, 48, 48], # [8] Tom high (hit, -, -)
|
||
[50, 50, 50], # [9] Tom very high (hit, -, -)
|
||
[42, 92, 46], # [10] Hihat (closed, half, open)
|
||
[44, 44, 44], # [11] Pedal hihat (hit, -, -)
|
||
[57, 98, 57], # [12] Crash medium (hit, choke, -)
|
||
[49, 97, 49], # [13] Crash high (hit, choke, -)
|
||
[55, 95, 55], # [14] Splash (hit, choke, -)
|
||
[51, 93, 127], # [15] Ride (middle, edge, bell)
|
||
[52, 96, 52], # [16] China (hit, choke, -)
|
||
]
|
||
|
||
# Articulation IDs that differ from their MIDI output note.
|
||
# Most IDs equal the MIDI note; only the non-standard ones are listed here.
|
||
# Source: alphaTab InstrumentArticulation.create(uniqueId, name, staffLine, outputMidi, ...)
|
||
_ART_TO_MIDI: dict[int, int] = {
|
||
91: 38, # Snare rim shot -> snare (38)
|
||
92: 46, # Hihat half-open -> open hihat (46)
|
||
93: 51, # Ride edge -> ride (51)
|
||
94: 51, # Ride choke -> ride (51)
|
||
95: 55, # Splash choke -> splash (55)
|
||
96: 52, # China choke -> china (52)
|
||
97: 49, # Crash high choke -> crash (49)
|
||
98: 57, # Crash medium choke -> crash 2 (57)
|
||
99: 56, # Cowbell low hit -> cowbell (56)
|
||
100: 56, # Cowbell low tip -> cowbell (56)
|
||
101: 56, # Cowbell medium tip -> cowbell (56)
|
||
102: 56, # Cowbell high hit -> cowbell (56)
|
||
103: 56, # Cowbell high tip -> cowbell (56)
|
||
104: 60, # Bongo high mute -> bongo high (60)
|
||
105: 60, # Bongo high slap -> bongo high (60)
|
||
106: 61, # Bongo low mute -> bongo low (61)
|
||
107: 61, # Bongo low slap -> bongo low (61)
|
||
108: 64, # Conga low slap -> conga low (64)
|
||
109: 64, # Conga low mute -> conga low (64)
|
||
110: 63, # Conga high slap -> conga high (63)
|
||
111: 54, # Tambourine return -> tambourine (54)
|
||
112: 54, # Tambourine roll -> tambourine (54)
|
||
113: 54, # Tambourine hand -> tambourine (54)
|
||
114: 43, # Grancassa -> tom very low (43)
|
||
115: 49, # Piatti hit -> crash (49)
|
||
116: 49, # Piatti hand -> crash (49)
|
||
117: 69, # Cabasa return -> cabasa (69)
|
||
118: 70, # Left maraca return -> maraca (70)
|
||
119: 70, # Right maraca hit -> maraca (70)
|
||
120: 70, # Right maraca return -> maraca (70)
|
||
122: 82, # Shaker return -> shaker (82)
|
||
123: 53, # Bell tree return -> ride bell (53)
|
||
124: 62, # Golpe thumb -> conga high mute (62)
|
||
125: 62, # Golpe finger -> conga high mute (62)
|
||
126: 59, # Ride cymbal 2 mid -> ride 2 (59)
|
||
127: 59, # Ride bell -> ride 2 (59)
|
||
}
|
||
|
||
# GP5 special fret->MIDI overrides (only 5 non-GM entries; all others are fret==MIDI)
|
||
# Source: alphaTab Gp3To5Importer._gp5PercussionInstrumentMap
|
||
_GP5_SPECIAL: dict[int, int] = {27: 42, 28: 60, 29: 29, 30: 30, 32: 31}
|
||
|
||
|
||
def _gp6_element_variation_to_midi(element: int, variation: int) -> int | None:
|
||
"""
|
||
Convert a GP6 Element+Variation pair to a MIDI percussion note number.
|
||
|
||
This is the primary drum encoding in all GPX (Guitar Pro 6) files.
|
||
Ported from alphaTab PercussionMapper.articulationFromElementVariation().
|
||
"""
|
||
if element < 0 or element >= len(_GP6_EV):
|
||
return None # Unknown element — silently skip
|
||
var = min(max(variation, 0), len(_GP6_EV[element]) - 1)
|
||
art_id = _GP6_EV[element][var]
|
||
return _ART_TO_MIDI.get(art_id, art_id)
|
||
|
||
|
||
# GPIF chord-diagram <Position finger="..."> names → RS finger integers,
|
||
# matching the editor (E1) + gp2rs/pyguitarpro convention:
|
||
# open/unused = -1, thumb = 0, index = 1, middle = 2, ring = 3, pinky = 4.
|
||
_GPIF_FINGER_MAP = {
|
||
'none': -1, 'open': -1, '': -1,
|
||
'thumb': 0,
|
||
'index': 1,
|
||
'middle': 2,
|
||
'ring': 3, 'annular': 3,
|
||
'pinky': 4, 'little': 4,
|
||
}
|
||
|
||
# Per-note <LeftFingering> teaching mark (§6.2.2). Unlike the chord-diagram
|
||
# <Position finger=".."> path above, GPIF stores a single note's fret-hand
|
||
# finger as a direct <Note> child element with the classical p-i-m-a-c letter
|
||
# codes (verified against GP8 exports), mapped to the same RS finger integers
|
||
# (open = -1, thumb = 0, index = 1, middle = 2, annular/ring = 3, little = 4).
|
||
_GPIF_LEFT_FINGERING_MAP = {
|
||
'open': -1, 'none': -1, '': -1,
|
||
'p': 0, 'thumb': 0,
|
||
'i': 1, 'index': 1,
|
||
'm': 2, 'middle': 2,
|
||
'a': 3, 'annular': 3, 'ring': 3,
|
||
'c': 4, 'little': 4, 'pinky': 4,
|
||
}
|
||
|
||
|
||
def _gpif_left_fingering(note_el) -> int:
|
||
"""Read a GPIF <Note>'s fret-hand finger (<LeftFingering>) -> RS finger int.
|
||
|
||
Returns -1 (unset) when absent or unrecognised — never fabricates a finger.
|
||
Teaching mark only (§6.2.2); never used for grading."""
|
||
raw = (note_el.findtext('LeftFingering') or '').strip().lower()
|
||
if not raw:
|
||
return -1
|
||
return _GPIF_LEFT_FINGERING_MAP.get(raw, -1)
|
||
|
||
|
||
def _rs_string_order(string_pitches: list[int]) -> dict[int, int]:
|
||
"""Map each GPIF string index → RS string index (0 = lowest pitch).
|
||
|
||
Mirrors the per-note transform in ``convert_file`` (sort GPIF string
|
||
indices by open pitch ascending, tiebreak on index, use the rank), so a
|
||
chord diagram's string indices land on the same RS strings as the played
|
||
notes regardless of format direction (GP6 .gpx high→low, GP8 .gp low→high).
|
||
"""
|
||
order = sorted(range(len(string_pitches)),
|
||
key=lambda i: (string_pitches[i], i))
|
||
return {gp: rs for rs, gp in enumerate(order)}
|
||
|
||
|
||
def _parse_chord_diagrams(track_el, string_pitches: list[int]) -> dict:
|
||
"""Map fret-pattern tuple → ``{'name', 'fingers'}`` from a track's diagrams.
|
||
|
||
GP7/GP8 GPIF stores authored chord diagrams per track under
|
||
``Properties/Property[@name="DiagramCollection"]/Items/Item``. Each Item
|
||
carries the chord name (its ``name`` attribute) and a ``<Diagram>`` with
|
||
per-string ``<Fret string=.. fret=..>`` plus
|
||
``<Fingering><Position finger=.. string=..></Fingering>``. Diagram string
|
||
indices share the positional space of note ``String`` indices, so they go
|
||
through the same pitch-rank transform; ``<Fret fret>`` is the absolute fret
|
||
(``baseFret`` is display-only and not applied).
|
||
|
||
Keying by fret pattern (width-normalised to ≥6, exactly like the template
|
||
build site) keeps the join key consistent with GP5 + the editor's
|
||
preserve-by-fret-key (E0). Returns ``{}`` when there are no diagrams or no
|
||
string tuning (orientation/width would be undefined).
|
||
"""
|
||
diagrams: dict[tuple, dict] = {}
|
||
if track_el is None or not string_pitches:
|
||
return diagrams
|
||
gp_to_rs = _rs_string_order(string_pitches)
|
||
for item in track_el.findall(
|
||
'.//Property[@name="DiagramCollection"]/Items/Item'):
|
||
diag = item.find('Diagram')
|
||
if diag is None:
|
||
continue
|
||
rs_frets: dict[int, int] = {}
|
||
for fr in diag.findall('Fret'):
|
||
try:
|
||
gp = int(fr.get('string'))
|
||
fret = int(fr.get('fret'))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if fret < 0:
|
||
continue
|
||
rs = gp_to_rs.get(gp)
|
||
if rs is not None:
|
||
rs_frets[rs] = fret
|
||
if not rs_frets:
|
||
continue
|
||
width = max(6, max(rs_frets) + 1)
|
||
frets = [-1] * width
|
||
fingers = [-1] * width
|
||
for rs, fret in rs_frets.items():
|
||
frets[rs] = fret
|
||
for pos in diag.findall('Fingering/Position'):
|
||
try:
|
||
gp = int(pos.get('string'))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
rs = gp_to_rs.get(gp)
|
||
if rs is None or not (0 <= rs < width) or frets[rs] < 0:
|
||
continue
|
||
fname = (pos.get('finger') or '').strip().lower()
|
||
fingers[rs] = _GPIF_FINGER_MAP.get(fname, -1)
|
||
# First diagram wins for a given voicing (stable, deterministic).
|
||
diagrams.setdefault(tuple(frets),
|
||
{'name': item.get('name', '') or '', 'fingers': fingers})
|
||
return diagrams
|
||
|
||
|
||
def _gpx_percussion_midis(track_el) -> list[int]:
|
||
"""Flatten a drumKit ``InstrumentSet``'s articulations into a list of GM
|
||
``OutputMidiNumber``s, positionally indexed to match a note's
|
||
``<InstrumentArticulation>`` value.
|
||
|
||
GP7/GP8 percussion notes carry the drum piece as a direct
|
||
``<InstrumentArticulation>N</InstrumentArticulation>`` child of ``<Note>``,
|
||
where N is the 0-based index into the track's InstrumentSet articulation
|
||
list (flattened across Elements in document order). Returns ``[]`` when the
|
||
track has no percussion InstrumentSet. Unparseable entries become ``-1`` so
|
||
indices stay aligned (``midi_to_piece(-1)`` is ``None`` → skipped).
|
||
"""
|
||
out: list[int] = []
|
||
iset = track_el.find('InstrumentSet') if track_el is not None else None
|
||
if iset is None:
|
||
return out
|
||
elements = iset.find('Elements')
|
||
if elements is None:
|
||
return out
|
||
for el in elements:
|
||
arts = el.find('Articulations')
|
||
if arts is None:
|
||
continue
|
||
for art in arts:
|
||
try:
|
||
out.append(int(art.findtext('OutputMidiNumber')))
|
||
except (TypeError, ValueError):
|
||
out.append(-1)
|
||
return out
|
||
|
||
|
||
def _note_midi(note_el: ET.Element, string_pitches: list[int],
|
||
perc_midis: list[int] | None = None) -> int | None:
|
||
"""
|
||
Extract MIDI note number from a GPIF <Note> element.
|
||
|
||
Handles all encoding variants found in Guitar Pro files:
|
||
|
||
0. InstrumentArticulation child (GP7/GP8 drums — primary encoding)
|
||
The drum piece is a direct ``<InstrumentArticulation>`` child of the
|
||
note: a positional index into the track's InstrumentSet articulation
|
||
list. `perc_midis` (from `_gpx_percussion_midis`) maps that index to the
|
||
GM OutputMidiNumber. This is the real GP8 percussion encoding — without
|
||
it every drum note decodes to None and the whole drum track drops.
|
||
|
||
1. Element + Variation (GPX / GP6 drums — primary encoding)
|
||
All GPX percussion tracks use this. The Element index selects the drum
|
||
piece; Variation selects the articulation (e.g. open vs closed hi-hat).
|
||
|
||
2. String + Fret (guitar/bass, and some GPX pitched tracks)
|
||
String is 0-based from the highest string. Pitch = string_pitches[idx] + fret.
|
||
Also used for guitar-model vocal tracks and some piano tabs (string_pitches
|
||
must be present and in descending MIDI order, high string first).
|
||
|
||
3. Tone + Octave (GPX melodic/piano tracks — diatonic step encoding)
|
||
Step is an integer 0–6 (C=0 … B=6). MIDI = (octave+1)*12 + semitone.
|
||
|
||
4. InstrumentArticulation index (GP7 primary encoding, rare in GPX)
|
||
A direct index into the track's percussionArticulations list. Not used
|
||
in GP6 files; handled here as a best-effort fallback using the standard
|
||
GM percussion table.
|
||
"""
|
||
# ── Encoding 0: percussion InstrumentArticulation child (GP7/GP8) ────
|
||
# A direct <InstrumentArticulation> child (NOT a Property) indexes into the
|
||
# track's InstrumentSet articulation list — resolve via `perc_midis`.
|
||
if perc_midis:
|
||
_ia = note_el.find('InstrumentArticulation')
|
||
if _ia is not None and _ia.text and _ia.text.strip().lstrip('-').isdigit():
|
||
# GP8 drumKit notes carry the authoritative GM percussion number in
|
||
# a <Property name="Midi"><Number>…</Number></Property> child.
|
||
# Prefer it: some exports pin InstrumentArticulation at 0 for EVERY
|
||
# note (a generic/unused kit articulation list), so resolving via
|
||
# perc_midis[idx] would collapse the whole track to one piece (the
|
||
# idx-0 articulation). The Midi property equals the articulation's
|
||
# OutputMidiNumber in well-formed files and is the real pitch when
|
||
# the articulation index is uninformative — so it's safe either way.
|
||
for _p in note_el.findall('.//Property'):
|
||
if _p.get('name') == 'Midi':
|
||
_num = _p.find('Number')
|
||
if (_num is not None and _num.text
|
||
and _num.text.strip().lstrip('-').isdigit()):
|
||
_mv = int(_num.text.strip())
|
||
return _mv if _mv >= 0 else None
|
||
break
|
||
_idx = int(_ia.text.strip())
|
||
if 0 <= _idx < len(perc_midis):
|
||
_m = perc_midis[_idx]
|
||
# `_gpx_percussion_midis` stores -1 for an articulation whose
|
||
# OutputMidiNumber was missing/unparseable; treat that as "no
|
||
# note" rather than emitting an invalid RS note (string=-1).
|
||
return _m if _m >= 0 else None
|
||
return None
|
||
|
||
props = {p.get('name'): p for p in note_el.findall('.//Property')}
|
||
|
||
# ── Encoding 1: Element + Variation (GPX drums) ──────────────────────
|
||
if 'Element' in props:
|
||
try:
|
||
element = int(props['Element'].findtext('Element') or 0)
|
||
variation = int((props.get('Variation') or ET.Element('x')).findtext('Variation') or 0)
|
||
return _gp6_element_variation_to_midi(element, variation)
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
# ── Encoding 2: String + Fret (guitar/bass/vocal/some piano) ─────────
|
||
if 'String' in props and 'Fret' in props:
|
||
try:
|
||
str_idx = int(props['String'].findtext('String') or 0)
|
||
fret = int(props['Fret'].findtext('Fret') or 0)
|
||
if string_pitches:
|
||
# GP6 String index 0 = highest string, and string_pitches is
|
||
# stored high→low (index 0 = highest) — the same ordering
|
||
# _gpx_tuning relies on. So index directly by str_idx; an
|
||
# earlier reverse (n-1-str_idx) transposed every String+Fret
|
||
# note to the wrong string's pitch.
|
||
if 0 <= str_idx < len(string_pitches):
|
||
return string_pitches[str_idx] + fret
|
||
return None
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
# ── Encoding 3: Tone + Octave (GPX melodic/piano) ────────────────────
|
||
if 'Tone' in props and 'Octave' in props:
|
||
try:
|
||
step = int(props['Tone'].findtext('Step') or 0)
|
||
octave = int(props['Octave'].findtext('Number') or 4)
|
||
semi = _STEP_TO_SEMI[step % 7]
|
||
return (octave + 1) * 12 + semi # C4 = MIDI 60
|
||
except (ValueError, TypeError, IndexError):
|
||
return None
|
||
|
||
# ── Encoding 4: InstrumentArticulation index (GP7 fallback) ──────────
|
||
if 'InstrumentArticulation' in props:
|
||
try:
|
||
art_id = int(props['InstrumentArticulation'].findtext('InstrumentArticulation') or 0)
|
||
# art_id is usually the MIDI note number directly for standard GM kit
|
||
return _ART_TO_MIDI.get(art_id, art_id)
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
return None
|
||
|
||
|
||
def _note_is_tie(note_el: ET.Element) -> bool:
|
||
"""True if this note is a tied continuation (destination tie)."""
|
||
tie = note_el.find('Tie')
|
||
if tie is None:
|
||
return False
|
||
# GP6 XML: <Tie origin="true"> on the first note, <Tie destination="true"> on the tied
|
||
return tie.get('destination', '').lower() in ('true', '1')
|
||
|
||
|
||
def _note_has_vibrato(note_el: ET.Element, prop_map: dict) -> bool:
|
||
"""True if a GP7/GP8 note carries fretting-hand vibrato.
|
||
|
||
GP7/GP8 encodes note vibrato as a DIRECT ``<Vibrato>Slight|Wide</Vibrato>``
|
||
child of ``<Note>`` — NOT a ``<Property>`` — so the property map alone never
|
||
matched it and note vibrato was silently dropped. Check the direct element
|
||
as well as (defensively) a ``<Property name="Vibrato">`` in ``prop_map``.
|
||
Whammy-bar ``VibratoWTremBar`` is a separate beat-level Property handled
|
||
elsewhere.
|
||
"""
|
||
return 'Vibrato' in prop_map or note_el.find('Vibrato') is not None
|
||
|
||
|
||
def _beat_has_tremolo(beat_el: ET.Element) -> bool:
|
||
"""True if a GP7/GP8 beat carries tremolo picking.
|
||
|
||
GPIF encodes tremolo picking as a DIRECT beat-level
|
||
``<Tremolo>1/8</Tremolo>`` child of ``<Beat>`` (the value is the rate). The
|
||
RS note model has a single boolean tremolo flag with no rate, so the rate is
|
||
intentionally ignored — any tremolo-picked beat maps to note tremolo across
|
||
it. Matched as a direct child (not ``.//``) so it is never confused with the
|
||
whammy-bar ``VibratoWTremBar`` Property, a separate beat-level effect
|
||
handled elsewhere.
|
||
"""
|
||
return beat_el.find('Tremolo') is not None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# list_tracks — mirrors gp2rs.list_tracks interface
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def list_tracks(gp_path: str) -> list[dict]:
|
||
"""List all tracks in a .gpx file with basic info for the editor UI."""
|
||
root = _load_gpif(gp_path)
|
||
tracks = _gpif_tracks(root)
|
||
# Note counts are computed once in _gpif_tracks ('note_count'); reuse them
|
||
# here instead of walking the bar/voice/beat graph a second time.
|
||
|
||
result = []
|
||
for i, t in enumerate(tracks):
|
||
is_bass = bool(
|
||
not t['is_drums'] # drums always have low/zero string pitches — must exclude
|
||
and (
|
||
(
|
||
not t['string_pitches'] # no string tuning = not guitar-family
|
||
and 32 <= t['midi_program'] <= 39
|
||
) or (
|
||
t['string_pitches']
|
||
and max(t['string_pitches']) <= 48 # bass top string ≤ C3
|
||
)
|
||
)
|
||
)
|
||
is_piano = (
|
||
not t['is_drums']
|
||
and not t['string_pitches']
|
||
and t['midi_program'] in set(range(0, 8)) | set(range(16, 24)) | {80, 81, 82, 83}
|
||
) or (
|
||
not t['is_drums']
|
||
and any(kw in t['name'].lower() for kw in ('piano', 'keys', 'keyboard', 'organ'))
|
||
)
|
||
|
||
is_vocal = _is_vocal_track(t)
|
||
|
||
result.append({
|
||
'index': i,
|
||
'name': t['name'],
|
||
'strings': len(t['string_pitches']),
|
||
'is_percussion': t['is_drums'],
|
||
'is_piano': is_piano,
|
||
'is_drums': t['is_drums'],
|
||
'is_bass': is_bass,
|
||
'is_vocal': is_vocal,
|
||
'instrument': t['midi_program'],
|
||
'notes': t['note_count'],
|
||
})
|
||
return result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# convert_file — mirrors gp2rs.convert_file interface
|
||
# Converts GPX tracks directly to arrangement XML, reusing gp2rs._build_xml
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
|
||
def _find_piano_pairs(
|
||
track_indices: list[int],
|
||
tracks: list[dict],
|
||
names: dict[int, str],
|
||
) -> tuple[list[int], dict[int, int]]:
|
||
"""
|
||
Detect Piano LH/RH track pairs and return a merge map.
|
||
|
||
Guitar Pro 6 models piano tracks on guitar/bass string templates (no native
|
||
keyboard type). A typical GPX piano part has two tracks — e.g. "Piano RH"
|
||
(7-string guitar, treble range) and "Piano LH" (5-string bass, bass range).
|
||
Importing them separately gives two Keys arrangements shown one at a time.
|
||
Merging them gives a single full-width Synthesia-style chart in the piano
|
||
highway, with both hands falling onto the correct keys simultaneously.
|
||
|
||
Detection: among the keys tracks being imported, find pairs where both share
|
||
a common stem and one name ends in 'rh' and the other in 'lh'
|
||
(case-insensitive, word-boundary matched).
|
||
|
||
Returns:
|
||
filtered_indices — track_indices with LH tracks removed (consumed by merge)
|
||
merge_map — {rh_track_idx: lh_track_idx}
|
||
"""
|
||
keys_tracks = [
|
||
i for i in track_indices
|
||
if 0 <= i < len(tracks)
|
||
and (
|
||
any(kw in tracks[i]['name'].lower()
|
||
for kw in ('piano', 'keys', 'keyboard'))
|
||
or names.get(i, '').lower().startswith('keys')
|
||
)
|
||
]
|
||
|
||
merge_map: dict[int, int] = {}
|
||
consumed: set[int] = set()
|
||
|
||
for i in keys_tracks:
|
||
if i in consumed:
|
||
continue
|
||
low_i = tracks[i]['name'].strip().lower()
|
||
if not re.search(r'\brh\b', low_i):
|
||
continue
|
||
stem = re.sub(r'\s*\brh\b\s*$', '', low_i).strip()
|
||
for j in keys_tracks:
|
||
if j == i or j in consumed:
|
||
continue
|
||
low_j = tracks[j]['name'].strip().lower()
|
||
if not re.search(r'\blh\b', low_j):
|
||
continue
|
||
stem_j = re.sub(r'\s*\blh\b\s*$', '', low_j).strip()
|
||
if stem_j == stem:
|
||
merge_map[i] = j
|
||
consumed.add(j)
|
||
break
|
||
|
||
filtered = [i for i in track_indices if i not in consumed]
|
||
return filtered, merge_map
|
||
|
||
|
||
|
||
def _collect_tone_events(
|
||
raw_idx: int,
|
||
masterbars: list,
|
||
bars_by_id: dict,
|
||
voices_dict: dict,
|
||
beats_dict: dict,
|
||
rhythms_dict: dict,
|
||
tempo_map: list,
|
||
audio_offset: float,
|
||
*,
|
||
tempo_bpm: float = 120.0,
|
||
) -> list[tuple[float, str]]:
|
||
"""
|
||
Extract tone change events for one track from its beat-level <Bank> elements.
|
||
|
||
Guitar Pro 6 stores the active RSE sound preset name as a <Bank> child on
|
||
the first beat of any bar where the sound is set or changes. We deduplicate
|
||
consecutive identical names so only genuine transitions are emitted.
|
||
|
||
Returns a list of (time_secs, bank_name) pairs, sorted by time, with
|
||
audio_offset applied. Empty list if the track has no Bank elements.
|
||
"""
|
||
current_time = 0.0
|
||
events: list[tuple[float, str]] = []
|
||
|
||
tempo_iter = iter(tempo_map)
|
||
next_tempo_bar, next_tempo_bpm = next(tempo_iter, (999999, 120.0))
|
||
# Start at the song's base tempo (same as convert_file's note builder); the
|
||
# per-bar loop applies each tempo change once its bar index is reached.
|
||
# Seeding from tempo_map[0] would wrongly apply a first tempo event that
|
||
# begins at a later bar to the bars before it, and hardcoding 120 would
|
||
# drift tone timestamps off the note timeline when the base tempo isn't 120.
|
||
cur_tempo = tempo_bpm
|
||
|
||
for mb_idx, mb in enumerate(masterbars):
|
||
# Advance tempo
|
||
while mb_idx >= next_tempo_bar:
|
||
cur_tempo = next_tempo_bpm
|
||
next_tempo_bar, next_tempo_bpm = next(tempo_iter, (999999, cur_tempo))
|
||
|
||
time_sig = mb.findtext('Time', '4/4')
|
||
try:
|
||
num_b, den_b = [int(x) for x in time_sig.split('/')]
|
||
except ValueError:
|
||
num_b, den_b = 4, 4
|
||
bar_duration = num_b * (4.0 / den_b) * (60.0 / cur_tempo)
|
||
|
||
bar_ids = mb.findtext('Bars', '').split()
|
||
bid = bar_ids[raw_idx] if raw_idx < len(bar_ids) else '-1'
|
||
|
||
if bid != '-1' and bid:
|
||
bar = bars_by_id.get(bid)
|
||
if bar is not None:
|
||
for vid in bar.findtext('Voices', '').split():
|
||
if vid == '-1':
|
||
continue
|
||
voice = voices_dict.get(vid)
|
||
if voice is None:
|
||
continue
|
||
voice_time = current_time
|
||
for beat_id in voice.findtext('Beats', '').split():
|
||
beat_el = beats_dict.get(beat_id)
|
||
if beat_el is None:
|
||
continue
|
||
dur = _beat_dur_secs(beat_el, rhythms_dict, cur_tempo)
|
||
bank = (beat_el.findtext('Bank') or '').strip()
|
||
if bank:
|
||
events.append((round(voice_time + audio_offset, 3), bank))
|
||
voice_time += dur
|
||
|
||
current_time += bar_duration
|
||
|
||
# Sort by time first, THEN drop consecutive duplicates. Multi-voice bars
|
||
# reset voice_time per voice so traversal order isn't chronological;
|
||
# deduping during traversal could drop an earlier-in-time tone change in a
|
||
# later-traversed voice. Collapsing only after sorting keeps the earliest
|
||
# change for each transition.
|
||
events.sort(key=lambda e: e[0])
|
||
deduped: list[tuple[float, str]] = []
|
||
for ev in events:
|
||
if not deduped or ev[1] != deduped[-1][1]:
|
||
deduped.append(ev)
|
||
return deduped
|
||
|
||
|
||
|
||
def _inject_tones(xml_str: str, tone_events: list[tuple[float, str]]) -> str:
|
||
"""
|
||
Inject a <tones> element into a chart arrangement XML string.
|
||
|
||
Parses the prettified XML returned by _build_xml, inserts the tones
|
||
block before </song>, and re-serialises. Noop if tone_events is empty.
|
||
|
||
Each entry in tone_events is (time_secs, name). Consecutive duplicates
|
||
are already removed by _collect_tone_events; here we assign sequential
|
||
integer IDs as required by the RS schema.
|
||
"""
|
||
if not tone_events:
|
||
return xml_str
|
||
|
||
import xml.etree.ElementTree as _ET
|
||
from xml.dom import minidom as _minidom
|
||
|
||
try:
|
||
root = _ET.fromstring(xml_str)
|
||
except _ET.ParseError:
|
||
return xml_str # don't corrupt XML on parse failure
|
||
|
||
# Record the initial tone as <tonebase> (unless one already exists) so tone
|
||
# extraction (lib/tones.py::_xml_tone_changes) and loose-XML playback know
|
||
# the base tone instead of leaving it empty.
|
||
_base_el = root.find('tonebase')
|
||
if _base_el is None:
|
||
_ET.SubElement(root, 'tonebase').text = tone_events[0][1]
|
||
elif not (_base_el.text or '').strip():
|
||
# Populate an empty/whitespace <tonebase> too — leaving it blank would
|
||
# defeat the point of recording the base tone. A non-empty existing
|
||
# base is preserved.
|
||
_base_el.text = tone_events[0][1]
|
||
|
||
# Replace ALL pre-existing <tones> blocks rather than appending another
|
||
# (idempotent if this is somehow called twice, or the source XML already
|
||
# carried one or more) — duplicate <tones> sections break tone parsing.
|
||
for existing_tones in root.findall('tones'):
|
||
root.remove(existing_tones)
|
||
|
||
tones_el = _ET.SubElement(root, 'tones', count=str(len(tone_events)))
|
||
for i, (t, name) in enumerate(tone_events):
|
||
_ET.SubElement(tones_el, 'tone',
|
||
id=str(i),
|
||
name=name,
|
||
time=f'{t:.3f}')
|
||
|
||
# _build_xml already pretty-printed the input, so parsing it leaves the
|
||
# indentation as whitespace-only text/tail nodes. Drop those before the
|
||
# second pretty-print, otherwise minidom preserves them and stacks fresh
|
||
# indentation on top, exploding the output with blank lines.
|
||
def _strip_ws(el: _ET.Element) -> None:
|
||
if el.text is not None and not el.text.strip():
|
||
el.text = None
|
||
if el.tail is not None and not el.tail.strip():
|
||
el.tail = None
|
||
for child in el:
|
||
_strip_ws(child)
|
||
|
||
_strip_ws(root)
|
||
raw = _ET.tostring(root, encoding='unicode')
|
||
dom = _minidom.parseString(raw)
|
||
return dom.toprettyxml(indent=' ', encoding=None)
|
||
|
||
|
||
|
||
def convert_vocal_track_to_pitch_sidecar(
|
||
root: ET.Element,
|
||
track: dict,
|
||
raw_idx: int,
|
||
masterbars: list,
|
||
bars_by_id: dict,
|
||
voices_dict: dict,
|
||
beats_dict: dict,
|
||
notes_dict: dict,
|
||
rhythms_dict: dict,
|
||
*,
|
||
tempo_bpm: float = 120.0,
|
||
audio_offset: float = 0.0,
|
||
) -> dict:
|
||
"""
|
||
Extract per-syllable pitch from a GPX vocal track as a vocal_pitch.json dict.
|
||
|
||
Returns the same payload shape as the lyrics-karaoke plugin's
|
||
``_persist_pitch`` function so GPX vocal pitch can be written directly
|
||
into a sloppak alongside lyrics.json:
|
||
|
||
{"version": 1, "notes": [{"t": float, "d": float, "midi": int}, ...]}
|
||
|
||
This is complementary to convert_vocal_track() which produces arrangement XML.
|
||
NOTE: nothing in this module calls this helper yet — convert_file() does not
|
||
invoke it, so no vocal_pitch.json is emitted automatically. A caller wanting
|
||
the pitch ribbon must call this itself and persist the returned dict (e.g.
|
||
write it as vocal_pitch.json into the sloppak). When wiring vocals into a
|
||
sloppak, call both:
|
||
- convert_vocal_track() → vocals arrangement XML (karaoke highway)
|
||
- convert_vocal_track_to_pitch_sidecar() → vocal_pitch.json (pitch ribbon)
|
||
|
||
Pitch source is the tab author's authored notes (exact), not AI audio
|
||
analysis — so this is more accurate than pYIN/CREPE for well-authored tabs.
|
||
"""
|
||
string_pitches = track['string_pitches']
|
||
notes: list[dict] = []
|
||
current_time = 0.0
|
||
|
||
# Per-bar tempo from map. _build_tempo_map() returns a synthetic [(0, 120)]
|
||
# when the file has no explicit tempo automations — in that case honour the
|
||
# caller-supplied tempo_bpm fallback instead of silently forcing 120 BPM.
|
||
_mt = root.find('MasterTrack')
|
||
_has_explicit_tempo = _mt is not None and any(
|
||
a.findtext('Type') == 'Tempo' for a in _mt.findall('.//Automations/*')
|
||
)
|
||
_tp = _build_tempo_map(root) if _has_explicit_tempo else [(0, tempo_bpm)]
|
||
_tp_iter = iter(_tp)
|
||
_next_bar, _next_bpm = next(_tp_iter, (999999, tempo_bpm))
|
||
_cur_tempo = tempo_bpm
|
||
|
||
for mb_idx, mb in enumerate(masterbars):
|
||
while mb_idx >= _next_bar:
|
||
_cur_tempo = _next_bpm
|
||
_next_bar, _next_bpm = next(_tp_iter, (999999, _cur_tempo))
|
||
|
||
time_sig = mb.findtext('Time', '4/4')
|
||
try:
|
||
num_b, den_b = [int(x) for x in time_sig.split('/')]
|
||
except ValueError:
|
||
num_b, den_b = 4, 4
|
||
bar_duration = num_b * (4.0 / den_b) * (60.0 / _cur_tempo)
|
||
|
||
bar_ids = mb.findtext('Bars', '').split()
|
||
bid = bar_ids[raw_idx] if raw_idx < len(bar_ids) else '-1'
|
||
|
||
if bid != '-1' and bid:
|
||
bar = bars_by_id.get(bid)
|
||
if bar is not None:
|
||
for vid in bar.findtext('Voices', '').split():
|
||
if vid == '-1':
|
||
continue
|
||
voice = voices_dict.get(vid)
|
||
if voice is None:
|
||
continue
|
||
voice_time = current_time
|
||
for beat_id in voice.findtext('Beats', '').split():
|
||
beat_el = beats_dict.get(beat_id)
|
||
if beat_el is None:
|
||
continue
|
||
dur = _beat_dur_secs(beat_el, rhythms_dict, _cur_tempo)
|
||
|
||
# Only emit notes that have a lyric — unvoiced beats
|
||
# (rests, instrumental fills) are excluded so the
|
||
# pitch ribbon stays aligned with lyric tokens.
|
||
lyric_el = beat_el.find('Lyrics')
|
||
has_lyric = (
|
||
lyric_el is not None
|
||
and lyric_el.find('Line') is not None
|
||
and (lyric_el.find('Line').text or '').strip()
|
||
)
|
||
if not has_lyric:
|
||
voice_time += dur
|
||
continue
|
||
|
||
notes_text = beat_el.findtext('Notes', '').strip()
|
||
for nid in notes_text.split():
|
||
note_el = notes_dict.get(nid)
|
||
if note_el is None:
|
||
continue
|
||
if _note_is_tie(note_el):
|
||
# Extend previous note's duration
|
||
if notes:
|
||
notes[-1]['d'] = round(
|
||
max(notes[-1]['d'],
|
||
(voice_time + audio_offset + dur) - notes[-1]['t']),
|
||
3,
|
||
)
|
||
continue
|
||
midi = _note_midi(note_el, string_pitches)
|
||
if midi is None:
|
||
continue
|
||
notes.append({
|
||
't': round(voice_time + audio_offset, 3),
|
||
'd': round(dur, 3),
|
||
'midi': midi,
|
||
})
|
||
break # one pitch per vocal beat
|
||
|
||
voice_time += dur
|
||
|
||
current_time += bar_duration
|
||
|
||
# Sort chronologically: multi-voice bars reset voice_time per voice, so
|
||
# traversal order isn't time-ordered. A time-series JSON should be sorted by
|
||
# onset for deterministic output and consumers that assume chronology.
|
||
notes.sort(key=lambda n: n['t'])
|
||
return {'version': 1, 'notes': notes}
|
||
|
||
|
||
def _notes_by_id(root: ET.Element) -> dict:
|
||
"""Map note id -> Note element from the GPIF `<Notes>` pool, tolerating
|
||
malformed pools that contain duplicate ids.
|
||
|
||
Some exporters emit each fretted note twice under the same id: a real
|
||
tablature note (String/Fret/Midi) followed by a degenerate
|
||
articulation-only twin (placeholder ConcertPitch + `<InstrumentArticulation>`,
|
||
no String/Fret). A plain dict comprehension keeps the *last* occurrence, so
|
||
every duplicated id resolves to the degenerate twin and that track's notes
|
||
silently vanish (observed: a whole guitar importing as 0 notes). Prefer the
|
||
note that carries String+Fret so dup-id'd guitar notes still decode; for
|
||
genuinely single notes (incl. drum articulation notes) behaviour is
|
||
unchanged.
|
||
"""
|
||
def _has_string_fret(el: ET.Element) -> bool:
|
||
names = {p.get('name') for p in el.findall('.//Property')}
|
||
return 'String' in names and 'Fret' in names
|
||
|
||
out: dict = {}
|
||
for n in (root.find('Notes') or []):
|
||
nid = n.get('id')
|
||
if nid is None:
|
||
continue
|
||
prev = out.get(nid)
|
||
if prev is None or (_has_string_fret(n) and not _has_string_fret(prev)):
|
||
out[nid] = n
|
||
return out
|
||
|
||
|
||
def _gpx_bend_scale(root: ET.Element) -> float:
|
||
"""Return the divisor that converts GPIF bend `<Float>` values to semitones.
|
||
|
||
GPIF bend magnitudes aren't on a single scale across exporters: some files
|
||
use ~100 for a whole-tone bend (≈50 per semitone), others use ~2500 per
|
||
semitone. Sniff the file's peak bend value and pick the matching divisor so
|
||
imported bend amounts land in a sane semitone range either way.
|
||
"""
|
||
peak = 0.0
|
||
for p in root.iter('Property'):
|
||
if p.get('name') in ('BendDestinationValue', 'BendMiddleValue',
|
||
'BendOriginValue'):
|
||
try:
|
||
peak = max(peak, float(p.findtext('Float') or 0))
|
||
except (ValueError, TypeError):
|
||
pass
|
||
# Small-scale files top out around a few hundred (100 = whole tone → /50);
|
||
# large-scale files use ~2500 per semitone.
|
||
return 50.0 if peak <= 400 else 2500.0
|
||
|
||
|
||
def _gpx_bend_float(tp: dict, name: str):
|
||
"""Read a GPIF bend `<Property><Float>` value from the property map, or None."""
|
||
el = tp.get(name)
|
||
if el is None:
|
||
return None
|
||
try:
|
||
return float(el.findtext('Float') or 0)
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
|
||
def _gpx_bend_shape(tp: dict, divisor: float, sustain: float):
|
||
"""Build ``(peak, intent, curve)`` from a GPIF note's bend Properties (§6.2.1).
|
||
|
||
GPIF describes a bend as origin / middle / destination value+offset pairs;
|
||
`value / divisor` is semitones (divisor auto-detected per file) and the
|
||
`*Offset` Properties are 0..100 (percent of the note's duration). Produces a
|
||
bnv curve of up to three points (mapping each offset to seconds-from-onset),
|
||
or ``None`` when there's no usable shape (no points, flat-zero, or a
|
||
zero-length note). When an offset Property is absent the stage falls back to
|
||
an evenly-spaced default (origin 0%, middle 50%, destination 100%).
|
||
|
||
NOTE: offset Property names should be confirmed against a real GP8 export;
|
||
the value path matches the existing scalar-bend extraction either way."""
|
||
from gp2rs import _bend_intent_from_values # lazy: gp2rs<->gpx circular
|
||
stages = (
|
||
('BendOriginValue', 'BendOriginOffset', 0.0),
|
||
('BendMiddleValue', 'BendMiddleOffset1', 50.0),
|
||
('BendDestinationValue', 'BendDestinationOffset', 100.0),
|
||
)
|
||
pts = []
|
||
for vkey, okey, default_off in stages:
|
||
v = _gpx_bend_float(tp, vkey)
|
||
if v is None:
|
||
continue
|
||
off = _gpx_bend_float(tp, okey)
|
||
if off is None:
|
||
off = default_off
|
||
off = max(0.0, min(100.0, off))
|
||
pts.append((off, round(v / divisor, 1)))
|
||
if not pts:
|
||
return 0.0, 0, None
|
||
pts.sort(key=lambda p: p[0])
|
||
values = [v for _, v in pts]
|
||
peak = round(max(values), 1)
|
||
intent = _bend_intent_from_values(values)
|
||
curve = None
|
||
if peak > 0 and sustain > 0 and len(pts) >= 2:
|
||
curve = [{"t": round(sustain * (off / 100.0), 3), "v": v}
|
||
for off, v in pts]
|
||
return peak, intent, curve
|
||
|
||
|
||
def _resolve_pending_slides(rs_notes, rs_chords, pending_slides):
|
||
"""Resolve GP slide flags collected during the beat loop into RS slide
|
||
fields, now that every note on each string is known.
|
||
|
||
``pending_slides`` is a list of ``(RsNote, rs_string, gp_slide_flags)`` or
|
||
``(RsNote, rs_string, gp_slide_flags, is_grace)``. Flag bits: 1/2 = pitched
|
||
shift/legato slide to the next note on the string; 4 = unpitched slide out
|
||
(down); 8 = unpitched slide out (up).
|
||
|
||
Grace notes are imported as shift-slides into the following main note, but a
|
||
grace beat is short so the converter's >0.2s rule zeroed its sustain — and
|
||
the highway only draws a slide trail when ``sus > 0`` (highway_3d
|
||
``slideOffsetWorldX``), so a zero-sustain slide is invisible. For any
|
||
zero-sustain shift-slide we stretch the sustain to span the gap up to its
|
||
slide target so the slide renders; already-sustained (normal) slides keep
|
||
their authored sustain.
|
||
|
||
``link_next`` (which tells the highway to suppress the target note's gem so
|
||
the slide visually connects) is set ONLY for normal slides. A grace note is
|
||
an ornament *before* the principal note, so its target IS re-struck and must
|
||
keep its gem — setting link_next there hid the main note and made the grace
|
||
look unrendered. Grace slides therefore get slide_to + sustain but NOT
|
||
link_next: a quick grace flick into a still-visible main note.
|
||
"""
|
||
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 _item in pending_slides:
|
||
_rn, _sstr, _flags = _item[0], _item[1], _item[2]
|
||
_is_grace = _item[3] if len(_item) > 3 else False
|
||
if _flags & 0b0011: # shift / legato → pitched slide to next
|
||
_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
|
||
if not _is_grace:
|
||
# Normal slide: suppress the target gem so the slide connects
|
||
# (matches GP5). A grace target is re-struck — keep its gem.
|
||
_rn.link_next = True
|
||
if _rn.sustain <= 0:
|
||
_gap = _nxt.time - _rn.time
|
||
if _gap > 0:
|
||
_rn.sustain = _gap
|
||
elif _flags & 0b0100: # slide out, downwards (unpitched)
|
||
_rn.slide_unpitch_to = max(1, _rn.fret - 5)
|
||
elif _flags & 0b1000: # slide out, upwards (unpitched)
|
||
_rn.slide_unpitch_to = _rn.fret + 5
|
||
|
||
|
||
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 .gpx file to arrangement XML arrangement files.
|
||
|
||
Mirrors gp2rs.convert_file so the editor plugin can call it transparently.
|
||
expand_repeats is accepted for API compatibility but repeat expansion from
|
||
GPX XML is not yet implemented (the GPIF repeat markup differs from GP5
|
||
binary and requires a separate walker — planned for a follow-up PR).
|
||
"""
|
||
root = _load_gpif(gp_path)
|
||
out = Path(output_dir)
|
||
out.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Metadata
|
||
score = root.find('Score')
|
||
title = (score.findtext('Title') or '').strip() if score is not None else ''
|
||
artist = (score.findtext('Artist') or '').strip() if score is not None else ''
|
||
album = (score.findtext('Album') or '').strip() if score is not None else ''
|
||
tempo_bpm = _gpif_tempo(root) # initial tempo (used as fallback)
|
||
tempo_map = _build_tempo_map(root) # full bar->bpm map for multi-tempo songs
|
||
|
||
tracks = _gpif_tracks(root)
|
||
|
||
if track_indices is None:
|
||
track_indices, auto_names = _auto_select_gpx(tracks)
|
||
if not arrangement_names:
|
||
arrangement_names = auto_names
|
||
|
||
names = arrangement_names or {}
|
||
|
||
# Pre-build lookup tables (shared across all tracks)
|
||
masterbars = list(root.find('MasterBars') or [])
|
||
|
||
# expand_repeats is accepted for gp2rs.convert_file parity but GPIF repeat
|
||
# expansion (Repeat / AlternateEndings / Directions) is not implemented yet.
|
||
# Surface that to the caller rather than only the docstring: if the score
|
||
# actually uses repeats, the produced bar count/timing will differ from the
|
||
# equivalent .gp5. Warn once so plugin code/logs don't silently drift.
|
||
if expand_repeats and any(
|
||
mb.find('Repeat') is not None or mb.find('AlternateEndings') is not None
|
||
for mb in masterbars
|
||
):
|
||
_log.warning(
|
||
"GPX '%s' contains repeat/volta markup but GPX repeat expansion is "
|
||
"not implemented; arrangement is emitted single-pass and may not "
|
||
"match the equivalent .gp5.", gp_path
|
||
)
|
||
bars_list = list(root.find('Bars') or [])
|
||
bars_by_id = {b.get('id'): b for b in bars_list}
|
||
voices_dict = {v.get('id'): v for v in (root.find('Voices') or [])}
|
||
beats_dict = {b.get('id'): b for b in (root.find('Beats') or [])}
|
||
notes_dict = _notes_by_id(root)
|
||
rhythms_dict = {r.get('id'): r for r in (root.find('Rhythms') or [])}
|
||
_bend_divisor = _gpx_bend_scale(root) # GPIF bend value -> semitones
|
||
|
||
# Map filtered track index -> raw track index (needed for bar lookup)
|
||
raw_tracks = list(root.find('Tracks') or [])
|
||
filtered_to_raw: dict[int, int] = {}
|
||
filtered_pos = 0
|
||
for raw_idx, t_el in enumerate(raw_tracks):
|
||
name = (t_el.findtext('Name') or '').strip()
|
||
if name.startswith('@$') and name.endswith('$@'):
|
||
continue
|
||
filtered_to_raw[filtered_pos] = raw_idx
|
||
filtered_pos += 1
|
||
|
||
|
||
# Detect and merge Piano LH+RH pairs into single full-keyboard arrangements
|
||
track_indices, _piano_merge_map = _find_piano_pairs(track_indices, tracks, names)
|
||
|
||
output_files = []
|
||
# All auto-assigned arrangement names handed out so far, so multiple
|
||
# arrangements get distinct labels (Lead, Rhythm, Combo, Bass, Bass 2, …)
|
||
# and the name-aware and positional guitar paths never collide.
|
||
_used_arr_names: set[str] = set()
|
||
|
||
def _unique_arr_name(base: str) -> str:
|
||
"""Return `base`, or `base 2`/`base 3`/… if it's already been used."""
|
||
if base not in _used_arr_names:
|
||
_used_arr_names.add(base)
|
||
return base
|
||
k = 2
|
||
while f"{base} {k}" in _used_arr_names:
|
||
k += 1
|
||
name = f"{base} {k}"
|
||
_used_arr_names.add(name)
|
||
return name
|
||
|
||
_KEYS_PROGS = set(range(0, 8)) | set(range(16, 24)) | {80, 81, 82, 83}
|
||
|
||
def _auto_guitar_hint(track_idx: int):
|
||
"""For a track that auto-resolves to a guitar arrangement, return its
|
||
role hint: 'lead', 'rhythm', or '' (unhinted). None when the track is
|
||
NOT an auto-named guitar (explicitly named, bass, drum, vocal, keys).
|
||
Mirrors the per-track classification in the conversion loop below."""
|
||
if track_idx >= len(tracks) or names.get(track_idx):
|
||
return None
|
||
t = tracks[track_idx]
|
||
if t['is_drums'] or _is_vocal_track(t):
|
||
return None
|
||
low = t['name'].lower()
|
||
sp = t['string_pitches']
|
||
prog = t['midi_program']
|
||
if (isinstance(prog, int) and 32 <= prog <= 39) or (bool(sp) and max(sp) <= 48) or 'bass' in low:
|
||
return None # bass
|
||
if (not sp and prog in _KEYS_PROGS) or any(kw in low for kw in ('piano', 'keys', 'keyboard', 'organ')):
|
||
return None # keys
|
||
if 'lead' in low and 'rhythm' not in low:
|
||
return 'lead'
|
||
if 'rhythm' in low and 'lead' not in low:
|
||
return 'rhythm'
|
||
return '' # guitar, no role hint
|
||
|
||
# Two-pass guitar role naming, resolved up front so the per-track loop just
|
||
# looks names up. Reserve every name-hinted Lead/Rhythm first, THEN fill
|
||
# unhinted guitars into the remaining canonical roles. A single pass would
|
||
# let an unhinted guitar that appears BEFORE a hinted one steal its role,
|
||
# pushing the real Lead/Rhythm to a non-canonical "Rhythm 2" that the
|
||
# downstream name-based path classification doesn't recognise.
|
||
_guitar_name_by_idx: dict[int, str] = {}
|
||
_unhinted_guitars: list[int] = []
|
||
for _ti in track_indices:
|
||
hint = _auto_guitar_hint(_ti)
|
||
if hint is None:
|
||
continue
|
||
if hint == 'lead':
|
||
_guitar_name_by_idx[_ti] = _unique_arr_name('Lead')
|
||
elif hint == 'rhythm':
|
||
_guitar_name_by_idx[_ti] = _unique_arr_name('Rhythm')
|
||
else:
|
||
_unhinted_guitars.append(_ti)
|
||
for _ti in _unhinted_guitars:
|
||
base = next((r for r in ('Lead', 'Rhythm', 'Combo') if r not in _used_arr_names), 'Combo')
|
||
_guitar_name_by_idx[_ti] = _unique_arr_name(base)
|
||
|
||
for track_idx in track_indices:
|
||
if track_idx >= len(tracks):
|
||
continue
|
||
track = tracks[track_idx]
|
||
arr_name = names.get(track_idx, '')
|
||
raw_idx = filtered_to_raw.get(track_idx, track_idx)
|
||
|
||
# Decide conversion mode
|
||
is_drum = track['is_drums'] or (arr_name.lower().startswith('drums'))
|
||
is_vocal = _is_vocal_track(track) or arr_name.lower().startswith('vocal')
|
||
# GP7/GP8 percussion: build the InstrumentArticulation-index → GM MIDI
|
||
# map once per drum track so each note's <InstrumentArticulation> child
|
||
# resolves to a real drum MIDI in `_note_midi` (Encoding 0).
|
||
_perc_midis = _gpx_percussion_midis(track.get('_el')) if is_drum else None
|
||
# GP6 models piano/keys parts on guitar/bass string templates, so a
|
||
# named keyboard track usually HAS string_pitches. Gate only the
|
||
# midi_program heuristic on `not string_pitches` (to avoid grabbing real
|
||
# guitars); a name/arrangement keyword forces keys regardless, so these
|
||
# tracks take the keys encoding and the LH/RH merge map actually applies.
|
||
is_keys = (
|
||
not is_drum and not is_vocal
|
||
and (
|
||
any(kw in track['name'].lower() for kw in ('piano', 'keys', 'keyboard', 'organ'))
|
||
or arr_name.lower().startswith('keys')
|
||
or (
|
||
not track['string_pitches']
|
||
and track['midi_program'] in set(range(0, 8)) | set(range(16, 24)) | {80, 81, 82, 83}
|
||
)
|
||
)
|
||
)
|
||
|
||
# Default arrangement name for guitar/bass tracks when the caller didn't
|
||
# supply one (the editor passes track_indices without names). Mirror
|
||
# gp2rs: detect bass by MIDI program (32-39 Bass family) or the top
|
||
# string's pitch (<= C3/48, which holds for 4/5/6-string bass), else
|
||
# fall back to a name keyword. Without this every GP8 guitar/bass track
|
||
# defaulted to "Lead", so basses imported as guitar arrangements.
|
||
if not arr_name and not is_drum and not is_vocal and not is_keys:
|
||
sp = track['string_pitches']
|
||
prog = track['midi_program']
|
||
is_bass = (
|
||
(isinstance(prog, int) and 32 <= prog <= 39)
|
||
or (bool(sp) and max(sp) <= 48)
|
||
)
|
||
low = track['name'].lower()
|
||
if is_bass or 'bass' in low:
|
||
arr_name = _unique_arr_name('Bass')
|
||
else:
|
||
# Guitar role was resolved up front (two-pass, honoring
|
||
# "lead"/"rhythm" in the GP track name so a Rhythm-before-Lead
|
||
# file isn't swapped by positional assignment).
|
||
arr_name = _guitar_name_by_idx.get(track_idx) or _unique_arr_name('Lead')
|
||
|
||
# Vocal tracks get their own converter — outputs vocals XML, not notes XML
|
||
if is_vocal:
|
||
xml_str = convert_vocal_track(
|
||
root, track, raw_idx,
|
||
masterbars, bars_by_id, voices_dict, beats_dict, notes_dict, rhythms_dict,
|
||
title=title, artist=artist, album=album, tempo_bpm=tempo_bpm,
|
||
audio_offset=audio_offset, arr_name=arr_name or 'Vocals',
|
||
)
|
||
filename = f"{_safe_filename_stem(track['name'])}_{arr_name or 'Vocals'}.xml"
|
||
filepath = safe_join(out, filename)
|
||
if filepath is None:
|
||
raise ValueError(f"unsafe output filename from track name: {track['name']!r}")
|
||
filepath.write_text(xml_str)
|
||
output_files.append(str(filepath))
|
||
continue
|
||
|
||
# Iterate all masterbars and collect notes for this track
|
||
from gp2rs import RsNote, RsBeat, RsSection, RsAnchor, ChordTemplate, RsChord, _build_xml
|
||
|
||
rs_notes: list[RsNote] = []
|
||
rs_chords: list[RsChord] = []
|
||
chord_templates: list[ChordTemplate] = []
|
||
chord_template_map: dict[tuple, int] = {}
|
||
# Authored chord diagrams (name + per-string fingering) for this track,
|
||
# keyed by fret pattern so they enrich matching played voicings.
|
||
chord_diagram_map = _parse_chord_diagrams(
|
||
track.get('_el'), track['string_pitches'])
|
||
beats_out: list[RsBeat] = []
|
||
sections: list[RsSection] = []
|
||
section_counts: dict[str, int] = {}
|
||
last_note_per_key: dict = {}
|
||
last_note_per_string: dict = {} # rs_string -> last RsNote (for HO/PO direction)
|
||
pending_slides: list = [] # (RsNote, rs_string, gp_slide_flags) — resolved post-loop
|
||
|
||
current_time = 0.0
|
||
num_raw_tracks = len(raw_tracks)
|
||
|
||
# Resolve current tempo per bar from the tempo map
|
||
_tempo_iter = iter(tempo_map)
|
||
_next_tempo_bar, _next_tempo_bpm = next(_tempo_iter, (999999, tempo_bpm))
|
||
_cur_tempo = tempo_bpm
|
||
|
||
for mb_idx_loop, mb in enumerate(masterbars):
|
||
# Advance tempo map
|
||
while mb_idx_loop >= _next_tempo_bar:
|
||
_cur_tempo = _next_tempo_bpm
|
||
_next_tempo_bar, _next_tempo_bpm = next(_tempo_iter, (999999, _cur_tempo))
|
||
|
||
time_sig = mb.findtext('Time', '4/4')
|
||
try:
|
||
num_b, den_b = [int(x) for x in time_sig.split('/')]
|
||
except ValueError:
|
||
num_b, den_b = 4, 4
|
||
beats_per_bar = num_b * (4.0 / den_b)
|
||
bar_duration = beats_per_bar * (60.0 / _cur_tempo)
|
||
# One beat = one denominator unit, NOT always a quarter note. For
|
||
# 6/8, 3/8, 12/8 etc. the beat is shorter than a quarter, so scale
|
||
# by (4/den_b); otherwise markers spread at quarter spacing and run
|
||
# past the bar end on compound/non-quarter meters.
|
||
beat_len = (60.0 / _cur_tempo) * (4.0 / den_b)
|
||
|
||
# Downbeat
|
||
beats_out.append(RsBeat(time=current_time + audio_offset, measure=-999)) # placeholder
|
||
for sub_b in range(1, num_b):
|
||
beats_out.append(RsBeat(
|
||
time=current_time + sub_b * beat_len + audio_offset,
|
||
measure=-1,
|
||
))
|
||
|
||
# Section markers
|
||
section_el = mb.find('Section')
|
||
if section_el is not None:
|
||
text = (section_el.findtext('Text') or '').strip()
|
||
if text:
|
||
sname = text.lower().replace(' ', '')
|
||
section_counts[sname] = section_counts.get(sname, 0) + 1
|
||
sections.append(RsSection(
|
||
name=sname,
|
||
time=current_time + audio_offset,
|
||
number=section_counts[sname],
|
||
))
|
||
|
||
# Get this track's bar
|
||
bar_ids = mb.findtext('Bars', '').split()
|
||
bid = bar_ids[raw_idx] if raw_idx < len(bar_ids) else '-1'
|
||
|
||
if bid != '-1' and bid:
|
||
bar = bars_by_id.get(bid)
|
||
if bar is not None:
|
||
for vid in bar.findtext('Voices', '').split():
|
||
if vid == '-1':
|
||
continue
|
||
voice = voices_dict.get(vid)
|
||
if voice is None:
|
||
continue
|
||
|
||
voice_time = current_time
|
||
for beat_id in voice.findtext('Beats', '').split():
|
||
beat_el = beats_dict.get(beat_id)
|
||
if beat_el is None:
|
||
continue
|
||
|
||
dur = _beat_dur_secs(beat_el, rhythms_dict, _cur_tempo)
|
||
t = voice_time + audio_offset
|
||
|
||
notes_text = beat_el.findtext('Notes', '').strip()
|
||
if notes_text:
|
||
beat_note_els = [
|
||
notes_dict[nid]
|
||
for nid in notes_text.split()
|
||
if nid in notes_dict
|
||
]
|
||
|
||
beat_rs_notes = []
|
||
for note_el in beat_note_els:
|
||
if _note_is_tie(note_el):
|
||
# Extend sustain of previous note at same pitch
|
||
midi = _note_midi(note_el, track['string_pitches'], _perc_midis)
|
||
if midi is not None:
|
||
prev = last_note_per_key.get(midi)
|
||
if prev is not None and prev.time < t:
|
||
prev.sustain = max(prev.sustain, (t + dur) - prev.time)
|
||
continue
|
||
|
||
midi = _note_midi(note_el, track['string_pitches'], _perc_midis)
|
||
if midi is None:
|
||
continue
|
||
|
||
if is_drum:
|
||
# GP6 decodes drum pieces to their real GM
|
||
# MIDI numbers directly (splash 55, china
|
||
# 52, ride bell 53, pedal hi-hat 44, …), so
|
||
# no GM_DRUM_MAP gate is needed — that gate
|
||
# only exists on the GP3-5 path because
|
||
# pyguitarpro emits raw MIDI. The generic
|
||
# midi -> (string, fret) encoding below
|
||
# represents any drum piece, so filtering
|
||
# here would silently drop valid pieces.
|
||
rs_str = midi // 24
|
||
rs_fret = midi % 24
|
||
elif is_keys:
|
||
rs_str = midi // 24
|
||
rs_fret = midi % 24
|
||
else:
|
||
# Guitar/bass: need string + fret from XML
|
||
props = {p.get('name'): p for p in note_el.findall('.//Property')}
|
||
if 'String' in props and 'Fret' in props:
|
||
try:
|
||
gp_str = int(props['String'].findtext('String') or 0)
|
||
fret = int(props['Fret'].findtext('Fret') or 0)
|
||
sp = track['string_pitches']
|
||
num_strings = len(sp)
|
||
# RS string 0 = lowest-pitched string.
|
||
# The GPX Note String index matches the
|
||
# <Pitches> array positionally, but that
|
||
# array's direction differs by format
|
||
# (GP6 .gpx high->low, GP8 .gp low->high),
|
||
# so derive the RS string from the open
|
||
# string's PITCH RANK rather than assuming
|
||
# a direction. Assuming high->low (the old
|
||
# `num_strings-1-gp_str`) mirrored every
|
||
# GP8 import — low-E notes landed on high-e.
|
||
# Tiebreak ties on the original index so
|
||
# two strings with the SAME open pitch
|
||
# (e.g. a unison course) still get distinct
|
||
# RS strings instead of colliding.
|
||
if 0 <= gp_str < num_strings:
|
||
order = sorted(
|
||
range(num_strings),
|
||
key=lambda i: (sp[i], i),
|
||
)
|
||
rs_str = order.index(gp_str)
|
||
else:
|
||
rs_str = gp_str
|
||
rs_fret = fret
|
||
except (ValueError, TypeError):
|
||
continue
|
||
else:
|
||
continue
|
||
|
||
sustain = dur if dur > 0.2 else 0.0
|
||
if is_drum:
|
||
sustain = 0.0
|
||
|
||
rn = RsNote(
|
||
time=t,
|
||
string=rs_str,
|
||
fret=rs_fret,
|
||
sustain=sustain,
|
||
)
|
||
|
||
# Techniques — GPIF stores these as <Property>
|
||
# elements (NOT child tags), so the old
|
||
# find('PalmMute')/find('Vibrato') reads never
|
||
# matched and every GPX/GP technique was dropped.
|
||
# Read them from the property map instead.
|
||
_tp = {p.get('name'): p
|
||
for p in note_el.findall('.//Property')}
|
||
if 'PalmMuted' in _tp:
|
||
rn.palm_mute = True
|
||
if 'Muted' in _tp: # dead/X note
|
||
rn.mute = True
|
||
if _note_has_vibrato(note_el, _tp):
|
||
rn.vibrato = True
|
||
if 'LeftHandTapping' in _tp or 'Tapped' in _tp:
|
||
rn.tap = True
|
||
# Fret-hand fingering -> fg teaching mark
|
||
# (§6.2.2). <LeftFingering> is a direct <Note>
|
||
# child, not a <Property>, so read it off
|
||
# note_el rather than the property map.
|
||
rn.fret_finger = _gpif_left_fingering(note_el)
|
||
if 'HarmonicType' in _tp:
|
||
_ht = (_tp['HarmonicType'].findtext('HType')
|
||
or '').strip().lower()
|
||
if _ht == 'pinch':
|
||
rn.harmonic_pinch = True
|
||
else:
|
||
rn.harmonic = True
|
||
# Hammer-on / pull-off: a HopoDestination note is
|
||
# reached via HO/PO from the prior note on the
|
||
# same string — pull-off when descending, else
|
||
# hammer-on.
|
||
if 'HopoDestination' in _tp:
|
||
_prev = last_note_per_string.get(rs_str)
|
||
if _prev is not None and _prev.fret > rs_fret:
|
||
rn.pull_off = True
|
||
else:
|
||
rn.hammer_on = True
|
||
# Bend: `bn` is the peak; `bnv`/`bt` capture
|
||
# the shape over time (§6.2.1). value/divisor
|
||
# = semitones (scale auto-detected per file).
|
||
if 'Bended' in _tp:
|
||
# Use the beat duration `dur`, not
|
||
# `rn.sustain` (zeroed for notes <= 0.2s),
|
||
# so short bends keep their bnv curve —
|
||
# matching the GP5 path, which maps over
|
||
# the raw note duration.
|
||
_peak, _intent, _curve = _gpx_bend_shape(
|
||
_tp, _bend_divisor, dur)
|
||
if _peak > 0:
|
||
rn.bend = _peak
|
||
rn.bend_intent = _intent
|
||
rn.bend_values = _curve
|
||
# Slide flags: 1/2 = pitched slide to the next
|
||
# note; 4 = slide out down, 8 = out up. Resolved
|
||
# post-loop (needs the next note on the string).
|
||
if 'Slide' in _tp:
|
||
try:
|
||
_sf = int(_tp['Slide'].findtext('Flags') or 0)
|
||
except (ValueError, TypeError):
|
||
_sf = 0
|
||
if _sf:
|
||
# A grace note (BeforeBeat) slides INTO
|
||
# the following main note, so force a
|
||
# pitched slide-to-next even when the
|
||
# flag is an "out" slide (4/8) — e.g.
|
||
# the "3/5" grace-slide. Flag 1 = shift.
|
||
_is_grace = (
|
||
beat_el.find('GraceNotes') is not None)
|
||
if _is_grace:
|
||
_sf = 1
|
||
pending_slides.append(
|
||
(rn, rs_str, _sf, _is_grace))
|
||
|
||
beat_rs_notes.append(rn)
|
||
last_note_per_key[midi] = rn
|
||
last_note_per_string[rs_str] = rn
|
||
|
||
# Whammy-as-vibrato: GPIF VibratoWTremBar is a
|
||
# bar wobble (Strength Slight/Wide), not a pitch
|
||
# dive, so map it to note vibrato across the beat
|
||
# (a dive would instead be an unpitched slide).
|
||
if beat_el.find(
|
||
'.//Property[@name="VibratoWTremBar"]') is not None:
|
||
for _bn in beat_rs_notes:
|
||
_bn.vibrato = True
|
||
|
||
# Tremolo picking: GP7/GP8 encodes the rate as a
|
||
# beat-level <Tremolo>1/8</Tremolo> child. The note
|
||
# model has a single tremolo flag (no rate), so map
|
||
# any tremolo-picked beat to note tremolo across it.
|
||
# Independent of vibrato above — a note can carry
|
||
# both. (Beat-level <Tremolo>, not the whammy
|
||
# VibratoWTremBar Property, which is handled above.)
|
||
if _beat_has_tremolo(beat_el):
|
||
for _bn in beat_rs_notes:
|
||
_bn.tremolo = True
|
||
|
||
if len(beat_rs_notes) == 1:
|
||
rs_notes.append(beat_rs_notes[0])
|
||
elif len(beat_rs_notes) > 1:
|
||
width = max(6, max(n.string for n in beat_rs_notes) + 1)
|
||
frets_t = [-1] * width
|
||
for n in beat_rs_notes:
|
||
if 0 <= n.string < width:
|
||
frets_t[n.string] = n.fret
|
||
fkey = tuple(frets_t)
|
||
if fkey not in chord_template_map:
|
||
chord_template_map[fkey] = len(chord_templates)
|
||
_diag = chord_diagram_map.get(fkey)
|
||
chord_templates.append(ChordTemplate(
|
||
name=(_diag['name'] if _diag else ''),
|
||
frets=list(frets_t),
|
||
fingers=(list(_diag['fingers']) if _diag
|
||
else [-1] * width),
|
||
))
|
||
rs_chords.append(RsChord(
|
||
time=t,
|
||
template_idx=chord_template_map[fkey],
|
||
notes=beat_rs_notes,
|
||
))
|
||
|
||
voice_time += dur
|
||
|
||
current_time += bar_duration
|
||
|
||
# Fix downbeat measure numbers
|
||
bar_num = 1
|
||
for b in beats_out:
|
||
if b.measure == -999:
|
||
b.measure = bar_num
|
||
bar_num += 1
|
||
|
||
if not sections:
|
||
sections.append(RsSection(name='default', time=audio_offset, number=1))
|
||
|
||
rs_notes.sort(key=lambda n: n.time)
|
||
rs_chords.sort(key=lambda c: c.time)
|
||
|
||
# Anchors
|
||
if is_drum or is_keys:
|
||
anchors = [RsAnchor(time=audio_offset, fret=1, width=24)]
|
||
else:
|
||
all_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_frets.append((cn.time, cn.fret))
|
||
all_frets.sort()
|
||
first_fret = all_frets[0][1] if all_frets else 1
|
||
anchors = [RsAnchor(time=audio_offset, fret=max(1, first_fret - 1), width=4)]
|
||
for t_f, fret in all_frets:
|
||
lo = anchors[-1].fret
|
||
if fret < lo or fret > lo + anchors[-1].width:
|
||
new_f = max(1, fret - 1)
|
||
if new_f != anchors[-1].fret:
|
||
anchors.append(RsAnchor(time=t_f, fret=new_f, width=4))
|
||
|
||
song_length = current_time + audio_offset
|
||
|
||
# Use the track's actual string count (matches gp2rs: len(track.strings)).
|
||
# Forcing a minimum of 6 emitted 4/5-string bass as a 6-string
|
||
# arrangement with mismatched tuning/string indexing. Guitar-family
|
||
# tracks always have string_pitches (that's how they're classified).
|
||
# Guitar-family tracks have string_pitches; but an explicit track_indices
|
||
# entry can route a program-only track (no tuning, not drum/keys/vocal)
|
||
# here — fall back to a 6-string default so num_strings/tuning are never
|
||
# empty (which would emit invalid arrangement metadata via _build_xml).
|
||
num_strings = 6 if (is_drum or is_keys) else (len(track['string_pitches']) or 6)
|
||
# force_standard_tuning parity with gp2rs.convert_file: E standard
|
||
# (all-zero offsets), frets unchanged. Drums/keys are always [0]*6.
|
||
if is_drum or is_keys or force_standard_tuning:
|
||
tuning = [0] * num_strings
|
||
else:
|
||
tuning = _gpx_tuning(track)
|
||
|
||
# Merge Piano LH notes into this (RH) arrangement if a pair was detected
|
||
if is_keys and track_idx in _piano_merge_map:
|
||
lh_idx = _piano_merge_map[track_idx]
|
||
lh_track = tracks[lh_idx]
|
||
lh_raw_idx = filtered_to_raw.get(lh_idx, lh_idx)
|
||
|
||
_lh_notes: list[RsNote] = []
|
||
_lh_last_per_key: dict[int, RsNote] = {}
|
||
_lh_tempo_iter = iter(tempo_map)
|
||
_lh_next_bar, _lh_next_bpm = next(_lh_tempo_iter, (999999, tempo_bpm))
|
||
_lh_cur_tempo = tempo_bpm
|
||
_lh_time = 0.0
|
||
|
||
for _lh_mb_idx, _lh_mb in enumerate(masterbars):
|
||
while _lh_mb_idx >= _lh_next_bar:
|
||
_lh_cur_tempo = _lh_next_bpm
|
||
_lh_next_bar, _lh_next_bpm = next(_lh_tempo_iter, (999999, _lh_cur_tempo))
|
||
_lh_ts = _lh_mb.findtext('Time', '4/4')
|
||
try:
|
||
_lh_nb, _lh_db = [int(x) for x in _lh_ts.split('/')]
|
||
except ValueError:
|
||
_lh_nb, _lh_db = 4, 4
|
||
_lh_bar_dur = _lh_nb * (4.0 / _lh_db) * (60.0 / _lh_cur_tempo)
|
||
_lh_bar_ids = _lh_mb.findtext('Bars', '').split()
|
||
_lh_bid = _lh_bar_ids[lh_raw_idx] if lh_raw_idx < len(_lh_bar_ids) else '-1'
|
||
if _lh_bid != '-1' and _lh_bid:
|
||
_lh_bar = bars_by_id.get(_lh_bid)
|
||
if _lh_bar is not None:
|
||
for _lh_vid in _lh_bar.findtext('Voices', '').split():
|
||
if _lh_vid == '-1':
|
||
continue
|
||
_lh_voice = voices_dict.get(_lh_vid)
|
||
if _lh_voice is None:
|
||
continue
|
||
_lh_vt = _lh_time
|
||
for _lh_beat_id in _lh_voice.findtext('Beats', '').split():
|
||
_lh_beat = beats_dict.get(_lh_beat_id)
|
||
if _lh_beat is None:
|
||
continue
|
||
_lh_dur = _beat_dur_secs(_lh_beat, rhythms_dict, _lh_cur_tempo)
|
||
for _lh_nid in _lh_beat.findtext('Notes', '').strip().split():
|
||
_lh_note_el = notes_dict.get(_lh_nid)
|
||
if _lh_note_el is None:
|
||
continue
|
||
if _note_is_tie(_lh_note_el):
|
||
# Extend the matching prior note (same
|
||
# pitch), mirroring the main builder's
|
||
# last_note_per_key handling — blindly
|
||
# extending the last-emitted note
|
||
# mishandles polyphonic (chord) LH parts.
|
||
_tie_midi = _note_midi(_lh_note_el, lh_track['string_pitches'])
|
||
if _tie_midi is not None:
|
||
_prev = _lh_last_per_key.get(_tie_midi)
|
||
# Full-precision comparison (matching
|
||
# the main builder); rounding only
|
||
# happens at XML serialization. Rounding
|
||
# here could make a short note appear to
|
||
# start at the tie time and skip the
|
||
# sustain extension.
|
||
_tie_t = _lh_vt + audio_offset
|
||
if _prev is not None and _prev.time < _tie_t:
|
||
_prev.sustain = max(
|
||
_prev.sustain,
|
||
(_tie_t + _lh_dur) - _prev.time,
|
||
)
|
||
continue
|
||
_lh_midi = _note_midi(_lh_note_el, lh_track['string_pitches'])
|
||
if _lh_midi is None:
|
||
continue
|
||
# Keep full-precision time (like the main
|
||
# convert_file() builder — rounding happens at
|
||
# serialization); same 0.2s sustain threshold.
|
||
_lh_rn = RsNote(
|
||
time=_lh_vt + audio_offset,
|
||
string=_lh_midi // 24,
|
||
fret=_lh_midi % 24,
|
||
sustain=_lh_dur if _lh_dur > 0.2 else 0.0,
|
||
)
|
||
_lh_notes.append(_lh_rn)
|
||
_lh_last_per_key[_lh_midi] = _lh_rn
|
||
_lh_vt += _lh_dur
|
||
_lh_time += _lh_bar_dur
|
||
|
||
# Merge: combine and deduplicate simultaneous same-pitch notes, then
|
||
# sort by time. Map each (time, string, fret) to its existing RsNote
|
||
# so that when both hands hit the same key at the same instant we
|
||
# keep the LONGER sustain instead of arbitrarily discarding the LH
|
||
# one. Seed from both single notes and chord notes — polyphonic RH
|
||
# beats live in rs_chords[*].notes, so seeding from rs_notes alone
|
||
# would let an identical LH note slip in as a duplicate.
|
||
_seen: dict[tuple, RsNote] = {}
|
||
for _n in rs_notes:
|
||
_seen.setdefault((round(_n.time, 3), _n.string, _n.fret), _n)
|
||
for _c in rs_chords:
|
||
for _cn in _c.notes:
|
||
_seen.setdefault((round(_cn.time, 3), _cn.string, _cn.fret), _cn)
|
||
for _lh_rn in _lh_notes:
|
||
_k = (round(_lh_rn.time, 3), _lh_rn.string, _lh_rn.fret)
|
||
_existing = _seen.get(_k)
|
||
if _existing is None:
|
||
rs_notes.append(_lh_rn)
|
||
_seen[_k] = _lh_rn
|
||
elif _lh_rn.sustain > _existing.sustain:
|
||
# Same key both hands — preserve the longer sustain (mutating
|
||
# the RsNote also updates it in place inside any RH chord).
|
||
_existing.sustain = _lh_rn.sustain
|
||
rs_notes.sort(key=lambda n: (n.time, n.string))
|
||
|
||
# Collapse "Keys 2" -> "Keys": the merged LH+RH is a single
|
||
# keyboard arrangement. Keep the standard "Keys" name (not "Piano")
|
||
# so downstream arrangement matching/filtering and the piano-highway
|
||
# auto-select (which keys on arr_name.startswith("keys")) still work.
|
||
arr_name = re.sub(r'\s*\d+$', '', arr_name).strip() or 'Keys'
|
||
|
||
# Resolve pending slides now that every note on each string is known.
|
||
# GPIF slide flags: 1=shift, 2=legato (both slide to the NEXT note on the
|
||
# string); 4=slide out downwards, 8=slide out upwards (unpitched).
|
||
if pending_slides:
|
||
_resolve_pending_slides(rs_notes, rs_chords, pending_slides)
|
||
|
||
# Build the arrangement XML *after* the optional piano merge so the
|
||
# merged LH notes and the renamed arrangement actually reach the output.
|
||
xml_str = _build_xml(
|
||
title=title or 'Untitled',
|
||
artist=artist or 'Unknown',
|
||
album=album or '',
|
||
year='',
|
||
arrangement=arr_name or ('Drums' if is_drum else ('Keys' if is_keys else 'Lead')),
|
||
tuning=tuning,
|
||
num_strings=num_strings,
|
||
song_length=song_length,
|
||
audio_offset=audio_offset,
|
||
beats=beats_out,
|
||
sections=sections,
|
||
notes=rs_notes,
|
||
chords=rs_chords,
|
||
chord_templates=chord_templates,
|
||
anchors=anchors,
|
||
tempo=int(tempo_bpm),
|
||
)
|
||
|
||
# Inject tone change markers for guitar/bass tracks
|
||
if not is_drum and not is_keys and not is_vocal and track['string_pitches']:
|
||
tone_events = _collect_tone_events(
|
||
raw_idx, masterbars, bars_by_id, voices_dict,
|
||
beats_dict, rhythms_dict, tempo_map, audio_offset,
|
||
tempo_bpm=tempo_bpm,
|
||
)
|
||
if tone_events:
|
||
xml_str = _inject_tones(xml_str, tone_events)
|
||
|
||
filename = f"{_safe_filename_stem(track['name'])}_{arr_name or 'arr'}.xml"
|
||
filepath = safe_join(out, filename)
|
||
if filepath is None:
|
||
raise ValueError(f"unsafe output filename from track name: {track['name']!r}")
|
||
filepath.write_text(xml_str)
|
||
output_files.append(str(filepath))
|
||
|
||
# Keys/piano tracks additionally get a standard-notation sidecar
|
||
# (`<stem>.notation.json`, sloppak-spec §5.3) so the sloppak assembly
|
||
# step can attach a `notation_<id>.json` + manifest `notation:`
|
||
# sub-key without re-walking the GP file. Best-effort: a notation bug
|
||
# must never break the arrangement XML conversion itself.
|
||
if is_keys:
|
||
try:
|
||
import gp2notation as _gp2notation
|
||
_lh_idx = _piano_merge_map.get(track_idx)
|
||
_payload = _gp2notation.convert_track_to_notation(
|
||
root, raw_idx, track['string_pitches'],
|
||
instrument='piano',
|
||
audio_offset=audio_offset,
|
||
track_name=track['name'],
|
||
lh_raw_idx=(
|
||
filtered_to_raw.get(_lh_idx, _lh_idx)
|
||
if _lh_idx is not None else None
|
||
),
|
||
lh_string_pitches=(
|
||
tracks[_lh_idx]['string_pitches']
|
||
if _lh_idx is not None else None
|
||
),
|
||
)
|
||
_gp2notation.write_notation_sidecar(filepath, _payload)
|
||
except Exception:
|
||
_log.exception(
|
||
"gp2notation: notation sidecar emission failed for track %r "
|
||
"— arrangement XML is unaffected", track['name'],
|
||
)
|
||
|
||
return output_files
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Vocal track detection and conversion
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# MIDI programs associated with voice / choir / lead synth used for vocals in GP tabs
|
||
_VOCAL_MIDI_PROGRAMS = {52, 53, 54, 85, 86, 87} # Choir Aahs, Voice Oohs, Synth Voice, Lead Voice
|
||
_VOCAL_NAME_KEYWORDS = {'vocal', 'voice', 'vox', 'sing', 'lyric', 'choir', 'lead voc', 'backing voc'}
|
||
|
||
|
||
def _is_vocal_track(track: dict) -> bool:
|
||
"""Return True if this track looks like a vocal/lyric part."""
|
||
name_l = track['name'].lower()
|
||
if any(kw in name_l for kw in _VOCAL_NAME_KEYWORDS):
|
||
return True
|
||
if track['midi_program'] in _VOCAL_MIDI_PROGRAMS and not track['is_drums']:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _gpx_lyric_to_rs(raw: str) -> str:
|
||
"""
|
||
Convert a GPX lyric token to the vocal lyric format.
|
||
|
||
GPX encodes syllable continuation with a trailing hyphen (e.g. "in-", "t-").
|
||
charts use the same convention for mid-word syllables. For word-final
|
||
syllables with no hyphen, RS requires a "+" suffix to signal "connect to
|
||
next syllable without a space" — but only when the next beat is a
|
||
continuation of the same word. We handle this at the sequence level in
|
||
convert_vocal_track() rather than per-token, so here we just normalise
|
||
whitespace and pass through.
|
||
|
||
Special GPX tokens:
|
||
"+" (rare) — word joiner used in some tabs; map to RS "+"
|
||
" " (empty/space) — rest beat with no lyric; skip at caller
|
||
"""
|
||
raw = raw.strip()
|
||
if not raw:
|
||
return ''
|
||
# GP sometimes uses "+" as an explicit word-joiner; keep it
|
||
return raw
|
||
|
||
|
||
def convert_vocal_track(
|
||
root: ET.Element,
|
||
track: dict,
|
||
raw_idx: int,
|
||
masterbars: list,
|
||
bars_by_id: dict,
|
||
voices_dict: dict,
|
||
beats_dict: dict,
|
||
notes_dict: dict,
|
||
rhythms_dict: dict,
|
||
*,
|
||
title: str = '',
|
||
artist: str = '',
|
||
album: str = '',
|
||
tempo_bpm: float = 120.0,
|
||
audio_offset: float = 0.0,
|
||
arr_name: str = 'Vocals',
|
||
) -> str:
|
||
"""
|
||
Convert a GPX vocal track to a chart vocals arrangement XML.
|
||
|
||
Each beat with a lyric and a note becomes a <vocal> element:
|
||
time — seconds from song start + audio_offset
|
||
note — MIDI pitch (raw String+Fret value, no transposition applied;
|
||
see module docstring for a note on vocal transposition)
|
||
length — duration in seconds (ties extend this)
|
||
lyric — syllable text, RS-formatted:
|
||
trailing "-" = mid-word hyphen (same as GPX)
|
||
trailing "+" = connect to next token (no space, no hyphen)
|
||
no suffix = word end (RS inserts a space before next)
|
||
|
||
Beats with a lyric but no pitch note are included as pitch-0 rests so the
|
||
display timeline stays intact. Beats with no lyric are skipped entirely.
|
||
|
||
The output is a minimal but valid vocals XML. It does not include
|
||
ebeats or phrases (RS parses vocal XMLs without them).
|
||
"""
|
||
string_pitches = track['string_pitches'] # high→low, standard guitar if vocal
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pass 1: collect raw (time, duration, lyric, midi_note) tuples
|
||
# ---------------------------------------------------------------------------
|
||
raw_vocals: list[dict] = [] # {time, length, lyric, note, is_tie_origin}
|
||
current_time = 0.0
|
||
|
||
# Build per-bar tempo map for vocal timing (handles tempo changes correctly)
|
||
_v_tempo_map = _build_tempo_map(root)
|
||
_v_tempo_iter = iter(_v_tempo_map)
|
||
_v_next_bar, _v_next_bpm = next(_v_tempo_iter, (999999, tempo_bpm))
|
||
_v_cur_tempo = tempo_bpm
|
||
|
||
for _v_mb_idx, mb in enumerate(masterbars):
|
||
# Advance tempo
|
||
while _v_mb_idx >= _v_next_bar:
|
||
_v_cur_tempo = _v_next_bpm
|
||
_v_next_bar, _v_next_bpm = next(_v_tempo_iter, (999999, _v_cur_tempo))
|
||
|
||
time_sig = mb.findtext('Time', '4/4')
|
||
try:
|
||
num_b, den_b = [int(x) for x in time_sig.split('/')]
|
||
except ValueError:
|
||
num_b, den_b = 4, 4
|
||
bar_duration = num_b * (4.0 / den_b) * (60.0 / _v_cur_tempo)
|
||
|
||
bar_ids = mb.findtext('Bars', '').split()
|
||
bid = bar_ids[raw_idx] if raw_idx < len(bar_ids) else '-1'
|
||
|
||
if bid != '-1' and bid:
|
||
bar = bars_by_id.get(bid)
|
||
if bar is not None:
|
||
for vid in bar.findtext('Voices', '').split():
|
||
if vid == '-1':
|
||
continue
|
||
voice = voices_dict.get(vid)
|
||
if voice is None:
|
||
continue
|
||
|
||
voice_time = current_time
|
||
for beat_id in voice.findtext('Beats', '').split():
|
||
beat_el = beats_dict.get(beat_id)
|
||
if beat_el is None:
|
||
continue
|
||
|
||
dur = _beat_dur_secs(beat_el, rhythms_dict, _v_cur_tempo)
|
||
|
||
# Extract lyric from beat
|
||
lyric_el = beat_el.find('Lyrics')
|
||
lyric_raw = ''
|
||
if lyric_el is not None:
|
||
line = lyric_el.find('Line')
|
||
if line is not None and line.text:
|
||
lyric_raw = line.text.strip()
|
||
|
||
# Extract pitch from note(s) on this beat
|
||
midi_note = 0
|
||
is_tie_origin = False
|
||
notes_text = beat_el.findtext('Notes', '').strip()
|
||
if notes_text:
|
||
for nid in notes_text.split():
|
||
note_el = notes_dict.get(nid)
|
||
if note_el is None:
|
||
continue
|
||
|
||
# Tie destination: extend previous vocal's length
|
||
if _note_is_tie(note_el):
|
||
if raw_vocals:
|
||
raw_vocals[-1]['length'] = max(
|
||
raw_vocals[-1]['length'],
|
||
(voice_time + audio_offset + dur) - raw_vocals[-1]['time']
|
||
)
|
||
# Do NOT advance voice_time here — the
|
||
# beat-end `voice_time += dur` below advances
|
||
# exactly once per beat. Incrementing here too
|
||
# double-advanced tied beats and drifted every
|
||
# later vocal event.
|
||
continue
|
||
|
||
# Tie origin: note continues into next beat
|
||
tie = note_el.find('Tie')
|
||
if tie is not None and tie.get('origin', '').lower() in ('true', '1'):
|
||
is_tie_origin = True
|
||
|
||
midi = _note_midi(note_el, string_pitches)
|
||
if midi is not None:
|
||
midi_note = midi
|
||
break # one pitch per vocal beat
|
||
|
||
# Only emit if there is a lyric (beats without lyrics are rests)
|
||
if lyric_raw:
|
||
lyric = _gpx_lyric_to_rs(lyric_raw)
|
||
if lyric:
|
||
raw_vocals.append({
|
||
'time': round(voice_time + audio_offset, 3),
|
||
'length': round(dur, 3),
|
||
'lyric': lyric,
|
||
'note': midi_note,
|
||
'is_tie_origin': is_tie_origin,
|
||
})
|
||
|
||
voice_time += dur
|
||
|
||
current_time += bar_duration
|
||
|
||
song_length = current_time + audio_offset
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pass 2: apply RS lyric suffix convention
|
||
#
|
||
# GPX already marks mid-word syllables with trailing "-" (e.g. "in-", "e-").
|
||
# RS additionally requires "+" on the last syllable of a word when the next
|
||
# syllable is a direct continuation with no space. In GPX this is implicitly
|
||
# signalled by the next token having a leading lowercase letter (continuation)
|
||
# vs an uppercase or punctuation-leading token (new word). We use a simpler
|
||
# heuristic: if this token ends with "-" it is already marked; otherwise it
|
||
# is a word-end and needs no extra suffix (RS treats no-suffix as word-end).
|
||
#
|
||
# The one GPX convention we need to convert: some tabs use "+" as an explicit
|
||
# join (no hyphen, no space). These are passed through unchanged.
|
||
# ---------------------------------------------------------------------------
|
||
# (No transformation needed beyond what _gpx_lyric_to_rs already does —
|
||
# GPX "-" maps directly to RS "-" for mid-word breaks, and word-final tokens
|
||
# with no suffix are correct as-is. No "+" insertion is required.)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pass 3: emit XML
|
||
# ---------------------------------------------------------------------------
|
||
return _build_vocals_xml(
|
||
title=title or 'Untitled',
|
||
artist=artist or 'Unknown',
|
||
album=album or '',
|
||
arrangement=arr_name,
|
||
song_length=song_length,
|
||
audio_offset=audio_offset,
|
||
vocals=raw_vocals,
|
||
tempo=int(tempo_bpm),
|
||
)
|
||
|
||
|
||
def _build_vocals_xml(
|
||
title: str,
|
||
artist: str,
|
||
album: str,
|
||
arrangement: str,
|
||
song_length: float,
|
||
audio_offset: float,
|
||
vocals: list[dict],
|
||
tempo: int,
|
||
) -> str:
|
||
"""Build a chart vocals arrangement XML string."""
|
||
from xml.dom import minidom
|
||
|
||
# vocals arrangement is a flat <vocals> document — NOT a
|
||
# <song> wrapper. Every lyric consumer in the codebase keys off the root
|
||
# tag being literally "vocals" (lib/loosefolder.py, server.py highway
|
||
# loader), so a <song> root would be silently skipped and the generated
|
||
# lyrics never loaded. The schema is
|
||
# just <vocals count="N"> with <vocal time note length lyric/> children;
|
||
# the song-level metadata params are not part of it.
|
||
root = ET.Element('vocals', count=str(len(vocals)))
|
||
for v in vocals:
|
||
ET.SubElement(root, 'vocal',
|
||
time=f"{v['time']:.3f}",
|
||
note=str(v['note']),
|
||
length=f"{v['length']:.3f}",
|
||
lyric=v['lyric'])
|
||
|
||
xml_str = ET.tostring(root, encoding='unicode')
|
||
dom = minidom.parseString(xml_str)
|
||
return dom.toprettyxml(indent=' ', encoding=None)
|
||
|
||
|
||
def _gpx_tuning(track: dict) -> list[int]:
|
||
"""Compute RS tuning offsets (semitones from standard) from GPX string pitches."""
|
||
from gp2rs import STANDARD_TUNING_GUITAR, STANDARD_TUNING_BASS
|
||
pitches = track['string_pitches']
|
||
n = len(pitches)
|
||
if n == 0:
|
||
return [0] * 6
|
||
|
||
is_bass = max(pitches) <= 48
|
||
if is_bass:
|
||
# 5-string bass comes in two standards: high-C (C G D A E, top MIDI 48)
|
||
# and low-B (G D A E B, top MIDI 43). Mirror gp2rs._standard_tuning_for:
|
||
# midpoint is 45.5, so top >= 46 selects high-C (drop the low string
|
||
# from the 6-string table); otherwise 4-string / low-B 5-string skips
|
||
# the high C. max(pitches) is the top string regardless of array order.
|
||
top_midi = max(pitches)
|
||
if n == 5 and top_midi >= 46:
|
||
standard = STANDARD_TUNING_BASS[:5]
|
||
elif n <= 5:
|
||
standard = STANDARD_TUNING_BASS[1:1 + n]
|
||
else:
|
||
standard = STANDARD_TUNING_BASS[:n]
|
||
else:
|
||
standard = STANDARD_TUNING_GUITAR[:n]
|
||
|
||
# RS tuning is low->high (index 0 = lowest string). Sort both the actual and
|
||
# standard pitches ascending so the offsets are correct regardless of the
|
||
# GPX <Pitches> array direction — GP6 (.gpx) lists them high->low while GP8
|
||
# (.gp) lists them low->high. Assuming high->low mirrored GP8 tunings.
|
||
pit_asc = sorted(pitches)
|
||
std_asc = sorted(standard)
|
||
m = min(n, len(std_asc))
|
||
offsets = [0] * n
|
||
for i in range(m):
|
||
offsets[i] = pit_asc[i] - std_asc[i]
|
||
return offsets
|
||
|
||
|
||
def _auto_select_gpx(tracks: list[dict]) -> tuple[list[int], dict[int, str]]:
|
||
"""Auto-select guitar/bass/keys/drums tracks and assign arrangement names."""
|
||
GUITAR_PROGS = set(range(24, 32))
|
||
BASS_PROGS = set(range(32, 40))
|
||
KEYS_PROGS = set(range(0, 8)) | set(range(16, 24)) | {80, 81, 82, 83}
|
||
SKIP_NAMES = {'string', 'choir', 'brass', 'flute', 'violin', 'cello', 'horn'}
|
||
|
||
selected = []
|
||
for i, t in enumerate(tracks):
|
||
# Skip note-empty tracks (placeholder / muted-empty) so auto-selection
|
||
# doesn't emit unusable empty arrangements — matches the non-GPX path.
|
||
if t.get('note_count', 1) == 0:
|
||
continue
|
||
if t['is_drums']:
|
||
selected.append((i, 'drums'))
|
||
continue
|
||
|
||
name_l = t['name'].lower()
|
||
|
||
if _is_vocal_track(t):
|
||
selected.append((i, 'vocal'))
|
||
continue
|
||
|
||
if any(kw in name_l for kw in SKIP_NAMES) and not t['string_pitches']:
|
||
continue
|
||
|
||
is_bass = (t['string_pitches'] and max(t['string_pitches']) <= 48) \
|
||
or t['midi_program'] in BASS_PROGS
|
||
is_guitar = bool(t['string_pitches']) and not is_bass
|
||
is_keys = (not t['string_pitches'] and t['midi_program'] in KEYS_PROGS) \
|
||
or any(kw in name_l for kw in ('piano', 'keys', 'organ'))
|
||
|
||
if is_bass:
|
||
selected.append((i, 'bass'))
|
||
elif is_guitar:
|
||
# Honor "lead"/"rhythm" in the GP track name so two guitars keep
|
||
# the author's roles instead of being labelled by appearance order
|
||
# (which swaps a Rhythm-before-Lead file). Unhinted → positional.
|
||
if 'lead' in name_l and 'rhythm' not in name_l:
|
||
selected.append((i, 'guitar_lead'))
|
||
elif 'rhythm' in name_l and 'lead' not in name_l:
|
||
selected.append((i, 'guitar_rhythm'))
|
||
else:
|
||
selected.append((i, 'guitar'))
|
||
elif is_keys:
|
||
selected.append((i, 'keys'))
|
||
|
||
if not selected:
|
||
for i, t in enumerate(tracks):
|
||
if not t['is_drums'] and t.get('note_count', 1) > 0:
|
||
selected.append((i, 'guitar'))
|
||
|
||
name_map = {}
|
||
counts: dict[str, int] = {}
|
||
RS_NAMES = {'bass': ('Bass',), 'keys': ('Keys',),
|
||
'drums': ('Drums',), 'vocal': ('Vocals',)}
|
||
used: set[str] = set()
|
||
|
||
def _unique(base: str) -> str:
|
||
if base not in used:
|
||
used.add(base)
|
||
return base
|
||
k = 2
|
||
while f"{base} {k}" in used:
|
||
k += 1
|
||
used.add(f"{base} {k}")
|
||
return f"{base} {k}"
|
||
|
||
# Two passes so name-hinted Lead/Rhythm guitars reserve their canonical role
|
||
# BEFORE unhinted guitars are filled in — otherwise an unhinted guitar that
|
||
# appears before a hinted one steals its role (real Rhythm → "Rhythm 2").
|
||
# Non-guitar roles are handled in pass 1. `name_map` keys by track index so
|
||
# this does not affect arrangement (selection) order, computed separately.
|
||
for idx, role in selected:
|
||
if role == 'guitar':
|
||
continue
|
||
if role == 'guitar_lead':
|
||
base = 'Lead'
|
||
elif role == 'guitar_rhythm':
|
||
base = 'Rhythm'
|
||
else:
|
||
counts[role] = counts.get(role, 0) + 1
|
||
c = counts[role]
|
||
names_for_role = RS_NAMES.get(role, (role.title(),))
|
||
base = names_for_role[min(c - 1, len(names_for_role) - 1)]
|
||
if c > len(names_for_role):
|
||
base = f"{names_for_role[-1]} {c}"
|
||
name_map[idx] = _unique(base)
|
||
|
||
for idx, role in selected:
|
||
if role != 'guitar':
|
||
continue
|
||
base = next((r for r in ('Lead', 'Rhythm', 'Combo') if r not in used), 'Combo')
|
||
name_map[idx] = _unique(base)
|
||
|
||
indices = [idx for idx, _role in selected]
|
||
return indices, name_map
|