feedBack/lib/notation_lift.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

341 lines
14 KiB
Python

"""Lift legacy guitar-wire keys notes into the Sloppak Notation Format.
The reusable heuristic core shared by the one-time
``scripts/lift_keys_notation.py`` converter and any in-process caller (e.g.
the Arrangement Editor's notation save path). It takes decoded wire notes
plus the song-level ``beats`` array and infers measures, written durations,
and a right/left-hand split, producing a validated ``notation`` payload.
The heuristics (see ``build_notation`` and friends):
1. **Wire decode** — ``decode_wire_notes`` unpacks ``midi = s*24 + f`` (the
Clone Hero / GP-import legacy encoding, sloppak-spec §5.3 legacy fallback),
including chord notes, to ``[{"t", "midi", "sus"}, ...]`` sorted by time.
2. **Measures / tempo** — ``downbeat_times`` reads the song-level downbeats
(``measure >= 0`` entries); ``measure_tempos`` derives a per-measure BPM
from their spacing at the given time signature.
3. **Durations** — wire sustain when > 0, else the gap to the next onset in
the same hand, quantized to the nearest plain or single-dotted
``{1,2,4,8,16,32}`` at the local tempo, floored at a 32nd
(``quantize_duration``).
4. **Hand split** — ``split_hands`` groups simultaneous onsets (within 10 ms);
a group spanning more than 12 semitones is split at its largest internal
interval gap (low side → ``lh``); otherwise the whole group goes by mean
pitch vs middle C (≥ 60 → ``rh``). Output is single-staff when everything
lands on one side.
``build_notation`` assembles these into the schema payload, splitting notes
that cross a barline into tied continuations, and validates via
``notation.validate_notation`` before returning (raising on an invalid build
so a caller never persists a payload the loader would drop). Returns ``None``
when there is nothing to lift (no notes or no downbeats).
"""
from __future__ import annotations
from bisect import bisect_right
import re
import notation as notation_mod
# Arrangement names that identify a piano-family arrangement.
KEYS_NAME_RE = re.compile(r"\b(keys|piano|keyboard|synth)\b", re.IGNORECASE)
# Onsets within this window are treated as one simultaneous group/beat.
SIMULTANEITY_WINDOW_S = 0.010
# A simultaneous group spanning more than this is split between two hands.
HAND_SPLIT_SPAN_SEMITONES = 12
MIDDLE_C = 60
# ── Wire decoding ─────────────────────────────────────────────────────────────
def decode_wire_notes(arr_data: dict) -> list[dict]:
"""Decode an arrangement JSON's notes + chord notes to
``[{"t": float, "midi": int, "sus": float}, ...]`` sorted by time.
Keys content packs absolute MIDI as ``midi = s*24 + f`` (sloppak-spec
§5.3 legacy fallback). Sustain is the ``sus`` field (``l`` accepted as a
legacy alias). Entries with malformed fields are skipped.
"""
out: list[dict] = []
def _push(t, s, f, sus):
try:
t = float(t)
midi = int(s) * 24 + int(f)
sus = float(sus or 0.0)
except (TypeError, ValueError):
return
if 0 <= midi <= 127:
out.append({"t": t, "midi": midi, "sus": max(0.0, sus)})
for n in arr_data.get("notes") or []:
if isinstance(n, dict):
_push(n.get("t"), n.get("s"), n.get("f"), n.get("sus", n.get("l")))
for ch in arr_data.get("chords") or []:
if not isinstance(ch, dict):
continue
ch_t = ch.get("t")
for cn in ch.get("notes") or []:
if isinstance(cn, dict):
# Chord notes carry no own time — they sound at the chord's t.
_push(cn.get("t", ch_t), cn.get("s"), cn.get("f"),
cn.get("sus", cn.get("l")))
out.sort(key=lambda n: (n["t"], n["midi"]))
return out
# ── Hand split ────────────────────────────────────────────────────────────────
def group_simultaneous(notes: list[dict]) -> list[list[dict]]:
"""Group time-sorted notes whose onsets fall within 10 ms of the group start."""
groups: list[list[dict]] = []
for n in notes:
if groups and n["t"] - groups[-1][0]["t"] <= SIMULTANEITY_WINDOW_S:
groups[-1].append(n)
else:
groups.append([n])
return groups
def split_hands(notes: list[dict]) -> dict[str, list[dict]]:
"""Assign every note to ``rh`` or ``lh`` per the heuristic.
Per simultaneous group: a span > 12 semitones splits at the largest
internal interval gap (low side → lh); otherwise the whole group goes by
mean pitch vs middle C (≥ 60 → rh).
"""
hands: dict[str, list[dict]] = {"rh": [], "lh": []}
for group in group_simultaneous(notes):
pitches = sorted(n["midi"] for n in group)
span = pitches[-1] - pitches[0]
if len(pitches) > 1 and span > HAND_SPLIT_SPAN_SEMITONES:
# 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:
mean = sum(pitches) / len(pitches)
hand = "rh" if mean >= MIDDLE_C else "lh"
hands[hand].extend(group)
return {h: ns for h, ns in hands.items() if ns}
# ── Timing ────────────────────────────────────────────────────────────────────
def downbeat_times(beats: list[dict]) -> list[float]:
"""Times of the song-level downbeats (entries with ``measure >= 0``)."""
out: list[float] = []
for b in beats or []:
if not isinstance(b, dict):
continue
try:
measure = int(b.get("measure", -1))
t = float(b.get("time", 0.0))
except (TypeError, ValueError):
continue
if measure >= 0:
out.append(t)
out.sort()
return out
def measure_tempos(downbeats: list[float], ts: tuple[int, int]) -> list[float]:
"""Per-measure BPM from downbeat spacing at the given time signature.
BPM is quarter-note based: a measure holds ``num * 4/den`` quarter notes,
so ``bpm = qn_per_measure * 60 / measure_duration``. The last measure has
no next downbeat and inherits the previous measure's tempo (120 BPM for
a single-measure song).
"""
num, den = ts
qn_per_measure = num * (4.0 / den)
tempos: list[float] = []
for i in range(len(downbeats)):
if i + 1 < len(downbeats) and downbeats[i + 1] > downbeats[i]:
dur = downbeats[i + 1] - downbeats[i]
tempos.append(qn_per_measure * 60.0 / dur)
else:
tempos.append(tempos[-1] if tempos else 120.0)
return tempos
def quantize_duration(dur_secs: float, bpm: float) -> tuple[int, int]:
"""Quantize a duration in seconds to ``(dur, dot)`` at the local tempo.
Candidates are the plain and single-dotted schema denominators
``{1,2,4,8,16,32}``; the closest in absolute seconds wins. Anything at or
below the 32nd floor returns ``(32, 0)``.
"""
qn = 60.0 / bpm
best: tuple[int, int] = (32, 0)
best_err = float("inf")
for den in (1, 2, 4, 8, 16, 32):
for dot in (0, 1):
cand = qn * (4.0 / den) * (1.5 if dot else 1.0)
err = abs(dur_secs - cand)
if err < best_err:
best_err = err
best = (den, dot)
# Floor: never quantize below a plain 32nd.
if dur_secs <= qn * (4.0 / 32):
return (32, 0)
return best
# ── Notation assembly ─────────────────────────────────────────────────────────
_STAFF_DEFS = {
"rh": {"id": "rh", "clef": "G2", "label": "Right Hand"},
"lh": {"id": "lh", "clef": "F4", "label": "Left Hand"},
}
def build_notation(
wire_notes: list[dict],
beats: list[dict],
ts: tuple[int, int] = (4, 4),
instrument: str = "piano",
) -> dict | None:
"""Build a notation payload from decoded wire notes + song beats.
Returns ``None`` when there is nothing to lift (no notes or no downbeats).
"""
if not wire_notes:
return None
downbeats = downbeat_times(beats)
if not downbeats:
return None
tempos = measure_tempos(downbeats, ts)
hands = split_hands(wire_notes)
# Anacrusis: onsets before the first downbeat get their own pickup
# measure (sloppak-spec §5.3 `pickup: true`) starting at the earliest
# such onset, rather than being clamped into measure 1 with beats that
# precede the measure's own `t`.
earliest_onset = min(n["t"] for ns in hands.values() for n in ns)
measure_starts = list(downbeats)
has_pickup = earliest_onset < downbeats[0] - 1e-6
if has_pickup:
measure_starts.insert(0, earliest_onset)
tempos.insert(0, tempos[0])
def _measure_index(t: float) -> int:
return max(0, min(len(measure_starts) - 1, bisect_right(measure_starts, t + 1e-9) - 1))
def _measure_end(mi: int) -> float | None:
return measure_starts[mi + 1] if mi + 1 < len(measure_starts) else None
# Per-hand: resolve each onset group into one beat with a duration,
# splitting notes that cross a barline into tied continuations — a beat
# longer than the space left in its measure is unrepresentable in
# standard notation (e.g. a half note starting on beat 4 of 4/4).
beats_by_measure: dict[int, dict[str, list[dict]]] = {}
def _emit(hand: str, mi: int, t: float, dur_secs: float, midis: list[int], tied: bool) -> None:
bpm = tempos[mi]
dur, dot = quantize_duration(dur_secs, bpm)
beat_out: dict = {"t": round(t, 3), "dur": dur}
if dot:
beat_out["dot"] = dot
beat_out["notes"] = [
{"midi": m, **({"tied": True} if tied else {})} for m in midis
]
beats_by_measure.setdefault(mi, {}).setdefault(hand, []).append(beat_out)
for hand, notes in hands.items():
groups = group_simultaneous(notes)
for gi, group in enumerate(groups):
t = group[0]["t"]
mi = _measure_index(t)
bpm = tempos[mi]
# Raw duration: longest wire sustain in the group when any is
# > 0, else the gap to the next onset group in the same hand
# (last group falls back to one quarter at the local tempo).
sus = max(n["sus"] for n in group)
if sus > 0:
raw = sus
elif gi + 1 < len(groups):
raw = groups[gi + 1][0]["t"] - t
else:
raw = 60.0 / bpm
# Sorted, deduplicated pitches (both hands striking the same key
# at the same instant collapses to one notehead).
midis = sorted({n["midi"] for n in group})
# Walk the span across barlines, emitting a tied continuation in
# each subsequent measure. Tolerance: half a 32nd at the local
# tempo, so quantization jitter doesn't split clean durations.
seg_t, remaining, tied = t, raw, False
while True:
seg_mi = _measure_index(seg_t)
end = _measure_end(seg_mi)
tol = (60.0 / tempos[seg_mi]) * (4.0 / 32) / 2
if end is None or seg_t + remaining <= end + tol:
_emit(hand, seg_mi, seg_t, remaining, midis, tied)
break
_emit(hand, seg_mi, seg_t, end - seg_t, midis, tied)
remaining -= end - seg_t
seg_t = end
tied = True
used_staves = [s for s in ("rh", "lh") if s in hands]
measures: list[dict] = []
num, den = ts
last_emitted_tempo: float | None = None
for mi, start in enumerate(measure_starts):
measure: dict = {"idx": mi + 1, "t": round(start, 3)}
if mi == 0:
measure["ts"] = [num, den]
if has_pickup:
measure["pickup"] = True
bpm = tempos[mi]
if last_emitted_tempo is None or abs(bpm - last_emitted_tempo) > 1.0:
measure["tempo"] = round(bpm, 2)
last_emitted_tempo = bpm
staves_payload: dict[str, dict] = {}
for staff_id in used_staves:
staff_beats = beats_by_measure.get(mi, {}).get(staff_id)
if staff_beats:
staff_beats.sort(key=lambda b: b["t"])
staves_payload[staff_id] = {"voices": [{"v": 1, "beats": staff_beats}]}
measure["staves"] = staves_payload
measures.append(measure)
payload = {
"version": notation_mod.SCHEMA_VERSION,
"instrument": instrument,
"staves": [_STAFF_DEFS[s] for s in used_staves],
"measures": measures,
}
ok, reason = notation_mod.validate_notation(payload)
if not ok: # importer bug guard — never write a payload the loader drops
raise ValueError(f"build_notation produced an invalid payload: {reason}")
return payload