Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions lib/gp2rs.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ class RsNote:
tremolo: bool = False
tap: bool = False
link_next: bool = False
# Teaching mark (§6.2.2): fret-hand finger (-1 unset, 0 thumb..4 pinky).
# Display only — never used for grading.
fret_finger: int = -1


@dataclass
Expand Down Expand Up @@ -255,6 +258,16 @@ def _bend_shape_xml_attrs(n: "RsNote") -> dict:
return attrs


def _finger_xml_attrs(n: "RsNote") -> dict:
"""Optional teaching-mark XML attribute for a <note>/<chordNote>: `fretFinger`
only when set (!= -1). `_parse_note` (lib/song.py) reads it back so a
GP-imported fret-hand finger survives import → wire → highway. Display only;
never used for grading (§6.2.2)."""
if getattr(n, "fret_finger", -1) != -1:
return {"fretFinger": str(int(n.fret_finger))}
return {}


def _tempo_at_tick(tick: int, tempo_map: list[TempoEvent]) -> float:
"""Get the tempo at a given tick."""
result = tempo_map[0].tempo
Expand Down Expand Up @@ -524,6 +537,19 @@ def _gp_string_to_rs(gp_string: int, num_strings: int) -> int:
return num_strings - gp_string


def _gp_finger_to_rs(fingering) -> int:
"""Coerce a pyguitarpro ``Fingering`` enum to an RS fret-hand finger int.

Fingering values are ``unknown=-2, open=-1, thumb=0, index=1, middle=2,
annular=3, little=4`` — already the RS finger integers for 0..4. Anything
open/unknown/out-of-range collapses to ``-1`` (unset), so we never invent a
finger. Teaching mark only (§6.2.2); never used for grading."""
val = getattr(fingering, "value", fingering)
if not isinstance(val, int) or val < 0 or val > 4:
return -1
return val


def _chord_fingers(chord, frets: list[int], num_strings: int) -> list[int]:
"""Per-string fingering for a chord template, in RS string order.

Expand Down Expand Up @@ -853,6 +879,11 @@ def convert_track(
if eff.tremoloPicking:
rn.tremolo = True

# Fret-hand fingering -> fg teaching mark (§6.2.2). Same
# Fingering enum + value convention as the chord path.
rn.fret_finger = _gp_finger_to_rs(
getattr(eff, "leftHandFinger", None))

# Whammy / tremolo bar (beat-level dive/raise). RS has no
# whammy attribute, so approximate the pitch movement as an
# unpitched slide: a dive slides down, a raise slides up, by
Expand Down Expand Up @@ -1164,6 +1195,7 @@ def _build_xml(
"ignore": "0",
}
attrs.update(_bend_shape_xml_attrs(n))
attrs.update(_finger_xml_attrs(n))
ET.SubElement(notes_el, "note", **attrs)

# Chords
Expand Down Expand Up @@ -1196,6 +1228,7 @@ def _build_xml(
"ignore": "0",
}
cn_attrs.update(_bend_shape_xml_attrs(cn))
cn_attrs.update(_finger_xml_attrs(cn))
ET.SubElement(chord_el, "chordNote", **cn_attrs)

# Anchors
Expand Down
30 changes: 30 additions & 0 deletions lib/gp2rs_gpx.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,31 @@ def _gp6_element_variation_to_midi(element: int, variation: int) -> int | None:
'pinky': 4, 'little': 4,
}

# Per-note <LeftFingering> teaching mark (§6.2.2). Unlike the chord-diagram
# <Position finger=".."> path above, GPIF stores a single note's fret-hand
# finger as a direct <Note> child element with the classical p-i-m-a-c letter
# codes (verified against GP8 exports), mapped to the same RS finger integers
# (open = -1, thumb = 0, index = 1, middle = 2, annular/ring = 3, little = 4).
_GPIF_LEFT_FINGERING_MAP = {
'open': -1, 'none': -1, '': -1,
'p': 0, 'thumb': 0,
'i': 1, 'index': 1,
'm': 2, 'middle': 2,
'a': 3, 'annular': 3, 'ring': 3,
'c': 4, 'little': 4, 'pinky': 4,
}


def _gpif_left_fingering(note_el) -> int:
"""Read a GPIF <Note>'s fret-hand finger (<LeftFingering>) -> RS finger int.

