char-slop/chordpad.vibed.software
git clone https://git.t4t.associates/char-slop/chordpad.vibed.software
a5e999d
main
1import { assertEquals , assertNotEquals , assertThrows } from "@std/assert" ; 2import { encodeCBOR } from "@char/cbor" ; 3import { voiceTrack } from "./harmony.ts" ; 4import { 5type ChordTrack , 6newChordTrack , 7newNoteTrack , 8newProject , 9NOTE_VEL , 10placeChord , 11type Project , 12projectKey , 13} from "./model.ts" ; 14import { 15fromStored , 16projectFromFile , 17projectToFile , 18type SavedProject , 19type StoredChord , 20type StoredProject , 21toStored , 22} from "./persist.ts" ; 23 24function sketch () :Project { 25const p = newProject (); 26p . grid = 1 / 3 ; 27p . loopRegion = { start :2 / 3 , end :8 }; 28const chords = newChordTrack (); 29for ( const [ start , dur , symbol ] of [ 30[ 0 , 4 , "C" ], 31[ 4 , 4 / 3 , "bVI" ], 32[ 16 / 3 , 8 / 3 , "iv" ], 33[ 8 , 4 , "V7" ], 34] as const ) { 35placeChord ( chords , { id :symbol , start, dur, symbol}); 36} 37chords . chords [ 2 ]. notes = [ 41 , 56 , 60 , 65 ]; // iv, hand-voiced 38chords . bass = false ; 39const melody = newNoteTrack (); 40melody . gain = - 4.5 ; 41melody . notes . push ( 42{ id :"n" , start :0 , dur :1 , pitch :72 , vel :NOTE_VEL }, 43{ id :"m" , start :1 / 3 , dur :1 / 6 , pitch :74 , vel :101 / 127 }, 44); 45p . tracks = [ chords , melody ]; 46return p ; 47} 48const chordsOf = ( p :Project ) => ( p . tracks [ 0 ] as ChordTrack ). chords ; 49const storedChords = ( s :StoredProject ) => 50s . tracks [ 0 ]. kind === "chords" ?s . tracks [ 0 ]. chords :[]; 51const sounding = ( p :Project ) => 52voiceTrack ( p . tracks [ 0 ] as ChordTrack , projectKey ( p )). map ( v => v . notes ); 53// ids only tell things apart while editing, so they're made anew on loading 54const withoutIds = ( p :Project ) => 55JSON . parse ( JSON . stringify ( p , ( k , v ) => ( k === "id" ?undefined :v ))); 56 57Deno . test ( "saved projects keep every chord's voicing, and which are pinned" , () => { 58const p = sketch (); 59const stored = toStored ( p ); 60assertEquals ( 61storedChords ( stored ). map ( c => c [ 4 ]), 62[ false , false , true , false ], 63); 64assertEquals ( 65storedChords ( stored ). map ( c => c [ 3 ]), 66sounding ( p ), 67); 68}); 69 70Deno . test ( "a project comes back as it was saved, triplets and all" , () => { 71const p = sketch (); 72const loaded = fromStored ( JSON . parse ( JSON . stringify ( toStored ( p )))); 73assertEquals ( withoutIds ( loaded ), withoutIds ( p )); 74assertEquals ( sounding ( loaded ), sounding ( p )); 75}); 76 77Deno . test ( "a chord that would now be voiced differently comes back pinned as it was" , () => { 78const stored = toStored ( sketch ()); 79// as if the voicing of ♭VI had changed since it was saved 80( storedChords ( stored )[ 1 ] as StoredChord )[ 3 ] = [ 44 , 72 , 75 , 80 ]; 81const p = fromStored ( stored ); 82assertEquals ( 83chordsOf ( p ). map ( c => c . notes !== undefined ), 84[ false , true , true , false ], 85); 86const { id :_ , ...flat } = chordsOf ( p )[ 1 ]; 87assertEquals ( flat , { start :4 , dur :4 / 3 , symbol :"bVI" , notes :[ 44 , 72 , 75 , 80 ] }); 88// and it's what plays, now and after (voicings are cached) 89assertEquals ( sounding ( p )[ 1 ], [ 44 , 72 , 75 , 80 ]); 90}); 91 92/** a project as it was saved before events were tuples */ 93function savedAsVersion2 () :SavedProject { 94const c = voiceTrack ( 95{ ...newChordTrack (), chords :[{ id :"c" , start :0 , dur :4 , symbol :"C" }] }, 96projectKey ( newProject ()), 97)[ 0 ]. notes ; 98return { 99version :2 , 100id :"old" , 101name :"from before" , 102modified :1 , 103bpm :96 , 104beatsPerBar :4 , 105key :{ tonic :"C" , mode :"ionian" }, 106chordLength :4 , 107grid :0.5 , 108loop :true , 109// and before tracks had a volume trim, or a bass to turn off 110tracks :[ 111{ 112id :"t" , 113name :"chords" , 114kind :"chords" , 115muted :false , 116view :{ low :38 , high :77 }, 117chords :[ 118{ id :"c" , start :0 , dur :4 , symbol :"C" , notes :c , pinned :false }, 119{ id :"p" , start :4 , dur :4 , notes :[ 41 , 56 , 60 , 65 ], pinned :true }, 120], 121}, 122{ 123id :"m" , 124name :"melody" , 125kind :"notes" , 126muted :true , 127view :{ low :55 , high :88 }, 128notes :[{ id :"n" , start :1.5 , dur :0.5 , pitch :72 , vel :0.8 }], 129}, 130], 131} as SavedProject ; 132} 133 134Deno . test ( "projects saved with objects rather than tuples still load" , () => { 135const p = fromStored ( savedAsVersion2 ()); 136assertEquals ( withoutIds ( p ), { 137name :"from before" , 138modified :1 , 139bpm :96 , 140beatsPerBar :4 , 141key :{ tonic :"C" , mode :"ionian" }, 142chordLength :4 , 143grid :0.5 , 144loop :true , 145tracks :[ 146{ 147name :"chords" , 148kind :"chords" , 149muted :false , 150gain :0 , 151bass :true , 152view :{ low :38 , high :77 }, 153chords :[ 154{ start :0 , dur :4 , symbol :"C" }, 155{ start :4 , dur :4 , notes :[ 41 , 56 , 60 , 65 ] }, 156], 157}, 158{ 159name :"melody" , 160kind :"notes" , 161muted :true , 162gain :0 , 163view :{ low :55 , high :88 }, 164// the nearest midi velocity 165notes :[{ start :1.5 , dur :0.5 , pitch :72 , vel :102 / 127 }], 166}, 167], 168}); 169assertEquals ( p . id , "old" ); 170}); 171 172Deno . test ( "projects from before voicings were kept aren't loaded" , () => { 173const legacy = { ...toStored ( sketch ()), version :1 } as unknown as StoredProject ; 174assertThrows (() => fromStored ( legacy ), Error , "version 1" ); 175}); 176 177Deno . test ( "a project file loads as a new project, the same as the one it came from" , () => { 178const p = { ...sketch (), name :"for a friend" , bpm :132 }; 179const loaded = projectFromFile ( projectToFile ( p )); 180// so loading it never takes the place of a project already here, even the one it came from 181assertNotEquals ( loaded . id , p . id ); 182assertEquals ( withoutIds ({ ...loaded , modified :0 }), withoutIds ({ ...p , modified :0 })); 183assertEquals ( sounding ( loaded ), sounding ( p )); 184 185const bytes = ( s :string ) => new TextEncoder (). encode ( s ); 186assertThrows ( 187() => projectFromFile ( bytes ( "not a project" )), 188Error , 189"isn't a chordpad project" , 190); 191assertThrows ( 192() => projectFromFile ( encodeCBOR ({ hello :1 })), 193Error , 194"isn't a chordpad project" , 195); 196assertThrows ( 197() => projectFromFile ( encodeCBOR ({ ...toStored ( p ), version :9 })), 198Error , 199"version 9" , 200); 201}); 202 203Deno . test ( "project files from when they were json still open" , () => { 204const file = new TextEncoder (). encode ( JSON . stringify ( savedAsVersion2 ())); 205const opened = projectFromFile ( file ); 206assertEquals ( 207withoutIds ({ ...opened , modified :0 }), 208withoutIds ({ ...fromStored ( savedAsVersion2 ()), modified :0 }), 209); 210});