mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-25 22:31:48 +00:00
Every real Guitar Pro 6 (.gpx) file failed to import with "GPX BCFS sector pointer out of range (malformed file)". A real .gpx's BCFZ-declared decompressed size isn't 0x1000-aligned, so its last (small) container file lands in a partial trailing sector. _parse_bcfs raised whenever a sector read would run past the buffer end, rejecting the whole container before score.gpif could be extracted -- so no GP6 file could be charted in the song editor. (GP7/GP8 .gp files take the ZIP path, not BCFS, which is why this wasn't caught earlier.) Clamp the final sector read to the buffer end (the per-file size field trims the padding anyway), matching canonical GPX readers (alphaTab / PyGuitarPro). A sector whose start is past the end still raises, so the malformed-file guard is preserved. Verified against two real GP6 files -- both now unpack to valid GPIF with all tracks. Adds the previously-missing positive BCFS round-trip coverage: partial-final-sector, multi-file, sector-aligned baseline, and the preserved out-of-range guard. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com>
1235 lines
52 KiB
Python
1235 lines
52 KiB
Python
"""Tests for lib/gp2rs_gpx.py — the Guitar Pro 6 (.gpx) import path.
|
|
|
|
Fixture-free: every test exercises a pure helper with hand-built inputs
|
|
(ElementTree fragments, tuning lists, crafted container headers). The binary
|
|
BCFZ/BCFS round-trip needs a real .gpx and is covered by manual validation in
|
|
the PR; here we pin the input-validation guards and the conversion helpers
|
|
that are easy to drive without a fixture.
|
|
"""
|
|
|
|
import struct
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import pytest
|
|
|
|
import gp2rs_gpx
|
|
from gp2rs_gpx import convert_file
|
|
|
|
from gp2rs_gpx import (
|
|
_decompress_bcfz,
|
|
_parse_bcfs,
|
|
_safe_filename_stem,
|
|
_note_is_tie,
|
|
_note_has_vibrato,
|
|
_beat_has_tremolo,
|
|
_note_midi,
|
|
_gpx_percussion_midis,
|
|
_gpx_tuning,
|
|
_gp6_element_variation_to_midi,
|
|
_GPX_MAX_DECOMPRESSED,
|
|
_find_piano_pairs,
|
|
convert_vocal_track_to_pitch_sidecar,
|
|
_collect_tone_events,
|
|
_inject_tones,
|
|
_resolve_pending_slides,
|
|
_gpx_bend_shape,
|
|
_gpif_left_fingering,
|
|
)
|
|
from gp2rs import RsNote
|
|
|
|
|
|
# ── _safe_filename_stem ─────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.parametrize("name, expected", [
|
|
("Lead Guitar", "Lead_Guitar"),
|
|
("AC/DC", "AC_DC"),
|
|
(r"..\..\evil", "evil"),
|
|
("../../etc/passwd", "etc_passwd"),
|
|
("C:\\Windows\\x", "C_Windows_x"),
|
|
("", "track"),
|
|
("...", "track"),
|
|
("Bass (5)", "Bass_5"),
|
|
])
|
|
def test_safe_filename_stem(name, expected):
|
|
out = _safe_filename_stem(name)
|
|
assert out == expected
|
|
# Never contains a path separator or traversal segment.
|
|
assert "/" not in out and "\\" not in out
|
|
assert ".." not in out
|
|
|
|
|
|
# ── _gpx_bend_shape (bn / bt / bnv, §6.2.1) ─────────────────────────────────
|
|
|
|
def _bend_props(**vals):
|
|
"""Build a GPIF property map {name: <Property> element} for the given
|
|
bend Float values, e.g. _bend_props(BendOriginValue=0, BendMiddleValue=100)."""
|
|
tp = {}
|
|
for name, num in vals.items():
|
|
tp[name] = ET.fromstring(
|
|
f'<Property name="{name}"><Float>{num}</Float></Property>')
|
|
return tp
|
|
|
|
|
|
def test_gpx_bend_shape_round_trip_curve():
|
|
"""origin/middle/destination value+offset → 3-point bnv; value/divisor=semis."""
|
|
tp = _bend_props(
|
|
BendOriginValue=0, BendOriginOffset=0,
|
|
BendMiddleValue=100, BendMiddleOffset1=50, # 100/50 = 2 semitones
|
|
BendDestinationValue=0, BendDestinationOffset=100,
|
|
)
|
|
peak, intent, curve = _gpx_bend_shape(tp, divisor=50.0, sustain=1.0)
|
|
assert peak == 2.0
|
|
assert intent == 4 # round-trip (up then back down)
|
|
assert curve == [
|
|
{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}, {"t": 1.0, "v": 0.0}]
|
|
|
|
|
|
def test_gpx_bend_shape_falls_back_to_even_spacing_without_offsets():
|
|
tp = _bend_props(BendOriginValue=0, BendDestinationValue=100) # no offsets
|
|
peak, intent, curve = _gpx_bend_shape(tp, divisor=50.0, sustain=1.0)
|
|
assert peak == 2.0
|
|
assert intent == 0 # plain up
|
|
# origin defaults to 0%, destination to 100%.
|
|
assert curve == [{"t": 0.0, "v": 0.0}, {"t": 1.0, "v": 2.0}]
|
|
|
|
|
|
def test_gpx_bend_shape_no_props_and_zero_length():
|
|
assert _gpx_bend_shape({}, divisor=50.0, sustain=1.0) == (0.0, 0, None)
|
|
# Peak + intent still derived for a zero-length note, but no curve.
|
|
peak, intent, curve = _gpx_bend_shape(
|
|
_bend_props(BendOriginValue=0, BendDestinationValue=100),
|
|
divisor=50.0, sustain=0.0)
|
|
assert peak == 2.0 and intent == 0 and curve is None
|
|
|
|
|
|
# ── _decompress_bcfz / _parse_bcfs input guards ─────────────────────────────
|
|
|
|
def test_decompress_bcfz_rejects_bad_magic():
|
|
with pytest.raises(ValueError):
|
|
_decompress_bcfz(b"XXXX" + b"\x00" * 8)
|
|
|
|
|
|
def test_decompress_bcfz_rejects_oversized_declared_size():
|
|
# 4 bytes after the magic are read verbatim as a little-endian uint32 = the
|
|
# declared decompressed size. Declare > cap -> ValueError before allocating.
|
|
blob = b"BCFZ" + struct.pack("<I", _GPX_MAX_DECOMPRESSED + 1)
|
|
with pytest.raises(ValueError):
|
|
_decompress_bcfz(blob)
|
|
|
|
|
|
def test_parse_bcfs_rejects_bad_magic():
|
|
with pytest.raises(ValueError):
|
|
_parse_bcfs(b"NOPE" + b"\x00" * 16)
|
|
|
|
|
|
# ── _parse_bcfs container round-trip (GP6 .gpx partial final-sector) ─────────
|
|
|
|
def _build_bcfs(entries, short_by=0):
|
|
"""Assemble a minimal in-memory BCFS container for _parse_bcfs.
|
|
|
|
``entries`` is ``[(name: bytes, payload: bytes, data_sector: int), ...]``.
|
|
The directory entry for entry *i* is written to sector ``i + 1``; each
|
|
entry's payload goes in the sector index it names. ``short_by`` truncates
|
|
the final buffer by N bytes to emulate a real .gpx's partial trailing
|
|
sector (the BCFZ-declared decompressed size isn't 0x1000-aligned). Layout
|
|
mirrors the reader: a 4-byte ``BCFS`` header, then 0x1000-byte sectors,
|
|
with every value read at ``HDR + sector * 0x1000``.
|
|
"""
|
|
SECTOR = 0x1000
|
|
HDR = 4
|
|
max_sector = max([e[2] for e in entries] + [len(entries)])
|
|
buf = bytearray(b"BCFS" + b"\x00" * ((max_sector + 1) * SECTOR))
|
|
|
|
def put_u32(off, val):
|
|
struct.pack_into("<I", buf, HDR + off, val)
|
|
|
|
for i, (name, payload, data_sector) in enumerate(entries):
|
|
dir_off = (i + 1) * SECTOR # directory entry -> sector i+1
|
|
put_u32(dir_off + 0x00, 2) # entry type: file
|
|
nm = name[:127]
|
|
buf[HDR + dir_off + 0x04: HDR + dir_off + 0x04 + len(nm)] = nm
|
|
put_u32(dir_off + 0x8C, len(payload)) # declared file size
|
|
put_u32(dir_off + 0x94, data_sector) # first data-sector pointer
|
|
put_u32(dir_off + 0x94 + 4, 0) # chain terminator
|
|
dpos = HDR + data_sector * SECTOR
|
|
buf[dpos: dpos + len(payload)] = payload
|
|
if short_by:
|
|
del buf[len(buf) - short_by:]
|
|
return bytes(buf)
|
|
|
|
|
|
def test_parse_bcfs_reads_short_final_sector():
|
|
"""The regression: a real .gpx ends a byte short of a full 0x1000 sector,
|
|
so its last (small) container file lands in a partial trailing sector. The
|
|
reader must clamp that read, not reject the whole container — rejecting it
|
|
is what made every GP6 .gpx fail to import with 'sector pointer out of
|
|
range'."""
|
|
bcfs = _build_bcfs([(b"score.gpif", b"hello", 2)], short_by=1)
|
|
assert (len(bcfs) - 4) % 0x1000 == 0x1000 - 1 # final sector is 1 short
|
|
assert _parse_bcfs(bcfs)["score.gpif"] == b"hello"
|
|
|
|
|
|
def test_parse_bcfs_full_sector_round_trip():
|
|
"""A sector-aligned container round-trips unchanged (baseline)."""
|
|
assert _parse_bcfs(_build_bcfs([(b"misc.xml", b"<x/>", 2)]))["misc.xml"] == b"<x/>"
|
|
|
|
|
|
def test_parse_bcfs_multi_file_short_final_sector():
|
|
"""Real-world shape: score.gpif plus small config files, the last one in
|
|
the partial trailing sector."""
|
|
out = _parse_bcfs(_build_bcfs([
|
|
(b"score.gpif", b"<GPIF/>", 3),
|
|
(b"LayoutConfiguration", b"AB", 4),
|
|
], short_by=1))
|
|
assert out["score.gpif"] == b"<GPIF/>"
|
|
assert out["LayoutConfiguration"] == b"AB"
|
|
|
|
|
|
def test_parse_bcfs_rejects_sector_starting_past_end():
|
|
"""A sector pointer whose *start* is beyond the container is genuinely
|
|
malformed and must still raise — the clamp tolerates a partial final
|
|
sector, not arbitrary out-of-range pointers."""
|
|
bcfs = bytearray(_build_bcfs([(b"x", b"y", 2)]))
|
|
struct.pack_into("<I", bcfs, 4 + 0x1000 + 0x94, 9999) # absurd data-sector ptr
|
|
with pytest.raises(ValueError, match="out of range"):
|
|
_parse_bcfs(bytes(bcfs))
|
|
|
|
|
|
# ── _note_is_tie ────────────────────────────────────────────────────────────
|
|
|
|
def test_note_is_tie_destination():
|
|
el = ET.fromstring('<Note><Tie destination="true"/></Note>')
|
|
assert _note_is_tie(el) is True
|
|
|
|
|
|
def test_note_is_tie_origin_only_is_not_tie():
|
|
el = ET.fromstring('<Note><Tie origin="true"/></Note>')
|
|
assert _note_is_tie(el) is False
|
|
|
|
|
|
def test_note_is_tie_absent():
|
|
assert _note_is_tie(ET.fromstring("<Note/>")) is False
|
|
|
|
|
|
# ── _gp6_element_variation_to_midi ──────────────────────────────────────────
|
|
|
|
def test_element_variation_out_of_range_is_none():
|
|
assert _gp6_element_variation_to_midi(9999, 0) is None
|
|
assert _gp6_element_variation_to_midi(-1, 0) is None
|
|
|
|
|
|
def test_element_variation_known_pieces():
|
|
# Element 0 = kick (GM 35), element 1 = snare (GM 38). Pin exact values so a
|
|
# mis-edit of the _GP6_EV / _ART_TO_MIDI tables is caught.
|
|
assert _gp6_element_variation_to_midi(0, 0) == 35
|
|
assert _gp6_element_variation_to_midi(1, 0) == 38
|
|
|
|
|
|
# ── _gpx_tuning ─────────────────────────────────────────────────────────────
|
|
# GPX string pitches are high->low (index 0 = highest string).
|
|
|
|
_INSTRUMENT_SET = """
|
|
<Track>
|
|
<InstrumentSet><Type>drumKit</Type><Elements>
|
|
<Element><Name>Snare</Name><Articulations>
|
|
<Articulation><OutputMidiNumber>38</OutputMidiNumber></Articulation>
|
|
<Articulation><OutputMidiNumber>37</OutputMidiNumber></Articulation>
|
|
</Articulations></Element>
|
|
<Element><Name>Kick</Name><Articulations>
|
|
<Articulation><OutputMidiNumber>36</OutputMidiNumber></Articulation>
|
|
</Articulations></Element>
|
|
</Elements></InstrumentSet>
|
|
</Track>
|
|
"""
|
|
|
|
|
|
def test_percussion_midis_flattens_articulations_in_order():
|
|
track_el = ET.fromstring(_INSTRUMENT_SET)
|
|
# Flattened across Elements in document order: snare hit, snare side, kick.
|
|
assert _gpx_percussion_midis(track_el) == [38, 37, 36]
|
|
|
|
|
|
def test_percussion_midis_empty_without_instrument_set():
|
|
assert _gpx_percussion_midis(ET.fromstring("<Track/>")) == []
|
|
|
|
|
|
def test_note_midi_decodes_percussion_articulation_index():
|
|
perc = [38, 37, 36]
|
|
# GP8 drum note: the piece is a direct <InstrumentArticulation> child
|
|
# indexing into the InstrumentSet articulation list (NOT a Property).
|
|
note = ET.fromstring(
|
|
'<Note><Properties>'
|
|
'<Property name="ConcertPitch"><Pitch><Step>C</Step><Octave>-1</Octave></Pitch></Property>'
|
|
'</Properties><InstrumentArticulation>2</InstrumentArticulation></Note>'
|
|
)
|
|
assert _note_midi(note, [], perc) == 36 # index 2 → kick
|
|
# Out-of-range index → None (skipped), not a crash.
|
|
bad = ET.fromstring('<Note><InstrumentArticulation>99</InstrumentArticulation></Note>')
|
|
assert _note_midi(bad, [], perc) is None
|
|
# A -1 sentinel (unparseable OutputMidiNumber) → None, not an invalid note.
|
|
note2 = ET.fromstring('<Note><InstrumentArticulation>1</InstrumentArticulation></Note>')
|
|
assert _note_midi(note2, [], [38, -1, 36]) is None
|
|
|
|
|
|
def test_tuning_6string_guitar_standard_is_zero():
|
|
# E B G D A E (MIDI 64 59 55 50 45 40)
|
|
assert _gpx_tuning({"string_pitches": [64, 59, 55, 50, 45, 40]}) == [0, 0, 0, 0, 0, 0]
|
|
|
|
|
|
def test_tuning_6string_guitar_eb_is_minus_one():
|
|
assert _gpx_tuning({"string_pitches": [63, 58, 54, 49, 44, 39]}) == [-1, -1, -1, -1, -1, -1]
|
|
|
|
|
|
def test_tuning_4string_bass_standard_is_zero():
|
|
# G D A E (high->low): 43 38 33 28
|
|
assert _gpx_tuning({"string_pitches": [43, 38, 33, 28]}) == [0, 0, 0, 0]
|
|
|
|
|
|
def test_tuning_5string_low_b_standard_is_zero():
|
|
# low-B 5-string, high->low: G D A E B = 43 38 33 28 23
|
|
assert _gpx_tuning({"string_pitches": [43, 38, 33, 28, 23]}) == [0, 0, 0, 0, 0]
|
|
|
|
|
|
def test_tuning_5string_high_c_standard_is_zero():
|
|
# high-C 5-string, high->low: C G D A E = 48 43 38 33 28.
|
|
# Regression guard: previously forced the low-B reference and produced
|
|
# non-zero offsets for a standard-tuned high-C bass.
|
|
assert _gpx_tuning({"string_pitches": [48, 43, 38, 33, 28]}) == [0, 0, 0, 0, 0]
|
|
|
|
|
|
def test_tuning_empty_pitches_defaults_six_zero():
|
|
assert _gpx_tuning({"string_pitches": []}) == [0, 0, 0, 0, 0, 0]
|
|
|
|
|
|
def test_tuning_6string_guitar_ascending_is_zero():
|
|
# GP8/.gp lists tuning pitches low->high (the opposite of GP6 .gpx). The
|
|
# offsets must still be all-zero for E-standard — `_gpx_tuning` is order-
|
|
# agnostic so a GP8 import isn't mirrored.
|
|
assert _gpx_tuning({"string_pitches": [40, 45, 50, 55, 59, 64]}) == [0, 0, 0, 0, 0, 0]
|
|
|
|
|
|
def test_tuning_4string_bass_ascending_is_zero():
|
|
assert _gpx_tuning({"string_pitches": [28, 33, 38, 43]}) == [0, 0, 0, 0]
|
|
|
|
|
|
# ── _find_piano_pairs ───────────────────────────────────────────────────────
|
|
|
|
def test_find_piano_pairs_returns_rh_to_lh_map():
|
|
# "Piano RH"/"Piano LH" share a stem -> map {rh: lh} (LH merges into RH at
|
|
# import time), LH consumed.
|
|
tracks = [{"name": "Piano RH"}, {"name": "Piano LH"}, {"name": "Lead Guitar"}]
|
|
names = {0: "Keys", 1: "Keys 2", 2: "Lead"}
|
|
filtered, merge_map = _find_piano_pairs([0, 1, 2], tracks, names)
|
|
assert merge_map == {0: 1}
|
|
assert filtered == [0, 2] # LH (1) removed, order otherwise preserved
|
|
|
|
|
|
def test_find_piano_pairs_no_lh_no_merge():
|
|
# An RH with no matching LH stem is left untouched.
|
|
tracks = [{"name": "Piano RH"}, {"name": "Synth Pad"}]
|
|
names = {0: "Keys", 1: "Keys 2"}
|
|
filtered, merge_map = _find_piano_pairs([0, 1], tracks, names)
|
|
assert merge_map == {}
|
|
assert filtered == [0, 1]
|
|
|
|
|
|
def test_find_piano_pairs_ignores_non_keys_tracks():
|
|
# "rh"/"lh" word boundaries on guitar tracks must not trigger a merge:
|
|
# only piano/keys/keyboard-named (or names[]=Keys*) tracks are considered.
|
|
tracks = [{"name": "Rhythm Guitar RH"}, {"name": "Lead Guitar LH"}]
|
|
names = {0: "Rhythm", 1: "Lead"}
|
|
filtered, merge_map = _find_piano_pairs([0, 1], tracks, names)
|
|
assert merge_map == {}
|
|
assert filtered == [0, 1]
|
|
|
|
|
|
# ── convert_vocal_track_to_pitch_sidecar ────────────────────────────────────
|
|
# Drives the per-syllable pitch extraction with a one-bar / one-beat GPX tree
|
|
# (String+Fret note encoding) — no real .gpx needed.
|
|
|
|
def _vocal_sidecar_args(*, with_lyric: bool):
|
|
"""Build the minimal ET fragments for a single quarter-note vocal beat at
|
|
middle C (string_pitches[0]=60, String 0 + Fret 0). Returns a dict of
|
|
keyword args (expanded with **) for convert_vocal_track_to_pitch_sidecar."""
|
|
lyric = "<Lyrics><Line>la</Line></Lyrics>" if with_lyric else ""
|
|
beat = ET.fromstring(
|
|
f'<Beat><Rhythm ref="r0"/>{lyric}<Notes>0</Notes></Beat>'
|
|
)
|
|
note = ET.fromstring(
|
|
'<Note>'
|
|
'<Property name="String"><String>0</String></Property>'
|
|
'<Property name="Fret"><Fret>0</Fret></Property>'
|
|
'</Note>'
|
|
)
|
|
masterbar = ET.fromstring('<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>')
|
|
return dict(
|
|
root=ET.fromstring('<GPIF/>'), # no MasterTrack -> 120 BPM
|
|
track={'string_pitches': [60]},
|
|
raw_idx=0,
|
|
masterbars=[masterbar],
|
|
bars_by_id={'0': ET.fromstring('<Bar><Voices>0</Voices></Bar>')},
|
|
voices_dict={'0': ET.fromstring('<Voice><Beats>0</Beats></Voice>')},
|
|
beats_dict={'0': beat},
|
|
notes_dict={'0': note},
|
|
rhythms_dict={'r0': ET.fromstring('<Rhythm><NoteValue>Quarter</NoteValue></Rhythm>')},
|
|
)
|
|
|
|
|
|
def test_vocal_pitch_sidecar_emits_lyric_note():
|
|
out = convert_vocal_track_to_pitch_sidecar(**_vocal_sidecar_args(with_lyric=True))
|
|
# Quarter note (1.0 qn) at 120 BPM = 1.0 * 60/120 = 0.5 s; pitch = 60.
|
|
assert out == {"version": 1, "notes": [{"t": 0.0, "d": 0.5, "midi": 60}]}
|
|
|
|
|
|
def test_vocal_pitch_sidecar_skips_beat_without_lyric():
|
|
out = convert_vocal_track_to_pitch_sidecar(**_vocal_sidecar_args(with_lyric=False))
|
|
assert out == {"version": 1, "notes": []}
|
|
|
|
|
|
# ── _collect_tone_events ────────────────────────────────────────────────────
|
|
|
|
def _tone_args(banks, tempo_map=((0, 120.0),)):
|
|
"""One 4/4 bar with one quarter-note beat per entry in `banks`; a None entry
|
|
means a beat with no <Bank>. Returns positional args for _collect_tone_events.
|
|
With the default 120 BPM map a quarter note is 0.5 s."""
|
|
beat_ids = " ".join(str(i) for i in range(len(banks)))
|
|
beats_dict = {}
|
|
for i, b in enumerate(banks):
|
|
bank_el = f"<Bank>{b}</Bank>" if b is not None else ""
|
|
beats_dict[str(i)] = ET.fromstring(f'<Beat><Rhythm ref="r0"/>{bank_el}</Beat>')
|
|
return (
|
|
0, # raw_idx
|
|
[ET.fromstring('<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>')],
|
|
{"0": ET.fromstring("<Bar><Voices>0</Voices></Bar>")},
|
|
{"0": ET.fromstring(f"<Voice><Beats>{beat_ids}</Beats></Voice>")},
|
|
beats_dict,
|
|
{"r0": ET.fromstring("<Rhythm><NoteValue>Quarter</NoteValue></Rhythm>")},
|
|
[tuple(t) for t in tempo_map], # tempo_map
|
|
0.0, # audio_offset
|
|
)
|
|
|
|
|
|
def test_collect_tone_events_emits_per_bank():
|
|
events = _collect_tone_events(*_tone_args(["Clean", "Dist"]))
|
|
assert events == [(0.0, "Clean"), (0.5, "Dist")]
|
|
|
|
|
|
def test_collect_tone_events_dedupes_consecutive_identical():
|
|
# Consecutive identical banks collapse to a single transition.
|
|
events = _collect_tone_events(*_tone_args(["Clean", "Clean", "Dist"]))
|
|
assert events == [(0.0, "Clean"), (1.0, "Dist")]
|
|
|
|
|
|
def test_collect_tone_events_empty_when_no_banks():
|
|
assert _collect_tone_events(*_tone_args([None, None])) == []
|
|
|
|
|
|
def test_collect_tone_events_honors_base_tempo_bpm():
|
|
# No bar-0 tempo event -> the base tempo_bpm seeds the timeline (matching
|
|
# convert_file). At 60 BPM a quarter note is 1.0 s, so the second bank
|
|
# change lands at 1.0 s (not 0.5 s as it would at the hardcoded 120).
|
|
events = _collect_tone_events(*_tone_args(["Clean", "Dist"], tempo_map=[]),
|
|
tempo_bpm=60.0)
|
|
assert events == [(0.0, "Clean"), (1.0, "Dist")]
|
|
|
|
|
|
# ── _inject_tones ───────────────────────────────────────────────────────────
|
|
|
|
def test_inject_tones_adds_tonebase_and_tones():
|
|
out = _inject_tones("<song><arrangement>Lead</arrangement></song>",
|
|
[(0.0, "Clean"), (4.5, "Dist")])
|
|
root = ET.fromstring(out)
|
|
assert root.findtext("tonebase") == "Clean" # base = first tone
|
|
tones = root.find("tones")
|
|
assert tones.get("count") == "2"
|
|
tone_els = tones.findall("tone")
|
|
assert [t.get("name") for t in tone_els] == ["Clean", "Dist"]
|
|
assert [t.get("id") for t in tone_els] == ["0", "1"]
|
|
assert [t.get("time") for t in tone_els] == ["0.000", "4.500"]
|
|
|
|
|
|
def test_inject_tones_does_not_bloat_whitespace():
|
|
# Re-pretty-printing an already-indented arrangement must not stack blank
|
|
# lines (regression guard for the double-pretty-print whitespace explosion).
|
|
pretty = "<song>\n <arrangement>Lead</arrangement>\n <notes count=\"0\"/>\n</song>\n"
|
|
out = _inject_tones(pretty, [(0.0, "Clean"), (4.5, "Dist")])
|
|
assert not any(line.strip() == "" for line in out.splitlines())
|
|
|
|
|
|
def test_inject_tones_noop_without_events():
|
|
xml = "<song><arrangement>Lead</arrangement></song>"
|
|
assert _inject_tones(xml, []) == xml
|
|
|
|
|
|
def test_inject_tones_preserves_existing_tonebase():
|
|
out = _inject_tones("<song><tonebase>Existing</tonebase></song>",
|
|
[(0.0, "Clean")])
|
|
root = ET.fromstring(out)
|
|
bases = root.findall("tonebase")
|
|
assert len(bases) == 1 and bases[0].text == "Existing"
|
|
|
|
|
|
def test_inject_tones_fills_empty_tonebase():
|
|
# An empty/whitespace <tonebase> gets populated with the first tone name.
|
|
out = _inject_tones("<song><tonebase> </tonebase></song>", [(0.0, "Clean")])
|
|
root = ET.fromstring(out)
|
|
bases = root.findall("tonebase")
|
|
assert len(bases) == 1 and bases[0].text == "Clean"
|
|
|
|
|
|
# ── convert_file end-to-end: Piano LH/RH merge ──────────────────────────────
|
|
# Drives the real converter with a hand-built GPIF tree (via monkeypatched
|
|
# _load_gpif) to cover the in-converter merge + rename that the helper tests
|
|
# can't reach. Two keys tracks (Piano RH / Piano LH), one quarter-note each.
|
|
|
|
_GPIF_PIANO = """
|
|
<GPIF>
|
|
<Score><Title>T</Title><Artist>A</Artist></Score>
|
|
<Tracks>
|
|
<Track id="0"><Name>Piano RH</Name>
|
|
<Property name="Tuning"><Pitches>72</Pitches></Property></Track>
|
|
<Track id="1"><Name>Piano LH</Name>
|
|
<Property name="Tuning"><Pitches>48</Pitches></Property></Track>
|
|
</Tracks>
|
|
<MasterBars>
|
|
<MasterBar><Time>4/4</Time><Bars>0 1</Bars></MasterBar>
|
|
</MasterBars>
|
|
<Bars>
|
|
<Bar id="0"><Voices>0</Voices></Bar>
|
|
<Bar id="1"><Voices>1</Voices></Bar>
|
|
</Bars>
|
|
<Voices>
|
|
<Voice id="0"><Beats>0</Beats></Voice>
|
|
<Voice id="1"><Beats>1</Beats></Voice>
|
|
</Voices>
|
|
<Beats>
|
|
<Beat id="0"><Rhythm ref="r0"/><Notes>0</Notes></Beat>
|
|
<Beat id="1"><Rhythm ref="r0"/><Notes>1</Notes></Beat>
|
|
</Beats>
|
|
<Notes>
|
|
<Note id="0">
|
|
<Property name="String"><String>0</String></Property>
|
|
<Property name="Fret"><Fret>0</Fret></Property></Note>
|
|
<Note id="1">
|
|
<Property name="String"><String>0</String></Property>
|
|
<Property name="Fret"><Fret>0</Fret></Property></Note>
|
|
</Notes>
|
|
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
|
|
</GPIF>
|
|
"""
|
|
|
|
|
|
def test_convert_file_merges_piano_lh_into_rh(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(gp2rs_gpx, "_load_gpif", lambda _p: ET.fromstring(_GPIF_PIANO))
|
|
out_files = convert_file(
|
|
"dummy.gpx", str(tmp_path),
|
|
track_indices=[0, 1],
|
|
arrangement_names={0: "Keys", 1: "Keys 2"},
|
|
)
|
|
# (1) LH is consumed by the merge -> a single combined arrangement file.
|
|
assert len(out_files) == 1
|
|
|
|
root = ET.parse(out_files[0]).getroot()
|
|
# (2) "Keys 2" collapses to the standard "Keys" name (not "Piano"), so the
|
|
# piano-highway auto-select (arr_name.startswith("keys")) still matches.
|
|
assert root.findtext("arrangement") == "Keys"
|
|
|
|
# (3) both hands' notes are present. Keys encoding packs MIDI as
|
|
# string=midi//24, fret=midi%24: RH pitch 72 -> string 3, LH pitch 48 ->
|
|
# string 2. Collect every emitted note (single <note> + chord <chordNote>).
|
|
strings = {
|
|
n.get("string")
|
|
for n in root.iter()
|
|
if n.tag in ("note", "chordNote")
|
|
}
|
|
assert "3" in strings # RH (midi 72)
|
|
assert "2" in strings # LH (midi 48) merged in
|
|
|
|
|
|
# ── convert_file end-to-end: GP8 ascending-tuning string order ──────────────
|
|
# GP8/.gp lists tuning low->high, so GPIF String index 0 = low E. A low-E note
|
|
# must land on RS string 0 (RS string 0 = lowest), not be mirrored to high-e.
|
|
|
|
_GPIF_GUITAR_ASCENDING = """
|
|
<GPIF>
|
|
<Score><Title>T</Title><Artist>A</Artist></Score>
|
|
<Tracks>
|
|
<Track id="0"><Name>Lead Guitar</Name>
|
|
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
|
|
</Tracks>
|
|
<MasterBars>
|
|
<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>
|
|
</MasterBars>
|
|
<Bars>
|
|
<Bar id="0"><Voices>0</Voices></Bar>
|
|
</Bars>
|
|
<Voices>
|
|
<Voice id="0"><Beats>0 1</Beats></Voice>
|
|
</Voices>
|
|
<Beats>
|
|
<Beat id="0"><Rhythm ref="r0"/><Notes>0</Notes></Beat>
|
|
<Beat id="1"><Rhythm ref="r0"/><Notes>1</Notes></Beat>
|
|
</Beats>
|
|
<Notes>
|
|
<Note id="0">
|
|
<Property name="String"><String>0</String></Property>
|
|
<Property name="Fret"><Fret>0</Fret></Property></Note>
|
|
<Note id="1">
|
|
<Property name="String"><String>5</String></Property>
|
|
<Property name="Fret"><Fret>0</Fret></Property></Note>
|
|
</Notes>
|
|
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
|
|
</GPIF>
|
|
"""
|
|
|
|
|
|
_GPIF_BASS_NEUTRAL_NAME = """
|
|
<GPIF>
|
|
<Score><Title>T</Title><Artist>A</Artist></Score>
|
|
<Tracks>
|
|
<Track id="0"><Name>Track 1</Name>
|
|
<Property name="Tuning"><Pitches>28 33 38 43</Pitches></Property></Track>
|
|
</Tracks>
|
|
<MasterBars><MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar></MasterBars>
|
|
<Bars><Bar id="0"><Voices>0</Voices></Bar></Bars>
|
|
<Voices><Voice id="0"><Beats>0</Beats></Voice></Voices>
|
|
<Beats><Beat id="0"><Rhythm ref="r0"/><Notes>0</Notes></Beat></Beats>
|
|
<Notes>
|
|
<Note id="0">
|
|
<Property name="String"><String>0</String></Property>
|
|
<Property name="Fret"><Fret>0</Fret></Property></Note>
|
|
</Notes>
|
|
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
|
|
</GPIF>
|
|
"""
|
|
|
|
|
|
def test_convert_file_bass_named_bass_not_lead(tmp_path, monkeypatch):
|
|
# A bass track (top string <= C3/48) with a neutral name must become a
|
|
# "Bass" arrangement, not the "Lead" default — otherwise it imports as
|
|
# guitar. Pitch-based detection so 5/6-string basses are covered too.
|
|
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
|
|
lambda _p: ET.fromstring(_GPIF_BASS_NEUTRAL_NAME))
|
|
out_files = convert_file("dummy.gp", str(tmp_path), track_indices=[0])
|
|
root = ET.parse(out_files[0]).getroot()
|
|
assert root.findtext("arrangement") == "Bass"
|
|
|
|
|
|
def test_convert_file_gp8_ascending_tuning_not_mirrored(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(gp2rs_gpx, "_load_gpif", lambda _p: ET.fromstring(_GPIF_GUITAR_ASCENDING))
|
|
out_files = convert_file(
|
|
"dummy.gp", str(tmp_path),
|
|
track_indices=[0], arrangement_names={0: "Lead"},
|
|
)
|
|
root = ET.parse(out_files[0]).getroot()
|
|
# Tuning is E-standard -> all-zero offsets with string0 = low E.
|
|
tun = root.find("tuning")
|
|
assert [int(tun.get(f"string{i}")) for i in range(6)] == [0, 0, 0, 0, 0, 0]
|
|
# GPIF String 0 (open low E) -> RS string 0; String 5 (open high e) -> 5.
|
|
placed = {(int(n.get("string")), int(n.get("fret")))
|
|
for n in root.iter() if n.tag == "note"}
|
|
assert (0, 0) in placed # low-E open note on RS string 0 (was 5 before fix)
|
|
assert (5, 0) in placed # high-e open note on RS string 5
|
|
|
|
|
|
# Beat 0 carries a beat-level <Tremolo>; beat 1 does not. End-to-end proof that
|
|
# the picked beat's note serializes tremolo="1" and the other stays "0".
|
|
_GPIF_GUITAR_TREMOLO = """
|
|
<GPIF>
|
|
<Score><Title>T</Title><Artist>A</Artist></Score>
|
|
<Tracks>
|
|
<Track id="0"><Name>Lead Guitar</Name>
|
|
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
|
|
</Tracks>
|
|
<MasterBars>
|
|
<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>
|
|
</MasterBars>
|
|
<Bars>
|
|
<Bar id="0"><Voices>0</Voices></Bar>
|
|
</Bars>
|
|
<Voices>
|
|
<Voice id="0"><Beats>0 1</Beats></Voice>
|
|
</Voices>
|
|
<Beats>
|
|
<Beat id="0"><Rhythm ref="r0"/><Tremolo>1/8</Tremolo><Notes>0</Notes></Beat>
|
|
<Beat id="1"><Rhythm ref="r0"/><Notes>1</Notes></Beat>
|
|
</Beats>
|
|
<Notes>
|
|
<Note id="0">
|
|
<Property name="String"><String>0</String></Property>
|
|
<Property name="Fret"><Fret>0</Fret></Property></Note>
|
|
<Note id="1">
|
|
<Property name="String"><String>5</String></Property>
|
|
<Property name="Fret"><Fret>0</Fret></Property></Note>
|
|
</Notes>
|
|
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
|
|
</GPIF>
|
|
"""
|
|
|
|
|
|
def test_convert_file_gp8_tremolo_beat_flags_note(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(gp2rs_gpx, "_load_gpif", lambda _p: ET.fromstring(_GPIF_GUITAR_TREMOLO))
|
|
out_files = convert_file(
|
|
"dummy.gp", str(tmp_path),
|
|
track_indices=[0], arrangement_names={0: "Lead"},
|
|
)
|
|
root = ET.parse(out_files[0]).getroot()
|
|
tremolo_by_string = {int(n.get("string")): n.get("tremolo")
|
|
for n in root.iter() if n.tag == "note"}
|
|
# Beat 0 note (RS string 0) was tremolo-picked; beat 1 note (string 5) wasn't.
|
|
assert tremolo_by_string[0] == "1"
|
|
assert tremolo_by_string[5] == "0"
|
|
|
|
|
|
def test_vocal_pitch_sidecar_sorts_multi_voice_by_time():
|
|
# Two voices in one bar. Voice 0 (traversed first) emits its lyric note at
|
|
# t=0.5 (a no-lyric quarter precedes it); voice 1 (traversed second) emits
|
|
# at t=0.0. Output must be chronological regardless of traversal order.
|
|
def _beat(nid, *, lyric):
|
|
ly = "<Lyrics><Line>la</Line></Lyrics>" if lyric else ""
|
|
return ET.fromstring(f'<Beat><Rhythm ref="r0"/>{ly}<Notes>{nid}</Notes></Beat>')
|
|
|
|
def _note():
|
|
return ET.fromstring(
|
|
'<Note><Property name="String"><String>0</String></Property>'
|
|
'<Property name="Fret"><Fret>0</Fret></Property></Note>'
|
|
)
|
|
|
|
out = convert_vocal_track_to_pitch_sidecar(
|
|
root=ET.fromstring('<GPIF/>'),
|
|
track={'string_pitches': [60]},
|
|
raw_idx=0,
|
|
masterbars=[ET.fromstring('<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>')],
|
|
bars_by_id={'0': ET.fromstring('<Bar><Voices>0 1</Voices></Bar>')},
|
|
voices_dict={
|
|
'0': ET.fromstring('<Voice><Beats>0 1</Beats></Voice>'), # rest, then lyric@0.5
|
|
'1': ET.fromstring('<Voice><Beats>2</Beats></Voice>'), # lyric@0.0
|
|
},
|
|
beats_dict={
|
|
'0': _beat('0', lyric=False),
|
|
'1': _beat('1', lyric=True),
|
|
'2': _beat('2', lyric=True),
|
|
},
|
|
notes_dict={'0': _note(), '1': _note(), '2': _note()},
|
|
rhythms_dict={'r0': ET.fromstring('<Rhythm><NoteValue>Quarter</NoteValue></Rhythm>')},
|
|
)
|
|
times = [n['t'] for n in out['notes']]
|
|
assert times == sorted(times) # chronological
|
|
assert times == [0.0, 0.5]
|
|
|
|
|
|
def test_notes_by_id_prefers_tablature_on_duplicate_ids():
|
|
# Malformed pool: same id twice — a real String/Fret note then a
|
|
# degenerate articulation-only twin. The TAB note must win so the
|
|
# referencing track's notes don't vanish.
|
|
from gp2rs_gpx import _notes_by_id
|
|
root = ET.fromstring(
|
|
"<GPIF><Notes>"
|
|
"<Note id=\"0\"><Properties>"
|
|
"<Property name=\"String\"><String>1</String></Property>"
|
|
"<Property name=\"Fret\"><Fret>3</Fret></Property>"
|
|
"</Properties></Note>"
|
|
"<Note id=\"0\"><Properties>"
|
|
"<Property name=\"ConcertPitch\"><Pitch><Step>C</Step><Octave>-1</Octave></Pitch></Property>"
|
|
"</Properties><InstrumentArticulation>8</InstrumentArticulation></Note>"
|
|
"</Notes></GPIF>"
|
|
)
|
|
nd = _notes_by_id(root)
|
|
names = {p.get("name") for p in nd["0"].findall(".//Property")}
|
|
assert "String" in names and "Fret" in names
|
|
|
|
|
|
def test_gpx_bend_scale_autodetects():
|
|
from gp2rs_gpx import _gpx_bend_scale
|
|
small = ET.fromstring('<GPIF><Note><Properties>'
|
|
'<Property name="BendDestinationValue"><Float>100</Float></Property>'
|
|
'</Properties></Note></GPIF>')
|
|
big = ET.fromstring('<GPIF><Note><Properties>'
|
|
'<Property name="BendDestinationValue"><Float>7500</Float></Property>'
|
|
'</Properties></Note></GPIF>')
|
|
assert _gpx_bend_scale(small) == 50.0
|
|
assert _gpx_bend_scale(big) == 2500.0
|
|
|
|
|
|
# ── _resolve_pending_slides (grace-slide sustain fix) ───────────────────────
|
|
|
|
def test_resolve_grace_slide_zero_sustain_gets_gap_sustain():
|
|
# A grace note imported as a shift-slide is short, so its sustain was
|
|
# zeroed by the >0.2s rule. The highway won't draw a slide trail for a
|
|
# sus<=0 note, so the resolver stretches it to span the gap to its target
|
|
# — making the slide renderable. A grace ornament's target IS re-struck,
|
|
# so link_next is NOT set (the main note keeps its gem); only the gap
|
|
# sustain + slide_to are applied.
|
|
grace = RsNote(time=80.0, string=4, fret=5, sustain=0.0)
|
|
target = RsNote(time=80.125, string=4, fret=4, sustain=1.0)
|
|
_resolve_pending_slides([grace, target], [], [(grace, 4, 1, True)]) # is_grace
|
|
assert grace.slide_to == 4
|
|
assert grace.link_next is False # main note's gem stays visible
|
|
assert grace.sustain == pytest.approx(0.125) # spans gap → renderable
|
|
assert target.sustain == pytest.approx(1.0) # untouched
|
|
assert target.slide_to == -1
|
|
|
|
|
|
def test_resolve_normal_slide_sets_link_next_and_keeps_sustain():
|
|
# A normal (non-grace) shift-slide suppresses its target gem via link_next
|
|
# and must NOT have its authored sustain stretched.
|
|
a = RsNote(time=0.0, string=0, fret=1, sustain=1.0)
|
|
b = RsNote(time=1.0, string=0, fret=3, sustain=1.0)
|
|
_resolve_pending_slides([a, b], [], [(a, 0, 2, False)]) # 2 = legato/shift
|
|
assert a.slide_to == 3
|
|
assert a.link_next is True
|
|
assert a.sustain == pytest.approx(1.0)
|
|
|
|
|
|
def test_resolve_three_tuple_back_compat_defaults_to_normal():
|
|
# A legacy 3-tuple (no is_grace) is treated as a normal slide (link_next set).
|
|
a = RsNote(time=0.0, string=0, fret=1, sustain=1.0)
|
|
b = RsNote(time=1.0, string=0, fret=3, sustain=1.0)
|
|
_resolve_pending_slides([a, b], [], [(a, 0, 2)])
|
|
assert a.slide_to == 3
|
|
assert a.link_next is True
|
|
|
|
|
|
def test_resolve_unpitched_slide_out_flags():
|
|
down = RsNote(time=0.0, string=0, fret=7, sustain=1.0)
|
|
up = RsNote(time=1.0, string=0, fret=7, sustain=1.0)
|
|
_resolve_pending_slides([down, up], [], [(down, 0, 4), (up, 0, 8)])
|
|
assert down.slide_unpitch_to == 2 # max(1, 7-5)
|
|
assert up.slide_unpitch_to == 12 # 7+5
|
|
assert down.sustain == pytest.approx(1.0)
|
|
|
|
|
|
def test_resolve_grace_slide_without_target_is_noop():
|
|
# No following note on the string → no slide, no sustain stretch, no crash.
|
|
grace = RsNote(time=80.0, string=4, fret=5, sustain=0.0)
|
|
_resolve_pending_slides([grace], [], [(grace, 4, 1)])
|
|
assert grace.slide_to == -1
|
|
assert grace.sustain == 0.0
|
|
|
|
|
|
def test_resolve_grace_slide_same_fret_target_no_slide():
|
|
# Slide to an identical fret isn't a slide; leave the note alone.
|
|
grace = RsNote(time=80.0, string=4, fret=5, sustain=0.0)
|
|
target = RsNote(time=80.125, string=4, fret=5, sustain=1.0)
|
|
_resolve_pending_slides([grace, target], [], [(grace, 4, 1)])
|
|
assert grace.slide_to == -1
|
|
assert grace.sustain == 0.0
|
|
|
|
|
|
# ── _note_has_vibrato (GP7/GP8 note vibrato import) ─────────────────────────
|
|
|
|
def test_note_vibrato_direct_element():
|
|
# GP7/GP8 encodes note vibrato as a direct <Vibrato> child of <Note>,
|
|
# NOT a <Property> — the regression this fixes.
|
|
for strength in ("Slight", "Wide"):
|
|
n = ET.fromstring(f'<Note id="1"><Vibrato>{strength}</Vibrato>'
|
|
'<Properties></Properties></Note>')
|
|
tp = {p.get('name'): p for p in n.findall('.//Property')}
|
|
assert _note_has_vibrato(n, tp) is True
|
|
|
|
|
|
def test_note_vibrato_property_form_also_detected():
|
|
# Defensive: a <Property name="Vibrato"> form is still recognised.
|
|
n = ET.fromstring('<Note id="1"><Properties>'
|
|
'<Property name="Vibrato"><Enable/></Property>'
|
|
'</Properties></Note>')
|
|
tp = {p.get('name'): p for p in n.findall('.//Property')}
|
|
assert _note_has_vibrato(n, tp) is True
|
|
|
|
|
|
def test_note_vibrato_absent_is_false():
|
|
n = ET.fromstring('<Note id="1"><Properties>'
|
|
'<Property name="PalmMuted"><Enable/></Property>'
|
|
'</Properties></Note>')
|
|
tp = {p.get('name'): p for p in n.findall('.//Property')}
|
|
assert _note_has_vibrato(n, tp) is False
|
|
|
|
|
|
def test_note_vibrato_ignores_whammy_trembar_property():
|
|
# VibratoWTremBar is a beat-level whammy property, handled separately; it
|
|
# must NOT be read as note vibrato by this note-level helper.
|
|
n = ET.fromstring('<Note id="1"><Properties>'
|
|
'<Property name="VibratoWTremBar"><Strength>Slight</Strength></Property>'
|
|
'</Properties></Note>')
|
|
tp = {p.get('name'): p for p in n.findall('.//Property')}
|
|
assert _note_has_vibrato(n, tp) is False
|
|
|
|
|
|
# ── _beat_has_tremolo (GP6/7/8 tremolo-picking import) ──────────────────────
|
|
|
|
def test_beat_tremolo_direct_element():
|
|
# GPIF encodes tremolo picking as a direct <Tremolo>rate</Tremolo> child of
|
|
# <Beat> — the regression this fixes (GPX never read it). Rate-agnostic.
|
|
for rate in ("1/8", "1/16", "1/32"):
|
|
b = ET.fromstring(f'<Beat id="1"><Rhythm ref="1"/>'
|
|
f'<Tremolo>{rate}</Tremolo></Beat>')
|
|
assert _beat_has_tremolo(b) is True
|
|
|
|
|
|
def test_beat_tremolo_absent_is_false():
|
|
b = ET.fromstring('<Beat id="1"><Rhythm ref="1"/><Notes>1</Notes></Beat>')
|
|
assert _beat_has_tremolo(b) is False
|
|
|
|
|
|
def test_beat_tremolo_is_direct_child_only():
|
|
# Matched as a direct child (not `.//`), so a <Tremolo> buried deeper must
|
|
# NOT trigger — guards against a false positive from unrelated nested markup.
|
|
b = ET.fromstring('<Beat id="1"><Properties>'
|
|
'<Property name="X"><Tremolo>1/8</Tremolo></Property>'
|
|
'</Properties></Beat>')
|
|
assert _beat_has_tremolo(b) is False
|
|
|
|
|
|
def test_beat_tremolo_independent_of_whammy_trembar():
|
|
# VibratoWTremBar is the separate whammy-bar effect; a beat carrying only
|
|
# that (no <Tremolo>) is not tremolo picking.
|
|
b = ET.fromstring('<Beat id="1"><Properties>'
|
|
'<Property name="VibratoWTremBar"><Strength>Slight</Strength></Property>'
|
|
'</Properties></Beat>')
|
|
assert _beat_has_tremolo(b) is False
|
|
|
|
|
|
# ── _gpif_left_fingering (GP7/GP8 per-note fret-hand finger -> fg) ───────────
|
|
# GPIF stores a single note's fret-hand finger as a direct <LeftFingering>
|
|
# child of <Note> (NOT a <Property>), with classical p-i-m-a-c letter codes —
|
|
# verified against real GP8 exports (Open / I / M observed). Maps to the same
|
|
# RS finger integers as the chord-diagram path (§6.2.2). Teaching mark only.
|
|
|
|
@pytest.mark.parametrize("code, expected", [
|
|
("Open", -1), ("P", 0), ("I", 1), ("M", 2), ("A", 3), ("C", 4),
|
|
("i", 1), ("m", 2), # case-insensitive
|
|
("index", 1), ("ring", 3), # word forms also accepted
|
|
])
|
|
def test_gpif_left_fingering_letter_codes(code, expected):
|
|
n = ET.fromstring(f'<Note id="1"><LeftFingering>{code}</LeftFingering>'
|
|
'<Properties></Properties></Note>')
|
|
assert _gpif_left_fingering(n) == expected
|
|
|
|
|
|
def test_gpif_left_fingering_absent_or_unknown_is_unset():
|
|
# No <LeftFingering> child, or an unrecognised value -> -1 (never fabricate).
|
|
assert _gpif_left_fingering(ET.fromstring('<Note id="1"/>')) == -1
|
|
assert _gpif_left_fingering(
|
|
ET.fromstring('<Note id="1"><LeftFingering>Z</LeftFingering></Note>')) == -1
|
|
assert _gpif_left_fingering(
|
|
ET.fromstring('<Note id="1"><LeftFingering></LeftFingering></Note>')) == -1
|
|
|
|
|
|
# ── convert_file: GP8 chord-diagram name + fingering extraction (E3) ─────────
|
|
# GP7/GP8 GPIF carries authored chord diagrams under a track's
|
|
# Property[@name="DiagramCollection"]. Each Item gives the chord name and a
|
|
# <Diagram> with per-string fret + finger. A played voicing matching that
|
|
# fret pattern must import with the diagram's name + fingers; a chart without
|
|
# a DiagramCollection must import with blank name + all-(-1) fingers.
|
|
|
|
def _gpif_chord_diagram(diagram_block: str) -> str:
|
|
# A two-note chord (low E fret 3 + A fret 2) on a low->high tuned guitar.
|
|
return f"""
|
|
<GPIF>
|
|
<Score><Title>T</Title><Artist>A</Artist></Score>
|
|
<Tracks>
|
|
<Track id="0"><Name>Lead Guitar</Name>
|
|
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property>
|
|
{diagram_block}
|
|
</Track>
|
|
</Tracks>
|
|
<MasterBars><MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar></MasterBars>
|
|
<Bars><Bar id="0"><Voices>0</Voices></Bar></Bars>
|
|
<Voices><Voice id="0"><Beats>0</Beats></Voice></Voices>
|
|
<Beats><Beat id="0"><Rhythm ref="r0"/><Notes>0 1</Notes></Beat></Beats>
|
|
<Notes>
|
|
<Note id="0">
|
|
<Property name="String"><String>0</String></Property>
|
|
<Property name="Fret"><Fret>3</Fret></Property></Note>
|
|
<Note id="1">
|
|
<Property name="String"><String>1</String></Property>
|
|
<Property name="Fret"><Fret>2</Fret></Property></Note>
|
|
</Notes>
|
|
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
|
|
</GPIF>
|
|
"""
|
|
|
|
|
|
_DIAGRAM_BLOCK = """
|
|
<Property name="DiagramCollection"><Items>
|
|
<Item id="1" name="G5">
|
|
<Diagram stringCount="6" fretCount="5" baseFret="0">
|
|
<Fret string="0" fret="3"/>
|
|
<Fret string="1" fret="2"/>
|
|
<Fingering>
|
|
<Position finger="Middle" fret="3" string="0"/>
|
|
<Position finger="Index" fret="2" string="1"/>
|
|
</Fingering>
|
|
</Diagram>
|
|
</Item>
|
|
</Items></Property>
|
|
"""
|
|
|
|
|
|
def _convert_first_chord_template(monkeypatch, tmp_path, gpif):
|
|
monkeypatch.setattr(gp2rs_gpx, "_load_gpif", lambda _p: ET.fromstring(gpif))
|
|
out_files = convert_file(
|
|
"dummy.gp", str(tmp_path),
|
|
track_indices=[0], arrangement_names={0: "Lead"},
|
|
)
|
|
root = ET.parse(out_files[0]).getroot()
|
|
cts = root.findall(".//chordTemplates/chordTemplate")
|
|
assert len(cts) == 1
|
|
return cts[0]
|
|
|
|
|
|
def test_convert_file_gp8_chord_diagram_enriches_template(tmp_path, monkeypatch):
|
|
ct = _convert_first_chord_template(
|
|
monkeypatch, tmp_path, _gpif_chord_diagram(_DIAGRAM_BLOCK))
|
|
# Diagram name + per-string fingering land on the matching voicing.
|
|
assert ct.get("chordName") == "G5"
|
|
# RS string 0 = low E (fret 3, Middle=2), string 1 = A (fret 2, Index=1).
|
|
assert ct.get("fret0") == "3" and ct.get("finger0") == "2"
|
|
assert ct.get("fret1") == "2" and ct.get("finger1") == "1"
|
|
# Unplayed strings stay -1 for both fret and finger.
|
|
assert [ct.get(f"finger{i}") for i in range(2, 6)] == ["-1"] * 4
|
|
|
|
|
|
def test_convert_file_gp8_no_diagram_leaves_template_blank(tmp_path, monkeypatch):
|
|
# Same chart, no DiagramCollection -> identical import to before E3.
|
|
ct = _convert_first_chord_template(
|
|
monkeypatch, tmp_path, _gpif_chord_diagram(""))
|
|
assert ct.get("chordName") == ""
|
|
assert [ct.get(f"finger{i}") for i in range(6)] == ["-1"] * 6
|
|
# Fret pattern itself is unchanged (the join key still works).
|
|
assert ct.get("fret0") == "3" and ct.get("fret1") == "2"
|
|
|
|
|
|
# ── GP import correctness fixes (tester-reported) ───────────────────────────
|
|
|
|
# Two guitar tracks, Rhythm listed BEFORE Lead. The importer used to name
|
|
# arrangements purely by appearance order (first guitar -> "Lead"), which
|
|
# swapped the roles for any file that lists Rhythm first.
|
|
_GPIF_RHYTHM_BEFORE_LEAD = """
|
|
<GPIF>
|
|
<Score><Title>T</Title><Artist>A</Artist></Score>
|
|
<Tracks>
|
|
<Track id="0"><Name>Rhythm Guitar</Name>
|
|
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
|
|
<Track id="1"><Name>Lead Guitar</Name>
|
|
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
|
|
</Tracks>
|
|
<MasterBars><MasterBar><Time>4/4</Time><Bars>0 1</Bars></MasterBar></MasterBars>
|
|
<Bars>
|
|
<Bar id="0"><Voices>0</Voices></Bar>
|
|
<Bar id="1"><Voices>1</Voices></Bar>
|
|
</Bars>
|
|
<Voices>
|
|
<Voice id="0"><Beats>0</Beats></Voice>
|
|
<Voice id="1"><Beats>1</Beats></Voice>
|
|
</Voices>
|
|
<Beats>
|
|
<Beat id="0"><Rhythm ref="r0"/><Notes>0</Notes></Beat>
|
|
<Beat id="1"><Rhythm ref="r0"/><Notes>1</Notes></Beat>
|
|
</Beats>
|
|
<Notes>
|
|
<Note id="0"><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>0</Fret></Property></Note>
|
|
<Note id="1"><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>0</Fret></Property></Note>
|
|
</Notes>
|
|
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
|
|
</GPIF>
|
|
"""
|
|
|
|
|
|
def test_convert_file_guitar_roles_follow_gp_name_not_order(tmp_path, monkeypatch):
|
|
# Editor path: track_indices but no arrangement_names → convert_file's
|
|
# fallback naming. The Rhythm track (listed first) must stay "Rhythm" and
|
|
# the Lead track "Lead" — not be swapped by appearance order.
|
|
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
|
|
lambda _p: ET.fromstring(_GPIF_RHYTHM_BEFORE_LEAD))
|
|
out_files = convert_file("dummy.gp", str(tmp_path), track_indices=[0, 1])
|
|
names = [ET.parse(f).getroot().findtext("arrangement") for f in out_files]
|
|
assert names == ["Rhythm", "Lead"]
|
|
|
|
|
|
def _gpif_bass(pitches: str) -> str:
|
|
return f"""
|
|
<GPIF>
|
|
<Score><Title>T</Title><Artist>A</Artist></Score>
|
|
<Tracks>
|
|
<Track id="0"><Name>Bass</Name>
|
|
<Property name="Tuning"><Pitches>{pitches}</Pitches></Property></Track>
|
|
</Tracks>
|
|
<MasterBars><MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar></MasterBars>
|
|
<Bars><Bar id="0"><Voices>0</Voices></Bar></Bars>
|
|
<Voices><Voice id="0"><Beats>0</Beats></Voice></Voices>
|
|
<Beats><Beat id="0"><Rhythm ref="r0"/><Notes>0</Notes></Beat></Beats>
|
|
<Notes>
|
|
<Note id="0"><Property name="String"><String>0</String></Property>
|
|
<Property name="Fret"><Fret>0</Fret></Property></Note>
|
|
</Notes>
|
|
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
|
|
</GPIF>
|
|
"""
|
|
|
|
|
|
@pytest.mark.parametrize("pitches,expected", [
|
|
("28 33 38 43", 4), # 4-string E-A-D-G
|
|
("23 28 33 38 43", 5), # 5-string low-B
|
|
])
|
|
def test_convert_file_bass_string_count_round_trips(tmp_path, monkeypatch, pitches, expected):
|
|
# The <tuning> element pads to 6 slots (RS2014 schema), which erased the
|
|
# 4-vs-5-string distinction. The serializer now records the real count in
|
|
# `stringCount`; parse_arrangement trims to it so a 5-string bass reports 5
|
|
# (was 4 → it rendered/played on 4 strings).
|
|
from song import parse_arrangement, arrangement_string_count
|
|
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
|
|
lambda _p: ET.fromstring(_gpif_bass(pitches)))
|
|
out_files = convert_file("dummy.gp", str(tmp_path), track_indices=[0])
|
|
root = ET.parse(out_files[0]).getroot()
|
|
assert root.find("tuning").get("stringCount") == str(expected)
|
|
arr = parse_arrangement(out_files[0])
|
|
assert len(arr.tuning) == expected
|
|
assert arrangement_string_count(arr) == expected
|
|
|
|
|
|
# A track with one normal note and one tie-DESTINATION note. The tie is folded
|
|
# into the previous note's sustain, so it is not a separate RS note; the
|
|
# importer's preview count must exclude it (matches the imported result).
|
|
_GPIF_WITH_TIE = """
|
|
<GPIF>
|
|
<Score><Title>T</Title><Artist>A</Artist></Score>
|
|
<Tracks>
|
|
<Track id="0"><Name>Lead</Name>
|
|
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
|
|
</Tracks>
|
|
<MasterBars><MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar></MasterBars>
|
|
<Bars><Bar id="0"><Voices>0</Voices></Bar></Bars>
|
|
<Voices><Voice id="0"><Beats>0 1</Beats></Voice></Voices>
|
|
<Beats>
|
|
<Beat id="0"><Rhythm ref="r0"/><Notes>0</Notes></Beat>
|
|
<Beat id="1"><Rhythm ref="r0"/><Notes>1</Notes></Beat>
|
|
</Beats>
|
|
<Notes>
|
|
<Note id="0"><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>3</Fret></Property></Note>
|
|
<Note id="1"><Tie destination="true"/><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>3</Fret></Property></Note>
|
|
</Notes>
|
|
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
|
|
</GPIF>
|
|
"""
|
|
|
|
|
|
def test_list_tracks_note_count_excludes_tie_continuations(monkeypatch):
|
|
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
|
|
lambda _p: ET.fromstring(_GPIF_WITH_TIE))
|
|
tracks = gp2rs_gpx.list_tracks("dummy.gp")
|
|
# 2 raw notes, 1 of them a tie destination → 1 importable note.
|
|
assert tracks[0]["notes"] == 1
|
|
|
|
|
|
# A hinted "Lead Guitar" followed by an UNHINTED guitar: the unhinted one must
|
|
# advance to the next canonical role ("Rhythm"), not collide into "Lead 2".
|
|
_GPIF_LEAD_THEN_UNHINTED = """
|
|
<GPIF>
|
|
<Score><Title>T</Title><Artist>A</Artist></Score>
|
|
<Tracks>
|
|
<Track id="0"><Name>Lead Guitar</Name>
|
|
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
|
|
<Track id="1"><Name>Guitar 2</Name>
|
|
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
|
|
</Tracks>
|
|
<MasterBars><MasterBar><Time>4/4</Time><Bars>0 1</Bars></MasterBar></MasterBars>
|
|
<Bars>
|
|
<Bar id="0"><Voices>0</Voices></Bar>
|
|
<Bar id="1"><Voices>1</Voices></Bar>
|
|
</Bars>
|
|
<Voices>
|
|
<Voice id="0"><Beats>0</Beats></Voice>
|
|
<Voice id="1"><Beats>1</Beats></Voice>
|
|
</Voices>
|
|
<Beats>
|
|
<Beat id="0"><Rhythm ref="r0"/><Notes>0</Notes></Beat>
|
|
<Beat id="1"><Rhythm ref="r0"/><Notes>1</Notes></Beat>
|
|
</Beats>
|
|
<Notes>
|
|
<Note id="0"><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>0</Fret></Property></Note>
|
|
<Note id="1"><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>0</Fret></Property></Note>
|
|
</Notes>
|
|
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
|
|
</GPIF>
|
|
"""
|
|
|
|
|
|
def test_convert_file_unhinted_guitar_takes_next_canonical_role(tmp_path, monkeypatch):
|
|
# Mix of hinted + unhinted guitars spreads across Lead → Rhythm, not Lead/Lead 2.
|
|
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
|
|
lambda _p: ET.fromstring(_GPIF_LEAD_THEN_UNHINTED))
|
|
out_files = convert_file("dummy.gp", str(tmp_path), track_indices=[0, 1])
|
|
names = [ET.parse(f).getroot().findtext("arrangement") for f in out_files]
|
|
assert names == ["Lead", "Rhythm"]
|
|
|
|
|
|
def test_auto_select_gpx_unhinted_guitar_takes_next_canonical_role():
|
|
# _auto_select_gpx path (auto-select-all): same spread rule.
|
|
root = ET.fromstring(_GPIF_LEAD_THEN_UNHINTED)
|
|
tracks = gp2rs_gpx._gpif_tracks(root)
|
|
_indices, names = gp2rs_gpx._auto_select_gpx(tracks)
|
|
assert sorted(names.values()) == ["Lead", "Rhythm"]
|
|
|
|
|
|
# Codex scenario: unhinted guitar BEFORE a later hinted "Rhythm Guitar". The
|
|
# real rhythm track must still get the canonical "Rhythm" (two-pass reserves
|
|
# hinted roles first); the unhinted one takes the leftover canonical role.
|
|
_GPIF_UNHINTED_BEFORE_RHYTHM = """
|
|
<GPIF>
|
|
<Score><Title>T</Title><Artist>A</Artist></Score>
|
|
<Tracks>
|
|
<Track id="0"><Name>Lead Guitar</Name>
|
|
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
|
|
<Track id="1"><Name>Guitar 2</Name>
|
|
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
|
|
<Track id="2"><Name>Rhythm Guitar</Name>
|
|
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
|
|
</Tracks>
|
|
<MasterBars><MasterBar><Time>4/4</Time><Bars>0 1 2</Bars></MasterBar></MasterBars>
|
|
<Bars>
|
|
<Bar id="0"><Voices>0</Voices></Bar>
|
|
<Bar id="1"><Voices>1</Voices></Bar>
|
|
<Bar id="2"><Voices>2</Voices></Bar>
|
|
</Bars>
|
|
<Voices>
|
|
<Voice id="0"><Beats>0</Beats></Voice>
|
|
<Voice id="1"><Beats>1</Beats></Voice>
|
|
<Voice id="2"><Beats>2</Beats></Voice>
|
|
</Voices>
|
|
<Beats>
|
|
<Beat id="0"><Rhythm ref="r0"/><Notes>0</Notes></Beat>
|
|
<Beat id="1"><Rhythm ref="r0"/><Notes>1</Notes></Beat>
|
|
<Beat id="2"><Rhythm ref="r0"/><Notes>2</Notes></Beat>
|
|
</Beats>
|
|
<Notes>
|
|
<Note id="0"><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>0</Fret></Property></Note>
|
|
<Note id="1"><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>0</Fret></Property></Note>
|
|
<Note id="2"><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>0</Fret></Property></Note>
|
|
</Notes>
|
|
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
|
|
</GPIF>
|
|
"""
|
|
|
|
|
|
def test_convert_file_unhinted_does_not_steal_later_rhythm(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
|
|
lambda _p: ET.fromstring(_GPIF_UNHINTED_BEFORE_RHYTHM))
|
|
out_files = convert_file("dummy.gp", str(tmp_path), track_indices=[0, 1, 2])
|
|
names = [ET.parse(f).getroot().findtext("arrangement") for f in out_files]
|
|
# Hinted roles reserved first → real Rhythm keeps canonical "Rhythm";
|
|
# the unhinted middle track takes the leftover canonical role.
|
|
assert names[0] == "Lead"
|
|
assert names[2] == "Rhythm"
|
|
assert names[1] == "Combo"
|
|
assert "Rhythm 2" not in names and "Lead 2" not in names
|
|
|
|
|
|
def test_auto_select_gpx_unhinted_does_not_steal_later_rhythm():
|
|
root = ET.fromstring(_GPIF_UNHINTED_BEFORE_RHYTHM)
|
|
tracks = gp2rs_gpx._gpif_tracks(root)
|
|
indices, names = gp2rs_gpx._auto_select_gpx(tracks)
|
|
assert names[indices[0]] == "Lead"
|
|
assert names[indices[2]] == "Rhythm"
|
|
assert names[indices[1]] == "Combo"
|