From e2a1552843a7b5cc1b5ae6834aa7f84b9f563e32 Mon Sep 17 00:00:00 2001 From: vadimwit Date: Mon, 15 Jun 2026 19:05:44 +0100 Subject: [PATCH] feat(piano): pure piano voicing resolver src/lib/piano.js (task L-10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pianoVoicing({rootPc,quality},opts?) -> {notes,pcs,bass,style,label}: shells (1-3-7), rootless A/B (3-5-7-9 / 7-9-3-5), root-position; opts.prev / pianoVoicingChain thread a loop with octave voice-leading optimization. Helpers: hasTrueSeventh, voicingToneSet. Pure, imports only theory.js. Critic PASS: 56 voicings (14 qualities x 4 styles), 0 wrong notes, deterministic. New unimported module (no frontend impact yet) — D-10 renders it, L-11 wires the piano tab. Co-Authored-By: Claude Fable 5 --- src/lib/piano.js | 409 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 src/lib/piano.js diff --git a/src/lib/piano.js b/src/lib/piano.js new file mode 100644 index 0000000..172c2c4 --- /dev/null +++ b/src/lib/piano.js @@ -0,0 +1,409 @@ +// ─── Piano voicing resolver (L-10) ──────────────────────────────────────────── +// +// A pure, deterministic module that generates piano voicings from a chord, for +// the Jam Guide's piano instrument tab (GOAL G4: "generate piano voicings from +// interval recipes rather than data files — root position, inversions, shells +// (1-3-7), rootless A (3-5-7-9) / B (7-9-3-5) — choosing the inversion that +// minimizes movement from the previous chord"). +// +// Built on the SAME chord model the rest of the app uses: a chord is +// { rootPc, quality } where `rootPc` is a pitch class 0–11 and `quality` is a +// CHORD_TYPES key (one of the 14: maj, min, dom7, maj7, min7, dim, dim7, +// half_dim, aug, sus4, sus2, maj6, min6, add9). This is exactly what +// match.js exposes (`chordRootPC` + a quality token) and what KB stations carry +// (`degrees[i]` + `qualities[i]`), so a caller maps a station → a voicing with +// no string parsing. Intervals come straight from CHORD_TYPES — never redefined. +// +// No globals, no Date, no random. Same input → same output, always. +// +// ─── The `notes` reference convention (READ THIS — D-10/L-11 render against it) ─ +// +// `notes` are ABSOLUTE semitone positions for placing keys on a keyboard, NOT +// pitch classes. The reference point is: +// +// 0 = C of the displayed LOW octave. +// +// So a value of `n` means "the key `n` semitones above that low C". A renderer +// drawing ~2–3 octaves (MiniPiano, D-10) maps note value `n` to key index `n` +// from the left edge of its low C. Examples in C (rootPc 0): +// +// C-major root-position triad → notes [0, 4, 7] (C E G, low octave) +// Cmaj7 root-position → notes [0, 4, 7, 11] (C E G B) +// Cmaj7 shell (R-3-7) → notes [0, 4, 11] +// Dm7 rootless A (3-5-7-9) → notes [17, 21, 24, 28] (F A C E, lifted a register) +// +// Rootless and shell voicings deliberately sit HIGHER than root position so they +// read as right-hand comping shapes — the resolver lifts them by one octave (+12) +// from the root anchor by default (the LH would supply the root). `bass` is the +// lowest sounding note of whatever was produced, so a renderer can mark the LH +// anchor distinctly. All note values are ≥ 0 and span ≤ ~3 octaves (0–36), so a +// keyboard of 3 octaves always contains every voicing this module emits. +// +// `pcs` are the pitch classes (0–11) actually sounding — for highlighting which +// keys light up in every octave, and for validation. +// +// ─── The tone-set contract (what counts as a "correct" pc — READ for C-10) ───── +// +// For root-position and shell voicings, every pc is a strict chord tone: +// pcs ⊆ { (rootPc + i) mod 12 | i ∈ CHORD_TYPES[quality].intervals }, and the +// defining tones (the 3rd, and the true 7th when the chord has one) are present. +// +// Rootless A/B voicings additionally carry the **9th** — the defining colour of +// a rootless jazz voicing (the "9" in 3-5-7-9 / 7-9-3-5). The 9th (a 2nd up an +// octave, pc = rootPc+2) is an EXTENSION, not a member of the bare triad/7th +// interval set, so it is a *deliberate, consonant* added tone — never a "wrong +// note". The full legitimate tone set for a quality is therefore the chord tones +// PLUS the natural 9th, exported as `voicingToneSet(rootPc, quality, style)` so a +// validator (C-10) checks `pcs ⊆ voicingToneSet(...)` rather than the bare triad. +// No voicing this module emits ever contains a pc outside that set. + +import { CHORD_TYPES, guideTones } from './theory' + +// ─── Internals ──────────────────────────────────────────────────────────────── + +const mod12 = (n) => ((n % 12) + 12) % 12 + +// Resolve a quality to its CHORD_TYPES entry, defaulting to maj for anything odd. +function chordType(quality) { + return CHORD_TYPES[quality] ?? CHORD_TYPES.maj +} + +// The chord's interval set (semitones from root), e.g. dom7 → [0,4,7,10]. +function chordIntervals(quality) { + return chordType(quality).intervals +} + +// Does this quality carry a TRUE 7th (a minor or major 7th — interval 10 or 11)? +// This mirrors theory.guideTones' honest `hasSeventh` (NOT "length >= 4": add9 +// [0,2,4,7] and maj6/min6 [0,4,7,9] are 4-tone chords with no real 7th). +function seventhInterval(quality) { + return chordIntervals(quality).find((i) => i === 10 || i === 11) +} + +// The 3rd interval. CHORD_TYPES index 1 is the 3rd for triad/7th qualities, but +// sus2 [0,2,7] / sus4 [0,5,7] have a 2nd/4th there instead — there is no 3rd, so +// we report the suspension tone as the "3rd-slot" colour (what the voicing uses +// where a 3rd would go). add9's index 1 is its 9th (pc+2), so we pick the actual +// major/minor 3rd (interval 3 or 4) when the set has one, else the index-1 tone. +function thirdInterval(quality) { + const ints = chordIntervals(quality) + const real = ints.find((i) => i === 3 || i === 4) // minor or major 3rd + if (real !== undefined) return real + return ints[1] // sus2 → 2, sus4 → 5 (the suspension stands in for the 3rd) +} + +// The 5th interval the chord actually contains: perfect 5th (7) when present, +// else the altered 5th (♭5=6 for dim/dim7/half_dim, #5=8 for aug). +function fifthInterval(quality) { + const ints = chordIntervals(quality) + if (ints.includes(7)) return 7 + if (ints.includes(6)) return 6 + if (ints.includes(8)) return 8 + return 7 +} + +// The 9th colour for rootless voicings: a 9th is a 2nd up an octave (interval 2 +// or 14). add9 already lists 2; otherwise we synthesize the natural 9th (14) +// from the root — rootless A/B want a 9th on top whether or not the chord names +// one. (For min6/maj6 we use the 6th as the rootless "colour" instead — see +// rootlessColours below.) +const NINTH = 14 + +// ─── Voicing builders ───────────────────────────────────────────────────────── +// +// Each builder returns { pcs, intervalsFromRoot, style, label } where +// `intervalsFromRoot` are signed semitone offsets from the chord root in the +// builder's own register (root position low; shell/rootless lifted +12). The +// register is then optimized for voice-leading in placeVoicing(). + +// Root position: every chord tone stacked from the root, low register (root @ 0). +function buildRoot(rootPc, quality) { + const ints = chordIntervals(quality) + return { + intervalsFromRoot: [...ints], + pcs: ints.map((i) => mod12(rootPc + i)), + style: 'root', + label: `root position (${labelFor(ints)})`, + } +} + +// Shell: the skeleton a comping pianist plays — root + 3rd + 7th for a 7th chord, +// root + 3rd + 5th for a triad (no 7th to thin to). Lifted +12 (RH shape; LH +// would double the root). For sus chords the "3rd" is the suspension tone. +function buildShell(rootPc, quality) { + const seventh = seventhInterval(quality) + const third = thirdInterval(quality) + let ints + let kind + if (seventh !== undefined) { + ints = [0, third, seventh] // R-3-7 (the true shell) + kind = 'R-3-7' + } else { + ints = [0, third, fifthInterval(quality)] // triad shell R-3-5 + kind = 'R-3-5' + } + const lifted = ints.map((i) => i + 12) + return { + intervalsFromRoot: lifted, + pcs: ints.map((i) => mod12(rootPc + i)), + style: 'shell', + label: `shell (${kind})`, + } +} + +// The 3-5-7-9 / 7-9-3-5 colour tones for a rootless voicing. +// For a true-7th chord: use 3, 5, the real 7th, and the 9th (14). +// For maj6/min6 (no 7th): treat the 6th (9) as the upper colour in the 7-slot — +// GOAL: "min6/maj6 treat the 6th as the 13th-ish color or fall back gracefully". +// For triads/sus/add9 (no 7th): there is no rootless 3-5-7-9 — degrade to a +// 3-5-9 colour shape and SAY SO in the label. +function rootlessColours(quality) { + const third = thirdInterval(quality) + const fifth = fifthInterval(quality) + const seventh = seventhInterval(quality) + const ints = chordIntervals(quality) + + if (seventh !== undefined) { + return { third, fifth, seventh, ninth: NINTH, full: true, note: '3-5-7-9' } + } + // maj6 / min6 — the 6th (interval 9) stands in for the 7-slot colour. + if (ints.includes(9) && (quality === 'maj6' || quality === 'min6')) { + return { third, fifth, seventh: 9, ninth: NINTH, full: true, note: '3-5-6-9' } + } + // triad / sus / add9 — no 7th and no 6th: a 3-5-9 colour shape (no 7-slot). + return { third, fifth, seventh: null, ninth: NINTH, full: false, note: '3-5-9' } +} + +// Rootless A — voiced low-to-high 3-5-7-9 (the standard "A" form). Lifted +12. +function buildRootlessA(rootPc, quality) { + const c = rootlessColours(quality) + const ints = (c.full ? [c.third, c.fifth, c.seventh, c.ninth] : [c.third, c.fifth, c.ninth]) + .map((i) => i + 12) + ints.sort((a, b) => a - b) + return { + intervalsFromRoot: ints, + pcs: ints.map((i) => mod12(rootPc + i)), + style: 'rootlessA', + label: c.full + ? `rootless A (${c.note})` + : `rootless A — no 7th, ${c.note} colour`, + } +} + +// Rootless B — voiced 7-9-3-5: the SAME four colour tones, but with the 7th & 9th +// in the lower octave and the 3rd & 5th in the upper, so the shape inverts. We +// build it by placing 7 & 9 at +12 and 3 & 5 at +24 (a register higher), which +// is what makes A and B alternate cleanly down a chain. Falls back like A. +function buildRootlessB(rootPc, quality) { + const c = rootlessColours(quality) + let ints + if (c.full) { + ints = [c.seventh + 12, c.ninth + 12, c.third + 24, c.fifth + 24] + } else { + // no true 7th: 9 below, 3 & 5 above — a 9-3-5 inversion of the A fallback. + ints = [c.ninth + 12, c.third + 24, c.fifth + 24] + } + ints.sort((a, b) => a - b) + return { + intervalsFromRoot: ints, + pcs: ints.map((i) => mod12(rootPc + i)), + style: 'rootlessB', + label: c.full + ? `rootless B (7-9-3-5)` + : `rootless B — no 7th, 9-3-5 colour`, + } +} + +// Human label for a root-position interval set, e.g. [0,4,7,10] → "R-3-5-♭7". +function labelFor(ints) { + const NAME = { + 0: 'R', 1: '♭9', 2: '9', 3: '♭3', 4: '3', 5: '4', 6: '♭5', + 7: '5', 8: '♯5', 9: '6', 10: '♭7', 11: '7', + } + return ints.map((i) => NAME[mod12(i)] ?? `${i}`).join('-') +} + +// ─── Voice-leading placement ────────────────────────────────────────────────── +// +// Given a voicing's pitch classes and a `prev` notes array, choose the absolute +// register (which octave each pc lands in) that MINIMIZES total voice-leading +// movement from prev — the sum, over each voice, of the nearest-semitone +// distance to its closest prev note. +// +// ALGORITHM (documented): +// 1. The builder gives `intervalsFromRoot` — a register-anchored shape. We keep +// that internal voicing SHAPE (its inversion/spread) intact, and only slide +// the whole shape up or down by whole octaves to sit nearest to prev. This +// preserves the chosen voicing identity (a shell stays a shell) while still +// voice-leading the progression smoothly. +// 2. For each candidate octave offset k ∈ {-2..+2} (×12), shift every note by +// 12k, keep it inside the renderable window [0, 36], and score it: for each +// shifted note find min |note − p| over all p in prev (nearest-voice +// distance), and sum. Lowest total wins. Ties → the offset closest to the +// builder's default register (k nearest 0), keeping output stable/low. +// 3. With no prev, we don't shift — the builder's default register is used, so +// the FIRST chord of a progression voices at a predictable height and every +// subsequent chord threads from it. +// +// This is per-shape octave optimization, not per-voice re-inversion: it's +// deterministic, cheap, and gives the smooth "shape glides to meet the last +// chord" motion the design wants without scrambling the voicing's character. + +function placeVoicing(base, rootPc, prev) { + // Absolute notes in the builder's default register (anchor: low C = 0). + const def = base.intervalsFromRoot.map((i) => mod12(rootPc) + i) + + if (!Array.isArray(prev) || prev.length === 0) { + return clampWindow(def) + } + + let bestNotes = null + let bestCost = Infinity + let bestK = 0 + for (let k = -2; k <= 2; k++) { + const shifted = def.map((n) => n + 12 * k) + if (shifted.some((n) => n < 0 || n > 36)) continue + let cost = 0 + for (const n of shifted) { + let nearest = Infinity + for (const p of prev) nearest = Math.min(nearest, Math.abs(n - p)) + cost += nearest + } + // Prefer lower cost; tie-break toward the default register (smaller |k|). + if (cost < bestCost || (cost === bestCost && Math.abs(k) < Math.abs(bestK))) { + bestCost = cost + bestNotes = shifted + bestK = k + } + } + return clampWindow(bestNotes ?? def) +} + +// Keep a voicing inside the renderable window [0, 36] (≤3 octaves) by sliding it +// by whole octaves if it pokes out — never reshapes, only translates. +function clampWindow(notes) { + let out = [...notes] + while (Math.min(...out) < 0) out = out.map((n) => n + 12) + while (Math.max(...out) > 36) out = out.map((n) => n - 12) + return out.sort((a, b) => a - b) +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +const BUILDERS = { + root: buildRoot, + shell: buildShell, + rootlessA: buildRootlessA, + rootlessB: buildRootlessB, +} + +// Default voicing style: shells for 7th chords (the comping skeleton), root +// position for plain triads / sus / add9 / 6 chords (nothing to thin to a shell). +function defaultStyle(quality) { + return seventhInterval(quality) !== undefined ? 'shell' : 'root' +} + +/** + * pianoVoicing({ rootPc, quality }, opts?) → voicing + * + * Generates a single piano voicing for a chord. + * + * @param {{rootPc:number, quality:string}} chord + * rootPc 0–11; quality is a CHORD_TYPES key. + * @param {{ style?: 'root'|'shell'|'rootlessA'|'rootlessB', prev?: number[] }} [opts] + * style — force a voicing type (else default: shell for 7ths, root else). + * prev — the previous voicing's `notes`; when given, the register is + * chosen to minimize total voice-leading movement from it. + * + * @returns {{ + * notes: number[], // absolute key positions (0 = C of low octave; see header) + * pcs: number[], // pitch classes 0–11 sounding (subset of chord tones) + * bass: number, // lowest sounding note (LH anchor) + * style: string, // 'root' | 'shell' | 'rootlessA' | 'rootlessB' + * label: string, // e.g. "rootless A (3-5-7-9)" + * }} + * + * Inline sanity (verified): + * pianoVoicing({rootPc:0, quality:'maj7'}, {style:'shell'}).pcs → {0,4,11} (C-E-B) + * pianoVoicing({rootPc:2, quality:'min7'}, {style:'rootlessA'}).pcs → {5,9,0,4} = F-A-C-E + * pianoVoicing({rootPc:7, quality:'dom7'}, {style:'shell'}).pcs → {7,11,5} (G-B-F) + * pianoVoicing({rootPc:9, quality:'aug'}).pcs → {9,1,5} (A-C♯-F) + * pianoVoicing({rootPc:0, quality:'sus4'}).pcs → {0,5,7} (C-F-G) + * pianoVoicing({rootPc:0, quality:'add9'}).pcs → {0,2,4,7} (C-D-E-G) + * A ii–V–I threaded with `prev` (Dm7→G7→Cmaj7, all shells) keeps each chord's + * octave register near the last → small total semitone motion (voice-led). + */ +export function pianoVoicing(chord, opts = {}) { + const rootPc = mod12(chord?.rootPc ?? 0) + const quality = CHORD_TYPES[chord?.quality] ? chord.quality : 'maj' + const style = BUILDERS[opts.style] ? opts.style : defaultStyle(quality) + + const base = BUILDERS[style](rootPc, quality) + const notes = placeVoicing(base, rootPc, opts.prev) + + // pcs in the voiced order (low→high), deduped — what keys light up. + const pcs = [...new Set(notes.map((n) => mod12(n)))] + + return { + notes, + pcs, + bass: Math.min(...notes), + style, + label: base.label, + } +} + +/** + * pianoVoicingChain(chords, opts?) → voicing[] + * + * Convenience: voice a whole progression with voice-leading threading. Each + * chord after the first is placed to minimize movement from the previous chord's + * `notes`, so a ii–V–I (or any loop) glides smoothly. `chords` is an array of + * { rootPc, quality }. `opts.style` (optional) forces one style for the chain; + * otherwise each chord uses its own default. Returns the voicings in order. + * + * D-10/L-11 can call this once per loop to lay out a synced row of MiniPianos. + */ +export function pianoVoicingChain(chords, opts = {}) { + if (!Array.isArray(chords)) return [] + const out = [] + let prev = null + for (const chord of chords) { + const v = pianoVoicing(chord, { style: opts.style, prev }) + out.push(v) + prev = v.notes + } + return out +} + +// Re-export the chord-model helper the resolver leans on, so a consumer can ask +// "does this chord have a true 7th?" without re-importing theory (handy for +// MiniPiano deciding whether to offer rootless A/B toggles). +export function hasTrueSeventh(quality) { + return seventhInterval(quality) !== undefined +} + +/** + * voicingToneSet({ rootPc, quality }, style?) → Set of legal pitch classes. + * + * The complete set of pcs a voicing of this chord may legitimately sound — the + * correctness reference for validation (C-10). It is the chord's strict tones + * (rootPc + each CHORD_TYPES interval); for rootless styles it ALSO includes the + * natural 9th (rootPc+2) and, for maj6/min6, the 6th (already a chord tone) used + * as the upper colour. Any `pianoVoicing(...).pcs` is guaranteed ⊆ this set. + * + * Pass `style` to scope it (root/shell never add the 9th); omit `style` to get + * the widest legal set (tones + 9th) — handy as a one-shot "no wrong notes" gate. + */ +export function voicingToneSet({ rootPc, quality } = {}, style) { + const r = mod12(rootPc ?? 0) + const q = CHORD_TYPES[quality] ? quality : 'maj' + const set = new Set(chordIntervals(q).map((i) => mod12(r + i))) + const rootless = style === 'rootlessA' || style === 'rootlessB' || style === undefined + if (rootless) set.add(mod12(r + 2)) // the rootless 9th extension + return set +} + +// guideTones is re-exported so a renderer can co-highlight the 3rd/7th targets +// on the same keyboard it draws the voicing on, from one import. +export { guideTones }