Files
strudel/packages/superdough/wavetable.mjs
T
2025-09-24 22:06:53 -07:00

252 lines
7.0 KiB
JavaScript

import { getAudioContext, registerSound } from './index.mjs';
import { clamp, getSoundIndex, valueToMidi } from './util.mjs';
import {
destroyAudioWorkletNode,
getADSRValues,
getFrequencyFromValue,
getParamADSR,
getPitchEnvelope,
getVibratoOscillator,
getWorklet,
webAudioTimeout,
} from './helpers.mjs';
import { logger } from './logger.mjs';
const WT_MAX_MIP_LEVELS = 6;
export const WarpMode = Object.freeze({
NONE: 0,
ASYM: 1,
MIRROR: 2,
BENDP: 3,
BENDM: 4,
BENDMP: 5,
SYNC: 6,
QUANT: 7,
FOLD: 8,
PWM: 9,
ORBIT: 10,
SPIN: 11,
CHAOS: 12,
PRIMES: 13,
BINARY: 14,
BROWNIAN: 15,
RECIPROCAL: 16,
WORMHOLE: 17,
LOGISTIC: 18,
SIGMOID: 19,
FRACTAL: 20,
FLIP: 21,
});
async function loadWavetableFrames(url, label, frameLen = 256) {
const ac = getAudioContext();
const buf = await loadBuffer(url, ac, label);
const ch0 = buf.getChannelData(0);
const total = ch0.length;
const numFrames = Math.floor(total / frameLen);
const frames = new Array(numFrames);
for (let i = 0; i < numFrames; i++) {
const start = i * frameLen;
frames[i] = ch0.subarray(start, start + frameLen);
}
// build mipmaps
const mipmaps = [frames];
let levelFrames = frames;
for (let level = 1; level < WT_MAX_MIP_LEVELS; level++) {
const prevLen = levelFrames[0].length;
if (prevLen <= 32) break;
const nextLen = prevLen >> 1;
const next = levelFrames.map((src) => {
const out = new Float32Array(nextLen);
for (let j = 0; j < nextLen; j++) {
out[j] = (src[2 * j] + src[2 * j + 1]) / 2;
}
return out;
});
mipmaps.push(next);
levelFrames = next;
}
return { frames, mipmaps, frameLen, numFrames };
}
const loadCache = {};
function humanFileSize(bytes, si) {
var thresh = si ? 1000 : 1024;
if (bytes < thresh) return bytes + ' B';
var units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
var u = -1;
do {
bytes /= thresh;
++u;
} while (bytes >= thresh);
return bytes.toFixed(1) + ' ' + units[u];
}
export function getTableInfo(hapValue, bank) {
const { wt, n = 0 } = hapValue;
let midi = valueToMidi(hapValue, 36);
let transpose = midi - 36; // C3 is middle C;
const index = getSoundIndex(n, bank.length);
const tableUrl = bank[index];
const label = `${wt}:${index}`;
return { transpose, tableUrl, index, midi, label };
}
const loadBuffer = (url, ac, wt, n = 0) => {
const label = wt ? `table "${wt}:${n}"` : 'table';
url = url.replace('#', '%23');
if (!loadCache[url]) {
logger(`[wavetable] load ${label}..`, 'load-table', { url });
const timestamp = Date.now();
loadCache[url] = fetch(url)
.then((res) => res.arrayBuffer())
.then(async (res) => {
const took = Date.now() - timestamp;
const size = humanFileSize(res.byteLength);
logger(`[wavetable] load ${label}... done! loaded ${size} in ${took}ms`, 'loaded-table', { url });
const decoded = await ac.decodeAudioData(res);
return decoded;
});
}
return loadCache[url];
};
function githubPath(base, subpath = '') {
if (!base.startsWith('github:')) {
throw new Error('expected "github:" at the start of pseudoUrl');
}
let [_, path] = base.split('github:');
path = path.endsWith('/') ? path.slice(0, -1) : path;
if (path.split('/').length === 2) {
// assume main as default branch if none set
path += '/main';
}
return `https://raw.githubusercontent.com/${path}/${subpath}`;
}
const _processTables = (json, baseUrl, frameLen) => {
return Object.entries(json).forEach(([key, value]) => {
if (typeof value === 'string') {
value = [value];
}
if (typeof value !== 'object') {
throw new Error('wrong json format for ' + key);
}
baseUrl = value._base || baseUrl;
if (baseUrl.startsWith('github:')) {
baseUrl = githubPath(baseUrl, '');
}
value = value.map((v) => baseUrl + v);
registerSound(key, (t, hapValue, onended) => onTriggerSynth(t, hapValue, onended, value, frameLen), {
type: 'wavetable',
tables: value,
baseUrl,
frameLen,
});
});
};
/**
* Loads a collection of wavetables to use with `wt`
*
* @name tables
*/
export const tables = async (url, frameLen, json) => {
if (json !== undefined) return _processTables(json, url, frameLen);
if (url.startsWith('github:')) {
url = githubPath(url, 'strudel.json');
}
if (url.startsWith('local:')) {
url = `http://localhost:5432`;
}
if (typeof fetch !== 'function') {
// not a browser
return;
}
const base = url.split('/').slice(0, -1).join('/');
if (typeof fetch === 'undefined') {
// skip fetch when in node / testing
return;
}
return fetch(url)
.then((res) => res.json())
.then((json) => _processTables(json, url, frameLen))
.catch((error) => {
console.error(error);
throw new Error(`error loading "${url}"`);
});
};
async function onTriggerSynth(t, value, onended, bank, frameLen) {
let { s, n = 0, duration } = value;
const ac = getAudioContext();
let [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]);
let sourceDesc, holdEnd, envEnd;
let { unison, spread, detune, wtPos, wtWarp, wtWarpMode } = value;
if (typeof wtWarpMode === 'string') {
wtWarpMode = WarpMode[wtWarpMode.toUpperCase()] ?? WarpMode.NONE;
}
const frequency = getFrequencyFromValue(value);
let { tableUrl, label } = getTableInfo(value, bank);
const payload = await loadWavetableFrames(tableUrl, label, frameLen);
holdEnd = t + duration;
envEnd = holdEnd + release + 0.01;
const worklet = getWorklet(
ac,
'wavetable-oscillator-processor',
{
begin: t,
end: envEnd,
frequency,
detune,
position: wtPos,
warp: wtWarp,
warpMode: wtWarpMode,
voices: unison,
spread,
},
{ outputChannelCount: [2] },
);
worklet.port.postMessage({ type: 'tables', payload });
sourceDesc = { source: worklet };
const { source } = sourceDesc;
if (ac.currentTime > t) {
logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight');
return;
}
if (!source) {
logger(`[wavetable] could not load "${s}:${n}"`, 'error');
return;
}
let vibratoOscillator = getVibratoOscillator(source.detune, value, t);
const envGain = ac.createGain();
const node = source.connect(envGain);
getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear');
getPitchEnvelope(source.detune, value, t, holdEnd);
const out = ac.createGain(); // we need a separate gain for the cutgroups because firefox...
node.connect(out);
let handle = { node: out, bufferSource: source, oscillator: worklet };
let timeoutNode = webAudioTimeout(
ac,
() => {
source.disconnect();
destroyAudioWorkletNode(source);
vibratoOscillator?.stop();
node.disconnect();
out.disconnect();
onended();
},
t,
envEnd,
);
handle.stop = (time) => {
timeoutNode.stop(time);
};
return handle;
}