1// A song's samples, loaded before its first bar. superdough fetches a 2// sample the first time a note plays it, and a note whose file is still 3// loading when it is due is dropped ("skip hap: still loading"), so on a 4// first listen a song's first hit of each clip could go silent. The 5// scheduler awaits `beforeStart` (useReplContext) before its first tick; 6// by then the song is evaluated and its sample maps registered, so every 7// sample the song plays as written starts loading, from the URL its first 8// hit will resolve (loadBuffer caches by URL). The song waits only for what 9// its first few cycles play; the rest loads while it plays, well before 10// it is needed. 11// 12// The song is queried as written (jevCore's asWritten): a plain query of a 13// section that has not begun would ask Jev about it. Sections Jev may 14// repeat or skip use the same sounds as the written ones. 15import { getAudioContext, getSampleInfo, loadBuffer, soundMap } from '@strudel/webaudio'; 16import { asWritten } from './jevCore.mjs'; 17 18const WAIT_MS = 8000; // a slow network starts the song late, and never silent 19const SOON = 4; // cycles: a sample first heard this early is waited for 20const CHUNK = 16; // cycles per query 21const MAX_CYCLES = 256; // a song that never ends (no jev(), or a loop) stops here 22 23// Every sample (bank and variant) the haps play, with the cycle it is first 24// heard at. 25export function samplesIn(haps, banks) { 26 const wanted = new Map(); 27 for (const hap of haps) { 28 const { value } = hap; 29 if (typeof value !== 'object' || value === null || value.s === undefined) continue; 30 const s = value.bank ? `${value.bank}_${value.s}` : String(value.s); 31 if (!Object.hasOwn(banks, s)) continue; 32 const n = Number(value.n ?? 0); 33 const at = hap.whole?.begin.valueOf() ?? 0; 34 const key = `${s}:${n}`; 35 const seen = wanted.get(key); 36 if (!seen || at < seen.at) wanted.set(key, { s, n, at }); 37 } 38 return [...wanted.values()]; 39} 40 41// The song's haps as written, until two chunks in a row play nothing; none 42// when it cannot be read ahead (useReplContext hands them to the preload 43// and to jevCore's setWrittenParts). 44export function songAsWritten(pattern) { 45 if (!pattern) return []; 46 try { 47 return writtenHaps(pattern); 48 } catch (e) { 49 console.warn('[jev] could not read the song ahead; samples load as they play', e); 50 return []; 51 } 52} 53 54function writtenHaps(pattern) { 55 const haps = []; 56 let empty = 0; 57 for (let from = 0; from < MAX_CYCLES && empty < 2; from += CHUNK) { 58 const chunk = asWritten(() => pattern.queryArc(from, from + CHUNK)); 59 empty = chunk.length ? 0 : empty + 1; 60 haps.push(...chunk); 61 } 62 return haps; 63} 64 65export async function preloadSamples(haps) { 66 if (!haps.length) return; 67 const banks = Object.fromEntries( 68 Object.entries(soundMap.get()) 69 .filter(([, sound]) => sound.data?.type === 'sample' && sound.data.samples) 70 .map(([name, sound]) => [name, sound.data.samples]), 71 ); 72 const wanted = samplesIn(haps, banks); 73 const ac = getAudioContext(); 74 // first heard first, so the ones waited for are fetched ahead of the rest 75 wanted.sort((a, b) => a.at - b.at); 76 const loads = wanted.map(({ s, n, at }) => ({ 77 at, 78 loading: loadBuffer(getSampleInfo({ s, n }, banks[s]).url, ac, s, n).catch(() => {}), 79 })); 80 const soon = loads.filter((l) => l.at < SOON).map((l) => l.loading); 81 if (!soon.length) return; 82 let timer; 83 await Promise.race([Promise.all(soon), new Promise((r) => (timer = setTimeout(r, WAIT_MS)))]); 84 clearTimeout(timer); 85}