From aec33710b751b84c7d65ce22caea24ed34a813c1 Mon Sep 17 00:00:00 2001 From: Aria Date: Wed, 31 Dec 2025 14:58:31 -0600 Subject: [PATCH 1/3] Working version --- packages/core/controls.mjs | 8 + packages/core/pattern.mjs | 16 + packages/superdough/feedbackdelay.mjs | 19 +- packages/superdough/modulators.mjs | 27 +- packages/superdough/reverbGen.mjs | 6 +- packages/superdough/superdough.mjs | 746 ++++++++++++++------------ 6 files changed, 472 insertions(+), 350 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 1bf3fa85c..b18a8dff6 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -2846,6 +2846,7 @@ registerSubControls('lfo', [ ['skew', 'sk'], ['curve'], ['sync', 's'], + ['fxi'], ]); registerSubControls('env', [ ['control', 'c'], @@ -2859,6 +2860,7 @@ registerSubControls('env', [ ['acurve', 'ac'], ['dcurve', 'dc'], ['rcurve', 'rc'], + ['fxi'], ]); registerSubControls('bmod', [ ['bus', 'b'], @@ -2867,6 +2869,7 @@ registerSubControls('bmod', [ ['depth', 'dep', 'dr'], ['depthabs', 'da'], ['dc'], + ['fxi'], ]); Pattern.prototype.modulate = function (type, config, id) { @@ -2932,6 +2935,7 @@ Pattern.prototype.modulate = function (type, config, id) { * @param {number | Pattern} [config.skew] Skew amount. Aliases: sk * @param {number | Pattern} [config.curve] Exponential curve amount. Aliases: c * @param {number | Pattern} [config.sync] Tempo-synced modulation rate. Aliases: s + * @param {number | Pattern} [config.fxi] FX index to target * @param {string | Pattern} id ID to use for this modulator * @returns Pattern * @@ -2985,6 +2989,7 @@ export const lfo = (config) => pure({}).lfo(config); * @param {number | Pattern} [config.acurve] Snappiness of attack curve (-1 = relaxed, 1 = snappy). Aliases: ac * @param {number | Pattern} [config.dcurve] Snappiness of decay curve (-1 = relaxed, 1 = snappy). Aliases: dc * @param {number | Pattern} [config.rcurve] Snappiness of release curve (-1 = relaxed, 1 = snappy). Aliases: rc + * @param {number | Pattern} [config.fxi] FX index to target * @param {string | Pattern} id ID to use for this modulator * @returns Pattern * @@ -3039,6 +3044,7 @@ export const env = (config) => pure({}).env(config); * @param {number | Pattern} [config.depth] Relative modulation depth. Aliases: dep, dr * @param {number | Pattern} [config.depthabs] Absolute modulation depth. Aliases: da * @param {number | Pattern} [config.dc] DC offset prior to application + * @param {number | Pattern} [config.fxi] FX index to target * @param {string | Pattern} id ID to use for this modulator * @returns Pattern * @@ -3065,3 +3071,5 @@ export const bmod = (config) => pure({}).bmod(config); * s("hh*16").bank("tr909").transient("<-1:1 1:-1>") */ export const { transient } = registerControl(['transient', 'transsustain']); + +export const { FXrelease, FXrel, FXr, fxr } = registerControl('FXrelease', 'FXrel', 'FXr', 'fxr'); diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 6f214386f..e6169399a 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -3721,3 +3721,19 @@ Pattern.prototype.phases = function (list) { export const phases = (list) => { return _ensureListPattern(list).as('phases'); }; + +/** + * Establishes an FX chain. Can be called by chaining .FX().FX().. + * calls and/or in a single .FX(, , ..) call. The , .. are _patterns_ which + * establish the controls of the given effect. See examples. + * @name FX + * @memberof Pattern + * @returns Pattern + */ +Pattern.prototype.FX = function (...effects) { + effects = effects.map(reify); + return this.withValue((v) => (vEff) => { + const currFX = v.FX ?? []; + return { ...v, FX: currFX.concat(vEff) }; + }).appLeft(parray(effects)); +}; diff --git a/packages/superdough/feedbackdelay.mjs b/packages/superdough/feedbackdelay.mjs index b8269bc02..387176134 100644 --- a/packages/superdough/feedbackdelay.mjs +++ b/packages/superdough/feedbackdelay.mjs @@ -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) { diff --git a/packages/superdough/modulators.mjs b/packages/superdough/modulators.mjs index 7a1fe252d..2b6fcf334 100644 --- a/packages/superdough/modulators.mjs +++ b/packages/superdough/modulators.mjs @@ -85,8 +85,19 @@ const getTargetParamsForControl = (control, nodes, subControl) => { }; export const connectLFO = (id, params, nodeTracker) => { - const { rate = 1, sync, cps, cycle, control = 'lfo', subControl, depth = 1, depthabs, ...filteredParams } = params; - const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker, subControl); + 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; @@ -101,14 +112,14 @@ export const connectLFO = (id, params, nodeTracker) => { max, }; const lfoNode = getLfo(getAudioContext(), modParams); - nodeTracker[`lfo_${id}`] = [lfoNode]; + nodeTracker[0][`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, ...filteredParams } = params; - const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker, subControl); + 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; @@ -123,15 +134,15 @@ export const connectEnvelope = (id, params, nodeTracker) => { decayCurve: dcurve, releaseCurve: rcurve, }); - nodeTracker[`env_${id}`] = [envNode]; + nodeTracker[0][`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 } = params; - const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker, subControl); + 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 }); diff --git a/packages/superdough/reverbGen.mjs b/packages/superdough/reverbGen.mjs index bb050874c..0949a30d5 100644 --- a/packages/superdough/reverbGen.mjs +++ b/packages/superdough/reverbGen.mjs @@ -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(); diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 8a3e9f7b3..9e9ffa12d 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -7,7 +7,7 @@ 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, @@ -18,6 +18,7 @@ import { getLfo, getWorklet, releaseAudioNode, + webAudioTimeout, } from './helpers.mjs'; import { map } from 'nanostores'; import { logger } from './logger.mjs'; @@ -193,6 +194,8 @@ let defaultDefaultValues = { i: 1, velocity: 1, fft: 8, + tremolodepth: 1, + tremolophase: 0, }; const defaultDefaultDefaultValues = Object.freeze({ ...defaultDefaultValues }); @@ -402,8 +405,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) => { - let nodes = {}; + // 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(); @@ -432,44 +464,17 @@ 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 = 0.01, dry, - shape, - shapevol = getDefaultValue('shapevol'), - distort, - distortvol = getDefaultValue('distortvol'), - distorttype = getDefaultValue('distorttype'), - pan, - vowel, delay = getDefaultValue('delay'), delayfeedback = getDefaultValue('delayfeedback'), delaysync = getDefaultValue('delaysync'), @@ -486,16 +491,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, - transient, - transsustain, + FX = [], + FXrelease, } = value; delaytime = delaytime ?? cycleToSeconds(delaysync, cps); @@ -510,18 +509,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); busgain = applyGainCurve(busgain); - gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future 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 @@ -535,8 +529,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) activeSoundSources.delete(chainID); } - const audioNodes = []; - if (['-', '~', '_'].includes(s)) { return; } @@ -549,19 +541,16 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) let sourceNode; if (source) { sourceNode = source(t, value, hapDuration, cps); - nodes['source'] = [sourceNode]; + nodes.main['source'] = [sourceNode]; } else if (getSound(s)) { const { onTrigger } = getSound(s); - const onEnded = () => { - audioNodes.forEach((n) => releaseAudioNode(n)); - activeSoundSources.delete(chainID); - }; + const onEnded = () => {}; const soundHandle = await onTrigger(t, value, onEnded, cps); if (soundHandle) { sourceNode = soundHandle.node; activeSoundSources.set(chainID, new WeakRef(soundHandle)); // allow GC - nodes = { ...nodes, ...soundHandle.nodes }; + nodes.main = { ...nodes.main, ...soundHandle.nodes }; } } else { throw new Error(`sound ${s} not found! Is it loaded?`); @@ -576,253 +565,339 @@ 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 })); - if (transient !== undefined) { - const transProcessor = getWorklet( - ac, - 'transient-processor', - {}, - { - processorOptions: { - attack: transient, - sustain: transsustain, - begin: t, - end: endWithRelease, + const chain = new Chain(sourceNode); // audio nodes that will be connected to each other sequentially + 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'), + } = 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); + + stretch !== undefined && chain.connect(getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: stretch })); + + if (fx.transient !== undefined) { + const transProcessor = getWorklet( + ac, + 'transient-processor', + {}, + { + processorOptions: { + attack: fx.transient, + sustain: fx.transsustain, + begin: t, + end: endWithRelease, + }, }, - }, - ); - chain.push(transProcessor); - nodes['transient'] = transProcessor; - } + ); + chain.connect(transProcessor); + fxNodes['transient'] = transProcessor; + } - // gain stage - const initialGain = gainNode(gain); - nodes['gain'] = [initialGain]; - chain.push(initialGain); + // gain stage + const initialGain = gainNode(gain); + fxNodes['gain'] = [initialGain]; + chain.connect(initialGain); - // filter - const ftype = getFilterType(value.ftype); + // filter + const ftype = getFilterType(value.ftype); - const filt = (params) => createFilter(ac, t, end, params, cps, cycle); - 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 { filter: lpf1, lfo: lfo1 } = filt(lpParams); - nodes['lpf'] = [lpf1]; - nodes['lpf_lfo'] = [lfo1]; - chain.push(lpf1); - lfo1 && audioNodes.push(lfo1); - if (ftype === '24db') { - const { filter: lpf2, lfo: lfo2 } = filt(lpParams); - nodes['lpf'].push(lpf2); - nodes['lpf_lfo'].push(lfo2); - chain.push(lpf2); - lfo2 && audioNodes.push(lfo2); + 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 vowelFilter = ac.createVowelFilter(fx.vowel); + fxNodes['vowel'] = [vowelFilter]; + chain.connect(vowelFilter); + } + + // 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(); + 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 (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 (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()[fx.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 { filter: hpf1, lfo: lfo1 } = filt(hpParams); - nodes['hpf'] = [hpf1]; - nodes['hpf_lfo'] = [lfo1]; - lfo1 && audioNodes.push(lfo1); - chain.push(hpf1); - if (ftype === '24db') { - const { filter: hpf2, lfo: lfo2 } = filt(hpParams); - nodes['hpf'].push(hpf2); - nodes['hpf_lfo'].push(lfo2); - 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 { filter: bpf1, lfo: lfo1 } = filt(bpParams); - nodes['bpf'] = [bpf1]; - nodes['bpf_lfo'] = [lfo1]; - chain.push(bpf1); - lfo1 && audioNodes.push(lfo1); - if (ftype === '24db') { - const { filter: bpf2, lfo: lfo2 } = filt(bpParams); - nodes['bpf'].push(bpf2); - nodes['bpf_lfo'].push(lfo2); - chain.push(bpf2); - lfo2 && audioNodes.push(lfo2); - } - } - - if (vowel !== undefined) { - const vowelFilter = ac.createVowelFilter(vowel); - nodes['vowel'] = [vowelFilter]; - chain.push(vowelFilter); - } - - // effects - if (coarse !== undefined) { - const coarseNode = getWorklet(ac, 'coarse-processor', { coarse }); - nodes['coarse'] = [coarseNode]; - chain.push(coarseNode); - } - if (crush !== undefined) { - const crushNode = getWorklet(ac, 'crush-processor', { crush }); - nodes['crush'] = [crushNode]; - chain.push(crushNode); - } - if (shape !== undefined) { - const shapeNode = getWorklet(ac, 'shape-processor', { shape, postgain: shapevol }); - nodes['shape'] = [shapeNode]; - chain.push(shapeNode); - } - if (distort !== undefined) { - const distortNode = getDistortion(distort, distortvol, distorttype); - nodes['distort'] = [distortNode]; - chain.push(distortNode); - } - - 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, { - 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, - begin: t, - end: endWithRelease, - }); - nodes['tremolo'] = [lfo]; - nodes['tremolo_gain'] = [amGain]; - lfo.connect(amGain.gain); - audioNodes.push(lfo); - chain.push(amGain); - } - - if (compressorThreshold !== undefined) { - const compressorNode = getCompressor( - ac, - compressorThreshold, - compressorRatio, - compressorKnee, - compressorAttack, - compressorRelease, - ); - nodes['compressor'] = [compressorNode]; - chain.push(compressorNode); - } - - // panning - if (pan !== undefined) { - const panner = ac.createStereoPanner(); - panner.pan.value = 2 * pan - 1; - chain.push(panner); - } - // phaser - if (phaserrate !== undefined && phaserdepth > 0) { - const { filterChain, lfo } = getPhaser(t, endWithRelease, phaserrate, phaserdepth, phasercenter, phasersweep); - nodes['phaser'] = [...filterChain]; - nodes['phaser_lfo'] = [lfo]; - chain.push(...filterChain); - audioNodes.push(lfo); + 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 }); - nodes['post'] = [post]; - chain.push(post); + nodes.main['post'] = [post]; + chain.connect(post); // delay if (delay > 0 && delaytime > 0 && delayfeedback > 0) { const delayNode = orbitBus.getDelay(delaytime, delayfeedback, t); - nodes['delay'] = [delayNode]; + nodes.main['delay'] = [delayNode]; const delaySend = orbitBus.sendDelay(post, delay); - nodes['delay_mix'] = [delaySend]; - audioNodes.push(delaySend); + nodes.main['delay_mix'] = [delaySend]; + chain.audioNodes.push(delaySend); } // reverb if (room > 0) { @@ -838,81 +913,92 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) roomIR = await loadBuffer(url, ac, ir, 0); } const roomNode = orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin); - nodes['room'] = [roomNode]; + nodes.main['room'] = [roomNode]; const reverbSend = orbitBus.sendReverb(post, room); - nodes['room_mix'] = [reverbSend]; - audioNodes.push(reverbSend); + nodes.main['room_mix'] = [reverbSend]; + chain.audioNodes.push(reverbSend); } if (bus != null) { const busNode = audioController.getBus(bus); const busSend = effectSend(post, busNode, busgain); - audioNodes.push(busSend); + chain.audioNodes.push(busSend); } if (djf != null) { const djfNode = orbitBus.getDjf(djf, t); - nodes['djf'] = [djfNode]; + nodes.main['djf'] = [djfNode]; } // analyser 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.push(...chain); - // finally, now that `nodes` is populated, set up modulators - if (value.lfo) { - for (const id of value.lfo.__ids) { - const params = value.lfo[id]; - const lfo = connectLFO( - id, - { - ...params, - cps, - cycle, - begin: t, - end: endWithRelease, - }, - nodes, - ); - lfo && audioNodes.push(lfo); + 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 (value.env) { - for (const id of value.env.__ids) { - const params = value.env[id]; - const env = connectEnvelope( - id, - { - ...params, - begin: t, - end: endWithRelease, - }, - nodes, - ); - env && audioNodes.push(env); + 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 (value.bmod) { - for (const id of value.bmod.__ids) { - const params = value.bmod[id]; - const { toCleanup } = connectBusModulator({ ...params, begin: t, end: endWithRelease }, nodes, controller); - audioNodes.push(...toCleanup); + 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); + } } - } + }); + webAudioTimeout( + ac, + () => { + chain.releaseNodes(); + activeSoundSources.delete(chainID); + }, + 0, + endWithRelease, + ); }; export const superdoughTrigger = (t, hap, ct, cps) => { From 882bcc513f12d62ab477bd336f09294ed528e2f4 Mon Sep 17 00:00:00 2001 From: Aria Date: Wed, 31 Dec 2025 15:17:06 -0600 Subject: [PATCH 2/3] Typos and some examples --- packages/core/pattern.mjs | 16 ++++++++ packages/superdough/modulators.mjs | 4 +- packages/superdough/superdough.mjs | 27 +++++++------ test/__snapshots__/examples.test.mjs.snap | 46 +++++++++++++++++++++++ 4 files changed, 79 insertions(+), 14 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index e6169399a..f9c1852df 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -3729,6 +3729,22 @@ export const phases = (list) => { * @name FX * @memberof Pattern * @returns Pattern + * @example + * $: s("[sbd ]*4").dec(.4) + * .FX( + * phaser(0.5).gain(2), + * bpf(800), + * distort(1.3), + * room(0.2), + * delay(0.5).gain(1.25), + * distort(0.3), + * ).fxr(1.7) // sets release time of effects (like delay) + * @example + * $: s("saw").fm(0.5) + * .delay(0.3) // outer effects are applied *last* + * .FX(coarse(4)) // first coarse + * .FX(lpf(500).lpe(4).lpa(1).lpd(2)) // then lpf + * .FX(distort(1)) // then distort */ Pattern.prototype.FX = function (...effects) { effects = effects.map(reify); diff --git a/packages/superdough/modulators.mjs b/packages/superdough/modulators.mjs index 2b6fcf334..ea9067102 100644 --- a/packages/superdough/modulators.mjs +++ b/packages/superdough/modulators.mjs @@ -112,7 +112,7 @@ export const connectLFO = (id, params, nodeTracker) => { max, }; const lfoNode = getLfo(getAudioContext(), modParams); - nodeTracker[0][`lfo_${id}`] = [lfoNode]; + nodeTracker.main[`lfo_${id}`] = [lfoNode]; targetParams.forEach((t) => lfoNode.connect(t)); return lfoNode; }; @@ -134,7 +134,7 @@ export const connectEnvelope = (id, params, nodeTracker) => { decayCurve: dcurve, releaseCurve: rcurve, }); - nodeTracker[0][`env_${id}`] = [envNode]; + nodeTracker.main[`env_${id}`] = [envNode]; targetParams.forEach((t) => envNode.connect(t)); return envNode; }; diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 9e9ffa12d..7546bfab4 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -196,6 +196,7 @@ let defaultDefaultValues = { fft: 8, tremolodepth: 1, tremolophase: 0, + release: 0.01, }; const defaultDefaultDefaultValues = Object.freeze({ ...defaultDefaultValues }); @@ -473,7 +474,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) duckattack, duckdepth, djf, - release = 0.01, + release = getDefaultValue('release'), dry, delay = getDefaultValue('delay'), delayfeedback = getDefaultValue('delayfeedback'), @@ -544,7 +545,18 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) nodes.main['source'] = [sourceNode]; } else if (getSound(s)) { const { onTrigger } = getSound(s); - const onEnded = () => {}; + // 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) { @@ -566,7 +578,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) return; } - const chain = new Chain(sourceNode); // audio nodes that will be connected to each other sequentially + 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; @@ -990,15 +1002,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) } } }); - webAudioTimeout( - ac, - () => { - chain.releaseNodes(); - activeSoundSources.delete(chainID); - }, - 0, - endWithRelease, - ); }; export const superdoughTrigger = (t, hap, ct, cps) => { diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 61ab11866..f6547bc61 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -565,6 +565,52 @@ exports[`runs examples > example "_euclidRot" example index 20 1`] = ` ] `; +exports[`runs examples > example "FX" example index 0 1`] = ` +[ + "[ 0/1 → 1/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 1/8 → 1/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 1/4 → 3/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 3/8 → 1/2 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 1/2 → 5/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 5/8 → 3/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 3/4 → 7/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 7/8 → 1/1 | s:lt decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 1/1 → 9/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 9/8 → 5/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 5/4 → 11/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 11/8 → 3/2 | s:oh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 3/2 → 13/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 13/8 → 7/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 7/4 → 15/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 15/8 → 2/1 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 2/1 → 17/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 17/8 → 9/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 9/4 → 19/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 19/8 → 5/2 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 5/2 → 21/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 21/8 → 11/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 11/4 → 23/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 23/8 → 3/1 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 3/1 → 25/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 25/8 → 13/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 13/4 → 27/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 27/8 → 7/2 | s:lt decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 7/2 → 29/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 29/8 → 15/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 15/4 → 31/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", + "[ 31/8 → 4/1 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]", +] +`; + +exports[`runs examples > example "FX" example index 1 1`] = ` +[ + "[ 0/1 → 1/1 | s:saw fmi:0.5 delay:0.3 FX:[{coarse:4} {cutoff:500 lpenv:4 lpattack:1 lpdecay:2} {distort:1}] ]", + "[ 1/1 → 2/1 | s:saw fmi:0.5 delay:0.3 FX:[{coarse:4} {cutoff:500 lpenv:4 lpattack:1 lpdecay:2} {distort:1}] ]", + "[ 2/1 → 3/1 | s:saw fmi:0.5 delay:0.3 FX:[{coarse:4} {cutoff:500 lpenv:4 lpattack:1 lpdecay:2} {distort:1}] ]", + "[ 3/1 → 4/1 | s:saw fmi:0.5 delay:0.3 FX:[{coarse:4} {cutoff:500 lpenv:4 lpattack:1 lpdecay:2} {distort:1}] ]", +] +`; + exports[`runs examples > example "accelerate" example index 0 1`] = ` [ "[ 0/1 → 2/1 | s:sax accelerate:0 ]", From ebaf7624f98698d65e53f6706a62b8af8ac3e0aa Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 1 Jan 2026 12:02:54 -0600 Subject: [PATCH 3/3] Fix lfos for vowels, phasers; fix ir index; more informative error message --- packages/superdough/modulators.mjs | 5 ++++- packages/superdough/superdough.mjs | 13 +++++++------ packages/superdough/superdoughdata.mjs | 2 +- packages/superdough/vowel.mjs | 17 +++++++++++------ 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/superdough/modulators.mjs b/packages/superdough/modulators.mjs index ea9067102..7d119a06b 100644 --- a/packages/superdough/modulators.mjs +++ b/packages/superdough/modulators.mjs @@ -62,7 +62,10 @@ const getTargetParamsForControl = (control, nodes, subControl) => { const lookupKey = subControl ? `${control}_${subControl}` : control; const targetInfo = getControlData(lookupKey) ?? getControlData(control); if (!targetInfo) { - errorLogger(new Error(`Could not find control data for target '${control}'`), 'superdough'); + 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; diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 7546bfab4..2e46b2d7e 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -597,6 +597,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) delaysync = getDefaultValue('delaysync'), delaytime, stretch = getDefaultValue('stretch'), + i = getDefaultValue('i'), } = fx; gain = applyGainCurve(nanFallback(gain, 1)); shapevol = applyGainCurve(shapevol); @@ -744,9 +745,9 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) } if (fx.vowel !== undefined) { - const vowelFilter = ac.createVowelFilter(fx.vowel); - fxNodes['vowel'] = [vowelFilter]; - chain.connect(vowelFilter); + const vowelNode = ac.createVowelFilter(fx.vowel); + fxNodes['vowel'] = vowelNode.filters; + chain.connect(vowelNode); } // effects @@ -839,7 +840,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) chain.audioNodes.push(lfo); } // delay - if (delay > 0 && delaytime > 0 && delayfeedback > 0) { + 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); @@ -856,7 +857,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) fxNodes['delay_mix'] = [wetDelay]; } // reverb - if (fx.room > 0) { + if (key !== 'main' && fx.room > 0) { let roomIR; if (fx.ir !== undefined) { let url; @@ -864,7 +865,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) 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()[fx.i % Object.values(sample.data.samples).length]; + url = Object.values(sample.data.samples).flat()[i % Object.values(sample.data.samples).length]; } roomIR = await loadBuffer(url, ac, fx.ir, 0); } diff --git a/packages/superdough/superdoughdata.mjs b/packages/superdough/superdoughdata.mjs index 2dac194f1..e43df9eef 100644 --- a/packages/superdough/superdoughdata.mjs +++ b/packages/superdough/superdoughdata.mjs @@ -87,7 +87,7 @@ const CONTROL_TARGETS = { compressorRelease: { node: 'compressor', param: 'release' }, // PHASER - phaserrate: { node: 'phaser_lfo', param: 'rate' }, + phaserrate: { node: 'phaser_lfo', param: 'frequency' }, phasersweep: { node: 'phaser_lfo', param: 'depth' }, phasercenter: { node: 'phaser', param: 'frequency' }, phaserdepth: { node: 'phaser', param: 'Q' }, diff --git a/packages/superdough/vowel.mjs b/packages/superdough/vowel.mjs index bfc1c9fcd..8ecbf9d5c 100644 --- a/packages/superdough/vowel.mjs +++ b/packages/superdough/vowel.mjs @@ -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,11 +70,13 @@ 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; } }