mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
feat(core): teaching marks fg/ch/sd — wire + GP import + sd derivation (§6.2.2) (#536)
Add the three OPTIONAL per-note feedpak 1.5.0 teaching marks — fg (fret-hand finger), ch (strum-group key), sd (scale degree) — to the Note model and wire format, mirroring the bend-shape work (#531). These are DISPLAY/TEACHING ONLY: nothing in the scoring / note-verification path reads them. - lib/song.py: Note.fret_finger / strum_group / scale_degree, default-omitted on the wire (fg/ch/sd) and decoded via _wire_int_optional; _parse_note reads the GP-written fretFinger XML attr. Pure helpers key_to_tonic_pc (§7.7 key name -> tonic pitch class) + scale_degree_for_pitch, plus base_open_string_midis / pitch_from_base / note_pitch_midi (tuning offsets + capo + fret -> MIDI, mirroring app.js _TUNING_BASE_MIDI). - lib/gp2rs.py: GP5 note.effect.leftHandFinger -> fg (RsNote field + fretFinger XML attr), reusing the chord Fingering value convention. - lib/gp2rs_gpx.py: GP8/GPIF per-note <LeftFingering> (p-i-m-a-c letter codes, verified against real GP8 exports) -> fg. - server.py highway_ws: derive sd for notes + chord notes from the active keys.json key + sounding pitch when the author didn't author one (author value wins); base hoisted out of the per-note loop. Tests: round-trip + omit-when-default + malformed-tolerance for fg/ch/sd; key_to_tonic_pc + scale_degree_for_pitch + note_pitch_midi (standard/drop-D/ capo/bass) units; GP5 leftHandFinger and GP8 <LeftFingering> import. Part of got-feedback/feedback#334 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a858617d71
commit
6ee5da3d8b
@@ -72,6 +72,9 @@ class RsNote:
|
|||||||
tremolo: bool = False
|
tremolo: bool = False
|
||||||
tap: bool = False
|
tap: bool = False
|
||||||
link_next: 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
|
@dataclass
|
||||||
@@ -255,6 +258,16 @@ def _bend_shape_xml_attrs(n: "RsNote") -> dict:
|
|||||||
return attrs
|
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:
|
def _tempo_at_tick(tick: int, tempo_map: list[TempoEvent]) -> float:
|
||||||
"""Get the tempo at a given tick."""
|
"""Get the tempo at a given tick."""
|
||||||
result = tempo_map[0].tempo
|
result = tempo_map[0].tempo
|
||||||
@@ -524,6 +537,19 @@ def _gp_string_to_rs(gp_string: int, num_strings: int) -> int:
|
|||||||
return num_strings - gp_string
|
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]:
|
def _chord_fingers(chord, frets: list[int], num_strings: int) -> list[int]:
|
||||||
"""Per-string fingering for a chord template, in RS string order.
|
"""Per-string fingering for a chord template, in RS string order.
|
||||||
|
|
||||||
@@ -853,6 +879,11 @@ def convert_track(
|
|||||||
if eff.tremoloPicking:
|
if eff.tremoloPicking:
|
||||||
rn.tremolo = True
|
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 / tremolo bar (beat-level dive/raise). RS has no
|
||||||
# whammy attribute, so approximate the pitch movement as an
|
# whammy attribute, so approximate the pitch movement as an
|
||||||
# unpitched slide: a dive slides down, a raise slides up, by
|
# unpitched slide: a dive slides down, a raise slides up, by
|
||||||
@@ -1164,6 +1195,7 @@ def _build_xml(
|
|||||||
"ignore": "0",
|
"ignore": "0",
|
||||||
}
|
}
|
||||||
attrs.update(_bend_shape_xml_attrs(n))
|
attrs.update(_bend_shape_xml_attrs(n))
|
||||||
|
attrs.update(_finger_xml_attrs(n))
|
||||||
ET.SubElement(notes_el, "note", **attrs)
|
ET.SubElement(notes_el, "note", **attrs)
|
||||||
|
|
||||||
# Chords
|
# Chords
|
||||||
@@ -1196,6 +1228,7 @@ def _build_xml(
|
|||||||
"ignore": "0",
|
"ignore": "0",
|
||||||
}
|
}
|
||||||
cn_attrs.update(_bend_shape_xml_attrs(cn))
|
cn_attrs.update(_bend_shape_xml_attrs(cn))
|
||||||
|
cn_attrs.update(_finger_xml_attrs(cn))
|
||||||
ET.SubElement(chord_el, "chordNote", **cn_attrs)
|
ET.SubElement(chord_el, "chordNote", **cn_attrs)
|
||||||
|
|
||||||
# Anchors
|
# Anchors
|
||||||
|
|||||||
@@ -457,6 +457,31 @@ _GPIF_FINGER_MAP = {
|
|||||||
'pinky': 4, 'little': 4,
|
'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]:
|
def _rs_string_order(string_pitches: list[int]) -> dict[int, int]:
|
||||||
"""Map each GPIF string index → RS string index (0 = lowest pitch).
|
"""Map each GPIF string index → RS string index (0 = lowest pitch).
|
||||||
@@ -1600,6 +1625,11 @@ def convert_file(
|
|||||||
rn.vibrato = True
|
rn.vibrato = True
|
||||||
if 'LeftHandTapping' in _tp or 'Tapped' in _tp:
|
if 'LeftHandTapping' in _tp or 'Tapped' in _tp:
|
||||||
rn.tap = True
|
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:
|
if 'HarmonicType' in _tp:
|
||||||
_ht = (_tp['HarmonicType'].findtext('HType')
|
_ht = (_tp['HarmonicType'].findtext('HType')
|
||||||
or '').strip().lower()
|
or '').strip().lower()
|
||||||
|
|||||||
+123
@@ -43,6 +43,18 @@ class Note:
|
|||||||
slap: bool = False
|
slap: bool = False
|
||||||
right_hand: int = -1
|
right_hand: int = -1
|
||||||
pick_direction: 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
|
ignore: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -238,6 +250,13 @@ def note_to_wire(n: Note) -> dict:
|
|||||||
{"t": round(p["t"], 3), "v": round(p["v"], 1)}
|
{"t": round(p["t"], 3), "v": round(p["v"], 1)}
|
||||||
for p in n.bend_values
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -327,6 +346,102 @@ def _sanitize_bend_curve(raw):
|
|||||||
return out
|
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:
|
def note_from_wire(d: dict, time: float | None = None) -> Note:
|
||||||
return Note(
|
return Note(
|
||||||
time=float(d.get("t", time if time is not None else 0.0)),
|
time=float(d.get("t", time if time is not None else 0.0)),
|
||||||
@@ -356,6 +471,10 @@ def note_from_wire(d: dict, time: float | None = None) -> Note:
|
|||||||
# the XML side's `_int_optional`.
|
# the XML side's `_int_optional`.
|
||||||
right_hand=_wire_int_optional(d.get("rh"), -1),
|
right_hand=_wire_int_optional(d.get("rh"), -1),
|
||||||
pick_direction=_wire_int_optional(d.get("pkd"), -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)),
|
ignore=bool(d.get("ig", False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -853,6 +972,10 @@ def _parse_note(n) -> Note:
|
|||||||
slap=_bool(n, "slap"),
|
slap=_bool(n, "slap"),
|
||||||
right_hand=_int_optional(n, "rightHand", -1),
|
right_hand=_int_optional(n, "rightHand", -1),
|
||||||
pick_direction=_int_optional(n, "pickDirection", -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"),
|
ignore=_bool(n, "ignore"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Slopsmith — FastAPI backend serving highway viewer + library."""
|
"""Slopsmith — FastAPI backend serving highway viewer + library."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import bisect
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -27,13 +28,17 @@ from safepath import safe_join
|
|||||||
from song import (
|
from song import (
|
||||||
anchor_to_wire,
|
anchor_to_wire,
|
||||||
arrangement_string_count,
|
arrangement_string_count,
|
||||||
|
base_open_string_midis,
|
||||||
compute_smart_names,
|
compute_smart_names,
|
||||||
chord_template_to_wire,
|
chord_template_to_wire,
|
||||||
chord_to_wire,
|
chord_to_wire,
|
||||||
hand_shape_to_wire,
|
hand_shape_to_wire,
|
||||||
|
key_to_tonic_pc,
|
||||||
load_song,
|
load_song,
|
||||||
note_to_wire,
|
note_to_wire,
|
||||||
phrase_to_wire,
|
phrase_to_wire,
|
||||||
|
pitch_from_base,
|
||||||
|
scale_degree_for_pitch,
|
||||||
)
|
)
|
||||||
from audio import find_wem_files, convert_wem
|
from audio import find_wem_files, convert_wem
|
||||||
from tunings import tuning_name, DEFAULT_TUNINGS, DEFAULT_REFERENCE_PITCH, apply_reference_pitch
|
from tunings import tuning_name, DEFAULT_TUNINGS, DEFAULT_REFERENCE_PITCH, apply_reference_pitch
|
||||||
@@ -7362,8 +7367,48 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
|||||||
"data": [],
|
"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
|
# 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
|
# Send in chunks of 500
|
||||||
for i in range(0, len(notes), 500):
|
for i in range(0, len(notes), 500):
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
@@ -7373,7 +7418,12 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Send chords
|
# 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):
|
for i in range(0, len(chords), 500):
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"type": "chords",
|
"type": "chords",
|
||||||
|
|||||||
@@ -1220,6 +1220,33 @@ def test_chord_diagram_fingers_extracted():
|
|||||||
assert [ct.get(f"finger{i}") for i in range(0, 4)] == ["-1"] * 4
|
assert [ct.get(f"finger{i}") for i in range(0, 4)] == ["-1"] * 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_note_left_hand_finger_imports_as_fg():
|
||||||
|
"""A GP single note's leftHandFinger imports as the `fg` teaching mark and
|
||||||
|
survives convert_track XML → _parse_note → note_to_wire (§6.2.2)."""
|
||||||
|
from song import _parse_note, note_to_wire
|
||||||
|
note = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=5)
|
||||||
|
note.effect.leftHandFinger = guitarpro.Fingering.middle # -> 2
|
||||||
|
beat = _ct_beat(tick=0, dur_value=4, notes=[note])
|
||||||
|
|
||||||
|
root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314
|
||||||
|
xn = root.findall(".//notes/note")[0]
|
||||||
|
assert xn.get("fretFinger") == "2"
|
||||||
|
assert note_to_wire(_parse_note(xn))["fg"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_note_open_finger_omits_fg():
|
||||||
|
"""Open/unset leftHandFinger leaves fg unset — no fabricated finger."""
|
||||||
|
from song import _parse_note, note_to_wire
|
||||||
|
note = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=5)
|
||||||
|
note.effect.leftHandFinger = guitarpro.Fingering.open # -1 -> unset
|
||||||
|
beat = _ct_beat(tick=0, dur_value=4, notes=[note])
|
||||||
|
|
||||||
|
root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314
|
||||||
|
xn = root.findall(".//notes/note")[0]
|
||||||
|
assert xn.get("fretFinger") is None
|
||||||
|
assert "fg" not in note_to_wire(_parse_note(xn))
|
||||||
|
|
||||||
|
|
||||||
def test_chord_without_diagram_has_blank_fingers():
|
def test_chord_without_diagram_has_blank_fingers():
|
||||||
# A plain two-note chord (effect.chord is None) is unchanged: blank name,
|
# A plain two-note chord (effect.chord is None) is unchanged: blank name,
|
||||||
# all-(-1) fingers — no regression for diagram-less charts.
|
# all-(-1) fingers — no regression for diagram-less charts.
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from gp2rs_gpx import (
|
|||||||
_inject_tones,
|
_inject_tones,
|
||||||
_resolve_pending_slides,
|
_resolve_pending_slides,
|
||||||
_gpx_bend_shape,
|
_gpx_bend_shape,
|
||||||
|
_gpif_left_fingering,
|
||||||
)
|
)
|
||||||
from gp2rs import RsNote
|
from gp2rs import RsNote
|
||||||
|
|
||||||
@@ -731,6 +732,32 @@ def test_note_vibrato_ignores_whammy_trembar_property():
|
|||||||
assert _note_has_vibrato(n, tp) is False
|
assert _note_has_vibrato(n, tp) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── _gpif_left_fingering (GP7/GP8 per-note fret-hand finger -> fg) ───────────
|
||||||
|
# GPIF stores a single note's fret-hand finger as a direct <LeftFingering>
|
||||||
|
# child of <Note> (NOT a <Property>), with classical p-i-m-a-c letter codes —
|
||||||
|
# verified against real GP8 exports (Open / I / M observed). Maps to the same
|
||||||
|
# RS finger integers as the chord-diagram path (§6.2.2). Teaching mark only.
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("code, expected", [
|
||||||
|
("Open", -1), ("P", 0), ("I", 1), ("M", 2), ("A", 3), ("C", 4),
|
||||||
|
("i", 1), ("m", 2), # case-insensitive
|
||||||
|
("index", 1), ("ring", 3), # word forms also accepted
|
||||||
|
])
|
||||||
|
def test_gpif_left_fingering_letter_codes(code, expected):
|
||||||
|
n = ET.fromstring(f'<Note id="1"><LeftFingering>{code}</LeftFingering>'
|
||||||
|
'<Properties></Properties></Note>')
|
||||||
|
assert _gpif_left_fingering(n) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpif_left_fingering_absent_or_unknown_is_unset():
|
||||||
|
# No <LeftFingering> child, or an unrecognised value -> -1 (never fabricate).
|
||||||
|
assert _gpif_left_fingering(ET.fromstring('<Note id="1"/>')) == -1
|
||||||
|
assert _gpif_left_fingering(
|
||||||
|
ET.fromstring('<Note id="1"><LeftFingering>Z</LeftFingering></Note>')) == -1
|
||||||
|
assert _gpif_left_fingering(
|
||||||
|
ET.fromstring('<Note id="1"><LeftFingering></LeftFingering></Note>')) == -1
|
||||||
|
|
||||||
|
|
||||||
# ── convert_file: GP8 chord-diagram name + fingering extraction (E3) ─────────
|
# ── convert_file: GP8 chord-diagram name + fingering extraction (E3) ─────────
|
||||||
# GP7/GP8 GPIF carries authored chord diagrams under a track's
|
# GP7/GP8 GPIF carries authored chord diagrams under a track's
|
||||||
# Property[@name="DiagramCollection"]. Each Item gives the chord name and a
|
# Property[@name="DiagramCollection"]. Each Item gives the chord name and a
|
||||||
|
|||||||
@@ -20,10 +20,14 @@ from song import (
|
|||||||
chord_to_wire,
|
chord_to_wire,
|
||||||
sanitize_tempos,
|
sanitize_tempos,
|
||||||
compute_smart_names,
|
compute_smart_names,
|
||||||
|
base_open_string_midis,
|
||||||
|
key_to_tonic_pc,
|
||||||
note_from_wire,
|
note_from_wire,
|
||||||
note_to_wire,
|
note_to_wire,
|
||||||
|
note_pitch_midi,
|
||||||
phrase_from_wire,
|
phrase_from_wire,
|
||||||
phrase_to_wire,
|
phrase_to_wire,
|
||||||
|
scale_degree_for_pitch,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -204,6 +208,109 @@ def test_note_bend_shape_omitted_when_default():
|
|||||||
assert decoded.bend_values is None
|
assert decoded.bend_values is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Teaching marks (§6.2.2) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_note_teaching_marks_round_trip():
|
||||||
|
"""fg/ch/sd survive the wire under their literal keys.
|
||||||
|
|
||||||
|
Pin the public wire keys explicitly (cross-language sloppak readers key
|
||||||
|
off the literal strings), like the rh/pkd test above.
|
||||||
|
"""
|
||||||
|
n = Note(
|
||||||
|
time=0.0, string=0, fret=0,
|
||||||
|
fret_finger=2, strum_group=5, scale_degree=7,
|
||||||
|
)
|
||||||
|
wire = note_to_wire(n)
|
||||||
|
assert wire["fg"] == 2
|
||||||
|
assert wire["ch"] == 5
|
||||||
|
assert wire["sd"] == 7
|
||||||
|
assert note_from_wire(wire) == n
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_teaching_marks_omitted_when_default():
|
||||||
|
"""fg/ch/sd are default-omitted (-1) and decode back to -1."""
|
||||||
|
wire = note_to_wire(Note(time=0.0, string=0, fret=0))
|
||||||
|
for omitted in ("fg", "ch", "sd"):
|
||||||
|
assert omitted not in wire, f"{omitted!r} should be default-omitted"
|
||||||
|
decoded = note_from_wire(wire)
|
||||||
|
assert decoded.fret_finger == -1
|
||||||
|
assert decoded.strum_group == -1
|
||||||
|
assert decoded.scale_degree == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_teaching_marks_tolerate_malformed_optional_ints():
|
||||||
|
"""fg/ch/sd survive null / empty / non-numeric wire values."""
|
||||||
|
for bad in (None, "", " ", "x", "inf"):
|
||||||
|
n = note_from_wire({"t": 0.0, "s": 0, "f": 0,
|
||||||
|
"fg": bad, "ch": bad, "sd": bad})
|
||||||
|
assert n.fret_finger == -1
|
||||||
|
assert n.strum_group == -1
|
||||||
|
assert n.scale_degree == -1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Scale-degree derivation helpers (§6.2.2 / §7.7) ──────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key,pc", [
|
||||||
|
("C", 0), ("c", 0),
|
||||||
|
("E", 4), ("Em", 4), ("E minor", 4),
|
||||||
|
("G", 7), ("G major", 7), ("Gmaj", 7),
|
||||||
|
("A#m", 10), ("Bb", 10), # enharmonic — same pitch class
|
||||||
|
("F#", 6), ("F#m", 6),
|
||||||
|
("Cb", 11), ("B#", 0), # accidentals wrap mod 12
|
||||||
|
])
|
||||||
|
def test_key_to_tonic_pc_parses_key_names(key, pc):
|
||||||
|
assert key_to_tonic_pc(key) == pc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [None, "", " ", "H", "xyz", "7", 5])
|
||||||
|
def test_key_to_tonic_pc_rejects_unparseable(bad):
|
||||||
|
assert key_to_tonic_pc(bad) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_scale_degree_for_pitch_standard_tuning_key_of_e():
|
||||||
|
"""Tonic E (pc 4), standard tuning: low-E open -> tonic, A-string fret 2 -> fifth."""
|
||||||
|
tonic = key_to_tonic_pc("E")
|
||||||
|
assert tonic == 4
|
||||||
|
low_e_open = 40 # E2
|
||||||
|
a_string_fret2 = 45 + 2 # A2 + 2 = B2
|
||||||
|
assert scale_degree_for_pitch(low_e_open, tonic) == 0 # tonic
|
||||||
|
assert scale_degree_for_pitch(a_string_fret2, tonic) == 7 # perfect fifth
|
||||||
|
assert scale_degree_for_pitch(40 + 3, tonic) == 3 # G2 -> minor third
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_pitch_midi_standard_tuning_offsets():
|
||||||
|
"""`arr.tuning` holds OFFSETS from standard (0 = standard), padded to 6 on
|
||||||
|
RS-XML; pitch = base + offset + capo + fret. Standard guitar: low-E open ->
|
||||||
|
40 (E2), A-string fret 2 -> 47 (B2)."""
|
||||||
|
arr = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0])
|
||||||
|
assert note_pitch_midi(arr, Note(time=0, string=0, fret=0)) == 40 # low E open
|
||||||
|
assert note_pitch_midi(arr, Note(time=0, string=1, fret=2)) == 47 # A + 2 = B
|
||||||
|
# Drop-D (low string offset -2): low-E string open sounds D2 = 38.
|
||||||
|
drop_d = Arrangement(name="Lead", tuning=[-2, 0, 0, 0, 0, 0])
|
||||||
|
assert note_pitch_midi(drop_d, Note(time=0, string=0, fret=0)) == 38
|
||||||
|
# Capo 2 raises every sounding pitch by 2 semitones.
|
||||||
|
capo2 = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0], capo=2)
|
||||||
|
assert note_pitch_midi(capo2, Note(time=0, string=0, fret=0)) == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_pitch_midi_bass_uses_bass_base():
|
||||||
|
"""A 4-string arrangement named 'Bass' uses the bass base (low E1 = 28),
|
||||||
|
not the guitar base (40)."""
|
||||||
|
bass = Arrangement(name="Bass", tuning=[0, 0, 0, 0])
|
||||||
|
assert note_pitch_midi(bass, Note(time=0, string=0, fret=0)) == 28
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_pitch_midi_out_of_range_string_is_none():
|
||||||
|
arr = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0])
|
||||||
|
assert note_pitch_midi(arr, Note(time=0, string=9, fret=0)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_open_string_midis_bass_vs_guitar():
|
||||||
|
assert base_open_string_midis(6, False)[0] == 40 # guitar low E
|
||||||
|
assert base_open_string_midis(4, True)[0] == 28 # bass low E
|
||||||
|
assert base_open_string_midis(4, False)[0] == 40 # 4-string guitar voicing
|
||||||
|
|
||||||
|
|
||||||
def test_note_bend_values_rounded_on_wire():
|
def test_note_bend_values_rounded_on_wire():
|
||||||
"""`bnv` rounds `t` to 3 and `v` to 1, matching the scalar `bn` precision."""
|
"""`bnv` rounds `t` to 3 and `v` to 1, matching the scalar `bn` precision."""
|
||||||
n = Note(
|
n = Note(
|
||||||
|
|||||||
Reference in New Issue
Block a user