feedBack/lib/song.py
Byron Gamatos c8e0ad3f75
fix(gp-import): correct bass string count, lead/rhythm roles, preview note count (#601)
Four tester-reported GP-import issues, all in the converter/parse layer:

* String count (bugs 2 & 4): <tuning> is padded to 6 slots, so a 4-string
  bass, 5-string bass and 6-string guitar were byte-identical and the real
  count was lost — a 5-string bass played on 4 strings and a 4-string bass
  showed a phantom B in the editor. Record the authoritative count in a new
  <tuning stringCount=N> attribute (gp2rs._build_xml) and trim the padded
  tail back to it on read (song.parse_arrangement). All consumers already
  trust a non-6 tuning length (arrangement_string_count, the editor's
  _stringCountFor and build-time _normalize_tuning_to_count), so this fixes
  the create-mode preview AND the built sloppak with no consumer changes.

* Lead/Rhythm reversed (bug 3): guitar arrangements were named by appearance
  order (first guitar -> Lead), swapping roles for files that list Rhythm
  before Lead. Honor 'lead'/'rhythm' in the GP track name; unhinted tracks
  keep positional fallback. Applied to both convert_file's fallback (the
  editor's track_indices-without-names path) and _auto_select_gpx, with
  cross-role dedup so name-based and positional labels can't collide.

* Preview note count (bug 1): the importer's per-track count included
  tie-continuation notes, which are folded into the previous note's sustain
  and never become separate RS notes (260 shown vs 241 imported). Exclude
  tie destinations so the preview matches the imported result.

Adds regression tests for all three. Bug 5 (no stems from synced audio) is
environment-dependent (best-effort demucs backend) and not addressed here.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:58:35 +02:00

1690 lines
69 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.

"""arrangement XML parser and song data models."""
from dataclasses import dataclass, field
from pathlib import Path
import bisect
import json
import logging
import math
import xml.etree.ElementTree as ET
log = logging.getLogger("feedBack.lib.song")
@dataclass
class Note:
time: float
string: int
fret: int
sustain: float = 0.0
slide_to: int = -1
slide_unpitch_to: int = -1
bend: float = 0.0
# Bend shape (§6.2.1, feedpak 1.4.0). `bend` stays the peak magnitude;
# `bend_intent` is the gesture (0 up, 1 release, 2 pre-bend,
# 3 pre-bend-release, 4 round-trip) and `bend_values` is the optional
# time-stamped curve [{t: seconds-from-onset, v: semitones}], authoritative
# when present. Both default-omitted on the wire; older readers ignore them.
bend_intent: int = 0
bend_values: list | None = None
hammer_on: bool = False
pull_off: bool = False
harmonic: bool = False
harmonic_pinch: bool = False
palm_mute: bool = False
mute: bool = False
vibrato: bool = False
tremolo: bool = False
accent: bool = False
link_next: bool = False
tap: bool = False
fret_hand_mute: bool = False
pluck: bool = False
slap: bool = False
right_hand: int = -1
pick_direction: int = -1
# Teaching marks (§6.2.2, feedpak 1.5.0) — display/teaching only; a grader
# MUST NEVER use these to judge whether a note was played correctly.
# `fret_finger` is the fret-hand finger (-1 unset, 0 thumb, 1..4
# index/middle/ring/pinky — same convention as a chord template's fingers);
# `strum_group` is a strum/rake key (>= -1, default -1; notes sharing a value
# >= 0 are one gesture, with `pick_direction` giving its direction);
# `scale_degree` is the note's pitch class as a chromatic offset 0..11 above
# the active key's tonic (default -1, MAY be derived from keys.json). All
# three default-omitted on the wire; older readers ignore them.
fret_finger: int = -1
strum_group: int = -1
scale_degree: int = -1
ignore: bool = False
@dataclass
class ChordTemplate:
name: str
fingers: list[int]
frets: list[int]
display_name: str = ""
arpeggio: bool = False
# Harmony annotation (§6.6) — key-independent voicing type, e.g. "open",
# "triad", "shell", "drop2", "barre". Display/teaching only, never grading.
voicing: str = ""
# Harmony annotation (§6.6) — the CAGED shape the fingering derives from,
# one of "C"/"A"/"G"/"E"/"D" ("" = unset). Display/teaching only, never grading.
caged: str = ""
# Harmony annotation (§6.6) — chromatic semitone offsets 0..11 above the
# chord root marking the quality-defining tones (e.g. dom7 -> [4, 10]).
# snake_case attr; rides the wire as camelCase "guideTones" (like
# display_name -> "displayName"). Display/teaching only, never grading.
guide_tones: list = field(default_factory=list)
@dataclass
class Chord:
time: float
chord_id: int
notes: list[Note] = field(default_factory=list)
high_density: bool = False
# Harmony annotation (§6.3.1) — key-dependent harmonic function on the chord
# INSTANCE: {rn: str, q: str, deg: int 0..11}. All three keys required when
# present (see _validate_fn). Display/teaching only, never grading.
fn: dict | None = None
@dataclass
class Anchor:
time: float
fret: int
width: int = 4
@dataclass
class Beat:
time: float
measure: int # -1 for non-downbeat
@dataclass
class Section:
name: str
number: int
start_time: float
@dataclass
class HandShape:
chord_id: int
start_time: float
end_time: float
# EOF / some custom song emit `arpeggio` on `<handShape>` (RS14+).
arpeggio: bool = False
@dataclass
class PhraseLevel:
"""One difficulty tier's worth of note/chord/anchor/hand-shape data for a
single phrase iteration. the arrangement XML stores these as `<level
difficulty="N">` blocks that repeat for every difficulty tier the chart
author wrote; feedBack used to collapse them to the phrase's
maxDifficulty and throw the rest away. Keeping them around lets the
highway render a "master difficulty" slider that picks a per-phrase
difficulty tier at render time (feedBack#48)."""
difficulty: int
notes: list[Note] = field(default_factory=list)
chords: list[Chord] = field(default_factory=list)
anchors: list[Anchor] = field(default_factory=list)
hand_shapes: list[HandShape] = field(default_factory=list)
@dataclass
class Phrase:
"""One phrase iteration with every difficulty tier the source chart
provided, scoped to the iteration's time range. `max_difficulty` is the
phrase's authored cap — `levels` may contain entries at or below that
cap (zero-indexed). The full-chart flat arrangement.notes/chords list
is built from max-difficulty levels and is unchanged by this addition;
phrases are additive metadata for the difficulty-slider consumer."""
start_time: float
end_time: float
max_difficulty: int
levels: list[PhraseLevel] = field(default_factory=list)
@dataclass
class Arrangement:
name: str
tuning: list[int] = field(default_factory=lambda: [0] * 6)
capo: int = 0
notes: list[Note] = field(default_factory=list)
chords: list[Chord] = field(default_factory=list)
anchors: list[Anchor] = field(default_factory=list)
hand_shapes: list[HandShape] = field(default_factory=list)
chord_templates: list[ChordTemplate] = field(default_factory=list)
# None for single-level sources (GP converter, old sloppaks) — frontends
# should treat a missing `phrases` as "no per-phrase difficulty data
# available, disable the slider". Populated from arrangement XML when
# multiple `<level>` tiers exist.
phrases: list[Phrase] | None = None
# Tone data lifted from the source archive by the sloppak converter and
# carried inline in the arrangement JSON. None for archive/loose playback
# (the highway reads those tones from the XML directly) and for old
# sloppaks predating tone support. Shape:
# {"base": str, "changes": [{"t": float, "name": str}],
# "definitions": [<raw RS tone object>]}
# `base`/`changes` drive the highway tone-change markers; `definitions`
# feed the Tones plugin gear panel.
tones: dict | None = None
# arrangement XML <arrangementProperties> flags for smart naming (feedBack feat/arrangement).
# Populated from the XML; default False/0 for sloppak / GP-imported sources.
path_lead: bool = False
path_rhythm: bool = False
path_bass: bool = False
bonus_arr: bool = False
represent: int = 0
# RS2014 custom song pitch-shift field (cents). Commonly -1200.0 (one octave
# down) for extended-range bass arrangements. 0.0 when absent or zero.
cent_offset: float = 0.0
# Per-chart tempo override (§6.10): [{time, bpm}]. None when the chart
# follows the song-level tempo; when present a Reader uses it for this
# chart and ignores the song-level tempo.
tempos: list | None = None
@dataclass
class Song:
title: str = ""
artist: str = ""
album: str = ""
year: int = 0
song_length: float = 0.0
offset: float = 0.0
beats: list[Beat] = field(default_factory=list)
sections: list[Section] = field(default_factory=list)
arrangements: list[Arrangement] = field(default_factory=list)
audio_path: str = ""
# Optional lyrics, one entry per syllable: {"t": float, "d": float, "w": str}
lyrics: list[dict] = field(default_factory=list)
# Provenance of the lyrics, when present. One of "xml" | "notechart" | "whisperx" |
# "user" — surfaces in the highway WS payload so the UI can render a badge
# (e.g. "auto-transcribed — may be inaccurate" for whisperx). The sloppak
# loader (lib/sloppak.py) defaults missing manifest keys to "xml" at load
# time so legacy sloppaks aren't mis-badged; the dataclass default of ""
# only persists for sources that build a Song without populating it (e.g.
# in-test stubs, the WS path before lyrics have been emitted).
lyrics_source: str = ""
# ── Wire format serialization (shared between highway_ws and sloppak loader) ──
#
# These helpers produce/consume the same JSON shape the highway WebSocket streams
# to the client. They are the authoritative definition of the `.sloppak`
# arrangement file format — see `arrangements/*.json` inside a sloppak.
def note_to_wire(n: Note) -> dict:
# The pre-existing technique keys (ho/po/hm/hp/pm/mt/vb/tr/ac/tp) keep
# being emitted unconditionally to preserve the legacy wire-format
# contract — older consumers may assume those keys exist. The new
# techniques (ln/fhm/plk/slp/rh/pkd/ig) are introduced default-omitted
# so the highway's per-note WebSocket payload doesn't inflate for the
# common case where they're unset. `note_from_wire` decodes missing
# keys to their dataclass defaults, matching the sloppak spec
# ("Omit fields equal to their default …").
out = {
"t": round(n.time, 3), "s": n.string, "f": n.fret,
"sus": round(n.sustain, 3),
"sl": n.slide_to, "slu": n.slide_unpitch_to,
"bn": round(n.bend, 1) if n.bend else 0,
"ho": n.hammer_on, "po": n.pull_off,
"hm": n.harmonic, "hp": n.harmonic_pinch,
"pm": n.palm_mute, "mt": n.mute,
"vb": n.vibrato,
"tr": n.tremolo, "ac": n.accent, "tp": n.tap,
}
if n.link_next:
out["ln"] = True
if n.fret_hand_mute:
out["fhm"] = True
if n.pluck:
out["plk"] = True
if n.slap:
out["slp"] = True
if n.right_hand != -1:
out["rh"] = n.right_hand
if n.pick_direction != -1:
out["pkd"] = n.pick_direction
if n.ignore:
out["ig"] = True
# Bend shape (§6.2.1) — default-omitted: `bt` only when non-zero, `bnv`
# only when a curve is present. Mirrors the spec's "omit fields equal to
# their default" so a plain bend stays a single `bn` scalar on the wire.
if n.bend_intent:
out["bt"] = int(n.bend_intent)
if n.bend_values:
out["bnv"] = [
{"t": round(p["t"], 3), "v": round(p["v"], 1)}
for p in n.bend_values
]
# Teaching marks (§6.2.2) — default-omitted, mirroring rh/pkd above.
if n.fret_finger != -1:
out["fg"] = n.fret_finger
if n.strum_group != -1:
out["ch"] = n.strum_group
if n.scale_degree != -1:
out["sd"] = n.scale_degree
return out
def chord_note_to_wire(cn: Note) -> dict:
# Chord notes omit their own time (the chord carries it).
d = note_to_wire(cn)
d.pop("t", None)
return d
def chord_to_wire(c: Chord) -> dict:
out = {
"t": round(c.time, 3),
"id": c.chord_id,
"hd": c.high_density,
"notes": [chord_note_to_wire(cn) for cn in c.notes],
}
# Harmony function (§6.3.1) — default-omitted, mirroring bend `bnv`. Re-validate
# on emit (not just decode) so a directly-constructed Chord can't put a partial
# or out-of-range fn on the wire, which would fail the schema's required-keys rule.
fn = _validate_fn(c.fn)
if fn:
out["fn"] = fn
return out
def anchor_to_wire(a: Anchor) -> dict:
return {"time": a.time, "fret": a.fret, "width": a.width}
def hand_shape_to_wire(h: HandShape) -> dict:
return {
"chord_id": h.chord_id,
"start_time": h.start_time,
"end_time": h.end_time,
"arp": h.arpeggio,
}
def chord_template_to_wire(ct: ChordTemplate) -> dict:
out = {
"name": ct.name,
# ChordTemplate.display_name defaults to "" on the dataclass, but
# the spec defaults displayName to name. Fall back here so
# templates that don't set display_name still serialize with a
# usable label (matches `ct.get("displayName", name)` on the
# parsing side, both XML and wire).
"displayName": ct.display_name or ct.name,
"arp": ct.arpeggio,
"fingers": list(ct.fingers),
"frets": list(ct.frets),
}
# Harmony voicing (§6.6) — default-omitted, only when non-empty.
if ct.voicing:
out["voicing"] = ct.voicing
# CAGED shape + guide tones (§6.6) — default-omitted, mirroring voicing.
# Sanitize on EMIT too (not just on decode): a directly-constructed template
# must not be able to write a non-enum `caged` or an out-of-range `guideTone`
# to the wire (the spec constrains caged to C/A/G/E/D and guideTones to 0..11).
_caged = _sanitize_caged(ct.caged)
if _caged:
out["caged"] = _caged
_guide_tones = _sanitize_guide_tones(ct.guide_tones)
if _guide_tones:
out["guideTones"] = _guide_tones
return out
# §6.6 CAGED shape enum — the only values accepted off the wire.
_CAGED_SHAPES = ("C", "A", "G", "E", "D")
def _sanitize_caged(val) -> str:
"""A wire `caged` is kept only when it is one of the CAGED shape letters;
anything else (None, int, list, unknown string) falls back to ""."""
return val if isinstance(val, str) and val in _CAGED_SHAPES else ""
def _sanitize_guide_tones(val) -> list:
"""A wire `guideTones` is kept only as the int entries in 0..11; non-list
input, non-ints (bool is an int subclass — rejected), and out-of-range
values are dropped so a malformed value can't round-trip."""
if not isinstance(val, list):
return []
return [v for v in val
if isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 11]
def _wire_int_optional(v, default=-1):
"""Parse optional wire ints; fall back to default on null/blank/invalid."""
if v is None:
return default
if isinstance(v, str) and not v.strip():
return default
try:
return int(v)
except (ValueError, TypeError, OverflowError):
try:
return int(float(v))
except (ValueError, TypeError, OverflowError):
return default
def _sanitize_bend_curve(raw):
"""Clean a time-stamped bend curve (``[{t, v}]``, §6.2.1): keep entries with
a finite, non-bool numeric ``t`` and ``v``, coerced to float and sorted by
``t``. Non-list / absent / all-invalid input -> ``None`` so an empty curve
round-trips as *omitted*, never ``[]``. ``t`` is seconds from the note
onset; ``v`` is semitones (same scale as the scalar ``bn`` peak)."""
if not isinstance(raw, list):
return None
out: list[dict] = []
for p in raw:
if not isinstance(p, dict):
continue
t = p.get("t")
v = p.get("v")
if (not isinstance(t, (int, float)) or isinstance(t, bool)
or not math.isfinite(t)):
continue
if (not isinstance(v, (int, float)) or isinstance(v, bool)
or not math.isfinite(v)):
continue
out.append({"t": float(t), "v": float(v)})
if not out:
return None
out.sort(key=lambda e: e["t"])
return out
# Natural-note letter -> pitch class (0 = C). Used to parse a keys.json key
# name's tonic for scale-degree derivation (§6.2.2 / §7.7).
_KEY_LETTER_PC = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
def key_to_tonic_pc(key) -> int | None:
"""Parse a keys.json key name (§7.7) to its tonic pitch class 0..11.
Reads only the leading note letter plus optional accidentals — e.g. ``"E"``,
``"Em"``, ``"A#m"``, ``"Bb"``, ``"F#"`` -> 4, 4, 10, 10, 6. The mode/quality
suffix (``m``/``maj``/``min``/scale name) is irrelevant to the tonic and is
ignored. Returns ``None`` for anything not starting with a valid note letter,
so callers can leave ``sd`` unset rather than guess. Used only for teaching
marks; never for grading."""
if not isinstance(key, str):
return None
s = key.strip()
if not s:
return None
pc = _KEY_LETTER_PC.get(s[0].upper())
if pc is None:
return None
# Consume any run of accidentals directly after the letter (``#``/``b``/
# unicode ♯/♭); stop at the first non-accidental (start of the mode suffix).
for ch in s[1:]:
if ch in ("#", ""):
pc += 1
elif ch in ("b", ""):
pc -= 1
else:
break
return pc % 12
def scale_degree_for_pitch(midi_pitch: int, tonic_pc: int) -> int:
"""Chromatic scale degree 0..11 of ``midi_pitch`` above tonic ``tonic_pc``
(§6.2.2): the pitch class distance in semitones, 0 = tonic, 7 = fifth.
Display/teaching only — MUST NEVER feed a grader."""
return (int(midi_pitch) - int(tonic_pc)) % 12
# Open-string base MIDI per string count, index 0 = lowest string. Mirrors
# app.js `_TUNING_BASE_MIDI` / highway_3d `_baseOpenStringMidis` so a derived
# scale degree agrees with the tuner + open-string labels. `arr.tuning` carries
# per-string OFFSETS from standard (not absolute pitch), so the sounding open
# pitch is `base + offset (+ capo)` — see `note_pitch_midi`.
_TUNING_BASE_MIDI = {
4: [28, 33, 38, 43],
5: [23, 28, 33, 38, 43],
6: [40, 45, 50, 55, 59, 64],
7: [35, 40, 45, 50, 55, 59, 64],
8: [30, 35, 40, 45, 50, 55, 59, 64],
}
def base_open_string_midis(string_count: int, is_bass: bool) -> list[int]:
"""Standard open-string base MIDI list for an arrangement, index 0 = lowest.
Mirrors app.js `_tuningOffsetsToFreqs`: a 4/5-string *bass* uses its own low
base, while a 4/5-string non-bass (a guitar voicing) borrows the low strings
of the 6-string base; 6/7/8 use their own. Unknown counts fall back to the
6-string base."""
n = int(string_count)
if n in (4, 5):
return _TUNING_BASE_MIDI[n] if is_bass else _TUNING_BASE_MIDI[6]
return _TUNING_BASE_MIDI.get(n, _TUNING_BASE_MIDI[6])
def pitch_from_base(base: list[int], capo: int, tuning: list[int],
string: int, fret: int) -> int | None:
"""Absolute sounding MIDI for one string+fret, given a precomputed open-string
``base`` (from :func:`base_open_string_midis`) and the arrangement's tuning
OFFSETS + capo. None when ``string`` has no tuning entry. Single source of the
pitch formula so the per-note hot path can hoist ``base`` out of the loop."""
if not (0 <= string < len(tuning)) or not base:
return None
root = base[string] if string < len(base) else base[-1]
return root + int(tuning[string]) + int(capo) + int(fret)
def note_pitch_midi(arr: "Arrangement", note: "Note") -> int | None:
"""Absolute sounding MIDI pitch of ``note`` on arrangement ``arr``, or None
when its string index has no tuning entry.
Pitch = standard base for the string + the arrangement's per-string tuning
OFFSET + capo + fret, matching the client's open-string/tuner math. Used to
derive the ``sd`` teaching mark (§6.2.2); display only, never grading.
O(notes) via ``arrangement_string_count`` — for a whole arrangement, hoist
the base with :func:`base_open_string_midis` and call :func:`pitch_from_base`
per note instead."""
is_bass = "bass" in (arr.name or "").lower()
base = base_open_string_midis(arrangement_string_count(arr), is_bass)
return pitch_from_base(base, int(getattr(arr, "capo", 0) or 0),
arr.tuning or [], note.string, note.fret)
def note_from_wire(d: dict, time: float | None = None) -> Note:
return Note(
time=float(d.get("t", time if time is not None else 0.0)),
string=int(d.get("s", 0)),
fret=int(d.get("f", 0)),
sustain=float(d.get("sus", 0.0)),
slide_to=int(d.get("sl", -1)),
slide_unpitch_to=int(d.get("slu", -1)),
bend=float(d.get("bn", 0.0)),
bend_intent=_wire_int_optional(d.get("bt"), 0),
bend_values=_sanitize_bend_curve(d.get("bnv")),
hammer_on=bool(d.get("ho", False)),
pull_off=bool(d.get("po", False)),
harmonic=bool(d.get("hm", False)),
harmonic_pinch=bool(d.get("hp", False)),
palm_mute=bool(d.get("pm", False)),
mute=bool(d.get("mt", False)),
vibrato=bool(d.get("vb", d.get("vibrato", False))),
tremolo=bool(d.get("tr", False)),
accent=bool(d.get("ac", False)),
tap=bool(d.get("tp", False)),
link_next=bool(d.get("ln", False)),
fret_hand_mute=bool(d.get("fhm", False)),
pluck=bool(d.get("plk", False)),
slap=bool(d.get("slp", False)),
# Optional integer metadata — graceful-fallback decode, matching
# the XML side's `_int_optional`.
right_hand=_wire_int_optional(d.get("rh"), -1),
pick_direction=_wire_int_optional(d.get("pkd"), -1),
# Teaching marks (§6.2.2) — display only, never used for grading.
fret_finger=_wire_int_optional(d.get("fg"), -1),
strum_group=_wire_int_optional(d.get("ch"), -1),
scale_degree=_wire_int_optional(d.get("sd"), -1),
ignore=bool(d.get("ig", False)),
)
def _validate_fn(raw) -> dict | None:
"""Validate an optional chord harmony function (§6.3.1).
Returns a clean ``{"rn", "q", "deg"}`` dict only when ``raw`` is an object
with a non-empty ``rn`` string, a non-empty ``q`` string, and an int ``deg``
in 0..11. Any malformed / missing-key / out-of-range input -> ``None`` so a
partial fn (which would fail the schema's required-keys rule) never rides the
wire. Display/teaching only — MUST NEVER feed a grader. Mirrors the
drop-to-default tolerance of `_sanitize_bend_curve`."""
if not isinstance(raw, dict):
return None
rn = raw.get("rn")
q = raw.get("q")
deg = raw.get("deg")
if not isinstance(rn, str) or not rn.strip():
return None
if not isinstance(q, str) or not q.strip():
return None
# bool is an int subclass — reject it so `deg=True` can't pass as 1.
if not isinstance(deg, int) or isinstance(deg, bool) or not (0 <= deg <= 11):
return None
return {"rn": rn.strip(), "q": q.strip(), "deg": deg}
def chord_from_wire(d: dict) -> Chord:
t = float(d.get("t", 0.0))
return Chord(
time=t,
chord_id=int(d.get("id", 0)),
high_density=bool(d.get("hd", False)),
notes=[note_from_wire(cn, time=t) for cn in d.get("notes", [])],
fn=_validate_fn(d.get("fn")),
)
def phrase_level_to_wire(pl: PhraseLevel) -> dict:
return {
"difficulty": pl.difficulty,
"notes": [note_to_wire(n) for n in pl.notes],
"chords": [chord_to_wire(c) for c in pl.chords],
"anchors": [anchor_to_wire(a) for a in pl.anchors],
"handshapes": [hand_shape_to_wire(h) for h in pl.hand_shapes],
}
def phrase_to_wire(p: Phrase) -> dict:
return {
"start_time": round(p.start_time, 3),
"end_time": round(p.end_time, 3),
"max_difficulty": p.max_difficulty,
"levels": [phrase_level_to_wire(lv) for lv in p.levels],
}
def phrase_level_from_wire(d: dict) -> PhraseLevel:
return PhraseLevel(
difficulty=int(d.get("difficulty", 0)),
notes=[note_from_wire(n) for n in d.get("notes", [])],
chords=[chord_from_wire(c) for c in d.get("chords", [])],
anchors=[
Anchor(time=float(a.get("time", 0)), fret=int(a.get("fret", 0)),
width=int(a.get("width", 4)))
for a in d.get("anchors", [])
],
hand_shapes=[
HandShape(chord_id=int(h.get("chord_id", 0)),
start_time=float(h.get("start_time", 0)),
end_time=float(h.get("end_time", 0)),
arpeggio=bool(h.get("arp", False)))
for h in d.get("handshapes", [])
],
)
def phrase_from_wire(d: dict) -> Phrase:
return Phrase(
start_time=float(d.get("start_time", 0.0)),
end_time=float(d.get("end_time", 0.0)),
max_difficulty=int(d.get("max_difficulty", 0)),
levels=[phrase_level_from_wire(lv) for lv in d.get("levels", [])],
)
def arrangement_string_count(arr: Arrangement) -> int:
"""Derive the active arrangement's string count.
Used by the server to emit ``stringCount`` in the song_info
WebSocket payload (feedBack-plugin-3dhighway#7).
The arrangement XML schema always emits 6 ``<tuning>`` slots regardless
of instrument (bass charts populate `string0``string3` and pad
`string4`/`string5` with zeros), so ``len(arr.tuning)`` is not
a reliable signal. Two independent signals get combined:
1. **Notes-derived lower bound.** The highest string index
referenced anywhere in notes + chord-notes, +1. A GP-imported
7-string guitar with notes on strings 0..6 reports 7 here.
But this is a LOWER BOUND only — a 6-string lead chart that
never plays string 5 reports 5, undercounting by 1.
2. **Name-based fallback.** Arrangements named "Bass" (case-
insensitive substring match) default to 4; everything else
defaults to 6. This catches the partial-string-usage case
where notes don't span all the instrument's strings.
A third signal — ``len(arr.tuning)`` when it isn't the arrangement XML
padded value of 6 — folds in for sloppak / GP-imported sources
where the tuning array is explicitly trimmed (4 for bass, 5 for
5-string bass, 7 for 7-string guitar, etc.). arrangement XML / archive
sources always emit length 6 regardless of instrument, so we
deliberately ignore that exact value to avoid mis-classifying
bass arrangements as guitar. ``< 6`` and ``> 6`` are both
trustworthy signals.
The result is ``max(notes_count, name_based, tuning_count)``
where ``tuning_count`` is ``len(arr.tuning)`` when ``!= 6``,
else 0. Worked examples:
* arrangement XML 4-string bass, full usage (tuning len 6, notes 0..3) →
max(4, 4, 0) = 4
* arrangement XML 4-string bass, sparse usage (tuning len 6, notes 0..2) →
max(3, 4, 0) = 4
* arrangement XML 6-string lead, full usage (tuning len 6, notes 0..5) →
max(6, 6, 0) = 6
* arrangement XML 6-string lead, sparse usage (tuning len 6, notes 0..4) →
max(5, 6, 0) = 6
* Sloppak 5-string bass, sparse usage (tuning len 5, notes 0..3) →
max(4, 4, 5) = 5
* GP 7-string guitar (tuning len 7, notes 0..6) → max(7, 6, 7) = 7
* GP 5-string bass (tuning len 5, notes 0..4) → max(5, 4, 5) = 5
* Empty arrangement named "Bass" (tuning len 6) →
max(0, 4, 0) = 4
* Empty arrangement named "Lead" (tuning len 6) →
max(0, 6, 0) = 6
Topkoa's issue argues plugins shouldn't do arrangement-name
matching; server-side fallback IS the right place for it
because it gives plugins a single reliable ``stringCount`` to
consume.
"""
max_s = -1
for n in arr.notes:
if n.string > max_s:
max_s = n.string
for ch in arr.chords:
for cn in ch.notes:
if cn.string > max_s:
max_s = cn.string
notes_count = max_s + 1 if max_s >= 0 else 0
name_based = 4 if "bass" in arr.name.lower() else 6
# Tuning-length signal — only trustworthy when NOT the arrangement XML
# padded value of 6. Length 4/5 indicates explicit bass / 5-string
# bass; length 7/8 indicates an extended-range guitar from GP.
tuning_len = len(arr.tuning)
tuning_count = tuning_len if tuning_len != 6 else 0
return max(notes_count, name_based, tuning_count)
def compute_smart_names(arrangements: list[Arrangement]) -> list[str | None]:
"""Compute smart display names for arrangements based on arrangement XML path flags.
Returns a list parallel to `arrangements`. Each entry is a descriptive
name like "Lead", "Alt. Lead", "Bonus Rhythm", "Bass", or None for
non-instrument arrangements (Vocals, ShowLights) and unrecognised
names whose path flags are all zero.
Path-type resolution (first match wins):
1. XML <arrangementProperties> flags (path_lead / path_rhythm / path_bass)
2. Name-based fallback when ALL three flags are zero — keeps sloppak /
GP-imported sources and custom song with unset flags working by mapping
"Lead" / "Rhythm" / "Bass" / "Combo" → the matching path. Anything
outside that set (Vocals, ShowLights, …) → None.
Naming rules per path type (Lead / Rhythm / Bass):
- Main group (bonusArr=False):
represent=1 → "Lead" (or "Rhythm" / "Bass") — the canonical
arrangement. If no entry has represent=1 (custom song with all-zero
flags), the first by represent-ascending order is promoted.
remaining (n_alts == 1) → "Alt. Lead"
remaining (n_alts >= 2) → "Alt. Lead 1", "Alt. Lead 2", ...
(sorted by represent ascending)
- Bonus group (bonusArr=True), sorted by represent ascending:
single → "Bonus Lead"
multiple → "Bonus Lead 1", "Bonus Lead 2", ...
"""
result: list[str | None] = [None] * len(arrangements)
# Name-to-(path_attr, bonus_override) fallback used when all XML path
# flags are zero. "Combo" is a guitar arrangement (lead + rhythm combined)
# — treat as Lead. load_song() also synthesises display names like
# "Bonus Lead" / "Bass 2" when manifest data is missing, so we recognise
# those too and force the bonus flag for "Bonus *". `None` for the
# override means "leave the dataclass's bonus_arr alone".
_NAME_FALLBACK: dict[str, tuple[str, bool | None]] = {
"lead": ("path_lead", None),
"rhythm": ("path_rhythm", None),
"bass": ("path_bass", None),
"bass 2": ("path_bass", None),
"combo": ("path_lead", None),
"bonus lead": ("path_lead", True),
"bonus rhythm": ("path_rhythm", True),
"bonus bass": ("path_bass", True),
"alt. lead": ("path_lead", False),
"alt. rhythm": ("path_rhythm", False),
"alt. bass": ("path_bass", False),
}
def _resolve(a: Arrangement) -> tuple[str | None, bool]:
"""Return (path_attr, bonus_arr) for an arrangement, applying the
name-based fallback when XML flags are all zero. Defensive against
non-string names from hand-edited archives / sloppak JSON."""
if a.path_lead:
return "path_lead", bool(a.bonus_arr)
if a.path_rhythm:
return "path_rhythm", bool(a.bonus_arr)
if a.path_bass:
return "path_bass", bool(a.bonus_arr)
name = a.name if isinstance(a.name, str) else ""
entry = _NAME_FALLBACK.get(name.strip().lower())
if entry is None:
return None, bool(a.bonus_arr)
path_attr, bonus_override = entry
return path_attr, bool(a.bonus_arr) if bonus_override is None else bonus_override
_resolved = [_resolve(a) for a in arrangements]
for path_attr, label in (
("path_lead", "Lead"),
("path_rhythm", "Rhythm"),
("path_bass", "Bass"),
):
type_arrs = [
(i, arrangements[i]) for i, (pa, _bonus) in enumerate(_resolved)
if pa == path_attr
]
if not type_arrs:
continue
# Main group (bonusArr=False):
# represent=1 → standard arrangement ("Lead")
# represent=0 (or any value != 1) → alternate arrangement ("Alt. Lead")
#
# If no arrangement has represent=1 (e.g. custom song defaults or all-zero
# flags with name fallback), fall back to treating the first by
# represent-ascending order as the standard so there is always a "Lead".
main_pairs = [(i, a) for i, a in type_arrs if not _resolved[i][1]]
standard = [(i, a) for i, a in main_pairs if a.represent == 1]
alts = sorted(
[(i, a) for i, a in main_pairs if a.represent != 1],
key=lambda x: x[1].represent,
)
if not standard and alts:
standard = [alts[0]]
alts = alts[1:]
# Guard: if somehow multiple represent=1 exist, promote only the first.
if len(standard) > 1:
extra = sorted(standard[1:], key=lambda x: x[1].represent)
alts = sorted(extra + alts, key=lambda x: x[1].represent)
standard = standard[:1]
for i, _ in standard:
result[i] = label
n_alts = len(alts)
for j, (i, _) in enumerate(alts):
if n_alts == 1:
result[i] = f"Alt. {label}"
else:
result[i] = f"Alt. {label} {j + 1}"
# Bonus group (bonusArr=True): sorted by represent ascending.
bonus_arrs = sorted(
[(i, a) for i, a in type_arrs if _resolved[i][1]],
key=lambda x: x[1].represent,
)
n_bonus = len(bonus_arrs)
for j, (i, _) in enumerate(bonus_arrs):
if n_bonus == 1:
result[i] = f"Bonus {label}"
else:
result[i] = f"Bonus {label} {j + 1}"
return result
def _finite_float(value, default: float = 0.0) -> float:
"""Coerce ``value`` to a finite float, falling back to ``default``.
Malformed custom song can put ``NaN``/``Infinity`` into float fields like RS2014
``<centOffset>``; ``float()`` accepts those, but they serialize to the
invalid JSON tokens ``NaN``/``Infinity`` (both over the highway WebSocket
and into sloppak ``.json`` files), which breaks downstream parsing.
"""
try:
v = float(value)
except (TypeError, ValueError):
return default
return v if math.isfinite(v) else default
def sanitize_tempos(events) -> list[dict]:
"""Clean a tempo-event list (``[{time, bpm}]``): keep entries with a finite
non-bool ``time`` and a finite ``bpm > 0``, coerced to float and sorted by
time. Non-list / all-invalid input -> ``[]``. Shared by the per-chart
arrangement ``tempos`` (§6.10) and the song-level ``song_timeline.tempos``."""
out: list[dict] = []
if isinstance(events, list):
for ev in events:
if not isinstance(ev, dict):
continue
t = ev.get("time")
bpm = ev.get("bpm")
if (not isinstance(t, (int, float)) or isinstance(t, bool)
or not math.isfinite(t)):
continue
if (not isinstance(bpm, (int, float)) or isinstance(bpm, bool)
or not math.isfinite(bpm) or bpm <= 0):
continue
out.append({"time": float(t), "bpm": float(bpm)})
out.sort(key=lambda e: e["time"])
return out
def arrangement_to_wire(arr: Arrangement) -> dict:
"""Serialize an Arrangement into a JSON-ready dict matching the wire format."""
out = {
"name": arr.name,
"tuning": list(arr.tuning),
"capo": arr.capo,
"centOffset": arr.cent_offset,
"notes": [note_to_wire(n) for n in arr.notes],
"chords": [chord_to_wire(c) for c in arr.chords],
"anchors": [anchor_to_wire(a) for a in arr.anchors],
"handshapes": [hand_shape_to_wire(h) for h in arr.hand_shapes],
"templates": [chord_template_to_wire(ct) for ct in arr.chord_templates],
}
# phrases is additive — only include the key when the source had
# multi-level data. Treat an empty list the same as None ("slider
# disabled"): emitting `"phrases": []` would otherwise signal
# "slider enabled but with no ladder" to consumers. Sloppak readers
# / old consumers that don't know about phrases just continue to
# see the flat-merge arrangement.
if arr.phrases:
out["phrases"] = [phrase_to_wire(p) for p in arr.phrases]
# `tones` is additive — only emitted when the source carried tone data
# (sloppaks converted from a archive). Absent on archive/loose-derived
# Arrangements and old sloppaks; readers treat a missing key as
# "no tones".
if arr.tones:
out["tones"] = arr.tones
# Per-chart tempo override (§6.10) — additive; omit when the chart follows
# the song-level tempo (empty/None).
if arr.tempos:
out["tempos"] = list(arr.tempos)
return out
def arrangement_from_wire(d: dict) -> Arrangement:
"""Parse a wire-format arrangement dict back into an Arrangement dataclass."""
return Arrangement(
name=d.get("name", ""),
tuning=list(d.get("tuning", [0] * 6)),
capo=int(d.get("capo", 0)),
cent_offset=_finite_float(d.get("centOffset", 0.0)),
tempos=(sanitize_tempos(d.get("tempos")) or None),
notes=[note_from_wire(n) for n in d.get("notes", [])],
chords=[chord_from_wire(c) for c in d.get("chords", [])],
anchors=[
Anchor(time=float(a.get("time", 0)), fret=int(a.get("fret", 0)),
width=int(a.get("width", 4)))
for a in d.get("anchors", [])
],
hand_shapes=[
HandShape(chord_id=int(h.get("chord_id", 0)),
start_time=float(h.get("start_time", 0)),
end_time=float(h.get("end_time", 0)),
arpeggio=bool(h.get("arp", False)))
for h in d.get("handshapes", [])
],
chord_templates=[
ChordTemplate(name=ct.get("name", ""),
display_name=ct.get("displayName", ct.get("name", "")),
arpeggio=bool(ct.get("arp", False)),
fingers=list(ct.get("fingers", [-1] * 6)),
frets=list(ct.get("frets", [-1] * 6)),
voicing=(ct.get("voicing")
if isinstance(ct.get("voicing"), str) else ""),
caged=_sanitize_caged(ct.get("caged")),
guide_tones=_sanitize_guide_tones(ct.get("guideTones")))
for ct in d.get("templates", [])
],
# `phrases` is optional — absent on single-level sources / older
# sloppaks. Preserve None (rather than []) to preserve the
# "slider disabled" signal downstream; an explicit empty list on
# the wire is treated the same as absent.
phrases=(
[phrase_from_wire(p) for p in d["phrases"]]
if d.get("phrases") else None
),
# `tones` is an opaque block written by the converter. An empty dict
# normalizes to None ("no tones") — symmetric with
# `arrangement_to_wire`, which only emits the key when `arr.tones` is
# truthy, and consistent with how `phrases` treats an empty value.
# Absent on older sloppaks.
tones=(d["tones"] if isinstance(d.get("tones"), dict) and d["tones"] else None),
)
def _float(elem, attr, default=0.0):
v = elem.get(attr)
return float(v) if v is not None else default
def _int(elem, attr, default=0):
v = elem.get(attr)
if v is None:
return default
try:
return int(v)
except ValueError:
return int(float(v))
def _int_optional(elem, attr, default=-1):
"""Graceful-fallback integer reader for *optional* XML attributes.
Use for fields that are merely metadata hints (right-hand fingering,
pick direction, etc.) where a malformed value from a third-party
arrangement XML emitter shouldn't abort the whole arrangement parse.
Required-field readers (`string`, `fret`, `chordId`, …) keep using
`_int` so a corrupted required attribute still fails fast at parse
time rather than silently defaulting to a wrong index.
"""
v = elem.get(attr)
if v is None or (isinstance(v, str) and not v.strip()):
return default
try:
return int(v)
except (ValueError, TypeError, OverflowError):
try:
return int(float(v))
except (ValueError, TypeError, OverflowError):
return default
_FALSE_LITERALS = frozenset({"", "0", "false", "False", "FALSE"})
def _bool(elem, attr):
v = elem.get(attr)
return v is not None and v not in _FALSE_LITERALS
def _hand_shape_arpeggio_flag(elem) -> bool:
"""charts / EOF may mark arpeggio on ``<handShape>`` (various casings)."""
for attr in ("arpeggio", "Arpeggio", "arp", "Arp"):
if _bool(elem, attr):
return True
return False
def _chord_template_arpeggio_flag(elem) -> bool:
"""charts commonly tag arpeggio templates in ``displayName`` via ``-arp``."""
for attr in ("arpeggio", "Arpeggio", "arp", "Arp"):
if _bool(elem, attr):
return True
display_name = elem.get("displayName", "")
if isinstance(display_name, str):
lowered = display_name.lower()
if "-arp" in lowered or "arpeggio" in lowered:
return True
return False
def _chord_high_density(elem: ET.Element) -> bool:
"""RS14 uses ``highDensity`` on ``<chord>``; some converters vary casing."""
for attr in ("highDensity", "highdensity", "HighDensity"):
if _bool(elem, attr):
return True
return False
def _parse_bend_values(n):
"""Read a `bendValues` JSON attribute (GP import emits it; §6.2.1) and
sanitize it into a [{t,v}] curve, or None when absent/malformed."""
raw = n.get("bendValues")
if not raw:
return None
try:
return _sanitize_bend_curve(json.loads(raw))
except (ValueError, TypeError):
return None
def _parse_note(n) -> Note:
return Note(
time=_float(n, "time"),
string=_int(n, "string"),
fret=_int(n, "fret"),
sustain=_float(n, "sustain"),
slide_to=_int(n, "slideTo", -1),
slide_unpitch_to=_int(n, "slideUnpitchTo", -1),
bend=_float(n, "bend"),
bend_intent=_int(n, "bendIntent", 0),
bend_values=_parse_bend_values(n),
hammer_on=_bool(n, "hammerOn"),
pull_off=_bool(n, "pullOff"),
harmonic=_bool(n, "harmonic"),
harmonic_pinch=_bool(n, "harmonicPinch"),
palm_mute=_bool(n, "palmMute"),
mute=_bool(n, "mute"),
vibrato=_bool(n, "vibrato"),
tremolo=_bool(n, "tremolo"),
accent=_bool(n, "accent"),
link_next=_bool(n, "linkNext"),
tap=_bool(n, "tap"),
fret_hand_mute=_bool(n, "fretHandMute"),
pluck=_bool(n, "pluck"),
slap=_bool(n, "slap"),
right_hand=_int_optional(n, "rightHand", -1),
pick_direction=_int_optional(n, "pickDirection", -1),
# Teaching mark (§6.2.2): GP import writes `fretFinger`; strum_group /
# scale_degree are authored downstream (editor / derived), not in chart
# XML, so they have no attribute to read here.
fret_finger=_int_optional(n, "fretFinger", -1),
ignore=_bool(n, "ignore"),
)
def parse_arrangement(xml_path: str) -> Arrangement:
"""Parse a chart arrangement XML file."""
tree = ET.parse(xml_path)
root = tree.getroot()
# Name
arr_name = ""
el = root.find("arrangement")
if el is not None and el.text:
arr_name = el.text
# Tuning. RS schema has string0..string5; 7+ string arrangements
# (7/8-string guitar, or pathological >6-string bass) carry
# additional string6+ attributes that we preserve when present.
# 4/5/6-string sources fit within string0..string5 and produce
# no extra attributes.
tuning = [0] * 6
el = root.find("tuning")
if el is not None:
for i in range(6):
tuning[i] = _int(el, f"string{i}")
i = 6
while el.get(f"string{i}") is not None:
tuning.append(_int(el, f"string{i}"))
i += 1
# Authoritative string count, written by the GP/RS serializer
# (gp2rs._build_xml). The schema pads `<tuning>` to 6 slots, which
# erases the 4-vs-5-vs-6-string distinction for standard tunings;
# when the real count was recorded, trim the padded tail so
# arrangement_string_count / the editor see 4 or 5 instead of 6.
# Absent (archive / legacy sources) → leave the 6-slot tuning as-is.
sc = el.get("stringCount")
if sc is not None:
try:
n = int(sc)
except (TypeError, ValueError):
n = 0
if 1 <= n <= len(tuning):
tuning = tuning[:n]
# Capo
capo = 0
el = root.find("capo")
if el is not None and el.text:
try:
capo = int(el.text)
except ValueError:
pass
# CentOffset — RS2014 pitch-shift field (cents). Present in all arrangement XML
# sources (archive, loose folders, GP-converted XML). Absent in very old
# files; default 0.0.
cent_offset = 0.0
el = root.find("centOffset")
if el is not None and el.text:
# _finite_float guards against malformed NaN/Infinity reaching the
# song_info WebSocket payload (invalid JSON) the same way song.offset
# is sanitized server-side.
cent_offset = _finite_float(el.text)
# Chord templates. RS schema names fret0..fret5 / finger0..finger5;
# extended-range arrangements emit additional fret6/finger6 (and so on)
# which we preserve so 7/8-string chord templates round-trip correctly.
chord_templates = []
container = root.find("chordTemplates")
if container is not None:
for ct in container.findall("chordTemplate"):
chord_name = ct.get("chordName", "")
# Spec defaults displayName to name; treat an explicit empty
# / whitespace-only displayName attribute the same as a
# missing one so the parsed dataclass always has a usable
# label (matches the wire emitter's `display_name or name`).
display_name_attr = ct.get("displayName", "")
display_name = display_name_attr.strip() or chord_name
width = 6
while ct.get(f"fret{width}") is not None or ct.get(f"finger{width}") is not None:
width += 1
chord_templates.append(
ChordTemplate(
name=chord_name,
display_name=display_name,
arpeggio=_chord_template_arpeggio_flag(ct),
fingers=[_int(ct, f"finger{i}", -1) for i in range(width)],
frets=[_int(ct, f"fret{i}", -1) for i in range(width)],
)
)
# Merge notes per-phrase: each phrase has its own maxDifficulty, and the full
# chart is built by taking each phrase's notes from its max difficulty level.
# For single-level XMLs (e.g. from GP converter), skip merging and use the one level directly.
levels_el = root.find("levels")
phrases_el = root.find("phrases")
phrase_iters_el = root.find("phraseIterations")
all_levels = {}
if levels_el is not None:
for level in levels_el.findall("level"):
all_levels[_int(level, "difficulty")] = level
notes = []
chords = []
anchors = []
hand_shapes = []
# Pre-parse each `<level>` once into time-sorted arrays plus a
# parallel list of times for bisect. The phrase merge below slices
# these per (phraseIteration × difficulty) pair; doing the XML walk
# once per level turns that work from
# O(phrases × levels × level_size) into
# O(levels × level_size + phrases × levels × log(level_size)).
# On long songs with deep ladders this is a big win.
def _parse_level_fully(level):
lv_notes = []
container = level.find("notes")
if container is not None:
for n in container.findall("note"):
lv_notes.append(_parse_note(n))
lv_notes.sort(key=lambda n: n.time)
lv_chords = []
container = level.find("chords")
if container is not None:
for c in container.findall("chord"):
t = _float(c, "time")
chord_note_elems = list(c.findall("chordNote"))
chord_notes = [_parse_note(cn) for cn in chord_note_elems]
cid = _int(c, "chordId")
# Chord-level technique flags — propagated to synthetic notes
# when there are no <chordNote> children (gallop/repeat strums).
_ch_pm = _bool(c, "palmMute")
_ch_mt = _bool(c, "mute")
# fretHandMute at the chord level is the chord-wide form of the
# per-note fret-hand mute. Keep it on its own field
# (`fret_hand_mute`, wire key "fhm") instead of folding it into
# `mute` ("mt"): that preserves wire-format fidelity and matches
# `_parse_note`, which maps XML fretHandMute -> fret_hand_mute.
_ch_fhm = _bool(c, "fretHandMute")
_ch_acc = _bool(c, "accent")
if not chord_notes and cid < len(chord_templates):
ct = chord_templates[cid]
for s in range(len(ct.frets)):
if ct.frets[s] >= 0:
chord_notes.append(Note(
time=t, string=s, fret=ct.frets[s],
palm_mute=_ch_pm,
mute=_ch_mt,
fret_hand_mute=_ch_fhm,
accent=_ch_acc,
))
elif chord_notes and (_ch_pm or _ch_mt or _ch_fhm or _ch_acc):
# Propagate chord-level flags to chordNote children that
# don't set them explicitly. `_parse_note` flattens an
# absent attribute and an explicit false literal (`""`,
# `"0"`, `"false"`) to the same False, so peek at the raw
# XML element via `cn_elem.get(...)` to distinguish them
# — an authored `<chordNote palmMute="0">` under a
# `<chord palmMute="1">` parent must keep palm_mute=False.
for cn, cn_elem in zip(chord_notes, chord_note_elems):
if _ch_pm and cn_elem.get("palmMute") is None:
cn.palm_mute = True
if _ch_mt and cn_elem.get("mute") is None:
cn.mute = True
if _ch_fhm and cn_elem.get("fretHandMute") is None:
cn.fret_hand_mute = True
if _ch_acc and cn_elem.get("accent") is None:
cn.accent = True
lv_chords.append(Chord(
time=t, chord_id=cid, notes=chord_notes,
high_density=_chord_high_density(c),
))
lv_chords.sort(key=lambda c: c.time)
lv_anchors = []
container = level.find("anchors")
if container is not None:
for a in container.findall("anchor"):
lv_anchors.append(Anchor(
time=_float(a, "time"), fret=_int(a, "fret"),
width=_int(a, "width", 4),
))
lv_anchors.sort(key=lambda a: a.time)
lv_hand_shapes = []
container = level.find("handShapes")
if container is not None:
for hs in container.findall("handShape"):
lv_hand_shapes.append(HandShape(
chord_id=_int(hs, "chordId"),
start_time=_float(hs, "startTime"),
end_time=_float(hs, "endTime"),
arpeggio=_hand_shape_arpeggio_flag(hs),
))
lv_hand_shapes.sort(key=lambda h: h.start_time)
return {
"notes": lv_notes,
"note_times": [n.time for n in lv_notes],
"chords": lv_chords,
"chord_times": [c.time for c in lv_chords],
"anchors": lv_anchors,
"anchor_times": [a.time for a in lv_anchors],
"hand_shapes": lv_hand_shapes,
"hs_times": [h.start_time for h in lv_hand_shapes],
}
parsed_levels = {diff: _parse_level_fully(el) for diff, el in all_levels.items()}
def _extract_level_slice(parsed, t_start, t_end):
"""Return (notes, chords, anchors, hand_shapes) for one pre-parsed level,
clipped to [t_start, t_end). Uses bisect on the parallel time arrays —
much cheaper than re-scanning XML when called per phrase-iteration."""
def _slice(items, times):
i0 = bisect.bisect_left(times, t_start)
i1 = bisect.bisect_left(times, t_end)
return items[i0:i1]
return (
_slice(parsed["notes"], parsed["note_times"]),
_slice(parsed["chords"], parsed["chord_times"]),
_slice(parsed["anchors"], parsed["anchor_times"]),
_slice(parsed["hand_shapes"], parsed["hs_times"]),
)
def _collect_from_parsed(parsed, t_start, t_end):
"""Append a pre-parsed level's time-clipped slice to the flat
arrangement lists. Used for the max-mastery merge that preserves
the pre-feedBack#48 behaviour for existing consumers."""
lv_notes, lv_chords, lv_anchors, lv_hand_shapes = _extract_level_slice(
parsed, t_start, t_end
)
notes.extend(lv_notes)
chords.extend(lv_chords)
anchors.extend(lv_anchors)
hand_shapes.extend(lv_hand_shapes)
def _collect_best_level_fallback():
"""Fallback merge when no usable phrase metadata is available: pick
the level with the most notes+chords and flatten it."""
best = max(
parsed_levels.values(),
key=lambda pl: len(pl["notes"]) + len(pl["chords"]),
)
_collect_from_parsed(best, 0.0, float("inf"))
# Per-phrase difficulty data for the master-difficulty slider
# (feedBack#48). Only populated when the XML has multiple levels AND
# phrase data — left as None for single-level sources so the frontend
# knows to disable the slider.
phrases: list[Phrase] | None = None
# If there's only one level, use it directly (no per-phrase merge needed)
if len(parsed_levels) == 1:
_collect_from_parsed(next(iter(parsed_levels.values())), 0.0, float("inf"))
# Merge per-phrase if we have phrase data and multiple levels
elif phrases_el is not None and phrase_iters_el is not None and parsed_levels:
phrase_list = phrases_el.findall("phrase")
iterations = phrase_iters_el.findall("phraseIteration")
# The last phrase iteration has no "next" to take its end time
# from, so derive one from the last real event across all parsed
# levels. Using a finite value here (instead of float('inf'))
# matters because this ends up in Phrase.end_time on the wire,
# and JSON has no Infinity literal — JS JSON.parse would reject
# it. Include all event types (note start + sustain end, chord
# start, anchor time, hand shape end_time) so the last phrase
# window covers the whole authored content even when the final
# event isn't a note/chord start. +1s pad ensures the final
# event itself falls inside the bisect_left < t_end window.
last_event = 0.0
for pl in parsed_levels.values():
for n in pl["notes"]:
last_event = max(last_event, n.time + n.sustain)
if pl["chord_times"]:
last_event = max(last_event, pl["chord_times"][-1])
if pl["anchor_times"]:
last_event = max(last_event, pl["anchor_times"][-1])
for h in pl["hand_shapes"]:
last_event = max(last_event, h.end_time)
# Also bound by the last phrase iteration's start time — some
# charts place a trailing-silence phrase marker past every
# authored event. Without this, the last phrase could end up
# with end_time < start_time (invalid window, empty slice).
for it in iterations:
last_event = max(last_event, _float(it, "time"))
song_end = last_event + 1.0
phrases = []
for i, it in enumerate(iterations):
pid = _int(it, "phraseId")
if pid >= len(phrase_list):
continue
max_diff = _int(phrase_list[pid], "maxDifficulty")
t_start = _float(it, "time")
t_end = _float(iterations[i + 1], "time") if i + 1 < len(iterations) else song_end
# Build a PhraseLevel for every difficulty tier the author
# wrote at or below this phrase's max — these are what the
# master-difficulty slider selects between at render time.
# Tiers above max_diff exist in some XMLs (authoring leftovers)
# and are skipped to match the reference player's behaviour.
# Capture the extracted slices so the flat max-mastery merge
# below can reuse one of them.
phrase_levels: list[PhraseLevel] = []
slices_by_diff: dict[int, tuple[list, list, list, list]] = {}
for diff in sorted(parsed_levels.keys()):
if diff > max_diff:
continue
slc = _extract_level_slice(parsed_levels[diff], t_start, t_end)
slices_by_diff[diff] = slc
lv_notes, lv_chords, lv_anchors, lv_hand_shapes = slc
phrase_levels.append(PhraseLevel(
difficulty=diff,
notes=lv_notes,
chords=lv_chords,
anchors=lv_anchors,
hand_shapes=lv_hand_shapes,
))
# If every authored level was above this phrase's max_diff
# (unusual but possible — e.g., the phrase block declares a
# max_diff lower than any <level> that was actually written),
# we have no ladder and no slice to flat-merge. Skip the
# iteration entirely so the later `if not phrases:` fallback
# can trigger a best-level merge for the whole arrangement.
if not phrase_levels:
continue
phrases.append(Phrase(
start_time=t_start,
end_time=t_end,
max_difficulty=max_diff,
levels=phrase_levels,
))
# Populate the flat max-mastery merge for existing consumers
# (today's highway, sloppak converter's fallback). Reuse the
# slice we just extracted for max_diff — or the closest tier
# below it if max_diff itself wasn't authored.
flat_diff = max_diff if max_diff in slices_by_diff else max(slices_by_diff)
lv_notes, lv_chords, lv_anchors, lv_hand_shapes = slices_by_diff[flat_diff]
notes.extend(lv_notes)
chords.extend(lv_chords)
anchors.extend(lv_anchors)
hand_shapes.extend(lv_hand_shapes)
# If the `<phraseIterations>` element was present but yielded
# no usable iterations (empty element, or every iteration had
# an out-of-range phraseId), revert to the "no phrase data"
# sentinel and run the best-level fallback inline so we don't
# ship an empty arrangement with the slider incorrectly enabled.
if not phrases:
phrases = None
_collect_best_level_fallback()
elif parsed_levels:
_collect_best_level_fallback()
notes.sort(key=lambda n: n.time)
chords.sort(key=lambda c: c.time)
anchors.sort(key=lambda a: a.time)
hand_shapes.sort(key=lambda h: h.start_time)
# Parse <arrangementProperties> for smart naming
path_lead = path_rhythm = path_bass = bonus_arr = False
represent = 0
el = root.find("arrangementProperties")
if el is not None:
path_lead = _bool(el, "pathLead")
path_rhythm = _bool(el, "pathRhythm")
path_bass = _bool(el, "pathBass")
bonus_arr = _bool(el, "bonusArr")
represent = _int(el, "represent", 0)
return Arrangement(
name=arr_name,
tuning=tuning,
capo=capo,
notes=notes,
chords=chords,
anchors=anchors,
hand_shapes=hand_shapes,
chord_templates=chord_templates,
phrases=phrases,
path_lead=path_lead,
path_rhythm=path_rhythm,
path_bass=path_bass,
bonus_arr=bonus_arr,
represent=represent,
cent_offset=cent_offset,
)
def _convert_sng_to_xml(extracted_dir: str):
"""No-op stub.
Historically this converted proprietary encrypted ``.notechart`` arrangement
files to XML via an external tool. That path has been removed: feedBack
reads only its own ``.sloppak`` format and loose-folder/GP/MusicXML-derived
arrangement XML, and never decodes or decrypts proprietary archives. Kept
as a no-op so ``load_song`` (which loads plain arrangement XML/JSON from a
directory) keeps its call site unchanged.
"""
return None
def load_song(extracted_dir: str) -> Song:
"""Load a song from a directory of arrangement XML/JSON files."""
# Proprietary note-chart→XML conversion has been removed; this is now a no-op.
_convert_sng_to_xml(extracted_dir)
song = Song()
xml_files = sorted(Path(extracted_dir).rglob("*.xml"))
# Build manifest lookups: xml_stem (lowercase) -> ArrangementName / path flags.
# The manifest JSON is the authoritative source for path flags (pathLead /
# pathRhythm / pathBass / bonusArr / represent) because the XML files bundled
# in official DLC archives often have all path flags set to "0", while the
# manifest correctly reflects what the authoring tool wrote.
def _mprop_int(key: str, props: dict) -> int:
val = props.get(key, 0)
try:
return int(val)
except (TypeError, ValueError):
return 0
_manifest_names: dict[str, str] = {}
_manifest_path_flags: dict[str, dict] = {}
for jf in Path(extracted_dir).rglob("*.json"):
try:
data = json.loads(jf.read_text())
entries = data.get("Entries") or {}
for k, v in entries.items():
attrs = v.get("Attributes") or {}
arr_name = attrs.get("ArrangementName", "")
if arr_name and arr_name not in ("Vocals", "ShowLights", "JVocals"):
stem = jf.stem.lower()
# Match by JSON filename stem (same as XML stem)
_manifest_names[stem] = arr_name
props = attrs.get("ArrangementProperties") or {}
_manifest_path_flags[stem] = {
"path_lead": bool(_mprop_int("pathLead", props)),
"path_rhythm": bool(_mprop_int("pathRhythm", props)),
"path_bass": bool(_mprop_int("pathBass", props)),
"bonus_arr": bool(_mprop_int("bonusArr", props)),
"represent": _mprop_int("represent", props),
}
except Exception:
continue
metadata_loaded = False
for xml_path in xml_files:
try:
tree = ET.parse(xml_path)
root = tree.getroot()
except ET.ParseError:
continue
if root.tag != "song":
continue
# Skip vocals and showlights
el = root.find("arrangement")
if el is not None and el.text:
low = el.text.lower().strip()
if low in ("vocals", "showlights", "jvocals"):
continue
# Metadata from first valid arrangement
if not metadata_loaded:
for tag, attr in [
("title", "title"),
("artistName", "artist"),
("albumName", "album"),
]:
el = root.find(tag)
if el is not None and el.text:
setattr(song, attr, el.text)
el = root.find("albumYear")
if el is not None and el.text:
try:
song.year = int(el.text)
except ValueError:
pass
el = root.find("songLength")
if el is not None and el.text:
song.song_length = float(el.text)
el = root.find("offset")
if el is not None and el.text:
song.offset = float(el.text)
# Beats
container = root.find("ebeats")
if container is not None:
for eb in container.findall("ebeat"):
song.beats.append(
Beat(time=_float(eb, "time"), measure=_int(eb, "measure", -1))
)
# Sections
container = root.find("sections")
if container is not None:
for s in container.findall("section"):
song.sections.append(
Section(
name=s.get("name", ""),
number=_int(s, "number"),
start_time=_float(s, "startTime"),
)
)
metadata_loaded = True
# Parse arrangement
arrangement = parse_arrangement(str(xml_path))
# Override path flags with manifest values when available. The XML
# bundled inside official DLC archives often has all flags as "0", while
# the manifest JSON carries the correct values written by the DLC author.
manifest_flags = _manifest_path_flags.get(xml_path.stem.lower())
if manifest_flags:
arrangement.path_lead = manifest_flags["path_lead"]
arrangement.path_rhythm = manifest_flags["path_rhythm"]
arrangement.path_bass = manifest_flags["path_bass"]
arrangement.bonus_arr = manifest_flags["bonus_arr"]
arrangement.represent = manifest_flags["represent"]
# Try to get the correct name from the manifest JSON
manifest_name = _manifest_names.get(xml_path.stem.lower())
if manifest_name:
arrangement.name = manifest_name
else:
# Fallback: map internal XML names to display names
_name_map = {
"part real_guitar": "Lead",
"part real_guitar_22": "Rhythm",
"part real_bass": "Bass",
"part real_guitar_bonus": "Bonus Lead",
"part real_bass_22": "Bass 2",
}
low = arrangement.name.lower().strip()
if low in _name_map:
arrangement.name = _name_map[low]
elif not arrangement.name or low.startswith("part "):
# Infer from filename
fname = xml_path.stem.lower()
if "lead" in fname:
arrangement.name = "Lead"
elif "rhythm" in fname:
arrangement.name = "Rhythm"
elif "bass" in fname:
arrangement.name = "Bass"
elif "combo" in fname:
arrangement.name = "Combo"
else:
arrangement.name = xml_path.stem
song.arrangements.append(arrangement)
# Sort: Lead > Combo > Rhythm > Bass > other
priority = {"lead": 0, "combo": 1, "rhythm": 2, "bass": 3}
song.arrangements.sort(key=lambda a: priority.get(a.name.lower(), 99))
# Fallback: read metadata from manifest JSON files (official DLC)
if not song.title or not song.artist:
_load_manifest_metadata(song, extracted_dir)
return song
def _load_manifest_metadata(song: Song, extracted_dir: str):
"""Read song metadata from manifest JSON files (used for official DLC)."""
d = Path(extracted_dir)
for jf in d.rglob("*.json"):
try:
data = json.loads(jf.read_text())
# Manifest JSON has: Entries -> {key} -> Attributes
entries = data.get("Entries") or data.get("entries") or {}
if entries:
for key, val in entries.items():
attrs = val.get("Attributes") or val.get("attributes") or {}
if not song.title and attrs.get("SongName"):
song.title = attrs["SongName"]
if not song.artist and attrs.get("ArtistName"):
song.artist = attrs["ArtistName"]
if not song.album and attrs.get("AlbumName"):
song.album = attrs["AlbumName"]
if not song.year and attrs.get("SongYear"):
try:
song.year = int(attrs["SongYear"])
except (ValueError, TypeError):
pass
if not song.song_length and attrs.get("SongLength"):
try:
song.song_length = float(attrs["SongLength"])
except (ValueError, TypeError):
pass
if song.title and song.artist:
return
# Also check flat structure (individual arrangement manifests)
attrs = data.get("Attributes") or data.get("attributes") or {}
if attrs:
if not song.title and attrs.get("SongName"):
song.title = attrs["SongName"]
if not song.artist and attrs.get("ArtistName"):
song.artist = attrs["ArtistName"]
if not song.album and attrs.get("AlbumName"):
song.album = attrs["AlbumName"]
if not song.year and attrs.get("SongYear"):
try:
song.year = int(attrs["SongYear"])
except (ValueError, TypeError):
pass
if not song.song_length and attrs.get("SongLength"):
try:
song.song_length = float(attrs["SongLength"])
except (ValueError, TypeError):
pass
if song.title and song.artist:
return
except Exception:
continue