4727645bb9
A forced rootless sus2 collided the suspended-2nd and synthesized 9th onto one key (notes [26,26,31]). Dedupe at the single funnel point ([...new Set(placed)]); pcs/bass derived from the deduped array. No-op on default/chain path (L-11 render unaffected). Critic: Set merges only identical absolute integers (same key) so no needed note is lost. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
418 lines
19 KiB
JavaScript
418 lines
19 KiB
JavaScript
// ─── 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<number> 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 }
|