char-slop/chordpad.vibed.software
git clone https://git.t4t.associates/char-slop/chordpad.vibed.software
3c7bc6e
main
1import type { Chord } from "./chord.ts" ; 2import { mod , pcOf } from "./pitch.ts" ; 3 4const UPPER_CENTER = 65 ; 5const BASS_CENTER = 41 ; 6 7/** distance between two voicings, pairing every note with its nearest neighbour on the other side */ 8function motion ( from :readonly number [], to :readonly number []) :number { 9const nearest = ( x :number , ys :readonly number []) => 10Math . min ( ...ys . map ( y => Math . abs ( x - y ))); 11return ( 12( to . reduce (( s , x ) => s + nearest ( x , from ), 0 ) + 13from . reduce (( s , y ) => s + nearest ( y , to ), 0 )) / 142 15); 16} 17 18const mean = ( xs :readonly number []) => xs . reduce (( a , b ) => a + b , 0 ) / xs . length ; 19 20/** stack the pitch classes in close position, starting from `low` */ 21function closeStack ( low :number , pcs :readonly number []) :number [] { 22return pcs . map ( pc => low + mod ( pc - low , 12 )). sort (( a , b ) => a - b ); 23} 24 25/** 26* a piano voicing: one bass note, plus the chord's upper structure led smoothly from `prev` 27* (the previous chord's voicing, bass first) 28*/ 29export function voiceChord ( chord :Chord , prev ?:readonly number []) :number [] { 30const root = pcOf ( chord . root ); 31const bassPc = chord . bass ?pcOf ( chord . bass ) :root ; 32 33let upper = chord . tones . map ( t => mod ( root + t , 12 )); 34// thin out dense chords: the fifth goes first, then the root when the bass already has it 35if ( upper . length > 4 && chord . tones . includes ( 7 )) 36upper = upper . filter ( p => p !== mod ( root + 7 , 12 )); 37if ( upper . length > 4 && bassPc === root ) upper = upper . filter ( p => p !== root ); 38 39const prevBass = prev ?.[ 0 ]; 40const prevUpper = prev ?. slice ( 1 ); 41 42let bass = 0 ; 43let bassCost = Infinity ; 44for ( let m = 33 ; m <= 52 ; m ++ ) { 45if ( mod ( m , 12 ) !== bassPc ) continue ; 46const cost = Math . abs ( m - ( prevBass ?? BASS_CENTER )) + 0.5 * Math . abs ( m - BASS_CENTER ); 47if ( cost < bassCost ) [ bass , bassCost ] = [ m , cost ]; 48} 49 50let best :number [] = []; 51let bestCost = Infinity ; 52for ( let low = 50 ; low <= 70 ; low ++ ) { 53if ( ! upper . includes ( mod ( low , 12 ))) continue ; 54const close = closeStack ( low , upper ); 55const drop2 = 56close . length >= 4 57 ?[ ...close . slice ( 0 , - 2 ), close . at ( - 2 ) ! - 12 , close . at ( - 1 ) ! ]. sort (( a , b ) => a - b ) 58 :undefined ; 59for ( const [ candidate , penalty ] of [ 60[ close , 0 ], 61[ drop2 , 1 ], 62] as const ) { 63if ( ! candidate || candidate [ 0 ] - bass < 5 ) continue ; 64const cost = 65( prevUpper ?. length ?motion ( prevUpper , candidate ) :0 ) + 660.25 * Math . abs ( mean ( candidate ) - UPPER_CENTER ) + 67penalty ; 68if ( cost < bestCost ) [ best , bestCost ] = [ candidate , cost ]; 69} 70} 71 72return [ bass , ...best ]; 73} 74 75/** move the upper structure to its next inversion up (+1) or down (-1), keeping the bass */ 76export function invertVoicing ( notes :readonly number [], direction :1 | - 1 ) :number [] { 77const [ bass , ...upper ] = [ ...notes ]. sort (( a , b ) => a - b ); 78if ( ! upper . length ) return [ bass + 12 * direction ]; 79if ( direction > 0 ) upper . push ( upper . shift () ! + 12 ); 80else if ( upper . at ( - 1 ) ! - 12 > bass ) upper . unshift ( upper . pop () ! - 12 ); 81return [ bass , ...upper ]. sort (( a , b ) => a - b ); 82}