feat(core): per-note bend shape (bt + bnv) — wire + GP import (#531)

Implements feedpak spec §6.2.1 (feedpak 1.4.0) per-note bend shape on the
core side:

- `bn` stays the bend's peak magnitude in semitones (unchanged).
- `bt` — bend intent (0 up, 1 release, 2 pre-bend, 3 pre-bend-release,
  4 round-trip), default 0, default-omitted on the wire.
- `bnv` — time-stamped bend curve [{t: seconds-from-onset, v: semitones}],
  authoritative when present; default-omitted. Older readers ignore both.

Wire (lib/song.py): Note.bend_intent/bend_values; note_to_wire emits bt/bnv
only when set; note_from_wire reads them via _sanitize_bend_curve (drops
malformed entries, empty -> None never []). _parse_note reads them from the
GP-import XML (bendIntent attr + bendValues JSON) so GP curves survive
import -> XML -> wire -> highway.

GP5 (lib/gp2rs.py): _gp_bend_shape maps pyguitarpro BendPoints to a bnv
curve — semitones = value/2.0 (consistent with the existing scalar bn),
t = position/12 * duration — and _bend_intent_from_values derives bt from
the shape. Emitted for <note> and <chordNote> via the shared _build_xml.

GP8 (lib/gp2rs_gpx.py): _gpx_bend_shape builds a 3-point curve from the
GPIF origin/middle/destination value+offset Properties (value/divisor
semitones, offset/100 * sustain seconds), reusing the shared _build_xml.
GPIF offset Property names should be confirmed against a real GP8 export.

Tests cover wire round-trip + default-omit + sanitization, GP5 unit/time
mapping + intent classification end-to-end through the XML, and the GP8
curve builder.

Part of got-feedback/feedback#334

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-20 23:17:39 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b8382139ca
commit e33df9a720
6 changed files with 417 additions and 39 deletions
+76
View File
@@ -20,9 +20,11 @@ import pytest
from gp2rs import (
GP_TICKS_PER_QUARTER,
TempoEvent,
_bend_intent_from_values,
_build_playback_schedule,
_compute_tuning,
_extract_year,
_gp_bend_shape,
_gp_string_to_rs,
_is_bass_track,
_standard_tuning_for,
@@ -864,6 +866,80 @@ def test_tied_note_without_predecessor_is_silently_dropped():
assert len(notes) == 0
# ── convert_track: bend shape (bn / bt / bnv, §6.2.1) ────────────────────────
def _ct_bend(points):
"""A pyguitarpro-shaped BendEffect: points are (position 0..12, value)
pairs where value is half-quarter-tone units (12 = 6 semitones)."""
return SimpleNamespace(
points=[SimpleNamespace(position=p, value=v) for p, v in points],
)
def test_bend_intent_classifier():
assert _bend_intent_from_values([0.0, 1.0, 2.0]) == 0 # up
assert _bend_intent_from_values([2.0, 1.0, 0.0]) == 3 # pre-bend+release
assert _bend_intent_from_values([2.0, 2.0]) == 2 # pre-bend held
assert _bend_intent_from_values([2.0, 1.0]) == 1 # release (let down)
assert _bend_intent_from_values([0.0, 2.0, 0.0]) == 4 # round-trip
assert _bend_intent_from_values([]) == 0
def test_gp_bend_shape_units_and_time():
"""value/2 = semitones; position/12 * duration = seconds-from-onset."""
# 0.5 s note, up-bend 0 → value 4 (2 semitones) at the end.
peak, intent, curve = _gp_bend_shape(_ct_bend([(0, 0), (12, 4)]), 0.5)
assert peak == 2.0
assert intent == 0
assert curve == [{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}]
# Zero-length note collapses every point to t=0 → no usable curve.
_, _, curve0 = _gp_bend_shape(_ct_bend([(0, 0), (12, 4)]), 0.0)
assert curve0 is None
# A single point carries only the peak, no curve.
_, _, curve1 = _gp_bend_shape(_ct_bend([(6, 4)]), 0.5)
assert curve1 is None
def test_bent_note_imports_with_curve_through_wire():
"""A GP up-bend imports with bn (peak) + bt + bnv, and survives
convert_track XML → _parse_note → note_to_wire."""
from song import _parse_note, note_to_wire
note = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=7)
# quarter @ 120 BPM = 0.5 s; round-trip bend 0 → 2 → 0 semitones.
note.effect.bend = _ct_bend([(0, 0), (6, 4), (12, 0)])
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("bend") == "2.0"
assert xn.get("bendIntent") == "4" # round-trip
import json
assert json.loads(xn.get("bendValues")) == [
{"t": 0.0, "v": 0.0}, {"t": 0.25, "v": 2.0}, {"t": 0.5, "v": 0.0}]
wire = note_to_wire(_parse_note(xn))
assert wire["bn"] == 2.0
assert wire["bt"] == 4
assert wire["bnv"] == [
{"t": 0.0, "v": 0.0}, {"t": 0.25, "v": 2.0}, {"t": 0.5, "v": 0.0}]
def test_non_bent_note_has_no_curve():
note = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=5) # bend=None
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("bend") == "0"
assert xn.get("bendIntent") is None
assert xn.get("bendValues") is None
from song import _parse_note
n = _parse_note(xn)
assert n.bend == 0.0
assert n.bend_intent == 0
assert n.bend_values is None
def _ct_multivoice_song(voices_beats):
"""Multi-voice variant of _ct_song. `voices_beats` is a list of beat-lists,
one per voice, all on the same single measure."""
+45
View File
@@ -31,6 +31,7 @@ from gp2rs_gpx import (
_collect_tone_events,
_inject_tones,
_resolve_pending_slides,
_gpx_bend_shape,
)
from gp2rs import RsNote
@@ -55,6 +56,50 @@ def test_safe_filename_stem(name, expected):
assert ".." not in out
# ── _gpx_bend_shape (bn / bt / bnv, §6.2.1) ─────────────────────────────────
def _bend_props(**vals):
"""Build a GPIF property map {name: <Property> element} for the given
bend Float values, e.g. _bend_props(BendOriginValue=0, BendMiddleValue=100)."""
tp = {}
for name, num in vals.items():
tp[name] = ET.fromstring(
f'<Property name="{name}"><Float>{num}</Float></Property>')
return tp
def test_gpx_bend_shape_round_trip_curve():
"""origin/middle/destination value+offset → 3-point bnv; value/divisor=semis."""
tp = _bend_props(
BendOriginValue=0, BendOriginOffset=0,
BendMiddleValue=100, BendMiddleOffset1=50, # 100/50 = 2 semitones
BendDestinationValue=0, BendDestinationOffset=100,
)
peak, intent, curve = _gpx_bend_shape(tp, divisor=50.0, sustain=1.0)
assert peak == 2.0
assert intent == 4 # round-trip (up then back down)
assert curve == [
{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}, {"t": 1.0, "v": 0.0}]
def test_gpx_bend_shape_falls_back_to_even_spacing_without_offsets():
tp = _bend_props(BendOriginValue=0, BendDestinationValue=100) # no offsets
peak, intent, curve = _gpx_bend_shape(tp, divisor=50.0, sustain=1.0)
assert peak == 2.0
assert intent == 0 # plain up
# origin defaults to 0%, destination to 100%.
assert curve == [{"t": 0.0, "v": 0.0}, {"t": 1.0, "v": 2.0}]
def test_gpx_bend_shape_no_props_and_zero_length():
assert _gpx_bend_shape({}, divisor=50.0, sustain=1.0) == (0.0, 0, None)
# Peak + intent still derived for a zero-length note, but no curve.
peak, intent, curve = _gpx_bend_shape(
_bend_props(BendOriginValue=0, BendDestinationValue=100),
divisor=50.0, sustain=0.0)
assert peak == 2.0 and intent == 0 and curve is None
# ── _decompress_bcfz / _parse_bcfs input guards ─────────────────────────────
def test_decompress_bcfz_rejects_bad_magic():
+79
View File
@@ -169,6 +169,85 @@ def test_note_bend_nonzero_rounded_to_one_decimal():
assert note_to_wire(n)["bn"] == 1.8
# ── Bend shape (bt / bnv, §6.2.1) ────────────────────────────────────────────
def test_note_bend_shape_round_trip():
"""A note with bend intent + a time-stamped curve survives the wire."""
n = Note(
time=0.5, string=0, fret=7, sustain=1.0,
bend=2.0,
bend_intent=4, # round-trip
bend_values=[
{"t": 0.0, "v": 0.0},
{"t": 0.25, "v": 2.0},
{"t": 0.5, "v": 0.0},
],
)
wire = note_to_wire(n)
assert wire["bt"] == 4
assert wire["bnv"] == [
{"t": 0.0, "v": 0.0},
{"t": 0.25, "v": 2.0},
{"t": 0.5, "v": 0.0},
]
assert note_from_wire(wire) == n
def test_note_bend_shape_omitted_when_default():
"""`bt`/`bnv` are default-omitted; absence decodes to 0 / None (not 0-present
/ not [])."""
wire = note_to_wire(Note(time=0.0, string=0, fret=0, bend=1.0))
assert "bt" not in wire
assert "bnv" not in wire
decoded = note_from_wire(wire)
assert decoded.bend_intent == 0
assert decoded.bend_values is None
def test_note_bend_values_rounded_on_wire():
"""`bnv` rounds `t` to 3 and `v` to 1, matching the scalar `bn` precision."""
n = Note(
time=0.0, string=0, fret=0, bend=1.0, bend_intent=1,
bend_values=[{"t": 0.123456, "v": 1.749}],
)
assert note_to_wire(n)["bnv"] == [{"t": 0.123, "v": 1.7}]
def test_note_bend_values_sanitized_from_wire():
"""Malformed `bnv` entries are dropped; bad/empty -> None; result sorted by t."""
# NaN / non-dict / non-numeric entries dropped, remaining sorted by t.
n = note_from_wire({
"t": 0.0, "s": 0, "f": 0, "bn": 2.0,
"bnv": [
{"t": 0.5, "v": 2.0},
{"t": 0.0, "v": 0.0},
{"t": "x", "v": 1.0}, # non-numeric t -> dropped
{"t": 0.25, "v": float("nan")}, # non-finite v -> dropped
"garbage", # non-dict -> dropped
],
})
assert n.bend_values == [{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}]
# Empty / non-list / all-invalid collapse to None (never []).
for bad in (None, [], "nope", [{"t": "a", "v": "b"}], [42]):
assert note_from_wire(
{"t": 0.0, "s": 0, "f": 0, "bnv": bad}).bend_values is None
def test_chord_note_carries_bend_shape():
"""Chord member notes inherit bt/bnv through chord_note_to_wire/chord_from_wire."""
c = Chord(
time=2.0, chord_id=0,
notes=[Note(
time=2.0, string=1, fret=5, bend=1.0, bend_intent=2,
bend_values=[{"t": 0.0, "v": 1.0}, {"t": 0.3, "v": 0.0}],
)],
)
decoded = chord_from_wire(chord_to_wire(c))
cn = decoded.notes[0]
assert cn.bend_intent == 2
assert cn.bend_values == [{"t": 0.0, "v": 1.0}, {"t": 0.3, "v": 0.0}]
# ── Chord round-trip ─────────────────────────────────────────────────────────
def test_chord_with_multiple_notes_round_trip():