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
+21 -5
View File
@@ -114,11 +114,27 @@ def split_hands(notes: list[dict]) -> dict[str, list[dict]]:
pitches = sorted(n["midi"] for n in group)
span = pitches[-1] - pitches[0]
if len(pitches) > 1 and span > HAND_SPLIT_SPAN_SEMITONES:
# Largest internal gap; ties resolve to the lowest such gap so the
# left hand keeps the tight low cluster.
gaps = [pitches[i + 1] - pitches[i] for i in range(len(pitches) - 1)]
split_after = gaps.index(max(gaps))
threshold = pitches[split_after] # lh: midi <= threshold
# Prefer middle C as the split boundary when notes straddle it —
# this correctly handles bass+treble chords from piano imports where
# the largest-gap heuristic picks the wrong split point (e.g.
# [G2, E3, C4]: largest gap is G2→E3 but the real split is E3|C4).
# BUT only when both resulting hands are themselves playable: a bass
# note under a treble voicing that merely dips below C4 (e.g.
# [E2, B3, D4, G4]) would otherwise land E2+B3 in one hand — a
# 19-semitone span that re-violates HAND_SPLIT_SPAN_SEMITONES. When
# the middle-C split produces an unplayable hand, fall back to the
# largest internal gap (which correctly isolates E2 there).
threshold = None
if pitches[0] < MIDDLE_C <= pitches[-1]:
_lh = [p for p in pitches if p < MIDDLE_C]
_rh = [p for p in pitches if p >= MIDDLE_C]
if (_lh[-1] - _lh[0] <= HAND_SPLIT_SPAN_SEMITONES
and _rh[-1] - _rh[0] <= HAND_SPLIT_SPAN_SEMITONES):
threshold = MIDDLE_C - 1 # lh: midi < MIDDLE_C
if threshold is None:
gaps = [pitches[i + 1] - pitches[i] for i in range(len(pitches) - 1)]
split_after = gaps.index(max(gaps))
threshold = pitches[split_after]
for n in group:
hands["lh" if n["midi"] <= threshold else "rh"].append(n)
else: