Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Further Envelope improvements #868

Merged
merged 43 commits into from
Jan 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
7da7554
it works
daslyfe Dec 15, 2023
9663c2e
fileter envelopes
daslyfe Dec 15, 2023
9f50f6e
Merge branch 'tidalcycles:main' into envelope_improvements
daslyfe Dec 15, 2023
88f677e
fix envelope behavior
daslyfe Dec 15, 2023
96e0cce
improve response
daslyfe Dec 16, 2023
93a4efc
updated fontloader
daslyfe Dec 16, 2023
b5dc65d
format
daslyfe Dec 16, 2023
fe60f34
removed accidental file commit
daslyfe Dec 17, 2023
d7fae26
create release audio param method, make volume envelopes consistant
daslyfe Dec 20, 2023
34ba81a
fixed test complaint
daslyfe Dec 20, 2023
f6d9ad5
trying to fix divergent firefox behavior
daslyfe Dec 20, 2023
30e402b
fixed release bug
daslyfe Dec 20, 2023
e0a7fb8
filter should decay to set frequency
daslyfe Dec 20, 2023
2dea391
remove unused variable
daslyfe Dec 20, 2023
deb973a
fixed hold behavior
daslyfe Dec 20, 2023
fb81f6f
still working on popping issue with firefox
daslyfe Dec 20, 2023
dec039e
fixed filter envelope popping...
daslyfe Dec 20, 2023
5671b40
account for phase complete
daslyfe Dec 20, 2023
77fee0b
fixed popping on font envelope
daslyfe Dec 20, 2023
12451ba
Merge branch 'tidalcycles:main' into envelope_improvements
daslyfe Dec 30, 2023
a800b32
Merge branch 'main' into envelope_improvements
daslyfe Dec 31, 2023
9756d63
prettier
daslyfe Dec 31, 2023
3eface7
Merge branch 'tidalcycles:main' into envelope_improvements
daslyfe Jan 1, 2024
83c820a
prettier again
daslyfe Jan 1, 2024
d582bbb
fix: format
felixroos Jan 3, 2024
2ecc6b3
add ad function
felixroos Jan 3, 2024
041a809
add ar function
felixroos Jan 3, 2024
a297409
add dec synonym for decay
felixroos Jan 3, 2024
2ee392b
fixed all the things
daslyfe Jan 5, 2024
b2f6389
change default FM env to exp because it modulates frequency and sound…
daslyfe Jan 5, 2024
150a1b8
restore buffer hold behavior
daslyfe Jan 5, 2024
62cd226
add silent flag to nanFallback
felixroos Jan 9, 2024
f785823
add fenv to filter cutoff controls
felixroos Jan 9, 2024
f99557c
fix: fanchor + default fanchor to 0 (breaking change)
felixroos Jan 9, 2024
c68dba8
fix: use end
felixroos Jan 10, 2024
f26fd18
use 0.001 as linear mintime as well (to prevent cracks)
felixroos Jan 10, 2024
1bc0934
Merge branch 'main' into envelope_improvements
felixroos Jan 10, 2024
f17459f
Merge branch 'main' into envelope_improvements
felixroos Jan 12, 2024
6f3b2fa
negative fenv values now stay in the same range as positives
felixroos Jan 12, 2024
4fb5c18
i have no clue why this works
felixroos Jan 13, 2024
eefa90a
fix: synth default envelope
felixroos Jan 13, 2024
e6d028f
soundfont mixing
felixroos Jan 14, 2024
0901948
fanchor default to 0.5 for now
felixroos Jan 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
24 changes: 17 additions & 7 deletions packages/core/controls.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
2 changes: 1 addition & 1 deletion packages/core/util.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
29 changes: 19 additions & 10 deletions packages/soundfonts/fontloader.mjs
Original file line number Diff line number Diff line change
@@ -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 = {};
Expand Down Expand Up @@ -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 },
);
Expand Down
180 changes: 88 additions & 92 deletions packages/superdough/helpers.mjs
Original file line number Diff line number Diff line change
@@ -1,84 +1,74 @@
import { getAudioContext } from './superdough.mjs';
import { clamp } from './util.mjs';
import { clamp, nanFallback } from './util.mjs';

export function gainNode(value) {
const node = getAudioContext().createGain();
node.gain.value = 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;
},
};
const getSlope = (y1, y2, x1, x2) => {
const denom = x2 - x1;
if (denom === 0) {
return 0;
}
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);

const ramp = curve === 'exponential' ? 'exponentialRampToValueAtTime' : 'linearRampToValueAtTime';
if (curve === 'exponential') {
min = Math.max(0.0001, min);
}
const range = max - min;
const peak = max;
const sustainVal = min + sustain * range;
const duration = end - begin;

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);
},
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;
}
};
};

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);
param.setValueAtTime(min, begin);
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 {
gainNode.gain.setValueAtTime(sustainGain, end);
//attack
param[ramp](envValAtTime(attack), begin + attack);
//decay
param[ramp](envValAtTime(attack + decay), begin + attack + decay);
//sustain
param.setValueAtTime(sustainVal, end);
}
gainNode.gain.exponentialRampToValueAtTime(0.001, end + release); // release */
return gainNode;
};

export const getParamADSR = (param, attack, decay, sustain, release, min, max, begin, end) => {
const range = max - min;
const peak = min + range;
const sustainLevel = min + sustain * range;
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));
//release
param[ramp](min, end + release);
};

export function getCompressor(ac, threshold, ratio, knee, attack, release) {
Expand All @@ -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;
}

Expand Down
34 changes: 19 additions & 15 deletions packages/superdough/sampler.mjs
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer>
Expand Down Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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
Expand Down