feat(core): per-note bend shape (bt + bnv) — wire + GP import (PR-A) - #531
Merged
Conversation
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 #334
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
byrongamatos
added a commit
that referenced
this pull request
Jun 20, 2026
#535) Post-merge Codex review of the bend-curve PRs (#531/#532) surfaced edge cases: - GP8 (#531 P2): bnv timing used rn.sustain, which is zeroed for notes <= 0.2s, so short GP8 bends kept the scalar bn but lost bt/bnv. Use the beat duration `dur` (matching the GP5 path) so the curve survives. - 2D highway (#532 P2): bnvNormalizedPoints mapped x over the curve's own t-range [first,last] instead of the note span, so curves not starting at 0 / ending at sus were time-distorted. Now maps over [0, sus] (clamped), with a curve-span fallback when sus<=0 (existing no-sus callers unaffected). - 3D highway (#532 P3): the sustain ribbon + bend chevron were gated on bn>0, so a note carrying an authoritative bnv with bn==0 drew no ribbon/marker. Both now also fire on bnv presence; chevron steps derived from max(bn, bnv peak). Codex-reviewed: clean (no findings). +1 JS test (sus-relative mapping + fallback). JS 8/8, 250 core GP/song tests pass. NB: GP8's short-bend path still lacks a dedicated synthetic-GPIF fixture (same gap as the GP8 offset-prop-names P3) — _gpx_bend_shape units cover the function; the fix is the one-line caller change. Part of #334. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
byrongamatos
added a commit
that referenced
this pull request
Jun 21, 2026
…n (§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 #334 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Implements the core half of per-note bend shape per feedpak spec §6.2.1 (feedpak 1.4.0). This is PR-A of three (PR-A core wire + GP import → PR-B highway render → PR-C editor authoring).
bn(existing) — bend peak magnitude in semitones, unchanged.bt— bend intent (int, default0):0up,1release,2pre-bend,3pre-bend-and-release,4round-trip. Default-omitted on the wire.bnv— time-stamped bend curve:[{ "t": seconds-from-onset, "v": semitones }], non-descendingt, authoritative when present. Default-omitted. Both additive — older readers ignore them.Changes
Wire —
lib/song.pyNote.bend_intent/Note.bend_valuesfields.note_to_wireemitsbtonly when non-zero andbnvonly when present (mirrors the existingln/fhm/… default-omit style).note_from_wirereads them;_sanitize_bend_curvedrops malformed entries (non-finite/non-numeric/non-dict) and collapses empty →None(never[]).chord_note_to_wire/chord_from_wire._parse_notereadsbendIntent+bendValues(JSON) from the GP-import XML, so a GP-imported curve survives import → XML → wire → highway.GP5 —
lib/gp2rs.py_gp_bend_shapemaps pyguitarproBendPoints to abnvcurve:semitones = value / 2.0(consistent with the existing scalarbn),t = position / 12 × duration(BendEffect.maxPosition == 12)._bend_intent_from_valuesderivesbtfrom the curve shape.<note>and<chordNote>via the shared_build_xml.GP8 —
lib/gp2rs_gpx.py_gpx_bend_shapebuilds a 3-point curve from the GPIF origin/middle/destination value+offset Properties (value / divisorsemitones,offset / 100 × sustainseconds), reusing the shared_build_xml.Testing
PYTHONPATH=lib python -m pytest tests/test_song.py tests/test_gp2rs.py tests/test_gp2rs_gpx.py -q→ 250 passed. Full suite green except the 2 pre-existingtest_diagnostics_bundlefailures (username-path redaction; fail on cleanmaintoo)._parse_note→note_to_wire.Codex preflight (read-only): no P1/P2 findings.
Notes / follow-ups
BendOriginOffset/BendMiddleOffset1/BendDestinationOffset) should be confirmed against a real GP8 export — there are no.gpxfixtures in-repo; the value path matches the existing scalar-bend extraction regardless, and missing offsets fall back to even spacing.Part of #334
🤖 Generated with Claude Code