jevstrudel.git / packages / superdough / wavetable.mjs
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}