From 351b273ab5a2e0af05f10813ea0276b12ecd408b Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sat, 20 Jun 2026 23:34:50 +0200 Subject: [PATCH] feat(highway): render per-note bend curve (bnv) on 2D + 3D (#532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- plugins/highway_3d/screen.js | 44 ++++++++++++++--- static/highway.js | 74 +++++++++++++++++++++------ tests/js/highway_bend_curve.test.js | 77 +++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 tests/js/highway_bend_curve.test.js diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 0bb5431..29d7b11 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -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; diff --git a/static/highway.js b/static/highway.js index 4ad74d8..4f40136 100644 --- a/static/highway.js +++ b/static/highway.js @@ -468,6 +468,16 @@ function createHighway() { 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. */ function fillTextReadable(text, x, y) { // ctx may be null when the 2D context was never acquired @@ -1601,27 +1611,59 @@ function createHighway() { // Bend notation if (bend && bend > 0 && sz >= 12) { 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 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.lineWidth = lw; - // Curved arrow - ctx.beginPath(); - ctx.moveTo(x, ay); - ctx.quadraticCurveTo(x + sz * 0.2, ay - arrowH * 0.5, x, tipY); - ctx.stroke(); + let labelTopY; // y of the highest drawn point, for the label + if (bnv && bnv.length >= 2) { + // Bend curve (§6.2.1): trace 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. + 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 - 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" + // Bend label: peak magnitude — "full", "1/2", "1 1/2", "2" let label; if (bend === 0.5) label = '½'; 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.textAlign = 'center'; ctx.textBaseline = 'bottom'; - fillTextReadable(label, x, tipY - 2); + fillTextReadable(label, x, labelTopY - 2); } if (sz < 14) return; // Skip small technique labels diff --git a/tests/js/highway_bend_curve.test.js b/tests/js/highway_bend_curve.test.js new file mode 100644 index 0000000..a20e164 --- /dev/null +++ b/tests/js/highway_bend_curve.test.js @@ -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 +});