Fully working version with multiple LFOs

This commit is contained in:
Aria 2025-08-20 10:08:36 -05:00
parent 4a70ac5a99
commit efb337677c
5 changed files with 103 additions and 48 deletions

View file

@ -1428,13 +1428,17 @@ export const { panorient } = registerControl('panorient');
// ['pitch2'],
// ['pitch3'],
// ['portamento'],
// TODO: LFO rate see https://tidalcycles.org/docs/patternlib/tutorials/synthesizers/#supersquare
export const { rate } = registerControl('rate');
export const { lfoNum } = registerControl('lfoNum');
export const { lfoTarget } = registerControl('lfoTarget');
export const { lfoParam } = registerControl('lfoParam');
export const { lfoNum } = registerControl('lfoNum');
export const { lfoBipolar } = registerControl('lfoBipolar');
export const { lfoRate } = registerControl('lfoRate');
export const { lfoDepth } = registerControl('lfoDepth');
export const { lfoDCOffset } = registerControl('lfoDCOffset');
export const { lfoShape } = registerControl('lfoShape');
export const { lfoSkew } = registerControl('lfoSkew');
export const { lfoCurve } = registerControl('lfoCurve');
export const { lfoSynced } = registerControl('lfoSynced');
// TODO: slide param for certain synths
export const { slide } = registerControl('slide');
// TODO: detune? https://tidalcycles.org/docs/patternlib/tutorials/synthesizers/#supersquare

View file

