Files
JamBuddy/src/lib/piano.js
T
vadimwit 4727645bb9 fix(piano): dedupe duplicate absolute notes in pianoVoicing (task L-13)
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>
2026-06-16 11:00:43 +01:00

418 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ─── 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 011 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 ~23 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 (036), so a
// keyboard of 3 octaves always contains every voicing this module emits.
//
// `pcs` are the pitch classes (011) 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 011; 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 011 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 iiVI 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 iiVI (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 }