import { getAudioContext, registerSound } from './index.mjs';
import { getCommonSampleInfo } from './util.mjs';
import {
applyFM,
applyParameterModulators,
destroyAudioWorkletNode,
getADSRValues,
getFrequencyFromValue,
getParamADSR,
getPitchEnvelope,
getVibratoOscillator,
getWorklet,
webAudioTimeout,
} from './helpers.mjs';
import { logger } from './logger.mjs';
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,
});
const seenKeys = new Set();
async function getPayload(url, label, frameLen = 2048) {
const key = `${url},${frameLen}`;
if (!seenKeys.has(key)) {
const buf = await loadBuffer(url, label);
const ch0 = buf.getChannelData(0);
const total = ch0.length;
const numFrames = Math.max(1, 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);
}
seenKeys.add(key);
return { frames, frameLen, numFrames, key };
}
return { frameLen, key }; // worklet will use the cached version
}
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];
}
// Extract the sample rate of a .wav file
function parseWavSampleRate(arrBuf) {
const dv = new DataView(arrBuf);
// Header is "RIFF<chunk size (4 bytes)>WAVE", so 12 bytes
let p = 12;
// Look through chunks for the format header
// (they will always have an 8 byte header (id and size) followed by a payload)
while (p + 8 <= dv.byteLength) {
// Parse id
const id = String.fromCharCode(dv.getUint8(p), dv.getUint8(p + 1), dv.getUint8(p + 2), dv.getUint8(p + 3));
// Parse chunk size
const size = dv.getUint32(p + 4, true);
if (id === 'fmt ') {
// The format chunk contains the sample rate after
// 8 bytes of header, 2 bytes of format tag, 2 bytes of num channels
// (for a total of 12)
return dv.getUint32(p + 12, true);
}
// Advance to next chunk
p += 8 + size + (size & 1);
}
return null;
}
async function decodeAtNativeRate(arr) {
const sr = parseWavSampleRate(arr) || 44100;
const tempAC = new OfflineAudioContext(1, 1, sr);
return await tempAC.decodeAudioData(arr);
}
const loadCache = {};
const loadBuffer = (url, label) => {
url = url.replace('#', '%23');
if (!loadCache[url]) {
logger(`[wavetable] load table ${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 table ${label}... done! loaded ${size} in ${took}ms`, 'loaded-table', { url });
const decoded = await decodeAtNativeRate(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, options = {}) => {
baseUrl = json._base || baseUrl;
return Object.entries(json).forEach(([key, tables]) => {
if (key === '_base') return false;
if (typeof tables === 'string') {
tables = [tables];
}
if (typeof tables !== 'object') {
throw new Error('wrong json format for ' + key);
}
let resolvedUrl = baseUrl;
if (resolvedUrl.startsWith('github:')) {
resolvedUrl = githubPath(resolvedUrl, '');
}
tables = tables
.map((t) => resolvedUrl + t)
.filter((t) => {
if (!t.toLowerCase().endsWith('.wav')) {
logger(`[wavetable] skipping ${t} -- wavetables must be ".wav" format`);
return false;
}
return true;
});
if (tables.length) {
registerWaveTable(key, tables, { baseUrl, frameLen });
}
});
};
export function registerWaveTable(key, tables, params) {
registerSound(
key,
(t, hapValue, onended, cps) => {
return onTriggerSynth(t, hapValue, onended, tables, cps, params?.frameLen ?? 2048);
},
{
type: 'wavetable',
tables,
...params,
},
);
}
/**
* Loads a collection of wavetables to use with `s`
*
* @name tables
*/
export const tables = async (url, frameLen, json, options = {}) => {
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;
}
if (typeof fetch === 'undefined') {
// skip fetch when in node / testing
return;
}
return fetch(url)
.then((res) => res.json())
.then((json) => _processTables(json, url, frameLen, options))
.catch((error) => {
console.error(error);
throw new Error(`error loading "${url}"`);
});
};
export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
const { s, n = 0, duration, clip } = value;
const ac = getAudioContext();
const [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]);
let { warpmode } = value;
if (typeof warpmode === 'string') {
warpmode = Warpmode[warpmode.toUpperCase()] ?? Warpmode.NONE;
}
const frequency = getFrequencyFromValue(value);
const { url, label } = getCommonSampleInfo(value, tables);
const payload = await getPayload(url, label, frameLen);
let holdEnd = t + duration;
if (clip !== undefined) {
holdEnd = Math.min(t + clip * duration, holdEnd);
}
const endWithRelease = holdEnd + release;
const envEnd = endWithRelease + 0.01;
const source = getWorklet(
ac,
'wavetable-oscillator-processor',
{
begin: t,
end: envEnd,
frequency,
freqspread: value.detune,
position: value.wt,
warp: value.warp,
warpMode: warpmode,
voices: Math.max(value.unison ?? 1, 1),
panspread: value.spread,
phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0,
},
{ outputChannelCount: [2] },
);
source.port.postMessage({ type: 'table', payload });
if (ac.currentTime > t) {
logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight');
return;
}
const posADSRParams = [value.wtattack, value.wtdecay, value.wtsustain, value.wtrelease];
const warpADSRParams = [value.warpattack, value.warpdecay, value.warpsustain, value.warprelease];
const wtParams = source.parameters;
const positionParam = wtParams.get('position');
const warpParam = wtParams.get('warp');
let wtrate = value.wtrate;
if (value.wtsync != null) {
wtrate = cps * value.wtsync;
}
const wtPosModulators = applyParameterModulators(
ac,
positionParam,
t,
endWithRelease,
{
offset: value.wt,
amount: value.wtenv,
defaultAmount: 0.5,
shape: 'linear',
values: posADSRParams,
holdEnd,
defaultValues: [0, 0.5, 0, 0.1],
},
{
frequency: wtrate,
depth: value.wtdepth,
defaultDepth: 0.5,
shape: value.wtshape,
skew: value.wtskew,
dcoffset: value.wtdc ?? 0,
},
);
let warprate = value.warprate;
if (value.warpsync != null) {
warprate = warprate = cps * value.warpsync;
}
const wtWarpModulators = applyParameterModulators(
ac,
warpParam,
t,
endWithRelease,
{
offset: value.warp,
amount: value.warpenv,
defaultAmount: 0.5,
shape: 'linear',
values: warpADSRParams,
holdEnd,
defaultValues: [0, 0.5, 0, 0.1],
},
{
frequency: warprate,
depth: value.warpdepth,
defaultDepth: 0.5,
shape: value.warpshape,
skew: value.warpskew,
dcoffset: value.warpdc ?? 0,
},
);
const vibratoOscillator = getVibratoOscillator(source.parameters.get('detune'), value, t);
const fm = applyFM(source.parameters.get('frequency'), value, t);
const envGain = ac.createGain();
const node = source.connect(envGain);
getParamADSR(node.gain, attack, decay, sustain, release, 0, 0.3, t, holdEnd, 'linear');
getPitchEnvelope(source.parameters.get('detune'), value, t, holdEnd);
const handle = { node, source };
const timeoutNode = webAudioTimeout(
ac,
() => {
destroyAudioWorkletNode(source);
vibratoOscillator?.stop();
fm?.stop();
node.disconnect();
wtPosModulators?.disconnect();
wtWarpModulators?.disconnect();
onended();
},
t,
envEnd,
);
handle.stop = (time) => {
timeoutNode.stop(time);
};
return handle;
}