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 // so Object.assign leaves a stale `true` from a previous
// muted chord note untouched. Reset it explicitly here. // muted chord note untouched. Reset it explicitly here.
_scrChordNote.fhm = cn.fhm || false; _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( drawNote(
_scrChordNote, _scrChordNote,
now, now,
@@ -11309,15 +11316,40 @@
return visualIdx >= (nStr - 1) * 0.5 ? -1 : 1; 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) { 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; 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))); 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 // Fallback: synthesize rise → hold → release from the scalar peak.
// release back down over the last ~30 %. Depicts the bend gesture // Ramp up over the first ~35 %, hold, then release over the last
// (up and back down) rather than a monotone climb that only ever // ~30 % — the bend gesture rather than a monotone climb. Drives both
// showed the bend going up. Drives both the sustain ribbon's Y // the sustain ribbon's Y contour and the gem's techniqueYNow offset.
// contour and the gem's techniqueYNow offset.
const RISE = BEND_ENV_RISE_FRAC, REL = BEND_ENV_RELEASE_FRAC; const RISE = BEND_ENV_RISE_FRAC, REL = BEND_ENV_RELEASE_FRAC;
let env; let env;
if (p < RISE) env = p / RISE; if (p < RISE) env = p / RISE;
+58 -16
View File
@@ -468,6 +468,16 @@ function createHighway() {
return w / 2 - hw + margin + t * usable; return w / 2 - hw + margin + t * usable;
} }
/** Map a bend curve [{t, v}] (§6.2.1) to [{x, v}] with x normalized to
* 0..1 across the curve's time span (0 when the span is degenerate).
* Pure — drives the 2D bend-shape glyph. */
function bnvNormalizedPoints(bnv) {
if (!Array.isArray(bnv) || bnv.length === 0) return [];
const t0 = bnv[0].t;
const span = bnv[bnv.length - 1].t - t0;
return bnv.map(p => ({ x: span > 0 ? (p.t - t0) / span : 0, v: p.v }));
}
/** Call while lefty mirror transform is active; keeps glyphs readable. */ /** Call while lefty mirror transform is active; keeps glyphs readable. */
function fillTextReadable(text, x, y) { function fillTextReadable(text, x, y) {
// ctx may be null when the 2D context was never acquired // ctx may be null when the 2D context was never acquired
@@ -1601,27 +1611,59 @@ function createHighway() {
// Bend notation // Bend notation
if (bend && bend > 0 && sz >= 12) { if (bend && bend > 0 && sz >= 12) {
const lw = Math.max(2, sz / 10); const lw = Math.max(2, sz / 10);
const arrowH = sz * 0.55 * Math.min(bend, 2); // taller for bigger bends
const ay = y - half - 4; const ay = y - half - 4;
const tipY = ay - arrowH; // px above the gem for a bend of `v` semitones (shared by the
// curve contour and the scalar-arrow fallback).
const hOf = (v) => sz * 0.55 * Math.min(Math.max(v, 0), 2);
const bnv = Array.isArray(opts?.bnv) ? opts.bnv : null;
ctx.strokeStyle = '#fff'; ctx.strokeStyle = '#fff';
ctx.lineWidth = lw; ctx.lineWidth = lw;
// Curved arrow let labelTopY; // y of the highest drawn point, for the label
ctx.beginPath(); if (bnv && bnv.length >= 2) {
ctx.moveTo(x, ay); // Bend curve (§6.2.1): trace the real shape as a contour above
ctx.quadraticCurveTo(x + sz * 0.2, ay - arrowH * 0.5, x, tipY); // the gem (round-trip rises then falls, pre-bend starts high,
ctx.stroke(); // release descends, …) — `bt` is implicit in the point shape.
const pts = bnvNormalizedPoints(bnv);
const gw = sz * 0.6;
const x0 = x - gw / 2;
ctx.beginPath();
pts.forEach((pt, i) => {
const px = x0 + pt.x * gw;
const py = ay - hOf(pt.v);
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
});
ctx.stroke();
// Arrowhead only when the gesture ends rising (plain bend /
// pre-bend); round-trip and release finish heading down.
const a = pts[pts.length - 2], b = pts[pts.length - 1];
if (b.v > a.v + 0.05) {
const tipX = x0 + b.x * gw, tipY = ay - hOf(b.v);
ctx.beginPath();
ctx.moveTo(tipX - sz * 0.1, tipY + sz * 0.12);
ctx.lineTo(tipX, tipY);
ctx.lineTo(tipX + sz * 0.1, tipY + sz * 0.12);
ctx.stroke();
}
labelTopY = ay - hOf(Math.max(...pts.map(p => p.v)));
} else {
// Fallback: single curved arrow up to the scalar peak.
const arrowH = hOf(bend); // taller for bigger bends
const tipY = ay - arrowH;
ctx.beginPath();
ctx.moveTo(x, ay);
ctx.quadraticCurveTo(x + sz * 0.2, ay - arrowH * 0.5, x, tipY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x - sz * 0.12, tipY + sz * 0.12);
ctx.lineTo(x, tipY);
ctx.lineTo(x + sz * 0.12, tipY + sz * 0.12);
ctx.stroke();
labelTopY = tipY;
}
// Arrowhead // Bend label: peak magnitude — "full", "1/2", "1 1/2", "2"
ctx.beginPath();
ctx.moveTo(x - sz * 0.12, tipY + sz * 0.12);
ctx.lineTo(x, tipY);
ctx.lineTo(x + sz * 0.12, tipY + sz * 0.12);
ctx.stroke();
// Bend label: "full", "1/2", "1 1/2", "2"
let label; let label;
if (bend === 0.5) label = '½'; if (bend === 0.5) label = '½';
else if (bend === 1) label = 'full'; else if (bend === 1) label = 'full';
@@ -1633,7 +1675,7 @@ function createHighway() {
ctx.font = `bold ${Math.max(9, sz * 0.28) | 0}px sans-serif`; ctx.font = `bold ${Math.max(9, sz * 0.28) | 0}px sans-serif`;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'bottom'; ctx.textBaseline = 'bottom';
fillTextReadable(label, x, tipY - 2); fillTextReadable(label, x, labelTopY - 2);
} }
if (sz < 14) return; // Skip small technique labels if (sz < 14) return; // Skip small technique labels
+77
View File
@@ -0,0 +1,77 @@
// Behavioural tests for the per-note bend-curve (bnv, §6.2.1) render helpers:
// `bnvNormalizedPoints` (static/highway.js, 2D glyph) and `bnvSampleAt`
// (plugins/highway_3d/screen.js, 3D Y gesture). Both are pure, so we extract
// the function source by brace-matching and eval it in isolation.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
function extractFn(src, name) {
const start = src.indexOf('function ' + name);
assert.ok(start >= 0, `function ${name} must exist`);
const open = src.indexOf('{', start);
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
}
throw new Error(`unbalanced braces extracting ${name}`);
}
function loadFn(file, name) {
const src = fs.readFileSync(path.join(__dirname, '..', '..', file), 'utf8');
return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)();
}
const bnvNormalizedPoints = loadFn('static/highway.js', 'bnvNormalizedPoints');
const bnvSampleAt = loadFn('plugins/highway_3d/screen.js', 'bnvSampleAt');
// ── bnvNormalizedPoints (2D) ─────────────────────────────────────────────────
test('bnvNormalizedPoints normalizes t to 0..1 across the span', () => {
const pts = bnvNormalizedPoints([
{ t: 0.5, v: 0 }, { t: 1.0, v: 2 }, { t: 1.5, v: 0 }]);
assert.deepEqual(pts, [
{ x: 0, v: 0 }, { x: 0.5, v: 2 }, { x: 1, v: 0 }]);
});
test('bnvNormalizedPoints handles degenerate/empty input', () => {
assert.deepEqual(bnvNormalizedPoints([]), []);
assert.deepEqual(bnvNormalizedPoints(null), []);
// All-same-t span collapses x to 0 (no divide-by-zero).
assert.deepEqual(bnvNormalizedPoints([{ t: 1, v: 1 }, { t: 1, v: 2 }]),
[{ x: 0, v: 1 }, { x: 0, v: 2 }]);
});
// ── bnvSampleAt (3D) ─────────────────────────────────────────────────────────
test('bnvSampleAt linearly interpolates between points', () => {
const bnv = [{ t: 0, v: 0 }, { t: 1, v: 2 }];
assert.equal(bnvSampleAt(bnv, 0.5), 1); // midpoint
assert.equal(bnvSampleAt(bnv, 0.25), 0.5);
});
test('bnvSampleAt clamps to the endpoints', () => {
const bnv = [{ t: 0.2, v: 1 }, { t: 0.8, v: 3 }];
assert.equal(bnvSampleAt(bnv, 0), 1); // before first
assert.equal(bnvSampleAt(bnv, 5), 3); // after last
});
test('bnvSampleAt traces a round-trip curve up then back down', () => {
const bnv = [{ t: 0, v: 0 }, { t: 0.5, v: 2 }, { t: 1, v: 0 }];
assert.equal(bnvSampleAt(bnv, 0.25), 1); // rising
assert.equal(bnvSampleAt(bnv, 0.5), 2); // peak
assert.equal(bnvSampleAt(bnv, 0.75), 1); // falling
});
test('bnvSampleAt returns 0 for an empty/invalid curve', () => {
assert.equal(bnvSampleAt([], 0.5), 0);
assert.equal(bnvSampleAt(null, 0.5), 0);
});
test('bnvSampleAt tolerates a zero-width segment (duplicate t)', () => {
const bnv = [{ t: 0, v: 0 }, { t: 0.5, v: 1 }, { t: 0.5, v: 2 }, { t: 1, v: 2 }];
assert.equal(bnvSampleAt(bnv, 0.5), 1); // first matching segment wins
});