Merge branch 'main' into glossing/lfo-anything

This commit is contained in:
Aria 2025-09-13 14:32:41 -05:00
commit 502f0b5c23
65 changed files with 2152 additions and 187 deletions

View file

@ -1,6 +1,6 @@
{
"name": "superdough",
"version": "1.2.4",
"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",

View file

@ -1,7 +1,9 @@
import reverbGen from './reverbGen.mjs';
import { clamp } from './util.mjs';
if (typeof AudioContext !== 'undefined') {
AudioContext.prototype.adjustLength = function (duration, buffer) {
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++) {
@ -9,22 +11,30 @@ if (typeof AudioContext !== 'undefined') {
let newData = newBuffer.getChannelData(channel);
for (let i = 0; i < newLength; i++) {
newData[i] = oldData[i] || 0;
// 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) {
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) => {
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);
convolver.buffer = this.adjustLength(d, ir, irspeed, irbegin);
} else {
reverbGen.generateReverb(
{
@ -41,7 +51,7 @@ if (typeof AudioContext !== 'undefined') {
);
}
};
convolver.generate(duration, fade, lp, dim, ir);
convolver.generate(duration, fade, lp, dim, ir, irspeed, irbegin);
return convolver;
};
}

View file

@ -196,6 +196,52 @@ function getSamplesPrefixHandler(url) {
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
@ -217,49 +263,8 @@ function getSamplesPrefixHandler(url) {
export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => {
if (typeof sampleMap === 'string') {
// check if custom prefix handler
const handler = getSamplesPrefixHandler(sampleMap);
if (handler) {
return handler(sampleMap);
}
sampleMap = resolveSpecialPaths(sampleMap);
if (sampleMap.startsWith('github:')) {
sampleMap = githubPath(sampleMap, 'strudel.json');
}
if (sampleMap.startsWith('local:')) {
sampleMap = `http://localhost:5432`;
}
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;
}
const base = sampleMap.split('/').slice(0, -1).join('/');
if (typeof fetch === 'undefined') {
// skip fetch when in node / testing
return;
}
return fetch(sampleMap)
.then((res) => res.json())
.then((json) => samples(json, baseUrl || json._base || base, options))
.catch((error) => {
console.error(error);
throw new Error(`error loading "${sampleMap}"`);
});
const [json, base] = await fetchSampleMap(sampleMap);
return samples(json, baseUrl || base, options);
}
const { prebake, tag } = options;
processSampleMap(

View file

@ -204,7 +204,7 @@ export const resetLoadedSounds = () => soundMap.set({});
let audioContext;
export const setDefaultAudioContext = () => {
audioContext = new AudioContext();
audioContext = new AudioContext({ latencyHint: 'playback' });
return audioContext;
};
@ -220,11 +220,17 @@ export function getAudioContextCurrentTime() {
return getAudioContext().currentTime;
}
let externalWorklets = [];
export function registerWorklet(url) {
externalWorklets.push(url);
}
let workletsLoading;
function loadWorklets() {
if (!workletsLoading) {
const audioCtx = getAudioContext();
workletsLoading = audioCtx.audioWorklet.addModule(workletsUrl);
const allWorkletURLs = externalWorklets.concat([workletsUrl]);
workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL)));
}
return workletsLoading;
@ -290,7 +296,6 @@ export async function initAudioOnFirstClick(options) {
return audioReady;
}
let delays = {};
const maxfeedback = 0.98;
let channelMerger, destinationGain;
@ -334,7 +339,7 @@ export const panic = () => {
channelMerger == null;
};
function getDelay(orbit, delaytime, delayfeedback, t, channels) {
function getDelay(orbit, delaytime, delayfeedback, t) {
if (delayfeedback > maxfeedback) {
//logger(`delayfeedback was clamped to ${maxfeedback} to save your ears`);
}
@ -412,7 +417,7 @@ function connectToOrbit(node, orbit) {
function setOrbit(audioContext, orbit, channels) {
if (orbits[orbit] == null) {
orbits[orbit] = {
gain: new GainNode(audioContext, { gain: 1 }),
gain: new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }),
};
connectToDestination(orbits[orbit].gain, channels);
}
@ -440,19 +445,22 @@ function duckOrbit(audioContext, targetOrbit, t, attacktime = 0.1, duckdepth = 1
}
let hasChanged = (now, before) => now !== undefined && now !== before;
function getReverb(orbit, duration, fade, lp, dim, ir) {
function getReverb(orbit, duration, fade, lp, dim, ir, irspeed, irbegin) {
// If no reverb has been created for a given orbit, create one
if (!orbits[orbit].reverbNode) {
const ac = getAudioContext();
const reverb = ac.createReverb(duration, fade, lp, dim, ir);
const reverb = ac.createReverb(duration, fade, lp, dim, ir, irspeed, irbegin);
connectToOrbit(reverb, orbit);
orbits[orbit].reverbNode = reverb;
}
if (
hasChanged(duration, orbits[orbit].reverbNode.duration) ||
hasChanged(fade, orbits[orbit].reverbNode.fade) ||
hasChanged(lp, orbits[orbit].reverbNode.lp) ||
hasChanged(dim, orbits[orbit].reverbNode.dim) ||
hasChanged(irspeed, orbits[orbit].reverbNode.irspeed) ||
hasChanged(irbegin, orbits[orbit].reverbNode.irbegin) ||
orbits[orbit].reverbNode.ir !== ir
) {
// only regenerate when something has changed
@ -460,7 +468,7 @@ function getReverb(orbit, duration, fade, lp, dim, ir) {
// stack(s("a"), s("b").rsize(8)).room(.5)
// this only works when args may stay undefined until here
// setting default values breaks this
orbits[orbit].reverbNode.generate(duration, fade, lp, dim, ir);
orbits[orbit].reverbNode.generate(duration, fade, lp, dim, ir, irspeed, irbegin);
}
return orbits[orbit].reverbNode;
}
@ -783,6 +791,8 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
roomdim,
roomsize,
ir,
irspeed,
irbegin,
i = getDefaultValue('i'),
velocity = getDefaultValue('velocity'),
analyze, // analyser wet
@ -1059,7 +1069,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
// delay
let delaySend;
if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
const delayNode = getDelay(orbit, delaytime, delayfeedback, t, orbitChannels);
const delayNode = getDelay(orbit, delaytime, delayfeedback, t);
nodes['delay'] = [delayNode];
delaySend = effectSend(post, delayNode, delay);
audioNodes.push(delaySend);
@ -1078,7 +1088,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
}
roomIR = await loadBuffer(url, ac, ir, 0);
}
const reverbNode = getReverb(orbit, roomsize, roomfade, roomlp, roomdim, roomIR, orbitChannels);
const reverbNode = getReverb(orbit, roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
nodes['room'] = [reverbNode];
reverbSend = effectSend(post, reverbNode, room);
audioNodes.push(reverbSend);