char-slop/chordpad.vibed.software
git clone https://git.t4t.associates/char-slop/chordpad.vibed.software
fcab920
main
1import { midiToFreq } from "../theory/pitch.ts" ; 2 3let engine :{ ctx :AudioContext ; out :AudioNode } | undefined ; 4 5export function audio () :{ ctx :AudioContext ; out :AudioNode } { 6if ( ! engine ) { 7const ctx = new AudioContext ({ latencyHint :"interactive" }); 8const compressor = ctx . createDynamicsCompressor (); 9compressor . threshold . value = - 16 ; 10compressor . ratio . value = 3 ; 11compressor . connect ( ctx . destination ); 12const out = ctx . createGain (); 13out . gain . value = 0.22 ; 14out . connect ( compressor ); 15engine = { ctx, out}; 16} 17if ( engine . ctx . state === "suspended" ) void engine . ctx . resume (); 18return engine ; 19} 20 21/** a sounding (or scheduled) note, which can be cut short */ 22export type Voice = { stop ( at ?:number ) :void ; readonly done :boolean }; 23 24const voices = new Set < Voice >(); 25 26/** seconds: a note's fade when released, and when cut short (about as quick as a damper) */ 27const RELEASE = 0.08 ; 28const CHOKE = 0.03 ; 29 30/** a fade from 1 to 0 without corners: half a cosine */ 31const CHOKE_CURVE = Float32Array . from ( 32{ length :64 }, 33( _ , i ) => ( 1 + Math . cos (( Math . PI * i ) / 63 )) / 2 , 34); 35 36/** 37* a two-operator-pair electric piano, after the DX7 E.PIANO patches: a 1:1 pair whose 38* brightness decays into a near-sine body, and a 1:14 pair for the struck-tine transient 39*/ 40export function playNote ( 41midi :number , 42when :number , 43dur :number , 44vel = 0.7 , 45out = audio (). out , 46) :Voice { 47const { ctx} = audio (); 48const t = Math . max ( when , ctx . currentTime ); 49const end = t + Math . max ( dur , 0.05 ); 50const f = midiToFreq ( midi ); 51// key tracking: higher notes are darker and shorter, like the real thing 52const kt = Math . min ( 1 , Math . max ( - 1 , ( midi - 60 ) / 36 )); 53 54const release = ctx . createGain (); 55// -3dB per octave around middle C, so the top end doesn't shout 56const level = 10 ** (( - 3 / 20 ) * (( midi - 60 ) / 12 )); 57release . gain . setValueAtTime ( level , t ); 58release . gain . setTargetAtTime ( 0 , end , RELEASE ); 59// sits at 1 until the note is cut short, so the fade out needn't know where the envelopes are 60const choke = ctx . createGain (); 61release . connect ( choke ). connect ( out ); 62 63let stoppedAt = Infinity ; 64const voice = { 65done :false , 66stop ( at = ctx . currentTime ) { 67if ( at >= stoppedAt ) return ; 68stoppedAt = at ; 69// this also cancels a start still to come, leaving it silent 70choke . gain . cancelScheduledValues ( at ); 71choke . gain . setValueCurveAtTime ( CHOKE_CURVE , at , CHOKE ); 72}, 73}; 74voices . add ( voice ); 75 76const osc = ( freq :number , detune = 0 ) => { 77const o = ctx . createOscillator (); 78o . frequency . value = freq ; 79o . detune . value = detune ; 80o . start ( t ); 81o . stop ( end + 0.8 ); 82return o ; 83}; 84const env = ( peak :number , tau :number , floor = 0 ) => { 85const g = ctx . createGain (); 86g . gain . setValueAtTime ( 0 , t ); 87g . gain . linearRampToValueAtTime ( peak , t + 0.002 ); 88g . gain . setTargetAtTime ( peak * floor , t + 0.002 , tau ); 89return g ; 90}; 91const pan = ( x :number ) => { 92const p = ctx . createStereoPanner (); 93p . pan . value = x ; 94p . connect ( release ); 95return p ; 96}; 97 98const brightness = ( 0.35 + 0.65 * vel ) * ( 1 - 0.4 * kt ); 99const bodyIndex = env ( f * 2 * brightness , 0.4 , 0.18 ); 100osc ( f ). connect ( bodyIndex ); 101const bodyLevel = 0.4 * ( 0.45 + 0.55 * vel ); 102for ( const [ detune , side ] of [ 103[ - 4 , - 0.3 ], 104[ 4 , 0.3 ], 105]) { 106const carrier = osc ( f , detune ); 107bodyIndex . connect ( carrier . frequency ); 108carrier . connect ( env ( bodyLevel , 2.4 * ( 1 - 0.5 * kt ))). connect ( pan ( side )); 109} 110 111const tine = osc ( f ); 112// up top the modulator would be past what the output can carry, so it's left out 113if ( f * 14 < ctx . sampleRate / 2 ) { 114const tineIndex = env ( f * 14 * 0.3 * vel * ( 1 - 0.3 * kt ), 0.035 ); 115osc ( f * 14 ). connect ( tineIndex ); 116tineIndex . connect ( tine . frequency ); 117} 118tine . connect ( env ( 0.28 * vel , 0.3 * ( 1 - 0.5 * kt ))). connect ( pan ( 0 )); 119 120tine . onended = () => { 121choke . disconnect (); 122voice . done = true ; 123voices . delete ( voice ); 124}; 125return voice ; 126} 127 128export function silence () :void { 129for ( const voice of voices ) voice . stop (); 130}