@ -21,7 +21,9 @@ const getSlope = (y1, y2, x1, x2) => {
export function getWorklet(ac, processor, params, config) {
const node = new AudioWorkletNode(ac, processor, config);
Object.entries(params).forEach(([key, value]) => {
node.parameters.get(key).value = value;
if (value !== undefined) {
node.parameters.get(key).value = value;
}
});
return node;
}

View file

@ -345,31 +345,21 @@ function getDelay(orbit, delaytime, delayfeedback, t, channels) {
return orbits[orbit].delayNode;
}
export function getLfo(audioContext, begin, end, properties = {}) {
const { shape = 0, ...props } = properties;
const { dcoffset = -0.5, depth = 1 } = properties;
export function getLfo(audioContext, properties = {}) {
// Extract some params we need for deriving other params
const { begin, shape = 0, ...props } = properties;
const lfoprops = {
frequency: 1,
depth,
skew: 0.5,
phaseoffset: 0,
time: begin,
begin,
end,
shape: getModulationShapeInput(shape),
dcoffset,
min: dcoffset * depth,
max: dcoffset * depth + depth,
curve: 1,
...props,
};
return getWorklet(audioContext, 'lfo-processor', lfoprops);
}
function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) {
function getPhaser(begin, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) {
const ac = getAudioContext();
const lfoGain = getLfo(ac, time, end, { frequency, depth: sweep * 2 });
const lfoGain = getLfo(ac, { frequency, depth: sweep * 2, begin, end });
//filters
const numStages = 2; //num of filters in series
@ -542,34 +532,69 @@ function _getNodeParams(node) {
return Array.from(params);
}
function connectLFO(lfoNum, target, param, frequency, depth, shape, bipolar, start, end) {
debugger;
function _connectLFO(params) {
const {
frequency = 1,
synced = 0,
cps = 0.5,
lfoNum = 1, // default to LFO 1
lfoTarget,
lfoParam,
...filteredParams
} = params;
filteredParams['frequency'] = synced ? frequency / cps : frequency;
let lfoNode = lfos[lfoNum];
const params = {
frequency,
depth,
}
if (lfoNode == null) {
const ac = getAudioContext();
const dcoffset = bipolar > 0.5 ? -0.5 : 0;
lfoNode = getLfo(ac, start, 1e9, { frequency, depth, shape, dcoffset});
lfoNode = getLfo(ac, filteredParams);
lfos[lfoNum] = lfoNode;
}
lfoNode.disconnect();
const targetNodes = nodes[target];
const targetNodes = nodes[lfoTarget];
if (targetNodes === undefined) {
const keys = Object.keys(nodes);
errorLogger(new Error(`Could not connect to target ${lfoTarget} -- it does not exist. Available options are ${keys.join(", ")}`), 'superdough');
return;
}
targetNodes.forEach((targetNode) => {
if (targetNode === undefined) {
const keys = Object.keys(nodes);
errorLogger(new Error(`Could not connect to target ${target} -- it does not exist. Available options are ${keys.join(", ")}`), 'superdough');
return;
}
const targetParam = _getNodeParam(targetNode, param);
const targetParam = _getNodeParam(targetNode, lfoParam);
if (targetParam === undefined) {
const parameters = _getNodeParams(targetNode);
errorLogger(new Error(`Could not connect to parameter ${param} on node ${target}. Available parameters are ${parameters.join(", ")}`), 'superdough');
errorLogger(new Error(`Could not connect to parameter ${lfoParam} on node ${lfoTarget}. Available parameters are ${parameters.join(", ")}`), 'superdough');
}
lfoNode.connect(targetParam);
});
const time = filteredParams.begin;
for (const [name, value] of Object.entries(filteredParams)) {
if (value == null) continue;
const p = lfoNode.parameters?.get(name);
if (p.cancelAndHoldAtTime) p.cancelAndHoldAtTime(time);
else p.cancelScheduledValues(time);
p.setValueAtTime(value, time);
}
}
function connectLFOs(time, params) {
// We break down params specifying multiple LFOs into a set of parameters for
// a single LFO
const numLFOs = [
[params.lfoNum].flat().length,
[params.lfoTarget].flat().length,
[params.lfoParam].flat().length,
].reduce((a, v) => Math.max(a, v)); // Number of LFOs is the max as implied by these values
for (let i = 0; i < numLFOs; i++) {
let singleParams = {};
for (const k in params) {
const v = params[k];
const flatV = [v].flat();
if (flatV.length !== numLFOs && flatV.length !== 1) {
errorLogger(new Error(`Could not setup LFOs. We derived ${numLFOs} as the intended number of LFOs, but ${k}: ${flatV} does not have matching length nor length 1`));
return;
}
singleParams[k] = flatV[i] ?? flatV[0];
}
_connectLFO(singleParams);
}
}
let activeSoundSources = new Map();
@ -691,13 +716,16 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
compressorKnee,
compressorAttack,
compressorRelease,
lfo,
lfoNum,
rate,
lfoTarget,
lfoParam,
lfoBipolar,
lfoRate,
lfoDepth,
lfoDCOffset,
lfoShape,
lfoSkew,
lfoCurve,
lfoSynced,
} = value;
delaytime = delaytime ?? cycleToSeconds(delaysync, cps);
@ -753,7 +781,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
sourceNode = source(t, value, hapDuration, cps);
nodes['source'] = [sourceNode];
} else if (getSound(s)) {
debugger;
const { onTrigger } = getSound(s);
const onEnded = () => {
audioNodes.forEach((n) => n?.disconnect());
@ -896,7 +923,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
const amGain = new GainNode(ac, { gain });
const time = cycle / cps;
const lfo = getLfo(ac, t, endWithRelease, {
const lfo = getLfo(ac, {
skew: tremoloskew ?? (tremoloshape != null ? 0.5 : 1),
frequency: tremolo,
depth: tremolodepth,
@ -907,6 +934,8 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
min: 0,
max: 1,
curve: 1.5,
begin: t,
end: endWithRelease,
});
lfo.connect(amGain.gain);
chain.push(amGain);
@ -985,7 +1014,23 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
audioNodes = audioNodes.concat(chain);
// finally, now that `nodes` is populated, set up LFOs
connectLFO(lfoNum, lfoTarget, lfoParam, rate, lfo, lfoBipolar, lfoShape, t, endWithRelease);
if (lfoTarget !== undefined && lfoParam !== undefined) {
connectLFOs(t, {
lfoNum,
lfoTarget,
lfoParam,
frequency: lfoRate,
depth: lfoDepth,
dcoffset: lfoDCOffset ?? 0, // override default value of 0.5
shape: lfoShape,
skew: lfoSkew,
curve: lfoCurve,
persistent: 1,
begin: t,
synced: lfoSynced,
cps: cps,
});
}
};
export const superdoughTrigger = (t, hap, ct, cps) => {

View file

@ -360,7 +360,7 @@ export function registerSynthSounds() {
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
let lfo;
if (pwsweep != 0) {
lfo = getLfo(ac, begin, end, { frequency: pwrate, depth: pwsweep });
lfo = getLfo(ac, { frequency: pwrate, depth: pwsweep, begin, end });
lfo.connect(o.parameters.get('pulsewidth'));
}
let timeoutNode = webAudioTimeout(

View file

@ -108,6 +108,7 @@ class LFOProcessor extends AudioWorkletProcessor {
{ name: 'dcoffset', defaultValue: 0 },
{ name: 'min', defaultValue: 0 },
{ name: 'max', defaultValue: 1 },
{ name: 'persistent', defaultValue: 0, min: 0, max: 1 }, // whether to ignore end
];
}
@ -123,9 +124,11 @@ class LFOProcessor extends AudioWorkletProcessor {
}
}
process(inputs, outputs, parameters) {
process(_inputs, outputs, parameters) {
const begin = parameters['begin'][0];
if (currentTime >= parameters.end[0]) {
const end = parameters['end'][0];
const persistent = parameters['persistent'][0];
if ((persistent < 0.5) && currentTime >= end) {
return false;
}
if (currentTime <= begin) {
@ -143,8 +146,9 @@ 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 min = dcoffset * depth;
const max = dcoffset * depth + depth;
const shape = waveShapeNames[parameters['shape'][0]];
const blockSize = output[0].length ?? 0;
@ -161,7 +165,7 @@ class LFOProcessor extends AudioWorkletProcessor {
}
this.incrementPhase(dt);
}
return true;
}
}