Merge branch 'main' into glossing/random

This commit is contained in:
Aria 2025-11-16 14:42:20 -06:00
commit aca500ba73
143 changed files with 13535 additions and 7573 deletions

View file

@ -153,6 +153,7 @@ samples('github:tidalcycles/dirt-samples')
The format is `github:<user>/<repo>/<branch>`.
If `<repo>` and `<branch>` are not specified, they will default to `samples` and `main` respectively.
It expects a `strudel.json` file to be present at the root of the given repository, which declares the sample paths in the repo.
The format is also expected to be the same as explained above.

View file

@ -0,0 +1,18 @@
let audioContext;
export const setDefaultAudioContext = () => {
audioContext = new AudioContext();
return audioContext;
};
export const getAudioContext = () => {
if (!audioContext) {
return setDefaultAudioContext();
}
return audioContext;
};
export function getAudioContextCurrentTime() {
return getAudioContext().currentTime;
}

View file

@ -1,4 +1,4 @@
import { getAudioContext } from './superdough.mjs';
import { getAudioContext } from './audioContext.mjs';
let worklet;
export async function dspWorklet(ac, code) {

View file

@ -1,6 +1,7 @@
import { getAudioContext } from './superdough.mjs';
import { clamp, nanFallback } from './util.mjs';
import { getAudioContext } from './audioContext.mjs';
import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
import { getNoiseBuffer } from './noise.mjs';
import { logger } from './logger.mjs';
export const noises = ['pink', 'white', 'brown', 'crackle'];
@ -10,6 +11,13 @@ export function gainNode(value) {
return node;
}
export function effectSend(input, effect, wet) {
const send = gainNode(wet);
input.connect(send);
send.connect(effect);
return send;
}
const getSlope = (y1, y2, x1, x2) => {
const denom = x2 - x1;
if (denom === 0) {
@ -21,7 +29,9 @@ const getSlope = (y1, y2, x1, x2) => {
export function getWorklet(ac, processor, params, config) {
const node = new AudioWorkletNode(ac, processor, config);
Object.entries(params).forEach(([key, value]) => {
node.parameters.get(key).value = value;
if (value !== undefined) {
node.parameters.get(key).value = value;
}
});
return node;
}
@ -88,6 +98,35 @@ export const getParamADSR = (
param[ramp](min, end + release);
};
function getModulationShapeInput(val) {
if (typeof val === 'number') {
return val % 5;
}
return { tri: 0, triangle: 0, sine: 1, ramp: 2, saw: 3, square: 4 }[val] ?? 0;
}
export function getLfo(audioContext, begin, end, properties = {}) {
const { shape = 0, ...props } = properties;
const { dcoffset = -0.5, depth = 1 } = properties;
const lfoprops = {
frequency: 1,
depth,
skew: 0.5,
phaseoffset: 0,
time: begin,
begin,
end,
shape: getModulationShapeInput(shape),
dcoffset,
min: dcoffset * depth,
max: dcoffset * depth + depth,
curve: 1,
...props,
};
return getWorklet(audioContext, 'lfo-processor', lfoprops);
}
export function getCompressor(ac, threshold, ratio, knee, attack, release) {
const options = {
threshold: threshold ?? -3,
@ -115,36 +154,92 @@ export const getADSRValues = (params, curve = 'linear', defaultValues) => {
return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)];
};
export function createFilter(context, type, frequency, Q, att, dec, sus, rel, fenv, start, end, fanchor, model, drive) {
const curve = 'exponential';
const [attack, decay, sustain, release] = getADSRValues([att, dec, sus, rel], curve, [0.005, 0.14, 0, 0.1]);
let filter;
let frequencyParam;
export function getParamLfo(audioContext, param, start, end, lfoValues) {
let { defaultDepth = 1, depth, dcoffset, ...getLfoInputs } = lfoValues;
if (depth == null) {
const hasLFOParams = Object.values(getLfoInputs).some((v) => v != null);
depth = hasLFOParams ? defaultDepth : 0;
}
let lfo;
if (depth) {
lfo = getLfo(audioContext, start, end, {
depth,
dcoffset,
...getLfoInputs,
});
lfo.connect(param);
}
return lfo;
}
// helper utility for applying standard modulators to a parameter
export function applyParameterModulators(audioContext, param, start, end, envelopeValues, lfoValues) {
let { amount, offset, defaultAmount = 1, curve = 'linear', values, holdEnd, defaultValues } = envelopeValues;
if (amount == null) {
const hasADSRParams = values.some((p) => p != null);
amount = hasADSRParams ? defaultAmount : 0;
}
const min = offset ?? 0;
const max = amount + min;
const diff = Math.abs(max - min);
if (diff) {
const [attack, decay, sustain, release] = getADSRValues(values, curve, defaultValues);
getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve);
}
const lfo = getParamLfo(audioContext, param, start, end, lfoValues);
return { lfo, disconnect: () => lfo?.disconnect() };
}
export function createFilter(context, start, end, params, cps) {
let {
frequency,
anchor,
env,
type,
model,
q = 1,
drive = 0.69,
depth,
dcoffset = -0.5,
skew,
shape,
rate,
sync,
} = params;
let frequencyParam, filter;
if (model === 'ladder') {
filter = getWorklet(context, 'ladder-processor', { frequency, q: Q, drive });
filter = getWorklet(context, 'ladder-processor', { frequency, q, drive });
frequencyParam = filter.parameters.get('frequency');
} else {
filter = context.createBiquadFilter();
filter.type = type;
filter.Q.value = Q;
filter.Q.value = q;
filter.frequency.value = frequency;
frequencyParam = filter.frequency;
}
const envelopeValues = [params.attack, params.decay, params.sustain, params.release];
const [attack, decay, sustain, release] = getADSRValues(envelopeValues, 'exponential', [0.005, 0.14, 0, 0.1]);
// envelope is active when any of these values is set
const hasEnvelope = att ?? dec ?? sus ?? rel ?? fenv;
const hasEnvelope = [...envelopeValues, env].some((v) => v !== undefined);
// Apply ADSR to filter frequency
if (hasEnvelope !== undefined) {
fenv = nanFallback(fenv, 1, true);
fanchor = nanFallback(fanchor, 0, true);
const fenvAbs = Math.abs(fenv);
const offset = fenvAbs * fanchor;
if (hasEnvelope) {
env = nanFallback(env, 1, true);
anchor = nanFallback(anchor, 0, true);
const envAbs = Math.abs(env);
const offset = envAbs * anchor;
let min = clamp(2 ** -offset * frequency, 0, 20000);
let max = clamp(2 ** (fenvAbs - offset) * frequency, 0, 20000);
if (fenv < 0) [min, max] = [max, min];
getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, curve);
return filter;
let max = clamp(2 ** (envAbs - offset) * frequency, 0, 20000);
if (env < 0) [min, max] = [max, min];
getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, 'exponential');
}
if (sync != null) {
rate = cps * sync;
}
const lfoValues = { depth, dcoffset, skew, shape, frequency: rate };
getParamLfo(context, frequencyParam, start, end, lfoValues);
return filter;
}
@ -174,7 +269,7 @@ let curves = ['linear', 'exponential'];
export function getPitchEnvelope(param, value, t, holdEnd) {
// envelope is active when any of these values is set
const hasEnvelope = value.pattack ?? value.pdecay ?? value.psustain ?? value.prelease ?? value.penv;
if (!hasEnvelope) {
if (hasEnvelope === undefined) {
return;
}
const penv = nanFallback(value.penv, 1, true);
@ -206,6 +301,11 @@ export function getVibratoOscillator(param, value, t) {
return vibratoOscillator;
}
}
export function scheduleAtTime(callback, targetTime, audioContext = getAudioContext()) {
const currentTime = audioContext.currentTime;
webAudioTimeout(audioContext, callback, currentTime, targetTime);
}
// ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities
// a bit of a hack, but it works very well :)
export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
@ -307,3 +407,121 @@ export function applyFM(param, value, begin) {
}
return { stop };
}
// Saturation curves
const __squash = (x) => x / (1 + x); // [0, inf) to [0, 1)
const _mod = (n, m) => ((n % m) + m) % m;
const _scurve = (x, k) => ((1 + k) * x) / (1 + k * Math.abs(x));
const _soft = (x, k) => Math.tanh(x * (1 + k));
const _hard = (x, k) => clamp((1 + k) * x, -1, 1);
const _fold = (x, k) => {
// Closed form folding for audio rate
let y = (1 + 0.5 * k) * x;
const window = _mod(y + 1, 4);
return 1 - Math.abs(window - 2);
};
const _sineFold = (x, k) => Math.sin((Math.PI / 2) * _fold(x, k));
const _cubic = (x, k) => {
const t = __squash(Math.log1p(k));
const cubic = (x - (t / 3) * x * x * x) / (1 - t / 3); // normalized to go from (-1, 1)
return _soft(cubic, k);
};
const _diode = (x, k, asym = false) => {
const g = 1 + 2 * k; // gain
const t = __squash(Math.log1p(k));
const bias = 0.07 * t;
const pos = _soft(x + bias, 2 * k);
const neg = _soft(asym ? bias : -x + bias, 2 * k);
const y = pos - neg;
// We divide by the derivative at 0 so that the distortion is roughly
// the identity map near 0 => small values are preserved and undistorted
const sech = 1 / Math.cosh(g * bias);
const sech2 = sech * sech; // derivative of soft (i.e. tanh) is sech^2
const denom = Math.max(1e-8, (asym ? 1 : 2) * g * sech2); // g from chain rule; 2 if both pos/neg have x
return _soft(y / denom, k);
};
const _asym = (x, k) => _diode(x, k, true);
const _chebyshev = (x, k) => {
const kl = 10 * Math.log1p(k);
let tnm1 = 1;
let tnm2 = x;
let tn;
let y = 0;
for (let i = 1; i < 64; i++) {
if (i < 2) {
// Already set inital conditions
y += i == 0 ? tnm1 : tnm2;
continue;
}
tn = 2 * x * tnm1 - tnm2; // https://en.wikipedia.org/wiki/Chebyshev_polynomials#Recurrence_definition
tnm2 = tnm1;
tnm1 = tn;
if (i % 2 === 0) {
y += Math.min((1.3 * kl) / i, 2) * tn;
}
}
// Soft clip
return _soft(y, kl / 20);
};
export const distortionAlgorithms = {
scurve: _scurve,
soft: _soft,
hard: _hard,
cubic: _cubic,
diode: _diode,
asym: _asym,
fold: _fold,
sinefold: _sineFold,
chebyshev: _chebyshev,
};
const _algoNames = Object.freeze(Object.keys(distortionAlgorithms));
export const getDistortionAlgorithm = (algo) => {
let index = algo;
if (typeof algo === 'string') {
index = _algoNames.indexOf(algo);
if (index === -1) {
logger(`[superdough] Could not find waveshaping algorithm ${algo}.
Available options are ${_algoNames.join(', ')}.
Defaulting to ${_algoNames[0]}.`);
index = 0;
}
}
const name = _algoNames[index % _algoNames.length]; // allow for wrapping if algo was a number
return distortionAlgorithms[name];
};
export const getDistortion = (distort, postgain, algorithm) => {
return getWorklet(getAudioContext(), 'distort-processor', { distort, postgain }, { processorOptions: { algorithm } });
};
export const getFrequencyFromValue = (value, defaultNote = 36) => {
let { note, freq } = value;
note = note || defaultNote;
if (typeof note === 'string') {
note = noteToMidi(note); // e.g. c3 => 48
}
// get frequency
if (!freq && typeof note === 'number') {
freq = midiToFreq(note); // + 48);
}
return Number(freq);
};
export const destroyAudioWorkletNode = (node) => {
if (node == null) {
return;
}
node.disconnect();
node.parameters.get('end')?.setValueAtTime(0, 0);
};

View file

@ -11,3 +11,5 @@ export * from './synth.mjs';
export * from './zzfx.mjs';
export * from './logger.mjs';
export * from './dspworklet.mjs';
export * from './audioContext.mjs';
export * from './wavetable.mjs';

View file

@ -1,8 +1,9 @@
let log = (msg) => console.log(msg);
export function errorLogger(e, origin = 'cyclist') {
//TODO: add some kind of debug flag that enables this while in dev mode
// console.error(e);
export function errorLogger(e, origin = 'superdough') {
if (process.env.NODE_ENV === 'development') {
console.error(e);
}
logger(`[${origin}] error: ${e.message}`);
}

View file

@ -1,5 +1,5 @@
import { drywet } from './helpers.mjs';
import { getAudioContext } from './superdough.mjs';
import { getAudioContext } from './audioContext.mjs';
let noiseCache = {};

View file

@ -1,6 +1,6 @@
{
"name": "superdough",
"version": "1.2.4",
"version": "1.2.6",
"description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.",
"main": "index.mjs",
"type": "module",
@ -37,5 +37,8 @@
},
"dependencies": {
"nanostores": "^0.11.3"
},
"engines": {
"node": ">=18.0.0"
}
}

View file

@ -1,7 +1,9 @@
import reverbGen from './reverbGen.mjs';
import { clamp } from './util.mjs';
if (typeof AudioContext !== 'undefined') {
AudioContext.prototype.adjustLength = function (duration, buffer) {
AudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) {
const sampleOffset = Math.floor(clamp(offsetAmount, 0, 1) * buffer.length);
const newLength = buffer.sampleRate * duration;
const newBuffer = this.createBuffer(buffer.numberOfChannels, buffer.length, buffer.sampleRate);
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
@ -9,22 +11,30 @@ if (typeof AudioContext !== 'undefined') {
let newData = newBuffer.getChannelData(channel);
for (let i = 0; i < newLength; i++) {
newData[i] = oldData[i] || 0;
// loop the buffer around to prevent
let position = (sampleOffset + i * Math.abs(speed)) % oldData.length;
if (speed < 1) {
position = position * -1;
}
newData[i] = oldData.at(position) || 0;
}
}
return newBuffer;
};
AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir) {
AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) {
const convolver = this.createConvolver();
convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir) => {
convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir, irspeed, irbegin) => {
convolver.duration = d;
convolver.fade = fade;
convolver.lp = lp;
convolver.dim = dim;
convolver.ir = ir;
convolver.irspeed = irspeed;
convolver.irbegin = irbegin;
if (ir) {
convolver.buffer = this.adjustLength(d, ir);
convolver.buffer = this.adjustLength(d, ir, irspeed, irbegin);
} else {
reverbGen.generateReverb(
{
@ -41,7 +51,7 @@ if (typeof AudioContext !== 'undefined') {
);
}
};
convolver.generate(duration, fade, lp, dim, ir);
convolver.generate(duration, fade, lp, dim, ir, irspeed, irbegin);
return convolver;
};
}

View file

@ -104,6 +104,8 @@ var applyGradualLowpass = function (input, lpFreqStart, lpFreqEnd, lpFreqEndAt,
player.start();
context.oncomplete = function (event) {
callback(event.renderedBuffer);
filter.disconnect();
player.disconnect();
};
context.startRendering();

View file

@ -1,5 +1,6 @@
import { noteToMidi, valueToMidi, getSoundIndex } from './util.mjs';
import { getAudioContext, registerSound } from './index.mjs';
import { getCommonSampleInfo } from './util.mjs';
import { registerSound, registerWaveTable } from './index.mjs';
import { getAudioContext } from './audioContext.mjs';
import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs';
import { logger } from './logger.mjs';
@ -22,39 +23,16 @@ function humanFileSize(bytes, si) {
return bytes.toFixed(1) + ' ' + units[u];
}
// deduces relevant info for sample loading from hap.value and sample definition
// it encapsulates the core sampler logic into a pure and synchronous function
// hapValue: Hap.value, bank: sample bank definition for sound "s" (values in strudel.json format)
export function getSampleInfo(hapValue, bank) {
const { s, n = 0, speed = 1.0 } = hapValue;
let midi = valueToMidi(hapValue, 36);
let transpose = midi - 36; // C3 is middle C;
let sampleUrl;
let index = 0;
if (Array.isArray(bank)) {
index = getSoundIndex(n, bank.length);
sampleUrl = bank[index];
} else {
const midiDiff = (noteA) => noteToMidi(noteA) - midi;
// object format will expect keys as notes
const closest = Object.keys(bank)
.filter((k) => !k.startsWith('_'))
.reduce(
(closest, key, j) => (!closest || Math.abs(midiDiff(key)) < Math.abs(midiDiff(closest)) ? key : closest),
null,
);
transpose = -midiDiff(closest); // semitones to repitch
index = getSoundIndex(n, bank[closest].length);
sampleUrl = bank[closest][index];
}
const label = `${s}:${index}`;
const { speed = 1.0 } = hapValue;
const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank);
let playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12);
return { transpose, sampleUrl, index, midi, label, playbackRate };
return { transpose, url, index, midi, label, playbackRate };
}
// takes hapValue and returns buffer + playbackRate.
export const getSampleBuffer = async (hapValue, bank, resolveUrl) => {
let { sampleUrl, label, playbackRate } = getSampleInfo(hapValue, bank);
let { url: sampleUrl, label, playbackRate } = getSampleInfo(hapValue, bank);
if (resolveUrl) {
sampleUrl = await resolveUrl(sampleUrl);
}
@ -79,14 +57,14 @@ export const getSampleBufferSource = async (hapValue, bank, resolveUrl) => {
bufferSource.buffer = buffer;
bufferSource.playbackRate.value = playbackRate;
const { s, loopBegin = 0, loopEnd = 1, begin = 0, end = 1 } = hapValue;
const { loopBegin = 0, loopEnd = 1, begin = 0, end = 1 } = hapValue;
// "The computation of the offset into the sound is performed using the sound buffer's natural sample rate,
// rather than the current playback rate, so even if the sound is playing at twice its normal speed,
// the midway point through a 10-second audio buffer is still 5."
const offset = begin * bufferSource.buffer.duration;
const loop = s.startsWith('wt_') ? 1 : hapValue.loop;
const loop = hapValue.loop;
if (loop) {
bufferSource.loop = true;
bufferSource.loopStart = loopBegin * bufferSource.buffer.duration - offset;
@ -143,13 +121,18 @@ function githubPath(base, subpath = '') {
if (!base.startsWith('github:')) {
throw new Error('expected "github:" at the start of pseudoUrl');
}
let [_, path] = base.split('github:');
let path = base.slice('github:'.length);
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}`;
let components = path.split('/');
let user = components[0];
let repo = components.length >= 2 ? components[1] : 'samples';
let branch = components.length >= 3 ? components[2] : 'main';
let other = components.slice(3);
other.push(subpath ? subpath : '');
other = other.join('/');
return `https://raw.githubusercontent.com/${user}/${repo}/${branch}/${other}`;
}
export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '') => {
@ -196,6 +179,52 @@ function getSamplesPrefixHandler(url) {
return;
}
export async function fetchSampleMap(url) {
// check if custom prefix handler
const handler = getSamplesPrefixHandler(url);
if (handler) {
return handler(url);
}
url = resolveSpecialPaths(url);
if (url.startsWith('github:')) {
url = githubPath(url, 'strudel.json');
}
if (url.startsWith('local:')) {
url = `http://localhost:5432`;
}
if (url.startsWith('shabda:')) {
let [_, path] = url.split('shabda:');
url = `https://shabda.ndre.gr/${path}.json?strudel=1`;
}
if (url.startsWith('shabda/speech')) {
let [_, path] = url.split('shabda/speech');
path = path.startsWith('/') ? path.substring(1) : path;
let [params, words] = path.split(':');
let gender = 'f';
let language = 'en-GB';
if (params) {
[language, gender] = params.split('/');
}
url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`;
}
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;
}
const json = await fetch(url)
.then((res) => res.json())
.catch((error) => {
console.error(error);
throw new Error(`error loading "${url}"`);
});
return [json, json._base || base];
}
/**
* Loads a collection of samples to use with `s`
* @example
@ -217,61 +246,16 @@ function getSamplesPrefixHandler(url) {
export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => {
if (typeof sampleMap === 'string') {
// check if custom prefix handler
const handler = getSamplesPrefixHandler(sampleMap);
if (handler) {
return handler(sampleMap);
}
sampleMap = resolveSpecialPaths(sampleMap);
if (sampleMap.startsWith('github:')) {
sampleMap = githubPath(sampleMap, 'strudel.json');
}
if (sampleMap.startsWith('local:')) {
sampleMap = `http://localhost:5432`;
}
if (sampleMap.startsWith('shabda:')) {
let [_, path] = sampleMap.split('shabda:');
sampleMap = `https://shabda.ndre.gr/${path}.json?strudel=1`;
}
if (sampleMap.startsWith('shabda/speech')) {
let [_, path] = sampleMap.split('shabda/speech');
path = path.startsWith('/') ? path.substring(1) : path;
let [params, words] = path.split(':');
let gender = 'f';
let language = 'en-GB';
if (params) {
[language, gender] = params.split('/');
}
sampleMap = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`;
}
if (typeof fetch !== 'function') {
// not a browser
return;
}
const base = sampleMap.split('/').slice(0, -1).join('/');
if (typeof fetch === 'undefined') {
// skip fetch when in node / testing
return;
}
return fetch(sampleMap)
.then((res) => res.json())
.then((json) => samples(json, baseUrl || json._base || base, options))
.catch((error) => {
console.error(error);
throw new Error(`error loading "${sampleMap}"`);
});
const [json, base] = await fetchSampleMap(sampleMap);
return samples(json, baseUrl || base, options);
}
const { prebake, tag } = options;
processSampleMap(
sampleMap,
(key, bank) =>
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank), {
type: 'sample',
samples: bank,
baseUrl,
prebake,
tag,
}),
(key, bank) => {
registerSampleSource(key, bank, { baseUrl, prebake, tag });
},
baseUrl,
);
};
@ -361,3 +345,20 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
return handle;
}
function registerSample(key, bank, params) {
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank), {
type: 'sample',
samples: bank,
...params,
});
}
export function registerSampleSource(key, bank, params) {
const isWavetable = key.startsWith('wt_');
if (isWavetable) {
registerWaveTable(key, bank, params);
} else {
registerSample(key, bank, params);
}
}

View file

@ -7,12 +7,14 @@ This program is free software: you can redistribute it and/or modify it under th
import './feedbackdelay.mjs';
import './reverb.mjs';
import './vowel.mjs';
import { clamp, nanFallback, _mod, cycleToSeconds, secondsToCycle } from './util.mjs';
import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
import workletsUrl from './worklets.mjs?audioworklet';
import { createFilter, gainNode, getCompressor, getWorklet, webAudioTimeout } from './helpers.mjs';
import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend } from './helpers.mjs';
import { map } from 'nanostores';
import { logger, errorLogger } from './logger.mjs';
import { logger } from './logger.mjs';
import { loadBuffer } from './sampler.mjs';
import { getAudioContext } from './audioContext.mjs';
import { SuperdoughAudioController } from './superdoughoutput.mjs';
export const DEFAULT_MAX_POLYPHONY = 128;
const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard';
@ -39,13 +41,6 @@ export function setMultiChannelOrbits(bool) {
multiChannelOrbits = bool == true;
}
function getModulationShapeInput(val) {
if (typeof val === 'number') {
return val % 5;
}
return { tri: 0, triangle: 0, sine: 1, ramp: 2, saw: 3, square: 4 }[val] ?? 0;
}
export const soundMap = map();
export function registerSound(key, onTrigger, data = {}) {
@ -135,6 +130,19 @@ export async function aliasBank(...args) {
}
}
/**
* Register an alias for a sound.
* @param {string} original - The original sound name
* @param {string} alias - The alias to use for the sound
*/
export function soundAlias(original, alias) {
if (getSound(original) == null) {
logger('soundAlias: original sound not found');
return;
}
soundMap.setKey(alias, getSound(original));
}
export function getSound(s) {
if (typeof s !== 'string') {
console.warn(`getSound: expected string got "${s}". fall back to triangle`);
@ -160,15 +168,11 @@ let defaultDefaultValues = {
gain: 0.8,
postgain: 1,
density: '.03',
ftype: '12db',
fanchor: 0,
resonance: 1,
hresonance: 1,
bandq: 1,
channels: [1, 2],
phaserdepth: 0.75,
shapevol: 1,
distortvol: 1,
distorttype: 0,
delay: 0,
byteBeatExpression: '0',
delayfeedback: 0.5,
@ -215,30 +219,17 @@ export function setVersionDefaults(version) {
export const resetLoadedSounds = () => soundMap.set({});
let audioContext;
export const setDefaultAudioContext = () => {
audioContext = new AudioContext();
return audioContext;
};
export const getAudioContext = () => {
if (!audioContext) {
return setDefaultAudioContext();
}
return audioContext;
};
export function getAudioContextCurrentTime() {
return getAudioContext().currentTime;
let externalWorklets = [];
export function registerWorklet(url) {
externalWorklets.push(url);
}
let workletsLoading;
function loadWorklets() {
if (!workletsLoading) {
const audioCtx = getAudioContext();
workletsLoading = audioCtx.audioWorklet.addModule(workletsUrl);
const allWorkletURLs = externalWorklets.concat([workletsUrl]);
workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL)));
}
return workletsLoading;
@ -304,89 +295,16 @@ export async function initAudioOnFirstClick(options) {
return audioReady;
}
let delays = {};
const maxfeedback = 0.98;
let channelMerger, destinationGain;
//update the output channel configuration to match user's audio device
export function initializeAudioOutput() {
const audioContext = getAudioContext();
const maxChannelCount = audioContext.destination.maxChannelCount;
audioContext.destination.channelCount = maxChannelCount;
channelMerger = new ChannelMergerNode(audioContext, { numberOfInputs: audioContext.destination.channelCount });
destinationGain = new GainNode(audioContext);
channelMerger.connect(destinationGain);
destinationGain.connect(audioContext.destination);
let controller;
function getSuperdoughAudioController() {
if (controller == null) {
controller = new SuperdoughAudioController(getAudioContext());
}
return controller;
}
// input: AudioNode, channels: ?Array<int>
export const connectToDestination = (input, channels = [0, 1]) => {
const ctx = getAudioContext();
if (channelMerger == null) {
initializeAudioOutput();
}
//This upmix can be removed if correct channel counts are set throughout the app,
// and then strudel could theoretically support surround sound audio files
const stereoMix = new StereoPannerNode(ctx);
input.connect(stereoMix);
const splitter = new ChannelSplitterNode(ctx, {
numberOfOutputs: stereoMix.channelCount,
});
stereoMix.connect(splitter);
channels.forEach((ch, i) => {
splitter.connect(channelMerger, i % stereoMix.channelCount, ch % ctx.destination.channelCount);
});
};
export const panic = () => {
if (destinationGain == null) {
return;
}
destinationGain.gain.linearRampToValueAtTime(0, getAudioContext().currentTime + 0.01);
destinationGain = null;
channelMerger == null;
};
function getDelay(orbit, delaytime, delayfeedback, t, channels) {
if (delayfeedback > maxfeedback) {
//logger(`delayfeedback was clamped to ${maxfeedback} to save your ears`);
}
delayfeedback = clamp(delayfeedback, 0, 0.98);
if (!orbits[orbit].delayNode) {
const ac = getAudioContext();
const dly = ac.createFeedbackDelay(1, delaytime, delayfeedback);
dly.start?.(t); // for some reason, this throws when audion extension is installed..
connectToOrbit(dly, orbit);
orbits[orbit].delayNode = dly;
}
orbits[orbit].delayNode.delayTime.value !== delaytime &&
orbits[orbit].delayNode.delayTime.setValueAtTime(delaytime, t);
orbits[orbit].delayNode.feedback.value !== delayfeedback &&
orbits[orbit].delayNode.feedback.setValueAtTime(delayfeedback, t);
return orbits[orbit].delayNode;
}
export function getLfo(audioContext, begin, end, properties = {}) {
const { shape = 0, ...props } = properties;
const { dcoffset = -0.5, depth = 1 } = properties;
const lfoprops = {
frequency: 1,
depth,
skew: 0.5,
phaseoffset: 0,
time: begin,
begin,
end,
shape: getModulationShapeInput(shape),
dcoffset,
min: dcoffset * depth,
max: dcoffset * depth + depth,
curve: 1,
...props,
};
return getWorklet(audioContext, 'lfo-processor', lfoprops);
export function connectToDestination(input, channels) {
const controller = getSuperdoughAudioController();
controller.output.connectToDestination(input, channels);
}
function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) {
@ -420,75 +338,6 @@ function getFilterType(ftype) {
return typeof ftype === 'number' ? filterTypes[Math.floor(_mod(ftype, filterTypes.length))] : ftype;
}
//type orbit {
// gain: number,
// reverbNode: reverbNode
// delayNode: delayNode
//}
let orbits = {};
function connectToOrbit(node, orbit) {
if (orbits[orbit] == null) {
errorLogger(new Error('target orbit does not exist'), 'superdough');
}
node.connect(orbits[orbit].gain);
}
function setOrbit(audioContext, orbit, channels) {
if (orbits[orbit] == null) {
orbits[orbit] = {
gain: new GainNode(audioContext, { gain: 1 }),
};
connectToDestination(orbits[orbit].gain, channels);
}
}
function duckOrbit(audioContext, targetOrbit, t, attacktime = 0.1, duckdepth = 1) {
const targetArr = [targetOrbit].flat();
targetArr.forEach((target) => {
if (orbits[target] == null) {
errorLogger(new Error(`duck target orbit ${target} does not exist`), 'superdough');
return;
}
webAudioTimeout(
audioContext,
() => {
orbits[target].gain.gain.cancelScheduledValues(t);
const currVal = orbits[target].gain.gain.value;
orbits[target].gain.gain.linearRampToValueAtTime(clamp(1 - Math.pow(duckdepth, 0.5), 0.01, currVal), t);
orbits[target].gain.gain.exponentialRampToValueAtTime(1, t + Math.max(0.002, attacktime));
},
0,
t - 0.01,
);
});
}
let hasChanged = (now, before) => now !== undefined && now !== before;
function getReverb(orbit, duration, fade, lp, dim, ir) {
// If no reverb has been created for a given orbit, create one
if (!orbits[orbit].reverbNode) {
const ac = getAudioContext();
const reverb = ac.createReverb(duration, fade, lp, dim, ir);
connectToOrbit(reverb, orbit);
orbits[orbit].reverbNode = reverb;
}
if (
hasChanged(duration, orbits[orbit].reverbNode.duration) ||
hasChanged(fade, orbits[orbit].reverbNode.fade) ||
hasChanged(lp, orbits[orbit].reverbNode.lp) ||
hasChanged(dim, orbits[orbit].reverbNode.dim) ||
orbits[orbit].reverbNode.ir !== ir
) {
// only regenerate when something has changed
// avoids endless regeneration on things like
// stack(s("a"), s("b").rsize(8)).room(.5)
// this only works when args may stay undefined until here
// setting default values breaks this
orbits[orbit].reverbNode.generate(duration, fade, lp, dim, ir);
}
return orbits[orbit].reverbNode;
}
export let analysers = {},
analysersData = {};
@ -521,15 +370,8 @@ export function getAnalyzerData(type = 'time', id = 1) {
return analysersData[id];
}
function effectSend(input, effect, wet) {
const send = gainNode(wet);
input.connect(send);
send.connect(effect);
return send;
}
export function resetGlobalEffects() {
orbits = {};
controller?.reset();
analysers = {};
analysersData = {};
}
@ -544,6 +386,7 @@ function mapChannelNumbers(channels) {
export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) => {
// new: t is always expected to be the absolute target onset time
const ac = getAudioContext();
const audioController = getSuperdoughAudioController();
let { stretch } = value;
if (stretch != null) {
@ -583,36 +426,13 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
postgain = getDefaultValue('postgain'),
density = getDefaultValue('density'),
duckorbit,
duckonset,
duckattack,
duckdepth,
djf,
// filters
fanchor = getDefaultValue('fanchor'),
drive = 0.69,
release = 0,
// low pass
cutoff,
lpenv,
lpattack,
lpdecay,
lpsustain,
lprelease,
resonance = getDefaultValue('resonance'),
// high pass
hpenv,
hcutoff,
hpattack,
hpdecay,
hpsustain,
hprelease,
hresonance = getDefaultValue('hresonance'),
// band pass
bpenv,
bandf,
bpattack,
bpdecay,
bpsustain,
bprelease,
bandq = getDefaultValue('bandq'),
//phaser
phaserrate: phaser,
@ -628,6 +448,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
shapevol = getDefaultValue('shapevol'),
distort,
distortvol = getDefaultValue('distortvol'),
distorttype = getDefaultValue('distorttype'),
pan,
vowel,
delay = getDefaultValue('delay'),
@ -641,6 +462,8 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
roomdim,
roomsize,
ir,
irspeed,
irbegin,
i = getDefaultValue('i'),
velocity = getDefaultValue('velocity'),
analyze, // analyser wet
@ -659,10 +482,9 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
);
const channels = value.channels != null ? mapChannelNumbers(value.channels) : orbitChannels;
setOrbit(ac, orbit, channels, t, cycle, cps);
const orbitBus = audioController.getOrbit(orbit, channels);
if (duckorbit != null) {
duckOrbit(ac, duckorbit, t, duckattack, duckdepth);
audioController.duck(duckorbit, t, duckonset, duckattack, duckdepth);
}
gain = applyGainCurve(nanFallback(gain, 1));
@ -681,7 +503,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
// oldest audio nodes will be destroyed if maximum polyphony is exceeded
for (let i = 0; i <= activeSoundSources.size - maxPolyphony; i++) {
const ch = activeSoundSources.entries().next();
const source = ch.value[1];
const source = ch.value[1].deref();
const chainID = ch.value[0];
const endTime = t + 0.25;
source?.node?.gain?.linearRampToValueAtTime(0, endTime);
@ -709,11 +531,11 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
audioNodes.forEach((n) => n?.disconnect());
activeSoundSources.delete(chainID);
};
const soundHandle = await onTrigger(t, value, onEnded);
const soundHandle = await onTrigger(t, value, onEnded, cps);
if (soundHandle) {
sourceNode = soundHandle.node;
activeSoundSources.set(chainID, soundHandle);
activeSoundSources.set(chainID, new WeakRef(soundHandle)); // allow GC
}
} else {
throw new Error(`sound ${s} not found! Is it loaded?`);
@ -735,57 +557,87 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
// gain stage
chain.push(gainNode(gain));
//filter
// filter
const ftype = getFilterType(value.ftype);
if (cutoff !== undefined) {
let lp = () =>
createFilter(
ac,
'lowpass',
cutoff,
resonance,
lpattack,
lpdecay,
lpsustain,
lprelease,
lpenv,
t,
end,
fanchor,
ftype,
drive,
);
if (value.cutoff !== undefined) {
const lpMap = {
frequency: 'cutoff',
q: 'resonance',
attack: 'lpattack',
decay: 'lpdecay',
sustain: 'lpsustain',
release: 'lprelease',
env: 'lpenv',
anchor: 'fanchor',
model: 'ftype',
drive: 'drive',
rate: 'lprate',
sync: 'lpsync',
depth: 'lpdepth',
shape: 'lpshape',
dcoffset: 'lpdc',
skew: 'lpskew',
};
const lpParams = pickAndRename(value, lpMap);
lpParams.type = 'lowpass';
let lp = () => createFilter(ac, t, end, lpParams, cps);
chain.push(lp());
if (ftype === '24db') {
chain.push(lp());
}
}
if (hcutoff !== undefined) {
let hp = () =>
createFilter(
ac,
'highpass',
hcutoff,
hresonance,
hpattack,
hpdecay,
hpsustain,
hprelease,
hpenv,
t,
end,
fanchor,
);
if (value.hcutoff !== undefined) {
const hpMap = {
frequency: 'hcutoff',
q: 'hresonance',
attack: 'hpattack',
decay: 'hpdecay',
sustain: 'hpsustain',
release: 'hprelease',
env: 'hpenv',
anchor: 'fanchor',
model: 'ftype',
drive: 'drive',
rate: 'hprate',
sync: 'hpsync',
depth: 'hpdepth',
shape: 'hpshape',
dcoffset: 'hpdc',
skew: 'hpskew',
};
const hpParams = pickAndRename(value, hpMap);
hpParams.type = 'highpass';
let hp = () => createFilter(ac, t, end, hpParams, cps);
chain.push(hp());
if (ftype === '24db') {
chain.push(hp());
}
}
if (bandf !== undefined) {
let bp = () =>
createFilter(ac, 'bandpass', bandf, bandq, bpattack, bpdecay, bpsustain, bprelease, bpenv, t, end, fanchor);
if (value.bandf !== undefined) {
const bpMap = {
frequency: 'bandf',
q: 'bandq',
attack: 'bpattack',
decay: 'bpdecay',
sustain: 'bpsustain',
release: 'bprelease',
env: 'bpenv',
anchor: 'fanchor',
model: 'ftype',
drive: 'drive',
rate: 'bprate',
sync: 'bpsync',
depth: 'bpdepth',
shape: 'bpshape',
dcoffset: 'bpdc',
skew: 'bpskew',
};
const bpParams = pickAndRename(value, bpMap);
bpParams.type = 'bandpass';
let bp = () => createFilter(ac, t, end, bpParams, cps);
chain.push(bp());
if (ftype === '24db') {
chain.push(bp());
@ -801,12 +653,20 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse }));
crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush }));
shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape, postgain: shapevol }));
distort !== undefined && chain.push(getWorklet(ac, 'distort-processor', { distort, postgain: distortvol }));
distort !== undefined && chain.push(getDistortion(distort, distortvol, distorttype));
if (tremolosync != null) {
tremolo = cps * tremolosync;
}
if (value.wtPosSynced != null) {
value.wtPosRate /= cps;
}
if (value.wtWarpSynced != null) {
value.wtWarpRate /= cps;
}
if (tremolo !== undefined) {
// Allow clipping of modulator for more dynamic possiblities, and to prevent speaker overload
// EX: a triangle waveform will clip like this /-\ when the depth is above 1
@ -852,14 +712,11 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
chain.push(post);
// delay
let delaySend;
if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
const delayNode = getDelay(orbit, delaytime, delayfeedback, t, orbitChannels);
delaySend = effectSend(post, delayNode, delay);
audioNodes.push(delaySend);
orbitBus.getDelay(delaytime, delayfeedback, t);
orbitBus.sendDelay(post, delay);
}
// reverb
let reverbSend;
if (room > 0) {
let roomIR;
if (ir !== undefined) {
@ -872,25 +729,28 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
}
roomIR = await loadBuffer(url, ac, ir, 0);
}
const reverbNode = getReverb(orbit, roomsize, roomfade, roomlp, roomdim, roomIR, orbitChannels);
reverbSend = effectSend(post, reverbNode, room);
audioNodes.push(reverbSend);
orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
const send = orbitBus.sendReverb(post, room);
audioNodes.push(send);
}
if (djf != null) {
orbitBus.getDjf(djf, t);
}
// analyser
let analyserSend;
if (analyze) {
const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5));
analyserSend = effectSend(post, analyserNode, 1);
const analyserSend = effectSend(post, analyserNode, 1);
audioNodes.push(analyserSend);
}
if (dry != null) {
dry = applyGainCurve(dry);
const dryGain = new GainNode(ac, { gain: dry });
chain.push(dryGain);
connectToOrbit(dryGain, orbit);
orbitBus.connectToOutput(dryGain);
} else {
connectToOrbit(post, orbit);
orbitBus.connectToOutput(post);
}
// connect chain elements together

View file

@ -0,0 +1,209 @@
import { effectSend, getWorklet, webAudioTimeout } from './helpers.mjs';
import { errorLogger } from './logger.mjs';
import { clamp } from './util.mjs';
let hasChanged = (now, before) => now !== undefined && now !== before;
export class Orbit {
reverbNode;
delayNode;
output;
summingNode;
djfNode;
audioContext;
constructor(audioContext) {
this.audioContext = audioContext;
this.output = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
this.summingNode = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
this.summingNode.connect(this.output);
}
disconnect() {
this.output.disconnect();
this.summingNode.disconnect();
this.delayNode?.disconnect();
this.reverbNode?.disconnect();
}
getDjf(value, t = 0) {
if (this.djfNode == null) {
this.djfNode = getWorklet(this.audioContext, 'djf-processor', { value });
this.summingNode.disconnect();
this.summingNode.connect(this.djfNode);
this.djfNode.connect(this.output);
}
const val = this.djfNode.parameters.get('value');
val.setValueAtTime(value, t);
}
getDelay(delaytime = 0, feedback = 0.5, t) {
const maxfeedback = 0.98;
if (feedback > maxfeedback) {
//logger(`feedback was clamped to ${maxfeedback} to save your ears`);
}
feedback = clamp(feedback, 0, 0.98);
if (this.delayNode == null) {
this.delayNode = this.audioContext.createFeedbackDelay(1, delaytime, feedback);
this.delayNode.connect(this.summingNode);
this.delayNode.start?.(t); // for some reason, this throws when audion extension is installed..
}
this.delayNode.delayTime.value !== delaytime && this.delayNode.delayTime.setValueAtTime(delaytime, t);
this.delayNode.feedback.value !== feedback && this.delayNode.feedback.setValueAtTime(feedback, t);
return this.delayNode;
}
getReverb(duration, fade, lp, dim, ir, irspeed, irbegin) {
// If no reverb has been created for a given orbit, create one
if (this.reverbNode == null) {
this.reverbNode = this.audioContext.createReverb(duration, fade, lp, dim, ir, irspeed, irbegin);
this.reverbNode.connect(this.summingNode);
}
if (
hasChanged(duration, this.reverbNode.duration) ||
hasChanged(fade, this.reverbNode.fade) ||
hasChanged(lp, this.reverbNode.lp) ||
hasChanged(dim, this.reverbNode.dim) ||
hasChanged(irspeed, this.reverbNode.irspeed) ||
hasChanged(irbegin, this.reverbNode.irbegin) ||
this.reverbNode.ir !== ir
) {
// only regenerate when something has changed
// avoids endless regeneration on things like
// stack(s("a"), s("b").rsize(8)).room(.5)
// this only works when args may stay undefined until here
// setting default values breaks this
this.reverbNode.generate(duration, fade, lp, dim, ir, irspeed, irbegin);
}
return this.reverbNode;
}
sendReverb(node, amount) {
return effectSend(node, this.reverbNode, amount);
}
sendDelay(node, amount) {
effectSend(node, this.delayNode, amount);
}
duck(t, onsettime = 0, attacktime = 0.1, depth = 1) {
const onset = onsettime;
const attack = Math.max(attacktime, 0.002);
const gainParam = this.output.gain;
webAudioTimeout(
this.audioContext,
() => {
const now = this.audioContext.currentTime;
// cancelScheduledValues and setValueAtTime together emulate cancelAndHoldAtTime
// on browsers which lack that method
const currVal = gainParam.value;
gainParam.cancelScheduledValues(now);
gainParam.setValueAtTime(currVal, now);
const t0 = Math.max(t, now); // guard against now > t
const duckedVal = clamp(1 - Math.sqrt(depth), 0.01, currVal);
gainParam.exponentialRampToValueAtTime(duckedVal, t0 + onset);
gainParam.exponentialRampToValueAtTime(1, t0 + onset + attack);
},
0,
t - 0.01,
);
}
connectToOutput(node) {
node.connect(this.summingNode);
}
}
export class SuperdoughOutput {
channelMerger;
destinationGain;
constructor(audioContext) {
this.audioContext = audioContext;
this.initializeAudio();
}
initializeAudio() {
const audioContext = this.audioContext;
const maxChannelCount = audioContext.destination.maxChannelCount;
this.audioContext.destination.channelCount = maxChannelCount;
this.channelMerger = new ChannelMergerNode(audioContext, { numberOfInputs: audioContext.destination.channelCount });
this.destinationGain = new GainNode(audioContext);
this.channelMerger.connect(this.destinationGain);
this.destinationGain.connect(audioContext.destination);
}
reset() {
this.disconnect();
this.initializeAudio();
}
disconnect() {
this.channelMerger.disconnect();
this.destinationGain.disconnect();
this.destinationGain = null;
this.channelMerger = null;
}
connectToDestination = (input, channels = [0, 1]) => {
//This upmix can be removed if correct channel counts are set throughout the app,
// and then strudel could theoretically support surround sound audio files
const stereoMix = new StereoPannerNode(this.audioContext);
input.connect(stereoMix);
const splitter = new ChannelSplitterNode(this.audioContext, {
numberOfOutputs: stereoMix.channelCount,
});
stereoMix.connect(splitter);
channels.forEach((ch, i) => {
splitter.connect(this.channelMerger, i % stereoMix.channelCount, ch % this.audioContext.destination.channelCount);
});
};
}
export class SuperdoughAudioController {
audioContext;
output;
nodes = {};
constructor(audioContext) {
this.audioContext = audioContext;
this.output = new SuperdoughOutput(audioContext);
}
reset() {
Array.from(this.nodes).forEach((node) => {
node.disconnect();
});
this.nodes = {};
this.output.reset();
}
duck(targetOrbits, t, onsettime = 0, attacktime = 0.1, depth = 1) {
const targetArr = [targetOrbits].flat();
const onsetArr = [onsettime].flat();
const attackArr = [attacktime].flat();
const depthArr = [depth].flat();
targetArr.forEach((target, idx) => {
const orbit = this.nodes[target];
if (orbit == null) {
errorLogger(new Error(`duck target orbit ${target} does not exist`), 'superdough');
return;
}
const onset = onsetArr[idx] ?? onsetArr[0];
const attack = Math.max(attackArr[idx] ?? attackArr[0], 0.002);
const depth = depthArr[idx] ?? depthArr[0];
orbit.duck(t, onset, attack, depth);
});
}
getOrbit(orbitNum, channels) {
if (this.nodes[orbitNum] == null) {
this.nodes[orbitNum] = new Orbit(this.audioContext);
this.output.connectToDestination(this.nodes[orbitNum].output, channels);
}
return this.nodes[orbitNum];
}
}

View file

@ -1,39 +1,22 @@
import { clamp, midiToFreq, noteToMidi } from './util.mjs';
import { registerSound, getAudioContext, soundMap, getLfo } from './superdough.mjs';
import { clamp } from './util.mjs';
import { registerSound, soundMap } from './superdough.mjs';
import { getAudioContext } from './audioContext.mjs';
import {
applyFM,
destroyAudioWorkletNode,
gainNode,
getADSRValues,
getFrequencyFromValue,
getLfo,
getParamADSR,
getPitchEnvelope,
getVibratoOscillator,
webAudioTimeout,
getWorklet,
noises,
webAudioTimeout,
} from './helpers.mjs';
import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
const getFrequencyFromValue = (value, defaultNote = 36) => {
let { note, freq } = value;
note = note || defaultNote;
if (typeof note === 'string') {
note = noteToMidi(note); // e.g. c3 => 48
}
// get frequency
if (!freq && typeof note === 'number') {
freq = midiToFreq(note); // + 48);
}
return Number(freq);
};
function destroyAudioWorkletNode(node) {
if (node == null) {
return;
}
node.disconnect();
node.parameters.get('end')?.setValueAtTime(0, 0);
}
const waveforms = ['triangle', 'square', 'sawtooth', 'sine'];
const waveformAliases = [
['tri', 'triangle'],

View file

@ -7,7 +7,7 @@ export const tokenizeNote = (note) => {
if (typeof note !== 'string') {
return [];
}
const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)([0-9]*)$/)?.slice(1) || [];
const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)(-?[0-9]*)$/)?.slice(1) || [];
if (!pc) {
return [];
}
@ -16,13 +16,17 @@ export const tokenizeNote = (note) => {
const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
const accs = { '#': 1, b: -1, s: 1, f: -1 };
export const getAccidentalsOffset = (accidentals) => {
return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0;
};
export const noteToMidi = (note, defaultOctave = 3) => {
const [pc, acc, oct = defaultOctave] = tokenizeNote(note);
if (!pc) {
throw new Error('not a note: "' + note + '"');
}
const chroma = chromas[pc.toLowerCase()];
const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0;
const offset = getAccidentalsOffset(acc);
return (Number(oct) + 1) * 12 + chroma + offset;
};
export const midiToFreq = (n) => {
@ -76,3 +80,37 @@ export function cycleToSeconds(cycle, cps) {
export function secondsToCycle(t, cps) {
return t * cps;
}
// deduces relevant info for sample loading from hap.value and sample definition
// it encapsulates the core sampler logic into a pure and synchronous function
// hapValue: Hap.value, bank: sample bank definition for sound "s" (values in strudel.json format)
export function getCommonSampleInfo(hapValue, bank) {
const { s, n = 0 } = hapValue;
let midi = valueToMidi(hapValue, 36);
let transpose = midi - 36; // C3 is middle C;
let url;
let index = 0;
if (Array.isArray(bank)) {
index = getSoundIndex(n, bank.length);
url = bank[index];
} else {
const midiDiff = (noteA) => noteToMidi(noteA) - midi;
// object format will expect keys as notes
const closest = Object.keys(bank)
.filter((k) => !k.startsWith('_'))
.reduce(
(closest, key, j) => (!closest || Math.abs(midiDiff(key)) < Math.abs(midiDiff(closest)) ? key : closest),
null,
);
transpose = -midiDiff(closest); // semitones to repitch
index = getSoundIndex(n, bank[closest].length);
url = bank[closest][index];
}
const label = `${s}:${index}`;
return { transpose, url, index, midi, label };
}
/** Selects entries from `source` and renames them via `map` */
export const pickAndRename = (source, map) => {
return Object.fromEntries(Object.entries(map).map(([newKey, oldKey]) => [newKey, source[oldKey]]));
};

View file

@ -0,0 +1,336 @@
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;
}

View file

@ -4,38 +4,59 @@
import OLAProcessor from './ola-processor';
import FFT from './fft.js';
import { getDistortionAlgorithm } from './helpers.mjs';
const blockSize = 128;
const PI = Math.PI;
const TWO_PI = 2 * PI;
const INVSR = 1 / sampleRate;
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
const _mod = (n, m) => ((n % m) + m) % m;
const mod = (n, m) => ((n % m) + m) % m;
const lerp = (a, b, n) => n * (b - a) + a;
const pv = (arr, n) => arr[n] ?? arr[0];
const frac = (x) => x - Math.floor(x);
// Restrict phase to the range [0, maxPhase) via wrapping
function wrapPhase(phase, maxPhase = 1) {
if (phase >= maxPhase) {
phase -= maxPhase;
} else if (phase < 0) {
phase += maxPhase;
// Fast integer ops for non-negative values
const ffloor = (x) => x | 0;
const fround = (x) => ffloor(x + 0.5);
const fceil = (x) => ffloor(x + 1);
const ffrac = (x) => x - ffloor(x);
const fast_tanh = (x) => {
const x2 = x ** 2;
return (x * (27.0 + x2)) / (27.0 + 9.0 * x2);
};
// Optimized per-voice detuner which precomputes constants
const getDetuner = (unison, detune) => {
if (unison < 2) {
return (_voiceIdx) => 0;
}
return phase;
}
const blockSize = 128;
const scale = detune / (unison - 1);
const center = detune * 0.5;
return (voiceIdx) => voiceIdx * scale - center;
};
const applySemitoneDetuneToFrequency = (frequency, detune) => {
return frequency * Math.pow(2, detune / 12);
};
// Smooth waveshape near discontinuities to remove frequencies above Nyquist and prevent aliasing
// referenced from https://www.kvraudio.com/forum/viewtopic.php?t=375517
function polyBlep(phase, dt) {
dt = Math.min(dt, 1 - dt);
const invdt = 1 / dt;
// Start of cycle
if (phase < dt) {
phase /= dt;
// 2 * (phase - phase^2/2 - 0.5)
return phase + phase - phase * phase - 1;
phase *= invdt;
return 2 * phase - phase ** 2 - 1;
}
// End of cycle
else if (phase > 1 - dt) {
phase = (phase - 1) / dt;
// 2 * (phase^2/2 + phase + 0.5)
return phase * phase + phase + phase + 1;
phase = (phase - 1) * invdt;
return phase ** 2 + 2 * phase + 1;
}
// 0 otherwise
else {
return 0;
@ -51,7 +72,7 @@ const waveshapes = {
return phase / skew;
},
sine(phase) {
return Math.sin(Math.PI * 2 * phase) * 0.5 + 0.5;
return Math.sin(TWO_PI * phase) * 0.5 + 0.5;
},
ramp(phase) {
return phase;
@ -85,12 +106,6 @@ const waveshapes = {
return v - polyBlep(phase, dt);
},
};
function getParamValue(block, param) {
if (param.length > 1) {
return param[block];
}
return param[0];
}
const waveShapeNames = Object.keys(waveshapes);
class LFOProcessor extends AudioWorkletProcessor {
@ -123,7 +138,7 @@ class LFOProcessor extends AudioWorkletProcessor {
}
}
process(inputs, outputs, parameters) {
process(_inputs, outputs, parameters) {
const begin = parameters['begin'][0];
if (currentTime >= parameters.end[0]) {
return false;
@ -150,9 +165,9 @@ class LFOProcessor extends AudioWorkletProcessor {
const blockSize = output[0].length ?? 0;
if (this.phase == null) {
this.phase = _mod(time * frequency + phaseoffset, 1);
this.phase = ffrac(time * frequency + phaseoffset);
}
const dt = frequency / sampleRate;
const dt = frequency * INVSR;
for (let n = 0; n < blockSize; n++) {
for (let i = 0; i < output.length; i++) {
let modval = (waveshapes[shape](this.phase, skew) + dcoffset) * depth;
@ -271,11 +286,73 @@ class ShapeProcessor extends AudioWorkletProcessor {
}
registerProcessor('shape-processor', ShapeProcessor);
function fast_tanh(x) {
const x2 = x * x;
return (x * (27.0 + x2)) / (27.0 + 9.0 * x2);
class TwoPoleFilter {
s0 = 0;
s1 = 0;
update(s, cutoff, resonance = 0) {
// Out of bound values can produce NaNs
resonance = clamp(resonance, 0, 1);
cutoff = clamp(cutoff, 0, sampleRate / 2 - 1);
const c = clamp(2 * Math.sin(cutoff * PI * INVSR), 0, 1.14);
const r = Math.pow(0.5, 8 * resonance + 1);
const mrc = 1 - r * c;
this.s0 = mrc * this.s0 - c * this.s1 + c * s; // bpf
this.s1 = mrc * this.s1 + c * this.s0; // lpf
return this.s1; // return lpf by default
}
}
const _PI = 3.14159265359;
class DJFProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [{ name: 'value', defaultValue: 0.5 }];
}
constructor() {
super();
this.filters = [new TwoPoleFilter(), new TwoPoleFilter()];
}
process(inputs, outputs, parameters) {
const input = inputs[0];
const output = outputs[0];
const hasInput = !(input[0] === undefined);
this.started = hasInput;
const value = clamp(parameters.value[0], 0, 1);
let filterType = 'none';
let cutoff;
let v = 1;
if (value > 0.51) {
filterType = 'hipass';
v = (value - 0.5) * 2;
} else if (value < 0.49) {
filterType = 'lopass';
v = value * 2;
}
cutoff = Math.pow(v * 11, 4);
for (let i = 0; i < input.length; i++) {
for (let n = 0; n < blockSize; n++) {
if (filterType == 'none') {
output[i][n] = input[i][n];
} else {
this.filters[i].update(input[i][n], cutoff, 0.1);
if (filterType === 'lopass') {
output[i][n] = this.filters[i].s1;
} else if (filterType === 'hipass') {
output[i][n] = input[i][n] - this.filters[i].s1;
} else {
output[i][n] = input[i][n];
}
}
}
}
return true;
}
}
registerProcessor('djf-processor', DJFProcessor);
//adapted from https://github.com/TheBouteillacBear/webaudioworklet-wasm?tab=MIT-1-ov-file
class LadderProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
@ -313,7 +390,7 @@ class LadderProcessor extends AudioWorkletProcessor {
const drive = clamp(Math.exp(parameters.drive[0]), 0.1, 2000);
let cutoff = parameters.frequency[0];
cutoff = (cutoff * 2 * _PI) / sampleRate;
cutoff = cutoff * TWO_PI * INVSR;
cutoff = cutoff > 1 ? 1 : cutoff;
const k = Math.min(8, resonance * 0.13);
@ -349,9 +426,10 @@ class DistortProcessor extends AudioWorkletProcessor {
];
}
constructor() {
constructor({ processorOptions }) {
super();
this.started = false;
this.algorithm = getDistortionAlgorithm(processorOptions.algorithm);
}
process(inputs, outputs, parameters) {
@ -363,13 +441,12 @@ class DistortProcessor extends AudioWorkletProcessor {
return false;
}
this.started = hasInput;
const shape = Math.expm1(parameters.distort[0]);
const postgain = Math.max(0.001, Math.min(1, parameters.postgain[0]));
for (let n = 0; n < blockSize; n++) {
for (let i = 0; i < input.length; i++) {
output[i][n] = (((1 + shape) * input[i][n]) / (1 + shape * Math.abs(input[i][n]))) * postgain;
const postgain = clamp(pv(parameters.postgain, n), 0.001, 1);
const shape = Math.expm1(pv(parameters.distort, n));
for (let ch = 0; ch < input.length; ch++) {
const x = input[ch][n];
output[ch][n] = postgain * this.algorithm(x, shape);
}
}
return true;
@ -378,21 +455,6 @@ class DistortProcessor extends AudioWorkletProcessor {
registerProcessor('distort-processor', DistortProcessor);
// SUPERSAW
function lerp(a, b, n) {
return n * (b - a) + a;
}
function getUnisonDetune(unison, detune, voiceIndex) {
if (unison < 2) {
return 0;
}
return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1));
}
function applySemitoneDetuneToFrequency(frequency, detune) {
return frequency * Math.pow(2, detune / 12);
}
class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
constructor() {
super();
@ -441,10 +503,11 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
name: 'voices',
defaultValue: 5,
min: 1,
automationRate: 'k-rate',
},
];
}
process(input, outputs, params) {
process(_input, outputs, params) {
if (currentTime <= params.begin[0]) {
return true;
}
@ -452,38 +515,36 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
// this.port.postMessage({ type: 'onended' });
return false;
}
const output = outputs[0];
const voices = params.voices[0];
const freqspread = params.freqspread[0];
const panspread = params.panspread[0] * 0.5 + 0.5;
const gain1 = Math.sqrt(1 - panspread);
const gain2 = Math.sqrt(panspread);
for (let n = 0; n < voices; n++) {
const isOdd = (n & 1) == 1;
let gainL = gain1;
let gainR = gain2;
// invert right and left gain
if (isOdd) {
gainL = gain2;
gainR = gain1;
}
for (let i = 0; i < output[0].length; i++) {
// Main detuning
let freq = applySemitoneDetuneToFrequency(params.frequency[i] ?? params.frequency[0], params.detune[0] / 100);
const voices = params.voices[0]; // k-rate
for (let i = 0; i < output[0].length; i++) {
const detune = pv(params.detune, i);
const freqspread = pv(params.freqspread, i);
const panspread = pv(params.panspread, i) * 0.5 + 0.5;
let gainL = Math.sqrt(1 - panspread);
let gainR = Math.sqrt(panspread);
let freq = pv(params.frequency, i);
// Main detuning
freq = applySemitoneDetuneToFrequency(freq, detune / 100);
const detuner = getDetuner(voices, freqspread);
for (let n = 0; n < voices; n++) {
// Individual voice detuning
freq = applySemitoneDetuneToFrequency(freq, getUnisonDetune(voices, freqspread, n));
const freqVoice = applySemitoneDetuneToFrequency(freq, detuner(n));
// We must wrap this here because it is passed into sawblep below which
// has domain [0, 1]
const dt = _mod(freq / sampleRate, 1);
const dt = ffrac(freqVoice * INVSR);
this.phase[n] = this.phase[n] ?? Math.random();
const v = waveshapes.sawblep(this.phase[n], dt);
output[0][i] = output[0][i] + v * gainL;
output[1][i] = output[1][i] + v * gainR;
output[0][i] += v * gainL;
output[1][i] += v * gainR;
this.phase[n] = wrapPhase(this.phase[n] + dt);
let pn = this.phase[n] + dt;
if (pn >= 1.0) pn -= 1.0;
this.phase[n] = pn;
// invert right and left gain
gainL = gainR;
gainR = gainL;
}
}
return true;
@ -495,12 +556,16 @@ registerProcessor('supersaw-oscillator', SuperSawOscillatorProcessor);
// Phase Vocoder sourced from https://github.com/olvb/phaze/tree/master?tab=readme-ov-file
const BUFFERED_BLOCK_SIZE = 2048;
const hannCache = new Map();
function genHannWindow(length) {
let win = new Float32Array(length);
for (var i = 0; i < length; i++) {
win[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / length));
if (!hannCache.has(length)) {
const win = new Float32Array(length);
for (let i = 0; i < length; i++) {
win[i] = 0.5 * (1 - Math.cos((TWO_PI * i) / length));
}
hannCache.set(length, win);
}
return win;
return hannCache.get(length);
}
class PhaseVocoderProcessor extends OLAProcessor {
@ -518,11 +583,10 @@ class PhaseVocoderProcessor extends OLAProcessor {
blockSize: BUFFERED_BLOCK_SIZE,
};
super(options);
this.fftSize = this.blockSize;
this.timeCursor = 0;
this.hannWindow = genHannWindow(this.blockSize);
this.fftSize = this.blockSize;
this.invfftSize = 1 / this.fftSize;
this.hannWindow = genHannWindow(this.fftSize);
// prepare FFT and pre-allocate buffers
this.fft = new FFT(this.fftSize);
this.freqComplexBuffer = this.fft.createComplexArray();
@ -535,52 +599,43 @@ class PhaseVocoderProcessor extends OLAProcessor {
processOLA(inputs, outputs, parameters) {
// no automation, take last value
let pitchFactor = parameters.pitchFactor[parameters.pitchFactor.length - 1];
if (pitchFactor < 0) {
pitchFactor = pitchFactor * 0.25;
}
pitchFactor = Math.max(0, pitchFactor + 1);
for (var i = 0; i < this.nbInputs; i++) {
for (var j = 0; j < inputs[i].length; j++) {
// big assumption here: output is symetric to input
var input = inputs[i][j];
var output = outputs[i][j];
for (let i = 0; i < this.nbInputs; i++) {
for (let j = 0; j < inputs[i].length; j++) {
const input = inputs[i][j];
const output = outputs[i][j];
this.applyHannWindow(input);
this.fft.realTransform(this.freqComplexBuffer, input);
this.computeMagnitudes();
this.findPeaks();
this.shiftPeaks(pitchFactor);
this.fft.completeSpectrum(this.freqComplexBufferShifted);
this.fft.inverseTransform(this.timeComplexBuffer, this.freqComplexBufferShifted);
this.fft.fromComplexArray(this.timeComplexBuffer, output);
this.applyHannWindow(output);
}
}
this.timeCursor += this.hopSize;
}
/** Apply Hann window in-place */
applyHannWindow(input) {
for (var i = 0; i < this.blockSize; i++) {
input[i] = input[i] * this.hannWindow[i] * 1.62;
for (let i = 0; i < this.blockSize; i++) {
input[i] *= this.hannWindow[i] * 1.62;
}
}
/** Compute squared magnitudes for peak finding **/
computeMagnitudes() {
var i = 0,
let i = 0,
j = 0;
while (i < this.magnitudes.length) {
let real = this.freqComplexBuffer[j];
let imag = this.freqComplexBuffer[j + 1];
const real = this.freqComplexBuffer[j];
const imag = this.freqComplexBuffer[j + 1];
// no need to sqrt for peak finding
this.magnitudes[i] = real ** 2 + imag ** 2;
i += 1;
@ -591,12 +646,10 @@ class PhaseVocoderProcessor extends OLAProcessor {
/** Find peaks in spectrum magnitudes **/
findPeaks() {
this.nbPeaks = 0;
var i = 2;
let end = this.magnitudes.length - 2;
let i = 2;
const end = this.magnitudes.length - 2;
while (i < end) {
let mag = this.magnitudes[i];
const mag = this.magnitudes[i];
if (this.magnitudes[i - 1] >= mag || this.magnitudes[i - 2] >= mag) {
i++;
continue;
@ -605,7 +658,6 @@ class PhaseVocoderProcessor extends OLAProcessor {
i++;
continue;
}
this.peakIndexes[this.nbPeaks] = i;
this.nbPeaks++;
i += 2;
@ -616,53 +668,44 @@ class PhaseVocoderProcessor extends OLAProcessor {
shiftPeaks(pitchFactor) {
// zero-fill new spectrum
this.freqComplexBufferShifted.fill(0);
for (var i = 0; i < this.nbPeaks; i++) {
let peakIndex = this.peakIndexes[i];
let peakIndexShifted = Math.round(peakIndex * pitchFactor);
for (let i = 0; i < this.nbPeaks; i++) {
const peakIndex = this.peakIndexes[i];
const peakIndexShifted = fround(peakIndex * pitchFactor);
if (peakIndexShifted > this.magnitudes.length) {
break;
}
// find region of influence
var startIndex = 0;
var endIndex = this.fftSize;
let startIndex = 0;
let endIndex = this.fftSize;
if (i > 0) {
let peakIndexBefore = this.peakIndexes[i - 1];
startIndex = peakIndex - Math.floor((peakIndex - peakIndexBefore) / 2);
startIndex = peakIndex - fround((peakIndex - this.peakIndexes[i - 1]) / 2);
}
if (i < this.nbPeaks - 1) {
let peakIndexAfter = this.peakIndexes[i + 1];
endIndex = peakIndex + Math.ceil((peakIndexAfter - peakIndex) / 2);
endIndex = peakIndex + fceil((this.peakIndexes[i + 1] - peakIndex) / 2);
}
// shift whole region of influence around peak to shifted peak
let startOffset = startIndex - peakIndex;
let endOffset = endIndex - peakIndex;
for (var j = startOffset; j < endOffset; j++) {
let binIndex = peakIndex + j;
let binIndexShifted = peakIndexShifted + j;
const startOffset = startIndex - peakIndex;
const endOffset = endIndex - peakIndex;
const omegaDelta = TWO_PI * this.invfftSize * (peakIndexShifted - peakIndex);
const phaseShiftReal = Math.cos(omegaDelta * this.timeCursor);
const phaseShiftImag = Math.sin(omegaDelta * this.timeCursor);
for (let j = startOffset; j < endOffset; j++) {
const binIndex = peakIndex + j;
const binIndexShifted = peakIndexShifted + j;
if (binIndexShifted >= this.magnitudes.length) {
break;
}
// apply phase correction
let omegaDelta = (2 * Math.PI * (binIndexShifted - binIndex)) / this.fftSize;
let phaseShiftReal = Math.cos(omegaDelta * this.timeCursor);
let phaseShiftImag = Math.sin(omegaDelta * this.timeCursor);
const indexReal = 2 * binIndex;
const indexImag = indexReal + 1;
const valueReal = this.freqComplexBuffer[indexReal];
const valueImag = this.freqComplexBuffer[indexImag];
let indexReal = binIndex * 2;
let indexImag = indexReal + 1;
let valueReal = this.freqComplexBuffer[indexReal];
let valueImag = this.freqComplexBuffer[indexImag];
const valueShiftedReal = valueReal * phaseShiftReal - valueImag * phaseShiftImag;
const valueShiftedImag = valueReal * phaseShiftImag + valueImag * phaseShiftReal;
let valueShiftedReal = valueReal * phaseShiftReal - valueImag * phaseShiftImag;
let valueShiftedImag = valueReal * phaseShiftImag + valueImag * phaseShiftReal;
let indexShiftedReal = binIndexShifted * 2;
let indexShiftedImag = indexShiftedReal + 1;
const indexShiftedReal = 2 * binIndexShifted;
const indexShiftedImag = indexShiftedReal + 1;
this.freqComplexBufferShifted[indexShiftedReal] += valueShiftedReal;
this.freqComplexBufferShifted[indexShiftedImag] += valueShiftedImag;
}
@ -676,11 +719,10 @@ registerProcessor('phase-vocoder-processor', PhaseVocoderProcessor);
class PulseOscillatorProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.pi = _PI;
this.phi = -this.pi; // phase
this.phi = -PI; // phase
this.Y0 = 0; // feedback memories
this.Y1 = 0;
this.PW = this.pi; // pulse width
this.PW = PI; // pulse width
this.B = 2.3; // feedback coefficient
this.dphif = 0; // filtered phase increment
this.envf = 0; // filtered envelope
@ -737,11 +779,11 @@ class PulseOscillatorProcessor extends AudioWorkletProcessor {
dphi;
for (let i = 0; i < (output[0].length ?? 0); i++) {
const pw = (1 - clamp(getParamValue(i, params.pulsewidth), -0.99, 0.99)) * this.pi;
const detune = getParamValue(i, params.detune);
const freq = applySemitoneDetuneToFrequency(getParamValue(i, params.frequency), detune / 100);
const pw = (1 - clamp(pv(params.pulsewidth, i), -0.99, 0.99)) * PI;
const detune = pv(params.detune, i);
const freq = applySemitoneDetuneToFrequency(pv(params.frequency, i), detune / 100);
dphi = freq * (this.pi / (sampleRate * 0.5)); // phase increment
dphi = freq * TWO_PI * INVSR; // phase increment
this.dphif += 0.1 * (dphi - this.dphif);
env *= 0.9998; // exponential decay envelope
@ -753,7 +795,7 @@ class PulseOscillatorProcessor extends AudioWorkletProcessor {
// Waveform generation (half-Tomisawa oscillators)
this.phi += this.dphif; // phase increment
if (this.phi >= this.pi) this.phi -= 2 * this.pi; // phase wrapping
if (this.phi >= PI) this.phi -= TWO_PI; // phase wrapping
// First half-Tomisawa generator
let out0 = Math.cos(this.phi + this.B * this.Y0); // self-phase modulation
@ -783,24 +825,23 @@ const chyx = {
/*bit reverse*/ br: function (x, size = 8) {
if (size > 32) {
throw new Error('br() Size cannot be greater than 32');
} else {
let result = 0;
for (let idx = 0; idx < size - 0; idx++) {
result += chyx.bitC(x, 2 ** idx, 2 ** (size - (idx + 1)));
}
return result;
}
let result = 0;
for (let idx = 0; idx < size; idx++) {
result |= chyx.bitC(x, 1 << idx, 1 << (size - (idx + 1)));
}
return result;
},
/*sin that loops every 128 "steps", instead of every pi steps*/ sinf: function (x) {
return Math.sin(x / (128 / Math.PI));
return Math.sin((x * PI) / 128);
},
/*cos that loops every 128 "steps", instead of every pi steps*/ cosf: function (x) {
return Math.cos(x / (128 / Math.PI));
return Math.cos((x * PI) / 128);
},
/*tan that loops every 128 "steps", instead of every pi steps*/ tanf: function (x) {
return Math.tan(x / (128 / Math.PI));
return Math.tan((x * PI) / 128);
},
/*converts t into a string composed of it's bits, regex's that*/ regG: function (t, X) {
/*converts t into a string composed of its bits; regexes that*/ regG: function (t, X) {
return X.test(t.toString(2));
},
};
@ -808,7 +849,7 @@ const chyx = {
// Create shortened Math functions
let mathParams, byteBeatHelperFuncs;
function getByteBeatFunc(codetext) {
if ((mathParams || byteBeatHelperFuncs) == null) {
if (mathParams == null) {
mathParams = Object.getOwnPropertyNames(Math);
byteBeatHelperFuncs = mathParams.map((k) => Math[k]);
const chyxNames = Object.getOwnPropertyNames(chyx);
@ -841,7 +882,7 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
this.func = getByteBeatFunc(codeText);
};
this.initialOffset = null;
this.initialOffset = 0;
this.t = null;
this.func = null;
}
@ -888,18 +929,19 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
this.t = params.begin[0] * sampleRate;
}
const output = outputs[0];
const scale = 256 * INVSR;
for (let i = 0; i < output[0].length; i++) {
const detune = getParamValue(i, params.detune);
const freq = applySemitoneDetuneToFrequency(getParamValue(i, params.frequency), detune / 100);
let local_t = (this.t / (sampleRate / 256)) * freq + this.initialOffset;
const detune = pv(params.detune, i);
const freq = applySemitoneDetuneToFrequency(pv(params.frequency, i), detune / 100);
const local_t = scale * freq * this.t + this.initialOffset;
const funcValue = this.func(local_t);
let signal = (funcValue & 255) / 127.5 - 1;
const out = signal * 0.2;
const signal = (funcValue & 255) / 127.5 - 1;
//prevent speaker blowout via clipping if threshold exceeds
const out = clamp(signal * 0.2, -0.4, 0.4);
for (let c = 0; c < output.length; c++) {
//prevent speaker blowout via clipping if threshold exceeds
output[c][i] = clamp(out, -0.4, 0.4);
output[c][i] = out;
}
this.t = this.t + 1;
this.t++;
}
return true; // keep the audio processing going
@ -907,3 +949,340 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
}
registerProcessor('byte-beat-processor', ByteBeatProcessor);
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,
});
function hash32(u) {
u = u + 0x7ed55d16 + (u << 12);
u = u ^ 0xc761c23c ^ (u >>> 19);
u = u + 0x165667b1 + (u << 5);
u = (u + 0xd3a2646c) ^ (u << 9);
u = u + 0xfd7046c5 + (u << 3);
u = u ^ 0xb55a4f09 ^ (u >>> 16);
return u >>> 0;
}
const hash01 = (i) => (hash32(i) >>> 8) / 0x01000000;
function bitReverse(i, n) {
let r = 0;
for (let b = 0; b < n; b++) {
r = (r << 1) | (i & 1);
i >>>= 1;
}
return r;
}
function noise(x) {
const i = Math.floor(x),
f = x - i;
const a = hash01(i),
b = hash01(i + 1);
return a + (b - a) * f;
}
function brownian(x, oct = 4) {
let amp = 0.5,
sum = 0,
norm = 0,
freq = 1;
for (let o = 0; o < oct; o++) {
sum += amp * noise(x * freq);
norm += amp;
amp *= 0.5;
freq *= 2;
}
return (sum / norm) * 2 - 1;
}
const tablesCache = {};
class WavetableOscillatorProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{ name: 'begin', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY },
{ name: 'end', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY },
{ name: 'frequency', defaultValue: 440, min: Number.EPSILON },
{ name: 'detune', defaultValue: 0 },
{ name: 'freqspread', defaultValue: 0.18, min: 0 },
{ name: 'position', defaultValue: 0, min: 0, max: 1 },
{ name: 'warp', defaultValue: 0, min: 0, max: 1 },
{ name: 'warpMode', defaultValue: 0 },
{ name: 'voices', defaultValue: 1, min: 1, automationRate: 'k-rate' },
{ name: 'panspread', defaultValue: 0.7, min: 0, max: 1 },
{ name: 'phaserand', defaultValue: 0, min: 0, max: 1 },
];
}
constructor(options) {
super(options);
this.frameLen = 0;
this.numFrames = 0;
this.phase = [];
this.port.onmessage = (e) => {
const { type, payload } = e.data || {};
if (type === 'table') {
const key = payload.key;
this.frameLen = payload.frameLen;
if (!tablesCache[key]) {
const tables = [payload.frames];
let table = tables[0];
for (let level = 1; level < 1; level++) {
const nextLen = table.length >> 1;
const nextTable = table.map((frame) => {
const avg = new Float32Array(nextLen);
for (let i = 0; i < nextLen; i++) {
avg[i] = (frame[2 * i] + frame[2 * i + 1]) / 2;
}
return avg;
});
tables.push(nextTable);
table = nextTable;
if (nextLen <= 32) break;
}
tablesCache[key] = tables;
}
this.tables = tablesCache[key];
this.numFrames = this.tables[0].length;
}
};
}
_mirror(x) {
return 1 - Math.abs(2 * x - 1);
}
_toBits(amt, min = 2, max = 12) {
const b = max + (min - max) * amt;
return { b, n: fround(Math.pow(2, b)) };
}
_warpPhase(phase, amt, mode) {
switch (mode) {
case WarpMode.NONE: {
return phase;
}
case WarpMode.ASYM: {
const a = 0.01 + 0.99 * amt;
return phase < a ? (0.5 * phase) / a : 0.5 + (0.5 * (phase - a)) / (1 - a);
}
case WarpMode.MIRROR: {
// Asym, then mirror
return this._mirror(this._warpPhase(phase, amt, WarpMode.ASYM));
}
case WarpMode.BENDP: {
return Math.pow(phase, 1 + 3 * amt);
}
case WarpMode.BENDM: {
return Math.pow(phase, 1 / (1 + 3 * amt));
}
case WarpMode.BENDMP: {
return amt < 0.5 ? this._warpPhase(phase, 1 - 2 * amt, 3) : this._warpPhase(phase, 2 * amt - 1, 2);
}
case WarpMode.SYNC: {
const syncRatio = Math.pow(16, amt ** 2);
return (phase * syncRatio) % 1;
}
case WarpMode.QUANT: {
const { n } = this._toBits(amt);
return ffloor(phase * n) / n;
}
case WarpMode.FOLD: {
const K = 7;
const k = 1 + Math.max(1, fround(K * amt));
return Math.abs(ffrac(k * phase) - 0.5) * 2;
}
case WarpMode.PWM: {
const w = clamp(0.5 + 0.49 * (2 * amt - 1), 0, 1);
if (phase < w) return (phase / w) * 0.5;
return 0.5 + ((phase - w) / (1 - w)) * 0.5;
}
case WarpMode.ORBIT: {
const depth = 0.5 * amt;
const n = 3;
return frac(phase + depth * Math.sin(TWO_PI * n * phase));
}
case WarpMode.SPIN: {
const depth = 0.5 * amt;
const { n } = this._toBits(amt, 1, 6);
return frac(phase + depth * Math.sin(TWO_PI * n * phase));
}
case WarpMode.CHAOS: {
const r = 3.7 + 0.3 * amt;
const logistic = r * phase * (1 - phase);
return clamp((1 - amt) * phase + amt * logistic, 0, 1);
}
case WarpMode.PRIMES: {
const isPrime = (n) => {
if (n < 2) return false;
if (n % 2 === 0) return n === 2;
for (let d = 3; d ** 2 <= n; d += 2) if (n % d === 0) return false;
return true;
};
let { n } = this._toBits(amt, 3);
while (!isPrime(n)) n++;
return ffloor(phase * n) / n;
}
case WarpMode.BINARY: {
let { b } = this._toBits(amt, 3);
b = fround(b);
const n = 1 << b;
const idx = ffloor(phase * n);
const ridx = bitReverse(idx, b);
return ridx / n;
}
case WarpMode.BROWNIAN: {
const disp = 0.25 * amt * brownian(64 * phase, 4);
return frac(phase + disp);
}
case WarpMode.RECIPROCAL: {
const g = 2 + 4 * amt;
const num = phase * g;
const den = phase + (1 - phase) * g;
const y = den > 1e-12 ? num / den : 0;
return clamp(y, 0, 1);
}
case WarpMode.WORMHOLE: {
const gap = clamp(0.8 * amt, 0, 1);
const a = 0.5 * (1 - gap);
const b = 0.5 * (1 + gap);
if (phase < a) return (phase / a) * 0.5;
if (phase > b) return 0.5 * (1 + (phase - b) / (1 - b));
return 0.5;
}
case WarpMode.LOGISTIC: {
let x = phase;
const r = 3.6 + 0.4 * amt;
const iters = 1 + fround(2 * amt);
for (let i = 0; i < iters; i++) x = r * x * (1 - x);
return clamp(x, 0, 1);
}
case WarpMode.SIGMOID: {
const k = 1 + 10 * amt;
const x = phase - 0.5;
const y = 1 / (1 + Math.exp(-k * x));
const y0 = 1 / (1 + Math.exp(0.5 * k));
const y1 = 1 / (1 + Math.exp(-0.5 * k));
return (y - y0) / (y1 - y0);
}
case WarpMode.FRACTAL: {
const d = 0.5 * Math.sin(TWO_PI * phase) * amt;
return frac(phase + d);
}
case WarpMode.FLIP: {
return phase;
}
default:
return phase;
}
}
_sampleFrame(frame, phase) {
const len = frame.length;
const pos = phase * len;
let i = pos | 0;
if (i >= len) i = 0; // fast wrap
const frac = pos - i;
const a = frame[i];
let i1 = i + 1;
if (i1 >= len) i1 = 0;
const b = frame[i1];
return a + (b - a) * frac;
}
_chooseMip(dphi) {
const approxHarm = clamp(dphi, 1e-6, 64);
let level = 0;
while (level + 1 < (this.tables?.length || 1) && approxHarm < this.tables[level][0].length / 8) {
level++;
}
return level;
}
process(_inputs, outputs, parameters) {
if (currentTime >= parameters.end[0]) {
return false;
}
if (currentTime <= parameters.begin[0]) {
return true;
}
const outL = outputs[0][0];
const outR = outputs[0][1] || outputs[0][0];
if (!this.tables) {
outL.fill(0);
if (outR !== outL) outR.set(outL);
return true;
}
const voices = parameters.voices[0]; // k-rate
for (let i = 0; i < outL.length; i++) {
const detune = pv(parameters.detune, i);
const freqspread = pv(parameters.freqspread, i);
const tablePos = clamp(pv(parameters.position, i), 0, 1);
const idx = tablePos * (this.numFrames - 1);
const fIdx = idx | 0;
const frac = idx - fIdx;
const warpAmount = clamp(pv(parameters.warp, i), 0, 1);
const warpMode = pv(parameters.warpMode, i);
const phaseRand = clamp(pv(parameters.phaserand, i), 0, 1);
const panspread = voices > 1 ? clamp(pv(parameters.panspread, i), 0, 1) : 0;
const gain1 = Math.sqrt(0.5 - 0.5 * panspread);
const gain2 = Math.sqrt(0.5 + 0.5 * panspread);
let f = pv(parameters.frequency, i);
f = applySemitoneDetuneToFrequency(f, detune / 100); // overall detune
const normalizer = 1 / Math.sqrt(voices);
const detuner = getDetuner(voices, freqspread);
for (let n = 0; n < voices; n++) {
const isOdd = (n & 1) == 1;
let gainL = gain1;
let gainR = gain2;
// invert right and left gain
if (isOdd) {
gainL = gain2;
gainR = gain1;
}
const fVoice = applySemitoneDetuneToFrequency(f, detuner(n)); // voice detune
const dPhase = fVoice * INVSR;
const level = this._chooseMip(dPhase);
const table = this.tables[level];
// warp phase then sample
this.phase[n] = this.phase[n] ?? Math.random() * phaseRand;
const ph = this._warpPhase(this.phase[n], warpAmount, warpMode);
const s0 = this._sampleFrame(table[fIdx], ph);
const s1 = this._sampleFrame(table[Math.min(this.numFrames - 1, fIdx + 1)], ph);
let s = s0 + (s1 - s0) * frac;
if (warpMode === WarpMode.FLIP && this.phase[n] < warpAmount) {
s = -s;
}
outL[i] += s * gainL * normalizer;
outR[i] += s * gainR * normalizer;
this.phase[n] = ffrac(this.phase[n] + dPhase);
}
}
return true;
}
}
registerProcessor('wavetable-oscillator-processor', WavetableOscillatorProcessor);

View file

@ -1,6 +1,7 @@
//import { ZZFX } from 'zzfx';
import { midiToFreq, noteToMidi } from './util.mjs';
import { registerSound, getAudioContext } from './superdough.mjs';
import { registerSound } from './superdough.mjs';
import { getAudioContext } from './audioContext.mjs';
import { buildSamples } from './zzfx_fork.mjs';
export const getZZFX = (value, t) => {

View file

@ -1,4 +1,4 @@
import { getAudioContext } from './superdough.mjs';
import { getAudioContext } from './audioContext.mjs';
// https://github.com/KilledByAPixel/ZzFX/blob/master/ZzFX.js#L85C5-L180C6
// changes: replaced this.volume with 1 + using sampleRate from getAudioContext()