char-slop/chordpad.vibed.software
git clone https://git.t4t.associates/char-slop/chordpad.vibed.software
66d16f5
main
1import { analyze , type Analysis , romanOf } from "../theory/analysis.ts" ; 2import { 3type Chord , 4chordMatches , 5chordName , 6chordPcs , 7chordSymbol , 8detectChord , 9detectChords , 10isKeyRelative , 11parseChord , 12} from "../theory/chord.ts" ; 13import type { Key } from "../theory/key.ts" ; 14import { formatNote , midiName , pcOf } from "../theory/pitch.ts" ; 15import { voiceChord } from "../theory/voicing.ts" ; 16import { type ChordEvent , type ChordTrack , type Project , projectKey } from "./model.ts" ; 17 18export type VoicedChord = { 19event :ChordEvent ; 20notes :number []; 21/** what's sounding: the typed chord, unless hand-voiced notes say otherwise */ 22chord ?:Chord ; 23analysis ?:Analysis ; 24label :string ; 25/** other readings of hand-voiced notes, likeliest first */ 26alternatives :string []; 27pinned :boolean ; 28}; 29 30const cache = new WeakMap < ChordTrack , { key :string ; voiced :VoicedChord [] }>(); 31 32/** the notes of a chord that a track plays: all of them, or without the lowest when its bass is off */ 33export const sounding = ( track :ChordTrack , notes :readonly number []) :number [] => 34track . bass ?[ ...notes ] :notes . filter ( n => n !== Math . min ( ...notes )); 35 36/** resolve every chord to notes, auto-voicing each unpinned chord from the one before it */ 37export function voiceTrack ( track :ChordTrack , key :Key ) :VoicedChord [] { 38const keyId = JSON . stringify ( key ); 39const cached = cache . get ( track ); 40if ( cached ?. key === keyId ) return cached . voiced ; 41 42let prev :number [] | undefined ; 43const voiced = track . chords . map (( event ) :VoicedChord => { 44const typed = event . symbol !== undefined ?parseChord ( event . symbol , key ) :undefined ; 45const notes = event . notes ?? ( typed ?voiceChord ( typed , prev ) :[]); 46const readings = 47event . notes && ! ( typed && chordMatches ( typed , notes )) 48 ?detectChords ( notes , key ) 49 :undefined ; 50const chord = readings ?readings [ 0 ] :typed ; 51if ( notes . length ) prev = notes ; 52const label = chord 53 ?chordName ( chord ) 54 :event . notes 55 ?notes . map ( n => midiName ( n , key . tonic )). join ( " " ) 56 :( event . symbol ?? "" ); 57return { 58 event, 59 notes, 60 chord, 61 label, 62alternatives :( readings ?? []). slice ( 1 , 4 ). map ( chordName ), 63pinned :!! event . notes , 64}; 65}); 66 67// with what follows each chord, to tell where a dominant leads 68voiced . forEach ( 69( v , i ) => ( v . analysis = v . chord && analyze ( v . chord , key , voiced [ i + 1 ]?. chord )), 70); 71cache . set ( track , { key :keyId , voiced}); 72return voiced ; 73} 74 75const sameChord = ( a :Chord , b :Chord ) => 76pcOf ( a . root ) === pcOf ( b . root ) && 77pcOf ( a . bass ?? a . root ) === pcOf ( b . bass ?? b . root ) && 78chordPcs ( a ). join () === chordPcs ( b ). join (); 79 80/** 81* how to write a chord relative to a key: as a scale degree if it was one and there is one for 82* it, otherwise as a roman numeral, and failing that by its letter name 83*/ 84function relativeSymbol ( chord :Chord , key :Key , wasDegree :boolean ) :string { 85const degrees = [ 1 , 2 , 3 , 4 , 5 , 6 , 7 ]. flatMap ( d => [ ` ${ d } ` , ` ${ d } 7` ]); 86const bass = chord . bass ?"/" + formatNote ( chord . bass , true ) :"" ; 87const candidates = [ ...( wasDegree ?degrees :[]), romanOf ( chord , key ) + bass ]; 88const reads = ( s :string ) => { 89const back = parseChord ( s , key ); 90return back !== undefined && sameChord ( back , chord ); 91}; 92return candidates . find ( reads ) ?? chordSymbol ( chord ); 93} 94 95/** 96* change a project's key without changing what it plays: chords written relative to the key are 97* rewritten to name the same chords in the new one, so every voicing stays as it was 98*/ 99export function rekey ( p :Project , key :Project [ "key" ]) :void { 100const from = projectKey ( p ); 101p . key = key ; 102const to = projectKey ( p ); 103for ( const track of p . tracks ) { 104if ( track . kind !== "chords" ) continue ; 105for ( const c of track . chords ) 106if ( c . symbol !== undefined ) c . symbol = respell ( c . symbol , from , to ); 107} 108} 109 110/** a symbol written relative to one key, rewritten to name the same chord in another */ 111export function respell ( symbol :string , from :Key , to :Key ) :string { 112if ( ! isKeyRelative ( symbol )) return symbol ; 113const chord = parseChord ( symbol , from ); 114return chord ?relativeSymbol ( chord , to , / ^[b#♭♯]?[1-7] / . test ( symbol . trim ())) :symbol ; 115} 116 117const sameNotes = ( a :readonly number [], b :readonly number []) => 118a . length === b . length && 119[ ...a ]. sort (( x , y ) => x - y ). join () === [ ...b ]. sort (( x , y ) => x - y ). join (); 120 121/** 122* unpin every chord whose hand-set notes are just what it'd be voiced as anyway, so it goes back 123* to following its neighbours. a chord with no symbol takes the name its notes are read as. this 124* changes nothing that sounds: an unpinned chord gets the same notes, so the one after it is voiced 125* the same too. tracks are only replaced where something changed, keeping their voicing caches 126*/ 127export function unpinRedundant ( p :Project ) :Project { 128const key = projectKey ( p ); 129let changed = false ; 130const tracks = p . tracks . map ( track => { 131if ( track . kind !== "chords" ) return track ; 132let prev :number [] | undefined ; 133let trackChanged = false ; 134const chords = track . chords . map ( c => { 135const typed = c . symbol !== undefined ?parseChord ( c . symbol , key ) :undefined ; 136if ( ! c . notes ) { 137const notes = typed ?voiceChord ( typed , prev ) :[]; 138if ( notes . length ) prev = notes ; 139return c ; 140} 141const before = prev ; 142if ( c . notes . length ) prev = c . notes ; 143const chord = typed && chordMatches ( typed , c . notes ) ?typed :detectChord ( c . notes , key ); 144if ( ! chord || ! sameNotes ( voiceChord ( chord , before ), c . notes )) return c ; 145trackChanged = true ; 146const { notes :_ , ...unpinned } = c ; 147return typed === chord ?unpinned :{ ...unpinned , symbol :chordSymbol ( chord ) }; 148}); 149if ( ! trackChanged ) return track ; 150changed = true ; 151return { ...track , chords}; 152}); 153return changed ?{ ...p , tracks} :p ; 154}