Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions plugins/highway_3d/screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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) {
Expand Down
30 changes: 23 additions & 7 deletions static/highway.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
}
}
Expand Down
41 changes: 31 additions & 10 deletions tests/js/highway_chord_harmony.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
}