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