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)
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'
finds a prefix handler for the given url (if any)
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
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]);
async stuff above took too long?
vibrato
329 const vibratoHandle = getVibratoOscillator(bufferSource.detune, value, t);
if none of these controls is set, the duration of the sound will be set to the duration of the sample slice
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
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}