WIP with compressors, filters, heavy worklets
This commit is contained in:
parent
eb18648a2a
commit
600ab0a83e
7 changed files with 166 additions and 81 deletions
|
|
@ -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;
|
let audioContext;
|
||||||
|
|
||||||
export const setDefaultAudioContext = () => {
|
export const setDefaultAudioContext = () => {
|
||||||
|
|
|
||||||
70
packages/superdough/nodePools.mjs
Normal file
70
packages/superdough/nodePools.mjs
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
/*
|
||||||
|
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];
|
||||||
|
|
||||||
|
const getParams = (node) => {
|
||||||
|
const params = new Set();
|
||||||
|
node.parameters?.forEach((param) => params.add(param));
|
||||||
|
const visited = new Set(); // prioritize deepest definition
|
||||||
|
let proto = node;
|
||||||
|
// Move up the prototype chain
|
||||||
|
while (proto !== Object.prototype) {
|
||||||
|
for (const key of Object.getOwnPropertyNames(proto)) {
|
||||||
|
if (visited.has(key)) continue;
|
||||||
|
visited.add(key);
|
||||||
|
const value = node[key];
|
||||||
|
if (value instanceof AudioParam) {
|
||||||
|
params.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
proto = Object.getPrototypeOf(proto);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const releaseNodeToPool = (node) => {
|
||||||
|
node.disconnect();
|
||||||
|
if (node instanceof AudioScheduledSourceNode) {
|
||||||
|
// not reusable
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fallback to a type-based key if the node was not created via getNodeFromPool
|
||||||
|
const key = node[POOL_KEY];
|
||||||
|
if (key == null) return;
|
||||||
|
const now = node.context?.currentTime ?? 0;
|
||||||
|
getParams(node).forEach((param) => param.cancelScheduledValues(now));
|
||||||
|
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) => {
|
||||||
|
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;
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
@ -9,7 +9,8 @@ import './reverb.mjs';
|
||||||
import './vowel.mjs';
|
import './vowel.mjs';
|
||||||
import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
|
import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
|
||||||
import workletsUrl from './worklets.mjs?audioworklet';
|
import workletsUrl from './worklets.mjs?audioworklet';
|
||||||
import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend } from './helpers.mjs';
|
import { createFilter, effectSend, gainNode, getCompressor, getDistortion, getLfo, getWorklet } from './helpers.mjs';
|
||||||
|
import { getNodeFromPool, isPoolable, releaseNodeToPool } from './nodePools.mjs';
|
||||||
import { map } from 'nanostores';
|
import { map } from 'nanostores';
|
||||||
import { logger } from './logger.mjs';
|
import { logger } from './logger.mjs';
|
||||||
import { loadBuffer } from './sampler.mjs';
|
import { loadBuffer } from './sampler.mjs';
|
||||||
|
|
@ -294,7 +295,7 @@ function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000
|
||||||
let fOffset = 0;
|
let fOffset = 0;
|
||||||
const filterChain = [];
|
const filterChain = [];
|
||||||
for (let i = 0; i < numStages; i++) {
|
for (let i = 0; i < numStages; i++) {
|
||||||
const filter = ac.createBiquadFilter();
|
const filter = getNodeFromPool('filter', () => ac.createBiquadFilter());
|
||||||
filter.type = 'notch';
|
filter.type = 'notch';
|
||||||
filter.gain.value = 1;
|
filter.gain.value = 1;
|
||||||
filter.frequency.value = centerFrequency + fOffset;
|
filter.frequency.value = centerFrequency + fOffset;
|
||||||
|
|
@ -506,7 +507,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||||
} else if (getSound(s)) {
|
} else if (getSound(s)) {
|
||||||
const { onTrigger } = getSound(s);
|
const { onTrigger } = getSound(s);
|
||||||
const onEnded = () => {
|
const onEnded = () => {
|
||||||
audioNodes.forEach((n) => n?.disconnect());
|
audioNodes.forEach((n) => (isPoolable(n) ? releaseNodeToPool(n) : n?.disconnect()));
|
||||||
activeSoundSources.delete(chainID);
|
activeSoundSources.delete(chainID);
|
||||||
};
|
};
|
||||||
const soundHandle = await onTrigger(t, value, onEnded, cps);
|
const soundHandle = await onTrigger(t, value, onEnded, cps);
|
||||||
|
|
|
||||||
|
|
@ -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 { effectSend, getWorklet, webAudioTimeout } from './helpers.mjs';
|
||||||
import { errorLogger } from './logger.mjs';
|
import { errorLogger } from './logger.mjs';
|
||||||
import { clamp } from './util.mjs';
|
import { clamp } from './util.mjs';
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
} from './helpers.mjs';
|
} from './helpers.mjs';
|
||||||
import { logger } from './logger.mjs';
|
import { logger } from './logger.mjs';
|
||||||
import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
|
import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
|
||||||
|
import { getNodeFromPool, markWorkletAsDead, releaseNodeToPool } from './nodePools.mjs';
|
||||||
|
|
||||||
const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user'];
|
const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user'];
|
||||||
const waveformAliases = [
|
const waveformAliases = [
|
||||||
|
|
@ -164,22 +165,25 @@ export function registerSynthSounds() {
|
||||||
const end = holdend + release + 0.01;
|
const end = holdend + release + 0.01;
|
||||||
const voices = clamp(unison, 1, 100);
|
const voices = clamp(unison, 1, 100);
|
||||||
let panspread = voices > 1 ? clamp(spread, 0, 1) : 0;
|
let panspread = voices > 1 ? clamp(spread, 0, 1) : 0;
|
||||||
let o = getWorklet(
|
const params = {
|
||||||
ac,
|
frequency,
|
||||||
'supersaw-oscillator',
|
begin,
|
||||||
{
|
end: end + 0.5, // add a grace period for pooling
|
||||||
frequency,
|
freqspread: detune,
|
||||||
begin,
|
voices,
|
||||||
end,
|
panspread,
|
||||||
freqspread: detune,
|
};
|
||||||
voices,
|
const factory = () => new AudioWorkletNode(ac, 'supersaw-oscillator', { outputChannelCount: [2] });
|
||||||
panspread,
|
const o = getNodeFromPool('supersaw', factory);
|
||||||
},
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
{
|
if (value !== undefined) {
|
||||||
outputChannelCount: [2],
|
o.parameters.get(key).value = value;
|
||||||
},
|
}
|
||||||
);
|
});
|
||||||
|
o.port.postMessage({ type: 'initialize' });
|
||||||
|
o.port.onMessage = (e) => {
|
||||||
|
if (e.data.type === 'died') markWorkletAsDead(o);
|
||||||
|
};
|
||||||
const gainAdjustment = 1 / Math.sqrt(voices);
|
const gainAdjustment = 1 / Math.sqrt(voices);
|
||||||
getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend);
|
getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend);
|
||||||
const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin);
|
const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin);
|
||||||
|
|
@ -192,7 +196,7 @@ export function registerSynthSounds() {
|
||||||
let timeoutNode = webAudioTimeout(
|
let timeoutNode = webAudioTimeout(
|
||||||
ac,
|
ac,
|
||||||
() => {
|
() => {
|
||||||
releaseAudioNode(o);
|
releaseNodeToPool(o);
|
||||||
onended();
|
onended();
|
||||||
fm?.stop();
|
fm?.stop();
|
||||||
vibratoOscillator?.stop();
|
vibratoOscillator?.stop();
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,9 @@ import {
|
||||||
getParamADSR,
|
getParamADSR,
|
||||||
getPitchEnvelope,
|
getPitchEnvelope,
|
||||||
getVibratoOscillator,
|
getVibratoOscillator,
|
||||||
getWorklet,
|
|
||||||
releaseAudioNode,
|
|
||||||
webAudioTimeout,
|
webAudioTimeout,
|
||||||
} from './helpers.mjs';
|
} from './helpers.mjs';
|
||||||
|
import { getNodeFromPool, markWorkletAsDead, releaseNodeToPool } from './nodePools.mjs';
|
||||||
import { logger } from './logger.mjs';
|
import { logger } from './logger.mjs';
|
||||||
|
|
||||||
export const Warpmode = Object.freeze({
|
export const Warpmode = Object.freeze({
|
||||||
|
|
@ -225,24 +224,29 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
|
||||||
}
|
}
|
||||||
const endWithRelease = holdEnd + release;
|
const endWithRelease = holdEnd + release;
|
||||||
const envEnd = endWithRelease + 0.01;
|
const envEnd = endWithRelease + 0.01;
|
||||||
const source = getWorklet(
|
const params = {
|
||||||
ac,
|
begin: t,
|
||||||
'wavetable-oscillator-processor',
|
end: envEnd + 0.5, // add a grace period for pooling
|
||||||
{
|
frequency,
|
||||||
begin: t,
|
freqspread: value.detune,
|
||||||
end: envEnd,
|
position: value.wt,
|
||||||
frequency,
|
warp: value.warp,
|
||||||
freqspread: value.detune,
|
warpMode: warpmode,
|
||||||
position: value.wt,
|
voices: Math.max(value.unison ?? 1, 1),
|
||||||
warp: value.warp,
|
panspread: value.spread,
|
||||||
warpMode: warpmode,
|
phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0,
|
||||||
voices: Math.max(value.unison ?? 1, 1),
|
};
|
||||||
panspread: value.spread,
|
const factory = () => new AudioWorkletNode(ac, 'wavetable-oscillator-processor', { outputChannelCount: [2] });
|
||||||
phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0,
|
const source = getNodeFromPool('wavetable', factory);
|
||||||
},
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
{ outputChannelCount: [2] },
|
if (value !== undefined) {
|
||||||
);
|
source.parameters.get(key).value = value;
|
||||||
source.port.postMessage({ type: 'table', payload });
|
}
|
||||||
|
});
|
||||||
|
source.port.postMessage({ type: 'initialize', payload });
|
||||||
|
source.port.onMessage = (e) => {
|
||||||
|
if (e.data.type === 'died') markWorkletAsDead(source);
|
||||||
|
};
|
||||||
if (ac.currentTime > t) {
|
if (ac.currentTime > t) {
|
||||||
logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight');
|
logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight');
|
||||||
return;
|
return;
|
||||||
|
|
@ -319,7 +323,7 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
|
||||||
const timeoutNode = webAudioTimeout(
|
const timeoutNode = webAudioTimeout(
|
||||||
ac,
|
ac,
|
||||||
() => {
|
() => {
|
||||||
releaseAudioNode(source);
|
releaseNodeToPool(source);
|
||||||
vibratoOscillator?.stop();
|
vibratoOscillator?.stop();
|
||||||
fm?.stop();
|
fm?.stop();
|
||||||
wtPosModulators?.disconnect();
|
wtPosModulators?.disconnect();
|
||||||
|
|
|
||||||
|
|
@ -458,6 +458,15 @@ registerProcessor('distort-processor', DistortProcessor);
|
||||||
class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
|
class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
this.port.onmessage = (e) => {
|
||||||
|
const { type, payload } = e.data || {};
|
||||||
|
if (type === 'initialize') {
|
||||||
|
this.initialize(payload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
initialize(_options) {
|
||||||
this.phase = [];
|
this.phase = [];
|
||||||
}
|
}
|
||||||
static get parameterDescriptors() {
|
static get parameterDescriptors() {
|
||||||
|
|
@ -1135,37 +1144,27 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||||
|
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super(options);
|
super(options);
|
||||||
|
this.port.onmessage = (e) => {
|
||||||
|
const { type, payload } = e.data || {};
|
||||||
|
if (type === 'initialize') {
|
||||||
|
this.initialize(payload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
initialize(options) {
|
||||||
this.frameLen = 0;
|
this.frameLen = 0;
|
||||||
this.numFrames = 0;
|
this.numFrames = 0;
|
||||||
this.phase = [];
|
this.phase = [];
|
||||||
|
if (options?.frames) {
|
||||||
this.port.onmessage = (e) => {
|
const key = options.key;
|
||||||
const { type, payload } = e.data || {};
|
this.frameLen = options.frameLen;
|
||||||
if (type === 'table') {
|
if (!tablesCache[key]) {
|
||||||
const key = payload.key;
|
tablesCache[key] = options.frames;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
};
|
this.table = tablesCache[key];
|
||||||
|
this.numFrames = this.table.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_mirror(x) {
|
_mirror(x) {
|
||||||
|
|
@ -1310,15 +1309,6 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||||
return a + (b - a) * frac;
|
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) {
|
process(_inputs, outputs, parameters) {
|
||||||
if (currentTime >= parameters.end[0]) {
|
if (currentTime >= parameters.end[0]) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -1362,14 +1352,12 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||||
}
|
}
|
||||||
const fVoice = applySemitoneDetuneToFrequency(f, detuner(n)); // voice detune
|
const fVoice = applySemitoneDetuneToFrequency(f, detuner(n)); // voice detune
|
||||||
const dPhase = fVoice * INVSR;
|
const dPhase = fVoice * INVSR;
|
||||||
const level = this._chooseMip(dPhase);
|
|
||||||
const table = this.tables[level];
|
|
||||||
|
|
||||||
// warp phase then sample
|
// warp phase then sample
|
||||||
this.phase[n] = this.phase[n] ?? Math.random() * phaseRand;
|
this.phase[n] = this.phase[n] ?? Math.random() * phaseRand;
|
||||||
const ph = this._warpPhase(this.phase[n], warpAmount, warpMode);
|
const ph = this._warpPhase(this.phase[n], warpAmount, warpMode);
|
||||||
const s0 = this._sampleFrame(table[fIdx], ph);
|
const s0 = this._sampleFrame(this.table[fIdx], ph);
|
||||||
const s1 = this._sampleFrame(table[Math.min(this.numFrames - 1, fIdx + 1)], ph);
|
const s1 = this._sampleFrame(this.table[Math.min(this.numFrames - 1, fIdx + 1)], ph);
|
||||||
let s = lerp(s0, s1, interpT);
|
let s = lerp(s0, s1, interpT);
|
||||||
if (warpMode === WarpMode.FLIP && this.phase[n] < warpAmount) {
|
if (warpMode === WarpMode.FLIP && this.phase[n] < warpAmount) {
|
||||||
s = -s;
|
s = -s;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue