From a3b803c3f33d9c029690ad0631adc9feec3c17ab Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 9 Oct 2025 18:28:59 -0500 Subject: [PATCH 1/3] First pass --- packages/core/controls.mjs | 145 +++++++++++++++++++++++ packages/core/test/pattern.test.mjs | 4 +- packages/superdough/helpers.mjs | 71 +++++++---- packages/superdough/superdough.mjs | 138 ++++++++++----------- packages/superdough/util.mjs | 5 + website/src/components/Header/Search.css | 4 +- website/src/pages/learn/samples.mdx | 2 +- 7 files changed, 271 insertions(+), 98 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 7e3c2a8e2..8a332ac26 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -1288,6 +1288,151 @@ export const { fanchor } = registerControl('fanchor'); */ // currently an alias of 'hcutoff' https://codeberg.org/uzu/strudel/issues/496 // ['hpf'], + +/** + * Rate of the LFO for the lowpass filter + * + * @name lprate + * @param {number | Pattern} rate rate in hertz + */ +export const { lprate } = registerControl('lprate'); + +/** + * Cycle-synced rate of the LFO for the lowpass filter + * + * @name lpsync + * @param {number | Pattern} rate rate in cycles + */ +export const { lpsync } = registerControl('lpsync'); + +/** + * Depth of the LFO for the lowpass filter + * + * @name lpdepth + * @param {number | Pattern} depth depth of modulation + */ +export const { lpdepth } = registerControl('lpdepth'); + +/** + * Shape of the LFO for the lowpass filter + * + * @name lpshape + * @param {number | Pattern} shape Shape of the lfo (0, 1, 2, ..) + */ +export const { lpshape } = registerControl('lpshape'); + +/** + * DC offset of the LFO for the lowpass filter + * + * @name lpdc + * @param {number | Pattern} dcoffset dc offset. set to 0 for unipolar + */ +export const { lpdc } = registerControl('lpdc'); + +/** + * Skew of the LFO for the lowpass filter + * + * @name lpskew + * @param {number | Pattern} skew How much to bend the LFO shape + */ +export const { lpskew } = registerControl('lpskew'); + +/** + * Rate of the LFO for the bandpass filter + * + * @name bprate + * @param {number | Pattern} rate rate in hertz + */ +export const { bprate } = registerControl('bprate'); + +/** + * Cycle-synced rate of the LFO for the bandpass filter + * + * @name bpsync + * @param {number | Pattern} rate rate in cycles + */ +export const { bpsync } = registerControl('bpsync'); + +/** + * Depth of the LFO for the bandpass filter + * + * @name bpdepth + * @param {number | Pattern} depth depth of modulation + */ +export const { bpdepth } = registerControl('bpdepth'); + +/** + * Shape of the LFO for the bandpass filter + * + * @name bpshape + * @param {number | Pattern} shape Shape of the lfo (0, 1, 2, ..) + */ +export const { bpshape } = registerControl('bpshape'); + +/** + * DC offset of the LFO for the bandpass filter + * + * @name bpdc + * @param {number | Pattern} dcoffset dc offset. set to 0 for unipolar + */ +export const { bpdc } = registerControl('bpdc'); + +/** + * Skew of the LFO for the bandpass filter + * + * @name bpskew + * @param {number | Pattern} skew How much to bend the LFO shape + */ +export const { bpskew } = registerControl('bpskew'); + +/** + * Rate of the LFO for the highpass filter + * + * @name hprate + * @param {number | Pattern} rate rate in hertz + */ +export const { hprate } = registerControl('hprate'); + +/** + * Cycle-synced rate of the LFO for the highpass filter + * + * @name hpsync + * @param {number | Pattern} rate rate in cycles + */ +export const { hpsync } = registerControl('hpsync'); + +/** + * Depth of the LFO for the highpass filter + * + * @name hpdepth + * @param {number | Pattern} depth depth of modulation + */ +export const { hpdepth } = registerControl('hpdepth'); + +/** + * Shape of the LFO for the highpass filter + * + * @name hpshape + * @param {number | Pattern} shape Shape of the lfo (0, 1, 2, ..) + */ +export const { hpshape } = registerControl('hpshape'); + +/** + * DC offset of the LFO for the highpass filter + * + * @name hpdc + * @param {number | Pattern} dcoffset dc offset. set to 0 for unipolar + */ +export const { hpdc } = registerControl('hpdc'); + +/** + * Skew of the LFO for the highpass filter + * + * @name hpskew + * @param {number | Pattern} skew How much to bend the LFO shape + */ +export const { hpskew } = registerControl('hpskew'); + /** * Applies a vibrato to the frequency of the oscillator. * diff --git a/packages/core/test/pattern.test.mjs b/packages/core/test/pattern.test.mjs index 1df5c8776..625f40756 100644 --- a/packages/core/test/pattern.test.mjs +++ b/packages/core/test/pattern.test.mjs @@ -796,7 +796,7 @@ describe('Pattern', () => { }); }); describe('apply', () => { - it('Can apply a function', () => { + (it('Can apply a function', () => { expect(sequence('a', 'b').apply(fast(2)).firstCycle()).toStrictEqual(sequence('a', 'b').fast(2).firstCycle()); }), it('Can apply a pattern of functions', () => { @@ -804,7 +804,7 @@ describe('Pattern', () => { expect(sequence('a', 'b').apply(fast(2), fast(3)).firstCycle()).toStrictEqual( sequence('a', 'b').fast(2, 3).firstCycle(), ); - }); + })); }); describe('layer', () => { it('Can layer up multiple functions', () => { diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 47bca330d..147291ad3 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -153,6 +153,24 @@ export const getADSRValues = (params, curve = 'linear', defaultValues) => { return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)]; }; +export function getParamLfo(audioContext, param, start, end, lfoValues) { + let { defaultDepth = 1, depth, dcoffset, ...getLfoInputs } = lfoValues; + if (depth == null) { + const hasLFOParams = Object.values(getLfoInputs).some((v) => v != null); + depth = hasLFOParams ? defaultDepth : 0; + } + let lfo; + if (depth) { + lfo = getLfo(audioContext, start, end, { + depth, + dcoffset, + ...getLfoInputs, + }); + lfo.connect(param); + } + return lfo; +} + // helper utility for applying standard modulators to a parameter export function applyParameterModulators(audioContext, param, start, end, envelopeValues, lfoValues) { let { amount, offset, defaultAmount = 1, curve = 'linear', values, holdEnd, defaultValues } = envelopeValues; @@ -169,41 +187,39 @@ export function applyParameterModulators(audioContext, param, start, end, envelo const [attack, decay, sustain, release] = getADSRValues(values, curve, defaultValues); getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve); } - let lfo; - let { defaultDepth = 1, depth, dcoffset, ...getLfoInputs } = lfoValues; - - if (depth == null) { - const hasLFOParams = Object.values(getLfoInputs).some((v) => v != null); - depth = hasLFOParams ? defaultDepth : 0; - } - if (depth) { - lfo = getLfo(audioContext, start, end, { - depth, - dcoffset, - ...getLfoInputs, - }); - lfo.connect(param); - } - + const lfo = getParamLfo(audioContext, param, start, end, lfoValues); return { lfo, disconnect: () => lfo?.disconnect() }; } -export function createFilter(context, type, frequency, Q, att, dec, sus, rel, fenv, start, end, fanchor, model, drive) { - const curve = 'exponential'; - const [attack, decay, sustain, release] = getADSRValues([att, dec, sus, rel], curve, [0.005, 0.14, 0, 0.1]); - let filter; +export function createFilter(context, start, end, params, cps) { + let { + frequency, + fanchor, + fenv, + type, + model, + q = 1, + drive = 0.69, + depth, + dcoffset = -0.5, + skew, + shape, + rate, + sync, + } = params; let frequencyParam; if (model === 'ladder') { - filter = getWorklet(context, 'ladder-processor', { frequency, q: Q, drive }); + filter = getWorklet(context, 'ladder-processor', { frequency, q, drive }); frequencyParam = filter.parameters.get('frequency'); } else { filter = context.createBiquadFilter(); filter.type = type; - filter.Q.value = Q; + filter.Q.value = q; filter.frequency.value = frequency; frequencyParam = filter.frequency; } - + const envelopeValues = [params.attack, params.decay, params.sustain, params.release]; + const [attack, decay, sustain, release] = getADSRValues(envelopeValues, 'exponential', [0.005, 0.14, 0, 0.1]); // envelope is active when any of these values is set const hasEnvelope = att ?? dec ?? sus ?? rel ?? fenv; // Apply ADSR to filter frequency @@ -216,8 +232,15 @@ export function createFilter(context, type, frequency, Q, att, dec, sus, rel, fe let max = clamp(2 ** (fenvAbs - offset) * frequency, 0, 20000); if (fenv < 0) [min, max] = [max, min]; getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, curve); - return filter; } + + if (sync != null) { + rate = cps * sync; + } + const lfoValues = { depth, dcoffset, skew, shape, frequency: rate }; + getParamLfo(context, frequencyParam, start, end, lfoValues); + // TODO: remember to disconnect LFO + // lfo?.disconnect() return filter; } diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 52ed9bff8..bae422354 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 } from './util.mjs'; +import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs'; import workletsUrl from './worklets.mjs?audioworklet'; import { createFilter, gainNode, getCompressor, getLfo, getWorklet, effectSend } from './helpers.mjs'; import { map } from 'nanostores'; @@ -145,11 +145,6 @@ let defaultDefaultValues = { gain: 0.8, postgain: 1, density: '.03', - ftype: '12db', - fanchor: 0, - resonance: 1, - hresonance: 1, - bandq: 1, channels: [1, 2], phaserdepth: 0.75, shapevol: 1, @@ -432,32 +427,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) djf, // filters fanchor = getDefaultValue('fanchor'), - drive = 0.69, release = 0, - // low pass - cutoff, - lpenv, - lpattack, - lpdecay, - lpsustain, - lprelease, - resonance = getDefaultValue('resonance'), - // high pass - hpenv, - hcutoff, - hpattack, - hpdecay, - hpsustain, - hprelease, - hresonance = getDefaultValue('hresonance'), - // band pass - bpenv, - bandf, - bpattack, - bpdecay, - bpsustain, - bprelease, - bandq = getDefaultValue('bandq'), //phaser phaserrate: phaser, @@ -581,57 +551,87 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) // gain stage chain.push(gainNode(gain)); - //filter + // filter const ftype = getFilterType(value.ftype); - if (cutoff !== undefined) { - let lp = () => - createFilter( - ac, - 'lowpass', - cutoff, - resonance, - lpattack, - lpdecay, - lpsustain, - lprelease, - lpenv, - t, - end, - fanchor, - ftype, - drive, - ); + + 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', + shape: 'lpshape', + dcoffset: 'lpdc', + skew: 'lpskew', + }; + const lpParams = pickAndRename(value, lpMap); + lpParams.type = 'lowpass'; + let lp = () => createFilter(ac, t, end, lpParams, cps); chain.push(lp()); if (ftype === '24db') { chain.push(lp()); } } - if (hcutoff !== undefined) { - let hp = () => - createFilter( - ac, - 'highpass', - hcutoff, - hresonance, - hpattack, - hpdecay, - hpsustain, - hprelease, - hpenv, - t, - end, - fanchor, - ); + 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', + shape: 'hpshape', + dcoffset: 'hpdc', + skew: 'hpskew', + }; + const hpParams = pickAndRename(value, hpMap); + hpParams.type = 'highpass'; + let hp = () => createFilter(ac, t, end, hpParams, cps); chain.push(hp()); if (ftype === '24db') { chain.push(hp()); } } - if (bandf !== undefined) { - let bp = () => - createFilter(ac, 'bandpass', bandf, bandq, bpattack, bpdecay, bpsustain, bprelease, bpenv, t, end, fanchor); + 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', + shape: 'bpshape', + dcoffset: 'bpdc', + skew: 'bpskew', + }; + const bpParams = pickAndRename(value, bpMap); + bpParams.type = 'bandpass'; + let bp = () => createFilter(ac, t, end, bpParams, cps); chain.push(bp()); if (ftype === '24db') { chain.push(bp()); diff --git a/packages/superdough/util.mjs b/packages/superdough/util.mjs index 475c05f63..f296cca00 100644 --- a/packages/superdough/util.mjs +++ b/packages/superdough/util.mjs @@ -105,3 +105,8 @@ export function getCommonSampleInfo(hapValue, bank) { const label = `${s}:${index}`; return { transpose, url, index, midi, label }; } + +/** Selects entries from `source` and renames them via `map` */ +export const pickAndRename = (source, map) => { + return Object.fromEntries(Object.entries(map).map(([newKey, oldKey]) => [newKey, source[oldKey]])); +}; diff --git a/website/src/components/Header/Search.css b/website/src/components/Header/Search.css index 456ef9f6b..b14146cbd 100644 --- a/website/src/components/Header/Search.css +++ b/website/src/components/Header/Search.css @@ -18,8 +18,8 @@ --docsearch-modal-background: var(--background); --docsearch-muted-color: color-mix(in srgb, var(--foreground), #fff 30%); --docsearch-key-gradient: var(--foreground); - --docsearch-key-shadow: inset 0 -2px 0 0 var(--gutterForeground), inset 0 0 1px 1px var(--foreground), - 0 1px 2px 1px var(--gutterBackground); + --docsearch-key-shadow: + inset 0 -2px 0 0 var(--gutterForeground), inset 0 0 1px 1px var(--foreground), 0 1px 2px 1px var(--gutterBackground); } .dark { --docsearch-muted-color: color-mix(in srgb, var(--foreground), #000 30%); diff --git a/website/src/pages/learn/samples.mdx b/website/src/pages/learn/samples.mdx index eb79cccf7..201d57dfa 100644 --- a/website/src/pages/learn/samples.mdx +++ b/website/src/pages/learn/samples.mdx @@ -381,7 +381,7 @@ Sampler effects are functions that can be used to change the behaviour of sample ### scrub -{' '} + ### speed From 57fac576779d8cfcf8477aa0b32e2e02b3015772 Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 9 Oct 2025 22:23:28 -0500 Subject: [PATCH 2/3] Remove todo --- packages/superdough/helpers.mjs | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 147291ad3..f26599855 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -239,8 +239,6 @@ export function createFilter(context, start, end, params, cps) { } const lfoValues = { depth, dcoffset, skew, shape, frequency: rate }; getParamLfo(context, frequencyParam, start, end, lfoValues); - // TODO: remember to disconnect LFO - // lfo?.disconnect() return filter; } From ac7e9c697851d569ab6681ce735bc7dc8d7d0bd5 Mon Sep 17 00:00:00 2001 From: Aria Date: Tue, 14 Oct 2025 11:37:41 -0500 Subject: [PATCH 3/3] Typos on envelope values --- packages/core/test/pattern.test.mjs | 4 ++-- packages/superdough/helpers.mjs | 24 ++++++++++++------------ website/src/components/Header/Search.css | 4 ++-- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/core/test/pattern.test.mjs b/packages/core/test/pattern.test.mjs index 625f40756..1df5c8776 100644 --- a/packages/core/test/pattern.test.mjs +++ b/packages/core/test/pattern.test.mjs @@ -796,7 +796,7 @@ describe('Pattern', () => { }); }); describe('apply', () => { - (it('Can apply a function', () => { + it('Can apply a function', () => { expect(sequence('a', 'b').apply(fast(2)).firstCycle()).toStrictEqual(sequence('a', 'b').fast(2).firstCycle()); }), it('Can apply a pattern of functions', () => { @@ -804,7 +804,7 @@ describe('Pattern', () => { expect(sequence('a', 'b').apply(fast(2), fast(3)).firstCycle()).toStrictEqual( sequence('a', 'b').fast(2, 3).firstCycle(), ); - })); + }); }); describe('layer', () => { it('Can layer up multiple functions', () => { diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 8e59eb720..b3fc53356 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -195,8 +195,8 @@ export function applyParameterModulators(audioContext, param, start, end, envelo export function createFilter(context, start, end, params, cps) { let { frequency, - fanchor, - fenv, + anchor, + env, type, model, q = 1, @@ -208,7 +208,7 @@ export function createFilter(context, start, end, params, cps) { rate, sync, } = params; - let frequencyParam; + let frequencyParam, filter; if (model === 'ladder') { filter = getWorklet(context, 'ladder-processor', { frequency, q, drive }); frequencyParam = filter.parameters.get('frequency'); @@ -222,17 +222,17 @@ export function createFilter(context, start, end, params, cps) { const envelopeValues = [params.attack, params.decay, params.sustain, params.release]; const [attack, decay, sustain, release] = getADSRValues(envelopeValues, 'exponential', [0.005, 0.14, 0, 0.1]); // envelope is active when any of these values is set - const hasEnvelope = att ?? dec ?? sus ?? rel ?? fenv; + const hasEnvelope = [...envelopeValues, env].some((v) => v !== undefined); // Apply ADSR to filter frequency - if (hasEnvelope !== undefined) { - fenv = nanFallback(fenv, 1, true); - fanchor = nanFallback(fanchor, 0, true); - const fenvAbs = Math.abs(fenv); - const offset = fenvAbs * fanchor; + if (hasEnvelope) { + env = nanFallback(env, 1, true); + anchor = nanFallback(anchor, 0, true); + const envAbs = Math.abs(env); + const offset = envAbs * anchor; let min = clamp(2 ** -offset * frequency, 0, 20000); - let max = clamp(2 ** (fenvAbs - offset) * frequency, 0, 20000); - if (fenv < 0) [min, max] = [max, min]; - getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, curve); + let max = clamp(2 ** (envAbs - offset) * frequency, 0, 20000); + if (env < 0) [min, max] = [max, min]; + getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, 'exponential'); } if (sync != null) { diff --git a/website/src/components/Header/Search.css b/website/src/components/Header/Search.css index b14146cbd..456ef9f6b 100644 --- a/website/src/components/Header/Search.css +++ b/website/src/components/Header/Search.css @@ -18,8 +18,8 @@ --docsearch-modal-background: var(--background); --docsearch-muted-color: color-mix(in srgb, var(--foreground), #fff 30%); --docsearch-key-gradient: var(--foreground); - --docsearch-key-shadow: - inset 0 -2px 0 0 var(--gutterForeground), inset 0 0 1px 1px var(--foreground), 0 1px 2px 1px var(--gutterBackground); + --docsearch-key-shadow: inset 0 -2px 0 0 var(--gutterForeground), inset 0 0 1px 1px var(--foreground), + 0 1px 2px 1px var(--gutterBackground); } .dark { --docsearch-muted-color: color-mix(in srgb, var(--foreground), #000 30%);