diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 0cfd780..d533120 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -10302,8 +10302,9 @@ // 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 _tmpl = bundle.chordTemplates?.[ch.id]; + const _h = chordHarmonyLabels(ch.fn, _tmpl?.voicing, _tmpl?.caged, _tmpl?.guideTones); + if (_h.rn || _h.voicing || _h.caged || _h.guideTones) { const hlW = 24 * K * _textSizeMul; const hlH = 9 * K * _textSizeMul; const frameLeft = cx - width / 2; @@ -10322,8 +10323,10 @@ s.scale.set(hlW, hlH, 1); hy += hlH; }; - _drawHarmony(_h.rn, '#ffcc66'); // sd teaching color - _drawHarmony(_h.voicing, '#7fd1ff'); // fg teaching color + _drawHarmony(_h.rn, '#ffcc66'); // sd teaching color + _drawHarmony(_h.voicing, '#7fd1ff'); // fg teaching color + _drawHarmony(_h.caged, '#a0ffa0'); // CAGED shape teaching color + _drawHarmony(_h.guideTones, '#d0a0ff'); // guide-tone teaching color } } @@ -11390,13 +11393,19 @@ 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) { + * function (instance `fn.rn` Roman numeral) and template `voicing`, + * `caged` shape, and `guideTones`. '' for each when absent/malformed; + * `caged`/`guideTones` come back pre-formatted ("CAGED: E" / "gt 4,10"). + * Pure; shared with the 2D highway and node-tested. Display only — never + * grading. */ + function chordHarmonyLabels(fn, voicing, caged, guideTones) { const rn = (fn && typeof fn.rn === 'string') ? fn.rn.trim() : ''; const vc = (typeof voicing === 'string') ? voicing.trim() : ''; - return { rn, voicing: vc }; + const cg = (typeof caged === 'string' && /^[CAGED]$/.test(caged.trim())) + ? 'CAGED: ' + caged.trim() : ''; + const gt = Array.isArray(guideTones) + ? guideTones.filter(n => Number.isInteger(n) && n >= 0 && n <= 11) : []; + return { rn, voicing: vc, caged: cg, guideTones: gt.length ? 'gt ' + gt.join(',') : '' }; } function bnvSampleAt(bnv, t) { diff --git a/static/highway.js b/static/highway.js index 83e4a7b..edadca4 100644 --- a/static/highway.js +++ b/static/highway.js @@ -511,13 +511,18 @@ function createHighway() { /** 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) { + * `voicing`, `caged` shape, and `guideTones`. Returns '' for each when + * absent or malformed; `caged`/`guideTones` come back pre-formatted + * ("CAGED: E" / "gt 4,10"). Pure; node-tested and shared by both highways. + * Display/teaching only — MUST NEVER feed a grader (honesty rule). */ + function chordHarmonyLabels(fn, voicing, caged, guideTones) { const rn = (fn && typeof fn.rn === 'string') ? fn.rn.trim() : ''; const vc = (typeof voicing === 'string') ? voicing.trim() : ''; - return { rn, voicing: vc }; + const cg = (typeof caged === 'string' && /^[CAGED]$/.test(caged.trim())) + ? 'CAGED: ' + caged.trim() : ''; + const gt = Array.isArray(guideTones) + ? guideTones.filter(n => Number.isInteger(n) && n >= 0 && n <= 11) : []; + return { rn, voicing: vc, caged: cg, guideTones: gt.length ? 'gt ' + gt.join(',') : '' }; } /** Teaching mark (§6.2.2): bucket drawn notes by their strum-group key `ch`. @@ -2217,8 +2222,9 @@ function createHighway() { // 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 { rn, voicing, caged, guideTones } = chordHarmonyLabels( + ch.fn, tmpl && tmpl.voicing, tmpl && tmpl.caged, tmpl && tmpl.guideTones); + if (rn || voicing || caged || guideTones) { const hx = hasNonZero ? (xMin + xMax) / 2 : (sorted.length >= 2 @@ -2240,6 +2246,16 @@ function createHighway() { if (voicing) { ctx.fillStyle = '#7fd1ff'; // matches the fg teaching color fillTextReadable(voicing, hx, stackY); + stackY -= sz * 0.45; + } + if (caged) { + ctx.fillStyle = '#a0ffa0'; // CAGED shape teaching color + fillTextReadable(caged, hx, stackY); + stackY -= sz * 0.45; + } + if (guideTones) { + ctx.fillStyle = '#d0a0ff'; // guide-tone teaching color + fillTextReadable(guideTones, hx, stackY); } } } diff --git a/tests/js/highway_chord_harmony.test.js b/tests/js/highway_chord_harmony.test.js index c522988..80f1048 100644 --- a/tests/js/highway_chord_harmony.test.js +++ b/tests/js/highway_chord_harmony.test.js @@ -29,21 +29,42 @@ 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}) surfaces rn + voicing + caged + guideTones`, () => { + assert.deepEqual(fn({ rn: 'ii7', q: 'm7', deg: 2 }, 'open', 'E', [4, 10]), + { rn: 'ii7', voicing: 'open', caged: 'CAGED: E', guideTones: 'gt 4,10' }); }); test(`chordHarmonyLabels (${name}) trims whitespace`, () => { - assert.deepEqual(fn({ rn: ' V7 ' }, ' drop2 '), - { rn: 'V7', voicing: 'drop2' }); + assert.deepEqual(fn({ rn: ' V7 ' }, ' drop2 ', ' G ', []), + { rn: 'V7', voicing: 'drop2', caged: 'CAGED: G', guideTones: '' }); }); 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: '' }); + assert.deepEqual(fn(null, undefined), + { rn: '', voicing: '', caged: '', guideTones: '' }); + assert.deepEqual(fn({}, ''), + { rn: '', voicing: '', caged: '', guideTones: '' }); + assert.deepEqual(fn({ rn: 7 }, 7), // non-string + { rn: '', voicing: '', caged: '', guideTones: '' }); + assert.deepEqual(fn(undefined, 'shell'), + { rn: '', voicing: 'shell', caged: '', guideTones: '' }); + assert.deepEqual(fn({ rn: 'vi' }, null), + { rn: 'vi', voicing: '', caged: '', guideTones: '' }); + }); + + test(`chordHarmonyLabels (${name}) rejects invalid caged enum`, () => { + assert.equal(fn(null, null, 'X').caged, ''); // not a CAGED letter + assert.equal(fn(null, null, 'e').caged, ''); // lower-case rejected + assert.equal(fn(null, null, 7).caged, ''); // non-string + assert.equal(fn(null, null, ['E']).caged, ''); // non-string + assert.equal(fn(null, null, 'C').caged, 'CAGED: C'); + }); + + test(`chordHarmonyLabels (${name}) filters out-of-range / non-int guide tones`, () => { + assert.equal(fn(null, null, '', [12, -1, 3, 'x', 10]).guideTones, 'gt 3,10'); + assert.equal(fn(null, null, '', [0, 11]).guideTones, 'gt 0,11'); // boundaries kept + assert.equal(fn(null, null, '', []).guideTones, ''); + assert.equal(fn(null, null, '', '4,10').guideTones, ''); // non-array + assert.equal(fn(null, null, '', [12, -1]).guideTones, ''); // all dropped }); }