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}