mirror of
https://github.com/eliasstepanik/strudel-docker.git
synced 2026-01-10 21:28:31 +00:00
280 lines
8.7 KiB
JavaScript
280 lines
8.7 KiB
JavaScript
import { getAudioContext } from './superdough.mjs';
|
|
import { clamp, nanFallback } from './util.mjs';
|
|
|
|
export function gainNode(value) {
|
|
const node = getAudioContext().createGain();
|
|
node.gain.value = value;
|
|
return node;
|
|
}
|
|
|
|
const getSlope = (y1, y2, x1, x2) => {
|
|
const denom = x2 - x1;
|
|
if (denom === 0) {
|
|
return 0;
|
|
}
|
|
return (y2 - y1) / (x2 - x1);
|
|
};
|
|
|
|
export function getWorklet(ac, processor, params, config) {
|
|
const node = new AudioWorkletNode(ac, processor, config);
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
node.parameters.get(key).value = value;
|
|
});
|
|
return node;
|
|
}
|
|
|
|
export const getParamADSR = (
|
|
param,
|
|
attack,
|
|
decay,
|
|
sustain,
|
|
release,
|
|
min,
|
|
max,
|
|
begin,
|
|
end,
|
|
//exponential works better for frequency modulations (such as filter cutoff) due to human ear perception
|
|
curve = 'exponential',
|
|
) => {
|
|
attack = nanFallback(attack);
|
|
decay = nanFallback(decay);
|
|
sustain = nanFallback(sustain);
|
|
release = nanFallback(release);
|
|
const ramp = curve === 'exponential' ? 'exponentialRampToValueAtTime' : 'linearRampToValueAtTime';
|
|
if (curve === 'exponential') {
|
|
min = min === 0 ? 0.001 : min;
|
|
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);
|
|
} else {
|
|
val = (time - attack) * getSlope(peak, sustainVal, 0, decay) + peak;
|
|
}
|
|
if (curve === 'exponential') {
|
|
val = val || 0.001;
|
|
}
|
|
return val;
|
|
};
|
|
|
|
param.setValueAtTime(min, begin);
|
|
if (attack > duration) {
|
|
//attack
|
|
param[ramp](envValAtTime(duration), end);
|
|
} else if (attack + decay > duration) {
|
|
//attack
|
|
param[ramp](envValAtTime(attack), begin + attack);
|
|
//decay
|
|
param[ramp](envValAtTime(duration), end);
|
|
} else {
|
|
//attack
|
|
param[ramp](envValAtTime(attack), begin + attack);
|
|
//decay
|
|
param[ramp](envValAtTime(attack + decay), begin + attack + decay);
|
|
//sustain
|
|
param.setValueAtTime(sustainVal, end);
|
|
}
|
|
//release
|
|
param[ramp](min, end + release);
|
|
};
|
|
|
|
export function getCompressor(ac, threshold, ratio, knee, attack, release) {
|
|
const options = {
|
|
threshold: threshold ?? -3,
|
|
ratio: ratio ?? 10,
|
|
knee: knee ?? 10,
|
|
attack: attack ?? 0.005,
|
|
release: release ?? 0.05,
|
|
};
|
|
return new DynamicsCompressorNode(ac, options);
|
|
}
|
|
|
|
// changes the default values of the envelope based on what parameters the user has defined
|
|
// so it behaves more like you would expect/familiar as other synthesis tools
|
|
// ex: sound(val).decay(val) will behave as a decay only envelope. sound(val).attack(val).decay(val) will behave like an "ad" env, etc.
|
|
|
|
export const getADSRValues = (params, curve = 'linear', defaultValues) => {
|
|
const envmin = curve === 'exponential' ? 0.001 : 0.001;
|
|
const releaseMin = 0.01;
|
|
const envmax = 1;
|
|
const [a, d, s, r] = params;
|
|
if (a == null && d == null && s == null && r == null) {
|
|
return defaultValues ?? [envmin, envmin, envmax, releaseMin];
|
|
}
|
|
const sustain = s != null ? s : (a != null && d == null) || (a == null && d == null) ? envmax : envmin;
|
|
return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)];
|
|
};
|
|
|
|
export function createFilter(context, type, frequency, Q, att, dec, sus, rel, fenv, start, end, fanchor, model, drive) {
|
|
const curve = 'exponential';
|
|
const [attack, decay, sustain, release] = getADSRValues([att, dec, sus, rel], curve, [0.005, 0.14, 0, 0.1]);
|
|
let filter;
|
|
let frequencyParam;
|
|
if (model === 'ladder') {
|
|
filter = getWorklet(context, 'ladder-processor', { frequency, q: Q, drive });
|
|
frequencyParam = filter.parameters.get('frequency');
|
|
} else {
|
|
filter = context.createBiquadFilter();
|
|
filter.type = type;
|
|
filter.Q.value = Q;
|
|
filter.frequency.value = frequency;
|
|
frequencyParam = filter.frequency;
|
|
}
|
|
|
|
// envelope is active when any of these values is set
|
|
const hasEnvelope = att ?? dec ?? sus ?? rel ?? fenv;
|
|
// 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;
|
|
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;
|
|
}
|
|
return filter;
|
|
}
|
|
|
|
// stays 1 until .5, then fades out
|
|
let wetfade = (d) => (d < 0.5 ? 1 : 1 - (d - 0.5) / 0.5);
|
|
|
|
// mix together dry and wet nodes. 0 = only dry 1 = only wet
|
|
// still not too sure about how this could be used more generally...
|
|
export function drywet(dry, wet, wetAmount = 0) {
|
|
const ac = getAudioContext();
|
|
if (!wetAmount) {
|
|
return dry;
|
|
}
|
|
let dry_gain = ac.createGain();
|
|
let wet_gain = ac.createGain();
|
|
dry.connect(dry_gain);
|
|
wet.connect(wet_gain);
|
|
dry_gain.gain.value = wetfade(wetAmount);
|
|
wet_gain.gain.value = wetfade(1 - wetAmount);
|
|
let mix = ac.createGain();
|
|
dry_gain.connect(mix);
|
|
wet_gain.connect(mix);
|
|
return mix;
|
|
}
|
|
|
|
let curves = ['linear', 'exponential'];
|
|
export function getPitchEnvelope(param, value, t, holdEnd) {
|
|
// envelope is active when any of these values is set
|
|
const hasEnvelope = value.pattack ?? value.pdecay ?? value.psustain ?? value.prelease ?? value.penv;
|
|
if (!hasEnvelope) {
|
|
return;
|
|
}
|
|
const penv = nanFallback(value.penv, 1, true);
|
|
const curve = curves[value.pcurve ?? 0];
|
|
let [pattack, pdecay, psustain, prelease] = getADSRValues(
|
|
[value.pattack, value.pdecay, value.psustain, value.prelease],
|
|
curve,
|
|
[0.2, 0.001, 1, 0.001],
|
|
);
|
|
let panchor = value.panchor ?? psustain;
|
|
const cents = penv * 100; // penv is in semitones
|
|
const min = 0 - cents * panchor;
|
|
const max = cents - cents * panchor;
|
|
getParamADSR(param, pattack, pdecay, psustain, prelease, min, max, t, holdEnd, curve);
|
|
}
|
|
|
|
export function getVibratoOscillator(param, value, t) {
|
|
const { vibmod = 0.5, vib } = value;
|
|
let vibratoOscillator;
|
|
if (vib > 0) {
|
|
vibratoOscillator = getAudioContext().createOscillator();
|
|
vibratoOscillator.frequency.value = vib;
|
|
const gain = getAudioContext().createGain();
|
|
// Vibmod is the amount of vibrato, in semitones
|
|
gain.gain.value = vibmod * 100;
|
|
vibratoOscillator.connect(gain);
|
|
gain.connect(param);
|
|
vibratoOscillator.start(t);
|
|
return vibratoOscillator;
|
|
}
|
|
}
|
|
// ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities
|
|
// a bit of a hack, but it works very well :)
|
|
export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
|
|
const constantNode = audioContext.createConstantSource();
|
|
constantNode.start(startTime);
|
|
constantNode.stop(stopTime);
|
|
constantNode.onended = () => {
|
|
onComplete();
|
|
};
|
|
return constantNode;
|
|
}
|
|
const mod = (freq, range = 1, type = 'sine') => {
|
|
const ctx = getAudioContext();
|
|
const osc = ctx.createOscillator();
|
|
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) };
|
|
};
|
|
const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => {
|
|
const carrfreq = frequencyparam.value;
|
|
const modfreq = carrfreq * harmonicityRatio;
|
|
const modgain = modfreq * modulationIndex;
|
|
return mod(modfreq, modgain, wave);
|
|
};
|
|
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].find((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);
|
|
}
|
|
}
|
|
return { stop };
|
|
}
|