mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-16 13:43:19 +00:00
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:
co-authored by
Claude Opus 4.8
parent
e33df9a720
commit
351b273ab5
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user