diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index a7c8e351..444b4ea1 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -891,6 +891,82 @@ const generic_params = [ * */ ['freq'], + // pitch envelope + /** + * Attack time of pitch envelope. + * + * @name pattack + * @synonyms patt + * @param {number | Pattern} time time in seconds + * @example + * note("").pattack("<0 .1 .25 .5>") + * + */ + ['pattack', 'patt'], + /** + * Decay time of pitch envelope. + * + * @name pdecay + * @synonyms pdec + * @param {number | Pattern} time time in seconds + * @example + * note("").pdecay("<0 .1 .25 .5>") + * + */ + ['pdecay', 'pdec'], + // TODO: how to use psustain?! + ['psustain', 'psus'], + /** + * Release time of pitch envelope + * + * @name prelease + * @synonyms prel + * @param {number | Pattern} time time in seconds + * @example + * note(" ~") + * .release(.5) // to hear the pitch release + * .prelease("<0 .1 .25 .5>") + * + */ + ['prelease', 'prel'], + /** + * Amount of pitch envelope. Negative values will flip the envelope. + * If you don't set other pitch envelope controls, `pattack:.2` will be the default. + * + * @name penv + * @param {number | Pattern} semitones change in semitones + * @example + * note("c") + * .penv("<12 7 1 .5 0 -1 -7 -12>") + * + */ + ['penv'], + /** + * Curve of envelope. Defaults to linear. exponential is good for kicks + * + * @name pcurve + * @param {number | Pattern} type 0 = linear, 1 = exponential + * @example + * note("g1*2") + * .s("sine").pdec(.5) + * .penv(32) + * .pcurve("<0 1>") + * + */ + ['pcurve'], + /** + * Sets the range anchor of the envelope: + * - anchor 0: range = [note, note + penv] + * - anchor 1: range = [note - penv, note] + * If you don't set an anchor, the value will default to the psustain value. + * + * @name panchor + * @param {number | Pattern} anchor anchor offset + * @example + * note("c").penv(12).panchor("<0 .5 1 .5>") + * + */ + ['panchor'], // TODO: https://tidalcycles.org/docs/configuration/MIDIOSC/control-voltage/#gate ['gate', 'gat'], // ['hatgrain'], diff --git a/packages/soundfonts/fontloader.mjs b/packages/soundfonts/fontloader.mjs index dc3f4b61..1a3395d5 100644 --- a/packages/soundfonts/fontloader.mjs +++ b/packages/soundfonts/fontloader.mjs @@ -1,5 +1,12 @@ import { noteToMidi, freqToMidi, getSoundIndex } from '@strudel.cycles/core'; -import { getAudioContext, registerSound, getParamADSR, getADSRValues } from '@strudel.cycles/webaudio'; +import { + getAudioContext, + registerSound, + getParamADSR, + getADSRValues, + getPitchEnvelope, + getVibratoOscillator, +} from '@strudel.cycles/webaudio'; import gm from './gm.mjs'; let loadCache = {}; @@ -149,10 +156,16 @@ export function registerSoundfonts() { getParamADSR(node.gain, attack, decay, sustain, release, 0, 0.3, time, holdEnd, 'linear'); let envEnd = holdEnd + release + 0.01; + // vibrato + let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, time); + // pitch envelope + getPitchEnvelope(bufferSource.detune, value, time, holdEnd); + bufferSource.stop(envEnd); const stop = (releaseTime) => {}; bufferSource.onended = () => { bufferSource.disconnect(); + vibratoOscillator?.stop(); node.disconnect(); onended(); }; diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index bee26f38..39e9977a 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -31,10 +31,10 @@ export const getParamADSR = ( decay = nanFallback(decay); sustain = nanFallback(sustain); release = nanFallback(release); - const ramp = curve === 'exponential' ? 'exponentialRampToValueAtTime' : 'linearRampToValueAtTime'; if (curve === 'exponential') { - min = Math.max(0.0001, min); + min = min === 0 ? 0.001 : min; + max = max === 0 ? 0.001 : max; } const range = max - min; const peak = max; @@ -42,12 +42,17 @@ export const getParamADSR = ( const duration = end - begin; const envValAtTime = (time) => { + let val; if (attack > time) { let slope = getSlope(min, peak, 0, attack); - return time * slope + (min > peak ? min : 0); + val = time * slope + (min > peak ? min : 0); } else { - return (time - attack) * getSlope(peak, sustainVal, 0, decay) + peak; + val = (time - attack) * getSlope(peak, sustainVal, 0, decay) + peak; } + if (curve === 'exponential') { + val = val || 0.001; + } + return val; }; param.setValueAtTime(min, begin); @@ -144,3 +149,40 @@ export function drywet(dry, wet, wetAmount = 0) { wet_gain.connect(mix); return mix; } + +let curves = ['linear', 'exponential']; +export function getPitchEnvelope(param, value, t, holdEnd) { + // envelope is active when any of these values is set + const hasEnvelope = value.pattack ?? value.pdecay ?? value.psustain ?? value.prelease ?? value.penv; + if (!hasEnvelope) { + return; + } + const penv = nanFallback(value.penv, 1, true); + const curve = curves[value.pcurve ?? 0]; + let [pattack, pdecay, psustain, prelease] = getADSRValues( + [value.pattack, value.pdecay, value.psustain, value.prelease], + curve, + [0.2, 0.001, 1, 0.001], + ); + let panchor = value.panchor ?? psustain; + const cents = penv * 100; // penv is in semitones + const min = 0 - cents * panchor; + const max = cents - cents * panchor; + getParamADSR(param, pattack, pdecay, psustain, prelease, min, max, t, holdEnd, curve); +} + +export function getVibratoOscillator(param, value, t) { + const { vibmod = 0.5, vib } = value; + let vibratoOscillator; + if (vib > 0) { + vibratoOscillator = getAudioContext().createOscillator(); + vibratoOscillator.frequency.value = vib; + const gain = getAudioContext().createGain(); + // Vibmod is the amount of vibrato, in semitones + gain.gain.value = vibmod * 100; + vibratoOscillator.connect(gain); + gain.connect(param); + vibratoOscillator.start(t); + return vibratoOscillator; + } +} diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index 932be995..19fec661 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, getParamADSR } from './helpers.mjs'; +import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs'; import { logger } from './logger.mjs'; const bufferCache = {}; // string: Promise @@ -244,8 +244,6 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { loopEnd = 1, end = 1, duration, - vib, - vibmod = 0.5, } = value; // load sample if (speed === 0) { @@ -263,17 +261,7 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { const bufferSource = await getSampleBufferSource(s, n, note, speed, freq, bank, resolveUrl); // vibrato - let vibratoOscillator; - if (vib > 0) { - vibratoOscillator = getAudioContext().createOscillator(); - vibratoOscillator.frequency.value = vib; - const gain = getAudioContext().createGain(); - // Vibmod is the amount of vibrato, in semitones - gain.gain.value = vibmod * 100; - vibratoOscillator.connect(gain); - gain.connect(bufferSource.detune); - vibratoOscillator.start(0); - } + let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, t); // asny stuff above took too long? if (ac.currentTime > t) { @@ -310,6 +298,9 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear'); + // pitch envelope + getPitchEnvelope(bufferSource.detune, value, t, holdEnd); + const out = ac.createGain(); // we need a separate gain for the cutgroups because firefox... node.connect(out); bufferSource.onended = function () { diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index 6a3e381a..6d646862 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, getParamADSR } from './helpers.mjs'; +import { gainNode, getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs'; import { getNoiseMix, getNoiseOscillator } from './noise.mjs'; const mod = (freq, range = 1, type = 'sine') => { @@ -105,15 +105,11 @@ export function waveformN(partials, type) { } // expects one of waveforms as s -export function getOscillator( - s, - t, - { +export function getOscillator(s, t, value) { + let { n: partials, note, freq, - vib = 0, - vibmod = 0.5, noise = 0, // fm fmh: fmHarmonicity = 1, @@ -126,8 +122,7 @@ export function getOscillator( fmvelocity: fmVelocity, fmwave: fmWaveform = 'sine', duration, - }, -) { + } = value; let ac = getAudioContext(); let o; // If no partials are given, use stock waveforms @@ -184,17 +179,10 @@ export function getOscillator( } // Additional oscillator for vibrato effect - let vibratoOscillator; - if (vib > 0) { - vibratoOscillator = getAudioContext().createOscillator(); - vibratoOscillator.frequency.value = vib; - const gain = getAudioContext().createGain(); - // Vibmod is the amount of vibrato, in semitones - gain.gain.value = vibmod * 100; - vibratoOscillator.connect(gain); - gain.connect(o.detune); - vibratoOscillator.start(t); - } + let vibratoOscillator = getVibratoOscillator(o.detune, value, t); + + // pitch envelope + getPitchEnvelope(o.detune, value, t, t + duration); let noiseMix; if (noise) { diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index dd7392d1..002ccb5f 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -3332,6 +3332,55 @@ exports[`runs examples > example "pan" example index 0 1`] = ` ] `; +exports[`runs examples > example "panchor" example index 0 1`] = ` +[ + "[ 0/1 → 1/1 | note:c penv:12 panchor:0 ]", + "[ 1/1 → 2/1 | note:c penv:12 panchor:0.5 ]", + "[ 2/1 → 3/1 | note:c penv:12 panchor:1 ]", + "[ 3/1 → 4/1 | note:c penv:12 panchor:0.5 ]", +] +`; + +exports[`runs examples > example "pattack" example index 0 1`] = ` +[ + "[ 0/1 → 1/1 | note:c pattack:0 ]", + "[ 1/1 → 2/1 | note:eb pattack:0.1 ]", + "[ 2/1 → 3/1 | note:g pattack:0.25 ]", + "[ 3/1 → 4/1 | note:bb pattack:0.5 ]", +] +`; + +exports[`runs examples > example "pcurve" example index 0 1`] = ` +[ + "[ 0/1 → 1/2 | note:g1 s:sine pdecay:0.5 penv:32 pcurve:0 ]", + "[ 1/2 → 1/1 | note:g1 s:sine pdecay:0.5 penv:32 pcurve:0 ]", + "[ 1/1 → 3/2 | note:g1 s:sine pdecay:0.5 penv:32 pcurve:1 ]", + "[ 3/2 → 2/1 | note:g1 s:sine pdecay:0.5 penv:32 pcurve:1 ]", + "[ 2/1 → 5/2 | note:g1 s:sine pdecay:0.5 penv:32 pcurve:0 ]", + "[ 5/2 → 3/1 | note:g1 s:sine pdecay:0.5 penv:32 pcurve:0 ]", + "[ 3/1 → 7/2 | note:g1 s:sine pdecay:0.5 penv:32 pcurve:1 ]", + "[ 7/2 → 4/1 | note:g1 s:sine pdecay:0.5 penv:32 pcurve:1 ]", +] +`; + +exports[`runs examples > example "pdecay" example index 0 1`] = ` +[ + "[ 0/1 → 1/1 | note:c pdecay:0 ]", + "[ 1/1 → 2/1 | note:eb pdecay:0.1 ]", + "[ 2/1 → 3/1 | note:g pdecay:0.25 ]", + "[ 3/1 → 4/1 | note:bb pdecay:0.5 ]", +] +`; + +exports[`runs examples > example "penv" example index 0 1`] = ` +[ + "[ 0/1 → 1/1 | note:c penv:12 ]", + "[ 1/1 → 2/1 | note:c penv:7 ]", + "[ 2/1 → 3/1 | note:c penv:1 ]", + "[ 3/1 → 4/1 | note:c penv:0.5 ]", +] +`; + exports[`runs examples > example "perlin" example index 0 1`] = ` [ "[ 0/1 → 1/4 | s:hh cutoff:512.5097280354112 ]", @@ -3660,6 +3709,15 @@ exports[`runs examples > example "postgain" example index 0 1`] = ` ] `; +exports[`runs examples > example "prelease" example index 0 1`] = ` +[ + "[ 0/1 → 1/2 | note:c release:0.5 prelease:0 ]", + "[ 1/1 → 3/2 | note:eb release:0.5 prelease:0.1 ]", + "[ 2/1 → 5/2 | note:g release:0.5 prelease:0.25 ]", + "[ 3/1 → 7/2 | note:bb release:0.5 prelease:0.5 ]", +] +`; + exports[`runs examples > example "press" example index 0 1`] = ` [ "[ 0/1 → 1/2 | s:hh ]", diff --git a/website/src/pages/learn/effects.mdx b/website/src/pages/learn/effects.mdx index a3ae91d7..be4a1184 100644 --- a/website/src/pages/learn/effects.mdx +++ b/website/src/pages/learn/effects.mdx @@ -138,6 +138,60 @@ There is one filter envelope for each filter type and thus one set of envelope f +# Pitch Envelope + +You can also control the pitch with envelopes! +Pitch envelopes can breathe life into static sounds: + +*<2!3 4>") + .scale("/8:pentatonic") + .s("gm_electric_guitar_jazz") + .penv("<.5 0 7 -2>*2").vib("4:.1") + .phaser(2).delay(.25).room(.3) + .size(4).fast(.75)`} +/> + +You also create some lovely chiptune-style sounds: + +/16")).jux(rev) +.chord(">") +.dict('ireal') +.voicing().add(note("<0 1>/8")) +.dec(.1).room(.2) +.segment("<4 [2 8]>") +.penv("<0 <2 -2>>").patt(.02)`} +/> + +Let's break down all pitch envelope controls: + +## pattack + + + +## pdecay + + + +## prelease + + + +## penv + + + +## pcurve + + + +## panchor + + + # Dynamics ## gain