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}

Returns the duration, in seconds, of the given sample. Has optional param n (for instance, the 2 in s("casio:2"))

Note: must be called with await, otherwise you'll get a pending Promise object.

@name getDuration,getDur @tag samples @param {string} sampleName @param {number} (optional) n

@example // Set a patterns cycle length to exactly the length of the sample samples('github:tidalcycles/dirt-samples') let k = await getDuration('sax') s("sax").cps(1/k)

50export const getDuration = (s, n = 0) => {
51  return getSampleBufferSource({ s, n }, soundMap.get(s)[s].data.samples).then((x) => x.bufferDuration);
52};
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}

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};

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;

The computation of the offset into the sound is performed using the sound buffer's natural duration, rather than the playback duration, so that even if the sound is playing at twice its normal speed, the midway point through a 10-second audio buffer is still 5.

96  const offset = begin * bufferDuration;
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};

allows adding a custom url prefix handler 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}

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}
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}

Loads a collection of samples to use with s @tags samples @example samples('github:tidalcycles/dirt-samples'); s("[bd ]*2, [ hh]*2, ~ sd") @example samples({ bd: '808bd/BD0000.WAV', sd: '808sd/SD0010.WAV' }, 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/'); s("[bd ]*2, [ hh]*2, ~ sd")

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};
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;

load sample

304  if (speed === 0) {
305    // no playback
306    return;
307  }
308  const ac = getAudioContext();

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]);
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  }

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  }

vibrato

329  const vibratoHandle = getVibratoOscillator(bufferSource.detune, value, t);
331  const time = t + nudge;
332  bufferSource.start(time, offset);
333
334  const envGain = ac.createGain();
335  const node = bufferSource.connect(envGain);

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;
343  getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear');

pitch envelope

346  getPitchEnvelope(bufferSource.detune, value, t, holdEnd);
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 };

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  }
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}