char-slop/chordpad.vibed.software
git clone https://git.t4t.associates/char-slop/chordpad.vibed.software
ee3b50a
main
1import { Signal } from "@char/aftercare" ; 2import { sounding , voiceTrack } from "../project/harmony.ts" ; 3import { type Project , projectEnd , projectKey , type Track } from "../project/model.ts" ; 4import { project } from "../project/store.ts" ; 5import { audio , playNote , silence , type Voice } from "./synth.ts" ; 6 7export const playing = new Signal ( false ); 8/** the beat currently sounding, while playing */ 9export const playhead = new Signal < number | null >( null ); 10 11const LOOKAHEAD = 0.15 ; 12 13/** a stretch of the timeline played at a fixed tempo, starting at `time` (audio clock) */ 14type Segment = { time :number ; beat :number ; spb :number }; 15let segments :Segment [] = []; 16let scheduledTo = 0 ; 17let timer :ReturnType < typeof setInterval > | undefined ; 18/** set when playback starts past the loop: it then plays on to the end, as if loop were off */ 19let pastLoop = false ; 20const looping = ( p :Project ) => p . loop && ! pastLoop ; 21let frame :number | undefined ; 22 23const songBeats = ( p :Project ) :number => { 24const bar = p . beatsPerBar ; 25return Math . max ( bar , Math . ceil ( projectEnd ( p ) / bar ) * bar ); 26}; 27 28export const loopRange = ( p :Project ) :{ start :number ; end :number } => 29p . loopRegion ?? { start :0 , end :songBeats ( p ) }; 30 31export function play ( at :number ) :void { 32stop (); 33const { ctx} = audio (); 34const p = project . get (); 35pastLoop = at >= loopRange ( p ). end ; 36segments = [{ time :ctx . currentTime + 0.05 , beat :at , spb :60 / p . bpm }]; 37scheduledTo = at ; 38playing . set ( true ); 39timer = setInterval ( tick , 25 ); 40tick (); 41frame = requestAnimationFrame ( animate ); 42} 43 44export function stop () :void { 45if ( ! playing . get ()) return ; 46clearInterval ( timer ); 47if ( frame !== undefined ) cancelAnimationFrame ( frame ); 48silence (); 49playing . set ( false ); 50playhead . set ( null ); 51} 52 53function tick () { 54const p = project . get (); 55const { ctx} = audio (); 56const spb = 60 / p . bpm ; 57let seg = segments . at ( - 1 ) ! ; 58if ( seg . spb !== spb ) { 59seg = { time :seg . time + ( scheduledTo - seg . beat ) * seg . spb , beat :scheduledTo , spb}; 60segments . push ( seg ); 61} 62 63const horizon = ctx . currentTime + LOOKAHEAD ; 64const { start, end} = loopRange ( p ); 65for (;;) { 66const until = seg . beat + ( horizon - seg . time ) / spb ; 67if ( ! looping ( p ) || until < end ) { 68schedule ( p , scheduledTo , until , seg , looping ( p ) ?end :Infinity ); 69scheduledTo = until ; 70break ; 71} 72schedule ( p , scheduledTo , end , seg , end ); 73// if the loop was moved to behind the playhead, wrap from here rather than from the past 74const wrap = seg . time + ( Math . max ( end , scheduledTo ) - seg . beat ) * spb ; 75seg = { time :wrap , beat :start , spb}; 76segments . push ( seg ); 77scheduledTo = start ; 78} 79segments = segments . filter ( 80( _ , i ) => i === segments . length - 1 || segments [ i + 1 ]. time > ctx . currentTime - 1 , 81); 82} 83 84/** where each track's notes go, at its volume: changes to it are heard at once, even mid-note */ 85const buses = new Map < string , GainNode >(); 86const level = ( track :Track ) => 10 ** ( track . gain / 20 ); 87 88function bus ( track :Track ) :GainNode { 89let node = buses . get ( track . id ); 90if ( ! node ) { 91const { ctx, out} = audio (); 92node = ctx . createGain (); 93node . gain . value = level ( track ); 94node . connect ( out ); 95buses . set ( track . id , node ); 96} 97return node ; 98} 99 100project . subscribe ( p => { 101for ( const track of p . tracks ) { 102const node = buses . get ( track . id ); 103// eased rather than stepped, which would click 104node ?. gain . setTargetAtTime ( level ( track ), node . context . currentTime , 0.01 ); 105} 106}); 107 108function schedule ( p :Project , from :number , to :number , seg :Segment , cutoff :number ) { 109const key = projectKey ( p ); 110const at = ( beat :number ) => seg . time + ( beat - seg . beat ) * seg . spb ; 111const length = ( start :number , dur :number ) => 112( Math . min ( start + dur , cutoff ) - start ) * seg . spb ; 113const inWindow = ( start :number ) => start >= from && start < to ; 114 115for ( const track of p . tracks ) { 116if ( track . muted ) continue ; 117const out = bus ( track ); 118if ( track . kind === "notes" ) { 119for ( const n of track . notes ) { 120if ( inWindow ( n . start )) 121playNote ( n . pitch , at ( n . start ), length ( n . start , n . dur ), n . vel , out ); 122} 123} else { 124for ( const { event, notes} of voiceTrack ( track , key )) { 125if ( ! inWindow ( event . start )) continue ; 126for ( const n of sounding ( track , notes )) 127playNote ( n , at ( event . start ), length ( event . start , event . dur ), 0.55 , out ); 128} 129} 130} 131} 132 133function animate () { 134const now = audio (). ctx . currentTime ; 135const seg = segments . findLast ( s => s . time <= now ) ?? segments [ 0 ]; 136const beat = Math . max ( seg . beat , seg . beat + ( now - seg . time ) / seg . spb ); 137const p = project . get (); 138if ( ! looping ( p ) && beat > songBeats ( p ) + 1 ) return stop (); 139playhead . set ( beat ); 140frame = requestAnimationFrame ( animate ); 141} 142 143/** hear some notes now, unless the transport is already making noise */ 144let auditioning :Voice [] = []; 145 146/** 147* hear some notes now (or after `delay` seconds), unless the transport is already making noise. 148* like hands leaving the keys for the next chord, each audition cuts off the one before it 149*/ 150export function audition ( pitches :readonly number [], dur = 0.7 , delay = 0 ) :void { 151if ( playing . get ()) return ; 152const at = audio (). ctx . currentTime + delay ; 153for ( const voice of auditioning ) voice . stop ( at ); 154auditioning = [ 155 ...auditioning . filter ( v => ! v . done ), 156 ...pitches . map ( p => playNote ( p , at , dur , 0.6 )), 157]; 158}