Compare commits

..

24 Commits

Author SHA1 Message Date
Switch Angel AKA Jade Rose bf21c4cf02 Merge branch 'main' into glossing/basic-node-pool 2026-01-10 22:58:28 +01:00
Jade (Rose) Rowland 741ff3f3f0 fix import 2026-01-10 16:55:53 -05:00
Jade (Rose) Rowland 4476921069 use releasenode 2026-01-10 16:46:11 -05:00
Aria 8c5e4a5780 Merge pull request 'Allow top level distortions for the purpose of FX' (#1884) from glossing/naked-distorts into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1884
2026-01-10 20:52:39 +01:00
Aria 34ae1bb305 Merge branch 'main' into glossing/naked-distorts 2026-01-10 20:38:34 +01:00
Aria 0cf11adc2c Allow naked distortions for the purpose of FX 2026-01-10 13:07:54 -06:00
Jade (Rose) Rowland 7a5020aa71 format 2026-01-10 01:42:43 -05:00
Jade (Rose) Rowland a205f7d873 rm dead code 2026-01-10 01:42:06 -05:00
Jade (Rose) Rowland 0e9c50bf0a fix import 2026-01-10 01:39:39 -05:00
Jade (Rose) Rowland e837c516c5 merge main 2026-01-10 01:37:14 -05:00
Jade (Rose) Rowland 3b2e9fa0b4 wip 2026-01-10 01:22:50 -05:00
Jade (Rose) Rowland 6a47139e0d wip 2026-01-10 01:07:09 -05:00
Aria f7f8c56c0a Merge pull request 'Bake in scaling by freq for FM with a gain node' (#1878) from glossing/fm-modulation into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1878
2026-01-09 03:43:35 +01:00
Aria c7e88e1ff2 Merge branch 'main' into glossing/fm-modulation 2026-01-09 03:31:26 +01:00
Aria f6a43f1b10 Make the scaling by freq in FM a separate node so that modulators behave like the fm control 2026-01-08 20:28:53 -06:00
Aria a3b183d304 Merge pull request 'Bug fix: Properly handle subcontrols' (#1877) from glossing/subcontrols into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1877
2026-01-09 02:53:37 +01:00
Aria f7175c0505 Properly handle subcontrols 2026-01-08 19:40:50 -06:00
Aria e26040d154 Merge pull request 'Feat: Add ability to turn mini parsing off with mini-off decorator' (#1786) from glossing/disable-mini into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1786
2026-01-07 20:06:17 +01:00
Aria 4c99f4866b Use setvalueattime to pin values and prevent bleed 2025-12-04 22:50:28 -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 246 additions and 109 deletions
+29 -8
View File
@@ -3586,6 +3586,20 @@ export const morph = (frompat, topat, bypat) => {
return frompat.innerBind((from) => topat.innerBind((to) => bypat.innerBind((by) => _morph(from, to, by)))); return frompat.innerBind((from) => topat.innerBind((to) => bypat.innerBind((by) => _morph(from, to, by))));
}; };
const _distortWithAlg = function (name) {
const func = function (args, pat) {
const argsPat = reify(args).fmap((v) => (Array.isArray(v) ? [...v, name] : [v, 1, name]));
if (!pat) {
return pure({}).distort(argsPat);
}
return pat.distort(argsPat);
};
Pattern.prototype[name] = function (args) {
return func(args, this);
};
return func;
};
/** /**
* Soft-clipping distortion * Soft-clipping distortion
* *
@@ -3594,6 +3608,8 @@ export const morph = (frompat, topat, bypat) => {
* @param {number | Pattern} volume linear postgain of the distortion * @param {number | Pattern} volume linear postgain of the distortion
* *
*/ */
export const soft = _distortWithAlg('soft');
/** /**
* Hard-clipping distortion * Hard-clipping distortion
* *
@@ -3602,6 +3618,8 @@ export const morph = (frompat, topat, bypat) => {
* @param {number | Pattern} volume linear postgain of the distortion * @param {number | Pattern} volume linear postgain of the distortion
* *
*/ */
export const hard = _distortWithAlg('hard');
/** /**
* Cubic polynomial distortion * Cubic polynomial distortion
* *
@@ -3610,6 +3628,8 @@ export const morph = (frompat, topat, bypat) => {
* @param {number | Pattern} volume linear postgain of the distortion * @param {number | Pattern} volume linear postgain of the distortion
* *
*/ */
export const cubic = _distortWithAlg('cubic');
/** /**
* Diode-emulating distortion * Diode-emulating distortion
* *
@@ -3618,6 +3638,8 @@ export const morph = (frompat, topat, bypat) => {
* @param {number | Pattern} volume linear postgain of the distortion * @param {number | Pattern} volume linear postgain of the distortion
* *
*/ */
export const diode = _distortWithAlg('diode');
/** /**
* Asymmetrical diode distortion * Asymmetrical diode distortion
* *
@@ -3626,6 +3648,8 @@ export const morph = (frompat, topat, bypat) => {
* @param {number | Pattern} volume linear postgain of the distortion * @param {number | Pattern} volume linear postgain of the distortion
* *
*/ */
export const asym = _distortWithAlg('asym');
/** /**
* Wavefolding distortion * Wavefolding distortion
* *
@@ -3634,6 +3658,8 @@ export const morph = (frompat, topat, bypat) => {
* @param {number | Pattern} volume linear postgain of the distortion * @param {number | Pattern} volume linear postgain of the distortion
* *
*/ */
export const fold = _distortWithAlg('fold');
/** /**
* Wavefolding distortion composed with sinusoid * Wavefolding distortion composed with sinusoid
* *
@@ -3642,6 +3668,8 @@ export const morph = (frompat, topat, bypat) => {
* @param {number | Pattern} volume linear postgain of the distortion * @param {number | Pattern} volume linear postgain of the distortion
* *
*/ */
export const sinefold = _distortWithAlg('sinefold');
/** /**
* Distortion via Chebyshev polynomials * Distortion via Chebyshev polynomials
* *
@@ -3650,14 +3678,7 @@ export const morph = (frompat, topat, bypat) => {
* @param {number | Pattern} volume linear postgain of the distortion * @param {number | Pattern} volume linear postgain of the distortion
* *
*/ */
const distAlgoNames = ['scurve', 'soft', 'hard', 'cubic', 'diode', 'asym', 'fold', 'sinefold', 'chebyshev']; export const chebyshev = _distortWithAlg('chebyshev');
for (const name of distAlgoNames) {
// Add aliases for distortion algorithms
Pattern.prototype[name] = function (args) {
const argsPat = reify(args).fmap((v) => (Array.isArray(v) ? [...v, name] : [v, 1, name]));
return this.distort(argsPat);
};
}
/** /**
* Turns a list of patterns into a single pattern which outputs list-values * Turns a list of patterns into a single pattern which outputs list-values
+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; let audioContext;
export const setDefaultAudioContext = () => { export const setDefaultAudioContext = () => {
+18 -8
View File
@@ -1,6 +1,7 @@
import { getAudioContext } from './audioContext.mjs'; import { getAudioContext } from './audioContext.mjs';
import { logger } from './logger.mjs'; import { logger } from './logger.mjs';
import { getNoiseBuffer } from './noise.mjs'; import { getNoiseBuffer } from './noise.mjs';
import { getNodeFromPool } from './nodePools.mjs';
import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs'; import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
export const noises = ['pink', 'white', 'brown', 'crackle']; export const noises = ['pink', 'white', 'brown', 'crackle'];
@@ -145,6 +146,7 @@ export function getLfo(audioContext, properties = {}) {
} }
export function getCompressor(ac, threshold, ratio, knee, attack, release) { export function getCompressor(ac, threshold, ratio, knee, attack, release) {
const node = getNodeFromPool('compressor', () => new DynamicsCompressorNode(ac, {}));
const options = { const options = {
threshold: threshold ?? -3, threshold: threshold ?? -3,
ratio: ratio ?? 10, ratio: ratio ?? 10,
@@ -152,7 +154,11 @@ export function getCompressor(ac, threshold, ratio, knee, attack, release) {
attack: attack ?? 0.005, attack: attack ?? 0.005,
release: release ?? 0.05, 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 // changes the default values of the envelope based on what parameters the user has defined
@@ -233,10 +239,13 @@ export function createFilter(context, start, end, params, cps, cycle) {
filter = getWorklet(context, 'ladder-processor', { frequency, q, drive }); filter = getWorklet(context, 'ladder-processor', { frequency, q, drive });
frequencyParam = filter.parameters.get('frequency'); frequencyParam = filter.parameters.get('frequency');
} else { } else {
filter = context.createBiquadFilter(); const factory = () => context.createBiquadFilter();
filter = getNodeFromPool('filter', factory);
filter.type = type; filter.type = type;
filter.Q.value = q; const now = context.currentTime;
filter.frequency.value = frequency; Object.entries({ Q: q, frequency }).forEach(([key, value]) => {
filter[key].setValueAtTime(value, now);
});
frequencyParam = filter.frequency; frequencyParam = filter.frequency;
} }
const envelopeValues = [params.attack, params.decay, params.sustain, params.release]; const envelopeValues = [params.attack, params.decay, params.sustain, params.release];
@@ -461,10 +470,11 @@ export function applyFM(param, value, begin) {
nodes[`fm_${idx}`] = [osc]; nodes[`fm_${idx}`] = [osc];
} }
const { input, output, freq, osc, toCleanup } = fms[idx]; const { input, output, freq, osc, toCleanup } = fms[idx];
const g = gainNode(amt * freq); const gAmt = gainNode(amt);
io.push(isMod ? output.connect(g) : input); const gFreq = gainNode(freq);
cleanupOnEnd(osc, [...toCleanup, g]); io.push(isMod ? output.connect(gAmt).connect(gFreq) : input);
nodes[`fm_${idx}_gain`] = [g]; cleanupOnEnd(osc, [...toCleanup, gAmt, gFreq]);
nodes[`fm_${idx}_gain`] = [gAmt];
} }
if (!io[1]) { if (!io[1]) {
logger( logger(
+4 -4
View File
@@ -32,8 +32,9 @@ const getNodeParam = (node, name) => {
const controlTargets = getSuperdoughControlTargets(); const controlTargets = getSuperdoughControlTargets();
const getControlData = (control) => { const getControlData = (control, subControl) => {
return controlTargets[control.split('_')[0]]; const controlNoIdx = control.split('_')[0];
return controlTargets[`${controlNoIdx}_${subControl}`] ?? controlTargets[controlNoIdx];
}; };
const getRangeForParam = (paramName, currentValue) => { const getRangeForParam = (paramName, currentValue) => {
@@ -59,8 +60,7 @@ const clampWithWaveShaper = (modulator, min, max) => {
}; };
const getTargetParamsForControl = (control, nodes, subControl) => { const getTargetParamsForControl = (control, nodes, subControl) => {
const lookupKey = subControl ? `${control}_${subControl}` : control; const targetInfo = getControlData(control, subControl);
const targetInfo = getControlData(lookupKey) ?? getControlData(control);
if (!targetInfo) { if (!targetInfo) {
errorLogger( errorLogger(
new Error(`Could not find control data for target '${control}'. It may not be modulatable.`), new Error(`Could not find control data for target '${control}'. It may not be modulatable.`),
+73
View File
@@ -0,0 +1,73 @@
/*
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;
}
if (node[IS_WORKLET_DEAD]) {
// Worklet already terminated, don't pool it
return;
}
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;
};
+5 -4
View File
@@ -9,6 +9,7 @@ import './reverb.mjs';
import './vowel.mjs'; import './vowel.mjs';
import { clamp, nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs'; import { clamp, nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
import workletsUrl from './worklets.mjs?audioworklet'; import workletsUrl from './worklets.mjs?audioworklet';
import { getNodeFromPool, isPoolable, releaseNodeToPool } from './nodePools.mjs';
import { import {
createFilter, createFilter,
effectSend, effectSend,
@@ -342,7 +343,7 @@ function getPhaser(begin, end, frequency = 1, depth = 0.5, centerFrequency = 100
let fOffset = 282; //for backward compat in #1800 let fOffset = 282; //for backward compat in #1800
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;
@@ -428,7 +429,7 @@ class Chain {
return this; return this;
} }
releaseNodes() { releaseNodes() {
this.audioNodes.forEach((n) => releaseAudioNode(n)); this.audioNodes.forEach((n) => (isPoolable(n) ? releaseNodeToPool(n) : releaseAudioNode(n)));
this.audioNodes = []; this.audioNodes = [];
this.tails = []; this.tails = [];
} }
@@ -545,8 +546,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
nodes.main['source'] = [sourceNode]; nodes.main['source'] = [sourceNode];
} else if (getSound(s)) { } else if (getSound(s)) {
const { onTrigger } = getSound(s); const { onTrigger } = getSound(s);
// We have to use onEnded because some sources (e.g. `sampler`) have
// an internal duration which is longer than `value.duration`
const onEnded = () => const onEnded = () =>
webAudioTimeout( webAudioTimeout(
ac, ac,
@@ -557,6 +557,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
0, 0,
endWithRelease, endWithRelease,
); );
const soundHandle = await onTrigger(t, value, onEnded, cps); const soundHandle = await onTrigger(t, value, onEnded, cps);
if (soundHandle) { if (soundHandle) {
+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 { 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';
+23 -17
View File
@@ -18,6 +18,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', 'one']; const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user', 'one'];
const waveformAliases = [ const waveformAliases = [
@@ -167,22 +168,27 @@ 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,
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);
}, const now = ac.currentTime;
{ Object.entries(params).forEach(([key, value]) => {
outputChannelCount: [2], const param = o.parameters.get(key);
}, const target = value !== undefined ? value : param.defaultValue;
); param.setValueAtTime(target, now);
});
o.port.postMessage({ type: 'initialize' });
o.port.onmessage = (e) => {
if (e.data.type === 'died') markWorkletAsDead(o);
o.port.onmessage = null;
};
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 vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin); const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin);
@@ -195,7 +201,7 @@ export function registerSynthSounds() {
let timeoutNode = webAudioTimeout( let timeoutNode = webAudioTimeout(
ac, ac,
() => { () => {
releaseAudioNode(o); releaseNodeToPool(o);
onended(); onended();
fmHandle?.stop(); fmHandle?.stop();
vibratoHandle?.stop(); vibratoHandle?.stop();
+28 -21
View File
@@ -8,10 +8,10 @@ import {
getParamADSR, getParamADSR,
getPitchEnvelope, getPitchEnvelope,
getVibratoOscillator, getVibratoOscillator,
getWorklet,
releaseAudioNode,
webAudioTimeout, webAudioTimeout,
releaseAudioNode,
} 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({
@@ -230,24 +230,31 @@ 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,
{ 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);
}, const now = ac.currentTime;
{ outputChannelCount: [2] }, Object.entries(params).forEach(([key, value]) => {
); const param = source.parameters.get(key);
source.port.postMessage({ type: 'table', payload }); const target = value !== undefined ? value : param.defaultValue;
param.setValueAtTime(target, now);
});
source.port.postMessage({ type: 'initialize', payload });
source.port.onmessage = (e) => {
if (e.data.type === 'died') markWorkletAsDead(source);
source.port.onmessage = null;
};
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;
@@ -333,7 +340,7 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
const timeoutNode = webAudioTimeout( const timeoutNode = webAudioTimeout(
ac, ac,
() => { () => {
releaseAudioNode(source); releaseNodeToPool(source);
vibratoHandle?.stop(); vibratoHandle?.stop();
fmHandle?.stop(); fmHandle?.stop();
releaseAudioNode(wtPosModulators); releaseAudioNode(wtPosModulators);
+48 -47
View File
@@ -463,6 +463,16 @@ registerProcessor('distort-processor', DistortProcessor);
class SuperSawOscillatorProcessor extends AudioWorkletProcessor { class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
constructor() { constructor() {
super(); super();
this.isAlive = true; // used internally to prevent multiple death messages
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() {
@@ -513,12 +523,16 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
]; ];
} }
process(_input, outputs, params) { process(_input, outputs, params) {
if (currentTime >= params.end[0]) { if (currentTime >= params.end[0] + 0.5) {
// should terminate // Outside of grace period - should terminate
if (this.isAlive) {
this.port.postMessage({ type: 'died' });
this.isAlive = false;
}
return false; return false;
} }
if (currentTime <= params.begin[0]) { if (currentTime >= params.end[0] || currentTime <= params.begin[0]) {
// keep alive // Inside of grace period or not yet started
return true; return true;
} }
const output = outputs[0]; const output = outputs[0];
@@ -1150,37 +1164,29 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
constructor(options) { constructor(options) {
super(options); super(options);
this.frameLen = 0; this.isAlive = true; // used internally to prevent multiple death messages
this.numFrames = 0;
this.phase = [];
this.port.onmessage = (e) => { this.port.onmessage = (e) => {
const { type, payload } = e.data || {}; const { type, payload } = e.data || {};
if (type === 'table') { if (type === 'initialize') {
const key = payload.key; this.initialize(payload);
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.initialize();
}
initialize(options) {
this.table = null;
this.frameLen = null;
this.numFrames = null;
this.phase = [];
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;
}
} }
_mirror(x) { _mirror(x) {
@@ -1325,25 +1331,22 @@ 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] + 0.5) {
// Outside of grace period - should terminate
if (this.isAlive) {
this.port.postMessage({ type: 'died' });
this.isAlive = false;
}
return false; return false;
} }
if (currentTime <= parameters.begin[0]) { if (currentTime >= parameters.end[0] || currentTime <= parameters.begin[0]) {
// Inside of grace period or not yet started
return true; return true;
} }
const outL = outputs[0][0]; const outL = outputs[0][0];
const outR = outputs[0][1] || outputs[0][0]; const outR = outputs[0][1] || outputs[0][0];
if (!this.tables) { if (!this.table) {
outL.fill(0); outL.fill(0);
if (outR !== outL) outR.set(outL); if (outR !== outL) outR.set(outL);
return true; return true;
@@ -1377,14 +1380,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;