char-slop/chordpad.vibed.software
git clone https://git.t4t.associates/char-slop/chordpad.vibed.software
a5e999d
main
1import { decodeCBOR , encodeCBOR } from "@char/cbor" ; 2import { unpinRedundant , voiceTrack } from "./harmony.ts" ; 3import type { ChordEvent , ChordTrack , NoteEvent , NoteTrack , Project } from "./model.ts" ; 4import { newId , projectKey } from "./model.ts" ; 5 6/** 7* how projects are saved: as json in the browser, and as cbor in files. every chord keeps the 8* notes it sounded with, pinned or not: an unpinned chord's notes are worked out from its symbol, 9* and if the way that's done ever changes, loading notices and pins the chord to what it was, 10* rather than it quietly sounding different 11* 12* there are a lot of chords and notes, so they're tuples rather than objects. times are in ticks, 13* which every grid (and the usual midi resolutions) divide into exactly, and velocities are midi's. 14* ids aren't kept: they only tell things apart while editing, and are made anew on loading 15*/ 16export type StoredProject = Omit < Project , "tracks" > & { version :3 ; tracks :StoredTrack [] }; 17type StoredTrack = 18| ( Omit < ChordTrack , "id" | "chords" > & { chords :StoredChord [] }) 19| ( Omit < NoteTrack , "id" | "notes" > & { notes :StoredNote [] }); 20export type StoredChord = [ 21start :number , 22dur :number , 23symbol :string | null , 24notes :number [], 25pinned :boolean , 26]; 27type StoredNote = [ start :number , dur :number , pitch :number , vel :number ]; 28 29/** as projects were saved before: objects throughout, times in beats */ 30type StoredProjectV2 = Omit < Project , "tracks" > & { 31version :2 ; 32tracks :( 33| ( Omit < ChordTrack , "chords" | "gain" | "bass" > & { 34gain ?:number ; 35bass ?:boolean ; 36chords :( Omit < ChordEvent , "notes" > & { notes :number []; pinned :boolean })[]; 37}) 38| ( Omit < NoteTrack , "gain" > & { gain ?:number }) 39)[]; 40}; 41export type SavedProject = StoredProject | StoredProjectV2 ; 42 43const TICKS_PER_BEAT = 1920 ; 44const toTicks = ( beats :number ) => Math . round ( beats * TICKS_PER_BEAT ); 45const toBeats = ( ticks :number ) => ticks / TICKS_PER_BEAT ; 46 47/** a project's own lengths (as opposed to its events'), converted between beats and ticks */ 48const retimed = < P extends Pick < Project , "chordLength" | "grid" | "loopRegion" > > ( 49p :P , 50f :( n :number ) => number , 51) :P => ({ 52 ...p , 53chordLength :f ( p . chordLength ), 54grid :f ( p . grid ), 55loopRegion :p . loopRegion && { start :f ( p . loopRegion . start ), end :f ( p . loopRegion . end ) }, 56}); 57 58const noteTuple = ( n :Omit < NoteEvent , "id" >) :StoredNote => [ 59toTicks ( n . start ), 60toTicks ( n . dur ), 61n . pitch , 62Math . round ( n . vel * 127 ), 63]; 64const chordTuple = ( 65c :Omit < ChordEvent , "id" | "notes" >, 66notes :number [], 67pinned :boolean , 68) :StoredChord => [ toTicks ( c . start ), toTicks ( c . dur ), c . symbol ?? null , notes , pinned ]; 69 70const sorted = ( notes :readonly number []) => [ ...notes ]. sort (( a , b ) => a - b ); 71const sameNotes = ( a :readonly number [], b :readonly number []) => 72a . length === b . length && sorted ( a ). every (( n , i ) => n === sorted ( b )[ i ]); 73 74export function toStored ( p :Project ) :StoredProject { 75const key = projectKey ( p ); 76return { 77 ...retimed ( p , toTicks ), 78version :3 , 79tracks :p . tracks . map (( track ) :StoredTrack => { 80if ( track . kind === "notes" ) { 81const { id :_ , notes, ...rest } = track ; 82return { ...rest , notes :notes . map ( noteTuple ) }; 83} 84const voiced = voiceTrack ( track , key ); 85const { id :_ , chords, ...rest } = track ; 86return { 87 ...rest , 88chords :chords . map (( c , i ) => chordTuple ( c , voiced [ i ]. notes , !! c . notes )), 89}; 90}), 91}; 92} 93 94function fromVersion2 ({ version :_ , tracks, ...p } :StoredProjectV2 ) :StoredProject { 95return { 96 ...retimed ( p , toTicks ), 97version :3 , 98// tracks from before they had a volume trim, or a bass to turn off 99tracks :tracks . map (({ id :_ , gain= 0 , ...track }) :StoredTrack => { 100if ( track . kind === "notes" ) return { ...track , gain, notes :track . notes . map ( noteTuple ) }; 101const chords = track . chords . map ( c => chordTuple ( c , c . notes , c . pinned )); 102return { ...track , gain, bass :track . bass ?? true , chords}; 103}), 104}; 105} 106 107export function fromStored ( saved :SavedProject ) :Project { 108const stored = saved . version === 2 ?fromVersion2 ( saved ) :saved ; 109if ( stored . version !== 3 ) { 110throw new Error ( `can't load a version ${ stored . version } project: it's too old, or too new` ); 111} 112 113const { version :_ , ...rest } = stored ; 114const unpinned = new Map < string , number []>(); 115const p :Project = { 116 ...retimed ( rest , toBeats ), 117tracks :stored . tracks . map ( track => { 118if ( track . kind === "notes" ) { 119const notes = track . notes . map (([ start , dur , pitch , vel ]) => ({ 120id :newId (), 121start :toBeats ( start ), 122dur :toBeats ( dur ), 123 pitch, 124vel :vel / 127 , 125})); 126return { ...track , id :newId (), notes}; 127} 128const chords = track . chords . map (([ start , dur , symbol , notes , pinned ]) :ChordEvent => { 129const c :ChordEvent = { id :newId (), start :toBeats ( start ), dur :toBeats ( dur ) }; 130if ( symbol !== null ) c . symbol = symbol ; 131if ( pinned ) return { ...c , notes}; 132unpinned . set ( c . id , notes ); 133return c ; 134}); 135return { ...track , id :newId (), chords}; 136}), 137}; 138 139// pin whatever would come out differently now. new tracks rather than changed ones: voicings 140// are cached against track objects 141const key = projectKey ( p ); 142const tracks = p . tracks . map ( track => { 143if ( track . kind !== "chords" ) return track ; 144const voiced = voiceTrack ( track , key ); 145const chords = track . chords . map (( c , i ) => { 146const was = unpinned . get ( c . id ); 147return was && ! sameNotes ( was , voiced [ i ]. notes ) ?{ ...c , notes :was } :c ; 148}); 149return { ...track , chords}; 150}); 151return unpinRedundant ({ ...p , tracks}); 152} 153 154/** a project as a file to give someone: as it's saved, but without where or when */ 155export function projectToFile ( p :Project ) :Uint8Array < ArrayBuffer > { 156const { id :_ , modified :__ , ...stored } = toStored ( p ); 157return encodeCBOR ( stored ) as Uint8Array < ArrayBuffer >; 158} 159 160/** a project from a file, as a new one: it never takes the place of one already here */ 161export function projectFromFile ( bytes :Uint8Array ) :Project { 162let stored ; 163try { 164// files from before projects were cbor are json, and start with its brace 165stored = 166bytes [ 0 ] === 0x7b ?JSON . parse ( new TextDecoder (). decode ( bytes )) :decodeCBOR ( bytes ); 167} catch { 168throw new Error ( "it isn't a chordpad project" ); 169} 170if ( typeof stored ?. version !== "number" || ! Array . isArray ( stored . tracks )) { 171throw new Error ( "it isn't a chordpad project" ); 172} 173return fromStored ({ ...stored , id :newId (), modified :Date . now () }); 174}