// ─── 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 placed = placeVoicing(base, rootPc, opts.prev) // Dedupe ABSOLUTE notes: two voices can collide on one key (e.g. a forced // rootless sus2 lands the suspended-2nd "3rd-slot" tone and the synthesized // 9th on the same absolute key, pc 2 → notes [26,26,31]). A duplicate value // would stack two highlights on one key in the renderer, so we drop the // redundant collided note here — the single point every style/quality flows // through — keeping the first occurrence. A no-op when nothing collides. const notes = [...new Set(placed)] // 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 }