feedBack/tests/test_gp2notation.py
OmikronApex 749af31cc3
fix: correctly import and notate multi-staff (piano/keys) tracks from GP8 (#692)
* fix: correctly import and notate multi-staff (piano/keys) tracks from GP8

Fixes bass stave being dropped on import (bar-column enumeration bug)
and wrong hand-split heuristic in notation_lift for chords straddling
middle C.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: byrongamatos <xasiklas@gmail.com>

* fix(gp-import): fold all grand-staff staves, per-stave tuning, playable hand-splits

Addresses review on #692 (topkoa):

- split_hands: only use the middle-C boundary when both resulting hands are
  within HAND_SPLIT_SPAN_SEMITONES, else fall back to the largest-gap
  heuristic — a hard middle-C split otherwise put a 19-semitone (unplayable)
  span in one hand for bass-under-treble voicings (e.g. E2+B3 under an Em7
  shape).
- Treat any multi-stave (grand-staff) track as keys end-to-end, so the
  stave-0 and folded stave-1+ notes share one encoding and note_count (which
  sums every stave column) matches what actually imports — closing the
  phantom-count case for grand-staff instruments the name/program heuristics
  miss (harp, celesta, marimba).
- Fold *every* extra stave (stave_columns[1:]), not just stave 1.
- Per-staff tuning fall-back to the track-level Tuning property so an untuned
  staff never yields an empty pitch list (silent note loss); via a shared
  _parse_tuning helper.
- Extract _collect_column_notes / _merge_lh_notes so the GPX LH/RH pair merge
  and the GP8 grand-staff fold share one implementation and can't drift in
  tie/timing/dedup handling.
- Rebuild filtered_to_raw from the already-computed stave_columns (one source
  of truth for the counting rule) and drop the dead num_raw_tracks/raw_tracks.

Tests: grand-staff fold + bar-column offset (test_gp2notation.py); both
middle-C split cases (test_notation_lift.py). CHANGELOG updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: byrongamatos <xasiklas@gmail.com>

---------

Signed-off-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 08:40:20 +02:00

607 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

"""Tests for lib/gp2notation.py — Guitar Pro → Sloppak Notation importer.
Fixture-free in the gp2rs_gpx style: every test drives the importer with a
hand-built GPIF ElementTree (and, for the convert_file wiring tests, a
monkeypatched ``_load_gpif``). Covers measure/beat/note emission, the
PR #703 voice→staff routing, tuplets, ties, dots, rests, beat_groups for
compound meters, tempo/ts change-only emission, the Piano LH/RH merge path,
and the sidecar + manifest attachment helpers.
"""
import json
import xml.etree.ElementTree as ET
import pytest
import yaml
import gp2notation
import gp2rs_gpx
from gp2notation import (
attach_notation_to_sloppak,
beat_groups_for,
convert_track_to_notation,
notation_sidecar_path,
write_notation_sidecar,
)
# ── beat_groups_for ──────────────────────────────────────────────────────────
@pytest.mark.parametrize("num, den, expected", [
(4, 4, None), # simple meters: omitted
(3, 4, None),
(2, 4, None),
(6, 8, [3, 3]), # compound
(9, 8, [3, 3, 3]),
(12, 8, [3, 3, 3, 3]),
(5, 8, [2, 3]), # common irregular defaults
(7, 8, [2, 2, 3]),
(3, 8, None), # single dotted beat — unambiguous
(4, 8, None),
(11, 8, None), # uncommon irregular — renderer default
])
def test_beat_groups_for(num, den, expected):
assert beat_groups_for(num, den) == expected
def test_beat_groups_sum_equals_numerator():
# Spec invariant: the sum must equal the time-signature numerator.
for num in range(2, 16):
groups = beat_groups_for(num, 8)
if groups is not None:
assert sum(groups) == num
# ── Fixture builders ─────────────────────────────────────────────────────────
def _gpif(tracks_xml, masterbars_xml, bars_xml, voices_xml, beats_xml,
notes_xml, rhythms_xml, extra=""):
return ET.fromstring(f"""
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
{extra}
<Tracks>{tracks_xml}</Tracks>
<MasterBars>{masterbars_xml}</MasterBars>
<Bars>{bars_xml}</Bars>
<Voices>{voices_xml}</Voices>
<Beats>{beats_xml}</Beats>
<Notes>{notes_xml}</Notes>
<Rhythms>{rhythms_xml}</Rhythms>
</GPIF>""")
def _note_sf(nid, string, fret, tied=False):
tie = '<Tie destination="true"/>' if tied else ""
return (f'<Note id="{nid}">{tie}'
f'<Property name="String"><String>{string}</String></Property>'
f'<Property name="Fret"><Fret>{fret}</Fret></Property></Note>')
def _note_tone(nid, step, octave):
return (f'<Note id="{nid}">'
f'<Property name="Tone"><Step>{step}</Step></Property>'
f'<Property name="Octave"><Number>{octave}</Number></Property></Note>')
# ── Voice → staff routing (PR #703 salvage) ──────────────────────────────────
def _two_voice_piano_root():
"""One 4/4 bar; voice 0 = E4 quarter (rh), voice 1 = C3 quarter (lh)."""
return _gpif(
tracks_xml='<Track id="0"><Name>Piano</Name>'
'<Property name="Tuning"><Pitches>40</Pitches></Property></Track>',
masterbars_xml='<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>',
bars_xml='<Bar id="0"><Voices>0 1</Voices></Bar>',
voices_xml='<Voice id="0"><Beats>b0</Beats></Voice>'
'<Voice id="1"><Beats>b1</Beats></Voice>',
beats_xml='<Beat id="b0"><Rhythm ref="r4"/><Notes>n0</Notes></Beat>'
'<Beat id="b1"><Rhythm ref="r4"/><Notes>n1</Notes></Beat>',
notes_xml=_note_sf("n0", 0, 24) + _note_sf("n1", 0, 8), # midi 64, 48
rhythms_xml='<Rhythm id="r4"><NoteValue>Quarter</NoteValue></Rhythm>',
)
def test_voice_zero_routes_rh_voice_one_routes_lh():
payload = convert_track_to_notation(_two_voice_piano_root(), 0, [40])
assert [s["id"] for s in payload["staves"]] == ["rh", "lh"]
assert payload["staves"][0]["clef"] == "G2"
assert payload["staves"][1]["clef"] == "F4"
m = payload["measures"][0]
rh_beats = m["staves"]["rh"]["voices"][0]["beats"]
lh_beats = m["staves"]["lh"]["voices"][0]["beats"]
assert rh_beats[0]["notes"] == [{"midi": 64}]
assert lh_beats[0]["notes"] == [{"midi": 48}]
def test_forced_lh_track_name_routes_everything_lh():
root = _two_voice_piano_root()
payload = convert_track_to_notation(root, 0, [40], track_name="Piano LH")
assert [s["id"] for s in payload["staves"]] == ["lh"]
m = payload["measures"][0]
assert "rh" not in m["staves"]
# Both voices land on lh, as separate voice entries.
voices = m["staves"]["lh"]["voices"]
assert [v["v"] for v in voices] == [1, 2]
def test_single_voice_track_emits_single_staff():
root = _gpif(
tracks_xml='<Track id="0"><Name>Synth</Name></Track>',
masterbars_xml='<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>',
bars_xml='<Bar id="0"><Voices>0</Voices></Bar>',
voices_xml='<Voice id="0"><Beats>b0</Beats></Voice>',
beats_xml='<Beat id="b0"><Rhythm ref="r4"/><Notes>n0</Notes></Beat>',
notes_xml=_note_tone("n0", 0, 4), # C4 = 60
rhythms_xml='<Rhythm id="r4"><NoteValue>Quarter</NoteValue></Rhythm>',
)
payload = convert_track_to_notation(root, 0, [])
assert [s["id"] for s in payload["staves"]] == ["rh"]
# ── Pitch extraction ─────────────────────────────────────────────────────────
def test_tone_octave_encoding_yields_absolute_midi():
root = _gpif(
tracks_xml='<Track id="0"><Name>Piano</Name></Track>',
masterbars_xml='<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>',
bars_xml='<Bar id="0"><Voices>0</Voices></Bar>',
voices_xml='<Voice id="0"><Beats>b0 b1</Beats></Voice>',
beats_xml='<Beat id="b0"><Rhythm ref="r4"/><Notes>n0</Notes></Beat>'
'<Beat id="b1"><Rhythm ref="r4"/><Notes>n1</Notes></Beat>',
notes_xml=_note_tone("n0", 0, 4) + _note_tone("n1", 5, 3), # C4=60, A3=57
rhythms_xml='<Rhythm id="r4"><NoteValue>Quarter</NoteValue></Rhythm>',
)
payload = convert_track_to_notation(root, 0, [])
beats = payload["measures"][0]["staves"]["rh"]["voices"][0]["beats"]
assert beats[0]["notes"] == [{"midi": 60}]
assert beats[1]["notes"] == [{"midi": 57}]
def test_string_fret_encoding_is_concert_pitch():
# string_pitches[0] = 48 (C3), fret 12 → midi 60 — absolute, no tuning offset.
root = _gpif(
tracks_xml='<Track id="0"><Name>Keys</Name>'
'<Property name="Tuning"><Pitches>48</Pitches></Property></Track>',
masterbars_xml='<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>',
bars_xml='<Bar id="0"><Voices>0</Voices></Bar>',
voices_xml='<Voice id="0"><Beats>b0</Beats></Voice>',
beats_xml='<Beat id="b0"><Rhythm ref="r4"/><Notes>n0</Notes></Beat>',
notes_xml=_note_sf("n0", 0, 12),
rhythms_xml='<Rhythm id="r4"><NoteValue>Quarter</NoteValue></Rhythm>',
)
payload = convert_track_to_notation(root, 0, [48])
beats = payload["measures"][0]["staves"]["rh"]["voices"][0]["beats"]
assert beats[0]["notes"] == [{"midi": 60}]
# ── Durations, dots, tuplets, rests, ties, timing ────────────────────────────
def test_durations_dots_tuplets_rests_and_times():
# 120 BPM 4/4: dotted-eighth (0.375s) + 16th (0.125s) + triplet-eighth
# rest (1/3 s) + quarter — times accumulate via _beat_secs.
root = _gpif(
tracks_xml='<Track id="0"><Name>Piano</Name></Track>',
masterbars_xml='<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>',
bars_xml='<Bar id="0"><Voices>0</Voices></Bar>',
voices_xml='<Voice id="0"><Beats>b0 b1 b2 b3</Beats></Voice>',
beats_xml='<Beat id="b0"><Rhythm ref="rde"/><Notes>n0</Notes></Beat>'
'<Beat id="b1"><Rhythm ref="r16"/><Notes>n1</Notes></Beat>'
'<Beat id="b2"><Rhythm ref="rt8"/></Beat>'
'<Beat id="b3"><Rhythm ref="r4"/><Notes>n2</Notes></Beat>',
notes_xml=_note_tone("n0", 0, 4) + _note_tone("n1", 1, 4) + _note_tone("n2", 2, 4),
rhythms_xml=(
'<Rhythm id="rde"><NoteValue>Eighth</NoteValue><AugmentationDot count="1"/></Rhythm>'
'<Rhythm id="r16"><NoteValue>16th</NoteValue></Rhythm>'
'<Rhythm id="rt8"><NoteValue>Eighth</NoteValue><PrimaryTuplet num="3" den="2"/></Rhythm>'
'<Rhythm id="r4"><NoteValue>Quarter</NoteValue></Rhythm>'
),
extra='<MasterTrack><Automations><Automation><Type>Tempo</Type>'
'<Bar>0</Bar><Value>120 2</Value></Automation></Automations></MasterTrack>',
)
payload = convert_track_to_notation(root, 0, [])
beats = payload["measures"][0]["staves"]["rh"]["voices"][0]["beats"]
assert beats[0] == {"t": 0.0, "dur": 8, "dot": 1, "notes": [{"midi": 60}]}
assert beats[1] == {"t": 0.375, "dur": 16, "notes": [{"midi": 62}]}
assert beats[2] == {"t": 0.5, "dur": 8, "tu": [3, 2], "rest": True}
# Triplet eighth at 120 BPM = (0.25 s × 2/3) ≈ 0.1667 s → next beat 0.667.
assert beats[3]["t"] == pytest.approx(0.667, abs=0.001)
assert beats[3]["dur"] == 4
def test_double_dot_written_dots_agree_with_times():
# 120 BPM: double-dotted quarter = 0.5 × 1.75 = 0.875 s. The written
# ``dot: 2`` must agree with the emitted absolute times (_beat_secs
# deliberately diverges from gp2rs_gpx._beat_dur_secs's single-dot
# approximation here — see the _beat_secs docstring).
root = _gpif(
tracks_xml='<Track id="0"><Name>Piano</Name></Track>',
masterbars_xml='<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>',
bars_xml='<Bar id="0"><Voices>0</Voices></Bar>',
voices_xml='<Voice id="0"><Beats>b0 b1</Beats></Voice>',
beats_xml='<Beat id="b0"><Rhythm ref="rdd"/><Notes>n0</Notes></Beat>'
'<Beat id="b1"><Rhythm ref="r8"/><Notes>n1</Notes></Beat>',
notes_xml=_note_tone("n0", 0, 4) + _note_tone("n1", 1, 4),
rhythms_xml=(
'<Rhythm id="rdd"><NoteValue>Quarter</NoteValue>'
'<AugmentationDot count="2"/></Rhythm>'
'<Rhythm id="r8"><NoteValue>Eighth</NoteValue></Rhythm>'
),
extra='<MasterTrack><Automations><Automation><Type>Tempo</Type>'
'<Bar>0</Bar><Value>120 2</Value></Automation></Automations></MasterTrack>',
)
payload = convert_track_to_notation(root, 0, [])
beats = payload["measures"][0]["staves"]["rh"]["voices"][0]["beats"]
assert beats[0] == {"t": 0.0, "dur": 4, "dot": 2, "notes": [{"midi": 60}]}
assert beats[1]["t"] == pytest.approx(0.875)
def test_tied_note_kept_with_tied_flag():
root = _gpif(
tracks_xml='<Track id="0"><Name>Piano</Name></Track>',
masterbars_xml='<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>',
bars_xml='<Bar id="0"><Voices>0</Voices></Bar>',
voices_xml='<Voice id="0"><Beats>b0 b1</Beats></Voice>',
beats_xml='<Beat id="b0"><Rhythm ref="r2"/><Notes>n0</Notes></Beat>'
'<Beat id="b1"><Rhythm ref="r2"/><Notes>n1</Notes></Beat>',
notes_xml=(
_note_tone("n0", 0, 4)
+ ('<Note id="n1"><Tie destination="true"/>'
'<Property name="Tone"><Step>0</Step></Property>'
'<Property name="Octave"><Number>4</Number></Property></Note>')
),
rhythms_xml='<Rhythm id="r2"><NoteValue>Half</NoteValue></Rhythm>',
)
payload = convert_track_to_notation(root, 0, [])
beats = payload["measures"][0]["staves"]["rh"]["voices"][0]["beats"]
# Unlike the RS-XML walk (tie destinations dropped, sustain extended),
# notation keeps the continuation beat with a tied note.
assert len(beats) == 2
assert beats[0]["notes"] == [{"midi": 60}]
assert beats[1]["notes"] == [{"midi": 60, "tied": True}]
def test_sub_32nd_beats_dropped_with_aligned_timing():
"""64th/128th beats are dropped (schema floor, v1 non-feature) — never
clamped into self-contradictory written durations — and the dropped span
still advances time so following beats stay aligned with the RS XML."""
root = _gpif(
tracks_xml='<Track id="0"><Name>Piano</Name></Track>',
masterbars_xml='<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>',
bars_xml='<Bar id="0"><Voices>0</Voices></Bar>',
voices_xml='<Voice id="0"><Beats>b0 b1</Beats></Voice>',
beats_xml='<Beat id="b0"><Rhythm ref="r64"/><Notes>n0</Notes></Beat>'
'<Beat id="b1"><Rhythm ref="r4"/><Notes>n1</Notes></Beat>',
notes_xml=_note_tone("n0", 0, 4) + _note_tone("n1", 4, 4),
rhythms_xml='<Rhythm id="r64"><NoteValue>64th</NoteValue></Rhythm>'
'<Rhythm id="r4"><NoteValue>Quarter</NoteValue></Rhythm>',
)
payload = convert_track_to_notation(root, 0, [])
beats = payload["measures"][0]["staves"]["rh"]["voices"][0]["beats"]
# The 64th is gone; the quarter that followed it starts one true 64th
# (0.03125s at 120 BPM, rounded to 0.031) after the bar start.
assert len(beats) == 1
assert beats[0]["dur"] == 4
assert beats[0]["t"] == 0.031
# ── Measure-level fields ─────────────────────────────────────────────────────
def test_ts_tempo_emitted_only_on_change_and_beat_groups():
# Bar 1: 4/4 @120. Bar 2: 6/8 (ts change → beat_groups). Bar 3: 6/8 @90.
root = _gpif(
tracks_xml='<Track id="0"><Name>Piano</Name></Track>',
masterbars_xml=(
'<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>'
'<MasterBar><Time>6/8</Time><Bars>1</Bars></MasterBar>'
'<MasterBar><Time>6/8</Time><Bars>2</Bars></MasterBar>'
),
bars_xml='<Bar id="0"><Voices>0</Voices></Bar>'
'<Bar id="1"><Voices>0</Voices></Bar>'
'<Bar id="2"><Voices>0</Voices></Bar>',
voices_xml='<Voice id="0"><Beats>b0</Beats></Voice>',
beats_xml='<Beat id="b0"><Rhythm ref="r4"/><Notes>n0</Notes></Beat>',
notes_xml=_note_tone("n0", 0, 4),
rhythms_xml='<Rhythm id="r4"><NoteValue>Quarter</NoteValue></Rhythm>',
extra='<MasterTrack><Automations>'
'<Automation><Type>Tempo</Type><Bar>0</Bar><Value>120 2</Value></Automation>'
'<Automation><Type>Tempo</Type><Bar>2</Bar><Value>90 2</Value></Automation>'
'</Automations></MasterTrack>',
)
payload = convert_track_to_notation(root, 0, [])
m1, m2, m3 = payload["measures"]
assert m1["idx"] == 1 and m1["t"] == 0.0
assert m1["ts"] == [4, 4] and "beat_groups" not in m1
assert m1["tempo"] == 120.0
# 4/4 @120 = 2.0s; ts change re-emitted with compound grouping.
assert m2["idx"] == 2 and m2["t"] == 2.0
assert m2["ts"] == [6, 8] and m2["beat_groups"] == [3, 3]
assert "tempo" not in m2
# 6/8 @120 = 6 * (4/8) * 0.5 = 1.5s; tempo change at bar 3.
assert m3["t"] == 3.5
assert "ts" not in m3 and "beat_groups" not in m3
assert m3["tempo"] == 90.0
def test_key_signature_emitted_on_change():
root = _gpif(
tracks_xml='<Track id="0"><Name>Piano</Name></Track>',
masterbars_xml=(
'<MasterBar><Key><AccidentalCount>2</AccidentalCount></Key>'
'<Time>4/4</Time><Bars>0</Bars></MasterBar>'
'<MasterBar><Key><AccidentalCount>2</AccidentalCount></Key>'
'<Time>4/4</Time><Bars>0</Bars></MasterBar>'
'<MasterBar><Key><AccidentalCount>-1</AccidentalCount></Key>'
'<Time>4/4</Time><Bars>0</Bars></MasterBar>'
),
bars_xml='<Bar id="0"><Voices>0</Voices></Bar>',
voices_xml='<Voice id="0"><Beats>b0</Beats></Voice>',
beats_xml='<Beat id="b0"><Rhythm ref="r4"/><Notes>n0</Notes></Beat>',
notes_xml=_note_tone("n0", 0, 4),
rhythms_xml='<Rhythm id="r4"><NoteValue>Quarter</NoteValue></Rhythm>',
)
payload = convert_track_to_notation(root, 0, [])
m1, m2, m3 = payload["measures"]
assert m1["ks"] == 2
assert "ks" not in m2
assert m3["ks"] == -1
def test_audio_offset_applied_to_measure_and_beat_times():
payload = convert_track_to_notation(_two_voice_piano_root(), 0, [40],
audio_offset=1.25)
m = payload["measures"][0]
assert m["t"] == 1.25
assert m["staves"]["rh"]["voices"][0]["beats"][0]["t"] == 1.25
# ── LH/RH pair merge ─────────────────────────────────────────────────────────
def test_merged_lh_track_lands_on_lh_staff():
# Track 0 = RH (single voice → rh); raw track 1 = LH partner (forced lh).
root = _gpif(
tracks_xml=(
'<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>'
),
masterbars_xml='<MasterBar><Time>4/4</Time><Bars>0 1</Bars></MasterBar>',
bars_xml='<Bar id="0"><Voices>0</Voices></Bar>'
'<Bar id="1"><Voices>1</Voices></Bar>',
voices_xml='<Voice id="0"><Beats>b0</Beats></Voice>'
'<Voice id="1"><Beats>b1</Beats></Voice>',
beats_xml='<Beat id="b0"><Rhythm ref="r4"/><Notes>n0</Notes></Beat>'
'<Beat id="b1"><Rhythm ref="r4"/><Notes>n1</Notes></Beat>',
notes_xml=_note_sf("n0", 0, 0) + _note_sf("n1", 0, 0), # midi 72 / 48
rhythms_xml='<Rhythm id="r4"><NoteValue>Quarter</NoteValue></Rhythm>',
)
payload = convert_track_to_notation(
root, 0, [72],
track_name="Piano RH", lh_raw_idx=1, lh_string_pitches=[48],
)
m = payload["measures"][0]
assert m["staves"]["rh"]["voices"][0]["beats"][0]["notes"] == [{"midi": 72}]
assert m["staves"]["lh"]["voices"][0]["beats"][0]["notes"] == [{"midi": 48}]
assert [s["id"] for s in payload["staves"]] == ["rh", "lh"]
# ── convert_file wiring (sidecar emission) ───────────────────────────────────
_GPIF_KEYS_AND_GUITAR = """
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
<Tracks>
<Track id="0"><Name>Piano</Name>
<Property name="Tuning"><Pitches>48</Pitches></Property></Track>
<Track id="1"><Name>Lead Guitar</Name>
<Property name="Tuning"><Pitches>64 59 55 50 45 40</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>b0</Beats></Voice>
<Voice id="1"><Beats>b1</Beats></Voice>
</Voices>
<Beats>
<Beat id="b0"><Rhythm ref="r0"/><Notes>n0</Notes></Beat>
<Beat id="b1"><Rhythm ref="r0"/><Notes>n1</Notes></Beat>
</Beats>
<Notes>
<Note id="n0">
<Property name="String"><String>0</String></Property>
<Property name="Fret"><Fret>12</Fret></Property></Note>
<Note id="n1">
<Property name="String"><String>0</String></Property>
<Property name="Fret"><Fret>5</Fret></Property></Note>
</Notes>
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
</GPIF>
"""
def test_convert_file_writes_notation_sidecar_for_keys_only(tmp_path, monkeypatch):
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
lambda _p: ET.fromstring(_GPIF_KEYS_AND_GUITAR))
out_files = gp2rs_gpx.convert_file(
"dummy.gpx", str(tmp_path),
track_indices=[0, 1],
arrangement_names={0: "Keys", 1: "Lead"},
)
assert len(out_files) == 2
keys_xml = next(p for p in out_files if "Keys" in p)
guitar_xml = next(p for p in out_files if "Lead" in p)
side = notation_sidecar_path(keys_xml)
assert side.exists()
assert not notation_sidecar_path(guitar_xml).exists()
payload = json.loads(side.read_text())
ok, reason = __import__("notation").validate_notation(payload)
assert ok, reason
assert payload["instrument"] == "piano"
beats = payload["measures"][0]["staves"]["rh"]["voices"][0]["beats"]
assert beats[0]["notes"] == [{"midi": 60}] # 48 + fret 12
# A GP8 grand-staff piano (one Track, two <Staff> entries → two MasterBar/Bars
# columns) followed by a guitar track. The treble stave carries a String=5 note
# that only decodes against the 6-entry treble tuning — under the old
# last-stave-tuning-wins bug it indexed out of range against the 5-entry bass
# tuning and was silently dropped. The guitar's single note sits at column 2,
# so it also exercises the bar-column offset after a multi-stave predecessor.
_GPIF_GRAND_STAFF_PIANO = """
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
<Tracks>
<Track id="0"><Name>Piano</Name>
<Staves>
<Staff><Properties><Property name="Tuning">
<Pitches>76 71 67 62 57 52</Pitches></Property></Properties></Staff>
<Staff><Properties><Property name="Tuning">
<Pitches>50 45 40 35 30</Pitches></Property></Properties></Staff>
</Staves></Track>
<Track id="1"><Name>Lead Guitar</Name>
<Property name="Tuning"><Pitches>64 59 55 50 45 40</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>b0</Beats></Voice>
<Voice id="1"><Beats>b1</Beats></Voice>
<Voice id="2"><Beats>b2</Beats></Voice>
</Voices>
<Beats>
<Beat id="b0"><Rhythm ref="r0"/><Notes>n0</Notes></Beat>
<Beat id="b1"><Rhythm ref="r0"/><Notes>n1</Notes></Beat>
<Beat id="b2"><Rhythm ref="r0"/><Notes>n2</Notes></Beat>
</Beats>
<Notes>
<Note id="n0">
<Property name="String"><String>5</String></Property>
<Property name="Fret"><Fret>0</Fret></Property></Note>
<Note id="n1">
<Property name="String"><String>0</String></Property>
<Property name="Fret"><Fret>0</Fret></Property></Note>
<Note id="n2">
<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_convert_file_folds_grand_staff_and_offsets_bar_column(tmp_path, monkeypatch):
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
lambda _p: ET.fromstring(_GPIF_GRAND_STAFF_PIANO))
out_files = gp2rs_gpx.convert_file(
"dummy.gpx", str(tmp_path), track_indices=[0, 1],
arrangement_names={0: "Keys", 1: "Lead"},
)
keys_xml = next(p for p in out_files if "Keys" in p)
guitar_xml = next(p for p in out_files if "Lead" in p)
def _notes(xml_path):
root = ET.parse(xml_path).getroot()
return [(int(n.get("string")), int(n.get("fret"))) for n in root.iter("note")]
# Both staves are folded into the one keys arrangement (keys encoding =
# midi//24, midi%24): treble E3 (52, the String=5 note the old bug dropped)
# AND bass D3 (50, the stave-1 column).
keys_midi = sorted(s * 24 + f for s, f in _notes(keys_xml))
assert keys_midi == [50, 52]
# The guitar note lives at bar column 2 (after the 2-column piano). Its own
# bar carries fret 3; the bass column it would wrongly read has fret 0, so a
# single fret-3 note proves the multi-stave column offset landed correctly.
guitar = _notes(guitar_xml)
assert len(guitar) == 1 and guitar[0][1] == 3
def test_convert_file_sidecar_failure_does_not_break_conversion(tmp_path, monkeypatch):
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
lambda _p: ET.fromstring(_GPIF_KEYS_AND_GUITAR))
def _boom(*_a, **_k):
raise RuntimeError("notation importer bug")
monkeypatch.setattr(gp2notation, "convert_track_to_notation", _boom)
out_files = gp2rs_gpx.convert_file(
"dummy.gpx", str(tmp_path),
track_indices=[0], arrangement_names={0: "Keys"},
)
# XML conversion is unaffected; only the sidecar is missing.
assert len(out_files) == 1
assert not notation_sidecar_path(out_files[0]).exists()
# ── Sidecar / manifest helpers ───────────────────────────────────────────────
VALID_PAYLOAD = {"version": 1, "instrument": "piano", "staves": [], "measures": []}
def test_write_notation_sidecar_rejects_invalid():
with pytest.raises(ValueError):
write_notation_sidecar("x.xml", {"version": 1, "staves": []}) # no measures
def test_attach_notation_to_sloppak(tmp_path):
pak = tmp_path / "song.sloppak"
pak.mkdir()
manifest = {
"title": "Song", "artist": "A",
"arrangements": [
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
{"id": "keys", "name": "Keys", "file": "arrangements/keys.json"},
],
"stems": [{"id": "full", "file": "stems/full.ogg"}],
}
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
out = attach_notation_to_sloppak(pak, "keys", VALID_PAYLOAD)
assert out == pak / "notation_keys.json"
assert json.loads(out.read_text()) == VALID_PAYLOAD
rewritten = yaml.safe_load((pak / "manifest.yaml").read_text())
entries = {e["id"]: e for e in rewritten["arrangements"]}
assert entries["keys"]["notation"] == "notation_keys.json"
assert "notation" not in entries["lead"]
# Original key order preserved (sort_keys=False round-trip); the manifest
# rewrite also stamps feedpak_version (spec §4), appended at the end.
assert list(rewritten.keys()) == [
"title", "artist", "arrangements", "stems", "feedpak_version"]
from sloppak import FEEDPAK_VERSION
assert rewritten["feedpak_version"] == FEEDPAK_VERSION
def test_attach_notation_unknown_arrangement_raises(tmp_path):
pak = tmp_path / "song.sloppak"
pak.mkdir()
(pak / "manifest.yaml").write_text(yaml.safe_dump({"arrangements": []}))
with pytest.raises(ValueError):
attach_notation_to_sloppak(pak, "keys", VALID_PAYLOAD)
def test_attach_notation_unsafe_id_raises(tmp_path):
pak = tmp_path / "song.sloppak"
pak.mkdir()
(pak / "manifest.yaml").write_text(yaml.safe_dump({"arrangements": []}))
with pytest.raises(ValueError):
attach_notation_to_sloppak(pak, "../evil", VALID_PAYLOAD)