Broken tests, but otherwise working

This commit is contained in:
Aria 2025-12-11 14:42:16 -06:00
parent 45aa1f5384
commit e7d1613bfb
4 changed files with 128 additions and 152 deletions

View file

@ -26,6 +26,7 @@ import {
} from './util.mjs'; } from './util.mjs';
import drawLine from './drawLine.mjs'; import drawLine from './drawLine.mjs';
import { errorLogger, logger } from './logger.mjs'; import { errorLogger, logger } from './logger.mjs';
import { getControlName } from './controls.mjs';
let stringParser; let stringParser;
@ -3753,28 +3754,34 @@ Pattern.prototype.modulate = function (type, config, idx) {
if (config == null || typeof config !== 'object') { if (config == null || typeof config !== 'object') {
return this; 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'`); logger(`[core] Modulation type ${type} not found. Please use one of 'lfo', 'env', 'bmod'`);
return this; return this;
} }
let output = this; let output = this;
let target = config.target; let defaultValue = {};
let defaultSet = 'target' in config;
for (const [rawKey, value] of Object.entries(config)) { for (const [rawKey, value] of Object.entries(config)) {
const key = resolveConfigKey(type, rawKey); const key = resolveConfigKey(type, rawKey);
if (key === 'target') continue; // we will set/default it below
const valuePat = reify(value); const valuePat = reify(value);
output = output output = output
.fmap((v) => (c) => { .fmap((v) => (c) => {
if (target == null) { if (!defaultSet) {
// default target to the control set just before this in the chain // default target to the control set just before this in the chain
// e.g. pat.gain(0.5).lfo({..}) will be a gain-LFO // 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] ??= []; v[type] ??= [];
const t = v[type]; const t = v[type];
idx ??= t.length; idx ??= t.length;
t[idx] ??= { target }; // set target t[idx] ??= defaultValue;
t[idx][key] = c; t[idx][key] = key === 'target' ? getControlName(c) : c;
return v; return v;
}) })
.appLeft(valuePat); .appLeft(valuePat);

View file

@ -25,7 +25,7 @@ import { errorLogger, logger } from './logger.mjs';
import { loadBuffer } from './sampler.mjs'; import { loadBuffer } from './sampler.mjs';
import { getAudioContext } from './audioContext.mjs'; import { getAudioContext } from './audioContext.mjs';
import { SuperdoughAudioController } from './superdoughoutput.mjs'; import { SuperdoughAudioController } from './superdoughoutput.mjs';
import { getSuperdoughControlData } from './superdoughdata.mjs'; import { getSuperdoughControlTargets } from './superdoughdata.mjs';
export const DEFAULT_MAX_POLYPHONY = 128; export const DEFAULT_MAX_POLYPHONY = 128;
const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard'; const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard';
@ -396,97 +396,60 @@ function _getNodeParams(node) {
return Array.from(params); return Array.from(params);
} }
const targetToParamGuess = { const controlTargets = getSuperdoughControlTargets();
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 _stripIndex = (control) => control?.replace(/\d+$/, '');
function _getControlData(control) { function _getControlData(control) {
return controlData[_getMainName(control)]; return controlTargets[_stripIndex(control)];
} }
function _getControlValue(control, value, data) { function _getRangeForParam(paramName, targetParams, currentValue) {
const main = _getMainName(control); if (paramName === 'frequency') {
return Number(value[main] ?? data.default); const liveValue = targetParams?.[0]?.value ?? currentValue ?? 0;
} return { min: 20 - liveValue, max: 24000 - liveValue };
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];
} }
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) { if (!targetNodes) {
const keys = Object.keys(nodes); const keys = Object.keys(nodes);
errorLogger( 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', 'superdough',
); );
return []; return { targetParams: [], paramName };
} }
const audioParams = []; const audioParams = [];
targetNodes.forEach((targetNode) => { targetNodes.forEach((targetNode) => {
const targetParam = _getNodeParam(targetNode, param); const targetParam = _getNodeParam(targetNode, paramName);
if (!targetParam) { if (!targetParam) {
const available = _getNodeParams(targetNode); const available = _getNodeParams(targetNode);
errorLogger( 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', 'superdough',
); );
return; return;
} }
audioParams.push(targetParam); audioParams.push(targetParam);
}); });
return audioParams; return { targetParams: audioParams, paramName };
}
function _getTargetParamsForControl(control, nodes) {
const main = _getMainName(control);
const data = _getControlData(main);
const targetParams = _getTargetParams(nodes, data.param);
return { targetParams, data };
} }
function connectLFO(idx, params, nodeTracker, value) { function connectLFO(idx, params, nodeTracker, value) {
const { rate = 1, sync, cps, cycle, target, depth = 1, depthabs, ...filteredParams } = params; const { rate = 1, sync, cps, cycle, target = 'lfo', depth = 1, depthabs, param, p, ...filteredParams } = params;
const { targetParams, data } = _getTargetParamsForControl(target, nodeTracker); const targetParam = param ?? p;
const currentValue = _getControlValue(target, value, data); const { targetParams, paramName } = _getTargetParamsForControl(target, nodeTracker, targetParam);
const { min, max } = _getControlClamp(data, currentValue); const currentValue = targetParams[0].value;
const { min, max } = _getRangeForParam(paramName, targetParams, currentValue);
const depthValue = depthabs != null ? depthabs : depth * currentValue; const depthValue = depthabs != null ? depthabs : depth * currentValue;
const modParams = { const modParams = {
...filteredParams, ...filteredParams,
@ -504,9 +467,9 @@ function connectLFO(idx, params, nodeTracker, value) {
function connectEnvelope(idx, params, nodeTracker, value) { function connectEnvelope(idx, params, nodeTracker, value) {
const { target, acurve, dcurve, rcurve, depth = 1, depthabs, ...filteredParams } = params; const { target, acurve, dcurve, rcurve, depth = 1, depthabs, ...filteredParams } = params;
const { targetParams, data } = _getTargetParamsForControl(target, nodeTracker); const { targetParams, paramName } = _getTargetParamsForControl(target, nodeTracker);
const currentValue = _getControlValue(target, value, data); const currentValue = targetParams[0].value;
const { min, max } = _getControlClamp(data, currentValue); const { min, max } = _getRangeForParam(paramName, targetParams, currentValue);
const depthValue = depthabs != null ? depthabs : depth * currentValue; const depthValue = depthabs != null ? depthabs : depth * currentValue;
const envNode = getEnvelope(getAudioContext(), { const envNode = getEnvelope(getAudioContext(), {
...filteredParams, ...filteredParams,
@ -525,17 +488,17 @@ function connectEnvelope(idx, params, nodeTracker, value) {
function connectBusModulator(params, nodeTracker, value) { function connectBusModulator(params, nodeTracker, value) {
const ac = getAudioContext(); const ac = getAudioContext();
const { target, depth = 1, depthabs } = params; 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 }); const dc = new ConstantSourceNode(ac, { offset: params.dc ?? 0 });
dc.start(params.begin); dc.start(params.begin);
const shifted = dc.connect(gainNode(1)); const shifted = dc.connect(gainNode(1));
signal.connect(shifted); signal.connect(shifted);
const { targetParams, data } = _getTargetParamsForControl(target, nodeTracker); const { targetParams, paramName } = _getTargetParamsForControl(target, nodeTracker);
const currentValue = _getControlValue(target, value, data); const currentValue = targetParams[0].value;
const { min, max } = _getControlClamp(data, currentValue); const { min, max } = _getRangeForParam(paramName, targetParams, currentValue);
const depthValue = depthabs != null ? depthabs : depth * currentValue; const depthValue = depthabs != null ? depthabs : depth * currentValue;
const maxAbsDepth = Math.min(Math.abs(min), Math.abs(max)); 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)); const modulator = shifted.connect(gainNode((Math.sign(depthValue) * boundedDepth) / 0.3));
webAudioTimeout( webAudioTimeout(
ac, ac,

View file

@ -4,92 +4,98 @@ Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
const CONTROL_DATA = { const CONTROL_TARGETS = {
stretch: { param: 'stretch.pitchFactor', default: 1, min: -4, max: 100 }, stretch: { node: 'stretch', param: 'pitchFactor' },
gain: { param: 'gain.gain', default: 0.8, min: 0, max: 10 }, gain: { node: 'gain', param: 'gain' },
postgain: { param: 'post.gain', default: 1, min: 0, max: 10 }, postgain: { node: 'post', param: 'gain' },
pan: { param: 'pan.pan', min: 0, max: 1 }, pan: { node: 'pan', param: 'pan' },
tremolo: { param: 'tremolo.rate', min: 0, max: 40 }, tremolo: { node: 'tremolo', param: 'rate' },
tremolosync: { param: 'tremolo.sync', min: 0, max: 8 }, tremolosync: { node: 'tremolo', param: 'sync' },
tremolodepth: { param: 'tremolo_gain.gain', default: 1, min: 0, max: 10 }, tremolodepth: { node: 'tremolo_gain', param: 'gain' },
tremoloskew: { param: 'tremolo.skew', min: 0, max: 1 }, tremoloskew: { node: 'tremolo', param: 'skew' },
tremolophase: { param: 'tremolo.phase', default: 0, min: 0, max: 1 }, tremolophase: { node: 'tremolo', param: 'phase' },
tremoloshape: { param: 'tremolo.shape', min: 0, max: 4 }, tremoloshape: { node: 'tremolo', param: 'shape' },
// MODULATORS
lfo: { node: 'lfo', param: 'frequency' },
env: { node: 'env', param: 'depth' },
bmod: { node: 'bmod', param: 'depth' },
// LPF // LPF
cutoff: { param: 'lpf.frequency', min: 20, max: 24000 }, cutoff: { node: 'lpf', param: 'frequency' },
resonance: { param: 'lpf.Q', min: 0.1, max: 30 }, resonance: { node: 'lpf', param: 'Q' },
lprate: { param: 'lpf_lfo.rate', min: 0, max: 40 }, lprate: { node: 'lpf_lfo', param: 'rate' },
lpsync: { param: 'lpf_lfo.sync', min: 0, max: 8 }, lpsync: { node: 'lpf_lfo', param: 'sync' },
lpdepth: { param: 'lpf_lfo.depth', min: 20, max: 24000 }, lpdepth: { node: 'lpf_lfo', param: 'depth' },
lpdepthfrequency: { param: 'lpf_lfo.depth', min: 20, max: 24000 }, lpdepthfrequency: { node: 'lpf_lfo', param: 'depth' },
lpshape: { param: 'lpf_lfo.shape', min: 0, max: 4 }, lpshape: { node: 'lpf_lfo', param: 'shape' },
lpdc: { param: 'lpf_lfo.dcoffset', min: -1, max: 1 }, lpdc: { node: 'lpf_lfo', param: 'dcoffset' },
lpskew: { param: 'lpf_lfo.skew', min: 0, max: 1 }, lpskew: { node: 'lpf_lfo', param: 'skew' },
// HPF // HPF
hcutoff: { param: 'hpf.frequency', min: 20, max: 24000 }, hcutoff: { node: 'hpf', param: 'frequency' },
hresonance: { param: 'hpf.Q', min: 0.1, max: 30 }, hresonance: { node: 'hpf', param: 'Q' },
hprate: { param: 'hpf_lfo.rate', min: 0, max: 40 }, hprate: { node: 'hpf_lfo', param: 'rate' },
hpsync: { param: 'hpf_lfo.sync', min: 0, max: 8 }, hpsync: { node: 'hpf_lfo', param: 'sync' },
hpdepth: { param: 'hpf_lfo.depth', min: 20, max: 24000 }, hpdepth: { node: 'hpf_lfo', param: 'depth' },
hpdepthfrequency: { param: 'hpf_lfo.depth', min: 20, max: 24000 }, hpdepthfrequency: { node: 'hpf_lfo', param: 'depth' },
hpshape: { param: 'hpf_lfo.shape', min: 0, max: 4 }, hpshape: { node: 'hpf_lfo', param: 'shape' },
hpdc: { param: 'hpf_lfo.dcoffset', min: -1, max: 1 }, hpdc: { node: 'hpf_lfo', param: 'dcoffset' },
hpskew: { param: 'hpf_lfo.skew', min: 0, max: 1 }, hpskew: { node: 'hpf_lfo', param: 'skew' },
// BPF // BPF
bandf: { param: 'bpf.frequency', min: 20, max: 24000 }, bandf: { node: 'bpf', param: 'frequency' },
bandq: { param: 'bpf.Q', min: 0.1, max: 30 }, bandq: { node: 'bpf', param: 'Q' },
bprate: { param: 'bpf_lfo.rate', min: 0, max: 40 }, bprate: { node: 'bpf_lfo', param: 'rate' },
bpsync: { param: 'bpf_lfo.sync', min: 0, max: 8 }, bpsync: { node: 'bpf_lfo', param: 'sync' },
bpdepth: { param: 'bpf_lfo.depth', min: 20, max: 24000 }, bpdepth: { node: 'bpf_lfo', param: 'depth' },
bpdepthfrequency: { param: 'bpf_lfo.depth', min: 20, max: 24000 }, bpdepthfrequency: { node: 'bpf_lfo', param: 'depth' },
bpshape: { param: 'bpf_lfo.shape', min: 0, max: 4 }, bpshape: { node: 'bpf_lfo', param: 'shape' },
bpdc: { param: 'bpf_lfo.dcoffset', min: -1, max: 1 }, bpdc: { node: 'bpf_lfo', param: 'dcoffset' },
bpskew: { param: 'bpf_lfo.skew', min: 0, max: 1 }, bpskew: { node: 'bpf_lfo', param: 'skew' },
vowel: { param: 'vowel.frequency', min: 200, max: 4000 }, vowel: { node: 'vowel', param: 'frequency' },
// DISTORTION // DISTORTION
coarse: { param: 'coarse.coarse', min: 1, max: 64 }, coarse: { node: 'coarse', param: 'coarse' },
crush: { param: 'crush.crush', min: 1, max: 16 }, crush: { node: 'crush', param: 'crush' },
shape: { param: 'shape.shape', min: -1, max: 0.999 }, shape: { node: 'shape', param: 'shape' },
shapevol: { param: 'shape.postgain', default: 1, min: 0, max: 1 }, shapevol: { node: 'shape', param: 'postgain' },
distort: { param: 'distort.distort', min: 0, max: 5 }, distort: { node: 'distort', param: 'distort' },
distortvol: { param: 'distort.postgain', default: 1, min: 0, max: 1 }, distortvol: { node: 'distort', param: 'postgain' },
// COMPRESSOR // COMPRESSOR
compressor: { param: 'compressor.threshold', default: -3, min: -100, max: 0 }, compressor: { node: 'compressor', param: 'threshold' },
compressorRatio: { param: 'compressor.ratio', default: 10, min: 1, max: 20 }, compressorRatio: { node: 'compressor', param: 'ratio' },
compressorKnee: { param: 'compressor.knee', default: 10, min: 0, max: 40 }, compressorKnee: { node: 'compressor', param: 'knee' },
compressorAttack: { param: 'compressor.attack', default: 0.005, min: 0, max: 1 }, compressorAttack: { node: 'compressor', param: 'attack' },
compressorRelease: { param: 'compressor.release', default: 0.05, min: 0, max: 2 }, compressorRelease: { node: 'compressor', param: 'release' },
// PHASER // PHASER
phaserrate: { param: 'phaser.rate', min: 0, max: 40 }, phaserrate: { node: 'phaser', param: 'rate' },
phaserdepth: { param: 'phaser.depth', default: 0.75, min: 0, max: 1 }, phaserdepth: { node: 'phaser', param: 'depth' },
phasersweep: { param: 'phaser.sweep', min: 0, max: 5000 }, phasersweep: { node: 'phaser', param: 'sweep' },
phasercenter: { param: 'phaser.frequency', min: 20, max: 24000 }, phasercenter: { node: 'phaser', param: 'frequency' },
// ORBIT EFFECTS // ORBIT EFFECTS
delaytime: { param: 'delay.delayTime', min: 0, max: 4 }, delaytime: { node: 'delay', param: 'delayTime' },
delayfeedback: { param: 'delay.feedback', default: 0.5, min: 0, max: 0.98 }, delayfeedback: { node: 'delay', param: 'feedback' },
delaysync: { param: 'delay.sync', default: 3 / 16, min: 0, max: 2 }, delaysync: { node: 'delay', param: 'sync' },
dry: { param: 'dry.gain', min: 0, max: 1 }, dry: { node: 'dry', param: 'gain' },
room: { param: 'room.wet', min: 0, max: 1 }, room: { node: 'room', param: 'wet' },
roomfade: { param: 'room.fade', min: 0, max: 1 }, roomfade: { node: 'room', param: 'fade' },
roomlp: { param: 'room.lp', min: 20, max: 24000 }, roomlp: { node: 'room', param: 'lp' },
djf: { param: 'djf.value', min: 0, max: 1 }, djf: { node: 'djf', param: 'value' },
busgain: { param: 'bus.gain', default: 1, min: 0, max: 10 }, busgain: { node: 'bus', param: 'gain' },
// SYNTHS // SYNTHS
detune: { param: 'source.detune', min: 0, max: 1 }, s: { node: 'source', param: 'frequency' },
wt: { param: 'source.position', min: 0, max: 1 }, detune: { node: 'source', param: 'detune' },
warp: { param: 'source.warp', min: 0, max: 1 }, wt: { node: 'source', param: 'position' },
freq: { param: 'source.frequency', min: 20, max: 24000 }, warp: { node: 'source', param: 'warp' },
freq: { node: 'source', param: 'frequency' },
}; };
export function getSuperdoughControlData() { export function getSuperdoughControlTargets() {
return CONTROL_DATA; return CONTROL_TARGETS;
} }

View file

@ -374,7 +374,7 @@ export function registerSynthSounds() {
const [attack, decay, sustain, release] = getADSRValues( const [attack, decay, sustain, release] = getADSRValues(
[value.attack, value.decay, value.sustain, value.release], [value.attack, value.decay, value.sustain, value.release],
'linear', 'linear',
[0.001, 0.05, 0.6, 0.01], [0.001, 0.05, 1, 0.01],
); );
const holdend = begin + value.duration; const holdend = begin + value.duration;
const end = holdend + release + 0.01; const end = holdend + release + 0.01;