diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs
index f28d13f6..1bf3fa85 100644
--- a/packages/core/controls.mjs
+++ b/packages/core/controls.mjs
@@ -4,7 +4,8 @@ Copyright (C) 2022 Strudel contributors - see .
*/
-import { Pattern, register, reify } from './pattern.mjs';
+import { logger } from './logger.mjs';
+import { Pattern, pure, register, reify } from './pattern.mjs';
export function createParam(names) {
let isMulti = Array.isArray(names);
@@ -2044,6 +2045,28 @@ export const { octave, oct } = registerControl('octave', 'oct');
* )
*/
export const { orbit } = registerControl('orbit', 'o');
+
+/**
+ * A `bus` is a send which can be used for mixing patterns. It combines with..
+ * s("bus") to play that bus through another pattern (for, say, applying non-linear
+ * effects like distortion to multiple signals)
+ *
+ * otherPat.bmod(..) (to modulate another pattern with the bus)
+ *
+ * @name bus
+ * @param {number | Pattern} number
+ */
+export const { bus } = registerControl('bus');
+
+/**
+ * Postgain multiplier prior to sending the signal to the audio bus.
+ *
+ * @name busgain
+ * @synonyms bgain
+ * @param {number | Pattern} number
+ */
+export const { busgain, bgain } = registerControl('busgain', 'bgain');
+
// TODO: what is this? not found in tidal doc Answer: gain is limited to maximum of 2. This allows you to go over that
export const { overgain } = registerControl('overgain');
// TODO: what is this? not found in tidal doc. Similar to above, but limited to 1
@@ -2088,8 +2111,7 @@ export const { panorient } = registerControl('panorient');
// ['pitch2'],
// ['pitch3'],
// ['portamento'],
-// TODO: LFO rate see https://tidalcycles.org/docs/patternlib/tutorials/synthesizers/#supersquare
-export const { rate } = registerControl('rate');
+
// TODO: slide param for certain synths
export const { slide } = registerControl('slide');
// TODO: detune? https://tidalcycles.org/docs/patternlib/tutorials/synthesizers/#supersquare
@@ -2515,7 +2537,6 @@ export const { curve } = registerControl('curve');
export const { deltaSlide } = registerControl('deltaSlide');
export const { pitchJump } = registerControl('pitchJump');
export const { pitchJumpTime } = registerControl('pitchJumpTime');
-export const { lfo, repeatTime } = registerControl('lfo', 'repeatTime');
// noise on the frequency or as bubo calls it "frequency fog" :)
export const { znoise } = registerControl('znoise');
export const { zmod } = registerControl('zmod');
@@ -2792,6 +2813,245 @@ export const scrub = register(
false,
);
+const subControlAliases = new Map();
+const registerSubControl = (control, subControl, ...aliases) => {
+ const aliasMap = subControlAliases.get(control) ?? new Map();
+ const allKeys = new Set([subControl, ...aliases]);
+ for (const alias of allKeys) {
+ aliasMap.set(String(alias).toLowerCase(), subControl);
+ }
+ subControlAliases.set(control, aliasMap);
+};
+
+const registerSubControls = (control, subControlAliases = []) => {
+ for (const [subControl, ...aliases] of subControlAliases) {
+ registerSubControl(control, subControl, ...aliases);
+ }
+};
+
+const getMainSubcontrolName = (control, subKey) => {
+ const aliasMap = subControlAliases.get(control);
+ if (!aliasMap) return subKey;
+ return aliasMap.get(String(subKey).toLowerCase()) ?? subKey;
+};
+
+registerSubControls('lfo', [
+ ['control', 'c'],
+ ['subControl', 'sc'],
+ ['rate', 'r'],
+ ['depth', 'dep', 'dr'],
+ ['depthabs', 'da'],
+ ['dcoffset', 'dc'],
+ ['shape', 'sh'],
+ ['skew', 'sk'],
+ ['curve'],
+ ['sync', 's'],
+]);
+registerSubControls('env', [
+ ['control', 'c'],
+ ['subControl', 'sc'],
+ ['attack', 'att', 'a'],
+ ['decay', 'dec', 'd'],
+ ['sustain', 'sus', 's'],
+ ['release', 'rel', 'r'],
+ ['depth', 'dep', 'dr'],
+ ['depthabs', 'da'],
+ ['acurve', 'ac'],
+ ['dcurve', 'dc'],
+ ['rcurve', 'rc'],
+]);
+registerSubControls('bmod', [
+ ['bus', 'b'],
+ ['control', 'c'],
+ ['subControl', 'sc'],
+ ['depth', 'dep', 'dr'],
+ ['depthabs', 'da'],
+ ['dc'],
+]);
+
+Pattern.prototype.modulate = function (type, config, id) {
+ config = { control: undefined, ...config };
+ 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 defaultValue = undefined;
+ for (const [rawKey, value] of Object.entries(config)) {
+ const key = getMainSubcontrolName(type, rawKey);
+ const valuePat = reify(value);
+ output = output
+ .fmap((v) => (c) => {
+ if (defaultValue === undefined) {
+ // default control to the control set just before this in the chain
+ // e.g. pat.gain(0.5).lfo({..}) will be a gain-LFO
+ let control = getControlName(Object.keys(v).at(-1));
+ if (modulatorKeys.includes(control)) {
+ control = `${control}_${[...v[control].__ids].at(-1)}`;
+ }
+ defaultValue = control;
+ }
+ v[type] ??= { __ids: new Set() };
+ const t = v[type];
+ id ??= t.__ids.size;
+ t[id] ??= { control: defaultValue };
+ t.__ids.add(id); // keeps track of insertion order
+ if (c === undefined) return v;
+ if (key === 'control' || key === 'subControl') {
+ t[id][key] = getControlName(c);
+ } else {
+ t[id][key] = c;
+ }
+ return v;
+ })
+ .appLeft(valuePat);
+ }
+ return output;
+};
+
+/**
+ * Configures an LFO. Can be called in sequence like pat.lfo(...).lfo(...) to set up multiple LFOs.
+ * There are two ways to declare which control will be modulated:
+ * 1. Explicitly put `control` in the config (e.g. `lfo({ c: "lpf" })`)
+ * 2. If the control parameter is absent, the control _immediately before_ the `lfo` call will be used
+ * (e.g. `s("saw").lpf(500).lfo()` to modulate `lpf`)
+ *
+ * Modulators can be referred to by `id` so that they can be updated later e.g. inside
+ * a `sometimes`. See example below.
+ *
+ * @name lfo
+ * @param {Object} config LFO configuration.
+ * @param {string | Pattern} [config.control] Node to modulate. Aliases: t
+ * @param {string | Pattern} [config.subControl] Sub-control name to append to the control key. Aliases: sc, p
+ * @param {number | Pattern} [config.rate] Modulation rate. Aliases: rate, r
+ * @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.dcoffset] DC offset / bias for the waveform. Aliases: dc
+ * @param {number | Pattern} [config.shape] Shape index. Aliases: sh
+ * @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 {string | Pattern} id ID to use for this modulator
+ * @returns Pattern
+ *
+ * @example
+ * s("saw").note("F1").lpf(500).lfo()
+ *
+ * @example
+ * s("saw").lfo().lpf(500).lfo({ s: 0.3 })
+ *
+ * @example
+ * s("saw").lpf(500).diode(0.3)
+ * .lfo({ c: "lpf" })
+ *
+ * @example
+ * s("pulse").lpf(500).lfo()
+ * .lfo({ c: "s" })
+ * .diode(0.3)
+ * .sometimes(x => x.lfo({ s: "8" }, 1)) // lfo #1 (0-indexed)
+ *
+ * @example
+ * s("pulse").lpf(500).lfo({ depth: 4 }, 'lpf_mod')
+ * .lfo({ c: "s" })
+ * .diode(0.3)
+ * .sometimes(x => x.lfo({ s: "8" }, 'lpf_mod'))
+ */
+Pattern.prototype.lfo = function (config, id) {
+ return this.modulate('lfo', config, id);
+};
+export const lfo = (config) => pure({}).lfo(config);
+
+/**
+ * Configures an envelope. Can be called in sequence like pat.env(...).env(...) to set up multiple envelopes
+ * There are two ways to declare which control will be modulated:
+ * 1. Explicitly put `control` in the config (e.g. `env({ c: "lpf" })`)
+ * 2. If the control parameter is absent, the control _immediately before_ the `env` call will be used
+ * (e.g. `s("saw").lpf(500).env({ a: 1 })` to modulate `lpf`)
+ *
+ * Modulators can be referred to by `id` so that they can be updated later e.g. inside
+ * a `sometimes`. See example below.
+ *
+ * @name env
+ * @param {Object} config Envelope configuration.
+ * @param {string | Pattern} [config.control] Node to modulate. Aliases: t
+ * @param {string | Pattern} [config.subControl] Sub-control name to append to the control key. Aliases: sc, p
+ * @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.attack] Time to reach depth. Aliases: att, a
+ * @param {number | Pattern} [config.decay] Time to reach sustain. Aliases: dec, d
+ * @param {number | Pattern} [config.sustain] Sustain depth. Aliases: sus, s
+ * @param {number | Pattern} [config.release] Time to return to nominal value. Aliases: rel, r
+ * @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 {string | Pattern} id ID to use for this modulator
+ * @returns Pattern
+ *
+ * @example
+ * s("saw").note("F1").lpf(500).env({ a: 1 })
+ *
+ * @example
+ * s("saw").env({ d: 1 }).note("F1")
+ * .lpq(4).lpf(50)
+ * .env({ a: 0.1, d: 1, ac: 0.8, dc: 0.3, depth: 50 })
+ *
+ * @example
+ * s("saw").lpf(500).diode(0.3)
+ * .env({ c: "lpf", a: 0.5, d: 0.5 })
+ *
+ * @example
+ * s("pulse").lpf(500).env({ a: 1 })
+ * .env({ c: "s", a: 1 })
+ * .diode(0.3)
+ * .sometimes(x => x.env({ a: "0.5" }, 1)) // envelope #1 (0-indexed)
+ *
+ * @example
+ * s("pulse").lpf(500).env({ a: 1 }, 'lpf_mod')
+ * .env({ c: "s", a: 1 })
+ * .diode(0.3)
+ * .sometimes(x => x.env({ a: "0.5" }, 'lpf_mod'))
+ */
+Pattern.prototype.env = function (config, id) {
+ return this.modulate('env', config, id);
+};
+export const env = (config) => pure({}).env(config);
+
+/**
+ * Modulates with the output from a given `bus`.
+ * Can be called in sequence like pat.bmod(...).bmod(...) to set up multiple modulators
+ *
+ * Send to an audio bus with `otherPat.bus(..)`.
+ *
+ * There are two ways to declare which control will be modulated:
+ * 1. Explicitly put `control` in the config (e.g. `bmod({ id: 2, c: "lpf" })`)
+ * 2. If the control parameter is absent, the control _immediately before_ the `bmod` call will be used
+ * (e.g. `s("saw").lpf(500).bmod({ id: 2 })` to modulate `lpf`)
+ *
+ * Modulators can be referred to by `id` so that they can be updated later e.g. inside
+ * a `sometimes`. See example below.
+ *
+ * @name bmod
+ * @param {Object} config Bus modulation configuration.
+ * @param {string | Pattern} [config.bus] Bus to get modulation signal from
+ * @param {string | Pattern} [config.control] Node to modulate. Aliases: t
+ * @param {string | Pattern} [config.subControl] Sub-control name to append to the control key. Aliases: sc, p
+ * @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 {string | Pattern} id ID to use for this modulator
+ * @returns Pattern
+ *
+ * @example
+ * modulator: s("one").seg(64).gain(slider(0, 0, 1)).bus(1).dry(0)
+ * carrier: s("saw").bmod({ b: 1 })
+ *
+ */
+Pattern.prototype.bmod = function (config, id) {
+ return this.modulate('bmod', config, id);
+};
+export const bmod = (config) => pure({}).bmod(config);
+
/**
* Transient shaper. Gives independent control over the emphasis on transients
* and sustains
diff --git a/packages/soundfonts/fontloader.mjs b/packages/soundfonts/fontloader.mjs
index 95e21089..b4ae5982 100644
--- a/packages/soundfonts/fontloader.mjs
+++ b/packages/soundfonts/fontloader.mjs
@@ -166,7 +166,7 @@ export function registerSoundfonts() {
let envEnd = holdEnd + release + 0.01;
// vibrato
- let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, time);
+ const vibratoHandle = getVibratoOscillator(bufferSource.detune, value, time);
// pitch envelope
getPitchEnvelope(bufferSource.detune, value, time, holdEnd);
@@ -174,10 +174,10 @@ export function registerSoundfonts() {
const stop = (releaseTime) => {};
onceEnded(bufferSource, () => {
releaseAudioNode(bufferSource);
- releaseAudioNode(vibratoOscillator);
+ vibratoHandle?.stop();
onended();
});
- return { node, stop };
+ return { node, stop, nodes: { source: [bufferSource], ...vibratoHandle?.nodes } };
},
{ type: 'soundfont', prebake: true, fonts },
);
diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs
index 6278251d..8bd8b067 100644
--- a/packages/superdough/helpers.mjs
+++ b/packages/superdough/helpers.mjs
@@ -104,22 +104,40 @@ function getModulationShapeInput(val) {
return { tri: 0, triangle: 0, sine: 1, ramp: 2, saw: 3, square: 4 }[val] ?? 0;
}
-export function getLfo(audioContext, begin, end, properties = {}) {
- const { shape = 0, ...props } = properties;
- const { dcoffset = -0.5, depth = 1 } = properties;
+export function getEnvelope(audioContext, properties = {}) {
+ return getWorklet(audioContext, 'envelope-processor', properties);
+}
+
+export function getLfo(audioContext, properties = {}) {
+ const {
+ shape = 0,
+ begin = 0,
+ end = 0,
+ time,
+ depth = 1,
+ dcoffset = -0.5,
+ frequency = 1,
+ skew = 0.5,
+ phaseoffset = 0,
+ curve = 1,
+ min,
+ max,
+ ...props
+ } = properties;
+
const lfoprops = {
- frequency: 1,
- depth,
- skew: 0.5,
- phaseoffset: 0,
- time: begin,
begin,
end,
- shape: getModulationShapeInput(shape),
+ time: time ?? begin,
+ depth,
dcoffset,
- min: dcoffset * depth,
- max: dcoffset * depth + depth,
- curve: 1,
+ frequency,
+ skew,
+ phaseoffset,
+ curve,
+ shape: getModulationShapeInput(shape),
+ min: min ?? dcoffset * depth,
+ max: max ?? dcoffset * depth + depth,
...props,
};
@@ -161,7 +179,9 @@ export function getParamLfo(audioContext, param, start, end, lfoValues) {
}
let lfo;
if (depth) {
- lfo = getLfo(audioContext, start, end, {
+ lfo = getLfo(audioContext, {
+ begin: start,
+ end,
depth,
dcoffset,
...getLfoInputs,
@@ -331,7 +351,7 @@ export function getVibratoOscillator(param, value, t) {
releaseAudioNode(vibratoOscillator);
});
vibratoOscillator.start(t);
- return vibratoOscillator;
+ return { stop: (t) => vibratoOscillator.stop(t), nodes: { vib: [vibratoOscillator], vib_gain: [gain] } };
}
}
@@ -387,6 +407,7 @@ export function applyFM(param, value, begin) {
const ac = getAudioContext();
const toStop = []; // fm oscillators we will expose `stop` for
const fms = {};
+ const nodes = {};
// Matrix
for (let i = 1; i <= 8; i++) {
for (let j = 0; j <= 8; j++) {
@@ -437,11 +458,13 @@ export function applyFM(param, value, begin) {
output = osc.connect(envGain);
}
fms[idx] = { input: osc.frequency, output, freq, osc, toCleanup };
+ nodes[`fm_${idx}`] = [osc];
}
const { input, output, freq, osc, toCleanup } = fms[idx];
const g = gainNode(amt * freq);
io.push(isMod ? output.connect(g) : input);
cleanupOnEnd(osc, [...toCleanup, g]);
+ nodes[`fm_${idx}_gain`] = [g];
}
if (!io[1]) {
logger(
@@ -454,6 +477,7 @@ export function applyFM(param, value, begin) {
}
}
return {
+ nodes,
stop: (t) => toStop.forEach((m) => m?.stop(t)),
};
}
diff --git a/packages/superdough/index.mjs b/packages/superdough/index.mjs
index a382bd15..c3d2f6c5 100644
--- a/packages/superdough/index.mjs
+++ b/packages/superdough/index.mjs
@@ -10,6 +10,7 @@ export * from './helpers.mjs';
export * from './synth.mjs';
export * from './zzfx.mjs';
export * from './logger.mjs';
+export * from './modulators.mjs';
export * from './dspworklet.mjs';
export * from './audioContext.mjs';
export * from './wavetable.mjs';
diff --git a/packages/superdough/modulators.mjs b/packages/superdough/modulators.mjs
new file mode 100644
index 00000000..96c68d4b
--- /dev/null
+++ b/packages/superdough/modulators.mjs
@@ -0,0 +1,158 @@
+/*
+modulators.mjs - Helpers for constructing modulators (envelopes, LFOs, etc.)
+Copyright (C) 2025 Strudel contributors - see
+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 .
+*/
+
+import { getAudioContext } from './audioContext.mjs';
+import { gainNode, getEnvelope, getLfo, webAudioTimeout } from './helpers.mjs';
+import { errorLogger } from './logger.mjs';
+import { getSuperdoughControlTargets } from './superdoughdata.mjs';
+import { clamp } from './util.mjs';
+
+const getNodeParam = (node, name) => {
+ // Worklet case
+ if (node?.parameters) {
+ const p = node.parameters.get(name);
+ if (p instanceof AudioParam) {
+ return p;
+ }
+ }
+ // Built-in node case
+ let p = node?.[name];
+ if (p === undefined && name === 'frequency') {
+ // Fallbacks for source nodes without 'frequency' params (e.g. soundfonts)
+ p = node?.['detune'] ?? node?.['playbackRate'];
+ }
+ if (p instanceof AudioParam) {
+ return p;
+ }
+ return undefined;
+};
+
+const controlTargets = getSuperdoughControlTargets();
+
+const getControlData = (control) => {
+ return controlTargets[control.split('_')[0]];
+};
+
+const getRangeForParam = (paramName, currentValue) => {
+ // We clamp the frequency to a reasonable range unless the currentValue
+ // is low, which indicates this may be an LFO
+ if (paramName === 'frequency' && currentValue >= 30) {
+ return { min: 20 - currentValue, max: 24000 - currentValue };
+ }
+ return { min: undefined, max: undefined };
+};
+
+const clampWithWaveShaper = (modulator, min, max) => {
+ const ac = getAudioContext();
+ const curve = new Float32Array(256);
+ for (let i = 0; i < curve.length; i++) {
+ const x = (i / (curve.length - 1)) * 2 - 1;
+ curve[i] = clamp(x * max, min, max);
+ }
+ const shaper = new WaveShaperNode(ac, { curve });
+ const scaleGain = gainNode(1 / max);
+ modulator.connect(scaleGain).connect(shaper);
+ return { modulator, toCleanup: [shaper, scaleGain] };
+};
+
+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');
+ return { targetParams: [], paramName: control };
+ }
+ const paramName = targetInfo.param;
+ const nodeKey = nodes[targetInfo.node] ? targetInfo.node : control;
+ const targetNodes = nodes[nodeKey];
+ if (!targetNodes) {
+ const keys = Object.keys(nodes);
+ errorLogger(
+ new Error(`Could not connect to target '${nodeKey}' — it does not exist. Available targets: ${keys.join(', ')}`),
+ 'superdough',
+ );
+ return { targetParams: [], paramName };
+ }
+ const audioParams = [];
+ targetNodes.forEach((targetNode) => {
+ const targetParam = getNodeParam(targetNode, paramName);
+ audioParams.push(targetParam);
+ });
+ return { targetParams: audioParams, paramName };
+};
+
+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);
+ if (!targetParams.length) return;
+ let currentValue = targetParams[0].value;
+ currentValue = currentValue === 0 ? 1 : currentValue;
+ const { min, max } = getRangeForParam(paramName, currentValue);
+ const depthValue = depthabs != null ? depthabs : depth * currentValue;
+ const modParams = {
+ ...filteredParams,
+ frequency: sync !== undefined ? sync * cps : rate,
+ time: cycle / cps,
+ depth: depthValue,
+ min,
+ max,
+ };
+ const lfoNode = getLfo(getAudioContext(), modParams);
+ nodeTracker[`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);
+ if (!targetParams.length) return;
+ let currentValue = targetParams[0].value;
+ currentValue = currentValue === 0 ? 1 : currentValue;
+ const { min, max } = getRangeForParam(paramName, currentValue);
+ const depthValue = depthabs != null ? depthabs : depth * currentValue;
+ const envNode = getEnvelope(getAudioContext(), {
+ ...filteredParams,
+ depth: depthValue,
+ min,
+ max,
+ attackCurve: acurve,
+ decayCurve: dcurve,
+ releaseCurve: rcurve,
+ });
+ nodeTracker[`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);
+ if (!targetParams.length) return { toCleanup: [] };
+ 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);
+ let currentValue = targetParams[0].value;
+ currentValue = currentValue === 0 ? 1 : currentValue;
+ const { min, max } = getRangeForParam(paramName, currentValue);
+ const depthValue = depthabs != null ? depthabs : depth * currentValue;
+ const depthGain = gainNode((Math.sign(depthValue) * Math.abs(depthValue)) / 0.3);
+ const unClamped = shifted.connect(depthGain);
+ let { modulator, toCleanup } = clampWithWaveShaper(unClamped, min, max);
+ webAudioTimeout(
+ ac,
+ () => {
+ targetParams.forEach((t) => modulator.connect(t));
+ },
+ 0,
+ params.begin,
+ );
+ toCleanup.push(dc, shifted, depthGain);
+ return { modulator, toCleanup };
+};
diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs
index 2deeb4b7..819de3c7 100644
--- a/packages/superdough/sampler.mjs
+++ b/packages/superdough/sampler.mjs
@@ -301,7 +301,7 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
}
// vibrato
- let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, t);
+ const vibratoHandle = getVibratoOscillator(bufferSource.detune, value, t);
const time = t + nudge;
bufferSource.start(time, offset);
@@ -324,7 +324,7 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
node.connect(out);
onceEnded(bufferSource, function () {
releaseAudioNode(bufferSource);
- releaseAudioNode(vibratoOscillator);
+ vibratoHandle?.stop();
releaseAudioNode(node);
releaseAudioNode(out);
onended();
@@ -334,7 +334,7 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
const stop = (endTime) => {
bufferSource.stop(endTime);
};
- const handle = { node: out, bufferSource, stop };
+ const handle = { node: out, nodes: { source: [bufferSource], ...vibratoHandle?.nodes }, stop };
// cut groups
if (cut !== undefined) {
diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs
index 1ce91c83..8a3e9f7b 100644
--- a/packages/superdough/superdough.mjs
+++ b/packages/superdough/superdough.mjs
@@ -11,18 +11,19 @@ import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
import workletsUrl from './worklets.mjs?audioworklet';
import {
createFilter,
+ effectSend,
gainNode,
getCompressor,
getDistortion,
getLfo,
getWorklet,
- effectSend,
releaseAudioNode,
} from './helpers.mjs';
import { map } from 'nanostores';
import { logger } from './logger.mjs';
+import { connectLFO, connectEnvelope, connectBusModulator } from './modulators.mjs';
import { loadBuffer } from './sampler.mjs';
-import { getAudioContext, setAudioContext } from './audioContext.mjs';
+import { getAudioContext } from './audioContext.mjs';
import { SuperdoughAudioController } from './superdoughoutput.mjs';
import { resetSeenKeys } from './wavetable.mjs';
@@ -184,6 +185,7 @@ let defaultDefaultValues = {
distortvol: 1,
distorttype: 0,
delay: 0,
+ busgain: 1,
byteBeatExpression: '0',
delayfeedback: 0.5,
delaysync: 3 / 16,
@@ -327,9 +329,9 @@ export function connectToDestination(input, channels) {
controller.output.connectToDestination(input, channels);
}
-function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) {
+function getPhaser(begin, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) {
const ac = getAudioContext();
- const lfo = getLfo(ac, time, end, { frequency, depth: sweep * 2 });
+ const lfo = getLfo(ac, { frequency, depth: sweep * 2, begin, end });
//filters
const numStages = 1; //num of filters in series
@@ -401,6 +403,7 @@ function mapChannelNumbers(channels) {
}
export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) => {
+ let nodes = {};
// new: t is always expected to be the absolute target onset time
const ac = getAudioContext();
const audioController = getSuperdoughAudioController();
@@ -472,6 +475,8 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
delaysync = getDefaultValue('delaysync'),
delaytime,
orbit = getDefaultValue('orbit'),
+ bus,
+ busgain = getDefaultValue('busgain'),
room,
roomfade,
roomlp,
@@ -512,6 +517,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
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;
@@ -529,7 +535,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
activeSoundSources.delete(chainID);
}
- let audioNodes = [];
+ const audioNodes = [];
if (['-', '~', '_'].includes(s)) {
return;
@@ -543,6 +549,7 @@ 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];
} else if (getSound(s)) {
const { onTrigger } = getSound(s);
const onEnded = () => {
@@ -554,6 +561,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
if (soundHandle) {
sourceNode = soundHandle.node;
activeSoundSources.set(chainID, new WeakRef(soundHandle)); // allow GC
+ nodes = { ...nodes, ...soundHandle.nodes };
}
} else {
throw new Error(`sound ${s} not found! Is it loaded?`);
@@ -572,29 +580,33 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
chain.push(sourceNode);
stretch !== undefined && chain.push(getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: stretch }));
- transient !== undefined &&
- chain.push(
- getWorklet(
- ac,
- 'transient-processor',
- {},
- {
- processorOptions: {
- attack: transient,
- sustain: transsustain,
- begin: t,
- end: endWithRelease,
- },
+ if (transient !== undefined) {
+ const transProcessor = getWorklet(
+ ac,
+ 'transient-processor',
+ {},
+ {
+ processorOptions: {
+ attack: transient,
+ sustain: transsustain,
+ begin: t,
+ end: endWithRelease,
},
- ),
+ },
);
+ chain.push(transProcessor);
+ nodes['transient'] = transProcessor;
+ }
// gain stage
- chain.push(gainNode(gain));
+ const initialGain = gainNode(gain);
+ nodes['gain'] = [initialGain];
+ chain.push(initialGain);
// filter
const ftype = getFilterType(value.ftype);
+ const filt = (params) => createFilter(ac, t, end, params, cps, cycle);
if (value.cutoff !== undefined) {
const lpMap = {
frequency: 'cutoff',
@@ -617,12 +629,15 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
};
const lpParams = pickAndRename(value, lpMap);
lpParams.type = 'lowpass';
- const lp = () => createFilter(ac, t, end, lpParams, cps, cycle);
- const { filter: lpf1, lfo: lfo1 } = lp();
+ 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 } = lp();
+ const { filter: lpf2, lfo: lfo2 } = filt(lpParams);
+ nodes['lpf'].push(lpf2);
+ nodes['lpf_lfo'].push(lfo2);
chain.push(lpf2);
lfo2 && audioNodes.push(lfo2);
}
@@ -650,12 +665,15 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
};
const hpParams = pickAndRename(value, hpMap);
hpParams.type = 'highpass';
- const hp = () => createFilter(ac, t, end, hpParams, cps, cycle);
- const { filter: hpf1, lfo: lfo1 } = hp();
- chain.push(hpf1);
+ 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 } = hp();
+ const { filter: hpf2, lfo: lfo2 } = filt(hpParams);
+ nodes['hpf'].push(hpf2);
+ nodes['hpf_lfo'].push(lfo2);
chain.push(hpf2);
lfo2 && audioNodes.push(lfo2);
}
@@ -683,12 +701,15 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
};
const bpParams = pickAndRename(value, bpMap);
bpParams.type = 'bandpass';
- const bp = () => createFilter(ac, t, end, bpParams, cps, cycle);
- const { filter: bpf1, lfo: lfo1 } = bp();
+ 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 } = bp();
+ const { filter: bpf2, lfo: lfo2 } = filt(bpParams);
+ nodes['bpf'].push(bpf2);
+ nodes['bpf_lfo'].push(lfo2);
chain.push(bpf2);
lfo2 && audioNodes.push(lfo2);
}
@@ -696,14 +717,31 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
if (vowel !== undefined) {
const vowelFilter = ac.createVowelFilter(vowel);
+ nodes['vowel'] = [vowelFilter];
chain.push(vowelFilter);
}
// effects
- coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse }));
- crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush }));
- shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape, postgain: shapevol }));
- distort !== undefined && chain.push(getDistortion(distort, distortvol, distorttype));
+ 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;
@@ -724,7 +762,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
const amGain = new GainNode(ac, { gain });
const time = cycle / cps;
- const lfo = getLfo(ac, t, endWithRelease, {
+ const lfo = getLfo(ac, {
skew: tremoloskew ?? (tremoloshape != null ? 0.5 : 1),
frequency: tremolo,
depth: tremolodepth,
@@ -735,16 +773,28 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
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);
}
- compressorThreshold !== undefined &&
- chain.push(
- getCompressor(ac, compressorThreshold, compressorRatio, compressorKnee, compressorAttack, compressorRelease),
+ if (compressorThreshold !== undefined) {
+ const compressorNode = getCompressor(
+ ac,
+ compressorThreshold,
+ compressorRatio,
+ compressorKnee,
+ compressorAttack,
+ compressorRelease,
);
+ nodes['compressor'] = [compressorNode];
+ chain.push(compressorNode);
+ }
// panning
if (pan !== undefined) {
@@ -755,19 +805,24 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
// phaser
if (phaserrate !== undefined && phaserdepth > 0) {
const { filterChain, lfo } = getPhaser(t, endWithRelease, phaserrate, phaserdepth, phasercenter, phasersweep);
- audioNodes.push(lfo);
+ nodes['phaser'] = [...filterChain];
+ nodes['phaser_lfo'] = [lfo];
chain.push(...filterChain);
+ audioNodes.push(lfo);
}
// last gain
const post = new GainNode(ac, { gain: postgain });
+ nodes['post'] = [post];
chain.push(post);
// delay
if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
- orbitBus.getDelay(delaytime, delayfeedback, t);
- const send = orbitBus.sendDelay(post, delay);
- audioNodes.push(send);
+ const delayNode = orbitBus.getDelay(delaytime, delayfeedback, t);
+ nodes['delay'] = [delayNode];
+ const delaySend = orbitBus.sendDelay(post, delay);
+ nodes['delay_mix'] = [delaySend];
+ audioNodes.push(delaySend);
}
// reverb
if (room > 0) {
@@ -782,13 +837,21 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
}
roomIR = await loadBuffer(url, ac, ir, 0);
}
- orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
- const send = orbitBus.sendReverb(post, room);
- audioNodes.push(send);
+ const roomNode = orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
+ nodes['room'] = [roomNode];
+ const reverbSend = orbitBus.sendReverb(post, room);
+ nodes['room_mix'] = [reverbSend];
+ audioNodes.push(reverbSend);
+ }
+ if (bus != null) {
+ const busNode = audioController.getBus(bus);
+ const busSend = effectSend(post, busNode, busgain);
+ audioNodes.push(busSend);
}
if (djf != null) {
- orbitBus.getDjf(djf, t);
+ const djfNode = orbitBus.getDjf(djf, t);
+ nodes['djf'] = [djfNode];
}
// analyser
@@ -808,7 +871,48 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
// connect chain elements together
chain.slice(1).reduce((last, current) => last.connect(current), chain[0]);
- audioNodes = audioNodes.concat(chain);
+ 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);
+ }
+ }
+ 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 (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);
+ }
+ }
};
export const superdoughTrigger = (t, hap, ct, cps) => {
diff --git a/packages/superdough/superdoughdata.mjs b/packages/superdough/superdoughdata.mjs
new file mode 100644
index 00000000..2dac194f
--- /dev/null
+++ b/packages/superdough/superdoughdata.mjs
@@ -0,0 +1,150 @@
+/*
+superdoughdata.mjs - Data needed for running superdough (defaults, mappings, etc.)
+Copyright (C) 2025 Strudel contributors - see
+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 .
+*/
+
+// Mapping from control name to webaudio node and parameter
+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: 'frequency' },
+ tremolosync: { node: 'tremolo', param: 'frequency' },
+ 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' },
+ lfo_rate: { node: 'lfo', param: 'frequency' },
+ lfo_sync: { node: 'lfo', param: 'frequency' },
+ lfo_depth: { node: 'lfo', param: 'depth' },
+ lfo_depthabs: { node: 'lfo', param: 'depth' },
+ lfo_skew: { node: 'lfo', param: 'skew' },
+ lfo_curve: { node: 'lfo', param: 'curve' },
+ lfo_dcoffset: { node: 'lfo', param: 'dcoffset' },
+ env: { node: 'env', param: 'depth' },
+ env_attack: { node: 'env', param: 'attack' },
+ env_decay: { node: 'env', param: 'decay' },
+ env_sustain: { node: 'env', param: 'sustain' },
+ env_release: { node: 'env', param: 'release' },
+ bmod: { node: 'bmod', param: 'depth' },
+ bmod_depth: { node: 'bmod', param: 'depth' },
+ bmod_depthabs: { node: 'bmod', param: 'depth' },
+
+ // LPF
+ 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: { 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: { 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: { node: 'vowel', param: 'frequency' },
+
+ // DISTORTION
+ 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' },
+ distorttype: { node: 'distort', param: 'distort' },
+
+ // COMPRESSOR
+ 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: { node: 'phaser_lfo', param: 'rate' },
+ phasersweep: { node: 'phaser_lfo', param: 'depth' },
+ phasercenter: { node: 'phaser', param: 'frequency' },
+ phaserdepth: { node: 'phaser', param: 'Q' },
+
+ // ORBIT EFFECTS
+ delay: { node: 'delay_mix', param: 'gain' },
+ delaytime: { node: 'delay', param: 'delayTime' },
+ delayfeedback: { node: 'delay', param: 'feedback' },
+ delaysync: { node: 'delay', param: 'delayTime' },
+ dry: { node: 'dry', param: 'gain' },
+ room: { node: 'room_mix', param: 'gain' },
+ djf: { node: 'djf', param: 'value' },
+ busgain: { node: 'bus', param: 'gain' },
+
+ // SYNTHS
+ s: { node: 'source', param: 'frequency' },
+ detune: { node: 'source', param: 'freqspread' },
+ wt: { node: 'source', param: 'position' },
+ warp: { node: 'source', param: 'warp' },
+ freq: { node: 'source', param: 'frequency' },
+ note: { node: 'source', param: 'frequency' },
+ wtdc: { node: 'wt_lfo', param: 'dc' },
+ wtskew: { node: 'wt_lfo', param: 'skew' },
+ wtrate: { node: 'wt_lfo', param: 'frequency' },
+ wtsync: { node: 'wt_lfo', param: 'frequency' },
+ wtdepth: { node: 'wt_lfo', param: 'depth' },
+ warpdc: { node: 'warp_lfo', param: 'dc' },
+ warpskew: { node: 'warp_lfo', param: 'skew' },
+ warprate: { node: 'warp_lfo', param: 'frequency' },
+ warpsync: { node: 'warp_lfo', param: 'frequency' },
+ warpdepth: { node: 'warp_lfo', param: 'depth' },
+ fmi: { node: 'fm_1_gain', param: 'gain' },
+ fmi2: { node: 'fm_2_gain', param: 'gain' },
+ fmi3: { node: 'fm_3_gain', param: 'gain' },
+ fmi4: { node: 'fm_4_gain', param: 'gain' },
+ fmi5: { node: 'fm_5_gain', param: 'gain' },
+ fmi6: { node: 'fm_6_gain', param: 'gain' },
+ fmi7: { node: 'fm_7_gain', param: 'gain' },
+ fmi8: { node: 'fm_8_gain', param: 'gain' },
+ fmh: { node: 'fm_1', param: 'frequency' },
+ fmh2: { node: 'fm_2', param: 'frequency' },
+ fmh3: { node: 'fm_3', param: 'frequency' },
+ fmh4: { node: 'fm_4', param: 'frequency' },
+ fmh5: { node: 'fm_5', param: 'frequency' },
+ fmh6: { node: 'fm_6', param: 'frequency' },
+ fmh7: { node: 'fm_7', param: 'frequency' },
+ fmh8: { node: 'fm_8', param: 'frequency' },
+ pw: { node: 'source', param: 'pulsewidth' },
+ pwrate: { node: 'pw_lfo', param: 'frequency' },
+ pwsweep: { node: 'pw_lfo', param: 'depth' },
+ vib: { node: 'vib', param: 'frequency' },
+ vibmod: { node: 'vib_gain', param: 'gain' },
+ byteBeatStartTime: { node: 'source', param: 'byteBeatStartTime' },
+ spread: { node: 'source', param: 'panspread' },
+ transient: { node: 'transient', param: 'attack' },
+};
+
+export function getSuperdoughControlTargets() {
+ return CONTROL_TARGETS;
+}
diff --git a/packages/superdough/superdoughoutput.mjs b/packages/superdough/superdoughoutput.mjs
index d8ead7d0..d3936646 100644
--- a/packages/superdough/superdoughoutput.mjs
+++ b/packages/superdough/superdoughoutput.mjs
@@ -2,7 +2,10 @@ import { effectSend, getWorklet, webAudioTimeout } from './helpers.mjs';
import { errorLogger } from './logger.mjs';
import { clamp } from './util.mjs';
-let hasChanged = (now, before) => now !== undefined && now !== before;
+const hasChanged = (now, before) => now !== undefined && now !== before;
+// Node with fixed stereo channel count to prevent clicking when the input signal
+// switches from mono to stereo
+const getStereoNode = (ac) => new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
export class Orbit {
reverbNode;
@@ -11,10 +14,11 @@ export class Orbit {
summingNode;
djfNode;
audioContext;
+
constructor(audioContext) {
this.audioContext = audioContext;
- this.output = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
- this.summingNode = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
+ this.output = getStereoNode(audioContext);
+ this.summingNode = getStereoNode(audioContext);
this.summingNode.connect(this.output);
}
@@ -34,6 +38,7 @@ export class Orbit {
}
const val = this.djfNode.parameters.get('value');
val.setValueAtTime(value, t);
+ return this.djfNode;
}
getDelay(delaytime = 0, feedback = 0.5, t) {
@@ -164,6 +169,7 @@ export class SuperdoughAudioController {
audioContext;
output;
nodes = {};
+ buses = {};
constructor(audioContext) {
this.audioContext = audioContext;
@@ -171,10 +177,14 @@ export class SuperdoughAudioController {
}
reset() {
- Array.from(this.nodes).forEach((node) => {
+ Object.values(this.nodes).forEach((node) => {
node.disconnect();
});
+ Object.values(this.buses).forEach((bus) => {
+ bus.disconnect();
+ });
this.nodes = {};
+ this.buses = {};
this.output.reset();
}
@@ -206,4 +216,11 @@ export class SuperdoughAudioController {
}
return this.nodes[orbitNum];
}
+
+ getBus(busNum) {
+ if (this.buses[busNum] == null) {
+ this.buses[busNum] = getStereoNode(this.audioContext);
+ }
+ return this.buses[busNum];
+ }
}
diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs
index d9ee0d93..6c8d1fea 100644
--- a/packages/superdough/synth.mjs
+++ b/packages/superdough/synth.mjs
@@ -1,5 +1,5 @@
import { clamp } from './util.mjs';
-import { registerSound, soundMap } from './superdough.mjs';
+import { getSuperdoughAudioController, registerSound, soundMap } from './superdough.mjs';
import { getAudioContext } from './audioContext.mjs';
import {
applyFM,
@@ -19,7 +19,7 @@ import {
import { logger } from './logger.mjs';
import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
-const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user'];
+const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user', 'one'];
const waveformAliases = [
['tri', 'triangle'],
['sqr', 'square'],
@@ -52,17 +52,17 @@ export function registerSynthSounds() {
// turn down
const g = gainNode(0.3);
- let sound = getOscillator(s, t, value, () => {
+ const sound = getOscillator(s, t, value, () => {
releaseAudioNode(g);
onended();
});
- let { node: o, stop, triggerRelease } = sound;
+ const { node: o, nodes, stop, triggerRelease } = sound;
const { duration } = value;
const envGain = gainNode(1);
- let node = o.connect(g).connect(envGain);
+ const node = o.connect(g).connect(envGain);
const holdEnd = t + duration;
getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear');
const envEnd = holdEnd + release + 0.01;
@@ -70,6 +70,7 @@ export function registerSynthSounds() {
stop(envEnd);
return {
node,
+ nodes,
stop: (endTime) => {
stop(endTime);
},
@@ -139,6 +140,7 @@ export function registerSynthSounds() {
return {
node,
+ nodes: { source: [o] },
stop: (endTime) => {
o.stop(endTime);
},
@@ -183,8 +185,8 @@ export function registerSynthSounds() {
const gainAdjustment = 1 / Math.sqrt(voices);
getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend);
- const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin);
- const fm = applyFM(o.parameters.get('frequency'), value, begin);
+ const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin);
+ const fmHandle = applyFM(o.parameters.get('frequency'), value, begin);
let envGain = gainNode(1);
envGain = o.connect(envGain);
@@ -195,8 +197,8 @@ export function registerSynthSounds() {
() => {
releaseAudioNode(o);
onended();
- fm?.stop();
- vibratoOscillator?.stop();
+ fmHandle?.stop();
+ vibratoHandle?.stop();
},
begin,
end,
@@ -204,6 +206,7 @@ export function registerSynthSounds() {
return {
node: envGain,
+ nodes: { source: [o], ...fmHandle?.nodes, ...vibratoHandle?.nodes },
stop: (time) => {
timeoutNode.stop(time);
},
@@ -279,6 +282,7 @@ export function registerSynthSounds() {
return {
node: envGain,
+ source: o,
stop: (time) => {
timeoutNode.stop(time);
},
@@ -329,25 +333,25 @@ export function registerSynthSounds() {
);
getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend);
- const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin);
- const fm = applyFM(o.parameters.get('frequency'), value, begin);
+ const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin);
+ const fmHandle = applyFM(o.parameters.get('frequency'), value, begin);
let envGain = gainNode(1);
envGain = o.connect(envGain);
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
- let lfo;
+ let pw_lfo;
if (pwsweep != 0) {
- lfo = getLfo(ac, begin, end, { frequency: pwrate, depth: pwsweep });
- lfo.connect(o.parameters.get('pulsewidth'));
+ pw_lfo = getLfo(ac, { frequency: pwrate, depth: pwsweep, begin, end });
+ pw_lfo.connect(o.parameters.get('pulsewidth'));
}
let timeoutNode = webAudioTimeout(
ac,
() => {
releaseAudioNode(o);
- releaseAudioNode(lfo);
+ releaseAudioNode(pw_lfo);
onended();
- fm?.stop();
- vibratoOscillator?.stop();
+ fmHandle?.stop();
+ vibratoHandle?.stop();
},
begin,
end,
@@ -355,6 +359,7 @@ export function registerSynthSounds() {
return {
node: envGain,
+ nodes: { source: [o], pw_lfo: [pw_lfo], ...fmHandle?.nodes, ...vibratoHandle?.nodes },
stop: (time) => {
timeoutNode.stop(time);
},
@@ -363,6 +368,41 @@ export function registerSynthSounds() {
{ prebake: true, type: 'synth' },
);
+ registerSound(
+ 'bus',
+ (begin, value, onended) => {
+ const ac = getAudioContext();
+ const [attack, decay, sustain, release] = getADSRValues(
+ [value.attack, value.decay, value.sustain, value.release],
+ 'linear',
+ [0.001, 0.05, 1, 0.01],
+ );
+ const holdend = begin + value.duration;
+ const end = holdend + release + 0.01;
+ const bus = getSuperdoughAudioController().getBus(value.n ?? 0);
+ const envGain = bus.connect(gainNode(0));
+ getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
+ const timeoutNode = webAudioTimeout(
+ ac,
+ () => {
+ bus.disconnect(envGain);
+ onended();
+ },
+ begin,
+ end,
+ );
+
+ return {
+ node: envGain,
+ nodes: { source: [bus] },
+ stop: (time) => {
+ timeoutNode.stop(time);
+ },
+ };
+ },
+ { prebake: true, type: 'input' },
+ );
+
[...noises].forEach((s) => {
registerSound(
s,
@@ -400,6 +440,7 @@ export function registerSynthSounds() {
stop(envEnd);
return {
node,
+ nodes: { source: [o] },
stop: (endTime) => {
stop(endTime);
},
@@ -467,8 +508,17 @@ export function getOscillator(s, t, value, onended) {
s = 'triangle';
}
s = s === 'user' && !partials ? 'triangle' : s;
- // If no partials are given, use stock waveforms
- if (!partials || partials?.length === 0 || s === 'sine') {
+ if (s === 'one') {
+ // Constant 1 oscillator (used for modulation)
+ o = new ConstantSourceNode(getAudioContext(), { offset: 1 });
+ o.start(t);
+ return {
+ node: o,
+ nodes: { source: o },
+ stop: (time) => o?.stop(time),
+ };
+ } else if (!partials || partials?.length === 0 || s === 'sine') {
+ // If no partials are given, use stock waveforms
o = getAudioContext().createOscillator();
o.type = s || 'triangle';
}
@@ -479,11 +529,11 @@ export function getOscillator(s, t, value, onended) {
// set frequency
o.frequency.value = getFrequencyFromValue(value);
- let vibratoOscillator = getVibratoOscillator(o.detune, value, t);
+ const vibratoHandle = getVibratoOscillator(o.detune, value, t);
// pitch envelope
getPitchEnvelope(o.detune, value, t, t + duration);
- const fmModulator = applyFM(o.frequency, value, t);
+ const fmHandle = applyFM(o.frequency, value, t);
let noiseMix;
if (noise) {
@@ -500,9 +550,10 @@ export function getOscillator(s, t, value, onended) {
return {
node: noiseMix?.node || o,
+ nodes: { source: [o], ...vibratoHandle?.nodes, ...fmHandle?.nodes },
stop: (time) => {
- fmModulator.stop(time);
- vibratoOscillator?.stop(time);
+ fmHandle.stop(time);
+ vibratoHandle?.stop(time);
noiseMix?.stop(time);
o.stop(time);
},
diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs
index ebf951a4..b4fc401f 100644
--- a/packages/superdough/wavetable.mjs
+++ b/packages/superdough/wavetable.mjs
@@ -314,19 +314,28 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
dcoffset: value.warpdc ?? 0,
},
);
- const vibratoOscillator = getVibratoOscillator(source.parameters.get('detune'), value, t);
- const fm = applyFM(source.parameters.get('frequency'), value, t);
+ const vibratoHandle = getVibratoOscillator(source.parameters.get('detune'), value, t);
+ const fmHandle = applyFM(source.parameters.get('frequency'), value, t);
const envGain = ac.createGain();
const node = source.connect(envGain);
getParamADSR(node.gain, attack, decay, sustain, release, 0, 0.3, t, holdEnd, 'linear');
getPitchEnvelope(source.parameters.get('detune'), value, t, holdEnd);
- const handle = { node, source };
+ const handle = {
+ node,
+ nodes: {
+ source: [source],
+ wt_lfo: [wtPosModulators],
+ warp_lfo: [wtWarpModulators],
+ ...fmHandle?.nodes,
+ ...vibratoHandle?.nodes,
+ },
+ };
const timeoutNode = webAudioTimeout(
ac,
() => {
releaseAudioNode(source);
- releaseAudioNode(vibratoOscillator);
- fm?.stop();
+ vibratoHandle?.stop();
+ fmHandle?.stop();
releaseAudioNode(wtPosModulators);
releaseAudioNode(wtWarpModulators);
onended();
diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs
index a59aeb75..8001c7da 100644
--- a/packages/superdough/worklets.mjs
+++ b/packages/superdough/worklets.mjs
@@ -124,8 +124,8 @@ class LFOProcessor extends AudioWorkletProcessor {
{ name: 'shape', defaultValue: 0 },
{ name: 'curve', defaultValue: 1 },
{ name: 'dcoffset', defaultValue: 0 },
- { name: 'min', defaultValue: 0 },
- { name: 'max', defaultValue: 1 },
+ { name: 'min', defaultValue: -1e9 },
+ { name: 'max', defaultValue: 1e9 },
];
}
@@ -143,7 +143,8 @@ class LFOProcessor extends AudioWorkletProcessor {
process(_inputs, outputs, parameters) {
const begin = parameters['begin'][0];
- if (currentTime >= parameters.end[0]) {
+ const end = parameters['end'][0];
+ if (currentTime >= end) {
return false;
}
if (currentTime <= begin) {
@@ -161,6 +162,7 @@ class LFOProcessor extends AudioWorkletProcessor {
const curve = parameters['curve'][0];
const dcoffset = parameters['dcoffset'][0];
+
const min = parameters['min'][0];
const max = parameters['max'][0];
const shape = waveShapeNames[parameters['shape'][0]];
@@ -967,7 +969,9 @@ class EnvelopeProcessor extends AudioWorkletProcessor {
{ name: 'attackCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
{ name: 'decayCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
{ name: 'releaseCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
- { name: 'peak', defaultValue: 1 },
+ { name: 'depth', defaultValue: 1 },
+ { name: 'min', defaultValue: -1e9 },
+ { name: 'max', defaultValue: 1e9 },
{ name: 'retrigger', defaultValue: 1, minValue: 0, maxValue: 1 },
];
}
@@ -1009,9 +1013,15 @@ class EnvelopeProcessor extends AudioWorkletProcessor {
}
process(_inputs, outputs, params) {
+ const begin = params['begin'][0];
+ const end = params['end'][0];
+ if (currentTime >= end) {
+ return false;
+ }
+ if (currentTime <= begin) {
+ return true;
+ }
const out = outputs[0][0];
- if (!out) return true;
- const begin = pv(params.begin, 0);
const retrigger = pv(params.retrigger, 0) >= 0.5; // convert to bool
if (begin !== this.beginTime && (this.state === 0 || retrigger)) {
// triggered
@@ -1029,7 +1039,9 @@ class EnvelopeProcessor extends AudioWorkletProcessor {
const aCurve = pv(params.attackCurve, i);
const dCurve = pv(params.decayCurve, i);
const rCurve = pv(params.releaseCurve, i);
- const peak = pv(params.peak, i);
+ const depth = pv(params.depth, i);
+ const min = pv(params.min, i);
+ const max = pv(params.max, i);
const states = [
{ time: Number.POSITIVE_INFINITY, start: 0, target: 0 }, // idle
{ time: attack, start: this.attackStart, target: 1, curve: aCurve },
@@ -1043,7 +1055,7 @@ class EnvelopeProcessor extends AudioWorkletProcessor {
this.state = (this.state + 1) % states.length;
time = states[this.state].time;
}
- out[i] = this.val * peak;
+ out[i] = clamp(this.val * depth, min, max);
}
return true;
}
diff --git a/packages/superdough/zzfx.mjs b/packages/superdough/zzfx.mjs
index 4a4c58c9..3d6b8e9f 100644
--- a/packages/superdough/zzfx.mjs
+++ b/packages/superdough/zzfx.mjs
@@ -90,6 +90,7 @@ export function registerZZFXSounds() {
});
return {
node: o,
+ nodes: { source: [o] },
stop: () => {},
};
},
diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap
index 5f8133ec..61ab1186 100644
--- a/test/__snapshots__/examples.test.mjs.snap
+++ b/test/__snapshots__/examples.test.mjs.snap
@@ -3803,6 +3803,51 @@ exports[`runs examples > example "end" example index 0 1`] = `
]
`;
+exports[`runs examples > example "env" example index 0 1`] = `
+[
+ "[ 0/1 → 1/1 | s:saw note:F1 cutoff:500 env:{0:{control:cutoff attack:1} __ids:{}} ]",
+ "[ 1/1 → 2/1 | s:saw note:F1 cutoff:500 env:{0:{control:cutoff attack:1} __ids:{}} ]",
+ "[ 2/1 → 3/1 | s:saw note:F1 cutoff:500 env:{0:{control:cutoff attack:1} __ids:{}} ]",
+ "[ 3/1 → 4/1 | s:saw note:F1 cutoff:500 env:{0:{control:cutoff attack:1} __ids:{}} ]",
+]
+`;
+
+exports[`runs examples > example "env" example index 1 1`] = `
+[
+ "[ 0/1 → 1/1 | s:saw env:{0:{control:s decay:1} 1:{control:cutoff attack:0.1 decay:1 acurve:0.8 dcurve:0.3 depth:50} __ids:{}} note:F1 resonance:4 cutoff:50 ]",
+ "[ 1/1 → 2/1 | s:saw env:{0:{control:s decay:1} 1:{control:cutoff attack:0.1 decay:1 acurve:0.8 dcurve:0.3 depth:50} __ids:{}} note:F1 resonance:4 cutoff:50 ]",
+ "[ 2/1 → 3/1 | s:saw env:{0:{control:s decay:1} 1:{control:cutoff attack:0.1 decay:1 acurve:0.8 dcurve:0.3 depth:50} __ids:{}} note:F1 resonance:4 cutoff:50 ]",
+ "[ 3/1 → 4/1 | s:saw env:{0:{control:s decay:1} 1:{control:cutoff attack:0.1 decay:1 acurve:0.8 dcurve:0.3 depth:50} __ids:{}} note:F1 resonance:4 cutoff:50 ]",
+]
+`;
+
+exports[`runs examples > example "env" example index 2 1`] = `
+[
+ "[ 0/1 → 1/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode env:{0:{control:cutoff attack:0.5 decay:0.5} __ids:{}} ]",
+ "[ 1/1 → 2/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode env:{0:{control:cutoff attack:0.5 decay:0.5} __ids:{}} ]",
+ "[ 2/1 → 3/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode env:{0:{control:cutoff attack:0.5 decay:0.5} __ids:{}} ]",
+ "[ 3/1 → 4/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode env:{0:{control:cutoff attack:0.5 decay:0.5} __ids:{}} ]",
+]
+`;
+
+exports[`runs examples > example "env" example index 3 1`] = `
+[
+ "[ 0/1 → 1/1 | s:pulse cutoff:500 env:{0:{control:cutoff attack:1} 1:{control:s attack:0.5} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]",
+ "[ 1/1 → 2/1 | s:pulse cutoff:500 env:{0:{control:cutoff attack:1} 1:{control:s attack:1} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]",
+ "[ 2/1 → 3/1 | s:pulse cutoff:500 env:{0:{control:cutoff attack:1} 1:{control:s attack:1} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]",
+ "[ 3/1 → 4/1 | s:pulse cutoff:500 env:{0:{control:cutoff attack:1} 1:{control:s attack:0.5} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]",
+]
+`;
+
+exports[`runs examples > example "env" example index 4 1`] = `
+[
+ "[ 0/1 → 1/1 | s:pulse cutoff:500 env:{1:{control:s attack:1} __ids:{} lpf_mod:{control:cutoff attack:0.5}} distort:0.3 distortvol:1 distorttype:diode ]",
+ "[ 1/1 → 2/1 | s:pulse cutoff:500 env:{1:{control:s attack:1} __ids:{} lpf_mod:{control:cutoff attack:1}} distort:0.3 distortvol:1 distorttype:diode ]",
+ "[ 2/1 → 3/1 | s:pulse cutoff:500 env:{1:{control:s attack:1} __ids:{} lpf_mod:{control:cutoff attack:1}} distort:0.3 distortvol:1 distorttype:diode ]",
+ "[ 3/1 → 4/1 | s:pulse cutoff:500 env:{1:{control:s attack:1} __ids:{} lpf_mod:{control:cutoff attack:0.5}} distort:0.3 distortvol:1 distorttype:diode ]",
+]
+`;
+
exports[`runs examples > example "euclid" example index 0 1`] = `
[
"[ 0/1 → 1/8 | note:c3 ]",
@@ -6113,6 +6158,51 @@ exports[`runs examples > example "leslie" example index 0 1`] = `
]
`;
+exports[`runs examples > example "lfo" example index 0 1`] = `
+[
+ "[ 0/1 → 1/1 | s:saw note:F1 cutoff:500 lfo:{0:{control:cutoff} __ids:{}} ]",
+ "[ 1/1 → 2/1 | s:saw note:F1 cutoff:500 lfo:{0:{control:cutoff} __ids:{}} ]",
+ "[ 2/1 → 3/1 | s:saw note:F1 cutoff:500 lfo:{0:{control:cutoff} __ids:{}} ]",
+ "[ 3/1 → 4/1 | s:saw note:F1 cutoff:500 lfo:{0:{control:cutoff} __ids:{}} ]",
+]
+`;
+
+exports[`runs examples > example "lfo" example index 1 1`] = `
+[
+ "[ 0/1 → 1/1 | s:saw lfo:{0:{control:s} 1:{control:cutoff sync:0.3} __ids:{}} cutoff:500 ]",
+ "[ 1/1 → 2/1 | s:saw lfo:{0:{control:s} 1:{control:cutoff sync:0.3} __ids:{}} cutoff:500 ]",
+ "[ 2/1 → 3/1 | s:saw lfo:{0:{control:s} 1:{control:cutoff sync:0.3} __ids:{}} cutoff:500 ]",
+ "[ 3/1 → 4/1 | s:saw lfo:{0:{control:s} 1:{control:cutoff sync:0.3} __ids:{}} cutoff:500 ]",
+]
+`;
+
+exports[`runs examples > example "lfo" example index 2 1`] = `
+[
+ "[ 0/1 → 1/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode lfo:{0:{control:cutoff} __ids:{}} ]",
+ "[ 1/1 → 2/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode lfo:{0:{control:cutoff} __ids:{}} ]",
+ "[ 2/1 → 3/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode lfo:{0:{control:cutoff} __ids:{}} ]",
+ "[ 3/1 → 4/1 | s:saw cutoff:500 distort:0.3 distortvol:1 distorttype:diode lfo:{0:{control:cutoff} __ids:{}} ]",
+]
+`;
+
+exports[`runs examples > example "lfo" example index 3 1`] = `
+[
+ "[ 0/1 → 1/1 | s:pulse cutoff:500 lfo:{0:{control:cutoff} 1:{control:s sync:8} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]",
+ "[ 1/1 → 2/1 | s:pulse cutoff:500 lfo:{0:{control:cutoff} 1:{control:s} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]",
+ "[ 2/1 → 3/1 | s:pulse cutoff:500 lfo:{0:{control:cutoff} 1:{control:s} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]",
+ "[ 3/1 → 4/1 | s:pulse cutoff:500 lfo:{0:{control:cutoff} 1:{control:s sync:8} __ids:{}} distort:0.3 distortvol:1 distorttype:diode ]",
+]
+`;
+
+exports[`runs examples > example "lfo" example index 4 1`] = `
+[
+ "[ 0/1 → 1/1 | s:pulse cutoff:500 lfo:{1:{control:s} __ids:{} lpf_mod:{control:cutoff depth:4 sync:8}} distort:0.3 distortvol:1 distorttype:diode ]",
+ "[ 1/1 → 2/1 | s:pulse cutoff:500 lfo:{1:{control:s} __ids:{} lpf_mod:{control:cutoff depth:4}} distort:0.3 distortvol:1 distorttype:diode ]",
+ "[ 2/1 → 3/1 | s:pulse cutoff:500 lfo:{1:{control:s} __ids:{} lpf_mod:{control:cutoff depth:4}} distort:0.3 distortvol:1 distorttype:diode ]",
+ "[ 3/1 → 4/1 | s:pulse cutoff:500 lfo:{1:{control:s} __ids:{} lpf_mod:{control:cutoff depth:4 sync:8}} distort:0.3 distortvol:1 distorttype:diode ]",
+]
+`;
+
exports[`runs examples > example "linger" example index 0 1`] = `
[
"[ 0/1 → 1/4 | s:lt ]",
diff --git a/test/examples.test.mjs b/test/examples.test.mjs
index 896a02f8..ebec2252 100644
--- a/test/examples.test.mjs
+++ b/test/examples.test.mjs
@@ -20,6 +20,7 @@ const skippedExamples = [
'accelerationX',
'defaultmidimap',
'midimaps',
+ 'bmod',
];
describe('runs examples', () => {