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:
vadimwit
2026-06-15 00:25:21 +01:00
parent 51358b2b56
commit d6fa7c9598
+151
View File
@@ -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 (011) + 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 011 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 (011) 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 — iiVI 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) {