1import { getBaseURL, getCommonSampleInfo } from './util.mjs';
2import { registerSound, registerWaveTable, soundMap } from './index.mjs';
3import { getAudioContext } from './audioContext.mjs';
4import {
5  getADSRValues,
6  getParamADSR,
7  getPitchEnvelope,
8  getVibratoOscillator,
9  onceEnded,
10  releaseAudioNode,
11} from './helpers.mjs';
12import { logger } from './logger.mjs';
13
14const bufferCache = {}; // string: Promise<ArrayBuffer>
15const loadCache = {}; // string: Promise<ArrayBuffer>
16
17export const getCachedBuffer = (url) => bufferCache[url];
18
19function humanFileSize(bytes, si) {
20  var thresh = si ? 1000 : 1024;
21  if (bytes < thresh) return bytes + ' B';
22  var units = si
23    ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
24    : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
25  var u = -1;
26  do {
27    bytes /= thresh;
28    ++u;
29  } while (bytes >= thresh);
30  return bytes.toFixed(1) + ' ' + units[u];
31}
32
33/**
34 * Returns the duration, in seconds, of the given sample.
35 * Has optional param `n` (for instance, the `2` in `s("casio:2")`)
36 *
37 * Note: `must` be called with await, otherwise you'll get a pending Promise object.
38 *
39 * @name getDuration,getDur
40 * @tag samples
41 * @param {string} sampleName
42 * @param {number} (optional) n
43 *
44 * @example
45 * // Set a patterns cycle length to exactly the length of the sample
46 * samples('github:tidalcycles/dirt-samples')
47 * let k = await getDuration('sax')
48 * s("sax").cps(1/k)
49 */
50export const getDuration = (s, n = 0) => {
51  return getSampleBufferSource({ s, n }, soundMap.get(s)[s].data.samples).then((x) => x.bufferDuration);
52};
53
54export const getDur = getDuration;
55
56export function getSampleInfo(hapValue, bank) {
57  const { speed = 1.0 } = hapValue;
58  const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank);
59  let playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12);
60  return { transpose, url, index, midi, label, playbackRate };
61}
62
63// takes hapValue and returns buffer + playbackRate.
64export const getSampleBuffer = async (hapValue, bank, resolveUrl) => {
65  let { url: sampleUrl, label, playbackRate } = getSampleInfo(hapValue, bank);
66  if (resolveUrl) {
67    sampleUrl = await resolveUrl(sampleUrl);
68  }
69  const ac = getAudioContext();
70  const buffer = await loadBuffer(sampleUrl, ac, label);
71
72  if (hapValue.unit === 'c') {
73    playbackRate = playbackRate * buffer.duration;
74  }
75  return { buffer, playbackRate };
76};
77
78// creates playback ready AudioBufferSourceNode from hapValue
79export const getSampleBufferSource = async (hapValue, bank, resolveUrl) => {
80  let { buffer, playbackRate } = await getSampleBuffer(hapValue, bank, resolveUrl);
81  if (hapValue.speed < 0) {
82    // should this be cached?
83    buffer = reverseBuffer(buffer);
84  }
85  const ac = getAudioContext();
86  const bufferSource = ac.createBufferSource();
87  bufferSource.buffer = buffer;
88  bufferSource.playbackRate.value = playbackRate;
89
90  const { loopBegin = 0, loopEnd = 1, begin = 0, end = 1 } = hapValue;
91  const bufferDuration = bufferSource.buffer.duration;
92
93  // The computation of the offset into the sound is performed using the sound buffer's natural duration,
94  // rather than the playback duration, so that even if the sound is playing at twice its normal speed,
95  // the midway point through a 10-second audio buffer is still 5.
96  const offset = begin * bufferDuration;
97
98  const loop = hapValue.loop;
99  if (loop) {
100    bufferSource.loop = true;
101    bufferSource.loopStart = loopBegin * bufferDuration;
102    bufferSource.loopEnd = loopEnd * bufferDuration;
103  }
104  const playbackDuration = bufferDuration / bufferSource.playbackRate.value;
105  const sliceDuration = (end - begin) * playbackDuration;
106  return { bufferSource, offset, bufferDuration, playbackDuration, sliceDuration };
107};
108
109export const loadBuffer = (url, ac, s, n = 0) => {
110  const label = s ? `sound "${s}:${n}"` : 'sample';
111  url = url.replace('#', '%23');
112  if (!loadCache[url]) {
113    logger(`[sampler] load ${label}..`, 'load-sample', { url });
114    const timestamp = Date.now();
115    loadCache[url] = fetch(url)
116      .then((res) => res.arrayBuffer())
117      .then(async (res) => {
118        const took = Date.now() - timestamp;
119        const size = humanFileSize(res.byteLength);
120        // const downSpeed = humanFileSize(res.byteLength / took);
121        logger(`[sampler] load ${label}... done! loaded ${size} in ${took}ms`, 'loaded-sample', { url });
122        const decoded = await ac.decodeAudioData(res);
123        bufferCache[url] = decoded;
124        return decoded;
125      });
126  }
127  return loadCache[url];
128};
129
130export function reverseBuffer(buffer) {
131  const ac = getAudioContext();
132  const reversed = ac.createBuffer(buffer.numberOfChannels, buffer.length, ac.sampleRate);
133  for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
134    reversed.copyToChannel(buffer.getChannelData(channel).slice().reverse(), channel, channel);
135  }
136  return reversed;
137}
138
139export const getLoadedBuffer = (url) => {
140  return bufferCache[url];
141};
142
143function resolveSpecialPaths(base) {
144  if (base.startsWith('bubo:')) {
145    const [_, repo] = base.split(':');
146    base = `github:Bubobubobubobubo/dough-${repo}`;
147  }
148  return base;
149}
150
151function githubPath(base, subpath = '') {
152  if (!base.startsWith('github:')) {
153    throw new Error('expected "github:" at the start of pseudoUrl');
154  }
155  let path = base.slice('github:'.length);
156  path = path.endsWith('/') ? path.slice(0, -1) : path;
157
158  let components = path.split('/');
159  let user = components[0];
160  let repo = components.length >= 2 ? components[1] : 'samples';
161  let branch = components.length >= 3 ? components[2] : 'main';
162  let other = components.slice(3);
163  other.push(subpath ? subpath : '');
164  other = other.join('/');
165
166  return `https://raw.githubusercontent.com/${user}/${repo}/${branch}/${other}`;
167}
168
169export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '') => {
170  return Object.entries(sampleMap).forEach(([key, value]) => {
171    if (typeof value === 'string') {
172      value = [value];
173    }
174    if (typeof value !== 'object') {
175      throw new Error('wrong sample map format for ' + key);
176    }
177    baseUrl = value._base || baseUrl;
178    baseUrl = resolveSpecialPaths(baseUrl);
179    if (baseUrl.startsWith('github:')) {
180      baseUrl = githubPath(baseUrl, '');
181    }
182    const fullUrl = (v) => baseUrl + v;
183    if (Array.isArray(value)) {
184      //return [key, value.map(replaceUrl)];
185      value = value.map(fullUrl);
186    } else {
187      // must be object
188      value = Object.fromEntries(
189        Object.entries(value).map(([note, samples]) => {
190          return [note, (typeof samples === 'string' ? [samples] : samples).map(fullUrl)];
191        }),
192      );
193    }
194    fn(key, value);
195  });
196};
197
198// allows adding a custom url prefix handler
199// for example, it is used by the desktop app to load samples starting with '~/music'
200let resourcePrefixHandlers = {};
201export function registerSamplesPrefix(prefix, resolve) {
202  resourcePrefixHandlers[prefix] = resolve;
203}
204// finds a prefix handler for the given url (if any)
205function getSamplesPrefixHandler(url) {
206  const handler = Object.entries(resourcePrefixHandlers).find(([key]) => url.startsWith(key));
207  if (handler) {
208    return handler[1];
209  }
210  return;
211}
212
213export async function fetchSampleMap(url) {
214  // check if custom prefix handler
215  const handler = getSamplesPrefixHandler(url);
216  if (handler) {
217    return handler(url);
218  }
219  url = resolveSpecialPaths(url);
220  if (url.startsWith('github:')) {
221    url = githubPath(url, 'strudel.json');
222  }
223  if (url.startsWith('local:')) {
224    url = `http://localhost:5432`;
225  }
226  if (url.startsWith('shabda:')) {
227    let [_, path] = url.split('shabda:');
228    url = `https://shabda.ndre.gr/${path}.json?strudel=1`;
229  }
230  if (url.startsWith('shabda/speech')) {
231    let [_, path] = url.split('shabda/speech');
232    path = path.startsWith('/') ? path.substring(1) : path;
233    let [params, words] = path.split(':');
234    let gender = 'f';
235    let language = 'en-GB';
236    if (params) {
237      [language, gender] = params.split('/');
238    }
239    url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`;
240  }
241  if (typeof fetch !== 'function') {
242    // not a browser
243    return;
244  }
245  const base = getBaseURL(url);
246  if (typeof fetch === 'undefined') {
247    // skip fetch when in node / testing
248    return;
249  }
250  const json = await fetch(url)
251    .then((res) => res.json())
252    .catch((error) => {
253      console.error(error);
254      throw new Error(`error loading "${url}"`);
255    });
256  return [json, json._base || base];
257}
258
259/**
260 * Loads a collection of samples to use with `s`
261 * @tags samples
262 * @example
263 * samples('github:tidalcycles/dirt-samples');
264 * s("[bd ~]*2, [~ hh]*2, ~ sd")
265 * @example
266 * samples({
267 *  bd: '808bd/BD0000.WAV',
268 *  sd: '808sd/SD0010.WAV'
269 *  }, 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/');
270 * s("[bd ~]*2, [~ hh]*2, ~ sd")
271 */
272
273export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => {
274  if (typeof sampleMap === 'string') {
275    const [json, base] = await fetchSampleMap(sampleMap);
276    return samples(json, baseUrl || base, options);
277  }
278  const { prebake, tag } = options;
279
280  processSampleMap(
281    sampleMap,
282    (key, bank) => {
283      registerSampleSource(key, bank, { baseUrl, prebake, tag });
284    },
285    baseUrl,
286  );
287};
288
289const cutGroups = [];
290
291export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
292  let {
293    s,
294    nudge = 0, // TODO: is this in seconds?
295    cut,
296    loop,
297    clip = undefined, // if set, samples will be cut off when the hap ends
298    n = 0,
299    speed = 1, // sample playback speed
300    duration,
301  } = value;
302
303  // load sample
304  if (speed === 0) {
305    // no playback
306    return;
307  }
308  const ac = getAudioContext();
309
310  // destructure adsr here, because the default should be different for synths and samples
311  let [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]);
312
313  const { bufferSource, sliceDuration, offset } = await getSampleBufferSource(value, bank, resolveUrl);
314
315  if (!bufferSource) {
316    logger(`[sampler] could not load "${s}:${n}"`, 'error');
317    return;
318  }
319
320  // async stuff above took too long?
321  if (ac.currentTime > t) {
322    logger(`[sampler] loading sound "${s}:${n}" took too long`, 'highlight');
323    // AudioBufferSourceNode will never be used. discard it
324    releaseAudioNode(bufferSource);
325    return;
326  }
327
328  // vibrato
329  const vibratoHandle = getVibratoOscillator(bufferSource.detune, value, t);
330
331  const time = t + nudge;
332  bufferSource.start(time, offset);
333
334  const envGain = ac.createGain();
335  const node = bufferSource.connect(envGain);
336
337  // if none of these controls is set, the duration of the sound will be set to the duration of the sample slice
338  if (clip == null && loop == null && value.release == null) {
339    duration = sliceDuration;
340  }
341  let holdEnd = t + duration;
342
343  getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear');
344
345  // pitch envelope
346  getPitchEnvelope(bufferSource.detune, value, t, holdEnd);
347
348  const out = ac.createGain(); // we need a separate gain for the cutgroups because firefox...
349  node.connect(out);
350  onceEnded(bufferSource, function () {
351    releaseAudioNode(bufferSource);
352    vibratoHandle?.stop();
353    releaseAudioNode(node);
354    releaseAudioNode(out);
355    onended();
356  });
357  let envEnd = holdEnd + release + 0.01;
358  bufferSource.stop(envEnd);
359  const stop = (endTime) => {
360    bufferSource.stop(endTime);
361  };
362  const handle = { node: out, nodes: { source: [bufferSource], ...vibratoHandle?.nodes }, stop };
363
364  // cut groups
365  if (cut !== undefined) {
366    const prev = cutGroups[cut];
367    if (prev) {
368      prev.node.gain.setValueAtTime(1, time);
369      prev.node.gain.linearRampToValueAtTime(0, time + 0.01);
370    }
371    cutGroups[cut] = handle;
372  }
373
374  return handle;
375}
376
377function registerSample(key, bank, params) {
378  registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank), {
379    type: 'sample',
380    samples: bank,
381    ...params,
382  });
383}
384
385export function registerSampleSource(key, bank, params) {
386  const isWavetable = key.startsWith('wt_');
387  if (isWavetable) {
388    registerWaveTable(key, bank, params);
389  } else {
390    registerSample(key, bank, params);
391  }
392}