Merge branch 'main' into global-vibrato
This commit is contained in:
commit
5aac042c80
106 changed files with 7033 additions and 3791 deletions
|
|
@ -1,7 +1,7 @@
|
|||
# superdough
|
||||
|
||||
superdough is a simple web audio sampler and synth, intended for live coding.
|
||||
It is the default output of [strudel](https://strudel.tidalcycles.org/).
|
||||
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
|
||||
|
|
|
|||
79
packages/superdough/dspworklet.mjs
Normal file
79
packages/superdough/dspworklet.mjs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { getAudioContext } from './superdough.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(t, hap, currentTime, duration, cps) {
|
||||
window.postMessage({ time: t, dough: hap.value, currentTime, duration, cps });
|
||||
}
|
||||
|
|
@ -78,6 +78,17 @@ export const getParamADSR = (param, attack, decay, sustain, release, min, max, b
|
|||
param.linearRampToValueAtTime(min, end + Math.max(release, 0.1));
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function createFilter(
|
||||
context,
|
||||
type,
|
||||
|
|
@ -112,3 +123,25 @@ export function createFilter(
|
|||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,3 +10,4 @@ export * from './helpers.mjs';
|
|||
export * from './synth.mjs';
|
||||
export * from './zzfx.mjs';
|
||||
export * from './logger.mjs';
|
||||
export * from './dspworklet.mjs';
|
||||
|
|
|
|||
63
packages/superdough/noise.mjs
Normal file
63
packages/superdough/noise.mjs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { drywet } from './helpers.mjs';
|
||||
import { getAudioContext } from './superdough.mjs';
|
||||
|
||||
let noiseCache = {};
|
||||
|
||||
// lazy generates noise buffers and keeps them forever
|
||||
function getNoiseBuffer(type) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
noiseCache[type] = noiseBuffer;
|
||||
return noiseBuffer;
|
||||
}
|
||||
|
||||
// expects one of noises as type
|
||||
export function getNoiseOscillator(type = 'white', t) {
|
||||
const ac = getAudioContext();
|
||||
const o = ac.createBufferSource();
|
||||
o.buffer = getNoiseBuffer(type);
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "superdough",
|
||||
"version": "0.9.8",
|
||||
"version": "0.9.10",
|
||||
"description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,23 +1,47 @@
|
|||
import reverbGen from './reverbGen.mjs';
|
||||
|
||||
if (typeof AudioContext !== 'undefined') {
|
||||
AudioContext.prototype.impulseResponse = function (duration, channels = 1) {
|
||||
const length = this.sampleRate * duration;
|
||||
const impulse = this.createBuffer(channels, length, this.sampleRate);
|
||||
const IR = impulse.getChannelData(0);
|
||||
for (let i = 0; i < length; i++) IR[i] = (2 * Math.random() - 1) * Math.pow(1 - i / length, duration);
|
||||
return impulse;
|
||||
AudioContext.prototype.adjustLength = function (duration, buffer) {
|
||||
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++) {
|
||||
newData[i] = oldData[i] || 0;
|
||||
}
|
||||
}
|
||||
return newBuffer;
|
||||
};
|
||||
|
||||
AudioContext.prototype.createReverb = function (duration) {
|
||||
AudioContext.prototype.createReverb = function (duration, fade, lp, dim, ir) {
|
||||
const convolver = this.createConvolver();
|
||||
convolver.setDuration = (d) => {
|
||||
convolver.buffer = this.impulseResponse(d);
|
||||
convolver.duration = duration;
|
||||
return convolver;
|
||||
convolver.generate = (d = 2, fade = 0.1, lp = 15000, dim = 1000, ir) => {
|
||||
convolver.duration = d;
|
||||
convolver.fade = fade;
|
||||
convolver.lp = lp;
|
||||
convolver.dim = dim;
|
||||
convolver.ir = ir;
|
||||
if (ir) {
|
||||
convolver.buffer = this.adjustLength(d, ir);
|
||||
} else {
|
||||
reverbGen.generateReverb(
|
||||
{
|
||||
audioContext: this,
|
||||
numChannels: 2,
|
||||
decayTime: d,
|
||||
fadeInTime: fade,
|
||||
lpFreqStart: lp,
|
||||
lpFreqEnd: dim,
|
||||
},
|
||||
(buffer) => {
|
||||
convolver.buffer = buffer;
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
convolver.setDuration(duration);
|
||||
convolver.generate(duration, fade, lp, dim, ir);
|
||||
return convolver;
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: make the reverb more exciting
|
||||
// check out https://blog.gskinner.com/archives/2019/02/reverb-web-audio-api.html
|
||||
|
|
|
|||
130
packages/superdough/reverbGen.mjs
Normal file
130
packages/superdough/reverbGen.mjs
Normal file
|
|
@ -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.<!Float32Array>} 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;
|
||||
|
|
@ -76,6 +76,7 @@ export const getSampleBufferSource = async (s, n, note, speed, freq, vib, vibmod
|
|||
|
||||
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();
|
||||
|
|
@ -158,7 +159,12 @@ function getSamplesPrefixHandler(url) {
|
|||
* 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 <chimp:0*2 chimp:1>")
|
||||
* @example
|
||||
* samples('shabda/speech/fr-FR/f:chocolat')
|
||||
* s("chocolat*4")
|
||||
*/
|
||||
|
||||
export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => {
|
||||
|
|
@ -168,11 +174,34 @@ export const samples = async (sampleMap, baseUrl = sampleMap._base || '', option
|
|||
if (handler) {
|
||||
return handler(sampleMap);
|
||||
}
|
||||
if (sampleMap.startsWith('bubo:')) {
|
||||
const [_, repo] = sampleMap.split(':');
|
||||
sampleMap = `github:Bubobubobubobubo/dough-${repo}`;
|
||||
}
|
||||
if (sampleMap.startsWith('github:')) {
|
||||
let [_, path] = sampleMap.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';
|
||||
}
|
||||
sampleMap = `https://raw.githubusercontent.com/${path}/strudel.json`;
|
||||
}
|
||||
if (sampleMap.startsWith('shabda:')) {
|
||||
let [_, path] = sampleMap.split('shabda:');
|
||||
sampleMap = `https://shabda.ndre.gr/${path}.json?strudel=1`;
|
||||
}
|
||||
if (sampleMap.startsWith('shabda/speech')) {
|
||||
let [_, path] = sampleMap.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('/');
|
||||
}
|
||||
sampleMap = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`;
|
||||
}
|
||||
if (typeof fetch !== 'function') {
|
||||
// not a browser
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -9,17 +9,21 @@ import './reverb.mjs';
|
|||
import './vowel.mjs';
|
||||
import { clamp } from './util.mjs';
|
||||
import workletsUrl from './worklets.mjs?url';
|
||||
import { createFilter, gainNode } from './helpers.mjs';
|
||||
import { createFilter, gainNode, getCompressor } from './helpers.mjs';
|
||||
import { map } from 'nanostores';
|
||||
import { logger } from './logger.mjs';
|
||||
import { loadBuffer } from './sampler.mjs';
|
||||
|
||||
export const soundMap = map();
|
||||
|
||||
export function registerSound(key, onTrigger, data = {}) {
|
||||
soundMap.setKey(key, { onTrigger, data });
|
||||
}
|
||||
|
||||
export function getSound(s) {
|
||||
return soundMap.get()[s];
|
||||
}
|
||||
|
||||
export const resetLoadedSounds = () => soundMap.set({});
|
||||
|
||||
let audioContext;
|
||||
|
|
@ -46,6 +50,7 @@ export const panic = () => {
|
|||
};
|
||||
|
||||
let workletsLoading;
|
||||
|
||||
function loadWorklets() {
|
||||
if (workletsLoading) {
|
||||
return workletsLoading;
|
||||
|
|
@ -89,6 +94,7 @@ export async function initAudioOnFirstClick(options) {
|
|||
|
||||
let delays = {};
|
||||
const maxfeedback = 0.98;
|
||||
|
||||
function getDelay(orbit, delaytime, delayfeedback, t) {
|
||||
if (delayfeedback > maxfeedback) {
|
||||
//logger(`delayfeedback was clamped to ${maxfeedback} to save your ears`);
|
||||
|
|
@ -107,21 +113,36 @@ function getDelay(orbit, delaytime, delayfeedback, t) {
|
|||
}
|
||||
|
||||
let reverbs = {};
|
||||
function getReverb(orbit, duration = 2) {
|
||||
|
||||
let hasChanged = (now, before) => now !== undefined && now !== before;
|
||||
|
||||
function getReverb(orbit, duration, fade, lp, dim, ir) {
|
||||
// If no reverb has been created for a given orbit, create one
|
||||
if (!reverbs[orbit]) {
|
||||
const ac = getAudioContext();
|
||||
const reverb = ac.createReverb(duration);
|
||||
const reverb = ac.createReverb(duration, fade, lp, dim, ir);
|
||||
reverb.connect(getDestination());
|
||||
reverbs[orbit] = reverb;
|
||||
}
|
||||
if (reverbs[orbit].duration !== duration) {
|
||||
reverbs[orbit] = reverbs[orbit].setDuration(duration);
|
||||
reverbs[orbit].duration = duration;
|
||||
if (
|
||||
hasChanged(duration, reverbs[orbit].duration) ||
|
||||
hasChanged(fade, reverbs[orbit].fade) ||
|
||||
hasChanged(lp, reverbs[orbit].lp) ||
|
||||
hasChanged(dim, reverbs[orbit].dim) ||
|
||||
reverbs[orbit].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
|
||||
reverbs[orbit].generate(duration, fade, lp, dim, ir);
|
||||
}
|
||||
return reverbs[orbit];
|
||||
}
|
||||
|
||||
export let analyser, analyserData /* s = {} */;
|
||||
|
||||
export function getAnalyser(/* orbit, */ fftSize = 2048) {
|
||||
if (!analyser /*s [orbit] */) {
|
||||
const analyserNode = getAudioContext().createAnalyser();
|
||||
|
|
@ -177,6 +198,7 @@ export const superdough = async (value, deadline, hapDuration) => {
|
|||
bank,
|
||||
source,
|
||||
gain = 0.8,
|
||||
postgain = 1,
|
||||
// filters
|
||||
ftype = '12db',
|
||||
fanchor = 0.5,
|
||||
|
|
@ -215,10 +237,20 @@ export const superdough = async (value, deadline, hapDuration) => {
|
|||
delaytime = 0.25,
|
||||
orbit = 1,
|
||||
room,
|
||||
size = 2,
|
||||
roomfade,
|
||||
roomlp,
|
||||
roomdim,
|
||||
roomsize,
|
||||
ir,
|
||||
i = 0,
|
||||
velocity = 1,
|
||||
analyze, // analyser wet
|
||||
fft = 8, // fftSize 0 - 10
|
||||
compressor: compressorThreshold,
|
||||
compressorRatio,
|
||||
compressorKnee,
|
||||
compressorAttack,
|
||||
compressorRelease,
|
||||
} = value;
|
||||
gain *= velocity; // legacy fix for velocity
|
||||
let toDisconnect = []; // audio nodes that will be disconnected when the source has ended
|
||||
|
|
@ -247,6 +279,7 @@ export const superdough = async (value, deadline, hapDuration) => {
|
|||
// 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;
|
||||
|
|
@ -333,6 +366,11 @@ export const superdough = async (value, deadline, hapDuration) => {
|
|||
crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush }));
|
||||
shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape }));
|
||||
|
||||
compressorThreshold !== undefined &&
|
||||
chain.push(
|
||||
getCompressor(ac, compressorThreshold, compressorRatio, compressorKnee, compressorAttack, compressorRelease),
|
||||
);
|
||||
|
||||
// panning
|
||||
if (pan !== undefined) {
|
||||
const panner = ac.createStereoPanner();
|
||||
|
|
@ -341,7 +379,7 @@ export const superdough = async (value, deadline, hapDuration) => {
|
|||
}
|
||||
|
||||
// last gain
|
||||
const post = gainNode(1);
|
||||
const post = gainNode(postgain);
|
||||
chain.push(post);
|
||||
post.connect(getDestination());
|
||||
|
||||
|
|
@ -353,8 +391,19 @@ export const superdough = async (value, deadline, hapDuration) => {
|
|||
}
|
||||
// reverb
|
||||
let reverbSend;
|
||||
if (room > 0 && size > 0) {
|
||||
const reverbNode = getReverb(orbit, size);
|
||||
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);
|
||||
}
|
||||
const reverbNode = getReverb(orbit, roomsize, roomfade, roomlp, roomdim, roomIR);
|
||||
reverbSend = effectSend(post, reverbNode, room);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { midiToFreq, noteToMidi } from './util.mjs';
|
||||
import { registerSound, getAudioContext } from './superdough.mjs';
|
||||
import { gainNode, getEnvelope, getExpEnvelope } from './helpers.mjs';
|
||||
import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
|
||||
|
||||
const mod = (freq, range = 1, type = 'sine') => {
|
||||
const ctx = getAudioContext();
|
||||
|
|
@ -20,75 +21,26 @@ const fm = (osc, harmonicityRatio, modulationIndex, wave = 'sine') => {
|
|||
return mod(modfreq, modgain, wave);
|
||||
};
|
||||
|
||||
const waveforms = ['sine', 'square', 'triangle', 'sawtooth'];
|
||||
const noises = ['pink', 'white', 'brown'];
|
||||
|
||||
export function registerSynthSounds() {
|
||||
['sine', 'square', 'triangle', 'sawtooth'].forEach((wave) => {
|
||||
[...waveforms, ...noises].forEach((s) => {
|
||||
registerSound(
|
||||
wave,
|
||||
s,
|
||||
(t, value, onended) => {
|
||||
// destructure adsr here, because the default should be different for synths and samples
|
||||
let {
|
||||
attack = 0.001,
|
||||
decay = 0.05,
|
||||
sustain = 0.6,
|
||||
release = 0.01,
|
||||
fmh: fmHarmonicity = 1,
|
||||
fmi: fmModulationIndex,
|
||||
fmenv: fmEnvelopeType = 'lin',
|
||||
fmattack: fmAttack,
|
||||
fmdecay: fmDecay,
|
||||
fmsustain: fmSustain,
|
||||
fmrelease: fmRelease,
|
||||
fmvelocity: fmVelocity,
|
||||
fmwave: fmWaveform = 'sine',
|
||||
vib = 0,
|
||||
vibmod = 0.5,
|
||||
} = value;
|
||||
let { n, note, freq } = value;
|
||||
// with synths, n and note are the same thing
|
||||
note = note || 36;
|
||||
if (typeof note === 'string') {
|
||||
note = noteToMidi(note); // e.g. c3 => 48
|
||||
}
|
||||
// get frequency
|
||||
if (!freq && typeof note === 'number') {
|
||||
freq = midiToFreq(note); // + 48);
|
||||
}
|
||||
// maybe pull out the above frequency resolution?? (there is also getFrequency but it has no default)
|
||||
// make oscillator
|
||||
const { node: o, stop } = getOscillator({
|
||||
t,
|
||||
s: wave,
|
||||
freq,
|
||||
vib,
|
||||
vibmod,
|
||||
partials: n,
|
||||
});
|
||||
let { attack = 0.001, decay = 0.05, sustain = 0.6, release = 0.01 } = value;
|
||||
|
||||
// FM + FM envelope
|
||||
let stopFm, fmEnvelope;
|
||||
if (fmModulationIndex) {
|
||||
const { node: modulator, stop } = fm(o, fmHarmonicity, fmModulationIndex, fmWaveform);
|
||||
if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].find((v) => v !== undefined)) {
|
||||
// no envelope by default
|
||||
modulator.connect(o.frequency);
|
||||
} else {
|
||||
fmAttack = fmAttack ?? 0.001;
|
||||
fmDecay = fmDecay ?? 0.001;
|
||||
fmSustain = fmSustain ?? 1;
|
||||
fmRelease = fmRelease ?? 0.001;
|
||||
fmVelocity = fmVelocity ?? 1;
|
||||
fmEnvelope = getEnvelope(fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity, t);
|
||||
if (fmEnvelopeType === 'exp') {
|
||||
fmEnvelope = getExpEnvelope(fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity, t);
|
||||
fmEnvelope.node.maxValue = fmModulationIndex * 2;
|
||||
fmEnvelope.node.minValue = 0.00001;
|
||||
}
|
||||
modulator.connect(fmEnvelope.node);
|
||||
fmEnvelope.node.connect(o.frequency);
|
||||
}
|
||||
stopFm = stop;
|
||||
let sound;
|
||||
if (waveforms.includes(s)) {
|
||||
sound = getOscillator(s, t, value);
|
||||
} else {
|
||||
sound = getNoiseOscillator(s, t);
|
||||
}
|
||||
|
||||
let { node: o, stop, triggerRelease } = sound;
|
||||
|
||||
// turn down
|
||||
const g = gainNode(0.3);
|
||||
|
||||
|
|
@ -104,10 +56,9 @@ export function registerSynthSounds() {
|
|||
node: o.connect(g).connect(envelope),
|
||||
stop: (releaseTime) => {
|
||||
releaseEnvelope(releaseTime);
|
||||
fmEnvelope?.stop(releaseTime);
|
||||
triggerRelease?.(releaseTime);
|
||||
let end = releaseTime + release;
|
||||
stop(end);
|
||||
stopFm?.(end);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
@ -122,13 +73,13 @@ export function waveformN(partials, type) {
|
|||
const ac = getAudioContext();
|
||||
const osc = ac.createOscillator();
|
||||
|
||||
const amplitudes = {
|
||||
sawtooth: (n) => 1 / n,
|
||||
square: (n) => (n % 2 === 0 ? 0 : 1 / n),
|
||||
triangle: (n) => (n % 2 === 0 ? 0 : 1 / (n * n)),
|
||||
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 (!amplitudes[type]) {
|
||||
if (!terms[type]) {
|
||||
throw new Error(`unknown wave type ${type}`);
|
||||
}
|
||||
|
||||
|
|
@ -136,8 +87,9 @@ export function waveformN(partials, type) {
|
|||
imag[0] = 0;
|
||||
let n = 1;
|
||||
while (n <= partials) {
|
||||
real[n] = amplitudes[type](n);
|
||||
imag[n] = 0;
|
||||
const [r, i] = terms[type](n);
|
||||
real[n] = r;
|
||||
imag[n] = i;
|
||||
n++;
|
||||
}
|
||||
|
||||
|
|
@ -146,36 +98,108 @@ export function waveformN(partials, type) {
|
|||
return osc;
|
||||
}
|
||||
|
||||
export function getOscillator({ s, freq, t, vib, vibmod, partials }) {
|
||||
// Make oscillator with partial count
|
||||
// expects one of waveforms as s
|
||||
export function getOscillator(
|
||||
s,
|
||||
t,
|
||||
{
|
||||
n: partials,
|
||||
note,
|
||||
freq,
|
||||
vib = 0,
|
||||
vibmod = 0.5,
|
||||
noise = 0,
|
||||
// fm
|
||||
fmh: fmHarmonicity = 1,
|
||||
fmi: fmModulationIndex,
|
||||
fmenv: fmEnvelopeType = 'lin',
|
||||
fmattack: fmAttack,
|
||||
fmdecay: fmDecay,
|
||||
fmsustain: fmSustain,
|
||||
fmrelease: fmRelease,
|
||||
fmvelocity: fmVelocity,
|
||||
fmwave: fmWaveform = 'sine',
|
||||
},
|
||||
) {
|
||||
let ac = getAudioContext();
|
||||
let o;
|
||||
// If no partials are given, use stock waveforms
|
||||
if (!partials || s === 'sine') {
|
||||
o = getAudioContext().createOscillator();
|
||||
o.type = s || 'triangle';
|
||||
} else {
|
||||
}
|
||||
// generate custom waveform if partials are given
|
||||
else {
|
||||
o = waveformN(partials, s);
|
||||
}
|
||||
|
||||
// get frequency from note...
|
||||
note = note || 36;
|
||||
if (typeof note === 'string') {
|
||||
note = noteToMidi(note); // e.g. c3 => 48
|
||||
}
|
||||
// get frequency
|
||||
if (!freq && typeof note === 'number') {
|
||||
freq = midiToFreq(note); // + 48);
|
||||
}
|
||||
|
||||
// set frequency
|
||||
o.frequency.value = Number(freq);
|
||||
o.start(t);
|
||||
|
||||
// FM
|
||||
let stopFm, fmEnvelope;
|
||||
if (fmModulationIndex) {
|
||||
const { node: modulator, stop } = fm(o, fmHarmonicity, fmModulationIndex, fmWaveform);
|
||||
if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].find((v) => v !== undefined)) {
|
||||
// no envelope by default
|
||||
modulator.connect(o.frequency);
|
||||
} else {
|
||||
fmAttack = fmAttack ?? 0.001;
|
||||
fmDecay = fmDecay ?? 0.001;
|
||||
fmSustain = fmSustain ?? 1;
|
||||
fmRelease = fmRelease ?? 0.001;
|
||||
fmVelocity = fmVelocity ?? 1;
|
||||
fmEnvelope = getEnvelope(fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity, t);
|
||||
if (fmEnvelopeType === 'exp') {
|
||||
fmEnvelope = getExpEnvelope(fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity, t);
|
||||
fmEnvelope.node.maxValue = fmModulationIndex * 2;
|
||||
fmEnvelope.node.minValue = 0.00001;
|
||||
}
|
||||
modulator.connect(fmEnvelope.node);
|
||||
fmEnvelope.node.connect(o.frequency);
|
||||
}
|
||||
stopFm = stop;
|
||||
}
|
||||
|
||||
// Additional oscillator for vibrato effect
|
||||
let vibrato_oscillator;
|
||||
let vibratoOscillator;
|
||||
if (vib > 0) {
|
||||
vibrato_oscillator = getAudioContext().createOscillator();
|
||||
vibrato_oscillator.frequency.value = vib;
|
||||
vibratoOscillator = getAudioContext().createOscillator();
|
||||
vibratoOscillator.frequency.value = vib;
|
||||
const gain = getAudioContext().createGain();
|
||||
// Vibmod is the amount of vibrato, in semitones
|
||||
gain.gain.value = vibmod * 100;
|
||||
vibrato_oscillator.connect(gain);
|
||||
vibratoOscillator.connect(gain);
|
||||
gain.connect(o.detune);
|
||||
vibrato_oscillator.start(t);
|
||||
vibratoOscillator.start(t);
|
||||
}
|
||||
|
||||
let noiseMix;
|
||||
if (noise) {
|
||||
noiseMix = getNoiseMix(o, noise, t);
|
||||
}
|
||||
|
||||
return {
|
||||
node: o,
|
||||
node: noiseMix?.node || o,
|
||||
stop: (time) => {
|
||||
vibrato_oscillator?.stop(time);
|
||||
vibratoOscillator?.stop(time);
|
||||
noiseMix?.stop(time);
|
||||
stopFm?.(time);
|
||||
o.stop(time);
|
||||
},
|
||||
triggerRelease: (time) => {
|
||||
fmEnvelope?.stop(time);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export const getZZFX = (value, t) => {
|
|||
pitchJump = 0,
|
||||
pitchJumpTime = 0,
|
||||
lfo = 0,
|
||||
noise = 0,
|
||||
znoise = 0,
|
||||
zmod = 0,
|
||||
zcrush = 0,
|
||||
zdelay = 0,
|
||||
|
|
@ -54,7 +54,7 @@ export const getZZFX = (value, t) => {
|
|||
pitchJump,
|
||||
pitchJumpTime,
|
||||
lfo,
|
||||
noise,
|
||||
znoise,
|
||||
zmod,
|
||||
zcrush,
|
||||
zdelay,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue