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
+44
View File
@@ -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