diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index d979ff23..97129779 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -234,7 +234,7 @@ const generic_params = [ * note("c3 e3").decay("<.1 .2 .3 .4>").sustain(0) * */ - ['decay'], + ['decay', 'dec'], /** * Amplitude envelope sustain level: The level which is reached after attack / decay, being sustained until the offset. * @@ -270,7 +270,7 @@ const generic_params = [ * s("bd sd,hh*3").bpf("<1000 2000 4000 8000>") * */ - [['bandf', 'bandq'], 'bpf', 'bp'], + [['bandf', 'bandq', 'bpenv'], 'bpf', 'bp'], // TODO: in tidal, it seems to be normalized /** * Sets the **b**and-**p**ass **q**-factor (resonance). @@ -481,7 +481,7 @@ const generic_params = [ * s("bd*8").lpf("1000:0 1000:10 1000:20 1000:30") * */ - [['cutoff', 'resonance'], 'ctf', 'lpf', 'lp'], + [['cutoff', 'resonance', 'lpenv'], 'ctf', 'lpf', 'lp'], /** * Sets the lowpass filter envelope modulation depth. @@ -758,7 +758,7 @@ const generic_params = [ * .vibmod("<.25 .5 1 2 12>:8") */ [['vibmod', 'vib'], 'vmod'], - [['hcutoff', 'hresonance'], 'hpf', 'hp'], + [['hcutoff', 'hresonance', 'hpenv'], 'hpf', 'hp'], /** * Controls the **h**igh-**p**ass **q**-value. * @@ -1394,10 +1394,20 @@ controls.adsr = register('adsr', (adsr, pat) => { const [attack, decay, sustain, release] = adsr; return pat.set({ attack, decay, sustain, release }); }); -controls.ds = register('ds', (ds, pat) => { - ds = !Array.isArray(ds) ? [ds] : ds; - const [decay, sustain] = ds; +controls.ad = register('ad', (t, pat) => { + t = !Array.isArray(t) ? [t] : t; + const [attack, decay = attack] = t; + return pat.attack(attack).decay(decay); +}); +controls.ds = register('ds', (t, pat) => { + t = !Array.isArray(t) ? [t] : t; + const [decay, sustain = 0] = t; return pat.set({ decay, sustain }); }); +controls.ds = register('ar', (t, pat) => { + t = !Array.isArray(t) ? [t] : t; + const [attack, release = attack] = t; + return pat.set({ attack, release }); +}); export default controls; diff --git a/packages/core/util.mjs b/packages/core/util.mjs index 6c0439b6..ef55de95 100644 --- a/packages/core/util.mjs +++ b/packages/core/util.mjs @@ -86,7 +86,7 @@ export const midi2note = (n) => { // modulo that works with negative numbers e.g. _mod(-1, 3) = 2. Works on numbers (rather than patterns of numbers, as @mod@ from pattern.mjs does) export const _mod = (n, m) => ((n % m) + m) % m; -export function nanFallback(value, fallback) { +export function nanFallback(value, fallback = 0) { if (isNaN(Number(value))) { logger(`"${value}" is not a number, falling back to ${fallback}`, 'warning'); return fallback; diff --git a/packages/soundfonts/fontloader.mjs b/packages/soundfonts/fontloader.mjs index c2f6a19c..dc3f4b61 100644 --- a/packages/soundfonts/fontloader.mjs +++ b/packages/soundfonts/fontloader.mjs @@ -1,5 +1,5 @@ import { noteToMidi, freqToMidi, getSoundIndex } from '@strudel.cycles/core'; -import { getAudioContext, registerSound, getEnvelope } from '@strudel.cycles/webaudio'; +import { getAudioContext, registerSound, getParamADSR, getADSRValues } from '@strudel.cycles/webaudio'; import gm from './gm.mjs'; let loadCache = {}; @@ -130,24 +130,33 @@ export function registerSoundfonts() { registerSound( name, async (time, value, onended) => { + const [attack, decay, sustain, release] = getADSRValues([ + value.attack, + value.decay, + value.sustain, + value.release, + ]); + + const { duration } = value; const n = getSoundIndex(value.n, fonts.length); - const { attack = 0.001, decay = 0.001, sustain = 1, release = 0.001 } = value; const font = fonts[n]; const ctx = getAudioContext(); const bufferSource = await getFontBufferSource(font, value, ctx); bufferSource.start(time); - const { node: envelope, stop: releaseEnvelope } = getEnvelope(attack, decay, sustain, release, 0.3, time); - bufferSource.connect(envelope); - const stop = (releaseTime) => { - const silentAt = releaseEnvelope(releaseTime); - bufferSource.stop(silentAt); - }; + const envGain = ctx.createGain(); + const node = bufferSource.connect(envGain); + const holdEnd = time + duration; + getParamADSR(node.gain, attack, decay, sustain, release, 0, 0.3, time, holdEnd, 'linear'); + let envEnd = holdEnd + release + 0.01; + + bufferSource.stop(envEnd); + const stop = (releaseTime) => {}; bufferSource.onended = () => { bufferSource.disconnect(); - envelope.disconnect(); + node.disconnect(); onended(); }; - return { node: envelope, stop }; + return { node, stop }; }, { type: 'soundfont', prebake: true, fonts }, ); diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 9194e2bb..bee26f38 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -1,5 +1,5 @@ import { getAudioContext } from './superdough.mjs'; -import { clamp } from './util.mjs'; +import { clamp, nanFallback } from './util.mjs'; export function gainNode(value) { const node = getAudioContext().createGain(); @@ -7,78 +7,68 @@ export function gainNode(value) { return node; } -// alternative to getADSR returning the gain node and a stop handle to trigger the release anytime in the future -export const getEnvelope = (attack, decay, sustain, release, velocity, begin) => { - const gainNode = getAudioContext().createGain(); - let phase = begin; - gainNode.gain.setValueAtTime(0, begin); - phase += attack; - gainNode.gain.linearRampToValueAtTime(velocity, phase); // attack - phase += decay; - let sustainLevel = sustain * velocity; - gainNode.gain.linearRampToValueAtTime(sustainLevel, phase); // decay / sustain - // sustain end - return { - node: gainNode, - stop: (t) => { - // to make sure the release won't begin before sustain is reached - phase = Math.max(t, phase); - // see https://github.com/tidalcycles/strudel/issues/522 - gainNode.gain.setValueAtTime(sustainLevel, phase); - phase += release; - gainNode.gain.linearRampToValueAtTime(0, phase); // release - return phase; - }, - }; -}; - -export const getExpEnvelope = (attack, decay, sustain, release, velocity, begin) => { - sustain = Math.max(0.001, sustain); - velocity = Math.max(0.001, velocity); - const gainNode = getAudioContext().createGain(); - gainNode.gain.setValueAtTime(0.0001, begin); - gainNode.gain.exponentialRampToValueAtTime(velocity, begin + attack); - gainNode.gain.exponentialRampToValueAtTime(sustain * velocity, begin + attack + decay); - return { - node: gainNode, - stop: (t) => { - // similar to getEnvelope, this will glitch if sustain level has not been reached - gainNode.gain.exponentialRampToValueAtTime(0.0001, t + release); - }, - }; -}; - -export const getADSR = (attack, decay, sustain, release, velocity, begin, end) => { - const gainNode = getAudioContext().createGain(); - gainNode.gain.setValueAtTime(0, begin); - gainNode.gain.linearRampToValueAtTime(velocity, begin + attack); // attack - gainNode.gain.linearRampToValueAtTime(sustain * velocity, begin + attack + decay); // sustain start - gainNode.gain.setValueAtTime(sustain * velocity, end); // sustain end - gainNode.gain.linearRampToValueAtTime(0, end + release); // release - // for some reason, using exponential ramping creates little cracklings - /* let t = begin; - gainNode.gain.setValueAtTime(0, t); - gainNode.gain.exponentialRampToValueAtTime(velocity, (t += attack)); - const sustainGain = Math.max(sustain * velocity, 0.001); - gainNode.gain.exponentialRampToValueAtTime(sustainGain, (t += decay)); - if (end - begin < attack + decay) { - gainNode.gain.cancelAndHoldAtTime(end); - } else { - gainNode.gain.setValueAtTime(sustainGain, end); +const getSlope = (y1, y2, x1, x2) => { + const denom = x2 - x1; + if (denom === 0) { + return 0; } - gainNode.gain.exponentialRampToValueAtTime(0.001, end + release); // release */ - return gainNode; + return (y2 - y1) / (x2 - x1); }; +export const getParamADSR = ( + param, + attack, + decay, + sustain, + release, + min, + max, + begin, + end, + //exponential works better for frequency modulations (such as filter cutoff) due to human ear perception + curve = 'exponential', +) => { + attack = nanFallback(attack); + decay = nanFallback(decay); + sustain = nanFallback(sustain); + release = nanFallback(release); -export const getParamADSR = (param, attack, decay, sustain, release, min, max, begin, end) => { + const ramp = curve === 'exponential' ? 'exponentialRampToValueAtTime' : 'linearRampToValueAtTime'; + if (curve === 'exponential') { + min = Math.max(0.0001, min); + } const range = max - min; - const peak = min + range; - const sustainLevel = min + sustain * range; + const peak = max; + const sustainVal = min + sustain * range; + const duration = end - begin; + + const envValAtTime = (time) => { + if (attack > time) { + let slope = getSlope(min, peak, 0, attack); + return time * slope + (min > peak ? min : 0); + } else { + return (time - attack) * getSlope(peak, sustainVal, 0, decay) + peak; + } + }; + param.setValueAtTime(min, begin); - param.linearRampToValueAtTime(peak, begin + attack); - param.linearRampToValueAtTime(sustainLevel, begin + attack + decay); - param.setValueAtTime(sustainLevel, end); - param.linearRampToValueAtTime(min, end + Math.max(release, 0.1)); + if (attack > duration) { + //attack + param[ramp](envValAtTime(duration), end); + } else if (attack + decay > duration) { + //attack + param[ramp](envValAtTime(attack), begin + attack); + //decay + param[ramp](envValAtTime(duration), end); + } else { + //attack + param[ramp](envValAtTime(attack), begin + attack); + //decay + param[ramp](envValAtTime(attack + decay), begin + attack + decay); + //sustain + param.setValueAtTime(sustainVal, end); + } + //release + param[ramp](min, end + release); }; export function getCompressor(ac, threshold, ratio, knee, attack, release) { @@ -92,38 +82,44 @@ export function getCompressor(ac, threshold, ratio, knee, attack, release) { return new DynamicsCompressorNode(ac, options); } -export function createFilter( - context, - type, - frequency, - Q, - attack, - decay, - sustain, - release, - fenv, - start, - end, - fanchor = 0.5, -) { +// changes the default values of the envelope based on what parameters the user has defined +// so it behaves more like you would expect/familiar as other synthesis tools +// ex: sound(val).decay(val) will behave as a decay only envelope. sound(val).attack(val).decay(val) will behave like an "ad" env, etc. + +export const getADSRValues = (params, curve = 'linear', defaultValues) => { + const envmin = curve === 'exponential' ? 0.001 : 0.001; + const releaseMin = 0.01; + const envmax = 1; + const [a, d, s, r] = params; + if (a == null && d == null && s == null && r == null) { + return defaultValues ?? [envmin, envmin, envmax, releaseMin]; + } + const sustain = s != null ? s : (a != null && d == null) || (a == null && d == null) ? envmax : envmin; + return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)]; +}; + +export function createFilter(context, type, frequency, Q, att, dec, sus, rel, fenv, start, end, fanchor) { + const curve = 'exponential'; + const [attack, decay, sustain, release] = getADSRValues([att, dec, sus, rel], curve, [0.005, 0.14, 0, 0.1]); const filter = context.createBiquadFilter(); + filter.type = type; filter.Q.value = Q; filter.frequency.value = frequency; - + // envelope is active when any of these values is set + const hasEnvelope = att ?? dec ?? sus ?? rel ?? fenv; // Apply ADSR to filter frequency - if (!isNaN(fenv) && fenv !== 0) { - const offset = fenv * fanchor; - - const min = clamp(2 ** -offset * frequency, 0, 20000); - const max = clamp(2 ** (fenv - offset) * frequency, 0, 20000); - - // console.log('min', min, 'max', max); - - getParamADSR(filter.frequency, attack, decay, sustain, release, min, max, start, end); + if (hasEnvelope !== undefined) { + fenv = nanFallback(fenv, 1, true); + fanchor = nanFallback(fanchor, 0, true); + const fenvAbs = Math.abs(fenv); + const offset = fenvAbs * fanchor; + let min = clamp(2 ** -offset * frequency, 0, 20000); + let max = clamp(2 ** (fenvAbs - offset) * frequency, 0, 20000); + if (fenv < 0) [min, max] = [max, min]; + getParamADSR(filter.frequency, attack, decay, sustain, release, min, max, start, end, curve); return filter; } - return filter; } diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index 754ccf9b..932be995 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -1,6 +1,6 @@ import { noteToMidi, valueToMidi, getSoundIndex } from './util.mjs'; import { getAudioContext, registerSound } from './index.mjs'; -import { getEnvelope } from './helpers.mjs'; +import { getADSRValues, getParamADSR } from './helpers.mjs'; import { logger } from './logger.mjs'; const bufferCache = {}; // string: Promise @@ -243,6 +243,7 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { begin = 0, loopEnd = 1, end = 1, + duration, vib, vibmod = 0.5, } = value; @@ -254,7 +255,8 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { loop = s.startsWith('wt_') ? 1 : value.loop; const ac = getAudioContext(); // destructure adsr here, because the default should be different for synths and samples - const { attack = 0.001, decay = 0.001, sustain = 1, release = 0.001 } = value; + + let [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]); //const soundfont = getSoundfontKey(s); const time = t + nudge; @@ -298,26 +300,28 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { bufferSource.loopEnd = loopEnd * bufferSource.buffer.duration - offset; } bufferSource.start(time, offset); - const { node: envelope, stop: releaseEnvelope } = getEnvelope(attack, decay, sustain, release, 1, t); - bufferSource.connect(envelope); + const envGain = ac.createGain(); + const node = bufferSource.connect(envGain); + if (clip == null && loop == null && value.release == null) { + const bufferDuration = bufferSource.buffer.duration / bufferSource.playbackRate.value; + duration = (end - begin) * bufferDuration; + } + let holdEnd = t + duration; + + getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear'); + const out = ac.createGain(); // we need a separate gain for the cutgroups because firefox... - envelope.connect(out); + node.connect(out); bufferSource.onended = function () { bufferSource.disconnect(); vibratoOscillator?.stop(); - envelope.disconnect(); + node.disconnect(); out.disconnect(); onended(); }; - const stop = (endTime, playWholeBuffer = clip === undefined && loop === undefined) => { - let releaseTime = endTime; - if (playWholeBuffer) { - const bufferDuration = bufferSource.buffer.duration / bufferSource.playbackRate.value; - releaseTime = t + (end - begin) * bufferDuration; - } - const silentAt = releaseEnvelope(releaseTime); - bufferSource.stop(silentAt); - }; + let envEnd = holdEnd + release + 0.01; + bufferSource.stop(envEnd); + const stop = (endTime, playWholeBuffer) => {}; const handle = { node: out, bufferSource, stop }; // cut groups diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index f6ce6c60..f5674f2c 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -280,26 +280,26 @@ export const superdough = async (value, deadline, hapDuration) => { // low pass cutoff, lpenv, - lpattack = 0.01, - lpdecay = 0.01, - lpsustain = 1, - lprelease = 0.01, + lpattack, + lpdecay, + lpsustain, + lprelease, resonance = 1, // high pass hpenv, hcutoff, - hpattack = 0.01, - hpdecay = 0.01, - hpsustain = 1, - hprelease = 0.01, + hpattack, + hpdecay, + hpsustain, + hprelease, hresonance = 1, // band pass bpenv, bandf, - bpattack = 0.01, - bpdecay = 0.01, - bpsustain = 1, - bprelease = 0.01, + bpattack, + bpdecay, + bpsustain, + bprelease, bandq = 1, channels = [1, 2], //phaser @@ -333,6 +333,7 @@ export const superdough = async (value, deadline, hapDuration) => { compressorAttack, compressorRelease, } = value; + gain = nanFallback(gain, 1); //music programs/audio gear usually increments inputs/outputs from 1, so imitate that behavior diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index 13c8b93b..6a3e381a 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -1,6 +1,6 @@ import { midiToFreq, noteToMidi } from './util.mjs'; import { registerSound, getAudioContext } from './superdough.mjs'; -import { gainNode, getEnvelope, getExpEnvelope } from './helpers.mjs'; +import { gainNode, getADSRValues, getParamADSR } from './helpers.mjs'; import { getNoiseMix, getNoiseOscillator } from './noise.mjs'; const mod = (freq, range = 1, type = 'sine') => { @@ -29,8 +29,11 @@ export function registerSynthSounds() { registerSound( s, (t, value, onended) => { - // destructure adsr here, because the default should be different for synths and samples - let { attack = 0.001, decay = 0.05, sustain = 0.6, release = 0.01 } = value; + const [attack, decay, sustain, release] = getADSRValues( + [value.attack, value.decay, value.sustain, value.release], + 'linear', + [0.001, 0.05, 0.6, 0.01], + ); let sound; if (waveforms.includes(s)) { @@ -45,21 +48,24 @@ export function registerSynthSounds() { // turn down const g = gainNode(0.3); - // gain envelope - const { node: envelope, stop: releaseEnvelope } = getEnvelope(attack, decay, sustain, release, 1, t); + const { duration } = value; o.onended = () => { o.disconnect(); g.disconnect(); onended(); }; + + const envGain = gainNode(1); + let 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; + triggerRelease?.(envEnd); + stop(envEnd); return { - node: o.connect(g).connect(envelope), - stop: (releaseTime) => { - const silentAt = releaseEnvelope(releaseTime); - triggerRelease?.(releaseTime); - stop(silentAt); - }, + node, + stop: (releaseTime) => {}, }; }, { type: 'synth', prebake: true }, @@ -112,13 +118,14 @@ export function getOscillator( // fm fmh: fmHarmonicity = 1, fmi: fmModulationIndex, - fmenv: fmEnvelopeType = 'lin', + fmenv: fmEnvelopeType = 'exp', fmattack: fmAttack, fmdecay: fmDecay, fmsustain: fmSustain, fmrelease: fmRelease, fmvelocity: fmVelocity, fmwave: fmWaveform = 'sine', + duration, }, ) { let ac = getAudioContext(); @@ -148,26 +155,30 @@ export function getOscillator( o.start(t); // FM - let stopFm, fmEnvelope; + let stopFm; + let envGain = ac.createGain(); if (fmModulationIndex) { const { node: modulator, stop } = fm(o, fmHarmonicity, fmModulationIndex, fmWaveform); if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].find((v) => v !== undefined)) { // no envelope by default modulator.connect(o.frequency); } else { - fmAttack = fmAttack ?? 0.001; - fmDecay = fmDecay ?? 0.001; - fmSustain = fmSustain ?? 1; - fmRelease = fmRelease ?? 0.001; - fmVelocity = fmVelocity ?? 1; - fmEnvelope = getEnvelope(fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity, t); - if (fmEnvelopeType === 'exp') { - fmEnvelope = getExpEnvelope(fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity, t); - fmEnvelope.node.maxValue = fmModulationIndex * 2; - fmEnvelope.node.minValue = 0.00001; - } - modulator.connect(fmEnvelope.node); - fmEnvelope.node.connect(o.frequency); + const [attack, decay, sustain, release] = getADSRValues([fmAttack, fmDecay, fmSustain, fmRelease]); + const holdEnd = t + duration; + getParamADSR( + envGain.gain, + attack, + decay, + sustain, + release, + 0, + 1, + t, + holdEnd, + fmEnvelopeType === 'exp' ? 'exponential' : 'linear', + ); + modulator.connect(envGain); + envGain.connect(o.frequency); } stopFm = stop; } @@ -199,7 +210,7 @@ export function getOscillator( o.stop(time); }, triggerRelease: (time) => { - fmEnvelope?.stop(time); + // envGain?.stop(time); }, }; } diff --git a/packages/superdough/util.mjs b/packages/superdough/util.mjs index 29eaa7bc..4e3c7e41 100644 --- a/packages/superdough/util.mjs +++ b/packages/superdough/util.mjs @@ -54,9 +54,9 @@ export const valueToMidi = (value, fallbackValue) => { return fallbackValue; }; -export function nanFallback(value, fallback) { +export function nanFallback(value, fallback = 0, silent) { if (isNaN(Number(value))) { - logger(`"${value}" is not a number, falling back to ${fallback}`, 'warning'); + !silent && logger(`"${value}" is not a number, falling back to ${fallback}`, 'warning'); return fallback; } return value; diff --git a/website/src/repl/panel/SoundsTab.jsx b/website/src/repl/panel/SoundsTab.jsx index a3639dfb..7c16b266 100644 --- a/website/src/repl/panel/SoundsTab.jsx +++ b/website/src/repl/panel/SoundsTab.jsx @@ -57,32 +57,36 @@ export function SoundsTab() { settingsMap.setKey('soundsFilter', 'user')} />
- {soundEntries.map(([name, { data, onTrigger }]) => ( - { - const ctx = getAudioContext(); - const params = { - note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined, - s: name, - clip: 1, - release: 0.5, - }; - const time = ctx.currentTime + 0.05; - const onended = () => trigRef.current?.node?.disconnect(); - trigRef.current = Promise.resolve(onTrigger(time, params, onended)); - trigRef.current.then((ref) => { - connectToDestination(ref?.node); - }); - }} - > - {' '} - {name} - {data?.type === 'sample' ? `(${getSamples(data.samples)})` : ''} - {data?.type === 'soundfont' ? `(${data.fonts.length})` : ''} - - ))} + {soundEntries.map(([name, { data, onTrigger }]) => { + return ( + { + const ctx = getAudioContext(); + const params = { + note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined, + s: name, + clip: 1, + release: 0.5, + sustain: 1, + duration: 0.5, + }; + const time = ctx.currentTime + 0.05; + const onended = () => trigRef.current?.node?.disconnect(); + trigRef.current = Promise.resolve(onTrigger(time, params, onended)); + trigRef.current.then((ref) => { + connectToDestination(ref?.node); + }); + }} + > + {' '} + {name} + {data?.type === 'sample' ? `(${getSamples(data.samples)})` : ''} + {data?.type === 'soundfont' ? `(${data.fonts.length})` : ''} + + ); + })} {!soundEntries.length ? 'No custom sounds loaded in this pattern (yet).' : ''}