Merge branch 'main' of ssh://codeberg.org/vvolhejn/strudel into radical-new-docs

This commit is contained in:
Václav Volhejn 2026-01-10 06:58:39 -08:00
commit cc15e3cd36
131 changed files with 13482 additions and 7546 deletions

View file

@ -89,7 +89,7 @@ superdough({ s: 'bd', delay: 0.5 }, 0, 1);
- `decay`: seconds of decay phase
- `sustain`: gain of sustain phase
- `release`: seconds of release phase
- `deadline`: seconds until the sound should play (0 = immediate)
- `deadline`: seconds from audio context initialization before playing the sound (getAudioContextCurrentTime() = immediate)
- `duration`: seconds the sound should last. optional for one shot samples, required for synth sounds
### registerSynthSounds()

View file

@ -5,6 +5,11 @@ export const setDefaultAudioContext = () => {
return audioContext;
};
export const setAudioContext = (context) => {
audioContext = context;
return audioContext;
};
export const getAudioContext = () => {
if (!audioContext) {
return setDefaultAudioContext();

View file

@ -5,19 +5,18 @@ if (typeof DelayNode !== 'undefined') {
wet = Math.abs(wet);
this.delayTime.value = time;
const feedbackGain = ac.createGain();
feedbackGain.gain.value = Math.min(Math.abs(feedback), 0.995);
this.feedback = feedbackGain.gain;
this.feedbackGain = ac.createGain();
this.feedbackGain.gain.value = Math.min(Math.abs(feedback), 0.995);
this.feedback = this.feedbackGain.gain;
const delayGain = ac.createGain();
delayGain.gain.value = wet;
this.delayGain = delayGain;
this.delayGain = ac.createGain();
this.delayGain.gain.value = wet;
this.connect(feedbackGain);
this.connect(delayGain);
feedbackGain.connect(this);
this.connect(this.feedbackGain);
this.connect(this.delayGain);
this.feedbackGain.connect(this);
this.connect = (target) => delayGain.connect(target);
this.connect = (target) => this.delayGain.connect(target);
return this;
}
start(t) {
@ -25,7 +24,7 @@ if (typeof DelayNode !== 'undefined') {
}
}
AudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) {
BaseAudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) {
return new FeedbackDelayNode(this, wet, time, feedback);
};
}

View file

