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])
|
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 ─────────────────────────────────────────────────
|
// ─── Progression suggestions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getSuggestedProgressions(root, mode) {
|
export function getSuggestedProgressions(root, mode) {
|
||||||
|
|||||||
Reference in New Issue
Block a user