1import { getAudioContext, registerSound } from './index.mjs'; 2import { getBaseURL, getCommonSampleInfo } from './util.mjs'; 3import { 4 applyFM, 5 applyParameterModulators, 6 getADSRValues, 7 getFrequencyFromValue, 8 getParamADSR, 9 getPitchEnvelope, 10 getVibratoOscillator, 11 webAudioTimeout, 12 releaseAudioNode, 13} from './helpers.mjs'; 14import { getNodeFromPool, releaseNodeToPool } from './nodePools.mjs'; 15import { logger } from './logger.mjs'; 16 17export const Warpmode = Object.freeze({ 18 NONE: 0, 19 ASYM: 1, 20 MIRROR: 2, 21 BENDP: 3, 22 BENDM: 4, 23 BENDMP: 5, 24 SYNC: 6, 25 QUANT: 7, 26 FOLD: 8, 27 PWM: 9, 28 ORBIT: 10, 29 SPIN: 11, 30 CHAOS: 12, 31 PRIMES: 13, 32 BINARY: 14, 33 BROWNIAN: 15, 34 RECIPROCAL: 16, 35 WORMHOLE: 17, 36 LOGISTIC: 18, 37 SIGMOID: 19, 38 FRACTAL: 20, 39 FLIP: 21, 40}); 41 42const seenKeys = new Set(); 43 44export function resetSeenKeys() { 45 seenKeys.clear(); 46} 47 48async function getPayload(url, label, frameLen = 2048) { 49 const key = `${url},${frameLen}`; 50 if (!seenKeys.has(key)) { 51 const buf = await loadBuffer(url, label); 52 const ch0 = buf.getChannelData(0); 53 const total = ch0.length; 54 const numFrames = Math.max(1, Math.floor(total / frameLen)); 55 const frames = new Array(numFrames); 56 for (let i = 0; i < numFrames; i++) { 57 const start = i * frameLen; 58 frames[i] = ch0.subarray(start, start + frameLen); 59 } 60 seenKeys.add(key); 61 return { frames, frameLen, numFrames, key }; 62 } 63 return { frameLen, key }; // worklet will use the cached version 64} 65 66function humanFileSize(bytes, si) { 67 var thresh = si ? 1000 : 1024; 68 if (bytes < thresh) return bytes + ' B'; 69 var units = si 70 ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] 71 : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; 72 var u = -1; 73 do { 74 bytes /= thresh; 75 ++u; 76 } while (bytes >= thresh); 77 return bytes.toFixed(1) + ' ' + units[u]; 78}
Extract the sample rate of a .wav file
81function parseWavSampleRate(arrBuf) { 82 const dv = new DataView(arrBuf); 83 // Header is "RIFF<chunk size (4 bytes)>WAVE", so 12 bytes 84 let p = 12; 85 // Look through chunks for the format header 86 // (they will always have an 8 byte header (id and size) followed by a payload) 87 while (p + 8 <= dv.byteLength) { 88 // Parse id 89 const id = String.fromCharCode(dv.getUint8(p), dv.getUint8(p + 1), dv.getUint8(p + 2), dv.getUint8(p + 3)); 90 // Parse chunk size 91 const size = dv.getUint32(p + 4, true); 92 if (id === 'fmt ') { 93 // The format chunk contains the sample rate after 94 // 8 bytes of header, 2 bytes of format tag, 2 bytes of num channels 95 // (for a total of 12) 96 return dv.getUint32(p + 12, true); 97 } 98 // Advance to next chunk 99 p += 8 + size + (size & 1); 100 } 101 return null; 102}
104async function decodeAtNativeRate(arr) { 105 const sr = parseWavSampleRate(arr) || 44100; 106 const tempAC = new OfflineAudioContext(1, 1, sr); 107 return await tempAC.decodeAudioData(arr); 108} 109 110const loadCache = {}; 111const loadBuffer = (url, label) => { 112 url = url.replace('#', '%23'); 113 if (!loadCache[url]) { 114 logger(`[wavetable] load table ${label}..`, 'load-table', { url }); 115 const timestamp = Date.now(); 116 loadCache[url] = fetch(url) 117 .then((res) => res.arrayBuffer()) 118 .then(async (res) => { 119 const took = Date.now() - timestamp; 120 const size = humanFileSize(res.byteLength); 121 logger(`[wavetable] load table ${label}... done! loaded ${size} in ${took}ms`, 'loaded-table', { url }); 122 const decoded = await decodeAtNativeRate(res); 123 return decoded; 124 }); 125 } 126 return loadCache[url]; 127}; 128 129function githubPath(base, subpath = '') { 130 if (!base.startsWith('github:')) { 131 throw new Error('expected "github:" at the start of pseudoUrl'); 132 } 133 let [_, path] = base.split('github:'); 134 path = path.endsWith('/') ? path.slice(0, -1) : path; 135 if (path.split('/').length === 2) { 136 // assume main as default branch if none set 137 path += '/main'; 138 } 139 return `https://raw.githubusercontent.com/${path}/${subpath}`; 140} 141 142const _processTables = (json, baseUrl, frameLen, options = {}) => { 143 baseUrl = json._base || baseUrl; 144 return Object.entries(json).forEach(([key, tables]) => { 145 if (key === '_base') return false; 146 if (typeof tables === 'string') { 147 tables = [tables]; 148 } 149 if (typeof tables !== 'object') { 150 throw new Error('wrong json format for ' + key); 151 } 152 let resolvedUrl = baseUrl; 153 if (resolvedUrl.startsWith('github:')) { 154 resolvedUrl = githubPath(resolvedUrl, ''); 155 } 156 tables = tables 157 .map((t) => resolvedUrl + t) 158 .filter((t) => { 159 if (!t.toLowerCase().endsWith('.wav')) { 160 logger(`[wavetable] skipping ${t} -- wavetables must be ".wav" format`); 161 return false; 162 } 163 return true; 164 }); 165 if (tables.length) { 166 registerWaveTable(key, tables, { baseUrl, frameLen }); 167 } 168 }); 169}; 170 171export function registerWaveTable(key, tables, params) { 172 registerSound( 173 key, 174 (t, hapValue, onended, cps) => { 175 return onTriggerSynth(t, hapValue, onended, tables, cps, params?.frameLen ?? 2048); 176 }, 177 { 178 type: 'wavetable', 179 tables, 180 ...params, 181 }, 182 ); 183}
Loads a collection of wavetables to use with s
@name tables @tags wavetable
191export const tables = async (url, frameLen, json, options = {}) => { 192 if (json !== undefined) return _processTables(json, url, frameLen); 193 if (url.startsWith('github:')) { 194 url = githubPath(url, 'strudel.json'); 195 } 196 if (url.startsWith('local:')) { 197 url = `http://localhost:5432`; 198 } 199 const base = getBaseURL(url); 200 if (typeof fetch !== 'function') { 201 // not a browser 202 return; 203 } 204 if (typeof fetch === 'undefined') { 205 // skip fetch when in node / testing 206 return; 207 } 208 return fetch(url) 209 .then((res) => res.json()) 210 .then((json) => _processTables(json, base, frameLen, options)) 211 .catch((error) => { 212 console.error(error); 213 throw new Error(`error loading "${url}"`); 214 }); 215};
217export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { 218 const { s, n = 0, duration, clip } = value; 219 const ac = getAudioContext(); 220 const [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]); 221 let { warpmode } = value; 222 if (typeof warpmode === 'string') { 223 warpmode = Warpmode[warpmode.toUpperCase()] ?? Warpmode.NONE; 224 } 225 const frequency = getFrequencyFromValue(value); 226 const { url, label } = getCommonSampleInfo(value, tables); 227 const payload = await getPayload(url, label, frameLen); 228 let holdEnd = t + duration; 229 if (clip !== undefined) { 230 holdEnd = Math.min(t + clip * duration, holdEnd); 231 } 232 const endWithRelease = holdEnd + release; 233 const envEnd = endWithRelease + 0.01; 234 const params = { 235 begin: t, 236 end: envEnd, 237 frequency, 238 freqspread: value.detune, 239 position: value.wt, 240 warp: value.warp, 241 warpMode: warpmode, 242 voices: Math.max(value.unison ?? 1, 1), 243 panspread: value.spread, 244 phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0, 245 }; 246 const factory = () => new AudioWorkletNode(ac, 'wavetable-oscillator-processor', { outputChannelCount: [2] }); 247 const source = getNodeFromPool('wavetable', factory); 248 Object.entries(params).forEach(([key, value]) => { 249 const param = source.parameters.get(key); 250 const target = value !== undefined ? value : param.defaultValue; 251 param.value = target; 252 }); 253 source.port.postMessage({ type: 'initialize', payload }); 254 if (ac.currentTime > t) { 255 logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight'); 256 return; 257 } 258 const posADSRParams = [value.wtattack, value.wtdecay, value.wtsustain, value.wtrelease]; 259 const warpADSRParams = [value.warpattack, value.warpdecay, value.warpsustain, value.warprelease]; 260 const wtParams = source.parameters; 261 const positionParam = wtParams.get('position'); 262 const warpParam = wtParams.get('warp'); 263 264 let wtrate = value.wtrate; 265 if (value.wtsync != null) { 266 wtrate = cps * value.wtsync; 267 } 268 269 const wtPosModulators = applyParameterModulators( 270 ac, 271 positionParam, 272 t, 273 endWithRelease, 274 { 275 offset: value.wt, 276 amount: value.wtenv, 277 defaultAmount: 0.5, 278 shape: 'linear', 279 values: posADSRParams, 280 holdEnd, 281 defaultValues: [0, 0.5, 0, 0.1], 282 }, 283 { 284 frequency: wtrate, 285 depth: value.wtdepth, 286 defaultDepth: 0.5, 287 shape: value.wtshape, 288 skew: value.wtskew, 289 dcoffset: value.wtdc ?? 0, 290 }, 291 ); 292 293 let warprate = value.warprate; 294 if (value.warpsync != null) { 295 warprate = warprate = cps * value.warpsync; 296 } 297 const wtWarpModulators = applyParameterModulators( 298 ac, 299 warpParam, 300 t, 301 endWithRelease, 302 { 303 offset: value.warp, 304 amount: value.warpenv, 305 defaultAmount: 0.5, 306 shape: 'linear', 307 values: warpADSRParams, 308 holdEnd, 309 defaultValues: [0, 0.5, 0, 0.1], 310 }, 311 { 312 frequency: warprate, 313 depth: value.warpdepth, 314 defaultDepth: 0.5, 315 shape: value.warpshape, 316 skew: value.warpskew, 317 dcoffset: value.warpdc ?? 0, 318 }, 319 ); 320 const vibratoHandle = getVibratoOscillator(source.parameters.get('detune'), value, t); 321 const fmHandle = applyFM(source.parameters.get('frequency'), value, t); 322 const envGain = ac.createGain(); 323 const node = source.connect(envGain); 324 getParamADSR(node.gain, attack, decay, sustain, release, 0, 0.3, t, holdEnd, 'linear'); 325 getPitchEnvelope(source.parameters.get('detune'), value, t, holdEnd); 326 const handle = { 327 node, 328 nodes: { 329 source: [source], 330 wt_lfo: [wtPosModulators], 331 warp_lfo: [wtWarpModulators], 332 ...fmHandle?.nodes, 333 ...vibratoHandle?.nodes, 334 }, 335 }; 336 const timeoutNode = webAudioTimeout( 337 ac, 338 () => { 339 releaseNodeToPool(source); 340 vibratoHandle?.stop(); 341 fmHandle?.stop(); 342 releaseAudioNode(wtPosModulators); 343 releaseAudioNode(wtWarpModulators); 344 onended(); 345 }, 346 t, 347 envEnd, 348 ); 349 handle.stop = (time) => { 350 timeoutNode.stop(time); 351 }; 352 return handle; 353}