"""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""" TA {extra} {tracks_xml} {masterbars_xml} {bars_xml} {voices_xml} {beats_xml} {notes_xml} {rhythms_xml} """) def _note_sf(nid, string, fret, tied=False): tie = '' if tied else "" return (f'{tie}' f'{string}' f'{fret}') def _note_tone(nid, step, octave): return (f'' f'{step}' f'{octave}') # ── 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='Piano' '40', masterbars_xml='0', bars_xml='0 1', voices_xml='b0' 'b1', beats_xml='n0' 'n1', notes_xml=_note_sf("n0", 0, 24) + _note_sf("n1", 0, 8), # midi 64, 48 rhythms_xml='Quarter', ) 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='Synth', masterbars_xml='0', bars_xml='0', voices_xml='b0', beats_xml='n0', notes_xml=_note_tone("n0", 0, 4), # C4 = 60 rhythms_xml='Quarter', ) 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='Piano', masterbars_xml='0', bars_xml='0', voices_xml='b0 b1', beats_xml='n0' 'n1', notes_xml=_note_tone("n0", 0, 4) + _note_tone("n1", 5, 3), # C4=60, A3=57 rhythms_xml='Quarter', ) 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='Keys' '48', masterbars_xml='0', bars_xml='0', voices_xml='b0', beats_xml='n0', notes_xml=_note_sf("n0", 0, 12), rhythms_xml='Quarter', ) 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='Piano', masterbars_xml='0', bars_xml='0', voices_xml='b0 b1 b2 b3', beats_xml='n0' 'n1' '' 'n2', notes_xml=_note_tone("n0", 0, 4) + _note_tone("n1", 1, 4) + _note_tone("n2", 2, 4), rhythms_xml=( 'Eighth' '16th' 'Eighth' 'Quarter' ), extra='Tempo' '0120 2', ) 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='Piano', masterbars_xml='0', bars_xml='0', voices_xml='b0 b1', beats_xml='n0' 'n1', notes_xml=_note_tone("n0", 0, 4) + _note_tone("n1", 1, 4), rhythms_xml=( 'Quarter' '' 'Eighth' ), extra='Tempo' '0120 2', ) 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='Piano', masterbars_xml='0', bars_xml='0', voices_xml='b0 b1', beats_xml='n0' 'n1', notes_xml=( _note_tone("n0", 0, 4) + ('' '0' '4') ), rhythms_xml='Half', ) 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='Piano', masterbars_xml='0', bars_xml='0', voices_xml='b0 b1', beats_xml='n0' 'n1', notes_xml=_note_tone("n0", 0, 4) + _note_tone("n1", 4, 4), rhythms_xml='64th' 'Quarter', ) 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='Piano', masterbars_xml=( '0' '1' '2' ), bars_xml='0' '0' '0', voices_xml='b0', beats_xml='n0', notes_xml=_note_tone("n0", 0, 4), rhythms_xml='Quarter', extra='' 'Tempo0120 2' 'Tempo290 2' '', ) 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='Piano', masterbars_xml=( '2' '0' '2' '0' '-1' '0' ), bars_xml='0', voices_xml='b0', beats_xml='n0', notes_xml=_note_tone("n0", 0, 4), rhythms_xml='Quarter', ) 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=( 'Piano RH' '72' 'Piano LH' '48' ), masterbars_xml='0 1', bars_xml='0' '1', voices_xml='b0' 'b1', beats_xml='n0' 'n1', notes_xml=_note_sf("n0", 0, 0) + _note_sf("n1", 0, 0), # midi 72 / 48 rhythms_xml='Quarter', ) 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 = """ TA Piano 48 Lead Guitar 64 59 55 50 45 40 0 1 0 1 b0 b1 n0 n1 0 12 0 5 Quarter """ 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 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 = """ TA Piano 76 71 67 62 57 52 50 45 40 35 30 Lead Guitar 64 59 55 50 45 40 0 1 2 0 1 2 b0 b1 b2 n0 n1 n2 5 0 0 0 0 3 Quarter """ 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)