Merge branch 'main' into feature/autocomplete-sound-names

This commit is contained in:
Dr Mathew R Pocock 2025-10-02 10:32:16 +02:00
commit 908a703b7f
4 changed files with 90 additions and 66 deletions

View file

@ -135,12 +135,14 @@ export class SuperdoughOutput {
} }
reset() { reset() {
this.disconnect();
this.initializeAudio();
}
disconnect() {
this.channelMerger.disconnect(); this.channelMerger.disconnect();
this.destinationGain.disconnect(); this.destinationGain.disconnect();
this.destinationGain = null; this.destinationGain = null;
this.channelMerger = null; this.channelMerger = null;
this.nodes = {};
this.initializeAudio();
} }
connectToDestination = (input, channels = [0, 1]) => { connectToDestination = (input, channels = [0, 1]) => {
//This upmix can be removed if correct channel counts are set throughout the app, //This upmix can be removed if correct channel counts are set throughout the app,
@ -172,6 +174,7 @@ export class SuperdoughAudioController {
Array.from(this.nodes).forEach((node) => { Array.from(this.nodes).forEach((node) => {
node.disconnect(); node.disconnect();
}); });
this.nodes = {};
this.output.reset(); this.output.reset();
} }

View file

@ -5,7 +5,6 @@ import {
destroyAudioWorkletNode, destroyAudioWorkletNode,
getADSRValues, getADSRValues,
getFrequencyFromValue, getFrequencyFromValue,
getLfo,
getParamADSR, getParamADSR,
getPitchEnvelope, getPitchEnvelope,
getVibratoOscillator, getVibratoOscillator,
@ -14,7 +13,6 @@ import {
} from './helpers.mjs'; } from './helpers.mjs';
import { logger } from './logger.mjs'; import { logger } from './logger.mjs';
const WT_MAX_MIP_LEVELS = 6;
export const Warpmode = Object.freeze({ export const Warpmode = Object.freeze({
NONE: 0, NONE: 0,
ASYM: 1, ASYM: 1,
@ -40,39 +38,25 @@ export const Warpmode = Object.freeze({
FLIP: 21, FLIP: 21,
}); });
async function loadWavetableFrames(url, label, frameLen = 2048) { const seenKeys = new Set();
const buf = await loadBuffer(url, label); async function getPayload(url, label, frameLen = 2048) {
const ch0 = buf.getChannelData(0); const key = `${url},${frameLen}`;
const total = ch0.length; if (!seenKeys.has(key)) {
const numFrames = Math.max(1, Math.floor(total / frameLen)); const buf = await loadBuffer(url, label);
const frames = new Array(numFrames); const ch0 = buf.getChannelData(0);
for (let i = 0; i < numFrames; i++) { const total = ch0.length;
const start = i * frameLen; const numFrames = Math.max(1, Math.floor(total / frameLen));
frames[i] = ch0.subarray(start, start + frameLen); const frames = new Array(numFrames);
for (let i = 0; i < numFrames; i++) {
const start = i * frameLen;
frames[i] = ch0.subarray(start, start + frameLen);
}
seenKeys.add(key);
return { frames, frameLen, numFrames, key };
} }
return { frameLen, key }; // worklet will use the cached version
// build mipmaps
const mipmaps = [frames];
let levelFrames = frames;
for (let level = 1; level < WT_MAX_MIP_LEVELS; level++) {
const prevLen = levelFrames[0].length;
if (prevLen <= 32) break;
const nextLen = prevLen >> 1;
const next = levelFrames.map((src) => {
const out = new Float32Array(nextLen);
for (let j = 0; j < nextLen; j++) {
out[j] = (src[2 * j] + src[2 * j + 1]) / 2;
}
return out;
});
mipmaps.push(next);
levelFrames = next;
}
return { frames, mipmaps, frameLen, numFrames };
} }
const loadCache = {};
function humanFileSize(bytes, si) { function humanFileSize(bytes, si) {
var thresh = si ? 1000 : 1024; var thresh = si ? 1000 : 1024;
if (bytes < thresh) return bytes + ' B'; if (bytes < thresh) return bytes + ' B';
@ -117,6 +101,7 @@ async function decodeAtNativeRate(arr) {
return await tempAC.decodeAudioData(arr); return await tempAC.decodeAudioData(arr);
} }
const loadCache = {};
const loadBuffer = (url, label) => { const loadBuffer = (url, label) => {
url = url.replace('#', '%23'); url = url.replace('#', '%23');
if (!loadCache[url]) { if (!loadCache[url]) {
@ -222,7 +207,7 @@ export const tables = async (url, frameLen, json, options = {}) => {
}; };
export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
const { s, n = 0, duration } = value; const { s, n = 0, duration, clip } = value;
const ac = getAudioContext(); const ac = getAudioContext();
const [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]); const [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]);
let { warpmode } = value; let { warpmode } = value;
@ -231,8 +216,11 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
} }
const frequency = getFrequencyFromValue(value); const frequency = getFrequencyFromValue(value);
const { url, label } = getCommonSampleInfo(value, tables); const { url, label } = getCommonSampleInfo(value, tables);
const payload = await loadWavetableFrames(url, label, frameLen); const payload = await getPayload(url, label, frameLen);
const holdEnd = t + duration; let holdEnd = t + duration;
if (clip !== undefined) {
holdEnd = Math.min(t + clip * duration, holdEnd);
}
const endWithRelease = holdEnd + release; const endWithRelease = holdEnd + release;
const envEnd = endWithRelease + 0.01; const envEnd = endWithRelease + 0.01;
const source = getWorklet( const source = getWorklet(
@ -252,7 +240,7 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
}, },
{ outputChannelCount: [2] }, { outputChannelCount: [2] },
); );
source.port.postMessage({ type: 'tables', payload }); source.port.postMessage({ type: 'table', payload });
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;
@ -322,13 +310,12 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
const vibratoOscillator = getVibratoOscillator(source.parameters.get('detune'), value, t); const vibratoOscillator = getVibratoOscillator(source.parameters.get('detune'), value, t);
const envGain = ac.createGain(); const envGain = ac.createGain();
const node = source.connect(envGain); const node = source.connect(envGain);
getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear'); getParamADSR(node.gain, attack, decay, sustain, release, 0, 0.3, t, holdEnd, 'linear');
getPitchEnvelope(source.parameters.get('detune'), value, t, holdEnd); getPitchEnvelope(source.parameters.get('detune'), value, t, holdEnd);
const handle = { node, source }; const handle = { node, source };
const timeoutNode = webAudioTimeout( const timeoutNode = webAudioTimeout(
ac, ac,
() => { () => {
source.disconnect();
destroyAudioWorkletNode(source); destroyAudioWorkletNode(source);
vibratoOscillator?.stop(); vibratoOscillator?.stop();
node.disconnect(); node.disconnect();

View file

@ -137,7 +137,7 @@ class LFOProcessor extends AudioWorkletProcessor {
} }
} }
process(inputs, outputs, parameters) { process(_inputs, outputs, parameters) {
const begin = parameters['begin'][0]; const begin = parameters['begin'][0];
if (currentTime >= parameters.end[0]) { if (currentTime >= parameters.end[0]) {
return false; return false;
@ -510,7 +510,7 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
}, },
]; ];
} }
process(input, outputs, params) { process(_input, outputs, params) {
if (currentTime <= params.begin[0]) { if (currentTime <= params.begin[0]) {
return true; return true;
} }
@ -1043,6 +1043,7 @@ function brownian(x, oct = 4) {
return (sum / norm) * 2 - 1; return (sum / norm) * 2 - 1;
} }
const tablesCache = {};
class WavetableOscillatorProcessor extends AudioWorkletProcessor { class WavetableOscillatorProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() { static get parameterDescriptors() {
return [ return [
@ -1061,31 +1062,40 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
constructor(options) { constructor(options) {
super(options); super(options);
this.tables = null;
this.frameLen = 0; this.frameLen = 0;
this.numFrames = 0; this.numFrames = 0;
this.phase = []; this.phase = [];
this.syncRatio = 1; this.invSR = 1 / sampleRate;
this.port.onmessage = (e) => { this.port.onmessage = (e) => {
const { type, payload } = e.data || {}; const { type, payload } = e.data || {};
if (type === 'tables') { if (type === 'table') {
this.tables = payload.mipmaps; const key = payload.key;
this.frameLen = payload.frameLen; 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.numFrames = this.tables[0].length;
} }
}; };
} }
_chooseMip(dphi) {
const approxHarm = Math.min(64, 1 / Math.max(1e-6, dphi));
let level = 0;
while (level + 1 < (this.tables?.length || 1) && approxHarm < this.tables[level][0].length / 8) {
level++;
}
return level;
}
_mirror(x) { _mirror(x) {
return 1 - Math.abs(2 * x - 1); return 1 - Math.abs(2 * x - 1);
} }
@ -1222,14 +1232,25 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
} }
_sampleFrame(frame, phase) { _sampleFrame(frame, phase) {
const pos = phase * frame.length; const len = frame.length;
const pos = phase * len;
const i = pos | 0; const i = pos | 0;
const frac = pos - i; const frac = pos - i;
const a = frame[i % frame.length]; const a = frame[i];
const b = frame[(i + 1) % frame.length]; const i1 = i + 1 < len ? i + 1 : 0; // fast wrap
const b = frame[i1];
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;
@ -1239,29 +1260,27 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
} }
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.tables) {
outL.fill(0); outL.fill(0);
if (outR !== outL) outR.set(outL); if (outR !== outL) outR.set(outL);
return true; return true;
} }
for (let i = 0; i < outL.length; i++) { for (let i = 0; i < outL.length; i++) {
const detune = pv(parameters.detune, i); const detune = pv(parameters.detune, i);
const tablePos = pv(parameters.position, i); const tablePos = clamp(pv(parameters.position, i), 0, 1);
const idx = tablePos * (this.numFrames - 1); const idx = tablePos * (this.numFrames - 1);
const fIdx = idx | 0; const fIdx = idx | 0;
const frac = idx - fIdx; const frac = idx - fIdx;
const warpAmount = pv(parameters.warp, i); const warpAmount = clamp(pv(parameters.warp, i), 0, 1);
const warpMode = pv(parameters.warpMode, i); const warpMode = pv(parameters.warpMode, i);
const voices = pv(parameters.voices, i); const voices = pv(parameters.voices, i);
const phaseRand = pv(parameters.phaserand, i); const phaseRand = clamp(pv(parameters.phaserand, i), 0, 1);
const spread = voices > 1 ? pv(parameters.spread, i) : 0; const spread = voices > 1 ? clamp(pv(parameters.spread, i), 0, 1) : 0;
const gain1 = Math.sqrt(0.5 - 0.5 * spread); const gain1 = Math.sqrt(0.5 - 0.5 * spread);
const gain2 = Math.sqrt(0.5 + 0.5 * spread); const gain2 = Math.sqrt(0.5 + 0.5 * spread);
let f = pv(parameters.frequency, i); let f = pv(parameters.frequency, i);
f = applySemitoneDetuneToFrequency(f, detune / 100); // overall detune f = applySemitoneDetuneToFrequency(f, detune / 100); // overall detune
const normalizer = 0.3 / Math.sqrt(voices); const normalizer = 1 / Math.sqrt(voices);
for (let n = 0; n < voices; n++) { for (let n = 0; n < voices; n++) {
const isOdd = (n & 1) == 1; const isOdd = (n & 1) == 1;
let gainL = gain1; let gainL = gain1;
@ -1272,7 +1291,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
gainR = gain1; gainR = gain1;
} }
const fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, detune, n)); // voice detune const fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, detune, n)); // voice detune
const dPhase = fVoice / sampleRate; const dPhase = fVoice * this.invSR;
const level = this._chooseMip(dPhase); const level = this._chooseMip(dPhase);
const table = this.tables[level]; const table = this.tables[level];

View file

@ -7,6 +7,21 @@ layout: ../../layouts/MainLayout.astro
This Guide shows you the different ways to get started with using Strudel in your own project. This Guide shows you the different ways to get started with using Strudel in your own project.
## Respect the license
First, please take a moment to understand Strudel's free/open source license,
[AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.en.html).
Here is a lay summary, but check the license for legal definitions and responsibilities.
- You can distribute modified versions if you keep track of the changes and the date you made them.
- You must license derivative work under the same license.
- Source code must be distributed along with web publication.
Among other things, it means that when you share your work, the whole application must be shared under the same free/open source license, or one compatible with it. This is because we want Strudel to stay free/open source. In other words, you are not permitted to distribute integrations of Strudel with libraries or other code that does not have a compatible free/open source license.
This also applies to clones informed by reading Strudel's source code, as legally speaking, that counts as a 'derivative work'. Again, please [read the licence](https://www.gnu.org/licenses/agpl-3.0.en.html) for details.
## Embedding the Strudel REPL ## Embedding the Strudel REPL
There are 3 quick ways to embed strudel in your website: There are 3 quick ways to embed strudel in your website: