Compare commits

...

11 Commits

Author SHA1 Message Date
Aria 3c407412c6 Merge branch 'glossing/basic-node-pool' into glossing/full-node-pool 2025-12-04 22:52:57 -06:00
Aria 4c99f4866b Use setvalueattime to pin values and prevent bleed 2025-12-04 22:50:28 -06:00
Aria 099288545e Working version 2025-12-04 22:12:54 -06:00
Aria 5738020ac2 Working version of defaulting 2025-12-04 21:41:52 -06:00
Aria 1478ff51a7 Defaulting 2025-12-04 21:33:33 -06:00
Aria 8314023c7a Mostly working - need to fix stretch and distort 2025-12-04 15:41:23 -06:00
Aria d50af71ba1 Merge branch 'main' into glossing/basic-node-pool 2025-12-04 14:20:41 -06:00
Aria 0664f90178 Remove old comment 2025-12-04 13:07:13 -06:00
Aria 4321814d36 Working version for compressor, filter, supersaw, wavetable 2025-12-04 13:04:33 -06:00
Aria 6610713018 Working version of supersaw, almost on wavetable 2025-12-04 12:40:28 -06:00
Aria 600ab0a83e WIP with compressors, filters, heavy worklets 2025-12-04 12:17:07 -06:00
10 changed files with 333 additions and 345 deletions
+9
View File
@@ -1,3 +1,12 @@
/*
audioContext.mjs - Audio Context manager
Sets up a common and accessible audio context for all superdough operations
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/audiocontext.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
let audioContext;
export const setDefaultAudioContext = () => {
+28 -8
View File
@@ -1,6 +1,7 @@
import { getAudioContext } from './audioContext.mjs';
import { logger } from './logger.mjs';
import { getNoiseBuffer } from './noise.mjs';
import { getNodeFromPool, markWorkletAsDead } from './nodePools.mjs';
import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
export const noises = ['pink', 'white', 'brown', 'crackle'];
@@ -36,6 +37,16 @@ export function getWorklet(ac, processor, params, config) {
return node;
}
export function getPooledWorklet(ac, processor, params, constructorOptions, reInitOptions) {
const node = getNodeFromPool(processor, () => new AudioWorkletNode(ac, processor, constructorOptions), params);
node.port.postMessage({ type: 'initialize', payload: reInitOptions });
node.port.onmessage = (e) => {
if (e.data.type === 'died') markWorkletAsDead(node);
node.port.onmessage = null;
};
return node;
}
export const getParamADSR = (
param,
attack,
@@ -124,10 +135,11 @@ export function getLfo(audioContext, begin, end, properties = {}) {
...props,
};
return getWorklet(audioContext, 'lfo-processor', lfoprops);
return getPooledWorklet(audioContext, 'lfo-processor', lfoprops);
}
export function getCompressor(ac, threshold, ratio, knee, attack, release) {
const node = getNodeFromPool('compressor', () => new DynamicsCompressorNode(ac, {}));
const options = {
threshold: threshold ?? -3,
ratio: ratio ?? 10,
@@ -135,7 +147,11 @@ export function getCompressor(ac, threshold, ratio, knee, attack, release) {
attack: attack ?? 0.005,
release: release ?? 0.05,
};
return new DynamicsCompressorNode(ac, options);
const now = ac.currentTime;
Object.entries(options).forEach(([key, value]) => {
node[key].setValueAtTime(value, now);
});
return node;
}
// changes the default values of the envelope based on what parameters the user has defined
@@ -211,13 +227,16 @@ export function createFilter(context, start, end, params, cps, cycle) {
let frequencyParam, filter;
if (model === 'ladder') {
filter = getWorklet(context, 'ladder-processor', { frequency, q, drive });
filter = getPooledWorklet(context, 'ladder-processor', { frequency, q, drive });
frequencyParam = filter.parameters.get('frequency');
} else {
filter = context.createBiquadFilter();
const factory = () => context.createBiquadFilter();
filter = getNodeFromPool('filter', factory);
filter.type = type;
filter.Q.value = q;
filter.frequency.value = frequency;
const now = context.currentTime;
Object.entries({ Q: q, frequency }).forEach(([key, value]) => {
filter[key].setValueAtTime(value, now);
});
frequencyParam = filter.frequency;
}
const envelopeValues = [params.attack, params.decay, params.sustain, params.release];
@@ -548,8 +567,9 @@ export const getDistortionAlgorithm = (algo) => {
return distortionAlgorithms[name];
};
export const getDistortion = (distort, postgain, algorithm) => {
return getWorklet(getAudioContext(), 'distort-processor', { distort, postgain }, { processorOptions: { algorithm } });
export const getDistortion = (fullParams) => {
const { algorithm, ...params } = fullParams;
return getPooledWorklet(getAudioContext(), 'distort-processor', params, {}, { algorithm });
};
export const getFrequencyFromValue = (value, defaultNote = 36) => {
+71
View File
@@ -0,0 +1,71 @@
/*
nodePools.mjs - Helper functions related to pooling and re-using audio nodes
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/nodePools.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const nodePools = new Map();
const POOL_KEY = Symbol('nodePoolKey');
const IS_WORKLET_DEAD = Symbol('nodePoolIsWorkletDead');
const MAX_POOL_SIZE = 64;
export const isPoolable = (node) => !!node[POOL_KEY];
export const releaseNodeToPool = (node) => {
node.disconnect();
if (node instanceof AudioScheduledSourceNode) {
// not reusable
return;
}
if (node[IS_WORKLET_DEAD]) {
// Worklet already terminated, don't pool it
return;
}
const key = node[POOL_KEY];
if (key == null) return;
const pool = nodePools.get(key) ?? [];
if (pool.length < MAX_POOL_SIZE) {
pool.push(new WeakRef(node));
nodePools.set(key, pool);
}
};
export const markWorkletAsDead = (worklet) => (worklet[IS_WORKLET_DEAD] = true);
// Attempt to get node from the pool. If this fails, fall back
// to building it with the factory
export const getNodeFromPool = (key, factory, params = {}) => {
const pool = nodePools.get(key) ?? [];
let node;
while (pool.length) {
const ref = pool.pop();
node = ref?.deref();
if (node != null && !node[IS_WORKLET_DEAD]) break;
}
if (node == null || node[IS_WORKLET_DEAD]) {
node = factory();
}
node[POOL_KEY] = key;
const paramMap = new Map();
if (node instanceof AudioWorkletNode) {
for (const [name, param] of node.parameters.entries()) {
paramMap.set(name, param);
}
} else {
for (const name of Object.getOwnPropertyNames(node)) {
const value = node[name];
if (value instanceof AudioParam) {
paramMap.set(name, value);
}
}
}
const now = node.context?.currentTime ?? 0;
paramMap.forEach((param, name) => {
param.cancelScheduledValues(now);
// Set values from `params` or restore defaults
const target = params[name] !== undefined ? params[name] : param.defaultValue;
param.setValueAtTime(target, now);
});
return node;
};
+6 -14
View File
@@ -1,22 +1,23 @@
'use strict';
import { makeReusable } from './worklets-common.mjs';
// sourced from https://github.com/olvb/phaze/tree/master?tab=readme-ov-file
const WEBAUDIO_BLOCK_SIZE = 128;
/** Overlap-Add Node */
class OLAProcessor extends AudioWorkletProcessor {
class OLAProcessor extends makeReusable(AudioWorkletProcessor) {
constructor(options) {
super(options);
this.started = false;
this.nbInputs = options.numberOfInputs;
this.nbOutputs = options.numberOfOutputs;
this.blockSize = options.processorOptions.blockSize;
// TODO for now, the only support hop size is the size of a web audio block
this.hopSize = WEBAUDIO_BLOCK_SIZE;
this.nbOverlaps = this.blockSize / this.hopSize;
}
initialize() {
// pre-allocate input buffers (will be reallocated if needed)
this.inputBuffers = new Array(this.nbInputs);
this.inputBuffersHead = new Array(this.nbInputs);
@@ -54,7 +55,6 @@ class OLAProcessor extends AudioWorkletProcessor {
allocateInputChannels(inputIndex, nbChannels) {
// allocate input buffers
this.inputBuffers[inputIndex] = new Array(nbChannels);
for (let i = 0; i < nbChannels; i++) {
this.inputBuffers[inputIndex][i] = new Float32Array(this.blockSize + WEBAUDIO_BLOCK_SIZE);
@@ -157,15 +157,8 @@ class OLAProcessor extends AudioWorkletProcessor {
}
}
process(inputs, outputs, params) {
const input = inputs[0];
const hasInput = !(input[0] === undefined);
if (this.started && !hasInput) {
return false;
}
this.started = hasInput;
processActive(inputs, outputs, params) {
this.reallocateChannelsIfNeeded(inputs, outputs);
this.readInputs(inputs);
this.shiftInputBuffers();
this.prepareInputBuffersToSend();
@@ -173,7 +166,6 @@ class OLAProcessor extends AudioWorkletProcessor {
this.handleOutputBuffersToRetrieve();
this.writeOutputs(outputs);
this.shiftOutputBuffers();
return true;
}
+23 -8
View File
@@ -9,7 +9,16 @@ import './reverb.mjs';
import './vowel.mjs';
import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
import workletsUrl from './worklets.mjs?audioworklet';
import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend } from './helpers.mjs';
import {
createFilter,
effectSend,
gainNode,
getCompressor,
getDistortion,
getLfo,
getPooledWorklet,
} from './helpers.mjs';
import { getNodeFromPool, isPoolable, releaseNodeToPool } from './nodePools.mjs';
import { map } from 'nanostores';
import { logger } from './logger.mjs';
import { loadBuffer } from './sampler.mjs';
@@ -294,7 +303,7 @@ function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000
let fOffset = 0;
const filterChain = [];
for (let i = 0; i < numStages; i++) {
const filter = ac.createBiquadFilter();
const filter = getNodeFromPool('filter', () => ac.createBiquadFilter());
filter.type = 'notch';
filter.gain.value = 1;
filter.frequency.value = centerFrequency + fOffset;
@@ -506,7 +515,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
} else if (getSound(s)) {
const { onTrigger } = getSound(s);
const onEnded = () => {
audioNodes.forEach((n) => n?.disconnect());
audioNodes.forEach((n) => (isPoolable(n) ? releaseNodeToPool(n) : n?.disconnect()));
activeSoundSources.delete(chainID);
};
const soundHandle = await onTrigger(t, value, onEnded, cps);
@@ -530,7 +539,10 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
}
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 }));
stretch !== undefined &&
chain.push(
getPooledWorklet(ac, 'phase-vocoder-processor', { pitchFactor: stretch, begin: t, end: endWithRelease }),
);
// gain stage
chain.push(gainNode(gain));
@@ -643,10 +655,13 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
}
// 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));
coarse !== undefined &&
chain.push(getPooledWorklet(ac, 'coarse-processor', { coarse, begin: t, end: endWithRelease }));
crush !== undefined && chain.push(getPooledWorklet(ac, 'crush-processor', { crush, begin: t, end: endWithRelease }));
shape !== undefined &&
chain.push(getPooledWorklet(ac, 'shape-processor', { shape, postgain: shapevol, begin: t, end: endWithRelease }));
distort !== undefined &&
chain.push(getDistortion({ distort, postgain: distortvol, algorithm: distorttype, begin: t, end: endWithRelease }));
if (tremolosync != null) {
tremolo = cps * tremolosync;
+9
View File
@@ -1,3 +1,12 @@
/*
superdoughoutput.mjs - Output controller for superdough
Handles setting up and mixing to the outputs as well as all global (orbit) effects
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdoughoutput.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { effectSend, getWorklet, webAudioTimeout } from './helpers.mjs';
import { errorLogger } from './logger.mjs';
import { clamp } from './util.mjs';
+13 -20
View File
@@ -9,14 +9,15 @@ import {
getLfo,
getParamADSR,
getPitchEnvelope,
getPooledWorklet,
getVibratoOscillator,
getWorklet,
noises,
releaseAudioNode,
webAudioTimeout,
} from './helpers.mjs';
import { logger } from './logger.mjs';
import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
import { releaseNodeToPool } from './nodePools.mjs';
const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user'];
const waveformAliases = [
@@ -164,22 +165,14 @@ 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 = getWorklet(
ac,
'supersaw-oscillator',
{
frequency,
begin,
end,
freqspread: detune,
voices,
panspread,
},
{
outputChannelCount: [2],
},
);
const params = {
frequency,
begin,
end,
freqspread: detune,
panspread,
};
const o = getPooledWorklet(ac, 'supersaw-oscillator', params, { outputChannelCount: [2] }, { voices });
const gainAdjustment = 1 / Math.sqrt(voices);
getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend);
const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin);
@@ -192,7 +185,7 @@ export function registerSynthSounds() {
let timeoutNode = webAudioTimeout(
ac,
() => {
releaseAudioNode(o);
releaseNodeToPool(o);
onended();
fm?.stop();
vibratoOscillator?.stop();
@@ -246,7 +239,7 @@ export function registerSynthSounds() {
const holdend = begin + duration;
const end = holdend + release + 0.01;
let o = getWorklet(
let o = getPooledWorklet(
ac,
'byte-beat-processor',
{
@@ -313,7 +306,7 @@ export function registerSynthSounds() {
);
const holdend = begin + duration;
const end = holdend + release + 0.01;
let o = getWorklet(
let o = getPooledWorklet(
ac,
'pulse-oscillator',
{
+16 -21
View File
@@ -7,11 +7,11 @@ import {
getFrequencyFromValue,
getParamADSR,
getPitchEnvelope,
getPooledWorklet,
getVibratoOscillator,
getWorklet,
releaseAudioNode,
webAudioTimeout,
} from './helpers.mjs';
import { releaseNodeToPool } from './nodePools.mjs';
import { logger } from './logger.mjs';
export const Warpmode = Object.freeze({
@@ -225,24 +225,19 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
}
const endWithRelease = holdEnd + release;
const envEnd = endWithRelease + 0.01;
const source = getWorklet(
ac,
'wavetable-oscillator-processor',
{
begin: t,
end: envEnd,
frequency,
freqspread: value.detune,
position: value.wt,
warp: value.warp,
warpMode: warpmode,
voices: Math.max(value.unison ?? 1, 1),
panspread: value.spread,
phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0,
},
{ outputChannelCount: [2] },
);
source.port.postMessage({ type: 'table', payload });
payload.voices = Math.max(value.unison ?? 1, 1);
const params = {
begin: t,
end: envEnd,
frequency,
freqspread: value.detune,
position: value.wt,
warp: value.warp,
warpMode: warpmode,
panspread: value.spread,
phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0,
};
const source = getPooledWorklet(ac, 'wavetable-oscillator-processor', params, { outputChannelCount: [2] }, payload);
if (ac.currentTime > t) {
logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight');
return;
@@ -319,7 +314,7 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
const timeoutNode = webAudioTimeout(
ac,
() => {
releaseAudioNode(source);
releaseNodeToPool(source);
vibratoOscillator?.stop();
fm?.stop();
wtPosModulators?.disconnect();
+70
View File
@@ -0,0 +1,70 @@
/*
worklets-common.mjs - Common worklet code
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/worklets-common.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Mixin to make class "reusable" (i.e. have a begin and end and properly handle `initialize` messages)
export const makeReusable = (Base) =>
class extends Base {
static get parameterDescriptors() {
return [
...(super.parameterDescriptors ?? []),
{
name: 'begin',
defaultValue: 0,
min: 0,
max: Number.POSITIVE_INFINITY,
automationRate: 'k-rate',
},
{
name: 'end',
defaultValue: 0,
min: 0,
max: Number.POSITIVE_INFINITY,
automationRate: 'k-rate',
},
];
}
constructor(options) {
super(options);
this.isAlive = true;
this.graceSeconds = options?.processorOptions?.graceSeconds ?? 0.5;
this.port.onmessage = (e) => {
const { type, payload } = e.data || {};
if (type === 'initialize') {
this.initialize(payload);
} else if (this.handlePortMessage) {
this.handlePortMessage(type, payload, e);
}
};
}
initialize(_options) {
// defined on subclasses
}
processActive(_inputs, _outputs, _params) {
// processing subclasses should do when we're between begin/end
return true;
}
process(inputs, outputs, params) {
const begin = params.begin[0];
const end = params.end[0];
if (currentTime >= end + this.graceSeconds) {
if (this.isAlive) {
this.port.postMessage({ type: 'died' });
this.isAlive = false;
}
return false;
}
if (currentTime < begin || currentTime >= end) {
return true;
}
return this.processActive(inputs, outputs, params);
}
};
+88 -274
View File
@@ -1,10 +1,11 @@
// coarse, crush, and shape processors adapted from dktr0's webdirt: https://github.com/dktr0/WebDirt/blob/5ce3d698362c54d6e1b68acc47eb2955ac62c793/dist/AudioWorklets.js
// LICENSE GNU General Public License v3.0 see https://github.com/dktr0/WebDirt/blob/main/LICENSE
// TOFIX: THIS FILE DOES NOT SUPPORT IMPORTS ON DEPOLYMENT
// TOFIX: THIS FILE DOES NOT SUPPORT IMPORTS ON DEPLOYMENT
import OLAProcessor from './ola-processor';
import FFT from './fft.js';
import { getDistortionAlgorithm } from './helpers.mjs';
import { makeReusable } from './worklets-common.mjs';
const blockSize = 128;
const PI = Math.PI;
@@ -108,12 +109,12 @@ const waveshapes = {
};
const waveShapeNames = Object.keys(waveshapes);
class LFOProcessor extends AudioWorkletProcessor {
class LFOProcessor extends makeReusable(AudioWorkletProcessor) {
static get parameterDescriptors() {
return [
{ name: 'begin', defaultValue: 0 },
...super.parameterDescriptors,
{ name: 'time', defaultValue: 0 },
{ name: 'end', defaultValue: 0 },
{ name: 'frequency', defaultValue: 0.5 },
{ name: 'skew', defaultValue: 0.5 },
{ name: 'depth', defaultValue: 1 },
@@ -126,9 +127,8 @@ class LFOProcessor extends AudioWorkletProcessor {
];
}
constructor() {
super();
this.phase;
initialize() {
this.phase = null;
}
incrementPhase(dt) {
@@ -138,15 +138,7 @@ class LFOProcessor extends AudioWorkletProcessor {
}
}
process(_inputs, outputs, parameters) {
const begin = parameters['begin'][0];
if (currentTime >= parameters.end[0]) {
return false;
}
if (currentTime <= begin) {
return true;
}
processActive(_inputs, outputs, parameters) {
const output = outputs[0];
const frequency = parameters['frequency'][0];
@@ -182,26 +174,14 @@ class LFOProcessor extends AudioWorkletProcessor {
}
registerProcessor('lfo-processor', LFOProcessor);
class CoarseProcessor extends AudioWorkletProcessor {
class CoarseProcessor extends makeReusable(AudioWorkletProcessor) {
static get parameterDescriptors() {
return [{ name: 'coarse', defaultValue: 1 }];
return [...super.parameterDescriptors, { name: 'coarse', defaultValue: 1 }];
}
constructor() {
super();
this.started = false;
}
process(inputs, outputs, parameters) {
processActive(inputs, outputs, parameters) {
const input = inputs[0];
const output = outputs[0];
const hasInput = !(input[0] === undefined);
if (this.started && !hasInput) {
return false;
}
this.started = hasInput;
let coarse = parameters.coarse[0] ?? 0;
coarse = Math.max(1, coarse);
for (let n = 0; n < blockSize; n++) {
@@ -214,26 +194,14 @@ class CoarseProcessor extends AudioWorkletProcessor {
}
registerProcessor('coarse-processor', CoarseProcessor);
class CrushProcessor extends AudioWorkletProcessor {
class CrushProcessor extends makeReusable(AudioWorkletProcessor) {
static get parameterDescriptors() {
return [{ name: 'crush', defaultValue: 0 }];
return [...super.parameterDescriptors, { name: 'crush', defaultValue: 0 }];
}
constructor() {
super();
this.started = false;
}
process(inputs, outputs, parameters) {
processActive(inputs, outputs, parameters) {
const input = inputs[0];
const output = outputs[0];
const hasInput = !(input[0] === undefined);
if (this.started && !hasInput) {
return false;
}
this.started = hasInput;
let crush = parameters.crush[0] ?? 8;
crush = Math.max(1, crush);
@@ -248,29 +216,14 @@ class CrushProcessor extends AudioWorkletProcessor {
}
registerProcessor('crush-processor', CrushProcessor);
class ShapeProcessor extends AudioWorkletProcessor {
class ShapeProcessor extends makeReusable(AudioWorkletProcessor) {
static get parameterDescriptors() {
return [
{ name: 'shape', defaultValue: 0 },
{ name: 'postgain', defaultValue: 1 },
];
return [...super.parameterDescriptors, { name: 'shape', defaultValue: 0 }, { name: 'postgain', defaultValue: 1 }];
}
constructor() {
super();
this.started = false;
}
process(inputs, outputs, parameters) {
processActive(inputs, outputs, parameters) {
const input = inputs[0];
const output = outputs[0];
const hasInput = !(input[0] === undefined);
if (this.started && !hasInput) {
return false;
}
this.started = hasInput;
let shape = parameters.shape[0];
shape = shape < 1 ? shape : 1.0 - 4e-10;
shape = (2.0 * shape) / (1.0 - shape);
@@ -354,17 +307,17 @@ class DJFProcessor extends AudioWorkletProcessor {
registerProcessor('djf-processor', DJFProcessor);
//adapted from https://github.com/TheBouteillacBear/webaudioworklet-wasm?tab=MIT-1-ov-file
class LadderProcessor extends AudioWorkletProcessor {
class LadderProcessor extends makeReusable(AudioWorkletProcessor) {
static get parameterDescriptors() {
return [
...super.parameterDescriptors,
{ name: 'frequency', defaultValue: 500 },
{ name: 'q', defaultValue: 1 },
{ name: 'drive', defaultValue: 0.69 },
];
}
constructor() {
super();
initialize(_options) {
this.started = false;
this.p0 = [0, 0];
this.p1 = [0, 0];
@@ -375,17 +328,9 @@ class LadderProcessor extends AudioWorkletProcessor {
this.p34 = [0, 0];
}
process(inputs, outputs, parameters) {
processActive(inputs, outputs, parameters) {
const input = inputs[0];
const output = outputs[0];
const hasInput = !(input[0] === undefined);
if (this.started && !hasInput) {
return false;
}
this.started = hasInput;
const resonance = parameters.q[0];
const drive = clamp(Math.exp(parameters.drive[0]), 0.1, 2000);
@@ -418,29 +363,18 @@ class LadderProcessor extends AudioWorkletProcessor {
}
registerProcessor('ladder-processor', LadderProcessor);
class DistortProcessor extends AudioWorkletProcessor {
class DistortProcessor extends makeReusable(AudioWorkletProcessor) {
static get parameterDescriptors() {
return [
{ name: 'distort', defaultValue: 0 },
{ name: 'postgain', defaultValue: 1 },
];
return [...super.parameterDescriptors, { name: 'distort', defaultValue: 0 }, { name: 'postgain', defaultValue: 1 }];
}
constructor({ processorOptions }) {
super();
this.started = false;
this.algorithm = getDistortionAlgorithm(processorOptions.algorithm);
initialize(options) {
this.algorithm = getDistortionAlgorithm(options?.algorithm);
}
process(inputs, outputs, parameters) {
processActive(inputs, outputs, parameters) {
const input = inputs[0];
const output = outputs[0];
const hasInput = !(input[0] === undefined);
if (this.started && !hasInput) {
return false;
}
this.started = hasInput;
for (let n = 0; n < blockSize; n++) {
const postgain = clamp(pv(parameters.postgain, n), 0.001, 1);
const shape = Math.expm1(pv(parameters.distort, n));
@@ -455,33 +389,15 @@ class DistortProcessor extends AudioWorkletProcessor {
registerProcessor('distort-processor', DistortProcessor);
// SUPERSAW
class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.phase = [];
}
class SuperSawOscillatorProcessor extends makeReusable(AudioWorkletProcessor) {
static get parameterDescriptors() {
return [
{
name: 'begin',
defaultValue: 0,
max: Number.POSITIVE_INFINITY,
min: 0,
},
{
name: 'end',
defaultValue: 0,
max: Number.POSITIVE_INFINITY,
min: 0,
},
...super.parameterDescriptors,
{
name: 'frequency',
defaultValue: 440,
min: Number.EPSILON,
},
{
name: 'panspread',
defaultValue: 0.4,
@@ -498,26 +414,14 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
defaultValue: 0,
min: 0,
},
{
name: 'voices',
defaultValue: 5,
min: 1,
automationRate: 'k-rate',
},
];
}
process(_input, outputs, params) {
if (currentTime >= params.end[0]) {
// should terminate
return false;
}
if (currentTime <= params.begin[0]) {
// keep alive
return true;
}
initialize(options) {
this.phase = [];
this.voices = options?.voices ?? 5;
}
processActive(_input, outputs, params) {
const output = outputs[0];
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);
@@ -527,8 +431,8 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
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++) {
const detuner = getDetuner(this.voices, freqspread);
for (let n = 0; n < this.voices; n++) {
// Individual voice detuning
const freqVoice = applySemitoneDetuneToFrequency(freq, detuner(n));
// We must wrap this here because it is passed into sawblep below which
@@ -572,12 +476,7 @@ function genHannWindow(length) {
class PhaseVocoderProcessor extends OLAProcessor {
static get parameterDescriptors() {
return [
{
name: 'pitchFactor',
defaultValue: 1.0,
},
];
return [...super.parameterDescriptors, { name: 'pitchFactor', defaultValue: 1.0 }];
}
constructor(options) {
@@ -585,9 +484,13 @@ class PhaseVocoderProcessor extends OLAProcessor {
blockSize: BUFFERED_BLOCK_SIZE,
};
super(options);
this.timeCursor = 0;
this.fftSize = this.blockSize;
this.invfftSize = 1 / this.fftSize;
}
initialize(options) {
super.initialize(options);
this.timeCursor = 0;
this.hannWindow = genHannWindow(this.fftSize);
// prepare FFT and pre-allocate buffers
this.fft = new FFT(this.fftSize);
@@ -718,9 +621,8 @@ class PhaseVocoderProcessor extends OLAProcessor {
registerProcessor('phase-vocoder-processor', PhaseVocoderProcessor);
// Adapted from https://www.musicdsp.org/en/latest/Effects/221-band-limited-pwm-generator.html
class PulseOscillatorProcessor extends AudioWorkletProcessor {
constructor() {
super();
class PulseOscillatorProcessor extends makeReusable(AudioWorkletProcessor) {
initialize() {
this.phi = -PI; // phase
this.Y0 = 0; // feedback memories
this.Y1 = 0;
@@ -732,20 +634,7 @@ class PulseOscillatorProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{
name: 'begin',
defaultValue: 0,
max: Number.POSITIVE_INFINITY,
min: 0,
},
{
name: 'end',
defaultValue: 0,
max: Number.POSITIVE_INFINITY,
min: 0,
},
...super.parameterDescriptors,
{
name: 'frequency',
defaultValue: 440,
@@ -766,20 +655,10 @@ class PulseOscillatorProcessor extends AudioWorkletProcessor {
];
}
process(inputs, outputs, params) {
if (this.disconnected) {
return false;
}
if (currentTime <= params.begin[0]) {
return true;
}
if (currentTime >= params.end[0]) {
return false;
}
processActive(inputs, outputs, params) {
const output = outputs[0];
let env = 1,
dphi;
for (let i = 0; i < (output[0].length ?? 0); i++) {
const pw = (1 - clamp(pv(params.pulsewidth, i), -0.99, 0.99)) * PI;
const detune = pv(params.detune, i);
@@ -862,28 +741,23 @@ function getByteBeatFunc(codetext) {
return new Function(...mathParams, 't', `return 0,\n${codetext || 0};`).bind(globalThis, ...byteBeatHelperFuncs);
}
class ByteBeatProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.port.onmessage = (event) => {
let { codeText } = event.data;
const { byteBeatStartTime } = event.data;
if (byteBeatStartTime != null) {
this.t = 0;
this.initialOffset = Math.floor(byteBeatStartTime);
}
//Optimization pulled from dollchan.net: https://github.com/Chasyxx/EnBeat_NEW, it seemed important
//Optimize code like eval(unescape(escape`XXXX`.replace(/u(..)/g,"$1%")))
codeText = codeText
.trim()
.replace(
/^eval\(unescape\(escape(?:`|\('|\("|\(`)(.*?)(?:`|'\)|"\)|`\)).replace\(\/u\(\.\.\)\/g,["'`]\$1%["'`]\)\)\)$/,
(match, m1) => unescape(escape(m1).replace(/u(..)/g, '$1%')),
);
this.func = getByteBeatFunc(codeText);
};
class ByteBeatProcessor extends makeReusable(AudioWorkletProcessor) {
initialize(options) {
let codeText = options?.codeText;
const byteBeatStartTime = options?.byteBeatStartTime;
if (byteBeatStartTime != null) {
this.t = 0;
this.initialOffset = Math.floor(byteBeatStartTime);
}
//Optimization pulled from dollchan.net: https://github.com/Chasyxx/EnBeat_NEW, it seemed important
//Optimize code like eval(unescape(escape`XXXX`.replace(/u(..)/g,"$1%")))
codeText = codeText
.trim()
.replace(
/^eval\(unescape\(escape(?:`|\('|\("|\(`)(.*?)(?:`|'\)|"\)|`\)).replace\(\/u\(\.\.\)\/g,["'`]\$1%["'`]\)\)\)$/,
(match, m1) => unescape(escape(m1).replace(/u(..)/g, '$1%')),
);
this.func = getByteBeatFunc(codeText);
this.initialOffset = 0;
this.t = null;
this.func = null;
@@ -891,12 +765,7 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{
name: 'begin',
defaultValue: 0,
max: Number.POSITIVE_INFINITY,
min: 0,
},
...super.parameterDescriptors,
{
name: 'frequency',
defaultValue: 440,
@@ -908,25 +777,10 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
min: Number.NEGATIVE_INFINITY,
max: Number.POSITIVE_INFINITY,
},
{
name: 'end',
defaultValue: 0,
max: Number.POSITIVE_INFINITY,
min: 0,
},
];
}
process(inputs, outputs, params) {
if (this.disconnected) {
return false;
}
if (currentTime <= params.begin[0]) {
return true;
}
if (currentTime >= params.end[0]) {
return false;
}
processActive(inputs, outputs, params) {
if (this.t == null) {
this.t = params.begin[0] * sampleRate;
}
@@ -952,11 +806,9 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
registerProcessor('byte-beat-processor', ByteBeatProcessor);
class EnvelopeProcessor extends AudioWorkletProcessor {
class EnvelopeProcessor extends makeReusable(AudioWorkletProcessor) {
static get parameterDescriptors() {
return [
{ name: 'begin', defaultValue: 0 },
{ name: 'end', defaultValue: 0 },
{ name: 'attack', defaultValue: 0.005, minValue: 0 },
{ name: 'decay', defaultValue: 0.14, minValue: 0 },
{ name: 'sustain', defaultValue: 0, minValue: 0, maxValue: 1 },
@@ -1116,56 +968,36 @@ function brownian(x, oct = 4) {
}
const tablesCache = {};
class WavetableOscillatorProcessor extends AudioWorkletProcessor {
class WavetableOscillatorProcessor extends makeReusable(AudioWorkletProcessor) {
static get parameterDescriptors() {
return [
{ name: 'begin', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY },
{ name: 'end', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY },
...super.parameterDescriptors,
{ name: 'frequency', defaultValue: 440, min: Number.EPSILON },
{ name: 'detune', defaultValue: 0 },
{ name: 'freqspread', defaultValue: 0.18, min: 0 },
{ name: 'position', defaultValue: 0, min: 0, max: 1 },
{ name: 'warp', defaultValue: 0, min: 0, max: 1 },
{ name: 'warpMode', defaultValue: 0 },
{ name: 'voices', defaultValue: 1, min: 1, automationRate: 'k-rate' },
{ name: 'panspread', defaultValue: 0.7, min: 0, max: 1 },
{ name: 'phaserand', defaultValue: 0, min: 0, max: 1 },
];
}
constructor(options) {
super(options);
this.frameLen = 0;
this.numFrames = 0;
initialize(options) {
this.table = null;
this.frameLen = null;
this.numFrames = null;
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]) {
const tables = [payload.frames];
let table = tables[0];
for (let level = 1; level < 1; level++) {
const nextLen = table.length >> 1;
const nextTable = table.map((frame) => {
const avg = new Float32Array(nextLen);
for (let i = 0; i < nextLen; i++) {
avg[i] = (frame[2 * i] + frame[2 * i + 1]) / 2;
}
return avg;
});
tables.push(nextTable);
table = nextTable;
if (nextLen <= 32) break;
}
tablesCache[key] = tables;
}
this.tables = tablesCache[key];
this.numFrames = this.tables[0].length;
if (options?.key) {
const key = options.key;
this.frameLen = options.frameLen;
if (!tablesCache[key]) {
tablesCache[key] = options.frames;
}
};
this.table = tablesCache[key];
this.numFrames = this.table.length;
}
this.voices = options?.voices ?? 1;
}
_mirror(x) {
@@ -1310,30 +1142,14 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
return a + (b - a) * frac;
}
_chooseMip(dphi) {
const approxHarm = clamp(dphi, 1e-6, 64);
let level = 0;
while (level + 1 < (this.tables?.length || 1) && approxHarm < this.tables[level][0].length / 8) {
level++;
}
return level;
}
process(_inputs, outputs, parameters) {
if (currentTime >= parameters.end[0]) {
return false;
}
if (currentTime <= parameters.begin[0]) {
return true;
}
processActive(_inputs, outputs, parameters) {
const outL = outputs[0][0];
const outR = outputs[0][1] || outputs[0][0];
if (!this.tables) {
if (!this.table) {
outL.fill(0);
if (outR !== outL) outR.set(outL);
return true;
}
const voices = parameters.voices[0]; // k-rate
for (let i = 0; i < outL.length; i++) {
const detune = pv(parameters.detune, i);
const freqspread = pv(parameters.freqspread, i);
@@ -1344,14 +1160,14 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
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 panspread = this.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 normalizer = 1 / Math.sqrt(this.voices);
const detuner = getDetuner(this.voices, freqspread);
for (let n = 0; n < this.voices; n++) {
const isOdd = (n & 1) == 1;
let gainL = gain1;
let gainR = gain2;
@@ -1362,14 +1178,12 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
}
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
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);
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.phase[n] < warpAmount) {
s = -s;