wip
This commit is contained in:
commit
6a47139e0d
56 changed files with 4142 additions and 558 deletions
|
|
@ -14,6 +14,11 @@ export const setDefaultAudioContext = () => {
|
|||
return audioContext;
|
||||
};
|
||||
|
||||
export const setAudioContext = (context) => {
|
||||
audioContext = context;
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
export const getAudioContext = () => {
|
||||
if (!audioContext) {
|
||||
return setDefaultAudioContext();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,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,
|
||||
|
|
@ -60,17 +61,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;
|
||||
|
|
@ -106,22 +105,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,
|
||||
};
|
||||
|
||||
|
|
@ -168,7 +185,9 @@ export function getParamLfo(audioContext, param, start, end, lfoValues) {
|
|||
}
|
||||
let lfo;
|
||||
if (depth) {
|
||||
lfo = getLfo(audioContext, start, end, {
|
||||
lfo = getLfo(audioContext, {
|
||||
begin: start,
|
||||
end,
|
||||
depth,
|
||||
dcoffset,
|
||||
...getLfoInputs,
|
||||
|
|
@ -195,7 +214,7 @@ export function applyParameterModulators(audioContext, param, start, end, envelo
|
|||
getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve);
|
||||
}
|
||||
const lfo = getParamLfo(audioContext, param, start, end, lfoValues);
|
||||
return { lfo, disconnect: () => lfo?.disconnect() };
|
||||
return lfo;
|
||||
}
|
||||
export function createFilter(context, start, end, params, cps, cycle) {
|
||||
let {
|
||||
|
|
@ -292,9 +311,12 @@ export function drywet(dry, wet, wetAmount = 0) {
|
|||
wet_gain.connect(mix);
|
||||
return {
|
||||
node: mix,
|
||||
onended: () => {
|
||||
dry_gain.disconnect(mix);
|
||||
wet_gain.disconnect(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);
|
||||
},
|
||||
|
|
@ -333,12 +355,12 @@ export function getVibratoOscillator(param, value, t) {
|
|||
gain.gain.value = vibmod * 100;
|
||||
vibratoOscillator.connect(gain);
|
||||
gain.connect(param);
|
||||
vibratoOscillator.onended = () => {
|
||||
gain.disconnect(param);
|
||||
vibratoOscillator.disconnect(gain);
|
||||
};
|
||||
onceEnded(vibratoOscillator, () => {
|
||||
releaseAudioNode(gain);
|
||||
releaseAudioNode(vibratoOscillator);
|
||||
});
|
||||
vibratoOscillator.start(t);
|
||||
return vibratoOscillator;
|
||||
return { stop: (t) => vibratoOscillator.stop(t), nodes: { vib: [vibratoOscillator], vib_gain: [gain] } };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -394,6 +416,7 @@ export function applyFM(param, value, begin) {
|
|||
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++) {
|
||||
|
|
@ -444,11 +467,14 @@ export function applyFM(param, value, begin) {
|
|||
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 g = gainNode(amt * freq);
|
||||
io.push(isMod ? output.connect(g) : input);
|
||||
cleanupOnEnd(osc, [...toCleanup, g]);
|
||||
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(
|
||||
|
|
@ -461,6 +487,7 @@ export function applyFM(param, value, begin) {
|
|||
}
|
||||
}
|
||||
return {
|
||||
nodes,
|
||||
stop: (t) => toStop.forEach((m) => m?.stop(t)),
|
||||
};
|
||||
}
|
||||
|
|
@ -599,7 +626,7 @@ export const releaseAudioNode = (node) => {
|
|||
// make sure all AudioScheduledSourceNodes are in a stopped state
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode
|
||||
if (node instanceof AudioScheduledSourceNode) {
|
||||
if (node.onended && node.onended.name !== 'cleanup') {
|
||||
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`,
|
||||
);
|
||||
|
|
@ -622,6 +649,8 @@ export const releaseAudioNode = (node) => {
|
|||
// 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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
178
packages/superdough/modulators.mjs
Normal file
178
packages/superdough/modulators.mjs
Normal 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 };
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { drywet } from './helpers.mjs';
|
||||
import { drywet, onceEnded, releaseAudioNode } from './helpers.mjs';
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
|
||||
let noiseCache = {};
|
||||
|
|
@ -65,9 +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);
|
||||
noiseOscillator.node.onended = () => noiseMix.onended();
|
||||
onceEnded(noiseOscillator.node, () => {
|
||||
releaseAudioNode(noiseOscillator.node);
|
||||
});
|
||||
return {
|
||||
node: noiseMix.node,
|
||||
stop: (time) => noiseOscillator?.stop(time),
|
||||
teardown: noiseMix.teardown,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -104,8 +106,8 @@ var applyGradualLowpass = function (input, lpFreqStart, lpFreqEnd, lpFreqEndAt,
|
|||
player.start();
|
||||
context.oncomplete = function (event) {
|
||||
callback(event.renderedBuffer);
|
||||
filter.disconnect();
|
||||
player.disconnect();
|
||||
releaseAudioNode(filter);
|
||||
releaseAudioNode(player);
|
||||
};
|
||||
context.startRendering();
|
||||
|
||||
|
|
|
|||
|
|
@ -65,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) => {
|
||||
|
|
@ -243,12 +244,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 = {}) => {
|
||||
|
|
@ -307,7 +302,7 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
|
|||
}
|
||||
|
||||
// vibrato
|
||||
let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, t);
|
||||
const vibratoHandle = getVibratoOscillator(bufferSource.detune, value, t);
|
||||
|
||||
const time = t + nudge;
|
||||
bufferSource.start(time, offset);
|
||||
|
|
@ -329,10 +324,10 @@ 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);
|
||||
onceEnded(bufferSource, function () {
|
||||
bufferSource.disconnect();
|
||||
vibratoOscillator?.stop();
|
||||
node.disconnect();
|
||||
out.disconnect();
|
||||
releaseAudioNode(bufferSource);
|
||||
vibratoHandle?.stop();
|
||||
releaseAudioNode(node);
|
||||
releaseAudioNode(out);
|
||||
onended();
|
||||
});
|
||||
let envEnd = holdEnd + release + 0.01;
|
||||
|
|
@ -340,7 +335,7 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -7,26 +7,49 @@ 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, pickAndRename } from './util.mjs';
|
||||
import { clamp, nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
|
||||
import workletsUrl from './worklets.mjs?audioworklet';
|
||||
import { createFilter, effectSend, gainNode, getCompressor, getDistortion, getLfo, getWorklet } from './helpers.mjs';
|
||||
import { getNodeFromPool, isPoolable, releaseNodeToPool } from './nodePools.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;
|
||||
}
|
||||
|
|
@ -44,6 +67,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;
|
||||
}
|
||||
|
|
@ -153,6 +187,7 @@ let defaultDefaultValues = {
|
|||
distortvol: 1,
|
||||
distorttype: 0,
|
||||
delay: 0,
|
||||
busgain: 1,
|
||||
byteBeatExpression: '0',
|
||||
delayfeedback: 0.5,
|
||||
delaysync: 3 / 16,
|
||||
|
|
@ -160,6 +195,9 @@ let defaultDefaultValues = {
|
|||
i: 1,
|
||||
velocity: 1,
|
||||
fft: 8,
|
||||
tremolodepth: 1,
|
||||
tremolophase: 0,
|
||||
release: 0.01,
|
||||
};
|
||||
|
||||
const defaultDefaultDefaultValues = Object.freeze({ ...defaultDefaultValues });
|
||||
|
|
@ -204,11 +242,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;
|
||||
|
|
@ -225,6 +265,7 @@ export async function initAudio(options = {}) {
|
|||
|
||||
setMaxPolyphony(maxPolyphony);
|
||||
setMultiChannelOrbits(multiChannelOrbits);
|
||||
resetSeenKeys();
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
|
@ -246,8 +287,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;
|
||||
|
|
@ -281,18 +323,24 @@ export function getSuperdoughAudioController() {
|
|||
}
|
||||
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 lfo = 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 = getNodeFromPool('filter', () => ac.createBiquadFilter());
|
||||
|
|
@ -303,12 +351,9 @@ function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000
|
|||
|
||||
lfo.connect(filter.detune);
|
||||
fOffset += 282;
|
||||
if (i > 0) {
|
||||
filterChain[i - 1].connect(filter);
|
||||
}
|
||||
filterChain.push(filter);
|
||||
}
|
||||
return { phaser: filterChain[filterChain.length - 1], lfo };
|
||||
return { filterChain, lfo };
|
||||
}
|
||||
|
||||
function getFilterType(ftype) {
|
||||
|
|
@ -321,7 +366,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;
|
||||
|
|
@ -362,7 +407,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();
|
||||
|
|
@ -383,7 +458,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)})`,
|
||||
|
|
@ -392,49 +466,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'),
|
||||
release = 0,
|
||||
|
||||
//phaser
|
||||
phaserrate,
|
||||
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,
|
||||
|
|
@ -444,14 +493,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);
|
||||
|
|
@ -466,17 +511,13 @@ 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
|
||||
|
|
@ -490,8 +531,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
|||
activeSoundSources.delete(chainID);
|
||||
}
|
||||
|
||||
let audioNodes = [];
|
||||
|
||||
if (['-', '~', '_'].includes(s)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -504,17 +543,31 @@ 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) => (isPoolable(n) ? releaseNodeToPool(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, new WeakRef(soundHandle)); // allow GC
|
||||
nodes.main = { ...nodes.main, ...soundHandle.nodes };
|
||||
}
|
||||
} else {
|
||||
throw new Error(`sound ${s} not found! Is it loaded?`);
|
||||
|
|
@ -529,189 +582,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 (stretch !== undefined) {
|
||||
const phaseVocoder = getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: stretch });
|
||||
chain.connect(phaseVocoder);
|
||||
fxNodes['stretch'] = [phaseVocoder];
|
||||
}
|
||||
|
||||
if (value.cutoff !== undefined) {
|
||||
const lpMap = {
|
||||
frequency: 'cutoff',
|
||||
q: 'resonance',
|
||||
attack: 'lpattack',
|
||||
decay: 'lpdecay',
|
||||
sustain: 'lpsustain',
|
||||
release: 'lprelease',
|
||||
env: 'lpenv',
|
||||
anchor: 'fanchor',
|
||||
model: 'ftype',
|
||||
drive: 'drive',
|
||||
rate: 'lprate',
|
||||
sync: 'lpsync',
|
||||
depth: 'lpdepth',
|
||||
depthfrequency: 'lpdepthfrequency',
|
||||
shape: 'lpshape',
|
||||
dcoffset: 'lpdc',
|
||||
skew: 'lpskew',
|
||||
};
|
||||
const lpParams = pickAndRename(value, lpMap);
|
||||
lpParams.type = 'lowpass';
|
||||
const lp = () => createFilter(ac, t, end, lpParams, cps, cycle);
|
||||
const { filter: lpf1, lfo: lfo1 } = lp();
|
||||
chain.push(lpf1);
|
||||
lfo1 && audioNodes.push(lfo1);
|
||||
if (ftype === '24db') {
|
||||
const { filter: lpf2, lfo: lfo2 } = lp();
|
||||
chain.push(lpf2);
|
||||
lfo2 && audioNodes.push(lfo2);
|
||||
if (fx.transient !== undefined) {
|
||||
const transProcessor = getWorklet(
|
||||
ac,
|
||||
'transient-processor',
|
||||
{},
|
||||
{
|
||||
processorOptions: {
|
||||
attack: fx.transient,
|
||||
sustain: fx.transsustain,
|
||||
begin: t,
|
||||
end: endWithRelease,
|
||||
},
|
||||
},
|
||||
);
|
||||
chain.connect(transProcessor);
|
||||
fxNodes['transient'] = transProcessor;
|
||||
}
|
||||
|
||||
// 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,
|
||||
fx.compressor,
|
||||
fx.compressorRatio,
|
||||
fx.compressorKnee,
|
||||
fx.compressorAttack,
|
||||
fx.compressorRelease,
|
||||
);
|
||||
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 (value.hcutoff !== undefined) {
|
||||
const hpMap = {
|
||||
frequency: 'hcutoff',
|
||||
q: 'hresonance',
|
||||
attack: 'hpattack',
|
||||
decay: 'hpdecay',
|
||||
sustain: 'hpsustain',
|
||||
release: 'hprelease',
|
||||
env: 'hpenv',
|
||||
anchor: 'fanchor',
|
||||
model: 'ftype',
|
||||
drive: 'drive',
|
||||
rate: 'hprate',
|
||||
sync: 'hpsync',
|
||||
depth: 'hpdepth',
|
||||
depthfrequency: 'hpdepthfrequency',
|
||||
shape: 'hpshape',
|
||||
dcoffset: 'hpdc',
|
||||
skew: 'hpskew',
|
||||
};
|
||||
const hpParams = pickAndRename(value, hpMap);
|
||||
hpParams.type = 'highpass';
|
||||
const hp = () => createFilter(ac, t, end, hpParams, cps, cycle);
|
||||
const { filter: hpf1, lfo: lfo1 } = hp();
|
||||
chain.push(hpf1);
|
||||
lfo1 && audioNodes.push(lfo1);
|
||||
if (ftype === '24db') {
|
||||
const { filter: hpf2, lfo: lfo2 } = hp();
|
||||
chain.push(hpf2);
|
||||
lfo2 && audioNodes.push(lfo2);
|
||||
}
|
||||
}
|
||||
|
||||
if (value.bandf !== undefined) {
|
||||
const bpMap = {
|
||||
frequency: 'bandf',
|
||||
q: 'bandq',
|
||||
attack: 'bpattack',
|
||||
decay: 'bpdecay',
|
||||
sustain: 'bpsustain',
|
||||
release: 'bprelease',
|
||||
env: 'bpenv',
|
||||
anchor: 'fanchor',
|
||||
model: 'ftype',
|
||||
drive: 'drive',
|
||||
rate: 'bprate',
|
||||
sync: 'bpsync',
|
||||
depth: 'bpdepth',
|
||||
depthfrequency: 'bpdepthfrequency',
|
||||
shape: 'bpshape',
|
||||
dcoffset: 'bpdc',
|
||||
skew: 'bpskew',
|
||||
};
|
||||
const bpParams = pickAndRename(value, bpMap);
|
||||
bpParams.type = 'bandpass';
|
||||
const bp = () => createFilter(ac, t, end, bpParams, cps, cycle);
|
||||
const { filter: bpf1, lfo: lfo1 } = bp();
|
||||
chain.push(bpf1);
|
||||
lfo1 && audioNodes.push(lfo1);
|
||||
if (ftype === '24db') {
|
||||
const { filter: bpf2, lfo: lfo2 } = bp();
|
||||
chain.push(bpf2);
|
||||
lfo2 && audioNodes.push(lfo2);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
audioNodes.push(lfo);
|
||||
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 (phaserrate !== undefined && phaserdepth > 0) {
|
||||
const { phaser, lfo } = getPhaser(t, endWithRelease, phaserrate, phaserdepth, phasercenter, phasersweep);
|
||||
audioNodes.push(lfo);
|
||||
chain.push(phaser);
|
||||
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);
|
||||
const send = orbitBus.sendDelay(post, delay);
|
||||
audioNodes.push(send);
|
||||
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) {
|
||||
|
|
@ -726,33 +935,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);
|
||||
const send = orbitBus.sendReverb(post, room);
|
||||
audioNodes.push(send);
|
||||
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) => {
|
||||
|
|
|
|||
150
packages/superdough/superdoughdata.mjs
Normal file
150
packages/superdough/superdoughdata.mjs
Normal 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;
|
||||
}
|
||||
|
|
@ -11,7 +11,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;
|
||||
|
|
@ -20,10 +23,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);
|
||||
}
|
||||
|
||||
|
|
@ -43,6 +47,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) {
|
||||
|
|
@ -173,6 +178,7 @@ export class SuperdoughAudioController {
|
|||
audioContext;
|
||||
output;
|
||||
nodes = {};
|
||||
buses = {};
|
||||
|
||||
constructor(audioContext) {
|
||||
this.audioContext = audioContext;
|
||||
|
|
@ -180,10 +186,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();
|
||||
}
|
||||
|
||||
|
|
@ -215,4 +225,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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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,
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
getVibratoOscillator,
|
||||
getWorklet,
|
||||
noises,
|
||||
onceEnded,
|
||||
releaseAudioNode,
|
||||
webAudioTimeout,
|
||||
} from './helpers.mjs';
|
||||
|
|
@ -19,7 +20,7 @@ import { logger } from './logger.mjs';
|
|||
import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
|
||||
import { getNodeFromPool, markWorkletAsDead, releaseNodeToPool } from './nodePools.mjs';
|
||||
|
||||
const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user'];
|
||||
const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user', 'one'];
|
||||
const waveformAliases = [
|
||||
['tri', 'triangle'],
|
||||
['sqr', 'square'],
|
||||
|
|
@ -52,17 +53,17 @@ export function registerSynthSounds() {
|
|||
// turn down
|
||||
const g = gainNode(0.3);
|
||||
|
||||
let sound = getOscillator(s, t, value, () => {
|
||||
g.disconnect();
|
||||
const sound = getOscillator(s, t, value, () => {
|
||||
releaseAudioNode(g);
|
||||
onended();
|
||||
});
|
||||
|
||||
let { node: o, stop, triggerRelease } = sound;
|
||||
const { node: o, nodes, stop, triggerRelease } = sound;
|
||||
|
||||
const { duration } = value;
|
||||
|
||||
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 +71,7 @@ export function registerSynthSounds() {
|
|||
stop(envEnd);
|
||||
return {
|
||||
node,
|
||||
nodes,
|
||||
stop: (endTime) => {
|
||||
stop(endTime);
|
||||
},
|
||||
|
|
@ -111,15 +113,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 +141,7 @@ export function registerSynthSounds() {
|
|||
|
||||
return {
|
||||
node,
|
||||
nodes: { source: [o] },
|
||||
stop: (endTime) => {
|
||||
o.stop(endTime);
|
||||
},
|
||||
|
|
@ -188,8 +191,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);
|
||||
|
||||
|
|
@ -200,8 +203,8 @@ export function registerSynthSounds() {
|
|||
() => {
|
||||
releaseNodeToPool(o);
|
||||
onended();
|
||||
fm?.stop();
|
||||
vibratoOscillator?.stop();
|
||||
fmHandle?.stop();
|
||||
vibratoHandle?.stop();
|
||||
},
|
||||
begin,
|
||||
end,
|
||||
|
|
@ -209,6 +212,7 @@ export function registerSynthSounds() {
|
|||
|
||||
return {
|
||||
node: envGain,
|
||||
nodes: { source: [o], ...fmHandle?.nodes, ...vibratoHandle?.nodes },
|
||||
stop: (time) => {
|
||||
timeoutNode.stop(time);
|
||||
},
|
||||
|
|
@ -284,6 +288,7 @@ export function registerSynthSounds() {
|
|||
|
||||
return {
|
||||
node: envGain,
|
||||
source: o,
|
||||
stop: (time) => {
|
||||
timeoutNode.stop(time);
|
||||
},
|
||||
|
|
@ -334,25 +339,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,
|
||||
() => {
|
||||
releaseAudioNode(o);
|
||||
releaseAudioNode(lfo);
|
||||
releaseAudioNode(pw_lfo);
|
||||
onended();
|
||||
fm?.stop();
|
||||
vibratoOscillator?.stop();
|
||||
fmHandle?.stop();
|
||||
vibratoHandle?.stop();
|
||||
},
|
||||
begin,
|
||||
end,
|
||||
|
|
@ -360,6 +365,7 @@ export function registerSynthSounds() {
|
|||
|
||||
return {
|
||||
node: envGain,
|
||||
nodes: { source: [o], pw_lfo: [pw_lfo], ...fmHandle?.nodes, ...vibratoHandle?.nodes },
|
||||
stop: (time) => {
|
||||
timeoutNode.stop(time);
|
||||
},
|
||||
|
|
@ -368,6 +374,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,
|
||||
|
|
@ -390,11 +431,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);
|
||||
|
|
@ -405,6 +446,7 @@ export function registerSynthSounds() {
|
|||
stop(envEnd);
|
||||
return {
|
||||
node,
|
||||
nodes: { source: [o] },
|
||||
stop: (endTime) => {
|
||||
stop(endTime);
|
||||
},
|
||||
|
|
@ -472,8 +514,17 @@ export function getOscillator(s, t, value, onended) {
|
|||
s = 'triangle';
|
||||
}
|
||||
s = s === 'user' && !partials ? 'triangle' : s;
|
||||
// If no partials are given, use stock waveforms
|
||||
if (!partials || partials?.length === 0 || s === 'sine') {
|
||||
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';
|
||||
}
|
||||
|
|
@ -484,29 +535,31 @@ export function getOscillator(s, t, value, onended) {
|
|||
// set frequency
|
||||
o.frequency.value = getFrequencyFromValue(value);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
o.onended = () => {
|
||||
noiseMix || o.disconnect();
|
||||
noiseMix?.node.disconnect();
|
||||
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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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] },
|
||||
|
|
@ -46,7 +48,8 @@ if (typeof GainNode !== 'undefined') {
|
|||
}
|
||||
const { gains, qs, freqs } = vowelFormant[letter];
|
||||
this.makeupGain = ac.createGain();
|
||||
this.audioNodes = [];
|
||||
this.filters = [];
|
||||
this.gains = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const gain = ac.createGain();
|
||||
gain.gain.value = gains[i];
|
||||
|
|
@ -56,9 +59,9 @@ if (typeof GainNode !== 'undefined') {
|
|||
filter.frequency.value = freqs[i];
|
||||
super.connect(filter);
|
||||
filter.connect(gain);
|
||||
this.audioNodes.push(filter);
|
||||
this.filters.push(filter);
|
||||
gain.connect(this.makeupGain);
|
||||
this.audioNodes.push(gain);
|
||||
this.gains.push(gain);
|
||||
}
|
||||
this.makeupGain.gain.value = 8; // how much makeup gain to add?
|
||||
return this;
|
||||
|
|
@ -67,15 +70,17 @@ if (typeof GainNode !== 'undefined') {
|
|||
this.makeupGain.connect(target);
|
||||
}
|
||||
disconnect() {
|
||||
this.makeupGain.disconnect();
|
||||
this.audioNodes.forEach((n) => n.disconnect());
|
||||
releaseAudioNode(this.makeupGain);
|
||||
this.filters.forEach(releaseAudioNode);
|
||||
this.gains.forEach(releaseAudioNode);
|
||||
super.disconnect();
|
||||
this.makeupGain = null;
|
||||
this.audioNodes = null;
|
||||
this.filters = null;
|
||||
this.gains = null;
|
||||
}
|
||||
}
|
||||
|
||||
AudioContext.prototype.createVowelFilter = function (letter) {
|
||||
BaseAudioContext.prototype.createVowelFilter = function (letter) {
|
||||
return new VowelNode(this, letter);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,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)) {
|
||||
|
|
@ -315,21 +320,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,
|
||||
() => {
|
||||
releaseNodeToPool(source);
|
||||
vibratoOscillator?.stop();
|
||||
fm?.stop();
|
||||
wtPosModulators?.disconnect();
|
||||
wtWarpModulators?.disconnect();
|
||||
vibratoHandle?.stop();
|
||||
fmHandle?.stop();
|
||||
releaseAudioNode(wtPosModulators);
|
||||
releaseAudioNode(wtWarpModulators);
|
||||
onended();
|
||||
},
|
||||
t,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ 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;
|
||||
|
|
@ -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]];
|
||||
|
|
@ -978,7 +983,9 @@ class EnvelopeProcessor extends AudioWorkletProcessor {
|
|||
{ 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: 'peak', defaultValue: 1 },
|
||||
{ name: 'depth', defaultValue: 1 },
|
||||
{ name: 'min', defaultValue: -1e9 },
|
||||
{ name: 'max', defaultValue: 1e9 },
|
||||
{ name: 'retrigger', defaultValue: 1, minValue: 0, maxValue: 1 },
|
||||
];
|
||||
}
|
||||
|
|
@ -1020,9 +1027,15 @@ class EnvelopeProcessor extends AudioWorkletProcessor {
|
|||
}
|
||||
|
||||
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];
|
||||
if (!out) return true;
|
||||
const begin = pv(params.begin, 0);
|
||||
const retrigger = pv(params.retrigger, 0) >= 0.5; // convert to bool
|
||||
if (begin !== this.beginTime && (this.state === 0 || retrigger)) {
|
||||
// triggered
|
||||
|
|
@ -1040,7 +1053,9 @@ class EnvelopeProcessor extends AudioWorkletProcessor {
|
|||
const aCurve = pv(params.attackCurve, i);
|
||||
const dCurve = pv(params.decayCurve, i);
|
||||
const rCurve = pv(params.releaseCurve, i);
|
||||
const peak = pv(params.peak, 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 },
|
||||
|
|
@ -1054,7 +1069,7 @@ class EnvelopeProcessor extends AudioWorkletProcessor {
|
|||
this.state = (this.state + 1) % states.length;
|
||||
time = states[this.state].time;
|
||||
}
|
||||
out[i] = this.val * peak;
|
||||
out[i] = clamp(this.val * depth, min, max);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1385,3 +1400,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);
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ export function registerZZFXSounds() {
|
|||
});
|
||||
return {
|
||||
node: o,
|
||||
nodes: { source: [o] },
|
||||
stop: () => {},
|
||||
};
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue