From 5f405467ec72687cf3ef20afcdf415577eaf912b Mon Sep 17 00:00:00 2001 From: Anton Duda Date: Wed, 22 Oct 2025 03:29:52 +0300 Subject: [PATCH] Add superdough strudel package --- src/strudel/superdough/README.md | 171 +++ src/strudel/superdough/audioContext.mjs | 18 + src/strudel/superdough/dspworklet.mjs | 79 ++ src/strudel/superdough/feedbackdelay.mjs | 31 + src/strudel/superdough/fft.js | 488 +++++++ src/strudel/superdough/helpers.mjs | 501 +++++++ src/strudel/superdough/index.mjs | 15 + src/strudel/superdough/logger.mjs | 14 + src/strudel/superdough/noise.mjs | 72 + src/strudel/superdough/ola-processor.js | 185 +++ src/strudel/superdough/package.json | 41 + src/strudel/superdough/reverb.mjs | 57 + src/strudel/superdough/reverbGen.mjs | 130 ++ src/strudel/superdough/sampler.mjs | 366 +++++ src/strudel/superdough/superdough.mjs | 739 +++++++++++ src/strudel/superdough/superdoughoutput.mjs | 209 +++ src/strudel/superdough/synth.mjs | 488 +++++++ src/strudel/superdough/util.mjs | 107 ++ src/strudel/superdough/vite.config.js | 20 + src/strudel/superdough/vowel.mjs | 69 + src/strudel/superdough/wavetable.mjs | 336 +++++ src/strudel/superdough/worklets.mjs | 1321 +++++++++++++++++++ src/strudel/superdough/zzfx.mjs | 125 ++ src/strudel/superdough/zzfx_fork.mjs | 120 ++ 24 files changed, 5702 insertions(+) create mode 100644 src/strudel/superdough/README.md create mode 100644 src/strudel/superdough/audioContext.mjs create mode 100644 src/strudel/superdough/dspworklet.mjs create mode 100644 src/strudel/superdough/feedbackdelay.mjs create mode 100644 src/strudel/superdough/fft.js create mode 100644 src/strudel/superdough/helpers.mjs create mode 100644 src/strudel/superdough/index.mjs create mode 100644 src/strudel/superdough/logger.mjs create mode 100644 src/strudel/superdough/noise.mjs create mode 100644 src/strudel/superdough/ola-processor.js create mode 100644 src/strudel/superdough/package.json create mode 100644 src/strudel/superdough/reverb.mjs create mode 100644 src/strudel/superdough/reverbGen.mjs create mode 100644 src/strudel/superdough/sampler.mjs create mode 100644 src/strudel/superdough/superdough.mjs create mode 100644 src/strudel/superdough/superdoughoutput.mjs create mode 100644 src/strudel/superdough/synth.mjs create mode 100644 src/strudel/superdough/util.mjs create mode 100644 src/strudel/superdough/vite.config.js create mode 100644 src/strudel/superdough/vowel.mjs create mode 100644 src/strudel/superdough/wavetable.mjs create mode 100644 src/strudel/superdough/worklets.mjs create mode 100644 src/strudel/superdough/zzfx.mjs create mode 100644 src/strudel/superdough/zzfx_fork.mjs diff --git a/src/strudel/superdough/README.md b/src/strudel/superdough/README.md new file mode 100644 index 0000000..f5947d8 --- /dev/null +++ b/src/strudel/superdough/README.md @@ -0,0 +1,171 @@ +# superdough + +superdough is a simple web audio sampler and synth, intended for live coding. +It is the default output of [strudel](https://strudel.cc/). +This package has no ties to strudel and can be used to quickly bake your own music system on the web. + +## Install + +via npm: + +```js +npm i superdough --save +``` + +## Use + +```js +import { superdough, samples, initAudioOnFirstClick, registerSynthSounds } from 'superdough'; + +const init = Promise.all([ + initAudioOnFirstClick(), + samples('github:tidalcycles/dirt-samples'), + registerSynthSounds(), +]); + +const loop = (t = 0) => { + // superdough(value, time, duration) + superdough({ s: 'bd', delay: 0.5 }, t); + superdough({ note: 'g1', s: 'sawtooth', cutoff: 600, resonance: 8 }, t, 0.125); + superdough({ note: 'g2', s: 'sawtooth', cutoff: 600, resonance: 8 }, t + 0.25, 0.125); + superdough({ s: 'hh' }, t + 0.25); + superdough({ s: 'sd', room: 0.5 }, t + 0.5); + superdough({ s: 'hh' }, t + 0.75); +}; + +document.getElementById('play').addEventListener('click', async () => { + await init; + let t = 0.1; + while (t < 16) { + loop(t++); + } +}); +``` + +[Open this in Codesandbox](https://codesandbox.io/s/superdough-demo-forked-sf8djh?file=/src/index.js) + +## API + +### superdough(value, deadline, duration) + +```js +superdough({ s: 'bd', delay: 0.5 }, 0, 1); +``` + +- `value`: the sound properties: + - `s`: the name of the sound as loaded via `samples` or `registerSound` + - `n`: selects sample with given index + - `bank`: prefix_ that is attached to the sound, e.g. `{ s: 'bd', bank: 'RolandTR909' }` = `{ s: 'RolandTR909_bd' }` + - `gain`: gain from 0 to 1 (higher values also work but might clip) + - `velocity`: additional gain multiplier + - `cutoff`: low pass filter cutoff + - `resonance`: low pass filter resonance + - `hcutoff`: high pass filter cutoff + - `hresonance`: high pass filter resonance + - `bandf`: band pass filter cutoff + - `bandq`: band pass filter resonance + - `crush`: amplitude bit crusher using given number of bits + - `distort`: distortion effect. might get loud! + - `pan`: stereo panning from 0 (left) to 1 (right) + - `phaser`: sets the speed of the modulation + - `phaserdepth`: the amount the signal is affected by the phaser effect. + - `phasersweep`: the frequency sweep range of the lfo for the phaser effect. + - `phasercenter`: the amount the signal is affected by the phaser effect. + - `vowel`: vowel filter. possible values: "a", "e", "i", "o", "u" + - `delay`: delay mix + - `delayfeedback`: delay feedback + - `delaytime`: delay time + - `room`: reverb mix + - `size`: reverb room size + - `orbit`: bus name for global effects `delay` and `room`. same orbits will get the same effects + - `freq`: repitches sound to given frequency in Hz + - `note`: repitches sound to given note or midi number + - `cut`: sets cut group. Sounds of same group will cut each other off + - `clip`: multiplies duration with given number + - `speed`: repitches sound by given factor + - `begin`: moves beginning of sample to given factor (between 0 and 1) + - `end`: moves end of sample to given factor (between 0 and 1) + - `attack`: seconds of attack phase + - `decay`: seconds of decay phase + - `sustain`: gain of sustain phase + - `release`: seconds of release phase +- `deadline`: seconds until the sound should play (0 = immediate) +- `duration`: seconds the sound should last. optional for one shot samples, required for synth sounds + +### registerSynthSounds() + +Loads the default waveforms `sawtooth`, `square`, `triangle` and `sine`. Use them like this: + +```js +superdough({ s:'sawtooth' }, 0, 1) +``` + +The duration needs to be set for these sounds! + +### samples(sampleMap) + +allows you to load samples from URLs. There are 3 ways to load samples + +1. sample map object +2. url of sample map json file +3. github repo + +#### sample map object + +You can pass a sample map like this: + +```js +samples({ + '_base': 'https://raw.githubusercontent.com/felixroos/samples/main/', + 'bd': 'president/president_bd.mp3', + 'sd': ['president/president_sd.mp3', 'president/president_sd2.mp3'], + 'hh': ['president/president_hh.mp3'], +}) +``` + +The `_base` property defines the root url while the others declare one or more sample paths for each sound. + +For example the full URL for `bd` would then be `https://raw.githubusercontent.com/felixroos/samples/main/president/president_bd.mp3` + +A loaded sound can then be played with `superdough({ s: 'bd' }, 0)`. + +If you declare multiple sounds, you can select them with `n`: `superdough({ s: 'sd', n: 1 }, 0)` + +The duration property is not needed for samples. + +#### loading samples from a json file + +Instead of passing an object as a sample map, you can also pass a URL to a json that contains a sample map: + +```js +samples('https://raw.githubusercontent.com/felixroos/samples/main/strudel.json') +``` + +The json file is expected to have the same format as described above. + +#### loading samples from a github repo + +Because it is common to use github for samples, there is a short way to load a sample map from github: + +```js +samples('github:tidalcycles/dirt-samples') +``` + +The format is `github://`. + +If `` and `` are not specified, they will default to `samples` and `main` respectively. +It expects a `strudel.json` file to be present at the root of the given repository, which declares the sample paths in the repo. + +The format is also expected to be the same as explained above. + +### initAudioOnFirstClick() + +Initializes audio and makes sure it is playable after the first click in the document. A click is needed because of the [Autoplay Policy](https://www.w3.org/TR/autoplay-detection/). +You can call this function when the document loads. +Then just make sure your first call of `superdough` happens after a click of something. + +## Credits + +- [ZZFX](https://github.com/KilledByAPixel/ZzFX) used for synths starting with z +- [SuperDirt](https://github.com/musikinformatik/SuperDirt) +- [WebDirt](https://github.com/dktr0/WebDirt) diff --git a/src/strudel/superdough/audioContext.mjs b/src/strudel/superdough/audioContext.mjs new file mode 100644 index 0000000..71e01d5 --- /dev/null +++ b/src/strudel/superdough/audioContext.mjs @@ -0,0 +1,18 @@ +let audioContext; + +export const setDefaultAudioContext = () => { + audioContext = new AudioContext(); + return audioContext; +}; + +export const getAudioContext = () => { + if (!audioContext) { + return setDefaultAudioContext(); + } + + return audioContext; +}; + +export function getAudioContextCurrentTime() { + return getAudioContext().currentTime; +} diff --git a/src/strudel/superdough/dspworklet.mjs b/src/strudel/superdough/dspworklet.mjs new file mode 100644 index 0000000..aac0806 --- /dev/null +++ b/src/strudel/superdough/dspworklet.mjs @@ -0,0 +1,79 @@ +import { getAudioContext } from './audioContext.mjs'; + +let worklet; +export async function dspWorklet(ac, code) { + const name = `dsp-worklet-${Date.now()}`; + const workletCode = `${code} +let __q = []; // trigger queue +class MyProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.t = 0; + this.stopped = false; + this.port.onmessage = (e) => { + if(e.data==='stop') { + this.stopped = true; + } else if(e.data?.dough) { + __q.push(e.data) + } else { + msg?.(e.data) + } + }; + } + process(inputs, outputs, parameters) { + const output = outputs[0]; + if(__q.length) { + for(let i=0;i<__q.length;++i) { + const deadline = __q[i].time-currentTime; + if(deadline<=0) { + trigger(__q[i].dough) + __q.splice(i,1) + } + } + } + for (let i = 0; i < output[0].length; i++) { + const out = dsp(this.t / sampleRate); + output.forEach((channel) => { + channel[i] = out; + }); + this.t++; + } + return !this.stopped; + } +} +registerProcessor('${name}', MyProcessor); +`; + const base64String = btoa(workletCode); + const dataURL = `data:text/javascript;base64,${base64String}`; + await ac.audioWorklet.addModule(dataURL); + const node = new AudioWorkletNode(ac, name); + const stop = () => node.port.postMessage('stop'); + return { node, stop }; +} +const stop = () => { + if (worklet) { + worklet?.stop(); + worklet?.node?.disconnect(); + } +}; + +if (typeof window !== 'undefined') { + window.addEventListener('message', (e) => { + if (e.data === 'strudel-stop') { + stop(); + } else if (e.data?.dough) { + worklet?.node.port.postMessage(e.data); + } + }); +} + +export const dough = async (code) => { + const ac = getAudioContext(); + stop(); + worklet = await dspWorklet(ac, code); + worklet.node.connect(ac.destination); +}; + +export function doughTrigger(hap, currentTime, cps, targetTime) { + window.postMessage({ time: targetTime, dough: hap.value, currentTime, duration: hap.duration, cps }); +} diff --git a/src/strudel/superdough/feedbackdelay.mjs b/src/strudel/superdough/feedbackdelay.mjs new file mode 100644 index 0000000..c182d65 --- /dev/null +++ b/src/strudel/superdough/feedbackdelay.mjs @@ -0,0 +1,31 @@ +if (typeof DelayNode !== 'undefined') { + class FeedbackDelayNode extends DelayNode { + constructor(ac, wet, time, feedback) { + super(ac); + wet = Math.abs(wet); + this.delayTime.value = time; + + const feedbackGain = ac.createGain(); + feedbackGain.gain.value = Math.min(Math.abs(feedback), 0.995); + this.feedback = feedbackGain.gain; + + const delayGain = ac.createGain(); + delayGain.gain.value = wet; + this.delayGain = delayGain; + + this.connect(feedbackGain); + this.connect(delayGain); + feedbackGain.connect(this); + + this.connect = (target) => delayGain.connect(target); + return this; + } + start(t) { + this.delayGain.gain.setValueAtTime(this.delayGain.gain.value, t + this.delayTime.value); + } + } + + AudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) { + return new FeedbackDelayNode(this, wet, time, feedback); + }; +} diff --git a/src/strudel/superdough/fft.js b/src/strudel/superdough/fft.js new file mode 100644 index 0000000..3829495 --- /dev/null +++ b/src/strudel/superdough/fft.js @@ -0,0 +1,488 @@ +'use strict'; +// sourced from https://github.com/indutny/fft.js/ +// LICENSE +// This software is licensed under the MIT License. +// Copyright Fedor Indutny, 2017. +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +export default class FFT { + constructor(size) { + this.size = size | 0; + if (this.size <= 1 || (this.size & (this.size - 1)) !== 0) + throw new Error('FFT size must be a power of two and bigger than 1'); + + this._csize = size << 1; + + // NOTE: Use of `var` is intentional for old V8 versions + var table = new Array(this.size * 2); + for (var i = 0; i < table.length; i += 2) { + const angle = (Math.PI * i) / this.size; + table[i] = Math.cos(angle); + table[i + 1] = -Math.sin(angle); + } + this.table = table; + + // Find size's power of two + var power = 0; + for (var t = 1; this.size > t; t <<= 1) power++; + + // Calculate initial step's width: + // * If we are full radix-4 - it is 2x smaller to give inital len=8 + // * Otherwise it is the same as `power` to give len=4 + this._width = power % 2 === 0 ? power - 1 : power; + + // Pre-compute bit-reversal patterns + this._bitrev = new Array(1 << this._width); + for (var j = 0; j < this._bitrev.length; j++) { + this._bitrev[j] = 0; + for (var shift = 0; shift < this._width; shift += 2) { + var revShift = this._width - shift - 2; + this._bitrev[j] |= ((j >>> shift) & 3) << revShift; + } + } + + this._out = null; + this._data = null; + this._inv = 0; + } + fromComplexArray(complex, storage) { + var res = storage || new Array(complex.length >>> 1); + for (var i = 0; i < complex.length; i += 2) res[i >>> 1] = complex[i]; + return res; + } + createComplexArray() { + const res = new Array(this._csize); + for (var i = 0; i < res.length; i++) res[i] = 0; + return res; + } + toComplexArray(input, storage) { + var res = storage || this.createComplexArray(); + for (var i = 0; i < res.length; i += 2) { + res[i] = input[i >>> 1]; + res[i + 1] = 0; + } + return res; + } + completeSpectrum(spectrum) { + var size = this._csize; + var half = size >>> 1; + for (var i = 2; i < half; i += 2) { + spectrum[size - i] = spectrum[i]; + spectrum[size - i + 1] = -spectrum[i + 1]; + } + } + transform(out, data) { + if (out === data) throw new Error('Input and output buffers must be different'); + + this._out = out; + this._data = data; + this._inv = 0; + this._transform4(); + this._out = null; + this._data = null; + } + realTransform(out, data) { + if (out === data) throw new Error('Input and output buffers must be different'); + + this._out = out; + this._data = data; + this._inv = 0; + this._realTransform4(); + this._out = null; + this._data = null; + } + inverseTransform(out, data) { + if (out === data) throw new Error('Input and output buffers must be different'); + + this._out = out; + this._data = data; + this._inv = 1; + this._transform4(); + for (var i = 0; i < out.length; i++) out[i] /= this.size; + this._out = null; + this._data = null; + } + // radix-4 implementation + // + // NOTE: Uses of `var` are intentional for older V8 version that do not + // support both `let compound assignments` and `const phi` + _transform4() { + var out = this._out; + var size = this._csize; + + // Initial step (permute and transform) + var width = this._width; + var step = 1 << width; + var len = (size / step) << 1; + + var outOff; + var t; + var bitrev = this._bitrev; + if (len === 4) { + for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { + const off = bitrev[t]; + this._singleTransform2(outOff, off, step); + } + } else { + // len === 8 + for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { + const off = bitrev[t]; + this._singleTransform4(outOff, off, step); + } + } + + // Loop through steps in decreasing order + var inv = this._inv ? -1 : 1; + var table = this.table; + for (step >>= 2; step >= 2; step >>= 2) { + len = (size / step) << 1; + var quarterLen = len >>> 2; + + // Loop through offsets in the data + for (outOff = 0; outOff < size; outOff += len) { + // Full case + var limit = outOff + quarterLen; + for (var i = outOff, k = 0; i < limit; i += 2, k += step) { + const A = i; + const B = A + quarterLen; + const C = B + quarterLen; + const D = C + quarterLen; + + // Original values + const Ar = out[A]; + const Ai = out[A + 1]; + const Br = out[B]; + const Bi = out[B + 1]; + const Cr = out[C]; + const Ci = out[C + 1]; + const Dr = out[D]; + const Di = out[D + 1]; + + // Middle values + const MAr = Ar; + const MAi = Ai; + + const tableBr = table[k]; + const tableBi = inv * table[k + 1]; + const MBr = Br * tableBr - Bi * tableBi; + const MBi = Br * tableBi + Bi * tableBr; + + const tableCr = table[2 * k]; + const tableCi = inv * table[2 * k + 1]; + const MCr = Cr * tableCr - Ci * tableCi; + const MCi = Cr * tableCi + Ci * tableCr; + + const tableDr = table[3 * k]; + const tableDi = inv * table[3 * k + 1]; + const MDr = Dr * tableDr - Di * tableDi; + const MDi = Dr * tableDi + Di * tableDr; + + // Pre-Final values + const T0r = MAr + MCr; + const T0i = MAi + MCi; + const T1r = MAr - MCr; + const T1i = MAi - MCi; + const T2r = MBr + MDr; + const T2i = MBi + MDi; + const T3r = inv * (MBr - MDr); + const T3i = inv * (MBi - MDi); + + // Final values + const FAr = T0r + T2r; + const FAi = T0i + T2i; + + const FCr = T0r - T2r; + const FCi = T0i - T2i; + + const FBr = T1r + T3i; + const FBi = T1i - T3r; + + const FDr = T1r - T3i; + const FDi = T1i + T3r; + + out[A] = FAr; + out[A + 1] = FAi; + out[B] = FBr; + out[B + 1] = FBi; + out[C] = FCr; + out[C + 1] = FCi; + out[D] = FDr; + out[D + 1] = FDi; + } + } + } + } + // radix-2 implementation + // + // NOTE: Only called for len=4 + _singleTransform2(outOff, off, step) { + const out = this._out; + const data = this._data; + + const evenR = data[off]; + const evenI = data[off + 1]; + const oddR = data[off + step]; + const oddI = data[off + step + 1]; + + const leftR = evenR + oddR; + const leftI = evenI + oddI; + const rightR = evenR - oddR; + const rightI = evenI - oddI; + + out[outOff] = leftR; + out[outOff + 1] = leftI; + out[outOff + 2] = rightR; + out[outOff + 3] = rightI; + } + // radix-4 + // + // NOTE: Only called for len=8 + _singleTransform4(outOff, off, step) { + const out = this._out; + const data = this._data; + const inv = this._inv ? -1 : 1; + const step2 = step * 2; + const step3 = step * 3; + + // Original values + const Ar = data[off]; + const Ai = data[off + 1]; + const Br = data[off + step]; + const Bi = data[off + step + 1]; + const Cr = data[off + step2]; + const Ci = data[off + step2 + 1]; + const Dr = data[off + step3]; + const Di = data[off + step3 + 1]; + + // Pre-Final values + const T0r = Ar + Cr; + const T0i = Ai + Ci; + const T1r = Ar - Cr; + const T1i = Ai - Ci; + const T2r = Br + Dr; + const T2i = Bi + Di; + const T3r = inv * (Br - Dr); + const T3i = inv * (Bi - Di); + + // Final values + const FAr = T0r + T2r; + const FAi = T0i + T2i; + + const FBr = T1r + T3i; + const FBi = T1i - T3r; + + const FCr = T0r - T2r; + const FCi = T0i - T2i; + + const FDr = T1r - T3i; + const FDi = T1i + T3r; + + out[outOff] = FAr; + out[outOff + 1] = FAi; + out[outOff + 2] = FBr; + out[outOff + 3] = FBi; + out[outOff + 4] = FCr; + out[outOff + 5] = FCi; + out[outOff + 6] = FDr; + out[outOff + 7] = FDi; + } + // Real input radix-4 implementation + _realTransform4() { + var out = this._out; + var size = this._csize; + + // Initial step (permute and transform) + var width = this._width; + var step = 1 << width; + var len = (size / step) << 1; + + var outOff; + var t; + var bitrev = this._bitrev; + if (len === 4) { + for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { + const off = bitrev[t]; + this._singleRealTransform2(outOff, off >>> 1, step >>> 1); + } + } else { + // len === 8 + for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { + const off = bitrev[t]; + this._singleRealTransform4(outOff, off >>> 1, step >>> 1); + } + } + + // Loop through steps in decreasing order + var inv = this._inv ? -1 : 1; + var table = this.table; + for (step >>= 2; step >= 2; step >>= 2) { + len = (size / step) << 1; + var halfLen = len >>> 1; + var quarterLen = halfLen >>> 1; + var hquarterLen = quarterLen >>> 1; + + // Loop through offsets in the data + for (outOff = 0; outOff < size; outOff += len) { + for (var i = 0, k = 0; i <= hquarterLen; i += 2, k += step) { + var A = outOff + i; + var B = A + quarterLen; + var C = B + quarterLen; + var D = C + quarterLen; + + // Original values + var Ar = out[A]; + var Ai = out[A + 1]; + var Br = out[B]; + var Bi = out[B + 1]; + var Cr = out[C]; + var Ci = out[C + 1]; + var Dr = out[D]; + var Di = out[D + 1]; + + // Middle values + var MAr = Ar; + var MAi = Ai; + + var tableBr = table[k]; + var tableBi = inv * table[k + 1]; + var MBr = Br * tableBr - Bi * tableBi; + var MBi = Br * tableBi + Bi * tableBr; + + var tableCr = table[2 * k]; + var tableCi = inv * table[2 * k + 1]; + var MCr = Cr * tableCr - Ci * tableCi; + var MCi = Cr * tableCi + Ci * tableCr; + + var tableDr = table[3 * k]; + var tableDi = inv * table[3 * k + 1]; + var MDr = Dr * tableDr - Di * tableDi; + var MDi = Dr * tableDi + Di * tableDr; + + // Pre-Final values + var T0r = MAr + MCr; + var T0i = MAi + MCi; + var T1r = MAr - MCr; + var T1i = MAi - MCi; + var T2r = MBr + MDr; + var T2i = MBi + MDi; + var T3r = inv * (MBr - MDr); + var T3i = inv * (MBi - MDi); + + // Final values + var FAr = T0r + T2r; + var FAi = T0i + T2i; + + var FBr = T1r + T3i; + var FBi = T1i - T3r; + + out[A] = FAr; + out[A + 1] = FAi; + out[B] = FBr; + out[B + 1] = FBi; + + // Output final middle point + if (i === 0) { + var FCr = T0r - T2r; + var FCi = T0i - T2i; + out[C] = FCr; + out[C + 1] = FCi; + continue; + } + + // Do not overwrite ourselves + if (i === hquarterLen) continue; + + // In the flipped case: + // MAi = -MAi + // MBr=-MBi, MBi=-MBr + // MCr=-MCr + // MDr=MDi, MDi=MDr + var ST0r = T1r; + var ST0i = -T1i; + var ST1r = T0r; + var ST1i = -T0i; + var ST2r = -inv * T3i; + var ST2i = -inv * T3r; + var ST3r = -inv * T2i; + var ST3i = -inv * T2r; + + var SFAr = ST0r + ST2r; + var SFAi = ST0i + ST2i; + + var SFBr = ST1r + ST3i; + var SFBi = ST1i - ST3r; + + var SA = outOff + quarterLen - i; + var SB = outOff + halfLen - i; + + out[SA] = SFAr; + out[SA + 1] = SFAi; + out[SB] = SFBr; + out[SB + 1] = SFBi; + } + } + } + } + // radix-2 implementation + // + // NOTE: Only called for len=4 + _singleRealTransform2(outOff, off, step) { + const out = this._out; + const data = this._data; + + const evenR = data[off]; + const oddR = data[off + step]; + + const leftR = evenR + oddR; + const rightR = evenR - oddR; + + out[outOff] = leftR; + out[outOff + 1] = 0; + out[outOff + 2] = rightR; + out[outOff + 3] = 0; + } + // radix-4 + // + // NOTE: Only called for len=8 + _singleRealTransform4(outOff, off, step) { + const out = this._out; + const data = this._data; + const inv = this._inv ? -1 : 1; + const step2 = step * 2; + const step3 = step * 3; + + // Original values + const Ar = data[off]; + const Br = data[off + step]; + const Cr = data[off + step2]; + const Dr = data[off + step3]; + + // Pre-Final values + const T0r = Ar + Cr; + const T1r = Ar - Cr; + const T2r = Br + Dr; + const T3r = inv * (Br - Dr); + + // Final values + const FAr = T0r + T2r; + + const FBr = T1r; + const FBi = -T3r; + + const FCr = T0r - T2r; + + const FDr = T1r; + const FDi = T3r; + + out[outOff] = FAr; + out[outOff + 1] = 0; + out[outOff + 2] = FBr; + out[outOff + 3] = FBi; + out[outOff + 4] = FCr; + out[outOff + 5] = 0; + out[outOff + 6] = FDr; + out[outOff + 7] = FDi; + } +} diff --git a/src/strudel/superdough/helpers.mjs b/src/strudel/superdough/helpers.mjs new file mode 100644 index 0000000..a471da9 --- /dev/null +++ b/src/strudel/superdough/helpers.mjs @@ -0,0 +1,501 @@ +import { getAudioContext } from './audioContext.mjs'; +import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs'; +import { getNoiseBuffer } from './noise.mjs'; +import { logger } from './logger.mjs'; + +export const noises = ['pink', 'white', 'brown', 'crackle']; + +export function gainNode(value) { + const node = getAudioContext().createGain(); + node.gain.value = value; + return node; +} + +export function effectSend(input, effect, wet) { + const send = gainNode(wet); + input.connect(send); + send.connect(effect); + return send; +} + +const getSlope = (y1, y2, x1, x2) => { + const denom = x2 - x1; + if (denom === 0) { + return 0; + } + return (y2 - y1) / (x2 - x1); +}; + +export function getWorklet(ac, processor, params, config) { + const node = new AudioWorkletNode(ac, processor, config); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + node.parameters.get(key).value = value; + } + }); + return node; +} + +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 = min === 0 ? 0.001 : min; + max = max === 0 ? 0.001 : max; + } + const range = max - min; + const peak = max; + const sustainVal = min + sustain * range; + const duration = end - begin; + + const envValAtTime = (time) => { + let val; + if (attack > time) { + let slope = getSlope(min, peak, 0, attack); + val = time * slope + (min > peak ? min : 0); + } else { + val = (time - attack) * getSlope(peak, sustainVal, 0, decay) + peak; + } + if (curve === 'exponential') { + val = val || 0.001; + } + return val; + }; + + 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 { + //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); +}; + +function getModulationShapeInput(val) { + if (typeof val === 'number') { + return val % 5; + } + return { tri: 0, triangle: 0, sine: 1, ramp: 2, saw: 3, square: 4 }[val] ?? 0; +} + +export function getLfo(audioContext, begin, end, properties = {}) { + const { shape = 0, ...props } = properties; + const { dcoffset = -0.5, depth = 1 } = properties; + const lfoprops = { + frequency: 1, + depth, + skew: 0.5, + phaseoffset: 0, + time: begin, + begin, + end, + shape: getModulationShapeInput(shape), + dcoffset, + min: dcoffset * depth, + max: dcoffset * depth + depth, + curve: 1, + ...props, + }; + + return getWorklet(audioContext, 'lfo-processor', lfoprops); +} + +export function getCompressor(ac, threshold, ratio, knee, attack, release) { + const options = { + threshold: threshold ?? -3, + ratio: ratio ?? 10, + knee: knee ?? 10, + attack: attack ?? 0.005, + release: release ?? 0.05, + }; + return new DynamicsCompressorNode(ac, options); +} + +// 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)]; +}; + +// helper utility for applying standard modulators to a parameter +export function applyParameterModulators(audioContext, param, start, end, envelopeValues, lfoValues) { + let { amount, offset, defaultAmount = 1, curve = 'linear', values, holdEnd, defaultValues } = envelopeValues; + + if (amount == null) { + const hasADSRParams = values.some((p) => p != null); + amount = hasADSRParams ? defaultAmount : 0; + } + + const min = offset ?? 0; + const max = amount + min; + const diff = Math.abs(max - min); + if (diff) { + const [attack, decay, sustain, release] = getADSRValues(values, curve, defaultValues); + getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve); + } + let lfo; + let { defaultDepth = 1, depth, dcoffset, ...getLfoInputs } = lfoValues; + + if (depth == null) { + const hasLFOParams = Object.values(getLfoInputs).some((v) => v != null); + depth = hasLFOParams ? defaultDepth : 0; + } + if (depth) { + lfo = getLfo(audioContext, start, end, { + depth, + dcoffset, + ...getLfoInputs, + }); + lfo.connect(param); + } + + return { lfo, disconnect: () => lfo?.disconnect() }; +} + +export function createFilter(context, type, frequency, Q, att, dec, sus, rel, fenv, start, end, fanchor, model, drive) { + const curve = 'exponential'; + const [attack, decay, sustain, release] = getADSRValues([att, dec, sus, rel], curve, [0.005, 0.14, 0, 0.1]); + let filter; + let frequencyParam; + if (model === 'ladder') { + filter = getWorklet(context, 'ladder-processor', { frequency, q: Q, drive }); + frequencyParam = filter.parameters.get('frequency'); + } else { + filter = context.createBiquadFilter(); + filter.type = type; + filter.Q.value = Q; + filter.frequency.value = frequency; + frequencyParam = filter.frequency; + } + + // envelope is active when any of these values is set + const hasEnvelope = att ?? dec ?? sus ?? rel ?? fenv; + // Apply ADSR to filter frequency + 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(frequencyParam, attack, decay, sustain, release, min, max, start, end, curve); + return filter; + } + return filter; +} + +// stays 1 until .5, then fades out +let wetfade = (d) => (d < 0.5 ? 1 : 1 - (d - 0.5) / 0.5); + +// mix together dry and wet nodes. 0 = only dry 1 = only wet +// still not too sure about how this could be used more generally... +export function drywet(dry, wet, wetAmount = 0) { + const ac = getAudioContext(); + if (!wetAmount) { + return dry; + } + let dry_gain = ac.createGain(); + let wet_gain = ac.createGain(); + dry.connect(dry_gain); + wet.connect(wet_gain); + dry_gain.gain.value = wetfade(wetAmount); + wet_gain.gain.value = wetfade(1 - wetAmount); + let mix = ac.createGain(); + dry_gain.connect(mix); + 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 === undefined) { + 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; + } +} +// ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities +// a bit of a hack, but it works very well :) +export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) { + const constantNode = new ConstantSourceNode(audioContext); + + // Certain browsers requires audio nodes to be connected in order for their onended events + // to fire, so we _mute it_ and then connect it to the destination + const zeroGain = gainNode(0); + zeroGain.connect(audioContext.destination); + constantNode.connect(zeroGain); + + // Schedule the `onComplete` callback to occur at `stopTime` + constantNode.onended = () => { + // Ensure garbage collection + try { + zeroGain.disconnect(); + } catch { + // pass + } + try { + constantNode.disconnect(); + } catch { + // pass + } + onComplete(); + }; + constantNode.start(startTime); + constantNode.stop(stopTime); + return constantNode; +} +const mod = (freq, range = 1, type = 'sine') => { + const ctx = getAudioContext(); + let osc; + if (noises.includes(type)) { + osc = ctx.createBufferSource(); + osc.buffer = getNoiseBuffer(type, 2); + osc.loop = true; + } else { + osc = ctx.createOscillator(); + osc.type = type; + osc.frequency.value = freq; + } + + osc.start(); + const g = new GainNode(ctx, { gain: range }); + osc.connect(g); // -range, range + return { node: g, stop: (t) => osc.stop(t) }; +}; +const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => { + const carrfreq = frequencyparam.value; + const modfreq = carrfreq * harmonicityRatio; + const modgain = modfreq * modulationIndex; + return mod(modfreq, modgain, wave); +}; +export function applyFM(param, value, begin) { + const { + fmh: fmHarmonicity = 1, + fmi: fmModulationIndex, + fmenv: fmEnvelopeType = 'exp', + fmattack: fmAttack, + fmdecay: fmDecay, + fmsustain: fmSustain, + fmrelease: fmRelease, + fmvelocity: fmVelocity, + fmwave: fmWaveform = 'sine', + duration, + } = value; + let modulator; + let stop = () => {}; + + if (fmModulationIndex) { + const ac = getAudioContext(); + const envGain = ac.createGain(); + const fmmod = fm(param, fmHarmonicity, fmModulationIndex, fmWaveform); + + modulator = fmmod.node; + stop = fmmod.stop; + if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].some((v) => v !== undefined)) { + // no envelope by default + modulator.connect(param); + } else { + const [attack, decay, sustain, release] = getADSRValues([fmAttack, fmDecay, fmSustain, fmRelease]); + const holdEnd = begin + duration; + getParamADSR( + envGain.gain, + attack, + decay, + sustain, + release, + 0, + 1, + begin, + holdEnd, + fmEnvelopeType === 'exp' ? 'exponential' : 'linear', + ); + modulator.connect(envGain); + envGain.connect(param); + } + } + return { stop }; +} + +// Saturation curves + +const __squash = (x) => x / (1 + x); // [0, inf) to [0, 1) +const _mod = (n, m) => ((n % m) + m) % m; + +const _scurve = (x, k) => ((1 + k) * x) / (1 + k * Math.abs(x)); +const _soft = (x, k) => Math.tanh(x * (1 + k)); +const _hard = (x, k) => clamp((1 + k) * x, -1, 1); + +const _fold = (x, k) => { + // Closed form folding for audio rate + let y = (1 + 0.5 * k) * x; + const window = _mod(y + 1, 4); + return 1 - Math.abs(window - 2); +}; + +const _sineFold = (x, k) => Math.sin((Math.PI / 2) * _fold(x, k)); + +const _cubic = (x, k) => { + const t = __squash(Math.log1p(k)); + const cubic = (x - (t / 3) * x * x * x) / (1 - t / 3); // normalized to go from (-1, 1) + return _soft(cubic, k); +}; + +const _diode = (x, k, asym = false) => { + const g = 1 + 2 * k; // gain + const t = __squash(Math.log1p(k)); + const bias = 0.07 * t; + const pos = _soft(x + bias, 2 * k); + const neg = _soft(asym ? bias : -x + bias, 2 * k); + const y = pos - neg; + // We divide by the derivative at 0 so that the distortion is roughly + // the identity map near 0 => small values are preserved and undistorted + const sech = 1 / Math.cosh(g * bias); + const sech2 = sech * sech; // derivative of soft (i.e. tanh) is sech^2 + const denom = Math.max(1e-8, (asym ? 1 : 2) * g * sech2); // g from chain rule; 2 if both pos/neg have x + return _soft(y / denom, k); +}; + +const _asym = (x, k) => _diode(x, k, true); + +const _chebyshev = (x, k) => { + const kl = 10 * Math.log1p(k); + let tnm1 = 1; + let tnm2 = x; + let tn; + let y = 0; + for (let i = 1; i < 64; i++) { + if (i < 2) { + // Already set inital conditions + y += i == 0 ? tnm1 : tnm2; + continue; + } + tn = 2 * x * tnm1 - tnm2; // https://en.wikipedia.org/wiki/Chebyshev_polynomials#Recurrence_definition + tnm2 = tnm1; + tnm1 = tn; + if (i % 2 === 0) { + y += Math.min((1.3 * kl) / i, 2) * tn; + } + } + // Soft clip + return _soft(y, kl / 20); +}; + +export const distortionAlgorithms = { + scurve: _scurve, + soft: _soft, + hard: _hard, + cubic: _cubic, + diode: _diode, + asym: _asym, + fold: _fold, + sinefold: _sineFold, + chebyshev: _chebyshev, +}; +const _algoNames = Object.freeze(Object.keys(distortionAlgorithms)); + +export const getDistortionAlgorithm = (algo) => { + let index = algo; + if (typeof algo === 'string') { + index = _algoNames.indexOf(algo); + if (index === -1) { + logger(`[superdough] Could not find waveshaping algorithm ${algo}. + Available options are ${_algoNames.join(', ')}. + Defaulting to ${_algoNames[0]}.`); + index = 0; + } + } + const name = _algoNames[index % _algoNames.length]; // allow for wrapping if algo was a number + return distortionAlgorithms[name]; +}; + +export const getDistortion = (distort, postgain, algorithm) => { + return getWorklet(getAudioContext(), 'distort-processor', { distort, postgain }, { processorOptions: { algorithm } }); +}; + +export const getFrequencyFromValue = (value, defaultNote = 36) => { + let { note, freq } = value; + note = note || defaultNote; + if (typeof note === 'string') { + note = noteToMidi(note); // e.g. c3 => 48 + } + // get frequency + if (!freq && typeof note === 'number') { + freq = midiToFreq(note); // + 48); + } + + return Number(freq); +}; + +export const destroyAudioWorkletNode = (node) => { + if (node == null) { + return; + } + node.disconnect(); + node.parameters.get('end')?.setValueAtTime(0, 0); +}; diff --git a/src/strudel/superdough/index.mjs b/src/strudel/superdough/index.mjs new file mode 100644 index 0000000..a382bd1 --- /dev/null +++ b/src/strudel/superdough/index.mjs @@ -0,0 +1,15 @@ +/* +index.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +export * from './superdough.mjs'; +export * from './sampler.mjs'; +export * from './helpers.mjs'; +export * from './synth.mjs'; +export * from './zzfx.mjs'; +export * from './logger.mjs'; +export * from './dspworklet.mjs'; +export * from './audioContext.mjs'; +export * from './wavetable.mjs'; diff --git a/src/strudel/superdough/logger.mjs b/src/strudel/superdough/logger.mjs new file mode 100644 index 0000000..99fd0cf --- /dev/null +++ b/src/strudel/superdough/logger.mjs @@ -0,0 +1,14 @@ +let log = (msg) => console.log(msg); + +export function errorLogger(e, origin = 'superdough') { + if (process.env.NODE_ENV === 'development') { + console.error(e); + } + logger(`[${origin}] error: ${e.message}`); +} + +export const logger = (...args) => log(...args); + +export const setLogger = (fn) => { + log = fn; +}; diff --git a/src/strudel/superdough/noise.mjs b/src/strudel/superdough/noise.mjs new file mode 100644 index 0000000..816dd25 --- /dev/null +++ b/src/strudel/superdough/noise.mjs @@ -0,0 +1,72 @@ +import { drywet } from './helpers.mjs'; +import { getAudioContext } from './audioContext.mjs'; + +let noiseCache = {}; + +// lazy generates noise buffers and keeps them forever +export function getNoiseBuffer(type, density) { + const ac = getAudioContext(); + if (noiseCache[type]) { + return noiseCache[type]; + } + const bufferSize = 2 * ac.sampleRate; + const noiseBuffer = ac.createBuffer(1, bufferSize, ac.sampleRate); + const output = noiseBuffer.getChannelData(0); + let lastOut = 0; + let b0, b1, b2, b3, b4, b5, b6; + b0 = b1 = b2 = b3 = b4 = b5 = b6 = 0.0; + + for (let i = 0; i < bufferSize; i++) { + if (type === 'white') { + output[i] = Math.random() * 2 - 1; + } else if (type === 'brown') { + let white = Math.random() * 2 - 1; + output[i] = (lastOut + 0.02 * white) / 1.02; + lastOut = output[i]; + } else if (type === 'pink') { + let white = Math.random() * 2 - 1; + b0 = 0.99886 * b0 + white * 0.0555179; + b1 = 0.99332 * b1 + white * 0.0750759; + b2 = 0.969 * b2 + white * 0.153852; + b3 = 0.8665 * b3 + white * 0.3104856; + b4 = 0.55 * b4 + white * 0.5329522; + b5 = -0.7616 * b5 - white * 0.016898; + output[i] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362; + output[i] *= 0.11; + b6 = white * 0.115926; + } else if (type === 'crackle') { + const probability = density * 0.01; + if (Math.random() < probability) { + output[i] = Math.random() * 2 - 1; + } else { + output[i] = 0; + } + } + } + + // Prevent caching to randomize crackles + if (type !== 'crackle') noiseCache[type] = noiseBuffer; + return noiseBuffer; +} + +// expects one of noises as type +export function getNoiseOscillator(type = 'white', t, density = 0.02) { + const ac = getAudioContext(); + const o = ac.createBufferSource(); + o.buffer = getNoiseBuffer(type, density); + o.loop = true; + o.start(t); + return { + node: o, + stop: (time) => o.stop(time), + }; +} + +export function getNoiseMix(inputNode, wet, t) { + const noiseOscillator = getNoiseOscillator('pink', t); + const noiseMix = drywet(inputNode, noiseOscillator.node, wet); + return { + node: noiseMix, + stop: (time) => noiseOscillator?.stop(time), + }; +} diff --git a/src/strudel/superdough/ola-processor.js b/src/strudel/superdough/ola-processor.js new file mode 100644 index 0000000..38d45a2 --- /dev/null +++ b/src/strudel/superdough/ola-processor.js @@ -0,0 +1,185 @@ +'use strict'; + +// sourced from https://github.com/olvb/phaze/tree/master?tab=readme-ov-file +const WEBAUDIO_BLOCK_SIZE = 128; + +/** Overlap-Add Node */ +class OLAProcessor extends AudioWorkletProcessor { + constructor(options) { + super(options); + this.started = false; + this.nbInputs = options.numberOfInputs; + this.nbOutputs = options.numberOfOutputs; + + this.blockSize = options.processorOptions.blockSize; + // TODO for now, the only support hop size is the size of a web audio block + this.hopSize = WEBAUDIO_BLOCK_SIZE; + + this.nbOverlaps = this.blockSize / this.hopSize; + + // pre-allocate input buffers (will be reallocated if needed) + this.inputBuffers = new Array(this.nbInputs); + this.inputBuffersHead = new Array(this.nbInputs); + this.inputBuffersToSend = new Array(this.nbInputs); + // default to 1 channel per input until we know more + for (let i = 0; i < this.nbInputs; i++) { + this.allocateInputChannels(i, 1); + } + // pre-allocate input buffers (will be reallocated if needed) + this.outputBuffers = new Array(this.nbOutputs); + this.outputBuffersToRetrieve = new Array(this.nbOutputs); + // default to 1 channel per output until we know more + for (let i = 0; i < this.nbOutputs; i++) { + this.allocateOutputChannels(i, 1); + } + } + + /** Handles dynamic reallocation of input/output channels buffer + (channel numbers may lety during lifecycle) **/ + reallocateChannelsIfNeeded(inputs, outputs) { + for (let i = 0; i < this.nbInputs; i++) { + let nbChannels = inputs[i].length; + if (nbChannels != this.inputBuffers[i].length) { + this.allocateInputChannels(i, nbChannels); + } + } + + for (let i = 0; i < this.nbOutputs; i++) { + let nbChannels = outputs[i].length; + if (nbChannels != this.outputBuffers[i].length) { + this.allocateOutputChannels(i, nbChannels); + } + } + } + + allocateInputChannels(inputIndex, nbChannels) { + // allocate input buffers + + this.inputBuffers[inputIndex] = new Array(nbChannels); + for (let i = 0; i < nbChannels; i++) { + this.inputBuffers[inputIndex][i] = new Float32Array(this.blockSize + WEBAUDIO_BLOCK_SIZE); + this.inputBuffers[inputIndex][i].fill(0); + } + + // allocate input buffers to send and head pointers to copy from + // (cannot directly send a pointer/subarray because input may be modified) + this.inputBuffersHead[inputIndex] = new Array(nbChannels); + this.inputBuffersToSend[inputIndex] = new Array(nbChannels); + for (let i = 0; i < nbChannels; i++) { + this.inputBuffersHead[inputIndex][i] = this.inputBuffers[inputIndex][i].subarray(0, this.blockSize); + this.inputBuffersToSend[inputIndex][i] = new Float32Array(this.blockSize); + } + } + + allocateOutputChannels(outputIndex, nbChannels) { + // allocate output buffers + this.outputBuffers[outputIndex] = new Array(nbChannels); + for (let i = 0; i < nbChannels; i++) { + this.outputBuffers[outputIndex][i] = new Float32Array(this.blockSize); + this.outputBuffers[outputIndex][i].fill(0); + } + + // allocate output buffers to retrieve + // (cannot send a pointer/subarray because new output has to be add to exising output) + this.outputBuffersToRetrieve[outputIndex] = new Array(nbChannels); + for (let i = 0; i < nbChannels; i++) { + this.outputBuffersToRetrieve[outputIndex][i] = new Float32Array(this.blockSize); + this.outputBuffersToRetrieve[outputIndex][i].fill(0); + } + } + + /** Read next web audio block to input buffers **/ + readInputs(inputs) { + // when playback is paused, we may stop receiving new samples + if (inputs[0].length && inputs[0][0].length == 0) { + for (let i = 0; i < this.nbInputs; i++) { + for (let j = 0; j < this.inputBuffers[i].length; j++) { + this.inputBuffers[i][j].fill(0, this.blockSize); + } + } + return; + } + + for (let i = 0; i < this.nbInputs; i++) { + for (let j = 0; j < this.inputBuffers[i].length; j++) { + let webAudioBlock = inputs[i][j]; + this.inputBuffers[i][j].set(webAudioBlock, this.blockSize); + } + } + } + + /** Write next web audio block from output buffers **/ + writeOutputs(outputs) { + for (let i = 0; i < this.nbInputs; i++) { + for (let j = 0; j < this.inputBuffers[i].length; j++) { + let webAudioBlock = this.outputBuffers[i][j].subarray(0, WEBAUDIO_BLOCK_SIZE); + outputs[i][j].set(webAudioBlock); + } + } + } + + /** Shift left content of input buffers to receive new web audio block **/ + shiftInputBuffers() { + for (let i = 0; i < this.nbInputs; i++) { + for (let j = 0; j < this.inputBuffers[i].length; j++) { + this.inputBuffers[i][j].copyWithin(0, WEBAUDIO_BLOCK_SIZE); + } + } + } + + /** Shift left content of output buffers to receive new web audio block **/ + shiftOutputBuffers() { + for (let i = 0; i < this.nbOutputs; i++) { + for (let j = 0; j < this.outputBuffers[i].length; j++) { + this.outputBuffers[i][j].copyWithin(0, WEBAUDIO_BLOCK_SIZE); + this.outputBuffers[i][j].subarray(this.blockSize - WEBAUDIO_BLOCK_SIZE).fill(0); + } + } + } + + /** Copy contents of input buffers to buffer actually sent to process **/ + prepareInputBuffersToSend() { + for (let i = 0; i < this.nbInputs; i++) { + for (let j = 0; j < this.inputBuffers[i].length; j++) { + this.inputBuffersToSend[i][j].set(this.inputBuffersHead[i][j]); + } + } + } + + /** Add contents of output buffers just processed to output buffers **/ + handleOutputBuffersToRetrieve() { + for (let i = 0; i < this.nbOutputs; i++) { + for (let j = 0; j < this.outputBuffers[i].length; j++) { + for (let k = 0; k < this.blockSize; k++) { + this.outputBuffers[i][j][k] += this.outputBuffersToRetrieve[i][j][k] / this.nbOverlaps; + } + } + } + } + + process(inputs, outputs, params) { + const input = inputs[0]; + const hasInput = !(input[0] === undefined); + if (this.started && !hasInput) { + return false; + } + this.started = hasInput; + this.reallocateChannelsIfNeeded(inputs, outputs); + + this.readInputs(inputs); + this.shiftInputBuffers(); + this.prepareInputBuffersToSend(); + this.processOLA(this.inputBuffersToSend, this.outputBuffersToRetrieve, params); + this.handleOutputBuffersToRetrieve(); + this.writeOutputs(outputs); + this.shiftOutputBuffers(); + + return true; + } + + processOLA(inputs, outputs, params) { + console.assert(false, 'Not overriden'); + } +} + +export default OLAProcessor; diff --git a/src/strudel/superdough/package.json b/src/strudel/superdough/package.json new file mode 100644 index 0000000..a821177 --- /dev/null +++ b/src/strudel/superdough/package.json @@ -0,0 +1,41 @@ +{ + "name": "superdough", + "version": "1.2.5", + "description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.", + "main": "index.mjs", + "type": "module", + "publishConfig": { + "main": "dist/index.mjs" + }, + "directories": { + "example": "examples" + }, + "scripts": { + "build": "vite build", + "prepublishOnly": "npm run build" + }, + "repository": { + "type": "git", + "url": "git+https://codeberg.org/uzu/strudel.git" + }, + "keywords": [ + "tidalcycles", + "strudel", + "pattern", + "livecoding", + "algorave" + ], + "author": "Felix Roos ", + "license": "AGPL-3.0-or-later", + "bugs": { + "url": "https://codeberg.org/uzu/strudel/issues" + }, + "homepage": "https://codeberg.org/uzu/strudel#readme", + "devDependencies": { + "vite": "^6.0.11", + "vite-plugin-bundle-audioworklet": "workspace:*" + }, + "dependencies": { + "nanostores": "^0.11.3" + } +} diff --git a/src/strudel/superdough/reverb.mjs b/src/strudel/superdough/reverb.mjs new file mode 100644 index 0000000..2960b59 --- /dev/null +++ b/src/strudel/superdough/reverb.mjs @@ -0,0 +1,57 @@ +import reverbGen from './reverbGen.mjs'; +import { clamp } from './util.mjs'; + +if (typeof AudioContext !== 'undefined') { + AudioContext.prototype.adjustLength = function (duration, buffer, speed = 1, offsetAmount = 0) { + const sampleOffset = Math.floor(clamp(offsetAmount, 0, 1) * buffer.length); + const newLength = buffer.sampleRate * duration; + const newBuffer = this.createBuffer(buffer.numberOfChannels, buffer.length, buffer.sampleRate); + for (let channel = 0; channel < buffer.numberOfChannels; channel++) { + let oldData = buffer.getChannelData(channel); + let newData = newBuffer.getChannelData(channel); + + for (let i = 0; i < newLength; i++) { + // loop the buffer around to prevent + let position = (sampleOffset + i * Math.abs(speed)) % oldData.length; + if (speed < 1) { + position = position * -1; + } + + newData[i] = oldData.at(position) || 0; + } + } + return newBuffer; + }; + + AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir, irspeed, irbegin) { + const convolver = this.createConvolver(); + convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir, irspeed, irbegin) => { + convolver.duration = d; + convolver.fade = fade; + convolver.lp = lp; + convolver.dim = dim; + convolver.ir = ir; + convolver.irspeed = irspeed; + convolver.irbegin = irbegin; + if (ir) { + convolver.buffer = this.adjustLength(d, ir, irspeed, irbegin); + } else { + reverbGen.generateReverb( + { + audioContext: this, + numChannels: 2, + decayTime: d, + fadeInTime: fade, + lpFreqStart: lp, + lpFreqEnd: dim, + }, + (buffer) => { + convolver.buffer = buffer; + }, + ); + } + }; + convolver.generate(duration, fade, lp, dim, ir, irspeed, irbegin); + return convolver; + }; +} diff --git a/src/strudel/superdough/reverbGen.mjs b/src/strudel/superdough/reverbGen.mjs new file mode 100644 index 0000000..d2533ca --- /dev/null +++ b/src/strudel/superdough/reverbGen.mjs @@ -0,0 +1,130 @@ +// 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 = audioContext.sampleRate; + 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/src/strudel/superdough/sampler.mjs b/src/strudel/superdough/sampler.mjs new file mode 100644 index 0000000..3232fa4 --- /dev/null +++ b/src/strudel/superdough/sampler.mjs @@ -0,0 +1,366 @@ +import { getCommonSampleInfo } from './util.mjs'; +import { registerSound, registerWaveTable } from './index.mjs'; +import { getAudioContext } from './audioContext.mjs'; +import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs'; +import { logger } from './logger.mjs'; + +const bufferCache = {}; // string: Promise +const loadCache = {}; // string: Promise + +export const getCachedBuffer = (url) => bufferCache[url]; + +function humanFileSize(bytes, si) { + var thresh = si ? 1000 : 1024; + if (bytes < thresh) return bytes + ' B'; + var units = si + ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + var u = -1; + do { + bytes /= thresh; + ++u; + } while (bytes >= thresh); + return bytes.toFixed(1) + ' ' + units[u]; +} + +export function getSampleInfo(hapValue, bank) { + const { speed = 1.0 } = hapValue; + const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank); + let playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12); + return { transpose, url, index, midi, label, playbackRate }; +} + +// takes hapValue and returns buffer + playbackRate. +export const getSampleBuffer = async (hapValue, bank, resolveUrl) => { + let { url: sampleUrl, label, playbackRate } = getSampleInfo(hapValue, bank); + if (resolveUrl) { + sampleUrl = await resolveUrl(sampleUrl); + } + const ac = getAudioContext(); + const buffer = await loadBuffer(sampleUrl, ac, label); + + if (hapValue.unit === 'c') { + playbackRate = playbackRate * buffer.duration; + } + return { buffer, playbackRate }; +}; + +// creates playback ready AudioBufferSourceNode from hapValue +export const getSampleBufferSource = async (hapValue, bank, resolveUrl) => { + let { buffer, playbackRate } = await getSampleBuffer(hapValue, bank, resolveUrl); + if (hapValue.speed < 0) { + // should this be cached? + buffer = reverseBuffer(buffer); + } + const ac = getAudioContext(); + const bufferSource = ac.createBufferSource(); + bufferSource.buffer = buffer; + bufferSource.playbackRate.value = playbackRate; + + const { loopBegin = 0, loopEnd = 1, begin = 0, end = 1 } = hapValue; + + // "The computation of the offset into the sound is performed using the sound buffer's natural sample rate, + // rather than the current playback rate, so even if the sound is playing at twice its normal speed, + // the midway point through a 10-second audio buffer is still 5." + const offset = begin * bufferSource.buffer.duration; + + const loop = hapValue.loop; + if (loop) { + bufferSource.loop = true; + bufferSource.loopStart = loopBegin * bufferSource.buffer.duration - offset; + bufferSource.loopEnd = loopEnd * bufferSource.buffer.duration - offset; + } + const bufferDuration = bufferSource.buffer.duration / bufferSource.playbackRate.value; + const sliceDuration = (end - begin) * bufferDuration; + return { bufferSource, offset, bufferDuration, sliceDuration }; +}; + +export const loadBuffer = (url, ac, s, n = 0) => { + const label = s ? `sound "${s}:${n}"` : 'sample'; + url = url.replace('#', '%23'); + if (!loadCache[url]) { + logger(`[sampler] load ${label}..`, 'load-sample', { url }); + const timestamp = Date.now(); + loadCache[url] = fetch(url) + .then((res) => res.arrayBuffer()) + .then(async (res) => { + const took = Date.now() - timestamp; + const size = humanFileSize(res.byteLength); + // const downSpeed = humanFileSize(res.byteLength / took); + logger(`[sampler] load ${label}... done! loaded ${size} in ${took}ms`, 'loaded-sample', { url }); + const decoded = await ac.decodeAudioData(res); + bufferCache[url] = decoded; + return decoded; + }); + } + return loadCache[url]; +}; + +export function reverseBuffer(buffer) { + const ac = getAudioContext(); + const reversed = ac.createBuffer(buffer.numberOfChannels, buffer.length, ac.sampleRate); + for (let channel = 0; channel < buffer.numberOfChannels; channel++) { + reversed.copyToChannel(buffer.getChannelData(channel).slice().reverse(), channel, channel); + } + return reversed; +} + +export const getLoadedBuffer = (url) => { + return bufferCache[url]; +}; + +function resolveSpecialPaths(base) { + if (base.startsWith('bubo:')) { + const [_, repo] = base.split(':'); + base = `github:Bubobubobubobubo/dough-${repo}`; + } + return base; +} + +function githubPath(base, subpath = '') { + if (!base.startsWith('github:')) { + throw new Error('expected "github:" at the start of pseudoUrl'); + } + let path = base.slice('github:'.length); + path = path.endsWith('/') ? path.slice(0, -1) : path; + + let components = path.split('/'); + let user = components[0]; + let repo = components.length >= 2 ? components[1] : 'samples'; + let branch = components.length >= 3 ? components[2] : 'main'; + let other = components.slice(3); + if (subpath) { + other.push(subpath); + } + other = other.join('/'); + + return `https://raw.githubusercontent.com/${user}/${repo}/${branch}/${other}`; +} + +export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '') => { + return Object.entries(sampleMap).forEach(([key, value]) => { + if (typeof value === 'string') { + value = [value]; + } + if (typeof value !== 'object') { + throw new Error('wrong sample map format for ' + key); + } + baseUrl = value._base || baseUrl; + baseUrl = resolveSpecialPaths(baseUrl); + if (baseUrl.startsWith('github:')) { + baseUrl = githubPath(baseUrl, ''); + } + const fullUrl = (v) => baseUrl + v; + if (Array.isArray(value)) { + //return [key, value.map(replaceUrl)]; + value = value.map(fullUrl); + } else { + // must be object + value = Object.fromEntries( + Object.entries(value).map(([note, samples]) => { + return [note, (typeof samples === 'string' ? [samples] : samples).map(fullUrl)]; + }), + ); + } + fn(key, value); + }); +}; + +// allows adding a custom url prefix handler +// for example, it is used by the desktop app to load samples starting with '~/music' +let resourcePrefixHandlers = {}; +export function registerSamplesPrefix(prefix, resolve) { + resourcePrefixHandlers[prefix] = resolve; +} +// finds a prefix handler for the given url (if any) +function getSamplesPrefixHandler(url) { + const handler = Object.entries(resourcePrefixHandlers).find(([key]) => url.startsWith(key)); + if (handler) { + return handler[1]; + } + return; +} + +export async function fetchSampleMap(url) { + // check if custom prefix handler + const handler = getSamplesPrefixHandler(url); + if (handler) { + return handler(url); + } + url = resolveSpecialPaths(url); + if (url.startsWith('github:')) { + url = githubPath(url, 'strudel.json'); + } + if (url.startsWith('local:')) { + url = `http://localhost:5432`; + } + if (url.startsWith('shabda:')) { + let [_, path] = url.split('shabda:'); + url = `https://shabda.ndre.gr/${path}.json?strudel=1`; + } + if (url.startsWith('shabda/speech')) { + let [_, path] = url.split('shabda/speech'); + path = path.startsWith('/') ? path.substring(1) : path; + let [params, words] = path.split(':'); + let gender = 'f'; + let language = 'en-GB'; + if (params) { + [language, gender] = params.split('/'); + } + url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`; + } + if (typeof fetch !== 'function') { + // not a browser + return; + } + const base = url.split('/').slice(0, -1).join('/'); + if (typeof fetch === 'undefined') { + // skip fetch when in node / testing + return; + } + const json = await fetch(url) + .then((res) => res.json()) + .catch((error) => { + console.error(error); + throw new Error(`error loading "${url}"`); + }); + return [json, json._base || base]; +} + +/** + * Loads a collection of samples to use with `s` + * @example + * samples('github:tidalcycles/dirt-samples'); + * s("[bd ~]*2, [~ hh]*2, ~ sd") + * @example + * samples({ + * bd: '808bd/BD0000.WAV', + * sd: '808sd/SD0010.WAV' + * }, 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/'); + * s("[bd ~]*2, [~ hh]*2, ~ sd") + * @example + * samples('shabda:noise,chimp:2') + * s("noise ") + * @example + * samples('shabda/speech/fr-FR/f:chocolat') + * s("chocolat*4") + */ + +export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => { + if (typeof sampleMap === 'string') { + const [json, base] = await fetchSampleMap(sampleMap); + return samples(json, baseUrl || base, options); + } + const { prebake, tag } = options; + + processSampleMap( + sampleMap, + (key, bank) => { + registerSampleSource(key, bank, { baseUrl, prebake, tag }); + }, + baseUrl, + ); +}; + +const cutGroups = []; + +export async function onTriggerSample(t, value, onended, bank, resolveUrl) { + let { + s, + nudge = 0, // TODO: is this in seconds? + cut, + loop, + clip = undefined, // if set, samples will be cut off when the hap ends + n = 0, + speed = 1, // sample playback speed + duration, + } = value; + + // load sample + if (speed === 0) { + // no playback + return; + } + const ac = getAudioContext(); + + // destructure adsr here, because the default should be different for synths and samples + let [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]); + + const { bufferSource, sliceDuration, offset } = await getSampleBufferSource(value, bank, resolveUrl); + + // asny stuff above took too long? + if (ac.currentTime > t) { + logger(`[sampler] still loading sound "${s}:${n}"`, 'highlight'); + // console.warn('sample still loading:', s, n); + return; + } + if (!bufferSource) { + logger(`[sampler] could not load "${s}:${n}"`, 'error'); + return; + } + + // vibrato + let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, t); + + const time = t + nudge; + bufferSource.start(time, offset); + + const envGain = ac.createGain(); + const node = bufferSource.connect(envGain); + + // if none of these controls is set, the duration of the sound will be set to the duration of the sample slice + if (clip == null && loop == null && value.release == null) { + duration = sliceDuration; + } + let holdEnd = t + duration; + + 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 () { + bufferSource.disconnect(); + vibratoOscillator?.stop(); + node.disconnect(); + out.disconnect(); + onended(); + }; + let envEnd = holdEnd + release + 0.01; + bufferSource.stop(envEnd); + const stop = (endTime) => { + bufferSource.stop(endTime); + }; + const handle = { node: out, bufferSource, stop }; + + // cut groups + if (cut !== undefined) { + const prev = cutGroups[cut]; + if (prev) { + prev.node.gain.setValueAtTime(1, time); + prev.node.gain.linearRampToValueAtTime(0, time + 0.01); + } + cutGroups[cut] = handle; + } + + return handle; +} + +function registerSample(key, bank, params) { + registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank), { + type: 'sample', + samples: bank, + ...params, + }); +} + +export function registerSampleSource(key, bank, params) { + const isWavetable = key.startsWith('wt_'); + if (isWavetable) { + registerWaveTable(key, bank, params); + } else { + registerSample(key, bank, params); + } +} diff --git a/src/strudel/superdough/superdough.mjs b/src/strudel/superdough/superdough.mjs new file mode 100644 index 0000000..a37504b --- /dev/null +++ b/src/strudel/superdough/superdough.mjs @@ -0,0 +1,739 @@ +/* +superdough.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +import './feedbackdelay.mjs'; +import './reverb.mjs'; +import './vowel.mjs'; +import { nanFallback, _mod, cycleToSeconds } from './util.mjs'; +import workletsUrl from './worklets.mjs?audioworklet'; +import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend } from './helpers.mjs'; +import { map } from 'nanostores'; +import { logger } from './logger.mjs'; +import { loadBuffer } from './sampler.mjs'; +import { getAudioContext } from './audioContext.mjs'; +import { SuperdoughAudioController } from './superdoughoutput.mjs'; + +export const DEFAULT_MAX_POLYPHONY = 128; +const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard'; + +let maxPolyphony = DEFAULT_MAX_POLYPHONY; + +export function setMaxPolyphony(polyphony) { + maxPolyphony = parseInt(polyphony) ?? DEFAULT_MAX_POLYPHONY; +} + +let multiChannelOrbits = false; +export function setMultiChannelOrbits(bool) { + multiChannelOrbits = bool == true; +} + +export const soundMap = map(); + +export function registerSound(key, onTrigger, data = {}) { + key = key.toLowerCase().replace(/\s+/g, '_'); + soundMap.setKey(key, { onTrigger, data }); +} + +let gainCurveFunc = (val) => val; + +export function applyGainCurve(val) { + return gainCurveFunc(val); +} + +export function setGainCurve(newGainCurveFunc) { + gainCurveFunc = newGainCurveFunc; +} + +function aliasBankMap(aliasMap) { + // Make all bank keys lower case for case insensitivity + for (const key in aliasMap) { + aliasMap[key.toLowerCase()] = aliasMap[key]; + } + + // Look through every sound... + const soundDictionary = soundMap.get(); + for (const key in soundDictionary) { + // Check if the sound is part of a bank... + const [bank, suffix] = key.split('_'); + if (!suffix) continue; + + // Check if the bank is aliased... + const aliasValue = aliasMap[bank]; + if (aliasValue) { + if (typeof aliasValue === 'string') { + // Alias a single alias + soundDictionary[`${aliasValue}_${suffix}`.toLowerCase()] = soundDictionary[key]; + } else if (Array.isArray(aliasValue)) { + // Alias multiple aliases + for (const alias of aliasValue) { + soundDictionary[`${alias}_${suffix}`.toLowerCase()] = soundDictionary[key]; + } + } + } + } + + // Update the sound map! + // We need to destructure here to trigger the update + soundMap.set({ ...soundDictionary }); +} + +async function aliasBankPath(path) { + const response = await fetch(path); + const aliasMap = await response.json(); + aliasBankMap(aliasMap); +} + +/** + * Register an alias for a bank of sounds. + * Optionally accepts a single argument map of bank aliases. + * Optionally accepts a single argument string of a path to a JSON file containing bank aliases. + * @param {string} bank - The bank to alias + * @param {string} alias - The alias to use for the bank + */ +export async function aliasBank(...args) { + switch (args.length) { + case 1: + if (typeof args[0] === 'string') { + return aliasBankPath(args[0]); + } else { + return aliasBankMap(args[0]); + } + case 2: + return aliasBankMap({ [args[0]]: args[1] }); + default: + throw new Error('aliasMap expects 1 or 2 arguments, received ' + args.length); + } +} + +/** + * Register an alias for a sound. + * @param {string} original - The original sound name + * @param {string} alias - The alias to use for the sound + */ +export function soundAlias(original, alias) { + if (getSound(original) == null) { + logger('soundAlias: original sound not found'); + return; + } + soundMap.setKey(alias, getSound(original)); +} + +export function getSound(s) { + if (typeof s !== 'string') { + console.warn(`getSound: expected string got "${s}". fall back to triangle`); + return soundMap.get().triangle; // is this good? + } + return soundMap.get()[s.toLowerCase()]; +} + +export const getAudioDevices = async () => { + await navigator.mediaDevices.getUserMedia({ audio: true }); + let mediaDevices = await navigator.mediaDevices.enumerateDevices(); + mediaDevices = mediaDevices.filter((device) => device.kind === 'audiooutput' && device.deviceId !== 'default'); + const devicesMap = new Map(); + devicesMap.set(DEFAULT_AUDIO_DEVICE_NAME, ''); + mediaDevices.forEach((device) => { + devicesMap.set(device.label, device.deviceId); + }); + return devicesMap; +}; + +let defaultDefaultValues = { + s: 'triangle', + gain: 0.8, + postgain: 1, + density: '.03', + ftype: '12db', + fanchor: 0, + resonance: 1, + hresonance: 1, + bandq: 1, + channels: [1, 2], + phaserdepth: 0.75, + shapevol: 1, + distortvol: 1, + distorttype: 0, + delay: 0, + byteBeatExpression: '0', + delayfeedback: 0.5, + delaysync: 3 / 16, + orbit: 1, + i: 1, + velocity: 1, + fft: 8, +}; + +const defaultDefaultDefaultValues = Object.freeze({ ...defaultDefaultValues }); + +export function setDefault(control, value) { + // const main = getControlName(control); // we cant do this because superdough is independent of strudel/core + defaultDefaultValues[control] = value; +} + +export function resetDefaults() { + defaultDefaultValues = { ...defaultDefaultDefaultValues }; +} + +let defaultControls = new Map(Object.entries(defaultDefaultValues)); + +export function setDefaultValue(key, value) { + defaultControls.set(key, value); +} +export function getDefaultValue(key) { + return defaultControls.get(key); +} +export function setDefaultValues(defaultsobj) { + Object.keys(defaultsobj).forEach((key) => { + setDefaultValue(key, defaultsobj[key]); + }); +} +export function resetDefaultValues() { + defaultControls = new Map(Object.entries(defaultDefaultValues)); +} +export function setVersionDefaults(version) { + resetDefaultValues(); + if (version === '1.0') { + setDefaultValue('fanchor', 0.5); + } +} + +export const resetLoadedSounds = () => soundMap.set({}); + +let externalWorklets = []; +export function registerWorklet(url) { + externalWorklets.push(url); +} + +let workletsLoading; +function loadWorklets() { + if (!workletsLoading) { + const audioCtx = getAudioContext(); + const allWorkletURLs = externalWorklets.concat([workletsUrl]); + workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL))); + } + + return workletsLoading; +} + +// this function should be called on first user interaction (to avoid console warning) +export async function initAudio(options = {}) { + const { + disableWorklets = false, + maxPolyphony, + audioDeviceName = DEFAULT_AUDIO_DEVICE_NAME, + multiChannelOrbits = false, + } = options; + + setMaxPolyphony(maxPolyphony); + setMultiChannelOrbits(multiChannelOrbits); + if (typeof window === 'undefined') { + return; + } + + const audioCtx = getAudioContext(); + + if (audioDeviceName != null && audioDeviceName != DEFAULT_AUDIO_DEVICE_NAME) { + try { + const devices = await getAudioDevices(); + const id = devices.get(audioDeviceName); + const isValidID = (id ?? '').length > 0; + if (audioCtx.sinkId !== id && isValidID) { + await audioCtx.setSinkId(id); + } + logger( + `[superdough] Audio Device set to ${audioDeviceName}, it might take a few seconds before audio plays on all output channels`, + ); + } catch { + logger('[superdough] failed to set audio interface', 'warning'); + } + } + + await audioCtx.resume(); + if (disableWorklets) { + logger('[superdough]: AudioWorklets disabled with disableWorklets'); + return; + } + try { + await loadWorklets(); + logger('[superdough] AudioWorklets loaded'); + } catch (err) { + console.warn('could not load AudioWorklet effects', err); + } + logger('[superdough] ready'); +} +let audioReady; +export async function initAudioOnFirstClick(options) { + if (!audioReady) { + audioReady = new Promise((resolve) => { + document.addEventListener('click', async function listener() { + document.removeEventListener('click', listener); + await initAudio(options); + resolve(); + }); + }); + } + return audioReady; +} + +let controller; +function getSuperdoughAudioController() { + if (controller == null) { + controller = new SuperdoughAudioController(getAudioContext()); + } + return controller; +} +export function connectToDestination(input, channels) { + const controller = getSuperdoughAudioController(); + controller.output.connectToDestination(input, channels); +} + +function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) { + const ac = getAudioContext(); + const lfoGain = getLfo(ac, time, end, { frequency, depth: sweep * 2 }); + + //filters + const numStages = 2; //num of filters in series + let fOffset = 0; + const filterChain = []; + for (let i = 0; i < numStages; i++) { + const filter = ac.createBiquadFilter(); + filter.type = 'notch'; + filter.gain.value = 1; + filter.frequency.value = centerFrequency + fOffset; + filter.Q.value = 2 - Math.min(Math.max(depth * 2, 0), 1.9); + + lfoGain.connect(filter.detune); + fOffset += 282; + if (i > 0) { + filterChain[i - 1].connect(filter); + } + filterChain.push(filter); + } + return filterChain[filterChain.length - 1]; +} + +function getFilterType(ftype) { + ftype = ftype ?? 0; + const filterTypes = ['12db', 'ladder', '24db']; + return typeof ftype === 'number' ? filterTypes[Math.floor(_mod(ftype, filterTypes.length))] : ftype; +} + +export let analysers = {}, + analysersData = {}; + +export function getAnalyserById(id, fftSize = 1024, smoothingTimeConstant = 0.5) { + if (!analysers[id]) { + // make sure this doesn't happen too often as it piles up garbage + const analyserNode = getAudioContext().createAnalyser(); + analyserNode.fftSize = fftSize; + analyserNode.smoothingTimeConstant = smoothingTimeConstant; + // getDestination().connect(analyserNode); + analysers[id] = analyserNode; + analysersData[id] = new Float32Array(analysers[id].frequencyBinCount); + } + if (analysers[id].fftSize !== fftSize) { + analysers[id].fftSize = fftSize; + analysersData[id] = new Float32Array(analysers[id].frequencyBinCount); + } + return analysers[id]; +} + +export function getAnalyzerData(type = 'time', id = 1) { + const getter = { + time: () => analysers[id]?.getFloatTimeDomainData(analysersData[id]), + frequency: () => analysers[id]?.getFloatFrequencyData(analysersData[id]), + }[type]; + if (!getter) { + throw new Error(`getAnalyzerData: ${type} not supported. use one of ${Object.keys(getter).join(', ')}`); + } + getter(); + return analysersData[id]; +} + +export function resetGlobalEffects() { + controller?.reset(); + analysers = {}; + analysersData = {}; +} + +let activeSoundSources = new Map(); +//music programs/audio gear usually increments inputs/outputs from 1, we need to subtract 1 from the input because the webaudio API channels start at 0 + +function mapChannelNumbers(channels) { + return (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1); +} + +export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) => { + // new: t is always expected to be the absolute target onset time + const ac = getAudioContext(); + const audioController = getSuperdoughAudioController(); + + let { stretch } = value; + if (stretch != null) { + //account for phase vocoder latency + const latency = 0.04; + t = t - latency; + } + if (typeof value !== 'object') { + throw new Error( + `expected hap.value to be an object, but got "${value}". Hint: append .note() or .s() to the end`, + 'error', + ); + } + + // duration is passed as value too.. + value.duration = hapDuration; + // calculate absolute time + + if (t < ac.currentTime) { + console.warn( + `[superdough]: cannot schedule sounds in the past (target: ${t.toFixed(2)}, now: ${ac.currentTime.toFixed(2)})`, + ); + return; + } + // destructure + let { + tremolo, + tremolosync, + tremolodepth = 1, + tremoloskew, + tremolophase = 0, + tremoloshape, + s = getDefaultValue('s'), + bank, + source, + gain = getDefaultValue('gain'), + postgain = getDefaultValue('postgain'), + density = getDefaultValue('density'), + duckorbit, + duckonset, + duckattack, + duckdepth, + djf, + // filters + fanchor = getDefaultValue('fanchor'), + drive = 0.69, + release = 0, + // low pass + cutoff, + lpenv, + lpattack, + lpdecay, + lpsustain, + lprelease, + resonance = getDefaultValue('resonance'), + // high pass + hpenv, + hcutoff, + hpattack, + hpdecay, + hpsustain, + hprelease, + hresonance = getDefaultValue('hresonance'), + // band pass + bpenv, + bandf, + bpattack, + bpdecay, + bpsustain, + bprelease, + bandq = getDefaultValue('bandq'), + + //phaser + phaserrate: phaser, + phaserdepth = getDefaultValue('phaserdepth'), + phasersweep, + phasercenter, + // + coarse, + + crush, + dry, + shape, + shapevol = getDefaultValue('shapevol'), + distort, + distortvol = getDefaultValue('distortvol'), + distorttype = getDefaultValue('distorttype'), + pan, + vowel, + delay = getDefaultValue('delay'), + delayfeedback = getDefaultValue('delayfeedback'), + delaysync = getDefaultValue('delaysync'), + delaytime, + orbit = getDefaultValue('orbit'), + room, + roomfade, + roomlp, + roomdim, + roomsize, + ir, + irspeed, + irbegin, + i = getDefaultValue('i'), + velocity = getDefaultValue('velocity'), + analyze, // analyser wet + fft = getDefaultValue('fft'), // fftSize 0 - 10 + compressor: compressorThreshold, + compressorRatio, + compressorKnee, + compressorAttack, + compressorRelease, + } = value; + + delaytime = delaytime ?? cycleToSeconds(delaysync, cps); + + const orbitChannels = mapChannelNumbers( + multiChannelOrbits && orbit > 0 ? [orbit * 2 - 1, orbit * 2] : getDefaultValue('channels'), + ); + + const channels = value.channels != null ? mapChannelNumbers(value.channels) : orbitChannels; + const orbitBus = audioController.getOrbit(orbit, channels); + if (duckorbit != null) { + audioController.duck(duckorbit, t, duckonset, duckattack, duckdepth); + } + + gain = applyGainCurve(nanFallback(gain, 1)); + postgain = applyGainCurve(postgain); + shapevol = applyGainCurve(shapevol); + distortvol = applyGainCurve(distortvol); + delay = applyGainCurve(delay); + velocity = applyGainCurve(velocity); + tremolodepth = applyGainCurve(tremolodepth); + gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future + + const end = t + hapDuration; + const endWithRelease = end + release; + const chainID = Math.round(Math.random() * 1000000); + + // oldest audio nodes will be destroyed if maximum polyphony is exceeded + for (let i = 0; i <= activeSoundSources.size - maxPolyphony; i++) { + const ch = activeSoundSources.entries().next(); + const source = ch.value[1]; + const chainID = ch.value[0]; + const endTime = t + 0.25; + source?.node?.gain?.linearRampToValueAtTime(0, endTime); + source?.stop?.(endTime); + activeSoundSources.delete(chainID); + } + + let audioNodes = []; + + if (['-', '~', '_'].includes(s)) { + return; + } + if (bank && s) { + s = `${bank}_${s}`; + value.s = s; + } + + // get source AudioNode + let sourceNode; + if (source) { + sourceNode = source(t, value, hapDuration, cps); + } else if (getSound(s)) { + const { onTrigger } = getSound(s); + const onEnded = () => { + audioNodes.forEach((n) => n?.disconnect()); + activeSoundSources.delete(chainID); + }; + const soundHandle = await onTrigger(t, value, onEnded, cps); + + if (soundHandle) { + sourceNode = soundHandle.node; + activeSoundSources.set(chainID, soundHandle); + } + } else { + throw new Error(`sound ${s} not found! Is it loaded?`); + } + if (!sourceNode) { + // if onTrigger does not return anything, we will just silently skip + // this can be used for things like speed(0) in the sampler + return; + } + + if (ac.currentTime > t) { + logger('[webaudio] skip hap: still loading', ac.currentTime - t); + return; + } + const chain = []; // audio nodes that will be connected to each other sequentially + chain.push(sourceNode); + stretch !== undefined && chain.push(getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: stretch })); + + // gain stage + chain.push(gainNode(gain)); + + //filter + const ftype = getFilterType(value.ftype); + if (cutoff !== undefined) { + let lp = () => + createFilter( + ac, + 'lowpass', + cutoff, + resonance, + lpattack, + lpdecay, + lpsustain, + lprelease, + lpenv, + t, + end, + fanchor, + ftype, + drive, + ); + chain.push(lp()); + if (ftype === '24db') { + chain.push(lp()); + } + } + + if (hcutoff !== undefined) { + let hp = () => + createFilter( + ac, + 'highpass', + hcutoff, + hresonance, + hpattack, + hpdecay, + hpsustain, + hprelease, + hpenv, + t, + end, + fanchor, + ); + chain.push(hp()); + if (ftype === '24db') { + chain.push(hp()); + } + } + + if (bandf !== undefined) { + let bp = () => + createFilter(ac, 'bandpass', bandf, bandq, bpattack, bpdecay, bpsustain, bprelease, bpenv, t, end, fanchor); + chain.push(bp()); + if (ftype === '24db') { + chain.push(bp()); + } + } + + if (vowel !== undefined) { + const vowelFilter = ac.createVowelFilter(vowel); + chain.push(vowelFilter); + } + + // effects + coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse })); + crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush })); + distort !== undefined && chain.push(getDistortion(distort, distortvol, distorttype)); + + if (tremolosync != null) { + tremolo = cps * tremolosync; + } + + if (value.wtPosSynced != null) { + value.wtPosRate /= cps; + } + + if (value.wtWarpSynced != null) { + value.wtWarpRate /= cps; + } + + if (tremolo !== undefined) { + // Allow clipping of modulator for more dynamic possiblities, and to prevent speaker overload + // EX: a triangle waveform will clip like this /-\ when the depth is above 1 + const gain = Math.max(1 - tremolodepth, 0); + const amGain = new GainNode(ac, { gain }); + + const time = cycle / cps; + const lfo = getLfo(ac, t, endWithRelease, { + skew: tremoloskew ?? (tremoloshape != null ? 0.5 : 1), + frequency: tremolo, + depth: tremolodepth, + time, + dcoffset: 0, + shape: tremoloshape, + phaseoffset: tremolophase, + min: 0, + max: 1, + curve: 1.5, + }); + lfo.connect(amGain.gain); + chain.push(amGain); + } + + compressorThreshold !== undefined && + chain.push( + getCompressor(ac, compressorThreshold, compressorRatio, compressorKnee, compressorAttack, compressorRelease), + ); + + // panning + if (pan !== undefined) { + const panner = ac.createStereoPanner(); + panner.pan.value = 2 * pan - 1; + chain.push(panner); + } + // phaser + if (phaser !== undefined && phaserdepth > 0) { + const phaserFX = getPhaser(t, endWithRelease, phaser, phaserdepth, phasercenter, phasersweep); + chain.push(phaserFX); + } + + // last gain + const post = new GainNode(ac, { gain: postgain }); + chain.push(post); + + // delay + if (delay > 0 && delaytime > 0 && delayfeedback > 0) { + orbitBus.getDelay(delaytime, delayfeedback, t); + orbitBus.sendDelay(post, delay); + } + // reverb + if (room > 0) { + let roomIR; + if (ir !== undefined) { + let url; + let sample = getSound(ir); + if (Array.isArray(sample)) { + url = sample.data.samples[i % sample.data.samples.length]; + } else if (typeof sample === 'object') { + url = Object.values(sample.data.samples).flat()[i % Object.values(sample.data.samples).length]; + } + roomIR = await loadBuffer(url, ac, ir, 0); + } + orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin); + orbitBus.sendReverb(post, room); + } + + if (djf != null) { + orbitBus.getDjf(djf, t); + } + + // analyser + if (analyze) { + const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5)); + const analyserSend = effectSend(post, analyserNode, 1); + audioNodes.push(analyserSend); + } + if (dry != null) { + dry = applyGainCurve(dry); + const dryGain = new GainNode(ac, { gain: dry }); + chain.push(dryGain); + orbitBus.connectToOutput(dryGain); + } else { + orbitBus.connectToOutput(post); + } + + // connect chain elements together + chain.slice(1).reduce((last, current) => last.connect(current), chain[0]); + audioNodes = audioNodes.concat(chain); +}; + +export const superdoughTrigger = (t, hap, ct, cps) => { + superdough(hap, t - ct, hap.duration / cps, cps); +}; diff --git a/src/strudel/superdough/superdoughoutput.mjs b/src/strudel/superdough/superdoughoutput.mjs new file mode 100644 index 0000000..afe91e7 --- /dev/null +++ b/src/strudel/superdough/superdoughoutput.mjs @@ -0,0 +1,209 @@ +import { effectSend, getWorklet, webAudioTimeout } from './helpers.mjs'; +import { errorLogger } from './logger.mjs'; +import { clamp } from './util.mjs'; + +let hasChanged = (now, before) => now !== undefined && now !== before; + +export class Orbit { + reverbNode; + delayNode; + output; + summingNode; + djfNode; + audioContext; + constructor(audioContext) { + this.audioContext = audioContext; + this.output = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); + this.summingNode = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); + this.summingNode.connect(this.output); + } + + disconnect() { + this.output.disconnect(); + this.summingNode.disconnect(); + this.delayNode?.disconnect(); + this.reverbNode?.disconnect(); + } + + getDjf(value, t = 0) { + if (this.djfNode == null) { + this.djfNode = getWorklet(this.audioContext, 'djf-processor', { value }); + this.summingNode.disconnect(); + this.summingNode.connect(this.djfNode); + this.djfNode.connect(this.output); + } + const val = this.djfNode.parameters.get('value'); + val.setValueAtTime(value, t); + } + + getDelay(delaytime = 0, feedback = 0.5, t) { + const maxfeedback = 0.98; + if (feedback > maxfeedback) { + //logger(`feedback was clamped to ${maxfeedback} to save your ears`); + } + feedback = clamp(feedback, 0, 0.98); + if (this.delayNode == null) { + this.delayNode = this.audioContext.createFeedbackDelay(1, delaytime, feedback); + this.delayNode.connect(this.summingNode); + this.delayNode.start?.(t); // for some reason, this throws when audion extension is installed.. + } + this.delayNode.delayTime.value !== delaytime && this.delayNode.delayTime.setValueAtTime(delaytime, t); + this.delayNode.feedback.value !== feedback && this.delayNode.feedback.setValueAtTime(feedback, t); + return this.delayNode; + } + + getReverb(duration, fade, lp, dim, ir, irspeed, irbegin) { + // If no reverb has been created for a given orbit, create one + if (this.reverbNode == null) { + this.reverbNode = this.audioContext.createReverb(duration, fade, lp, dim, ir, irspeed, irbegin); + this.reverbNode.connect(this.summingNode); + } + + if ( + hasChanged(duration, this.reverbNode.duration) || + hasChanged(fade, this.reverbNode.fade) || + hasChanged(lp, this.reverbNode.lp) || + hasChanged(dim, this.reverbNode.dim) || + hasChanged(irspeed, this.reverbNode.irspeed) || + hasChanged(irbegin, this.reverbNode.irbegin) || + this.reverbNode.ir !== ir + ) { + // only regenerate when something has changed + // avoids endless regeneration on things like + // stack(s("a"), s("b").rsize(8)).room(.5) + // this only works when args may stay undefined until here + // setting default values breaks this + this.reverbNode.generate(duration, fade, lp, dim, ir, irspeed, irbegin); + } + return this.reverbNode; + } + sendReverb(node, amount) { + effectSend(node, this.reverbNode, amount); + } + + sendDelay(node, amount) { + effectSend(node, this.delayNode, amount); + } + + duck(t, onsettime = 0, attacktime = 0.1, depth = 1) { + const onset = onsettime; + const attack = Math.max(attacktime, 0.002); + const gainParam = this.output.gain; + webAudioTimeout( + this.audioContext, + () => { + const now = this.audioContext.currentTime; + + // cancelScheduledValues and setValueAtTime together emulate cancelAndHoldAtTime + // on browsers which lack that method + const currVal = gainParam.value; + gainParam.cancelScheduledValues(now); + gainParam.setValueAtTime(currVal, now); + + const t0 = Math.max(t, now); // guard against now > t + const duckedVal = clamp(1 - Math.sqrt(depth), 0.01, currVal); + gainParam.exponentialRampToValueAtTime(duckedVal, t0 + onset); + gainParam.exponentialRampToValueAtTime(1, t0 + onset + attack); + }, + 0, + t - 0.01, + ); + } + + connectToOutput(node) { + node.connect(this.summingNode); + } +} + +export class SuperdoughOutput { + channelMerger; + destinationGain; + + constructor(audioContext) { + this.audioContext = audioContext; + this.initializeAudio(); + } + + initializeAudio() { + const audioContext = this.audioContext; + const maxChannelCount = audioContext.destination.maxChannelCount; + this.audioContext.destination.channelCount = maxChannelCount; + this.channelMerger = new ChannelMergerNode(audioContext, { numberOfInputs: audioContext.destination.channelCount }); + this.destinationGain = new GainNode(audioContext); + this.channelMerger.connect(this.destinationGain); + this.destinationGain.connect(audioContext.destination); + } + + reset() { + this.disconnect(); + this.initializeAudio(); + } + disconnect() { + this.channelMerger.disconnect(); + this.destinationGain.disconnect(); + this.destinationGain = null; + this.channelMerger = null; + } + connectToDestination = (input, channels = [0, 1]) => { + //This upmix can be removed if correct channel counts are set throughout the app, + // and then strudel could theoretically support surround sound audio files + const stereoMix = new StereoPannerNode(this.audioContext); + input.connect(stereoMix); + + const splitter = new ChannelSplitterNode(this.audioContext, { + numberOfOutputs: stereoMix.channelCount, + }); + stereoMix.connect(splitter); + channels.forEach((ch, i) => { + splitter.connect(this.channelMerger, i % stereoMix.channelCount, ch % this.audioContext.destination.channelCount); + }); + }; +} + +export class SuperdoughAudioController { + audioContext; + output; + nodes = {}; + + constructor(audioContext) { + this.audioContext = audioContext; + this.output = new SuperdoughOutput(audioContext); + } + + reset() { + Array.from(this.nodes).forEach((node) => { + node.disconnect(); + }); + this.nodes = {}; + this.output.reset(); + } + + duck(targetOrbits, t, onsettime = 0, attacktime = 0.1, depth = 1) { + const targetArr = [targetOrbits].flat(); + const onsetArr = [onsettime].flat(); + const attackArr = [attacktime].flat(); + const depthArr = [depth].flat(); + + targetArr.forEach((target, idx) => { + const orbit = this.nodes[target]; + + if (orbit == null) { + errorLogger(new Error(`duck target orbit ${target} does not exist`), 'superdough'); + return; + } + const onset = onsetArr[idx] ?? onsetArr[0]; + const attack = Math.max(attackArr[idx] ?? attackArr[0], 0.002); + const depth = depthArr[idx] ?? depthArr[0]; + + orbit.duck(t, onset, attack, depth); + }); + } + + getOrbit(orbitNum, channels) { + if (this.nodes[orbitNum] == null) { + this.nodes[orbitNum] = new Orbit(this.audioContext); + this.output.connectToDestination(this.nodes[orbitNum].output, channels); + } + return this.nodes[orbitNum]; + } +} diff --git a/src/strudel/superdough/synth.mjs b/src/strudel/superdough/synth.mjs new file mode 100644 index 0000000..e35b988 --- /dev/null +++ b/src/strudel/superdough/synth.mjs @@ -0,0 +1,488 @@ +import { clamp } from './util.mjs'; +import { registerSound, soundMap } from './superdough.mjs'; +import { getAudioContext } from './audioContext.mjs'; +import { + applyFM, + destroyAudioWorkletNode, + gainNode, + getADSRValues, + getFrequencyFromValue, + getLfo, + getParamADSR, + getPitchEnvelope, + getVibratoOscillator, + getWorklet, + noises, + webAudioTimeout, +} from './helpers.mjs'; +import { getNoiseMix, getNoiseOscillator } from './noise.mjs'; + +const waveforms = ['triangle', 'square', 'sawtooth', 'sine']; +const waveformAliases = [ + ['tri', 'triangle'], + ['sqr', 'square'], + ['saw', 'sawtooth'], + ['sin', 'sine'], +]; + +function makeSaturationCurve(amount, n_samples) { + const k = typeof amount === 'number' ? amount : 50; + const curve = new Float32Array(n_samples); + + for (let i = 0; i < n_samples; i++) { + const x = (i * 2) / n_samples - 1; + curve[i] = Math.tanh(x * k); + } + return curve; +} + +export function registerSynthSounds() { + [...waveforms].forEach((s) => { + registerSound( + s, + (t, value, onended) => { + 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 = getOscillator(s, t, value); + let { node: o, stop, triggerRelease } = sound; + + // turn down + const g = gainNode(0.3); + + 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, + stop: (endTime) => { + stop(endTime); + }, + }; + }, + { type: 'synth', prebake: true }, + ); + }); + + registerSound( + 'sbd', + (t, value, onended) => { + const { duration, decay = 0.5, pdecay = 0.5, penv = 36, clip } = value; + const ctx = getAudioContext(); + const attackhold = 0.02; + const noiselvl = 1.2; + const noisedecay = 0.025; + const mixGain = 1; + + const o = ctx.createOscillator(); + o.type = 'triangle'; + o.frequency.value = getFrequencyFromValue(value, 29); + o.detune.setValueAtTime(penv * 100, 0); + o.detune.setValueAtTime(penv * 100, t); + o.detune.exponentialRampToValueAtTime(0.001, t + pdecay); + const g = gainNode(1); + g.gain.setValueAtTime(1, t + attackhold); + g.gain.exponentialRampToValueAtTime(0.001, t + attackhold + decay); + o.start(t); + + const noise = getNoiseOscillator('brown', t, 2); + const noiseGain = gainNode(1); + noiseGain.gain.setValueAtTime(noiselvl, t); + noiseGain.gain.exponentialRampToValueAtTime(0.001, t + noisedecay); + + const sat = new WaveShaperNode(ctx); + // tri to sine diode shaper emulation + sat.curve = makeSaturationCurve(2, ctx.sampleRate); + + const mix = gainNode(mixGain); + + o.onended = () => { + o.disconnect(); + g.disconnect(); + sat.disconnect(); + noise.node.disconnect(); + noiseGain.disconnect(); + mix.disconnect(); + onended(); + }; + + const node = o.connect(sat).connect(g).connect(mix); + noise.node.connect(noiseGain).connect(mix); + + const holdEnd = t + decay; + let end = holdEnd + 0.01; + if (clip != null) { + end = Math.min(t + clip * duration, end); + } + + // prevent clicking + mix.gain.setValueAtTime(mixGain, end - 0.01); + mix.gain.linearRampToValueAtTime(0, end); + + o.stop(end); + noise.stop(end); + + return { + node, + stop: (endTime) => { + o.stop(endTime); + }, + }; + }, + { type: 'synth', prebake: true }, + ); + + registerSound( + 'supersaw', + (begin, value, onended) => { + const ac = getAudioContext(); + let { duration, n, unison = 5, spread = 0.6, detune } = value; + detune = detune ?? n ?? 0.18; + const frequency = getFrequencyFromValue(value); + + const [attack, decay, sustain, release] = getADSRValues( + [value.attack, value.decay, value.sustain, value.release], + 'linear', + [0.001, 0.05, 0.6, 0.01], + ); + + const holdend = begin + duration; + const end = holdend + release + 0.01; + const voices = clamp(unison, 1, 100); + let panspread = voices > 1 ? clamp(spread, 0, 1) : 0; + let o = getWorklet( + ac, + 'supersaw-oscillator', + { + frequency, + begin, + end, + freqspread: detune, + voices, + panspread, + }, + { + outputChannelCount: [2], + }, + ); + + const gainAdjustment = 1 / Math.sqrt(voices); + getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend); + const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin); + const fm = applyFM(o.parameters.get('frequency'), value, begin); + let envGain = gainNode(1); + envGain = o.connect(envGain); + + getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 0.3 * gainAdjustment, begin, holdend, 'linear'); + + let timeoutNode = webAudioTimeout( + ac, + () => { + destroyAudioWorkletNode(o); + envGain.disconnect(); + onended(); + fm?.stop(); + vibratoOscillator?.stop(); + }, + begin, + end, + ); + + return { + node: envGain, + stop: (time) => { + timeoutNode.stop(time); + }, + }; + }, + { prebake: true, type: 'synth' }, + ); + + registerSound( + 'bytebeat', + (begin, value, onended) => { + const defaultBeats = [ + '(t%255 >= t/255%255)*255', + '(t*(t*8%60 <= 300)|(-t)*(t*4%512 < 256))+t/400', + 't', + 't*(t >> 10^t)', + 't&128', + 't&t>>8', + '((t%255+t%128+t%64+t%32+t%16+t%127.8+t%64.8+t%32.8+t%16.8)/3)', + '((t%64+t%63.8+t%64.15+t%64.35+t%63.5)/1.25)', + '(t&(t>>7)-t)', + '(sin(t*PI/128)*127+127)', + '((t^t/2+t+64*(sin((t*PI/64)+(t*PI/32768))+64))%128*2)', + '((t^t/2+t+64*(cos >> 0))%127.85*2)', + '((t^t/2+t+64)%128*2)', + '(((t * .25)^(t * .25)/100+(t * .25))%128)*2', + '((t^t/2+t+64)%7 * 24)', + ]; + const { n = 0 } = value; + const frequency = getFrequencyFromValue(value); + const { byteBeatExpression = defaultBeats[n % defaultBeats.length], byteBeatStartTime } = value; + + const ac = getAudioContext(); + + let { duration } = value; + const [attack, decay, sustain, release] = getADSRValues( + [value.attack, value.decay, value.sustain, value.release], + 'linear', + [0.001, 0.05, 0.6, 0.01], + ); + const holdend = begin + duration; + const end = holdend + release + 0.01; + + let o = getWorklet( + ac, + 'byte-beat-processor', + { + frequency, + begin, + end, + }, + { + outputChannelCount: [2], + }, + ); + + o.port.postMessage({ codeText: byteBeatExpression, byteBeatStartTime, frequency }); + + let envGain = gainNode(1); + envGain = o.connect(envGain); + + getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear'); + + let timeoutNode = webAudioTimeout( + ac, + () => { + destroyAudioWorkletNode(o); + envGain.disconnect(); + onended(); + }, + begin, + end, + ); + + return { + node: envGain, + stop: (time) => { + timeoutNode.stop(time); + }, + }; + }, + { prebake: true, type: 'synth' }, + ); + + registerSound( + 'pulse', + (begin, value, onended) => { + const ac = getAudioContext(); + let { pwrate, pwsweep } = value; + if (pwsweep == null) { + if (pwrate != null) { + pwsweep = 0.3; + } else { + pwsweep = 0; + } + } + + if (pwrate == null && pwsweep != null) { + pwrate = 1; + } + + let { duration, pw: pulsewidth = 0.5 } = value; + const frequency = getFrequencyFromValue(value); + + const [attack, decay, sustain, release] = getADSRValues( + [value.attack, value.decay, value.sustain, value.release], + 'linear', + [0.001, 0.05, 0.6, 0.01], + ); + const holdend = begin + duration; + const end = holdend + release + 0.01; + let o = getWorklet( + ac, + 'pulse-oscillator', + { + frequency, + begin, + end, + pulsewidth, + }, + { + outputChannelCount: [2], + }, + ); + + getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend); + const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin); + const fm = applyFM(o.parameters.get('frequency'), value, begin); + let envGain = gainNode(1); + envGain = o.connect(envGain); + + getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear'); + let lfo; + if (pwsweep != 0) { + lfo = getLfo(ac, begin, end, { frequency: pwrate, depth: pwsweep }); + lfo.connect(o.parameters.get('pulsewidth')); + } + let timeoutNode = webAudioTimeout( + ac, + () => { + destroyAudioWorkletNode(o); + destroyAudioWorkletNode(lfo); + envGain.disconnect(); + onended(); + fm?.stop(); + vibratoOscillator?.stop(); + }, + begin, + end, + ); + + return { + node: envGain, + stop: (time) => { + timeoutNode.stop(time); + }, + }; + }, + { prebake: true, type: 'synth' }, + ); + + [...noises].forEach((s) => { + registerSound( + s, + (t, value, onended) => { + 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; + + let { density } = value; + sound = getNoiseOscillator(s, t, density); + + let { node: o, stop, triggerRelease } = sound; + + // turn down + const g = gainNode(0.3); + + 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, + stop: (endTime) => { + stop(endTime); + }, + }; + }, + { type: 'synth', prebake: true }, + ); + }); + waveformAliases.forEach(([alias, actual]) => soundMap.set({ ...soundMap.get(), [alias]: soundMap.get()[actual] })); +} + +export function waveformN(partials, type) { + const real = new Float32Array(partials + 1); + const imag = new Float32Array(partials + 1); + const ac = getAudioContext(); + const osc = ac.createOscillator(); + + const terms = { + sawtooth: (n) => [0, -1 / n], + square: (n) => [0, n % 2 === 0 ? 0 : 1 / n], + triangle: (n) => [n % 2 === 0 ? 0 : 1 / (n * n), 0], + }; + + if (!terms[type]) { + throw new Error(`unknown wave type ${type}`); + } + + real[0] = 0; // dc offset + imag[0] = 0; + let n = 1; + while (n <= partials) { + const [r, i] = terms[type](n); + real[n] = r; + imag[n] = i; + n++; + } + + const wave = ac.createPeriodicWave(real, imag); + osc.setPeriodicWave(wave); + return osc; +} + +// expects one of waveforms as s +export function getOscillator(s, t, value) { + let { n: partials, duration, noise = 0 } = value; + let o; + // If no partials are given, use stock waveforms + if (!partials || s === 'sine') { + o = getAudioContext().createOscillator(); + o.type = s || 'triangle'; + } + // generate custom waveform if partials are given + else { + o = waveformN(partials, s); + } + // set frequency + o.frequency.value = getFrequencyFromValue(value); + o.start(t); + + let vibratoOscillator = getVibratoOscillator(o.detune, value, t); + + // pitch envelope + getPitchEnvelope(o.detune, value, t, t + duration); + const fmModulator = applyFM(o.frequency, value, t); + + let noiseMix; + if (noise) { + noiseMix = getNoiseMix(o, noise, t); + } + + return { + node: noiseMix?.node || o, + stop: (time) => { + fmModulator.stop(time); + vibratoOscillator?.stop(time); + noiseMix?.stop(time); + o.stop(time); + }, + triggerRelease: (time) => { + // envGain?.stop(time); + }, + }; +} diff --git a/src/strudel/superdough/util.mjs b/src/strudel/superdough/util.mjs new file mode 100644 index 0000000..475c05f --- /dev/null +++ b/src/strudel/superdough/util.mjs @@ -0,0 +1,107 @@ +import { logger } from './logger.mjs'; + +// currently duplicate with core util.mjs to skip dependency +// TODO: add separate util module? + +export const tokenizeNote = (note) => { + if (typeof note !== 'string') { + return []; + } + const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)(-?[0-9]*)$/)?.slice(1) || []; + if (!pc) { + return []; + } + return [pc, acc, oct ? Number(oct) : undefined]; +}; +const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +const accs = { '#': 1, b: -1, s: 1, f: -1 }; + +export const noteToMidi = (note, defaultOctave = 3) => { + const [pc, acc, oct = defaultOctave] = tokenizeNote(note); + if (!pc) { + throw new Error('not a note: "' + note + '"'); + } + const chroma = chromas[pc.toLowerCase()]; + const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0; + return (Number(oct) + 1) * 12 + chroma + offset; +}; +export const midiToFreq = (n) => { + return Math.pow(2, (n - 69) / 12) * 440; +}; +export const clamp = (num, min, max) => Math.min(Math.max(num, min), max); + +export const freqToMidi = (freq) => { + return (12 * Math.log(freq / 440)) / Math.LN2 + 69; +}; + +export const valueToMidi = (value, fallbackValue) => { + if (typeof value !== 'object') { + throw new Error('valueToMidi: expected object value'); + } + let { freq, note } = value; + if (typeof freq === 'number') { + return freqToMidi(freq); + } + if (typeof note === 'string') { + return noteToMidi(note); + } + if (typeof note === 'number') { + return note; + } + if (!fallbackValue) { + throw new Error('valueToMidi: expected freq or note to be set'); + } + return fallbackValue; +}; + +export function nanFallback(value, fallback = 0, silent) { + if (isNaN(Number(value))) { + !silent && logger(`"${value}" is not a number, falling back to ${fallback}`, 'warning'); + return fallback; + } + return value; +} +// 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; + +// round to nearest int, negative numbers will output a subtracted index +export const getSoundIndex = (n, numSounds) => { + return _mod(Math.round(nanFallback(n, 0)), numSounds); +}; + +export function cycleToSeconds(cycle, cps) { + return cycle / cps; +} + +export function secondsToCycle(t, cps) { + return t * cps; +} + +// deduces relevant info for sample loading from hap.value and sample definition +// it encapsulates the core sampler logic into a pure and synchronous function +// hapValue: Hap.value, bank: sample bank definition for sound "s" (values in strudel.json format) +export function getCommonSampleInfo(hapValue, bank) { + const { s, n = 0 } = hapValue; + let midi = valueToMidi(hapValue, 36); + let transpose = midi - 36; // C3 is middle C; + let url; + let index = 0; + if (Array.isArray(bank)) { + index = getSoundIndex(n, bank.length); + url = bank[index]; + } else { + const midiDiff = (noteA) => noteToMidi(noteA) - midi; + // object format will expect keys as notes + const closest = Object.keys(bank) + .filter((k) => !k.startsWith('_')) + .reduce( + (closest, key, j) => (!closest || Math.abs(midiDiff(key)) < Math.abs(midiDiff(closest)) ? key : closest), + null, + ); + transpose = -midiDiff(closest); // semitones to repitch + index = getSoundIndex(n, bank[closest].length); + url = bank[closest][index]; + } + const label = `${s}:${index}`; + return { transpose, url, index, midi, label }; +} diff --git a/src/strudel/superdough/vite.config.js b/src/strudel/superdough/vite.config.js new file mode 100644 index 0000000..10ac0f0 --- /dev/null +++ b/src/strudel/superdough/vite.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite'; +import { dependencies } from './package.json'; +import { resolve } from 'path'; +import bundleAudioWorkletPlugin from 'vite-plugin-bundle-audioworklet'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [bundleAudioWorkletPlugin()], + build: { + lib: { + entry: resolve(__dirname, 'index.mjs'), + formats: ['es'], + fileName: (ext) => ({ es: 'index.mjs', cjs: 'index.cjs' })[ext], + }, + rollupOptions: { + external: [...Object.keys(dependencies)], + }, + target: 'esnext', + }, +}); diff --git a/src/strudel/superdough/vowel.mjs b/src/strudel/superdough/vowel.mjs new file mode 100644 index 0000000..3f30aef --- /dev/null +++ b/src/strudel/superdough/vowel.mjs @@ -0,0 +1,69 @@ +// credits to webdirt: https://github.com/dktr0/WebDirt/blob/41342e81d6ad694a2310d491fef7b7e8b0929efe/js-src/Graph.js#L597 +export var vowelFormant = { + a: { freqs: [660, 1120, 2750, 3000, 3350], gains: [1, 0.5012, 0.0708, 0.0631, 0.0126], qs: [80, 90, 120, 130, 140] }, + e: { freqs: [440, 1800, 2700, 3000, 3300], gains: [1, 0.1995, 0.1259, 0.1, 0.1], qs: [70, 80, 100, 120, 120] }, + i: { freqs: [270, 1850, 2900, 3350, 3590], gains: [1, 0.0631, 0.0631, 0.0158, 0.0158], qs: [40, 90, 100, 120, 120] }, + o: { freqs: [430, 820, 2700, 3000, 3300], gains: [1, 0.3162, 0.0501, 0.0794, 0.01995], qs: [40, 80, 100, 120, 120] }, + u: { freqs: [370, 630, 2750, 3000, 3400], gains: [1, 0.1, 0.0708, 0.0316, 0.01995], qs: [40, 60, 100, 120, 120] }, + ae: { freqs: [650, 1515, 2400, 3000, 3350], gains: [1, 0.5, 0.1008, 0.0631, 0.0126], qs: [80, 90, 120, 130, 140] }, + aa: { freqs: [560, 900, 2570, 3000, 3300], gains: [1, 0.5, 0.0708, 0.0631, 0.0126], qs: [80, 90, 120, 130, 140] }, + oe: { freqs: [500, 1430, 2300, 3000, 3300], gains: [1, 0.2, 0.0708, 0.0316, 0.01995], qs: [40, 60, 100, 120, 120] }, + ue: { freqs: [250, 1750, 2150, 3200, 3300], gains: [1, 0.1, 0.0708, 0.0316, 0.01995], qs: [40, 60, 100, 120, 120] }, + y: { freqs: [400, 1460, 2400, 3000, 3300], gains: [1, 0.2, 0.0708, 0.0316, 0.02995], qs: [40, 60, 100, 120, 120] }, + uh: { freqs: [600, 1250, 2100, 3100, 3500], gains: [1, 0.3, 0.0608, 0.0316, 0.01995], qs: [40, 70, 100, 120, 130] }, + un: { freqs: [500, 1240, 2280, 3000, 3500], gains: [1, 0.1, 0.1708, 0.0216, 0.02995], qs: [40, 60, 100, 120, 120] }, + en: { freqs: [600, 1480, 2450, 3200, 3300], gains: [1, 0.15, 0.0708, 0.0316, 0.02995], qs: [40, 60, 100, 120, 120] }, + an: { freqs: [700, 1050, 2500, 3000, 3300], gains: [1, 0.1, 0.0708, 0.0316, 0.02995], qs: [40, 60, 100, 120, 120] }, + on: { freqs: [500, 1080, 2350, 3000, 3300], gains: [1, 0.1, 0.0708, 0.0316, 0.02995], qs: [40, 60, 100, 120, 120] }, + get æ() { + return this.ae; + }, + get ø() { + return this.oe; + }, + get ɑ() { + return this.aa; + }, + get å() { + return this.aa; + }, + get ö() { + return this.oe; + }, + get ü() { + return this.ue; + }, + get ı() { + return this.y; + }, +}; +if (typeof GainNode !== 'undefined') { + class VowelNode extends GainNode { + constructor(ac, letter) { + super(ac); + if (!vowelFormant[letter]) { + throw new Error('vowel: unknown vowel ' + letter); + } + const { gains, qs, freqs } = vowelFormant[letter]; + const makeupGain = ac.createGain(); + for (let i = 0; i < 5; i++) { + const gain = ac.createGain(); + gain.gain.value = gains[i]; + const filter = ac.createBiquadFilter(); + filter.type = 'bandpass'; + filter.Q.value = qs[i]; + filter.frequency.value = freqs[i]; + this.connect(filter); + filter.connect(gain); + gain.connect(makeupGain); + } + makeupGain.gain.value = 8; // how much makeup gain to add? + this.connect = (target) => makeupGain.connect(target); + return this; + } + } + + AudioContext.prototype.createVowelFilter = function (letter) { + return new VowelNode(this, letter); + }; +} diff --git a/src/strudel/superdough/wavetable.mjs b/src/strudel/superdough/wavetable.mjs new file mode 100644 index 0000000..01d4eb8 --- /dev/null +++ b/src/strudel/superdough/wavetable.mjs @@ -0,0 +1,336 @@ +import { getAudioContext, registerSound } from './index.mjs'; +import { getCommonSampleInfo } from './util.mjs'; +import { + applyFM, + applyParameterModulators, + destroyAudioWorkletNode, + getADSRValues, + getFrequencyFromValue, + getParamADSR, + getPitchEnvelope, + getVibratoOscillator, + getWorklet, + webAudioTimeout, +} from './helpers.mjs'; +import { logger } from './logger.mjs'; + +export const Warpmode = Object.freeze({ + NONE: 0, + ASYM: 1, + MIRROR: 2, + BENDP: 3, + BENDM: 4, + BENDMP: 5, + SYNC: 6, + QUANT: 7, + FOLD: 8, + PWM: 9, + ORBIT: 10, + SPIN: 11, + CHAOS: 12, + PRIMES: 13, + BINARY: 14, + BROWNIAN: 15, + RECIPROCAL: 16, + WORMHOLE: 17, + LOGISTIC: 18, + SIGMOID: 19, + FRACTAL: 20, + FLIP: 21, +}); + +const seenKeys = new Set(); +async function getPayload(url, label, frameLen = 2048) { + const key = `${url},${frameLen}`; + if (!seenKeys.has(key)) { + const buf = await loadBuffer(url, label); + const ch0 = buf.getChannelData(0); + const total = ch0.length; + const numFrames = Math.max(1, Math.floor(total / frameLen)); + const frames = new Array(numFrames); + for (let i = 0; i < numFrames; i++) { + const start = i * frameLen; + frames[i] = ch0.subarray(start, start + frameLen); + } + seenKeys.add(key); + return { frames, frameLen, numFrames, key }; + } + return { frameLen, key }; // worklet will use the cached version +} + +function humanFileSize(bytes, si) { + var thresh = si ? 1000 : 1024; + if (bytes < thresh) return bytes + ' B'; + var units = si + ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + var u = -1; + do { + bytes /= thresh; + ++u; + } while (bytes >= thresh); + return bytes.toFixed(1) + ' ' + units[u]; +} + +// Extract the sample rate of a .wav file +function parseWavSampleRate(arrBuf) { + const dv = new DataView(arrBuf); + // Header is "RIFFWAVE", so 12 bytes + let p = 12; + // Look through chunks for the format header + // (they will always have an 8 byte header (id and size) followed by a payload) + while (p + 8 <= dv.byteLength) { + // Parse id + const id = String.fromCharCode(dv.getUint8(p), dv.getUint8(p + 1), dv.getUint8(p + 2), dv.getUint8(p + 3)); + // Parse chunk size + const size = dv.getUint32(p + 4, true); + if (id === 'fmt ') { + // The format chunk contains the sample rate after + // 8 bytes of header, 2 bytes of format tag, 2 bytes of num channels + // (for a total of 12) + return dv.getUint32(p + 12, true); + } + // Advance to next chunk + p += 8 + size + (size & 1); + } + return null; +} + +async function decodeAtNativeRate(arr) { + const sr = parseWavSampleRate(arr) || 44100; + const tempAC = new OfflineAudioContext(1, 1, sr); + return await tempAC.decodeAudioData(arr); +} + +const loadCache = {}; +const loadBuffer = (url, label) => { + url = url.replace('#', '%23'); + if (!loadCache[url]) { + logger(`[wavetable] load table ${label}..`, 'load-table', { url }); + const timestamp = Date.now(); + loadCache[url] = fetch(url) + .then((res) => res.arrayBuffer()) + .then(async (res) => { + const took = Date.now() - timestamp; + const size = humanFileSize(res.byteLength); + logger(`[wavetable] load table ${label}... done! loaded ${size} in ${took}ms`, 'loaded-table', { url }); + const decoded = await decodeAtNativeRate(res); + return decoded; + }); + } + return loadCache[url]; +}; + +function githubPath(base, subpath = '') { + if (!base.startsWith('github:')) { + throw new Error('expected "github:" at the start of pseudoUrl'); + } + let [_, path] = base.split('github:'); + path = path.endsWith('/') ? path.slice(0, -1) : path; + if (path.split('/').length === 2) { + // assume main as default branch if none set + path += '/main'; + } + return `https://raw.githubusercontent.com/${path}/${subpath}`; +} + +const _processTables = (json, baseUrl, frameLen, options = {}) => { + baseUrl = json._base || baseUrl; + return Object.entries(json).forEach(([key, tables]) => { + if (key === '_base') return false; + if (typeof tables === 'string') { + tables = [tables]; + } + if (typeof tables !== 'object') { + throw new Error('wrong json format for ' + key); + } + let resolvedUrl = baseUrl; + if (resolvedUrl.startsWith('github:')) { + resolvedUrl = githubPath(resolvedUrl, ''); + } + tables = tables + .map((t) => resolvedUrl + t) + .filter((t) => { + if (!t.toLowerCase().endsWith('.wav')) { + logger(`[wavetable] skipping ${t} -- wavetables must be ".wav" format`); + return false; + } + return true; + }); + if (tables.length) { + registerWaveTable(key, tables, { baseUrl, frameLen }); + } + }); +}; + +export function registerWaveTable(key, tables, params) { + registerSound( + key, + (t, hapValue, onended, cps) => { + return onTriggerSynth(t, hapValue, onended, tables, cps, params?.frameLen ?? 2048); + }, + { + type: 'wavetable', + tables, + ...params, + }, + ); +} + +/** + * Loads a collection of wavetables to use with `s` + * + * @name tables + */ +export const tables = async (url, frameLen, json, options = {}) => { + if (json !== undefined) return _processTables(json, url, frameLen); + if (url.startsWith('github:')) { + url = githubPath(url, 'strudel.json'); + } + if (url.startsWith('local:')) { + url = `http://localhost:5432`; + } + if (typeof fetch !== 'function') { + // not a browser + return; + } + if (typeof fetch === 'undefined') { + // skip fetch when in node / testing + return; + } + return fetch(url) + .then((res) => res.json()) + .then((json) => _processTables(json, url, frameLen, options)) + .catch((error) => { + console.error(error); + throw new Error(`error loading "${url}"`); + }); +}; + +export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { + const { s, n = 0, duration, clip } = value; + const ac = getAudioContext(); + const [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]); + let { warpmode } = value; + if (typeof warpmode === 'string') { + warpmode = Warpmode[warpmode.toUpperCase()] ?? Warpmode.NONE; + } + const frequency = getFrequencyFromValue(value); + const { url, label } = getCommonSampleInfo(value, tables); + const payload = await getPayload(url, label, frameLen); + let holdEnd = t + duration; + if (clip !== undefined) { + holdEnd = Math.min(t + clip * duration, holdEnd); + } + const endWithRelease = holdEnd + release; + const envEnd = endWithRelease + 0.01; + const source = getWorklet( + ac, + 'wavetable-oscillator-processor', + { + begin: t, + end: envEnd, + frequency, + freqspread: value.detune, + position: value.wt, + warp: value.warp, + warpMode: warpmode, + voices: Math.max(value.unison ?? 1, 1), + panspread: value.spread, + phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0, + }, + { outputChannelCount: [2] }, + ); + source.port.postMessage({ type: 'table', payload }); + if (ac.currentTime > t) { + logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight'); + return; + } + const posADSRParams = [value.wtattack, value.wtdecay, value.wtsustain, value.wtrelease]; + const warpADSRParams = [value.warpattack, value.warpdecay, value.warpsustain, value.warprelease]; + const wtParams = source.parameters; + const positionParam = wtParams.get('position'); + const warpParam = wtParams.get('warp'); + + let wtrate = value.wtrate; + if (value.wtsync != null) { + wtrate = cps * value.wtsync; + } + + const wtPosModulators = applyParameterModulators( + ac, + positionParam, + t, + endWithRelease, + { + offset: value.wt, + amount: value.wtenv, + defaultAmount: 0.5, + shape: 'linear', + values: posADSRParams, + holdEnd, + defaultValues: [0, 0.5, 0, 0.1], + }, + { + frequency: wtrate, + depth: value.wtdepth, + defaultDepth: 0.5, + shape: value.wtshape, + skew: value.wtskew, + dcoffset: value.wtdc ?? 0, + }, + ); + + let warprate = value.warprate; + if (value.warpsync != null) { + warprate = warprate = cps * value.warpsync; + } + const wtWarpModulators = applyParameterModulators( + ac, + warpParam, + t, + endWithRelease, + { + offset: value.warp, + amount: value.warpenv, + defaultAmount: 0.5, + shape: 'linear', + values: warpADSRParams, + holdEnd, + defaultValues: [0, 0.5, 0, 0.1], + }, + { + frequency: warprate, + depth: value.warpdepth, + defaultDepth: 0.5, + shape: value.warpshape, + skew: value.warpskew, + dcoffset: value.warpdc ?? 0, + }, + ); + const vibratoOscillator = getVibratoOscillator(source.parameters.get('detune'), value, t); + const fm = applyFM(source.parameters.get('frequency'), value, t); + const envGain = ac.createGain(); + const node = source.connect(envGain); + getParamADSR(node.gain, attack, decay, sustain, release, 0, 0.3, t, holdEnd, 'linear'); + getPitchEnvelope(source.parameters.get('detune'), value, t, holdEnd); + const handle = { node, source }; + const timeoutNode = webAudioTimeout( + ac, + () => { + destroyAudioWorkletNode(source); + vibratoOscillator?.stop(); + fm?.stop(); + node.disconnect(); + wtPosModulators?.disconnect(); + wtWarpModulators?.disconnect(); + onended(); + }, + t, + envEnd, + ); + handle.stop = (time) => { + timeoutNode.stop(time); + }; + return handle; +} diff --git a/src/strudel/superdough/worklets.mjs b/src/strudel/superdough/worklets.mjs new file mode 100644 index 0000000..7d7ef8e --- /dev/null +++ b/src/strudel/superdough/worklets.mjs @@ -0,0 +1,1321 @@ +// coarse, crush, and shape processors adapted from dktr0's webdirt: https://github.com/dktr0/WebDirt/blob/5ce3d698362c54d6e1b68acc47eb2955ac62c793/dist/AudioWorklets.js +// LICENSE GNU General Public License v3.0 see https://github.com/dktr0/WebDirt/blob/main/LICENSE +// TOFIX: THIS FILE DOES NOT SUPPORT IMPORTS ON DEPOLYMENT + +import OLAProcessor from './ola-processor'; +import FFT from './fft.js'; +import { getDistortionAlgorithm } from './helpers.mjs'; + +const clamp = (num, min, max) => Math.min(Math.max(num, min), max); +const mod = (n, m) => ((n % m) + m) % m; +const lerp = (a, b, n) => n * (b - a) + a; +const pv = (arr, n) => arr[n] ?? arr[0]; +const frac = (x) => x - Math.floor(x); +const ffloor = (x) => x | 0; // fast floor for non-negative + +const getUnisonDetune = (unison, detune, voiceIndex) => { + if (unison < 2) { + return 0; + } + return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1)); +}; +const applySemitoneDetuneToFrequency = (frequency, detune) => { + return frequency * Math.pow(2, detune / 12); +}; + +// Restrict phase to the range [0, maxPhase) via wrapping +function wrapPhase(phase, maxPhase = 1) { + if (phase >= maxPhase) { + phase -= maxPhase; + } else if (phase < 0) { + phase += maxPhase; + } + return phase; +} +const blockSize = 128; +// Smooth waveshape near discontinuities to remove frequencies above Nyquist and prevent aliasing +// referenced from https://www.kvraudio.com/forum/viewtopic.php?t=375517 +function polyBlep(phase, dt) { + dt = Math.min(dt, 1 - dt); + // Start of cycle + if (phase < dt) { + phase /= dt; + // 2 * (phase - phase^2/2 - 0.5) + return phase + phase - phase * phase - 1; + } + + // End of cycle + else if (phase > 1 - dt) { + phase = (phase - 1) / dt; + // 2 * (phase^2/2 + phase + 0.5) + return phase * phase + phase + phase + 1; + } + + // 0 otherwise + else { + return 0; + } +} +// The order is important for dough integration +const waveshapes = { + tri(phase, skew = 0.5) { + const x = 1 - skew; + if (phase >= skew) { + return 1 / x - phase / x; + } + return phase / skew; + }, + sine(phase) { + return Math.sin(Math.PI * 2 * phase) * 0.5 + 0.5; + }, + ramp(phase) { + return phase; + }, + saw(phase) { + return 1 - phase; + }, + + square(phase, skew = 0.5) { + if (phase >= skew) { + return 0; + } + return 1; + }, + custom(phase, values = [0, 1]) { + const numParts = values.length - 1; + const currPart = Math.floor(phase * numParts); + + const partLength = 1 / numParts; + const startVal = clamp(values[currPart], 0, 1); + const endVal = clamp(values[currPart + 1], 0, 1); + const y2 = endVal; + const y1 = startVal; + const x1 = 0; + const x2 = partLength; + const slope = (y2 - y1) / (x2 - x1); + return slope * (phase - partLength * currPart) + startVal; + }, + sawblep(phase, dt) { + const v = 2 * phase - 1; + return v - polyBlep(phase, dt); + }, +}; +function getParamValue(block, param) { + if (param.length > 1) { + return param[block]; + } + return param[0]; +} + +const waveShapeNames = Object.keys(waveshapes); +class LFOProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return [ + { name: 'begin', defaultValue: 0 }, + { name: 'time', defaultValue: 0 }, + { name: 'end', defaultValue: 0 }, + { name: 'frequency', defaultValue: 0.5 }, + { name: 'skew', defaultValue: 0.5 }, + { name: 'depth', defaultValue: 1 }, + { name: 'phaseoffset', defaultValue: 0 }, + { name: 'shape', defaultValue: 0 }, + { name: 'curve', defaultValue: 1 }, + { name: 'dcoffset', defaultValue: 0 }, + { name: 'min', defaultValue: 0 }, + { name: 'max', defaultValue: 1 }, + ]; + } + + constructor() { + super(); + this.phase; + } + + incrementPhase(dt) { + this.phase += dt; + if (this.phase > 1.0) { + this.phase = this.phase - 1; + } + } + + process(_inputs, outputs, parameters) { + const begin = parameters['begin'][0]; + if (currentTime >= parameters.end[0]) { + return false; + } + if (currentTime <= begin) { + return true; + } + + const output = outputs[0]; + const frequency = parameters['frequency'][0]; + + const time = parameters['time'][0]; + const depth = parameters['depth'][0]; + const skew = parameters['skew'][0]; + const phaseoffset = parameters['phaseoffset'][0]; + + const curve = parameters['curve'][0]; + + const dcoffset = parameters['dcoffset'][0]; + const min = parameters['min'][0]; + const max = parameters['max'][0]; + const shape = waveShapeNames[parameters['shape'][0]]; + + const blockSize = output[0].length ?? 0; + + if (this.phase == null) { + this.phase = mod(time * frequency + phaseoffset, 1); + } + const dt = frequency / sampleRate; + for (let n = 0; n < blockSize; n++) { + for (let i = 0; i < output.length; i++) { + let modval = (waveshapes[shape](this.phase, skew) + dcoffset) * depth; + modval = Math.pow(modval, curve); + output[i][n] = clamp(modval, min, max); + } + this.incrementPhase(dt); + } + + return true; + } +} +registerProcessor('lfo-processor', LFOProcessor); + +class CoarseProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return [{ name: 'coarse', defaultValue: 1 }]; + } + + constructor() { + super(); + this.started = false; + } + + process(inputs, outputs, parameters) { + const input = inputs[0]; + const output = outputs[0]; + + const hasInput = !(input[0] === undefined); + if (this.started && !hasInput) { + return false; + } + this.started = hasInput; + + let coarse = parameters.coarse[0] ?? 0; + coarse = Math.max(1, coarse); + for (let n = 0; n < blockSize; n++) { + for (let i = 0; i < input.length; i++) { + output[i][n] = n % coarse === 0 ? input[i][n] : output[i][n - 1]; + } + } + return true; + } +} +registerProcessor('coarse-processor', CoarseProcessor); + +class CrushProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return [{ name: 'crush', defaultValue: 0 }]; + } + + constructor() { + super(); + this.started = false; + } + + process(inputs, outputs, parameters) { + const input = inputs[0]; + const output = outputs[0]; + + const hasInput = !(input[0] === undefined); + if (this.started && !hasInput) { + return false; + } + this.started = hasInput; + + let crush = parameters.crush[0] ?? 8; + crush = Math.max(1, crush); + + for (let n = 0; n < blockSize; n++) { + for (let i = 0; i < input.length; i++) { + const x = Math.pow(2, crush - 1); + output[i][n] = Math.round(input[i][n] * x) / x; + } + } + return true; + } +} +registerProcessor('crush-processor', CrushProcessor); + +class ShapeProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return [ + { name: 'shape', defaultValue: 0 }, + { name: 'postgain', defaultValue: 1 }, + ]; + } + + constructor() { + super(); + this.started = false; + } + + process(inputs, outputs, parameters) { + const input = inputs[0]; + const output = outputs[0]; + + const hasInput = !(input[0] === undefined); + if (this.started && !hasInput) { + return false; + } + this.started = hasInput; + + let shape = parameters.shape[0]; + shape = shape < 1 ? shape : 1.0 - 4e-10; + shape = (2.0 * shape) / (1.0 - shape); + const postgain = Math.max(0.001, Math.min(1, parameters.postgain[0])); + + for (let n = 0; n < blockSize; n++) { + for (let i = 0; i < input.length; i++) { + output[i][n] = (((1 + shape) * input[i][n]) / (1 + shape * Math.abs(input[i][n]))) * postgain; + } + } + return true; + } +} +registerProcessor('shape-processor', ShapeProcessor); + +class TwoPoleFilter { + s0 = 0; + s1 = 0; + update(s, cutoff, resonance = 0) { + // Out of bound values can produce NaNs + resonance = clamp(resonance, 0, 1); + cutoff = clamp(cutoff, 0, sampleRate / 2 - 1); + const c = clamp(2 * Math.sin(cutoff * (_PI / sampleRate)), 0, 1.14); + const r = Math.pow(0.5, (resonance + 0.125) / 0.125); + const mrc = 1 - r * c; + this.s0 = mrc * this.s0 - c * this.s1 + c * s; // bpf + this.s1 = mrc * this.s1 + c * this.s0; // lpf + return this.s1; // return lpf by default + } +} + +class DJFProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return [{ name: 'value', defaultValue: 0.5 }]; + } + + constructor() { + super(); + this.filters = [new TwoPoleFilter(), new TwoPoleFilter()]; + } + + process(inputs, outputs, parameters) { + const input = inputs[0]; + const output = outputs[0]; + + const hasInput = !(input[0] === undefined); + this.started = hasInput; + + const value = clamp(parameters.value[0], 0, 1); + let filterType = 'none'; + let cutoff; + let v = 1; + if (value > 0.51) { + filterType = 'hipass'; + v = (value - 0.5) * 2; + } else if (value < 0.49) { + filterType = 'lopass'; + v = value * 2; + } + cutoff = Math.pow(v * 11, 4); + + for (let i = 0; i < input.length; i++) { + for (let n = 0; n < blockSize; n++) { + if (filterType == 'none') { + output[i][n] = input[i][n]; + } else { + this.filters[i].update(input[i][n], cutoff, 0.1); + if (filterType === 'lopass') { + output[i][n] = this.filters[i].s1; + } else if (filterType === 'hipass') { + output[i][n] = input[i][n] - this.filters[i].s1; + } else { + output[i][n] = input[i][n]; + } + } + } + } + return true; + } +} +registerProcessor('djf-processor', DJFProcessor); + +function fast_tanh(x) { + const x2 = x * x; + return (x * (27.0 + x2)) / (27.0 + 9.0 * x2); +} +const _PI = 3.14159265359; +//adapted from https://github.com/TheBouteillacBear/webaudioworklet-wasm?tab=MIT-1-ov-file +class LadderProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return [ + { name: 'frequency', defaultValue: 500 }, + { name: 'q', defaultValue: 1 }, + { name: 'drive', defaultValue: 0.69 }, + ]; + } + + constructor() { + super(); + this.started = false; + this.p0 = [0, 0]; + this.p1 = [0, 0]; + this.p2 = [0, 0]; + this.p3 = [0, 0]; + this.p32 = [0, 0]; + this.p33 = [0, 0]; + this.p34 = [0, 0]; + } + + process(inputs, outputs, parameters) { + const input = inputs[0]; + const output = outputs[0]; + + const hasInput = !(input[0] === undefined); + if (this.started && !hasInput) { + return false; + } + + this.started = hasInput; + + const resonance = parameters.q[0]; + const drive = clamp(Math.exp(parameters.drive[0]), 0.1, 2000); + + let cutoff = parameters.frequency[0]; + cutoff = (cutoff * 2 * _PI) / sampleRate; + cutoff = cutoff > 1 ? 1 : cutoff; + + const k = Math.min(8, resonance * 0.13); + // drive makeup * resonance volume loss makeup + let makeupgain = (1 / drive) * Math.min(1.75, 1 + k); + + for (let n = 0; n < blockSize; n++) { + for (let i = 0; i < input.length; i++) { + const out = this.p3[i] * 0.360891 + this.p32[i] * 0.41729 + this.p33[i] * 0.177896 + this.p34[i] * 0.0439725; + + this.p34[i] = this.p33[i]; + this.p33[i] = this.p32[i]; + this.p32[i] = this.p3[i]; + + this.p0[i] += (fast_tanh(input[i][n] * drive - k * out) - fast_tanh(this.p0[i])) * cutoff; + this.p1[i] += (fast_tanh(this.p0[i]) - fast_tanh(this.p1[i])) * cutoff; + this.p2[i] += (fast_tanh(this.p1[i]) - fast_tanh(this.p2[i])) * cutoff; + this.p3[i] += (fast_tanh(this.p2[i]) - fast_tanh(this.p3[i])) * cutoff; + + output[i][n] = out * makeupgain; + } + } + return true; + } +} +registerProcessor('ladder-processor', LadderProcessor); + +class DistortProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return [ + { name: 'distort', defaultValue: 0 }, + { name: 'postgain', defaultValue: 1 }, + ]; + } + + constructor({ processorOptions }) { + super(); + this.started = false; + this.algorithm = getDistortionAlgorithm(processorOptions.algorithm); + } + + process(inputs, outputs, parameters) { + const input = inputs[0]; + const output = outputs[0]; + + const hasInput = !(input[0] === undefined); + if (this.started && !hasInput) { + return false; + } + this.started = hasInput; + for (let n = 0; n < blockSize; n++) { + const postgain = clamp(pv(parameters.postgain, n), 0.001, 1); + const shape = Math.expm1(pv(parameters.distort, n)); + for (let ch = 0; ch < input.length; ch++) { + const x = input[ch][n]; + output[ch][n] = postgain * this.algorithm(x, shape); + } + } + return true; + } +} +registerProcessor('distort-processor', DistortProcessor); + +// SUPERSAW +class SuperSawOscillatorProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.phase = []; + } + static get parameterDescriptors() { + return [ + { + name: 'begin', + defaultValue: 0, + max: Number.POSITIVE_INFINITY, + min: 0, + }, + + { + name: 'end', + defaultValue: 0, + max: Number.POSITIVE_INFINITY, + min: 0, + }, + + { + name: 'frequency', + defaultValue: 440, + min: Number.EPSILON, + }, + + { + name: 'panspread', + defaultValue: 0.4, + min: 0, + max: 1, + }, + { + name: 'freqspread', + defaultValue: 0.2, + min: 0, + }, + { + name: 'detune', + defaultValue: 0, + min: 0, + }, + + { + name: 'voices', + defaultValue: 5, + min: 1, + }, + ]; + } + process(_input, outputs, params) { + if (currentTime <= params.begin[0]) { + return true; + } + if (currentTime >= params.end[0]) { + // this.port.postMessage({ type: 'onended' }); + return false; + } + + const output = outputs[0]; + + for (let i = 0; i < output[0].length; i++) { + const detune = pv(params.detune, i); + const voices = pv(params.voices, i); + const freqspread = pv(params.freqspread, i); + const panspread = pv(params.panspread, i) * 0.5 + 0.5; + const gain1 = Math.sqrt(1 - panspread); + const gain2 = Math.sqrt(panspread); + let freq = pv(params.frequency, i); + // Main detuning + freq = applySemitoneDetuneToFrequency(freq, detune / 100); + for (let n = 0; n < voices; n++) { + const isOdd = (n & 1) == 1; + let gainL = gain1; + let gainR = gain2; + // invert right and left gain + if (isOdd) { + gainL = gain2; + gainR = gain1; + } + // Individual voice detuning + const freqVoice = applySemitoneDetuneToFrequency(freq, getUnisonDetune(voices, freqspread, n)); + // We must wrap this here because it is passed into sawblep below which + // has domain [0, 1] + const dt = mod(freqVoice / sampleRate, 1); + this.phase[n] = this.phase[n] ?? Math.random(); + const v = waveshapes.sawblep(this.phase[n], dt); + + output[0][i] = output[0][i] + v * gainL; + output[1][i] = output[1][i] + v * gainR; + + this.phase[n] = wrapPhase(this.phase[n] + dt); + } + } + return true; + } +} + +registerProcessor('supersaw-oscillator', SuperSawOscillatorProcessor); + +// Phase Vocoder sourced from https://github.com/olvb/phaze/tree/master?tab=readme-ov-file +const BUFFERED_BLOCK_SIZE = 2048; + +function genHannWindow(length) { + let win = new Float32Array(length); + for (var i = 0; i < length; i++) { + win[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / length)); + } + return win; +} + +class PhaseVocoderProcessor extends OLAProcessor { + static get parameterDescriptors() { + return [ + { + name: 'pitchFactor', + defaultValue: 1.0, + }, + ]; + } + + constructor(options) { + options.processorOptions = { + blockSize: BUFFERED_BLOCK_SIZE, + }; + super(options); + + this.fftSize = this.blockSize; + this.timeCursor = 0; + + this.hannWindow = genHannWindow(this.blockSize); + // prepare FFT and pre-allocate buffers + this.fft = new FFT(this.fftSize); + this.freqComplexBuffer = this.fft.createComplexArray(); + this.freqComplexBufferShifted = this.fft.createComplexArray(); + this.timeComplexBuffer = this.fft.createComplexArray(); + this.magnitudes = new Float32Array(this.fftSize / 2 + 1); + this.peakIndexes = new Int32Array(this.magnitudes.length); + this.nbPeaks = 0; + } + + processOLA(inputs, outputs, parameters) { + // no automation, take last value + + let pitchFactor = parameters.pitchFactor[parameters.pitchFactor.length - 1]; + + if (pitchFactor < 0) { + pitchFactor = pitchFactor * 0.25; + } + pitchFactor = Math.max(0, pitchFactor + 1); + + for (var i = 0; i < this.nbInputs; i++) { + for (var j = 0; j < inputs[i].length; j++) { + // big assumption here: output is symetric to input + var input = inputs[i][j]; + var output = outputs[i][j]; + + this.applyHannWindow(input); + + this.fft.realTransform(this.freqComplexBuffer, input); + + this.computeMagnitudes(); + this.findPeaks(); + this.shiftPeaks(pitchFactor); + + this.fft.completeSpectrum(this.freqComplexBufferShifted); + this.fft.inverseTransform(this.timeComplexBuffer, this.freqComplexBufferShifted); + this.fft.fromComplexArray(this.timeComplexBuffer, output); + this.applyHannWindow(output); + } + } + + this.timeCursor += this.hopSize; + } + + /** Apply Hann window in-place */ + applyHannWindow(input) { + for (var i = 0; i < this.blockSize; i++) { + input[i] = input[i] * this.hannWindow[i] * 1.62; + } + } + + /** Compute squared magnitudes for peak finding **/ + computeMagnitudes() { + var i = 0, + j = 0; + while (i < this.magnitudes.length) { + let real = this.freqComplexBuffer[j]; + let imag = this.freqComplexBuffer[j + 1]; + // no need to sqrt for peak finding + this.magnitudes[i] = real ** 2 + imag ** 2; + i += 1; + j += 2; + } + } + + /** Find peaks in spectrum magnitudes **/ + findPeaks() { + this.nbPeaks = 0; + var i = 2; + let end = this.magnitudes.length - 2; + + while (i < end) { + let mag = this.magnitudes[i]; + + if (this.magnitudes[i - 1] >= mag || this.magnitudes[i - 2] >= mag) { + i++; + continue; + } + if (this.magnitudes[i + 1] >= mag || this.magnitudes[i + 2] >= mag) { + i++; + continue; + } + + this.peakIndexes[this.nbPeaks] = i; + this.nbPeaks++; + i += 2; + } + } + + /** Shift peaks and regions of influence by pitchFactor into new specturm */ + shiftPeaks(pitchFactor) { + // zero-fill new spectrum + this.freqComplexBufferShifted.fill(0); + + for (var i = 0; i < this.nbPeaks; i++) { + let peakIndex = this.peakIndexes[i]; + let peakIndexShifted = Math.round(peakIndex * pitchFactor); + + if (peakIndexShifted > this.magnitudes.length) { + break; + } + + // find region of influence + var startIndex = 0; + var endIndex = this.fftSize; + if (i > 0) { + let peakIndexBefore = this.peakIndexes[i - 1]; + startIndex = peakIndex - Math.floor((peakIndex - peakIndexBefore) / 2); + } + if (i < this.nbPeaks - 1) { + let peakIndexAfter = this.peakIndexes[i + 1]; + endIndex = peakIndex + Math.ceil((peakIndexAfter - peakIndex) / 2); + } + + // shift whole region of influence around peak to shifted peak + let startOffset = startIndex - peakIndex; + let endOffset = endIndex - peakIndex; + for (var j = startOffset; j < endOffset; j++) { + let binIndex = peakIndex + j; + let binIndexShifted = peakIndexShifted + j; + + if (binIndexShifted >= this.magnitudes.length) { + break; + } + + // apply phase correction + let omegaDelta = (2 * Math.PI * (binIndexShifted - binIndex)) / this.fftSize; + let phaseShiftReal = Math.cos(omegaDelta * this.timeCursor); + let phaseShiftImag = Math.sin(omegaDelta * this.timeCursor); + + let indexReal = binIndex * 2; + let indexImag = indexReal + 1; + let valueReal = this.freqComplexBuffer[indexReal]; + let valueImag = this.freqComplexBuffer[indexImag]; + + let valueShiftedReal = valueReal * phaseShiftReal - valueImag * phaseShiftImag; + let valueShiftedImag = valueReal * phaseShiftImag + valueImag * phaseShiftReal; + + let indexShiftedReal = binIndexShifted * 2; + let indexShiftedImag = indexShiftedReal + 1; + this.freqComplexBufferShifted[indexShiftedReal] += valueShiftedReal; + this.freqComplexBufferShifted[indexShiftedImag] += valueShiftedImag; + } + } + } +} + +registerProcessor('phase-vocoder-processor', PhaseVocoderProcessor); + +// Adapted from https://www.musicdsp.org/en/latest/Effects/221-band-limited-pwm-generator.html +class PulseOscillatorProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.pi = _PI; + this.phi = -this.pi; // phase + this.Y0 = 0; // feedback memories + this.Y1 = 0; + this.PW = this.pi; // pulse width + this.B = 2.3; // feedback coefficient + this.dphif = 0; // filtered phase increment + this.envf = 0; // filtered envelope + } + + static get parameterDescriptors() { + return [ + { + name: 'begin', + defaultValue: 0, + max: Number.POSITIVE_INFINITY, + min: 0, + }, + + { + name: 'end', + defaultValue: 0, + max: Number.POSITIVE_INFINITY, + min: 0, + }, + + { + name: 'frequency', + defaultValue: 440, + min: Number.EPSILON, + }, + { + name: 'detune', + defaultValue: 0, + min: Number.NEGATIVE_INFINITY, + max: Number.POSITIVE_INFINITY, + }, + { + name: 'pulsewidth', + defaultValue: 1, + min: 0, + max: Number.POSITIVE_INFINITY, + }, + ]; + } + + process(inputs, outputs, params) { + if (this.disconnected) { + return false; + } + if (currentTime <= params.begin[0]) { + return true; + } + if (currentTime >= params.end[0]) { + return false; + } + const output = outputs[0]; + let env = 1, + dphi; + + for (let i = 0; i < (output[0].length ?? 0); i++) { + const pw = (1 - clamp(getParamValue(i, params.pulsewidth), -0.99, 0.99)) * this.pi; + const detune = getParamValue(i, params.detune); + const freq = applySemitoneDetuneToFrequency(getParamValue(i, params.frequency), detune / 100); + + dphi = freq * (this.pi / (sampleRate * 0.5)); // phase increment + this.dphif += 0.1 * (dphi - this.dphif); + + env *= 0.9998; // exponential decay envelope + this.envf += 0.1 * (env - this.envf); + + // Feedback coefficient control + this.B = 2.3 * (1 - 0.0001 * freq); // feedback limitation + if (this.B < 0) this.B = 0; + + // Waveform generation (half-Tomisawa oscillators) + this.phi += this.dphif; // phase increment + if (this.phi >= this.pi) this.phi -= 2 * this.pi; // phase wrapping + + // First half-Tomisawa generator + let out0 = Math.cos(this.phi + this.B * this.Y0); // self-phase modulation + this.Y0 = 0.5 * (out0 + this.Y0); // anti-hunting filter + + // Second half-Tomisawa generator (with phase offset for pulse width) + let out1 = Math.cos(this.phi + this.B * this.Y1 + pw); + this.Y1 = 0.5 * (out1 + this.Y1); // anti-hunting filter + + for (let o = 0; o < output.length; o++) { + // Combination of both oscillators with envelope applied + output[o][i] = 0.15 * (out0 - out1) * this.envf; + } + } + + return true; // keep the audio processing going + } +} + +registerProcessor('pulse-oscillator', PulseOscillatorProcessor); + +/** BYTE BEATS */ +const chyx = { + /*bit*/ bitC: function (x, y, z) { + return x & y ? z : 0; + }, + /*bit reverse*/ br: function (x, size = 8) { + if (size > 32) { + throw new Error('br() Size cannot be greater than 32'); + } else { + let result = 0; + for (let idx = 0; idx < size - 0; idx++) { + result += chyx.bitC(x, 2 ** idx, 2 ** (size - (idx + 1))); + } + return result; + } + }, + /*sin that loops every 128 "steps", instead of every pi steps*/ sinf: function (x) { + return Math.sin(x / (128 / Math.PI)); + }, + /*cos that loops every 128 "steps", instead of every pi steps*/ cosf: function (x) { + return Math.cos(x / (128 / Math.PI)); + }, + /*tan that loops every 128 "steps", instead of every pi steps*/ tanf: function (x) { + return Math.tan(x / (128 / Math.PI)); + }, + /*converts t into a string composed of it's bits, regex's that*/ regG: function (t, X) { + return X.test(t.toString(2)); + }, +}; + +// Create shortened Math functions +let mathParams, byteBeatHelperFuncs; +function getByteBeatFunc(codetext) { + if ((mathParams || byteBeatHelperFuncs) == null) { + mathParams = Object.getOwnPropertyNames(Math); + byteBeatHelperFuncs = mathParams.map((k) => Math[k]); + const chyxNames = Object.getOwnPropertyNames(chyx); + const chyxFuncs = chyxNames.map((k) => chyx[k]); + mathParams.push('int', 'window', ...chyxNames); + byteBeatHelperFuncs.push(Math.floor, globalThis, ...chyxFuncs); + } + return new Function(...mathParams, 't', `return 0,\n${codetext || 0};`).bind(globalThis, ...byteBeatHelperFuncs); +} + +class ByteBeatProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.port.onmessage = (event) => { + let { codeText } = event.data; + const { byteBeatStartTime } = event.data; + if (byteBeatStartTime != null) { + this.t = 0; + this.initialOffset = Math.floor(byteBeatStartTime); + } + + //Optimization pulled from dollchan.net: https://github.com/Chasyxx/EnBeat_NEW, it seemed important + //Optimize code like eval(unescape(escape`XXXX`.replace(/u(..)/g,"$1%"))) + codeText = codeText + .trim() + .replace( + /^eval\(unescape\(escape(?:`|\('|\("|\(`)(.*?)(?:`|'\)|"\)|`\)).replace\(\/u\(\.\.\)\/g,["'`]\$1%["'`]\)\)\)$/, + (match, m1) => unescape(escape(m1).replace(/u(..)/g, '$1%')), + ); + + this.func = getByteBeatFunc(codeText); + }; + this.initialOffset = null; + this.t = null; + this.func = null; + } + + static get parameterDescriptors() { + return [ + { + name: 'begin', + defaultValue: 0, + max: Number.POSITIVE_INFINITY, + min: 0, + }, + { + name: 'frequency', + defaultValue: 440, + min: Number.EPSILON, + }, + { + name: 'detune', + defaultValue: 0, + min: Number.NEGATIVE_INFINITY, + max: Number.POSITIVE_INFINITY, + }, + { + name: 'end', + defaultValue: 0, + max: Number.POSITIVE_INFINITY, + min: 0, + }, + ]; + } + + process(inputs, outputs, params) { + if (this.disconnected) { + return false; + } + if (currentTime <= params.begin[0]) { + return true; + } + if (currentTime >= params.end[0]) { + return false; + } + if (this.t == null) { + this.t = params.begin[0] * sampleRate; + } + const output = outputs[0]; + for (let i = 0; i < output[0].length; i++) { + const detune = getParamValue(i, params.detune); + const freq = applySemitoneDetuneToFrequency(getParamValue(i, params.frequency), detune / 100); + let local_t = (this.t / (sampleRate / 256)) * freq + this.initialOffset; + const funcValue = this.func(local_t); + let signal = (funcValue & 255) / 127.5 - 1; + const out = signal * 0.2; + for (let c = 0; c < output.length; c++) { + //prevent speaker blowout via clipping if threshold exceeds + output[c][i] = clamp(out, -0.4, 0.4); + } + this.t = this.t + 1; + } + + return true; // keep the audio processing going + } +} + +registerProcessor('byte-beat-processor', ByteBeatProcessor); + +export const WarpMode = Object.freeze({ + NONE: 0, + ASYM: 1, + MIRROR: 2, + BENDP: 3, + BENDM: 4, + BENDMP: 5, + SYNC: 6, + QUANT: 7, + FOLD: 8, + PWM: 9, + ORBIT: 10, + SPIN: 11, + CHAOS: 12, + PRIMES: 13, + BINARY: 14, + BROWNIAN: 15, + RECIPROCAL: 16, + WORMHOLE: 17, + LOGISTIC: 18, + SIGMOID: 19, + FRACTAL: 20, + FLIP: 21, +}); + +function hash32(u) { + u = u + 0x7ed55d16 + (u << 12); + u = u ^ 0xc761c23c ^ (u >>> 19); + u = u + 0x165667b1 + (u << 5); + u = (u + 0xd3a2646c) ^ (u << 9); + u = u + 0xfd7046c5 + (u << 3); + u = u ^ 0xb55a4f09 ^ (u >>> 16); + return u >>> 0; +} +const hash01 = (i) => (hash32(i) >>> 8) / 0x01000000; + +function bitReverse(i, n) { + let r = 0; + for (let b = 0; b < n; b++) { + r = (r << 1) | (i & 1); + i >>>= 1; + } + return r; +} + +function noise(x) { + const i = Math.floor(x), + f = x - i; + const a = hash01(i), + b = hash01(i + 1); + return a + (b - a) * f; +} + +function brownian(x, oct = 4) { + let amp = 0.5, + sum = 0, + norm = 0, + freq = 1; + for (let o = 0; o < oct; o++) { + sum += amp * noise(x * freq); + norm += amp; + amp *= 0.5; + freq *= 2; + } + return (sum / norm) * 2 - 1; +} + +const tablesCache = {}; +class WavetableOscillatorProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return [ + { name: 'begin', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY }, + { name: 'end', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY }, + { name: 'frequency', defaultValue: 440, min: Number.EPSILON }, + { name: 'detune', defaultValue: 0 }, + { name: 'freqspread', defaultValue: 0.18, min: 0 }, + { name: 'position', defaultValue: 0, min: 0, max: 1 }, + { name: 'warp', defaultValue: 0, min: 0, max: 1 }, + { name: 'warpMode', defaultValue: 0 }, + { name: 'voices', defaultValue: 1, min: 1 }, + { name: 'panspread', defaultValue: 0.7, min: 0, max: 1 }, + { name: 'phaserand', defaultValue: 0, min: 0, max: 1 }, + ]; + } + + constructor(options) { + super(options); + this.frameLen = 0; + this.numFrames = 0; + this.phase = []; + this.invSR = 1 / sampleRate; + + this.port.onmessage = (e) => { + const { type, payload } = e.data || {}; + if (type === 'table') { + const key = payload.key; + this.frameLen = payload.frameLen; + if (!tablesCache[key]) { + const tables = [payload.frames]; + let table = tables[0]; + for (let level = 1; level < 1; level++) { + const nextLen = table.length >> 1; + const nextTable = table.map((frame) => { + const avg = new Float32Array(nextLen); + for (let i = 0; i < nextLen; i++) { + avg[i] = (frame[2 * i] + frame[2 * i + 1]) / 2; + } + return avg; + }); + tables.push(nextTable); + table = nextTable; + if (nextLen <= 32) break; + } + tablesCache[key] = tables; + } + this.tables = tablesCache[key]; + this.numFrames = this.tables[0].length; + } + }; + } + + _mirror(x) { + return 1 - Math.abs(2 * x - 1); + } + + _toBits(amt, min = 2, max = 12) { + const b = max + (min - max) * amt; + return { b, n: Math.round(Math.pow(2, b)) }; + } + + _warpPhase(phase, amt, mode) { + switch (mode) { + case WarpMode.NONE: { + return phase; + } + case WarpMode.ASYM: { + const a = 0.01 + 0.99 * amt; + return phase < a ? (0.5 * phase) / a : 0.5 + (0.5 * (phase - a)) / (1 - a); + } + case WarpMode.MIRROR: { + // Asym, then mirror + return this._mirror(this._warpPhase(phase, amt, WarpMode.ASYM)); + } + case WarpMode.BENDP: { + return Math.pow(phase, 1 + 3 * amt); + } + case WarpMode.BENDM: { + return Math.pow(phase, 1 / (1 + 3 * amt)); + } + case WarpMode.BENDMP: { + return amt < 0.5 ? this._warpPhase(phase, 1 - 2 * amt, 3) : this._warpPhase(phase, 2 * amt - 1, 2); + } + case WarpMode.SYNC: { + const syncRatio = Math.pow(16, amt * amt); + return (phase * syncRatio) % 1; + } + case WarpMode.QUANT: { + const { n } = this._toBits(amt); + return ffloor(phase * n) / n; + } + case WarpMode.FOLD: { + const K = 7; + const k = 1 + Math.max(1, Math.round(K * amt)); + return Math.abs(frac(k * phase) - 0.5) * 2; + } + case WarpMode.PWM: { + const w = clamp(0.5 + 0.49 * (2 * amt - 1), 0, 1); + if (phase < w) return (phase / w) * 0.5; + return 0.5 + ((phase - w) / (1 - w)) * 0.5; + } + case WarpMode.ORBIT: { + const depth = 0.5 * amt; + const n = 3; + return frac(phase + depth * Math.sin(2 * Math.PI * n * phase)); + } + case WarpMode.SPIN: { + const depth = 0.5 * amt; + const { n } = this._toBits(amt, 1, 6); + return frac(phase + depth * Math.sin(2 * Math.PI * n * phase)); + } + case WarpMode.CHAOS: { + const r = 3.7 + 0.3 * amt; + const logistic = r * phase * (1 - phase); + return clamp((1 - amt) * phase + amt * logistic, 0, 1); + } + case WarpMode.PRIMES: { + const isPrime = (n) => { + if (n < 2) return false; + if (n % 2 === 0) return n === 2; + for (let d = 3; d * d <= n; d += 2) if (n % d === 0) return false; + return true; + }; + let { n } = this._toBits(amt, 3); + while (!isPrime(n)) n++; + return ffloor(phase * n) / n; + } + case WarpMode.BINARY: { + let { b } = this._toBits(amt, 3); + b = Math.round(b); + const n = 1 << b; + const idx = ffloor(phase * n); + const ridx = bitReverse(idx, b); + return ridx / n; + } + case WarpMode.MODULAR: { + const { n } = this._toBits(amt); + const depth = 0.5 * amt; + const jump = frac(phase * n) / n; + return frac(phase + depth * jump); + } + case WarpMode.BROWNIAN: { + const disp = 0.25 * amt * brownian(64 * phase, 4); + return frac(phase + disp); + } + case WarpMode.RECIPROCAL: { + const g = 2 + 4 * amt; + const num = phase * g; + const den = phase + (1 - phase) * g; + const y = den > 1e-12 ? num / den : 0; + return clamp(y, 0, 1); + } + case WarpMode.WORMHOLE: { + const gap = clamp(0.8 * amt, 0, 1); + const a = 0.5 * (1 - gap); + const b = 0.5 * (1 + gap); + if (phase < a) return (phase / a) * 0.5; + if (phase > b) return 0.5 * (1 + (phase - b) / (1 - b)); + return 0.5; + } + case WarpMode.LOGISTIC: { + let x = phase; + const r = 3.6 + 0.4 * amt; + const iters = 1 + Math.round(2 * amt); + for (let i = 0; i < iters; i++) x = r * x * (1 - x); + return clamp(x, 0, 1); + } + case WarpMode.SIGMOID: { + const k = 1 + 10 * amt; + const x = phase - 0.5; + const y = 1 / (1 + Math.exp(-k * x)); + const y0 = 1 / (1 + Math.exp(0.5 * k)); + const y1 = 1 / (1 + Math.exp(-0.5 * k)); + return (y - y0) / (y1 - y0); + } + case WarpMode.FRACTAL: { + const d = 0.5 * Math.sin(2 * Math.PI * phase) * amt; + return frac(phase + d); + } + case WarpMode.FLIP: { + return phase; + } + default: + return phase; + } + } + + _sampleFrame(frame, phase) { + const len = frame.length; + const pos = phase * len; + let i = pos | 0; + if (i >= len) i = 0; // fast wrap + const frac = pos - i; + const a = frame[i]; + let i1 = i + 1; + if (i1 >= len) i1 = 0; + const b = frame[i1]; + return a + (b - a) * frac; + } + + _chooseMip(dphi) { + const approxHarm = clamp(dphi, 1e-6, 64); + let level = 0; + while (level + 1 < (this.tables?.length || 1) && approxHarm < this.tables[level][0].length / 8) { + level++; + } + return level; + } + + process(_inputs, outputs, parameters) { + if (currentTime >= parameters.end[0]) { + return false; + } + if (currentTime <= parameters.begin[0]) { + return true; + } + const outL = outputs[0][0]; + const outR = outputs[0][1] || outputs[0][0]; + if (!this.tables) { + outL.fill(0); + if (outR !== outL) outR.set(outL); + return true; + } + for (let i = 0; i < outL.length; i++) { + const detune = pv(parameters.detune, i); + const freqspread = pv(parameters.freqspread, i); + const tablePos = clamp(pv(parameters.position, i), 0, 1); + const idx = tablePos * (this.numFrames - 1); + const fIdx = idx | 0; + const frac = idx - fIdx; + const warpAmount = clamp(pv(parameters.warp, i), 0, 1); + const warpMode = pv(parameters.warpMode, i); + const voices = pv(parameters.voices, i); + const phaseRand = clamp(pv(parameters.phaserand, i), 0, 1); + const panspread = voices > 1 ? clamp(pv(parameters.panspread, i), 0, 1) : 0; + const gain1 = Math.sqrt(0.5 - 0.5 * panspread); + const gain2 = Math.sqrt(0.5 + 0.5 * panspread); + let f = pv(parameters.frequency, i); + f = applySemitoneDetuneToFrequency(f, detune / 100); // overall detune + const normalizer = 1 / Math.sqrt(voices); + for (let n = 0; n < voices; n++) { + const isOdd = (n & 1) == 1; + let gainL = gain1; + let gainR = gain2; + // invert right and left gain + if (isOdd) { + gainL = gain2; + gainR = gain1; + } + const fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, freqspread, n)); // voice detune + const dPhase = fVoice * this.invSR; + const level = this._chooseMip(dPhase); + const table = this.tables[level]; + + // warp phase then sample + this.phase[n] = this.phase[n] ?? Math.random() * phaseRand; + const ph = this._warpPhase(this.phase[n], warpAmount, warpMode); + const s0 = this._sampleFrame(table[fIdx], ph); + const s1 = this._sampleFrame(table[Math.min(this.numFrames - 1, fIdx + 1)], ph); + let s = s0 + (s1 - s0) * frac; + if (warpMode === WarpMode.FLIP && this.phase[n] < warpAmount) { + s = -s; + } + outL[i] += s * gainL * normalizer; + outR[i] += s * gainR * normalizer; + this.phase[n] = wrapPhase(this.phase[n] + dPhase); + } + } + return true; + } +} + +registerProcessor('wavetable-oscillator-processor', WavetableOscillatorProcessor); diff --git a/src/strudel/superdough/zzfx.mjs b/src/strudel/superdough/zzfx.mjs new file mode 100644 index 0000000..32db039 --- /dev/null +++ b/src/strudel/superdough/zzfx.mjs @@ -0,0 +1,125 @@ +//import { ZZFX } from 'zzfx'; +import { midiToFreq, noteToMidi } from './util.mjs'; +import { registerSound } from './superdough.mjs'; +import { getAudioContext } from './audioContext.mjs'; +import { buildSamples } from './zzfx_fork.mjs'; + +export const getZZFX = (value, t) => { + let { + s, + note = 36, + freq, + // + zrand = 0, + attack = 0, + decay = 0, + sustain = 0.8, + release = 0.1, + curve = 1, + slide = 0, + deltaSlide = 0, + pitchJump = 0, + pitchJumpTime = 0, + lfo = 0, + znoise = 0, + zmod = 0, + zcrush = 0, + zdelay = 0, + tremolo = 0, + duration = 0.2, + zzfx, + } = value; + const sustainTime = Math.max(duration - attack - decay, 0); + if (typeof note === 'string') { + note = noteToMidi(note); // e.g. c3 => 48 + } + // get frequency + if (!freq && typeof note === 'number') { + freq = midiToFreq(note); + } + s = s.replace('z_', ''); + const shape = ['sine', 'triangle', 'sawtooth', 'tan', 'noise'].indexOf(s) || 0; + curve = s === 'square' ? 0 : curve; + + const params = zzfx || [ + 0.25, // volume + zrand, + freq, + attack, + sustainTime, + release, + shape, + curve, + slide, + deltaSlide, + pitchJump, + pitchJumpTime, + lfo, + znoise, + zmod, + zcrush, + zdelay, + sustain, // sustain volume! + decay, + tremolo, + ]; + // console.log(redableZZFX(params)); + + const samples = /* ZZFX. */ buildSamples(...params); + const context = getAudioContext(); + const buffer = context.createBuffer(1, samples.length, context.sampleRate); + buffer.getChannelData(0).set(samples); + const source = getAudioContext().createBufferSource(); + source.buffer = buffer; + source.start(t); + return { + node: source, + }; +}; + +export function registerZZFXSounds() { + ['zzfx', 'z_sine', 'z_sawtooth', 'z_triangle', 'z_square', 'z_tan', 'z_noise'].forEach((wave) => { + registerSound( + wave, + (t, value, onended) => { + const { node: o } = getZZFX({ s: wave, ...value }, t); + o.onended = () => { + o.disconnect(); + onended(); + }; + return { + node: o, + stop: () => {}, + }; + }, + { type: 'synth', prebake: true }, + ); + }); +} + +// just for debugging +function redableZZFX(params) { + const paramOrder = [ + 'volume', + 'zrand', + 'frequency', + 'attack', + 'sustain', + 'release', + 'shape', + 'curve', + 'slide', + 'deltaSlide', + 'pitchJump', + 'pitchJumpTime', + 'lfo', + 'noise', + 'zmod', + 'zcrush', + 'zdelay', + 'sustainVolume', + 'decay', + 'tremolo', + ]; + return Object.fromEntries(paramOrder.map((param, i) => [param, params[i]])); +} diff --git a/src/strudel/superdough/zzfx_fork.mjs b/src/strudel/superdough/zzfx_fork.mjs new file mode 100644 index 0000000..f3ee6a0 --- /dev/null +++ b/src/strudel/superdough/zzfx_fork.mjs @@ -0,0 +1,120 @@ +import { getAudioContext } from './audioContext.mjs'; + +// https://github.com/KilledByAPixel/ZzFX/blob/master/ZzFX.js#L85C5-L180C6 +// changes: replaced this.volume with 1 + using sampleRate from getAudioContext() +export function buildSamples( + volume = 1, + randomness = 0.05, + frequency = 220, + attack = 0, + sustain = 0, + release = 0.1, + shape = 0, + shapeCurve = 1, + slide = 0, + deltaSlide = 0, + pitchJump = 0, + pitchJumpTime = 0, + repeatTime = 0, + noise = 0, + modulation = 0, + bitCrush = 0, + delay = 0, + sustainVolume = 1, + decay = 0, + tremolo = 0, +) { + // init parameters + let PI2 = Math.PI * 2, + sampleRate = getAudioContext().sampleRate, + sign = (v) => (v > 0 ? 1 : -1), + startSlide = (slide *= (500 * PI2) / sampleRate / sampleRate), + startFrequency = (frequency *= ((1 + randomness * 2 * Math.random() - randomness) * PI2) / sampleRate), + b = [], + t = 0, + tm = 0, + i = 0, + j = 1, + r = 0, + c = 0, + s = 0, + f, + length; + + // scale by sample rate + attack = attack * sampleRate + 9; // minimum attack to prevent pop + decay *= sampleRate; + sustain *= sampleRate; + release *= sampleRate; + delay *= sampleRate; + deltaSlide *= (500 * PI2) / sampleRate ** 3; + modulation *= PI2 / sampleRate; + pitchJump *= PI2 / sampleRate; + pitchJumpTime *= sampleRate; + repeatTime = (repeatTime * sampleRate) | 0; + + // generate waveform + for (length = (attack + decay + sustain + release + delay) | 0; i < length; b[i++] = s) { + if (!(++c % ((bitCrush * 100) | 0))) { + // bit crush + s = shape + ? shape > 1 + ? shape > 2 + ? shape > 3 // wave shape + ? Math.sin((t % PI2) ** 3) // 4 noise + : Math.max(Math.min(Math.tan(t), 1), -1) // 3 tan + : 1 - (((((2 * t) / PI2) % 2) + 2) % 2) // 2 saw + : 1 - 4 * Math.abs(Math.round(t / PI2) - t / PI2) // 1 triangle + : Math.sin(t); // 0 sin + + s = + (repeatTime + ? 1 - tremolo + tremolo * Math.sin((PI2 * i) / repeatTime) // tremolo + : 1) * + sign(s) * + Math.abs(s) ** shapeCurve * // curve 0=square, 2=pointy + volume * + 1 * // envelope + (i < attack + ? i / attack // attack + : i < attack + decay // decay + ? 1 - ((i - attack) / decay) * (1 - sustainVolume) // decay falloff + : i < attack + decay + sustain // sustain + ? sustainVolume // sustain volume + : i < length - delay // release + ? ((length - i - delay) / release) * // release falloff + sustainVolume // release volume + : 0); // post release + + s = delay + ? s / 2 + + (delay > i + ? 0 // delay + : ((i < length - delay ? 1 : (length - i) / delay) * // release delay + b[(i - delay) | 0]) / + 2) + : s; // sample delay + } + + f = + (frequency += slide += deltaSlide) * // frequency + Math.cos(modulation * tm++); // modulation + t += f - f * noise * (1 - (((Math.sin(i) + 1) * 1e9) % 2)); // noise + + if (j && ++j > pitchJumpTime) { + // pitch jump + frequency += pitchJump; // apply pitch jump + startFrequency += pitchJump; // also apply to start + j = 0; // stop pitch jump time + } + + if (repeatTime && !(++r % repeatTime)) { + // repeat + frequency = startFrequency; // reset frequency + slide = startSlide; // reset slide + j ||= 1; // reset pitch jump time + } + } + + return b; +}