From e7d1613bfb4978adb498d9ccff87475c584698f5 Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 11 Dec 2025 14:42:16 -0600 Subject: [PATCH] Broken tests, but otherwise working --- packages/core/pattern.mjs | 21 ++-- packages/superdough/superdough.mjs | 113 +++++++------------ packages/superdough/superdoughdata.mjs | 144 +++++++++++++------------ packages/superdough/synth.mjs | 2 +- 4 files changed, 128 insertions(+), 152 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index b0420a2e6..7d0e46138 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -26,6 +26,7 @@ import { } from './util.mjs'; import drawLine from './drawLine.mjs'; import { errorLogger, logger } from './logger.mjs'; +import { getControlName } from './controls.mjs'; let stringParser; @@ -3753,28 +3754,34 @@ Pattern.prototype.modulate = function (type, config, idx) { if (config == null || typeof config !== 'object') { return this; } - if (!['lfo', 'env', 'bmod'].includes(type)) { + const modulatorKeys = ['lfo', 'env', 'bmod']; + if (!modulatorKeys.includes(type)) { logger(`[core] Modulation type ${type} not found. Please use one of 'lfo', 'env', 'bmod'`); return this; } let output = this; - let target = config.target; + let defaultValue = {}; + let defaultSet = 'target' in config; for (const [rawKey, value] of Object.entries(config)) { const key = resolveConfigKey(type, rawKey); - if (key === 'target') continue; // we will set/default it below const valuePat = reify(value); output = output .fmap((v) => (c) => { - if (target == null) { + if (!defaultSet) { // default target to the control set just before this in the chain // e.g. pat.gain(0.5).lfo({..}) will be a gain-LFO - target = Object.keys(v).at(-1); + let control = getControlName(Object.keys(v).at(-1)); + if (modulatorKeys.includes(control)) { + control = `${control}${v[type].length - 1}`; + } + defaultValue = { target: control }; + defaultSet = true; } v[type] ??= []; const t = v[type]; idx ??= t.length; - t[idx] ??= { target }; // set target - t[idx][key] = c; + t[idx] ??= defaultValue; + t[idx][key] = key === 'target' ? getControlName(c) : c; return v; }) .appLeft(valuePat); diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 32d7f0d63..7cbc58b98 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -25,7 +25,7 @@ import { errorLogger, logger } from './logger.mjs'; import { loadBuffer } from './sampler.mjs'; import { getAudioContext } from './audioContext.mjs'; import { SuperdoughAudioController } from './superdoughoutput.mjs'; -import { getSuperdoughControlData } from './superdoughdata.mjs'; +import { getSuperdoughControlTargets } from './superdoughdata.mjs'; export const DEFAULT_MAX_POLYPHONY = 128; const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard'; @@ -396,97 +396,60 @@ function _getNodeParams(node) { return Array.from(params); } -const targetToParamGuess = { - source: 'detune', - lpf: 'frequency', - bpf: 'frequency', - hpf: 'frequency', - distort: 'distort', - gain: 'gain', - vowel: 'frequency', - coarse: 'coarse', - crush: 'crush', - shape: 'shape', - compressor: 'threshold', - pan: 'pan', - phaser: 'rate', - post: 'gain', - delay: 'delayTime', - djf: 'value', - lfo: 'frequency', - env: 'depth', - send: 'depth', -}; - -const controlData = getSuperdoughControlData(); - -// TODO: We would like to use the aliases in `controls` here, but cannot as -// the modules are independent. We may want to inject that method here in situations -// where superdough/core are run in tandem -export let getControlName; -const _getMainName = (control) => getControlName?.(control) ?? control; +const controlTargets = getSuperdoughControlTargets(); +const _stripIndex = (control) => control?.replace(/\d+$/, ''); function _getControlData(control) { - return controlData[_getMainName(control)]; + return controlTargets[_stripIndex(control)]; } -function _getControlValue(control, value, data) { - const main = _getMainName(control); - return Number(value[main] ?? data.default); -} - -function _getControlClamp(data, currentValue) { - const { min, max } = data; - return { min: min - currentValue, max: max - currentValue }; -} - -function _getTargetParams(nodes, target) { - let param; - if (target.includes('.')) { - const split = target.split('.'); - target = split[0]; - param = split[1]; - } else { - const targetWithoutIndex = target.replace(/(\d+)$/, ''); - param = targetToParamGuess[targetWithoutIndex]; +function _getRangeForParam(paramName, targetParams, currentValue) { + if (paramName === 'frequency') { + const liveValue = targetParams?.[0]?.value ?? currentValue ?? 0; + return { min: 20 - liveValue, max: 24000 - liveValue }; } - const targetNodes = nodes[target]; + return { min: undefined, max: undefined }; +} + +function _getTargetParamsForControl(control, nodes, paramOverride) { + const targetInfo = _getControlData(control); + if (!targetInfo) { + errorLogger(`Could not find control data for target '${control}'`, 'superdough'); + return { targetParams: [], paramName: control }; + } + const paramName = paramOverride ?? targetInfo.param; + const nodeKey = nodes[control] ? control : targetInfo.node; + const targetNodes = nodes[nodeKey]; if (!targetNodes) { const keys = Object.keys(nodes); errorLogger( - `Could not connect to target '${target}' — it does not exist. Available targets: ${keys.join(', ')}`, + `Could not connect to target '${nodeKey}' — it does not exist. Available targets: ${keys.join(', ')}`, 'superdough', ); - return []; + return { targetParams: [], paramName }; } const audioParams = []; targetNodes.forEach((targetNode) => { - const targetParam = _getNodeParam(targetNode, param); + const targetParam = _getNodeParam(targetNode, paramName); if (!targetParam) { const available = _getNodeParams(targetNode); errorLogger( - `Could not connect to parameter '${param}' on '${target}'. Available parameters: ${available.join(', ')}`, + `Could not connect to parameter '${paramName}' on '${nodeKey}'. Available parameters: ${available.join(', ')}`, 'superdough', ); return; } audioParams.push(targetParam); }); - return audioParams; -} - -function _getTargetParamsForControl(control, nodes) { - const main = _getMainName(control); - const data = _getControlData(main); - const targetParams = _getTargetParams(nodes, data.param); - return { targetParams, data }; + return { targetParams: audioParams, paramName }; } function connectLFO(idx, params, nodeTracker, value) { - const { rate = 1, sync, cps, cycle, target, depth = 1, depthabs, ...filteredParams } = params; - const { targetParams, data } = _getTargetParamsForControl(target, nodeTracker); - const currentValue = _getControlValue(target, value, data); - const { min, max } = _getControlClamp(data, currentValue); + const { rate = 1, sync, cps, cycle, target = 'lfo', depth = 1, depthabs, param, p, ...filteredParams } = params; + const targetParam = param ?? p; + const { targetParams, paramName } = _getTargetParamsForControl(target, nodeTracker, targetParam); + const currentValue = targetParams[0].value; + const { min, max } = _getRangeForParam(paramName, targetParams, currentValue); const depthValue = depthabs != null ? depthabs : depth * currentValue; const modParams = { ...filteredParams, @@ -504,9 +467,9 @@ function connectLFO(idx, params, nodeTracker, value) { function connectEnvelope(idx, params, nodeTracker, value) { const { target, acurve, dcurve, rcurve, depth = 1, depthabs, ...filteredParams } = params; - const { targetParams, data } = _getTargetParamsForControl(target, nodeTracker); - const currentValue = _getControlValue(target, value, data); - const { min, max } = _getControlClamp(data, currentValue); + const { targetParams, paramName } = _getTargetParamsForControl(target, nodeTracker); + const currentValue = targetParams[0].value; + const { min, max } = _getRangeForParam(paramName, targetParams, currentValue); const depthValue = depthabs != null ? depthabs : depth * currentValue; const envNode = getEnvelope(getAudioContext(), { ...filteredParams, @@ -525,17 +488,17 @@ function connectEnvelope(idx, params, nodeTracker, value) { function connectBusModulator(params, nodeTracker, value) { const ac = getAudioContext(); const { target, depth = 1, depthabs } = params; - const signal = controller.getBus(params.bus).output; + const signal = controller.getBus(params.bus); const dc = new ConstantSourceNode(ac, { offset: params.dc ?? 0 }); dc.start(params.begin); const shifted = dc.connect(gainNode(1)); signal.connect(shifted); - const { targetParams, data } = _getTargetParamsForControl(target, nodeTracker); - const currentValue = _getControlValue(target, value, data); - const { min, max } = _getControlClamp(data, currentValue); + const { targetParams, paramName } = _getTargetParamsForControl(target, nodeTracker); + const currentValue = targetParams[0].value; + const { min, max } = _getRangeForParam(paramName, targetParams, currentValue); const depthValue = depthabs != null ? depthabs : depth * currentValue; const maxAbsDepth = Math.min(Math.abs(min), Math.abs(max)); - const boundedDepth = Math.min(Math.abs(depthValue), maxAbsDepth); + const boundedDepth = Math.min(Math.abs(depthValue), maxAbsDepth) || Math.abs(depthValue); const modulator = shifted.connect(gainNode((Math.sign(depthValue) * boundedDepth) / 0.3)); webAudioTimeout( ac, diff --git a/packages/superdough/superdoughdata.mjs b/packages/superdough/superdoughdata.mjs index fe9bfe46d..cc161b0ee 100644 --- a/packages/superdough/superdoughdata.mjs +++ b/packages/superdough/superdoughdata.mjs @@ -4,92 +4,98 @@ Copyright (C) 2025 Strudel contributors - see . */ -const CONTROL_DATA = { - stretch: { param: 'stretch.pitchFactor', default: 1, min: -4, max: 100 }, - gain: { param: 'gain.gain', default: 0.8, min: 0, max: 10 }, - postgain: { param: 'post.gain', default: 1, min: 0, max: 10 }, - pan: { param: 'pan.pan', min: 0, max: 1 }, - tremolo: { param: 'tremolo.rate', min: 0, max: 40 }, - tremolosync: { param: 'tremolo.sync', min: 0, max: 8 }, - tremolodepth: { param: 'tremolo_gain.gain', default: 1, min: 0, max: 10 }, - tremoloskew: { param: 'tremolo.skew', min: 0, max: 1 }, - tremolophase: { param: 'tremolo.phase', default: 0, min: 0, max: 1 }, - tremoloshape: { param: 'tremolo.shape', min: 0, max: 4 }, +const CONTROL_TARGETS = { + stretch: { node: 'stretch', param: 'pitchFactor' }, + gain: { node: 'gain', param: 'gain' }, + postgain: { node: 'post', param: 'gain' }, + pan: { node: 'pan', param: 'pan' }, + tremolo: { node: 'tremolo', param: 'rate' }, + tremolosync: { node: 'tremolo', param: 'sync' }, + tremolodepth: { node: 'tremolo_gain', param: 'gain' }, + tremoloskew: { node: 'tremolo', param: 'skew' }, + tremolophase: { node: 'tremolo', param: 'phase' }, + tremoloshape: { node: 'tremolo', param: 'shape' }, + + // MODULATORS + lfo: { node: 'lfo', param: 'frequency' }, + env: { node: 'env', param: 'depth' }, + bmod: { node: 'bmod', param: 'depth' }, // LPF - cutoff: { param: 'lpf.frequency', min: 20, max: 24000 }, - resonance: { param: 'lpf.Q', min: 0.1, max: 30 }, - lprate: { param: 'lpf_lfo.rate', min: 0, max: 40 }, - lpsync: { param: 'lpf_lfo.sync', min: 0, max: 8 }, - lpdepth: { param: 'lpf_lfo.depth', min: 20, max: 24000 }, - lpdepthfrequency: { param: 'lpf_lfo.depth', min: 20, max: 24000 }, - lpshape: { param: 'lpf_lfo.shape', min: 0, max: 4 }, - lpdc: { param: 'lpf_lfo.dcoffset', min: -1, max: 1 }, - lpskew: { param: 'lpf_lfo.skew', min: 0, max: 1 }, + cutoff: { node: 'lpf', param: 'frequency' }, + resonance: { node: 'lpf', param: 'Q' }, + lprate: { node: 'lpf_lfo', param: 'rate' }, + lpsync: { node: 'lpf_lfo', param: 'sync' }, + lpdepth: { node: 'lpf_lfo', param: 'depth' }, + lpdepthfrequency: { node: 'lpf_lfo', param: 'depth' }, + lpshape: { node: 'lpf_lfo', param: 'shape' }, + lpdc: { node: 'lpf_lfo', param: 'dcoffset' }, + lpskew: { node: 'lpf_lfo', param: 'skew' }, // HPF - hcutoff: { param: 'hpf.frequency', min: 20, max: 24000 }, - hresonance: { param: 'hpf.Q', min: 0.1, max: 30 }, - hprate: { param: 'hpf_lfo.rate', min: 0, max: 40 }, - hpsync: { param: 'hpf_lfo.sync', min: 0, max: 8 }, - hpdepth: { param: 'hpf_lfo.depth', min: 20, max: 24000 }, - hpdepthfrequency: { param: 'hpf_lfo.depth', min: 20, max: 24000 }, - hpshape: { param: 'hpf_lfo.shape', min: 0, max: 4 }, - hpdc: { param: 'hpf_lfo.dcoffset', min: -1, max: 1 }, - hpskew: { param: 'hpf_lfo.skew', min: 0, max: 1 }, + hcutoff: { node: 'hpf', param: 'frequency' }, + hresonance: { node: 'hpf', param: 'Q' }, + hprate: { node: 'hpf_lfo', param: 'rate' }, + hpsync: { node: 'hpf_lfo', param: 'sync' }, + hpdepth: { node: 'hpf_lfo', param: 'depth' }, + hpdepthfrequency: { node: 'hpf_lfo', param: 'depth' }, + hpshape: { node: 'hpf_lfo', param: 'shape' }, + hpdc: { node: 'hpf_lfo', param: 'dcoffset' }, + hpskew: { node: 'hpf_lfo', param: 'skew' }, // BPF - bandf: { param: 'bpf.frequency', min: 20, max: 24000 }, - bandq: { param: 'bpf.Q', min: 0.1, max: 30 }, - bprate: { param: 'bpf_lfo.rate', min: 0, max: 40 }, - bpsync: { param: 'bpf_lfo.sync', min: 0, max: 8 }, - bpdepth: { param: 'bpf_lfo.depth', min: 20, max: 24000 }, - bpdepthfrequency: { param: 'bpf_lfo.depth', min: 20, max: 24000 }, - bpshape: { param: 'bpf_lfo.shape', min: 0, max: 4 }, - bpdc: { param: 'bpf_lfo.dcoffset', min: -1, max: 1 }, - bpskew: { param: 'bpf_lfo.skew', min: 0, max: 1 }, + bandf: { node: 'bpf', param: 'frequency' }, + bandq: { node: 'bpf', param: 'Q' }, + bprate: { node: 'bpf_lfo', param: 'rate' }, + bpsync: { node: 'bpf_lfo', param: 'sync' }, + bpdepth: { node: 'bpf_lfo', param: 'depth' }, + bpdepthfrequency: { node: 'bpf_lfo', param: 'depth' }, + bpshape: { node: 'bpf_lfo', param: 'shape' }, + bpdc: { node: 'bpf_lfo', param: 'dcoffset' }, + bpskew: { node: 'bpf_lfo', param: 'skew' }, - vowel: { param: 'vowel.frequency', min: 200, max: 4000 }, + vowel: { node: 'vowel', param: 'frequency' }, // DISTORTION - coarse: { param: 'coarse.coarse', min: 1, max: 64 }, - crush: { param: 'crush.crush', min: 1, max: 16 }, - shape: { param: 'shape.shape', min: -1, max: 0.999 }, - shapevol: { param: 'shape.postgain', default: 1, min: 0, max: 1 }, - distort: { param: 'distort.distort', min: 0, max: 5 }, - distortvol: { param: 'distort.postgain', default: 1, min: 0, max: 1 }, + coarse: { node: 'coarse', param: 'coarse' }, + crush: { node: 'crush', param: 'crush' }, + shape: { node: 'shape', param: 'shape' }, + shapevol: { node: 'shape', param: 'postgain' }, + distort: { node: 'distort', param: 'distort' }, + distortvol: { node: 'distort', param: 'postgain' }, // COMPRESSOR - compressor: { param: 'compressor.threshold', default: -3, min: -100, max: 0 }, - compressorRatio: { param: 'compressor.ratio', default: 10, min: 1, max: 20 }, - compressorKnee: { param: 'compressor.knee', default: 10, min: 0, max: 40 }, - compressorAttack: { param: 'compressor.attack', default: 0.005, min: 0, max: 1 }, - compressorRelease: { param: 'compressor.release', default: 0.05, min: 0, max: 2 }, + compressor: { node: 'compressor', param: 'threshold' }, + compressorRatio: { node: 'compressor', param: 'ratio' }, + compressorKnee: { node: 'compressor', param: 'knee' }, + compressorAttack: { node: 'compressor', param: 'attack' }, + compressorRelease: { node: 'compressor', param: 'release' }, // PHASER - phaserrate: { param: 'phaser.rate', min: 0, max: 40 }, - phaserdepth: { param: 'phaser.depth', default: 0.75, min: 0, max: 1 }, - phasersweep: { param: 'phaser.sweep', min: 0, max: 5000 }, - phasercenter: { param: 'phaser.frequency', min: 20, max: 24000 }, + phaserrate: { node: 'phaser', param: 'rate' }, + phaserdepth: { node: 'phaser', param: 'depth' }, + phasersweep: { node: 'phaser', param: 'sweep' }, + phasercenter: { node: 'phaser', param: 'frequency' }, // ORBIT EFFECTS - delaytime: { param: 'delay.delayTime', min: 0, max: 4 }, - delayfeedback: { param: 'delay.feedback', default: 0.5, min: 0, max: 0.98 }, - delaysync: { param: 'delay.sync', default: 3 / 16, min: 0, max: 2 }, - dry: { param: 'dry.gain', min: 0, max: 1 }, - room: { param: 'room.wet', min: 0, max: 1 }, - roomfade: { param: 'room.fade', min: 0, max: 1 }, - roomlp: { param: 'room.lp', min: 20, max: 24000 }, - djf: { param: 'djf.value', min: 0, max: 1 }, - busgain: { param: 'bus.gain', default: 1, min: 0, max: 10 }, + delaytime: { node: 'delay', param: 'delayTime' }, + delayfeedback: { node: 'delay', param: 'feedback' }, + delaysync: { node: 'delay', param: 'sync' }, + dry: { node: 'dry', param: 'gain' }, + room: { node: 'room', param: 'wet' }, + roomfade: { node: 'room', param: 'fade' }, + roomlp: { node: 'room', param: 'lp' }, + djf: { node: 'djf', param: 'value' }, + busgain: { node: 'bus', param: 'gain' }, // SYNTHS - detune: { param: 'source.detune', min: 0, max: 1 }, - wt: { param: 'source.position', min: 0, max: 1 }, - warp: { param: 'source.warp', min: 0, max: 1 }, - freq: { param: 'source.frequency', min: 20, max: 24000 }, + s: { node: 'source', param: 'frequency' }, + detune: { node: 'source', param: 'detune' }, + wt: { node: 'source', param: 'position' }, + warp: { node: 'source', param: 'warp' }, + freq: { node: 'source', param: 'frequency' }, }; -export function getSuperdoughControlData() { - return CONTROL_DATA; +export function getSuperdoughControlTargets() { + return CONTROL_TARGETS; } diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index 3686e13d1..35c0bd041 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -374,7 +374,7 @@ export function registerSynthSounds() { const [attack, decay, sustain, release] = getADSRValues( [value.attack, value.decay, value.sustain, value.release], 'linear', - [0.001, 0.05, 0.6, 0.01], + [0.001, 0.05, 1, 0.01], ); const holdend = begin + value.duration; const end = holdend + release + 0.01;