char-slop/chordpad.vibed.software
git clone https://git.t4t.associates/char-slop/chordpad.vibed.software
a5e999d
main
1import type { Key , ModeId } from "../theory/key.ts" ; 2import { parseNote , pcOf } from "../theory/pitch.ts" ; 3 4export type NoteEvent = { id :string ; start :number ; dur :number ; pitch :number ; vel :number }; 5/** how hard new notes are played: a midi velocity, as they're saved */ 6export const NOTE_VEL = 96 / 127 ; 7 8/** a chord is what was typed, what was voiced by hand, or both. times are in beats */ 9export type ChordEvent = { 10id :string ; 11start :number ; 12dur :number ; 13symbol ?:string ; 14notes ?:number []; 15}; 16 17type TrackBase = { 18id :string ; 19name :string ; 20muted :boolean ; 21/** volume trim, in dB */ 22gain :number ; 23/** visible pitch range of the piano roll */ 24view :{ low :number ; high :number }; 25}; 26export type ChordTrack = TrackBase & { 27kind :"chords" ; 28chords :ChordEvent []; 29/** whether each chord's lowest note sounds: off when another track carries the bass */ 30bass :boolean ; 31}; 32export type NoteTrack = TrackBase & { kind :"notes" ; notes :NoteEvent [] }; 33export type Track = ChordTrack | NoteTrack ; 34 35export type Project = { 36id :string ; 37name :string ; 38modified :number ; 39bpm :number ; 40beatsPerBar :number ; 41key :{ tonic :string ; mode :ModeId }; 42/** default length of newly typed chords, in beats */ 43chordLength :number ; 44/** note grid, in beats */ 45grid :number ; 46loop :boolean ; 47/** what gets looped, in beats; the whole song when absent */ 48loopRegion ?:{ start :number ; end :number }; 49tracks :Track []; 50}; 51 52// not randomUUID: that only exists in secure contexts, and this may be served over plain http 53export const newId = () :string => 54Array . from ( crypto . getRandomValues ( new Uint8Array ( 4 )), b => 55b . toString ( 16 ). padStart ( 2 , "0" ), 56). join ( "" ); 57 58export const newChordTrack = ( name = "chords" ) :ChordTrack => ({ 59id :newId (), 60 name, 61kind :"chords" , 62muted :false , 63gain :0 , 64bass :true , 65view :{ low :38 , high :77 }, 66chords :[], 67}); 68 69export const newNoteTrack = ( name = "melody" ) :NoteTrack => ({ 70id :newId (), 71 name, 72kind :"notes" , 73muted :false , 74gain :0 , 75view :{ low :57 , high :88 }, 76notes :[], 77}); 78 79export const UNTITLED = "untitled" ; 80 81type Timed = { start :number ; dur :number }; 82const events = ( t :Track ) :readonly Timed [] => ( t . kind === "chords" ?t . chords :t . notes ); 83 84/** nothing worth keeping: never named, and nothing written in it */ 85export const isBlank = ( p :Project ) :boolean => 86p . name === UNTITLED && p . tracks . every ( t => events ( t ). length === 0 ); 87 88export const newProject = () :Project => ({ 89id :newId (), 90name :UNTITLED , 91modified :Date . now (), 92bpm :96 , 93beatsPerBar :4 , 94key :{ tonic :"C" , mode :"ionian" }, 95chordLength :1 , 96grid :0.25 , 97loop :true , 98tracks :[ newChordTrack (), newNoteTrack ()], 99}); 100 101/** the tonics the key picker offers */ 102export const TONICS = [ 103"C" , 104"C#" , 105"Db" , 106"D" , 107"D#" , 108"Eb" , 109"E" , 110"F" , 111"F#" , 112"Gb" , 113"G" , 114"G#" , 115"Ab" , 116"A" , 117"A#" , 118"Bb" , 119"B" , 120]; 121 122/** a tonic as the key picker spells it (D♯ → E♭, C♭ → B) */ 123export function pickableTonic ( tonic :string ) :string { 124const note = parseNote ( tonic )?. note ; 125if ( ! note || TONICS . includes ( tonic )) return note ?tonic :"C" ; 126return TONICS . find ( t => pcOf ( parseNote ( t ) ! . note ) === pcOf ( note )) ! ; 127} 128 129/** a piano roll range that fits these pitches, at least two and a half octaves tall */ 130export function fitView ( pitches :readonly number []) :{ low :number ; high :number } { 131const [ lo , hi ] = [ Math . min ( ...pitches ), Math . max ( ...pitches )]; 132const span = Math . max ( 31 , hi - lo + 6 ); 133const low = Math . min ( 127 - span , Math . max ( 0 , Math . round (( lo + hi - span ) / 2 ))); 134return { low, high :low + span }; 135} 136 137/** a key as projects store it, for working with */ 138export const keyOf = ( key :Project [ "key" ]) :Key => ({ 139tonic :parseNote ( key . tonic ) ! . note , 140mode :key . mode , 141}); 142 143export const projectKey = ( p :Project ) :Key => keyOf ( p . key ); 144 145export const projectEnd = ( p :Project ) :number => 146Math . max ( 0 , ...p . tracks . flatMap ( t => events ( t ). map ( e => e . start + e . dur ))); 147 148export function findTrack < K extends Track [ "kind" ] > ( 149p :Project , 150id :string , 151kind :K , 152) :Extract < Track , { kind :K }> | undefined { 153const t = p . tracks . find ( t => t . id === id ); 154return t ?. kind === kind ?( t as Extract < Track , { kind :K }>) :undefined ; 155} 156 157/** 158* chord boundaries are compared exactly, but sums of triplet positions drift in floating point 159* (4/3 + 1/3 falls just short of 5/3). rounding every boundary onto a fixed grid that holds both 160* 64ths and triplets keeps equal positions equal 161*/ 162export const canonBeat = ( beats :number ) :number => Math . round ( beats * 192 ) / 192 ; 163 164/** put a chord in place, cutting away whatever it overlaps */ 165export function placeChord ( track :ChordTrack , placed :ChordEvent ) :void { 166const chord = { ...placed , start :canonBeat ( placed . start ), dur :canonBeat ( placed . dur ) }; 167const end = canonBeat ( chord . start + chord . dur ); 168track . chords = track . chords . flatMap ( c => { 169if ( c . id === chord . id ) return []; 170const cEnd = canonBeat ( c . start + c . dur ); 171if ( cEnd <= chord . start || c . start >= end ) return [ c ]; 172const kept :ChordEvent [] = []; 173if ( c . start < chord . start ) kept . push ({ ...c , dur :canonBeat ( chord . start - c . start ) }); 174if ( cEnd > end ) { 175const id = kept . length ?newId () :c . id ; 176kept . push ({ ...c , id, start :end , dur :canonBeat ( cEnd - end ) }); 177} 178return kept ; 179}); 180track . chords . push ( chord ); 181track . chords . sort (( a , b ) => a . start - b . start ); 182} 183 184/** 185* put chords just before a target chord, to lead into it: in the back half of the chord before 186* it, so the target still lands where it was; or, with nothing right before, in the target's own 187* front half. false if there isn't room for them on the grid 188*/ 189export function insertLeadIn ( 190track :ChordTrack , 191targetId :string , 192symbols :readonly string [], 193grid :number , 194) :boolean { 195const target = track . chords . find ( c => c . id === targetId ); 196if ( ! target ) return false ; 197const before = track . chords . find ( c => canonBeat ( c . start + c . dur ) === target . start ); 198const half = ( dur :number ) => Math . floor ( dur / 2 / grid ) * grid ; 199const needed = symbols . length * grid ; 200 201let start :number ; 202let span :number ; 203if ( before && half ( before . dur ) >= needed ) { 204span = half ( before . dur ); 205before . dur = canonBeat ( before . dur - span ); 206start = canonBeat ( target . start - span ); 207} else if ( half ( target . dur ) >= needed ) { 208span = half ( target . dur ); 209start = target . start ; 210target . start = canonBeat ( target . start + span ); 211target . dur = canonBeat ( target . dur - span ); 212} else return false ; 213 214const each = span / symbols . length ; 215symbols . forEach (( symbol , i ) => { 216const at = canonBeat ( start + i * each ); 217track . chords . push ({ 218id :newId (), 219start :at , 220dur :canonBeat ( start + ( i + 1 ) * each ) - at , 221 symbol, 222}); 223}); 224track . chords . sort (( a , b ) => a . start - b . start ); 225return true ; 226} 227 228/** chords taken out of a track, their starts relative to the first */ 229export type ChordClip = { chords :Omit < ChordEvent , "id" >[]; span :number }; 230 231export function copyChords ( track :ChordTrack , ids :readonly string []) :ChordClip | undefined { 232const chosen = track . chords . filter ( c => ids . includes ( c . id )); 233if ( ! chosen . length ) return undefined ; 234const first = chosen [ 0 ]. start ; 235return { 236chords :chosen . map (({ id :_ , ...c }) => ({ ...c , start :canonBeat ( c . start - first ) })), 237span :canonBeat ( Math . max ( ...chosen . map ( c => c . start + c . dur )) - first ), 238}; 239} 240 241/** put copied chords in at a beat, over whatever's there; gives the new chords' ids */ 242export function pasteChords ( track :ChordTrack , clip :ChordClip , at :number ) :string [] { 243return clip . chords . map ( c => { 244const id = newId (); 245placeChord ( track , { ...structuredClone ( c ), id, start :at + c . start }); 246return id ; 247}); 248} 249 250/** notes taken out of a track, their starts relative to the first */ 251export type NoteClip = { notes :Omit < NoteEvent , "id" >[]; span :number }; 252 253export function copyNotes ( track :NoteTrack , ids :readonly string []) :NoteClip | undefined { 254const chosen = track . notes . filter ( n => ids . includes ( n . id )); 255if ( ! chosen . length ) return undefined ; 256const first = Math . min ( ...chosen . map ( n => n . start )); 257return { 258notes :chosen 259. map (({ id :_ , ...n }) => ({ ...n , start :n . start - first })) 260. sort (( a , b ) => a . start - b . start || a . pitch - b . pitch ), 261span :Math . max ( ...chosen . map ( n => n . start + n . dur )) - first , 262}; 263} 264 265/** add copied notes at a beat, alongside what's there; gives the new notes' ids */ 266export function pasteNotes ( track :NoteTrack , clip :NoteClip , at :number ) :string [] { 267return clip . notes . map ( n => { 268const id = newId (); 269track . notes . push ({ ...n , id, start :at + n . start }); 270return id ; 271}); 272} 273 274/** move a track to before the one at `to` (or to the end, at the track count) */ 275export function moveTrack ( p :Project , id :string , to :number ) :void { 276const from = p . tracks . findIndex ( t => t . id === id ); 277const [ track ] = p . tracks . splice ( from , 1 ); 278p . tracks . splice ( from < to ?to - 1 :to , 0 , track ); 279}