feat(highway): render per-note bend curve (bnv) on 2D + 3D (#532)

PR-B of the bend-shape feature (feedpak §6.2.1). Both highways drew a bend
from the scalar `bn` only; now they trace the authoritative `bnv` curve
([{t, v}]) when present and fall back to the `bn` arc/envelope otherwise.

2D (static/highway.js drawNote): when a note carries `bnv`, draw the real
shape as a contour above the gem (round-trip rises then falls, pre-bend
starts high, release descends — `bt` is implicit in the point shape), with
an arrowhead only when the gesture ends rising. `bnvNormalizedPoints` maps
{t,v} to a 0..1 x span. The scalar-arrow path is preserved unchanged as the
fallback; the peak label is unchanged.

3D (plugins/highway_3d/screen.js): `bnvSampleAt` linearly interpolates the
curve (clamped to its endpoints) and `bendSemisAtTime` samples it when
present, else keeps the synthetic rise→hold→release envelope from `bn`. The
chevron count still comes from the peak. Fixed a stale-scratch hazard: the
reused `_scrChordNote` now resets `bnv`/`bt` (omit-when-default) after
Object.assign, mirroring the existing `fhm` reset, so a chord note without a
curve can't inherit the previous note's contour.

Render-only — no wire/schema change. Pure helpers covered by
tests/js/highway_bend_curve.test.js (interp, clamping, round-trip,
degenerate/empty); node --check passes on both files; full tests/js green.

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:34:50 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent e33df9a720
commit 351b273ab5
3 changed files with 173 additions and 22 deletions
+38 -6
View File
@@ -9823,6 +9823,13 @@
// so Object.assign leaves a stale `true` from a previous
// muted chord note untouched. Reset it explicitly here.
_scrChordNote.fhm = cn.fhm || false;
// Same stale-scratch hazard for the bend shape:
// `bnv`/`bt` are omit-when-default on the wire, so a
// chord note without them would otherwise inherit the
// previous note's curve (and bendSemisAtTime would
// apply the wrong contour). Reset explicitly.
_scrChordNote.bnv = Array.isArray(cn.bnv) ? cn.bnv : undefined;
_scrChordNote.bt = cn.bt || 0;
drawNote(
_scrChordNote,
now,
@@ -11309,15 +11316,40 @@
return visualIdx >= (nStr - 1) * 0.5 ? -1 : 1;
}
function bnvSampleAt(bnv, t) {
// Linear interpolation of a bend curve [{t, v}] (§6.2.1; t is
// seconds from the note onset) at elapsed time t. Clamps to the
// endpoints; returns 0 for an empty/invalid curve.
if (!Array.isArray(bnv) || bnv.length === 0) return 0;
if (t <= bnv[0].t) return bnv[0].v;
const last = bnv[bnv.length - 1];
if (t >= last.t) return last.v;
for (let i = 1; i < bnv.length; i++) {
const a = bnv[i - 1], b = bnv[i];
if (t <= b.t) {
const span = b.t - a.t;
return span > 0 ? a.v + (b.v - a.v) * ((t - a.t) / span) : b.v;
}
}
return last.v;
}
function bendSemisAtTime(n, chartTime) {
if (!(n?.sus > 0)) return 0;
// When the note carries an authoritative bend curve (§6.2.1),
// sample its real shape at the elapsed time so the gem's Y gesture
// and sustain ribbon follow the actual bend (pre-bend, round-trip,
// release, …). Negative samples clamp to 0 (upward-only Y offset).
if (Array.isArray(n.bnv) && n.bnv.length) {
return Math.max(0, bnvSampleAt(n.bnv, chartTime - n.t));
}
const bn = Number(n?.bn) || 0;
if (!(bn > 0) || !(n?.sus > 0)) return 0;
if (!(bn > 0)) return 0;
const p = Math.max(0, Math.min(1, (chartTime - n.t) / Math.max(n.sus, 1e-6)));
// rise → hold → release: ramp up over the first ~35 %, hold, then
// release back down over the last ~30 %. Depicts the bend gesture
// (up and back down) rather than a monotone climb that only ever
// showed the bend going up. Drives both the sustain ribbon's Y
// contour and the gem's techniqueYNow offset.
// Fallback: synthesize rise → hold → release from the scalar peak.
// Ramp up over the first ~35 %, hold, then release over the last
// ~30 % — the bend gesture rather than a monotone climb. Drives both
// the sustain ribbon's Y contour and the gem's techniqueYNow offset.
const RISE = BEND_ENV_RISE_FRAC, REL = BEND_ENV_RELEASE_FRAC;
let env;
if (p < RISE) env = p / RISE;