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:
Byron Gamatos
2026-06-21 11:03:15 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent ea22791984
commit 3fc077cbc1
3 changed files with 133 additions and 0 deletions
+49
View 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: '' });
});
}