diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs
index a612ab610..05725264a 100644
--- a/packages/codemirror/codemirror.mjs
+++ b/packages/codemirror/codemirror.mjs
@@ -27,7 +27,7 @@ import { updateWidgets, widgetPlugin } from './widget.mjs';
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
-const extensions = {
+export const extensions = {
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
isBracketMatchingEnabled: (on) => (on ? bracketMatching({ brackets: '()[]{}<>' }) : []),
isBracketClosingEnabled: (on) => (on ? closeBrackets() : []),
@@ -48,7 +48,7 @@ const extensions = {
]
: [],
};
-const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()]));
+export const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()]));
export const defaultSettings = {
keybindings: 'codemirror',
@@ -411,7 +411,7 @@ export class StrudelMirror {
}
}
-function parseBooleans(value) {
+export function parseBooleans(value) {
return { true: true, false: false }[value] ?? value;
}
diff --git a/packages/codemirror/index.mjs b/packages/codemirror/index.mjs
index 3a5f2a23c..2d5b3de21 100644
--- a/packages/codemirror/index.mjs
+++ b/packages/codemirror/index.mjs
@@ -4,3 +4,4 @@ export * from './flash.mjs';
export * from './slider.mjs';
export * from './themes.mjs';
export * from './widget.mjs';
+export { Vim } from './keybindings.mjs';
diff --git a/packages/codemirror/keybindings.mjs b/packages/codemirror/keybindings.mjs
index 4ef910fe0..0a38be1ff 100644
--- a/packages/codemirror/keybindings.mjs
+++ b/packages/codemirror/keybindings.mjs
@@ -133,6 +133,8 @@ const keymaps = {
vscode: vscodeExtension,
};
+export { Vim } from '@replit/codemirror-vim';
+
export function keybindings(name) {
const active = keymaps[name];
const extensions = active ? [Prec.high(active())] : [];
diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs
index f28d13f62..b18a8dff6 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,251 @@ 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'],
+ ['fxi'],
+]);
+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'],
+ ['fxi'],
+]);
+registerSubControls('bmod', [
+ ['bus', 'b'],
+ ['control', 'c'],
+ ['subControl', 'sc'],
+ ['depth', 'dep', 'dr'],
+ ['depthabs', 'da'],
+ ['dc'],
+ ['fxi'],
+]);
+
+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 {number | Pattern} [config.fxi] FX index to target
+ * @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 {number | Pattern} [config.fxi] FX index to target
+ * @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 {number | Pattern} [config.fxi] FX index to target
+ * @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
@@ -2805,3 +3071,5 @@ export const scrub = register(
* s("hh*16").bank("tr909").transient("<-1:1 1:-1>")
*/
export const { transient } = registerControl(['transient', 'transsustain']);
+
+export const { FXrelease, FXrel, FXr, fxr } = registerControl('FXrelease', 'FXrel', 'FXr', 'fxr');
diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs
index 6f214386f..02cf91881 100644
--- a/packages/core/pattern.mjs
+++ b/packages/core/pattern.mjs
@@ -3721,3 +3721,35 @@ Pattern.prototype.phases = function (list) {
export const phases = (list) => {
return _ensureListPattern(list).as('phases');
};
+
+/**
+ * Establishes an FX chain. Can be called by chaining .FX(fx1).FX(fx2)..
+ * calls and/or in a single .FX(fx1, fx2, ..) call. The fx1, .. are _patterns_ which
+ * establish the controls of the given effect. See examples.
+ * @name FX
+ * @memberof Pattern
+ * @returns Pattern
+ * @example
+ * $: s("[sbd ]*4").dec(.4)
+ * .FX(
+ * phaser(0.5).gain(2),
+ * bpf(800),
+ * distort(1.3),
+ * room(0.2),
+ * delay(0.5).gain(1.25),
+ * distort(0.3),
+ * ).fxr(1.7) // sets release time of effects (like delay)
+ * @example
+ * $: s("saw").fm(0.5)
+ * .delay(0.3) // outer effects are applied *last*
+ * .FX(coarse(4)) // first coarse
+ * .FX(lpf(500).lpe(4).lpa(1).lpd(2)) // then lpf
+ * .FX(distort(1)) // then distort
+ */
+Pattern.prototype.FX = function (...effects) {
+ effects = effects.map(reify);
+ return this.withValue((v) => (vEff) => {
+ const currFX = v.FX ?? [];
+ return { ...v, FX: currFX.concat(vEff) };
+ }).appLeft(parray(effects));
+};
diff --git a/packages/osc/README.md b/packages/osc/README.md
index 0a7bfedcf..c606daa59 100644
--- a/packages/osc/README.md
+++ b/packages/osc/README.md
@@ -46,4 +46,4 @@ all(osc)
[open in repl](https://strudel.cc/#JDogcygiYmQqNCIpCgphbGwob3NjKQ%3D%3D)
-You can read more about [how to use Superdirt with Strudel the Tutorial](https://strudel.cc/learn/input-output/#superdirt-api)
+You can read more about [how to use Superdirt with Strudel](https://strudel.cc/learn/input-output/#oscsuperdirtstrudeldirt) in the tutorial.
diff --git a/packages/soundfonts/fontloader.mjs b/packages/soundfonts/fontloader.mjs
index 95e210890..b4ae59828 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/feedbackdelay.mjs b/packages/superdough/feedbackdelay.mjs
index b8269bc02..387176134 100644
--- a/packages/superdough/feedbackdelay.mjs
+++ b/packages/superdough/feedbackdelay.mjs
@@ -5,19 +5,18 @@ if (typeof DelayNode !== 'undefined') {
wet = Math.abs(wet);
this.delayTime.value = time;
- const feedbackGain = ac.createGain();
- feedbackGain.gain.value = Math.min(Math.abs(feedback), 0.995);
- this.feedback = feedbackGain.gain;
+ this.feedbackGain = ac.createGain();
+ this.feedbackGain.gain.value = Math.min(Math.abs(feedback), 0.995);
+ this.feedback = this.feedbackGain.gain;
- const delayGain = ac.createGain();
- delayGain.gain.value = wet;
- this.delayGain = delayGain;
+ this.delayGain = ac.createGain();
+ this.delayGain.gain.value = wet;
- this.connect(feedbackGain);
- this.connect(delayGain);
- feedbackGain.connect(this);
+ this.connect(this.feedbackGain);
+ this.connect(this.delayGain);
+ this.feedbackGain.connect(this);
- this.connect = (target) => delayGain.connect(target);
+ this.connect = (target) => this.delayGain.connect(target);
return this;
}
start(t) {
diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs
index 6278251d7..8bd8b067b 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 a382bd15a..c3d2f6c52 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 000000000..7d119a06b
--- /dev/null
+++ b/packages/superdough/modulators.mjs
@@ -0,0 +1,178 @@
+/*
+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}'. It may not be modulatable.`),
+ '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,
+ fxi = 'main',
+ depth = 1,
+ depthabs,
+ ...filteredParams
+ } = params;
+ const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl);
+ if (!targetParams.length) return;
+ let currentValue = targetParams[0].value;
+ currentValue = currentValue === 0 ? 1 : currentValue;
+ 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.main[`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, fxi = 'main', ...filteredParams } = params;
+ const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl);
+ if (!targetParams.length) return;
+ let currentValue = targetParams[0].value;
+ currentValue = currentValue === 0 ? 1 : currentValue;
+ 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.main[`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, fxi = 'main' } = params;
+ const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl);
+ if (!targetParams.length) return { toCleanup: [] };
+ const signal = controller.getBus(params.bus);
+ const dc = new ConstantSourceNode(ac, { offset: params.dc ?? 0 });
+ 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);
+ const toCleanup = [];
+ let modulator = unClamped;
+ if (min !== undefined && max !== undefined) {
+ const wsData = clampWithWaveShaper(unClamped, min, max);
+ modulator = wsData.modulator;
+ toCleanup.push(...wsData.toCleanup);
+ }
+ webAudioTimeout(
+ ac,
+ () => {
+ targetParams.forEach((t) => modulator.connect(t));
+ },
+ 0,
+ params.begin,
+ );
+ toCleanup.push(dc, shifted, depthGain);
+ return { modulator, toCleanup };
+};
diff --git a/packages/superdough/reverbGen.mjs b/packages/superdough/reverbGen.mjs
index bb050874c..0949a30d5 100644
--- a/packages/superdough/reverbGen.mjs
+++ b/packages/superdough/reverbGen.mjs
@@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+import { releaseAudioNode } from './helpers.mjs';
+
var reverbGen = {};
/** Generates a reverb impulse response.
@@ -104,8 +106,8 @@ var applyGradualLowpass = function (input, lpFreqStart, lpFreqEnd, lpFreqEndAt,
player.start();
context.oncomplete = function (event) {
callback(event.renderedBuffer);
- filter.disconnect();
- player.disconnect();
+ releaseAudioNode(filter);
+ releaseAudioNode(player);
};
context.startRendering();
diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs
index 2deeb4b74..819de3c78 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 4fd6164dc..33c1e404f 100644
--- a/packages/superdough/superdough.mjs
+++ b/packages/superdough/superdough.mjs
@@ -7,22 +7,24 @@ This program is free software: you can redistribute it and/or modify it under th
import './feedbackdelay.mjs';
import './reverb.mjs';
import './vowel.mjs';
-import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
+import { clamp, nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
import workletsUrl from './worklets.mjs?audioworklet';
import {
createFilter,
+ effectSend,
gainNode,
getCompressor,
getDistortion,
getLfo,
getWorklet,
- effectSend,
releaseAudioNode,
+ webAudioTimeout,
} 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 +186,7 @@ let defaultDefaultValues = {
distortvol: 1,
distorttype: 0,
delay: 0,
+ busgain: 1,
byteBeatExpression: '0',
delayfeedback: 0.5,
delaysync: 3 / 16,
@@ -191,6 +194,9 @@ let defaultDefaultValues = {
i: 1,
velocity: 1,
fft: 8,
+ tremolodepth: 1,
+ tremolophase: 0,
+ release: 0.01,
};
const defaultDefaultDefaultValues = Object.freeze({ ...defaultDefaultValues });
@@ -327,9 +333,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
@@ -359,7 +365,7 @@ export let analysers = {},
analysersData = {};
export function getAnalyserById(id, fftSize = 1024, smoothingTimeConstant = 0.5) {
- if (!analysers[id] || analysers[id].audioContext != getAudioContext()) {
+ if (!analysers[id] || analysers[id].context != getAudioContext()) {
// make sure this doesn't happen too often as it piles up garbage
const analyserNode = getAudioContext().createAnalyser();
analyserNode.fftSize = fftSize;
@@ -400,7 +406,37 @@ function mapChannelNumbers(channels) {
return (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1);
}
+class Chain {
+ constructor(head) {
+ this.audioNodes = [head];
+ this.tails = [head];
+ }
+ connect(...nodes) {
+ nodes.forEach((node) => {
+ this.tails.forEach((tail) => {
+ tail.connect(node);
+ });
+ });
+ this.tails = nodes;
+ this.audioNodes.push(...nodes);
+ return this;
+ }
+ connectOne(idx, node) {
+ this.tails[idx].connect(node);
+ this.tails[idx] = node;
+ this.audioNodes.push(node);
+ return this;
+ }
+ releaseNodes() {
+ this.audioNodes.forEach((n) => releaseAudioNode(n));
+ this.audioNodes = [];
+ this.tails = [];
+ }
+}
+
export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) => {
+ // mapping from main FX and numbered FX chains to nodes
+ const nodes = { main: {} };
// new: t is always expected to be the absolute target onset time
const ac = getAudioContext();
const audioController = getSuperdoughAudioController();
@@ -429,49 +465,24 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
}
// destructure
let {
- tremolo,
- tremolosync,
- tremolodepth = 1,
- tremoloskew,
- tremolophase = 0,
- tremoloshape,
s = getDefaultValue('s'),
bank,
source,
- gain = getDefaultValue('gain'),
postgain = getDefaultValue('postgain'),
- density = getDefaultValue('density'),
duckorbit,
duckonset,
duckattack,
duckdepth,
djf,
- // filters
- fanchor = getDefaultValue('fanchor'),
- release = 0,
-
- //phaser
- phaserrate,
- phaserdepth = getDefaultValue('phaserdepth'),
- phasersweep,
- phasercenter,
- //
- coarse,
-
- crush,
+ release = getDefaultValue('release'),
dry,
- shape,
- shapevol = getDefaultValue('shapevol'),
- distort,
- distortvol = getDefaultValue('distortvol'),
- distorttype = getDefaultValue('distorttype'),
- pan,
- vowel,
delay = getDefaultValue('delay'),
delayfeedback = getDefaultValue('delayfeedback'),
delaysync = getDefaultValue('delaysync'),
delaytime,
orbit = getDefaultValue('orbit'),
+ bus,
+ busgain = getDefaultValue('busgain'),
room,
roomfade,
roomlp,
@@ -481,16 +492,10 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
irspeed,
irbegin,
i = getDefaultValue('i'),
- velocity = getDefaultValue('velocity'),
analyze, // analyser wet
fft = getDefaultValue('fft'), // fftSize 0 - 10
- compressor: compressorThreshold,
- compressorRatio,
- compressorKnee,
- compressorAttack,
- compressorRelease,
- transient,
- transsustain,
+ FX = [],
+ FXrelease,
} = value;
delaytime = delaytime ?? cycleToSeconds(delaysync, cps);
@@ -505,17 +510,13 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
audioController.duck(duckorbit, t, duckonset, duckattack, duckdepth);
}
- gain = applyGainCurve(nanFallback(gain, 1));
postgain = applyGainCurve(postgain);
- shapevol = applyGainCurve(shapevol);
- distortvol = applyGainCurve(distortvol);
delay = applyGainCurve(delay);
- velocity = applyGainCurve(velocity);
- tremolodepth = applyGainCurve(tremolodepth);
- gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future
+ busgain = applyGainCurve(busgain);
const end = t + hapDuration;
- const endWithRelease = end + release;
+ const fullRelease = Math.max(release, FXrelease ?? 0);
+ const endWithRelease = end + fullRelease;
const chainID = Math.round(Math.random() * 1000000);
// oldest audio nodes will be destroyed if maximum polyphony is exceeded
@@ -529,8 +530,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
activeSoundSources.delete(chainID);
}
- let audioNodes = [];
-
if (['-', '~', '_'].includes(s)) {
return;
}
@@ -543,17 +542,27 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
let sourceNode;
if (source) {
sourceNode = source(t, value, hapDuration, cps);
+ nodes.main['source'] = [sourceNode];
} else if (getSound(s)) {
const { onTrigger } = getSound(s);
- const onEnded = () => {
- audioNodes.forEach((n) => releaseAudioNode(n));
- activeSoundSources.delete(chainID);
- };
+ // We have to use onEnded because some sources (e.g. `sampler`) have
+ // an internal duration which is longer than `value.duration`
+ const onEnded = () =>
+ webAudioTimeout(
+ ac,
+ () => {
+ chain.releaseNodes();
+ activeSoundSources.delete(chainID);
+ },
+ 0,
+ endWithRelease,
+ );
const soundHandle = await onTrigger(t, value, onEnded, cps);
if (soundHandle) {
sourceNode = soundHandle.node;
activeSoundSources.set(chainID, new WeakRef(soundHandle)); // allow GC
+ nodes.main = { ...nodes.main, ...soundHandle.nodes };
}
} else {
throw new Error(`sound ${s} not found! Is it loaded?`);
@@ -568,206 +577,345 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
logger('[webaudio] skip hap: still loading', ac.currentTime - t);
return;
}
- const chain = []; // audio nodes that will be connected to each other sequentially
- chain.push(sourceNode);
- stretch !== undefined && chain.push(getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: stretch }));
- transient !== undefined &&
- chain.push(
- getWorklet(
+ const chain = new Chain(sourceNode); // connection manager which tracks audio nodes for releasing
+ FX = [...FX, value]; // run through the FX chain and then run through all FX outside of it as well
+ for (let [idx, fx] of Object.entries(FX)) {
+ const key = idx == FX.length - 1 ? 'main' : idx;
+ nodes[key] ??= {};
+ const fxNodes = nodes[key];
+ let {
+ gain = getDefaultValue('gain'),
+ velocity = getDefaultValue('velocity'),
+ shapevol = getDefaultValue('shapevol'),
+ distorttype = getDefaultValue('distorttype'),
+ distortvol = getDefaultValue('distortvol'),
+ tremolodepth = getDefaultValue('tremolodepth'),
+ phaserdepth = getDefaultValue('phaserdepth'),
+ delay = getDefaultValue('delay'),
+ delayfeedback = getDefaultValue('delayfeedback'),
+ delaysync = getDefaultValue('delaysync'),
+ delaytime,
+ stretch = getDefaultValue('stretch'),
+ i = getDefaultValue('i'),
+ } = fx;
+ gain = applyGainCurve(nanFallback(gain, 1));
+ shapevol = applyGainCurve(shapevol);
+ distortvol = applyGainCurve(distortvol);
+ velocity = applyGainCurve(velocity);
+ tremolodepth = applyGainCurve(tremolodepth);
+ gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future
+ delaytime = delaytime ?? cycleToSeconds(delaysync, cps);
+
+ if (stretch !== undefined) {
+ const phaseVocoder = getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: stretch });
+ chain.connect(phaseVocoder);
+ fxNodes['stretch'] = [phaseVocoder];
+ }
+
+ if (fx.transient !== undefined) {
+ const transProcessor = getWorklet(
ac,
'transient-processor',
{},
{
processorOptions: {
- attack: transient,
- sustain: transsustain,
+ attack: fx.transient,
+ sustain: fx.transsustain,
begin: t,
end: endWithRelease,
},
},
- ),
- );
+ );
+ chain.connect(transProcessor);
+ fxNodes['transient'] = transProcessor;
+ }
- // gain stage
- chain.push(gainNode(gain));
+ // gain stage
+ const initialGain = gainNode(gain);
+ fxNodes['gain'] = [initialGain];
+ chain.connect(initialGain);
- // filter
- const ftype = getFilterType(value.ftype);
+ // filter
+ const ftype = getFilterType(value.ftype);
- if (value.cutoff !== undefined) {
- const lpMap = {
- frequency: 'cutoff',
- q: 'resonance',
- attack: 'lpattack',
- decay: 'lpdecay',
- sustain: 'lpsustain',
- release: 'lprelease',
- env: 'lpenv',
- anchor: 'fanchor',
- model: 'ftype',
- drive: 'drive',
- rate: 'lprate',
- sync: 'lpsync',
- depth: 'lpdepth',
- depthfrequency: 'lpdepthfrequency',
- shape: 'lpshape',
- dcoffset: 'lpdc',
- skew: 'lpskew',
- };
- const lpParams = pickAndRename(value, lpMap);
- lpParams.type = 'lowpass';
- const lp = () => createFilter(ac, t, end, lpParams, cps, cycle);
- const { filter: lpf1, lfo: lfo1 } = lp();
- chain.push(lpf1);
- lfo1 && audioNodes.push(lfo1);
- if (ftype === '24db') {
- const { filter: lpf2, lfo: lfo2 } = lp();
- chain.push(lpf2);
- lfo2 && audioNodes.push(lfo2);
+ const filt = (params) => createFilter(ac, t, end, params, cps, cycle);
+ if (fx.cutoff !== undefined) {
+ const lpMap = {
+ frequency: 'cutoff',
+ q: 'resonance',
+ attack: 'lpattack',
+ decay: 'lpdecay',
+ sustain: 'lpsustain',
+ release: 'lprelease',
+ env: 'lpenv',
+ anchor: 'fanchor',
+ model: 'ftype',
+ drive: 'drive',
+ rate: 'lprate',
+ sync: 'lpsync',
+ depth: 'lpdepth',
+ depthfrequency: 'lpdepthfrequency',
+ shape: 'lpshape',
+ dcoffset: 'lpdc',
+ skew: 'lpskew',
+ };
+ const lpParams = pickAndRename(fx, lpMap);
+ lpParams.type = 'lowpass';
+ const { filter: lpf1, lfo: lfo1 } = filt(lpParams);
+ fxNodes['lpf'] = [lpf1];
+ fxNodes['lpf_lfo'] = [lfo1];
+ chain.connect(lpf1);
+ lfo1 && chain.audioNodes.push(lfo1);
+ if (ftype === '24db') {
+ const { filter: lpf2, lfo: lfo2 } = filt(lpParams);
+ fxNodes['lpf'].push(lpf2);
+ fxNodes['lpf_lfo'].push(lfo2);
+ chain.connect(lpf2);
+ lfo2 && chain.audioNodes.push(lfo2);
+ }
+ }
+
+ if (fx.hcutoff !== undefined) {
+ const hpMap = {
+ frequency: 'hcutoff',
+ q: 'hresonance',
+ attack: 'hpattack',
+ decay: 'hpdecay',
+ sustain: 'hpsustain',
+ release: 'hprelease',
+ env: 'hpenv',
+ anchor: 'fanchor',
+ model: 'ftype',
+ drive: 'drive',
+ rate: 'hprate',
+ sync: 'hpsync',
+ depth: 'hpdepth',
+ depthfrequency: 'hpdepthfrequency',
+ shape: 'hpshape',
+ dcoffset: 'hpdc',
+ skew: 'hpskew',
+ };
+ const hpParams = pickAndRename(fx, hpMap);
+ hpParams.type = 'highpass';
+ const { filter: hpf1, lfo: lfo1 } = filt(hpParams);
+ fxNodes['hpf'] = [hpf1];
+ fxNodes['hpf_lfo'] = [lfo1];
+ lfo1 && chain.audioNodes.push(lfo1);
+ chain.connect(hpf1);
+ if (ftype === '24db') {
+ const { filter: hpf2, lfo: lfo2 } = filt(hpParams);
+ fxNodes['hpf'].push(hpf2);
+ fxNodes['hpf_lfo'].push(lfo2);
+ chain.connect(hpf2);
+ lfo2 && chain.audioNodes.push(lfo2);
+ }
+ }
+
+ if (fx.bandf !== undefined) {
+ const bpMap = {
+ frequency: 'bandf',
+ q: 'bandq',
+ attack: 'bpattack',
+ decay: 'bpdecay',
+ sustain: 'bpsustain',
+ release: 'bprelease',
+ env: 'bpenv',
+ anchor: 'fanchor',
+ model: 'ftype',
+ drive: 'drive',
+ rate: 'bprate',
+ sync: 'bpsync',
+ depth: 'bpdepth',
+ depthfrequency: 'bpdepthfrequency',
+ shape: 'bpshape',
+ dcoffset: 'bpdc',
+ skew: 'bpskew',
+ };
+ const bpParams = pickAndRename(fx, bpMap);
+ bpParams.type = 'bandpass';
+ const { filter: bpf1, lfo: lfo1 } = filt(bpParams);
+ fxNodes['bpf'] = [bpf1];
+ fxNodes['bpf_lfo'] = [lfo1];
+ chain.connect(bpf1);
+ lfo1 && chain.audioNodes.push(lfo1);
+ if (ftype === '24db') {
+ const { filter: bpf2, lfo: lfo2 } = filt(bpParams);
+ fxNodes['bpf'].push(bpf2);
+ fxNodes['bpf_lfo'].push(lfo2);
+ chain.connect(bpf2);
+ lfo2 && chain.audioNodes.push(lfo2);
+ }
+ }
+
+ if (fx.vowel !== undefined) {
+ const vowelNode = ac.createVowelFilter(fx.vowel);
+ fxNodes['vowel'] = vowelNode.filters;
+ chain.connect(vowelNode);
+ }
+
+ // effects
+ if (fx.coarse !== undefined) {
+ const coarseNode = getWorklet(ac, 'coarse-processor', { coarse: fx.coarse });
+ fxNodes['coarse'] = [coarseNode];
+ chain.connect(coarseNode);
+ }
+ if (fx.crush !== undefined) {
+ const crushNode = getWorklet(ac, 'crush-processor', { crush: fx.crush });
+ fxNodes['crush'] = [crushNode];
+ chain.connect(crushNode);
+ }
+ if (fx.shape !== undefined) {
+ const shapeNode = getWorklet(ac, 'shape-processor', { shape: fx.shape, postgain: shapevol });
+ fxNodes['shape'] = [shapeNode];
+ chain.connect(shapeNode);
+ }
+ if (fx.distort !== undefined) {
+ const distortNode = getDistortion(fx.distort, distortvol, distorttype);
+ fxNodes['distort'] = [distortNode];
+ chain.connect(distortNode);
+ }
+
+ let tremolo = fx.tremolo;
+ if (fx.tremolosync != null) {
+ tremolo = cps * fx.tremolosync;
+ }
+
+ if (tremolo !== undefined) {
+ // Allow clipping of modulator for more dynamic possiblities, and to prevent speaker overload
+ // EX: a triangle waveform will clip like this /-\ when the depth is above 1
+ const gain = Math.max(1 - tremolodepth, 0);
+ const amGain = new GainNode(ac, { gain });
+
+ const time = cycle / cps;
+ const lfo = getLfo(ac, {
+ skew: fx.tremoloskew ?? (fx.tremoloshape != null ? 0.5 : 1),
+ frequency: tremolo,
+ depth: tremolodepth,
+ time,
+ dcoffset: 0,
+ shape: fx.tremoloshape,
+ phaseoffset: fx.tremolophase,
+ min: 0,
+ max: 1,
+ curve: 1.5,
+ begin: t,
+ end: endWithRelease,
+ });
+ fxNodes['tremolo'] = [lfo];
+ fxNodes['tremolo_gain'] = [amGain];
+ lfo.connect(amGain.gain);
+ chain.audioNodes.push(lfo);
+ chain.connect(amGain);
+ }
+
+ if (fx.compressor !== undefined) {
+ const compressorNode = getCompressor(
+ ac,
+ fx.compressor,
+ fx.compressorRatio,
+ fx.compressorKnee,
+ fx.compressorAttack,
+ fx.compressorRelease,
+ );
+ fxNodes['compressor'] = [compressorNode];
+ chain.connect(compressorNode);
+ }
+
+ // panning
+ if (fx.pan !== undefined) {
+ const panner = ac.createStereoPanner();
+ fxNodes['pan'] = [panner];
+ panner.pan.value = 2 * fx.pan - 1;
+ chain.connect(panner);
+ }
+ // phaser
+ if (fx.phaserrate !== undefined && phaserdepth > 0) {
+ const { filterChain, lfo } = getPhaser(
+ t,
+ endWithRelease,
+ fx.phaserrate,
+ phaserdepth,
+ fx.phasercenter,
+ fx.phasersweep,
+ );
+ fxNodes['phaser'] = [...filterChain];
+ fxNodes['phaser_lfo'] = [lfo];
+ filterChain.forEach((f) => chain.connect(f));
+ chain.audioNodes.push(lfo);
+ }
+ // delay
+ if (key !== 'main' && delay > 0 && delaytime > 0 && delayfeedback > 0) {
+ const dry = gainNode(1);
+ delayfeedback = clamp(delayfeedback, 0, 0.98);
+ const delayNode = ac.createFeedbackDelay(1, delaytime, delayfeedback);
+ const wetDelay = gainNode(delay);
+ const dryDelay = gainNode(fx.dry ?? 1);
+ const sum = new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
+ chain
+ .connect(dry)
+ .connect(dryDelay, delayNode)
+ .connectOne(1, wetDelay) // connect delayNode -> wetDelay
+ .connect(sum);
+ chain.audioNodes.push(delayNode.feedbackGain, delayNode.delayGain);
+ fxNodes['delay'] = [delayNode];
+ fxNodes['delay_mix'] = [wetDelay];
+ }
+ // reverb
+ if (key !== 'main' && fx.room > 0) {
+ let roomIR;
+ if (fx.ir !== undefined) {
+ let url;
+ let sample = getSound(fx.ir);
+ if (Array.isArray(sample)) {
+ url = sample.data.samples[fx.i % sample.data.samples.length];
+ } else if (typeof sample === 'object') {
+ url = Object.values(sample.data.samples).flat()[i % Object.values(sample.data.samples).length];
+ }
+ roomIR = await loadBuffer(url, ac, fx.ir, 0);
+ }
+ const dry = gainNode(1);
+ const reverbNode = ac.createReverb(
+ fx.roomsize,
+ fx.roomfade,
+ fx.roomlp,
+ fx.roomdim,
+ roomIR,
+ fx.irspeed,
+ fx.irbegin,
+ );
+ const wetReverb = gainNode(fx.room);
+ const dryReverb = gainNode(fx.dry ?? 1);
+ const sum = new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
+ chain
+ .connect(dry)
+ .connect(dryReverb, reverbNode)
+ .connectOne(1, wetReverb) // connect reverbNode -> wetReverb
+ .connect(sum);
+ fxNodes['room'] = [reverbNode];
+ fxNodes['room_mix'] = [wetReverb];
}
}
- if (value.hcutoff !== undefined) {
- const hpMap = {
- frequency: 'hcutoff',
- q: 'hresonance',
- attack: 'hpattack',
- decay: 'hpdecay',
- sustain: 'hpsustain',
- release: 'hprelease',
- env: 'hpenv',
- anchor: 'fanchor',
- model: 'ftype',
- drive: 'drive',
- rate: 'hprate',
- sync: 'hpsync',
- depth: 'hpdepth',
- depthfrequency: 'hpdepthfrequency',
- shape: 'hpshape',
- dcoffset: 'hpdc',
- skew: 'hpskew',
- };
- const hpParams = pickAndRename(value, hpMap);
- hpParams.type = 'highpass';
- const hp = () => createFilter(ac, t, end, hpParams, cps, cycle);
- const { filter: hpf1, lfo: lfo1 } = hp();
- chain.push(hpf1);
- lfo1 && audioNodes.push(lfo1);
- if (ftype === '24db') {
- const { filter: hpf2, lfo: lfo2 } = hp();
- chain.push(hpf2);
- lfo2 && audioNodes.push(lfo2);
- }
- }
-
- if (value.bandf !== undefined) {
- const bpMap = {
- frequency: 'bandf',
- q: 'bandq',
- attack: 'bpattack',
- decay: 'bpdecay',
- sustain: 'bpsustain',
- release: 'bprelease',
- env: 'bpenv',
- anchor: 'fanchor',
- model: 'ftype',
- drive: 'drive',
- rate: 'bprate',
- sync: 'bpsync',
- depth: 'bpdepth',
- depthfrequency: 'bpdepthfrequency',
- shape: 'bpshape',
- dcoffset: 'bpdc',
- skew: 'bpskew',
- };
- const bpParams = pickAndRename(value, bpMap);
- bpParams.type = 'bandpass';
- const bp = () => createFilter(ac, t, end, bpParams, cps, cycle);
- const { filter: bpf1, lfo: lfo1 } = bp();
- chain.push(bpf1);
- lfo1 && audioNodes.push(lfo1);
- if (ftype === '24db') {
- const { filter: bpf2, lfo: lfo2 } = bp();
- chain.push(bpf2);
- lfo2 && audioNodes.push(lfo2);
- }
- }
-
- if (vowel !== undefined) {
- const vowelFilter = ac.createVowelFilter(vowel);
- 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 (tremolosync != null) {
- tremolo = cps * tremolosync;
- }
-
- if (value.wtPosSynced != null) {
- value.wtPosRate /= cps;
- }
-
- if (value.wtWarpSynced != null) {
- value.wtWarpRate /= cps;
- }
-
- if (tremolo !== undefined) {
- // Allow clipping of modulator for more dynamic possiblities, and to prevent speaker overload
- // EX: a triangle waveform will clip like this /-\ when the depth is above 1
- const gain = Math.max(1 - tremolodepth, 0);
- const amGain = new GainNode(ac, { gain });
-
- const time = cycle / cps;
- const lfo = getLfo(ac, t, endWithRelease, {
- skew: tremoloskew ?? (tremoloshape != null ? 0.5 : 1),
- frequency: tremolo,
- depth: tremolodepth,
- time,
- dcoffset: 0,
- shape: tremoloshape,
- phaseoffset: tremolophase,
- min: 0,
- max: 1,
- curve: 1.5,
- });
- lfo.connect(amGain.gain);
- audioNodes.push(lfo);
- chain.push(amGain);
- }
-
- compressorThreshold !== undefined &&
- chain.push(
- getCompressor(ac, compressorThreshold, compressorRatio, compressorKnee, compressorAttack, compressorRelease),
- );
-
- // panning
- if (pan !== undefined) {
- const panner = ac.createStereoPanner();
- panner.pan.value = 2 * pan - 1;
- chain.push(panner);
- }
- // phaser
- if (phaserrate !== undefined && phaserdepth > 0) {
- const { filterChain, lfo } = getPhaser(t, endWithRelease, phaserrate, phaserdepth, phasercenter, phasersweep);
- audioNodes.push(lfo);
- chain.push(...filterChain);
+ if (FXrelease !== undefined && FXrelease > release) {
+ const releaseNode = gainNode(1);
+ releaseNode.gain.setValueAtTime(1, end + release);
+ releaseNode.gain.linearRampToValueAtTime(0, endWithRelease);
+ chain.connect(releaseNode);
}
// last gain
const post = new GainNode(ac, { gain: postgain });
- chain.push(post);
+ nodes.main['post'] = [post];
+ chain.connect(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.main['delay'] = [delayNode];
+ const delaySend = orbitBus.sendDelay(post, delay);
+ nodes.main['delay_mix'] = [delaySend];
+ chain.audioNodes.push(delaySend);
}
// reverb
if (room > 0) {
@@ -782,33 +930,84 @@ 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.main['room'] = [roomNode];
+ const reverbSend = orbitBus.sendReverb(post, room);
+ nodes.main['room_mix'] = [reverbSend];
+ chain.audioNodes.push(reverbSend);
+ }
+ if (bus != null) {
+ const busNode = audioController.getBus(bus);
+ const busSend = effectSend(post, busNode, busgain);
+ chain.audioNodes.push(busSend);
}
if (djf != null) {
- orbitBus.getDjf(djf, t);
+ const djfNode = orbitBus.getDjf(djf, t);
+ nodes.main['djf'] = [djfNode];
}
// analyser
if (analyze && !(ac instanceof OfflineAudioContext)) {
const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5));
const analyserSend = effectSend(post, analyserNode, 1);
- audioNodes.push(analyserSend);
+ chain.audioNodes.push(analyserSend);
}
if (dry != null) {
dry = applyGainCurve(dry);
const dryGain = new GainNode(ac, { gain: dry });
- chain.push(dryGain);
+ chain.connect(dryGain);
orbitBus.connectToOutput(dryGain);
} else {
orbitBus.connectToOutput(post);
}
- // connect chain elements together
- chain.slice(1).reduce((last, current) => last.connect(current), chain[0]);
- audioNodes = audioNodes.concat(chain);
+ // finally, now that `nodes` is populated, set up modulators
+ FX.forEach((fx, idx) => {
+ const key = idx === FX.length - 1 ? 'main' : idx;
+ if (fx.lfo) {
+ for (const id of fx.lfo.__ids) {
+ const params = fx.lfo[id];
+ params.fxi ??= key;
+ const lfo = connectLFO(
+ id,
+ {
+ ...params,
+ cps,
+ cycle,
+ begin: t,
+ end: endWithRelease,
+ },
+ nodes,
+ );
+ lfo && chain.audioNodes.push(lfo);
+ }
+ }
+ if (fx.env) {
+ for (const id of fx.env.__ids) {
+ const params = fx.env[id];
+ params.fxi ??= key;
+ const env = connectEnvelope(
+ id,
+ {
+ ...params,
+ begin: t,
+ end: endWithRelease,
+ },
+ nodes,
+ );
+ env && chain.audioNodes.push(env);
+ }
+ }
+ if (fx.bmod) {
+ for (const id of fx.bmod.__ids) {
+ const params = fx.bmod[id];
+ params.fxi ??= key;
+ const { toCleanup } = connectBusModulator({ ...params, begin: t, end: endWithRelease }, nodes, controller);
+ chain.audioNodes.push(...toCleanup);
+ }
+ }
+ });
};
export const superdoughTrigger = (t, hap, ct, cps) => {
diff --git a/packages/superdough/superdoughdata.mjs b/packages/superdough/superdoughdata.mjs
new file mode 100644
index 000000000..e43df9eef
--- /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: 'frequency' },
+ 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 d8ead7d06..d3936646a 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 d9ee0d936..6c8d1fea9 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/vowel.mjs b/packages/superdough/vowel.mjs
index bfc1c9fcd..8ecbf9d5c 100644
--- a/packages/superdough/vowel.mjs
+++ b/packages/superdough/vowel.mjs
@@ -1,3 +1,5 @@
+import { releaseAudioNode } from './helpers.mjs';
+
// credits to webdirt: https://github.com/dktr0/WebDirt/blob/41342e81d6ad694a2310d491fef7b7e8b0929efe/js-src/Graph.js#L597
export var vowelFormant = {
a: { freqs: [660, 1120, 2750, 3000, 3350], gains: [1, 0.5012, 0.0708, 0.0631, 0.0126], qs: [80, 90, 120, 130, 140] },
@@ -46,7 +48,8 @@ if (typeof GainNode !== 'undefined') {
}
const { gains, qs, freqs } = vowelFormant[letter];
this.makeupGain = ac.createGain();
- this.audioNodes = [];
+ this.filters = [];
+ this.gains = [];
for (let i = 0; i < 5; i++) {
const gain = ac.createGain();
gain.gain.value = gains[i];
@@ -56,9 +59,9 @@ if (typeof GainNode !== 'undefined') {
filter.frequency.value = freqs[i];
super.connect(filter);
filter.connect(gain);
- this.audioNodes.push(filter);
+ this.filters.push(filter);
gain.connect(this.makeupGain);
- this.audioNodes.push(gain);
+ this.gains.push(gain);
}
this.makeupGain.gain.value = 8; // how much makeup gain to add?
return this;
@@ -67,11 +70,13 @@ if (typeof GainNode !== 'undefined') {
this.makeupGain.connect(target);
}
disconnect() {
- this.makeupGain.disconnect();
- this.audioNodes.forEach((n) => n.disconnect());
+ releaseAudioNode(this.makeupGain);
+ this.filters.forEach(releaseAudioNode);
+ this.gains.forEach(releaseAudioNode);
super.disconnect();
this.makeupGain = null;
- this.audioNodes = null;
+ this.filters = null;
+ this.gains = null;
}
}
diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs
index ebf951a49..b4fc401f1 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 a59aeb756..8001c7da8 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 4a4c58c9c..3d6b8e9f5 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 5f8133ec5..f6547bc61 100644
--- a/test/__snapshots__/examples.test.mjs.snap
+++ b/test/__snapshots__/examples.test.mjs.snap
@@ -565,6 +565,52 @@ exports[`runs examples > example "_euclidRot" example index 20 1`] = `
]
`;
+exports[`runs examples > example "FX" example index 0 1`] = `
+[
+ "[ 0/1 → 1/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 1/8 → 1/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 1/4 → 3/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 3/8 → 1/2 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 1/2 → 5/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 5/8 → 3/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 3/4 → 7/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 7/8 → 1/1 | s:lt decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 1/1 → 9/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 9/8 → 5/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 5/4 → 11/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 11/8 → 3/2 | s:oh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 3/2 → 13/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 13/8 → 7/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 7/4 → 15/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 15/8 → 2/1 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 2/1 → 17/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 17/8 → 9/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 9/4 → 19/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 19/8 → 5/2 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 5/2 → 21/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 21/8 → 11/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 11/4 → 23/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 23/8 → 3/1 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 3/1 → 25/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 25/8 → 13/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 13/4 → 27/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 27/8 → 7/2 | s:lt decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 7/2 → 29/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 29/8 → 15/4 | s:hh decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 15/4 → 31/8 | s:sbd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+ "[ 31/8 → 4/1 | s:bd decay:0.4 FX:[{phaserrate:0.5 gain:2} {bandf:800} {distort:1.3} {room:0.2} {delay:0.5 gain:1.25} {distort:0.3}] FXrelease:1.7 ]",
+]
+`;
+
+exports[`runs examples > example "FX" example index 1 1`] = `
+[
+ "[ 0/1 → 1/1 | s:saw fmi:0.5 delay:0.3 FX:[{coarse:4} {cutoff:500 lpenv:4 lpattack:1 lpdecay:2} {distort:1}] ]",
+ "[ 1/1 → 2/1 | s:saw fmi:0.5 delay:0.3 FX:[{coarse:4} {cutoff:500 lpenv:4 lpattack:1 lpdecay:2} {distort:1}] ]",
+ "[ 2/1 → 3/1 | s:saw fmi:0.5 delay:0.3 FX:[{coarse:4} {cutoff:500 lpenv:4 lpattack:1 lpdecay:2} {distort:1}] ]",
+ "[ 3/1 → 4/1 | s:saw fmi:0.5 delay:0.3 FX:[{coarse:4} {cutoff:500 lpenv:4 lpattack:1 lpdecay:2} {distort:1}] ]",
+]
+`;
+
exports[`runs examples > example "accelerate" example index 0 1`] = `
[
"[ 0/1 → 2/1 | s:sax accelerate:0 ]",
@@ -3803,6 +3849,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 +6204,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 896a02f8a..ebec2252e 100644
--- a/test/examples.test.mjs
+++ b/test/examples.test.mjs
@@ -20,6 +20,7 @@ const skippedExamples = [
'accelerationX',
'defaultmidimap',
'midimaps',
+ 'bmod',
];
describe('runs examples', () => {
diff --git a/website/src/components/Dough/Dough.astro b/website/src/components/Dough/Dough.astro
new file mode 100644
index 000000000..91b50e07b
--- /dev/null
+++ b/website/src/components/Dough/Dough.astro
@@ -0,0 +1,17 @@
+---
+import '../../repl/Repl.css';
+---
+
+
+
+
+
diff --git a/website/src/components/Dough/dough-mirror.mjs b/website/src/components/Dough/dough-mirror.mjs
new file mode 100644
index 000000000..7784402e9
--- /dev/null
+++ b/website/src/components/Dough/dough-mirror.mjs
@@ -0,0 +1,153 @@
+import {
+ initEditor,
+ codemirrorSettings,
+ flash,
+ compartments,
+ extensions,
+ parseBooleans,
+ activateTheme,
+ updateMiniLocations,
+ highlightMiniLocations,
+} from '@strudel/codemirror';
+import { evalScope, hash2code, code2hash } from '@strudel/core';
+import { Framer } from '@strudel/draw';
+import { persistentAtom } from '@nanostores/persistent';
+import { DoughRepl } from './dough-repl.mjs';
+
+let initialCode = '$: note("c a f e")';
+
+export const code = persistentAtom('vanilla-repl-code', initialCode, {
+ encode: JSON.stringify,
+ decode: JSON.parse,
+});
+
+if (typeof window !== 'undefined') {
+ try {
+ const codeParam = window.location.href.split('#')[1] || '';
+ if (codeParam) {
+ const codeFromHash = hash2code(codeParam);
+ code.set(codeFromHash);
+ }
+ } catch (err) {
+ console.error('could not init code from url');
+ }
+}
+
+export class DoughMirror {
+ constructor(options) {
+ const { root, initialCode = code.get(), bgFill = true } = options;
+ this.root = root;
+ this.code = initialCode;
+ this.repl = new DoughRepl();
+ this.prebaked = this.prebake();
+
+ // init codemirror
+ this.editor = initEditor({
+ root,
+ initialCode: this.code,
+ onChange: (v) => {
+ if (v.docChanged) {
+ this.code = v.state.doc.toString();
+ code.set(this.code);
+ }
+ },
+ onEvaluate: this.evaluate.bind(this),
+ onStop: () => this.stop(),
+ mondo: false,
+ });
+ const settings = codemirrorSettings.get();
+ this.setFontSize(settings.fontSize);
+ this.setFontFamily(settings.fontFamily);
+
+ // init event highlighting
+ this.framer = new Framer(
+ (time) => {
+ const frameHaps = this.repl.processHaps();
+ highlightMiniLocations(this.editor, time, frameHaps);
+ },
+ (err) => console.log('Framer error', err),
+ );
+ }
+ prebake() {
+ const modulesLoading = evalScope(import('@strudel/core'), import('@strudel/tonal'), import('@strudel/mini'));
+ return Promise.all([modulesLoading, this.repl.prebake()]);
+ }
+ async evaluate() {
+ this.framer.start();
+ this.flash();
+ await this.prebaked;
+ const { miniLocations } = await this.repl.evaluate(this.code);
+ window.location.hash = '#' + code2hash(this.code);
+ updateMiniLocations(this.editor, miniLocations);
+ }
+ stop() {
+ this.repl.stop();
+ this.framer.stop();
+ highlightMiniLocations(this.editor, 0, []);
+ }
+ // added synonym (compared to StrudelMirror)
+ settings(settings = {}) {
+ this.updateSettings(settings);
+ }
+
+ // the rest is copy pasted from StrudelMirror:
+
+ updateSettings(settings = {}) {
+ settings.fontSize && this.setFontSize(settings.fontSize);
+ settings.fontFamily && this.setFontFamily(settings.fontFamily);
+ for (let key in extensions) {
+ if (key in settings) {
+ this.reconfigureExtension(key, settings[key]);
+ }
+ }
+ const updated = { ...codemirrorSettings.get(), ...settings };
+ // console.log(updated);
+ codemirrorSettings.set(updated);
+ }
+ reconfigureExtension(key, value) {
+ if (!extensions[key]) {
+ console.warn(`extension ${key} is not known`);
+ return;
+ }
+ value = parseBooleans(value);
+ const newValue = extensions[key](value, this);
+ this.editor.dispatch({
+ effects: compartments[key].reconfigure(newValue),
+ });
+ if (key === 'theme') {
+ activateTheme(value);
+ }
+ }
+ flash(ms) {
+ flash(this.editor, ms);
+ }
+ setFontSize(size) {
+ this.root.style.fontSize = size + 'px';
+ }
+ setFontFamily(family) {
+ this.root.style.fontFamily = family;
+ const scroller = this.root.querySelector('.cm-scroller');
+ if (scroller) {
+ scroller.style.fontFamily = family;
+ }
+ }
+ setCode(code, offset = 0) {
+ const changes = {
+ from: 0,
+ to: this.editor.state.doc.length + offset,
+ insert: code,
+ };
+ this.editor.dispatch({ changes });
+ }
+ getCursorLocation() {
+ return this.editor.state.selection.main.head;
+ }
+ setCursorLocation(col) {
+ return this.editor.dispatch({ selection: { anchor: col } });
+ }
+ appendCode(code) {
+ const cursor = this.getCursorLocation();
+ this.setCode(this.code + code);
+ this.setCursorLocation(cursor);
+ }
+}
diff --git a/website/src/components/Dough/dough-repl.mjs b/website/src/components/Dough/dough-repl.mjs
new file mode 100644
index 000000000..c8b47a3e8
--- /dev/null
+++ b/website/src/components/Dough/dough-repl.mjs
@@ -0,0 +1,109 @@
+// import { Dough, doughsamples } from 'dough-synth';
+import { Dough, doughsamples } from 'https://unpkg.com/dough-synth@0.1.9/dough.js';
+import { Pattern, noteToMidi, evaluate, stack } from '@strudel/core';
+// import doughUrl from 'dough-synth?url';
+import { transpiler } from '@strudel/transpiler';
+//const doughBaseUrl = doughUrl.split('/').slice(0, -1).join('/') + '/';
+const doughBaseUrl = 'https://unpkg.com/dough-synth@0.1.9/';
+
+Object.assign(globalThis, { doughsamples });
+
+export class DoughRepl {
+ pattern;
+ latency = 0.1;
+ cps = 0.5;
+ origin;
+ t0;
+ lasttime;
+ strudel;
+ q = [];
+ constructor() {
+ this.ready = this.init();
+ }
+ async init() {
+ // init dough immediately, so that it can attach the document click event to initAudio immediately
+ this.dough = new Dough({
+ base: doughBaseUrl,
+ //base: "../", // local dev
+ onTick: ({ t0, t1 }) => {
+ if (!this.pattern) {
+ return;
+ }
+ this.origin ??= t0;
+ this.t0 = t0;
+ this.lasttime = performance.now();
+ const a = (t0 - this.origin) * this.cps;
+ const b = (t1 - this.origin) * this.cps;
+
+ const haps = this.pattern.queryArc(a, b).filter((hap) => hap.hasOnset());
+ if (!haps.length) {
+ return;
+ }
+ haps.forEach((hap) => {
+ const time = hap.whole.begin.valueOf() / this.cps + this.origin + this.latency;
+ const duration = hap.duration.valueOf() / this.cps;
+ const event = {
+ dough: 'play',
+ ...hap.value,
+ time,
+ duration,
+ };
+ if (event.note && typeof event.note === 'string') {
+ event.note = noteToMidi(event.note);
+ }
+ //console.log("event", JSON.stringify(Object.entries(event)));
+ this.dough.evaluate(event);
+ this.q.push({ event, hap });
+ });
+ },
+ });
+ // miniAllStrings();
+ const setcps = (cps) => (this.cps = cps);
+ const setcpm = (cpm) => setcps(cpm / 60);
+ const replScope = { setcps, setcpm };
+ Object.assign(globalThis, replScope);
+ }
+ async evaluate(code) {
+ await this.ready;
+ let patterns = [];
+ Pattern.prototype.p = function (id) {
+ if (!id.startsWith('_')) {
+ patterns.push(this);
+ }
+ };
+
+ let { meta } = await evaluate(code, transpiler, { addReturn: false, wrapAsync: true, emitWidgets: false });
+ const { miniLocations } = meta;
+
+ this.pattern = stack(...patterns);
+ return { miniLocations, pattern: this.pattern };
+ }
+ stop() {
+ this.pattern = undefined;
+ this.origin = undefined;
+ }
+ prebake() {
+ return Promise.all([
+ // doughsamples('github:eddyflux/crate')
+ ]);
+ }
+ // tbd: move this to dough-synth
+ get time() {
+ return this.t0 + (performance.now() - this.lasttime) / 1000 + this.latency;
+ }
+ processHaps() {
+ const currentHaps = [];
+ const time = this.time;
+ this.q = this.q.filter(({ event, hap }) => {
+ const end = event.time + event.duration;
+ const isActive = time >= event.time && time <= end;
+ if (isActive) {
+ currentHaps.push(hap);
+ }
+ return end > time; // delete old events
+ // we do NOT return !isActive, because a frame might miss an event, which would cause a leak
+ });
+ // console.log(this.q.length); // to check for leaks
+ return currentHaps;
+ }
+}
diff --git a/website/src/pages/dough/index.astro b/website/src/pages/dough/index.astro
new file mode 100644
index 000000000..9909fc6da
--- /dev/null
+++ b/website/src/pages/dough/index.astro
@@ -0,0 +1,15 @@
+---
+import HeadCommon from '../../components/HeadCommon.astro';
+import Dough from '../../components/Dough/Dough.astro';
+---
+
+
+
+
+ Strudel Dough REPL
+
+
+
+ mastodon
+
+
diff --git a/website/src/pages/learn/metadata.mdx b/website/src/pages/learn/metadata.mdx
index 61db16f0e..c94257925 100644
--- a/website/src/pages/learn/metadata.mdx
+++ b/website/src/pages/learn/metadata.mdx
@@ -11,9 +11,9 @@ import { JsDoc } from '../../docs/JsDoc';
You can optionally add some music metadata in your Strudel code, by using tags in code comments:
```js
-// @title Hey Hoo
-// @by Sam Tagada
-// @license CC BY-NC-SA
+// @title My Cool Song
+// @by John Doe
+// @license CC-BY-SA-4.0
```
Like other comments, those are ignored by Strudel, but it can be used by other tools to retrieve some information about the music.
@@ -24,22 +24,22 @@ You can also use comment blocks:
```js
/*
-@title Hey Hoo
-@by Sam Tagada
-@license CC BY-NC-SA
+@title My Cool Song
+@by John Doe
+@license CC-BY-SA-4.0
*/
```
Or define multiple tags in one line:
```js
-// @title Hey Hoo @by Sam Tagada @license CC BY-NC-SA
+// @title My Cool Song @by John Doe @license CC-BY-SA-4.0
```
The `title` tag has an alternative syntax using quotes (must be defined at the very begining):
```js
-// "Hey Hoo" @by Sam Tagada
+// "My Cool Song" @by John Doe
```
## Tags list
@@ -48,20 +48,22 @@ Available tags are:
- `@title`: music title
- `@by`: music author(s), separated by comma, eventually followed with a link in `<>` (ex: `@by John Doe `)
-- `@license`: music license(s), e.g. CC BY-NC-SA. Unsure? [Choose a creative commons license here](https://creativecommons.org/choose/)
+- `@license`: music license(s), separated by comma. Each license should be specified by using the correct identifier in the [https://spdx.org/licenses/](SPDX License List). Example: CC-BY-SA-4.0. Unsure? [Choose a Creative Commons license here](https://creativecommons.org/choose/).
- `@details`: some additional information about the music
-- `@url`: web page(s) related to the music (git repo, soundcloud link, etc.)
-- `@genre`: music genre(s) (pop, jazz, etc)
+- `@url`: web page(s) related to the music (git repository, Soundcloud link, etc.)
+- `@genre`: music genre(s) (pop, jazz, etc.)
- `@album`: music album name
+Note to tool authors: _Never_ trust that a song has filled those fields with syntactically correct values; make sure your software is robust enough it doesn't break if it encounters bad values
+
## Multiple values
Some of them accepts several values, using the comma or new line separator, or duplicating the tag:
```js
/*
-@by Sam Tagada
- Jimmy
+@by John Doe
+ Jane Doe
@genre pop, jazz
@url https://example.com
@url https://example.org
@@ -72,11 +74,11 @@ You can also add optional prefixes and use tags where you want:
```js
/*
-song @by Sam Tagada
-samples @by Jimmy
+song @by John Doe
+samples @by Jane Doe
*/
...
-note("a3 c#4 e4 a4") // @by Sandy
+note("a3 c#4 e4 a4") // @by Sandy Sue
```
## Multiline
diff --git a/website/src/pages/technical-manual/repl.mdx b/website/src/pages/technical-manual/repl.mdx
index f74a4fb8b..d8a22cf55 100644
--- a/website/src/pages/technical-manual/repl.mdx
+++ b/website/src/pages/technical-manual/repl.mdx
@@ -41,7 +41,7 @@ note("c3 [e3 g3]*2")
is transpiled to:
```strudel
-note(m('c3 [e3 g3]', 5))
+note(m('c3 [e3 g3]*2', 5))
```
Here, the string is wrapped in `m`, which will create a pattern from a mini-notation string. As the second parameter, it gets passed source code location of the string, which enables highlighting active events later.
diff --git a/website/src/pages/technical-manual/vim.mdx b/website/src/pages/technical-manual/vim.mdx
index fa53aa550..6ec41d4a2 100644
--- a/website/src/pages/technical-manual/vim.mdx
+++ b/website/src/pages/technical-manual/vim.mdx
@@ -35,3 +35,24 @@ Troubleshooting
- If :w logs but evaluation doesn't apply, ensure Vim keybindings are active and try again. You can also use Ctrl+Enter as a fallback.
- For :q / gc, ensure focus is inside the editor. If an error occurs, reload the page to reset editor state and try again.
+
+## Adding custom keybindings
+
+To add custom keybindings the `Vim` object can be used (either from within a pattern or in a prebake script)
+
+Example:
+
+```javascript
+// Map 'jk' to Escape in normal mode
+Vim.map('jk', '', 'insert');
+// Map 'U' to :w (Evaluate the current code)
+Vim.map('U', ':w', 'normal');
+// Map 'Q' to :q — Stop/pause playback
+Vim.map('Q', ':q', 'normal');
+// Map 'J' to find next '$' (jump to next label)
+Vim.map('J', '/\\$', 'normal');
+// Map 'K' to find previous '$' (jump to previous label)
+Vim.map('K', '?\\$', 'normal');
+```
+
+For more information on how to use the `Vim` object see [CodeMirror Vim](https://github.com/replit/codemirror-vim)
diff --git a/website/src/pages/workshop/first-sounds.mdx b/website/src/pages/workshop/first-sounds.mdx
index 1d659972b..84deb0551 100644
--- a/website/src/pages/workshop/first-sounds.mdx
+++ b/website/src/pages/workshop/first-sounds.mdx
@@ -377,4 +377,4 @@ insect [crow metal] - -,
punchcard
/>
-Now that we know the basics of how to make beats, let's look at how we can play [notes](/workshop/first-notes)
+Now that we know the basics of how to make beats, let's look at how we can play [notes](/workshop/first-notes).