From 02cd79a6d981df25f7d75ed6a47024565db29ed5 Mon Sep 17 00:00:00 2001 From: Aria Date: Mon, 25 Aug 2025 08:44:18 -0500 Subject: [PATCH 01/32] Add wavetable oscillator with scanning, warps, and detune --- packages/core/controls.mjs | 39 +++ packages/sampler/sample-server.mjs | 39 ++- packages/superdough/helpers.mjs | 28 +- packages/superdough/index.mjs | 1 + packages/superdough/superdough.mjs | 12 +- packages/superdough/synth.mjs | 27 +- packages/superdough/wavetable.mjs | 253 +++++++++++++++++++ packages/superdough/worklets.mjs | 393 ++++++++++++++++++++++++++--- 8 files changed, 715 insertions(+), 77 deletions(-) create mode 100644 packages/superdough/wavetable.mjs diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index e6181d453..ba77f1a35 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -87,6 +87,45 @@ export function registerControl(names, ...aliases) { */ export const { s, sound } = registerControl(['s', 'n', 'gain'], 'sound'); +/** + * Position in the wavetable of the wavetable oscillator + * + * @name wtPos + * @param {number | Pattern} position Position in the wavetable from 0 to 1 + * @synonyms wavetablePosition + * + */ +export const { wtPos, wavetablePosition } = registerControl('wtPos', 'wavetablePosition'); + +/** + * Amount of warp (alteration of the waveform) to apply to the wavetable oscillator + * + * @name wtWarp + * @param {number | Pattern} amount Warp of the wavetable from 0 to 1 + * @synonyms wavetableWarp + * + */ +export const { wtWarp, wavetableWarp } = registerControl('wtWarp', 'wavetableWarp'); + +/** + * Amount of warp (alteration of the waveform) to apply to the wavetable oscillator. + * + * The current options are: + * 0 = asym + * 1 = mirror + * 2 = bend+ + * 3 = bend- + * 4 = bend+/- + * 5 = sync + * 6 = quantize + * + * @name wtWarpMode + * @param {number | Pattern} mode Warp mode: an integer + * @synonyms wavetableWarpMode + * + */ +export const { wtWarpMode, wavetableWarpMode } = registerControl('wtWarpMode', 'wavetableWarpMode'); + /** * Define a custom webaudio node to use as a sound source. * diff --git a/packages/sampler/sample-server.mjs b/packages/sampler/sample-server.mjs index 08456add9..2832741aa 100644 --- a/packages/sampler/sample-server.mjs +++ b/packages/sampler/sample-server.mjs @@ -1,10 +1,10 @@ #!/usr/bin/env node import cowsay from 'cowsay'; -import { createReadStream, existsSync } from 'fs'; +import { createReadStream, existsSync, writeFileSync } from 'fs'; import { readdir } from 'fs/promises'; import http from 'http'; -import { join, sep } from 'path'; +import { join, sep, resolve } from 'path'; import os from 'os'; // eslint-disable-next-line @@ -36,17 +36,19 @@ async function getFilesInDirectory(directory) { return files; } -async function getBanks(directory) { +async function getBanks(directory, flat = false) { let files = await getFilesInDirectory(directory); let banks = {}; directory = directory.split(sep).join('/'); files = files.map((path) => { path = path.split(sep).join('/'); - const [bank] = path.split('/').slice(-2); + const subDir = path.replace(directory, ''); + const subDirFlat = subDir.replaceAll('/', '_').slice(1); // remove initial underscore + const subDirFlatStem = subDirFlat.replace(/\.[^.]+$/, ''); // remove extension + let bank = flat ? subDirFlatStem : subDir.split('/')[0]; banks[bank] = banks[bank] || []; - const relativeUrl = path.replace(directory, ''); - banks[bank].push(relativeUrl); - return relativeUrl; + banks[bank].push(subDir); + return subDir; }); banks._base = `http://localhost:5432`; return { banks, files }; @@ -54,14 +56,25 @@ async function getBanks(directory) { const args = process.argv.slice(2); +function getArgValue(flag) { + const i = args.indexOf(flag); + if (i !== -1) { + const nextIsFlag = args[i + 1]?.startsWith('--') ?? true; + if (nextIsFlag) return true; + return args[i + 1]; + } +} + // eslint-disable-next-line -const directory = process.cwd(); +let directory = getArgValue('--dir') || process.cwd(); +directory = resolve(directory); if (args.includes('--json')) { - const { banks, files } = await getBanks(directory); + const { banks } = await getBanks(directory, getArgValue('--flat')); const json = JSON.stringify(banks); - console.log(json); - process.exit(0); + const outFile = resolve(directory, 'strudel.json'); + writeFileSync(outFile, json, 'utf8'); + console.log(`Wrote json to ${outFile}`); } console.log( @@ -74,7 +87,7 @@ console.log( const server = http.createServer(async (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); - const { banks, files } = await getBanks(directory); + const { banks, files } = await getBanks(directory, getArgValue('--flat')); if (req.url === '/') { res.setHeader('Content-Type', 'application/json'); return res.end(JSON.stringify(banks)); @@ -82,7 +95,7 @@ const server = http.createServer(async (req, res) => { let subpath = decodeURIComponent(req.url); const filePath = join(directory, subpath.split('/').join(sep)); - //console.log('GET:', filePath); + // console.log('GET:', filePath); const isFound = existsSync(filePath); if (!isFound) { res.statusCode = 404; diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 81bec9399..7879cff00 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -1,5 +1,5 @@ import { getAudioContext } from './superdough.mjs'; -import { clamp, nanFallback } from './util.mjs'; +import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs'; import { getNoiseBuffer } from './noise.mjs'; export const noises = ['pink', 'white', 'brown', 'crackle']; @@ -21,7 +21,9 @@ const getSlope = (y1, y2, x1, x2) => { export function getWorklet(ac, processor, params, config) { const node = new AudioWorkletNode(ac, processor, config); Object.entries(params).forEach(([key, value]) => { - node.parameters.get(key).value = value; + if (value !== undefined) { + node.parameters.get(key).value = value; + } }); return node; } @@ -307,3 +309,25 @@ export function applyFM(param, value, begin) { } return { stop }; } + +export const getFrequencyFromValue = (value, defaultNote = 36) => { + let { note, freq } = value; + note = note || defaultNote; + if (typeof note === 'string') { + note = noteToMidi(note); // e.g. c3 => 48 + } + // get frequency + if (!freq && typeof note === 'number') { + freq = midiToFreq(note); // + 48); + } + + return Number(freq); +}; + +export const destroyAudioWorkletNode = (node) => { + if (node == null) { + return; + } + node.disconnect(); + node.parameters.get('end')?.setValueAtTime(0, 0); +}; diff --git a/packages/superdough/index.mjs b/packages/superdough/index.mjs index fd49fe338..a7e87ffae 100644 --- a/packages/superdough/index.mjs +++ b/packages/superdough/index.mjs @@ -11,3 +11,4 @@ export * from './synth.mjs'; export * from './zzfx.mjs'; export * from './logger.mjs'; export * from './dspworklet.mjs'; +export * from './wavetable.mjs'; diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index eb1466d91..37579005a 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -555,6 +555,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) tremolophase = 0, tremoloshape, s = getDefaultValue('s'), + wt, bank, source, gain = getDefaultValue('gain'), @@ -681,8 +682,13 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) let sourceNode; if (source) { sourceNode = source(t, value, hapDuration, cps); - } else if (getSound(s)) { - const { onTrigger } = getSound(s); + } else { + const soundSource = wt ?? s; + const sound = getSound(soundSource); + if (!sound) { + throw new Error(`sound ${soundSource} not found! Is it loaded?`); + } + const { onTrigger } = sound; const onEnded = () => { audioNodes.forEach((n) => n?.disconnect()); activeSoundSources.delete(chainID); @@ -693,8 +699,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) sourceNode = soundHandle.node; activeSoundSources.set(chainID, soundHandle); } - } else { - throw new Error(`sound ${s} not found! Is it loaded?`); } if (!sourceNode) { // if onTrigger does not return anything, we will just silently skip diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index 71138ae41..5b1b4edf1 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -1,39 +1,20 @@ -import { clamp, midiToFreq, noteToMidi } from './util.mjs'; +import { clamp } from './util.mjs'; import { registerSound, getAudioContext, soundMap, getLfo } from './superdough.mjs'; import { applyFM, + destroyAudioWorkletNode, gainNode, getADSRValues, + getFrequencyFromValue, getParamADSR, getPitchEnvelope, getVibratoOscillator, - webAudioTimeout, getWorklet, noises, + webAudioTimeout, } from './helpers.mjs'; import { getNoiseMix, getNoiseOscillator } from './noise.mjs'; -const getFrequencyFromValue = (value, defaultNote = 36) => { - let { note, freq } = value; - note = note || defaultNote; - if (typeof note === 'string') { - note = noteToMidi(note); // e.g. c3 => 48 - } - // get frequency - if (!freq && typeof note === 'number') { - freq = midiToFreq(note); // + 48); - } - - return Number(freq); -}; -function destroyAudioWorkletNode(node) { - if (node == null) { - return; - } - node.disconnect(); - node.parameters.get('end')?.setValueAtTime(0, 0); -} - const waveforms = ['triangle', 'square', 'sawtooth', 'sine']; const waveformAliases = [ ['tri', 'triangle'], diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs new file mode 100644 index 000000000..d18f1fdff --- /dev/null +++ b/packages/superdough/wavetable.mjs @@ -0,0 +1,253 @@ +import { getAudioContext, registerSound } from './index.mjs'; +import { clamp, getSoundIndex, valueToMidi } from './util.mjs'; +import { + destroyAudioWorkletNode, + getADSRValues, + getFrequencyFromValue, + getParamADSR, + getPitchEnvelope, + getVibratoOscillator, + getWorklet, + webAudioTimeout, +} from './helpers.mjs'; +import { logger } from './logger.mjs'; + +const WT_MAX_MIP_LEVELS = 6; +export const WarpMode = Object.freeze({ + NONE: 0, + ASYM: 1, + MIRROR: 2, + BENDP: 3, + BENDM: 4, + BENDMP: 5, + SYNC: 6, + QUANT: 7, + FOLD: 8, + PWM: 9, + ORBIT: 10, + SPIN: 11, + CHAOS: 12, + PRIMES: 13, + BINARY: 14, + BROWNIAN: 15, + RECIPROCAL: 16, + WORMHOLE: 17, + LOGISTIC: 18, + SIGMOID: 19, + FRACTAL: 20, + FLIP: 21, +}); + +async function loadWavetableFrames(url, label, frameLen = 256) { + const ac = getAudioContext(); + const buf = await loadBuffer(url, ac, label); + const ch0 = buf.getChannelData(0); + const total = ch0.length; + const numFrames = Math.floor(total / frameLen); + const frames = new Array(numFrames); + for (let i = 0; i < numFrames; i++) { + const start = i * frameLen; + frames[i] = ch0.subarray(start, start + frameLen); + } + + // 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) { + var thresh = si ? 1000 : 1024; + if (bytes < thresh) return bytes + ' B'; + var units = si + ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + var u = -1; + do { + bytes /= thresh; + ++u; + } while (bytes >= thresh); + return bytes.toFixed(1) + ' ' + units[u]; +} + +export function getTableInfo(hapValue, bank) { + const { wt, n = 0 } = hapValue; + let midi = valueToMidi(hapValue, 36); + let transpose = midi - 36; // C3 is middle C; + const index = getSoundIndex(n, bank.length); + const tableUrl = bank[index]; + const label = `${wt}:${index}`; + return { transpose, tableUrl, index, midi, label }; +} + +const loadBuffer = (url, ac, wt, n = 0) => { + const label = wt ? `table "${wt}:${n}"` : 'table'; + url = url.replace('#', '%23'); + if (!loadCache[url]) { + logger(`[wavetable] load ${label}..`, 'load-table', { url }); + const timestamp = Date.now(); + loadCache[url] = fetch(url) + .then((res) => res.arrayBuffer()) + .then(async (res) => { + const took = Date.now() - timestamp; + const size = humanFileSize(res.byteLength); + logger(`[wavetable] load ${label}... done! loaded ${size} in ${took}ms`, 'loaded-table', { url }); + const decoded = await ac.decodeAudioData(res); + return decoded; + }); + } + return loadCache[url]; +}; + +function githubPath(base, subpath = '') { + if (!base.startsWith('github:')) { + throw new Error('expected "github:" at the start of pseudoUrl'); + } + let [_, path] = base.split('github:'); + path = path.endsWith('/') ? path.slice(0, -1) : path; + if (path.split('/').length === 2) { + // assume main as default branch if none set + path += '/main'; + } + return `https://raw.githubusercontent.com/${path}/${subpath}`; +} + +const _processTables = (json, baseUrl, frameLen) => { + return Object.entries(json).forEach(([key, value]) => { + if (typeof value === 'string') { + value = [value]; + } + if (typeof value !== 'object') { + throw new Error('wrong json format for ' + key); + } + baseUrl = value._base || baseUrl; + if (baseUrl.startsWith('github:')) { + baseUrl = githubPath(baseUrl, ''); + } + value = value.map((v) => baseUrl + v); + registerSound(key, (t, hapValue, onended) => onTriggerSynth(t, hapValue, onended, value, frameLen), { + type: 'wavetable', + tables: value, + baseUrl, + frameLen, + }); + }); +}; + +/** + * Loads a collection of wavetables to use with `wt` + * + * @name tables + */ +export const tables = async (url, frameLen, json) => { + if (json !== undefined) return _processTables(json, url, frameLen); + if (url.startsWith('github:')) { + url = githubPath(url, 'strudel.json'); + } + if (url.startsWith('local:')) { + url = `http://localhost:5432`; + } + if (typeof fetch !== 'function') { + // not a browser + return; + } + const base = url.split('/').slice(0, -1).join('/'); + if (typeof fetch === 'undefined') { + // skip fetch when in node / testing + return; + } + return fetch(url) + .then((res) => res.json()) + .then((json) => _processTables(json, url, frameLen)) + .catch((error) => { + console.error(error); + throw new Error(`error loading "${url}"`); + }); +}; + +async function onTriggerSynth(t, value, onended, bank, frameLen) { + let { s, n = 0, duration } = value; + const ac = getAudioContext(); + let [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]); + let sourceDesc, holdEnd, envEnd; + let { unison = 5, spread = 0.6, detune, wtPos, wtWarp, wtWarpMode } = value; + if (typeof wtWarpMode === 'string') { + wtWarpMode = WarpMode[wtWarpMode.toUpperCase()] ?? WarpMode.NONE; + } + detune = detune ?? 0.18; + const frequency = getFrequencyFromValue(value); + const voices = clamp(unison, 1, 100); + let { tableUrl, label } = getTableInfo(value, bank); + const payload = await loadWavetableFrames(tableUrl, label, frameLen); + holdEnd = t + duration; + envEnd = holdEnd + release + 0.01; + const worklet = getWorklet( + ac, + 'wavetable-oscillator-processor', + { + begin: t, + end: envEnd, + frequency, + detune, + position: wtPos, + warp: wtWarp, + warpMode: wtWarpMode, + voices, + spread, + }, + { outputChannelCount: [2] }, + ); + worklet.port.postMessage({ type: 'tables', payload }); + sourceDesc = { source: worklet }; + const { source } = sourceDesc; + if (ac.currentTime > t) { + logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight'); + return; + } + if (!source) { + logger(`[wavetable] could not load "${s}:${n}"`, 'error'); + return; + } + let vibratoOscillator = getVibratoOscillator(source.detune, value, t); + const envGain = ac.createGain(); + const node = source.connect(envGain); + getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear'); + getPitchEnvelope(source.detune, value, t, holdEnd); + + const out = ac.createGain(); // we need a separate gain for the cutgroups because firefox... + node.connect(out); + let handle = { node: out, bufferSource: source }; + let timeoutNode = webAudioTimeout( + ac, + () => { + source.disconnect(); + destroyAudioWorkletNode(source); + vibratoOscillator?.stop(); + node.disconnect(); + out.disconnect(); + onended(); + }, + t, + envEnd, + ); + handle.stop = (time) => { + timeoutNode.stop(time); + }; + return handle; +} diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 2406d56dc..7775803d9 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -6,7 +6,21 @@ import OLAProcessor from './ola-processor'; import FFT from './fft.js'; const clamp = (num, min, max) => Math.min(Math.max(num, min), max); -const _mod = (n, m) => ((n % m) + m) % m; +const mod = (n, m) => ((n % m) + m) % m; +const lerp = (a, b, n) => n * (b - a) + a; +const pv = (arr, n) => arr[n] ?? arr[0]; +const frac = (x) => x - Math.floor(x); +const ffloor = (x) => x | 0; // fast floor for non-negative + +const getUnisonDetune = (unison, detune, voiceIndex) => { + if (unison < 2) { + return 0; + } + return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1)); +}; +const applySemitoneDetuneToFrequency = (frequency, detune) => { + return frequency * Math.pow(2, detune / 12); +}; // Restrict phase to the range [0, maxPhase) via wrapping function wrapPhase(phase, maxPhase = 1) { @@ -150,7 +164,7 @@ class LFOProcessor extends AudioWorkletProcessor { const blockSize = output[0].length ?? 0; if (this.phase == null) { - this.phase = _mod(time * frequency + phaseoffset, 1); + this.phase = mod(time * frequency + phaseoffset, 1); } const dt = frequency / sampleRate; for (let n = 0; n < blockSize; n++) { @@ -378,21 +392,6 @@ class DistortProcessor extends AudioWorkletProcessor { registerProcessor('distort-processor', DistortProcessor); // SUPERSAW -function lerp(a, b, n) { - return n * (b - a) + a; -} - -function getUnisonDetune(unison, detune, voiceIndex) { - if (unison < 2) { - return 0; - } - return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1)); -} - -function applySemitoneDetuneToFrequency(frequency, detune) { - return frequency * Math.pow(2, detune / 12); -} - class SuperSawOscillatorProcessor extends AudioWorkletProcessor { constructor() { super(); @@ -454,29 +453,31 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor { } const output = outputs[0]; - const voices = params.voices[0]; - const freqspread = params.freqspread[0]; - const panspread = params.panspread[0] * 0.5 + 0.5; - const gain1 = Math.sqrt(1 - panspread); - const gain2 = Math.sqrt(panspread); - for (let n = 0; n < voices; n++) { - const isOdd = (n & 1) == 1; - let gainL = gain1; - let gainR = gain2; - // invert right and left gain - if (isOdd) { - gainL = gain2; - gainR = gain1; - } - for (let i = 0; i < output[0].length; i++) { - // Main detuning - let freq = applySemitoneDetuneToFrequency(params.frequency[i] ?? params.frequency[0], params.detune[0] / 100); + for (let i = 0; i < output[0].length; i++) { + const detune = pv(params.detune, i); + const voices = pv(params.voices, i); + const freqspread = pv(params.freqspread, i); + const panspread = pv(params.panspread, i) * 0.5 + 0.5; + const gain1 = Math.sqrt(1 - panspread); + const gain2 = Math.sqrt(panspread); + let freq = pv(params.frequency, i); + // Main detuning + freq = applySemitoneDetuneToFrequency(freq, detune / 100); + for (let n = 0; n < voices; n++) { + const isOdd = (n & 1) == 1; + let gainL = gain1; + let gainR = gain2; + // invert right and left gain + if (isOdd) { + gainL = gain2; + gainR = gain1; + } // Individual voice detuning freq = applySemitoneDetuneToFrequency(freq, getUnisonDetune(voices, freqspread, n)); // We must wrap this here because it is passed into sawblep below which // has domain [0, 1] - const dt = _mod(freq / sampleRate, 1); + const dt = mod(freq / sampleRate, 1); this.phase[n] = this.phase[n] ?? Math.random(); const v = waveshapes.sawblep(this.phase[n], dt); @@ -907,3 +908,325 @@ class ByteBeatProcessor extends AudioWorkletProcessor { } registerProcessor('byte-beat-processor', ByteBeatProcessor); + + +export const WarpMode = Object.freeze({ + NONE: 0, + ASYM: 1, + MIRROR: 2, + BENDP: 3, + BENDM: 4, + BENDMP: 5, + SYNC: 6, + QUANT: 7, + FOLD: 8, + PWM: 9, + ORBIT: 10, + SPIN: 11, + CHAOS: 12, + PRIMES: 13, + BINARY: 14, + BROWNIAN: 15, + RECIPROCAL: 16, + WORMHOLE: 17, + LOGISTIC: 18, + SIGMOID: 19, + FRACTAL: 20, + FLIP: 21, +}); + +function hash32(u) { + u = u + 0x7ed55d16 + (u << 12); + u = u ^ 0xc761c23c ^ (u >>> 19); + u = u + 0x165667b1 + (u << 5); + u = (u + 0xd3a2646c) ^ (u << 9); + u = u + 0xfd7046c5 + (u << 3); + u = u ^ 0xb55a4f09 ^ (u >>> 16); + return u >>> 0; +} +const hash01 = (i) => (hash32(i) >>> 8) / 0x01000000; + +function bitReverse(i, n) { + let r = 0; + for (let b = 0; b < n; b++) { + r = (r << 1) | (i & 1); + i >>>= 1; + } + return r; +} + +function noise(x) { + const i = Math.floor(x), + f = x - i; + const a = hash01(i), + b = hash01(i + 1); + return a + (b - a) * f; +} + +function brownian(x, oct = 4) { + let amp = 0.5, + sum = 0, + norm = 0, + freq = 1; + for (let o = 0; o < oct; o++) { + sum += amp * noise(x * freq); + norm += amp; + amp *= 0.5; + freq *= 2; + } + return (sum / norm) * 2 - 1; +} + +class WavetableOscillatorProcessor extends 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 }, + { name: 'frequency', defaultValue: 220, minValue: 0.01, maxValue: 20000 }, + { name: 'detune', defaultValue: 0 }, + { name: 'position', defaultValue: 0, minValue: 0, maxValue: 1 }, + { name: 'warp', defaultValue: 0, minValue: 0, maxValue: 1 }, + { name: 'warpMode', defaultValue: 0 }, + { name: 'voices', defaultValue: 1, minValue: 1, maxValue: 32 }, + { name: 'spread', defaultValue: 0, minValue: 0, maxValue: 1 }, + ]; + } + + constructor(options) { + super(options); + this.tables = null; + this.frameLen = 0; + this.numFrames = 0; + this.phase = []; + this.syncRatio = 1; + + this.port.onmessage = (e) => { + const { type, payload } = e.data || {}; + if (type === 'tables') { + this.tables = payload.mipmaps; + this.frameLen = payload.frameLen; + this.numFrames = this.tables[0].length; + } + }; + this.lfoPhase = 0; + } + + _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) { + return 1 - Math.abs(2 * x - 1); + } + + _toBits(amt, min = 2, max = 12) { + const b = max + (min - max) * amt; + return { b, n: Math.round(Math.pow(2, b)) }; + } + + _warpPhase(phase, amt, mode) { + switch (mode) { + case WarpMode.NONE: { + return phase; + } + case WarpMode.ASYM: { + const a = 0.01 + 0.99 * amt; + return phase < a ? (0.5 * phase) / a : 0.5 + (0.5 * (phase - a)) / (1 - a); + } + case WarpMode.MIRROR: { + // Asym, then mirror + return this._mirror(this._warpPhase(phase, amt, WarpMode.ASYM)); + } + case WarpMode.BENDP: { + return Math.pow(phase, 1 + 3 * amt); + } + case WarpMode.BENDM: { + return Math.pow(phase, 1 / (1 + 3 * amt)); + } + case WarpMode.BENDMP: { + return amt < 0.5 ? this._warpPhase(phase, 1 - 2 * amt, 3) : this._warpPhase(phase, 2 * amt - 1, 2); + } + case WarpMode.SYNC: { + const syncRatio = Math.pow(16, amt * amt); + return (phase * syncRatio) % 1; + } + case WarpMode.QUANT: { + const { n } = this._toBits(amt); + return ffloor(phase * n) / n; + } + case WarpMode.FOLD: { + const K = 7; + const k = 1 + Math.max(1, Math.round(K * amt)); + return Math.abs(frac(k * phase) - 0.5) * 2; + } + case WarpMode.PWM: { + const w = clamp(0.5 + 0.49 * (2 * amt - 1), 0, 1); + if (phase < w) return (phase / w) * 0.5; + return 0.5 + ((phase - w) / (1 - w)) * 0.5; + } + case WarpMode.ORBIT: { + const depth = 0.5 * amt; + const n = 3; + return frac(phase + depth * Math.sin(2 * Math.PI * n * phase)); + } + case WarpMode.SPIN: { + const depth = 0.5 * amt; + const { n } = this._toBits(amt, 1, 6); + return frac(phase + depth * Math.sin(2 * Math.PI * n * phase)); + } + case WarpMode.CHAOS: { + const r = 3.7 + 0.3 * amt; + const logistic = r * phase * (1 - phase); + return clamp((1 - amt) * phase + amt * logistic, 0, 1); + } + case WarpMode.PRIMES: { + const isPrime = (n) => { + if (n < 2) return false; + if (n % 2 === 0) return n === 2; + for (let d = 3; d * d <= n; d += 2) if (n % d === 0) return false; + return true; + }; + let { n } = this._toBits(amt, 3); + while (!isPrime(n)) n++; + return ffloor(phase * n) / n; + } + case WarpMode.BINARY: { + let { b } = this._toBits(amt, 3); + b = Math.round(b); + const n = 1 << b; + const idx = ffloor(phase * n); + const ridx = bitReverse(idx, b); + return ridx / n; + } + case WarpMode.MODULAR: { + const { n } = this._toBits(amt); + const depth = 0.5 * amt; + const jump = frac(phase * n) / n; + return frac(phase + depth * jump); + } + case WarpMode.BROWNIAN: { + const disp = 0.25 * amt * brownian(64 * phase, 4); + return frac(phase + disp); + } + case WarpMode.RECIPROCAL: { + const g = 2 + 4 * amt; + const num = phase * g; + const den = phase + (1 - phase) * g; + const y = den > 1e-12 ? num / den : 0; + return clamp(y, 0, 1); + } + case WarpMode.WORMHOLE: { + const gap = clamp(0.8 * amt, 0, 1); + const a = 0.5 * (1 - gap); + const b = 0.5 * (1 + gap); + if (phase < a) return (phase / a) * 0.5; + if (phase > b) return 0.5 * (1 + (phase - b) / (1 - b)); + return 0.5; + } + case WarpMode.LOGISTIC: { + let x = phase; + const r = 3.6 + 0.4 * amt; + const iters = 1 + Math.round(2 * amt); + for (let i = 0; i < iters; i++) x = r * x * (1 - x); + return clamp(x, 0, 1); + } + case WarpMode.SIGMOID: { + const k = 1 + 10 * amt; + const x = phase - 0.5; + const y = 1 / (1 + Math.exp(-k * x)); + const y0 = 1 / (1 + Math.exp(0.5 * k)); + const y1 = 1 / (1 + Math.exp(-0.5 * k)); + return (y - y0) / (y1 - y0); + } + case WarpMode.FRACTAL: { + const d = 0.5 * Math.sin(2 * Math.PI * phase) * amt; + return frac(phase + d); + } + case WarpMode.FLIP: { + return phase; + } + default: + return phase; + } + } + + _sampleFrame(frame, phase) { + const pos = phase * (frame.length - 1); + const i = pos | 0; + const frac = pos - i; + const a = frame[i]; + const b = frame[(i + 1) % frame.length]; + return a + (b - a) * frac; + } + + process(_inputs, outputs, parameters) { + if (currentTime >= parameters.end[0]) { + return false; + } + if (currentTime <= parameters.begin[0]) { + return true; + } + const outL = outputs[0][0]; + const outR = outputs[0][1] || outputs[0][0]; + + if (!this.tables) { + outL.fill(0); + if (outR !== outL) outR.set(outL); + return true; + } + + for (let i = 0; i < outL.length; i++) { + const detune = pv(parameters.detune, i); + const spread = pv(parameters.spread, i) * 0.5 + 0.5; + const tablePos = pv(parameters.position, i); //Math.sin(2 * Math.PI * this.lfoPhase); + // morph across frames + const idx = tablePos * (this.numFrames - 1); + const fIdx = idx | 0; + const frac = idx - fIdx; + const warpAmount = 0.5 * Math.sin(2 * Math.PI * this.lfoPhase) + 0.5; // pv(parameters.warp, i); + const warpMode = pv(parameters.warpMode, i); + const voices = pv(parameters.voices, i); + const gain1 = Math.sqrt(1 - spread); + const gain2 = Math.sqrt(spread); + let f = pv(parameters.frequency, i); + f = applySemitoneDetuneToFrequency(f, detune / 100); // overall detune + for (let n = 0; n < voices; n++) { + const isOdd = (n & 1) == 1; + let gainL = gain1; + let gainR = gain2; + // invert right and left gain + if (isOdd) { + gainL = gain2; + gainR = gain1; + } + let fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, detune, n)); // voice detune + const dPhase = fVoice / sampleRate; + const level = this._chooseMip(dPhase); + const bank = this.tables[level]; + + // warp phase then sample + this.phase[n] = this.phase[n] ?? Math.random(); + let ph = this._warpPhase(this.phase[n], warpAmount, warpMode); + const s0 = this._sampleFrame(bank[fIdx], ph); + const s1 = this._sampleFrame(bank[Math.min(this.numFrames - 1, fIdx + 1)], ph); + let s = s0 + (s1 - s0) * frac; + if (warpMode === WarpMode.FLIP && this.phase[n] < warpAmount) { + s = -s; + } + outL[i] += (s * gainL) / Math.sqrt(voices); + outR[i] += (s * gainR) / Math.sqrt(voices); + this.phase[n] = wrapPhase(this.phase[n] + dPhase); + } + this.lfoPhase += 1 / sampleRate; + if (this.lfoPhase >= 1) this.lfoPhase -= 1; + } + return true; + } +} + +registerProcessor('wavetable-oscillator-processor', WavetableOscillatorProcessor); From d1869c18ba526e8ca251fdcc3aaff048d8f9ba4c Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 28 Aug 2025 16:23:40 -0500 Subject: [PATCH 02/32] Remove internal LFO --- packages/superdough/wavetable.mjs | 2 +- packages/superdough/worklets.mjs | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index d18f1fdff..9b9e325f7 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -232,7 +232,7 @@ async function onTriggerSynth(t, value, onended, bank, frameLen) { const out = ac.createGain(); // we need a separate gain for the cutgroups because firefox... node.connect(out); - let handle = { node: out, bufferSource: source }; + let handle = { node: out, bufferSource: source, oscillator: worklet }; let timeoutNode = webAudioTimeout( ac, () => { diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 7775803d9..ce0e7ac80 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -1008,7 +1008,6 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { this.numFrames = this.tables[0].length; } }; - this.lfoPhase = 0; } _chooseMip(dphi) { @@ -1183,12 +1182,11 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { for (let i = 0; i < outL.length; i++) { const detune = pv(parameters.detune, i); const spread = pv(parameters.spread, i) * 0.5 + 0.5; - const tablePos = pv(parameters.position, i); //Math.sin(2 * Math.PI * this.lfoPhase); - // morph across frames + const tablePos = pv(parameters.position, i); const idx = tablePos * (this.numFrames - 1); const fIdx = idx | 0; const frac = idx - fIdx; - const warpAmount = 0.5 * Math.sin(2 * Math.PI * this.lfoPhase) + 0.5; // pv(parameters.warp, i); + const warpAmount = pv(parameters.warp, i); const warpMode = pv(parameters.warpMode, i); const voices = pv(parameters.voices, i); const gain1 = Math.sqrt(1 - spread); @@ -1222,8 +1220,6 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { outR[i] += (s * gainR) / Math.sqrt(voices); this.phase[n] = wrapPhase(this.phase[n] + dPhase); } - this.lfoPhase += 1 / sampleRate; - if (this.lfoPhase >= 1) this.lfoPhase -= 1; } return true; } From fe46e1da5372ddf3c86e023566e9984012fd070e Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 28 Aug 2025 16:28:33 -0500 Subject: [PATCH 03/32] Update docstring to include new warp modes; save sample server for a separate PR --- packages/core/controls.mjs | 10 ++------ packages/sampler/sample-server.mjs | 39 ++++++++++-------------------- 2 files changed, 15 insertions(+), 34 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index ba77f1a35..1099fe9ec 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -110,14 +110,8 @@ export const { wtWarp, wavetableWarp } = registerControl('wtWarp', 'wavetableWar /** * Amount of warp (alteration of the waveform) to apply to the wavetable oscillator. * - * The current options are: - * 0 = asym - * 1 = mirror - * 2 = bend+ - * 3 = bend- - * 4 = bend+/- - * 5 = sync - * 6 = quantize + * The current options are: none, asym, bendp, bendm, bendmp, sync, quant, fold, pwm, orbit, + * spin, chaos, primes, binary, brownian, reciprocal, wormhole, logistic, sigmoid, fractal, flip * * @name wtWarpMode * @param {number | Pattern} mode Warp mode: an integer diff --git a/packages/sampler/sample-server.mjs b/packages/sampler/sample-server.mjs index 2832741aa..08456add9 100644 --- a/packages/sampler/sample-server.mjs +++ b/packages/sampler/sample-server.mjs @@ -1,10 +1,10 @@ #!/usr/bin/env node import cowsay from 'cowsay'; -import { createReadStream, existsSync, writeFileSync } from 'fs'; +import { createReadStream, existsSync } from 'fs'; import { readdir } from 'fs/promises'; import http from 'http'; -import { join, sep, resolve } from 'path'; +import { join, sep } from 'path'; import os from 'os'; // eslint-disable-next-line @@ -36,19 +36,17 @@ async function getFilesInDirectory(directory) { return files; } -async function getBanks(directory, flat = false) { +async function getBanks(directory) { let files = await getFilesInDirectory(directory); let banks = {}; directory = directory.split(sep).join('/'); files = files.map((path) => { path = path.split(sep).join('/'); - const subDir = path.replace(directory, ''); - const subDirFlat = subDir.replaceAll('/', '_').slice(1); // remove initial underscore - const subDirFlatStem = subDirFlat.replace(/\.[^.]+$/, ''); // remove extension - let bank = flat ? subDirFlatStem : subDir.split('/')[0]; + const [bank] = path.split('/').slice(-2); banks[bank] = banks[bank] || []; - banks[bank].push(subDir); - return subDir; + const relativeUrl = path.replace(directory, ''); + banks[bank].push(relativeUrl); + return relativeUrl; }); banks._base = `http://localhost:5432`; return { banks, files }; @@ -56,25 +54,14 @@ async function getBanks(directory, flat = false) { const args = process.argv.slice(2); -function getArgValue(flag) { - const i = args.indexOf(flag); - if (i !== -1) { - const nextIsFlag = args[i + 1]?.startsWith('--') ?? true; - if (nextIsFlag) return true; - return args[i + 1]; - } -} - // eslint-disable-next-line -let directory = getArgValue('--dir') || process.cwd(); -directory = resolve(directory); +const directory = process.cwd(); if (args.includes('--json')) { - const { banks } = await getBanks(directory, getArgValue('--flat')); + const { banks, files } = await getBanks(directory); const json = JSON.stringify(banks); - const outFile = resolve(directory, 'strudel.json'); - writeFileSync(outFile, json, 'utf8'); - console.log(`Wrote json to ${outFile}`); + console.log(json); + process.exit(0); } console.log( @@ -87,7 +74,7 @@ console.log( const server = http.createServer(async (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); - const { banks, files } = await getBanks(directory, getArgValue('--flat')); + const { banks, files } = await getBanks(directory); if (req.url === '/') { res.setHeader('Content-Type', 'application/json'); return res.end(JSON.stringify(banks)); @@ -95,7 +82,7 @@ const server = http.createServer(async (req, res) => { let subpath = decodeURIComponent(req.url); const filePath = join(directory, subpath.split('/').join(sep)); - // console.log('GET:', filePath); + //console.log('GET:', filePath); const isFound = existsSync(filePath); if (!isFound) { res.statusCode = 404; From 46a45b4596902e734cada6dfadf3742cd8b445f9 Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 4 Sep 2025 20:51:06 -0500 Subject: [PATCH 04/32] Some missing cleanup --- packages/superdough/superdough.mjs | 11 ++++------- packages/superdough/worklets.mjs | 1 - website/src/repl/components/panel/SettingsTab.jsx | 2 +- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 8ee3ee4c3..d436a816a 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -686,13 +686,8 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) let sourceNode; if (source) { sourceNode = source(t, value, hapDuration, cps); - } else { - const soundSource = wt ?? s; - const sound = getSound(soundSource); - if (!sound) { - throw new Error(`sound ${soundSource} not found! Is it loaded?`); - } - const { onTrigger } = sound; + } else if (getSound(s)) { + const { onTrigger } = getSound(s); const onEnded = () => { audioNodes.forEach((n) => n?.disconnect()); activeSoundSources.delete(chainID); @@ -703,6 +698,8 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) sourceNode = soundHandle.node; activeSoundSources.set(chainID, soundHandle); } + } else { + throw new Error(`sound ${s} not found! Is it loaded?`); } if (!sourceNode) { // if onTrigger does not return anything, we will just silently skip diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index ce0e7ac80..b0c0e8e2d 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -909,7 +909,6 @@ class ByteBeatProcessor extends AudioWorkletProcessor { registerProcessor('byte-beat-processor', ByteBeatProcessor); - export const WarpMode = Object.freeze({ NONE: 0, ASYM: 1, diff --git a/website/src/repl/components/panel/SettingsTab.jsx b/website/src/repl/components/panel/SettingsTab.jsx index 8f02ca8b8..26c9ae287 100644 --- a/website/src/repl/components/panel/SettingsTab.jsx +++ b/website/src/repl/components/panel/SettingsTab.jsx @@ -312,7 +312,7 @@ export function SettingsTab({ started }) { confirmDialog('Sure?').then((r) => { if (r) { const { userPatterns } = settingsMap.get(); // keep current patterns - settingsMap.set({...defaultSettings, userPatterns}); + settingsMap.set({ ...defaultSettings, userPatterns }); } }); }} From 2cba1dcb317fea1046a8a95f673ad888e1960723 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Mon, 15 Sep 2025 21:46:37 -0400 Subject: [PATCH 05/32] working --- packages/core/signal.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index 6bd6fde67..f736ce71f 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -16,7 +16,7 @@ export function steady(value) { } export const signal = (func) => { - const query = (state) => [new Hap(undefined, state.span, func(state.span.begin))]; + const query = (state) => [new Hap(undefined, state.span, func(state.span.begin.valueOf()))]; return new Pattern(query); }; @@ -152,7 +152,10 @@ export const itri2 = fastcat(isaw2, saw2); * * @return {Pattern} */ -export const time = signal(id); +export const time = signal((x) => { + console.info(typeof x) +return x +}); /** * The mouse's x position value ranges from 0 to 1. From 7dfd7dbcdc4500cf69d4a7767ba74f0adc42ffa7 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Mon, 15 Sep 2025 21:47:40 -0400 Subject: [PATCH 06/32] rmconsole --- packages/core/signal.mjs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index f736ce71f..2646ccf7c 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -152,10 +152,7 @@ export const itri2 = fastcat(isaw2, saw2); * * @return {Pattern} */ -export const time = signal((x) => { - console.info(typeof x) -return x -}); +export const time = signal(id); /** * The mouse's x position value ranges from 0 to 1. From 74e27ca94f977b2586616ccb023a6a6f96edbedb Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Mon, 15 Sep 2025 21:57:56 -0400 Subject: [PATCH 07/32] coerce in correct place --- packages/core/signal.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index 2646ccf7c..b246cb9e3 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -16,7 +16,7 @@ export function steady(value) { } export const signal = (func) => { - const query = (state) => [new Hap(undefined, state.span, func(state.span.begin.valueOf()))]; + const query = (state) => [new Hap(undefined, state.span, func(state.span.begin))]; return new Pattern(query); }; @@ -152,7 +152,9 @@ export const itri2 = fastcat(isaw2, saw2); * * @return {Pattern} */ -export const time = signal(id); +export const time = signal(x => { + return x.valueOf() +}); /** * The mouse's x position value ranges from 0 to 1. From 4cc453c640bda94df291b3cb562f5d4d9b75f576 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Mon, 15 Sep 2025 22:09:05 -0400 Subject: [PATCH 08/32] fix test --- packages/core/signal.mjs | 4 ++-- packages/core/test/pattern.test.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index b246cb9e3..79e45da7d 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -152,8 +152,8 @@ export const itri2 = fastcat(isaw2, saw2); * * @return {Pattern} */ -export const time = signal(x => { - return x.valueOf() +export const time = signal((x) => { + return x.valueOf(); }); /** diff --git a/packages/core/test/pattern.test.mjs b/packages/core/test/pattern.test.mjs index 1df5c8776..3b6619b56 100644 --- a/packages/core/test/pattern.test.mjs +++ b/packages/core/test/pattern.test.mjs @@ -877,7 +877,7 @@ describe('Pattern', () => { .squeezeJoin() .queryArc(3, 4) .map((x) => x.value), - ).toStrictEqual([Fraction(3)]); + ).toStrictEqual([3]); }); }); describe('ply', () => { From 9cdba1a50ee806bd56215d2635ec1cad1338a204 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Tue, 16 Sep 2025 11:36:21 -0400 Subject: [PATCH 09/32] Working --- packages/core/pattern.mjs | 4 +++- packages/core/signal.mjs | 4 +--- packages/superdough/superdough.mjs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 262b92bb3..2991cffea 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -6,6 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th import TimeSpan from './timespan.mjs'; import Fraction, { lcm } from './fraction.mjs'; +import FractionClass from 'fraction.js'; import Hap from './hap.mjs'; import State from './state.mjs'; import { unionWithObj } from './value.mjs'; @@ -26,6 +27,7 @@ import { } from './util.mjs'; import drawLine from './drawLine.mjs'; import { logger } from './logger.mjs'; +import fraction from './fraction.mjs'; let stringParser; @@ -999,7 +1001,7 @@ addToPrototype('weaveWith', function (t, ...funcs) { // compose matrix functions function _nonArrayObject(x) { - return !Array.isArray(x) && typeof x === 'object'; + return !Array.isArray(x) && typeof x === 'object' && !(x instanceof FractionClass); } function _composeOp(a, b, func) { if (_nonArrayObject(a) || _nonArrayObject(b)) { diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index 79e45da7d..6bd6fde67 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -152,9 +152,7 @@ export const itri2 = fastcat(isaw2, saw2); * * @return {Pattern} */ -export const time = signal((x) => { - return x.valueOf(); -}); +export const time = signal(id); /** * The mouse's x position value ranges from 0 to 1. diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index f1f308a7d..b1df93916 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -465,7 +465,7 @@ function duckOrbit(audioContext, targetOrbit, t, onsettime = 0, attacktime = 0.1 gainParam.cancelScheduledValues(now); gainParam.setValueAtTime(currVal, now); - const t0 = Math.max(t, now); // guard against now > t + const t0 = now; // guard against now > t const duckedVal = clamp(1 - Math.sqrt(depth), 0.01, currVal); gainParam.exponentialRampToValueAtTime(duckedVal, t0 + onset); gainParam.exponentialRampToValueAtTime(1, t0 + onset + attack); From 89b7eac789e221097a55d5ee20904d5c14b7c3ab Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Tue, 16 Sep 2025 11:52:36 -0400 Subject: [PATCH 10/32] fix test --- packages/core/fraction.mjs | 2 ++ packages/core/pattern.mjs | 6 ++---- packages/core/test/pattern.test.mjs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/fraction.mjs b/packages/core/fraction.mjs index 2e3bc68ea..076fbadf6 100644 --- a/packages/core/fraction.mjs +++ b/packages/core/fraction.mjs @@ -126,6 +126,8 @@ export const lcm = (...fractions) => { ); }; +export const isFraction = (x) => x instanceof Fraction; + fraction._original = Fraction; export default fraction; diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 2991cffea..196248eba 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -5,8 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th */ import TimeSpan from './timespan.mjs'; -import Fraction, { lcm } from './fraction.mjs'; -import FractionClass from 'fraction.js'; +import Fraction, {isFraction, lcm } from './fraction.mjs'; import Hap from './hap.mjs'; import State from './state.mjs'; import { unionWithObj } from './value.mjs'; @@ -27,7 +26,6 @@ import { } from './util.mjs'; import drawLine from './drawLine.mjs'; import { logger } from './logger.mjs'; -import fraction from './fraction.mjs'; let stringParser; @@ -1001,7 +999,7 @@ addToPrototype('weaveWith', function (t, ...funcs) { // compose matrix functions function _nonArrayObject(x) { - return !Array.isArray(x) && typeof x === 'object' && !(x instanceof FractionClass); + return !Array.isArray(x) && typeof x === 'object' && !isFraction(x); } function _composeOp(a, b, func) { if (_nonArrayObject(a) || _nonArrayObject(b)) { diff --git a/packages/core/test/pattern.test.mjs b/packages/core/test/pattern.test.mjs index 3b6619b56..1df5c8776 100644 --- a/packages/core/test/pattern.test.mjs +++ b/packages/core/test/pattern.test.mjs @@ -877,7 +877,7 @@ describe('Pattern', () => { .squeezeJoin() .queryArc(3, 4) .map((x) => x.value), - ).toStrictEqual([3]); + ).toStrictEqual([Fraction(3)]); }); }); describe('ply', () => { From f2b2f9f9591e5c37667be04b15a35f593aff3cf1 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Tue, 16 Sep 2025 11:53:19 -0400 Subject: [PATCH 11/32] format --- packages/core/pattern.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 196248eba..d0be3f599 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th */ import TimeSpan from './timespan.mjs'; -import Fraction, {isFraction, lcm } from './fraction.mjs'; +import Fraction, { isFraction, lcm } from './fraction.mjs'; import Hap from './hap.mjs'; import State from './state.mjs'; import { unionWithObj } from './value.mjs'; From a68c7847531ff94faf0a9f0e048643117703d606 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Tue, 16 Sep 2025 11:55:31 -0400 Subject: [PATCH 12/32] rm unessecary change --- packages/superdough/superdough.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index b1df93916..f1f308a7d 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -465,7 +465,7 @@ function duckOrbit(audioContext, targetOrbit, t, onsettime = 0, attacktime = 0.1 gainParam.cancelScheduledValues(now); gainParam.setValueAtTime(currVal, now); - const t0 = now; // guard against now > t + const t0 = Math.max(t, now); // guard against now > t const duckedVal = clamp(1 - Math.sqrt(depth), 0.01, currVal); gainParam.exponentialRampToValueAtTime(duckedVal, t0 + onset); gainParam.exponentialRampToValueAtTime(1, t0 + onset + attack); From 5445f0812fe1b7147aeb4866d15114afe56f809c Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Wed, 17 Sep 2025 12:41:46 +0200 Subject: [PATCH 13/32] configurable port for osc bridge + add bin field --- packages/osc/package.json | 1 + packages/osc/server.js | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/osc/package.json b/packages/osc/package.json index d5a272328..6e8a3394a 100644 --- a/packages/osc/package.json +++ b/packages/osc/package.json @@ -3,6 +3,7 @@ "version": "1.2.4", "description": "OSC messaging for strudel", "main": "osc.mjs", + "bin": "./server.mjs", "type": "module", "publishConfig": { "main": "dist/index.mjs" diff --git a/packages/osc/server.js b/packages/osc/server.js index 75fc5b1c0..c727e65f7 100644 --- a/packages/osc/server.js +++ b/packages/osc/server.js @@ -6,6 +6,19 @@ This program is free software: you can redistribute it and/or modify it under th import OSC from 'osc-js'; +const args = process.argv.slice(2); +function getArgValue(flag) { + const i = args.indexOf(flag); + if (i !== -1) { + const nextIsFlag = args[i + 1]?.startsWith('--') ?? true; + if (nextIsFlag) return true; + return args[i + 1]; + } +} + +let udpClientPort = Number(getArgValue('--port')) || 57120; +// dirt = 7771 + const config = { receiver: 'ws', // @param {string} Where messages sent via 'send' method will be delivered to, 'ws' for Websocket clients, 'udp' for udp client udpServer: { @@ -17,7 +30,7 @@ const config = { }, udpClient: { host: 'localhost', // @param {string} Hostname of udp client for messaging - port: 57120, // @param {number} Port of udp client for messaging + port: udpClientPort, // @param {number} Port of udp client for messaging }, wsServer: { host: 'localhost', // @param {string} Hostname of WebSocket server From 820744dc27a80e70a14fab0110c5219ff3c22cc7 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Wed, 17 Sep 2025 12:42:11 +0200 Subject: [PATCH 14/32] bump osc to 1.2.5 --- packages/osc/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/osc/package.json b/packages/osc/package.json index 6e8a3394a..bf7fb474d 100644 --- a/packages/osc/package.json +++ b/packages/osc/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/osc", - "version": "1.2.4", + "version": "1.2.5", "description": "OSC messaging for strudel", "main": "osc.mjs", "bin": "./server.mjs", From 5d7c6c3e4b29e22ded1ecb07ce52bd03a994920c Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Wed, 17 Sep 2025 12:47:41 +0200 Subject: [PATCH 15/32] fix: add node shebang + bump osc to 1.2.6 --- packages/osc/package.json | 2 +- packages/osc/server.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/osc/package.json b/packages/osc/package.json index bf7fb474d..ad0d383ff 100644 --- a/packages/osc/package.json +++ b/packages/osc/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/osc", - "version": "1.2.5", + "version": "1.2.6", "description": "OSC messaging for strudel", "main": "osc.mjs", "bin": "./server.mjs", diff --git a/packages/osc/server.js b/packages/osc/server.js index c727e65f7..4d87862ca 100644 --- a/packages/osc/server.js +++ b/packages/osc/server.js @@ -1,3 +1,5 @@ +#!/usr/bin/env node + /* server.js - Copyright (C) 2022 Strudel contributors - see From aba594b72ab9c1591167b4f73088c4539f40bfd8 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Wed, 17 Sep 2025 12:52:42 +0200 Subject: [PATCH 16/32] fix: bin field --- packages/osc/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/osc/package.json b/packages/osc/package.json index ad0d383ff..239acda19 100644 --- a/packages/osc/package.json +++ b/packages/osc/package.json @@ -1,9 +1,9 @@ { "name": "@strudel/osc", - "version": "1.2.6", + "version": "1.2.7", "description": "OSC messaging for strudel", "main": "osc.mjs", - "bin": "./server.mjs", + "bin": "./server.js", "type": "module", "publishConfig": { "main": "dist/index.mjs" From 28d7e0e489440beec0fedfbbf1a676d78bd63a73 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Wed, 17 Sep 2025 13:39:28 +0200 Subject: [PATCH 17/32] export osc function to be able to do all(osc) --- packages/osc/osc.mjs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/osc/osc.mjs b/packages/osc/osc.mjs index 8262e5569..fe0691522 100644 --- a/packages/osc/osc.mjs +++ b/packages/osc/osc.mjs @@ -6,7 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th import OSC from 'osc-js'; -import { logger, parseNumeral, Pattern, isNote, noteToMidi, ClockCollator } from '@strudel/core'; +import { logger, parseNumeral, register, isNote, noteToMidi, ClockCollator } from '@strudel/core'; let connection; // Promise function connect() { @@ -81,6 +81,4 @@ export async function oscTrigger(hap, currentTime, cps = 1, targetTime) { * @memberof Pattern * @returns Pattern */ -Pattern.prototype.osc = function () { - return this.onTrigger(oscTrigger); -}; +export const osc = register('osc', (pat) => pat.onTrigger(oscTrigger)); From cdb623cb66484843915e35fc2a7de2e3e029c71e Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Wed, 17 Sep 2025 13:39:35 +0200 Subject: [PATCH 18/32] overhaul osc readme --- packages/osc/README.md | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/osc/README.md b/packages/osc/README.md index 3dc65e16e..d5ee60fbc 100644 --- a/packages/osc/README.md +++ b/packages/osc/README.md @@ -4,36 +4,35 @@ OSC output for strudel patterns! Currently only tested with super collider / sup ## Usage -OSC will only work if you run the REPL locally + the OSC server besides it: +Assuming you have [node.js](https://nodejs.org/) installed, you can run the osc bridge server via: -From the project root: - -```js -npm run repl +```sh +npx @strudel/osc ``` -and in a seperate shell: - -```js -npm run osc -``` - -This should give you +You should see something like: ```log osc client running on port 57120 -osc server running on port 57121 +osc server running on port 7771 websocket server running on port 8080 ``` -Now open Supercollider (with the super dirt startup file) +By default it will use port 57120 for the osc client, which is what [superdirt](https://github.com/musikinformatik/SuperDirt) uses. You can change it via the `--port` option: -Now open the REPL and type: - -```js -s(" hh").osc() +```sh +npx @strudel/osc --port 7771 # classic dirt ``` -or just [click here](https://strudel.cc/#cygiPGJkIHNkPiBoaCIpLm9zYygp)... +To test it in strudel, you have can use `all(osc)` to send all events through osc: + +```js +$: s("bd*4") + +all(osc) +``` + + +[open in repl](hhttps://strudel.cc/#JDogcygiYmQqNCIpCgphbGwob3NjKQ%3D%3D) You can read more about [how to use Superdirt with Strudel the Tutorial](https://strudel.cc/learn/input-output/#superdirt-api) From 663229ecde6e6cff0a9aa71386e2fa3e6fe10a51 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Wed, 17 Sep 2025 13:50:02 +0200 Subject: [PATCH 19/32] bump osc to 1.2.8 --- packages/osc/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/osc/package.json b/packages/osc/package.json index 239acda19..4244c2c61 100644 --- a/packages/osc/package.json +++ b/packages/osc/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/osc", - "version": "1.2.7", + "version": "1.2.8", "description": "OSC messaging for strudel", "main": "osc.mjs", "bin": "./server.js", From 91ad3d729fb60f04e6dcc58f1286236e398cf331 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 18 Sep 2025 20:36:40 +0200 Subject: [PATCH 20/32] feat: osc add --debug flag to log messages + log errors --- packages/osc/server.js | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/osc/server.js b/packages/osc/server.js index 4d87862ca..2571c49d6 100644 --- a/packages/osc/server.js +++ b/packages/osc/server.js @@ -19,7 +19,7 @@ function getArgValue(flag) { } let udpClientPort = Number(getArgValue('--port')) || 57120; -// dirt = 7771 +let debug = Number(getArgValue('--debug')) || 0; const config = { receiver: 'ws', // @param {string} Where messages sent via 'send' method will be delivered to, 'ws' for Websocket clients, 'udp' for udp client @@ -42,8 +42,34 @@ const config = { const osc = new OSC({ plugin: new OSC.BridgePlugin(config) }); -osc.open(); // start a WebSocket server on port 8080 +if (debug) { + osc.on('*', (message) => { + const { address, args } = message; + let str = ''; + for (let i = 0; i < args.length; i += 2) { + str += `${args[i]}: ${args[i + 1]} `; + } + console.log(`${address} ${str}`); + }); +} + +osc.on('error', (message) => { + if (message.toString().includes('EADDRINUSE')) { + console.log(`------ ERROR ------- +osc server already running! to stop it: +1. run "lsof -ti :57121 | xargs kill -9" (macos / linux) +2. re-run the osc server +`); + } else { + console.log(message); + } +}); + +osc.open(); console.log('osc client running on port', config.udpClient.port); console.log('osc server running on port', config.udpServer.port); console.log('websocket server running on port', config.wsServer.port); +if (debug) { + console.log('debug logs enabled. incoming messages will appear below'); +} From a25f7637968c0036e527254973fec59edc1cb52d Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 18 Sep 2025 20:37:19 +0200 Subject: [PATCH 21/32] docs: add --debug flag to readme --- packages/osc/README.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/osc/README.md b/packages/osc/README.md index d5ee60fbc..0a7bfedcf 100644 --- a/packages/osc/README.md +++ b/packages/osc/README.md @@ -14,16 +14,28 @@ You should see something like: ```log osc client running on port 57120 -osc server running on port 7771 +osc server running on port 57121 websocket server running on port 8080 ``` +### --port + By default it will use port 57120 for the osc client, which is what [superdirt](https://github.com/musikinformatik/SuperDirt) uses. You can change it via the `--port` option: ```sh npx @strudel/osc --port 7771 # classic dirt ``` +### --debug + +To log all incoming osc messages, add the `--debug` flag: + +```sh +npx @strudel/osc --debug +``` + +## Usage in Strudel + To test it in strudel, you have can use `all(osc)` to send all events through osc: ```js @@ -32,7 +44,6 @@ $: s("bd*4") all(osc) ``` - -[open in repl](hhttps://strudel.cc/#JDogcygiYmQqNCIpCgphbGwob3NjKQ%3D%3D) +[open in repl](https://strudel.cc/#JDogcygiYmQqNCIpCgphbGwob3NjKQ%3D%3D) You can read more about [how to use Superdirt with Strudel the Tutorial](https://strudel.cc/learn/input-output/#superdirt-api) From c54fa7d2665569006b71aff9019ea17aac9dd725 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 18 Sep 2025 20:39:05 +0200 Subject: [PATCH 22/32] chore: bump osc to 1.2.8 --- packages/osc/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/osc/package.json b/packages/osc/package.json index 4244c2c61..92aa30bf3 100644 --- a/packages/osc/package.json +++ b/packages/osc/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/osc", - "version": "1.2.8", + "version": "1.2.9", "description": "OSC messaging for strudel", "main": "osc.mjs", "bin": "./server.js", From 1c3e07afd3c4fac5367b5afd18dfe4242a72c18c Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 18 Sep 2025 20:40:25 +0200 Subject: [PATCH 23/32] fix: rephrase error message + bump to 1.2.10 --- packages/osc/package.json | 2 +- packages/osc/server.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/osc/package.json b/packages/osc/package.json index 92aa30bf3..bc828d798 100644 --- a/packages/osc/package.json +++ b/packages/osc/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/osc", - "version": "1.2.9", + "version": "1.2.10", "description": "OSC messaging for strudel", "main": "osc.mjs", "bin": "./server.js", diff --git a/packages/osc/server.js b/packages/osc/server.js index 2571c49d6..d7ec21c4b 100644 --- a/packages/osc/server.js +++ b/packages/osc/server.js @@ -56,7 +56,7 @@ if (debug) { osc.on('error', (message) => { if (message.toString().includes('EADDRINUSE')) { console.log(`------ ERROR ------- -osc server already running! to stop it: +a server is already running on port 57121! to stop it: 1. run "lsof -ti :57121 | xargs kill -9" (macos / linux) 2. re-run the osc server `); From 8833d623f71f89df51b79a4f8467456f72daa278 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 21 Sep 2025 16:06:28 +0200 Subject: [PATCH 24/32] fix: all function in mondo --- packages/mondough/mondough.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mondough/mondough.mjs b/packages/mondough/mondough.mjs index e409d5a3b..669183279 100644 --- a/packages/mondough/mondough.mjs +++ b/packages/mondough/mondough.mjs @@ -85,7 +85,7 @@ function evaluator(node, scope) { let pat; if (type === 'plain' && typeof variable !== 'undefined') { // some function names are not patternable, so we skip reification here - if (['!', 'extend', '@', 'expand', 'square', 'angle'].includes(value)) { + if (['!', 'extend', '@', 'expand', 'square', 'angle', 'all'].includes(value)) { return variable; } pat = reify(variable); From 86248328cc2be957da71566aeb671f69a10ccabe Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 21 Sep 2025 16:09:10 +0200 Subject: [PATCH 25/32] fix: mondo setcps / setcpm --- packages/core/repl.mjs | 17 +++++++++++++++-- packages/mondough/mondough.mjs | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index 47231af4d..23c5b25e0 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -85,7 +85,17 @@ export function repl({ const start = () => scheduler.start(); const pause = () => scheduler.pause(); const toggle = () => scheduler.toggle(); - const setCps = (cps) => scheduler.setCps(cps); + const setCps = (cps) => { + scheduler.setCps(unpure(cps)); + return silence; + }; + + function unpure(pat) { + if (pat._Pattern) { + return pat.__pure; + } + return pat; + } /** * Changes the global tempo to the given cycles per minute @@ -97,7 +107,10 @@ export function repl({ * setcpm(140/4) // =140 bpm in 4/4 * $: s("bd*4,[- sd]*2").bank('tr707') */ - const setCpm = (cpm) => scheduler.setCps(cpm / 60); + const setCpm = (cpm) => { + scheduler.setCps(unpure(cpm) / 60); + return silence; + }; // TODO - not documented as jsdoc examples as the test framework doesn't simulate enough context for `each` and `all`.. diff --git a/packages/mondough/mondough.mjs b/packages/mondough/mondough.mjs index 669183279..b7ee83787 100644 --- a/packages/mondough/mondough.mjs +++ b/packages/mondough/mondough.mjs @@ -85,7 +85,7 @@ function evaluator(node, scope) { let pat; if (type === 'plain' && typeof variable !== 'undefined') { // some function names are not patternable, so we skip reification here - if (['!', 'extend', '@', 'expand', 'square', 'angle', 'all'].includes(value)) { + if (['!', 'extend', '@', 'expand', 'square', 'angle', 'all', 'setcpm', 'setcps'].includes(value)) { return variable; } pat = reify(variable); From 452827630b2ea1949674e645835a960bb1ae1be1 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 21 Sep 2025 16:13:09 +0200 Subject: [PATCH 26/32] move unpure up --- packages/core/repl.mjs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index 23c5b25e0..53562f216 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -74,6 +74,14 @@ export function repl({ return silence; }; + // helper to get a patternified pure value out + function unpure(pat) { + if (pat._Pattern) { + return pat.__pure; + } + return pat; + } + const setPattern = async (pattern, autostart = true) => { pattern = editPattern?.(pattern) || pattern; await scheduler.setPattern(pattern, autostart); @@ -90,13 +98,6 @@ export function repl({ return silence; }; - function unpure(pat) { - if (pat._Pattern) { - return pat.__pure; - } - return pat; - } - /** * Changes the global tempo to the given cycles per minute * From d3d9f23c3c2a0ae5ab2d4f1e3c479a83f11f40de Mon Sep 17 00:00:00 2001 From: Aria Date: Wed, 24 Sep 2025 22:06:53 -0700 Subject: [PATCH 27/32] Remove unnecessary defaulting --- packages/superdough/wavetable.mjs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index 9b9e325f7..ccf319201 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -186,13 +186,11 @@ async function onTriggerSynth(t, value, onended, bank, frameLen) { const ac = getAudioContext(); let [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]); let sourceDesc, holdEnd, envEnd; - let { unison = 5, spread = 0.6, detune, wtPos, wtWarp, wtWarpMode } = value; + let { unison, spread, detune, wtPos, wtWarp, wtWarpMode } = value; if (typeof wtWarpMode === 'string') { wtWarpMode = WarpMode[wtWarpMode.toUpperCase()] ?? WarpMode.NONE; } - detune = detune ?? 0.18; const frequency = getFrequencyFromValue(value); - const voices = clamp(unison, 1, 100); let { tableUrl, label } = getTableInfo(value, bank); const payload = await loadWavetableFrames(tableUrl, label, frameLen); holdEnd = t + duration; @@ -208,7 +206,7 @@ async function onTriggerSynth(t, value, onended, bank, frameLen) { position: wtPos, warp: wtWarp, warpMode: wtWarpMode, - voices, + voices: unison, spread, }, { outputChannelCount: [2] }, From e3741ae8faad7f1b9c160a6949c71a0ff3b03ee2 Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 25 Sep 2025 00:05:50 -0700 Subject: [PATCH 28/32] Add control over phase randomization and fix a bug with supersaw --- packages/core/controls.mjs | 10 ++++++++++ packages/superdough/superdough.mjs | 1 - packages/superdough/wavetable.mjs | 13 +++++++------ packages/superdough/worklets.mjs | 8 +++++--- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 7bf554577..89583b475 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -120,6 +120,16 @@ export const { wtWarp, wavetableWarp } = registerControl('wtWarp', 'wavetableWar */ export const { wtWarpMode, wavetableWarpMode } = registerControl('wtWarpMode', 'wavetableWarpMode'); +/** + * Amount of randomness of the initial phase of the wavetable oscillator. + * + * @name wtPhaseRand + * @param {number | Pattern} mode Warp mode: an integer + * @synonyms wavetableWarpMode + * + */ +export const { wtPhaseRand, wavetablePhaseRand } = registerControl('wtPhaseRand', 'wavetablePhaseRand'); + /** * Define a custom webaudio node to use as a sound source. * diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index b2b5c8d67..f1f308a7d 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -594,7 +594,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) tremolophase = 0, tremoloshape, s = getDefaultValue('s'), - wt, bank, source, gain = getDefaultValue('gain'), diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index ccf319201..044b651e7 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -186,7 +186,7 @@ async function onTriggerSynth(t, value, onended, bank, frameLen) { const ac = getAudioContext(); let [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]); let sourceDesc, holdEnd, envEnd; - let { unison, spread, detune, wtPos, wtWarp, wtWarpMode } = value; + let { wtWarpMode } = value; if (typeof wtWarpMode === 'string') { wtWarpMode = WarpMode[wtWarpMode.toUpperCase()] ?? WarpMode.NONE; } @@ -202,12 +202,13 @@ async function onTriggerSynth(t, value, onended, bank, frameLen) { begin: t, end: envEnd, frequency, - detune, - position: wtPos, - warp: wtWarp, + detune: value.detune, + position: value.wtPos, + warp: value.wtWarp, warpMode: wtWarpMode, - voices: unison, - spread, + voices: value.unison, + spread: value.spread, + phaserand: value.wtPhaseRand, }, { outputChannelCount: [2] }, ); diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index b0c0e8e2d..44b82f624 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -474,10 +474,10 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor { gainR = gain1; } // Individual voice detuning - freq = applySemitoneDetuneToFrequency(freq, getUnisonDetune(voices, freqspread, n)); + const voiceFreq = applySemitoneDetuneToFrequency(freq, getUnisonDetune(voices, freqspread, n)); // We must wrap this here because it is passed into sawblep below which // has domain [0, 1] - const dt = mod(freq / sampleRate, 1); + const dt = mod(voiceFreq / sampleRate, 1); this.phase[n] = this.phase[n] ?? Math.random(); const v = waveshapes.sawblep(this.phase[n], dt); @@ -988,6 +988,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { { name: 'warpMode', defaultValue: 0 }, { name: 'voices', defaultValue: 1, minValue: 1, maxValue: 32 }, { name: 'spread', defaultValue: 0, minValue: 0, maxValue: 1 }, + { name: 'phaserand', defaultValue: 1, minValue: 0, maxValue: 1 }, ]; } @@ -1188,6 +1189,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { const warpAmount = pv(parameters.warp, i); const warpMode = pv(parameters.warpMode, i); const voices = pv(parameters.voices, i); + const phaseRand = pv(parameters.phaserand, i); const gain1 = Math.sqrt(1 - spread); const gain2 = Math.sqrt(spread); let f = pv(parameters.frequency, i); @@ -1207,7 +1209,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { const bank = this.tables[level]; // warp phase then sample - this.phase[n] = this.phase[n] ?? Math.random(); + this.phase[n] = this.phase[n] ?? Math.random() * phaseRand; let ph = this._warpPhase(this.phase[n], warpAmount, warpMode); const s0 = this._sampleFrame(bank[fIdx], ph); const s1 = this._sampleFrame(bank[Math.min(this.numFrames - 1, fIdx + 1)], ph); From bd8d207a3d7271fde88f578f36f1901e8bef0008 Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 25 Sep 2025 00:17:13 -0700 Subject: [PATCH 29/32] Typo --- packages/core/controls.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 89583b475..989097d69 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -114,7 +114,7 @@ export const { wtWarp, wavetableWarp } = registerControl('wtWarp', 'wavetableWar * spin, chaos, primes, binary, brownian, reciprocal, wormhole, logistic, sigmoid, fractal, flip * * @name wtWarpMode - * @param {number | Pattern} mode Warp mode: an integer + * @param {number | Pattern} mode Warp mode * @synonyms wavetableWarpMode * */ @@ -124,8 +124,8 @@ export const { wtWarpMode, wavetableWarpMode } = registerControl('wtWarpMode', ' * Amount of randomness of the initial phase of the wavetable oscillator. * * @name wtPhaseRand - * @param {number | Pattern} mode Warp mode: an integer - * @synonyms wavetableWarpMode + * @param {number | Pattern} amount Randomness of the initial phase. Between 0 (not random) and 1 (fully random) + * @synonyms wavetablePhaseRand * */ export const { wtPhaseRand, wavetablePhaseRand } = registerControl('wtPhaseRand', 'wavetablePhaseRand'); From 96981c3c1d8ddc1e837c48ee8e4c3c883e6066e7 Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 25 Sep 2025 00:36:28 -0700 Subject: [PATCH 30/32] A bit more cleanup --- packages/core/controls.mjs | 2 +- packages/superdough/wavetable.mjs | 31 ++++++++++--------------------- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 989097d69..9b5519704 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -114,7 +114,7 @@ export const { wtWarp, wavetableWarp } = registerControl('wtWarp', 'wavetableWar * spin, chaos, primes, binary, brownian, reciprocal, wormhole, logistic, sigmoid, fractal, flip * * @name wtWarpMode - * @param {number | Pattern} mode Warp mode + * @param {number | string | Pattern} mode Warp mode * @synonyms wavetableWarpMode * */ diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index 044b651e7..feb5098b0 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -182,20 +182,19 @@ export const tables = async (url, frameLen, json) => { }; async function onTriggerSynth(t, value, onended, bank, frameLen) { - let { s, n = 0, duration } = value; + const { s, n = 0, duration } = value; const ac = getAudioContext(); - let [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]); - let sourceDesc, holdEnd, envEnd; + const [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]); let { wtWarpMode } = value; if (typeof wtWarpMode === 'string') { wtWarpMode = WarpMode[wtWarpMode.toUpperCase()] ?? WarpMode.NONE; } const frequency = getFrequencyFromValue(value); - let { tableUrl, label } = getTableInfo(value, bank); + const { tableUrl, label } = getTableInfo(value, bank); const payload = await loadWavetableFrames(tableUrl, label, frameLen); - holdEnd = t + duration; - envEnd = holdEnd + release + 0.01; - const worklet = getWorklet( + const holdEnd = t + duration; + const envEnd = holdEnd + release; + const source = getWorklet( ac, 'wavetable-oscillator-processor', { @@ -212,34 +211,24 @@ async function onTriggerSynth(t, value, onended, bank, frameLen) { }, { outputChannelCount: [2] }, ); - worklet.port.postMessage({ type: 'tables', payload }); - sourceDesc = { source: worklet }; - const { source } = sourceDesc; + source.port.postMessage({ type: 'tables', payload }); if (ac.currentTime > t) { logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight'); return; } - if (!source) { - logger(`[wavetable] could not load "${s}:${n}"`, 'error'); - return; - } - let vibratoOscillator = getVibratoOscillator(source.detune, value, t); + const vibratoOscillator = getVibratoOscillator(source.detune, value, t); const envGain = ac.createGain(); const node = source.connect(envGain); getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear'); getPitchEnvelope(source.detune, value, t, holdEnd); - - const out = ac.createGain(); // we need a separate gain for the cutgroups because firefox... - node.connect(out); - let handle = { node: out, bufferSource: source, oscillator: worklet }; - let timeoutNode = webAudioTimeout( + const handle = { node, source }; + const timeoutNode = webAudioTimeout( ac, () => { source.disconnect(); destroyAudioWorkletNode(source); vibratoOscillator?.stop(); node.disconnect(); - out.disconnect(); onended(); }, t, From f8f42565ae36839b63b37e85d2a7be5a1d7b322c Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 25 Sep 2025 00:40:25 -0700 Subject: [PATCH 31/32] Typo - add back slight delay in cleanup --- packages/superdough/wavetable.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index feb5098b0..5805685d7 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -193,7 +193,7 @@ async function onTriggerSynth(t, value, onended, bank, frameLen) { const { tableUrl, label } = getTableInfo(value, bank); const payload = await loadWavetableFrames(tableUrl, label, frameLen); const holdEnd = t + duration; - const envEnd = holdEnd + release; + const envEnd = holdEnd + release + 0.01; const source = getWorklet( ac, 'wavetable-oscillator-processor', From 8b2c35b7a3217a57fdc954ba3eca5d4592720520 Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 25 Sep 2025 01:10:41 -0700 Subject: [PATCH 32/32] Cleanup --- packages/superdough/wavetable.mjs | 22 ++++++++++------------ packages/superdough/worklets.mjs | 8 ++++---- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index 5805685d7..c628549e7 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -1,5 +1,5 @@ import { getAudioContext, registerSound } from './index.mjs'; -import { clamp, getSoundIndex, valueToMidi } from './util.mjs'; +import { getSoundIndex, valueToMidi } from './util.mjs'; import { destroyAudioWorkletNode, getADSRValues, @@ -86,28 +86,27 @@ function humanFileSize(bytes, si) { return bytes.toFixed(1) + ' ' + units[u]; } -export function getTableInfo(hapValue, bank) { - const { wt, n = 0 } = hapValue; +export function getTableInfo(hapValue, tableUrls) { + const { s, n = 0 } = hapValue; let midi = valueToMidi(hapValue, 36); let transpose = midi - 36; // C3 is middle C; - const index = getSoundIndex(n, bank.length); - const tableUrl = bank[index]; - const label = `${wt}:${index}`; + const index = getSoundIndex(n, tableUrls.length); + const tableUrl = tableUrls[index]; + const label = `${s}:${index}`; return { transpose, tableUrl, index, midi, label }; } -const loadBuffer = (url, ac, wt, n = 0) => { - const label = wt ? `table "${wt}:${n}"` : 'table'; +const loadBuffer = (url, ac, label) => { url = url.replace('#', '%23'); if (!loadCache[url]) { - logger(`[wavetable] load ${label}..`, 'load-table', { url }); + logger(`[wavetable] load table ${label}..`, 'load-table', { url }); const timestamp = Date.now(); loadCache[url] = fetch(url) .then((res) => res.arrayBuffer()) .then(async (res) => { const took = Date.now() - timestamp; const size = humanFileSize(res.byteLength); - logger(`[wavetable] load ${label}... done! loaded ${size} in ${took}ms`, 'loaded-table', { url }); + logger(`[wavetable] load table ${label}... done! loaded ${size} in ${took}ms`, 'loaded-table', { url }); const decoded = await ac.decodeAudioData(res); return decoded; }); @@ -151,7 +150,7 @@ const _processTables = (json, baseUrl, frameLen) => { }; /** - * Loads a collection of wavetables to use with `wt` + * Loads a collection of wavetables to use with `s` * * @name tables */ @@ -167,7 +166,6 @@ export const tables = async (url, frameLen, json) => { // not a browser return; } - const base = url.split('/').slice(0, -1).join('/'); if (typeof fetch === 'undefined') { // skip fetch when in node / testing return; diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 44b82f624..daf19e537 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -474,10 +474,10 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor { gainR = gain1; } // Individual voice detuning - const voiceFreq = applySemitoneDetuneToFrequency(freq, getUnisonDetune(voices, freqspread, n)); + const freqVoice = applySemitoneDetuneToFrequency(freq, getUnisonDetune(voices, freqspread, n)); // We must wrap this here because it is passed into sawblep below which // has domain [0, 1] - const dt = mod(voiceFreq / sampleRate, 1); + const dt = mod(freqVoice / sampleRate, 1); this.phase[n] = this.phase[n] ?? Math.random(); const v = waveshapes.sawblep(this.phase[n], dt); @@ -1203,14 +1203,14 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { gainL = gain2; gainR = gain1; } - let fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, detune, n)); // voice detune + const fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, detune, n)); // voice detune const dPhase = fVoice / sampleRate; const level = this._chooseMip(dPhase); const bank = this.tables[level]; // warp phase then sample this.phase[n] = this.phase[n] ?? Math.random() * phaseRand; - let ph = this._warpPhase(this.phase[n], warpAmount, warpMode); + const ph = this._warpPhase(this.phase[n], warpAmount, warpMode); const s0 = this._sampleFrame(bank[fIdx], ph); const s1 = this._sampleFrame(bank[Math.min(this.numFrames - 1, fIdx + 1)], ph); let s = s0 + (s1 - s0) * frac;