char-slop/chordpad.vibed.software
git clone https://git.t4t.associates/char-slop/chordpad.vibed.software
040fec5
main
1import { Signal } from "@char/aftercare" ; 2import { audition , play , playing , stop } from "../audio/transport.ts" ; 3import { fetchTheoryTab , theoryTabPath , theoryTabProject } from "../import/theorytab.ts" ; 4import { sounding , voiceTrack } from "../project/harmony.ts" ; 5import { 6type ChordTrack , 7type ChordClip , 8findTrack , 9fitView , 10insertLeadIn , 11type NoteClip , 12pasteChords , 13pasteNotes , 14newChordTrack , 15newId , 16newNoteTrack , 17canonBeat , 18placeChord , 19type Project , 20type Track , 21projectKey , 22} from "../project/model.ts" ; 23import { commit , openNewProject , project } from "../project/store.ts" ; 24import { 25type Chord , 26chordMatches , 27chordSymbol , 28isKeyRelative , 29parseChord , 30} from "../theory/chord.ts" ; 31import { romanOf } from "../theory/analysis.ts" ; 32import { type Key , stepInScale } from "../theory/key.ts" ; 33import { voiceChord } from "../theory/voicing.ts" ; 34import { projectMidi } from "../midi/export.ts" ; 35import { parseMidi } from "../midi/smf.ts" ; 36import { chordsOf } from "../midi/chords.ts" ; 37import { activeTrackId , cursor , selectedIds , selection } from "./state.ts" ; 38import { chooseSections } from "./theorytab-dialog.tsx" ; 39import { choosePartKinds } from "./midi-dialog.tsx" ; 40import { saveFile } from "./dom.tsx" ; 41import { projectFromFile , projectToFile } from "../project/persist.ts" ; 42 43export const togglePlay = () :void => ( playing . get () ?stop () :play ( cursor . get ())); 44 45const chordTrack = ( p :Project , id :string ) => findTrack ( p , id , "chords" ); 46 47const voiced = ( trackId :string , id :string ) => { 48const p = project . get (); 49const track = chordTrack ( p , trackId ); 50return track && voiceTrack ( track , projectKey ( p )). find ( v => v . event . id === id ); 51}; 52 53/** hear some of a chord track's notes as the track would play them */ 54const auditionIn = ( trackId :string , notes :readonly number [], dur :number , delay = 0 ) => { 55const track = chordTrack ( project . get (), trackId ); 56audition ( track ?sounding ( track , notes ) :notes , dur , delay ); 57}; 58 59const auditionChord = ( trackId :string , id :string ) => 60auditionIn ( trackId , voiced ( trackId , id )?. notes ?? [], 0.9 ); 61 62/** length of a new chord at `start`: the default, unless it'd run into the next chord */ 63function newChordLength ( p :Project , track :ChordTrack , start :number ) { 64const next = track . chords . find ( c => c . start > start )?. start ?? Infinity ; 65return Math . min ( p . chordLength , next - start ); 66} 67 68/** write a chord symbol at the cursor, replacing the chord there, then step past it */ 69export function writeChord ( trackId :string , symbol :string , { quiet= false } = {}) :void { 70const p = project . get (); 71const track = chordTrack ( p , trackId ); 72if ( ! track ) return ; 73const start = cursor . get (); 74const existing = track . chords . find ( c => c . start === start ); 75const dur = existing ?. dur ?? newChordLength ( p , track , start ); 76const id = existing ?. id ?? newId (); 77commit ( d => placeChord ( chordTrack ( d , trackId ) ! , { id, start, dur, symbol})); 78cursor . set ( start + dur ); 79if ( ! quiet ) auditionChord ( trackId , id ); 80} 81 82/** the chord track the palette writes to: the active one, or else the first */ 83export function paletteTrack ( p :Project ) :ChordTrack | undefined { 84const active = p . tracks . find ( t => t . id === activeTrackId . get ()); 85return active ?. kind === "chords" ?active :p . tracks . find ( t => t . kind === "chords" ); 86} 87 88/** 89* where to write a chord that follows on from the current one: just after the chord at the 90* cursor, if nothing's there yet; or else at the cursor, as the palette does 91*/ 92export function nextChordAt () :number { 93const track = paletteTrack ( project . get ()); 94const here = track ?. chords . find ( c => c . start === cursor . get ()); 95if ( ! here ) return cursor . get (); 96const end = canonBeat ( here . start + here . dur ); 97return track ! . chords . some ( c => c . start === end ) ?here . start :end ; 98} 99 100/** hear a chord as it would sound written at a beat, led on from the chord before it */ 101export function previewChord ( symbol :string , at = cursor . get ()) :void { 102const p = project . get (); 103const key = projectKey ( p ); 104const chord = parseChord ( symbol , key ); 105if ( ! chord ) return ; 106const track = paletteTrack ( p ); 107const before = track && voiceTrack ( track , key ). findLast ( v => v . event . start < at ); 108const notes = voiceChord ( chord , before ?. notes ); 109audition ( track ?sounding ( track , notes ) :notes , 0.9 ); 110} 111 112export function writeChordFromPalette ( symbol :string , at = cursor . get ()) :void { 113cursor . set ( at ); 114let track = paletteTrack ( project . get ()); 115if ( ! track ) { 116track = newChordTrack (); 117const added = track ; 118commit ( d => void d . tracks . unshift ( added )); 119} 120activeTrackId . set ( track . id ); 121// quiet: double-clicking to get here already previewed it 122writeChord ( track . id , symbol , { quiet :true }); 123} 124 125export function createChordWithNote ( trackId :string , beat :number , pitch :number ) :void { 126const p = project . get (); 127const track = chordTrack ( p , trackId ); 128if ( ! track ) return ; 129const start = Math . floor ( beat / p . grid ) * p . grid ; 130const chord = { id :newId (), start, dur :newChordLength ( p , track , start ), notes :[ pitch ] }; 131commit ( d => placeChord ( chordTrack ( d , trackId ) ! , chord )); 132cursor . set ( start ); 133audition ([ pitch ]); 134} 135 136/** hand-voice a chord; its symbol is kept only while it still describes the notes */ 137function setChordNotes ( track :ChordTrack , id :string , notes :number [], key :Key ) { 138const c = track . chords . find ( c => c . id === id ); 139if ( ! c ) return ; 140if ( ! notes . length ) { 141track . chords = track . chords . filter ( x => x !== c ); 142return ; 143} 144c . notes = [ ...new Set ( notes )]. sort (( a , b ) => a - b ); 145const typed = c . symbol !== undefined ?parseChord ( c . symbol , key ) :undefined ; 146if ( ! ( typed && chordMatches ( typed , c . notes ))) delete c . symbol ; 147} 148 149export function revoice ( 150trackId :string , 151id :string , 152change :( notes :number []) => number [], 153) :void { 154const v = voiced ( trackId , id ); 155if ( ! v ) return ; 156const notes = change ( v . notes ). filter ( n => n >= 0 && n < 128 ); 157const key = projectKey ( project . get ()); 158commit ( d => setChordNotes ( chordTrack ( d , trackId ) ! , id , notes , key )); 159auditionIn ( trackId , notes , 0.9 ); 160} 161 162export const toggleChordNote = ( trackId :string , id :string , pitch :number ) :void => 163revoice ( trackId , id , notes => 164notes . includes ( pitch ) ?notes . filter ( n => n !== pitch ) :[ ...notes , pitch ], 165); 166 167/** throw away a chord's hand-set notes, so it's voiced from its neighbours again */ 168export function unpin ( trackId :string , id :string ) :void { 169const v = voiced ( trackId , id ); 170if ( ! v ?. pinned ) return ; 171const symbol = v . event . symbol ?? ( v . chord && chordSymbol ( v . chord )); 172if ( symbol === undefined ) return ; 173commit ( d => { 174const c = chordTrack ( d , trackId ) ! . chords . find ( c => c . id === id ) ! ; 175c . symbol = symbol ; 176delete c . notes ; 177}); 178auditionChord ( trackId , id ); 179} 180 181export function resizeChord ( trackId :string , id :string , delta :number ) :void { 182commit ( d => { 183const track = chordTrack ( d , trackId ) ! ; 184const c = track . chords . find ( c => c . id === id ); 185if ( c ) placeChord ( track , { ...c , dur :Math . max ( d . grid , c . dur + delta ) }); 186}); 187} 188 189/** step the cursor to the neighbouring chord boundary */ 190export function moveChordCursor ( track :ChordTrack , direction :1 | - 1 ) :void { 191const at = cursor . get (); 192const step = project . get (). chordLength ; 193const bounds = [ 194 ...new Set ([ 0 , ...track . chords . flatMap ( c => [ c . start , canonBeat ( c . start + c . dur )])]), 195]. sort (( a , b ) => a - b ); 196cursor . set ( 197direction > 0 198 ?( bounds . find ( b => b > at ) ?? at + step ) 199 :( bounds . findLast ( b => b < at ) ?? Math . max ( 0 , at - step )), 200); 201const here = track . chords . find ( c => c . start === cursor . get ()); 202selection . set ( here ?{ trackId :track . id , ids :[ here . id ] } :null ); 203} 204 205export function deleteNotes ( trackId :string , ids :readonly string []) :void { 206commit ( d => { 207const track = findTrack ( d , trackId , "notes" ) ! ; 208track . notes = track . notes . filter ( n => ! ids . includes ( n . id )); 209}); 210selection . set ( null ); 211} 212 213export function nudgeNotes ( 214trackId :string , 215ids :readonly string [], 216by :{ beats ?:number ; pitch ?:number ; degrees ?:number ; dur ?:number }, 217) :void { 218const p = project . get (); 219const key = projectKey ( p ); 220const pitched = ( pitch :number ) => 221( by . degrees ?stepInScale ( pitch , key , by . degrees ) :pitch ) + ( by . pitch ?? 0 ); 222commit ( d => { 223for ( const n of findTrack ( d , trackId , "notes" ) ! . notes ) { 224if ( ! ids . includes ( n . id )) continue ; 225n . start = Math . max ( 0 , n . start + ( by . beats ?? 0 )); 226n . pitch = Math . min ( 127 , Math . max ( 0 , pitched ( n . pitch ))); 227n . dur = Math . max ( p . grid , n . dur + ( by . dur ?? 0 )); 228} 229}); 230if ( by . pitch || by . degrees ) { 231const track = findTrack ( project . get (), trackId , "notes" ) ! ; 232audition ( 233track . notes . filter ( n => ids . includes ( n . id )). map ( n => n . pitch ), 2340.4 , 235); 236} 237} 238 239export function addTrack ( kind :"chords" | "notes" ) :void { 240const track = kind === "chords" ?newChordTrack () :newNoteTrack (); 241commit ( d => void d . tracks . push ( track )); 242activeTrackId . set ( track . id ); 243} 244 245export function removeTrack ( id :string ) :void { 246commit ( d => void ( d . tracks = d . tracks . filter ( t => t . id !== id ))); 247if ( activeTrackId . get () === id ) activeTrackId . set ( project . get (). tracks [ 0 ]?. id ?? null ); 248} 249 250/** add every part of a midi file as a track of chords or of melody, starting from beat 0 */ 251export async function importMidi ( file :File ) :Promise < void > { 252let parts ; 253try { 254parts = parseMidi ( new Uint8Array ( await file . arrayBuffer ())); 255} catch ( e ) { 256alert ( `couldn't import ${ file . name } : ${( e as Error ). message } ` ); 257return ; 258} 259if ( ! parts . length ) { 260alert ( ` ${ file . name } has no (non-drum) notes in it` ); 261return ; 262} 263 264const kinds = await choosePartKinds ( file . name , parts ); 265if ( ! kinds ) return ; 266 267const base = file . name . replace ( / \.midi?$ / i , "" ); 268const tracks = parts . map (( part , i ) :Track => { 269const name = parts . length > 1 ?` ${ base } : ${ part . name } ` :base ; 270const view = fitView ( part . notes . map ( n => n . pitch )); 271if ( kinds [ i ] === "chords" ) { 272const chords = chordsOf ( part . notes ). map ( c => ({ id :newId (), ...c })); 273return { ...newChordTrack ( name ), view, chords}; 274} 275return { ...newNoteTrack ( name ), view, notes :part . notes . map ( n => ({ id :newId (), ...n })) }; 276}); 277commit ( d => void d . tracks . push ( ...tracks )); 278activeTrackId . set ( tracks [ 0 ]. id ); 279// the header, not the lane: it's pinned to the left, so this won't scroll sideways 280requestAnimationFrame (() => 281document 282. querySelector ( `.track-head[data-track=" ${ tracks [ 0 ]. id } "]` ) 283?. scrollIntoView ({ block :"nearest" }), 284); 285} 286 287export const importingTheoryTab = new Signal ( false ); 288 289/** start a new project from a TheoryTab: every section's chords and melody */ 290export async function importTheoryTab ( url :string ) :Promise < void > { 291const path = theoryTabPath ( url ); 292if ( ! path ) { 293alert ( "that doesn't look like a TheoryTab link (hooktheory.com/theorytab/view/…)" ); 294return ; 295} 296if ( importingTheoryTab . get ()) return ; 297importingTheoryTab . set ( true ); 298let tab ; 299try { 300tab = await fetchTheoryTab ( path ); 301} catch ( e ) { 302alert ( `couldn't import that TheoryTab: ${( e as Error ). message } ` ); 303return ; 304} finally { 305importingTheoryTab . set ( false ); 306} 307 308const sections = tab . sections . length > 1 ?await chooseSections ( tab ) :tab . sections ; 309if ( ! sections ) return ; 310const part = sections . length === 1 && tab . sections . length > 1 ?` ( ${ sections [ 0 ]. name } )` :"" ; 311try { 312openNewProject ( theoryTabProject ({ title :tab . title + part , sections})); 313} catch ( e ) { 314alert ( `couldn't import that TheoryTab: ${( e as Error ). message } ` ); 315} 316} 317 318/** a new chord written the way `like` was: roman if it was, so it keeps following the key */ 319const writtenLike = ( like :string | undefined , chord :Chord , key :Key ) => 320like !== undefined && isKeyRelative ( like ) ?romanOf ( chord , key ) :chordSymbol ( chord ); 321 322/** replace a chord with a variation of it, auto-voiced */ 323export function transformChord ( 324trackId :string , 325id :string , 326change :( chord :Chord , key :Key ) => Chord | undefined , 327) :void { 328const v = voiced ( trackId , id ); 329const key = projectKey ( project . get ()); 330const next = v ?. chord && change ( v . chord , key ); 331if ( ! next ) return ; 332const symbol = writtenLike ( v . event . symbol , next , key ); 333commit ( d => { 334const c = chordTrack ( d , trackId ) ! . chords . find ( c => c . id === id ) ! ; 335c . symbol = symbol ; 336delete c . notes ; 337}); 338auditionChord ( trackId , id ); 339} 340 341/** put chords before this one that lead into it, then play them into it */ 342export function leadInto ( trackId :string , id :string , chords :Chord []) :void { 343const v = voiced ( trackId , id ); 344const p = project . get (); 345const key = projectKey ( p ); 346if ( ! v ) return ; 347const symbols = chords . map ( c => writtenLike ( v . event . symbol , c , key )); 348if ( ! insertLeadIn ( structuredClone ( chordTrack ( p , trackId ) ! ), id , symbols , p . grid )) return ; 349commit ( d => void insertLeadIn ( chordTrack ( d , trackId ) ! , id , symbols , p . grid )); 350const after = voiceTrack ( chordTrack ( project . get (), trackId ) ! , key ); 351const target = after . findIndex ( c => c . event . id === id ); 352const sequence = after . slice ( target - chords . length , target + 1 ); 353sequence . forEach (( c , i ) => auditionIn ( trackId , c . notes , 0.5 , i * 0.45 )); 354cursor . set ( after [ target ]. event . start ); 355} 356 357/** download the project as a midi file */ 358export function exportMidi () :void { 359const p = project . get (); 360saveFile ( p . name , ".mid" , projectMidi ( p ), "audio/midi" ); 361} 362 363export function exportProject () :void { 364const p = project . get (); 365saveFile ( p . name , ".chordpad" , projectToFile ( p ), "application/cbor" ); 366} 367 368export async function importProject ( file :File ) :Promise < void > { 369try { 370openNewProject ( projectFromFile ( new Uint8Array ( await file . arrayBuffer ()))); 371} catch ( e ) { 372alert ( `couldn't open ${ file . name } : ${( e as Error ). message } ` ); 373} 374} 375 376/** the chords an edit applies to: those selected in the track, or else the one at the cursor */ 377export function chordsToEdit ( track :ChordTrack ) :string [] { 378const selected = selectedIds ( track . id ). filter ( id => track . chords . some ( c => c . id === id )); 379if ( selected . length ) return selected ; 380const here = track . chords . find ( c => c . start === cursor . get ()); 381return here ?[ here . id ] :[]; 382} 383 384export function deleteChords ( trackId :string , ids :readonly string []) :void { 385if ( ! ids . length ) return ; 386commit ( d => { 387const track = chordTrack ( d , trackId ) ! ; 388track . chords = track . chords . filter ( c => ! ids . includes ( c . id )); 389}); 390selection . set ( null ); 391} 392 393/** put chords in at the cursor, over whatever's there, and select them */ 394export function pasteClip ( trackId :string , clip :ChordClip ) :void { 395const at = cursor . get (); 396let ids :string [] = []; 397commit ( d => void ( ids = pasteChords ( chordTrack ( d , trackId ) ! , clip , at ))); 398selection . set ({ trackId, ids}); 399cursor . set ( at + clip . span ); 400} 401 402/** add notes at the cursor and select them, moving the cursor past them to paste again after */ 403export function pasteNoteClip ( trackId :string , clip :NoteClip ) :void { 404const at = cursor . get (); 405let ids :string [] = []; 406commit ( d => void ( ids = pasteNotes ( findTrack ( d , trackId , "notes" ) ! , clip , at ))); 407selection . set ({ trackId, ids}); 408cursor . set ( at + clip . span ); 409}