Returns -1 (unset) when absent or unrecognised — never fabricates a finger.
Teaching mark only (§6.2.2); never used for grading."""
raw = (note_el.findtext('LeftFingering') or '').strip().lower()
if not raw:
return -1
return _GPIF_LEFT_FINGERING_MAP.get(raw, -1)


def _rs_string_order(string_pitches: list[int]) -> dict[int, int]:
"""Map each GPIF string index → RS string index (0 = lowest pitch).
Expand Down Expand Up @@ -1600,6 +1625,11 @@ def convert_file(
rn.vibrato = True
if 'LeftHandTapping' in _tp or 'Tapped' in _tp:
rn.tap = True
# Fret-hand fingering -> fg teaching mark
# (§6.2.2). <LeftFingering> is a direct <Note>
# child, not a <Property>, so read it off
# note_el rather than the property map.
rn.fret_finger = _gpif_left_fingering(note_el)
if 'HarmonicType' in _tp:
_ht = (_tp['HarmonicType'].findtext('HType')
or '').strip().lower()
Expand Down
123 changes: 123 additions & 0 deletions lib/song.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ class Note:
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


Expand Down Expand Up @@ -238,6 +250,13 @@ def note_to_wire(n: Note) -> dict:
{"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


Expand Down Expand Up @@ -327,6 +346,102 @@ def _sanitize_bend_curve(raw):
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)),
Expand Down Expand Up @@ -356,6 +471,10 @@ def note_from_wire(d: dict, time: float | None = None) -> Note:
# 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)),
)

Expand Down Expand Up @@ -853,6 +972,10 @@ def _parse_note(n) -> Note:
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"),
)

Expand Down
54 changes: 52 additions & 2 deletions server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Slopsmith — FastAPI backend serving highway viewer + library."""

import asyncio
import bisect
import hashlib
import json
import logging
Expand All @@ -27,13 +28,17 @@
from song import (
anchor_to_wire,
arrangement_string_count,
base_open_string_midis,
compute_smart_names,
chord_template_to_wire,
chord_to_wire,
hand_shape_to_wire,
key_to_tonic_pc,
load_song,
note_to_wire,
phrase_to_wire,
pitch_from_base,
scale_degree_for_pitch,
)
from audio import find_wem_files, convert_wem
from tunings import tuning_name, DEFAULT_TUNINGS, DEFAULT_REFERENCE_PITCH, apply_reference_pitch
Expand Down Expand Up @@ -7362,8 +7367,48 @@ def _xml_rank(xp):
"data": [],
})

# Teaching mark sd (§6.2.2): derive each note's scale degree from the
# active key (keys.json §7.7) + its sounding pitch (tuning[string] +
# fret), only when the author didn't author one. Display/teaching only —
# NEVER feeds grading. Notes whose string/fret has no tuning entry, or
# that have no active key, or whose key name is unparseable, stay unset.
_key_events = (
(loaded_slop.keys.get("events") or [])
if (is_slop and loaded_slop is not None and loaded_slop.keys is not None)
else []
)
_key_times = [e["t"] for e in _key_events]
_key_tonics = [key_to_tonic_pc(e.get("key")) for e in _key_events]
_tuning = arr.tuning or []
# Hoist the open-string base out of the per-note loop: arr.tuning holds
# per-string OFFSETS from standard, so the sounding pitch is
# base[string] + offset + capo + fret (matches the tuner / open-string
# labels). arrangement_string_count is O(notes), so compute once here.
_base = base_open_string_midis(
arrangement_string_count(arr), "bass" in (arr.name or "").lower())
_capo = int(getattr(arr, "capo", 0) or 0)

def _fill_scale_degree(wire: dict, n, t: float) -> None:
# Author-provided sd wins — note_to_wire already emitted it.
if "sd" in wire or not _key_times:
return
idx = bisect.bisect_right(_key_times, t) - 1
if idx < 0:
return
tonic = _key_tonics[idx]
if tonic is None:
return
midi = pitch_from_base(_base, _capo, _tuning, n.string, n.fret)
if midi is None:
return
wire["sd"] = scale_degree_for_pitch(midi, tonic)

# Send notes in chunks
notes = [note_to_wire(n) for n in arr.notes]
notes = []
for n in arr.notes:
w = note_to_wire(n)
_fill_scale_degree(w, n, n.time)
notes.append(w)
# Send in chunks of 500
for i in range(0, len(notes), 500):
await websocket.send_json({
Expand All @@ -7373,7 +7418,12 @@ def _xml_rank(xp):
})

# Send chords
chords = [chord_to_wire(c) for c in arr.chords]
chords = []
for c in arr.chords:
cw = chord_to_wire(c)
for cn, cnw in zip(c.notes, cw.get("notes", [])):
_fill_scale_degree(cnw, cn, c.time)
chords.append(cw)
for i in range(0, len(chords), 500):
await websocket.send_json({
"type": "chords",
Expand Down
Loading
Loading