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>
This commit is contained in:
OmikronApex
2026-07-02 08:40:20 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent 991eadeff6
commit 749af31cc3
5 changed files with 343 additions and 145 deletions
+80
View File
@@ -454,6 +454,86 @@ def test_convert_file_writes_notation_sidecar_for_keys_only(tmp_path, monkeypatc
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))
+20
View File
@@ -85,6 +85,26 @@ def test_split_hands_narrow_group_goes_by_mean_vs_middle_c():
assert "rh" in high and "lh" not in high # mean ~65.5 ≥ 60
def test_split_hands_straddling_middle_c_splits_at_middle_c():
# [G2, E3, C4] = [43, 52, 60]: largest gap is G2→E3 (9) but the musically
# correct split is E3|C4. Middle-C boundary → lh=[G2,E3], rh=[C4].
notes = [{"t": 0.0, "midi": m, "sus": 0} for m in (43, 52, 60)]
hands = nl.split_hands(notes)
assert sorted(n["midi"] for n in hands["lh"]) == [43, 52]
assert [n["midi"] for n in hands["rh"]] == [60]
def test_split_hands_middle_c_split_falls_back_when_it_makes_unplayable_hand():
# Em7-shape RH voicing over a low bass note: [E2, B3, D4, G4] = [40,59,62,67].
# A hard middle-C split would put E2+B3 in the LH — a 19-semitone span that
# re-violates the 12-semitone threshold. Must fall back to the largest gap,
# isolating E2 in the LH and keeping the treble voicing in the RH.
notes = [{"t": 0.0, "midi": m, "sus": 0} for m in (40, 59, 62, 67)]
hands = nl.split_hands(notes)
assert [n["midi"] for n in hands["lh"]] == [40]
assert sorted(n["midi"] for n in hands["rh"]) == [59, 62, 67]
# ── Timing ───────────────────────────────────────────────────────────────────
def test_downbeat_times_filters_non_downbeats_and_sorts():