diff --git a/.eslintignore b/.eslintignore index 13e635f3f..58d3643dc 100644 --- a/.eslintignore +++ b/.eslintignore @@ -18,4 +18,5 @@ vite.config.js **/*.json **/dev-dist **/dist -/src-tauri/target/**/* \ No newline at end of file +/src-tauri/target/**/* +reverbGen.mjs \ No newline at end of file diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 39c2df2f8..88c2c0725 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -979,20 +979,64 @@ const generic_params = [ * */ [['room', 'size']], + /** + * Reverb lowpass starting frequency (in hertz). + * When this property is changed, the reverb will be recaculated, so only change this sparsely.. + * + * @name roomlp + * @synonyms rlp + * @param {number} frequency between 0 and 20000hz + * @example + * s("bd sd").room(0.5).rlp(10000) + * @example + * s("bd sd").room(0.5).rlp(5000) + */ + ['roomlp', 'rlp'], + /** + * Reverb lowpass frequency at -60dB (in hertz). + * When this property is changed, the reverb will be recaculated, so only change this sparsely.. + * + * @name roomdim + * @synonyms rdim + * @param {number} frequency between 0 and 20000hz + * @example + * s("bd sd").room(0.5).rlp(10000).rdim(8000) + * @example + * s("bd sd").room(0.5).rlp(5000).rdim(400) + * + */ + ['roomdim', 'rdim'], + /** + * Reverb fade time (in seconds). + * When this property is changed, the reverb will be recaculated, so only change this sparsely.. + * + * @name roomfade + * @synonyms rfade + * @param {number} seconds for the reverb to fade + * @example + * s("bd sd").room(0.5).rlp(10000).rfade(0.5) + * @example + * s("bd sd").room(0.5).rlp(5000).rfade(4) + * + */ + ['roomfade', 'rfade'], /** * Sets the room size of the reverb, see {@link room}. + * When this property is changed, the reverb will be recaculated, so only change this sparsely.. * * @name roomsize * @param {number | Pattern} size between 0 and 10 - * @synonyms size, sz + * @synonyms rsize, sz, size + * @example + * s("bd sd").room(.8).rsize(1) * @example - * s("bd sd").room(.8).roomsize("<0 1 2 4 8>") + * s("bd sd").room(.8).rsize(4) * */ // TODO: find out why : // s("bd sd").room(.8).roomsize("<0 .2 .4 .6 .8 [1,0]>").osc() // .. does not work. Is it because room is only one effect? - ['size', 'sz', 'roomsize'], + ['roomsize', 'size', 'sz', 'rsize'], // ['sagogo'], // ['sclap'], // ['sclaves'], diff --git a/packages/superdough/reverb.mjs b/packages/superdough/reverb.mjs index e6d31f6aa..de6c903e0 100644 --- a/packages/superdough/reverb.mjs +++ b/packages/superdough/reverb.mjs @@ -1,23 +1,30 @@ -if (typeof AudioContext !== 'undefined') { - AudioContext.prototype.impulseResponse = function (duration, channels = 1) { - const length = this.sampleRate * duration; - const impulse = this.createBuffer(channels, length, this.sampleRate); - const IR = impulse.getChannelData(0); - for (let i = 0; i < length; i++) IR[i] = (2 * Math.random() - 1) * Math.pow(1 - i / length, duration); - return impulse; - }; +import reverbGen from './reverbGen.mjs'; - AudioContext.prototype.createReverb = function (duration) { +if (typeof AudioContext !== 'undefined') { + AudioContext.prototype.generateReverb = reverbGen.generateReverb; + AudioContext.prototype.createReverb = function (duration, fade, lp, dim) { const convolver = this.createConvolver(); - convolver.setDuration = (d) => { - convolver.buffer = this.impulseResponse(d); - convolver.duration = duration; - return convolver; + convolver.generate = (d, fade, lp, dim) => { + this.generateReverb( + { + audioContext: this, + sampleRate: 44100, + numChannels: 2, + decayTime: d, + fadeInTime: fade, + lpFreqStart: lp, + lpFreqEnd: dim, + }, + (buffer) => { + convolver.buffer = buffer; + }, + ); + convolver.duration = d; + convolver.fade = fade; + convolver.lp = lp; + convolver.dim = dim; }; - convolver.setDuration(duration); + convolver.generate(duration, fade, lp, dim); return convolver; }; } - -// TODO: make the reverb more exciting -// check out https://blog.gskinner.com/archives/2019/02/reverb-web-audio-api.html diff --git a/packages/superdough/reverbGen.mjs b/packages/superdough/reverbGen.mjs new file mode 100644 index 000000000..494299372 --- /dev/null +++ b/packages/superdough/reverbGen.mjs @@ -0,0 +1,129 @@ +// Copyright 2014 Alan deLespinasse +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +var reverbGen = {}; + +/** Generates a reverb impulse response. + + @param {!Object} params TODO: Document the properties. + @param {!function(!AudioBuffer)} callback Function to call when + the impulse response has been generated. The impulse response + is passed to this function as its parameter. May be called + immediately within the current execution context, or later. */ +reverbGen.generateReverb = function (params, callback) { + var audioContext = params.audioContext || new AudioContext(); + var sampleRate = params.sampleRate || 44100; + var numChannels = params.numChannels || 2; + // params.decayTime is the -60dB fade time. We let it go 50% longer to get to -90dB. + var totalTime = params.decayTime * 1.5; + var decaySampleFrames = Math.round(params.decayTime * sampleRate); + var numSampleFrames = Math.round(totalTime * sampleRate); + var fadeInSampleFrames = Math.round((params.fadeInTime || 0) * sampleRate); + // 60dB is a factor of 1 million in power, or 1000 in amplitude. + var decayBase = Math.pow(1 / 1000, 1 / decaySampleFrames); + var reverbIR = audioContext.createBuffer(numChannels, numSampleFrames, sampleRate); + for (var i = 0; i < numChannels; i++) { + var chan = reverbIR.getChannelData(i); + for (var j = 0; j < numSampleFrames; j++) { + chan[j] = randomSample() * Math.pow(decayBase, j); + } + for (var j = 0; j < fadeInSampleFrames; j++) { + chan[j] *= j / fadeInSampleFrames; + } + } + + applyGradualLowpass(reverbIR, params.lpFreqStart || 0, params.lpFreqEnd || 0, params.decayTime, callback); +}; + +/** Creates a canvas element showing a graph of the given data. + + @param {!Float32Array} data An array of numbers, or a Float32Array. + @param {number} width Width in pixels of the canvas. + @param {number} height Height in pixels of the canvas. + @param {number} min Minimum value of data for the graph (lower edge). + @param {number} max Maximum value of data in the graph (upper edge). + @return {!CanvasElement} The generated canvas element. */ +reverbGen.generateGraph = function (data, width, height, min, max) { + var canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + var gc = canvas.getContext('2d'); + gc.fillStyle = '#000'; + gc.fillRect(0, 0, canvas.width, canvas.height); + gc.fillStyle = '#fff'; + var xscale = width / data.length; + var yscale = height / (max - min); + for (var i = 0; i < data.length; i++) { + gc.fillRect(i * xscale, height - (data[i] - min) * yscale, 1, 1); + } + return canvas; +}; + +/** Applies a constantly changing lowpass filter to the given sound. + + @private + @param {!AudioBuffer} input + @param {number} lpFreqStart + @param {number} lpFreqEnd + @param {number} lpFreqEndAt + @param {!function(!AudioBuffer)} callback May be called + immediately within the current execution context, or later.*/ +var applyGradualLowpass = function (input, lpFreqStart, lpFreqEnd, lpFreqEndAt, callback) { + if (lpFreqStart == 0) { + callback(input); + return; + } + var channelData = getAllChannelData(input); + var context = new OfflineAudioContext(input.numberOfChannels, channelData[0].length, input.sampleRate); + var player = context.createBufferSource(); + player.buffer = input; + var filter = context.createBiquadFilter(); + + lpFreqStart = Math.min(lpFreqStart, input.sampleRate / 2); + lpFreqEnd = Math.min(lpFreqEnd, input.sampleRate / 2); + + filter.type = 'lowpass'; + filter.Q.value = 0.0001; + filter.frequency.setValueAtTime(lpFreqStart, 0); + filter.frequency.linearRampToValueAtTime(lpFreqEnd, lpFreqEndAt); + + player.connect(filter); + filter.connect(context.destination); + player.start(); + context.oncomplete = function (event) { + callback(event.renderedBuffer); + }; + context.startRendering(); + + window.filterNode = filter; +}; + +/** @private + @param {!AudioBuffer} buffer + @return {!Array.} An array containing the Float32Array of each channel's samples. */ +var getAllChannelData = function (buffer) { + var channels = []; + for (var i = 0; i < buffer.numberOfChannels; i++) { + channels[i] = buffer.getChannelData(i); + } + return channels; +}; + +/** @private + @return {number} A random number from -1 to 1. */ +var randomSample = function () { + return Math.random() * 2 - 1; +}; + +export default reverbGen; diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 289e8d97a..ea8afc58c 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -107,17 +107,25 @@ function getDelay(orbit, delaytime, delayfeedback, t) { } let reverbs = {}; -function getReverb(orbit, duration = 2) { + +function getReverb(orbit, duration = 2, fade, lp, dim) { + // If no reverb has been created for a given orbit, create one if (!reverbs[orbit]) { const ac = getAudioContext(); - const reverb = ac.createReverb(duration); + const reverb = ac.createReverb(duration, fade, lp, dim); reverb.connect(getDestination()); reverbs[orbit] = reverb; } - if (reverbs[orbit].duration !== duration) { - reverbs[orbit] = reverbs[orbit].setDuration(duration); - reverbs[orbit].duration = duration; + + if ( + reverbs[orbit].duration !== duration || + reverbs[orbit].fade !== fade || + reverbs[orbit].lp !== lp || + reverbs[orbit].dim !== dim + ) { + reverbs[orbit].generate(duration, fade, lp, dim); } + return reverbs[orbit]; } @@ -215,7 +223,10 @@ export const superdough = async (value, deadline, hapDuration) => { delaytime = 0.25, orbit = 1, room, - size = 2, + roomfade = 0.1, + roomlp = 15000, + roomdim = 1000, + roomsize = 2, velocity = 1, analyze, // analyser wet fft = 8, // fftSize 0 - 10 @@ -353,8 +364,8 @@ export const superdough = async (value, deadline, hapDuration) => { } // reverb let reverbSend; - if (room > 0 && size > 0) { - const reverbNode = getReverb(orbit, size); + if (room > 0 && roomsize > 0) { + const reverbNode = getReverb(orbit, roomsize, roomfade, roomlp, roomdim); reverbSend = effectSend(post, reverbNode, room); } diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 4a4c966de..f2bc70395 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -3663,16 +3663,107 @@ exports[`runs examples > example "room" example index 1 1`] = ` ] `; +exports[`runs examples > example "roomdim" example index 0 1`] = ` +[ + "[ 0/1 → 1/2 | s:bd room:0.5 roomlp:10000 roomdim:8000 ]", + "[ 1/2 → 1/1 | s:sd room:0.5 roomlp:10000 roomdim:8000 ]", + "[ 1/1 → 3/2 | s:bd room:0.5 roomlp:10000 roomdim:8000 ]", + "[ 3/2 → 2/1 | s:sd room:0.5 roomlp:10000 roomdim:8000 ]", + "[ 2/1 → 5/2 | s:bd room:0.5 roomlp:10000 roomdim:8000 ]", + "[ 5/2 → 3/1 | s:sd room:0.5 roomlp:10000 roomdim:8000 ]", + "[ 3/1 → 7/2 | s:bd room:0.5 roomlp:10000 roomdim:8000 ]", + "[ 7/2 → 4/1 | s:sd room:0.5 roomlp:10000 roomdim:8000 ]", +] +`; + +exports[`runs examples > example "roomdim" example index 1 1`] = ` +[ + "[ 0/1 → 1/2 | s:bd room:0.5 roomlp:5000 roomdim:400 ]", + "[ 1/2 → 1/1 | s:sd room:0.5 roomlp:5000 roomdim:400 ]", + "[ 1/1 → 3/2 | s:bd room:0.5 roomlp:5000 roomdim:400 ]", + "[ 3/2 → 2/1 | s:sd room:0.5 roomlp:5000 roomdim:400 ]", + "[ 2/1 → 5/2 | s:bd room:0.5 roomlp:5000 roomdim:400 ]", + "[ 5/2 → 3/1 | s:sd room:0.5 roomlp:5000 roomdim:400 ]", + "[ 3/1 → 7/2 | s:bd room:0.5 roomlp:5000 roomdim:400 ]", + "[ 7/2 → 4/1 | s:sd room:0.5 roomlp:5000 roomdim:400 ]", +] +`; + +exports[`runs examples > example "roomfade" example index 0 1`] = ` +[ + "[ 0/1 → 1/2 | s:bd room:0.5 roomlp:10000 roomfade:0.5 ]", + "[ 1/2 → 1/1 | s:sd room:0.5 roomlp:10000 roomfade:0.5 ]", + "[ 1/1 → 3/2 | s:bd room:0.5 roomlp:10000 roomfade:0.5 ]", + "[ 3/2 → 2/1 | s:sd room:0.5 roomlp:10000 roomfade:0.5 ]", + "[ 2/1 → 5/2 | s:bd room:0.5 roomlp:10000 roomfade:0.5 ]", + "[ 5/2 → 3/1 | s:sd room:0.5 roomlp:10000 roomfade:0.5 ]", + "[ 3/1 → 7/2 | s:bd room:0.5 roomlp:10000 roomfade:0.5 ]", + "[ 7/2 → 4/1 | s:sd room:0.5 roomlp:10000 roomfade:0.5 ]", +] +`; + +exports[`runs examples > example "roomfade" example index 1 1`] = ` +[ + "[ 0/1 → 1/2 | s:bd room:0.5 roomlp:5000 roomfade:4 ]", + "[ 1/2 → 1/1 | s:sd room:0.5 roomlp:5000 roomfade:4 ]", + "[ 1/1 → 3/2 | s:bd room:0.5 roomlp:5000 roomfade:4 ]", + "[ 3/2 → 2/1 | s:sd room:0.5 roomlp:5000 roomfade:4 ]", + "[ 2/1 → 5/2 | s:bd room:0.5 roomlp:5000 roomfade:4 ]", + "[ 5/2 → 3/1 | s:sd room:0.5 roomlp:5000 roomfade:4 ]", + "[ 3/1 → 7/2 | s:bd room:0.5 roomlp:5000 roomfade:4 ]", + "[ 7/2 → 4/1 | s:sd room:0.5 roomlp:5000 roomfade:4 ]", +] +`; + +exports[`runs examples > example "roomlp" example index 0 1`] = ` +[ + "[ 0/1 → 1/2 | s:bd room:0.5 roomlp:10000 ]", + "[ 1/2 → 1/1 | s:sd room:0.5 roomlp:10000 ]", + "[ 1/1 → 3/2 | s:bd room:0.5 roomlp:10000 ]", + "[ 3/2 → 2/1 | s:sd room:0.5 roomlp:10000 ]", + "[ 2/1 → 5/2 | s:bd room:0.5 roomlp:10000 ]", + "[ 5/2 → 3/1 | s:sd room:0.5 roomlp:10000 ]", + "[ 3/1 → 7/2 | s:bd room:0.5 roomlp:10000 ]", + "[ 7/2 → 4/1 | s:sd room:0.5 roomlp:10000 ]", +] +`; + +exports[`runs examples > example "roomlp" example index 1 1`] = ` +[ + "[ 0/1 → 1/2 | s:bd room:0.5 roomlp:5000 ]", + "[ 1/2 → 1/1 | s:sd room:0.5 roomlp:5000 ]", + "[ 1/1 → 3/2 | s:bd room:0.5 roomlp:5000 ]", + "[ 3/2 → 2/1 | s:sd room:0.5 roomlp:5000 ]", + "[ 2/1 → 5/2 | s:bd room:0.5 roomlp:5000 ]", + "[ 5/2 → 3/1 | s:sd room:0.5 roomlp:5000 ]", + "[ 3/1 → 7/2 | s:bd room:0.5 roomlp:5000 ]", + "[ 7/2 → 4/1 | s:sd room:0.5 roomlp:5000 ]", +] +`; + exports[`runs examples > example "roomsize" example index 0 1`] = ` [ - "[ 0/1 → 1/2 | s:bd room:0.8 size:0 ]", - "[ 1/2 → 1/1 | s:sd room:0.8 size:0 ]", - "[ 1/1 → 3/2 | s:bd room:0.8 size:1 ]", - "[ 3/2 → 2/1 | s:sd room:0.8 size:1 ]", - "[ 2/1 → 5/2 | s:bd room:0.8 size:2 ]", - "[ 5/2 → 3/1 | s:sd room:0.8 size:2 ]", - "[ 3/1 → 7/2 | s:bd room:0.8 size:4 ]", - "[ 7/2 → 4/1 | s:sd room:0.8 size:4 ]", + "[ 0/1 → 1/2 | s:bd room:0.8 roomsize:1 ]", + "[ 1/2 → 1/1 | s:sd room:0.8 roomsize:1 ]", + "[ 1/1 → 3/2 | s:bd room:0.8 roomsize:1 ]", + "[ 3/2 → 2/1 | s:sd room:0.8 roomsize:1 ]", + "[ 2/1 → 5/2 | s:bd room:0.8 roomsize:1 ]", + "[ 5/2 → 3/1 | s:sd room:0.8 roomsize:1 ]", + "[ 3/1 → 7/2 | s:bd room:0.8 roomsize:1 ]", + "[ 7/2 → 4/1 | s:sd room:0.8 roomsize:1 ]", +] +`; + +exports[`runs examples > example "roomsize" example index 1 1`] = ` +[ + "[ 0/1 → 1/2 | s:bd room:0.8 roomsize:4 ]", + "[ 1/2 → 1/1 | s:sd room:0.8 roomsize:4 ]", + "[ 1/1 → 3/2 | s:bd room:0.8 roomsize:4 ]", + "[ 3/2 → 2/1 | s:sd room:0.8 roomsize:4 ]", + "[ 2/1 → 5/2 | s:bd room:0.8 roomsize:4 ]", + "[ 5/2 → 3/1 | s:sd room:0.8 roomsize:4 ]", + "[ 3/1 → 7/2 | s:bd room:0.8 roomsize:4 ]", + "[ 7/2 → 4/1 | s:sd room:0.8 roomsize:4 ]", ] `; diff --git a/website/src/pages/learn/effects.mdx b/website/src/pages/learn/effects.mdx index f77ab4c47..2ee6c44a2 100644 --- a/website/src/pages/learn/effects.mdx +++ b/website/src/pages/learn/effects.mdx @@ -183,24 +183,40 @@ global effects use the same chain for all events of the same orbit: -## delay +## Delay + +### delay -## delaytime +### delaytime -## delayfeedback +### delayfeedback -## room +## Reverb + +### room -## roomsize +### roomsize +### roomfade + + + +### roomlp + + + +### roomdim + + + Next, we'll look at strudel's support for [Csound](/learn/csound).