mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 12:52:29 +00:00
feat(highway): render chord harmony fn.rn + voicing on 2D + 3D (§6.3.1, §6.6) (#541)
* feat(core): carry chord harmony fn + template voicing on the wire (§6.3.1, §6.6)
Add two OPTIONAL per-chord harmony annotations (feedpak 1.7.0), mirroring the
teaching-marks (fg/ch/sd) wire work:
- Chord.fn (instance): {rn, q, deg} harmonic-function object, key-dependent.
Validated by _validate_fn on BOTH decode and emit so a partial / out-of-range
fn (which would fail the schema's required-keys rule) never rides the wire.
Default-omitted, mirroring bend bnv.
- ChordTemplate.voicing (template): key-independent voicing-type string
("open", "triad", "shell", "drop2", "barre", ...). Emitted only when
non-empty; non-string wire values fall back to "".
Display/teaching only — never fed to a grader (honesty rule). fn auto-derivation
is DEFERRED (carry-only): a complete rn/q needs chord-quality analysis, and a
deg-only fn would be schema-invalid, so server.py carries author-provided fn
unchanged. GP import unchanged (no reliable per-chord function/voicing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(highway): render chord harmony fn.rn + voicing on 2D + 3D (§6.3.1, §6.6)
Draw the chord's harmonic-function Roman numeral (instance fn.rn) and its
template voicing string, stacked above the chord name on both highways. A shared
pure helper chordHarmonyLabels(fn, voicing) formats the two labels (empty when
absent/malformed) and is node-tested against both files.
Both labels are gated behind the EXISTING teaching-marks opt-in
(_showTeachingMarks / teachingMarksVisible bundle flag) — they're chord-level
teaching overlays, same class as sd/ch, so they stay off the default highway.
2D guards the empty-note-chord case; 3D reuses the gold chord-label sprite style.
Render only — no scoring / NoteVerifier path is touched (honesty rule).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ea22791984
commit
3fc077cbc1
@ -10296,6 +10296,37 @@
|
||||
lbl.scale.set(lblWS, lblHS, 1);
|
||||
}
|
||||
|
||||
// Harmony annotations (§6.3.1 / §6.6) — the chord's
|
||||
// function (fn.rn Roman numeral) and template voicing,
|
||||
// stacked above the chord name. Gated by the
|
||||
// teaching-marks opt-in (mirrors the 2D overlay). Display
|
||||
// only — never grading.
|
||||
if (_drawTeachingMarks && firstInShapeRun && !chordWireHighDensity(ch)) {
|
||||
const _h = chordHarmonyLabels(ch.fn, bundle.chordTemplates?.[ch.id]?.voicing);
|
||||
if (_h.rn || _h.voicing) {
|
||||
const hlW = 24 * K * _textSizeMul;
|
||||
const hlH = 9 * K * _textSizeMul;
|
||||
const frameLeft = cx - width / 2;
|
||||
const baseX = frameLeft - hlW / 2 + NW * 0.94;
|
||||
const opacity = Math.min(1, 0.3 + fade * 0.7) * chordTailMul;
|
||||
// Start one chord-name-height above the name and
|
||||
// stack upward so labels never overlap the gems.
|
||||
let hy = yMaxF + hlH * 1.6;
|
||||
const _drawHarmony = (text, colorHex) => {
|
||||
if (!text) return;
|
||||
const s = pChordLbl.get();
|
||||
const m = txtMat(text, colorHex, true, 'chord');
|
||||
if (s.material.map !== m.map) { s.material.map = m.map; s.material.needsUpdate = true; }
|
||||
s.material.opacity = opacity;
|
||||
s.position.set(baseX, hy, z);
|
||||
s.scale.set(hlW, hlH, 1);
|
||||
hy += hlH;
|
||||
};
|
||||
_drawHarmony(_h.rn, '#ffcc66'); // sd teaching color
|
||||
_drawHarmony(_h.voicing, '#7fd1ff'); // fg teaching color
|
||||
}
|
||||
}
|
||||
|
||||
// Shape-based barre detection for the 3D indicator.
|
||||
// Drives off chord notes alone — independent of label
|
||||
// availability, so charts whose chordTemplates lack a
|
||||
@ -11358,6 +11389,15 @@
|
||||
if (!Number.isInteger(sd) || sd < 0 || sd > 11) return '';
|
||||
return String(sd);
|
||||
}
|
||||
/** Harmony annotations (§6.3.1 / §6.6): display labels for a chord's
|
||||
* function (instance `fn.rn` Roman numeral) and template `voicing`
|
||||
* string. '' for each when absent/malformed. Pure; shared with the 2D
|
||||
* highway and node-tested. Display only — never grading. */
|
||||
function chordHarmonyLabels(fn, voicing) {
|
||||
const rn = (fn && typeof fn.rn === 'string') ? fn.rn.trim() : '';
|
||||
const vc = (typeof voicing === 'string') ? voicing.trim() : '';
|
||||
return { rn, voicing: vc };
|
||||
}
|
||||
|
||||
function bnvSampleAt(bnv, t) {
|
||||
// Linear interpolation of a bend curve [{t, v}] (§6.2.1; t is
|
||||
|
||||
@ -509,6 +509,17 @@ function createHighway() {
|
||||
return String(sd);
|
||||
}
|
||||
|
||||
/** Harmony annotations (§6.3.1 / §6.6): display labels for a chord's
|
||||
* harmonic function (the instance `fn.rn` Roman numeral) and its template
|
||||
* `voicing` string. Returns '' for each when absent or malformed. Pure;
|
||||
* node-tested and shared by both highways. Display/teaching only — MUST
|
||||
* NEVER feed a grader (honesty rule). */
|
||||
function chordHarmonyLabels(fn, voicing) {
|
||||
const rn = (fn && typeof fn.rn === 'string') ? fn.rn.trim() : '';
|
||||
const vc = (typeof voicing === 'string') ? voicing.trim() : '';
|
||||
return { rn, voicing: vc };
|
||||
}
|
||||
|
||||
/** Teaching mark (§6.2.2): bucket drawn notes by their strum-group key `ch`.
|
||||
* Returns the groups (in first-seen order) for each ch value >= 0 that has
|
||||
* at least two members — a lone note is not a strum gesture. Pure; drives
|
||||
@ -2200,6 +2211,39 @@ function createHighway() {
|
||||
fillTextReadable(tmpl.name, labelX, labelY);
|
||||
}
|
||||
|
||||
// Harmony annotations (§6.3.1 / §6.6) — the chord's function
|
||||
// (fn.rn Roman numeral) and template voicing, stacked above the
|
||||
// chord name. Gated behind the teaching-marks opt-in (same overlay
|
||||
// class as sd/ch) so they don't clutter the default highway.
|
||||
// Display only — never grading.
|
||||
if (_showTeachingMarks && !ch.hd && p.scale > 0.15 && sorted.length > 0) {
|
||||
const { rn, voicing } = chordHarmonyLabels(ch.fn, tmpl && tmpl.voicing);
|
||||
if (rn || voicing) {
|
||||
const hx = hasNonZero
|
||||
? (xMin + xMax) / 2
|
||||
: (sorted.length >= 2
|
||||
? (fretX(frameLeftFret, p.scale, W) + fretX(frameRightFret, p.scale, W)) / 2
|
||||
: fretX(sorted[0].f, p.scale, W));
|
||||
// Baseline = just above where the chord name sits.
|
||||
const nameY = hasNonZero
|
||||
? (p.y * H - actualTotalH / 2 - sz * 0.7 - sz * 0.4)
|
||||
: (p.y * H - sz * 0.8);
|
||||
ctx.font = `bold ${Math.max(10, sz * 0.32) | 0}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
let stackY = nameY - sz * 0.5;
|
||||
if (rn) {
|
||||
ctx.fillStyle = '#ffcc66'; // matches the sd teaching color
|
||||
fillTextReadable(rn, hx, stackY);
|
||||
stackY -= sz * 0.45;
|
||||
}
|
||||
if (voicing) {
|
||||
ctx.fillStyle = '#7fd1ff'; // matches the fg teaching color
|
||||
fillTextReadable(voicing, hx, stackY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notes — wide colored bar for open strings inside a chord,
|
||||
// normal note glyph otherwise.
|
||||
// Classify into bent / unbent arrays inline (was: post-filter
|
||||
|
||||
49
tests/js/highway_chord_harmony.test.js
Normal file
49
tests/js/highway_chord_harmony.test.js
Normal file
@ -0,0 +1,49 @@
|
||||
// Behavioural tests for the chord harmony-annotation render helper
|
||||
// chordHarmonyLabels (§6.3.1 / §6.6), shared by the 2D and 3D highways.
|
||||
// Pure, so we extract the function source by brace-matching and eval it in
|
||||
// isolation — same pattern as highway_teaching_marks.test.js.
|
||||
|
||||
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 labels2D = loadFn('static/highway.js', 'chordHarmonyLabels');
|
||||
const labels3D = loadFn('plugins/highway_3d/screen.js', 'chordHarmonyLabels');
|
||||
|
||||
for (const [name, fn] of [['2D', labels2D], ['3D', labels3D]]) {
|
||||
test(`chordHarmonyLabels (${name}) surfaces rn + voicing`, () => {
|
||||
assert.deepEqual(fn({ rn: 'ii7', q: 'm7', deg: 2 }, 'open'),
|
||||
{ rn: 'ii7', voicing: 'open' });
|
||||
});
|
||||
|
||||
test(`chordHarmonyLabels (${name}) trims whitespace`, () => {
|
||||
assert.deepEqual(fn({ rn: ' V7 ' }, ' drop2 '),
|
||||
{ rn: 'V7', voicing: 'drop2' });
|
||||
});
|
||||
|
||||
test(`chordHarmonyLabels (${name}) empties absent / malformed inputs`, () => {
|
||||
assert.deepEqual(fn(null, undefined), { rn: '', voicing: '' });
|
||||
assert.deepEqual(fn({}, ''), { rn: '', voicing: '' });
|
||||
assert.deepEqual(fn({ rn: 7 }, 7), { rn: '', voicing: '' }); // non-string
|
||||
assert.deepEqual(fn(undefined, 'shell'), { rn: '', voicing: 'shell' });
|
||||
assert.deepEqual(fn({ rn: 'vi' }, null), { rn: 'vi', voicing: '' });
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user