char-slop/chordpad.vibed.software
git clone https://git.t4t.associates/char-slop/chordpad.vibed.software
3c7bc6e
main
1import { describeTones } from "./describe.ts" ; 2import { type Key , MODES , type ModeId , modesFrom } from "./key.ts" ; 3import { 4accidentalValue , 5formatNote , 6mod , 7parseNote , 8type Pc , 9pcOf , 10type Spelled , 11spellDegree , 12spellPc , 13} from "./pitch.ts" ; 14 15export type Chord = { 16root :Spelled ; 17/** semitones above the root: sorted, unique, within one octave, starting with 0 */ 18tones :number []; 19/** only present when it differs from the root */ 20bass ?:Spelled ; 21suffix :string ; 22}; 23 24export const ROMANS = [ "I" , "II" , "III" , "IV" , "V" , "VI" , "VII" ]; 25const ROMAN_RE = / ^([b#♭♯]*)(VII|VI|V|IV|III|II|I|vii|vi|v|iv|iii|ii|i) / ; 26const DEGREE_RE = / ^([b#♭♯]?)([1-7])(7?)$ / ; 27 28/** whether a symbol is written relative to the key (a roman numeral or scale degree) */ 29export const isKeyRelative = ( symbol :string ) :boolean => 30ROMAN_RE . test ( symbol . trim ()) || / ^[b#♭♯]?[1-7] / . test ( symbol . trim ()); 31 32export const normalizeTones = ( tones :Iterable < number >) :number [] => 33[ ...new Set ([ ...tones ]. map ( t => mod ( t , 12 )))]. sort (( a , b ) => a - b ); 34 35const sameList = ( a :readonly number [], b :readonly number []) => 36a . length === b . length && a . every (( x , i ) => x === b [ i ]); 37 38function makeChord ( root :Spelled , tones :Iterable < number >, typedSuffix = "" ) :Chord { 39const normal = normalizeTones ( tones ); 40// "alt" is shorthand no spelled-out name improves on 41const suffix = typedSuffix . includes ( "alt" ) ?typedSuffix :describeTones ( normal )?. suffix ; 42return { root, tones :normal , suffix :suffix ?? typedSuffix }; 43} 44 45/** a chord from its root and the semitones above it, if the shape has a name */ 46export function chordFrom ( root :Spelled , tones :number [], bass ?:Spelled ) :Chord | undefined { 47if ( ! describeTones ( tones )) return undefined ; 48const chord = makeChord ( root , tones ); 49return bass ?withBass ( chord , bass ) :chord ; 50} 51 52function withBass ( chord :Chord , bass :Spelled ) :Chord { 53return pcOf ( bass ) === pcOf ( chord . root ) ?chord :{ ...chord , bass}; 54} 55 56/** absolute pitch classes, including the bass */ 57export function chordPcs ( chord :Chord ) :Pc [] { 58const root = pcOf ( chord . root ); 59const pcs = chord . tones . map ( t => mod ( root + t , 12 )); 60if ( chord . bass ) pcs . push ( pcOf ( chord . bass )); 61return normalizeTones ( pcs ); 62} 63 64export const prettySymbol = ( s :string ) :string => 65s . replace ( / b(?=\d) / g , "♭" ). replace ( / # / g , "♯" ); 66 67/** in a form `parseChord` reads back */ 68export const chordSymbol = ( chord :Chord ) :string => 69formatNote ( chord . root , true ) + 70chord . suffix + 71( chord . bass ?"/" + formatNote ( chord . bass , true ) :"" ); 72 73export const chordName = ( chord :Chord ) :string => 74formatNote ( chord . root ) + 75prettySymbol ( chord . suffix ) + 76( chord . bass ?"/" + formatNote ( chord . bass ) :"" ); 77 78const EXTENSION_SEMIS :Record < string , number > = { 79"2" :2 , 80"4" :5 , 81"5" :7 , 82"6" :9 , 83"9" :2 , 84"11" :5 , 85"13" :9 , 86}; 87 88function parseSuffix ( src :string , minor :boolean ) :number [] | undefined { 89let s = src . replace ( / [()\s,] / g , "" ); 90const eat = ( re :RegExp ) => { 91const m = re . exec ( s ); 92if ( m ) s = s . slice ( m [ 0 ]. length ); 93return m ?? undefined ; 94}; 95 96let third :number | undefined = minor ?3 :4 ; 97let fifth :number | undefined = 7 ; 98let seventh :number | undefined ; 99let major7 = false ; 100// a bare triangle means maj7, unlike a bare "maj" or "M" 101let triangle = false ; 102const eatMajor = () => { 103const m = eat ( / ^(maj|Maj|MAJ|M|Δ|\^) / ); 104if ( m ) [ major7 , triangle ] = [ true , / [Δ^] / . test ( m [ 0 ])]; 105}; 106const ext = new Set < number >(); 107 108if ( eat ( / ^ø7? / )) [ third , fifth , seventh ] = [ 3 , 6 , 10 ]; 109else if ( eat ( / ^(dim|°|o(?!mit)) / )) { 110[ third , fifth ] = [ 3 , 6 ]; 111if ( eat ( / ^7 / )) seventh = 9 ; 112} else if ( eat ( / ^(aug|\+) / )) { 113fifth = 8 ; 114eatMajor (); 115} else if ( eat ( / ^(min|mi|m|-)(?!aj) / )) { 116third = 3 ; 117eatMajor (); 118} else eatMajor (); 119 120const num = eat ( / ^(6\/9|69|13|11|9|7|6|5) / )?.[ 0 ]; 121const extensions :Record < string , number []> = { "9" :[ 2 ], "11" :[ 2 , 5 ], "13" :[ 2 , 9 ] }; 122if ( num === "5" ) third = undefined ; 123else if ( num === "6" ) ext . add ( 9 ); 124else if ( num === "6/9" || num === "69" ) ext . add ( 9 ). add ( 2 ); 125else if ( num ) { 126seventh ??= major7 ?11 :10 ; 127for ( const t of extensions [ num ] ?? []) ext . add ( t ); 128} else if ( triangle ) seventh = 11 ; 129 130while ( s ) { 131let m ; 132if (( m = eat ( / ^sus([24])? / ))) third = m [ 1 ] === "2" ?2 :5 ; 133else if (( m = eat ( / ^add([#♯b♭]?)(13|11|9|6|4|2) / ))) { 134ext . add ( EXTENSION_SEMIS [ m [ 2 ]] + accidentalValue ( m [ 1 ])); 135} else if (( m = eat ( / ^([#♯b♭+-])(13|11|9|5) / ))) { 136const semis = EXTENSION_SEMIS [ m [ 2 ]] + ( "#♯+" . includes ( m [ 1 ]) ?1 :- 1 ); 137if ( m [ 2 ] === "5" ) fifth = semis ; 138else { 139ext . delete ( EXTENSION_SEMIS [ m [ 2 ]]); 140ext . add ( semis ); 141} 142} else if ( eat ( / ^alt / )) { 143seventh ??= 10 ; 144fifth = undefined ; 145for ( const t of [ 1 , 3 , 6 , 8 ]) ext . add ( t ); 146} else if (( m = eat ( / ^(no|omit)([35]) / ))) { 147if ( m [ 2 ] === "3" ) third = undefined ; 148else fifth = undefined ; 149} else return undefined ; 150} 151 152return normalizeTones ( 153[ 0 , third , fifth , seventh , ...ext ]. filter (( t ) :t isnumber => t !== undefined ), 154); 155} 156 157/** the diatonic triad (or seventh chord) of `mode` built on `degree` (0-based), spelled in `key` */ 158export function degreeChord ( key :Key , mode :ModeId , degree :number , seventh :boolean ) :Chord { 159const steps = MODES [ mode ]. steps ; 160const at = ( k :number ) => steps [( degree + k ) % 7 ] + 12 * Math . floor (( degree + k ) / 7 ); 161const root = spellDegree ( key . tonic , degree , steps [ degree ]); 162const stack = seventh ?[ 0 , 2 , 4 , 6 ] :[ 0 , 2 , 4 ]; 163return makeChord ( 164root , 165stack . map ( k => at ( k ) - steps [ degree ]), 166); 167} 168 169/** 170* parse a chord symbol. accepts letter names (`Cm7b5`, `F#/C#`), and roman numerals (`III`, 171* `bVI`, `V7/V`) and scale degrees (`4`, `57`, `b7`) counted in the key's mode: in A minor, `III` 172* is C and `#vi` is F♯m. a secondary's roman counts in the major scale of its target 173*/ 174export function parseChord ( text :string , key :Key ) :Chord | undefined { 175const src = text . trim (); 176if ( ! src ) return undefined ; 177 178const slash = src . lastIndexOf ( "/" ); 179if ( slash > 0 ) { 180const left = src . slice ( 0 , slash ); 181const right = src . slice ( slash + 1 ); 182const bass = parseNote ( right ); 183if ( bass && bass . rest === "" ) { 184const chord = parseChord ( left , key ); 185return chord && withBass ( chord , bass . note ); 186} 187if ( ROMAN_RE . test ( right ) || DEGREE_RE . test ( right )) { 188const target = parseChord ( right , key ); 189return target && parseChord ( left , { tonic :target . root , mode :"ionian" }); 190} 191} 192 193const roman = ROMAN_RE . exec ( src ); 194if ( roman ) { 195const degree = ROMANS . indexOf ( roman [ 2 ]. toUpperCase ()); 196const minor = roman [ 2 ] !== roman [ 2 ]. toUpperCase (); 197const semis = MODES [ key . mode ]. steps [ degree ] + accidentalValue ( roman [ 1 ]); 198const root = spellDegree ( key . tonic , degree , semis ); 199const rest = src . slice ( roman [ 0 ]. length ); 200const tones = parseSuffix ( rest , minor ); 201const fallback = ( minor && ! /^(dim|°|o|ø) / . test ( rest ) ?"m" :"" ) + rest ; 202return tones && makeChord ( root , tones , fallback ); 203} 204 205const scaleDegree = DEGREE_RE . exec ( src ); 206if ( scaleDegree ) { 207const degree = Number ( scaleDegree [ 2 ]) - 1 ; 208const semis = MODES [ key . mode ]. steps [ degree ] + accidentalValue ( scaleDegree [ 1 ]); 209// a bare degree is diatonic; an altered one borrows from whichever mode has it 210const mode = scaleDegree [ 1 ] 211 ?modesFrom ( key . mode ). find ( m => MODES [ m ]. steps [ degree ] === semis ) 212 :key . mode ; 213return mode && degreeChord ( key , mode , degree , scaleDegree [ 3 ] === "7" ); 214} 215 216const letter = parseNote ( src ); 217if ( ! letter ) return undefined ; 218const tones = parseSuffix ( letter . rest , false ); 219return tones && makeChord ( letter . note , tones , letter . rest ); 220} 221 222const INVERTED = 1 ; 223const FOREIGN_BASS = 1.3 ; 224 225/** 226* readings of a set of midi notes, likeliest first. each note can be the root, with the lowest 227* as an inversion's bass; or the lowest can be a separate bass under a chord made of the rest 228*/ 229export function detectChords ( midis :readonly number [], key :Key ) :Chord [] { 230if ( ! midis . length ) return []; 231const sorted = [ ...midis ]. sort (( a , b ) => a - b ); 232const bass = mod ( sorted [ 0 ], 12 ); 233const pcs = normalizeTones ( sorted ); 234const upper = normalizeTones ( sorted . slice ( 1 )); 235 236const readings :{ cost :number ; root :Pc ; tones :number [] }[] = []; 237const consider = ( root :Pc , notes :Pc [], penalty :number ) => { 238const tones = notes . map ( p => p - root ); 239const described = describeTones ( tones ); 240if ( described ) readings . push ({ cost :described . cost + penalty , root, tones}); 241}; 242for ( const root of pcs ) consider ( root , pcs , root === bass ?0 :INVERTED ); 243if ( ! upper . includes ( bass ) && upper . length >= 3 ) { 244for ( const root of upper ) consider ( root , upper , FOREIGN_BASS ); 245} 246 247const spelledBass = spellPc ( bass , key . tonic ); 248return readings 249. sort (( a , b ) => a . cost - b . cost ) 250. map ( r => withBass ( makeChord ( spellPc ( r . root , key . tonic ), r . tones ), spelledBass )); 251} 252 253export const detectChord = ( midis :readonly number [], key :Key ) :Chord | undefined => 254detectChords ( midis , key )[ 0 ]; 255 256/** whether the notes sound the chord: same pitch classes, same bass */ 257export function chordMatches ( chord :Chord , midis :readonly number []) :boolean { 258if ( ! midis . length ) return false ; 259const bass = chord . bass ?? chord . root ; 260return ( 261sameList ( chordPcs ( chord ), normalizeTones ( midis )) && 262mod ( Math . min ( ...midis ), 12 ) === pcOf ( bass ) 263); 264}