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 683c249b..8d8fb02b 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, getADSRValues } from '@strudel.cycles/webaudio'; +import { getAudioContext, registerSound, getParamADSR, getADSRValues } from '@strudel.cycles/webaudio'; import gm from './gm.mjs'; let loadCache = {}; @@ -136,23 +136,27 @@ export function registerSoundfonts() { value.sustain, value.release, ]); + + const { duration } = value; const n = getSoundIndex(value.n, fonts.length); 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, 1, 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 7544ab7f..ac22fc7c 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -1,33 +1,5 @@ import { getAudioContext } from './superdough.mjs'; -import { clamp } from './util.mjs'; - -const setRelease = (param, phase, sustain, startTime, endTime, endValue, curve = 'linear') => { - const ctx = getAudioContext(); - const ramp = curve === 'exponential' ? 'exponentialRampToValueAtTime' : 'linearRampToValueAtTime'; - // if the decay stage is complete before the note event is done, we don't need to do anything special - if (phase < startTime) { - param.setValueAtTime(sustain, startTime); - param[ramp](endValue, endTime); - } else if (param.cancelAndHoldAtTime == null) { - //this replicates cancelAndHoldAtTime behavior for Firefox - setTimeout( - () => { - //sustain at current value - const currValue = param.value; - param.cancelScheduledValues(0); - param.setValueAtTime(currValue, 0); - - //release - param[ramp](endValue, endTime); - }, - (startTime - ctx.currentTime) * 1000, - ); - } else { - //stop the envelope, hold the value, and then set the release stage - param.cancelAndHoldAtTime(startTime); - param[ramp](endValue, endTime); - } -}; +import { clamp, nanFallback } from './util.mjs'; export function gainNode(value) { const node = getAudioContext().createGain(); @@ -35,44 +7,13 @@ 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) => { - const endTime = t + release; - setRelease(gainNode.gain, phase, sustainLevel, t, endTime, 0); - // helps prevent pops from overlapping sounds - return endTime; - }, - }; +const getSlope = (y1, y2, x1, x2) => { + const denom = x2 - x1; + if (denom === 0) { + return 0; + } + return (y2 - y1) / (x2 - x1); }; - -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 getParamADSR = ( param, attack, @@ -86,23 +27,47 @@ export const getParamADSR = ( //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); + const ramp = curve === 'exponential' ? 'exponentialRampToValueAtTime' : 'linearRampToValueAtTime'; - let phase = begin; + if (curve === 'exponential') { + min = Math.max(0.0001, min); + } const range = max - min; const peak = min + range; + const sustainVal = min + sustain * range; + const duration = end - begin; + + const envValAtTime = (time) => { + if (attack > time) { + return time * getSlope(min, peak, 0, attack) + 0; + } else { + return (time - attack) * getSlope(peak, sustainVal, 0, decay) + peak; + } + }; param.setValueAtTime(min, begin); - phase += attack; - - //attack - param[ramp](peak, phase); - phase += decay; - const sustainLevel = min + sustain * range; - - //decay - param[ramp](sustainLevel, phase); - - setRelease(param, phase, sustainLevel, end, end + release, min, curve); + 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) { diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index ac39da66..f0e46221 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 { getADSRValues, 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; @@ -255,7 +256,7 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { const ac = getAudioContext(); // destructure adsr here, because the default should be different for synths and samples - const [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]); + let [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]); //const soundfont = getSoundfontKey(s); const time = t + nudge; @@ -299,25 +300,29 @@ 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); + const 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(); }; + let envEnd = holdEnd + release + 0.01; + bufferSource.stop(envEnd); 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); + // did not reimplement this behavior, because it mostly seems to confuse people... + // if (playWholeBuffer) { + // const bufferDuration = bufferSource.buffer.duration / bufferSource.playbackRate.value; + // envEnd = t + (end - begin) * bufferDuration; + // } + // bufferSource.stop(envEnd); }; const handle = { node: out, bufferSource, stop }; diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index 09c8163b..cfe3567c 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, getADSRValues, 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') => { @@ -43,26 +43,30 @@ export function registerSynthSounds() { let { density } = value; sound = getNoiseOscillator(s, t, density); } + let { node: o, stop, triggerRelease } = sound; // 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 }, @@ -122,6 +126,7 @@ export function getOscillator( fmrelease: fmRelease, fmvelocity: fmVelocity, fmwave: fmWaveform = 'sine', + duration, }, ) { let ac = getAudioContext(); @@ -151,26 +156,38 @@ 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); + const [attack, decay, sustain, release] = getADSRValues([fmAttack, fmDecay, fmSustain, fmRelease]); + + const holdEnd = t + duration; + // let envEnd = holdEnd + release + 0.01; + + getParamADSR( + envGain.gain, + attack, + decay, + sustain, + release, + 0, + 1, + t, + holdEnd, + fmEnvelopeType === 'exp' ? 'exponential' : 'linear', + ); + if (fmEnvelopeType === 'exp') { - fmEnvelope = getExpEnvelope(fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity, t); - fmEnvelope.node.maxValue = fmModulationIndex * 2; - fmEnvelope.node.minValue = 0.00001; + envGain.maxValue = fmModulationIndex * 2; + envGain.minValue = 0.00001; } - modulator.connect(fmEnvelope.node); - fmEnvelope.node.connect(o.frequency); + modulator.connect(envGain); + envGain.connect(o.frequency); } stopFm = stop; } @@ -202,7 +219,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..cebabfda 100644 --- a/packages/superdough/util.mjs +++ b/packages/superdough/util.mjs @@ -54,7 +54,7 @@ export const valueToMidi = (value, fallbackValue) => { return fallbackValue; }; -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/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).' : ''}