Compare commits

..

21 Commits

Author SHA1 Message Date
Aria 79e0503474 Properly handle multiple releases 2025-11-18 19:58:03 -06:00
Aria 3a3d8299b8 Tweaks to deal with clicking 2025-11-18 19:30:51 -06:00
Aria e0a61b7f1e Further cleanup of chaining; remove fxr in favor of ADSR 2025-11-18 18:31:14 -06:00
Aria 5647bd84f9 Switch to a full signal chain including sources 2025-11-18 17:47:17 -06:00
Aria 971cfe15fc Merge main and use parray 2025-11-18 16:07:22 -06:00
Aria be8aebff92 Merge branch 'main' into glossing/multifx 2025-11-18 16:04:17 -06:00
Aria 9db821e50f Map -> forEach 2025-11-18 16:03:56 -06:00
Aria 89247d92db Revert to old stop format for compatibility 2025-11-18 13:52:22 -06:00
Aria c828adce03 More cleanup cleanup 2025-11-18 11:29:32 -06:00
Aria 7e45389db3 Merge branch 'main' into glossing/multifx 2025-11-16 14:34:50 -06:00
Aria 601eaebb93 More cleanup 2025-11-16 14:27:47 -06:00
Aria 6b72e421cd Some cleanup from main merge 2025-11-16 14:20:53 -06:00
Aria f3f7824111 Merge branch 'main' into glossing/multifx 2025-11-16 13:37:24 -06:00
Aria 29ee90f994 Fully working version 2025-10-22 22:32:14 -05:00
Aria 837b87e13a Some merge cleanup 2025-10-14 21:47:59 -05:00
Aria d07134dc5f Merge main 2025-10-14 21:36:57 -05:00
Aria 93a38003ed Codeformat and pure worklet calls 2025-09-23 11:55:27 -05:00
Aria bb0ea3b4ad Working version with cleanup and such 2025-09-22 21:56:47 -05:00
Aria 99e4855545 Working version 2025-09-22 16:39:12 -05:00
Aria 555949366e First pass 2025-09-21 23:29:39 -05:00
Aria 93f7265f5c PoC 2025-09-21 13:11:04 -05:00
20 changed files with 687 additions and 777 deletions
+2 -19
View File
@@ -162,7 +162,6 @@ export class StrudelMirror {
this.onDraw = onDraw || this.draw;
this.id = id || s4();
this.solo = solo;
this.hasPainters = false;
this.drawer = new Drawer((haps, time, _, painters) => {
const currentFrame = haps.filter((hap) => hap.isActive(time));
@@ -178,7 +177,7 @@ export class StrudelMirror {
onToggle: (started) => {
replOptions?.onToggle?.(started);
if (started) {
this.shouldAnimate() && this.drawer.start(this.repl.scheduler);
this.drawer.start(this.repl.scheduler);
if (this.solo) {
// stop other repls when this one is started
document.dispatchEvent(
@@ -208,10 +207,9 @@ export class StrudelMirror {
updateWidgets(this.editor, widgets);
updateMiniLocations(this.editor, this.miniLocations);
replOptions?.afterEval?.(options);
this.hasPainters = options.pattern.getPainters().length > 0;
// if no painters are set (.onPaint was not called), then we only need
// the present moment (for highlighting)
const drawTime = this.hasPainters ? this.drawTime : [0, 0];
const drawTime = options.pattern.getPainters().length ? this.drawTime : [0, 0];
this.drawer.setDrawTime(drawTime);
// invalidate drawer after we've set the appropriate drawTime
this.drawer.invalidate(this.repl.scheduler);
@@ -239,7 +237,6 @@ export class StrudelMirror {
cmEditor.style.backgroundColor = 'transparent';
}
const settings = codemirrorSettings.get();
this.isPatternHighlightingEnabled = parseBooleans(settings.isPatternHighlightingEnabled);
this.setFontSize(settings.fontSize);
this.setFontFamily(settings.fontFamily);
@@ -324,17 +321,8 @@ export class StrudelMirror {
flash(this.editor, ms);
}
highlight(haps, time) {
if (!this.isPatternHighlightingEnabled) {
return;
}
highlightMiniLocations(this.editor, time, haps);
}
shouldAnimate() {
return (
this.repl.scheduler.started &&
(this.isPatternHighlightingEnabled || this.hasPainters || this.onDraw !== this.draw)
);
}
setFontSize(size) {
this.root.style.fontSize = size + 'px';
}
@@ -355,11 +343,6 @@ export class StrudelMirror {
this.editor.dispatch({
effects: compartments[key].reconfigure(newValue),
});
if (key === 'isPatternHighlightingEnabled') {
this.isPatternHighlightingEnabled = value;
this.drawer.stop();
this.shouldAnimate() && this.drawer.start(this.repl.scheduler);
}
if (key === 'theme') {
activateTheme(value);
}
-3
View File
@@ -91,9 +91,6 @@ Fraction.prototype.or = function (other) {
};
const fraction = (n) => {
if (n instanceof Fraction) {
return n;
}
if (typeof n === 'number') {
/*
https://github.com/infusion/Fraction.js/#doubles
+23
View File
@@ -3687,3 +3687,26 @@ Pattern.prototype.phases = function (list) {
export const phases = (list) => {
return _ensureListPattern(list).as('phases');
};
/**
* Establishes a signal chain. Can be called in sequence like pat.chain(...).chain(...) and so forth
* and/or in a single .chain(..., ..., etc) call. The arguments to `chain` are _patterns_ which each act like
* a self-contained pattern and follow the normal [signal chain](https://strudel.cc/learn/effects/).
*
* If multiple sound generators are present within the chain, they will be mixed in at the location where
* they are declared.
*
* @name chain
* @memberof Pattern
* @param {Pattern | Pattern[]} patterns Patterns to combine into a single chain
* @returns Pattern
*/
Pattern.prototype.chain = function (...pats) {
pats = pats.map(reify);
return this.withValue((v) => (vEff) => {
const currChain = v.chain ?? [];
return { ...v, chain: currChain.concat(vEff) };
}).appLeft(parray(pats));
};
export const chain = (pats) => pure({}).chain(pats);
+3 -14
View File
@@ -152,9 +152,9 @@ export function repl({
// allows muting a pattern x with x_ or _x
return silence;
}
if (id.includes('$')) {
if (id === '$') {
// allows adding anonymous patterns with $:
id = `${id}${anonymousIndex}`;
id = `$${anonymousIndex}`;
anonymousIndex++;
}
pPatterns[id] = this;
@@ -215,19 +215,8 @@ export function repl({
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
if (Object.keys(pPatterns).length) {
let patterns = [];
let soloActive = false;
for (const [key, value] of Object.entries(pPatterns)) {
// handle soloed patterns ex: S$: s("bd!4")
const isSolod = key.length > 1 && key.startsWith('S');
if (isSolod && soloActive === false) {
// first time we see a soloed pattern, clear existing patterns
patterns = [];
soloActive = true;
}
if (!soloActive || (soloActive && isSolod)) {
const valWithState = value.withState((state) => state.setControls({ id: key }));
patterns.push(valWithState);
}
patterns.push(value.withState((state) => state.setControls({ id: key })));
}
if (eachTransform) {
// Explicit lambda so only element (not index and array) are passed
+1 -1
View File
@@ -524,7 +524,7 @@ export const wchoose = (...pairs) => wchooseWith(rand, ...pairs);
* @example
* wchooseCycles(["bd",10], ["hh",1], ["sd",1]).s().fast(8)
* @example
* wchooseCycles(["c c c",5], ["a a a",3], ["f f f",1]).fast(4).note()
* wchooseCycles(["bd bd bd",5], ["hh hh hh",3], ["sd sd sd",1]).fast(4).s()
* @example
* // The probability can itself be a pattern
* wchooseCycles(["bd(3,8)","<5 0>"], ["hh hh hh",3]).fast(4).s()
+1 -6
View File
@@ -253,12 +253,7 @@ export const pairs = function (xs) {
return result;
};
// Optimized clamp (~1.7x faster than Math.max/min approach)
export const clamp = (num, min, max) => {
if (num < min) return min;
if (num > max) return max;
return num;
};
export const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
/* solmization, not used yet */
const solfeggio = ['Do', 'Reb', 'Re', 'Mib', 'Mi', 'Fa', 'Solb', 'Sol', 'Lab', 'La', 'Sib', 'Si']; /*solffegio notes*/
+5 -9
View File
@@ -1,5 +1,6 @@
import { noteToMidi, freqToMidi, getSoundIndex } from '@strudel/core';
import {
cleanupNodes,
getAudioContext,
registerSound,
getParamADSR,
@@ -161,22 +162,17 @@ export function registerSoundfonts() {
const node = bufferSource.connect(envGain);
const holdEnd = time + duration;
getParamADSR(node.gain, attack, decay, sustain, release, 0, 0.3, time, holdEnd, 'linear');
let envEnd = holdEnd + release + 0.01;
// vibrato
let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, time);
// pitch envelope
getPitchEnvelope(bufferSource.detune, value, time, holdEnd);
bufferSource.stop(envEnd);
const stop = (releaseTime) => {};
bufferSource.onended = () => {
bufferSource.disconnect();
vibratoOscillator?.stop();
node.disconnect();
cleanupNodes([bufferSource, vibratoOscillator, node]);
onended();
};
return { node, stop };
const envEnd = holdEnd + release + 0.01;
bufferSource.stop(envEnd);
return { node, stop: bufferSource.stop };
},
{ type: 'soundfont', prebake: true, fonts },
);
+25 -81
View File
@@ -36,27 +36,6 @@ export function getWorklet(ac, processor, params, config) {
return node;
}
// Pool of inactive (weakly referenced) voices to be reused if possible
const voicePools = {};
// Cycles through the pool attempting to find an inactive voice that hasn't been GC'd
export const claimVoice = (key) => {
voicePools[key] ??= [];
const pool = voicePools[key];
let node;
while (pool.length && !node) {
const ref = pool.pop();
node = ref?.deref?.();
}
return node;
};
// Releases a voice from use and adds to the inactive pool
export const releaseVoice = (key, node) => {
voicePools[key] ??= [];
voicePools[key].push(new WeakRef(node));
};
export const getParamADSR = (
param,
attack,
@@ -259,7 +238,7 @@ export function createFilter(context, start, end, params, cps) {
if (sync != null) {
rate = cps * sync;
}
const lfoValues = { depth, dcoffset, skew, shape, frequency: rate, min: 10, max: 20000 };
const lfoValues = { depth, dcoffset, skew, shape, frequency: rate };
getParamLfo(context, frequencyParam, start, end, lfoValues);
return filter;
}
@@ -283,15 +262,7 @@ export function drywet(dry, wet, wetAmount = 0) {
let mix = ac.createGain();
dry_gain.connect(mix);
wet_gain.connect(mix);
return {
node: mix,
onended: () => {
dry_gain.disconnect(mix);
wet_gain.disconnect(mix);
dry.disconnect(dry_gain);
wet.disconnect(wet_gain);
},
};
return mix;
}
let curves = ['linear', 'exponential'];
@@ -326,10 +297,6 @@ export function getVibratoOscillator(param, value, t) {
gain.gain.value = vibmod * 100;
vibratoOscillator.connect(gain);
gain.connect(param);
vibratoOscillator.onended = () => {
gain.disconnect(param);
vibratoOscillator.disconnect(gain);
};
vibratoOscillator.start(t);
return vibratoOscillator;
}
@@ -339,17 +306,6 @@ export function scheduleAtTime(callback, targetTime, audioContext = getAudioCont
const currentTime = audioContext.currentTime;
webAudioTimeout(audioContext, callback, currentTime, targetTime);
}
let silencer;
function getSilencer() {
const ac = getAudioContext();
if (!silencer) {
silencer = gainNode(0);
silencer.connect(ac.destination);
}
return silencer;
}
// ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities
// a bit of a hack, but it works very well :)
export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
@@ -357,11 +313,18 @@ export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
// 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 = getSilencer();
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 {
@@ -447,16 +410,11 @@ export function applyFM(param, value, begin) {
// Saturation curves
const fastTanh = (x) => {
const x2 = x * x;
const y = (x * (27 + x2)) / (27 + 9 * x2);
return y < -1 ? -1 : y > 1 ? 1 : y;
};
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) => fastTanh(x * (1 + k));
const _soft = (x, k) => Math.tanh(x * (1 + k));
const _hard = (x, k) => clamp((1 + k) * x, -1, 1);
const _fold = (x, k) => {
@@ -496,8 +454,13 @@ const _chebyshev = (x, k) => {
let tnm1 = 1;
let tnm2 = x;
let tn;
let y = x;
for (let i = 2; i < 64; i++) {
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;
@@ -555,32 +518,13 @@ export const getFrequencyFromValue = (value, defaultNote = 36) => {
return Number(freq);
};
export const destroyAudioWorkletNode = (node) => {
if (node == null) {
return;
}
node.disconnect();
node.parameters.get('end')?.setValueAtTime(0, 0);
export const cleanupNode = (node, time) => {
if (node == null) return;
node.disconnect?.();
node.parameters?.get('end')?.setValueAtTime(0, 0);
node.stop?.(time);
};
export const setParams = (node, params, t) => {
const ac = getAudioContext();
t ??= ac.currentTime;
const workletParams = node && node.parameters && typeof node.parameters.get === 'function';
for (const [key, value] of Object.entries(params)) {
if (value == null) continue;
let param;
if (workletParams) {
param = node.parameters.get(key);
}
if (param == null && node[key] instanceof AudioParam) {
param = node[key];
}
if (param instanceof AudioParam) {
param.cancelScheduledValues(t);
param.setValueAtTime(value, t);
} else {
node[key] = value;
}
}
export const cleanupNodes = (nodes, time) => {
nodes.forEach((n) => cleanupNode(n, time));
};
+1 -2
View File
@@ -65,9 +65,8 @@ export function getNoiseOscillator(type = 'white', t, density = 0.02) {
export function getNoiseMix(inputNode, wet, t) {
const noiseOscillator = getNoiseOscillator('pink', t);
const noiseMix = drywet(inputNode, noiseOscillator.node, wet);
noiseOscillator.node.onended = () => noiseMix.onended();
return {
node: noiseMix.node,
node: noiseMix,
stop: (time) => noiseOscillator?.stop(time),
};
}
+4 -12
View File
@@ -1,7 +1,7 @@
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 { getADSRValues, cleanupNodes, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs';
import { logger } from './logger.mjs';
const bufferCache = {}; // string: Promise<ArrayBuffer>
@@ -319,19 +319,11 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
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();
const stop = () => {
cleanupNodes([bufferSource, vibratoOscillator, node]);
onended();
};
let envEnd = holdEnd + release + 0.01;
bufferSource.stop(envEnd);
const stop = (endTime) => {
bufferSource.stop(endTime);
};
const handle = { node: out, bufferSource, stop };
const handle = { node: out, stop };
// cut groups
if (cut !== undefined) {
+351 -244
View File
@@ -7,9 +7,22 @@ This program is free software: you can redistribute it and/or modify it under th
import './feedbackdelay.mjs';
import './reverb.mjs';
import './vowel.mjs';
import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
import { _mod, clamp, cycleToSeconds, pickAndRename } from './util.mjs';
import workletsUrl from './worklets.mjs?audioworklet';
import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend } from './helpers.mjs';
import {
cleanupNode,
cleanupNodes,
createFilter,
gainNode,
getADSRValues,
getCompressor,
getDistortion,
getLfo,
getParamADSR,
getWorklet,
effectSend,
webAudioTimeout,
} from './helpers.mjs';
import { map } from 'nanostores';
import { logger } from './logger.mjs';
import { loadBuffer } from './sampler.mjs';
@@ -159,6 +172,9 @@ let defaultDefaultValues = {
i: 1,
velocity: 1,
fft: 8,
tremolodepth: 1,
tremolophase: 0,
drive: 0.69,
};
const defaultDefaultDefaultValues = Object.freeze({ ...defaultDefaultValues });
@@ -391,44 +407,17 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
}
// 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'),
release = 0,
//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'),
@@ -443,18 +432,21 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
irspeed,
irbegin,
i = getDefaultValue('i'),
velocity = getDefaultValue('velocity'),
analyze, // analyser wet
fft = getDefaultValue('fft'), // fftSize 0 - 10
compressor: compressorThreshold,
compressorRatio,
compressorKnee,
compressorAttack,
compressorRelease,
chain: subChains = [],
} = value;
delaytime = delaytime ?? cycleToSeconds(delaysync, cps);
if (value.wtPosSynced != null) {
value.wtPosRate /= cps;
}
if (value.wtWarpSynced != null) {
value.wtWarpRate /= cps;
}
const orbitChannels = mapChannelNumbers(
multiChannelOrbits && orbit > 0 ? [orbit * 2 - 1, orbit * 2] : getDefaultValue('channels'),
);
@@ -465,33 +457,20 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
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 stopped if maximum polyphony is exceeded
while (activeSoundSources.size >= maxPolyphony) {
const { value: entry } = activeSoundSources.entries().next();
if (!entry) break;
const [oldChainID, ref] = entry;
const handle = ref?.deref();
// 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].deref();
const chainID = ch.value[0];
const endTime = t + 0.25;
handle?.node?.gain?.linearRampToValueAtTime?.(0, endTime);
handle?.stop?.(endTime);
activeSoundSources.delete(oldChainID);
source?.node?.gain?.linearRampToValueAtTime(0, endTime);
source?.stop?.(endTime);
activeSoundSources.delete(chainID);
}
let audioNodes = [];
if (['-', '~', '_'].includes(s)) {
return;
}
@@ -499,202 +478,318 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
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, new WeakRef(soundHandle)); // allow GC
const end = t + hapDuration;
let fullRelease = release;
for (const ch of subChains) {
fullRelease = Math.max(fullRelease, ch.release ?? 0);
}
const fullEndWithRelease = end + fullRelease + 0.02;
// The following ensures we run through the value once as normal _and then_ through all the subchains
subChains = [value, ...subChains];
let prev;
for (const ch of subChains) {
const audioNodes = [];
const chain = [];
ch.duration = hapDuration;
const endWithRelease = end + Math.max(release, ch.release ?? 0);
const chainID = Math.round(Math.random() * 1000000);
// get source AudioNode
let sourceNode;
if (source) {
sourceNode = source(t, ch, hapDuration, cps);
chain.push(sourceNode);
} else if (ch.s !== undefined && getSound(ch.s)) {
const { onTrigger } = getSound(ch.s);
const soundHandle = await onTrigger(t, ch, () => {}, cps);
if (soundHandle) {
sourceNode = soundHandle.node;
activeSoundSources.set(chainID, new WeakRef(soundHandle)); // allow GC
}
chain.push(sourceNode);
} else if (ch.s !== undefined) {
throw new Error(`sound ${ch.s} not found! Is it loaded?`);
}
} 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 (value.cutoff !== undefined) {
const lpMap = {
frequency: 'cutoff',
q: 'resonance',
attack: 'lpattack',
decay: 'lpdecay',
sustain: 'lpsustain',
release: 'lprelease',
env: 'lpenv',
anchor: 'fanchor',
model: 'ftype',
drive: 'drive',
rate: 'lprate',
sync: 'lpsync',
depth: 'lpdepth',
shape: 'lpshape',
dcoffset: 'lpdc',
skew: 'lpskew',
};
const lpParams = pickAndRename(value, lpMap);
lpParams.type = 'lowpass';
let lp = () => createFilter(ac, t, end, lpParams, cps);
chain.push(lp());
if (ftype === '24db') {
chain.push(lp());
if (ac.currentTime > t) {
logger('[webaudio] skip hap: still loading', ac.currentTime - t);
return;
}
}
if (value.hcutoff !== undefined) {
const hpMap = {
frequency: 'hcutoff',
q: 'hresonance',
attack: 'hpattack',
decay: 'hpdecay',
sustain: 'hpsustain',
release: 'hprelease',
env: 'hpenv',
anchor: 'fanchor',
model: 'ftype',
drive: 'drive',
rate: 'hprate',
sync: 'hpsync',
depth: 'hpdepth',
shape: 'hpshape',
dcoffset: 'hpdc',
skew: 'hpskew',
};
const hpParams = pickAndRename(value, hpMap);
hpParams.type = 'highpass';
let hp = () => createFilter(ac, t, end, hpParams, cps);
chain.push(hp());
if (ftype === '24db') {
chain.push(hp());
const prevExists = prev !== undefined;
const sourceExists = sourceNode !== undefined;
if (!prevExists && !sourceExists) {
// if there are no signals, we will just silently continue
// this can be used for things like speed(0) in the sampler
continue;
} else if (prevExists && !sourceExists) {
// just FX -- apply ADSR now
const adsrParams = [ch.attack, ch.decay, ch.sustain, ch.release];
if (adsrParams.some((v) => v != null)) {
const [attack, decay, sustain, release] = getADSRValues(adsrParams, 'linear', [0.001, 0.05, 0.6, 0.01]);
const adsrNode = gainNode(1);
chain.push(adsrNode);
getParamADSR(adsrNode.gain, attack, decay, sustain, release, 0, 1, t, t + hapDuration, 'linear');
}
}
}
if (value.bandf !== undefined) {
const bpMap = {
frequency: 'bandf',
q: 'bandq',
attack: 'bpattack',
decay: 'bpdecay',
sustain: 'bpsustain',
release: 'bprelease',
env: 'bpenv',
anchor: 'fanchor',
model: 'ftype',
drive: 'drive',
rate: 'bprate',
sync: 'bpsync',
depth: 'bpdepth',
shape: 'bpshape',
dcoffset: 'bpdc',
skew: 'bpskew',
};
const bpParams = pickAndRename(value, bpMap);
bpParams.type = 'bandpass';
let bp = () => createFilter(ac, t, end, bpParams, cps);
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 }));
shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape, postgain: shapevol }));
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),
webAudioTimeout(
ac,
() => {
audioNodes.forEach((n) => n?.disconnect());
activeSoundSources.delete(chainID);
},
0,
fullEndWithRelease,
);
let {
gain = getDefaultValue('gain'),
velocity = getDefaultValue('velocity'),
shapevol = getDefaultValue('shapevol'),
distorttype = getDefaultValue('distorttype'),
distortvol = getDefaultValue('distortvol'),
tremolodepth = getDefaultValue('tremolodepth'),
phaserdepth = getDefaultValue('phaserdepth'),
delay = getDefaultValue('delay'),
delayfeedback = getDefaultValue('delayfeedback'),
delaysync = getDefaultValue('delaysync'),
delaytime,
} = ch;
delaytime = delaytime ?? cycleToSeconds(delaysync, cps);
gain = applyGainCurve(gain);
velocity = applyGainCurve(velocity);
gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future
shapevol = applyGainCurve(shapevol);
distortvol = applyGainCurve(distortvol);
tremolodepth = applyGainCurve(tremolodepth);
ch.drive = ch.drive ?? getDefaultValue('drive');
ch.fanchor = ch.fanchor ?? getDefaultValue('fanchor');
ch.stretch !== undefined && chain.push(getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: ch.stretch }));
// 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);
// gain stage
chain.push(gainNode(gain));
//filter
const ftype = getFilterType(ch.ftype);
if (ch.cutoff !== undefined) {
const lpMap = {
frequency: 'cutoff',
q: 'resonance',
attack: 'lpattack',
decay: 'lpdecay',
sustain: 'lpsustain',
release: 'lprelease',
env: 'lpenv',
anchor: 'fanchor',
model: 'ftype',
drive: 'drive',
rate: 'lprate',
sync: 'lpsync',
depth: 'lpdepth',
shape: 'lpshape',
dcoffset: 'lpdc',
skew: 'lpskew',
};
const lpParams = pickAndRename(ch, lpMap);
lpParams.type = 'lowpass';
let lp = () => createFilter(ac, t, end, lpParams, cps);
chain.push(lp());
if (ch.ftype === '24db') {
chain.push(lp());
}
}
if (ch.hcutoff !== undefined) {
const hpMap = {
frequency: 'hcutoff',
q: 'hresonance',
attack: 'hpattack',
decay: 'hpdecay',
sustain: 'hpsustain',
release: 'hprelease',
env: 'hpenv',
anchor: 'fanchor',
model: 'ftype',
drive: 'drive',
rate: 'hprate',
sync: 'hpsync',
depth: 'hpdepth',
shape: 'hpshape',
dcoffset: 'hpdc',
skew: 'hpskew',
};
const hpParams = pickAndRename(ch, hpMap);
hpParams.type = 'highpass';
let hp = () => createFilter(ac, t, end, hpParams, cps);
chain.push(hp());
if (ch.ftype === '24db') {
chain.push(hp());
}
}
if (ch.bandf !== undefined) {
const bpMap = {
frequency: 'bandf',
q: 'bandq',
attack: 'bpattack',
decay: 'bpdecay',
sustain: 'bpsustain',
release: 'bprelease',
env: 'bpenv',
anchor: 'fanchor',
model: 'ftype',
drive: 'drive',
rate: 'bprate',
sync: 'bpsync',
depth: 'bpdepth',
shape: 'bpshape',
dcoffset: 'bpdc',
skew: 'bpskew',
};
const bpParams = pickAndRename(ch, bpMap);
bpParams.type = 'bandpass';
let bp = () => createFilter(ac, t, end, bpParams, cps);
chain.push(bp());
if (ftype === '24db') {
chain.push(bp());
}
}
if (ch.vowel !== undefined) {
const vowelFilter = ac.createVowelFilter(ch.vowel);
chain.push(vowelFilter);
}
// effects
ch.coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse: ch.coarse }));
ch.crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush: ch.crush }));
ch.shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape: ch.shape, postgain: shapevol }));
ch.distort !== undefined && chain.push(getDistortion(ch.distort, distortvol, distorttype));
let tremolo = ch.tremolo;
if (ch.tremolosync != null) {
tremolo = cps * ch.tremolosync;
}
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: ch.tremoloskew ?? (ch.tremoloshape != null ? 0.5 : 1),
frequency: tremolo,
depth: tremolodepth,
time,
dcoffset: 0,
shape: ch.tremoloshape,
phaseoffset: ch.tremolophase,
min: 0,
max: 1,
curve: 1.5,
});
lfo.connect(amGain.gain);
chain.push(amGain);
}
ch.compressor !== undefined &&
chain.push(
getCompressor(
ac,
ch.compressor,
ch.compressorRatio,
ch.compressorKnee,
ch.compressorAttack,
ch.compressorRelease,
),
);
// panning
if (ch.pan !== undefined) {
const panner = ac.createStereoPanner();
panner.pan.value = 2 * ch.pan - 1;
chain.push(panner);
}
// phaser
if (ch.phaserrate !== undefined && phaserdepth > 0) {
const phaserFX = getPhaser(t, endWithRelease, ch.phaserrate, phaserdepth, ch.phasercenter, ch.phasersweep);
chain.push(phaserFX);
}
// delay
if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
const dry = gainNode(1);
delayfeedback = clamp(delayfeedback, 0, 0.98);
const delayNode = ac.createFeedbackDelay(1, delaytime, delayfeedback);
const wetDelay = gainNode(delay);
const dryDelay = gainNode(ch.dry ?? 1);
const sum = new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
dry.connect(dryDelay).connect(sum);
dry.connect(delayNode).connect(wetDelay).connect(sum);
const stop = () => cleanupNodes([dryDelay, delayNode, dry, wetDelay]);
chain.push({ input: dry, output: sum, disconnect: stop });
}
// reverb
if (ch.room > 0) {
let roomIR;
if (ch.ir !== undefined) {
let url;
let sample = getSound(ch.ir);
if (Array.isArray(sample)) {
url = sample.data.samples[ch.i % sample.data.samples.length];
} else if (typeof sample === 'object') {
url = Object.values(sample.data.samples).flat()[ch.i % Object.values(sample.data.samples).length];
}
roomIR = await loadBuffer(url, ac, ch.ir, 0);
}
const dry = gainNode(1);
const reverbNode = ac.createReverb(
ch.roomsize,
ch.roomfade,
ch.roomlp,
ch.roomdim,
roomIR,
ch.irspeed,
ch.irbegin,
);
const wetReverb = gainNode(ch.room);
const dryReverb = gainNode(ch.dry ?? 1);
const sum = new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
dry.connect(dryReverb).connect(sum);
dry.connect(reverbNode).connect(wetReverb).connect(sum);
const stop = () => cleanupNodes([dryReverb, reverbNode, dry, wetReverb]);
chain.push({ input: dry, output: sum, disconnect: stop });
}
if (prevExists) {
const prevOut = prev.output ?? prev;
if (sourceExists) {
// mix with the previous output
const mixer = new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
prevOut.connect(mixer);
chain.push(mixer);
} else {
prevOut.connect(chain[0]);
}
}
// connect chain elements together
chain.slice(1).reduce((last, current) => {
const lastC = last.output ?? last;
const currentC = current.input ?? current;
lastC.connect(currentC);
return current.output ?? current;
}, chain[0]);
audioNodes.push(...chain);
prev = chain[chain.length - 1];
}
const outerAudioNodes = [];
const outerChain = [prev];
// last gain
const post = new GainNode(ac, { gain: postgain });
chain.push(post);
outerChain.push(post);
// delay
if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
orbitBus.getDelay(delaytime, delayfeedback, t);
const send = orbitBus.sendDelay(post, delay);
audioNodes.push(send);
outerAudioNodes.push(send);
}
// reverb
if (room > 0) {
@@ -711,7 +806,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
}
orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
const send = orbitBus.sendReverb(post, room);
audioNodes.push(send);
outerAudioNodes.push(send);
}
if (djf != null) {
@@ -722,20 +817,32 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
if (analyze) {
const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5));
const analyserSend = effectSend(post, analyserNode, 1);
audioNodes.push(analyserSend);
outerAudioNodes.push(analyserSend);
}
if (dry != null) {
dry = applyGainCurve(dry);
const dryGain = new GainNode(ac, { gain: dry });
chain.push(dryGain);
outerChain.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);
outerChain.slice(1).reduce((last, current) => {
const lastC = last.output ?? last;
const currentC = current.input ?? current;
lastC.connect(currentC);
return current.output ?? current;
}, outerChain[0]);
outerAudioNodes.push(...outerChain);
webAudioTimeout(
ac,
() => {
outerAudioNodes.forEach((n) => n?.disconnect?.());
},
0,
fullEndWithRelease,
);
};
export const superdoughTrigger = (t, hap, ct, cps) => {
+56 -117
View File
@@ -3,8 +3,7 @@ import { registerSound, soundMap } from './superdough.mjs';
import { getAudioContext } from './audioContext.mjs';
import {
applyFM,
claimVoice,
destroyAudioWorkletNode,
cleanupNodes,
gainNode,
getADSRValues,
getFrequencyFromValue,
@@ -13,8 +12,6 @@ import {
getPitchEnvelope,
getVibratoOscillator,
getWorklet,
setParams,
releaseVoice,
noises,
webAudioTimeout,
} from './helpers.mjs';
@@ -50,31 +47,21 @@ export function registerSynthSounds() {
'linear',
[0.001, 0.05, 0.6, 0.01],
);
const { node: o, stop } = getOscillator(s, t, value);
// turn down
const g = gainNode(0.3);
let sound = getOscillator(s, t, value, () => {
g.disconnect();
o.onended = () => {
cleanupNodes([o, g]);
onended();
});
let { node: o, stop, triggerRelease } = sound;
const { duration } = value;
const envGain = gainNode(1);
let node = o.connect(g).connect(envGain);
const holdEnd = t + duration;
};
const node = o.connect(g).connect(gainNode(1));
const holdEnd = t + value.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);
},
stop,
};
},
{ type: 'synth', prebake: true },
@@ -114,12 +101,7 @@ export function registerSynthSounds() {
const mix = gainNode(mixGain);
o.onended = () => {
o.disconnect();
g.disconnect();
sat.disconnect();
noise.node.disconnect();
noiseGain.disconnect();
mix.disconnect();
cleanupNodes([o, g, sat, noise.node, noiseGain]);
onended();
};
@@ -137,13 +119,10 @@ export function registerSynthSounds() {
mix.gain.linearRampToValueAtTime(0, end);
o.stop(end);
noise.stop(end);
return {
node,
stop: (endTime) => {
o.stop(endTime);
},
stop: o.stop,
};
},
{ type: 'synth', prebake: true },
@@ -167,51 +146,42 @@ export function registerSynthSounds() {
const end = holdend + release + 0.01;
const voices = clamp(unison, 1, 100);
let panspread = voices > 1 ? clamp(spread, 0, 1) : 0;
let o = claimVoice('supersaw');
const params = {
frequency,
begin,
end,
freqspread: detune,
panspread,
};
const processorOptions = {
voices,
};
if (o == null) {
o = getWorklet(ac, 'supersaw-oscillator', params, {
let o = getWorklet(
ac,
'supersaw-oscillator',
{
frequency,
begin,
end,
freqspread: detune,
voices,
panspread,
},
{
outputChannelCount: [2],
processorOptions,
});
} else {
setParams(o, params);
o.port.postMessage({ type: 'reset', payload: { processorOptions } });
}
},
);
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);
const envGain = o.connect(gainNode(1));
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 0.3 * gainAdjustment, begin, holdend, 'linear');
const node = o.connect(gainNode(1));
getParamADSR(node.gain, attack, decay, sustain, release, 0, 0.3 * gainAdjustment, begin, holdend, 'linear');
const timeoutNode = webAudioTimeout(
ac,
() => {
destroyAudioWorkletNode(o);
envGain.disconnect();
releaseVoice('supersaw', o);
cleanupNodes([o, fm, vibratoOscillator]);
onended();
fm?.stop();
vibratoOscillator?.stop();
},
begin,
end,
);
return {
node: envGain,
stop: (time) => {
timeoutNode.stop(time);
},
node,
stop: timeoutNode.stop,
};
},
{ prebake: true, type: 'synth' },
@@ -264,30 +234,21 @@ export function registerSynthSounds() {
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');
const node = o.connect(gainNode(1));
getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
const timeoutNode = webAudioTimeout(
ac,
() => {
destroyAudioWorkletNode(o);
envGain.disconnect();
cleanupNodes([node, o]);
onended();
},
begin,
end,
);
return {
node: envGain,
stop: (time) => {
timeoutNode.stop(time);
},
node,
stop: timeoutNode.stop,
};
},
{ prebake: true, type: 'synth' },
@@ -337,10 +298,9 @@ export function registerSynthSounds() {
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);
const node = o.connect(gainNode(1));
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
let lfo;
if (pwsweep != 0) {
lfo = getLfo(ac, begin, end, { frequency: pwrate, depth: pwsweep });
@@ -349,22 +309,15 @@ export function registerSynthSounds() {
const timeoutNode = webAudioTimeout(
ac,
() => {
destroyAudioWorkletNode(o);
destroyAudioWorkletNode(lfo);
envGain.disconnect();
cleanupNodes([node, o, lfo, fm, vibratoOscillator]);
onended();
fm?.stop();
vibratoOscillator?.stop();
},
begin,
end,
);
return {
node: envGain,
stop: (time) => {
timeoutNode.stop(time);
},
node,
stop: timeoutNode.stop,
};
},
{ prebake: true, type: 'synth' },
@@ -385,31 +338,26 @@ export function registerSynthSounds() {
let { density } = value;
sound = getNoiseOscillator(s, t, density);
let { node: o, stop, triggerRelease } = sound;
let { node: o, stop } = 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');
o.onended = () => {
cleanupNodes([node, o, g]);
onended();
};
const envEnd = holdEnd + release + 0.01;
triggerRelease?.(envEnd);
stop(envEnd);
return {
node,
stop: (endTime) => {
stop(endTime);
},
stop,
};
},
{ type: 'synth', prebake: true },
@@ -463,7 +411,7 @@ export function waveformN(partials, phases, type) {
}
// expects one of waveforms as s
export function getOscillator(s, t, value, onended) {
export function getOscillator(s, t, value) {
const { duration, noise = 0 } = value;
const partials = value.partials ?? value.n;
let o;
@@ -485,6 +433,7 @@ export function getOscillator(s, t, value, onended) {
}
// set frequency
o.frequency.value = getFrequencyFromValue(value);
o.start(t);
let vibratoOscillator = getVibratoOscillator(o.detune, value, t);
@@ -496,24 +445,14 @@ export function getOscillator(s, t, value, onended) {
if (noise) {
noiseMix = getNoiseMix(o, noise, t);
}
o.onended = () => {
o.disconnect();
noiseMix?.node.disconnect();
onended();
const stop = (time) => {
fmModulator.stop(time);
vibratoOscillator?.stop(time);
noiseMix?.stop(time);
o.stop(time);
};
o.start(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);
},
stop,
};
}
+2 -38
View File
@@ -3,8 +3,6 @@ import { logger } from './logger.mjs';
// currently duplicate with core util.mjs to skip dependency
// TODO: add separate util module?
export const LN2 = Math.LN2;
export const tokenizeNote = (note) => {
if (typeof note !== 'string') {
return [];
@@ -34,9 +32,10 @@ export const noteToMidi = (note, defaultOctave = 3) => {
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)) / LN2 + 69;
return (12 * Math.log(freq / 440)) / Math.LN2 + 69;
};
export const valueToMidi = (value, fallbackValue) => {
@@ -115,38 +114,3 @@ export function getCommonSampleInfo(hapValue, bank) {
export const pickAndRename = (source, map) => {
return Object.fromEntries(Object.entries(map).map(([newKey, oldKey]) => [newKey, source[oldKey]]));
};
// Optimized / numerical functions (ideal for hot dsp loops)
// Optimized clamp (~1.7x faster than Math.max/min approach)
export const clamp = (num, min, max) => {
if (num < min) return min;
if (num > max) return max;
return num;
};
export const mod = (n, m) => ((n % m) + m) % m;
export const lerp = (a, b, n) => n * (b - a) + a;
export const frac = (x) => x - Math.floor(x);
// Fast integer ops for non-negative values
export const ffloor = (x) => x | 0;
export const fround = (x) => ffloor(x + 0.5);
export const fceil = (x) => ffloor(x + 1);
export const ffrac = (x) => x - ffloor(x);
export const fastexpm1 = (x) => x * (1 + x + 0.5 * x * x); // Taylor approximation
export const fastpow2 = (x) => {
// Taylor approximation of 2 ^ x
const a = x * LN2;
return 1 + a * (1 + a * (0.5 + a / 6));
};
export const applySemitoneDetuneToFrequency = (frequency, detune) => {
return frequency * Math.pow(2, detune / 12);
};
export const applyFastDetune = (frequency, detune) => {
if (detune < 4) return frequency * fastpow2(detune / 12);
return applySemitoneDetuneToFrequency(frequency, detune);
};
+24 -42
View File
@@ -3,18 +3,14 @@ import { getCommonSampleInfo } from './util.mjs';
import {
applyFM,
applyParameterModulators,
claimVoice,
gainNode,
cleanupNodes,
getADSRValues,
getFrequencyFromValue,
getParamADSR,
getPitchEnvelope,
getVibratoOscillator,
getWorklet,
setParams,
releaseVoice,
webAudioTimeout,
destroyAudioWorkletNode,
} from './helpers.mjs';
import { logger } from './logger.mjs';
@@ -222,39 +218,32 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
const frequency = getFrequencyFromValue(value);
const { url, label } = getCommonSampleInfo(value, tables);
const payload = await getPayload(url, label, frameLen);
const voiceKey = `wavetable:${payload.key}`;
let holdEnd = t + duration;
if (clip !== undefined) {
holdEnd = Math.min(t + clip * duration, holdEnd);
}
const endWithRelease = holdEnd + release;
const envEnd = endWithRelease + 0.01;
let source = claimVoice(voiceKey);
const params = {
begin: t,
end: envEnd,
frequency,
freqspread: value.detune,
position: value.wt,
warp: value.warp,
warpMode: warpmode,
panspread: value.spread,
};
const processorOptions = {
phaseRand: value.wtphaserand ?? (value.unison > 1 ? 1 : 0),
voices: Math.max(value.unison ?? 1, 1),
};
if (source == null) {
source = getWorklet(ac, 'wavetable-oscillator-processor', params, { outputChannelCount: [2], processorOptions });
} else {
setParams(source, params);
source.port.postMessage({ type: 'reset', payload: { processorOptions } });
}
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');
destroyAudioWorkletNode(source);
releaseVoice(voiceKey, source);
return;
}
const posADSRParams = [value.wtattack, value.wtdecay, value.wtsustain, value.wtrelease];
@@ -321,27 +310,20 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
);
const vibratoOscillator = getVibratoOscillator(source.parameters.get('detune'), value, t);
const fm = applyFM(source.parameters.get('frequency'), value, t);
const envGain = source.connect(gainNode(1));
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 0.3, t, holdEnd, 'linear');
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: envGain };
const handle = { node, source };
const timeoutNode = webAudioTimeout(
ac,
() => {
vibratoOscillator?.stop();
fm?.stop();
wtPosModulators?.disconnect();
wtWarpModulators?.disconnect();
envGain.disconnect();
destroyAudioWorkletNode(source);
releaseVoice(voiceKey, source);
cleanupNodes([source, vibratoOscillator, fm, node, wtPosModulators, wtWarpModulators]);
onended();
},
t,
envEnd,
);
handle.stop = (time) => {
timeoutNode.stop(time);
};
handle.stop = timeoutNode.stop;
return handle;
}
+136 -129
View File
@@ -5,29 +5,27 @@
import OLAProcessor from './ola-processor';
import FFT from './fft.js';
import { getDistortionAlgorithm } from './helpers.mjs';
import {
applyFastDetune,
applySemitoneDetuneToFrequency,
clamp,
fastexpm1,
fceil,
ffloor,
ffrac,
frac,
fround,
lerp,
} from './util.mjs';
const blockSize = 128;
const PI = Math.PI;
const TWO_PI = 2 * PI;
const INVSR = 1 / sampleRate;
const MAX_VOICES = 64;
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);
// Fast integer ops for non-negative values
const ffloor = (x) => x | 0;
const fround = (x) => ffloor(x + 0.5);
const fceil = (x) => ffloor(x + 1);
const ffrac = (x) => x - ffloor(x);
const fast_tanh = (x) => {
const x2 = x * x;
return (x * (27 + x2)) / (27 + 9 * x2);
const x2 = x ** 2;
return (x * (27.0 + x2)) / (27.0 + 9.0 * x2);
};
// Optimized per-voice detuner which precomputes constants
@@ -40,6 +38,10 @@ const getDetuner = (unison, detune) => {
return (voiceIdx) => voiceIdx * scale - center;
};
const applySemitoneDetuneToFrequency = (frequency, detune) => {
return frequency * Math.pow(2, detune / 12);
};
// 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) {
@@ -167,11 +169,10 @@ class LFOProcessor extends AudioWorkletProcessor {
}
const dt = frequency * INVSR;
for (let n = 0; n < blockSize; n++) {
let modval = (waveshapes[shape](this.phase, skew) + dcoffset) * depth;
modval = curve !== 1 ? Math.pow(modval, curve) : modval;
const out = clamp(modval, min, max);
for (let i = 0; i < output.length; i++) {
output[i][n] = out;
let modval = (waveshapes[shape](this.phase, skew) + dcoffset) * depth;
modval = Math.pow(modval, curve);
output[i][n] = clamp(modval, min, max);
}
this.incrementPhase(dt);
}
@@ -442,7 +443,7 @@ class DistortProcessor extends AudioWorkletProcessor {
this.started = hasInput;
for (let n = 0; n < blockSize; n++) {
const postgain = clamp(pv(parameters.postgain, n), 0.001, 1);
const shape = fastexpm1(pv(parameters.distort, n));
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);
@@ -455,17 +456,9 @@ registerProcessor('distort-processor', DistortProcessor);
// SUPERSAW
class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
constructor(options) {
constructor() {
super();
this.phases = new Float32Array(MAX_VOICES);
this.reset(options.processorOptions);
}
reset(options) {
const phases = this.phases;
for (let i = 0; i < phases.length; i++) {
phases[i] = Math.random();
}
this.voices = options.voices;
this.phase = [];
}
static get parameterDescriptors() {
return [
@@ -475,17 +468,20 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
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,
@@ -502,57 +498,53 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
defaultValue: 0,
min: 0,
},
{
name: 'voices',
defaultValue: 5,
min: 1,
automationRate: 'k-rate',
},
];
}
process(_input, outputs, params) {
const started = currentTime >= params.begin[0];
const ended = currentTime >= params.end[0];
if (!started || ended) {
if (currentTime <= params.begin[0]) {
return true;
}
if (currentTime >= params.end[0]) {
// this.port.postMessage({ type: 'onended' });
return false;
}
const output = outputs[0];
// fast k-rate paths
let freq, detune, freqspread, detuner;
if (params.freqspread.length === 1) {
freqspread = params.freqspread[0];
detuner = getDetuner(this.voices, freqspread);
}
if (params.frequency.length === 1 && params.detune.length === 1) {
freq = params.frequency[0];
detune = params.detune[0];
freq = applySemitoneDetuneToFrequency(freq, detune / 100);
}
let panspread, gainL, gainR;
if (params.panspread.length === 1) {
panspread = this.voices > 1 ? clamp(params.panspread[0], 0, 1) : 0;
gainL = Math.sqrt(0.5 - 0.5 * panspread);
gainR = Math.sqrt(0.5 + 0.5 * panspread);
}
const voices = params.voices[0]; // k-rate
for (let i = 0; i < output[0].length; i++) {
const detune = pv(params.detune, i);
const freqspread = pv(params.freqspread, i);
const panspread = pv(params.panspread, i) * 0.5 + 0.5;
gainL = Math.sqrt(1 - panspread);
gainR = Math.sqrt(panspread);
// Main detuning (a-rate path)
detune ??= pv(params.detune, i);
freqspread ??= pv(params.freqspread, i);
freq ??= applySemitoneDetuneToFrequency(pv(params.frequency, i), detune / 100);
detuner ??= getDetuner(this.voices, freqspread);
for (let n = 0; n < this.voices; n++) {
let gainL = Math.sqrt(1 - panspread);
let gainR = Math.sqrt(panspread);
let freq = pv(params.frequency, i);
// Main detuning
freq = applySemitoneDetuneToFrequency(freq, detune / 100);
const detuner = getDetuner(voices, freqspread);
for (let n = 0; n < voices; n++) {
// Individual voice detuning
const freqVoice = applyFastDetune(freq, detuner(n));
const freqVoice = applySemitoneDetuneToFrequency(freq, detuner(n));
// We must wrap this here because it is passed into sawblep below which
// has domain [0, 1]
const dt = frac(freqVoice * INVSR);
const v = waveshapes.sawblep(this.phases[n], dt);
const dt = ffrac(freqVoice * INVSR);
this.phase[n] = this.phase[n] ?? Math.random();
const v = waveshapes.sawblep(this.phase[n], dt);
output[0][i] += v * gainL;
output[1][i] += v * gainR;
let pn = this.phases[n] + dt;
let pn = this.phase[n] + dt;
if (pn >= 1.0) pn -= 1.0;
this.phases[n] = pn;
this.phase[n] = pn;
// invert right and left gain
const tmp = gainL;
gainL = gainR;
gainR = tmp;
gainR = gainL;
}
}
return true;
@@ -1132,8 +1124,10 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
{ 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, automationRate: 'k-rate' },
{ name: 'warpMode', defaultValue: 0 },
{ name: 'voices', defaultValue: 1, min: 1, automationRate: 'k-rate' },
{ name: 'panspread', defaultValue: 0.7, min: 0, max: 1 },
{ name: 'phaserand', defaultValue: 0, min: 0, max: 1 },
];
}
@@ -1141,32 +1135,35 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
super(options);
this.frameLen = 0;
this.numFrames = 0;
this.phases = new Float32Array(MAX_VOICES);
this.phase = [];
this.port.onmessage = (e) => {
const { type, payload } = e.data || {};
if (type === 'table') {
const key = payload.key;
this.frameLen = payload.frameLen;
if (!tablesCache[key]) {
tablesCache[key] = payload.frames;
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.table = tablesCache[key];
this.numFrames = this.table.length;
} else if (type === 'reset') {
this.reset(payload.processorOptions);
this.tables = tablesCache[key];
this.numFrames = this.tables[0].length;
}
};
this.reset(options.processorOptions);
}
reset(options) {
const phaseRand = options.phaseRand ?? 1;
const phases = this.phases;
for (let i = 0; i < phases.length; i++) {
phases[i] = Math.random() * phaseRand;
}
this.voices = options.voices;
this.normalizer = 1 / Math.sqrt(this.voices);
}
_mirror(x) {
@@ -1311,63 +1308,73 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
return a + (b - a) * frac;
}
process(_inputs, outputs, params) {
const started = currentTime >= params.begin[0];
const ended = currentTime >= params.end[0];
if (!started || ended) {
_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];
// fast k-rate paths
let freq, detune, freqspread, detuner;
if (params.freqspread.length === 1) {
freqspread = params.freqspread[0];
detuner = getDetuner(this.voices, freqspread);
if (!this.tables) {
outL.fill(0);
if (outR !== outL) outR.set(outL);
return true;
}
if (params.frequency.length === 1 && params.detune.length === 1) {
freq = params.frequency[0];
detune = params.detune[0];
freq = applySemitoneDetuneToFrequency(freq, detune / 100);
}
let panspread, gainL, gainR;
if (params.panspread.length === 1) {
panspread = this.voices > 1 ? clamp(params.panspread[0], 0, 1) : 0;
gainL = Math.sqrt(0.5 - 0.5 * panspread);
gainR = Math.sqrt(0.5 + 0.5 * panspread);
}
const warpMode = params.warpMode[0];
const voices = parameters.voices[0]; // k-rate
for (let i = 0; i < outL.length; i++) {
const tablePos = clamp(pv(params.position, i), 0, 1);
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 interpT = idx - fIdx;
const warpAmount = clamp(pv(params.warp, i), 0, 1);
panspread ??= this.voices > 1 ? clamp(pv(params.panspread, i), 0, 1) : 0;
gainL ??= Math.sqrt(0.5 - 0.5 * panspread);
gainR ??= Math.sqrt(0.5 + 0.5 * panspread);
detune ??= pv(params.detune, i);
freqspread ??= pv(params.freqspread, i);
freq ??= applySemitoneDetuneToFrequency(pv(params.frequency, i), detune / 100); // overall detune
detuner ??= getDetuner(this.voices, freqspread);
for (let n = 0; n < this.voices; n++) {
const freqVoice = applyFastDetune(freq, detuner(n)); // voice detune
const dPhase = freqVoice * INVSR;
const frac = idx - fIdx;
const warpAmount = clamp(pv(parameters.warp, i), 0, 1);
const warpMode = pv(parameters.warpMode, 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);
const detuner = getDetuner(voices, freqspread);
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, detuner(n)); // voice detune
const dPhase = fVoice * INVSR;
const level = this._chooseMip(dPhase);
const table = this.tables[level];
// warp phase then sample
const ph = this._warpPhase(this.phases[n], warpAmount, warpMode);
const s0 = this._sampleFrame(this.table[fIdx], ph);
const s1 = this._sampleFrame(this.table[Math.min(this.numFrames - 1, fIdx + 1)], ph);
let s = lerp(s0, s1, interpT);
if (warpMode === WarpMode.FLIP && this.phases[n] < warpAmount) {
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 * this.normalizer;
outR[i] += s * gainR * this.normalizer;
this.phases[n] = frac(this.phases[n] + dPhase);
// invert right and left gain
const tmp = gainL;
gainR = gainL;
gainL = tmp;
outL[i] += s * gainL * normalizer;
outR[i] += s * gainR * normalizer;
this.phase[n] = ffrac(this.phase[n] + dPhase);
}
}
return true;
+2 -1
View File
@@ -3,6 +3,7 @@ import { midiToFreq, noteToMidi } from './util.mjs';
import { registerSound } from './superdough.mjs';
import { getAudioContext } from './audioContext.mjs';
import { buildSamples } from './zzfx_fork.mjs';
import { cleanupNode } from './helpers.mjs';
export const getZZFX = (value, t) => {
let {
@@ -84,7 +85,7 @@ export function registerZZFXSounds() {
(t, value, onended) => {
const { node: o } = getZZFX({ s: wave, ...value }, t);
o.onended = () => {
o.disconnect();
cleanupNode(o);
onended();
};
return {
+1 -5
View File
@@ -1,10 +1,6 @@
import { Dough } from './dough.mjs';
const clamp = (num, min, max) => {
if (num < min) return min;
if (num > max) return max;
return num;
};
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
class DoughProcessor extends AudioWorkletProcessor {
constructor() {
+1 -5
View File
@@ -6,11 +6,7 @@ const PI_DIV_SR = Math.PI / SAMPLE_RATE;
const ISR = 1 / SAMPLE_RATE;
let gainCurveFunc = (val) => Math.pow(val, 2);
const clamp = (num, min, max) => {
if (num < min) return min;
if (num > max) return max;
return num;
};
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
function applyGainCurve(val) {
return gainCurveFunc(val);
+48 -48
View File
@@ -12484,54 +12484,54 @@ exports[`runs examples > example "wchooseCycles" example index 0 1`] = `
exports[`runs examples > example "wchooseCycles" example index 1 1`] = `
[
"[ 0/1 → 1/12 | note:c ]",
"[ 1/12 → 1/6 | note:c ]",
"[ 1/6 → 1/4 | note:c ]",
"[ 1/4 → 1/3 | note:c ]",
"[ 1/3 → 5/12 | note:c ]",
"[ 5/12 → 1/2 | note:c ]",
"[ 1/2 → 7/12 | note:f ]",
"[ 7/12 → 2/3 | note:f ]",
"[ 2/3 → 3/4 | note:f ]",
"[ 3/4 → 5/6 | note:c ]",
"[ 5/6 → 11/12 | note:c ]",
"[ 11/12 → 1/1 | note:c ]",
"[ 1/1 → 13/12 | note:c ]",
"[ 13/12 → 7/6 | note:c ]",
"[ 7/6 → 5/4 | note:c ]",
"[ 5/4 → 4/3 | note:c ]",
"[ 4/3 → 17/12 | note:c ]",
"[ 17/12 → 3/2 | note:c ]",
"[ 3/2 → 19/12 | note:a ]",
"[ 19/12 → 5/3 | note:a ]",
"[ 5/3 → 7/4 | note:a ]",
"[ 7/4 → 11/6 | note:c ]",
"[ 11/6 → 23/12 | note:c ]",
"[ 23/12 → 2/1 | note:c ]",
"[ 2/1 → 25/12 | note:a ]",
"[ 25/12 → 13/6 | note:a ]",
"[ 13/6 → 9/4 | note:a ]",
"[ 9/4 → 7/3 | note:a ]",
"[ 7/3 → 29/12 | note:a ]",
"[ 29/12 → 5/2 | note:a ]",
"[ 5/2 → 31/12 | note:c ]",
"[ 31/12 → 8/3 | note:c ]",
"[ 8/3 → 11/4 | note:c ]",
"[ 11/4 → 17/6 | note:c ]",
"[ 17/6 → 35/12 | note:c ]",
"[ 35/12 → 3/1 | note:c ]",
"[ 3/1 → 37/12 | note:c ]",
"[ 37/12 → 19/6 | note:c ]",
"[ 19/6 → 13/4 | note:c ]",
"[ 13/4 → 10/3 | note:f ]",
"[ 10/3 → 41/12 | note:f ]",
"[ 41/12 → 7/2 | note:f ]",
"[ 7/2 → 43/12 | note:a ]",
"[ 43/12 → 11/3 | note:a ]",
"[ 11/3 → 15/4 | note:a ]",
"[ 15/4 → 23/6 | note:c ]",
"[ 23/6 → 47/12 | note:c ]",
"[ 47/12 → 4/1 | note:c ]",
"[ 0/1 → 1/12 | s:bd ]",
"[ 1/12 → 1/6 | s:bd ]",
"[ 1/6 → 1/4 | s:bd ]",
"[ 1/4 → 1/3 | s:bd ]",
"[ 1/3 → 5/12 | s:bd ]",
"[ 5/12 → 1/2 | s:bd ]",
"[ 1/2 → 7/12 | s:sd ]",
"[ 7/12 → 2/3 | s:sd ]",
"[ 2/3 → 3/4 | s:sd ]",
"[ 3/4 → 5/6 | s:bd ]",
"[ 5/6 → 11/12 | s:bd ]",
"[ 11/12 → 1/1 | s:bd ]",
"[ 1/1 → 13/12 | s:bd ]",
"[ 13/12 → 7/6 | s:bd ]",
"[ 7/6 → 5/4 | s:bd ]",
"[ 5/4 → 4/3 | s:bd ]",
"[ 4/3 → 17/12 | s:bd ]",
"[ 17/12 → 3/2 | s:bd ]",
"[ 3/2 → 19/12 | s:hh ]",
"[ 19/12 → 5/3 | s:hh ]",
"[ 5/3 → 7/4 | s:hh ]",
"[ 7/4 → 11/6 | s:bd ]",
"[ 11/6 → 23/12 | s:bd ]",
"[ 23/12 → 2/1 | s:bd ]",
"[ 2/1 → 25/12 | s:hh ]",
"[ 25/12 → 13/6 | s:hh ]",
"[ 13/6 → 9/4 | s:hh ]",
"[ 9/4 → 7/3 | s:hh ]",
"[ 7/3 → 29/12 | s:hh ]",
"[ 29/12 → 5/2 | s:hh ]",
"[ 5/2 → 31/12 | s:bd ]",
"[ 31/12 → 8/3 | s:bd ]",
"[ 8/3 → 11/4 | s:bd ]",
"[ 11/4 → 17/6 | s:bd ]",
"[ 17/6 → 35/12 | s:bd ]",
"[ 35/12 → 3/1 | s:bd ]",
"[ 3/1 → 37/12 | s:bd ]",
"[ 37/12 → 19/6 | s:bd ]",
"[ 19/6 → 13/4 | s:bd ]",
"[ 13/4 → 10/3 | s:sd ]",
"[ 10/3 → 41/12 | s:sd ]",
"[ 41/12 → 7/2 | s:sd ]",
"[ 7/2 → 43/12 | s:hh ]",
"[ 43/12 → 11/3 | s:hh ]",
"[ 11/3 → 15/4 | s:hh ]",
"[ 15/4 → 23/6 | s:bd ]",
"[ 23/6 → 47/12 | s:bd ]",
"[ 47/12 → 4/1 | s:bd ]",
]
`;
@@ -126,7 +126,7 @@ export function SoundsTab() {
try {
// Pre-load the sample by calling onTrigger with a future time
// This triggers the loading but schedules playback for later
const time = ctx.currentTime + 0.05;
const time = ctx.currentTime + 0.5; // Give 500ms for loading
const ref = await onTrigger(time, params, onended);
trigRef.current = ref;
if (ref?.node) {