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."""