diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 216cc7e..0cfd780 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -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 diff --git a/static/highway.js b/static/highway.js index 13fbb1e..83e4a7b 100644 --- a/static/highway.js +++ b/static/highway.js @@ -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 diff --git a/tests/js/highway_chord_harmony.test.js b/tests/js/highway_chord_harmony.test.js new file mode 100644 index 0000000..c522988 --- /dev/null +++ b/tests/js/highway_chord_harmony.test.js @@ -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: '' }); + }); +}