This commit is contained in:
Jade (Rose) Rowland 2025-09-21 23:54:04 -04:00
parent 98b7003504
commit c5bd2a7487
5 changed files with 238 additions and 175 deletions

View file

@ -402,6 +402,18 @@ export const { bandq, bpq } = registerControl('bandq', 'bpq');
* *
*/ */
export const { begin } = registerControl('begin'); export const { begin } = registerControl('begin');
/**
* A pattern of numbers from 0 to 1. Skips the beginning of each sample, e.g. `0.25` to cut off the first quarter from each sample.
*
* @memberof Pattern
* @name bufferHold
* @param {number | Pattern} amount between 0 and 1, where 1 is the length of the sample
* @example
* samples({ rave: 'rave/AREUREADY.wav' }, 'github:tidalcycles/dirt-samples')
* s("rave").begin("<0 .25 .5 .75>").fast(2)
*
*/
export const { bufferHold } = registerControl('bufferHold');
/** /**
* The same as .begin, but cuts off the end off each sample. * The same as .begin, but cuts off the end off each sample.
* *

View file

@ -23,6 +23,9 @@ if (typeof DelayNode !== 'undefined') {
start(t) { start(t) {
this.delayGain.gain.setValueAtTime(this.delayGain.gain.value, t + this.delayTime.value); this.delayGain.gain.setValueAtTime(this.delayGain.gain.value, t + this.delayTime.value);
} }
stop(t) {
this.delayGain.gain.setValueAtTime(0, t);
}
} }
AudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) { AudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) {

View file

@ -10,6 +10,13 @@ export function gainNode(value) {
return node; 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 getSlope = (y1, y2, x1, x2) => {
const denom = x2 - x1; const denom = x2 - x1;
if (denom === 0) { if (denom === 0) {

View file

@ -7,12 +7,13 @@ This program is free software: you can redistribute it and/or modify it under th
import './feedbackdelay.mjs'; import './feedbackdelay.mjs';
import './reverb.mjs'; import './reverb.mjs';
import './vowel.mjs'; import './vowel.mjs';
import { clamp, nanFallback, _mod, cycleToSeconds, secondsToCycle } from './util.mjs'; import { nanFallback, _mod, cycleToSeconds } from './util.mjs';
import workletsUrl from './worklets.mjs?audioworklet'; import workletsUrl from './worklets.mjs?audioworklet';
import { createFilter, gainNode, getCompressor, getWorklet, webAudioTimeout } from './helpers.mjs'; import { createFilter, gainNode, getCompressor, getWorklet, effectSend } from './helpers.mjs';
import { map } from 'nanostores'; import { map } from 'nanostores';
import { logger, errorLogger } from './logger.mjs'; import { logger } from './logger.mjs';
import { loadBuffer } from './sampler.mjs'; import { loadBuffer } from './sampler.mjs';
import { SuperdoughAudioController } from './superdoughoutput.mjs';
export const DEFAULT_MAX_POLYPHONY = 128; export const DEFAULT_MAX_POLYPHONY = 128;
const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard'; const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard';
@ -301,65 +302,16 @@ export async function initAudioOnFirstClick(options) {
return audioReady; return audioReady;
} }
const maxfeedback = 0.98; let controller;
function getSuperdoughAudioController() {
let channelMerger, destinationGain; if (controller == null) {
//update the output channel configuration to match user's audio device controller = new SuperdoughAudioController(getAudioContext());
export function initializeAudioOutput() { }
const audioContext = getAudioContext(); return controller;
const maxChannelCount = audioContext.destination.maxChannelCount;
audioContext.destination.channelCount = maxChannelCount;
channelMerger = new ChannelMergerNode(audioContext, { numberOfInputs: audioContext.destination.channelCount });
destinationGain = new GainNode(audioContext);
channelMerger.connect(destinationGain);
destinationGain.connect(audioContext.destination);
} }
export function connectToDestination(input, channels) {
// input: AudioNode, channels: ?Array<int> const controller = getSuperdoughAudioController();
export const connectToDestination = (input, channels = [0, 1]) => { controller.output.connectToDestination(input, channels);
const ctx = getAudioContext();
if (channelMerger == null) {
initializeAudioOutput();
}
//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(ctx);
input.connect(stereoMix);
const splitter = new ChannelSplitterNode(ctx, {
numberOfOutputs: stereoMix.channelCount,
});
stereoMix.connect(splitter);
channels.forEach((ch, i) => {
splitter.connect(channelMerger, i % stereoMix.channelCount, ch % ctx.destination.channelCount);
});
};
export const panic = () => {
if (destinationGain == null) {
return;
}
destinationGain.gain.linearRampToValueAtTime(0, getAudioContext().currentTime + 0.01);
destinationGain = null;
channelMerger == null;
};
function getDelay(orbit, delaytime, delayfeedback, t) {
if (delayfeedback > maxfeedback) {
//logger(`delayfeedback was clamped to ${maxfeedback} to save your ears`);
}
delayfeedback = clamp(delayfeedback, 0, 0.98);
let delayNode = orbits[orbit].delayNode;
if (delayNode === undefined) {
const ac = getAudioContext();
delayNode = ac.createFeedbackDelay(1, delaytime, delayfeedback);
delayNode.start?.(t); // for some reason, this throws when audion extension is installed..
connectToOrbit(delayNode, orbit);
orbits[orbit].delayNode = delayNode;
}
delayNode.delayTime.value !== delaytime && delayNode.delayTime.setValueAtTime(delaytime, t);
delayNode.feedback.value !== delayfeedback && delayNode.feedback.setValueAtTime(delayfeedback, t);
return delayNode;
} }
export function getLfo(audioContext, begin, end, properties = {}) { export function getLfo(audioContext, begin, end, properties = {}) {
@ -415,97 +367,6 @@ function getFilterType(ftype) {
return typeof ftype === 'number' ? filterTypes[Math.floor(_mod(ftype, filterTypes.length))] : ftype; return typeof ftype === 'number' ? filterTypes[Math.floor(_mod(ftype, filterTypes.length))] : ftype;
} }
// type orbit {
// output: GainNode,
// reverbNode: ConvolverNode
// delayNode: FeedbackDelayNode
// }
let orbits = {};
function connectToOrbit(node, orbit) {
if (orbits[orbit] == null) {
errorLogger(new Error('target orbit does not exist'), 'superdough');
}
node.connect(orbits[orbit].output);
}
function setOrbit(audioContext, orbit, channels) {
if (orbits[orbit] == null) {
orbits[orbit] = {
// Setup output node through which all audio filters prior to hitting
// the destination (and thus allows for global volume automation)
output: new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }),
};
connectToDestination(orbits[orbit].output, channels);
}
}
function duckOrbit(audioContext, targetOrbit, t, onsettime = 0, attacktime = 0.1, duckdepth = 1) {
const targetArr = [targetOrbit].flat();
const onsetArr = [onsettime].flat();
const attackArr = [attacktime].flat();
const depthArr = [duckdepth].flat();
targetArr.forEach((target, idx) => {
if (orbits[target] == 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];
const gainParam = orbits[target].output.gain;
webAudioTimeout(
audioContext,
() => {
const now = 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,
);
});
}
let hasChanged = (now, before) => now !== undefined && now !== before;
function getReverb(orbit, duration, fade, lp, dim, ir, irspeed, irbegin) {
// If no reverb has been created for a given orbit, create one
let reverbNode = orbits[orbit].reverbNode;
if (reverbNode === undefined) {
const ac = getAudioContext();
reverbNode = ac.createReverb(duration, fade, lp, dim, ir, irspeed, irbegin);
connectToOrbit(reverbNode, orbit);
orbits[orbit].reverbNode = reverbNode;
}
if (
hasChanged(duration, reverbNode.duration) ||
hasChanged(fade, reverbNode.fade) ||
hasChanged(lp, reverbNode.lp) ||
hasChanged(dim, reverbNode.dim) ||
hasChanged(irspeed, reverbNode.irspeed) ||
hasChanged(irbegin, reverbNode.irbegin) ||
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
reverbNode.generate(duration, fade, lp, dim, ir, irspeed, irbegin);
}
return reverbNode;
}
export let analysers = {}, export let analysers = {},
analysersData = {}; analysersData = {};
@ -538,15 +399,8 @@ export function getAnalyzerData(type = 'time', id = 1) {
return analysersData[id]; return analysersData[id];
} }
function effectSend(input, effect, wet) {
const send = gainNode(wet);
input.connect(send);
send.connect(effect);
return send;
}
export function resetGlobalEffects() { export function resetGlobalEffects() {
orbits = {}; controller.reset();
analysers = {}; analysers = {};
analysersData = {}; analysersData = {};
} }
@ -561,6 +415,7 @@ function mapChannelNumbers(channels) {
export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) => { 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 // new: t is always expected to be the absolute target onset time
const ac = getAudioContext(); const ac = getAudioContext();
const audioController = getSuperdoughAudioController();
let { stretch } = value; let { stretch } = value;
if (stretch != null) { if (stretch != null) {
@ -679,10 +534,9 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
); );
const channels = value.channels != null ? mapChannelNumbers(value.channels) : orbitChannels; const channels = value.channels != null ? mapChannelNumbers(value.channels) : orbitChannels;
setOrbit(ac, orbit, channels, t, cycle, cps); const orbitBus = audioController.getOrbit(orbit, channels);
if (duckorbit != null) { if (duckorbit != null) {
duckOrbit(ac, duckorbit, t, duckonset, duckattack, duckdepth); orbitBus.duck(duckorbit, t, duckonset, duckattack, duckdepth);
} }
gain = applyGainCurve(nanFallback(gain, 1)); gain = applyGainCurve(nanFallback(gain, 1));
@ -872,14 +726,11 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
chain.push(post); chain.push(post);
// delay // delay
let delaySend;
if (delay > 0 && delaytime > 0 && delayfeedback > 0) { if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
const delayNode = getDelay(orbit, delaytime, delayfeedback, t); orbitBus.getDelay(delaytime, delayfeedback, t);
delaySend = effectSend(post, delayNode, delay); orbitBus.sendDelay(post, delay);
audioNodes.push(delaySend);
} }
// reverb // reverb
let reverbSend;
if (room > 0) { if (room > 0) {
let roomIR; let roomIR;
if (ir !== undefined) { if (ir !== undefined) {
@ -892,25 +743,23 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
} }
roomIR = await loadBuffer(url, ac, ir, 0); roomIR = await loadBuffer(url, ac, ir, 0);
} }
const reverbNode = getReverb(orbit, roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin); orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
reverbSend = effectSend(post, reverbNode, room); orbitBus.sendReverb(post, room);
audioNodes.push(reverbSend);
} }
// analyser // analyser
let analyserSend;
if (analyze) { if (analyze) {
const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5)); const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5));
analyserSend = effectSend(post, analyserNode, 1); const analyserSend = effectSend(post, analyserNode, 1);
audioNodes.push(analyserSend); audioNodes.push(analyserSend);
} }
if (dry != null) { if (dry != null) {
dry = applyGainCurve(dry); dry = applyGainCurve(dry);
const dryGain = new GainNode(ac, { gain: dry }); const dryGain = new GainNode(ac, { gain: dry });
chain.push(dryGain); chain.push(dryGain);
connectToOrbit(dryGain, orbit); orbitBus.connectToOutput(dryGain);
} else { } else {
connectToOrbit(post, orbit); orbitBus.connectToOutput(post);
} }
// connect chain elements together // connect chain elements together

View file

@ -0,0 +1,192 @@
import { effectSend, webAudioTimeout } from './helpers.mjs';
import { errorLogger } from './logger.mjs';
let hasChanged = (now, before) => now !== undefined && now !== before;
export class Orbit {
reverbNode;
delayNode;
output;
summingNode;
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();
}
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.channelMerger.disconnect();
this.destinationGain.disconnect();
this.destinationGain = null;
this.channelMerger = null;
this.nodes = {};
this.initializeAudio();
}
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.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, onsettime: onset, attacktime: 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];
}
}