feat(theory): guideTones, voiceLeadingPairs, soloScale for Roadmap (task L-01b)
Additive pure exports: guideTones(rootPc,quality) -> 3rd/7th pcs (triad fallback flagged hasSeventh:false); voiceLeadingPairs(a,b) -> nearest-tone 7->3 rails; soloScale(quality,mode) -> default solo scale (dom7->mixo, m7->dorian, maj7->ionian, half-dim->locrian, dom7-in-minor->phrygian dominant). Critic PASS: ii-V-I yields C->B and F->E rails; no existing export changed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -235,6 +235,157 @@ export function getChordsInKey(root, mode) {
|
||||
return scale.map((degree, i) => noteName(rootIdx + degree) + qualities[i])
|
||||
}
|
||||
|
||||
// ─── Jam Guide: derived improv theory (L-01b) ─────────────────────────────────
|
||||
//
|
||||
// Three additive, pure helpers that feed the Roadmap Jam Guide. They are
|
||||
// key-agnostic: chords are described as a pitch class (0–11) + a CHORD_TYPES
|
||||
// quality key (e.g. 'min7', 'dom7', 'maj7', 'half_dim'), exactly the shape the
|
||||
// KB progression data already uses (`degrees` + `qualities`). They reuse
|
||||
// CHORD_TYPES / getChordTones rather than re-deriving intervals.
|
||||
//
|
||||
// Chord arg shape: { root, quality } where root is a pitch class 0–11 and
|
||||
// quality is a CHORD_TYPES key. This matches the KB station model
|
||||
// ({ degrees[i], qualities[i] }) so a caller maps a station to a chord with no
|
||||
// string parsing.
|
||||
|
||||
// Internal: resolve a chord's pitch-class tone set from a CHORD_TYPES key.
|
||||
// Returns the intervals mapped to absolute pitch classes, preserving the
|
||||
// CHORD_TYPES interval order (index 0 = root, 1 = 3rd, last = 7th when present).
|
||||
function chordTonePcs(rootPc, quality) {
|
||||
const type = CHORD_TYPES[quality] ?? CHORD_TYPES.maj
|
||||
const r = ((rootPc % 12) + 12) % 12
|
||||
return type.intervals.map(i => (r + i) % 12)
|
||||
}
|
||||
|
||||
/**
|
||||
* guideTones(rootPc, quality) → { third, seventh, root }
|
||||
*
|
||||
* The guide tones a soloist targets: a chord's 3rd and 7th. By CHORD_TYPES
|
||||
* interval ordering, index 1 is always the 3rd and (for a 7th chord) the last
|
||||
* interval is the 7th. For triads with no 7th there is no real guide 7th, so we
|
||||
* fall back to the 5th (the next most stable anchor) and flag it via
|
||||
* `hasSeventh: false` so a caller can label it honestly ("5th", not "7th").
|
||||
*
|
||||
* Returns pitch classes (0–11) so the Roadmap TARGET lane can place dots in any
|
||||
* key. `root` is included as the third anchor the design's badges reference.
|
||||
*
|
||||
* Sanity (C major): guideTones(0,'maj7') → third 4 (E), seventh 11 (B).
|
||||
* guideTones(7,'dom7') → third 11 (B), seventh 5 (F).
|
||||
* guideTones(2,'min7') → third 5 (F), seventh 0 (C).
|
||||
*/
|
||||
export function guideTones(rootPc, quality) {
|
||||
const type = CHORD_TYPES[quality] ?? CHORD_TYPES.maj
|
||||
const r = ((rootPc % 12) + 12) % 12
|
||||
const ints = type.intervals
|
||||
const third = (r + ints[1]) % 12 // index 1 is always the 3rd
|
||||
const hasSeventh = ints.length >= 4 // CHORD_TYPES 7ths add a 4th tone
|
||||
// 7th when present, else the 5th as the secondary anchor (index 2 = the 5th
|
||||
// for every triad in CHORD_TYPES, which is what a triad soloist leans on).
|
||||
const seventh = (r + ints[hasSeventh ? ints.length - 1 : 2]) % 12
|
||||
return { third, seventh, root: r, hasSeventh }
|
||||
}
|
||||
|
||||
/**
|
||||
* voiceLeadingPairs(chordA, chordB) → [{ from, to, semitones }]
|
||||
*
|
||||
* The voice-leading rails between two adjacent stations. For each guide tone of
|
||||
* chordA (its 3rd and 7th) it finds the nearest tone of chordB (chordB's full
|
||||
* tone set) and returns the smallest signed semitone move (negative = falls,
|
||||
* positive = rises). Only rails moving ≤2 semitones are kept — that is the
|
||||
* "smooth voice leading" band; bigger leaps are not rails. A 0-semitone rail
|
||||
* (a held common tone) is kept so the design can draw "B holds → next loop".
|
||||
*
|
||||
* Each chord is { root, quality } (pitch class + CHORD_TYPES key).
|
||||
*
|
||||
* Sanity — ii–V–I in C (the gold-standard rails):
|
||||
* Dm7 → G7 : 7th of Dm7 (C=0) → 3rd of G7 (B=11) ⇒ { from:0, to:11, semitones:-1 }
|
||||
* G7 → Cmaj7: 7th of G7 (F=5) → 3rd of Cmaj7 (E=4) ⇒ { from:5, to:4, semitones:-1 }
|
||||
* i.e. the classic 7→3 falls a half-step, proving C→B and F→E.
|
||||
*/
|
||||
export function voiceLeadingPairs(chordA, chordB) {
|
||||
const a = guideTones(chordA.root, chordA.quality)
|
||||
const targets = chordTonePcs(chordB.root, chordB.quality)
|
||||
|
||||
// smallest signed interval from pc x to pc y, in range (-6, 6]
|
||||
const signedStep = (x, y) => {
|
||||
let d = (((y - x) % 12) + 12) % 12
|
||||
if (d > 6) d -= 12
|
||||
return d
|
||||
}
|
||||
|
||||
const rails = []
|
||||
for (const from of [a.seventh, a.third]) { // 7th first (the headline 7→3 rail)
|
||||
let best = null
|
||||
for (const to of targets) {
|
||||
const semitones = signedStep(from, to)
|
||||
if (Math.abs(semitones) > 2) continue // only smooth moves are rails
|
||||
if (best === null || Math.abs(semitones) < Math.abs(best.semitones)) {
|
||||
best = { from, to, semitones }
|
||||
}
|
||||
}
|
||||
if (best) rails.push(best)
|
||||
}
|
||||
return rails
|
||||
}
|
||||
|
||||
// Default solo scale per chord quality (used when a KB pack didn't author an
|
||||
// improv.scales entry for a degree). Maps a CHORD_TYPES key → a SCALES mode.
|
||||
// 'locrian' is named here even though it isn't in SCALES (the KB references it
|
||||
// for half-diminished); intervals are provided so a caller never has to look it
|
||||
// up in SCALES for the half_dim case.
|
||||
const SOLO_SCALE_BY_QUALITY = {
|
||||
maj: 'major',
|
||||
maj7: 'major', // Ionian; packs may upgrade to Lydian via improv.scales
|
||||
maj6: 'major',
|
||||
add9: 'major',
|
||||
dom7: 'mixolydian',
|
||||
min: 'dorian',
|
||||
min7: 'dorian',
|
||||
min6: 'dorian',
|
||||
half_dim: 'locrian',
|
||||
dim: 'diminished',
|
||||
dim7: 'diminished',
|
||||
aug: 'whole_tone',
|
||||
sus4: 'mixolydian',
|
||||
sus2: 'major',
|
||||
}
|
||||
|
||||
// Locrian isn't in SCALES (no diatonic degree uses it); supply its intervals so
|
||||
// soloScale can return a complete { name, intervals } for half-diminished.
|
||||
const LOCRIAN_INTERVALS = [0, 1, 3, 5, 6, 8, 10]
|
||||
|
||||
/**
|
||||
* soloScale(quality, mode) → { name, intervals }
|
||||
*
|
||||
* The computed default scale to solo over a chord of the given quality — the
|
||||
* fallback for packs that didn't author an improv.scales entry. Returns the
|
||||
* same shape callers already get from SCALES (a relative interval set) plus its
|
||||
* mode `name`, so the Roadmap SCALE lane can label it ("G mixolydian") and the
|
||||
* fretboard can offset the intervals against the chord root.
|
||||
*
|
||||
* `mode` (the song's key mode, e.g. 'major'/'minor') is an optional context
|
||||
* hint: a dominant chord in a minor key implies the ♭9 colour, so we nudge
|
||||
* dom7 → phrygian dominant there; otherwise it is ignored. This keeps the
|
||||
* default sensible without needing per-chord KB data.
|
||||
*
|
||||
* Sanity: soloScale('dom7') → { name:'mixolydian', intervals:[0,2,4,5,7,9,10] }
|
||||
* soloScale('min7') → { name:'dorian', intervals:[0,2,3,5,7,9,10] }
|
||||
* soloScale('maj7') → { name:'major', intervals:[0,2,4,5,7,9,11] }
|
||||
* soloScale('half_dim') → { name:'locrian', intervals:[0,1,3,5,6,8,10] }
|
||||
* soloScale('dom7','minor') → phrygian-dominant intervals (♭9 over the V)
|
||||
*/
|
||||
export function soloScale(quality, mode) {
|
||||
// Dominant in a minor key → Phrygian dominant (the ♭9/♭13 "V of i" sound).
|
||||
if (quality === 'dom7' && mode === 'minor') {
|
||||
return { name: 'phrygian_dominant', intervals: [0, 1, 4, 5, 7, 8, 10] }
|
||||
}
|
||||
const name = SOLO_SCALE_BY_QUALITY[quality] ?? 'major'
|
||||
const intervals = name === 'locrian'
|
||||
? LOCRIAN_INTERVALS
|
||||
: (SCALES[name] ?? SCALES.major)
|
||||
return { name, intervals }
|
||||
}
|
||||
|
||||
// ─── Progression suggestions ─────────────────────────────────────────────────
|
||||
|
||||
export function getSuggestedProgressions(root, mode) {
|
||||
|
||||
Reference in New Issue
Block a user