jevstrudel.git / website / src / jev / preload.mjs
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}