From c5bd2a7487a8da8ee61bbaedfd0d278e29eba75e Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Sun, 21 Sep 2025 23:54:04 -0400 Subject: [PATCH 01/12] working --- packages/core/controls.mjs | 12 ++ packages/superdough/feedbackdelay.mjs | 3 + packages/superdough/helpers.mjs | 7 + packages/superdough/superdough.mjs | 199 +++-------------------- packages/superdough/superdoughoutput.mjs | 192 ++++++++++++++++++++++ 5 files changed, 238 insertions(+), 175 deletions(-) create mode 100644 packages/superdough/superdoughoutput.mjs diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 044cfa96..f961e4fa 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -402,6 +402,18 @@ export const { bandq, bpq } = registerControl('bandq', 'bpq'); * */ export const { begin } = registerControl('begin'); +/** + * A pattern of numbers from 0 to 1. Skips the beginning of each sample, e.g. `0.25` to cut off the first quarter from each sample. + * + * @memberof Pattern + * @name bufferHold + * @param {number | Pattern} amount between 0 and 1, where 1 is the length of the sample + * @example + * samples({ rave: 'rave/AREUREADY.wav' }, 'github:tidalcycles/dirt-samples') + * s("rave").begin("<0 .25 .5 .75>").fast(2) + * + */ +export const { bufferHold } = registerControl('bufferHold'); /** * The same as .begin, but cuts off the end off each sample. * diff --git a/packages/superdough/feedbackdelay.mjs b/packages/superdough/feedbackdelay.mjs index c182d655..13beeb1c 100644 --- a/packages/superdough/feedbackdelay.mjs +++ b/packages/superdough/feedbackdelay.mjs @@ -23,6 +23,9 @@ if (typeof DelayNode !== 'undefined') { start(t) { this.delayGain.gain.setValueAtTime(this.delayGain.gain.value, t + this.delayTime.value); } + stop(t) { + this.delayGain.gain.setValueAtTime(0, t); + } } AudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) { diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 69e7e856..5b2fa1a4 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -10,6 +10,13 @@ export function gainNode(value) { return node; } +export function effectSend(input, effect, wet) { + const send = gainNode(wet); + input.connect(send); + send.connect(effect); + return send; +} + const getSlope = (y1, y2, x1, x2) => { const denom = x2 - x1; if (denom === 0) { diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index f1f308a7..0d1845b9 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -7,12 +7,13 @@ This program is free software: you can redistribute it and/or modify it under th import './feedbackdelay.mjs'; import './reverb.mjs'; import './vowel.mjs'; -import { clamp, nanFallback, _mod, cycleToSeconds, secondsToCycle } from './util.mjs'; +import { nanFallback, _mod, cycleToSeconds } from './util.mjs'; import workletsUrl from './worklets.mjs?audioworklet'; -import { createFilter, gainNode, getCompressor, getWorklet, webAudioTimeout } from './helpers.mjs'; +import { createFilter, gainNode, getCompressor, getWorklet, effectSend } from './helpers.mjs'; import { map } from 'nanostores'; -import { logger, errorLogger } from './logger.mjs'; +import { logger } from './logger.mjs'; import { loadBuffer } from './sampler.mjs'; +import { SuperdoughAudioController } from './superdoughoutput.mjs'; export const DEFAULT_MAX_POLYPHONY = 128; const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard'; @@ -301,65 +302,16 @@ export async function initAudioOnFirstClick(options) { return audioReady; } -const maxfeedback = 0.98; - -let channelMerger, destinationGain; -//update the output channel configuration to match user's audio device -export function initializeAudioOutput() { - const audioContext = getAudioContext(); - const maxChannelCount = audioContext.destination.maxChannelCount; - audioContext.destination.channelCount = maxChannelCount; - channelMerger = new ChannelMergerNode(audioContext, { numberOfInputs: audioContext.destination.channelCount }); - destinationGain = new GainNode(audioContext); - channelMerger.connect(destinationGain); - destinationGain.connect(audioContext.destination); +let controller; +function getSuperdoughAudioController() { + if (controller == null) { + controller = new SuperdoughAudioController(getAudioContext()); + } + return controller; } - -// input: AudioNode, channels: ?Array -export const connectToDestination = (input, channels = [0, 1]) => { - const ctx = getAudioContext(); - if (channelMerger == null) { - initializeAudioOutput(); - } - //This upmix can be removed if correct channel counts are set throughout the app, - // and then strudel could theoretically support surround sound audio files - const stereoMix = new StereoPannerNode(ctx); - input.connect(stereoMix); - - const splitter = new ChannelSplitterNode(ctx, { - numberOfOutputs: stereoMix.channelCount, - }); - stereoMix.connect(splitter); - channels.forEach((ch, i) => { - splitter.connect(channelMerger, i % stereoMix.channelCount, ch % ctx.destination.channelCount); - }); -}; - -export const panic = () => { - if (destinationGain == null) { - return; - } - destinationGain.gain.linearRampToValueAtTime(0, getAudioContext().currentTime + 0.01); - destinationGain = null; - channelMerger == null; -}; - -function getDelay(orbit, delaytime, delayfeedback, t) { - if (delayfeedback > maxfeedback) { - //logger(`delayfeedback was clamped to ${maxfeedback} to save your ears`); - } - delayfeedback = clamp(delayfeedback, 0, 0.98); - let delayNode = orbits[orbit].delayNode; - if (delayNode === undefined) { - const ac = getAudioContext(); - delayNode = ac.createFeedbackDelay(1, delaytime, delayfeedback); - delayNode.start?.(t); // for some reason, this throws when audion extension is installed.. - connectToOrbit(delayNode, orbit); - orbits[orbit].delayNode = delayNode; - } - delayNode.delayTime.value !== delaytime && delayNode.delayTime.setValueAtTime(delaytime, t); - delayNode.feedback.value !== delayfeedback && delayNode.feedback.setValueAtTime(delayfeedback, t); - return delayNode; +export function connectToDestination(input, channels) { + const controller = getSuperdoughAudioController(); + controller.output.connectToDestination(input, channels); } export function getLfo(audioContext, begin, end, properties = {}) { @@ -415,97 +367,6 @@ function getFilterType(ftype) { return typeof ftype === 'number' ? filterTypes[Math.floor(_mod(ftype, filterTypes.length))] : ftype; } -// type orbit { -// output: GainNode, -// reverbNode: ConvolverNode -// delayNode: FeedbackDelayNode -// } -let orbits = {}; -function connectToOrbit(node, orbit) { - if (orbits[orbit] == null) { - errorLogger(new Error('target orbit does not exist'), 'superdough'); - } - node.connect(orbits[orbit].output); -} - -function setOrbit(audioContext, orbit, channels) { - if (orbits[orbit] == null) { - orbits[orbit] = { - // Setup output node through which all audio filters prior to hitting - // the destination (and thus allows for global volume automation) - output: new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }), - }; - connectToDestination(orbits[orbit].output, channels); - } -} - -function duckOrbit(audioContext, targetOrbit, t, onsettime = 0, attacktime = 0.1, duckdepth = 1) { - const targetArr = [targetOrbit].flat(); - const onsetArr = [onsettime].flat(); - const attackArr = [attacktime].flat(); - const depthArr = [duckdepth].flat(); - - targetArr.forEach((target, idx) => { - if (orbits[target] == null) { - errorLogger(new Error(`duck target orbit ${target} does not exist`), 'superdough'); - return; - } - const onset = onsetArr[idx] ?? onsetArr[0]; - const attack = Math.max(attackArr[idx] ?? attackArr[0], 0.002); - const depth = depthArr[idx] ?? depthArr[0]; - const gainParam = orbits[target].output.gain; - webAudioTimeout( - audioContext, - () => { - const now = audioContext.currentTime; - - // cancelScheduledValues and setValueAtTime together emulate cancelAndHoldAtTime - // on browsers which lack that method - const currVal = gainParam.value; - gainParam.cancelScheduledValues(now); - gainParam.setValueAtTime(currVal, now); - - const t0 = Math.max(t, now); // guard against now > t - const duckedVal = clamp(1 - Math.sqrt(depth), 0.01, currVal); - gainParam.exponentialRampToValueAtTime(duckedVal, t0 + onset); - gainParam.exponentialRampToValueAtTime(1, t0 + onset + attack); - }, - 0, - t - 0.01, - ); - }); -} - -let hasChanged = (now, before) => now !== undefined && now !== before; -function getReverb(orbit, duration, fade, lp, dim, ir, irspeed, irbegin) { - // If no reverb has been created for a given orbit, create one - let reverbNode = orbits[orbit].reverbNode; - if (reverbNode === undefined) { - const ac = getAudioContext(); - reverbNode = ac.createReverb(duration, fade, lp, dim, ir, irspeed, irbegin); - connectToOrbit(reverbNode, orbit); - orbits[orbit].reverbNode = reverbNode; - } - - if ( - hasChanged(duration, reverbNode.duration) || - hasChanged(fade, reverbNode.fade) || - hasChanged(lp, reverbNode.lp) || - hasChanged(dim, reverbNode.dim) || - hasChanged(irspeed, reverbNode.irspeed) || - hasChanged(irbegin, reverbNode.irbegin) || - reverbNode.ir !== ir - ) { - // only regenerate when something has changed - // avoids endless regeneration on things like - // stack(s("a"), s("b").rsize(8)).room(.5) - // this only works when args may stay undefined until here - // setting default values breaks this - reverbNode.generate(duration, fade, lp, dim, ir, irspeed, irbegin); - } - return reverbNode; -} - export let analysers = {}, analysersData = {}; @@ -538,15 +399,8 @@ export function getAnalyzerData(type = 'time', id = 1) { return analysersData[id]; } -function effectSend(input, effect, wet) { - const send = gainNode(wet); - input.connect(send); - send.connect(effect); - return send; -} - export function resetGlobalEffects() { - orbits = {}; + controller.reset(); analysers = {}; analysersData = {}; } @@ -561,6 +415,7 @@ function mapChannelNumbers(channels) { export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) => { // new: t is always expected to be the absolute target onset time const ac = getAudioContext(); + const audioController = getSuperdoughAudioController(); let { stretch } = value; if (stretch != null) { @@ -679,10 +534,9 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) ); const channels = value.channels != null ? mapChannelNumbers(value.channels) : orbitChannels; - setOrbit(ac, orbit, channels, t, cycle, cps); - + const orbitBus = audioController.getOrbit(orbit, channels); if (duckorbit != null) { - duckOrbit(ac, duckorbit, t, duckonset, duckattack, duckdepth); + orbitBus.duck(duckorbit, t, duckonset, duckattack, duckdepth); } gain = applyGainCurve(nanFallback(gain, 1)); @@ -872,14 +726,11 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) chain.push(post); // delay - let delaySend; if (delay > 0 && delaytime > 0 && delayfeedback > 0) { - const delayNode = getDelay(orbit, delaytime, delayfeedback, t); - delaySend = effectSend(post, delayNode, delay); - audioNodes.push(delaySend); + orbitBus.getDelay(delaytime, delayfeedback, t); + orbitBus.sendDelay(post, delay); } // reverb - let reverbSend; if (room > 0) { let roomIR; if (ir !== undefined) { @@ -892,25 +743,23 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) } roomIR = await loadBuffer(url, ac, ir, 0); } - const reverbNode = getReverb(orbit, roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin); - reverbSend = effectSend(post, reverbNode, room); - audioNodes.push(reverbSend); + orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin); + orbitBus.sendReverb(post, room); } // analyser - let analyserSend; if (analyze) { const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5)); - analyserSend = effectSend(post, analyserNode, 1); + const analyserSend = effectSend(post, analyserNode, 1); audioNodes.push(analyserSend); } if (dry != null) { dry = applyGainCurve(dry); const dryGain = new GainNode(ac, { gain: dry }); chain.push(dryGain); - connectToOrbit(dryGain, orbit); + orbitBus.connectToOutput(dryGain); } else { - connectToOrbit(post, orbit); + orbitBus.connectToOutput(post); } // connect chain elements together diff --git a/packages/superdough/superdoughoutput.mjs b/packages/superdough/superdoughoutput.mjs new file mode 100644 index 00000000..cb391ead --- /dev/null +++ b/packages/superdough/superdoughoutput.mjs @@ -0,0 +1,192 @@ +import { effectSend, webAudioTimeout } from './helpers.mjs'; +import { errorLogger } from './logger.mjs'; + +let hasChanged = (now, before) => now !== undefined && now !== before; + +export class Orbit { + reverbNode; + delayNode; + output; + summingNode; + audioContext; + constructor(audioContext) { + this.audioContext = audioContext; + this.output = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); + this.summingNode = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); + this.summingNode.connect(this.output); + } + + disconnect() { + this.output.disconnect(); + this.summingNode.disconnect(); + this.delayNode?.disconnect(); + this.reverbNode?.disconnect(); + } + + getDelay(delaytime = 0, feedback = 0.5, t) { + const maxfeedback = 0.98; + if (feedback > maxfeedback) { + //logger(`feedback was clamped to ${maxfeedback} to save your ears`); + } + feedback = clamp(feedback, 0, 0.98); + if (this.delayNode == null) { + this.delayNode = this.audioContext.createFeedbackDelay(1, delaytime, feedback); + this.delayNode.connect(this.summingNode); + this.delayNode.start?.(t); // for some reason, this throws when audion extension is installed.. + } + this.delayNode.delayTime.value !== delaytime && this.delayNode.delayTime.setValueAtTime(delaytime, t); + this.delayNode.feedback.value !== feedback && this.delayNode.feedback.setValueAtTime(feedback, t); + return this.delayNode; + } + + getReverb(duration, fade, lp, dim, ir, irspeed, irbegin) { + // If no reverb has been created for a given orbit, create one + if (this.reverbNode == null) { + this.reverbNode = this.audioContext.createReverb(duration, fade, lp, dim, ir, irspeed, irbegin); + this.reverbNode.connect(this.summingNode); + } + + if ( + hasChanged(duration, this.reverbNode.duration) || + hasChanged(fade, this.reverbNode.fade) || + hasChanged(lp, this.reverbNode.lp) || + hasChanged(dim, this.reverbNode.dim) || + hasChanged(irspeed, this.reverbNode.irspeed) || + hasChanged(irbegin, this.reverbNode.irbegin) || + this.reverbNode.ir !== ir + ) { + // only regenerate when something has changed + // avoids endless regeneration on things like + // stack(s("a"), s("b").rsize(8)).room(.5) + // this only works when args may stay undefined until here + // setting default values breaks this + this.reverbNode.generate(duration, fade, lp, dim, ir, irspeed, irbegin); + } + return this.reverbNode; + } + sendReverb(node, amount) { + effectSend(node, this.reverbNode, amount); + } + + sendDelay(node, amount) { + effectSend(node, this.delayNode, amount); + } + + duck(t, onsettime = 0, attacktime = 0.1, depth = 1) { + const onset = onsettime; + const attack = Math.max(attacktime, 0.002); + const gainParam = this.output.gain; + webAudioTimeout( + this.audioContext, + () => { + const now = this.audioContext.currentTime; + + // cancelScheduledValues and setValueAtTime together emulate cancelAndHoldAtTime + // on browsers which lack that method + const currVal = gainParam.value; + gainParam.cancelScheduledValues(now); + gainParam.setValueAtTime(currVal, now); + + const t0 = Math.max(t, now); // guard against now > t + const duckedVal = clamp(1 - Math.sqrt(depth), 0.01, currVal); + gainParam.exponentialRampToValueAtTime(duckedVal, t0 + onset); + gainParam.exponentialRampToValueAtTime(1, t0 + onset + attack); + }, + 0, + t - 0.01, + ); + } + + connectToOutput(node) { + node.connect(this.summingNode); + } +} + +export class SuperdoughOutput { + channelMerger; + destinationGain; + + constructor(audioContext) { + this.audioContext = audioContext; + this.initializeAudio(); + } + + initializeAudio() { + const audioContext = this.audioContext; + const maxChannelCount = audioContext.destination.maxChannelCount; + this.audioContext.destination.channelCount = maxChannelCount; + this.channelMerger = new ChannelMergerNode(audioContext, { numberOfInputs: audioContext.destination.channelCount }); + this.destinationGain = new GainNode(audioContext); + this.channelMerger.connect(this.destinationGain); + this.destinationGain.connect(audioContext.destination); + } + + reset() { + this.channelMerger.disconnect(); + this.destinationGain.disconnect(); + this.destinationGain = null; + this.channelMerger = null; + this.nodes = {}; + this.initializeAudio(); + } + connectToDestination = (input, channels = [0, 1]) => { + //This upmix can be removed if correct channel counts are set throughout the app, + // and then strudel could theoretically support surround sound audio files + const stereoMix = new StereoPannerNode(this.audioContext); + input.connect(stereoMix); + + const splitter = new ChannelSplitterNode(this.audioContext, { + numberOfOutputs: stereoMix.channelCount, + }); + stereoMix.connect(splitter); + channels.forEach((ch, i) => { + splitter.connect(this.channelMerger, i % stereoMix.channelCount, ch % this.audioContext.destination.channelCount); + }); + }; +} + +export class SuperdoughAudioController { + audioContext; + output; + nodes = {}; + + constructor(audioContext) { + this.audioContext = audioContext; + this.output = new SuperdoughOutput(audioContext); + } + + reset() { + Array.from(this.nodes).forEach((node) => { + node.disconnect(); + }); + this.output.reset(); + } + + duck(targetOrbits, t, onsettime = 0, attacktime = 0.1, depth = 1) { + const targetArr = [targetOrbits].flat(); + const onsetArr = [onsettime].flat(); + const attackArr = [attacktime].flat(); + const depthArr = [depth].flat(); + + targetArr.forEach((target, idx) => { + const orbit = this.nodes[target]; + + if (orbit == null) { + errorLogger(new Error(`duck target orbit ${target} does not exist`), 'superdough'); + return; + } + const onset = onsetArr[idx] ?? onsetArr[0]; + const attack = Math.max(attackArr[idx] ?? attackArr[0], 0.002); + const depth = depthArr[idx] ?? depthArr[0]; + orbit.duck({ t, onsettime: onset, attacktime: attack, depth }); + }); + } + + getOrbit(orbitNum, channels) { + if (this.nodes[orbitNum] == null) { + this.nodes[orbitNum] = new Orbit(this.audioContext); + this.output.connectToDestination(this.nodes[orbitNum].output, channels); + } + return this.nodes[orbitNum]; + } +} From beafd9c7001c5976ae936fc669f3f522b547c435 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Mon, 22 Sep 2025 00:07:06 -0400 Subject: [PATCH 02/12] format --- packages/superdough/superdough.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 0d1845b9..922fbbe4 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -400,7 +400,7 @@ export function getAnalyzerData(type = 'time', id = 1) { } export function resetGlobalEffects() { - controller.reset(); + controller?.reset(); analysers = {}; analysersData = {}; } From 82893ffc226bb47df9269d270845f206391109ba Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Mon, 22 Sep 2025 00:11:02 -0400 Subject: [PATCH 03/12] fix import --- packages/superdough/superdoughoutput.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/superdough/superdoughoutput.mjs b/packages/superdough/superdoughoutput.mjs index cb391ead..df1a29ab 100644 --- a/packages/superdough/superdoughoutput.mjs +++ b/packages/superdough/superdoughoutput.mjs @@ -1,5 +1,6 @@ import { effectSend, webAudioTimeout } from './helpers.mjs'; import { errorLogger } from './logger.mjs'; +import {clamp} from './util.mjs' let hasChanged = (now, before) => now !== undefined && now !== before; From da926805b37dc28a2cce4185b890f77350b7e2ac Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Mon, 22 Sep 2025 00:12:48 -0400 Subject: [PATCH 04/12] rm dead code --- packages/core/controls.mjs | 12 ------------ packages/superdough/superdough.mjs | 1 - packages/superdough/superdoughoutput.mjs | 2 +- 3 files changed, 1 insertion(+), 14 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index f961e4fa..044cfa96 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -402,18 +402,6 @@ export const { bandq, bpq } = registerControl('bandq', 'bpq'); * */ export const { begin } = registerControl('begin'); -/** - * A pattern of numbers from 0 to 1. Skips the beginning of each sample, e.g. `0.25` to cut off the first quarter from each sample. - * - * @memberof Pattern - * @name bufferHold - * @param {number | Pattern} amount between 0 and 1, where 1 is the length of the sample - * @example - * samples({ rave: 'rave/AREUREADY.wav' }, 'github:tidalcycles/dirt-samples') - * s("rave").begin("<0 .25 .5 .75>").fast(2) - * - */ -export const { bufferHold } = registerControl('bufferHold'); /** * The same as .begin, but cuts off the end off each sample. * diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 922fbbe4..72c167ad 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -727,7 +727,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) // delay if (delay > 0 && delaytime > 0 && delayfeedback > 0) { - orbitBus.getDelay(delaytime, delayfeedback, t); orbitBus.sendDelay(post, delay); } // reverb diff --git a/packages/superdough/superdoughoutput.mjs b/packages/superdough/superdoughoutput.mjs index df1a29ab..29113e46 100644 --- a/packages/superdough/superdoughoutput.mjs +++ b/packages/superdough/superdoughoutput.mjs @@ -1,6 +1,6 @@ import { effectSend, webAudioTimeout } from './helpers.mjs'; import { errorLogger } from './logger.mjs'; -import {clamp} from './util.mjs' +import { clamp } from './util.mjs'; let hasChanged = (now, before) => now !== undefined && now !== before; From fdcdc3aaa0e82ff1c6c69a4604cf7091fc463a10 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Mon, 22 Sep 2025 00:13:59 -0400 Subject: [PATCH 05/12] rm deadcode --- packages/superdough/feedbackdelay.mjs | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/superdough/feedbackdelay.mjs b/packages/superdough/feedbackdelay.mjs index 13beeb1c..c182d655 100644 --- a/packages/superdough/feedbackdelay.mjs +++ b/packages/superdough/feedbackdelay.mjs @@ -23,9 +23,6 @@ if (typeof DelayNode !== 'undefined') { start(t) { this.delayGain.gain.setValueAtTime(this.delayGain.gain.value, t + this.delayTime.value); } - stop(t) { - this.delayGain.gain.setValueAtTime(0, t); - } } AudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) { From 1eb9dc73fa6259c29aafbfb4d39972a8fbf72c97 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Mon, 22 Sep 2025 00:34:57 -0400 Subject: [PATCH 06/12] fix duck --- packages/superdough/superdough.mjs | 3 ++- packages/superdough/superdoughoutput.mjs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 72c167ad..f22e82d7 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -536,7 +536,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) const channels = value.channels != null ? mapChannelNumbers(value.channels) : orbitChannels; const orbitBus = audioController.getOrbit(orbit, channels); if (duckorbit != null) { - orbitBus.duck(duckorbit, t, duckonset, duckattack, duckdepth); + audioController.duck(duckorbit, t, duckonset, duckattack, duckdepth); } gain = applyGainCurve(nanFallback(gain, 1)); @@ -727,6 +727,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) // delay if (delay > 0 && delaytime > 0 && delayfeedback > 0) { + orbitBus.getDelay(delaytime, delayfeedback, t); orbitBus.sendDelay(post, delay); } // reverb diff --git a/packages/superdough/superdoughoutput.mjs b/packages/superdough/superdoughoutput.mjs index 29113e46..57c2e856 100644 --- a/packages/superdough/superdoughoutput.mjs +++ b/packages/superdough/superdoughoutput.mjs @@ -179,7 +179,8 @@ export class SuperdoughAudioController { const onset = onsetArr[idx] ?? onsetArr[0]; const attack = Math.max(attackArr[idx] ?? attackArr[0], 0.002); const depth = depthArr[idx] ?? depthArr[0]; - orbit.duck({ t, onsettime: onset, attacktime: attack, depth }); + + orbit.duck(t, onset, attack, depth); }); } From 0633954e2498cacb28fb69a6770d0854f589a0a8 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Wed, 24 Sep 2025 00:23:05 -0700 Subject: [PATCH 07/12] working --- packages/superdough/superdough.mjs | 5 + packages/superdough/superdoughoutput.mjs | 346 ++++++++++++----------- packages/superdough/worklets.mjs | 75 +++++ 3 files changed, 259 insertions(+), 167 deletions(-) diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index f22e82d7..1d3028e8 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -458,6 +458,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) duckonset, duckattack, duckdepth, + djf, // filters fanchor = getDefaultValue('fanchor'), drive = 0.69, @@ -747,6 +748,10 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) orbitBus.sendReverb(post, room); } + if (djf != null) { + orbitBus.getDjf(djf, t) + } + // analyser if (analyze) { const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5)); diff --git a/packages/superdough/superdoughoutput.mjs b/packages/superdough/superdoughoutput.mjs index 57c2e856..38760d04 100644 --- a/packages/superdough/superdoughoutput.mjs +++ b/packages/superdough/superdoughoutput.mjs @@ -1,194 +1,206 @@ -import { effectSend, webAudioTimeout } from './helpers.mjs'; +import { effectSend, getWorklet, webAudioTimeout } from './helpers.mjs'; import { errorLogger } from './logger.mjs'; import { clamp } from './util.mjs'; let hasChanged = (now, before) => now !== undefined && now !== before; export class Orbit { - reverbNode; - delayNode; - output; - summingNode; - audioContext; - constructor(audioContext) { - this.audioContext = audioContext; - this.output = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); - this.summingNode = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); - this.summingNode.connect(this.output); - } - - disconnect() { - this.output.disconnect(); - this.summingNode.disconnect(); - this.delayNode?.disconnect(); - this.reverbNode?.disconnect(); - } - - getDelay(delaytime = 0, feedback = 0.5, t) { - const maxfeedback = 0.98; - if (feedback > maxfeedback) { - //logger(`feedback was clamped to ${maxfeedback} to save your ears`); - } - feedback = clamp(feedback, 0, 0.98); - if (this.delayNode == null) { - this.delayNode = this.audioContext.createFeedbackDelay(1, delaytime, feedback); - this.delayNode.connect(this.summingNode); - this.delayNode.start?.(t); // for some reason, this throws when audion extension is installed.. - } - this.delayNode.delayTime.value !== delaytime && this.delayNode.delayTime.setValueAtTime(delaytime, t); - this.delayNode.feedback.value !== feedback && this.delayNode.feedback.setValueAtTime(feedback, t); - return this.delayNode; - } - - getReverb(duration, fade, lp, dim, ir, irspeed, irbegin) { - // If no reverb has been created for a given orbit, create one - if (this.reverbNode == null) { - this.reverbNode = this.audioContext.createReverb(duration, fade, lp, dim, ir, irspeed, irbegin); - this.reverbNode.connect(this.summingNode); + reverbNode; + delayNode; + output; + summingNode; + djfNode; + audioContext; + constructor(audioContext) { + this.audioContext = audioContext; + this.output = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); + this.summingNode = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); + this.summingNode.connect(this.output); } - if ( - hasChanged(duration, this.reverbNode.duration) || - hasChanged(fade, this.reverbNode.fade) || - hasChanged(lp, this.reverbNode.lp) || - hasChanged(dim, this.reverbNode.dim) || - hasChanged(irspeed, this.reverbNode.irspeed) || - hasChanged(irbegin, this.reverbNode.irbegin) || - this.reverbNode.ir !== ir - ) { - // only regenerate when something has changed - // avoids endless regeneration on things like - // stack(s("a"), s("b").rsize(8)).room(.5) - // this only works when args may stay undefined until here - // setting default values breaks this - this.reverbNode.generate(duration, fade, lp, dim, ir, irspeed, irbegin); + disconnect() { + this.output.disconnect(); + this.summingNode.disconnect(); + this.delayNode?.disconnect(); + this.reverbNode?.disconnect(); } - return this.reverbNode; - } - sendReverb(node, amount) { - effectSend(node, this.reverbNode, amount); - } - sendDelay(node, amount) { - effectSend(node, this.delayNode, amount); - } + getDjf(value, t = 0) { + if (this.djfNode == null){ + this.djfNode = getWorklet(this.audioContext, 'djf-processor', {value}) + this.summingNode.disconnect() + this.summingNode.connect(this.djfNode) + this.djfNode.connect(this.output) + } + const val = this.djfNode.parameters.get('value') + val.setValueAtTime(value, t) + } + + getDelay(delaytime = 0, feedback = 0.5, t) { + const maxfeedback = 0.98; + if (feedback > maxfeedback) { + //logger(`feedback was clamped to ${maxfeedback} to save your ears`); + } + feedback = clamp(feedback, 0, 0.98); + if (this.delayNode == null) { + this.delayNode = this.audioContext.createFeedbackDelay(1, delaytime, feedback); + this.delayNode.connect(this.summingNode); + this.delayNode.start?.(t); // for some reason, this throws when audion extension is installed.. + } + this.delayNode.delayTime.value !== delaytime && this.delayNode.delayTime.setValueAtTime(delaytime, t); + this.delayNode.feedback.value !== feedback && this.delayNode.feedback.setValueAtTime(feedback, t); + return this.delayNode; + } - duck(t, onsettime = 0, attacktime = 0.1, depth = 1) { - const onset = onsettime; - const attack = Math.max(attacktime, 0.002); - const gainParam = this.output.gain; - webAudioTimeout( - this.audioContext, - () => { - const now = this.audioContext.currentTime; + getReverb(duration, fade, lp, dim, ir, irspeed, irbegin) { + // If no reverb has been created for a given orbit, create one + if (this.reverbNode == null) { + this.reverbNode = this.audioContext.createReverb(duration, fade, lp, dim, ir, irspeed, irbegin); + this.reverbNode.connect(this.summingNode); + } - // cancelScheduledValues and setValueAtTime together emulate cancelAndHoldAtTime - // on browsers which lack that method - const currVal = gainParam.value; - gainParam.cancelScheduledValues(now); - gainParam.setValueAtTime(currVal, now); + if ( + hasChanged(duration, this.reverbNode.duration) || + hasChanged(fade, this.reverbNode.fade) || + hasChanged(lp, this.reverbNode.lp) || + hasChanged(dim, this.reverbNode.dim) || + hasChanged(irspeed, this.reverbNode.irspeed) || + hasChanged(irbegin, this.reverbNode.irbegin) || + this.reverbNode.ir !== ir + ) { + // only regenerate when something has changed + // avoids endless regeneration on things like + // stack(s("a"), s("b").rsize(8)).room(.5) + // this only works when args may stay undefined until here + // setting default values breaks this + this.reverbNode.generate(duration, fade, lp, dim, ir, irspeed, irbegin); + } + return this.reverbNode; + } + sendReverb(node, amount) { + effectSend(node, this.reverbNode, amount); + } - const t0 = Math.max(t, now); // guard against now > t - const duckedVal = clamp(1 - Math.sqrt(depth), 0.01, currVal); - gainParam.exponentialRampToValueAtTime(duckedVal, t0 + onset); - gainParam.exponentialRampToValueAtTime(1, t0 + onset + attack); - }, - 0, - t - 0.01, - ); - } + sendDelay(node, amount) { + effectSend(node, this.delayNode, amount); + } - connectToOutput(node) { - node.connect(this.summingNode); - } + duck(t, onsettime = 0, attacktime = 0.1, depth = 1) { + const onset = onsettime; + const attack = Math.max(attacktime, 0.002); + const gainParam = this.output.gain; + webAudioTimeout( + this.audioContext, + () => { + const now = this.audioContext.currentTime; + + // cancelScheduledValues and setValueAtTime together emulate cancelAndHoldAtTime + // on browsers which lack that method + const currVal = gainParam.value; + gainParam.cancelScheduledValues(now); + gainParam.setValueAtTime(currVal, now); + + const t0 = Math.max(t, now); // guard against now > t + const duckedVal = clamp(1 - Math.sqrt(depth), 0.01, currVal); + gainParam.exponentialRampToValueAtTime(duckedVal, t0 + onset); + gainParam.exponentialRampToValueAtTime(1, t0 + onset + attack); + }, + 0, + t - 0.01, + ); + } + + connectToOutput(node) { + node.connect(this.summingNode); + } } export class SuperdoughOutput { - channelMerger; - destinationGain; + channelMerger; + destinationGain; - constructor(audioContext) { - this.audioContext = audioContext; - this.initializeAudio(); - } + constructor(audioContext) { + this.audioContext = audioContext; + this.initializeAudio(); + } - initializeAudio() { - const audioContext = this.audioContext; - const maxChannelCount = audioContext.destination.maxChannelCount; - this.audioContext.destination.channelCount = maxChannelCount; - this.channelMerger = new ChannelMergerNode(audioContext, { numberOfInputs: audioContext.destination.channelCount }); - this.destinationGain = new GainNode(audioContext); - this.channelMerger.connect(this.destinationGain); - this.destinationGain.connect(audioContext.destination); - } + initializeAudio() { + const audioContext = this.audioContext; + const maxChannelCount = audioContext.destination.maxChannelCount; + this.audioContext.destination.channelCount = maxChannelCount; + this.channelMerger = new ChannelMergerNode(audioContext, { numberOfInputs: audioContext.destination.channelCount }); + this.destinationGain = new GainNode(audioContext); + this.channelMerger.connect(this.destinationGain); + this.destinationGain.connect(audioContext.destination); + } - reset() { - this.channelMerger.disconnect(); - this.destinationGain.disconnect(); - this.destinationGain = null; - this.channelMerger = null; - this.nodes = {}; - this.initializeAudio(); - } - connectToDestination = (input, channels = [0, 1]) => { - //This upmix can be removed if correct channel counts are set throughout the app, - // and then strudel could theoretically support surround sound audio files - const stereoMix = new StereoPannerNode(this.audioContext); - input.connect(stereoMix); + reset() { + this.channelMerger.disconnect(); + this.destinationGain.disconnect(); + this.destinationGain = null; + this.channelMerger = null; + this.nodes = {}; + this.initializeAudio(); + } + connectToDestination = (input, channels = [0, 1]) => { + //This upmix can be removed if correct channel counts are set throughout the app, + // and then strudel could theoretically support surround sound audio files + const stereoMix = new StereoPannerNode(this.audioContext); + input.connect(stereoMix); - const splitter = new ChannelSplitterNode(this.audioContext, { - numberOfOutputs: stereoMix.channelCount, - }); - stereoMix.connect(splitter); - channels.forEach((ch, i) => { - splitter.connect(this.channelMerger, i % stereoMix.channelCount, ch % this.audioContext.destination.channelCount); - }); - }; + const splitter = new ChannelSplitterNode(this.audioContext, { + numberOfOutputs: stereoMix.channelCount, + }); + stereoMix.connect(splitter); + channels.forEach((ch, i) => { + splitter.connect(this.channelMerger, i % stereoMix.channelCount, ch % this.audioContext.destination.channelCount); + }); + }; } export class SuperdoughAudioController { - audioContext; - output; - nodes = {}; + audioContext; + output; + nodes = {}; - constructor(audioContext) { - this.audioContext = audioContext; - this.output = new SuperdoughOutput(audioContext); - } - - reset() { - Array.from(this.nodes).forEach((node) => { - node.disconnect(); - }); - this.output.reset(); - } - - duck(targetOrbits, t, onsettime = 0, attacktime = 0.1, depth = 1) { - const targetArr = [targetOrbits].flat(); - const onsetArr = [onsettime].flat(); - const attackArr = [attacktime].flat(); - const depthArr = [depth].flat(); - - targetArr.forEach((target, idx) => { - const orbit = this.nodes[target]; - - if (orbit == null) { - errorLogger(new Error(`duck target orbit ${target} does not exist`), 'superdough'); - return; - } - const onset = onsetArr[idx] ?? onsetArr[0]; - const attack = Math.max(attackArr[idx] ?? attackArr[0], 0.002); - const depth = depthArr[idx] ?? depthArr[0]; - - orbit.duck(t, onset, attack, depth); - }); - } - - getOrbit(orbitNum, channels) { - if (this.nodes[orbitNum] == null) { - this.nodes[orbitNum] = new Orbit(this.audioContext); - this.output.connectToDestination(this.nodes[orbitNum].output, channels); + constructor(audioContext) { + this.audioContext = audioContext; + this.output = new SuperdoughOutput(audioContext); + } + + reset() { + Array.from(this.nodes).forEach((node) => { + node.disconnect(); + }); + this.output.reset(); + } + + duck(targetOrbits, t, onsettime = 0, attacktime = 0.1, depth = 1) { + const targetArr = [targetOrbits].flat(); + const onsetArr = [onsettime].flat(); + const attackArr = [attacktime].flat(); + const depthArr = [depth].flat(); + + targetArr.forEach((target, idx) => { + const orbit = this.nodes[target]; + + if (orbit == null) { + errorLogger(new Error(`duck target orbit ${target} does not exist`), 'superdough'); + return; + } + const onset = onsetArr[idx] ?? onsetArr[0]; + const attack = Math.max(attackArr[idx] ?? attackArr[0], 0.002); + const depth = depthArr[idx] ?? depthArr[0]; + + orbit.duck(t, onset, attack, depth); + }); + } + + getOrbit(orbitNum, channels) { + if (this.nodes[orbitNum] == null) { + this.nodes[orbitNum] = new Orbit(this.audioContext); + this.output.connectToDestination(this.nodes[orbitNum].output, channels); + } + return this.nodes[orbitNum]; } - return this.nodes[orbitNum]; - } } diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 2406d56d..06e81f38 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -271,6 +271,81 @@ class ShapeProcessor extends AudioWorkletProcessor { } registerProcessor('shape-processor', ShapeProcessor); +class TwoPoleFilter { + s0 = 0; + s1 = 0; + update(s, cutoff, resonance = 0) { + // Out of bound values can produce NaNs + resonance = clamp(resonance, 0, 1) + cutoff = clamp(cutoff, 0, sampleRate / 2 - 1) + const c = clamp(2 * Math.sin(cutoff * (_PI / sampleRate)), 0, 1.14); + const r = Math.pow(0.5, (resonance + 0.125) / 0.125); + const mrc = 1 - r * c; + this.s0 = mrc * this.s0 - c * this.s1 + c * s; // bpf + this.s1 = mrc * this.s1 + c * this.s0; // lpf + return this.s1; // return lpf by default + } +} + +class DJFProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return [ + { name: 'value', defaultValue: 0.5 }, + ]; + } + + constructor() { + super(); + this.filters = [new TwoPoleFilter(), new TwoPoleFilter()] + } + + process(inputs, outputs, parameters) { + const input = inputs[0]; + const output = outputs[0]; + + const hasInput = !(input[0] === undefined); + this.started = hasInput; + + const value = clamp(parameters.value[0], 0, 1); + let filterType = 'none' + let cutoff + let v = 1; + if (value > 0.5) { + filterType = 'hipass' + v = (value - .5) * 2 + } else if (value < 0.5) { + filterType = 'lopass' + v = value * 2 + } + cutoff = Math.pow((v * 11), 4) + + // let cutoff = parameters.frequency[0]; + + for (let i = 0; i < input.length; i++) { + for (let n = 0; n < blockSize; n++) { + if (filterType == 'none') { + output[i][n] = input[i][n] + } else { + this.filters[i].update(input[i][n], cutoff, 0.2) + if (filterType === 'lopass') { + output[i][n] = this.filters[i].s1 + } else if (filterType === 'hipass') { + output[i][n] = input[i][n] - this.filters[i].s1 + } else { + output[i][n] = input[i][n] + } + } + + + + + } + } + return true; + } +} +registerProcessor('djf-processor', DJFProcessor); + function fast_tanh(x) { const x2 = x * x; return (x * (27.0 + x2)) / (27.0 + 9.0 * x2); From 28c94efaa9d036fd361e3cc8708e1465e39a1432 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Wed, 24 Sep 2025 00:24:25 -0700 Subject: [PATCH 08/12] format --- packages/superdough/superdough.mjs | 2 +- packages/superdough/superdoughoutput.mjs | 356 +++++++++++------------ packages/superdough/worklets.mjs | 38 +-- 3 files changed, 195 insertions(+), 201 deletions(-) diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 1d3028e8..63ed7d30 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -749,7 +749,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) } if (djf != null) { - orbitBus.getDjf(djf, t) + orbitBus.getDjf(djf, t); } // analyser diff --git a/packages/superdough/superdoughoutput.mjs b/packages/superdough/superdoughoutput.mjs index 38760d04..9ef7bcbf 100644 --- a/packages/superdough/superdoughoutput.mjs +++ b/packages/superdough/superdoughoutput.mjs @@ -5,202 +5,202 @@ import { clamp } from './util.mjs'; let hasChanged = (now, before) => now !== undefined && now !== before; export class Orbit { - reverbNode; - delayNode; - output; - summingNode; - djfNode; - audioContext; - constructor(audioContext) { - this.audioContext = audioContext; - this.output = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); - this.summingNode = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); - this.summingNode.connect(this.output); + reverbNode; + delayNode; + output; + summingNode; + djfNode; + audioContext; + constructor(audioContext) { + this.audioContext = audioContext; + this.output = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); + this.summingNode = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); + this.summingNode.connect(this.output); + } + + disconnect() { + this.output.disconnect(); + this.summingNode.disconnect(); + this.delayNode?.disconnect(); + this.reverbNode?.disconnect(); + } + + getDjf(value, t = 0) { + if (this.djfNode == null) { + this.djfNode = getWorklet(this.audioContext, 'djf-processor', { value }); + this.summingNode.disconnect(); + this.summingNode.connect(this.djfNode); + this.djfNode.connect(this.output); + } + const val = this.djfNode.parameters.get('value'); + val.setValueAtTime(value, t); + } + + getDelay(delaytime = 0, feedback = 0.5, t) { + const maxfeedback = 0.98; + if (feedback > maxfeedback) { + //logger(`feedback was clamped to ${maxfeedback} to save your ears`); + } + feedback = clamp(feedback, 0, 0.98); + if (this.delayNode == null) { + this.delayNode = this.audioContext.createFeedbackDelay(1, delaytime, feedback); + this.delayNode.connect(this.summingNode); + this.delayNode.start?.(t); // for some reason, this throws when audion extension is installed.. + } + this.delayNode.delayTime.value !== delaytime && this.delayNode.delayTime.setValueAtTime(delaytime, t); + this.delayNode.feedback.value !== feedback && this.delayNode.feedback.setValueAtTime(feedback, t); + return this.delayNode; + } + + getReverb(duration, fade, lp, dim, ir, irspeed, irbegin) { + // If no reverb has been created for a given orbit, create one + if (this.reverbNode == null) { + this.reverbNode = this.audioContext.createReverb(duration, fade, lp, dim, ir, irspeed, irbegin); + this.reverbNode.connect(this.summingNode); } - disconnect() { - this.output.disconnect(); - this.summingNode.disconnect(); - this.delayNode?.disconnect(); - this.reverbNode?.disconnect(); + if ( + hasChanged(duration, this.reverbNode.duration) || + hasChanged(fade, this.reverbNode.fade) || + hasChanged(lp, this.reverbNode.lp) || + hasChanged(dim, this.reverbNode.dim) || + hasChanged(irspeed, this.reverbNode.irspeed) || + hasChanged(irbegin, this.reverbNode.irbegin) || + this.reverbNode.ir !== ir + ) { + // only regenerate when something has changed + // avoids endless regeneration on things like + // stack(s("a"), s("b").rsize(8)).room(.5) + // this only works when args may stay undefined until here + // setting default values breaks this + this.reverbNode.generate(duration, fade, lp, dim, ir, irspeed, irbegin); } + return this.reverbNode; + } + sendReverb(node, amount) { + effectSend(node, this.reverbNode, amount); + } - getDjf(value, t = 0) { - if (this.djfNode == null){ - this.djfNode = getWorklet(this.audioContext, 'djf-processor', {value}) - this.summingNode.disconnect() - this.summingNode.connect(this.djfNode) - this.djfNode.connect(this.output) - } - const val = this.djfNode.parameters.get('value') - val.setValueAtTime(value, t) - } - - getDelay(delaytime = 0, feedback = 0.5, t) { - const maxfeedback = 0.98; - if (feedback > maxfeedback) { - //logger(`feedback was clamped to ${maxfeedback} to save your ears`); - } - feedback = clamp(feedback, 0, 0.98); - if (this.delayNode == null) { - this.delayNode = this.audioContext.createFeedbackDelay(1, delaytime, feedback); - this.delayNode.connect(this.summingNode); - this.delayNode.start?.(t); // for some reason, this throws when audion extension is installed.. - } - this.delayNode.delayTime.value !== delaytime && this.delayNode.delayTime.setValueAtTime(delaytime, t); - this.delayNode.feedback.value !== feedback && this.delayNode.feedback.setValueAtTime(feedback, t); - return this.delayNode; - } + sendDelay(node, amount) { + effectSend(node, this.delayNode, amount); + } - getReverb(duration, fade, lp, dim, ir, irspeed, irbegin) { - // If no reverb has been created for a given orbit, create one - if (this.reverbNode == null) { - this.reverbNode = this.audioContext.createReverb(duration, fade, lp, dim, ir, irspeed, irbegin); - this.reverbNode.connect(this.summingNode); - } + duck(t, onsettime = 0, attacktime = 0.1, depth = 1) { + const onset = onsettime; + const attack = Math.max(attacktime, 0.002); + const gainParam = this.output.gain; + webAudioTimeout( + this.audioContext, + () => { + const now = this.audioContext.currentTime; - if ( - hasChanged(duration, this.reverbNode.duration) || - hasChanged(fade, this.reverbNode.fade) || - hasChanged(lp, this.reverbNode.lp) || - hasChanged(dim, this.reverbNode.dim) || - hasChanged(irspeed, this.reverbNode.irspeed) || - hasChanged(irbegin, this.reverbNode.irbegin) || - this.reverbNode.ir !== ir - ) { - // only regenerate when something has changed - // avoids endless regeneration on things like - // stack(s("a"), s("b").rsize(8)).room(.5) - // this only works when args may stay undefined until here - // setting default values breaks this - this.reverbNode.generate(duration, fade, lp, dim, ir, irspeed, irbegin); - } - return this.reverbNode; - } - sendReverb(node, amount) { - effectSend(node, this.reverbNode, amount); - } + // cancelScheduledValues and setValueAtTime together emulate cancelAndHoldAtTime + // on browsers which lack that method + const currVal = gainParam.value; + gainParam.cancelScheduledValues(now); + gainParam.setValueAtTime(currVal, now); - sendDelay(node, amount) { - effectSend(node, this.delayNode, amount); - } + const t0 = Math.max(t, now); // guard against now > t + const duckedVal = clamp(1 - Math.sqrt(depth), 0.01, currVal); + gainParam.exponentialRampToValueAtTime(duckedVal, t0 + onset); + gainParam.exponentialRampToValueAtTime(1, t0 + onset + attack); + }, + 0, + t - 0.01, + ); + } - duck(t, onsettime = 0, attacktime = 0.1, depth = 1) { - const onset = onsettime; - const attack = Math.max(attacktime, 0.002); - const gainParam = this.output.gain; - webAudioTimeout( - this.audioContext, - () => { - const now = this.audioContext.currentTime; - - // cancelScheduledValues and setValueAtTime together emulate cancelAndHoldAtTime - // on browsers which lack that method - const currVal = gainParam.value; - gainParam.cancelScheduledValues(now); - gainParam.setValueAtTime(currVal, now); - - const t0 = Math.max(t, now); // guard against now > t - const duckedVal = clamp(1 - Math.sqrt(depth), 0.01, currVal); - gainParam.exponentialRampToValueAtTime(duckedVal, t0 + onset); - gainParam.exponentialRampToValueAtTime(1, t0 + onset + attack); - }, - 0, - t - 0.01, - ); - } - - connectToOutput(node) { - node.connect(this.summingNode); - } + connectToOutput(node) { + node.connect(this.summingNode); + } } export class SuperdoughOutput { - channelMerger; - destinationGain; + channelMerger; + destinationGain; - constructor(audioContext) { - this.audioContext = audioContext; - this.initializeAudio(); - } + constructor(audioContext) { + this.audioContext = audioContext; + this.initializeAudio(); + } - initializeAudio() { - const audioContext = this.audioContext; - const maxChannelCount = audioContext.destination.maxChannelCount; - this.audioContext.destination.channelCount = maxChannelCount; - this.channelMerger = new ChannelMergerNode(audioContext, { numberOfInputs: audioContext.destination.channelCount }); - this.destinationGain = new GainNode(audioContext); - this.channelMerger.connect(this.destinationGain); - this.destinationGain.connect(audioContext.destination); - } + initializeAudio() { + const audioContext = this.audioContext; + const maxChannelCount = audioContext.destination.maxChannelCount; + this.audioContext.destination.channelCount = maxChannelCount; + this.channelMerger = new ChannelMergerNode(audioContext, { numberOfInputs: audioContext.destination.channelCount }); + this.destinationGain = new GainNode(audioContext); + this.channelMerger.connect(this.destinationGain); + this.destinationGain.connect(audioContext.destination); + } - reset() { - this.channelMerger.disconnect(); - this.destinationGain.disconnect(); - this.destinationGain = null; - this.channelMerger = null; - this.nodes = {}; - this.initializeAudio(); - } - connectToDestination = (input, channels = [0, 1]) => { - //This upmix can be removed if correct channel counts are set throughout the app, - // and then strudel could theoretically support surround sound audio files - const stereoMix = new StereoPannerNode(this.audioContext); - input.connect(stereoMix); + reset() { + this.channelMerger.disconnect(); + this.destinationGain.disconnect(); + this.destinationGain = null; + this.channelMerger = null; + this.nodes = {}; + this.initializeAudio(); + } + connectToDestination = (input, channels = [0, 1]) => { + //This upmix can be removed if correct channel counts are set throughout the app, + // and then strudel could theoretically support surround sound audio files + const stereoMix = new StereoPannerNode(this.audioContext); + input.connect(stereoMix); - const splitter = new ChannelSplitterNode(this.audioContext, { - numberOfOutputs: stereoMix.channelCount, - }); - stereoMix.connect(splitter); - channels.forEach((ch, i) => { - splitter.connect(this.channelMerger, i % stereoMix.channelCount, ch % this.audioContext.destination.channelCount); - }); - }; + const splitter = new ChannelSplitterNode(this.audioContext, { + numberOfOutputs: stereoMix.channelCount, + }); + stereoMix.connect(splitter); + channels.forEach((ch, i) => { + splitter.connect(this.channelMerger, i % stereoMix.channelCount, ch % this.audioContext.destination.channelCount); + }); + }; } export class SuperdoughAudioController { - audioContext; - output; - nodes = {}; + audioContext; + output; + nodes = {}; - constructor(audioContext) { - this.audioContext = audioContext; - this.output = new SuperdoughOutput(audioContext); - } - - reset() { - Array.from(this.nodes).forEach((node) => { - node.disconnect(); - }); - this.output.reset(); - } - - duck(targetOrbits, t, onsettime = 0, attacktime = 0.1, depth = 1) { - const targetArr = [targetOrbits].flat(); - const onsetArr = [onsettime].flat(); - const attackArr = [attacktime].flat(); - const depthArr = [depth].flat(); - - targetArr.forEach((target, idx) => { - const orbit = this.nodes[target]; - - if (orbit == null) { - errorLogger(new Error(`duck target orbit ${target} does not exist`), 'superdough'); - return; - } - const onset = onsetArr[idx] ?? onsetArr[0]; - const attack = Math.max(attackArr[idx] ?? attackArr[0], 0.002); - const depth = depthArr[idx] ?? depthArr[0]; - - orbit.duck(t, onset, attack, depth); - }); - } - - getOrbit(orbitNum, channels) { - if (this.nodes[orbitNum] == null) { - this.nodes[orbitNum] = new Orbit(this.audioContext); - this.output.connectToDestination(this.nodes[orbitNum].output, channels); - } - return this.nodes[orbitNum]; + constructor(audioContext) { + this.audioContext = audioContext; + this.output = new SuperdoughOutput(audioContext); + } + + reset() { + Array.from(this.nodes).forEach((node) => { + node.disconnect(); + }); + this.output.reset(); + } + + duck(targetOrbits, t, onsettime = 0, attacktime = 0.1, depth = 1) { + const targetArr = [targetOrbits].flat(); + const onsetArr = [onsettime].flat(); + const attackArr = [attacktime].flat(); + const depthArr = [depth].flat(); + + targetArr.forEach((target, idx) => { + const orbit = this.nodes[target]; + + if (orbit == null) { + errorLogger(new Error(`duck target orbit ${target} does not exist`), 'superdough'); + return; + } + const onset = onsetArr[idx] ?? onsetArr[0]; + const attack = Math.max(attackArr[idx] ?? attackArr[0], 0.002); + const depth = depthArr[idx] ?? depthArr[0]; + + orbit.duck(t, onset, attack, depth); + }); + } + + getOrbit(orbitNum, channels) { + if (this.nodes[orbitNum] == null) { + this.nodes[orbitNum] = new Orbit(this.audioContext); + this.output.connectToDestination(this.nodes[orbitNum].output, channels); } + return this.nodes[orbitNum]; + } } diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 06e81f38..4d25617f 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -276,8 +276,8 @@ class TwoPoleFilter { s1 = 0; update(s, cutoff, resonance = 0) { // Out of bound values can produce NaNs - resonance = clamp(resonance, 0, 1) - cutoff = clamp(cutoff, 0, sampleRate / 2 - 1) + resonance = clamp(resonance, 0, 1); + cutoff = clamp(cutoff, 0, sampleRate / 2 - 1); const c = clamp(2 * Math.sin(cutoff * (_PI / sampleRate)), 0, 1.14); const r = Math.pow(0.5, (resonance + 0.125) / 0.125); const mrc = 1 - r * c; @@ -289,14 +289,12 @@ class TwoPoleFilter { class DJFProcessor extends AudioWorkletProcessor { static get parameterDescriptors() { - return [ - { name: 'value', defaultValue: 0.5 }, - ]; + return [{ name: 'value', defaultValue: 0.5 }]; } constructor() { super(); - this.filters = [new TwoPoleFilter(), new TwoPoleFilter()] + this.filters = [new TwoPoleFilter(), new TwoPoleFilter()]; } process(inputs, outputs, parameters) { @@ -307,38 +305,34 @@ class DJFProcessor extends AudioWorkletProcessor { this.started = hasInput; const value = clamp(parameters.value[0], 0, 1); - let filterType = 'none' - let cutoff + let filterType = 'none'; + let cutoff; let v = 1; if (value > 0.5) { - filterType = 'hipass' - v = (value - .5) * 2 + filterType = 'hipass'; + v = (value - 0.5) * 2; } else if (value < 0.5) { - filterType = 'lopass' - v = value * 2 + filterType = 'lopass'; + v = value * 2; } - cutoff = Math.pow((v * 11), 4) + cutoff = Math.pow(v * 11, 4); // let cutoff = parameters.frequency[0]; for (let i = 0; i < input.length; i++) { for (let n = 0; n < blockSize; n++) { if (filterType == 'none') { - output[i][n] = input[i][n] + output[i][n] = input[i][n]; } else { - this.filters[i].update(input[i][n], cutoff, 0.2) + this.filters[i].update(input[i][n], cutoff, 0.2); if (filterType === 'lopass') { - output[i][n] = this.filters[i].s1 + output[i][n] = this.filters[i].s1; } else if (filterType === 'hipass') { - output[i][n] = input[i][n] - this.filters[i].s1 + output[i][n] = input[i][n] - this.filters[i].s1; } else { - output[i][n] = input[i][n] + output[i][n] = input[i][n]; } } - - - - } } return true; From da81ba00fb056f77c23ceccab29e92ad78892fd6 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Wed, 24 Sep 2025 20:32:45 -0700 Subject: [PATCH 09/12] adjust control range --- packages/superdough/worklets.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 4d25617f..fa0fa534 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -308,10 +308,10 @@ class DJFProcessor extends AudioWorkletProcessor { let filterType = 'none'; let cutoff; let v = 1; - if (value > 0.5) { + if (value > 0.52) { filterType = 'hipass'; v = (value - 0.5) * 2; - } else if (value < 0.5) { + } else if (value < 0.48) { filterType = 'lopass'; v = value * 2; } From c474b8c92e882ca739fa95d7004aaacf8816e435 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Wed, 24 Sep 2025 20:33:51 -0700 Subject: [PATCH 10/12] rm comment --- packages/superdough/worklets.mjs | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index fa0fa534..2a675bba 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -317,8 +317,6 @@ class DJFProcessor extends AudioWorkletProcessor { } cutoff = Math.pow(v * 11, 4); - // let cutoff = parameters.frequency[0]; - for (let i = 0; i < input.length; i++) { for (let n = 0; n < blockSize; n++) { if (filterType == 'none') { From dfc4a0818c551b8978fa537da05557a4edfc4f43 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Thu, 25 Sep 2025 23:39:08 -0700 Subject: [PATCH 11/12] res adjust --- packages/superdough/worklets.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 9592dd7d..af8a4a63 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -322,10 +322,10 @@ class DJFProcessor extends AudioWorkletProcessor { let filterType = 'none'; let cutoff; let v = 1; - if (value > 0.52) { + if (value > 0.51) { filterType = 'hipass'; v = (value - 0.5) * 2; - } else if (value < 0.48) { + } else if (value < 0.49) { filterType = 'lopass'; v = value * 2; } @@ -336,7 +336,7 @@ class DJFProcessor extends AudioWorkletProcessor { if (filterType == 'none') { output[i][n] = input[i][n]; } else { - this.filters[i].update(input[i][n], cutoff, 0.2); + this.filters[i].update(input[i][n], cutoff, 0.1); if (filterType === 'lopass') { output[i][n] = this.filters[i].s1; } else if (filterType === 'hipass') { From 266cb614a87a6fbe7b7d8e32c211adcf6923339c Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Thu, 25 Sep 2025 23:44:32 -0700 Subject: [PATCH 12/12] add better example --- packages/core/controls.mjs | 2 +- test/__snapshots__/examples.test.mjs.snap | 52 ++++++++++++++--------- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 9b551970..2565b73b 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -1171,7 +1171,7 @@ export const { resonance, lpq } = registerControl('resonance', 'lpq'); * @name djf * @param {number | Pattern} cutoff below 0.5 is low pass filter, above is high pass filter * @example - * n("0 3 7 [10,24]").s('superzow').octave(3).djf("<.5 .25 .5 .75>").osc() + * n(irand(16).seg(8)).scale("d:phrygian").s("supersaw").djf("<.5 .3 .2 .75>") * */ export const { djf } = registerControl('djf'); diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index f91503ca..a0597558 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -2831,26 +2831,38 @@ exports[`runs examples > example "distort" example index 1 1`] = ` exports[`runs examples > example "djf" example index 0 1`] = ` [ - "[ 0/1 → 1/4 | n:0 s:superzow octave:3 djf:0.5 ]", - "[ 1/4 → 1/2 | n:3 s:superzow octave:3 djf:0.5 ]", - "[ 1/2 → 3/4 | n:7 s:superzow octave:3 djf:0.5 ]", - "[ 3/4 → 1/1 | n:10 s:superzow octave:3 djf:0.5 ]", - "[ 3/4 → 1/1 | n:24 s:superzow octave:3 djf:0.5 ]", - "[ 1/1 → 5/4 | n:0 s:superzow octave:3 djf:0.25 ]", - "[ 5/4 → 3/2 | n:3 s:superzow octave:3 djf:0.25 ]", - "[ 3/2 → 7/4 | n:7 s:superzow octave:3 djf:0.25 ]", - "[ 7/4 → 2/1 | n:10 s:superzow octave:3 djf:0.25 ]", - "[ 7/4 → 2/1 | n:24 s:superzow octave:3 djf:0.25 ]", - "[ 2/1 → 9/4 | n:0 s:superzow octave:3 djf:0.5 ]", - "[ 9/4 → 5/2 | n:3 s:superzow octave:3 djf:0.5 ]", - "[ 5/2 → 11/4 | n:7 s:superzow octave:3 djf:0.5 ]", - "[ 11/4 → 3/1 | n:10 s:superzow octave:3 djf:0.5 ]", - "[ 11/4 → 3/1 | n:24 s:superzow octave:3 djf:0.5 ]", - "[ 3/1 → 13/4 | n:0 s:superzow octave:3 djf:0.75 ]", - "[ 13/4 → 7/2 | n:3 s:superzow octave:3 djf:0.75 ]", - "[ 7/2 → 15/4 | n:7 s:superzow octave:3 djf:0.75 ]", - "[ 15/4 → 4/1 | n:10 s:superzow octave:3 djf:0.75 ]", - "[ 15/4 → 4/1 | n:24 s:superzow octave:3 djf:0.75 ]", + "[ 0/1 → 1/8 | note:D3 s:supersaw djf:0.5 ]", + "[ 1/8 → 1/4 | note:G4 s:supersaw djf:0.5 ]", + "[ 1/4 → 3/8 | note:Bb3 s:supersaw djf:0.5 ]", + "[ 3/8 → 1/2 | note:C4 s:supersaw djf:0.5 ]", + "[ 1/2 → 5/8 | note:A3 s:supersaw djf:0.5 ]", + "[ 5/8 → 3/4 | note:F3 s:supersaw djf:0.5 ]", + "[ 3/4 → 7/8 | note:G3 s:supersaw djf:0.5 ]", + "[ 7/8 → 1/1 | note:C4 s:supersaw djf:0.5 ]", + "[ 1/1 → 9/8 | note:Eb4 s:supersaw djf:0.3 ]", + "[ 9/8 → 5/4 | note:G4 s:supersaw djf:0.3 ]", + "[ 5/4 → 11/8 | note:A4 s:supersaw djf:0.3 ]", + "[ 11/8 → 3/2 | note:F3 s:supersaw djf:0.3 ]", + "[ 3/2 → 13/8 | note:F4 s:supersaw djf:0.3 ]", + "[ 13/8 → 7/4 | note:D4 s:supersaw djf:0.3 ]", + "[ 7/4 → 15/8 | note:G3 s:supersaw djf:0.3 ]", + "[ 15/8 → 2/1 | note:F4 s:supersaw djf:0.3 ]", + "[ 2/1 → 17/8 | note:Eb5 s:supersaw djf:0.2 ]", + "[ 17/8 → 9/4 | note:D5 s:supersaw djf:0.2 ]", + "[ 9/4 → 19/8 | note:Bb3 s:supersaw djf:0.2 ]", + "[ 19/8 → 5/2 | note:C5 s:supersaw djf:0.2 ]", + "[ 5/2 → 21/8 | note:D4 s:supersaw djf:0.2 ]", + "[ 21/8 → 11/4 | note:F3 s:supersaw djf:0.2 ]", + "[ 11/4 → 23/8 | note:G4 s:supersaw djf:0.2 ]", + "[ 23/8 → 3/1 | note:D3 s:supersaw djf:0.2 ]", + "[ 3/1 → 25/8 | note:G3 s:supersaw djf:0.75 ]", + "[ 25/8 → 13/4 | note:Bb3 s:supersaw djf:0.75 ]", + "[ 13/4 → 27/8 | note:Eb5 s:supersaw djf:0.75 ]", + "[ 27/8 → 7/2 | note:C4 s:supersaw djf:0.75 ]", + "[ 7/2 → 29/8 | note:C4 s:supersaw djf:0.75 ]", + "[ 29/8 → 15/4 | note:Eb5 s:supersaw djf:0.75 ]", + "[ 15/4 → 31/8 | note:Bb4 s:supersaw djf:0.75 ]", + "[ 31/8 → 4/1 | note:A4 s:supersaw djf:0.75 ]", ] `;