@ -1,7 +1,7 @@
import { getAudioContext } from './audioContext.mjs';
import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
import { getNoiseBuffer } from './noise.mjs';
import { logger } from './logger.mjs';
import { getNoiseBuffer } from './noise.mjs';
import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
export const noises = ['pink', 'white', 'brown', 'crackle'];
@ -42,6 +42,7 @@ export const getParamADSR = (
decay,
sustain,
release,
// min = value at start of attack, max = value at end of attack; it is possible that max < min
min,
max,
begin,
@ -59,17 +60,15 @@ export const getParamADSR = (
max = max === 0 ? 0.001 : max;
}
const range = max - min;
const peak = max;
const sustainVal = min + sustain * range;
const duration = end - begin;
const envValAtTime = (time) => {
let val;
if (attack > time) {
let slope = getSlope(min, peak, 0, attack);
val = time * slope + (min > peak ? min : 0);
val = time * getSlope(min, max, 0, attack) + min;
} else {
val = (time - attack) * getSlope(peak, sustainVal, 0, decay) + peak;
val = (time - attack) * getSlope(max, sustainVal, 0, decay) + max;
}
if (curve === 'exponential') {
val = val || 0.001;
@ -105,22 +104,40 @@ function getModulationShapeInput(val) {
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;
export function getEnvelope(audioContext, properties = {}) {
return getWorklet(audioContext, 'envelope-processor', properties);
}
export function getLfo(audioContext, properties = {}) {
const {
shape = 0,
begin = 0,
end = 0,
time,
depth = 1,
dcoffset = -0.5,
frequency = 1,
skew = 0.5,
phaseoffset = 0,
curve = 1,
min,
max,
...props
} = properties;
const lfoprops = {
frequency: 1,
depth,
skew: 0.5,
phaseoffset: 0,
time: begin,
begin,
end,
shape: getModulationShapeInput(shape),
time: time ?? begin,
depth,
dcoffset,
min: dcoffset * depth,
max: dcoffset * depth + depth,
curve: 1,
frequency,
skew,
phaseoffset,
curve,
shape: getModulationShapeInput(shape),
min: min ?? dcoffset * depth,
max: max ?? dcoffset * depth + depth,
...props,
};
@ -154,6 +171,26 @@ 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 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, {
begin: 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;
@ -170,56 +207,78 @@ export function applyParameterModulators(audioContext, param, start, end, envelo
const [attack, decay, sustain, release] = getADSRValues(values, curve, defaultValues);
getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve);
}
let lfo;
let { defaultDepth = 1, depth, dcoffset, ...getLfoInputs } = lfoValues;
if (depth == null) {
const hasLFOParams = Object.values(getLfoInputs).some((v) => v != null);
depth = hasLFOParams ? defaultDepth : 0;
}
if (depth) {
lfo = getLfo(audioContext, start, end, {
depth,
dcoffset,
...getLfoInputs,
});
lfo.connect(param);
}
return { lfo, disconnect: () => lfo?.disconnect() };
const lfo = getParamLfo(audioContext, param, start, end, lfoValues);
return lfo;
}
export function createFilter(context, start, end, params, cps, cycle) {
let {
frequency,
anchor,
env,
type,
model,
q = 1,
drive = 0.69,
depth,
depthfrequency,
dcoffset = -0.5,
skew,
shape,
rate,
sync,
} = params;
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;
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');
}
return filter;
if (sync != null) {
rate = cps * sync;
}
const hasLFO = [depth, depthfrequency, skew, shape, rate].some((v) => v !== undefined);
let lfo;
if (hasLFO) {
depth = depth ?? 1;
const time = cycle / cps;
const modDepth = depthfrequency ?? (depth ?? 1) * frequency;
const lfoValues = {
depth: modDepth,
dcoffset,
skew,
shape,
frequency: rate ?? cps,
min: -frequency + 30,
max: 20000 - frequency,
time,
curve: 1,
};
lfo = getParamLfo(context, frequencyParam, start, end, lfoValues);
}
return { filter, lfo };
}
// stays 1 until .5, then fades out
@ -241,7 +300,18 @@ export function drywet(dry, wet, wetAmount = 0) {
let mix = ac.createGain();
dry_gain.connect(mix);
wet_gain.connect(mix);
return mix;
return {
node: mix,
teardown: () => {
releaseAudioNode(dry_gain);
releaseAudioNode(wet_gain);
// it is not the responsability of drywet
// to call `releaseAudioNode` on
// the 2 external args dry and wet
dry.disconnect(dry_gain);
wet.disconnect(wet_gain);
},
};
}
let curves = ['linear', 'exponential'];
@ -276,10 +346,19 @@ export function getVibratoOscillator(param, value, t) {
gain.gain.value = vibmod * 100;
vibratoOscillator.connect(gain);
gain.connect(param);
onceEnded(vibratoOscillator, () => {
releaseAudioNode(gain);
releaseAudioNode(vibratoOscillator);
});
vibratoOscillator.start(t);
return vibratoOscillator;
return { stop: (t) => vibratoOscillator.stop(t), nodes: { vib: [vibratoOscillator], vib_gain: [gain] } };
}
}
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) {
@ -292,25 +371,17 @@ export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
constantNode.connect(zeroGain);
// Schedule the `onComplete` callback to occur at `stopTime`
constantNode.onended = () => {
// Ensure garbage collection
try {
zeroGain.disconnect();
} catch {
// pass
}
try {
constantNode.disconnect();
} catch {
// pass
}
onceEnded(constantNode, () => {
releaseAudioNode(zeroGain);
releaseAudioNode(constantNode);
onComplete();
};
});
constantNode.start(startTime);
constantNode.stop(stopTime);
return constantNode;
}
const mod = (freq, range = 1, type = 'sine') => {
const mod = (freq, type = 'sine') => {
const ctx = getAudioContext();
let osc;
if (noises.includes(type)) {
@ -322,64 +393,94 @@ const mod = (freq, range = 1, type = 'sine') => {
osc.type = type;
osc.frequency.value = freq;
}
osc.start();
const g = new GainNode(ctx, { gain: range });
osc.connect(g); // -range, range
return { node: g, stop: (t) => osc.stop(t) };
return osc;
};
const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => {
const fm = (frequencyparam, harmonicityRatio, wave = 'sine') => {
const carrfreq = frequencyparam.value;
const modfreq = carrfreq * harmonicityRatio;
const modgain = modfreq * modulationIndex;
return mod(modfreq, modgain, wave);
return { osc: mod(modfreq, wave), freq: modfreq };
};
export function applyFM(param, value, begin) {
const {
fmh: fmHarmonicity = 1,
fmi: fmModulationIndex,
fmenv: fmEnvelopeType = 'exp',
fmattack: fmAttack,
fmdecay: fmDecay,
fmsustain: fmSustain,
fmrelease: fmRelease,
fmvelocity: fmVelocity,
fmwave: fmWaveform = 'sine',
duration,
} = value;
let modulator;
let stop = () => {};
if (fmModulationIndex) {
const ac = getAudioContext();
const envGain = ac.createGain();
const fmmod = fm(param, fmHarmonicity, fmModulationIndex, fmWaveform);
modulator = fmmod.node;
stop = fmmod.stop;
if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].some((v) => v !== undefined)) {
// no envelope by default
modulator.connect(param);
} else {
const [attack, decay, sustain, release] = getADSRValues([fmAttack, fmDecay, fmSustain, fmRelease]);
const holdEnd = begin + duration;
getParamADSR(
envGain.gain,
attack,
decay,
sustain,
release,
0,
1,
begin,
holdEnd,
fmEnvelopeType === 'exp' ? 'exponential' : 'linear',
);
modulator.connect(envGain);
envGain.connect(param);
const ac = getAudioContext();
const toStop = []; // fm oscillators we will expose `stop` for
const fms = {};
const nodes = {};
// Matrix
for (let i = 1; i <= 8; i++) {
for (let j = 0; j <= 8; j++) {
let control;
if (i === j + 1) {
// Standard fm3 -> fm2 -> fm1 -> param usage
const iS = i === 1 ? '' : i;
control = `fmi${iS}`;
} else {
control = `fmi${i}${j}`;
}
const amt = value[control];
if (!amt) continue;
let io = [];
for (let [isMod, idx] of [
[true, i], // source
[false, j], // target
]) {
if (idx === 0) {
io.push(param);
continue;
}
if (!fms[idx]) {
const idxS = idx === 1 ? '' : idx;
const { osc, freq } = fm(param, value[`fmh${idxS}`] ?? 1, value[`fmwave${idxS}`] ?? 'sine');
toStop.push(osc);
const toCleanup = [osc]; // nodes we want to cleanup after oscillator `stop`
const adsr = ['attack', 'decay', 'sustain', 'release'].map((s) => value[`fm${s}${idxS}`]);
let output = osc;
if (adsr.some((v) => v !== undefined)) {
const envGain = ac.createGain();
const [attack, decay, sustain, release] = getADSRValues(adsr);
const holdEnd = begin + value.duration;
const fmEnvelopeType = value[`fmenv${idxS}`] ?? 'exp';
getParamADSR(
envGain.gain,
attack,
decay,
sustain,
release,
0,
1,
begin,
holdEnd,
fmEnvelopeType === 'exp' ? 'exponential' : 'linear',
);
toCleanup.push(envGain);
output = osc.connect(envGain);
}
fms[idx] = { input: osc.frequency, output, freq, osc, toCleanup };
nodes[`fm_${idx}`] = [osc];
}
const { input, output, freq, osc, toCleanup } = fms[idx];
const gAmt = gainNode(amt);
const gFreq = gainNode(freq);
io.push(isMod ? output.connect(gAmt).connect(gFreq) : input);
cleanupOnEnd(osc, [...toCleanup, gAmt, gFreq]);
nodes[`fm_${idx}_gain`] = [gAmt];
}
if (!io[1]) {
logger(
`[superdough] control ${control} failed to connect FM ${i} to target ${j} due to missing frequency parameter (likely because fm${j} is noise)`,
'warning',
);
continue;
}
io[0].connect(io[1]);
}
}
return { stop };
return {
nodes,
stop: (t) => toStop.forEach((m) => m?.stop(t)),
};
}
// Saturation curves
@ -479,7 +580,7 @@ export const getDistortion = (distort, postgain, algorithm) => {
};
export const getFrequencyFromValue = (value, defaultNote = 36) => {
let { note, freq } = value;
let { note, freq, octave = 0 } = value;
note = note || defaultNote;
if (typeof note === 'string') {
note = noteToMidi(note); // e.g. c3 => 48
@ -488,14 +589,64 @@ export const getFrequencyFromValue = (value, defaultNote = 36) => {
if (!freq && typeof note === 'number') {
freq = midiToFreq(note); // + 48);
}
freq *= Math.pow(2, octave);
return Number(freq);
};
export const destroyAudioWorkletNode = (node) => {
if (node == null) {
return;
}
node.disconnect();
node.parameters.get('end')?.setValueAtTime(0, 0);
// This helper should be used instead of the `node.onended = callback` pattern
// It adds a mechanism to help minimize gc retention
export const onceEnded = (node, callback) => {
const onended = callback;
node.onended = function cleanup() {
onended && onended();
this.onended = null;
};
};
export const releaseAudioNode = (node) => {
if (node == null) return;
// check we received an AudioNode
if (!(node instanceof AudioNode)) {
throw new Error('releaseAudioNode can only release an AudioNode');
}
// https://developer.mozilla.org/en-US/docs/Web/API/AudioNode/disconnect
node.disconnect();
// make sure all AudioScheduledSourceNodes are in a stopped state
// https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode
if (node instanceof AudioScheduledSourceNode) {
if (process.env.NODE_ENV === 'development' && node.onended && node.onended.name !== 'cleanup') {
logger(
`[superdough] Deprecation warning: it seems your code path is setting 'node.onended = callback' instead of using the onceEnded helper`,
);
}
try {
node.stop();
} catch (e) {
// At the stage, `start` was not called on the node
// but an `onended` callback releasing resources may exist
// and we want it to fire :
// - we force a start/stop cycle so that `onended` gets called
// - we `lock` the node so that no-one can start it
node.start(node.context.currentTime + 5); // will never happen
node.stop();
}
}
// https://www.w3.org/TR/webaudio-1.1/#AudioNode-actively-processing
// An AudioWorkletNode is actively processing when its AudioWorkletProcessor's [[callable process]]
// returns true and either its active source flag is true or
// any AudioNode connected to one of its inputs is actively processing.
if (node instanceof AudioWorkletNode) {
// while `end` is not native to the web audio API, it is common practice in superdough
// to use that param in the worklets to trigger returning false from the processor
node.parameters.get('end')?.setValueAtTime(0, 0);
}
};
// Once the `anchor` node has ended, release all nodes in `toCleanup`
export const cleanupOnEnd = (anchor, toCleanup) => {
onceEnded(anchor, () => toCleanup.forEach((n) => releaseAudioNode(n)));
};

View file

@ -10,6 +10,7 @@ export * from './helpers.mjs';
export * from './synth.mjs';
export * from './zzfx.mjs';
export * from './logger.mjs';
export * from './modulators.mjs';
export * from './dspworklet.mjs';
export * from './audioContext.mjs';
export * from './wavetable.mjs';

View file

@ -0,0 +1,178 @@
/*
modulators.mjs - Helpers for constructing modulators (envelopes, LFOs, etc.)
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/modulators.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { getAudioContext } from './audioContext.mjs';
import { gainNode, getEnvelope, getLfo, webAudioTimeout } from './helpers.mjs';
import { errorLogger } from './logger.mjs';
import { getSuperdoughControlTargets } from './superdoughdata.mjs';
import { clamp } from './util.mjs';
const getNodeParam = (node, name) => {
// Worklet case
if (node?.parameters) {
const p = node.parameters.get(name);
if (p instanceof AudioParam) {
return p;
}
}
// Built-in node case
let p = node?.[name];
if (p === undefined && name === 'frequency') {
// Fallbacks for source nodes without 'frequency' params (e.g. soundfonts)
p = node?.['detune'] ?? node?.['playbackRate'];
}
if (p instanceof AudioParam) {
return p;
}
return undefined;
};
const controlTargets = getSuperdoughControlTargets();
const getControlData = (control, subControl) => {
const controlNoIdx = control.split('_')[0];
return controlTargets[`${controlNoIdx}_${subControl}`] ?? controlTargets[controlNoIdx];
};
const getRangeForParam = (paramName, currentValue) => {
// We clamp the frequency to a reasonable range unless the currentValue
// is low, which indicates this may be an LFO
if (paramName === 'frequency' && currentValue >= 30) {
return { min: 20 - currentValue, max: 24000 - currentValue };
}
return { min: undefined, max: undefined };
};
const clampWithWaveShaper = (modulator, min, max) => {
const ac = getAudioContext();
const curve = new Float32Array(256);
for (let i = 0; i < curve.length; i++) {
const x = (i / (curve.length - 1)) * 2 - 1;
curve[i] = clamp(x * max, min, max);
}
const shaper = new WaveShaperNode(ac, { curve });
const scaleGain = gainNode(1 / max);
modulator.connect(scaleGain).connect(shaper);
return { modulator, toCleanup: [shaper, scaleGain] };
};
const getTargetParamsForControl = (control, nodes, subControl) => {
const targetInfo = getControlData(control, subControl);
if (!targetInfo) {
errorLogger(
new Error(`Could not find control data for target '${control}'. It may not be modulatable.`),
'superdough',
);
return { targetParams: [], paramName: control };
}
const paramName = targetInfo.param;
const nodeKey = nodes[targetInfo.node] ? targetInfo.node : control;
const targetNodes = nodes[nodeKey];
if (!targetNodes) {
const keys = Object.keys(nodes);
errorLogger(
new Error(`Could not connect to target '${nodeKey}' — it does not exist. Available targets: ${keys.join(', ')}`),
'superdough',
);
return { targetParams: [], paramName };
}
const audioParams = [];
targetNodes.forEach((targetNode) => {
const targetParam = getNodeParam(targetNode, paramName);
audioParams.push(targetParam);
});
return { targetParams: audioParams, paramName };
};
export const connectLFO = (id, params, nodeTracker) => {
const {
rate = 1,
sync,
cps,
cycle,
control = 'lfo',
subControl,
fxi = 'main',
depth = 1,
depthabs,
...filteredParams
} = params;
const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl);
if (!targetParams.length) return;
let currentValue = targetParams[0].value;
currentValue = currentValue === 0 ? 1 : currentValue;
const { min, max } = getRangeForParam(paramName, currentValue);
const depthValue = depthabs != null ? depthabs : depth * currentValue;
const modParams = {
...filteredParams,
frequency: sync !== undefined ? sync * cps : rate,
time: cycle / cps,
depth: depthValue,
min,
max,
};
const lfoNode = getLfo(getAudioContext(), modParams);
nodeTracker.main[`lfo_${id}`] = [lfoNode];
targetParams.forEach((t) => lfoNode.connect(t));
return lfoNode;
};
export const connectEnvelope = (id, params, nodeTracker) => {
const { control, subControl, acurve, dcurve, rcurve, depth = 1, depthabs, fxi = 'main', ...filteredParams } = params;
const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl);
if (!targetParams.length) return;
let currentValue = targetParams[0].value;
currentValue = currentValue === 0 ? 1 : currentValue;
const { min, max } = getRangeForParam(paramName, currentValue);
const depthValue = depthabs != null ? depthabs : depth * currentValue;
const envNode = getEnvelope(getAudioContext(), {
...filteredParams,
depth: depthValue,
min,
max,
attackCurve: acurve,
decayCurve: dcurve,
releaseCurve: rcurve,
});
nodeTracker.main[`env_${id}`] = [envNode];
targetParams.forEach((t) => envNode.connect(t));
return envNode;
};
export const connectBusModulator = (params, nodeTracker, controller) => {
const ac = getAudioContext();
const { control, subControl, depth = 1, depthabs, fxi = 'main' } = params;
const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl);
if (!targetParams.length) return { toCleanup: [] };
const signal = controller.getBus(params.bus);
const dc = new ConstantSourceNode(ac, { offset: params.dc ?? 0 });
dc.start(params.begin);
const shifted = dc.connect(gainNode(1));
signal.connect(shifted);
let currentValue = targetParams[0].value;
currentValue = currentValue === 0 ? 1 : currentValue;
const { min, max } = getRangeForParam(paramName, currentValue);
const depthValue = depthabs != null ? depthabs : depth * currentValue;
const depthGain = gainNode((Math.sign(depthValue) * Math.abs(depthValue)) / 0.3);
const unClamped = shifted.connect(depthGain);
const toCleanup = [];
let modulator = unClamped;
if (min !== undefined && max !== undefined) {
const wsData = clampWithWaveShaper(unClamped, min, max);
modulator = wsData.modulator;
toCleanup.push(...wsData.toCleanup);
}
webAudioTimeout(
ac,
() => {
targetParams.forEach((t) => modulator.connect(t));
},
0,
params.begin,
);
toCleanup.push(dc, shifted, depthGain);
return { modulator, toCleanup };
};

View file

@ -1,4 +1,4 @@
import { drywet } from './helpers.mjs';
import { drywet, onceEnded, releaseAudioNode } from './helpers.mjs';
import { getAudioContext } from './audioContext.mjs';
let noiseCache = {};
@ -65,8 +65,12 @@ export function getNoiseOscillator(type = 'white', t, density = 0.02) {
export function getNoiseMix(inputNode, wet, t) {
const noiseOscillator = getNoiseOscillator('pink', t);
const noiseMix = drywet(inputNode, noiseOscillator.node, wet);
onceEnded(noiseOscillator.node, () => {
releaseAudioNode(noiseOscillator.node);
});
return {
node: noiseMix,
node: noiseMix.node,
stop: (time) => noiseOscillator?.stop(time),
teardown: noiseMix.teardown,
};
}

View file

@ -37,5 +37,8 @@
},
"dependencies": {
"nanostores": "^0.11.3"
},
"engines": {
"node": ">=18.0.0"
}
}

View file

@ -2,7 +2,7 @@ import reverbGen from './reverbGen.mjs';
import { clamp } from './util.mjs';
if (typeof AudioContext !== 'undefined') {
AudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) {
BaseAudioContext.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);
@ -23,7 +23,7 @@ if (typeof AudioContext !== 'undefined') {
return newBuffer;
};
AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) {
BaseAudioContext.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, irspeed, irbegin) => {
convolver.duration = d;

View file

@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { releaseAudioNode } from './helpers.mjs';
var reverbGen = {};
/** Generates a reverb impulse response.
@ -107,6 +109,8 @@ var applyGradualLowpass = function (input, lpFreqStart, lpFreqEnd, lpFreqEndAt,
player.start();
context.oncomplete = function (event) {
callback(event.renderedBuffer);
releaseAudioNode(filter);
releaseAudioNode(player);
};
context.startRendering();

View file

@ -1,7 +1,14 @@
import { getCommonSampleInfo } from './util.mjs';
import { getBaseURL, getCommonSampleInfo } from './util.mjs';
import { registerSound, registerWaveTable } from './index.mjs';
import { getAudioContext } from './audioContext.mjs';
import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs';
import {
getADSRValues,
getParamADSR,
getPitchEnvelope,
getVibratoOscillator,
onceEnded,
releaseAudioNode,
} from './helpers.mjs';
import { logger } from './logger.mjs';
const bufferCache = {}; // string: Promise<ArrayBuffer>
@ -58,21 +65,22 @@ export const getSampleBufferSource = async (hapValue, bank, resolveUrl) => {
bufferSource.playbackRate.value = playbackRate;
const { loopBegin = 0, loopEnd = 1, begin = 0, end = 1 } = hapValue;
const bufferDuration = bufferSource.buffer.duration;
// "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;
// The computation of the offset into the sound is performed using the sound buffer's natural duration,
// rather than the playback duration, so that 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 * bufferDuration;
const loop = hapValue.loop;
if (loop) {
bufferSource.loop = true;
bufferSource.loopStart = loopBegin * bufferSource.buffer.duration - offset;
bufferSource.loopEnd = loopEnd * bufferSource.buffer.duration - offset;
bufferSource.loopStart = loopBegin * bufferDuration;
bufferSource.loopEnd = loopEnd * bufferDuration;
}
const bufferDuration = bufferSource.buffer.duration / bufferSource.playbackRate.value;
const sliceDuration = (end - begin) * bufferDuration;
return { bufferSource, offset, bufferDuration, sliceDuration };
const playbackDuration = bufferDuration / bufferSource.playbackRate.value;
const sliceDuration = (end - begin) * playbackDuration;
return { bufferSource, offset, bufferDuration, playbackDuration, sliceDuration };
};
export const loadBuffer = (url, ac, s, n = 0) => {
@ -211,7 +219,7 @@ export async function fetchSampleMap(url) {
// not a browser
return;
}
const base = url.split('/').slice(0, -1).join('/');
const base = getBaseURL(url);
if (typeof fetch === 'undefined') {
// skip fetch when in node / testing
return;
@ -237,12 +245,6 @@ export async function fetchSampleMap(url) {
* sd: '808sd/SD0010.WAV'
* }, 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/');
* s("[bd ~]*2, [~ hh]*2, ~ sd")
* @example
* samples('shabda:noise,chimp:2')
* s("noise <chimp:0*2 chimp:1>")
* @example
* samples('shabda/speech/fr-FR/f:chocolat')
* s("chocolat*4")
*/
export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => {
@ -287,19 +289,21 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
const { bufferSource, sliceDuration, offset } = await getSampleBufferSource(value, bank, resolveUrl);
// asny stuff above took too long?
if (ac.currentTime > t) {
logger(`[sampler] still loading sound "${s}:${n}"`, 'highlight');
// console.warn('sample still loading:', s, n);
return;
}
if (!bufferSource) {
logger(`[sampler] could not load "${s}:${n}"`, 'error');
return;
}
// async stuff above took too long?
if (ac.currentTime > t) {
logger(`[sampler] loading sound "${s}:${n}" took too long`, 'highlight');
// AudioBufferSourceNode will never be used. discard it
releaseAudioNode(bufferSource);
return;
}
// vibrato
let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, t);
const vibratoHandle = getVibratoOscillator(bufferSource.detune, value, t);
const time = t + nudge;
bufferSource.start(time, offset);
@ -320,19 +324,19 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
const out = ac.createGain(); // we need a separate gain for the cutgroups because firefox...
node.connect(out);
bufferSource.onended = function () {
bufferSource.disconnect();
vibratoOscillator?.stop();
node.disconnect();
out.disconnect();
onceEnded(bufferSource, function () {
releaseAudioNode(bufferSource);
vibratoHandle?.stop();
releaseAudioNode(node);
releaseAudioNode(out);
onended();
};
});
let envEnd = holdEnd + release + 0.01;
bufferSource.stop(envEnd);
const stop = (endTime) => {
bufferSource.stop(endTime);
};
const handle = { node: out, bufferSource, stop };
const handle = { node: out, nodes: { source: [bufferSource], ...vibratoHandle?.nodes }, stop };
// cut groups
if (cut !== undefined) {

View file

@ -7,25 +7,48 @@ 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 { nanFallback, _mod, cycleToSeconds } from './util.mjs';
import { clamp, nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
import workletsUrl from './worklets.mjs?audioworklet';
import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend } from './helpers.mjs';
import {
createFilter,
effectSend,
gainNode,
getCompressor,
getDistortion,
getLfo,
getWorklet,
releaseAudioNode,
webAudioTimeout,
} from './helpers.mjs';
import { map } from 'nanostores';
import { logger } from './logger.mjs';
import { connectLFO, connectEnvelope, connectBusModulator } from './modulators.mjs';
import { loadBuffer } from './sampler.mjs';
import { getAudioContext } from './audioContext.mjs';
import { SuperdoughAudioController } from './superdoughoutput.mjs';
import { resetSeenKeys } from './wavetable.mjs';
export const DEFAULT_MAX_POLYPHONY = 128;
const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard';
let maxPolyphony = DEFAULT_MAX_POLYPHONY;
export let maxPolyphony = DEFAULT_MAX_POLYPHONY;
/**
* Set the max polyphony. If notes are ringing out via `release` then they will
* start to die out in first-in-first-out order once the max polyphony has been hit
*
* @name setMaxPolyphony
* @param {number} Max polyphony. Defaults to 128
* @example
* setMaxPolyphony(4)
* n(irand(24).seg(8)).scale("C#3:minor").room(1).release(4).gain(0.5)
*
*/
export function setMaxPolyphony(polyphony) {
maxPolyphony = parseInt(polyphony) ?? DEFAULT_MAX_POLYPHONY;
}
let multiChannelOrbits = false;
export let multiChannelOrbits = false;
export function setMultiChannelOrbits(bool) {
multiChannelOrbits = bool == true;
}
@ -43,6 +66,17 @@ export function applyGainCurve(val) {
return gainCurveFunc(val);
}
/**
* Apply a function to all gains provided in patterns. Can be used to rescale gain to be
* quadratic, exponential, etc. rather than linear
*
* @name setGainCurve
* @param {Function} function to apply to all gain values
* @example
* setGainCurve((x) => x * x) // quadratic gain
* s("bd*4").gain(0.5) // equivalent to 0.25 gain normally
*
*/
export function setGainCurve(newGainCurveFunc) {
gainCurveFunc = newGainCurveFunc;
}
@ -149,17 +183,13 @@ 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,
busgain: 1,
byteBeatExpression: '0',
delayfeedback: 0.5,
delaysync: 3 / 16,
@ -167,6 +197,9 @@ let defaultDefaultValues = {
i: 1,
velocity: 1,
fft: 8,
tremolodepth: 1,
tremolophase: 0,
release: 0.01,
};
const defaultDefaultDefaultValues = Object.freeze({ ...defaultDefaultValues });
@ -211,11 +244,13 @@ export function registerWorklet(url) {
}
let workletsLoading;
function loadWorklets() {
export function loadWorklets() {
if (!workletsLoading) {
const audioCtx = getAudioContext();
const allWorkletURLs = externalWorklets.concat([workletsUrl]);
workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL)));
workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL))).then(
() => (workletsLoading = undefined),
);
}
return workletsLoading;
@ -232,6 +267,7 @@ export async function initAudio(options = {}) {
setMaxPolyphony(maxPolyphony);
setMultiChannelOrbits(multiChannelOrbits);
resetSeenKeys();
if (typeof window === 'undefined') {
return;
}
@ -253,8 +289,9 @@ export async function initAudio(options = {}) {
logger('[superdough] failed to set audio interface', 'warning');
}
}
await audioCtx.resume();
if ((!audioCtx) instanceof OfflineAudioContext) {
await audioCtx.resume();
}
if (disableWorklets) {
logger('[superdough]: AudioWorklets disabled with disableWorklets');
return;
@ -271,8 +308,8 @@ let audioReady;
export async function initAudioOnFirstClick(options) {
if (!audioReady) {
audioReady = new Promise((resolve) => {
document.addEventListener('click', async function listener() {
document.removeEventListener('click', listener);
document.addEventListener('mousedown', async function listener() {
document.removeEventListener('mousedown', listener);
await initAudio(options);
resolve();
});
@ -282,24 +319,30 @@ export async function initAudioOnFirstClick(options) {
}
let controller;
function getSuperdoughAudioController() {
export function getSuperdoughAudioController() {
if (controller == null) {
controller = new SuperdoughAudioController(getAudioContext());
}
return controller;
}
export function setSuperdoughAudioController(newController) {
controller = newController;
return controller;
}
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) {
function getPhaser(begin, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) {
const ac = getAudioContext();
const lfoGain = getLfo(ac, time, end, { frequency, depth: sweep * 2 });
const lfo = getLfo(ac, { frequency, depth: sweep * 2, begin, end });
//filters
const numStages = 2; //num of filters in series
let fOffset = 0;
const numStages = 1; //num of filters in series
let fOffset = 282; //for backward compat in #1800
const filterChain = [];
for (let i = 0; i < numStages; i++) {
const filter = ac.createBiquadFilter();
@ -308,14 +351,11 @@ function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000
filter.frequency.value = centerFrequency + fOffset;
filter.Q.value = 2 - Math.min(Math.max(depth * 2, 0), 1.9);
lfoGain.connect(filter.detune);
lfo.connect(filter.detune);
fOffset += 282;
if (i > 0) {
filterChain[i - 1].connect(filter);
}
filterChain.push(filter);
}
return filterChain[filterChain.length - 1];
return { filterChain, lfo };
}
function getFilterType(ftype) {
@ -328,7 +368,7 @@ export let analysers = {},
analysersData = {};
export function getAnalyserById(id, fftSize = 1024, smoothingTimeConstant = 0.5) {
if (!analysers[id]) {
if (!analysers[id] || analysers[id].context != getAudioContext()) {
// make sure this doesn't happen too often as it piles up garbage
const analyserNode = getAudioContext().createAnalyser();
analyserNode.fftSize = fftSize;
@ -369,7 +409,37 @@ function mapChannelNumbers(channels) {
return (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1);
}
class Chain {
constructor(head) {
this.audioNodes = [head];
this.tails = [head];
}
connect(...nodes) {
nodes.forEach((node) => {
this.tails.forEach((tail) => {
tail.connect(node);
});
});
this.tails = nodes;
this.audioNodes.push(...nodes);
return this;
}
connectOne(idx, node) {
this.tails[idx].connect(node);
this.tails[idx] = node;
this.audioNodes.push(node);
return this;
}
releaseNodes() {
this.audioNodes.forEach((n) => releaseAudioNode(n));
this.audioNodes = [];
this.tails = [];
}
}
export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) => {
// mapping from main FX and numbered FX chains to nodes
const nodes = { main: {} };
// new: t is always expected to be the absolute target onset time
const ac = getAudioContext();
const audioController = getSuperdoughAudioController();
@ -390,7 +460,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
// duration is passed as value too..
value.duration = hapDuration;
// calculate absolute time
if (t < ac.currentTime) {
console.warn(
`[superdough]: cannot schedule sounds in the past (target: ${t.toFixed(2)}, now: ${ac.currentTime.toFixed(2)})`,
@ -399,74 +468,24 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
}
// destructure
let {
tremolo,
tremolosync,
tremolodepth = 1,
tremoloskew,
tremolophase = 0,
tremoloshape,
s = getDefaultValue('s'),
bank,
source,
gain = getDefaultValue('gain'),
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,
phaserdepth = getDefaultValue('phaserdepth'),
phasersweep,
phasercenter,
//
coarse,
crush,
release = getDefaultValue('release'),
dry,
shape,
shapevol = getDefaultValue('shapevol'),
distort,
distortvol = getDefaultValue('distortvol'),
distorttype = getDefaultValue('distorttype'),
pan,
vowel,
delay = getDefaultValue('delay'),
delayfeedback = getDefaultValue('delayfeedback'),
delaysync = getDefaultValue('delaysync'),
delaytime,
orbit = getDefaultValue('orbit'),
bus,
busgain = getDefaultValue('busgain'),
room,
roomfade,
roomlp,
@ -476,14 +495,10 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
irspeed,
irbegin,
i = getDefaultValue('i'),
velocity = getDefaultValue('velocity'),
analyze, // analyser wet
fft = getDefaultValue('fft'), // fftSize 0 - 10
compressor: compressorThreshold,
compressorRatio,
compressorKnee,
compressorAttack,
compressorRelease,
FX = [],
FXrelease,
} = value;
delaytime = delaytime ?? cycleToSeconds(delaysync, cps);
@ -498,23 +513,19 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
audioController.duck(duckorbit, t, duckonset, duckattack, duckdepth);
}
gain = applyGainCurve(nanFallback(gain, 1));
postgain = applyGainCurve(postgain);
shapevol = applyGainCurve(shapevol);
distortvol = applyGainCurve(distortvol);
delay = applyGainCurve(delay);
velocity = applyGainCurve(velocity);
tremolodepth = applyGainCurve(tremolodepth);
gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future
busgain = applyGainCurve(busgain);
const end = t + hapDuration;
const endWithRelease = end + release;
const fullRelease = Math.max(release, FXrelease ?? 0);
const endWithRelease = end + fullRelease;
const chainID = Math.round(Math.random() * 1000000);
// 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);
@ -522,8 +533,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
activeSoundSources.delete(chainID);
}
let audioNodes = [];
if (['-', '~', '_'].includes(s)) {
return;
}
@ -536,17 +545,27 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
let sourceNode;
if (source) {
sourceNode = source(t, value, hapDuration, cps);
nodes.main['source'] = [sourceNode];
} else if (getSound(s)) {
const { onTrigger } = getSound(s);
const onEnded = () => {
audioNodes.forEach((n) => n?.disconnect());
activeSoundSources.delete(chainID);
};
// We have to use onEnded because some sources (e.g. `sampler`) have
// an internal duration which is longer than `value.duration`
const onEnded = () =>
webAudioTimeout(
ac,
() => {
chain.releaseNodes();
activeSoundSources.delete(chainID);
},
0,
endWithRelease,
);
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
nodes.main = { ...nodes.main, ...soundHandle.nodes };
}
} else {
throw new Error(`sound ${s} not found! Is it loaded?`);
@ -561,141 +580,345 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
logger('[webaudio] skip hap: still loading', ac.currentTime - t);
return;
}
const chain = []; // audio nodes that will be connected to each other sequentially
chain.push(sourceNode);
stretch !== undefined && chain.push(getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: stretch }));
// gain stage
chain.push(gainNode(gain));
const chain = new Chain(sourceNode); // connection manager which tracks audio nodes for releasing
FX = [...FX, value]; // run through the FX chain and then run through all FX outside of it as well
for (let [idx, fx] of Object.entries(FX)) {
const key = idx == FX.length - 1 ? 'main' : idx;
nodes[key] ??= {};
const fxNodes = nodes[key];
let {
gain = getDefaultValue('gain'),
velocity = getDefaultValue('velocity'),
shapevol = getDefaultValue('shapevol'),
distorttype = getDefaultValue('distorttype'),
distortvol = getDefaultValue('distortvol'),
tremolodepth = getDefaultValue('tremolodepth'),
phaserdepth = getDefaultValue('phaserdepth'),
delay = getDefaultValue('delay'),
delayfeedback = getDefaultValue('delayfeedback'),
delaysync = getDefaultValue('delaysync'),
delaytime,
stretch = getDefaultValue('stretch'),
i = getDefaultValue('i'),
} = fx;
gain = applyGainCurve(nanFallback(gain, 1));
shapevol = applyGainCurve(shapevol);
distortvol = applyGainCurve(distortvol);
velocity = applyGainCurve(velocity);
tremolodepth = applyGainCurve(tremolodepth);
gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future
delaytime = delaytime ?? cycleToSeconds(delaysync, cps);
//filter
const ftype = getFilterType(value.ftype);
if (cutoff !== undefined) {
let lp = () =>
createFilter(
if (stretch !== undefined) {
const phaseVocoder = getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: stretch });
chain.connect(phaseVocoder);
fxNodes['stretch'] = [phaseVocoder];
}
if (fx.transient !== undefined) {
const transProcessor = getWorklet(
ac,
'lowpass',
cutoff,
resonance,
lpattack,
lpdecay,
lpsustain,
lprelease,
lpenv,
t,
end,
fanchor,
ftype,
drive,
'transient-processor',
{},
{
processorOptions: {
attack: fx.transient,
sustain: fx.transsustain,
begin: t,
end: endWithRelease,
},
},
);
chain.push(lp());
if (ftype === '24db') {
chain.push(lp());
chain.connect(transProcessor);
fxNodes['transient'] = transProcessor;
}
}
if (hcutoff !== undefined) {
let hp = () =>
createFilter(
// gain stage
const initialGain = gainNode(gain);
fxNodes['gain'] = [initialGain];
chain.connect(initialGain);
// filter
const ftype = getFilterType(value.ftype);
const filt = (params) => createFilter(ac, t, end, params, cps, cycle);
if (fx.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',
depthfrequency: 'lpdepthfrequency',
shape: 'lpshape',
dcoffset: 'lpdc',
skew: 'lpskew',
};
const lpParams = pickAndRename(fx, lpMap);
lpParams.type = 'lowpass';
const { filter: lpf1, lfo: lfo1 } = filt(lpParams);
fxNodes['lpf'] = [lpf1];
fxNodes['lpf_lfo'] = [lfo1];
chain.connect(lpf1);
lfo1 && chain.audioNodes.push(lfo1);
if (ftype === '24db') {
const { filter: lpf2, lfo: lfo2 } = filt(lpParams);
fxNodes['lpf'].push(lpf2);
fxNodes['lpf_lfo'].push(lfo2);
chain.connect(lpf2);
lfo2 && chain.audioNodes.push(lfo2);
}
}
if (fx.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',
depthfrequency: 'hpdepthfrequency',
shape: 'hpshape',
dcoffset: 'hpdc',
skew: 'hpskew',
};
const hpParams = pickAndRename(fx, hpMap);
hpParams.type = 'highpass';
const { filter: hpf1, lfo: lfo1 } = filt(hpParams);
fxNodes['hpf'] = [hpf1];
fxNodes['hpf_lfo'] = [lfo1];
lfo1 && chain.audioNodes.push(lfo1);
chain.connect(hpf1);
if (ftype === '24db') {
const { filter: hpf2, lfo: lfo2 } = filt(hpParams);
fxNodes['hpf'].push(hpf2);
fxNodes['hpf_lfo'].push(lfo2);
chain.connect(hpf2);
lfo2 && chain.audioNodes.push(lfo2);
}
}
if (fx.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',
depthfrequency: 'bpdepthfrequency',
shape: 'bpshape',
dcoffset: 'bpdc',
skew: 'bpskew',
};
const bpParams = pickAndRename(fx, bpMap);
bpParams.type = 'bandpass';
const { filter: bpf1, lfo: lfo1 } = filt(bpParams);
fxNodes['bpf'] = [bpf1];
fxNodes['bpf_lfo'] = [lfo1];
chain.connect(bpf1);
lfo1 && chain.audioNodes.push(lfo1);
if (ftype === '24db') {
const { filter: bpf2, lfo: lfo2 } = filt(bpParams);
fxNodes['bpf'].push(bpf2);
fxNodes['bpf_lfo'].push(lfo2);
chain.connect(bpf2);
lfo2 && chain.audioNodes.push(lfo2);
}
}
if (fx.vowel !== undefined) {
const vowelNode = ac.createVowelFilter(fx.vowel);
fxNodes['vowel'] = vowelNode.filters;
chain.connect(vowelNode);
}
// effects
if (fx.coarse !== undefined) {
const coarseNode = getWorklet(ac, 'coarse-processor', { coarse: fx.coarse });
fxNodes['coarse'] = [coarseNode];
chain.connect(coarseNode);
}
if (fx.crush !== undefined) {
const crushNode = getWorklet(ac, 'crush-processor', { crush: fx.crush });
fxNodes['crush'] = [crushNode];
chain.connect(crushNode);
}
if (fx.shape !== undefined) {
const shapeNode = getWorklet(ac, 'shape-processor', { shape: fx.shape, postgain: shapevol });
fxNodes['shape'] = [shapeNode];
chain.connect(shapeNode);
}
if (fx.distort !== undefined) {
const distortNode = getDistortion(fx.distort, distortvol, distorttype);
fxNodes['distort'] = [distortNode];
chain.connect(distortNode);
}
let tremolo = fx.tremolo;
if (fx.tremolosync != null) {
tremolo = cps * fx.tremolosync;
}
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
const gain = Math.max(1 - tremolodepth, 0);
const amGain = new GainNode(ac, { gain });
const time = cycle / cps;
const lfo = getLfo(ac, {
skew: fx.tremoloskew ?? (fx.tremoloshape != null ? 0.5 : 1),
frequency: tremolo,
depth: tremolodepth,
time,
dcoffset: 0,
shape: fx.tremoloshape,
phaseoffset: fx.tremolophase,
min: 0,
max: 1,
curve: 1.5,
begin: t,
end: endWithRelease,
});
fxNodes['tremolo'] = [lfo];
fxNodes['tremolo_gain'] = [amGain];
lfo.connect(amGain.gain);
chain.audioNodes.push(lfo);
chain.connect(amGain);
}
if (fx.compressor !== undefined) {
const compressorNode = getCompressor(
ac,
'highpass',
hcutoff,
hresonance,
hpattack,
hpdecay,
hpsustain,
hprelease,
hpenv,
t,
end,
fanchor,
fx.compressor,
fx.compressorRatio,
fx.compressorKnee,
fx.compressorAttack,
fx.compressorRelease,
);
chain.push(hp());
if (ftype === '24db') {
chain.push(hp());
fxNodes['compressor'] = [compressorNode];
chain.connect(compressorNode);
}
// panning
if (fx.pan !== undefined) {
const panner = ac.createStereoPanner();
fxNodes['pan'] = [panner];
panner.pan.value = 2 * fx.pan - 1;
chain.connect(panner);
}
// phaser
if (fx.phaserrate !== undefined && phaserdepth > 0) {
const { filterChain, lfo } = getPhaser(
t,
endWithRelease,
fx.phaserrate,
phaserdepth,
fx.phasercenter,
fx.phasersweep,
);
fxNodes['phaser'] = [...filterChain];
fxNodes['phaser_lfo'] = [lfo];
filterChain.forEach((f) => chain.connect(f));
chain.audioNodes.push(lfo);
}
// delay
if (key !== 'main' && delay > 0 && delaytime > 0 && delayfeedback > 0) {
const dry = gainNode(1);
delayfeedback = clamp(delayfeedback, 0, 0.98);
const delayNode = ac.createFeedbackDelay(1, delaytime, delayfeedback);
const wetDelay = gainNode(delay);
const dryDelay = gainNode(fx.dry ?? 1);
const sum = new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
chain
.connect(dry)
.connect(dryDelay, delayNode)
.connectOne(1, wetDelay) // connect delayNode -> wetDelay
.connect(sum);
chain.audioNodes.push(delayNode.feedbackGain, delayNode.delayGain);
fxNodes['delay'] = [delayNode];
fxNodes['delay_mix'] = [wetDelay];
}
// reverb
if (key !== 'main' && fx.room > 0) {
let roomIR;
if (fx.ir !== undefined) {
let url;
let sample = getSound(fx.ir);
if (Array.isArray(sample)) {
url = sample.data.samples[fx.i % sample.data.samples.length];
} else if (typeof sample === 'object') {
url = Object.values(sample.data.samples).flat()[i % Object.values(sample.data.samples).length];
}
roomIR = await loadBuffer(url, ac, fx.ir, 0);
}
const dry = gainNode(1);
const reverbNode = ac.createReverb(
fx.roomsize,
fx.roomfade,
fx.roomlp,
fx.roomdim,
roomIR,
fx.irspeed,
fx.irbegin,
);
const wetReverb = gainNode(fx.room);
const dryReverb = gainNode(fx.dry ?? 1);
const sum = new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
chain
.connect(dry)
.connect(dryReverb, reverbNode)
.connectOne(1, wetReverb) // connect reverbNode -> wetReverb
.connect(sum);
fxNodes['room'] = [reverbNode];
fxNodes['room_mix'] = [wetReverb];
}
}
if (bandf !== undefined) {
let bp = () =>
createFilter(ac, 'bandpass', bandf, bandq, bpattack, bpdecay, bpsustain, bprelease, bpenv, t, end, fanchor);
chain.push(bp());
if (ftype === '24db') {
chain.push(bp());
}
}
if (vowel !== undefined) {
const vowelFilter = ac.createVowelFilter(vowel);
chain.push(vowelFilter);
}
// effects
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(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
const gain = Math.max(1 - tremolodepth, 0);
const amGain = new GainNode(ac, { gain });
const time = cycle / cps;
const lfo = getLfo(ac, t, endWithRelease, {
skew: tremoloskew ?? (tremoloshape != null ? 0.5 : 1),
frequency: tremolo,
depth: tremolodepth,
time,
dcoffset: 0,
shape: tremoloshape,
phaseoffset: tremolophase,
min: 0,
max: 1,
curve: 1.5,
});
lfo.connect(amGain.gain);
chain.push(amGain);
}
compressorThreshold !== undefined &&
chain.push(
getCompressor(ac, compressorThreshold, compressorRatio, compressorKnee, compressorAttack, compressorRelease),
);
// panning
if (pan !== undefined) {
const panner = ac.createStereoPanner();
panner.pan.value = 2 * pan - 1;
chain.push(panner);
}
// phaser
if (phaser !== undefined && phaserdepth > 0) {
const phaserFX = getPhaser(t, endWithRelease, phaser, phaserdepth, phasercenter, phasersweep);
chain.push(phaserFX);
if (FXrelease !== undefined && FXrelease > release) {
const releaseNode = gainNode(1);
releaseNode.gain.setValueAtTime(1, end + release);
releaseNode.gain.linearRampToValueAtTime(0, endWithRelease);
chain.connect(releaseNode);
}
// last gain
const post = new GainNode(ac, { gain: postgain });
chain.push(post);
nodes.main['post'] = [post];
chain.connect(post);
// delay
if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
orbitBus.getDelay(delaytime, delayfeedback, t);
orbitBus.sendDelay(post, delay);
const delayNode = orbitBus.getDelay(delaytime, delayfeedback, t);
nodes.main['delay'] = [delayNode];
const delaySend = orbitBus.sendDelay(post, delay);
nodes.main['delay_mix'] = [delaySend];
chain.audioNodes.push(delaySend);
}
// reverb
if (room > 0) {
@ -710,32 +933,84 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
}
roomIR = await loadBuffer(url, ac, ir, 0);
}
orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
orbitBus.sendReverb(post, room);
const roomNode = orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
nodes.main['room'] = [roomNode];
const reverbSend = orbitBus.sendReverb(post, room);
nodes.main['room_mix'] = [reverbSend];
chain.audioNodes.push(reverbSend);
}
if (bus != null) {
const busNode = audioController.getBus(bus);
const busSend = effectSend(post, busNode, busgain);
chain.audioNodes.push(busSend);
}
if (djf != null) {
orbitBus.getDjf(djf, t);
const djfNode = orbitBus.getDjf(djf, t);
nodes.main['djf'] = [djfNode];
}
// analyser
if (analyze) {
if (analyze && !(ac instanceof OfflineAudioContext)) {
const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5));
const analyserSend = effectSend(post, analyserNode, 1);
audioNodes.push(analyserSend);
chain.audioNodes.push(analyserSend);
}
if (dry != null) {
dry = applyGainCurve(dry);
const dryGain = new GainNode(ac, { gain: dry });
chain.push(dryGain);
chain.connect(dryGain);
orbitBus.connectToOutput(dryGain);
} else {
orbitBus.connectToOutput(post);
}
// connect chain elements together
chain.slice(1).reduce((last, current) => last.connect(current), chain[0]);
audioNodes = audioNodes.concat(chain);
// finally, now that `nodes` is populated, set up modulators
FX.forEach((fx, idx) => {
const key = idx === FX.length - 1 ? 'main' : idx;
if (fx.lfo) {
for (const id of fx.lfo.__ids) {
const params = fx.lfo[id];
params.fxi ??= key;
const lfo = connectLFO(
id,
{
...params,
cps,
cycle,
begin: t,
end: endWithRelease,
},
nodes,
);
lfo && chain.audioNodes.push(lfo);
}
}
if (fx.env) {
for (const id of fx.env.__ids) {
const params = fx.env[id];
params.fxi ??= key;
const env = connectEnvelope(
id,
{
...params,
begin: t,
end: endWithRelease,
},
nodes,
);
env && chain.audioNodes.push(env);
}
}
if (fx.bmod) {
for (const id of fx.bmod.__ids) {
const params = fx.bmod[id];
params.fxi ??= key;
const { toCleanup } = connectBusModulator({ ...params, begin: t, end: endWithRelease }, nodes, controller);
chain.audioNodes.push(...toCleanup);
}
}
});
};
export const superdoughTrigger = (t, hap, ct, cps) => {

View file

@ -0,0 +1,150 @@
/*
superdoughdata.mjs - Data needed for running superdough (defaults, mappings, etc.)
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdoughdata.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Mapping from control name to webaudio node and parameter
const CONTROL_TARGETS = {
stretch: { node: 'stretch', param: 'pitchFactor' },
gain: { node: 'gain', param: 'gain' },
postgain: { node: 'post', param: 'gain' },
pan: { node: 'pan', param: 'pan' },
tremolo: { node: 'tremolo', param: 'frequency' },
tremolosync: { node: 'tremolo', param: 'frequency' },
tremolodepth: { node: 'tremolo_gain', param: 'gain' },
tremoloskew: { node: 'tremolo', param: 'skew' },
tremolophase: { node: 'tremolo', param: 'phase' },
tremoloshape: { node: 'tremolo', param: 'shape' },
// MODULATORS
lfo: { node: 'lfo', param: 'frequency' },
lfo_rate: { node: 'lfo', param: 'frequency' },
lfo_sync: { node: 'lfo', param: 'frequency' },
lfo_depth: { node: 'lfo', param: 'depth' },
lfo_depthabs: { node: 'lfo', param: 'depth' },
lfo_skew: { node: 'lfo', param: 'skew' },
lfo_curve: { node: 'lfo', param: 'curve' },
lfo_dcoffset: { node: 'lfo', param: 'dcoffset' },
env: { node: 'env', param: 'depth' },
env_attack: { node: 'env', param: 'attack' },
env_decay: { node: 'env', param: 'decay' },
env_sustain: { node: 'env', param: 'sustain' },
env_release: { node: 'env', param: 'release' },
bmod: { node: 'bmod', param: 'depth' },
bmod_depth: { node: 'bmod', param: 'depth' },
bmod_depthabs: { node: 'bmod', param: 'depth' },
// LPF
cutoff: { node: 'lpf', param: 'frequency' },
resonance: { node: 'lpf', param: 'Q' },
lprate: { node: 'lpf_lfo', param: 'rate' },
lpsync: { node: 'lpf_lfo', param: 'sync' },
lpdepth: { node: 'lpf_lfo', param: 'depth' },
lpdepthfrequency: { node: 'lpf_lfo', param: 'depth' },
lpshape: { node: 'lpf_lfo', param: 'shape' },
lpdc: { node: 'lpf_lfo', param: 'dcoffset' },
lpskew: { node: 'lpf_lfo', param: 'skew' },
// HPF
hcutoff: { node: 'hpf', param: 'frequency' },
hresonance: { node: 'hpf', param: 'Q' },
hprate: { node: 'hpf_lfo', param: 'rate' },
hpsync: { node: 'hpf_lfo', param: 'sync' },
hpdepth: { node: 'hpf_lfo', param: 'depth' },
hpdepthfrequency: { node: 'hpf_lfo', param: 'depth' },
hpshape: { node: 'hpf_lfo', param: 'shape' },
hpdc: { node: 'hpf_lfo', param: 'dcoffset' },
hpskew: { node: 'hpf_lfo', param: 'skew' },
// BPF
bandf: { node: 'bpf', param: 'frequency' },
bandq: { node: 'bpf', param: 'Q' },
bprate: { node: 'bpf_lfo', param: 'rate' },
bpsync: { node: 'bpf_lfo', param: 'sync' },
bpdepth: { node: 'bpf_lfo', param: 'depth' },
bpdepthfrequency: { node: 'bpf_lfo', param: 'depth' },
bpshape: { node: 'bpf_lfo', param: 'shape' },
bpdc: { node: 'bpf_lfo', param: 'dcoffset' },
bpskew: { node: 'bpf_lfo', param: 'skew' },
vowel: { node: 'vowel', param: 'frequency' },
// DISTORTION
coarse: { node: 'coarse', param: 'coarse' },
crush: { node: 'crush', param: 'crush' },
shape: { node: 'shape', param: 'shape' },
shapevol: { node: 'shape', param: 'postgain' },
distort: { node: 'distort', param: 'distort' },
distortvol: { node: 'distort', param: 'postgain' },
distorttype: { node: 'distort', param: 'distort' },
// COMPRESSOR
compressor: { node: 'compressor', param: 'threshold' },
compressorRatio: { node: 'compressor', param: 'ratio' },
compressorKnee: { node: 'compressor', param: 'knee' },
compressorAttack: { node: 'compressor', param: 'attack' },
compressorRelease: { node: 'compressor', param: 'release' },
// PHASER
phaserrate: { node: 'phaser_lfo', param: 'frequency' },
phasersweep: { node: 'phaser_lfo', param: 'depth' },
phasercenter: { node: 'phaser', param: 'frequency' },
phaserdepth: { node: 'phaser', param: 'Q' },
// ORBIT EFFECTS
delay: { node: 'delay_mix', param: 'gain' },
delaytime: { node: 'delay', param: 'delayTime' },
delayfeedback: { node: 'delay', param: 'feedback' },
delaysync: { node: 'delay', param: 'delayTime' },
dry: { node: 'dry', param: 'gain' },
room: { node: 'room_mix', param: 'gain' },
djf: { node: 'djf', param: 'value' },
busgain: { node: 'bus', param: 'gain' },
// SYNTHS
s: { node: 'source', param: 'frequency' },
detune: { node: 'source', param: 'freqspread' },
wt: { node: 'source', param: 'position' },
warp: { node: 'source', param: 'warp' },
freq: { node: 'source', param: 'frequency' },
note: { node: 'source', param: 'frequency' },
wtdc: { node: 'wt_lfo', param: 'dc' },
wtskew: { node: 'wt_lfo', param: 'skew' },
wtrate: { node: 'wt_lfo', param: 'frequency' },
wtsync: { node: 'wt_lfo', param: 'frequency' },
wtdepth: { node: 'wt_lfo', param: 'depth' },
warpdc: { node: 'warp_lfo', param: 'dc' },
warpskew: { node: 'warp_lfo', param: 'skew' },
warprate: { node: 'warp_lfo', param: 'frequency' },
warpsync: { node: 'warp_lfo', param: 'frequency' },
warpdepth: { node: 'warp_lfo', param: 'depth' },
fmi: { node: 'fm_1_gain', param: 'gain' },
fmi2: { node: 'fm_2_gain', param: 'gain' },
fmi3: { node: 'fm_3_gain', param: 'gain' },
fmi4: { node: 'fm_4_gain', param: 'gain' },
fmi5: { node: 'fm_5_gain', param: 'gain' },
fmi6: { node: 'fm_6_gain', param: 'gain' },
fmi7: { node: 'fm_7_gain', param: 'gain' },
fmi8: { node: 'fm_8_gain', param: 'gain' },
fmh: { node: 'fm_1', param: 'frequency' },
fmh2: { node: 'fm_2', param: 'frequency' },
fmh3: { node: 'fm_3', param: 'frequency' },
fmh4: { node: 'fm_4', param: 'frequency' },
fmh5: { node: 'fm_5', param: 'frequency' },
fmh6: { node: 'fm_6', param: 'frequency' },
fmh7: { node: 'fm_7', param: 'frequency' },
fmh8: { node: 'fm_8', param: 'frequency' },
pw: { node: 'source', param: 'pulsewidth' },
pwrate: { node: 'pw_lfo', param: 'frequency' },
pwsweep: { node: 'pw_lfo', param: 'depth' },
vib: { node: 'vib', param: 'frequency' },
vibmod: { node: 'vib_gain', param: 'gain' },
byteBeatStartTime: { node: 'source', param: 'byteBeatStartTime' },
spread: { node: 'source', param: 'panspread' },
transient: { node: 'transient', param: 'attack' },
};
export function getSuperdoughControlTargets() {
return CONTROL_TARGETS;
}

View file

@ -2,7 +2,10 @@ 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;
const hasChanged = (now, before) => now !== undefined && now !== before;
// Node with fixed stereo channel count to prevent clicking when the input signal
// switches from mono to stereo
const getStereoNode = (ac) => new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
export class Orbit {
reverbNode;
@ -11,10 +14,11 @@ export class Orbit {
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.output = getStereoNode(audioContext);
this.summingNode = getStereoNode(audioContext);
this.summingNode.connect(this.output);
}
@ -34,6 +38,7 @@ export class Orbit {
}
const val = this.djfNode.parameters.get('value');
val.setValueAtTime(value, t);
return this.djfNode;
}
getDelay(delaytime = 0, feedback = 0.5, t) {
@ -78,11 +83,11 @@ export class Orbit {
return this.reverbNode;
}
sendReverb(node, amount) {
effectSend(node, this.reverbNode, amount);
return effectSend(node, this.reverbNode, amount);
}
sendDelay(node, amount) {
effectSend(node, this.delayNode, amount);
return effectSend(node, this.delayNode, amount);
}
duck(t, onsettime = 0, attacktime = 0.1, depth = 1) {
@ -164,6 +169,7 @@ export class SuperdoughAudioController {
audioContext;
output;
nodes = {};
buses = {};
constructor(audioContext) {
this.audioContext = audioContext;
@ -171,10 +177,14 @@ export class SuperdoughAudioController {
}
reset() {
Array.from(this.nodes).forEach((node) => {
Object.values(this.nodes).forEach((node) => {
node.disconnect();
});
Object.values(this.buses).forEach((bus) => {
bus.disconnect();
});
this.nodes = {};
this.buses = {};
this.output.reset();
}
@ -206,4 +216,11 @@ export class SuperdoughAudioController {
}
return this.nodes[orbitNum];
}
getBus(busNum) {
if (this.buses[busNum] == null) {
this.buses[busNum] = getStereoNode(this.audioContext);
}
return this.buses[busNum];
}
}

View file

@ -1,9 +1,8 @@
import { clamp } from './util.mjs';
import { registerSound, soundMap } from './superdough.mjs';
import { getSuperdoughAudioController, registerSound, soundMap } from './superdough.mjs';
import { getAudioContext } from './audioContext.mjs';
import {
applyFM,
destroyAudioWorkletNode,
gainNode,
getADSRValues,
getFrequencyFromValue,
@ -13,11 +12,14 @@ import {
getVibratoOscillator,
getWorklet,
noises,
onceEnded,
releaseAudioNode,
webAudioTimeout,
} from './helpers.mjs';
import { logger } from './logger.mjs';
import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
const waveforms = ['triangle', 'square', 'sawtooth', 'sine'];
const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user', 'one'];
const waveformAliases = [
['tri', 'triangle'],
['sqr', 'square'],
@ -47,22 +49,20 @@ export function registerSynthSounds() {
[0.001, 0.05, 0.6, 0.01],
);
let sound = getOscillator(s, t, value);
let { node: o, stop, triggerRelease } = sound;
// turn down
const g = gainNode(0.3);
const sound = getOscillator(s, t, value, () => {
releaseAudioNode(g);
onended();
});
const { node: o, nodes, stop, triggerRelease } = sound;
const { duration } = value;
o.onended = () => {
o.disconnect();
g.disconnect();
onended();
};
const envGain = gainNode(1);
let node = o.connect(g).connect(envGain);
const node = o.connect(g).connect(envGain);
const holdEnd = t + duration;
getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear');
const envEnd = holdEnd + release + 0.01;
@ -70,6 +70,7 @@ export function registerSynthSounds() {
stop(envEnd);
return {
node,
nodes,
stop: (endTime) => {
stop(endTime);
},
@ -111,15 +112,15 @@ export function registerSynthSounds() {
const mix = gainNode(mixGain);
o.onended = () => {
o.disconnect();
g.disconnect();
sat.disconnect();
noise.node.disconnect();
noiseGain.disconnect();
mix.disconnect();
onceEnded(o, () => {
releaseAudioNode(o);
releaseAudioNode(g);
releaseAudioNode(sat);
releaseAudioNode(noise.node);
releaseAudioNode(noiseGain);
releaseAudioNode(mix);
onended();
};
});
const node = o.connect(sat).connect(g).connect(mix);
noise.node.connect(noiseGain).connect(mix);
@ -139,6 +140,7 @@ export function registerSynthSounds() {
return {
node,
nodes: { source: [o] },
stop: (endTime) => {
o.stop(endTime);
},
@ -183,8 +185,8 @@ export function registerSynthSounds() {
const gainAdjustment = 1 / Math.sqrt(voices);
getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend);
const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin);
const fm = applyFM(o.parameters.get('frequency'), value, begin);
const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin);
const fmHandle = applyFM(o.parameters.get('frequency'), value, begin);
let envGain = gainNode(1);
envGain = o.connect(envGain);
@ -193,11 +195,10 @@ export function registerSynthSounds() {
let timeoutNode = webAudioTimeout(
ac,
() => {
destroyAudioWorkletNode(o);
envGain.disconnect();
releaseAudioNode(o);
onended();
fm?.stop();
vibratoOscillator?.stop();
fmHandle?.stop();
vibratoHandle?.stop();
},
begin,
end,
@ -205,6 +206,7 @@ export function registerSynthSounds() {
return {
node: envGain,
nodes: { source: [o], ...fmHandle?.nodes, ...vibratoHandle?.nodes },
stop: (time) => {
timeoutNode.stop(time);
},
@ -271,8 +273,7 @@ export function registerSynthSounds() {
let timeoutNode = webAudioTimeout(
ac,
() => {
destroyAudioWorkletNode(o);
envGain.disconnect();
releaseAudioNode(o);
onended();
},
begin,
@ -281,6 +282,7 @@ export function registerSynthSounds() {
return {
node: envGain,
source: o,
stop: (time) => {
timeoutNode.stop(time);
},
@ -331,26 +333,25 @@ export function registerSynthSounds() {
);
getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend);
const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin);
const fm = applyFM(o.parameters.get('frequency'), value, begin);
const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin);
const fmHandle = applyFM(o.parameters.get('frequency'), value, begin);
let envGain = gainNode(1);
envGain = o.connect(envGain);
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
let lfo;
let pw_lfo;
if (pwsweep != 0) {
lfo = getLfo(ac, begin, end, { frequency: pwrate, depth: pwsweep });
lfo.connect(o.parameters.get('pulsewidth'));
pw_lfo = getLfo(ac, { frequency: pwrate, depth: pwsweep, begin, end });
pw_lfo.connect(o.parameters.get('pulsewidth'));
}
let timeoutNode = webAudioTimeout(
ac,
() => {
destroyAudioWorkletNode(o);
destroyAudioWorkletNode(lfo);
envGain.disconnect();
releaseAudioNode(o);
releaseAudioNode(pw_lfo);
onended();
fm?.stop();
vibratoOscillator?.stop();
fmHandle?.stop();
vibratoHandle?.stop();
},
begin,
end,
@ -358,6 +359,7 @@ export function registerSynthSounds() {
return {
node: envGain,
nodes: { source: [o], pw_lfo: [pw_lfo], ...fmHandle?.nodes, ...vibratoHandle?.nodes },
stop: (time) => {
timeoutNode.stop(time);
},
@ -366,6 +368,41 @@ export function registerSynthSounds() {
{ prebake: true, type: 'synth' },
);
registerSound(
'bus',
(begin, value, onended) => {
const ac = getAudioContext();
const [attack, decay, sustain, release] = getADSRValues(
[value.attack, value.decay, value.sustain, value.release],
'linear',
[0.001, 0.05, 1, 0.01],
);
const holdend = begin + value.duration;
const end = holdend + release + 0.01;
const bus = getSuperdoughAudioController().getBus(value.n ?? 0);
const envGain = bus.connect(gainNode(0));
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
const timeoutNode = webAudioTimeout(
ac,
() => {
bus.disconnect(envGain);
onended();
},
begin,
end,
);
return {
node: envGain,
nodes: { source: [bus] },
stop: (time) => {
timeoutNode.stop(time);
},
};
},
{ prebake: true, type: 'input' },
);
[...noises].forEach((s) => {
registerSound(
s,
@ -388,11 +425,11 @@ export function registerSynthSounds() {
const { duration } = value;
o.onended = () => {
o.disconnect();
g.disconnect();
onceEnded(o, () => {
releaseAudioNode(o);
releaseAudioNode(g);
onended();
};
});
const envGain = gainNode(1);
let node = o.connect(g).connect(envGain);
@ -403,6 +440,7 @@ export function registerSynthSounds() {
stop(envEnd);
return {
node,
nodes: { source: [o] },
stop: (endTime) => {
stop(endTime);
},
@ -414,9 +452,13 @@ export function registerSynthSounds() {
waveformAliases.forEach(([alias, actual]) => soundMap.set({ ...soundMap.get(), [alias]: soundMap.get()[actual] }));
}
export function waveformN(partials, type) {
const real = new Float32Array(partials + 1);
const imag = new Float32Array(partials + 1);
const PI2 = 2 * Math.PI;
export function waveformN(partials, phases, type) {
const isList = typeof partials === 'object';
partials = isList ? partials : new Float32Array(partials).fill(1);
const len = partials.length;
const real = new Float32Array(len + 1);
const imag = new Float32Array(len + 1);
const ac = getAudioContext();
const osc = ac.createOscillator();
@ -424,20 +466,29 @@ export function waveformN(partials, type) {
sawtooth: (n) => [0, -1 / n],
square: (n) => [0, n % 2 === 0 ? 0 : 1 / n],
triangle: (n) => [n % 2 === 0 ? 0 : 1 / (n * n), 0],
user: (_n) => [0, 1],
};
if (!terms[type]) {
throw new Error(`unknown wave type ${type}`);
}
real[0] = 0; // dc offset
imag[0] = 0;
let n = 1;
while (n <= partials) {
const [r, i] = terms[type](n);
real[n] = r;
imag[n] = i;
n++;
for (let n = 0; n < len; n++) {
const mag = partials[n];
const [r, i] = terms[type](n + 1); // we skip n === 0 as this is dc offset
const phase = phases?.[n] ?? 0;
// Scale by `partials`
let R = r * mag;
let I = i * mag;
// Apply rotation by the phase
if (phase !== 0) {
const c = Math.cos(PI2 * phase);
const s = Math.sin(PI2 * phase);
R = c * R - s * I;
I = s * R + c * I;
}
real[n + 1] = R;
imag[n + 1] = I;
}
const wave = ac.createPeriodicWave(real, imag);
@ -446,38 +497,63 @@ export function waveformN(partials, type) {
}
// expects one of waveforms as s
export function getOscillator(s, t, value) {
let { n: partials, duration, noise = 0 } = value;
export function getOscillator(s, t, value, onended) {
const { duration, noise = 0 } = value;
const partials = value.partials ?? value.n;
let o;
// If no partials are given, use stock waveforms
if (!partials || s === 'sine') {
if (s === 'user' && !partials) {
logger(
`[superdough] Synth 'user' was selected, but partials not specified. Defaulting to triangle. Use pat.partials to setup custom waveform`,
);
s = 'triangle';
}
s = s === 'user' && !partials ? 'triangle' : s;
if (s === 'one') {
// Constant 1 oscillator (used for modulation)
o = new ConstantSourceNode(getAudioContext(), { offset: 1 });
o.start(t);
return {
node: o,
nodes: { source: o },
stop: (time) => o?.stop(time),
};
} else if (!partials || partials?.length === 0 || s === 'sine') {
// If no partials are given, use stock waveforms
o = getAudioContext().createOscillator();
o.type = s || 'triangle';
}
// generate custom waveform if partials are given
else {
o = waveformN(partials, s);
o = waveformN(partials, value.phases, s);
}
// set frequency
o.frequency.value = getFrequencyFromValue(value);
o.start(t);
let vibratoOscillator = getVibratoOscillator(o.detune, value, t);
const vibratoHandle = getVibratoOscillator(o.detune, value, t);
// pitch envelope
getPitchEnvelope(o.detune, value, t, t + duration);
const fmModulator = applyFM(o.frequency, value, t);
const fmHandle = applyFM(o.frequency, value, t);
let noiseMix;
if (noise) {
noiseMix = getNoiseMix(o, noise, t);
}
onceEnded(o, () => {
noiseMix?.teardown();
releaseAudioNode(o);
releaseAudioNode(noiseMix?.node);
onended();
});
o.start(t);
return {
node: noiseMix?.node || o,
nodes: { source: [o], ...vibratoHandle?.nodes, ...fmHandle?.nodes },
stop: (time) => {
fmModulator.stop(time);
vibratoOscillator?.stop(time);
fmHandle.stop(time);
vibratoHandle?.stop(time);
noiseMix?.stop(time);
o.stop(time);
},

View file

@ -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) => {
@ -105,3 +109,18 @@ export function getCommonSampleInfo(hapValue, bank) {
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]]));
};
export const getBaseURL = (url) => {
try {
// For real URLs
return new URL('.', new URL(url)).href.replace(/\/$/, ''); // removes trailing slash
} catch {
// For pseudo URLS
return url.split('/').slice(0, -1).join('/');
}
};

View file

@ -1,3 +1,5 @@
import { releaseAudioNode } from './helpers.mjs';
// credits to webdirt: https://github.com/dktr0/WebDirt/blob/41342e81d6ad694a2310d491fef7b7e8b0929efe/js-src/Graph.js#L597
export var vowelFormant = {
a: { freqs: [660, 1120, 2750, 3000, 3350], gains: [1, 0.5012, 0.0708, 0.0631, 0.0126], qs: [80, 90, 120, 130, 140] },
@ -45,7 +47,9 @@ if (typeof GainNode !== 'undefined') {
throw new Error('vowel: unknown vowel ' + letter);
}
const { gains, qs, freqs } = vowelFormant[letter];
const makeupGain = ac.createGain();
this.makeupGain = ac.createGain();
this.filters = [];
this.gains = [];
for (let i = 0; i < 5; i++) {
const gain = ac.createGain();
gain.gain.value = gains[i];
@ -53,17 +57,30 @@ if (typeof GainNode !== 'undefined') {
filter.type = 'bandpass';
filter.Q.value = qs[i];
filter.frequency.value = freqs[i];
this.connect(filter);
super.connect(filter);
filter.connect(gain);
gain.connect(makeupGain);
this.filters.push(filter);
gain.connect(this.makeupGain);
this.gains.push(gain);
}
makeupGain.gain.value = 8; // how much makeup gain to add?
this.connect = (target) => makeupGain.connect(target);
this.makeupGain.gain.value = 8; // how much makeup gain to add?
return this;
}
connect(target) {
this.makeupGain.connect(target);
}
disconnect() {
releaseAudioNode(this.makeupGain);
this.filters.forEach(releaseAudioNode);
this.gains.forEach(releaseAudioNode);
super.disconnect();
this.makeupGain = null;
this.filters = null;
this.gains = null;
}
}
AudioContext.prototype.createVowelFilter = function (letter) {
BaseAudioContext.prototype.createVowelFilter = function (letter) {
return new VowelNode(this, letter);
};
}

View file

@ -1,15 +1,15 @@
import { getAudioContext, registerSound } from './index.mjs';
import { getCommonSampleInfo } from './util.mjs';
import { getBaseURL, getCommonSampleInfo } from './util.mjs';
import {
applyFM,
applyParameterModulators,
destroyAudioWorkletNode,
getADSRValues,
getFrequencyFromValue,
getParamADSR,
getPitchEnvelope,
getVibratoOscillator,
getWorklet,
releaseAudioNode,
webAudioTimeout,
} from './helpers.mjs';
import { logger } from './logger.mjs';
@ -40,6 +40,11 @@ export const Warpmode = Object.freeze({
});
const seenKeys = new Set();
export function resetSeenKeys() {
seenKeys.clear();
}
async function getPayload(url, label, frameLen = 2048) {
const key = `${url},${frameLen}`;
if (!seenKeys.has(key)) {
@ -191,6 +196,7 @@ export const tables = async (url, frameLen, json, options = {}) => {
if (url.startsWith('local:')) {
url = `http://localhost:5432`;
}
const base = getBaseURL(url);
if (typeof fetch !== 'function') {
// not a browser
return;
@ -201,7 +207,7 @@ export const tables = async (url, frameLen, json, options = {}) => {
}
return fetch(url)
.then((res) => res.json())
.then((json) => _processTables(json, url, frameLen, options))
.then((json) => _processTables(json, base, frameLen, options))
.catch((error) => {
console.error(error);
throw new Error(`error loading "${url}"`);
@ -309,22 +315,30 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
dcoffset: value.warpdc ?? 0,
},
);
const vibratoOscillator = getVibratoOscillator(source.parameters.get('detune'), value, t);
const fm = applyFM(source.parameters.get('frequency'), value, t);
const vibratoHandle = getVibratoOscillator(source.parameters.get('detune'), value, t);
const fmHandle = 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 handle = {
node,
nodes: {
source: [source],
wt_lfo: [wtPosModulators],
warp_lfo: [wtWarpModulators],
...fmHandle?.nodes,
...vibratoHandle?.nodes,
},
};
const timeoutNode = webAudioTimeout(
ac,
() => {
destroyAudioWorkletNode(source);
vibratoOscillator?.stop();
fm?.stop();
node.disconnect();
wtPosModulators?.disconnect();
wtWarpModulators?.disconnect();
releaseAudioNode(source);
vibratoHandle?.stop();
fmHandle?.stop();
releaseAudioNode(wtPosModulators);
releaseAudioNode(wtWarpModulators);
onended();
},
t,

View file

@ -6,51 +6,60 @@ 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 timeToCoeff = (t) => 1 - Math.exp(-INVSR / t);
const dbToLin = (db) => Math.pow(10, db / 20);
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
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);
const ffloor = (x) => x | 0; // fast floor for non-negative
const getUnisonDetune = (unison, detune, voiceIndex) => {
if (unison < 2) {
return 0;
}
return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1));
// 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;
}
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);
};
// 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;
}
return phase;
}
const blockSize = 128;
// 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;
@ -66,7 +75,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;
@ -100,12 +109,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 {
@ -121,8 +124,8 @@ class LFOProcessor extends AudioWorkletProcessor {
{ name: 'shape', defaultValue: 0 },
{ name: 'curve', defaultValue: 1 },
{ name: 'dcoffset', defaultValue: 0 },
{ name: 'min', defaultValue: 0 },
{ name: 'max', defaultValue: 1 },
{ name: 'min', defaultValue: -1e9 },
{ name: 'max', defaultValue: 1e9 },
];
}
@ -140,7 +143,8 @@ class LFOProcessor extends AudioWorkletProcessor {
process(_inputs, outputs, parameters) {
const begin = parameters['begin'][0];
if (currentTime >= parameters.end[0]) {
const end = parameters['end'][0];
if (currentTime >= end) {
return false;
}
if (currentTime <= begin) {
@ -158,6 +162,7 @@ class LFOProcessor extends AudioWorkletProcessor {
const curve = parameters['curve'][0];
const dcoffset = parameters['dcoffset'][0];
const min = parameters['min'][0];
const max = parameters['max'][0];
const shape = waveShapeNames[parameters['shape'][0]];
@ -165,9 +170,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;
@ -293,8 +298,8 @@ class TwoPoleFilter {
// 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 / sampleRate)), 0, 1.14);
const r = Math.pow(0.5, (resonance + 0.125) / 0.125);
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
@ -353,11 +358,6 @@ class DJFProcessor extends AudioWorkletProcessor {
}
registerProcessor('djf-processor', DJFProcessor);
function fast_tanh(x) {
const x2 = x * x;
return (x * (27.0 + x2)) / (27.0 + 9.0 * x2);
}
const _PI = 3.14159265359;
//adapted from https://github.com/TheBouteillacBear/webaudioworklet-wasm?tab=MIT-1-ov-file
class LadderProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
@ -395,7 +395,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);
@ -508,51 +508,50 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
name: 'voices',
defaultValue: 5,
min: 1,
automationRate: 'k-rate',
},
];
}
process(_input, outputs, params) {
if (currentTime <= params.begin[0]) {
return true;
}
if (currentTime >= params.end[0]) {
// this.port.postMessage({ type: 'onended' });
// should terminate
return false;
}
if (currentTime <= params.begin[0]) {
// keep alive
return true;
}
const output = outputs[0];
const voices = params.voices[0]; // k-rate
for (let i = 0; i < output[0].length; i++) {
const detune = pv(params.detune, i);
const voices = pv(params.voices, i);
const freqspread = pv(params.freqspread, i);
const panspread = pv(params.panspread, i) * 0.5 + 0.5;
const gain1 = Math.sqrt(1 - panspread);
const gain2 = Math.sqrt(panspread);
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++) {
const isOdd = (n & 1) == 1;
let gainL = gain1;
let gainR = gain2;
// invert right and left gain
if (isOdd) {
gainL = gain2;
gainR = gain1;
}
// Individual voice detuning
const freqVoice = 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(freqVoice / sampleRate, 1);
const dt = frac(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
const tmp = gainL;
gainL = gainR;
gainR = tmp;
}
}
return true;
@ -564,12 +563,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 {
@ -587,11 +590,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();
@ -604,35 +606,26 @@ 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;
}
@ -640,8 +633,8 @@ class PhaseVocoderProcessor extends OLAProcessor {
* @tags internals
*/
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;
}
}
@ -649,11 +642,11 @@ class PhaseVocoderProcessor extends OLAProcessor {
* @tags internals
**/
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;
@ -666,12 +659,10 @@ class PhaseVocoderProcessor extends OLAProcessor {
**/
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;
@ -680,7 +671,6 @@ class PhaseVocoderProcessor extends OLAProcessor {
i++;
continue;
}
this.peakIndexes[this.nbPeaks] = i;
this.nbPeaks++;
i += 2;
@ -693,53 +683,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;
}
@ -753,11 +734,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
@ -814,11 +794,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
@ -830,7 +810,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
@ -862,24 +842,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));
},
};
@ -887,7 +866,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);
@ -920,7 +899,7 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
this.func = getByteBeatFunc(codeText);
};
this.initialOffset = null;
this.initialOffset = 0;
this.t = null;
this.func = null;
}
@ -967,18 +946,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
@ -987,6 +967,112 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
registerProcessor('byte-beat-processor', ByteBeatProcessor);
class EnvelopeProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{ name: 'begin', defaultValue: 0 },
{ name: 'end', defaultValue: 0 },
{ name: 'attack', defaultValue: 0.005, minValue: 0 },
{ name: 'decay', defaultValue: 0.14, minValue: 0 },
{ name: 'sustain', defaultValue: 0, minValue: 0, maxValue: 1 },
{ name: 'release', defaultValue: 0.1, minValue: 0 },
{ name: 'attackCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
{ name: 'decayCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
{ name: 'releaseCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
{ name: 'depth', defaultValue: 1 },
{ name: 'min', defaultValue: -1e9 },
{ name: 'max', defaultValue: 1e9 },
{ name: 'retrigger', defaultValue: 1, minValue: 0, maxValue: 1 },
];
}
constructor() {
super();
this.val = 0;
this.segIdx = 0;
this.state = 0;
this.beginTime = 0;
this.endTime = 0;
this.attackStart = 0;
}
_warp(phase, curvature, strength = 8) {
if (phase === 0 || phase === 1) return phase; // fast exit
if (curvature > 0) {
// snappier
const exp = 1 + strength * curvature;
return 1 - Math.pow(1 - phase, exp);
} else {
// more calm
const exp = 1 - strength * curvature;
return Math.pow(phase, exp);
}
}
_advance(start, target, time, curvature) {
if (time === 0 || start === target) {
this.val = target;
} else {
// We compute our progress through this section of the envelope in time
// as a `phase` value, which is warped by the curvature, and then used
// to compute the value of the envelope at that time
const phase = Math.min(1, (currentTime - this.beginTime) / time);
const phaseWarped = this._warp(phase, curvature);
this.val = start + (target - start) * phaseWarped;
}
}
process(_inputs, outputs, params) {
const begin = params['begin'][0];
const end = params['end'][0];
if (currentTime >= end) {
return false;
}
if (currentTime <= begin) {
return true;
}
const out = outputs[0][0];
const retrigger = pv(params.retrigger, 0) >= 0.5; // convert to bool
if (begin !== this.beginTime && (this.state === 0 || retrigger)) {
// triggered
this.beginTime = begin;
this.state = 1;
this.endTime = pv(params.end, 0);
this.attackStart = this.val;
}
const susTime = this.endTime - this.beginTime;
for (let i = 0; i < out.length; i++) {
const attack = pv(params.attack, i);
const decay = pv(params.decay, i);
const sustain = pv(params.sustain, i);
const release = pv(params.release, i);
const aCurve = pv(params.attackCurve, i);
const dCurve = pv(params.decayCurve, i);
const rCurve = pv(params.releaseCurve, i);
const depth = pv(params.depth, i);
const min = pv(params.min, i);
const max = pv(params.max, i);
const states = [
{ time: Number.POSITIVE_INFINITY, start: 0, target: 0 }, // idle
{ time: attack, start: this.attackStart, target: 1, curve: aCurve },
{ time: attack + decay, start: 1, target: sustain, curve: dCurve },
{ time: susTime, start: sustain, target: sustain },
{ time: susTime + release, start: sustain, target: 0, curve: rCurve },
];
let { time, start, target, curve } = states[this.state];
this._advance(start, target, time, curve);
while (currentTime - this.beginTime >= time) {
this.state = (this.state + 1) % states.length;
time = states[this.state].time;
}
out[i] = clamp(this.val * depth, min, max);
}
return true;
}
}
registerProcessor('envelope-processor', EnvelopeProcessor);
export const WarpMode = Object.freeze({
NONE: 0,
ASYM: 1,
@ -1066,7 +1152,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
{ 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 },
{ 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 },
];
@ -1077,7 +1163,6 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
this.frameLen = 0;
this.numFrames = 0;
this.phase = [];
this.invSR = 1 / sampleRate;
this.port.onmessage = (e) => {
const { type, payload } = e.data || {};
@ -1114,7 +1199,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
_toBits(amt, min = 2, max = 12) {
const b = max + (min - max) * amt;
return { b, n: Math.round(Math.pow(2, b)) };
return { b, n: fround(Math.pow(2, b)) };
}
_warpPhase(phase, amt, mode) {
@ -1140,7 +1225,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
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 * amt);
const syncRatio = Math.pow(16, amt ** 2);
return (phase * syncRatio) % 1;
}
case WarpMode.QUANT: {
@ -1149,8 +1234,8 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
}
case WarpMode.FOLD: {
const K = 7;
const k = 1 + Math.max(1, Math.round(K * amt));
return Math.abs(frac(k * phase) - 0.5) * 2;
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);
@ -1160,12 +1245,12 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
case WarpMode.ORBIT: {
const depth = 0.5 * amt;
const n = 3;
return frac(phase + depth * Math.sin(2 * Math.PI * n * phase));
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(2 * Math.PI * n * phase));
return frac(phase + depth * Math.sin(TWO_PI * n * phase));
}
case WarpMode.CHAOS: {
const r = 3.7 + 0.3 * amt;
@ -1176,7 +1261,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
const isPrime = (n) => {
if (n < 2) return false;
if (n % 2 === 0) return n === 2;
for (let d = 3; d * d <= n; d += 2) if (n % d === 0) return false;
for (let d = 3; d ** 2 <= n; d += 2) if (n % d === 0) return false;
return true;
};
let { n } = this._toBits(amt, 3);
@ -1185,18 +1270,12 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
}
case WarpMode.BINARY: {
let { b } = this._toBits(amt, 3);
b = Math.round(b);
b = fround(b);
const n = 1 << b;
const idx = ffloor(phase * n);
const ridx = bitReverse(idx, b);
return ridx / n;
}
case WarpMode.MODULAR: {
const { n } = this._toBits(amt);
const depth = 0.5 * amt;
const jump = frac(phase * n) / n;
return frac(phase + depth * jump);
}
case WarpMode.BROWNIAN: {
const disp = 0.25 * amt * brownian(64 * phase, 4);
return frac(phase + disp);
@ -1219,7 +1298,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
case WarpMode.LOGISTIC: {
let x = phase;
const r = 3.6 + 0.4 * amt;
const iters = 1 + Math.round(2 * amt);
const iters = 1 + fround(2 * amt);
for (let i = 0; i < iters; i++) x = r * x * (1 - x);
return clamp(x, 0, 1);
}
@ -1232,7 +1311,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
return (y - y0) / (y1 - y0);
}
case WarpMode.FRACTAL: {
const d = 0.5 * Math.sin(2 * Math.PI * phase) * amt;
const d = 0.5 * Math.sin(TWO_PI * phase) * amt;
return frac(phase + d);
}
case WarpMode.FLIP: {
@ -1279,16 +1358,16 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
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 interpT = idx - fIdx;
const warpAmount = clamp(pv(parameters.warp, i), 0, 1);
const warpMode = pv(parameters.warpMode, i);
const voices = pv(parameters.voices, 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);
@ -1296,6 +1375,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
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;
@ -1305,8 +1385,8 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
gainL = gain2;
gainR = gain1;
}
const fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, freqspread, n)); // voice detune
const dPhase = fVoice * this.invSR;
const fVoice = applySemitoneDetuneToFrequency(f, detuner(n)); // voice detune
const dPhase = fVoice * INVSR;
const level = this._chooseMip(dPhase);
const table = this.tables[level];
@ -1315,13 +1395,13 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
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;
let s = lerp(s0, s1, interpT);
if (warpMode === WarpMode.FLIP && this.phase[n] < warpAmount) {
s = -s;
}
outL[i] += s * gainL * normalizer;
outR[i] += s * gainR * normalizer;
this.phase[n] = wrapPhase(this.phase[n] + dPhase);
this.phase[n] = frac(this.phase[n] + dPhase);
}
}
return true;
@ -1329,3 +1409,82 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
}
registerProcessor('wavetable-oscillator-processor', WavetableOscillatorProcessor);
class TransientProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [];
}
constructor(options) {
super();
this.gainCoeff = timeToCoeff(0.2);
this.avgGain = 1;
let {
attackTime = 0.003,
sustainTime = 0.08,
attack = 0,
sustain = 0,
sensitivity = 0.1,
mix = 1,
begin = 0,
end = 0,
} = options.processorOptions;
attackTime = clamp(attackTime, 0.0005, 0.05);
sustainTime = clamp(sustainTime, 0.01, 0.5);
this.attackCoeff = timeToCoeff(attackTime);
this.sustainCoeff = timeToCoeff(sustainTime);
this.attackAmt = clamp(attack, -1, 1);
this.sustainAmt = clamp(sustain, -1, 1);
this.scaling = 0.5 + 5 * clamp(sensitivity, 0, 1);
this.mix = clamp(mix, 0, 1);
this.begin = begin;
this.end = end;
this.attackEnv = new Float32Array(2); // assume stereo
this.sustainEnv = new Float32Array(2);
}
process(inputs, outputs, _params) {
const input = inputs[0];
const output = outputs[0];
if (currentTime >= this.end) {
return false;
}
if (currentTime <= this.begin) {
return true;
}
const channels = input.length;
if (channels > this.attackEnv.length) {
this.attackEnv = new Float32Array(channels);
this.sustainEnv = new Float32Array(channels);
}
let avgGain = this.avgGain;
for (let ch = 0; ch < channels; ch++) {
let attEnv = this.attackEnv[ch];
let susEnv = this.sustainEnv[ch];
for (let n = 0; n < blockSize; n++) {
const sample = input[ch][n];
const x = Math.abs(sample);
attEnv = lerp(attEnv, x, this.attackCoeff);
susEnv = lerp(susEnv, x, this.sustainCoeff);
const peakiness = clamp((this.scaling * (attEnv - susEnv)) / (susEnv + 1e-6), -1.5, 1.5);
const attScale = peakiness > 0 ? peakiness : 0;
const susScale = peakiness < 0 ? -peakiness : 0;
const attackGain = dbToLin(this.attackAmt * attScale * 18);
const sustainGain = dbToLin(this.sustainAmt * susScale * 36);
const gain = clamp(attackGain * sustainGain, 0, 8);
avgGain = lerp(avgGain, gain, this.gainCoeff);
const makeup = avgGain > 1e-3 ? 1 / avgGain : 1;
const wet = sample * gain * makeup;
let y = lerp(sample, wet, this.mix);
y /= 1 + Math.abs(y); // soft clip
output[ch][n] = y;
}
this.attackEnv[ch] = attEnv;
this.sustainEnv[ch] = susEnv;
}
this.avgGain = avgGain;
return true;
}
}
registerProcessor('transient-processor', TransientProcessor);

View file

@ -3,6 +3,7 @@ import { midiToFreq, noteToMidi } from './util.mjs';
import { registerSound } from './superdough.mjs';
import { getAudioContext } from './audioContext.mjs';
import { buildSamples } from './zzfx_fork.mjs';
import { onceEnded, releaseAudioNode } from './helpers.mjs';
export const getZZFX = (value, t) => {
let {
@ -83,12 +84,13 @@ export function registerZZFXSounds() {
wave,
(t, value, onended) => {
const { node: o } = getZZFX({ s: wave, ...value }, t);
o.onended = () => {
o.disconnect();
onceEnded(o, () => {
releaseAudioNode(o);
onended();
};
});
return {
node: o,
nodes: { source: [o] },
stop: () => {},
};
},