Skip to content

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

Merged
byrongamatos merged 1 commit into
mainfrom
feat/bend-shape-core
Jun 20, 2026
Merged

feat(core): per-note bend shape (bt + bnv) — wire + GP import (PR-A)#531
byrongamatos merged 1 commit into
mainfrom
feat/bend-shape-core

Conversation

@byrongamatos

Copy link
Copy Markdown
Contributor

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, default 0): 0 up, 1 release, 2 pre-bend, 3 pre-bend-and-release, 4 round-trip. Default-omitted on the wire.
  • bnv — time-stamped bend curve: [{ "t": seconds-from-onset, "v": semitones }], non-descending t, authoritative when present. Default-omitted. Both additive — older readers ignore them.

Changes

Wire — lib/song.py

  • Note.bend_intent / Note.bend_values fields.
  • note_to_wire emits bt only when non-zero and bnv only when present (mirrors the existing ln/fhm/… default-omit style).
  • note_from_wire reads them; _sanitize_bend_curve drops malformed entries (non-finite/non-numeric/non-dict) and collapses empty → None (never []).
  • Chord notes inherit automatically through chord_note_to_wire / chord_from_wire.
  • _parse_note reads bendIntent + bendValues (JSON) from the GP-import XML, so a GP-imported curve survives 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 (BendEffect.maxPosition == 12).
  • _bend_intent_from_values derives bt from the curve 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.

Testing

PYTHONPATH=lib python -m pytest tests/test_song.py tests/test_gp2rs.py tests/test_gp2rs_gpx.py -q250 passed. Full suite green except the 2 pre-existing test_diagnostics_bundle failures (username-path redaction; fail on clean main too).

  • Wire round-trip + default-omit + sanitization (incl. NaN/bad-entry dropping).
  • GP5 unit/time mapping + intent classifier, verified end-to-end through the emitted XML → _parse_notenote_to_wire.
  • GP8 curve builder (offset→time mapping, even-spacing fallback, zero-length guard).

Codex preflight (read-only): no P1/P2 findings.

Notes / follow-ups

  • The GPIF bend offset Property names (BendOriginOffset / BendMiddleOffset1 / BendDestinationOffset) should be confirmed against a real GP8 export — there are no .gpx fixtures in-repo; the value path matches the existing scalar-bend extraction regardless, and missing offsets fall back to even spacing.
  • Render (PR-B) and editor authoring (PR-C) follow.

Part of #334

🤖 Generated with Claude Code

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
byrongamatos merged commit e33df9a into main Jun 20, 2026
0 of 3 checks passed
@byrongamatos
byrongamatos deleted the feat/bend-shape-core branch June 20, 2026 21:17
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant