From 483843bd0a776ff0690712b5495499a0535c2c1f Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 1 Jun 2025 18:06:36 +0200 Subject: [PATCH 01/95] begin supradough --- packages/superdough/dough.mjs | 445 +++++++++++++++++++++++++++++++ packages/superdough/synth.mjs | 4 +- packages/superdough/worklets.mjs | 53 ++++ packages/webaudio/supradough.mjs | 39 +++ 4 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 packages/superdough/dough.mjs create mode 100644 packages/webaudio/supradough.mjs diff --git a/packages/superdough/dough.mjs b/packages/superdough/dough.mjs new file mode 100644 index 000000000..f3365b06c --- /dev/null +++ b/packages/superdough/dough.mjs @@ -0,0 +1,445 @@ +// this is dough, the superdough without dependencies + +const ISR = 1 / sampleRate; +// https://garten.salat.dev/audio-DSP/oscillators.html +export class SineOsc { + phase = 0; + update(freq) { + const value = Math.sin(this.phase * 2 * Math.PI); + this.phase = (this.phase + freq / sampleRate) % 1; + return value; + } +} + +export class ZawOsc { + phase = 0; + update(freq) { + this.phase += ISR * freq; + return (this.phase % 1) * 2 - 1; + } +} + +function polyBlep(t, dt) { + // 0 <= t < 1 + if (t < dt) { + t /= dt; + // 2 * (t - t^2/2 - 0.5) + return t + t - t * t - 1; + } + // -1 < t < 0 + if (t > 1 - dt) { + t = (t - 1) / dt; + // 2 * (t^2/2 + t + 0.5) + return t * t + t + t + 1; + } + // 0 otherwise + return 0; +} +export class SawOsc { + //phase = Math.random(); + phase = 0; + update(freq) { + const dt = freq / sampleRate; + let p = polyBlep(this.phase, dt); + let s = 2 * this.phase - 1 - p; + this.phase += dt; + if (this.phase > 1) { + this.phase -= 1; + } + return s; + } +} + +export class TriOsc { + phase = 0; + update(freq) { + this.phase += ISR * freq; + let phase = this.phase % 1; + let value = phase < 0.5 ? 2 * phase : 1 - 2 * (phase - 0.5); + return value * 2 - 1; + } +} + +export class Lpf { + s0 = 0; + s1 = 0; + update(s, cutoff, resonance = 0) { + // Out of bound values can produce NaNs + cutoff = Math.min(cutoff, 1); + resonance = Math.max(resonance, 0); + var c = Math.pow(0.5, (1 - cutoff) / 0.125); + var r = Math.pow(0.5, (resonance + 0.125) / 0.125); + var mrc = 1 - r * c; + var v0 = this.s0; + var v1 = this.s1; + // Apply the filter to the sample + v0 = mrc * v0 - c * v1 + c * s; + v1 = mrc * v1 + c * v0; + s = v1; + this.s0 = v0; + this.s1 = v1; + return s; + } +} + +export class PulseOsc { + phase = 0; + update(freq, duty = 0.5) { + this.phase += ISR * freq; + let cyclePos = this.phase % 1; + return cyclePos < duty ? 1 : -1; + } +} + +export class Dust { + update = (density) => (Math.random() < density * ISR ? Math.random() : 0); +} + +export class Impulse { + phase = 1; + update(freq) { + this.phase += ISR * freq; + let v = this.phase >= 1 ? 1 : 0; + this.phase = this.phase % 1; + return v; + } +} + +export class ClockDiv { + inSgn = true; + outSgn = true; + clockCnt = 0; + update(clock, factor) { + let curSgn = clock > 0; + if (this.inSgn != curSgn) { + this.clockCnt++; + if (this.clockCnt >= factor) { + this.clockCnt = 0; + this.outSgn = !this.outSgn; + } + } + + this.inSgn = curSgn; + return this.outSgn ? 1 : -1; + } +} + +export class Hold { + value = 0; + trigSgn = false; + update(input, trig) { + if (!this.trigSgn && trig > 0) this.value = input; + this.trigSgn = trig > 0; + return this.value; + } +} + +function lerp(x, y0, y1) { + if (x >= 1) return y1; + + return y0 + x * (y1 - y0); +} + +export class ADSR { + state = 'off'; + startTime = 0; + startVal = 0; + + update(curTime, gate, attack, decay, susVal, release) { + switch (this.state) { + case 'off': { + if (gate > 0) { + this.state = 'attack'; + this.startTime = curTime; + this.startVal = 0; + } + return 0; + } + case 'attack': { + let time = curTime - this.startTime; + if (time > attack) { + this.state = 'decay'; + this.startTime = curTime; + return 1; + } + return lerp(time / attack, this.startVal, 1); + } + case 'decay': { + let time = curTime - this.startTime; + let curVal = lerp(time / decay, 1, susVal); + if (gate <= 0) { + this.state = 'release'; + this.startTime = curTime; + this.startVal = curVal; + return curVal; + } + if (time > decay) { + this.state = 'sustain'; + this.startTime = curTime; + return susVal; + } + return curVal; + } + case 'sustain': { + if (gate <= 0) { + this.state = 'release'; + this.startTime = curTime; + this.startVal = susVal; + } + return susVal; + } + case 'release': { + let time = curTime - this.startTime; + if (time > release) { + this.state = 'off'; + return 0; + } + let curVal = lerp(time / release, this.startVal, 0); + if (gate > 0) { + this.state = 'attack'; + this.startTime = curTime; + this.startVal = curVal; + } + return curVal; + } + } + throw 'invalid envelope state'; + } +} + +/* + impulse(1).ad(.1).mul(sine(200)) +.add(x=>x.delay(.1).mul(.8)) +.out()*/ +const MAX_DELAY_TIME = 10; +export class Delay { + writeIdx = 0; + readIdx = 0; + buffer = new Float32Array(MAX_DELAY_TIME * sampleRate); // .fill(0) + write(s, delayTime) { + this.writeIdx = (this.writeIdx + 1) % this.buffer.length; + this.buffer[this.writeIdx] = s; + // Calculate how far in the past to read + let numSamples = Math.min(Math.floor(sampleRate * delayTime), this.buffer.length - 1); + this.readIdx = this.writeIdx - numSamples; + // If past the start of the buffer, wrap around + if (this.readIdx < 0) this.readIdx += this.buffer.length; + } + update(input, delayTime) { + this.write(input, delayTime); + return this.buffer[this.readIdx]; + } +} + +export class Fold { + update(input = 0, rate = 0) { + if (rate < 0) rate = 0; + rate = rate + 1; + input = input * rate; + return 4 * (Math.abs(0.25 * input + 0.25 - Math.round(0.25 * input + 0.25)) - 0.25); + } +} + +export class Lag { + lagUnit = 4410; + s = 0; + update(input, rate) { + // Remap so the useful range is around [0, 1] + rate = rate * this.lagUnit; + if (rate < 1) rate = 1; + this.s += (1 / rate) * (input - this.s); + return this.s; + } +} + +export class Slew { + last = 0; + update(input, up, dn) { + const upStep = up * ISR; + const downStep = dn * ISR; + let delta = input - this.last; + if (delta > upStep) { + delta = upStep; + } else if (delta < -downStep) { + delta = -downStep; + } + this.last += delta; + return this.last; + } +} + +export function applyDistortion(x, amount) { + amount = Math.min(Math.max(amount, 0), 1); + amount -= 0.01; + var k = (2 * amount) / (1 - amount); + var y = ((1 + k) * x) / (1 + k * Math.abs(x)); + return y; +} + +export class Sequence { + clockSgn = true; + step = 0; + first = true; + update(clock, ...ins) { + if (!this.clockSgn && clock > 0) { + this.step = (this.step + 1) % ins.length; + this.clockSgn = clock > 0; + return 0; // set first sample to zero to retrigger gates on step change... + } + this.clockSgn = clock > 0; + return ins[this.step]; + } +} + +export function _rangex(sig, min, max) { + let logmin = Math.log(min); + let range = Math.log(max) - logmin; + const unipolar = (sig + 1) / 2; + return Math.exp(unipolar * range + logmin); +} + +// duplicate +export const getADSRValues = (params, curve = 'linear', defaultValues) => { + const envmin = curve === 'exponential' ? 0.001 : 0.001; + const releaseMin = 0.01; + const envmax = 1; + const [a, d, s, r] = params; + if (a == null && d == null && s == null && r == null) { + return defaultValues ?? [envmin, envmin, envmax, releaseMin]; + } + const sustain = s != null ? s : (a != null && d == null) || (a == null && d == null) ? envmax : envmin; + return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)]; +}; +let oscillators = { + sine: SineOsc, + saw: SawOsc, + zaw: ZawOsc, + sawtooth: SawOsc, + zawtooth: ZawOsc, + tri: TriOsc, + triangle: TriOsc, + pulse: PulseOsc, + dust: Dust, + impulse: Impulse, +}; + +const defaultDefaultValues = { + s: 'triangle', + gain: 0.8, + postgain: 1, + density: '.03', + ftype: '12db', + fanchor: 0, + resonance: 1, + hresonance: 1, + bandq: 1, + channels: [1, 2], + phaserdepth: 0.75, + shapevol: 1, + distortvol: 1, + delay: 0, + byteBeatExpression: '0', + delayfeedback: 0.5, + delaytime: 0.25, + orbit: 1, + i: 1, + velocity: 1, + fft: 8, + z: 'triangle', +}; + +let getDefaultValue = (key) => defaultDefaultValues[key]; + +export class Dough { + init(value) { + // params without defaults: + /* + bank, + source, + cutoff, + lpenv, + lpattack, + lpdecay, + lpsustain, + lprelease, + hpenv, + hcutoff, + hpattack, + hpdecay, + hpsustain, + hprelease, + bpenv, + bandf, + bpattack, + bpdecay, + bpsustain, + bprelease, + phaserrate, + phasersweep, + phasercenter, + coarse, + crush, + shape, + distort, + pan, + vowel, + room, + roomfade, + roomlp, + roomdim, + roomsize, + ir, + analyze, + */ + Object.assign(this, value); + // params with defaults: + this.s = this.s ?? getDefaultValue('s'); + this.gain = this.gain ?? getDefaultValue('gain'); + this.postgain = this.postgain ?? getDefaultValue('postgain'); + this.density = this.density ?? getDefaultValue('density'); + this.fanchor = this.fanchor ?? getDefaultValue('fanchor'); + this.drive = this.drive ?? 0.69; + this.resonance = this.resonance ?? getDefaultValue('resonance'); + this.hresonance = this.hresonance ?? getDefaultValue('hresonance'); + this.bandq = this.bandq ?? getDefaultValue('bandq'); + this.phaserdepth = this.phaserdepth ?? getDefaultValue('phaserdepth'); + this.shapevol = this.shapevol ?? getDefaultValue('shapevol'); + this.distortvol = this.distortvol ?? getDefaultValue('distortvol'); + this.delay = this.delay ?? getDefaultValue('delay'); + this.delayfeedback = this.delayfeedback ?? getDefaultValue('delayfeedback'); + this.delaytime = this.delaytime ?? getDefaultValue('delaytime'); + this.orbit = this.orbit ?? getDefaultValue('orbit'); + this.i = this.i ?? getDefaultValue('i'); + this.velocity = this.velocity ?? getDefaultValue('velocity'); + this.fft = this.fft ?? getDefaultValue('fft'); + + [this.attack, this.decay, this.sustain, this.release] = getADSRValues([ + this.attack, + this.decay, + this.sustain, + this.release, + ]); + + const SourceClass = oscillators[this.s] ?? TriOsc; + this._sound = new SourceClass(); + this._lpf = this.cutoff ? new Lpf() : null; + this._adsr = new ADSR(); + } + update(t) { + if (!this._sound) { + return 0; + } + // sound source + let s = this._sound.update(this.freq); + // lpf + s = this._lpf ? this._lpf.update(s, this.cutoff, this.resonance) : s; + // not sure if gain is applied here + s = s * this.gain; + // envelope + let gate = Number(t >= this._begin && t <= this._end); + const env = this._adsr.update(t, gate, this.attack, this.decay, this.sustain, this.release); + s = s * env; + s = s * this.postgain; + return s; + } +} diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index 834e54c6d..7d175d6c4 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -12,7 +12,7 @@ import { } from './helpers.mjs'; import { getNoiseMix, getNoiseOscillator } from './noise.mjs'; -const getFrequencyFromValue = (value) => { +export const getFrequencyFromValue = (value) => { let { note, freq } = value; note = note || 36; if (typeof note === 'string') { @@ -25,7 +25,7 @@ const getFrequencyFromValue = (value) => { return Number(freq); }; -function destroyAudioWorkletNode(node) { +export function destroyAudioWorkletNode(node) { if (node == null) { return; } diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 24f7434a9..43a21477b 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -4,6 +4,7 @@ import OLAProcessor from './ola-processor'; import FFT from './fft.js'; +import { Dough } from './dough.mjs'; const clamp = (num, min, max) => Math.min(Math.max(num, min), max); const _mod = (n, m) => ((n % m) + m) % m; @@ -895,3 +896,55 @@ class ByteBeatProcessor extends AudioWorkletProcessor { } registerProcessor('byte-beat-processor', ByteBeatProcessor); + +class DoughProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.dough = new Dough(); + this.port.onmessage = (event) => this.dough.init(event.data); + } + static get parameterDescriptors() { + return [ + { + name: 'begin', + defaultValue: 0, + max: Number.POSITIVE_INFINITY, + min: 0, + }, + { + name: 'end', + defaultValue: 0, + max: Number.POSITIVE_INFINITY, + min: 0, + }, + ]; + } + + process(inputs, outputs, params) { + if (this.disconnected) { + return false; + } + if (currentTime <= params.begin[0]) { + return true; + } + if (currentTime >= params.end[0]) { + return false; + } + if (this.t == null) { + this.t = params.begin[0] * sampleRate; + } + const output = outputs[0]; + for (let i = 0; i < output[0].length; i++) { + const out = this.dough.update(currentTime); + + for (let c = 0; c < output.length; c++) { + //prevent speaker blowout via clipping if threshold exceeds + output[c][i] = clamp(out, -1, 1); + } + this.t = this.t + 1; + } + return true; // keep the audio processing going + } +} + +registerProcessor('dough-processor', DoughProcessor); diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs new file mode 100644 index 000000000..514d5e606 --- /dev/null +++ b/packages/webaudio/supradough.mjs @@ -0,0 +1,39 @@ +import { Pattern } from '@strudel/core'; +import { connectToDestination, destroyAudioWorkletNode, getAudioContext } from 'superdough'; + +Pattern.prototype.supradough = function () { + return this.onTrigger((_, hap, __, cps, begin) => { + const { value } = hap; + value.freq = getFrequencyFromValue(hap.value); + const ac = getAudioContext(); + + const release = getADSRValues( + [value.attack, value.decay, value.sustain, value.release], + 'linear', + [0.001, 0.05, 0.6, 0.01], + )[3]; + + const duration = hap.duration / cps; + const holdend = begin + duration; + const end = holdend + release + 0.01; + value._begin = begin; // these are needed for the gate signal + value._end = end; + + let o = getWorklet( + ac, + 'dough-processor', + { + begin, // we might not need these, as we could send them via postMessage below + end, + }, + { + outputChannelCount: [2], + }, + ); + + o.port.postMessage(value); // send value to worklet + let timeoutNode = webAudioTimeout(ac, () => destroyAudioWorkletNode(o), begin, end); + timeoutNode.stop(end + 0.125); + connectToDestination(o); // channels? + }, 1); +}; From 5336569c25dfa0440396a61cc919d7b2cd29af70 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 1 Jun 2025 21:20:28 +0200 Subject: [PATCH 02/95] proper lpf mapping + remove redundant worklet params, --- packages/superdough/dough.mjs | 19 ++++++++++++++--- packages/superdough/worklets.mjs | 36 +++++++++++--------------------- packages/webaudio/supradough.mjs | 5 +---- packages/webaudio/webaudio.mjs | 1 + 4 files changed, 30 insertions(+), 31 deletions(-) diff --git a/packages/superdough/dough.mjs b/packages/superdough/dough.mjs index f3365b06c..876cfdf11 100644 --- a/packages/superdough/dough.mjs +++ b/packages/superdough/dough.mjs @@ -351,7 +351,7 @@ const defaultDefaultValues = { let getDefaultValue = (key) => defaultDefaultValues[key]; export class Dough { - init(value) { + init(value, sampleRate) { // params without defaults: /* bank, @@ -424,6 +424,14 @@ export class Dough { this._sound = new SourceClass(); this._lpf = this.cutoff ? new Lpf() : null; this._adsr = new ADSR(); + + this.piOverSr = Math.PI / sampleRate; + this.eighthOverLogHalf = 0.125 / Math.log(0.5); + } + // credits to pulu: https://github.com/felixroos/kabelsalat/issues/35 + freq2cutoff(freq) { + const c = 2 * Math.sin(freq * this.piOverSr); + return 1 - Math.log(c) * this.eighthOverLogHalf; } update(t) { if (!this._sound) { @@ -432,14 +440,19 @@ export class Dough { // sound source let s = this._sound.update(this.freq); // lpf - s = this._lpf ? this._lpf.update(s, this.cutoff, this.resonance) : s; + if (this._lpf) { + const cutoff = this.freq2cutoff(this.cutoff); + s = this._lpf ? this._lpf.update(s, cutoff, this.resonance) : s; + } // not sure if gain is applied here s = s * this.gain; // envelope let gate = Number(t >= this._begin && t <= this._end); + /* Math.random() > 0.99 && console.log('gate', gate); */ const env = this._adsr.update(t, gate, this.attack, this.decay, this.sustain, this.release); s = s * env; - s = s * this.postgain; + + s = s * this.postgain * 0.3; return s; } } diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 43a21477b..7633c6441 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -901,42 +901,30 @@ class DoughProcessor extends AudioWorkletProcessor { constructor() { super(); this.dough = new Dough(); - this.port.onmessage = (event) => this.dough.init(event.data); - } - static get parameterDescriptors() { - return [ - { - name: 'begin', - defaultValue: 0, - max: Number.POSITIVE_INFINITY, - min: 0, - }, - { - name: 'end', - defaultValue: 0, - max: Number.POSITIVE_INFINITY, - min: 0, - }, - ]; + this.port.onmessage = (event) => this.dough.init(event.data, sampleRate); } process(inputs, outputs, params) { if (this.disconnected) { return false; } - if (currentTime <= params.begin[0]) { + if (this.dough._begin === undefined) { return true; } - if (currentTime >= params.end[0]) { - return false; + if (currentTime <= this.dough._begin) { + return true; } - if (this.t == null) { - this.t = params.begin[0] * sampleRate; + if (currentTime >= this.dough._end + 1) { + return false; // this causes cracks for some reason (seems to kick in too early for some reason) + // it works with + 1 but not sure why this is needed + } + if (this.t === undefined) { + this.t = Math.floor(this.dough._begin * sampleRate); } const output = outputs[0]; for (let i = 0; i < output[0].length; i++) { - const out = this.dough.update(currentTime); - + // const out = this.dough.update(currentTime); + const out = this.dough.update(this.t / sampleRate); for (let c = 0; c < output.length; c++) { //prevent speaker blowout via clipping if threshold exceeds output[c][i] = clamp(out, -1, 1); diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs index 514d5e606..8887a4132 100644 --- a/packages/webaudio/supradough.mjs +++ b/packages/webaudio/supradough.mjs @@ -22,10 +22,7 @@ Pattern.prototype.supradough = function () { let o = getWorklet( ac, 'dough-processor', - { - begin, // we might not need these, as we could send them via postMessage below - end, - }, + {}, { outputChannelCount: [2], }, diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index 44a683480..8f7d041a9 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -6,6 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th import * as strudel from '@strudel/core'; import { superdough, getAudioContext, setLogger, doughTrigger } from 'superdough'; +import './supradough.mjs'; const { Pattern, logger, repl } = strudel; setLogger(logger); From 5129fa667791abb0498f189a0ea3c7c506eac61c Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Wed, 4 Jun 2025 00:48:32 +0100 Subject: [PATCH 03/95] fix click --- packages/superdough/dough.mjs | 7 +++++-- packages/webaudio/supradough.mjs | 11 +++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/superdough/dough.mjs b/packages/superdough/dough.mjs index 876cfdf11..bff024150 100644 --- a/packages/superdough/dough.mjs +++ b/packages/superdough/dough.mjs @@ -146,6 +146,7 @@ export class ADSR { startVal = 0; update(curTime, gate, attack, decay, susVal, release) { + console.info('here') switch (this.state) { case 'off': { if (gate > 0) { @@ -352,6 +353,7 @@ let getDefaultValue = (key) => defaultDefaultValues[key]; export class Dough { init(value, sampleRate) { + // params without defaults: /* bank, @@ -447,12 +449,13 @@ export class Dough { // not sure if gain is applied here s = s * this.gain; // envelope - let gate = Number(t >= this._begin && t <= this._end); + let gate = Number(t >= this._begin && t <= this._holdEnd); + /* Math.random() > 0.99 && console.log('gate', gate); */ const env = this._adsr.update(t, gate, this.attack, this.decay, this.sustain, this.release); s = s * env; - s = s * this.postgain * 0.3; + s = s * this.postgain * .3 return s; } } diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs index 8887a4132..dcf2cf4c0 100644 --- a/packages/webaudio/supradough.mjs +++ b/packages/webaudio/supradough.mjs @@ -1,6 +1,5 @@ import { Pattern } from '@strudel/core'; -import { connectToDestination, destroyAudioWorkletNode, getAudioContext } from 'superdough'; - +import { connectToDestination, destroyAudioWorkletNode, getAudioContext, webAudioTimeout } from 'superdough'; Pattern.prototype.supradough = function () { return this.onTrigger((_, hap, __, cps, begin) => { const { value } = hap; @@ -14,10 +13,11 @@ Pattern.prototype.supradough = function () { )[3]; const duration = hap.duration / cps; - const holdend = begin + duration; - const end = holdend + release + 0.01; + const holdEnd = begin + duration; + const end = holdEnd + release + 0.01; value._begin = begin; // these are needed for the gate signal value._end = end; + value._holdEnd = holdEnd let o = getWorklet( ac, @@ -29,8 +29,7 @@ Pattern.prototype.supradough = function () { ); o.port.postMessage(value); // send value to worklet - let timeoutNode = webAudioTimeout(ac, () => destroyAudioWorkletNode(o), begin, end); - timeoutNode.stop(end + 0.125); + webAudioTimeout(ac, () => destroyAudioWorkletNode(o), begin, end); connectToDestination(o); // channels? }, 1); }; From ff4b7ca1718fc7ffd409f6bd62b3e1234d7acd38 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Wed, 4 Jun 2025 01:08:36 +0100 Subject: [PATCH 04/95] fix volume --- packages/superdough/dough.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/superdough/dough.mjs b/packages/superdough/dough.mjs index bff024150..32ca86ef7 100644 --- a/packages/superdough/dough.mjs +++ b/packages/superdough/dough.mjs @@ -146,7 +146,6 @@ export class ADSR { startVal = 0; update(curTime, gate, attack, decay, susVal, release) { - console.info('here') switch (this.state) { case 'off': { if (gate > 0) { @@ -191,6 +190,7 @@ export class ADSR { } case 'release': { let time = curTime - this.startTime; + if (time > release) { this.state = 'off'; return 0; @@ -353,7 +353,7 @@ let getDefaultValue = (key) => defaultDefaultValues[key]; export class Dough { init(value, sampleRate) { - + // params without defaults: /* bank, @@ -455,7 +455,7 @@ export class Dough { const env = this._adsr.update(t, gate, this.attack, this.decay, this.sustain, this.release); s = s * env; - s = s * this.postgain * .3 + s = s * this.postgain * .2 return s; } } From 930dabe4d6e4952445b952cb88c04a0bef49ee1d Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 5 Jun 2025 09:54:44 +0200 Subject: [PATCH 05/95] dough as a singleton worklet with voice allocation --- packages/superdough/dough.mjs | 78 ++++++++++++++++++++++++++++++-- packages/superdough/worklets.mjs | 25 +++------- packages/webaudio/supradough.mjs | 40 ++++++++++------ 3 files changed, 104 insertions(+), 39 deletions(-) diff --git a/packages/superdough/dough.mjs b/packages/superdough/dough.mjs index 32ca86ef7..65cc3793f 100644 --- a/packages/superdough/dough.mjs +++ b/packages/superdough/dough.mjs @@ -351,9 +351,8 @@ const defaultDefaultValues = { let getDefaultValue = (key) => defaultDefaultValues[key]; -export class Dough { - init(value, sampleRate) { - +export class DoughVoice { + constructor(value) { // params without defaults: /* bank, @@ -427,7 +426,7 @@ export class Dough { this._lpf = this.cutoff ? new Lpf() : null; this._adsr = new ADSR(); - this.piOverSr = Math.PI / sampleRate; + this.piOverSr = Math.PI / value.sampleRate; this.eighthOverLogHalf = 0.125 / Math.log(0.5); } // credits to pulu: https://github.com/felixroos/kabelsalat/issues/35 @@ -455,7 +454,76 @@ export class Dough { const env = this._adsr.update(t, gate, this.attack, this.decay, this.sustain, this.release); s = s * env; - s = s * this.postgain * .2 + s = s * this.postgain * 0.2; return s; } } + +// this class is the interface to the "outer world" +// it handles spawning and despawning of DoughVoice's +export class Dough { + voices = []; // DoughVoice[] + vid = 0; + q = []; + l = 0; // tbd + r = 0; // tbd + t = 0; + // sampleRate: number, currentTime: number (seconds) + constructor(sampleRate, currentTime) { + this.sampleRate = sampleRate; + this.t = Math.floor(currentTime * sampleRate); // samples + // console.log('init dough', this.sampleRate, this.t); + } + scheduleSpawn(value) { + const time = value._begin; // set from supradough.mjs + this.schedule({ time, type: 'spawn', arg: value }); + } + spawn(value) { + value.id = this.vid++; + const voice = new DoughVoice(value); + this.voices.push(voice); + console.log('spawn', voice.id, value._holdDuration, 'voices:', this.voices.length); + // schedule removal + const endTime = Math.ceil(value._end * this.sampleRate); + this.schedule({ time: endTime /* + 48000 */, type: 'despawn', arg: voice.id }); + } + despawn(vid) { + this.voices = this.voices.filter((v) => v.id !== vid); + console.log('despawn', vid, 'voices:', this.voices.length); + } + // schedules a function call with a single argument + // msg = {time:number,type:string, arg: any} + // the Dough method "type" will be called with "arg" at "time" + schedule(msg) { + if (!this.q.length) { + // if empty, just push + this.q.push(msg); + return; + } + // not empty + // find index where msg.time fits in + let i = 0; + while (i < this.q.length && this.q[i].time < msg.time) { + i++; + } + // this ensures q stays sorted by time, so we only need to check q[0] + this.q.splice(i, 0, msg); + } + // maybe update should be called once per block instead for perf reasons? + update() { + // go over q + while (this.q.length > 0 && this.q[0].time <= this.t) { + // console.log('schedule', this.q[0]); + // trigger due messages. q is sorted, so we only need to check q[0] + this[this.q[0].type](this.q[0].arg); // type is expected to be a Dough method + this.q.shift(); + } + // add active voices + let sum = 0; + for (let v = 0; v < this.voices.length; v++) { + sum += this.voices[v].update(this.t / this.sampleRate); + } + this.t++; + return sum; + } +} diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 7633c6441..5e5494255 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -900,36 +900,23 @@ registerProcessor('byte-beat-processor', ByteBeatProcessor); class DoughProcessor extends AudioWorkletProcessor { constructor() { super(); - this.dough = new Dough(); - this.port.onmessage = (event) => this.dough.init(event.data, sampleRate); + this.dough = new Dough(sampleRate, currentTime); + this.port.onmessage = (event) => { + event.data.sampleRate = sampleRate; + this.dough.scheduleSpawn(event.data); + }; } - process(inputs, outputs, params) { if (this.disconnected) { return false; } - if (this.dough._begin === undefined) { - return true; - } - if (currentTime <= this.dough._begin) { - return true; - } - if (currentTime >= this.dough._end + 1) { - return false; // this causes cracks for some reason (seems to kick in too early for some reason) - // it works with + 1 but not sure why this is needed - } - if (this.t === undefined) { - this.t = Math.floor(this.dough._begin * sampleRate); - } const output = outputs[0]; for (let i = 0; i < output[0].length; i++) { - // const out = this.dough.update(currentTime); - const out = this.dough.update(this.t / sampleRate); + const out = this.dough.update(); for (let c = 0; c < output.length; c++) { //prevent speaker blowout via clipping if threshold exceeds output[c][i] = clamp(out, -1, 1); } - this.t = this.t + 1; } return true; // keep the audio processing going } diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs index dcf2cf4c0..f774b7180 100644 --- a/packages/webaudio/supradough.mjs +++ b/packages/webaudio/supradough.mjs @@ -1,10 +1,27 @@ import { Pattern } from '@strudel/core'; -import { connectToDestination, destroyAudioWorkletNode, getAudioContext, webAudioTimeout } from 'superdough'; +import { connectToDestination, getAudioContext, getWorklet } from 'superdough'; + +let doughWorklet; + +function initDoughWorklet() { + const ac = getAudioContext(); + doughWorklet = getWorklet( + ac, + 'dough-processor', + {}, + { + outputChannelCount: [2], + }, + ); + /* webAudioTimeout(ac, () => destroyAudioWorkletNode(doughWorklet), begin, end); */ + connectToDestination(doughWorklet); // channels? +} + Pattern.prototype.supradough = function () { return this.onTrigger((_, hap, __, cps, begin) => { const { value } = hap; + // todo: could these calculations be made inside dough as well? value.freq = getFrequencyFromValue(hap.value); - const ac = getAudioContext(); const release = getADSRValues( [value.attack, value.decay, value.sustain, value.release], @@ -17,19 +34,12 @@ Pattern.prototype.supradough = function () { const end = holdEnd + release + 0.01; value._begin = begin; // these are needed for the gate signal value._end = end; - value._holdEnd = holdEnd + value._holdEnd = holdEnd; + value._holdDuration = duration + release; - let o = getWorklet( - ac, - 'dough-processor', - {}, - { - outputChannelCount: [2], - }, - ); - - o.port.postMessage(value); // send value to worklet - webAudioTimeout(ac, () => destroyAudioWorkletNode(o), begin, end); - connectToDestination(o); // channels? + if (!doughWorklet) { + initDoughWorklet(); + } + doughWorklet.port.postMessage(value); }, 1); }; From 2cbd35f6d2a964331b199530ea2be80c1b6e5491 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 5 Jun 2025 10:26:14 +0200 Subject: [PATCH 06/95] refactor: make webaudio glue as minimal as possible --- packages/superdough/dough.mjs | 36 ++++++++++++++++++++++++++++++-- packages/webaudio/supradough.mjs | 22 +++---------------- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/packages/superdough/dough.mjs b/packages/superdough/dough.mjs index 65cc3793f..032bb5a69 100644 --- a/packages/superdough/dough.mjs +++ b/packages/superdough/dough.mjs @@ -311,6 +311,7 @@ export const getADSRValues = (params, curve = 'linear', defaultValues) => { const sustain = s != null ? s : (a != null && d == null) || (a == null && d == null) ? envmax : envmin; return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)]; }; + let oscillators = { sine: SineOsc, saw: SawOsc, @@ -351,6 +352,33 @@ const defaultDefaultValues = { let getDefaultValue = (key) => defaultDefaultValues[key]; +const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +const accs = { '#': 1, b: -1, s: 1, f: -1 }; +const note2midi = (note, defaultOctave = 3) => { + const [pc, acc = '', oct = defaultOctave] = + String(note) + .match(/^([a-gA-G])([#bsf]*)([0-9]*)$/) + ?.slice(1) || []; + if (!pc) { + throw new Error('not a note: "' + note + '"'); + } + const chroma = chromas[pc.toLowerCase()]; + const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0; + return (Number(oct) + 1) * 12 + chroma + offset; +}; +const getFrequency = (value) => { + let { note, freq } = value; + note = note || 36; + if (typeof note === 'string') { + note = note2midi(note); // e.g. c3 => 48 + } + if (!freq && typeof note === 'number') { + freq = Math.pow(2, (note - 69) / 12) * 440; + } + + return Number(freq); +}; + export class DoughVoice { constructor(value) { // params without defaults: @@ -392,6 +420,7 @@ export class DoughVoice { ir, analyze, */ + value.freq = getFrequency(value); Object.assign(this, value); // params with defaults: this.s = this.s ?? getDefaultValue('s'); @@ -421,6 +450,9 @@ export class DoughVoice { this.release, ]); + this._holdEnd = this._begin + this._duration; // needed for gate + this._end = this._holdEnd + this.release + 0.01; // needed for despawn + const SourceClass = oscillators[this.s] ?? TriOsc; this._sound = new SourceClass(); this._lpf = this.cutoff ? new Lpf() : null; @@ -482,9 +514,9 @@ export class Dough { value.id = this.vid++; const voice = new DoughVoice(value); this.voices.push(voice); - console.log('spawn', voice.id, value._holdDuration, 'voices:', this.voices.length); + console.log('spawn', voice.id, 'voices:', this.voices.length); // schedule removal - const endTime = Math.ceil(value._end * this.sampleRate); + const endTime = Math.ceil(voice._end * this.sampleRate); this.schedule({ time: endTime /* + 48000 */, type: 'despawn', arg: voice.id }); } despawn(vid) { diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs index f774b7180..8df18bed6 100644 --- a/packages/webaudio/supradough.mjs +++ b/packages/webaudio/supradough.mjs @@ -13,33 +13,17 @@ function initDoughWorklet() { outputChannelCount: [2], }, ); - /* webAudioTimeout(ac, () => destroyAudioWorkletNode(doughWorklet), begin, end); */ connectToDestination(doughWorklet); // channels? } Pattern.prototype.supradough = function () { return this.onTrigger((_, hap, __, cps, begin) => { - const { value } = hap; - // todo: could these calculations be made inside dough as well? - value.freq = getFrequencyFromValue(hap.value); - - const release = getADSRValues( - [value.attack, value.decay, value.sustain, value.release], - 'linear', - [0.001, 0.05, 0.6, 0.01], - )[3]; - - const duration = hap.duration / cps; - const holdEnd = begin + duration; - const end = holdEnd + release + 0.01; - value._begin = begin; // these are needed for the gate signal - value._end = end; - value._holdEnd = holdEnd; - value._holdDuration = duration + release; + hap.value._begin = begin; + hap.value._duration = hap.duration / cps; if (!doughWorklet) { initDoughWorklet(); } - doughWorklet.port.postMessage(value); + doughWorklet.port.postMessage(hap.value); }, 1); }; From f0e6c5483c012de6d4c5de79fa2ac301a0c9ae94 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 5 Jun 2025 10:30:53 +0200 Subject: [PATCH 07/95] handle invalid spawns + remove logs --- packages/superdough/dough.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/superdough/dough.mjs b/packages/superdough/dough.mjs index 032bb5a69..7261c4a5a 100644 --- a/packages/superdough/dough.mjs +++ b/packages/superdough/dough.mjs @@ -507,6 +507,12 @@ export class Dough { // console.log('init dough', this.sampleRate, this.t); } scheduleSpawn(value) { + if (value._begin === undefined) { + throw new Error('[dough]: scheduleSpawn expected _begin to be set'); + } + if (value._duration === undefined) { + throw new Error('[dough]: scheduleSpawn expected _duration to be set'); + } const time = value._begin; // set from supradough.mjs this.schedule({ time, type: 'spawn', arg: value }); } @@ -514,14 +520,14 @@ export class Dough { value.id = this.vid++; const voice = new DoughVoice(value); this.voices.push(voice); - console.log('spawn', voice.id, 'voices:', this.voices.length); + // console.log('spawn', voice.id, 'voices:', this.voices.length); // schedule removal const endTime = Math.ceil(voice._end * this.sampleRate); this.schedule({ time: endTime /* + 48000 */, type: 'despawn', arg: voice.id }); } despawn(vid) { this.voices = this.voices.filter((v) => v.id !== vid); - console.log('despawn', vid, 'voices:', this.voices.length); + // console.log('despawn', vid, 'voices:', this.voices.length); } // schedules a function call with a single argument // msg = {time:number,type:string, arg: any} From ccec7e725ab6344d8a4e58828f6b1cba1c85f776 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 5 Jun 2025 11:04:55 +0200 Subject: [PATCH 08/95] poc: render pattern audio with node script --- packages/superdough/.gitignore | 1 + packages/superdough/dough-export.mjs | 61 ++++++++++++++++++++++++++++ packages/superdough/dough.mjs | 15 +++---- packages/superdough/package.json | 3 +- packages/superdough/worklets.mjs | 5 +-- pnpm-lock.yaml | 8 ++++ 6 files changed, 81 insertions(+), 12 deletions(-) create mode 100644 packages/superdough/.gitignore create mode 100644 packages/superdough/dough-export.mjs diff --git a/packages/superdough/.gitignore b/packages/superdough/.gitignore new file mode 100644 index 000000000..d21cbdf3e --- /dev/null +++ b/packages/superdough/.gitignore @@ -0,0 +1 @@ +pattern.wav diff --git a/packages/superdough/dough-export.mjs b/packages/superdough/dough-export.mjs new file mode 100644 index 000000000..5f8b9a8ee --- /dev/null +++ b/packages/superdough/dough-export.mjs @@ -0,0 +1,61 @@ +// this is a poc of how a pattern can be rendered as a wav file using node +// run via: node dough-export.mjs +import fs from 'node:fs'; +import WavEncoder from 'wav-encoder'; +import { evalScope } from '@strudel/core'; +import { miniAllStrings } from '@strudel/mini'; +import { Dough } from './dough.mjs'; + +await evalScope( + import('@strudel/core'), + import('@strudel/mini'), + import('@strudel/tonal'), + // import('@strudel/tonal'), +); + +miniAllStrings(); // allows using single quotes for mini notation / skip transpilation + +let sampleRate = 48000, + cps = 0.5; + +let pat = note('[c e g b]*3') + .add(note(7)) + .lpf(sine.rangex(200, 4000).slow(2)) + .lpq(0.3) + .s('*2') + .att(0.01) + .rel(0.2) + .clip(2) + .delay(0.5) + .jux(rev) + .sometimes(add(note(12))) + .gain(0.25) + .slow(1 / cps); + +let cycles = 4; +let seconds = cycles + 1; // 1s release tail +const haps = pat.queryArc(0, cycles); + +const dough = new Dough(sampleRate); + +console.log('spawn voices...'); +haps.forEach((hap) => { + hap.value._begin = Number(hap.whole.begin); + hap.value._duration = hap.duration / cps; + dough.scheduleSpawn(hap.value); +}); +console.log(`render ${seconds}s long buffer...`); +const buffer = new Float32Array(seconds * sampleRate); +while (dough.t <= buffer.length) { + buffer[dough.t] = dough.update(); +} +console.log('done!'); + +const patternAudio = { + sampleRate, + channelData: [buffer], +}; + +WavEncoder.encode(patternAudio).then((buffer) => { + fs.writeFileSync('pattern.wav', new Float32Array(buffer)); +}); diff --git a/packages/superdough/dough.mjs b/packages/superdough/dough.mjs index 7261c4a5a..21918f95b 100644 --- a/packages/superdough/dough.mjs +++ b/packages/superdough/dough.mjs @@ -1,12 +1,12 @@ // this is dough, the superdough without dependencies - -const ISR = 1 / sampleRate; +const SAMPLE_RATE = typeof sampleRate !== 'undefined' ? sampleRate : 48000; +const ISR = 1 / SAMPLE_RATE; // https://garten.salat.dev/audio-DSP/oscillators.html export class SineOsc { phase = 0; update(freq) { const value = Math.sin(this.phase * 2 * Math.PI); - this.phase = (this.phase + freq / sampleRate) % 1; + this.phase = (this.phase + freq / SAMPLE_RATE) % 1; return value; } } @@ -39,7 +39,7 @@ export class SawOsc { //phase = Math.random(); phase = 0; update(freq) { - const dt = freq / sampleRate; + const dt = freq / SAMPLE_RATE; let p = polyBlep(this.phase, dt); let s = 2 * this.phase - 1 - p; this.phase += dt; @@ -216,12 +216,12 @@ const MAX_DELAY_TIME = 10; export class Delay { writeIdx = 0; readIdx = 0; - buffer = new Float32Array(MAX_DELAY_TIME * sampleRate); // .fill(0) + buffer = new Float32Array(MAX_DELAY_TIME * SAMPLE_RATE); // .fill(0) write(s, delayTime) { this.writeIdx = (this.writeIdx + 1) % this.buffer.length; this.buffer[this.writeIdx] = s; // Calculate how far in the past to read - let numSamples = Math.min(Math.floor(sampleRate * delayTime), this.buffer.length - 1); + let numSamples = Math.min(Math.floor(SAMPLE_RATE * delayTime), this.buffer.length - 1); this.readIdx = this.writeIdx - numSamples; // If past the start of the buffer, wrap around if (this.readIdx < 0) this.readIdx += this.buffer.length; @@ -501,7 +501,7 @@ export class Dough { r = 0; // tbd t = 0; // sampleRate: number, currentTime: number (seconds) - constructor(sampleRate, currentTime) { + constructor(sampleRate = 48000, currentTime = 0) { this.sampleRate = sampleRate; this.t = Math.floor(currentTime * sampleRate); // samples // console.log('init dough', this.sampleRate, this.t); @@ -513,6 +513,7 @@ export class Dough { if (value._duration === undefined) { throw new Error('[dough]: scheduleSpawn expected _duration to be set'); } + value.sampleRate = this.sampleRate; const time = value._begin; // set from supradough.mjs this.schedule({ time, type: 'spawn', arg: value }); } diff --git a/packages/superdough/package.json b/packages/superdough/package.json index a835f252d..6b45144e4 100644 --- a/packages/superdough/package.json +++ b/packages/superdough/package.json @@ -33,7 +33,8 @@ "homepage": "https://github.com/tidalcycles/strudel#readme", "devDependencies": { "vite": "^6.0.11", - "vite-plugin-bundle-audioworklet": "workspace:*" + "vite-plugin-bundle-audioworklet": "workspace:*", + "wav-encoder": "^1.3.0" }, "dependencies": { "nanostores": "^0.11.3" diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 5e5494255..2edcc6f53 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -901,10 +901,7 @@ class DoughProcessor extends AudioWorkletProcessor { constructor() { super(); this.dough = new Dough(sampleRate, currentTime); - this.port.onmessage = (event) => { - event.data.sampleRate = sampleRate; - this.dough.scheduleSpawn(event.data); - }; + this.port.onmessage = (event) => this.dough.scheduleSpawn(event.data); } process(inputs, outputs, params) { if (this.disconnected) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a23c80924..ed8a7c014 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -485,6 +485,9 @@ importers: vite-plugin-bundle-audioworklet: specifier: workspace:* version: link:../vite-plugin-bundle-audioworklet + wav-encoder: + specifier: ^1.3.0 + version: 1.3.0 packages/tidal: dependencies: @@ -7464,6 +7467,9 @@ packages: walk-up-path@3.0.1: resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==} + wav-encoder@1.3.0: + resolution: {integrity: sha512-FXJdEu2qDOI+wbVYZpu21CS1vPEg5NaxNskBr4SaULpOJMrLE6xkH8dECa7PiS+ZoeyvP7GllWUAxPN3AvFSEw==} + wav@1.0.2: resolution: {integrity: sha512-viHtz3cDd/Tcr/HbNqzQCofKdF6kWUymH9LGDdskfWFoIy/HJ+RTihgjEcHfnsy1PO4e9B+y4HwgTwMrByquhg==} @@ -15839,6 +15845,8 @@ snapshots: walk-up-path@3.0.1: {} + wav-encoder@1.3.0: {} + wav@1.0.2: dependencies: buffer-alloc: 1.2.0 From 87598e10d1c1af739cfc26f9efebe6e0039f6be1 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 6 Jun 2025 08:17:20 +0200 Subject: [PATCH 09/95] coarse --- packages/superdough/dough.mjs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/superdough/dough.mjs b/packages/superdough/dough.mjs index 21918f95b..b6ded0d15 100644 --- a/packages/superdough/dough.mjs +++ b/packages/superdough/dough.mjs @@ -292,6 +292,19 @@ export class Sequence { } } +// sample rate bit crusher +export class Coarse { + hold = 0; + t = 0; + update(input, coarse) { + if (this.t++ % coarse === 0) { + this.t = 0; + this.hold = input; + } + return this.hold; + } +} + export function _rangex(sig, min, max) { let logmin = Math.log(min); let range = Math.log(max) - logmin; @@ -457,6 +470,7 @@ export class DoughVoice { this._sound = new SourceClass(); this._lpf = this.cutoff ? new Lpf() : null; this._adsr = new ADSR(); + this._coarse = this.coarse ? new Coarse() : null; this.piOverSr = Math.PI / value.sampleRate; this.eighthOverLogHalf = 0.125 / Math.log(0.5); @@ -475,7 +489,10 @@ export class DoughVoice { // lpf if (this._lpf) { const cutoff = this.freq2cutoff(this.cutoff); - s = this._lpf ? this._lpf.update(s, cutoff, this.resonance) : s; + s = this._lpf.update(s, cutoff, this.resonance); + } + if (this._coarse) { + s = this._coarse.update(s, this.coarse); } // not sure if gain is applied here s = s * this.gain; From 0cc18a64682c7f984f549e0e0240694e0721811e Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 6 Jun 2025 08:27:47 +0200 Subject: [PATCH 10/95] crush (might be wrong) --- packages/superdough/dough.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/superdough/dough.mjs b/packages/superdough/dough.mjs index b6ded0d15..bd226cc72 100644 --- a/packages/superdough/dough.mjs +++ b/packages/superdough/dough.mjs @@ -305,6 +305,15 @@ export class Coarse { } } +// amplitude bit crusher +export class Crush { + update(input, crush) { + crush = Math.max(1, crush); + const x = Math.pow(2, crush - 1); + return Math.round(input * x) / x; + } +} + export function _rangex(sig, min, max) { let logmin = Math.log(min); let range = Math.log(max) - logmin; @@ -471,6 +480,7 @@ export class DoughVoice { this._lpf = this.cutoff ? new Lpf() : null; this._adsr = new ADSR(); this._coarse = this.coarse ? new Coarse() : null; + this._crush = this.crush ? new Crush() : null; this.piOverSr = Math.PI / value.sampleRate; this.eighthOverLogHalf = 0.125 / Math.log(0.5); @@ -494,6 +504,9 @@ export class DoughVoice { if (this._coarse) { s = this._coarse.update(s, this.coarse); } + if (this._crush) { + s = this._crush.update(s, this.crush); + } // not sure if gain is applied here s = s * this.gain; // envelope From 623bf9336865de8ff68bc8d2387a67e2ee412e6e Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 6 Jun 2025 08:50:02 +0200 Subject: [PATCH 11/95] panning --- packages/superdough/dough.mjs | 25 +++++++++++++++++++------ packages/superdough/worklets.mjs | 4 ++-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/superdough/dough.mjs b/packages/superdough/dough.mjs index bd226cc72..e6190f140 100644 --- a/packages/superdough/dough.mjs +++ b/packages/superdough/dough.mjs @@ -370,6 +370,7 @@ const defaultDefaultValues = { velocity: 1, fft: 8, z: 'triangle', + pan: 0.5, }; let getDefaultValue = (key) => defaultDefaultValues[key]; @@ -402,6 +403,8 @@ const getFrequency = (value) => { }; export class DoughVoice { + l = 0; + r = 0; constructor(value) { // params without defaults: /* @@ -464,6 +467,7 @@ export class DoughVoice { this.i = this.i ?? getDefaultValue('i'); this.velocity = this.velocity ?? getDefaultValue('velocity'); this.fft = this.fft ?? getDefaultValue('fft'); + this.pan = this.pan ?? getDefaultValue('pan'); [this.attack, this.decay, this.sustain, this.release] = getADSRValues([ this.attack, @@ -517,7 +521,15 @@ export class DoughVoice { s = s * env; s = s * this.postgain * 0.2; - return s; + + if (this.pan === 0.5) { + this.l = this.r = s; // mono + } else { + // stereo + const pos = (this.pan * Math.PI) / 2; + this.l = s * Math.cos(pos); + this.r = s * Math.sin(pos); + } } } @@ -527,8 +539,7 @@ export class Dough { voices = []; // DoughVoice[] vid = 0; q = []; - l = 0; // tbd - r = 0; // tbd + channels = [0, 0]; t = 0; // sampleRate: number, currentTime: number (seconds) constructor(sampleRate = 48000, currentTime = 0) { @@ -588,11 +599,13 @@ export class Dough { this.q.shift(); } // add active voices - let sum = 0; + this.channels[0] = 0; + this.channels[1] = 0; for (let v = 0; v < this.voices.length; v++) { - sum += this.voices[v].update(this.t / this.sampleRate); + this.voices[v].update(this.t / this.sampleRate); + this.channels[0] += this.voices[v].l; + this.channels[1] += this.voices[v].r; } this.t++; - return sum; } } diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 2edcc6f53..d8f189779 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -909,10 +909,10 @@ class DoughProcessor extends AudioWorkletProcessor { } const output = outputs[0]; for (let i = 0; i < output[0].length; i++) { - const out = this.dough.update(); + this.dough.update(); for (let c = 0; c < output.length; c++) { //prevent speaker blowout via clipping if threshold exceeds - output[c][i] = clamp(out, -1, 1); + output[c][i] = clamp(this.dough.channels[c], -1, 1); } } return true; // keep the audio processing going From 9ed690ce4551a471beb7784208f2fc275f7a808e Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 6 Jun 2025 09:37:40 +0200 Subject: [PATCH 12/95] pulse osc + velocity --- packages/superdough/dough.mjs | 38 ++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/superdough/dough.mjs b/packages/superdough/dough.mjs index e6190f140..323e78d5c 100644 --- a/packages/superdough/dough.mjs +++ b/packages/superdough/dough.mjs @@ -82,7 +82,25 @@ export class Lpf { } } -export class PulseOsc { +class PulseOsc { + constructor(phase = 0) { + this.phase = phase; + } + saw(offset, dt) { + let phase = (this.phase + offset) % 1; + let p = polyBlep(phase, dt); + return 2 * phase - 1 - p; + } + update(freq, pw = 0.5) { + const dt = freq / sampleRate; + let pulse = this.saw(0, dt) - this.saw(pw, dt); + this.phase = (this.phase + dt) % 1; + return pulse + pw * 2 - 1; + } +} + +// non bandlimited (has aliasing) +export class PulzeOsc { phase = 0; update(freq, duty = 0.5) { this.phase += ISR * freq; @@ -343,14 +361,16 @@ let oscillators = { tri: TriOsc, triangle: TriOsc, pulse: PulseOsc, + pulze: PulzeOsc, dust: Dust, impulse: Impulse, }; const defaultDefaultValues = { s: 'triangle', - gain: 0.8, + gain: 1, postgain: 1, + velocity: 1, density: '.03', ftype: '12db', fanchor: 0, @@ -367,7 +387,6 @@ const defaultDefaultValues = { delaytime: 0.25, orbit: 1, i: 1, - velocity: 1, fft: 8, z: 'triangle', pan: 0.5, @@ -450,6 +469,7 @@ export class DoughVoice { // params with defaults: this.s = this.s ?? getDefaultValue('s'); this.gain = this.gain ?? getDefaultValue('gain'); + this.velocity = this.velocity ?? getDefaultValue('velocity'); this.postgain = this.postgain ?? getDefaultValue('postgain'); this.density = this.density ?? getDefaultValue('density'); this.fanchor = this.fanchor ?? getDefaultValue('fanchor'); @@ -465,7 +485,6 @@ export class DoughVoice { this.delaytime = this.delaytime ?? getDefaultValue('delaytime'); this.orbit = this.orbit ?? getDefaultValue('orbit'); this.i = this.i ?? getDefaultValue('i'); - this.velocity = this.velocity ?? getDefaultValue('velocity'); this.fft = this.fft ?? getDefaultValue('fft'); this.pan = this.pan ?? getDefaultValue('pan'); @@ -498,8 +517,13 @@ export class DoughVoice { if (!this._sound) { return 0; } + let s = 0; // sound source - let s = this._sound.update(this.freq); + if (this.s === 'pulse') { + s = this._sound.update(this.freq, this.pw ?? 0.5); + } else { + s = this._sound.update(this.freq); + } // lpf if (this._lpf) { const cutoff = this.freq2cutoff(this.cutoff); @@ -511,8 +535,8 @@ export class DoughVoice { if (this._crush) { s = this._crush.update(s, this.crush); } - // not sure if gain is applied here - s = s * this.gain; + // not sure if gain/velocity is applied here + s = s * this.gain * this.velocity; // envelope let gate = Number(t >= this._begin && t <= this._holdEnd); From 794bf86904fe89e9213e75258a06f33a6b7e87f0 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 6 Jun 2025 09:56:41 +0200 Subject: [PATCH 13/95] distort --- packages/superdough/dough.mjs | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/superdough/dough.mjs b/packages/superdough/dough.mjs index 323e78d5c..d687bb63c 100644 --- a/packages/superdough/dough.mjs +++ b/packages/superdough/dough.mjs @@ -332,6 +332,26 @@ export class Crush { } } +// (unused) overdrive-style distortion (adapted from noisecraft) +export class Overdrive { + update(input, amount = 0, postgain = 1) { + amount = Math.min(Math.max(amount, 0), 1); + amount -= 0.01; + const shape = (2 * amount) / (1 - amount); + return (((1 + shape) * input) / (1 + shape * Math.abs(input))) * postgain; + } +} + +// this is the distort from superdough +export class Distort { + update(input, distort = 0, postgain = 1) { + postgain = Math.max(0.001, Math.min(1, postgain)); + const shape = Math.expm1(distort); + return (((1 + shape) * input) / (1 + shape * Math.abs(input))) * postgain; + } +} +// distortion could be expressed as a function, because it's stateless + export function _rangex(sig, min, max) { let logmin = Math.log(min); let range = Math.log(max) - logmin; @@ -504,6 +524,7 @@ export class DoughVoice { this._adsr = new ADSR(); this._coarse = this.coarse ? new Coarse() : null; this._crush = this.crush ? new Crush() : null; + this._distort = this.distort ? new Distort() : null; this.piOverSr = Math.PI / value.sampleRate; this.eighthOverLogHalf = 0.125 / Math.log(0.5); @@ -529,12 +550,11 @@ export class DoughVoice { const cutoff = this.freq2cutoff(this.cutoff); s = this._lpf.update(s, cutoff, this.resonance); } - if (this._coarse) { - s = this._coarse.update(s, this.coarse); - } - if (this._crush) { - s = this._crush.update(s, this.crush); - } + + this._coarse && (s = this._coarse.update(s, this.coarse)); + this._crush && (s = this._crush.update(s, this.crush)); + this._distort && (s = this._distort.update(s, this.distort, this.distortvol)); + // not sure if gain/velocity is applied here s = s * this.gain * this.velocity; // envelope From 6cf0b6f6515910da199a939740296a694100e5bf Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 6 Jun 2025 10:12:49 +0200 Subject: [PATCH 14/95] hpf + bpf --- packages/superdough/dough.mjs | 58 +++++++++++++++++------------------ 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/superdough/dough.mjs b/packages/superdough/dough.mjs index d687bb63c..dce26ab3b 100644 --- a/packages/superdough/dough.mjs +++ b/packages/superdough/dough.mjs @@ -60,7 +60,7 @@ export class TriOsc { } } -export class Lpf { +export class TwoPoleFilter { s0 = 0; s1 = 0; update(s, cutoff, resonance = 0) { @@ -70,15 +70,10 @@ export class Lpf { var c = Math.pow(0.5, (1 - cutoff) / 0.125); var r = Math.pow(0.5, (resonance + 0.125) / 0.125); var mrc = 1 - r * c; - var v0 = this.s0; - var v1 = this.s1; - // Apply the filter to the sample - v0 = mrc * v0 - c * v1 + c * s; - v1 = mrc * v1 + c * v0; - s = v1; - this.s0 = v0; - this.s1 = v1; - return s; + + this.s0 = mrc * this.s0 - c * this.s1 + c * s; // bpf + this.s1 = mrc * this.s1 + c * this.s0; // lpf + return this.s1; // return lpf by default } } @@ -287,6 +282,7 @@ export class Slew { } } +// overdrive style distortion (adapted from noisecraft) currently unused export function applyDistortion(x, amount) { amount = Math.min(Math.max(amount, 0), 1); amount -= 0.01; @@ -332,16 +328,6 @@ export class Crush { } } -// (unused) overdrive-style distortion (adapted from noisecraft) -export class Overdrive { - update(input, amount = 0, postgain = 1) { - amount = Math.min(Math.max(amount, 0), 1); - amount -= 0.01; - const shape = (2 * amount) / (1 - amount); - return (((1 + shape) * input) / (1 + shape * Math.abs(input))) * postgain; - } -} - // this is the distort from superdough export class Distort { update(input, distort = 0, postgain = 1) { @@ -394,9 +380,12 @@ const defaultDefaultValues = { density: '.03', ftype: '12db', fanchor: 0, - resonance: 1, - hresonance: 1, - bandq: 1, + //resonance: 1, // superdough resonance is scaled differently + resonance: 0, + //hresonance: 1, // superdough resonance is scaled differently + hresonance: 0, + // bandq: 1, // superdough resonance is scaled differently + bandq: 0, channels: [1, 2], phaserdepth: 0.75, shapevol: 1, @@ -470,11 +459,7 @@ export class DoughVoice { phaserrate, phasersweep, phasercenter, - coarse, - crush, shape, - distort, - pan, vowel, room, roomfade, @@ -520,7 +505,9 @@ export class DoughVoice { const SourceClass = oscillators[this.s] ?? TriOsc; this._sound = new SourceClass(); - this._lpf = this.cutoff ? new Lpf() : null; + this._lpf = this.cutoff ? new TwoPoleFilter() : null; + this._hpf = this.hcutoff ? new TwoPoleFilter() : null; + this._bpf = this.bandf ? new TwoPoleFilter() : null; this._adsr = new ADSR(); this._coarse = this.coarse ? new Coarse() : null; this._crush = this.crush ? new Crush() : null; @@ -548,7 +535,20 @@ export class DoughVoice { // lpf if (this._lpf) { const cutoff = this.freq2cutoff(this.cutoff); - s = this._lpf.update(s, cutoff, this.resonance); + this._lpf.update(s, cutoff, this.resonance); + s = this._lpf.s1; + } + // hpf + if (this._hpf) { + const cutoff = this.freq2cutoff(this.hcutoff); + this._hpf.update(s, cutoff, this.hresonance); + s = s - this._hpf.s1; + } + // bpf + if (this._bpf) { + const cutoff = this.freq2cutoff(this.bandf); + this._bpf.update(s, cutoff, this.bandq); + s = this._bpf.s0; } this._coarse && (s = this._coarse.update(s, this.coarse)); From e4cd5dc552bcd3038b25b2914d475626d2277423 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 6 Jun 2025 10:55:38 +0200 Subject: [PATCH 15/95] rough filter envelope implementation --- packages/superdough/dough.mjs | 60 ++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/packages/superdough/dough.mjs b/packages/superdough/dough.mjs index dce26ab3b..09f85e153 100644 --- a/packages/superdough/dough.mjs +++ b/packages/superdough/dough.mjs @@ -505,10 +505,44 @@ export class DoughVoice { const SourceClass = oscillators[this.s] ?? TriOsc; this._sound = new SourceClass(); + + // filter setup this._lpf = this.cutoff ? new TwoPoleFilter() : null; + if (this.lpenv) { + this._lpenv = new ADSR(); + [this.lpattack, this.lpdecay, this.lpsustain, this.lprelease] = getADSRValues([ + this.lpattack, + this.lpdecay, + this.lpsustain, + this.lprelease, + ]); + } + this._hpf = this.hcutoff ? new TwoPoleFilter() : null; + if (this.hpenv) { + this._hpenv = new ADSR(); + [this.hpattack, this.hpdecay, this.hpsustain, this.hprelease] = getADSRValues([ + this.hpattack, + this.hpdecay, + this.hpsustain, + this.hprelease, + ]); + } this._bpf = this.bandf ? new TwoPoleFilter() : null; + if (this.bpenv) { + this._bpenv = new ADSR(); + [this.bpattack, this.bpdecay, this.bpsustain, this.bprelease] = getADSRValues([ + this.bpattack, + this.bpdecay, + this.bpsustain, + this.bprelease, + ]); + } + + // gain envelope this._adsr = new ADSR(); + + // fx setup this._coarse = this.coarse ? new Coarse() : null; this._crush = this.crush ? new Crush() : null; this._distort = this.distort ? new Distort() : null; @@ -532,21 +566,36 @@ export class DoughVoice { } else { s = this._sound.update(this.freq); } + let gate = Number(t >= this._begin && t <= this._holdEnd); + s = s * this.gain * this.velocity; + // lpf if (this._lpf) { - const cutoff = this.freq2cutoff(this.cutoff); + let cutoff = this.freq2cutoff(this.cutoff); + if (this._lpenv) { + const env = this._lpenv.update(t, gate, this.lpattack, this.lpdecay, this.lpsustain, this.lprelease) ** 2; + cutoff = cutoff + env * this.lpenv * cutoff; // todo proper scaling + } this._lpf.update(s, cutoff, this.resonance); s = this._lpf.s1; } // hpf if (this._hpf) { - const cutoff = this.freq2cutoff(this.hcutoff); + let cutoff = this.freq2cutoff(this.hcutoff); + if (this._hpenv) { + const env = this._hpenv.update(t, gate, this.hpattack, this.hpdecay, this.hpsustain, this.hprelease) ** 2; + cutoff = cutoff + env * this.hpenv * cutoff; // todo proper scaling + } this._hpf.update(s, cutoff, this.hresonance); s = s - this._hpf.s1; } // bpf if (this._bpf) { - const cutoff = this.freq2cutoff(this.bandf); + let cutoff = this.freq2cutoff(this.bandf); + if (this._bpenv) { + const env = this._bpenv.update(t, gate, this.bpattack, this.bpdecay, this.bpsustain, this.bprelease) ** 2; + cutoff = cutoff + env * this.bpenv * cutoff; // todo proper scaling + } this._bpf.update(s, cutoff, this.bandq); s = this._bpf.s0; } @@ -555,11 +604,6 @@ export class DoughVoice { this._crush && (s = this._crush.update(s, this.crush)); this._distort && (s = this._distort.update(s, this.distort, this.distortvol)); - // not sure if gain/velocity is applied here - s = s * this.gain * this.velocity; - // envelope - let gate = Number(t >= this._begin && t <= this._holdEnd); - /* Math.random() > 0.99 && console.log('gate', gate); */ const env = this._adsr.update(t, gate, this.attack, this.decay, this.sustain, this.release); s = s * env; From 998a0102471f63eec033f3bc52e330e1fcfc7b6e Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 6 Jun 2025 11:21:01 +0200 Subject: [PATCH 16/95] better filter envelopes --- packages/superdough/dough.mjs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/superdough/dough.mjs b/packages/superdough/dough.mjs index 09f85e153..a292b0c8b 100644 --- a/packages/superdough/dough.mjs +++ b/packages/superdough/dough.mjs @@ -571,31 +571,34 @@ export class DoughVoice { // lpf if (this._lpf) { - let cutoff = this.freq2cutoff(this.cutoff); + let cutoff = this.cutoff; if (this._lpenv) { const env = this._lpenv.update(t, gate, this.lpattack, this.lpdecay, this.lpsustain, this.lprelease) ** 2; - cutoff = cutoff + env * this.lpenv * cutoff; // todo proper scaling + cutoff = 2 ** this.lpenv * env * cutoff + cutoff; } + cutoff = this.freq2cutoff(cutoff); this._lpf.update(s, cutoff, this.resonance); s = this._lpf.s1; } // hpf if (this._hpf) { - let cutoff = this.freq2cutoff(this.hcutoff); + let cutoff = this.hcutoff; if (this._hpenv) { const env = this._hpenv.update(t, gate, this.hpattack, this.hpdecay, this.hpsustain, this.hprelease) ** 2; - cutoff = cutoff + env * this.hpenv * cutoff; // todo proper scaling + cutoff = 2 ** this.hpenv * env * cutoff + cutoff; } + cutoff = this.freq2cutoff(cutoff); this._hpf.update(s, cutoff, this.hresonance); s = s - this._hpf.s1; } // bpf if (this._bpf) { - let cutoff = this.freq2cutoff(this.bandf); + let cutoff = this.bandf; if (this._bpenv) { const env = this._bpenv.update(t, gate, this.bpattack, this.bpdecay, this.bpsustain, this.bprelease) ** 2; - cutoff = cutoff + env * this.bpenv * cutoff; // todo proper scaling + cutoff = 2 ** this.bpenv * env * cutoff + cutoff; } + cutoff = this.freq2cutoff(cutoff); this._bpf.update(s, cutoff, this.bandq); s = this._bpf.s0; } From ccc90d547cf85ec06b0433b6c02eb18c52112f1c Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 6 Jun 2025 15:47:19 +0200 Subject: [PATCH 17/95] ignore dough-export to fix checks --- eslint.config.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index 19d9bb390..0ba44da07 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -42,6 +42,7 @@ export default [ '**/hydra.mjs', '**/jsdoc-synonyms.js', 'packages/hs2js/src/hs2js.mjs', + 'packages/superdough/dough-export.mjs', '**/samples', ], }, From 43c05cb5041e18431dd297be5fb5a9b3044aaf0d Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 6 Jun 2025 16:17:05 +0200 Subject: [PATCH 18/95] move supradough to separate package --- eslint.config.mjs | 2 +- packages/superdough/package.json | 3 +- packages/superdough/superdough.mjs | 9 ++++- packages/superdough/synth.mjs | 4 +- packages/superdough/worklets.mjs | 25 ------------- .../{superdough => supradough}/.gitignore | 0 packages/supradough/README.md | 3 ++ .../dough-export.mjs | 0 packages/supradough/dough-worklet.mjs | 27 ++++++++++++++ packages/{superdough => supradough}/dough.mjs | 0 packages/supradough/index.mjs | 4 ++ packages/supradough/package.json | 37 +++++++++++++++++++ packages/webaudio/package.json | 3 +- packages/webaudio/webaudio.mjs | 6 ++- pnpm-lock.yaml | 12 ++++++ 15 files changed, 102 insertions(+), 33 deletions(-) rename packages/{superdough => supradough}/.gitignore (100%) create mode 100644 packages/supradough/README.md rename packages/{superdough => supradough}/dough-export.mjs (100%) create mode 100644 packages/supradough/dough-worklet.mjs rename packages/{superdough => supradough}/dough.mjs (100%) create mode 100644 packages/supradough/index.mjs create mode 100644 packages/supradough/package.json diff --git a/eslint.config.mjs b/eslint.config.mjs index 0ba44da07..f2cf86307 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -42,7 +42,7 @@ export default [ '**/hydra.mjs', '**/jsdoc-synonyms.js', 'packages/hs2js/src/hs2js.mjs', - 'packages/superdough/dough-export.mjs', + 'packages/supradough/dough-export.mjs', '**/samples', ], }, diff --git a/packages/superdough/package.json b/packages/superdough/package.json index 6b45144e4..a835f252d 100644 --- a/packages/superdough/package.json +++ b/packages/superdough/package.json @@ -33,8 +33,7 @@ "homepage": "https://github.com/tidalcycles/strudel#readme", "devDependencies": { "vite": "^6.0.11", - "vite-plugin-bundle-audioworklet": "workspace:*", - "wav-encoder": "^1.3.0" + "vite-plugin-bundle-audioworklet": "workspace:*" }, "dependencies": { "nanostores": "^0.11.3" diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 1069d4e84..a6e54435c 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -190,11 +190,18 @@ export function getAudioContextCurrentTime() { return getAudioContext().currentTime; } +let externalWorklets = []; +export function registerWorklet(url) { + externalWorklets.push(url); +} + let workletsLoading; function loadWorklets() { if (!workletsLoading) { const audioCtx = getAudioContext(); - workletsLoading = audioCtx.audioWorklet.addModule(workletsUrl); + const allWorkletURLs = externalWorklets.concat([workletsUrl]); + console.log('allWorkletURLs', allWorkletURLs); + workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL))); } return workletsLoading; diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index 7d175d6c4..834e54c6d 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -12,7 +12,7 @@ import { } from './helpers.mjs'; import { getNoiseMix, getNoiseOscillator } from './noise.mjs'; -export const getFrequencyFromValue = (value) => { +const getFrequencyFromValue = (value) => { let { note, freq } = value; note = note || 36; if (typeof note === 'string') { @@ -25,7 +25,7 @@ export const getFrequencyFromValue = (value) => { return Number(freq); }; -export function destroyAudioWorkletNode(node) { +function destroyAudioWorkletNode(node) { if (node == null) { return; } diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index d8f189779..24f7434a9 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -4,7 +4,6 @@ import OLAProcessor from './ola-processor'; import FFT from './fft.js'; -import { Dough } from './dough.mjs'; const clamp = (num, min, max) => Math.min(Math.max(num, min), max); const _mod = (n, m) => ((n % m) + m) % m; @@ -896,27 +895,3 @@ class ByteBeatProcessor extends AudioWorkletProcessor { } registerProcessor('byte-beat-processor', ByteBeatProcessor); - -class DoughProcessor extends AudioWorkletProcessor { - constructor() { - super(); - this.dough = new Dough(sampleRate, currentTime); - this.port.onmessage = (event) => this.dough.scheduleSpawn(event.data); - } - process(inputs, outputs, params) { - if (this.disconnected) { - return false; - } - const output = outputs[0]; - for (let i = 0; i < output[0].length; i++) { - this.dough.update(); - for (let c = 0; c < output.length; c++) { - //prevent speaker blowout via clipping if threshold exceeds - output[c][i] = clamp(this.dough.channels[c], -1, 1); - } - } - return true; // keep the audio processing going - } -} - -registerProcessor('dough-processor', DoughProcessor); diff --git a/packages/superdough/.gitignore b/packages/supradough/.gitignore similarity index 100% rename from packages/superdough/.gitignore rename to packages/supradough/.gitignore diff --git a/packages/supradough/README.md b/packages/supradough/README.md new file mode 100644 index 000000000..a8cfa84b3 --- /dev/null +++ b/packages/supradough/README.md @@ -0,0 +1,3 @@ +# supradough + +platform agnostic synth and sampler intended for live coding. a reimplementation of superdough. \ No newline at end of file diff --git a/packages/superdough/dough-export.mjs b/packages/supradough/dough-export.mjs similarity index 100% rename from packages/superdough/dough-export.mjs rename to packages/supradough/dough-export.mjs diff --git a/packages/supradough/dough-worklet.mjs b/packages/supradough/dough-worklet.mjs new file mode 100644 index 000000000..0361afa0d --- /dev/null +++ b/packages/supradough/dough-worklet.mjs @@ -0,0 +1,27 @@ +import { Dough } from './dough.mjs'; + +const clamp = (num, min, max) => Math.min(Math.max(num, min), max); + +class DoughProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.dough = new Dough(sampleRate, currentTime); + this.port.onmessage = (event) => this.dough.scheduleSpawn(event.data); + } + process(inputs, outputs, params) { + if (this.disconnected) { + return false; + } + const output = outputs[0]; + for (let i = 0; i < output[0].length; i++) { + this.dough.update(); + for (let c = 0; c < output.length; c++) { + //prevent speaker blowout via clipping if threshold exceeds + output[c][i] = clamp(this.dough.channels[c], -1, 1); + } + } + return true; // keep the audio processing going + } +} + +registerProcessor('dough-processor', DoughProcessor); diff --git a/packages/superdough/dough.mjs b/packages/supradough/dough.mjs similarity index 100% rename from packages/superdough/dough.mjs rename to packages/supradough/dough.mjs diff --git a/packages/supradough/index.mjs b/packages/supradough/index.mjs new file mode 100644 index 000000000..1bce17f6d --- /dev/null +++ b/packages/supradough/index.mjs @@ -0,0 +1,4 @@ +import _workletUrl from './dough-worklet.mjs?audioworklet'; + +export * from './dough.mjs'; +export const workletUrl = _workletUrl; diff --git a/packages/supradough/package.json b/packages/supradough/package.json new file mode 100644 index 000000000..7e465c0a9 --- /dev/null +++ b/packages/supradough/package.json @@ -0,0 +1,37 @@ +{ + "name": "supradough", + "version": "1.2.3", + "description": "platform agnostic synth and sampler intended for live coding. a reimplementation of superdough.", + "main": "index.mjs", + "type": "module", + "publishConfig": { + "main": "dist/index.mjs" + }, + "scripts": { + "build": "vite build", + "prepublishOnly": "npm run build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/tidalcycles/strudel.git" + }, + "keywords": [ + "tidalcycles", + "strudel", + "pattern", + "livecoding", + "algorave" + ], + "author": "Felix Roos ", + "license": "AGPL-3.0-or-later", + "bugs": { + "url": "https://github.com/tidalcycles/strudel/issues" + }, + "homepage": "https://github.com/tidalcycles/strudel#readme", + "devDependencies": { + "vite": "^6.0.11", + "vite-plugin-bundle-audioworklet": "workspace:*", + "wav-encoder": "^1.3.0" + }, + "dependencies": {} +} diff --git a/packages/webaudio/package.json b/packages/webaudio/package.json index 5714fddf3..2617c2872 100644 --- a/packages/webaudio/package.json +++ b/packages/webaudio/package.json @@ -35,7 +35,8 @@ "dependencies": { "@strudel/core": "workspace:*", "@strudel/draw": "workspace:*", - "superdough": "workspace:*" + "superdough": "workspace:*", + "supradough": "workspace:*" }, "devDependencies": { "vite": "^6.0.11" diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index 8f7d041a9..de53bb5cd 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -5,8 +5,12 @@ This program is free software: you can redistribute it and/or modify it under th */ import * as strudel from '@strudel/core'; -import { superdough, getAudioContext, setLogger, doughTrigger } from 'superdough'; +import { superdough, getAudioContext, setLogger, doughTrigger, registerWorklet } from 'superdough'; import './supradough.mjs'; +import { workletUrl } from 'supradough'; + +registerWorklet(workletUrl); + const { Pattern, logger, repl } = strudel; setLogger(logger); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed8a7c014..805412ede 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -485,6 +485,15 @@ importers: vite-plugin-bundle-audioworklet: specifier: workspace:* version: link:../vite-plugin-bundle-audioworklet + + packages/supradough: + devDependencies: + vite: + specifier: ^6.0.11 + version: 6.0.11(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0) + vite-plugin-bundle-audioworklet: + specifier: workspace:* + version: link:../vite-plugin-bundle-audioworklet wav-encoder: specifier: ^1.3.0 version: 1.3.0 @@ -597,6 +606,9 @@ importers: superdough: specifier: workspace:* version: link:../superdough + supradough: + specifier: workspace:* + version: link:../supradough devDependencies: vite: specifier: ^6.0.11 From 49f38aeddbf7ce2815805b7c9640df0ba848a0bb Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 6 Jun 2025 16:26:17 +0200 Subject: [PATCH 19/95] remove log --- packages/superdough/superdough.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index a6e54435c..2629790df 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -200,7 +200,6 @@ function loadWorklets() { if (!workletsLoading) { const audioCtx = getAudioContext(); const allWorkletURLs = externalWorklets.concat([workletsUrl]); - console.log('allWorkletURLs', allWorkletURLs); workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL))); } From 1dac69635db5bf39324d2febdfc9aae45dc84706 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sat, 7 Jun 2025 10:40:34 +0200 Subject: [PATCH 20/95] feedback delay (still hard coded feedback / time values) --- packages/supradough/dough.mjs | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index a292b0c8b..ba00d672f 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -229,7 +229,7 @@ const MAX_DELAY_TIME = 10; export class Delay { writeIdx = 0; readIdx = 0; - buffer = new Float32Array(MAX_DELAY_TIME * SAMPLE_RATE); // .fill(0) + buffer = new Float32Array(MAX_DELAY_TIME * SAMPLE_RATE); //.fill(0) write(s, delayTime) { this.writeIdx = (this.writeIdx + 1) % this.buffer.length; this.buffer[this.writeIdx] = s; @@ -479,19 +479,13 @@ export class DoughVoice { this.density = this.density ?? getDefaultValue('density'); this.fanchor = this.fanchor ?? getDefaultValue('fanchor'); this.drive = this.drive ?? 0.69; - this.resonance = this.resonance ?? getDefaultValue('resonance'); - this.hresonance = this.hresonance ?? getDefaultValue('hresonance'); - this.bandq = this.bandq ?? getDefaultValue('bandq'); this.phaserdepth = this.phaserdepth ?? getDefaultValue('phaserdepth'); this.shapevol = this.shapevol ?? getDefaultValue('shapevol'); this.distortvol = this.distortvol ?? getDefaultValue('distortvol'); - this.delay = this.delay ?? getDefaultValue('delay'); - this.delayfeedback = this.delayfeedback ?? getDefaultValue('delayfeedback'); - this.delaytime = this.delaytime ?? getDefaultValue('delaytime'); - this.orbit = this.orbit ?? getDefaultValue('orbit'); this.i = this.i ?? getDefaultValue('i'); this.fft = this.fft ?? getDefaultValue('fft'); this.pan = this.pan ?? getDefaultValue('pan'); + this.orbit = this.orbit ?? getDefaultValue('orbit'); [this.attack, this.decay, this.sustain, this.release] = getADSRValues([ this.attack, @@ -508,6 +502,7 @@ export class DoughVoice { // filter setup this._lpf = this.cutoff ? new TwoPoleFilter() : null; + this.resonance = this.resonance ?? getDefaultValue('resonance'); if (this.lpenv) { this._lpenv = new ADSR(); [this.lpattack, this.lpdecay, this.lpsustain, this.lprelease] = getADSRValues([ @@ -519,6 +514,7 @@ export class DoughVoice { } this._hpf = this.hcutoff ? new TwoPoleFilter() : null; + this.hresonance = this.hresonance ?? getDefaultValue('hresonance'); if (this.hpenv) { this._hpenv = new ADSR(); [this.hpattack, this.hpdecay, this.hpsustain, this.hprelease] = getADSRValues([ @@ -529,6 +525,7 @@ export class DoughVoice { ]); } this._bpf = this.bandf ? new TwoPoleFilter() : null; + this.bandq = this.bandq ?? getDefaultValue('bandq'); if (this.bpenv) { this._bpenv = new ADSR(); [this.bpattack, this.bpdecay, this.bpsustain, this.bprelease] = getADSRValues([ @@ -547,6 +544,12 @@ export class DoughVoice { this._crush = this.crush ? new Crush() : null; this._distort = this.distort ? new Distort() : null; + // delay + this.delay = this.delay ?? getDefaultValue('delay'); + this.delayfeedback = this.delayfeedback ?? getDefaultValue('delayfeedback'); + this.delaytime = this.delaytime ?? getDefaultValue('delaytime'); + + // precalculated values this.piOverSr = Math.PI / value.sampleRate; this.eighthOverLogHalf = 0.125 / Math.log(0.5); } @@ -631,12 +634,17 @@ export class Dough { vid = 0; q = []; channels = [0, 0]; + delaysend = [0, 0]; + delaytime = getDefaultValue('delaytime'); + delayfeedback = getDefaultValue('delayfeedback'); t = 0; // sampleRate: number, currentTime: number (seconds) constructor(sampleRate = 48000, currentTime = 0) { this.sampleRate = sampleRate; this.t = Math.floor(currentTime * sampleRate); // samples // console.log('init dough', this.sampleRate, this.t); + this._delayL = new Delay(); + this._delayR = new Delay(); } scheduleSpawn(value) { if (value._begin === undefined) { @@ -696,7 +704,18 @@ export class Dough { this.voices[v].update(this.t / this.sampleRate); this.channels[0] += this.voices[v].l; this.channels[1] += this.voices[v].r; + if (this.voices[v].delay) { + this.delaysend[0] += this.voices[v].l * this.voices[v].delay; + this.delaysend[1] += this.voices[v].r * this.voices[v].delay; + } } + // todo: how to change delaytime / delayfeedback from a voice? + const delayL = this._delayL.update(this.delaysend[0], this.delaytime); + const delayR = this._delayR.update(this.delaysend[1], this.delaytime); + this.delaysend[0] = delayL * this.delayfeedback; + this.delaysend[1] = delayR * this.delayfeedback; + this.channels[0] += delayL; + this.channels[1] += delayR; this.t++; } } From d743570546d3659cd949441180e4a79df2fd023f Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sat, 7 Jun 2025 13:12:35 +0200 Subject: [PATCH 21/95] set delay time and feedback from voice --- packages/supradough/dough.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index ba00d672f..14bab22fe 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -707,6 +707,8 @@ export class Dough { if (this.voices[v].delay) { this.delaysend[0] += this.voices[v].l * this.voices[v].delay; this.delaysend[1] += this.voices[v].r * this.voices[v].delay; + this.delaytime = this.voices[v].delaytime; // we trust that these are initialized in the voice + this.delayfeedback = this.voices[v].delayfeedback; } } // todo: how to change delaytime / delayfeedback from a voice? From e54449f5f73cf0d41ff14f460bdd4ca9ccfa59eb Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sat, 7 Jun 2025 13:24:06 +0200 Subject: [PATCH 22/95] white brown pink noise + crackle alias for dust --- packages/supradough/dough.mjs | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index 14bab22fe..2050054ec 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -108,6 +108,51 @@ export class Dust { update = (density) => (Math.random() < density * ISR ? Math.random() : 0); } +export class WhiteNoise { + update() { + return Math.random() * 2 - 1; + } +} + +export class BrownNoise { + constructor() { + this.out = 0; + } + update() { + let white = Math.random() * 2 - 1; + this.out = (this.out + 0.02 * white) / 1.02; + return this.out; + } +} + +export class PinkNoise { + constructor() { + this.b0 = 0; + this.b1 = 0; + this.b2 = 0; + this.b3 = 0; + this.b4 = 0; + this.b5 = 0; + this.b6 = 0; + } + + update() { + const white = Math.random() * 2 - 1; + + this.b0 = 0.99886 * this.b0 + white * 0.0555179; + this.b1 = 0.99332 * this.b1 + white * 0.0750759; + this.b2 = 0.969 * this.b2 + white * 0.153852; + this.b3 = 0.8665 * this.b3 + white * 0.3104856; + this.b4 = 0.55 * this.b4 + white * 0.5329522; + this.b5 = -0.7616 * this.b5 - white * 0.016898; + + const pink = this.b0 + this.b1 + this.b2 + this.b3 + this.b4 + this.b5 + this.b6 + white * 0.5362; + this.b6 = white * 0.115926; + + return pink * 0.11; + } +} + export class Impulse { phase = 1; update(freq) { @@ -369,7 +414,11 @@ let oscillators = { pulse: PulseOsc, pulze: PulzeOsc, dust: Dust, + crackle: Dust, impulse: Impulse, + white: WhiteNoise, + brown: BrownNoise, + pink: PinkNoise, }; const defaultDefaultValues = { From 77463a199aaf68d2d32b4653b2c49443958be9f7 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sat, 7 Jun 2025 13:26:43 +0200 Subject: [PATCH 23/95] add square alias for pulse --- packages/supradough/dough.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index 2050054ec..0c0e667d9 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -412,6 +412,7 @@ let oscillators = { tri: TriOsc, triangle: TriOsc, pulse: PulseOsc, + square: PulseOsc, pulze: PulzeOsc, dust: Dust, crackle: Dust, From b000f2297d23ddc4b46b8fa5ad4848d504652ea8 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sat, 7 Jun 2025 20:31:47 +0200 Subject: [PATCH 24/95] fm --- packages/supradough/dough.mjs | 36 ++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index 0c0e667d9..08469e634 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -449,6 +449,7 @@ const defaultDefaultValues = { fft: 8, z: 'triangle', pan: 0.5, + fmh: 1, }; let getDefaultValue = (key) => defaultDefaultValues[key]; @@ -518,6 +519,8 @@ export class DoughVoice { roomsize, ir, analyze, + fmh, + fmi */ value.freq = getFrequency(value); Object.assign(this, value); @@ -550,6 +553,20 @@ export class DoughVoice { const SourceClass = oscillators[this.s] ?? TriOsc; this._sound = new SourceClass(); + if (this.fmi) { + this._fm = new SineOsc(); + this.fmh = this.fmh ?? getDefaultValue('fmh'); + if (this.fmenv) { + this._fmenv = new ADSR(); + [this.fmattack, this.fmdecay, this.fmsustain, this.fmrelease] = getADSRValues([ + this.fmattack, + this.fmdecay, + this.fmsustain, + this.fmrelease, + ]); + } + } + // filter setup this._lpf = this.cutoff ? new TwoPoleFilter() : null; this.resonance = this.resonance ?? getDefaultValue('resonance'); @@ -613,13 +630,26 @@ export class DoughVoice { return 0; } let s = 0; + let gate = Number(t >= this._begin && t <= this._holdEnd); + + let freq = this.freq; + if (this._fm) { + let fmi = this.fmi; + if (this._fmenv) { + const env = this._fmenv.update(t, gate, this.fmattack, this.fmdecay, this.fmsustain, this.fmrelease) ** 2; + fmi = /* 2 ** */ this.fmenv * env * fmi; // todo: find good scaling + } + const modfreq = freq * this.fmh; + const modgain = modfreq * fmi; + freq = freq + this._fm.update(modfreq) * modgain; + } + // sound source if (this.s === 'pulse') { - s = this._sound.update(this.freq, this.pw ?? 0.5); + s = this._sound.update(freq, this.pw ?? 0.5); } else { - s = this._sound.update(this.freq); + s = this._sound.update(freq); } - let gate = Number(t >= this._begin && t <= this._holdEnd); s = s * this.gain * this.velocity; // lpf From 2f2bf5bf29541ea1c4edee5e70302b109f54ce0b Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sat, 7 Jun 2025 22:36:54 +0200 Subject: [PATCH 25/95] fix: note2midi for notes without octave --- packages/supradough/dough.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index 08469e634..2853f3971 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -457,7 +457,7 @@ let getDefaultValue = (key) => defaultDefaultValues[key]; const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; const accs = { '#': 1, b: -1, s: 1, f: -1 }; const note2midi = (note, defaultOctave = 3) => { - const [pc, acc = '', oct = defaultOctave] = + let [pc, acc = '', oct = ''] = String(note) .match(/^([a-gA-G])([#bsf]*)([0-9]*)$/) ?.slice(1) || []; @@ -466,13 +466,14 @@ const note2midi = (note, defaultOctave = 3) => { } const chroma = chromas[pc.toLowerCase()]; const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0; - return (Number(oct) + 1) * 12 + chroma + offset; + oct = Number(oct || defaultOctave); + return (oct + 1) * 12 + chroma + offset; }; const getFrequency = (value) => { let { note, freq } = value; note = note || 36; if (typeof note === 'string') { - note = note2midi(note); // e.g. c3 => 48 + note = note2midi(note, 3); // e.g. c3 => 48 } if (!freq && typeof note === 'number') { freq = Math.pow(2, (note - 69) / 12) * 440; From 5988e3d3b48541444440f6c165ea1b37ed22ff02 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sat, 7 Jun 2025 22:42:48 +0200 Subject: [PATCH 26/95] fix: hot reloading for dough.mjs (see comment) --- packages/supradough/index.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/supradough/index.mjs b/packages/supradough/index.mjs index 1bce17f6d..54a835495 100644 --- a/packages/supradough/index.mjs +++ b/packages/supradough/index.mjs @@ -1,4 +1,4 @@ -import _workletUrl from './dough-worklet.mjs?audioworklet'; +import _workletUrl from './dough-worklet.mjs?url'; // todo: change ?url to ?audioworklet before build (?audioworklet doesn't hot reload) export * from './dough.mjs'; export const workletUrl = _workletUrl; From ab052ce1ac6fe4232edd65e877dc2cb041df0d48 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sat, 7 Jun 2025 23:15:09 +0200 Subject: [PATCH 27/95] fix: polyblepped sawtooth + fm = death --- packages/supradough/dough.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index 2853f3971..826172a2f 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -450,6 +450,7 @@ const defaultDefaultValues = { z: 'triangle', pan: 0.5, fmh: 1, + fmenv: 0, // differs from superdough }; let getDefaultValue = (key) => defaultDefaultValues[key]; @@ -540,6 +541,7 @@ export class DoughVoice { this.fft = this.fft ?? getDefaultValue('fft'); this.pan = this.pan ?? getDefaultValue('pan'); this.orbit = this.orbit ?? getDefaultValue('orbit'); + this.fmenv = this.fmenv ?? getDefaultValue('fmenv'); [this.attack, this.decay, this.sustain, this.release] = getADSRValues([ this.attack, @@ -551,6 +553,9 @@ export class DoughVoice { this._holdEnd = this._begin + this._duration; // needed for gate this._end = this._holdEnd + this.release + 0.01; // needed for despawn + if (this.s === 'saw' || this.s === 'sawtooth') { + this.s = 'zaw'; // polyblepped saw when fm is applied + } const SourceClass = oscillators[this.s] ?? TriOsc; this._sound = new SourceClass(); @@ -638,7 +643,7 @@ export class DoughVoice { let fmi = this.fmi; if (this._fmenv) { const env = this._fmenv.update(t, gate, this.fmattack, this.fmdecay, this.fmsustain, this.fmrelease) ** 2; - fmi = /* 2 ** */ this.fmenv * env * fmi; // todo: find good scaling + fmi = this.fmenv * env * fmi; } const modfreq = freq * this.fmh; const modgain = modfreq * fmi; From ac7ee376e5a0ed121526096baa3839a2e117aebe Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 8 Jun 2025 00:43:58 +0200 Subject: [PATCH 28/95] pitch envelope --- packages/supradough/dough.mjs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index 826172a2f..809b8d52b 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -559,6 +559,16 @@ export class DoughVoice { const SourceClass = oscillators[this.s] ?? TriOsc; this._sound = new SourceClass(); + if (this.penv) { + this._penv = new ADSR(); + [this.pattack, this.pdecay, this.psustain, this.prelease] = getADSRValues([ + this.pattack, + this.pdecay, + this.psustain, + this.prelease, + ]); + } + if (this.fmi) { this._fm = new SineOsc(); this.fmh = this.fmh ?? getDefaultValue('fmh'); @@ -650,6 +660,11 @@ export class DoughVoice { freq = freq + this._fm.update(modfreq) * modgain; } + if (this._penv) { + const env = this._penv.update(t, gate, this.pattack, this.pdecay, this.psustain, this.prelease) ** 2; + freq = freq + env * this.penv; + } + // sound source if (this.s === 'pulse') { s = this._sound.update(freq, this.pw ?? 0.5); From 0f2aa9569b9cabc3c1144e01c5586e4099baab0d Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 8 Jun 2025 02:04:49 +0200 Subject: [PATCH 29/95] half working samples --- packages/supradough/dough-worklet.mjs | 14 ++- packages/supradough/dough.mjs | 41 ++++++-- packages/webaudio/index.mjs | 1 + packages/webaudio/supradough.mjs | 138 +++++++++++++++++++++++++- 4 files changed, 182 insertions(+), 12 deletions(-) diff --git a/packages/supradough/dough-worklet.mjs b/packages/supradough/dough-worklet.mjs index 0361afa0d..97a35c487 100644 --- a/packages/supradough/dough-worklet.mjs +++ b/packages/supradough/dough-worklet.mjs @@ -6,7 +6,19 @@ class DoughProcessor extends AudioWorkletProcessor { constructor() { super(); this.dough = new Dough(sampleRate, currentTime); - this.port.onmessage = (event) => this.dough.scheduleSpawn(event.data); + this.port.onmessage = (event) => { + if (event.data.spawn) { + this.dough.scheduleSpawn(event.data.spawn); + } else if (event.data.sample) { + this.dough.loadSample(event.data.sample, event.data.channels); + } else if (event.data.samples) { + event.data.samples.forEach(([name, channels]) => { + this.dough.loadSample(name, channels); + }); + } else { + console.log('unrecognized event type', event.data); + } + }; } process(inputs, outputs, params) { if (this.disconnected) { diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index 809b8d52b..e42b4f8e8 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -383,6 +383,19 @@ export class Distort { } // distortion could be expressed as a function, because it's stateless +export class BufferPlayer { + channels = []; + pos = 0; + update(freq, channel = 0) { + if (this.pos >= this.channels[channel].length) { + return 0; + } + let s = this.channels[channel][this.pos]; + this.pos++; + return s; + } +} + export function _rangex(sig, min, max) { let logmin = Math.log(min); let range = Math.log(max) - logmin; @@ -403,7 +416,7 @@ export const getADSRValues = (params, curve = 'linear', defaultValues) => { return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)]; }; -let oscillators = { +let shapes = { sine: SineOsc, saw: SawOsc, zaw: ZawOsc, @@ -553,11 +566,20 @@ export class DoughVoice { this._holdEnd = this._begin + this._duration; // needed for gate this._end = this._holdEnd + this.release + 0.01; // needed for despawn + this.s ??= 'triangle'; if (this.s === 'saw' || this.s === 'sawtooth') { this.s = 'zaw'; // polyblepped saw when fm is applied } - const SourceClass = oscillators[this.s] ?? TriOsc; - this._sound = new SourceClass(); + if (shapes[this.s]) { + const SourceClass = shapes[this.s]; + this._sound = new SourceClass(); + } else if (value.samples.has(this.s)) { + this._sample = new BufferPlayer(); + this._sample.channels = value.samples.get(this.s); + this._sample.pos = 0; + } else { + console.warn('sound not found', this.s); + } if (this.penv) { this._penv = new ADSR(); @@ -642,7 +664,7 @@ export class DoughVoice { return 1 - Math.log(c) * this.eighthOverLogHalf; } update(t) { - if (!this._sound) { + if (!this._sound && !this._sample) { return 0; } let s = 0; @@ -666,10 +688,12 @@ export class DoughVoice { } // sound source - if (this.s === 'pulse') { + if (this._sound && this.s === 'pulse') { s = this._sound.update(freq, this.pw ?? 0.5); - } else { + } else if (this._sound) { s = this._sound.update(freq); + } else if (this._sample) { + s = this._sample.update(freq, 0); // tbd: stereo samples... } s = s * this.gain * this.velocity; @@ -738,6 +762,7 @@ export class Dough { delaysend = [0, 0]; delaytime = getDefaultValue('delaytime'); delayfeedback = getDefaultValue('delayfeedback'); + samples = new Map(); t = 0; // sampleRate: number, currentTime: number (seconds) constructor(sampleRate = 48000, currentTime = 0) { @@ -747,6 +772,9 @@ export class Dough { this._delayL = new Delay(); this._delayR = new Delay(); } + loadSample(name, channels) { + this.samples.set(name, channels); + } scheduleSpawn(value) { if (value._begin === undefined) { throw new Error('[dough]: scheduleSpawn expected _begin to be set'); @@ -760,6 +788,7 @@ export class Dough { } spawn(value) { value.id = this.vid++; + value.samples = this.samples; const voice = new DoughVoice(value); this.voices.push(voice); // console.log('spawn', voice.id, 'voices:', this.voices.length); diff --git a/packages/webaudio/index.mjs b/packages/webaudio/index.mjs index 59672b617..f89e12696 100644 --- a/packages/webaudio/index.mjs +++ b/packages/webaudio/index.mjs @@ -7,4 +7,5 @@ This program is free software: you can redistribute it and/or modify it under th export * from './webaudio.mjs'; export * from './scope.mjs'; export * from './spectrum.mjs'; +export * from './supradough.mjs'; export * from 'superdough'; diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs index 8df18bed6..38b2330c3 100644 --- a/packages/webaudio/supradough.mjs +++ b/packages/webaudio/supradough.mjs @@ -20,10 +20,138 @@ Pattern.prototype.supradough = function () { return this.onTrigger((_, hap, __, cps, begin) => { hap.value._begin = begin; hap.value._duration = hap.duration / cps; - - if (!doughWorklet) { - initDoughWorklet(); - } - doughWorklet.port.postMessage(hap.value); + !doughWorklet && initDoughWorklet(); + doughWorklet.port.postMessage({ spawn: hap.value }); }, 1); }; + +async function loadSampleChannels(url) { + const buffer = await fetch(url) + .then((res) => res.arrayBuffer()) + .then((buf) => getAudioContext().decodeAudioData(buf)); + // console.log('buffer', buffer, buffer.numberOfChannels); + let channels = []; + for (let i = 0; i < buffer.numberOfChannels; i++) { + channels.push(buffer.getChannelData(i)); + } + return channels; +} + +let samples = { + casio: [ + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/casio/high.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/casio/low.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/casio/noise.wav', + ], + crow: [ + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/crow/000_crow.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/crow/001_crow2.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/crow/002_crow3.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/crow/003_crow4.wav', + ], + insect: [ + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/insect/000_everglades_conehead.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/insect/001_robust_shieldback.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/insect/002_seashore_meadow_katydid.wav', + ], + wind: [ + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/000_wind1.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/001_wind10.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/002_wind2.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/003_wind3.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/004_wind4.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/005_wind5.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/006_wind6.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/007_wind7.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/008_wind8.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/009_wind9.wav', + ], + jazz: [ + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/jazz/000_BD.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/jazz/001_CB.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/jazz/002_FX.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/jazz/003_HH.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/jazz/004_OH.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/jazz/005_P1.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/jazz/006_P2.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/jazz/007_SN.wav', + ], + metal: [ + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/000_0.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/001_1.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/002_2.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/003_3.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/004_4.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/005_5.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/006_6.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/007_7.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/008_8.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/009_9.wav', + ], + east: [ + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/000_nipon_wood_block.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/001_ohkawa_mute.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/002_ohkawa_open.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/003_shime_hi.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/004_shime_hi_2.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/005_shime_mute.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/006_taiko_1.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/007_taiko_2.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/008_taiko_3.wav', + ], + space: [ + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/000_0.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/001_1.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/002_11.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/003_12.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/004_13.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/005_14.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/006_15.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/007_16.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/008_17.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/009_18.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/010_2.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/011_3.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/012_4.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/013_5.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/014_6.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/015_7.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/016_8.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/017_9.wav', + ], + numbers: [ + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/0.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/1.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/2.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/3.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/4.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/5.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/6.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/7.wav', + 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/8.wav', + ], + piano: ['https://raw.githubusercontent.com/felixroos/dough-samples/refs/heads/main/piano/A3v8.mp3'], + flute: ['https://raw.githubusercontent.com/felixroos/samples/refs/heads/main/flute/c4.mp3'], + bd: [ + 'https://raw.githubusercontent.com/geikha/tidal-drum-machines/15eac73c5e878550f91d864a4863e014799403f1/machines/RolandTR909/rolandtr909-bd/Bassdrum-01.wav', + ], +}; +// for some reason, only piano and flute work.. is it because mp3?? + +let loaded = false; +export async function doughsample() { + !doughWorklet && initDoughWorklet(); + if (loaded) { + return; + } + loaded = true; + const sampleMap = await Promise.all( + Object.entries(samples).map(async ([key, url]) => { + url = url[0]; + console.log(key, 'url', url); + return [key, await loadSampleChannels(url)]; + }), + ); + console.log('sampleMap', sampleMap); + doughWorklet.port.postMessage({ samples: sampleMap }); +} From 356d4360ecbbe09a21b697de7c563a4387b572cd Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 8 Jun 2025 09:05:14 +0200 Subject: [PATCH 30/95] fix: scheduling + sample playback --- packages/supradough/dough.mjs | 14 ++++++-------- packages/webaudio/supradough.mjs | 28 ++++++++++++++-------------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index e42b4f8e8..dc083bac7 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -384,6 +384,7 @@ export class Distort { // distortion could be expressed as a function, because it's stateless export class BufferPlayer { + static samples = new Map(); channels = []; pos = 0; update(freq, channel = 0) { @@ -573,10 +574,9 @@ export class DoughVoice { if (shapes[this.s]) { const SourceClass = shapes[this.s]; this._sound = new SourceClass(); - } else if (value.samples.has(this.s)) { + } else if (BufferPlayer.samples.has(this.s)) { this._sample = new BufferPlayer(); - this._sample.channels = value.samples.get(this.s); - this._sample.pos = 0; + this._sample.channels = BufferPlayer.samples.get(this.s); } else { console.warn('sound not found', this.s); } @@ -735,7 +735,6 @@ export class DoughVoice { this._crush && (s = this._crush.update(s, this.crush)); this._distort && (s = this._distort.update(s, this.distort, this.distortvol)); - /* Math.random() > 0.99 && console.log('gate', gate); */ const env = this._adsr.update(t, gate, this.attack, this.decay, this.sustain, this.release); s = s * env; @@ -762,7 +761,6 @@ export class Dough { delaysend = [0, 0]; delaytime = getDefaultValue('delaytime'); delayfeedback = getDefaultValue('delayfeedback'); - samples = new Map(); t = 0; // sampleRate: number, currentTime: number (seconds) constructor(sampleRate = 48000, currentTime = 0) { @@ -773,7 +771,7 @@ export class Dough { this._delayR = new Delay(); } loadSample(name, channels) { - this.samples.set(name, channels); + BufferPlayer.samples.set(name, channels); } scheduleSpawn(value) { if (value._begin === undefined) { @@ -783,12 +781,12 @@ export class Dough { throw new Error('[dough]: scheduleSpawn expected _duration to be set'); } value.sampleRate = this.sampleRate; - const time = value._begin; // set from supradough.mjs + // convert seconds to samples + const time = Math.floor(value._begin * this.sampleRate); // set from supradough.mjs this.schedule({ time, type: 'spawn', arg: value }); } spawn(value) { value.id = this.vid++; - value.samples = this.samples; const voice = new DoughVoice(value); this.voices.push(voice); // console.log('spawn', voice.id, 'voices:', this.voices.length); diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs index 38b2330c3..317f3aa31 100644 --- a/packages/webaudio/supradough.mjs +++ b/packages/webaudio/supradough.mjs @@ -25,18 +25,6 @@ Pattern.prototype.supradough = function () { }, 1); }; -async function loadSampleChannels(url) { - const buffer = await fetch(url) - .then((res) => res.arrayBuffer()) - .then((buf) => getAudioContext().decodeAudioData(buf)); - // console.log('buffer', buffer, buffer.numberOfChannels); - let channels = []; - for (let i = 0; i < buffer.numberOfChannels; i++) { - channels.push(buffer.getChannelData(i)); - } - return channels; -} - let samples = { casio: [ 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/casio/high.wav', @@ -138,6 +126,17 @@ let samples = { }; // for some reason, only piano and flute work.. is it because mp3?? +async function loadSampleChannels(url) { + const buffer = await fetch(url) + .then((res) => res.arrayBuffer()) + .then((buf) => getAudioContext().decodeAudioData(buf)); + let channels = []; + for (let i = 0; i < buffer.numberOfChannels; i++) { + channels.push(buffer.getChannelData(i)); + } + return channels; +} + let loaded = false; export async function doughsample() { !doughWorklet && initDoughWorklet(); @@ -148,8 +147,9 @@ export async function doughsample() { const sampleMap = await Promise.all( Object.entries(samples).map(async ([key, url]) => { url = url[0]; - console.log(key, 'url', url); - return [key, await loadSampleChannels(url)]; + const channels = await loadSampleChannels(url); + // console.log(key, 'url', url, channels); + return [key, channels]; }), ); console.log('sampleMap', sampleMap); From 55aef1885117a94c4a153368f3b29f762a698db8 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 8 Jun 2025 14:43:23 +0200 Subject: [PATCH 31/95] buffers can now be repitched (still with aliasing) --- packages/supradough/dough-worklet.mjs | 6 +++--- packages/supradough/dough.mjs | 22 ++++++++++++++-------- packages/webaudio/supradough.mjs | 10 ++++------ 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/supradough/dough-worklet.mjs b/packages/supradough/dough-worklet.mjs index 97a35c487..dd2315628 100644 --- a/packages/supradough/dough-worklet.mjs +++ b/packages/supradough/dough-worklet.mjs @@ -10,10 +10,10 @@ class DoughProcessor extends AudioWorkletProcessor { if (event.data.spawn) { this.dough.scheduleSpawn(event.data.spawn); } else if (event.data.sample) { - this.dough.loadSample(event.data.sample, event.data.channels); + this.dough.loadSample(event.data.sample, event.data.channels, event.data.sampleRate); } else if (event.data.samples) { - event.data.samples.forEach(([name, channels]) => { - this.dough.loadSample(name, channels); + event.data.samples.forEach(([name, channels, sampleRate]) => { + this.dough.loadSample(name, channels, sampleRate); }); } else { console.log('unrecognized event type', event.data); diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index dc083bac7..b378ee70e 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -385,14 +385,16 @@ export class Distort { export class BufferPlayer { static samples = new Map(); - channels = []; + buffer; // { channels: Float32Array, sampleRate: number } pos = 0; + sampleFreq = 261.626; // middle c update(freq, channel = 0) { - if (this.pos >= this.channels[channel].length) { + if (this.pos >= this.buffer.channels[channel].length) { return 0; } - let s = this.channels[channel][this.pos]; - this.pos++; + const speed = ((freq / this.sampleFreq) * this.buffer.sampleRate) / SAMPLE_RATE; + let s = this.buffer.channels[channel][Math.floor(this.pos)]; + this.pos = this.pos + speed; return s; } } @@ -576,7 +578,8 @@ export class DoughVoice { this._sound = new SourceClass(); } else if (BufferPlayer.samples.has(this.s)) { this._sample = new BufferPlayer(); - this._sample.channels = BufferPlayer.samples.get(this.s); + const buffer = BufferPlayer.samples.get(this.s); + this._sample.buffer = buffer; } else { console.warn('sound not found', this.s); } @@ -738,7 +741,10 @@ export class DoughVoice { const env = this._adsr.update(t, gate, this.attack, this.decay, this.sustain, this.release); s = s * env; - s = s * this.postgain * 0.2; + s = s * this.postgain; + if (!this._sample) { + s = s * 0.2; // turn down waveforms + } if (this.pan === 0.5) { this.l = this.r = s; // mono @@ -770,8 +776,8 @@ export class Dough { this._delayL = new Delay(); this._delayR = new Delay(); } - loadSample(name, channels) { - BufferPlayer.samples.set(name, channels); + loadSample(name, channels, sampleRate) { + BufferPlayer.samples.set(name, { channels, sampleRate }); } scheduleSpawn(value) { if (value._begin === undefined) { diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs index 317f3aa31..269dcda95 100644 --- a/packages/webaudio/supradough.mjs +++ b/packages/webaudio/supradough.mjs @@ -118,7 +118,7 @@ let samples = { 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/7.wav', 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/8.wav', ], - piano: ['https://raw.githubusercontent.com/felixroos/dough-samples/refs/heads/main/piano/A3v8.mp3'], + piano: ['https://raw.githubusercontent.com/felixroos/dough-samples/refs/heads/main/piano/C3v8.mp3'], flute: ['https://raw.githubusercontent.com/felixroos/samples/refs/heads/main/flute/c4.mp3'], bd: [ 'https://raw.githubusercontent.com/geikha/tidal-drum-machines/15eac73c5e878550f91d864a4863e014799403f1/machines/RolandTR909/rolandtr909-bd/Bassdrum-01.wav', @@ -126,7 +126,7 @@ let samples = { }; // for some reason, only piano and flute work.. is it because mp3?? -async function loadSampleChannels(url) { +async function loadSampleChannels(key, url) { const buffer = await fetch(url) .then((res) => res.arrayBuffer()) .then((buf) => getAudioContext().decodeAudioData(buf)); @@ -134,7 +134,7 @@ async function loadSampleChannels(url) { for (let i = 0; i < buffer.numberOfChannels; i++) { channels.push(buffer.getChannelData(i)); } - return channels; + return [key, channels, buffer.sampleRate]; } let loaded = false; @@ -147,9 +147,7 @@ export async function doughsample() { const sampleMap = await Promise.all( Object.entries(samples).map(async ([key, url]) => { url = url[0]; - const channels = await loadSampleChannels(url); - // console.log(key, 'url', url, channels); - return [key, channels]; + return loadSampleChannels(key, url); }), ); console.log('sampleMap', sampleMap); From ec9109dd892386c65e3a374da48ed8eb0df53b59 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Sun, 8 Jun 2025 19:24:55 +0200 Subject: [PATCH 32/95] exponential lerp --- packages/supradough/dough.mjs | 38 ++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index 08469e634..7ee353277 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -192,16 +192,30 @@ export class Hold { } } -function lerp(x, y0, y1) { +function lerp(x, y0, y1, exponent = 1) { + if (x <= 0) return y0; if (x >= 1) return y1; - return y0 + x * (y1 - y0); + let curvedX; + + if (exponent === 0) { + curvedX = x; // linear + } else if (exponent > 0) { + curvedX = Math.pow(x, exponent); // ease-in + } else { + curvedX = 1 - Math.pow(1 - x, -exponent); // ease-out + } + + return y0 + (y1 - y0) * curvedX; } export class ADSR { - state = 'off'; - startTime = 0; - startVal = 0; + constructor(props = {}) { + this.state = 'off' + this.startTime = 0; + this.startVal = 0; + this.curve = props.curve ?? 1; + } update(curTime, gate, attack, decay, susVal, release) { switch (this.state) { @@ -220,11 +234,11 @@ export class ADSR { this.startTime = curTime; return 1; } - return lerp(time / attack, this.startVal, 1); + return lerp(time / attack, this.startVal, 1, this.curve); } case 'decay': { let time = curTime - this.startTime; - let curVal = lerp(time / decay, 1, susVal); + let curVal = lerp(time / decay, 1, susVal, -this.curve); if (gate <= 0) { this.state = 'release'; this.startTime = curTime; @@ -253,7 +267,7 @@ export class ADSR { this.state = 'off'; return 0; } - let curVal = lerp(time / release, this.startVal, 0); + let curVal = lerp(time / release, this.startVal, 0, -this.curve); if (gate > 0) { this.state = 'attack'; this.startTime = curTime; @@ -564,6 +578,7 @@ export class DoughVoice { this.fmsustain, this.fmrelease, ]); + } } @@ -571,7 +586,9 @@ export class DoughVoice { this._lpf = this.cutoff ? new TwoPoleFilter() : null; this.resonance = this.resonance ?? getDefaultValue('resonance'); if (this.lpenv) { - this._lpenv = new ADSR(); + + this._lpenv = new ADSR({curve: 4}); + [this.lpattack, this.lpdecay, this.lpsustain, this.lprelease] = getADSRValues([ this.lpattack, this.lpdecay, @@ -584,6 +601,7 @@ export class DoughVoice { this.hresonance = this.hresonance ?? getDefaultValue('hresonance'); if (this.hpenv) { this._hpenv = new ADSR(); + [this.hpattack, this.hpdecay, this.hpsustain, this.hprelease] = getADSRValues([ this.hpattack, this.hpdecay, @@ -604,7 +622,7 @@ export class DoughVoice { } // gain envelope - this._adsr = new ADSR(); + this._adsr = new ADSR({curve: 2}); // fx setup this._coarse = this.coarse ? new Coarse() : null; From 181e51ef0e1421031187231c8b131aca9e3570cc Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Sun, 8 Jun 2025 20:26:20 +0200 Subject: [PATCH 33/95] adjustable envelope curves --- packages/supradough/dough.mjs | 38 +++++++++++++++++------------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index e16d11f8a..1b1be3e1d 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -214,7 +214,7 @@ export class ADSR { this.state = 'off' this.startTime = 0; this.startVal = 0; - this.curve = props.curve ?? 1; + this.decayCurve = props.decayCurve ?? 1; } update(curTime, gate, attack, decay, susVal, release) { @@ -234,11 +234,11 @@ export class ADSR { this.startTime = curTime; return 1; } - return lerp(time / attack, this.startVal, 1, this.curve); + return lerp(time / attack, this.startVal, 1, 1); } case 'decay': { let time = curTime - this.startTime; - let curVal = lerp(time / decay, 1, susVal, -this.curve); + let curVal = lerp(time / decay, 1, susVal, -this.decayCurve); if (gate <= 0) { this.state = 'release'; this.startTime = curTime; @@ -267,7 +267,7 @@ export class ADSR { this.state = 'off'; return 0; } - let curVal = lerp(time / release, this.startVal, 0, -this.curve); + let curVal = lerp(time / release, this.startVal, 0, -this.decayCurve); if (gate > 0) { this.state = 'attack'; this.startTime = curTime; @@ -599,7 +599,7 @@ export class DoughVoice { } if (this.penv) { - this._penv = new ADSR(); + this._penv = new ADSR({ decayCurve: 4 }); [this.pattack, this.pdecay, this.psustain, this.prelease] = getADSRValues([ this.pattack, this.pdecay, @@ -612,14 +612,14 @@ export class DoughVoice { this._fm = new SineOsc(); this.fmh = this.fmh ?? getDefaultValue('fmh'); if (this.fmenv) { - this._fmenv = new ADSR(); + this._fmenv = new ADSR({ decayCurve: 2 }); [this.fmattack, this.fmdecay, this.fmsustain, this.fmrelease] = getADSRValues([ this.fmattack, this.fmdecay, this.fmsustain, this.fmrelease, ]); - + } } @@ -628,8 +628,8 @@ export class DoughVoice { this.resonance = this.resonance ?? getDefaultValue('resonance'); if (this.lpenv) { - this._lpenv = new ADSR({curve: 4}); - + this._lpenv = new ADSR({ decayCurve: 4 }); + [this.lpattack, this.lpdecay, this.lpsustain, this.lprelease] = getADSRValues([ this.lpattack, this.lpdecay, @@ -641,8 +641,8 @@ export class DoughVoice { this._hpf = this.hcutoff ? new TwoPoleFilter() : null; this.hresonance = this.hresonance ?? getDefaultValue('hresonance'); if (this.hpenv) { - this._hpenv = new ADSR(); - + this._hpenv = new ADSR({ decayCurve: 4 }); + [this.hpattack, this.hpdecay, this.hpsustain, this.hprelease] = getADSRValues([ this.hpattack, this.hpdecay, @@ -653,7 +653,7 @@ export class DoughVoice { this._bpf = this.bandf ? new TwoPoleFilter() : null; this.bandq = this.bandq ?? getDefaultValue('bandq'); if (this.bpenv) { - this._bpenv = new ADSR(); + this._bpenv = new ADSR({ decayCurve: 4 }); [this.bpattack, this.bpdecay, this.bpsustain, this.bprelease] = getADSRValues([ this.bpattack, this.bpdecay, @@ -663,7 +663,7 @@ export class DoughVoice { } // gain envelope - this._adsr = new ADSR({curve: 2}); + this._adsr = new ADSR({ decayCurve: 2 }); // fx setup this._coarse = this.coarse ? new Coarse() : null; @@ -695,7 +695,7 @@ export class DoughVoice { if (this._fm) { let fmi = this.fmi; if (this._fmenv) { - const env = this._fmenv.update(t, gate, this.fmattack, this.fmdecay, this.fmsustain, this.fmrelease) ** 2; + const env = this._fmenv.update(t, gate, this.fmattack, this.fmdecay, this.fmsustain, this.fmrelease); fmi = this.fmenv * env * fmi; } const modfreq = freq * this.fmh; @@ -704,7 +704,7 @@ export class DoughVoice { } if (this._penv) { - const env = this._penv.update(t, gate, this.pattack, this.pdecay, this.psustain, this.prelease) ** 2; + const env = this._penv.update(t, gate, this.pattack, this.pdecay, this.psustain, this.prelease); freq = freq + env * this.penv; } @@ -722,8 +722,8 @@ export class DoughVoice { if (this._lpf) { let cutoff = this.cutoff; if (this._lpenv) { - const env = this._lpenv.update(t, gate, this.lpattack, this.lpdecay, this.lpsustain, this.lprelease) ** 2; - cutoff = 2 ** this.lpenv * env * cutoff + cutoff; + const env = this._lpenv.update(t, gate, this.lpattack, this.lpdecay, this.lpsustain, this.lprelease); + cutoff = this.lpenv * env * cutoff + cutoff; } cutoff = this.freq2cutoff(cutoff); this._lpf.update(s, cutoff, this.resonance); @@ -733,7 +733,7 @@ export class DoughVoice { if (this._hpf) { let cutoff = this.hcutoff; if (this._hpenv) { - const env = this._hpenv.update(t, gate, this.hpattack, this.hpdecay, this.hpsustain, this.hprelease) ** 2; + const env = this._hpenv.update(t, gate, this.hpattack, this.hpdecay, this.hpsustain, this.hprelease); cutoff = 2 ** this.hpenv * env * cutoff + cutoff; } cutoff = this.freq2cutoff(cutoff); @@ -744,7 +744,7 @@ export class DoughVoice { if (this._bpf) { let cutoff = this.bandf; if (this._bpenv) { - const env = this._bpenv.update(t, gate, this.bpattack, this.bpdecay, this.bpsustain, this.bprelease) ** 2; + const env = this._bpenv.update(t, gate, this.bpattack, this.bpdecay, this.bpsustain, this.bprelease); cutoff = 2 ** this.bpenv * env * cutoff + cutoff; } cutoff = this.freq2cutoff(cutoff); From 73ef43dd2c85db8493c7a0d41fda2ec0cd824f58 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 8 Jun 2025 22:56:55 +0200 Subject: [PATCH 34/95] move things around in init + shorten things --- packages/supradough/dough.mjs | 226 ++++++++++++---------------------- 1 file changed, 79 insertions(+), 147 deletions(-) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index 1b1be3e1d..f0665b779 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -211,7 +211,7 @@ function lerp(x, y0, y1, exponent = 1) { export class ADSR { constructor(props = {}) { - this.state = 'off' + this.state = 'off'; this.startTime = 0; this.startVal = 0; this.decayCurve = props.decayCurve ?? 1; @@ -398,7 +398,7 @@ export class Distort { // distortion could be expressed as a function, because it's stateless export class BufferPlayer { - static samples = new Map(); + static samples = new Map(); // string -> { channels, sampleRate } buffer; // { channels: Float32Array, sampleRate: number } pos = 0; sampleFreq = 261.626; // middle c @@ -421,7 +421,7 @@ export function _rangex(sig, min, max) { } // duplicate -export const getADSRValues = (params, curve = 'linear', defaultValues) => { +export const getADSR = (params, curve = 'linear', defaultValues) => { const envmin = curve === 'exponential' ? 0.001 : 0.001; const releaseMin = 0.01; const envmax = 1; @@ -517,167 +517,99 @@ export class DoughVoice { l = 0; r = 0; constructor(value) { - // params without defaults: - /* - bank, - source, - cutoff, - lpenv, - lpattack, - lpdecay, - lpsustain, - lprelease, - hpenv, - hcutoff, - hpattack, - hpdecay, - hpsustain, - hprelease, - bpenv, - bandf, - bpattack, - bpdecay, - bpsustain, - bprelease, - phaserrate, - phasersweep, - phasercenter, - shape, - vowel, - room, - roomfade, - roomlp, - roomdim, - roomsize, - ir, - analyze, - fmh, - fmi - */ value.freq = getFrequency(value); - Object.assign(this, value); - // params with defaults: - this.s = this.s ?? getDefaultValue('s'); - this.gain = this.gain ?? getDefaultValue('gain'); - this.velocity = this.velocity ?? getDefaultValue('velocity'); - this.postgain = this.postgain ?? getDefaultValue('postgain'); - this.density = this.density ?? getDefaultValue('density'); - this.fanchor = this.fanchor ?? getDefaultValue('fanchor'); - this.drive = this.drive ?? 0.69; - this.phaserdepth = this.phaserdepth ?? getDefaultValue('phaserdepth'); - this.shapevol = this.shapevol ?? getDefaultValue('shapevol'); - this.distortvol = this.distortvol ?? getDefaultValue('distortvol'); - this.i = this.i ?? getDefaultValue('i'); - this.fft = this.fft ?? getDefaultValue('fft'); - this.pan = this.pan ?? getDefaultValue('pan'); - this.orbit = this.orbit ?? getDefaultValue('orbit'); - this.fmenv = this.fmenv ?? getDefaultValue('fmenv'); + let $ = this; + Object.assign($, value); + $.s = $.s ?? getDefaultValue('s'); + $.gain = $.gain ?? getDefaultValue('gain'); + $.velocity = $.velocity ?? getDefaultValue('velocity'); + $.postgain = $.postgain ?? getDefaultValue('postgain'); + $.density = $.density ?? getDefaultValue('density'); + $.fanchor = $.fanchor ?? getDefaultValue('fanchor'); + $.drive = $.drive ?? 0.69; + $.phaserdepth = $.phaserdepth ?? getDefaultValue('phaserdepth'); + $.shapevol = $.shapevol ?? getDefaultValue('shapevol'); + $.distortvol = $.distortvol ?? getDefaultValue('distortvol'); + $.i = $.i ?? getDefaultValue('i'); + $.fft = $.fft ?? getDefaultValue('fft'); + $.pan = $.pan ?? getDefaultValue('pan'); + $.orbit = $.orbit ?? getDefaultValue('orbit'); + $.fmenv = $.fmenv ?? getDefaultValue('fmenv'); + $.resonance = $.resonance ?? getDefaultValue('resonance'); + $.hresonance = $.hresonance ?? getDefaultValue('hresonance'); + $.bandq = $.bandq ?? getDefaultValue('bandq'); - [this.attack, this.decay, this.sustain, this.release] = getADSRValues([ - this.attack, - this.decay, - this.sustain, - this.release, - ]); + [$.attack, $.decay, $.sustain, $.release] = getADSR([$.attack, $.decay, $.sustain, $.release]); - this._holdEnd = this._begin + this._duration; // needed for gate - this._end = this._holdEnd + this.release + 0.01; // needed for despawn + $._holdEnd = $._begin + $._duration; // needed for gate + $._end = $._holdEnd + $.release + 0.01; // needed for despawn - this.s ??= 'triangle'; - if (this.s === 'saw' || this.s === 'sawtooth') { - this.s = 'zaw'; // polyblepped saw when fm is applied + $.s ??= 'triangle'; + if ($.s === 'saw' || $.s === 'sawtooth') { + $.s = 'zaw'; // polyblepped saw when fm is applied } - if (shapes[this.s]) { - const SourceClass = shapes[this.s]; - this._sound = new SourceClass(); - } else if (BufferPlayer.samples.has(this.s)) { - this._sample = new BufferPlayer(); - const buffer = BufferPlayer.samples.get(this.s); - this._sample.buffer = buffer; + + if (shapes[$.s]) { + const SourceClass = shapes[$.s]; + $._sound = new SourceClass(); + $._channels = 1; + } else if (BufferPlayer.samples.has($.s)) { + $._sample = new BufferPlayer(); + const buffer = BufferPlayer.samples.get($.s); + $._sample.buffer = buffer; // {channels,sampleRate} + $._channels = $._sample.buffer.channels.length; } else { - console.warn('sound not found', this.s); + console.warn('sound not found', $.s); } - if (this.penv) { - this._penv = new ADSR({ decayCurve: 4 }); - [this.pattack, this.pdecay, this.psustain, this.prelease] = getADSRValues([ - this.pattack, - this.pdecay, - this.psustain, - this.prelease, - ]); + if ($.penv) { + $._penv = new ADSR({ decayCurve: 4 }); + [$.pattack, $.pdecay, $.psustain, $.prelease] = getADSR([$.pattack, $.pdecay, $.psustain, $.prelease]); } - if (this.fmi) { - this._fm = new SineOsc(); - this.fmh = this.fmh ?? getDefaultValue('fmh'); - if (this.fmenv) { - this._fmenv = new ADSR({ decayCurve: 2 }); - [this.fmattack, this.fmdecay, this.fmsustain, this.fmrelease] = getADSRValues([ - this.fmattack, - this.fmdecay, - this.fmsustain, - this.fmrelease, - ]); - + if ($.fmi) { + $._fm = new SineOsc(); + $.fmh = $.fmh ?? getDefaultValue('fmh'); + if ($.fmenv) { + $._fmenv = new ADSR({ decayCurve: 2 }); + [$.fmattack, $.fmdecay, $.fmsustain, $.fmrelease] = getADSR([$.fmattack, $.fmdecay, $.fmsustain, $.fmrelease]); } } - // filter setup - this._lpf = this.cutoff ? new TwoPoleFilter() : null; - this.resonance = this.resonance ?? getDefaultValue('resonance'); - if (this.lpenv) { - - this._lpenv = new ADSR({ decayCurve: 4 }); - - [this.lpattack, this.lpdecay, this.lpsustain, this.lprelease] = getADSRValues([ - this.lpattack, - this.lpdecay, - this.lpsustain, - this.lprelease, - ]); - } - - this._hpf = this.hcutoff ? new TwoPoleFilter() : null; - this.hresonance = this.hresonance ?? getDefaultValue('hresonance'); - if (this.hpenv) { - this._hpenv = new ADSR({ decayCurve: 4 }); - - [this.hpattack, this.hpdecay, this.hpsustain, this.hprelease] = getADSRValues([ - this.hpattack, - this.hpdecay, - this.hpsustain, - this.hprelease, - ]); - } - this._bpf = this.bandf ? new TwoPoleFilter() : null; - this.bandq = this.bandq ?? getDefaultValue('bandq'); - if (this.bpenv) { - this._bpenv = new ADSR({ decayCurve: 4 }); - [this.bpattack, this.bpdecay, this.bpsustain, this.bprelease] = getADSRValues([ - this.bpattack, - this.bpdecay, - this.bpsustain, - this.bprelease, - ]); - } - // gain envelope - this._adsr = new ADSR({ decayCurve: 2 }); - - // fx setup - this._coarse = this.coarse ? new Coarse() : null; - this._crush = this.crush ? new Crush() : null; - this._distort = this.distort ? new Distort() : null; - + $._adsr = new ADSR({ decayCurve: 2 }); // delay - this.delay = this.delay ?? getDefaultValue('delay'); - this.delayfeedback = this.delayfeedback ?? getDefaultValue('delayfeedback'); - this.delaytime = this.delaytime ?? getDefaultValue('delaytime'); + $.delay = $.delay ?? getDefaultValue('delay'); + $.delayfeedback = $.delayfeedback ?? getDefaultValue('delayfeedback'); + $.delaytime = $.delaytime ?? getDefaultValue('delaytime'); // precalculated values - this.piOverSr = Math.PI / value.sampleRate; - this.eighthOverLogHalf = 0.125 / Math.log(0.5); + $.piOverSr = Math.PI / value.sampleRate; + $.eighthOverLogHalf = 0.125 / Math.log(0.5); + + // filter setup + if ($.lpenv) { + $._lpenv = new ADSR({ decayCurve: 4 }); + [$.lpattack, $.lpdecay, $.lpsustain, $.lprelease] = getADSR([$.lpattack, $.lpdecay, $.lpsustain, $.lprelease]); + } + if ($.hpenv) { + $._hpenv = new ADSR({ decayCurve: 4 }); + [$.hpattack, $.hpdecay, $.hpsustain, $.hprelease] = getADSR([$.hpattack, $.hpdecay, $.hpsustain, $.hprelease]); + } + if ($.bpenv) { + $._bpenv = new ADSR({ decayCurve: 4 }); + [$.bpattack, $.bpdecay, $.bpsustain, $.bprelease] = getADSR([$.bpattack, $.bpdecay, $.bpsustain, $.bprelease]); + } + + // 1 per channel:: + $._lpf = $.cutoff ? new TwoPoleFilter() : null; + $._hpf = $.hcutoff ? new TwoPoleFilter() : null; + $._bpf = $.bandf ? new TwoPoleFilter() : null; + + // fx setup + $._coarse = $.coarse ? new Coarse() : null; + $._crush = $.crush ? new Crush() : null; + $._distort = $.distort ? new Distort() : null; } // credits to pulu: https://github.com/felixroos/kabelsalat/issues/35 freq2cutoff(freq) { From 1081fc69e9130bed6c28a516d8a17d6003be7e13 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Mon, 9 Jun 2025 09:52:29 +0200 Subject: [PATCH 35/95] refactor: stereo sample support + channelwise fx --- packages/supradough/dough-worklet.mjs | 2 +- packages/supradough/dough.mjs | 176 +++++++++++++++----------- 2 files changed, 101 insertions(+), 77 deletions(-) diff --git a/packages/supradough/dough-worklet.mjs b/packages/supradough/dough-worklet.mjs index dd2315628..18c316b2d 100644 --- a/packages/supradough/dough-worklet.mjs +++ b/packages/supradough/dough-worklet.mjs @@ -29,7 +29,7 @@ class DoughProcessor extends AudioWorkletProcessor { this.dough.update(); for (let c = 0; c < output.length; c++) { //prevent speaker blowout via clipping if threshold exceeds - output[c][i] = clamp(this.dough.channels[c], -1, 1); + output[c][i] = clamp(this.dough.out[c], -1, 1); } } return true; // keep the audio processing going diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index f0665b779..963bd9bdc 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -399,15 +399,20 @@ export class Distort { export class BufferPlayer { static samples = new Map(); // string -> { channels, sampleRate } - buffer; // { channels: Float32Array, sampleRate: number } + buffer; // Float32Array + sampleRate; pos = 0; sampleFreq = 261.626; // middle c - update(freq, channel = 0) { - if (this.pos >= this.buffer.channels[channel].length) { + constructor(buffer, sampleRate) { + this.buffer = buffer; + this.sampleRate = sampleRate; + } + update(freq) { + if (this.pos >= this.buffer.length) { return 0; } - const speed = ((freq / this.sampleFreq) * this.buffer.sampleRate) / SAMPLE_RATE; - let s = this.buffer.channels[channel][Math.floor(this.pos)]; + const speed = ((freq / this.sampleFreq) * this.sampleRate) / SAMPLE_RATE; + let s = this.buffer[Math.floor(this.pos)]; this.pos = this.pos + speed; return s; } @@ -514,8 +519,7 @@ const getFrequency = (value) => { }; export class DoughVoice { - l = 0; - r = 0; + out = [0, 0]; constructor(value) { value.freq = getFrequency(value); let $ = this; @@ -554,10 +558,12 @@ export class DoughVoice { $._sound = new SourceClass(); $._channels = 1; } else if (BufferPlayer.samples.has($.s)) { - $._sample = new BufferPlayer(); - const buffer = BufferPlayer.samples.get($.s); - $._sample.buffer = buffer; // {channels,sampleRate} - $._channels = $._sample.buffer.channels.length; + const sample = BufferPlayer.samples.get($.s); + $._buffers = []; + $._channels = sample.channels.length; + for (let i = 0; i < $._channels; i++) { + $._buffers.push(new BufferPlayer(sample.channels[i], sample.sampleRate)); + } } else { console.warn('sound not found', $.s); } @@ -601,15 +607,21 @@ export class DoughVoice { [$.bpattack, $.bpdecay, $.bpsustain, $.bprelease] = getADSR([$.bpattack, $.bpdecay, $.bpsustain, $.bprelease]); } - // 1 per channel:: - $._lpf = $.cutoff ? new TwoPoleFilter() : null; - $._hpf = $.hcutoff ? new TwoPoleFilter() : null; - $._bpf = $.bandf ? new TwoPoleFilter() : null; - - // fx setup - $._coarse = $.coarse ? new Coarse() : null; - $._crush = $.crush ? new Crush() : null; - $._distort = $.distort ? new Distort() : null; + // channelwise effects setup + $._lpf = $.cutoff ? [] : null; + $._hpf = $.hcutoff ? [] : null; + $._bpf = $.bandf ? [] : null; + $._coarse = $.coarse ? [] : null; + $._crush = $.crush ? [] : null; + $._distort = $.distort ? [] : null; + for (let i = 0; i < this._channels; i++) { + $._lpf?.push(new TwoPoleFilter()); + $._hpf?.push(new TwoPoleFilter()); + $._bpf?.push(new TwoPoleFilter()); + $._coarse?.push(new Coarse()); + $._crush?.push(new Crush()); + $._distort?.push(new Distort()); + } } // credits to pulu: https://github.com/felixroos/kabelsalat/issues/35 freq2cutoff(freq) { @@ -617,13 +629,13 @@ export class DoughVoice { return 1 - Math.log(c) * this.eighthOverLogHalf; } update(t) { - if (!this._sound && !this._sample) { + if (!this._sound && !this._buffers) { return 0; } - let s = 0; let gate = Number(t >= this._begin && t <= this._holdEnd); let freq = this.freq; + // frequency modulation if (this._fm) { let fmi = this.fmi; if (this._fmenv) { @@ -635,74 +647,86 @@ export class DoughVoice { freq = freq + this._fm.update(modfreq) * modgain; } + // pitch envelope if (this._penv) { const env = this._penv.update(t, gate, this.pattack, this.pdecay, this.psustain, this.prelease); freq = freq + env * this.penv; } - // sound source - if (this._sound && this.s === 'pulse') { - s = this._sound.update(freq, this.pw ?? 0.5); - } else if (this._sound) { - s = this._sound.update(freq); - } else if (this._sample) { - s = this._sample.update(freq, 0); // tbd: stereo samples... - } - s = s * this.gain * this.velocity; - - // lpf + // filters + let lpf = this.cutoff; if (this._lpf) { - let cutoff = this.cutoff; if (this._lpenv) { const env = this._lpenv.update(t, gate, this.lpattack, this.lpdecay, this.lpsustain, this.lprelease); - cutoff = this.lpenv * env * cutoff + cutoff; + lpf = this.lpenv * env * lpf + lpf; } - cutoff = this.freq2cutoff(cutoff); - this._lpf.update(s, cutoff, this.resonance); - s = this._lpf.s1; + lpf = this.freq2cutoff(lpf); } - // hpf + let hpf = this.hcutoff; if (this._hpf) { - let cutoff = this.hcutoff; if (this._hpenv) { const env = this._hpenv.update(t, gate, this.hpattack, this.hpdecay, this.hpsustain, this.hprelease); - cutoff = 2 ** this.hpenv * env * cutoff + cutoff; + hpf = 2 ** this.hpenv * env * hpf + hpf; } - cutoff = this.freq2cutoff(cutoff); - this._hpf.update(s, cutoff, this.hresonance); - s = s - this._hpf.s1; + hpf = this.freq2cutoff(hpf); } - // bpf + let bpf = this.bandf; if (this._bpf) { - let cutoff = this.bandf; if (this._bpenv) { const env = this._bpenv.update(t, gate, this.bpattack, this.bpdecay, this.bpsustain, this.bprelease); - cutoff = 2 ** this.bpenv * env * cutoff + cutoff; + bpf = 2 ** this.bpenv * env * bpf + bpf; } - cutoff = this.freq2cutoff(cutoff); - this._bpf.update(s, cutoff, this.bandq); - s = this._bpf.s0; + bpf = this.freq2cutoff(bpf); } - - this._coarse && (s = this._coarse.update(s, this.coarse)); - this._crush && (s = this._crush.update(s, this.crush)); - this._distort && (s = this._distort.update(s, this.distort, this.distortvol)); - + // gain envelope const env = this._adsr.update(t, gate, this.attack, this.decay, this.sustain, this.release); - s = s * env; - s = s * this.postgain; - if (!this._sample) { - s = s * 0.2; // turn down waveforms + // channelwise dsp + for (let i = 0; i < this._channels; i++) { + // sound source + if (this._sound && this.s === 'pulse') { + this.out[i] = this._sound.update(freq, this.pw ?? 0.5); + } else if (this._sound) { + this.out[i] = this._sound.update(freq); + } else if (this._buffers) { + this.out[i] = this._buffers[i].update(freq); + } + this.out[i] = this.out[i] * this.gain * this.velocity; + + if (this._lpf) { + this._lpf[i].update(this.out[i], lpf, this.resonance); + this.out[i] = this._lpf[i].s1; + } + if (this._hpf) { + this._hpf[i].update(this.out[i], hcutoff, this.hresonance); + this.out[i] = this.out[i] - this._hpf[i].s1; + } + if (this._bpf) { + this._bpf[i].update(this.out[i], bpf, this.bandq); + this.out[i] = this._bpf[i].s0; + } + if (this._coarse) { + this.out[i] = this._coarse[i].update(this.out[i], this.coarse); + } + if (this._crush) { + this.out[i] = this._crush[i].update(this.out[i], this.crush); + } + if (this._distort) { + this.out[i] = this._distort[i].update(this.out[i], this.distort, this.distortvol); + } + this.out[i] = this.out[i] * env; + this.out[i] = this.out[i] * this.postgain; + if (!this._buffers) { + this.out[i] = this.out[i] * 0.2; // turn down waveform + } } - - if (this.pan === 0.5) { - this.l = this.r = s; // mono - } else { - // stereo - const pos = (this.pan * Math.PI) / 2; - this.l = s * Math.cos(pos); - this.r = s * Math.sin(pos); + if (this._channels === 1) { + this.out[1] = this.out[0]; + } + if (this.pan !== 0.5) { + const panpos = (this.pan * Math.PI) / 2; + this.out[0] = this.out[0] * Math.cos(panpos); + this.out[1] = this.out[1] * Math.sin(panpos); } } } @@ -713,7 +737,7 @@ export class Dough { voices = []; // DoughVoice[] vid = 0; q = []; - channels = [0, 0]; + out = [0, 0]; delaysend = [0, 0]; delaytime = getDefaultValue('delaytime'); delayfeedback = getDefaultValue('delayfeedback'); @@ -782,15 +806,15 @@ export class Dough { this.q.shift(); } // add active voices - this.channels[0] = 0; - this.channels[1] = 0; + this.out[0] = 0; + this.out[1] = 0; for (let v = 0; v < this.voices.length; v++) { this.voices[v].update(this.t / this.sampleRate); - this.channels[0] += this.voices[v].l; - this.channels[1] += this.voices[v].r; + this.out[0] += this.voices[v].out[0]; + this.out[1] += this.voices[v].out[1]; if (this.voices[v].delay) { - this.delaysend[0] += this.voices[v].l * this.voices[v].delay; - this.delaysend[1] += this.voices[v].r * this.voices[v].delay; + this.delaysend[0] += this.voices[v].out[0] * this.voices[v].delay; + this.delaysend[1] += this.voices[v].out[1] * this.voices[v].delay; this.delaytime = this.voices[v].delaytime; // we trust that these are initialized in the voice this.delayfeedback = this.voices[v].delayfeedback; } @@ -800,8 +824,8 @@ export class Dough { const delayR = this._delayR.update(this.delaysend[1], this.delaytime); this.delaysend[0] = delayL * this.delayfeedback; this.delaysend[1] = delayR * this.delayfeedback; - this.channels[0] += delayL; - this.channels[1] += delayR; + this.out[0] += delayL; + this.out[1] += delayR; this.t++; } } From d1b6b6be8541b833410f147672aa68071dda3f5f Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Mon, 9 Jun 2025 09:54:24 +0200 Subject: [PATCH 36/95] fix: hpf --- packages/supradough/dough.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index 963bd9bdc..975840cb3 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -698,7 +698,7 @@ export class DoughVoice { this.out[i] = this._lpf[i].s1; } if (this._hpf) { - this._hpf[i].update(this.out[i], hcutoff, this.hresonance); + this._hpf[i].update(this.out[i], hpf, this.hresonance); this.out[i] = this.out[i] - this._hpf[i].s1; } if (this._bpf) { From 3ac0fdf619d824757aa9f2f6b0da8366b7b42463 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Mon, 9 Jun 2025 11:07:57 +0200 Subject: [PATCH 37/95] rudimentary sample map loading (doughsamples) --- packages/superdough/sampler.mjs | 91 ++++++++-------- packages/supradough/dough.mjs | 19 ++-- packages/webaudio/supradough.mjs | 178 ++++++++++++------------------- 3 files changed, 126 insertions(+), 162 deletions(-) diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index 18d1b7797..9188c17c3 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -196,6 +196,52 @@ function getSamplesPrefixHandler(url) { return; } +export async function fetchSampleMap(url) { + // check if custom prefix handler + const handler = getSamplesPrefixHandler(url); + if (handler) { + return handler(url); + } + url = resolveSpecialPaths(url); + if (url.startsWith('github:')) { + url = githubPath(url, 'strudel.json'); + } + if (url.startsWith('local:')) { + url = `http://localhost:5432`; + } + if (url.startsWith('shabda:')) { + let [_, path] = url.split('shabda:'); + url = `https://shabda.ndre.gr/${path}.json?strudel=1`; + } + if (url.startsWith('shabda/speech')) { + let [_, path] = url.split('shabda/speech'); + path = path.startsWith('/') ? path.substring(1) : path; + let [params, words] = path.split(':'); + let gender = 'f'; + let language = 'en-GB'; + if (params) { + [language, gender] = params.split('/'); + } + url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`; + } + 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; + } + const json = await fetch(url) + .then((res) => res.json()) + .catch((error) => { + console.error(error); + throw new Error(`error loading "${url}"`); + }); + return [json, json._base || base]; +} + /** * Loads a collection of samples to use with `s` * @example @@ -217,49 +263,8 @@ function getSamplesPrefixHandler(url) { export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => { if (typeof sampleMap === 'string') { - // check if custom prefix handler - const handler = getSamplesPrefixHandler(sampleMap); - if (handler) { - return handler(sampleMap); - } - sampleMap = resolveSpecialPaths(sampleMap); - if (sampleMap.startsWith('github:')) { - sampleMap = githubPath(sampleMap, 'strudel.json'); - } - if (sampleMap.startsWith('local:')) { - sampleMap = `http://localhost:5432`; - } - if (sampleMap.startsWith('shabda:')) { - let [_, path] = sampleMap.split('shabda:'); - sampleMap = `https://shabda.ndre.gr/${path}.json?strudel=1`; - } - if (sampleMap.startsWith('shabda/speech')) { - let [_, path] = sampleMap.split('shabda/speech'); - path = path.startsWith('/') ? path.substring(1) : path; - let [params, words] = path.split(':'); - let gender = 'f'; - let language = 'en-GB'; - if (params) { - [language, gender] = params.split('/'); - } - sampleMap = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`; - } - if (typeof fetch !== 'function') { - // not a browser - return; - } - const base = sampleMap.split('/').slice(0, -1).join('/'); - if (typeof fetch === 'undefined') { - // skip fetch when in node / testing - return; - } - return fetch(sampleMap) - .then((res) => res.json()) - .then((json) => samples(json, baseUrl || json._base || base, options)) - .catch((error) => { - console.error(error); - throw new Error(`error loading "${sampleMap}"`); - }); + const [json, base] = await fetchSampleMap(sampleMap); + return samples(json, baseUrl || base, options); } const { prebake, tag } = options; processSampleMap( diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index 975840cb3..207638737 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -402,7 +402,7 @@ export class BufferPlayer { buffer; // Float32Array sampleRate; pos = 0; - sampleFreq = 261.626; // middle c + sampleFreq = note2freq(); constructor(buffer, sampleRate) { this.buffer = buffer; this.sampleRate = sampleRate; @@ -411,7 +411,7 @@ export class BufferPlayer { if (this.pos >= this.buffer.length) { return 0; } - const speed = ((freq / this.sampleFreq) * this.sampleRate) / SAMPLE_RATE; + const speed = ((freq / this.sampleFreq) * SAMPLE_RATE) / this.sampleRate; let s = this.buffer[Math.floor(this.pos)]; this.pos = this.pos + speed; return s; @@ -458,6 +458,7 @@ let shapes = { }; const defaultDefaultValues = { + note: 48, s: 'triangle', gain: 1, postgain: 1, @@ -505,23 +506,19 @@ const note2midi = (note, defaultOctave = 3) => { oct = Number(oct || defaultOctave); return (oct + 1) * 12 + chroma + offset; }; -const getFrequency = (value) => { - let { note, freq } = value; - note = note || 36; +const midi2freq = (midi) => Math.pow(2, (midi - 69) / 12) * 440; +const note2freq = (note) => { + note = note || getDefaultValue('note'); if (typeof note === 'string') { note = note2midi(note, 3); // e.g. c3 => 48 } - if (!freq && typeof note === 'number') { - freq = Math.pow(2, (note - 69) / 12) * 440; - } - - return Number(freq); + return midi2freq(note); }; export class DoughVoice { out = [0, 0]; constructor(value) { - value.freq = getFrequency(value); + value.freq ??= note2freq(value.note); let $ = this; Object.assign($, value); $.s = $.s ?? getDefaultValue('s'); diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs index 269dcda95..3b45415df 100644 --- a/packages/webaudio/supradough.mjs +++ b/packages/webaudio/supradough.mjs @@ -25,105 +25,58 @@ Pattern.prototype.supradough = function () { }, 1); }; -let samples = { - casio: [ - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/casio/high.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/casio/low.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/casio/noise.wav', - ], - crow: [ - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/crow/000_crow.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/crow/001_crow2.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/crow/002_crow3.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/crow/003_crow4.wav', - ], - insect: [ - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/insect/000_everglades_conehead.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/insect/001_robust_shieldback.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/insect/002_seashore_meadow_katydid.wav', - ], - wind: [ - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/000_wind1.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/001_wind10.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/002_wind2.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/003_wind3.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/004_wind4.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/005_wind5.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/006_wind6.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/007_wind7.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/008_wind8.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/wind/009_wind9.wav', - ], - jazz: [ - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/jazz/000_BD.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/jazz/001_CB.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/jazz/002_FX.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/jazz/003_HH.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/jazz/004_OH.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/jazz/005_P1.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/jazz/006_P2.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/jazz/007_SN.wav', - ], - metal: [ - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/000_0.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/001_1.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/002_2.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/003_3.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/004_4.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/005_5.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/006_6.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/007_7.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/008_8.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/metal/009_9.wav', - ], - east: [ - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/000_nipon_wood_block.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/001_ohkawa_mute.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/002_ohkawa_open.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/003_shime_hi.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/004_shime_hi_2.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/005_shime_mute.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/006_taiko_1.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/007_taiko_2.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/east/008_taiko_3.wav', - ], - space: [ - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/000_0.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/001_1.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/002_11.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/003_12.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/004_13.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/005_14.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/006_15.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/007_16.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/008_17.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/009_18.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/010_2.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/011_3.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/012_4.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/013_5.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/014_6.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/015_7.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/016_8.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/space/017_9.wav', - ], - numbers: [ - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/0.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/1.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/2.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/3.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/4.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/5.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/6.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/7.wav', - 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/numbers/8.wav', - ], - piano: ['https://raw.githubusercontent.com/felixroos/dough-samples/refs/heads/main/piano/C3v8.mp3'], - flute: ['https://raw.githubusercontent.com/felixroos/samples/refs/heads/main/flute/c4.mp3'], - bd: [ - 'https://raw.githubusercontent.com/geikha/tidal-drum-machines/15eac73c5e878550f91d864a4863e014799403f1/machines/RolandTR909/rolandtr909-bd/Bassdrum-01.wav', - ], -}; +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}`; +} +export async function fetchSampleMap(url) { + if (url.startsWith('github:')) { + url = githubPath(url, 'strudel.json'); + } + if (url.startsWith('local:')) { + url = `http://localhost:5432`; + } + if (url.startsWith('shabda:')) { + let [_, path] = url.split('shabda:'); + url = `https://shabda.ndre.gr/${path}.json?strudel=1`; + } + if (url.startsWith('shabda/speech')) { + let [_, path] = url.split('shabda/speech'); + path = path.startsWith('/') ? path.substring(1) : path; + let [params, words] = path.split(':'); + let gender = 'f'; + let language = 'en-GB'; + if (params) { + [language, gender] = params.split('/'); + } + url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`; + } + 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; + } + const json = await fetch(url) + .then((res) => res.json()) + .catch((error) => { + console.error(error); + throw new Error(`error loading "${url}"`); + }); + return [json, json._base || base]; +} + // for some reason, only piano and flute work.. is it because mp3?? async function loadSampleChannels(key, url) { @@ -138,18 +91,27 @@ async function loadSampleChannels(key, url) { } let loaded = false; -export async function doughsample() { +export async function doughsamples(sampleMap, baseUrl) { + if (typeof sampleMap === 'string') { + const [json, base] = await fetchSampleMap(sampleMap); + // console.log('json', json, 'base', base); + return doughsamples(json, base); + } !doughWorklet && initDoughWorklet(); if (loaded) { return; } loaded = true; - const sampleMap = await Promise.all( - Object.entries(samples).map(async ([key, url]) => { - url = url[0]; - return loadSampleChannels(key, url); - }), - ); - console.log('sampleMap', sampleMap); - doughWorklet.port.postMessage({ samples: sampleMap }); + const json = ( + await Promise.all( + Object.entries(sampleMap).map(async ([key, url]) => { + if (key !== '_base') { + url = baseUrl + url[0]; + return loadSampleChannels(key, url); + } + }), + ) + ).filter(Boolean); + // console.log('sampleMap', json); + doughWorklet.port.postMessage({ samples: json }); } From aeaed50446e6f31e0a722ed3f19b8bd99e25efbb Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Mon, 9 Jun 2025 15:18:47 +0200 Subject: [PATCH 38/95] gaincurve --- packages/supradough/dough.mjs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index 207638737..fe3786a05 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -1,6 +1,15 @@ // this is dough, the superdough without dependencies const SAMPLE_RATE = typeof sampleRate !== 'undefined' ? sampleRate : 48000; const ISR = 1 / SAMPLE_RATE; + +let gainCurveFunc = (val) => Math.pow(val, 2); + +function applyGainCurve(val) { + return gainCurveFunc(val); +} +// function setGainCurve(newGainCurveFunc) { +// gainCurveFunc = newGainCurveFunc; +// } // https://garten.salat.dev/audio-DSP/oscillators.html export class SineOsc { phase = 0; @@ -522,15 +531,15 @@ export class DoughVoice { let $ = this; Object.assign($, value); $.s = $.s ?? getDefaultValue('s'); - $.gain = $.gain ?? getDefaultValue('gain'); - $.velocity = $.velocity ?? getDefaultValue('velocity'); - $.postgain = $.postgain ?? getDefaultValue('postgain'); + $.gain = applyGainCurve($.gain ?? getDefaultValue('gain')); + $.velocity = applyGainCurve($.velocity ?? getDefaultValue('velocity')); + $.postgain = applyGainCurve($.postgain ?? getDefaultValue('postgain')); $.density = $.density ?? getDefaultValue('density'); $.fanchor = $.fanchor ?? getDefaultValue('fanchor'); $.drive = $.drive ?? 0.69; $.phaserdepth = $.phaserdepth ?? getDefaultValue('phaserdepth'); - $.shapevol = $.shapevol ?? getDefaultValue('shapevol'); - $.distortvol = $.distortvol ?? getDefaultValue('distortvol'); + $.shapevol = applyGainCurve($.shapevol ?? getDefaultValue('shapevol')); + $.distortvol = applyGainCurve($.distortvol ?? getDefaultValue('distortvol')); $.i = $.i ?? getDefaultValue('i'); $.fft = $.fft ?? getDefaultValue('fft'); $.pan = $.pan ?? getDefaultValue('pan'); @@ -582,7 +591,7 @@ export class DoughVoice { // gain envelope $._adsr = new ADSR({ decayCurve: 2 }); // delay - $.delay = $.delay ?? getDefaultValue('delay'); + $.delay = applyGainCurve($.delay ?? getDefaultValue('delay')); $.delayfeedback = $.delayfeedback ?? getDefaultValue('delayfeedback'); $.delaytime = $.delaytime ?? getDefaultValue('delaytime'); From 3d300077563dd4f978b8315f6beeb5b9abfe3a8c Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Mon, 9 Jun 2025 15:55:44 +0200 Subject: [PATCH 39/95] supersaw --- packages/supradough/dough.mjs | 59 +++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index fe3786a05..4af3b7118 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -45,8 +45,9 @@ function polyBlep(t, dt) { return 0; } export class SawOsc { - //phase = Math.random(); - phase = 0; + constructor(props = {}) { + this.phase = props.phase ?? 0 + } update(freq) { const dt = freq / SAMPLE_RATE; let p = polyBlep(this.phase, dt); @@ -59,6 +60,59 @@ export class SawOsc { } } +function getUnisonDetune(unison, detune, voiceIndex) { + if (unison < 2) { + return 0; + } + const lerp = (a, b, n) => { + return n * (b - a) + a; + } + return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1)); +} +function applySemitoneDetuneToFrequency(frequency, detune) { + return frequency * Math.pow(2, detune / 12); +} +export class SupersawOsc { + constructor(props = {}) { + //TODO: figure out a good way to pass in these params + this.voices = props.voices ?? 5; + this.freqspread = props.freqspread ?? .2 + this.panspread = props.panspread ?? 0.4; + this.phase = new Float32Array(this.voices).map(() => Math.random()); + } + update(freq) { + const gain1 = Math.sqrt(1 - this.panspread); + const gain2 = Math.sqrt(this.panspread); + let sl = 0 + let sr = 0 + for (let n = 0; n < this.voices; n++) { + const freqAdjusted = applySemitoneDetuneToFrequency(freq, getUnisonDetune(this.voices, this.freqspread, n)) + const dt = freqAdjusted / SAMPLE_RATE; + const isOdd = (n & 1) == 1; + let gainL = gain1; + let gainR = gain2; + // invert right and left gain + if (isOdd) { + gainL = gain2; + gainR = gain1; + } + let p = polyBlep(this.phase[n], dt); + let s = 2 * this.phase[n] - 1 - p; + sl = sl + s * gainL + sr = sr + s * gainL + + this.phase[n] += dt; + if (this.phase[n] > 1) { + this.phase[n] -= 1; + } + } + + return sl + sr + //TODO: make stereo + // return [sl, sr]; + } +} + export class TriOsc { phase = 0; update(freq) { @@ -453,6 +507,7 @@ let shapes = { zaw: ZawOsc, sawtooth: SawOsc, zawtooth: ZawOsc, + supersaw: SupersawOsc, tri: TriOsc, triangle: TriOsc, pulse: PulseOsc, From e8254735bb3ad281bbaa4c0838cbe9a2fac7d2d7 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Mon, 9 Jun 2025 19:35:50 +0200 Subject: [PATCH 40/95] apply speed to synths and samples + add normalize flag (for fit) --- packages/supradough/dough.mjs | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index 4af3b7118..b11de6c24 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -46,7 +46,7 @@ function polyBlep(t, dt) { } export class SawOsc { constructor(props = {}) { - this.phase = props.phase ?? 0 + this.phase = props.phase ?? 0; } update(freq) { const dt = freq / SAMPLE_RATE; @@ -66,7 +66,7 @@ function getUnisonDetune(unison, detune, voiceIndex) { } const lerp = (a, b, n) => { return n * (b - a) + a; - } + }; return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1)); } function applySemitoneDetuneToFrequency(frequency, detune) { @@ -76,17 +76,17 @@ export class SupersawOsc { constructor(props = {}) { //TODO: figure out a good way to pass in these params this.voices = props.voices ?? 5; - this.freqspread = props.freqspread ?? .2 + this.freqspread = props.freqspread ?? 0.2; this.panspread = props.panspread ?? 0.4; this.phase = new Float32Array(this.voices).map(() => Math.random()); } update(freq) { const gain1 = Math.sqrt(1 - this.panspread); const gain2 = Math.sqrt(this.panspread); - let sl = 0 - let sr = 0 + let sl = 0; + let sr = 0; for (let n = 0; n < this.voices; n++) { - const freqAdjusted = applySemitoneDetuneToFrequency(freq, getUnisonDetune(this.voices, this.freqspread, n)) + const freqAdjusted = applySemitoneDetuneToFrequency(freq, getUnisonDetune(this.voices, this.freqspread, n)); const dt = freqAdjusted / SAMPLE_RATE; const isOdd = (n & 1) == 1; let gainL = gain1; @@ -98,8 +98,8 @@ export class SupersawOsc { } let p = polyBlep(this.phase[n], dt); let s = 2 * this.phase[n] - 1 - p; - sl = sl + s * gainL - sr = sr + s * gainL + sl = sl + s * gainL; + sr = sr + s * gainL; this.phase[n] += dt; if (this.phase[n] > 1) { @@ -107,7 +107,7 @@ export class SupersawOsc { } } - return sl + sr + return sl + sr; //TODO: make stereo // return [sl, sr]; } @@ -466,15 +466,22 @@ export class BufferPlayer { sampleRate; pos = 0; sampleFreq = note2freq(); - constructor(buffer, sampleRate) { + constructor(buffer, sampleRate, normalize) { this.buffer = buffer; this.sampleRate = sampleRate; + this.duration = this.buffer.length / this.sampleRate; + this.speed = SAMPLE_RATE / this.sampleRate; + if (normalize) { + // this will make the buffer last 1s if freq = sampleFreq + // it's useful to loop samples (e.g. fit function) + this.speed *= this.duration; + } } update(freq) { if (this.pos >= this.buffer.length) { return 0; } - const speed = ((freq / this.sampleFreq) * SAMPLE_RATE) / this.sampleRate; + const speed = (freq / this.sampleFreq) * this.speed; let s = this.buffer[Math.floor(this.pos)]; this.pos = this.pos + speed; return s; @@ -551,6 +558,7 @@ const defaultDefaultValues = { pan: 0.5, fmh: 1, fmenv: 0, // differs from superdough + speed: 1, }; let getDefaultValue = (key) => defaultDefaultValues[key]; @@ -603,6 +611,7 @@ export class DoughVoice { $.resonance = $.resonance ?? getDefaultValue('resonance'); $.hresonance = $.hresonance ?? getDefaultValue('hresonance'); $.bandq = $.bandq ?? getDefaultValue('bandq'); + $.speed = $.speed ?? getDefaultValue('speed'); [$.attack, $.decay, $.sustain, $.release] = getADSR([$.attack, $.decay, $.sustain, $.release]); @@ -623,7 +632,7 @@ export class DoughVoice { $._buffers = []; $._channels = sample.channels.length; for (let i = 0; i < $._channels; i++) { - $._buffers.push(new BufferPlayer(sample.channels[i], sample.sampleRate)); + $._buffers.push(new BufferPlayer(sample.channels[i], sample.sampleRate, $.unit === 'c')); // tbd unit === 'c' } } else { console.warn('sound not found', $.s); @@ -695,7 +704,7 @@ export class DoughVoice { } let gate = Number(t >= this._begin && t <= this._holdEnd); - let freq = this.freq; + let freq = this.freq * this.speed; // frequency modulation if (this._fm) { let fmi = this.fmi; From c9f494d8657e5990ded89a02dbaf8ab80caa1e40 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Mon, 9 Jun 2025 22:10:09 +0200 Subject: [PATCH 41/95] fix: node export --- packages/supradough/dough-export.mjs | 3 ++- packages/supradough/dough.mjs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/supradough/dough-export.mjs b/packages/supradough/dough-export.mjs index 5f8b9a8ee..a9f5614b0 100644 --- a/packages/supradough/dough-export.mjs +++ b/packages/supradough/dough-export.mjs @@ -47,7 +47,8 @@ haps.forEach((hap) => { console.log(`render ${seconds}s long buffer...`); const buffer = new Float32Array(seconds * sampleRate); while (dough.t <= buffer.length) { - buffer[dough.t] = dough.update(); + dough.update(); + buffer[dough.t] = dough.out[0]; } console.log('done!'); diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index b11de6c24..aa2e1dfd5 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -150,7 +150,7 @@ class PulseOsc { return 2 * phase - 1 - p; } update(freq, pw = 0.5) { - const dt = freq / sampleRate; + const dt = freq / SAMPLE_RATE; let pulse = this.saw(0, dt) - this.saw(pw, dt); this.phase = (this.phase + dt) % 1; return pulse + pw * 2 - 1; From e99adffc562e6495b02d19d8d66fc684935a77ec Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Mon, 9 Jun 2025 22:29:35 +0200 Subject: [PATCH 42/95] vibrato --- packages/supradough/dough.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index aa2e1dfd5..8a648a449 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -643,6 +643,11 @@ export class DoughVoice { [$.pattack, $.pdecay, $.psustain, $.prelease] = getADSR([$.pattack, $.pdecay, $.psustain, $.prelease]); } + if ($.vib) { + $._vib = new SineOsc(); + $.vibmod = $.vibmod ?? getDefaultValue('vibmod'); + } + if ($.fmi) { $._fm = new SineOsc(); $.fmh = $.fmh ?? getDefaultValue('fmh'); @@ -705,6 +710,7 @@ export class DoughVoice { let gate = Number(t >= this._begin && t <= this._holdEnd); let freq = this.freq * this.speed; + // frequency modulation if (this._fm) { let fmi = this.fmi; @@ -717,6 +723,11 @@ export class DoughVoice { freq = freq + this._fm.update(modfreq) * modgain; } + // vibrato + if (this._vib) { + freq = freq * 2 ** ((this._vib.update(this.vib) * this.vibmod) / 12); + } + // pitch envelope if (this._penv) { const env = this._penv.update(t, gate, this.pattack, this.pdecay, this.psustain, this.prelease); From 3963b807ebb35815890858e2d09bfadbc9e8a9f4 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Mon, 9 Jun 2025 22:32:41 +0200 Subject: [PATCH 43/95] fix: calling doughsamples multiple times --- packages/webaudio/supradough.mjs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs index 3b45415df..d9775642e 100644 --- a/packages/webaudio/supradough.mjs +++ b/packages/webaudio/supradough.mjs @@ -90,18 +90,12 @@ async function loadSampleChannels(key, url) { return [key, channels, buffer.sampleRate]; } -let loaded = false; export async function doughsamples(sampleMap, baseUrl) { if (typeof sampleMap === 'string') { const [json, base] = await fetchSampleMap(sampleMap); // console.log('json', json, 'base', base); return doughsamples(json, base); } - !doughWorklet && initDoughWorklet(); - if (loaded) { - return; - } - loaded = true; const json = ( await Promise.all( Object.entries(sampleMap).map(async ([key, url]) => { @@ -113,5 +107,6 @@ export async function doughsamples(sampleMap, baseUrl) { ) ).filter(Boolean); // console.log('sampleMap', json); + !doughWorklet && initDoughWorklet(); doughWorklet.port.postMessage({ samples: json }); } From 7e8206dcbc67570c5001869e86251199c0fd5ad7 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Tue, 10 Jun 2025 12:31:21 +0200 Subject: [PATCH 44/95] pitched delay --- packages/core/controls.mjs | 19 ++++++++++++--- packages/supradough/dough.mjs | 45 +++++++++++++++++++++++------------ 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 21c183da9..9c9bdbc8c 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -964,14 +964,27 @@ export const { delay } = registerControl(['delay', 'delaytime', 'delayfeedback'] * */ export const { delayfeedback, delayfb, dfb } = registerControl('delayfeedback', 'delayfb', 'dfb'); + +/** + * Sets the level of the signal that is fed back into the delay. + * Caution: Values >= 1 will result in a signal that gets louder and louder! Don't do it + * + * @name delayfeedback + * @param {number | Pattern} feedback between 0 and 1 + * @synonyms delayfb, dfb + * @example + * s("bd").delay(.25).delayfeedback("<.25 .5 .75 1>") + * + */ +export const { delayspeed } = registerControl('delayspeed'); /** * Sets the time of the delay effect. * - * @name delaytime - * @param {number | Pattern} seconds between 0 and Infinity + * @name delayspeed + * @param {number | Pattern} delayspeed controls the pitch of the delay feedback * @synonyms delayt, dt * @example - * s("bd bd").delay(.25).delaytime("<.125 .25 .5 1>") + * note("d d a# a".fast(2)).s("sawtooth").delay(.8).delaytime(1/2).delayspeed("<2 .5 -1 -2>") * */ export const { delaytime, delayt, dt } = registerControl('delaytime', 'delayt', 'dt'); diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index 8a648a449..8e3544c9f 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -44,6 +44,7 @@ function polyBlep(t, dt) { // 0 otherwise return 0; } + export class SawOsc { constructor(props = {}) { this.phase = props.phase ?? 0; @@ -349,21 +350,31 @@ export class ADSR { .out()*/ const MAX_DELAY_TIME = 10; export class Delay { - writeIdx = 0; - readIdx = 0; - buffer = new Float32Array(MAX_DELAY_TIME * SAMPLE_RATE); //.fill(0) - write(s, delayTime) { - this.writeIdx = (this.writeIdx + 1) % this.buffer.length; - this.buffer[this.writeIdx] = s; - // Calculate how far in the past to read - let numSamples = Math.min(Math.floor(SAMPLE_RATE * delayTime), this.buffer.length - 1); - this.readIdx = this.writeIdx - numSamples; - // If past the start of the buffer, wrap around - if (this.readIdx < 0) this.readIdx += this.buffer.length; + constructor(_props = {}) { + this.buffer = new Float32Array(MAX_DELAY_TIME * SAMPLE_RATE); + this.writeIdx = 0; + this.readIdx = 0; + this.numSamples = 0; } - update(input, delayTime) { + write(s, delayTime) { + // Calculate how far in the past to read + this.numSamples = Math.min(Math.floor(SAMPLE_RATE * delayTime), this.buffer.length - 1); + this.writeIdx = (this.writeIdx + 1) % this.numSamples; + this.buffer[this.writeIdx] = s; + this.readIdx = this.writeIdx - this.numSamples + 1; + + // If past the start of the buffer, wrap around (Q: is this possible?) + if (this.readIdx < 0) this.readIdx += this.numSamples; + } + update(input, delayTime, speed = 1) { this.write(input, delayTime); - return this.buffer[this.readIdx]; + let index = this.readIdx; + if (speed < 0) { + index = this.numSamples - Math.floor(Math.abs(this.readIdx * speed) % this.numSamples); + } else { + index = Math.floor(this.readIdx * speed) % this.numSamples; + } + return this.buffer[index]; } } @@ -550,6 +561,7 @@ const defaultDefaultValues = { delay: 0, byteBeatExpression: '0', delayfeedback: 0.5, + delayspeed: 1, delaytime: 0.25, orbit: 1, i: 1, @@ -662,6 +674,7 @@ export class DoughVoice { // delay $.delay = applyGainCurve($.delay ?? getDefaultValue('delay')); $.delayfeedback = $.delayfeedback ?? getDefaultValue('delayfeedback'); + $.delayspeed = $.delayspeed ?? getDefaultValue('delayspeed'); $.delaytime = $.delaytime ?? getDefaultValue('delaytime'); // precalculated values @@ -822,6 +835,7 @@ export class Dough { delaysend = [0, 0]; delaytime = getDefaultValue('delaytime'); delayfeedback = getDefaultValue('delayfeedback'); + delayspeed = getDefaultValue('delayspeed'); t = 0; // sampleRate: number, currentTime: number (seconds) constructor(sampleRate = 48000, currentTime = 0) { @@ -897,12 +911,13 @@ export class Dough { this.delaysend[0] += this.voices[v].out[0] * this.voices[v].delay; this.delaysend[1] += this.voices[v].out[1] * this.voices[v].delay; this.delaytime = this.voices[v].delaytime; // we trust that these are initialized in the voice + this.delayspeed = this.voices[v].delayspeed; // we trust that these are initialized in the voice this.delayfeedback = this.voices[v].delayfeedback; } } // todo: how to change delaytime / delayfeedback from a voice? - const delayL = this._delayL.update(this.delaysend[0], this.delaytime); - const delayR = this._delayR.update(this.delaysend[1], this.delaytime); + const delayL = this._delayL.update(this.delaysend[0], this.delaytime, this.delayspeed); + const delayR = this._delayR.update(this.delaysend[1], this.delaytime, this.delayspeed); this.delaysend[0] = delayL * this.delayfeedback; this.delaysend[1] = delayR * this.delayfeedback; this.out[0] += delayL; From 43504d16b66e739590ec80c4fd18e10f54c31802 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Tue, 10 Jun 2025 16:03:42 +0200 Subject: [PATCH 45/95] add chorus --- packages/core/controls.mjs | 11 ++++++ packages/supradough/dough.mjs | 64 ++++++++++++++++++++++++++++++++--- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 9c9bdbc8c..ed440b56d 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -285,6 +285,17 @@ export const { fmvelocity } = registerControl('fmvelocity'); */ export const { bank } = registerControl('bank'); +/** + * mix control for the chorus effect + * + * @name chorus + * @param {string | Pattern} chorus mix amount between 0 and 1 + * @example + * note("d d a# a").s("sawtooth").chorus(.5) + * + */ +export const { chorus } = registerControl('chorus'); + // analyser node send amount 0 - 1 (used by scope) export const { analyze } = registerControl('analyze'); // fftSize of analyser diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index 8e3544c9f..6be0af78c 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -7,6 +7,22 @@ let gainCurveFunc = (val) => Math.pow(val, 2); function applyGainCurve(val) { return gainCurveFunc(val); } + +/** + * Equal Power Crossfade function. + * Smoothly transitions between signals A and B, maintaining consistent perceived loudness. + * + * @param {number} a - Signal A (can be a single value or an array value in buffer processing). + * @param {number} b - Signal B (can be a single value or an array value in buffer processing). + * @param {number} m - Crossfade parameter (0.0 = all A, 1.0 = all B, 0.5 = equal mix). + * @returns {number} Crossfaded output value. + */ +function crossfade(a, b, m) { + const aGain = Math.sin((1 - m) * 0.5 * Math.PI); + const bGain = Math.sin(m * 0.5 * Math.PI); + return a * aGain + b * bGain; +} + // function setGainCurve(newGainCurveFunc) { // gainCurveFunc = newGainCurveFunc; // } @@ -349,7 +365,8 @@ export class ADSR { .add(x=>x.delay(.1).mul(.8)) .out()*/ const MAX_DELAY_TIME = 10; -export class Delay { +export class PitchDelay { + lpf = new TwoPoleFilter(); constructor(_props = {}) { this.buffer = new Float32Array(MAX_DELAY_TIME * SAMPLE_RATE); this.writeIdx = 0; @@ -374,7 +391,38 @@ export class Delay { } else { index = Math.floor(this.readIdx * speed) % this.numSamples; } - return this.buffer[index]; + const s = this.lpf.update(this.buffer[index], 0.9, 0); + + return s; + } +} + +export class Delay { + writeIdx = 0; + readIdx = 0; + buffer = new Float32Array(MAX_DELAY_TIME * SAMPLE_RATE); //.fill(0) + write(s, delayTime) { + this.writeIdx = (this.writeIdx + 1) % this.buffer.length; + this.buffer[this.writeIdx] = s; + // Calculate how far in the past to read + let numSamples = Math.min(Math.floor(SAMPLE_RATE * delayTime), this.buffer.length - 1); + this.readIdx = this.writeIdx - numSamples; + // If past the start of the buffer, wrap around + if (this.readIdx < 0) this.readIdx += this.buffer.length; + } + update(input, delayTime) { + this.write(input, delayTime); + return this.buffer[this.readIdx]; + } +} +//TODO: Figure out why clicking at the start off the buffer +export class Chorus { + delay = new Delay(); + modulator = new TriOsc(); + update(input, mix, delayTime, modulationFreq, modulationDepth) { + const m = this.modulator.update(modulationFreq) * modulationDepth; + const c = this.delay.update(input, delayTime * (1 + m)); + return crossfade(input, c, mix); } } @@ -540,6 +588,7 @@ let shapes = { }; const defaultDefaultValues = { + chorus: 0, note: 48, s: 'triangle', gain: 1, @@ -616,6 +665,7 @@ export class DoughVoice { $.shapevol = applyGainCurve($.shapevol ?? getDefaultValue('shapevol')); $.distortvol = applyGainCurve($.distortvol ?? getDefaultValue('distortvol')); $.i = $.i ?? getDefaultValue('i'); + $.chorus = $.chorus ?? getDefaultValue('chorus'); $.fft = $.fft ?? getDefaultValue('fft'); $.pan = $.pan ?? getDefaultValue('pan'); $.orbit = $.orbit ?? getDefaultValue('orbit'); @@ -696,6 +746,7 @@ export class DoughVoice { } // channelwise effects setup + $._chorus = $.chorus ? [] : null; $._lpf = $.cutoff ? [] : null; $._hpf = $.hcutoff ? [] : null; $._bpf = $.bandf ? [] : null; @@ -706,6 +757,7 @@ export class DoughVoice { $._lpf?.push(new TwoPoleFilter()); $._hpf?.push(new TwoPoleFilter()); $._bpf?.push(new TwoPoleFilter()); + $._chorus?.push(new Chorus()); $._coarse?.push(new Coarse()); $._crush?.push(new Crush()); $._distort?.push(new Distort()); @@ -786,6 +838,10 @@ export class DoughVoice { this.out[i] = this._buffers[i].update(freq); } this.out[i] = this.out[i] * this.gain * this.velocity; + if (this._chorus) { + const c = this._chorus[i].update(this.out[i], this.chorus, 0.03 + 0.05 * i, 1, 0.11); + this.out[i] = c + this.out[i]; + } if (this._lpf) { this._lpf[i].update(this.out[i], lpf, this.resonance); @@ -842,8 +898,8 @@ export class Dough { this.sampleRate = sampleRate; this.t = Math.floor(currentTime * sampleRate); // samples // console.log('init dough', this.sampleRate, this.t); - this._delayL = new Delay(); - this._delayR = new Delay(); + this._delayL = new PitchDelay(); + this._delayR = new PitchDelay(); } loadSample(name, channels, sampleRate) { BufferPlayer.samples.set(name, { channels, sampleRate }); From bbd7ed0f27f1b49eefb674afa70ea8fe1acbcbe8 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 12 Jun 2025 02:25:04 +0200 Subject: [PATCH 46/95] lazy sample loading --- packages/webaudio/supradough.mjs | 48 ++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs index d9775642e..10664aa89 100644 --- a/packages/webaudio/supradough.mjs +++ b/packages/webaudio/supradough.mjs @@ -16,11 +16,35 @@ function initDoughWorklet() { connectToDestination(doughWorklet); // channels? } +const soundMap = new Map(); +const loadedSounds = new Map(); + Pattern.prototype.supradough = function () { return this.onTrigger((_, hap, __, cps, begin) => { hap.value._begin = begin; hap.value._duration = hap.duration / cps; !doughWorklet && initDoughWorklet(); + const s = (hap.value.bank ? hap.value.bank + '_' : '') + hap.value.s; + const n = hap.value.n ?? 0; + const soundKey = `${s}:${n}`; + if (soundMap.has(s)) { + hap.value.s = soundKey; // dough.mjs is unaware of bank and n (only maps keys to buffers) + } + if (soundMap.has(s) && !loadedSounds.has(soundKey)) { + const urls = soundMap.get(s); + const url = urls[n % urls.length]; + console.log(`load ${soundKey} from ${url}`); + const loadSample = fetchSample(url); + loadedSounds.set(soundKey, loadSample); + loadSample.then(({ channels, sampleRate }) => + doughWorklet.port.postMessage({ + sample: soundKey, + channels, + sampleRate, + }), + ); + } + doughWorklet.port.postMessage({ spawn: hap.value }); }, 1); }; @@ -79,7 +103,7 @@ export async function fetchSampleMap(url) { // for some reason, only piano and flute work.. is it because mp3?? -async function loadSampleChannels(key, url) { +async function fetchSample(url) { const buffer = await fetch(url) .then((res) => res.arrayBuffer()) .then((buf) => getAudioContext().decodeAudioData(buf)); @@ -87,7 +111,7 @@ async function loadSampleChannels(key, url) { for (let i = 0; i < buffer.numberOfChannels; i++) { channels.push(buffer.getChannelData(i)); } - return [key, channels, buffer.sampleRate]; + return { channels, sampleRate: buffer.sampleRate }; } export async function doughsamples(sampleMap, baseUrl) { @@ -96,17 +120,11 @@ export async function doughsamples(sampleMap, baseUrl) { // console.log('json', json, 'base', base); return doughsamples(json, base); } - const json = ( - await Promise.all( - Object.entries(sampleMap).map(async ([key, url]) => { - if (key !== '_base') { - url = baseUrl + url[0]; - return loadSampleChannels(key, url); - } - }), - ) - ).filter(Boolean); - // console.log('sampleMap', json); - !doughWorklet && initDoughWorklet(); - doughWorklet.port.postMessage({ samples: json }); + Object.entries(sampleMap).map(async ([key, urls]) => { + if (key !== '_base') { + urls = urls.map((url) => baseUrl + url); + // console.log('set', key, urls); + soundMap.set(key, urls); + } + }); } From 5cbd38a3e83b0b29414675bee6a1a961a13c93fa Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 12 Jun 2025 02:25:46 +0200 Subject: [PATCH 47/95] improve filter performance making it accept hz directly --- packages/supradough/dough.mjs | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index 6be0af78c..711922b63 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -1,5 +1,6 @@ // this is dough, the superdough without dependencies const SAMPLE_RATE = typeof sampleRate !== 'undefined' ? sampleRate : 48000; +const PI_DIV_SR = Math.PI / SAMPLE_RATE; const ISR = 1 / SAMPLE_RATE; let gainCurveFunc = (val) => Math.pow(val, 2); @@ -145,11 +146,13 @@ export class TwoPoleFilter { s1 = 0; update(s, cutoff, resonance = 0) { // Out of bound values can produce NaNs - cutoff = Math.min(cutoff, 1); resonance = Math.max(resonance, 0); - var c = Math.pow(0.5, (1 - cutoff) / 0.125); - var r = Math.pow(0.5, (resonance + 0.125) / 0.125); - var mrc = 1 - r * c; + + cutoff = Math.min(cutoff, 20000); + const c = 2 * Math.sin(cutoff * PI_DIV_SR); + + const r = Math.pow(0.5, (resonance + 0.125) / 0.125); + const mrc = 1 - r * c; this.s0 = mrc * this.s0 - c * this.s1 + c * s; // bpf this.s1 = mrc * this.s1 + c * this.s0; // lpf @@ -591,6 +594,7 @@ const defaultDefaultValues = { chorus: 0, note: 48, s: 'triangle', + bank: '', gain: 1, postgain: 1, velocity: 1, @@ -620,6 +624,7 @@ const defaultDefaultValues = { fmh: 1, fmenv: 0, // differs from superdough speed: 1, + pw: 0.5, }; let getDefaultValue = (key) => defaultDefaultValues[key]; @@ -674,14 +679,14 @@ export class DoughVoice { $.hresonance = $.hresonance ?? getDefaultValue('hresonance'); $.bandq = $.bandq ?? getDefaultValue('bandq'); $.speed = $.speed ?? getDefaultValue('speed'); + $.pw = $.pw ?? getDefaultValue('pw'); [$.attack, $.decay, $.sustain, $.release] = getADSR([$.attack, $.decay, $.sustain, $.release]); $._holdEnd = $._begin + $._duration; // needed for gate $._end = $._holdEnd + $.release + 0.01; // needed for despawn - $.s ??= 'triangle'; - if ($.s === 'saw' || $.s === 'sawtooth') { + if ($.fmi && ($.s === 'saw' || $.s === 'sawtooth')) { $.s = 'zaw'; // polyblepped saw when fm is applied } @@ -697,7 +702,7 @@ export class DoughVoice { $._buffers.push(new BufferPlayer(sample.channels[i], sample.sampleRate, $.unit === 'c')); // tbd unit === 'c' } } else { - console.warn('sound not found', $.s); + console.warn('sound not loaded', $.s); } if ($.penv) { @@ -727,10 +732,6 @@ export class DoughVoice { $.delayspeed = $.delayspeed ?? getDefaultValue('delayspeed'); $.delaytime = $.delaytime ?? getDefaultValue('delaytime'); - // precalculated values - $.piOverSr = Math.PI / value.sampleRate; - $.eighthOverLogHalf = 0.125 / Math.log(0.5); - // filter setup if ($.lpenv) { $._lpenv = new ADSR({ decayCurve: 4 }); @@ -763,11 +764,6 @@ export class DoughVoice { $._distort?.push(new Distort()); } } - // credits to pulu: https://github.com/felixroos/kabelsalat/issues/35 - freq2cutoff(freq) { - const c = 2 * Math.sin(freq * this.piOverSr); - return 1 - Math.log(c) * this.eighthOverLogHalf; - } update(t) { if (!this._sound && !this._buffers) { return 0; @@ -806,7 +802,6 @@ export class DoughVoice { const env = this._lpenv.update(t, gate, this.lpattack, this.lpdecay, this.lpsustain, this.lprelease); lpf = this.lpenv * env * lpf + lpf; } - lpf = this.freq2cutoff(lpf); } let hpf = this.hcutoff; if (this._hpf) { @@ -814,7 +809,6 @@ export class DoughVoice { const env = this._hpenv.update(t, gate, this.hpattack, this.hpdecay, this.hpsustain, this.hprelease); hpf = 2 ** this.hpenv * env * hpf + hpf; } - hpf = this.freq2cutoff(hpf); } let bpf = this.bandf; if (this._bpf) { @@ -822,7 +816,6 @@ export class DoughVoice { const env = this._bpenv.update(t, gate, this.bpattack, this.bpdecay, this.bpsustain, this.bprelease); bpf = 2 ** this.bpenv * env * bpf + bpf; } - bpf = this.freq2cutoff(bpf); } // gain envelope const env = this._adsr.update(t, gate, this.attack, this.decay, this.sustain, this.release); @@ -831,7 +824,7 @@ export class DoughVoice { for (let i = 0; i < this._channels; i++) { // sound source if (this._sound && this.s === 'pulse') { - this.out[i] = this._sound.update(freq, this.pw ?? 0.5); + this.out[i] = this._sound.update(freq, this.pw); } else if (this._sound) { this.out[i] = this._sound.update(freq); } else if (this._buffers) { From 3caf08e199a7f210094cae213e934ead30750933 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 12 Jun 2025 02:26:08 +0200 Subject: [PATCH 48/95] up latency --- 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 2629790df..ef43d7906 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -174,7 +174,7 @@ export const resetLoadedSounds = () => soundMap.set({}); let audioContext; export const setDefaultAudioContext = () => { - audioContext = new AudioContext(); + audioContext = new AudioContext({ latencyHint: 'playback' }); return audioContext; }; From 14193abe0315dcd54d17ba2680f85bdf0373ca9b Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 12 Jun 2025 02:26:50 +0200 Subject: [PATCH 49/95] improve export: add stereo + measure performance + add bigger pattern for testing --- packages/supradough/dough-export.mjs | 103 +++++++++++++++++++++------ 1 file changed, 82 insertions(+), 21 deletions(-) diff --git a/packages/supradough/dough-export.mjs b/packages/supradough/dough-export.mjs index a9f5614b0..a6c55bf46 100644 --- a/packages/supradough/dough-export.mjs +++ b/packages/supradough/dough-export.mjs @@ -16,23 +16,72 @@ await evalScope( miniAllStrings(); // allows using single quotes for mini notation / skip transpilation let sampleRate = 48000, - cps = 0.5; + cps = 0.4; -let pat = note('[c e g b]*3') - .add(note(7)) - .lpf(sine.rangex(200, 4000).slow(2)) - .lpq(0.3) - .s('*2') - .att(0.01) - .rel(0.2) - .clip(2) - .delay(0.5) - .jux(rev) - .sometimes(add(note(12))) - .gain(0.25) +/* await doughsamples('github:eddyflux/crate'); +await doughsamples('github:eddyflux/wax'); */ + +let pat = note('c,eb,g,') + .s('sine') + .press() + .add(note(24)) + .fmi(3) + .fmh(5.01) + .dec(0.4) + .delay('.6:<.12 .22>:.8') + .jux(press) + .rarely(add(note('12'))) + .lpf(400) + .lpq(0.2) + .lpd(0.4) + .lpenv(3) + .fmdecay(0.4) + .fmenv(1) + .postgain(0.6) + .stack(s('*8').dec(0.07).rarely(ply('2')).delay(0.5).hpf(sine.range(200, 2000).slow(4)).hpq(0.2)) + .stack( + s('[- white@3]*2') + .dec(0.4) + .hpf('<2000!3 <4000 8000>>*4') + .hpq(0.6) + .ply('<1 2>*4') + .postgain(0.5) + .delay(0.5) + .jux(rev) + .lpf(5000), + ) + .stack( + note('*2') + .s('square') + .lpf(sine.range(100, 300).slow(4)) + .lpe(1) + .segment(8) + .lpd(0.3) + .lpq(0.2) + .dec(0.2) + .speed('<1 2>') + .ply('<1 2>') + .postgain(1), + ) + .stack( + chord('') + .voicing() + .s('') + .clip(1) + .rel(0.4) + .vib('4:.2') + .gain(0.7) + .hpf(1200) + .fm(0.5) + .att(1) + .lpa(0.5) + .lpf(200) + .lpenv(4) + .chorus(0.8), + ) .slow(1 / cps); -let cycles = 4; +let cycles = 30; let seconds = cycles + 1; // 1s release tail const haps = pat.queryArc(0, cycles); @@ -41,20 +90,32 @@ const dough = new Dough(sampleRate); console.log('spawn voices...'); haps.forEach((hap) => { hap.value._begin = Number(hap.whole.begin); - hap.value._duration = hap.duration / cps; + hap.value._duration = hap.duration/* / cps */; dough.scheduleSpawn(hap.value); }); -console.log(`render ${seconds}s long buffer...`); -const buffer = new Float32Array(seconds * sampleRate); -while (dough.t <= buffer.length) { +console.log(`render ${seconds}s long buffer, each dot is 1 second:`); +const buffers = [new Float32Array(seconds * sampleRate), new Float32Array(seconds * sampleRate)]; +let t = performance.now(); +while (dough.t <= buffers[0].length) { dough.update(); - buffer[dough.t] = dough.out[0]; + buffers[0][dough.t] = dough.out[0]; + buffers[1][dough.t] = dough.out[1]; + if (dough.t % sampleRate === 0) { + process.stdout.write('.'); + } } -console.log('done!'); +const took = (performance.now() - t) / 1000; +const load = (took / seconds) * 100; +const speed = (seconds / took).toFixed(2); +console.log(''); +console.log(`done! +rendered ${seconds}s in ${took.toFixed(2)}s +speed: ${speed}x +load: ${load.toFixed(2)}%`); const patternAudio = { sampleRate, - channelData: [buffer], + channelData: buffers, }; WavEncoder.encode(patternAudio).then((buffer) => { From 8363ba5a41ca3210d6cd4b436ead3692d7d29d3c Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 12 Jun 2025 10:22:35 +0200 Subject: [PATCH 50/95] format --- packages/supradough/dough-export.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/supradough/dough-export.mjs b/packages/supradough/dough-export.mjs index a6c55bf46..bd4b530b3 100644 --- a/packages/supradough/dough-export.mjs +++ b/packages/supradough/dough-export.mjs @@ -90,7 +90,7 @@ const dough = new Dough(sampleRate); console.log('spawn voices...'); haps.forEach((hap) => { hap.value._begin = Number(hap.whole.begin); - hap.value._duration = hap.duration/* / cps */; + hap.value._duration = hap.duration /* / cps */; dough.scheduleSpawn(hap.value); }); console.log(`render ${seconds}s long buffer, each dot is 1 second:`); From 239987af0b70169d6db451f277a3f24da59ca203 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 12 Jun 2025 10:23:06 +0200 Subject: [PATCH 51/95] snapshot --- test/__snapshots__/examples.test.mjs.snap | 67 +++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 0eacfdfb0..e3df24d47 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -1847,6 +1847,27 @@ exports[`runs examples > example "chop" example index 0 1`] = ` ] `; +exports[`runs examples > example "chorus" example index 0 1`] = ` +[ + "[ 0/1 → 1/4 | note:d s:sawtooth chorus:0.5 ]", + "[ 1/4 → 1/2 | note:d s:sawtooth chorus:0.5 ]", + "[ 1/2 → 3/4 | note:a# s:sawtooth chorus:0.5 ]", + "[ 3/4 → 1/1 | note:a s:sawtooth chorus:0.5 ]", + "[ 1/1 → 5/4 | note:d s:sawtooth chorus:0.5 ]", + "[ 5/4 → 3/2 | note:d s:sawtooth chorus:0.5 ]", + "[ 3/2 → 7/4 | note:a# s:sawtooth chorus:0.5 ]", + "[ 7/4 → 2/1 | note:a s:sawtooth chorus:0.5 ]", + "[ 2/1 → 9/4 | note:d s:sawtooth chorus:0.5 ]", + "[ 9/4 → 5/2 | note:d s:sawtooth chorus:0.5 ]", + "[ 5/2 → 11/4 | note:a# s:sawtooth chorus:0.5 ]", + "[ 11/4 → 3/1 | note:a s:sawtooth chorus:0.5 ]", + "[ 3/1 → 13/4 | note:d s:sawtooth chorus:0.5 ]", + "[ 13/4 → 7/2 | note:d s:sawtooth chorus:0.5 ]", + "[ 7/2 → 15/4 | note:a# s:sawtooth chorus:0.5 ]", + "[ 15/4 → 4/1 | note:a s:sawtooth chorus:0.5 ]", +] +`; + exports[`runs examples > example "chunk" example index 0 1`] = ` [ "[ 0/1 → 1/4 | note:A4 ]", @@ -2505,6 +2526,52 @@ exports[`runs examples > example "delayfeedback" example index 0 1`] = ` ] `; +exports[`runs examples > example "delayfeedback" example index 0 2`] = ` +[ + "[ 0/1 → 1/1 | s:bd delay:0.25 delayfeedback:0.25 ]", + "[ 1/1 → 2/1 | s:bd delay:0.25 delayfeedback:0.5 ]", + "[ 2/1 → 3/1 | s:bd delay:0.25 delayfeedback:0.75 ]", + "[ 3/1 → 4/1 | s:bd delay:0.25 delayfeedback:1 ]", +] +`; + +exports[`runs examples > example "delayspeed" example index 0 1`] = ` +[ + "[ 0/1 → 1/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", + "[ 1/8 → 1/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", + "[ 1/4 → 3/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", + "[ 3/8 → 1/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", + "[ 1/2 → 5/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", + "[ 5/8 → 3/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", + "[ 3/4 → 7/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", + "[ 7/8 → 1/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", + "[ 1/1 → 9/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", + "[ 9/8 → 5/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", + "[ 5/4 → 11/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", + "[ 11/8 → 3/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", + "[ 3/2 → 13/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", + "[ 13/8 → 7/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", + "[ 7/4 → 15/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", + "[ 15/8 → 2/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", + "[ 2/1 → 17/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", + "[ 17/8 → 9/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", + "[ 9/4 → 19/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", + "[ 19/8 → 5/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", + "[ 5/2 → 21/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", + "[ 21/8 → 11/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", + "[ 11/4 → 23/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", + "[ 23/8 → 3/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", + "[ 3/1 → 25/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", + "[ 25/8 → 13/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", + "[ 13/4 → 27/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", + "[ 27/8 → 7/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", + "[ 7/2 → 29/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", + "[ 29/8 → 15/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", + "[ 15/4 → 31/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", + "[ 31/8 → 4/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", +] +`; + exports[`runs examples > example "delaytime" example index 0 1`] = ` [ "[ 0/1 → 1/2 | s:bd delay:0.25 delaytime:0.125 ]", From 5efcd14f4084295e287cf4a97364e7e932db994e Mon Sep 17 00:00:00 2001 From: anecondev Date: Wed, 18 Jun 2025 17:07:13 +0200 Subject: [PATCH 52/95] Update website/src/pages/learn/code.mdx Correct "appended" to "prepended" --- website/src/pages/learn/code.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/pages/learn/code.mdx b/website/src/pages/learn/code.mdx index ca2564dec..7057c4576 100644 --- a/website/src/pages/learn/code.mdx +++ b/website/src/pages/learn/code.mdx @@ -44,7 +44,7 @@ xxx("foo").yyy("bar") Generally, `xxx` and `yyy` are called [_functions_](), while `foo` and `bar` are called function [_arguments_ or _parameters_](). So far, we've used the functions to declare which aspect of the sound we want to control, and their arguments for the actual data. -The `yyy` function is called a [_chained_ function](https://en.wikipedia.org/wiki/Method_chaining), because it is appended with a dot (`.`). +The `yyy` function is called a [_chained_ function](https://en.wikipedia.org/wiki/Method_chaining), because it is prepended with a dot (`.`). Generally, the idea with chaining is that code such as `a("this").b("that").c("other")` allows `a`, `b` and `c` functions to happen in a specified order, without needing to write them as three separate lines of code. You can think of this as being similar to chaining audio effects together using guitar pedals or digital audio effects. From cbb4207bab08eb1a791d8e77f9508ddd37431324 Mon Sep 17 00:00:00 2001 From: dudymas Date: Fri, 4 Jul 2025 18:50:21 -0400 Subject: [PATCH 53/95] chore(docker): basic support added --- Dockerfile | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..b0c6618be --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM node:24 + +WORKDIR /app + +RUN npm install pnpm --global + +COPY pnpm-workspace.yaml ./ +COPY package.json pnpm-lock.yaml ./ +COPY packages/ ./packages/ +COPY examples/ ./examples/ +RUN mkdir -p website/public +COPY website/package.json ./website/ + +RUN pnpm install + + +COPY . . + +EXPOSE 4321 + +CMD ["pnpm", "dev"] From 31c1450edc8eb606716fc5e35af52614bf67c8c3 Mon Sep 17 00:00:00 2001 From: dudymas Date: Fri, 4 Jul 2025 18:50:21 -0400 Subject: [PATCH 54/95] docs(website/learn/xen): add tune() examples --- website/src/pages/learn/xen.mdx | 64 +++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 website/src/pages/learn/xen.mdx diff --git a/website/src/pages/learn/xen.mdx b/website/src/pages/learn/xen.mdx new file mode 100644 index 000000000..21db10eb4 --- /dev/null +++ b/website/src/pages/learn/xen.mdx @@ -0,0 +1,64 @@ +--- +title: Xen Harmonic Functions +layout: ../../layouts/MainLayout.astro +--- + +import { MiniRepl } from '../../docs/MiniRepl'; +import { JsDoc } from '../../docs/JsDoc'; + +# Xen Harmonic Functions + +These functions allow the use of scales other than your typical chromatic 12 based ones. + +### tune(scale) + + + +Here's an example of how to configure a basic hexany scale: + + + +Try other scales like `hexany1`, `iraq`, `gumbeng`, `gunkali`, or `tranh3` + +For a full list of available scales from tunejs, see http://abbernie.github.io/tune/scales.html + +You can set your root to be a particular note with `getFreq` + + + +Some tunings become more pronounced with a longer reverb decay: + + -".tune("gumbeng") + .mul(getFreq('c3')) + .freq().clip(.8).room("3:10").rdim(10000).rfade(5)`} +/> + +Additionally, you can combo this with `fmap` so that the base note changes: + +".fmap(getFreq)) + .freq().legato("2 .7").room("1:15").rdim(8500).rlp(14000).rfade(8)`} +/> + +Combining this with various polyrhythm tricks can become very evocative: + + ~ ~,<-4 -5>" + .transpose(4) + .tune("iraq") + .mul("".fmap(getFreq)) + .freq().clip(.5).room(1).rfade(9)`} +/> From 37fde8f3e807a64289701ed67dd14d9d30713cc7 Mon Sep 17 00:00:00 2001 From: dudymas Date: Sat, 5 Jul 2025 13:43:09 -0400 Subject: [PATCH 55/95] docs(website/learn/xen): discussed strumming --- website/src/config.ts | 1 + website/src/pages/learn/xen.mdx | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/website/src/config.ts b/website/src/config.ts index 2e2e264da..f335b272e 100644 --- a/website/src/config.ts +++ b/website/src/config.ts @@ -103,6 +103,7 @@ export const SIDEBAR: Sidebar = { Understand: [ { text: 'Coding syntax', link: 'learn/code' }, { text: 'Pitch', link: 'understand/pitch' }, + { text: 'Xen Harmonic Functions', link: 'learn/xen' }, { text: 'Cycles', link: 'understand/cycles' }, { text: 'Voicings', link: 'understand/voicings' }, { text: 'Pattern Alignment', link: 'technical-manual/alignment' }, diff --git a/website/src/pages/learn/xen.mdx b/website/src/pages/learn/xen.mdx index 21db10eb4..32e2c0b43 100644 --- a/website/src/pages/learn/xen.mdx +++ b/website/src/pages/learn/xen.mdx @@ -62,3 +62,39 @@ Combining this with various polyrhythm tricks can become very evocative: .mul("".fmap(getFreq)) .freq().clip(.5).room(1).rfade(9)`} /> + +Another helpful trick when exploring new tunings is to strum them. +Many have a much more enchanting sound that was chosen over many generations of musicians for being strummed. + +Take the `sanza` tuning: + + +Notes 7 and 9 will clash quite a bit if you arp them normally. Many tunings will have this sort of sound, and it can feel distracting on its own. +See how close they are on the pitch wheel? + + + +This quality is often due to how the tunings were formed with instruments that were played differently than a piano. +As such, some tunings are much better strummed, with the subtle clash of the detuned notes actually making the sound much more magical: + +") + .tune("sanza") + .mul(getFreq('c3')).freq() + .legato("3").room(1).rfade(5)`} +/> + +Note the legato and reverb effects make sure the sound of the strumming gets to wash together. Alternating the direction of the strum can make the +tones sound even more alive, too. + +The `tranh3` tuning has a similar set of notes, with two clashing. You might trying plugging that in above and see if you find a favorite strumming pattern. From 0722cf7ded81194dc91aef2690c30eb3c5cf4781 Mon Sep 17 00:00:00 2001 From: dudymas Date: Sat, 5 Jul 2025 13:43:42 -0400 Subject: [PATCH 56/95] feat(xen/tunejs): support sending a custom tuning --- packages/xen/tunejs.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/xen/tunejs.js b/packages/xen/tunejs.js index 6b5e7cb7c..5806cf596 100644 --- a/packages/xen/tunejs.js +++ b/packages/xen/tunejs.js @@ -139,10 +139,10 @@ Tune.prototype.MIDI = function(stepIn,octaveIn) { /* Load a new scale */ -Tune.prototype.loadScale = function(name){ +Tune.prototype.loadScale = function(scale){ /* load the scale */ - var freqs = TuningList[name].frequencies + var freqs = isArrayOfNumbers(scale) ? scale : TuningList[scale].frequencies this.scale = [] for (var i=0;i 0 && arg.every(item => typeof item === 'number' && !isNaN(item)); +} + +/* allow an array of values too */ +Tune.prototype.isValidScale = function(scale) { + return !!TuningList[scale] || isArrayOfNumbers(scale) ; } /* Return a collection of notes as an array */ From 5632d83afb124d679be292890a49ffd31b34e690 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Wed, 16 Jul 2025 02:21:39 +0200 Subject: [PATCH 57/95] fix: supradough onTrigger after breaking change --- packages/webaudio/supradough.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs index 10664aa89..f97251a07 100644 --- a/packages/webaudio/supradough.mjs +++ b/packages/webaudio/supradough.mjs @@ -20,7 +20,7 @@ const soundMap = new Map(); const loadedSounds = new Map(); Pattern.prototype.supradough = function () { - return this.onTrigger((_, hap, __, cps, begin) => { + return this.onTrigger((hap, __, cps, begin) => { hap.value._begin = begin; hap.value._duration = hap.duration / cps; !doughWorklet && initDoughWorklet(); From 10156f00670462b0280351c549c92b4728a95bff Mon Sep 17 00:00:00 2001 From: Dsm0 Date: Tue, 15 Jul 2025 19:13:24 -0700 Subject: [PATCH 58/95] add basicSetup for keybindings --- packages/codemirror/basicSetup.mjs | 63 +++++++++++++++++++++++++++++ packages/codemirror/codemirror.mjs | 8 +++- packages/codemirror/keybindings.mjs | 5 ++- packages/codemirror/package.json | 2 +- pnpm-lock.yaml | 37 ++++++++++++----- 5 files changed, 101 insertions(+), 14 deletions(-) create mode 100644 packages/codemirror/basicSetup.mjs diff --git a/packages/codemirror/basicSetup.mjs b/packages/codemirror/basicSetup.mjs new file mode 100644 index 000000000..3faf7d7ab --- /dev/null +++ b/packages/codemirror/basicSetup.mjs @@ -0,0 +1,63 @@ +import { + keymap, + highlightSpecialChars, + drawSelection, + highlightActiveLine, + dropCursor, + rectangularSelection, + crosshairCursor, + lineNumbers, + highlightActiveLineGutter, +} from '@codemirror/view'; +import { + defaultHighlightStyle, + syntaxHighlighting, + bracketMatching, + foldGutter, + foldKeymap, +} from '@codemirror/language'; +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; +import { completionKeymap, closeBracketsKeymap } from '@codemirror/autocomplete'; + +// Taken + slightly modified from https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts + +export const basicSetup = (() => [ + lineNumbers(), + highlightActiveLineGutter(), + highlightSpecialChars(), + history(), + foldGutter(), + drawSelection(), + dropCursor(), + // EditorState.allowMultipleSelections.of(true), + // indentOnInput(), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + // autocompletion(), + rectangularSelection(), + crosshairCursor(), + highlightActiveLine(), + highlightSelectionMatches(), + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...searchKeymap, + ...historyKeymap, + ...foldKeymap, + ...completionKeymap, + ]), +])(); + +/// A minimal set of extensions to create a functional editor. Only +/// includes [the default keymap](#commands.defaultKeymap), [undo +/// history](#commands.history), [special character +/// highlighting](#view.highlightSpecialChars), [custom selection +/// drawing](#view.drawSelection), and [default highlight +/// style](#language.defaultHighlightStyle). +export const minimalSetup = (() => [ + highlightSpecialChars(), + history(), + drawSelection(), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + keymap.of([...defaultKeymap, ...historyKeymap]), +])(); diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index f47f6eba5..195971df9 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -2,7 +2,7 @@ import { closeBrackets } from '@codemirror/autocomplete'; export { toggleComment, toggleBlockComment, toggleLineComment, toggleBlockCommentByLine } from '@codemirror/commands'; // import { search, highlightSelectionMatches } from '@codemirror/search'; import { history, indentWithTab } from '@codemirror/commands'; -import { javascript } from '@codemirror/lang-javascript'; +import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; import { defaultHighlightStyle, syntaxHighlighting, bracketMatching } from '@codemirror/language'; import { Compartment, EditorState, Prec } from '@codemirror/state'; import { @@ -24,6 +24,7 @@ import { initTheme, activateTheme, theme } from './themes.mjs'; import { sliderPlugin, updateSliderWidgets } from './slider.mjs'; import { widgetPlugin, updateWidgets } from './widget.mjs'; import { persistentAtom } from '@nanostores/persistent'; +import { basicSetup } from './basicSetup.mjs'; const extensions = { isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []), @@ -85,7 +86,12 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo /* search(), highlightSelectionMatches(), */ ...initialSettings, + basicSetup, mondo ? [] : javascript(), + javascriptLanguage.data.of({ + closeBrackets: { brackets: ['(', '[', '{', "'", '"', '<'] }, + bracketMatching: { brackets: ['(', '[', '{', "'", '"', '<'] }, + }), sliderPlugin, widgetPlugin, // indentOnInput(), // works without. already brought with javascript extension? diff --git a/packages/codemirror/keybindings.mjs b/packages/codemirror/keybindings.mjs index 1201959a2..a72b164eb 100644 --- a/packages/codemirror/keybindings.mjs +++ b/packages/codemirror/keybindings.mjs @@ -3,8 +3,9 @@ import { keymap, ViewPlugin } from '@codemirror/view'; // import { searchKeymap } from '@codemirror/search'; import { emacs } from '@replit/codemirror-emacs'; import { vim } from '@replit/codemirror-vim'; +// import { vim } from './vim_test.mjs'; import { vscodeKeymap } from '@replit/codemirror-vscode-keymap'; -import { defaultKeymap, historyKeymap } from '@codemirror/commands'; +import { defaultKeymap } from '@codemirror/commands'; const vscodePlugin = ViewPlugin.fromClass( class { @@ -27,5 +28,5 @@ const keymaps = { export function keybindings(name) { const active = keymaps[name]; - return [active ? active() : [], keymap.of(historyKeymap)]; + return [active ? active() : []]; } diff --git a/packages/codemirror/package.json b/packages/codemirror/package.json index 4f8508c90..a802be2ed 100644 --- a/packages/codemirror/package.json +++ b/packages/codemirror/package.json @@ -42,7 +42,7 @@ "@lezer/highlight": "^1.2.1", "@nanostores/persistent": "^0.10.2", "@replit/codemirror-emacs": "^6.1.0", - "@replit/codemirror-vim": "^6.2.1", + "@replit/codemirror-vim": "^6.3.0", "@replit/codemirror-vscode-keymap": "^6.0.2", "@strudel/core": "workspace:*", "@strudel/draw": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7aecd35bd..810ffb9d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,8 +201,8 @@ importers: specifier: ^6.1.0 version: 6.1.0(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2) '@replit/codemirror-vim': - specifier: ^6.2.1 - version: 6.2.1(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2) + specifier: ^6.3.0 + version: 6.3.0(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2) '@replit/codemirror-vscode-keymap': specifier: ^6.0.2 version: 6.0.2(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2) @@ -215,6 +215,9 @@ importers: '@strudel/transpiler': specifier: workspace:* version: link:../transpiler + codemirror: + specifier: ^6.0.2 + version: 6.0.2 nanostores: specifier: ^0.11.3 version: 0.11.3 @@ -2232,14 +2235,14 @@ packages: '@codemirror/state': ^6.0.1 '@codemirror/view': ^6.3.0 - '@replit/codemirror-vim@6.2.1': - resolution: {integrity: sha512-qDAcGSHBYU5RrdO//qCmD8K9t6vbP327iCj/iqrkVnjbrpFhrjOt92weGXGHmTNRh16cUtkUZ7Xq7rZf+8HVow==} + '@replit/codemirror-vim@6.3.0': + resolution: {integrity: sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==} peerDependencies: - '@codemirror/commands': ^6.0.0 - '@codemirror/language': ^6.1.0 - '@codemirror/search': ^6.2.0 - '@codemirror/state': ^6.0.1 - '@codemirror/view': ^6.0.3 + '@codemirror/commands': 6.x.x + '@codemirror/language': 6.x.x + '@codemirror/search': 6.x.x + '@codemirror/state': 6.x.x + '@codemirror/view': 6.x.x '@replit/codemirror-vscode-keymap@6.0.2': resolution: {integrity: sha512-j45qTwGxzpsv82lMD/NreGDORFKSctMDVkGRopaP+OrzSzv+pXDQuU3LnFvKpasyjVT0lf+PKG1v2DSCn/vxxg==} @@ -3445,6 +3448,9 @@ packages: resolution: {integrity: sha512-FMabTRlc5t5zjdenF6mS0MBeFZm0XqHqeOkcskKFb/LYCcRQ5fVgLOHVc4Lq9CqABd9zhjwPjMBCJvMCziSVtA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -7657,6 +7663,7 @@ packages: workbox-google-analytics@7.0.0: resolution: {integrity: sha512-MEYM1JTn/qiC3DbpvP2BVhyIH+dV/5BjHk756u9VbwuAhu0QHyKscTnisQuz21lfRpOwiS9z4XdqeVAKol0bzg==} + deprecated: It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained workbox-navigation-preload@7.0.0: resolution: {integrity: sha512-juWCSrxo/fiMz3RsvDspeSLGmbgC0U9tKqcUPZBCf35s64wlaLXyn2KdHHXVQrb2cqF7I0Hc9siQalainmnXJA==} @@ -9595,7 +9602,7 @@ snapshots: '@codemirror/state': 6.5.1 '@codemirror/view': 6.36.2 - '@replit/codemirror-vim@6.2.1(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2)': + '@replit/codemirror-vim@6.3.0(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2)': dependencies: '@codemirror/commands': 6.8.0 '@codemirror/language': 6.10.8 @@ -11043,6 +11050,16 @@ snapshots: cmd-shim@6.0.3: {} + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.18.4 + '@codemirror/commands': 6.8.0 + '@codemirror/language': 6.10.8 + '@codemirror/lint': 6.8.4 + '@codemirror/search': 6.5.8 + '@codemirror/state': 6.5.1 + '@codemirror/view': 6.36.2 + collapse-white-space@2.1.0: {} color-convert@2.0.1: From 06fafbd8ebfdda48c86848e00ae13a49641b2c5d Mon Sep 17 00:00:00 2001 From: Dsm0 Date: Wed, 16 Jul 2025 15:03:55 -0700 Subject: [PATCH 59/95] Ensure no default extensions conflict with extensions in defaultSettings --- packages/codemirror/basicSetup.mjs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/codemirror/basicSetup.mjs b/packages/codemirror/basicSetup.mjs index 3faf7d7ab..de5b30417 100644 --- a/packages/codemirror/basicSetup.mjs +++ b/packages/codemirror/basicSetup.mjs @@ -23,20 +23,20 @@ import { completionKeymap, closeBracketsKeymap } from '@codemirror/autocomplete' // Taken + slightly modified from https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts export const basicSetup = (() => [ - lineNumbers(), - highlightActiveLineGutter(), + // lineNumbers(), + // highlightActiveLineGutter(), highlightSpecialChars(), history(), - foldGutter(), + // foldGutter(), drawSelection(), dropCursor(), // EditorState.allowMultipleSelections.of(true), // indentOnInput(), - syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + // syntaxHighlighting(defaultHighlightStyle, { fallback: true }), // autocompletion(), rectangularSelection(), crosshairCursor(), - highlightActiveLine(), + // highlightActiveLine(), highlightSelectionMatches(), keymap.of([ ...closeBracketsKeymap, From 5448fc607748bceec0002b92c93be4a7814d3b3b Mon Sep 17 00:00:00 2001 From: Dsm0 Date: Wed, 16 Jul 2025 15:17:34 -0700 Subject: [PATCH 60/95] removed duplication and unnecessary keymappings in codemirror config --- packages/codemirror/basicSetup.mjs | 16 ++++++++-------- packages/codemirror/codemirror.mjs | 3 +-- packages/codemirror/keybindings.mjs | 2 +- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/codemirror/basicSetup.mjs b/packages/codemirror/basicSetup.mjs index de5b30417..02294b93a 100644 --- a/packages/codemirror/basicSetup.mjs +++ b/packages/codemirror/basicSetup.mjs @@ -27,24 +27,24 @@ export const basicSetup = (() => [ // highlightActiveLineGutter(), highlightSpecialChars(), history(), - // foldGutter(), - drawSelection(), + // foldGutter(), + // drawSelection(), dropCursor(), // EditorState.allowMultipleSelections.of(true), // indentOnInput(), - // syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + // syntaxHighlighting(defaultHighlightStyle, { fallback: true }), // autocompletion(), rectangularSelection(), crosshairCursor(), - // highlightActiveLine(), - highlightSelectionMatches(), + // highlightActiveLine(), + // highlightSelectionMatches(), keymap.of([ ...closeBracketsKeymap, ...defaultKeymap, - ...searchKeymap, + // ...searchKeymap, ...historyKeymap, - ...foldKeymap, - ...completionKeymap, + // ...foldKeymap, + // ...completionKeymap, ]), ])(); diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 195971df9..4dc23996f 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -1,7 +1,7 @@ import { closeBrackets } from '@codemirror/autocomplete'; export { toggleComment, toggleBlockComment, toggleLineComment, toggleBlockCommentByLine } from '@codemirror/commands'; // import { search, highlightSelectionMatches } from '@codemirror/search'; -import { history, indentWithTab } from '@codemirror/commands'; +import { indentWithTab } from '@codemirror/commands'; import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; import { defaultHighlightStyle, syntaxHighlighting, bracketMatching } from '@codemirror/language'; import { Compartment, EditorState, Prec } from '@codemirror/state'; @@ -97,7 +97,6 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo // indentOnInput(), // works without. already brought with javascript extension? // bracketMatching(), // does not do anything syntaxHighlighting(defaultHighlightStyle), - history(), EditorView.updateListener.of((v) => onChange(v)), drawSelection({ cursorBlinkRate: 0 }), Prec.highest( diff --git a/packages/codemirror/keybindings.mjs b/packages/codemirror/keybindings.mjs index a72b164eb..ca5f34f4c 100644 --- a/packages/codemirror/keybindings.mjs +++ b/packages/codemirror/keybindings.mjs @@ -28,5 +28,5 @@ const keymaps = { export function keybindings(name) { const active = keymaps[name]; - return [active ? active() : []]; + return [active ? Prec.high(active()) : []]; } From 32fe73aba2949a4725f458844a558e275afd9591 Mon Sep 17 00:00:00 2001 From: Aria Date: Tue, 19 Aug 2025 11:10:34 -0500 Subject: [PATCH 61/95] Update effects documentation to include information on signal flow --- website/public/img/strudel-signal-flow.png | Bin 0 -> 37017 bytes website/src/pages/learn/effects.mdx | 118 +++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 website/public/img/strudel-signal-flow.png diff --git a/website/public/img/strudel-signal-flow.png b/website/public/img/strudel-signal-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..c5099c917c0e569dd8b5f78bb4eb36e0f7c77baf GIT binary patch literal 37017 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 + +The signal chain in Strudel is as follows: + +- An sound-generating event is triggered by a pattern + - This has a start time and a duration, which is usually +controlled by the note length and ADSR parameters + - If we exceed the max polyphony, old sounds begin to die off + - Muted sounds (one whose `s` value is `-`, `~`, or `_`) are skipped +- A sound is produced (through, say, a sample or an oscillator) + - This is where detune-based effects (like `detune`, `penv`, etc. occur) +- The following will only occur if their respective parameters are turned on. Note that all of these are +_single use_ effects, meaning that multiple occurrences of them in a pattern will simply override the values +(i.e. you can't (currently) do `s("bd").lpf(100).distort(2).lpf(800)` to lowpass, distort, and then lowpass +again) + - Phase vocoder (`stretch`) + - Gain is applied (`gain`) + - This is where the main (volume) ADSR happens + - A lowpass filter (`lpf`) + - A highpass filter (`hpf`) + - A bandpass filter (`bandpass`) + - A vowel filter (`vowel`) + - Sample rate reduction (`coarse`) + - Bit crushing (`crush`) + - Waveshape distortion (`shape`) + - Normal distortion (`distort`) + - Tremolo (`tremolo`) + - Compressor (`compressor`) + - Panning (`pan`) + - Phaser (`phaser`) + - Postgain (`post`) +- The sound is then split into multiple destinations + - Main output (amount controlled by `dry` parameter) + - This is where the `duck` function will apply sidechain + - Analyzer (used for tooling like `scope` and `spectrum`) + - Per-orbit effects (see the section below) + - Delay send (amount controlled by `delay` parameter) + - Reverb send (amount controlled by `delay` parameter) + +## Orbits + +Orbits are the way in which outputs are handled in Strudel. If you are listening in +normal circumstances, you will just hear all of them mixed down to stereo at the output. However you can also +use routers like Blackhole 16 to retrieve and record all of the split channels in a DAW for later processing. +By default all orbits are mono, however with the "Multi Channel Orbits" setting (under settings at the right) +you can use them as 2 channel stereo outs. + +The default orbit is `1` and it is set with `orbit`. You may send a sound to multiple orbits via mininotation + + + +but please be careful as this will create three copies of the sound behind the scenes, meaning that if they are mixed +down to a single output, they will triple the volume. We've reduced the gain here to save your ears. + +⚠️ There is only one delay and reverb per orbit, so please be aware that if you attempt to change the parameters on two +patterns pointing to the same orbit, it can lead to unpredictable results. Compare, for example, this pretty pluck +with a large reverb: + + + +versus the same pluck with a muted kick drum coming in and overwriting the `roomsize` value (occasionally) + + +This is due to them sharing the same orbit (the default of `1`). It can be corrected simply by updating the orbits to be +distinct: + + + +## Continuous changes + +As all of the above is triggered by a _sound occurring_, it is often the case that parameters may not be +modified continuously in time. For example, + + + +Will not produce a continually LFO'd low-pass filter due to the `tri` only being sample every time the note hits +(in this case the default of once per cycle). You can fake it by introducing more sound-generating events, e.g.: + + + +Some parameters _do_ induce continuous variations in time, though: +* The ADSR curve (governed by `attack`, `sustain`, `decay`, `release`) +* The pitch envelope curve (governed by `penv` and its associated ADSR) +* The FM curve (`fmenv`) +* The filter envelopes (`lpenv`, `hpenv`, `bpenv`) +* Tremolo +* Phaser + # Filters Filters are an essential building block of [subtractive synthesis](https://en.wikipedia.org/wiki/Subtractive_synthesis). From ce7cff2c3b6a25994e595b96849cd289474fb472 Mon Sep 17 00:00:00 2001 From: Aria Date: Tue, 19 Aug 2025 11:26:25 -0500 Subject: [PATCH 62/95] Clean up wording on orbit routing --- website/src/pages/learn/effects.mdx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/website/src/pages/learn/effects.mdx b/website/src/pages/learn/effects.mdx index 5e41eb92d..70218b78f 100644 --- a/website/src/pages/learn/effects.mdx +++ b/website/src/pages/learn/effects.mdx @@ -54,11 +54,9 @@ again) ## Orbits -Orbits are the way in which outputs are handled in Strudel. If you are listening in -normal circumstances, you will just hear all of them mixed down to stereo at the output. However you can also -use routers like Blackhole 16 to retrieve and record all of the split channels in a DAW for later processing. -By default all orbits are mono, however with the "Multi Channel Orbits" setting (under settings at the right) -you can use them as 2 channel stereo outs. +Orbits are the way in which outputs are handled in Strudel. By default all orbits are mixed down to channels `1` and `2` in stereo, however with the "Multi Channel Orbits" setting +(under settings at the right) you can use them as individual 2 channel stereo outs (orbit `i` will be mapped to +to channels `2i` and `2i + 1`). You can then use routers like Blackhole 16 to retrieve and record all of the channels in a DAW for later processing. The default orbit is `1` and it is set with `orbit`. You may send a sound to multiple orbits via mininotation From 59c8d707149ceeddda26aaa69bbdf7f7a42a4f40 Mon Sep 17 00:00:00 2001 From: Aria Date: Tue, 19 Aug 2025 11:27:56 -0500 Subject: [PATCH 63/95] Code format --- website/src/pages/learn/effects.mdx | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/website/src/pages/learn/effects.mdx b/website/src/pages/learn/effects.mdx index 70218b78f..4194757ef 100644 --- a/website/src/pages/learn/effects.mdx +++ b/website/src/pages/learn/effects.mdx @@ -19,15 +19,15 @@ The signal chain in Strudel is as follows: - An sound-generating event is triggered by a pattern - This has a start time and a duration, which is usually -controlled by the note length and ADSR parameters + controlled by the note length and ADSR parameters - If we exceed the max polyphony, old sounds begin to die off - Muted sounds (one whose `s` value is `-`, `~`, or `_`) are skipped - A sound is produced (through, say, a sample or an oscillator) - This is where detune-based effects (like `detune`, `penv`, etc. occur) - The following will only occur if their respective parameters are turned on. Note that all of these are -_single use_ effects, meaning that multiple occurrences of them in a pattern will simply override the values -(i.e. you can't (currently) do `s("bd").lpf(100).distort(2).lpf(800)` to lowpass, distort, and then lowpass -again) + _single use_ effects, meaning that multiple occurrences of them in a pattern will simply override the values + (i.e. you can't (currently) do `s("bd").lpf(100).distort(2).lpf(800)` to lowpass, distort, and then lowpass + again) - Phase vocoder (`stretch`) - Gain is applied (`gain`) - This is where the main (volume) ADSR happens @@ -77,13 +77,14 @@ $: s("triangle*4").decay(0.5).n(irand(12)).scale('C minor') /> versus the same pluck with a muted kick drum coming in and overwriting the `roomsize` value (occasionally) + This is due to them sharing the same orbit (the default of `1`). It can be corrected simply by updating the orbits to be @@ -95,7 +96,7 @@ distinct: $: s("triangle*4").decay(0.5).n(irand(12)).scale('C minor') .room(1).roomsize(10).orbit(2) -$: s("bd*4").room(0.01).roomsize(0.01).postgain(0)`} +$: s("bd\*4").room(0.01).roomsize(0.01).postgain(0)`} /> ## Continuous changes @@ -115,17 +116,17 @@ Will not produce a continually LFO'd low-pass filter due to the `tri` only being Some parameters _do_ induce continuous variations in time, though: -* The ADSR curve (governed by `attack`, `sustain`, `decay`, `release`) -* The pitch envelope curve (governed by `penv` and its associated ADSR) -* The FM curve (`fmenv`) -* The filter envelopes (`lpenv`, `hpenv`, `bpenv`) -* Tremolo -* Phaser + +- The ADSR curve (governed by `attack`, `sustain`, `decay`, `release`) +- The pitch envelope curve (governed by `penv` and its associated ADSR) +- The FM curve (`fmenv`) +- The filter envelopes (`lpenv`, `hpenv`, `bpenv`) +- Tremolo +- Phaser # Filters From 34e8a574726fa01e036a8147ddc729d090003dfb Mon Sep 17 00:00:00 2001 From: Aria Date: Tue, 19 Aug 2025 11:32:07 -0500 Subject: [PATCH 64/95] Typos grammar etc --- website/src/pages/learn/effects.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/pages/learn/effects.mdx b/website/src/pages/learn/effects.mdx index 4194757ef..268d7071a 100644 --- a/website/src/pages/learn/effects.mdx +++ b/website/src/pages/learn/effects.mdx @@ -87,7 +87,7 @@ $: s("triangle*4").decay(0.5).n(irand(12)).scale('C minor') $: s("bd\*4").room(0.01).roomsize(0.01).postgain(0)`} /> -This is due to them sharing the same orbit (the default of `1`). It can be corrected simply by updating the orbits to be +This is due to them sharing the same orbit: the default of `1`. It can be corrected simply by updating the orbits to be distinct: -Will not produce a continually LFO'd low-pass filter due to the `tri` only being sample every time the note hits +Will not produce a continually LFO'd low-pass filter due to the `tri` only being sampled every time the note hits (in this case the default of once per cycle). You can fake it by introducing more sound-generating events, e.g.: Date: Tue, 19 Aug 2025 16:06:18 -0500 Subject: [PATCH 65/95] Mention order of effects --- website/src/pages/learn/effects.mdx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/website/src/pages/learn/effects.mdx b/website/src/pages/learn/effects.mdx index 268d7071a..7a57b898f 100644 --- a/website/src/pages/learn/effects.mdx +++ b/website/src/pages/learn/effects.mdx @@ -24,9 +24,9 @@ The signal chain in Strudel is as follows: - Muted sounds (one whose `s` value is `-`, `~`, or `_`) are skipped - A sound is produced (through, say, a sample or an oscillator) - This is where detune-based effects (like `detune`, `penv`, etc. occur) -- The following will only occur if their respective parameters are turned on. Note that all of these are - _single use_ effects, meaning that multiple occurrences of them in a pattern will simply override the values - (i.e. you can't (currently) do `s("bd").lpf(100).distort(2).lpf(800)` to lowpass, distort, and then lowpass +- The following will occur _in order_ and only if they've been called in the pattern. Note that all of these are + single use effects, meaning that multiple occurrences of them in a pattern will simply override the values + (e.g. you can't do `s("bd").lpf(100).distort(2).lpf(800)` to lowpass, distort, and then lowpass again) - Phase vocoder (`stretch`) - Gain is applied (`gain`) @@ -127,6 +127,7 @@ Some parameters _do_ induce continuous variations in time, though: - The filter envelopes (`lpenv`, `hpenv`, `bpenv`) - Tremolo - Phaser +- Ducking (`duckorbit`) # Filters From a0fc52b1ec2d1d8786ae8f711fe777aadf15be8a Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 21 Aug 2025 14:42:52 -0500 Subject: [PATCH 66/95] Final tweaks --- website/src/pages/learn/effects.mdx | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/website/src/pages/learn/effects.mdx b/website/src/pages/learn/effects.mdx index 7a57b898f..d9efe8f8a 100644 --- a/website/src/pages/learn/effects.mdx +++ b/website/src/pages/learn/effects.mdx @@ -45,17 +45,21 @@ The signal chain in Strudel is as follows: - Phaser (`phaser`) - Postgain (`post`) - The sound is then split into multiple destinations - - Main output (amount controlled by `dry` parameter) - - This is where the `duck` function will apply sidechain - - Analyzer (used for tooling like `scope` and `spectrum`) - - Per-orbit effects (see the section below) - - Delay send (amount controlled by `delay` parameter) - - Reverb send (amount controlled by `delay` parameter) + - Dry output (amount controlled by `dry` parameter) + - The sends + - Analyzers + - These are used for tooling like `scope` and `spectrum` and their setup usually happens behind the scenes + - Delay (amount controlled by `delay` parameter) + - Reverb (amount controlled by `room` parameter) +- The dry output, delay, and reverb are joined into what is called the "orbit" of the pattern (see more in the section below) + - The `duck` effect affects the volume of all signals in the orbit + - The orbit is then sent to the mixer ## Orbits -Orbits are the way in which outputs are handled in Strudel. By default all orbits are mixed down to channels `1` and `2` in stereo, however with the "Multi Channel Orbits" setting -(under settings at the right) you can use them as individual 2 channel stereo outs (orbit `i` will be mapped to +Orbits are the way in which outputs are handled in Strudel. They also prescribe which delay and reverb to associate with the dry signal. +By default, all orbits are mixed down to channels `1` and `2` in stereo, however with the "Multi Channel Orbits" setting +(under Settings at the right) you can use them as individual 2 channel stereo outs (orbit `i` will be mapped to to channels `2i` and `2i + 1`). You can then use routers like Blackhole 16 to retrieve and record all of the channels in a DAW for later processing. The default orbit is `1` and it is set with `orbit`. You may send a sound to multiple orbits via mininotation @@ -76,7 +80,7 @@ $: s("triangle*4").decay(0.5).n(irand(12)).scale('C minor') .room(1).roomsize(10)`} /> -versus the same pluck with a muted kick drum coming in and overwriting the `roomsize` value (occasionally) +versus the same pluck with a muted kick drum coming in and overwriting the `roomsize` value: Date: Tue, 26 Aug 2025 12:53:38 -0500 Subject: [PATCH 67/95] Add a space after paragraphs in descriptions and add padding to hover tooltips --- website/src/repl/Repl.css | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/website/src/repl/Repl.css b/website/src/repl/Repl.css index b8443081f..b74eff218 100644 --- a/website/src/repl/Repl.css +++ b/website/src/repl/Repl.css @@ -72,8 +72,7 @@ /* Override default styles from the codemirror inline css for autocomplete info tooltip*/ .cm-tooltip.cm-completionInfo { - padding: 12px !important; - padding-bottom: 12px !important; + padding: 0 !important; border: 1px solid var(--foreground) !important; border-radius: 4px !important; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important; @@ -87,6 +86,8 @@ /* Main tooltip container */ .autocomplete-info-tooltip { + padding: 12px !important; + padding-bottom: 12px !important; border-radius: 4px !important; color: var(--foreground); font-family: var(--font-family, 'SF Mono', 'Monaco', monospace); @@ -97,6 +98,10 @@ min-width: 400px; } +.autocomplete-info-tooltip p { + margin-bottom: 1em; +} + .autocomplete-info-function-name { font-size: 15px; font-weight: 600; From 877bc95a5836bd4d45755dcc5ac673af1cc7e05b Mon Sep 17 00:00:00 2001 From: Aria Date: Tue, 26 Aug 2025 12:54:39 -0500 Subject: [PATCH 68/95] Switch to more specific class and fix p->div --- packages/codemirror/autocomplete.mjs | 2 +- website/src/repl/Repl.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/codemirror/autocomplete.mjs b/packages/codemirror/autocomplete.mjs index c0845895e..646107236 100644 --- a/packages/codemirror/autocomplete.mjs +++ b/packages/codemirror/autocomplete.mjs @@ -58,7 +58,7 @@ export const Autocomplete = ({ doc, label }) => h`

${label || getDocLabel(doc)}

- ${doc.description ? `

${doc.description}

` : ''} + ${doc.description ? `
${doc.description}
` : ''} ${buildParamsList(doc.params)} ${buildExamples(doc.examples)}
diff --git a/website/src/repl/Repl.css b/website/src/repl/Repl.css index b74eff218..ad8b6a40d 100644 --- a/website/src/repl/Repl.css +++ b/website/src/repl/Repl.css @@ -98,7 +98,7 @@ min-width: 400px; } -.autocomplete-info-tooltip p { +.autocomplete-info-function-description p { margin-bottom: 1em; } From a5886bb9d46259dea09539d29e7dc5bc9017e507 Mon Sep 17 00:00:00 2001 From: Aria Date: Tue, 26 Aug 2025 14:14:00 -0500 Subject: [PATCH 69/95] Use white-space instead; fix scrolling; don't close autocomplete tooltips on click --- packages/codemirror/autocomplete.mjs | 14 ++++++++------ website/src/repl/Repl.css | 15 +++++++++------ 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/codemirror/autocomplete.mjs b/packages/codemirror/autocomplete.mjs index 646107236..59ca8adf2 100644 --- a/packages/codemirror/autocomplete.mjs +++ b/packages/codemirror/autocomplete.mjs @@ -56,11 +56,13 @@ const buildExamples = (examples) => export const Autocomplete = ({ doc, label }) => h` -
-

${label || getDocLabel(doc)}

- ${doc.description ? `
${doc.description}
` : ''} - ${buildParamsList(doc.params)} - ${buildExamples(doc.examples)} +
+
+

${label || getDocLabel(doc)}

+ ${doc.description ? `
${doc.description}
` : ''} + ${buildParamsList(doc.params)} + ${buildExamples(doc.examples)} +
`[0]; @@ -98,4 +100,4 @@ export const strudelAutocomplete = (context) => { }; export const isAutoCompletionEnabled = (enabled) => - enabled ? [autocompletion({ override: [strudelAutocomplete] })] : []; + enabled ? [autocompletion({ override: [strudelAutocomplete], closeOnBlur: false })] : []; diff --git a/website/src/repl/Repl.css b/website/src/repl/Repl.css index ad8b6a40d..498679090 100644 --- a/website/src/repl/Repl.css +++ b/website/src/repl/Repl.css @@ -79,15 +79,12 @@ max-width: 500px !important; min-width: 300px !important; max-height: 400px !important; - white-space: normal !important; - overflow: auto !important; background-color: var(--lineHighlight) !important; } /* Main tooltip container */ -.autocomplete-info-tooltip { +.autocomplete-info-container { padding: 12px !important; - padding-bottom: 12px !important; border-radius: 4px !important; color: var(--foreground); font-family: var(--font-family, 'SF Mono', 'Monaco', monospace); @@ -96,10 +93,16 @@ max-width: 600px; max-height: 400px; min-width: 400px; + white-space: normal !important; + overflow-y: auto !important; } -.autocomplete-info-function-description p { - margin-bottom: 1em; +.autocomplete-info-tooltip { + overflow-y: auto !important; +} + +.autocomplete-info-function-description { + white-space: pre-wrap !important; } .autocomplete-info-function-name { From 189e650a733b85d183babc680b8e14fc49b1c486 Mon Sep 17 00:00:00 2001 From: Aria Date: Fri, 29 Aug 2025 12:18:28 -0500 Subject: [PATCH 70/95] Add flat flag, automatic json saving; filter out non-audio at base dir --- packages/sampler/sample-server.mjs | 74 ++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 19 deletions(-) diff --git a/packages/sampler/sample-server.mjs b/packages/sampler/sample-server.mjs index 08456add9..31b83f5a3 100644 --- a/packages/sampler/sample-server.mjs +++ b/packages/sampler/sample-server.mjs @@ -1,14 +1,20 @@ #!/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, resolve, sep } from 'path'; +import readline from 'readline'; import os from 'os'; -// eslint-disable-next-line const LOG = !!process.env.LOG || false; +const VALID_AUDIO_EXTENSIONS = ['wav', 'mp3', 'ogg']; + +const isAudioFile = (f) => { + const ext = f.split('.').slice(-1)[0].toLowerCase(); + return VALID_AUDIO_EXTENSIONS.includes(ext); +}; async function getFilesInDirectory(directory) { let files = []; @@ -21,32 +27,32 @@ async function getFilesInDirectory(directory) { continue; } try { - const subFiles = (await getFilesInDirectory(fullPath)).filter((f) => - ['wav', 'mp3', 'ogg'].includes(f.split('.').slice(-1)[0].toLowerCase()), - ); + const subFiles = (await getFilesInDirectory(fullPath)).filter(isAudioFile); files = files.concat(subFiles); LOG && console.log(`${dirent.name} (${subFiles.length})`); } catch (err) { LOG && console.warn(`skipped due to error: ${fullPath}`); } } else { - files.push(fullPath); + isAudioFile(fullPath) && files.push(fullPath); } } 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 : path.split('/').slice(-2)[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 +60,44 @@ async function getBanks(directory) { const args = process.argv.slice(2); -// eslint-disable-next-line -const directory = process.cwd(); +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]; + } +} +function getInput(query) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => + rl.question(query, (response) => { + rl.close(); + resolve(response); + }), + ); +} + +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'); + if (existsSync(outFile)) { + const answer = await getInput(`Warning: File already exists at ${outFile}. Overwrite? (y/N): `); + if (answer.toLowerCase() !== 'y') { + console.log('Aborted.'); + process.exit(0); + } + } + writeFileSync(outFile, json, 'utf8'); + console.log(`Wrote json to ${outFile}`); } console.log( @@ -74,7 +110,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 +118,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 6a09f54b25ebaa12abca4178412e94548f78ebac Mon Sep 17 00:00:00 2001 From: Aria Date: Sun, 31 Aug 2025 01:03:09 -0500 Subject: [PATCH 71/95] Add vibrato to list of continuous modulators --- website/src/pages/learn/effects.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/website/src/pages/learn/effects.mdx b/website/src/pages/learn/effects.mdx index d9efe8f8a..15df506c8 100644 --- a/website/src/pages/learn/effects.mdx +++ b/website/src/pages/learn/effects.mdx @@ -129,8 +129,9 @@ Some parameters _do_ induce continuous variations in time, though: - The pitch envelope curve (governed by `penv` and its associated ADSR) - The FM curve (`fmenv`) - The filter envelopes (`lpenv`, `hpenv`, `bpenv`) -- Tremolo -- Phaser +- Tremolo (`tremolo`) +- Phaser (`phaser`) +- Vibrato (`vib`) - Ducking (`duckorbit`) # Filters From 142160d79ae7a2eef1a6f38996b30352134b6cfc Mon Sep 17 00:00:00 2001 From: James Walker Date: Mon, 1 Sep 2025 21:08:06 +0100 Subject: [PATCH 72/95] Add examples for ? and | operators --- website/src/pages/learn/mini-notation.mdx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/website/src/pages/learn/mini-notation.mdx b/website/src/pages/learn/mini-notation.mdx index 7dbf67ce2..c7327bb86 100644 --- a/website/src/pages/learn/mini-notation.mdx +++ b/website/src/pages/learn/mini-notation.mdx @@ -168,6 +168,16 @@ Using "!" we can repeat without speeding up: *2")`} punchcard /> +## Randomness + +Events with a "?" placed after them will have a 50% chance of playing: + + + +Events separated by a "|" will be chosen from at random: + + + ## Mini-notation review To recap what we've learned so far, compare the following patterns: @@ -179,6 +189,8 @@ To recap what we've learned so far, compare the following patterns: *2")`} /> *2")`} /> *2")`} /> +*2")`} /> +*2")`} /> ## Euclidian rhythms From 591c3fe08f26ad8b21c5559b3dcdd3adc9ced236 Mon Sep 17 00:00:00 2001 From: James Walker Date: Wed, 3 Sep 2025 19:16:47 +0100 Subject: [PATCH 73/95] Add documentation for ?n in mini-notation --- website/src/pages/learn/mini-notation.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/website/src/pages/learn/mini-notation.mdx b/website/src/pages/learn/mini-notation.mdx index c7327bb86..02ead7434 100644 --- a/website/src/pages/learn/mini-notation.mdx +++ b/website/src/pages/learn/mini-notation.mdx @@ -170,10 +170,14 @@ Using "!" we can repeat without speeding up: ## Randomness -Events with a "?" placed after them will have a 50% chance of playing: +Events with a "?" placed after them will have a 50% chance of being removed from the pattern: +Adding a number between 0 and 1 after the "?" will affect the likelihood of the event being removed. For example, events with "?0.1" placed after them will have a 10% chance of being removed: + + + Events separated by a "|" will be chosen from at random: From 0a96fa5896848d83a7fed79a00d98d029cf8e6e5 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sat, 6 Sep 2025 00:42:36 +0200 Subject: [PATCH 74/95] hotfix: comment out tauri stuff to fix osc --- packages/osc/superdirtoutput.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/osc/superdirtoutput.js b/packages/osc/superdirtoutput.js index 3f48e66bd..e8fef0301 100644 --- a/packages/osc/superdirtoutput.js +++ b/packages/osc/superdirtoutput.js @@ -1,8 +1,8 @@ -import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs'; -import { isTauri } from '../desktopbridge/utils.mjs'; +/* import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs'; +import { isTauri } from '../desktopbridge/utils.mjs'; */ import { oscTrigger } from './osc.mjs'; -const trigger = isTauri() ? oscTriggerTauri : oscTrigger; +const trigger = /* isTauri() ? oscTriggerTauri : */ oscTrigger; export const superdirtOutput = (hap, deadline, hapDuration, cps, targetTime) => { const currentTime = performance.now() / 1000; From 2fbdc9fdd730a147b1e38398e7d3d8a2ba9cb89a Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sat, 6 Sep 2025 00:43:03 +0200 Subject: [PATCH 75/95] hotfix: formatting --- website/src/repl/components/panel/SettingsTab.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 977420e74dfe4a0e2c93b94f6d346a21f765a77e Mon Sep 17 00:00:00 2001 From: "Daniel D. Beck" Date: Sat, 6 Sep 2025 15:33:08 +0200 Subject: [PATCH 76/95] Fix formatting of REPL footnote --- website/src/pages/technical-manual/repl.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/src/pages/technical-manual/repl.mdx b/website/src/pages/technical-manual/repl.mdx index f53efac41..8c4287af5 100644 --- a/website/src/pages/technical-manual/repl.mdx +++ b/website/src/pages/technical-manual/repl.mdx @@ -9,7 +9,9 @@ import { MiniRepl } from '../../docs/MiniRepl'; {/* The [REPL](https://strudel.cc/) is the place where all packages come together to form a live coding system. It can also be seen as a reference implementation for users of the library. */} -While Strudel can be used as a library in any JavaScript codebase, its main, reference user interface is the Strudel REPL^[REPL stands for read, evaluate, print/play, loop. It is friendly jargon for an interactive programming interface from computing heritage, usually for a commandline interface but also applied to live coding editors.], which is a browser-based live coding environment. This live code editor is dedicated to manipulating Strudel patterns while they play. The REPL features built-in visual feedback, highlighting which elements in the patterned (mini-notation) sequences are influencing the event that is currently being played. This feedback is designed to support both learning and live use of Strudel. +While Strudel can be used as a library in any JavaScript codebase, its main, reference user interface is the Strudel REPL[^1], which is a browser-based live coding environment. This live code editor is dedicated to manipulating Strudel patterns while they play. The REPL features built-in visual feedback, highlighting which elements in the patterned (mini-notation) sequences are influencing the event that is currently being played. This feedback is designed to support both learning and live use of Strudel. + +[^1]: REPL stands for read, evaluate, print/play, loop. It is friendly jargon for an interactive programming interface from computing heritage, usually for a commandline interface but also applied to live coding editors. Besides a UI for playback control and meta information, the main part of the REPL interface is the code editor powered by CodeMirror. In it, the user can edit and evaluate pattern code live, using one of the available synthesis outputs to create music and/or sound art. The control flow of the REPL follows 3 basic steps: From 2511dcc09e180d9b5fdb3ac1591ddc0059999739 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Sun, 7 Sep 2025 15:02:13 -0400 Subject: [PATCH 77/95] working --- 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 a518f49b6..792e5a28d 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -413,7 +413,7 @@ function connectToOrbit(node, orbit) { function setOrbit(audioContext, orbit, channels) { if (orbits[orbit] == null) { orbits[orbit] = { - gain: new GainNode(audioContext, { gain: 1 }), + gain: new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }), }; connectToDestination(orbits[orbit].gain, channels); } From e7e80bfd83b3661e92a93def0c84011e330fb512 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Sun, 7 Sep 2025 22:21:03 -0400 Subject: [PATCH 78/95] working --- .../repl/components/button/action-button.jsx | 10 +++++ .../src/repl/components/panel/PatternsTab.jsx | 13 +----- .../src/repl/components/panel/SoundsTab.jsx | 41 ++++++++++++++----- website/src/repl/idbutils.mjs | 8 +++- website/src/settings.mjs | 10 ++++- 5 files changed, 58 insertions(+), 24 deletions(-) create mode 100644 website/src/repl/components/button/action-button.jsx diff --git a/website/src/repl/components/button/action-button.jsx b/website/src/repl/components/button/action-button.jsx new file mode 100644 index 000000000..d589b7bed --- /dev/null +++ b/website/src/repl/components/button/action-button.jsx @@ -0,0 +1,10 @@ +import cx from '@src/cx.mjs'; + +export function ActionButton({ children, label, labelIsHidden, className, ...buttonProps }) { + return ( + + ); +} diff --git a/website/src/repl/components/panel/PatternsTab.jsx b/website/src/repl/components/panel/PatternsTab.jsx index 8a43e4a1b..8e2b75b96 100644 --- a/website/src/repl/components/panel/PatternsTab.jsx +++ b/website/src/repl/components/panel/PatternsTab.jsx @@ -12,8 +12,8 @@ import { useMemo } from 'react'; import { getMetadata } from '../../../metadata_parser.js'; import { useExamplePatterns } from '../../useExamplePatterns.jsx'; import { parseJSON, isUdels } from '../../util.mjs'; -import { ButtonGroup } from './Forms.jsx'; -import { settingsMap, useSettings } from '../../../settings.mjs'; +import { useSettings } from '../../../settings.mjs'; +import { ActionButton } from '../button/action-button.jsx'; import { Pagination } from '../pagination/Pagination.jsx'; import { useState } from 'react'; import { useDebounce } from '../usedebounce.jsx'; @@ -75,15 +75,6 @@ function PatternButtons({ patterns, activePattern, onClick, started }) { ); } -function ActionButton({ children, onClick, label, labelIsHidden }) { - return ( - - ); -} - const updateCodeWindow = (context, patternData, reset = false) => { context.handleUpdate(patternData, reset); }; diff --git a/website/src/repl/components/panel/SoundsTab.jsx b/website/src/repl/components/panel/SoundsTab.jsx index a976eb3d2..7b91f4cf2 100644 --- a/website/src/repl/components/panel/SoundsTab.jsx +++ b/website/src/repl/components/panel/SoundsTab.jsx @@ -2,16 +2,21 @@ import useEvent from '@src/useEvent.mjs'; import { useStore } from '@nanostores/react'; import { getAudioContext, soundMap, connectToDestination } from '@strudel/webaudio'; import { useMemo, useRef, useState } from 'react'; -import { settingsMap, useSettings } from '../../../settings.mjs'; +import { settingsMap, soundFilterType, useSettings } from '../../../settings.mjs'; import { ButtonGroup } from './Forms.jsx'; import ImportSoundsButton from './ImportSoundsButton.jsx'; import { Textbox } from '../textbox/Textbox.jsx'; +import { ActionButton } from '../button/action-button.jsx'; +import { confirmDialog } from '@src/repl/util.mjs'; +import { clearIDB, userSamplesDBConfig } from '@src/repl/idbutils.mjs'; +import { prebake } from '@src/repl/prebake.mjs'; const getSamples = (samples) => Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1; export function SoundsTab() { const sounds = useStore(soundMap); + const { soundsFilter } = useSettings(); const [search, setSearch] = useState(''); const { BASE_URL } = import.meta.env; @@ -27,18 +32,19 @@ export function SoundsTab() { .sort((a, b) => a[0].localeCompare(b[0])) .filter(([name]) => name.toLowerCase().includes(search.toLowerCase())); - if (soundsFilter === 'user') { + if (soundsFilter === soundFilterType.USER) { return filtered.filter(([_, { data }]) => !data.prebake); } - if (soundsFilter === 'drums') { + if (soundsFilter === soundFilterType.DRUMS) { return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag === 'drum-machines'); } - if (soundsFilter === 'samples') { + if (soundsFilter === soundFilterType.SAMPLES) { return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag !== 'drum-machines'); } - if (soundsFilter === 'synths') { + if (soundsFilter === soundFilterType.SYNTHS) { return filtered.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type)); } + //TODO: tidy this up, it does not need to be saved in settings if (soundsFilter === 'importSounds') { return []; } @@ -57,10 +63,10 @@ export function SoundsTab() { }); }); return ( -
+
setSearch(v)} /> -
+
settingsMap.setKey('soundsFilter', value)} @@ -73,6 +79,23 @@ export function SoundsTab() { }} >
+ { + { + try { + const confirmed = await confirmDialog('Delete all imported user samples?'); + if (confirmed) { + clearIDB(userSamplesDBConfig.dbName); + soundMap.set({}); + await prebake(); + } + } catch (e) { + console.error(e); + } + }} + /> + }
{soundEntries.map(([name, { data, onTrigger }]) => { @@ -151,9 +174,7 @@ export function SoundsTab() { ) : ( '' )} - {!soundEntries.length && soundsFilter !== 'importSounds' - ? 'No custom sounds loaded in this pattern (yet).' - : ''} + {!soundEntries.length && soundsFilter !== 'importSounds' ? 'No custom sounds loaded (yet).' : ''}
); diff --git a/website/src/repl/idbutils.mjs b/website/src/repl/idbutils.mjs index 5fc62c576..f26ee0479 100644 --- a/website/src/repl/idbutils.mjs +++ b/website/src/repl/idbutils.mjs @@ -12,17 +12,21 @@ export const userSamplesDBConfig = { }; // deletes all of the databases, useful for debugging -function clearIDB() { +function clearAllIDB() { window.indexedDB .databases() .then((r) => { - for (var i = 0; i < r.length; i++) window.indexedDB.deleteDatabase(r[i].name); + for (var i = 0; i < r.length; i++) clearIDB(r[i].name); }) .then(() => { alert('All data cleared.'); }); } +export function clearIDB(dbName) { + return window.indexedDB.deleteDatabase(dbName); +} + // queries the DB, and registers the sounds so they can be played export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete = () => {}) { openDB(config, (objectStore) => { diff --git a/website/src/settings.mjs b/website/src/settings.mjs index 3d99b656c..9c3d78146 100644 --- a/website/src/settings.mjs +++ b/website/src/settings.mjs @@ -8,6 +8,14 @@ export const audioEngineTargets = { osc: 'osc', }; +export const soundFilterType = { + USER: 'user', + DRUMS: 'drums', + SAMPLES: 'samples', + SYNTHS: 'synths', + ALL: 'all', +}; + export const defaultSettings = { activeFooter: 'intro', keybindings: 'codemirror', @@ -28,7 +36,7 @@ export const defaultSettings = { fontSize: 18, latestCode: '', isZen: false, - soundsFilter: 'all', + soundsFilter: soundFilterType.ALL, patternFilter: 'community', // panelPosition: window.innerWidth > 1000 ? 'right' : 'bottom', //FIX: does not work on astro panelPosition: 'right', From 5414bbe85ddec4729506bd6891593ba8418f2e7f Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Sun, 7 Sep 2025 22:27:48 -0400 Subject: [PATCH 79/95] only show button on user samples --- website/src/repl/components/panel/SoundsTab.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/src/repl/components/panel/SoundsTab.jsx b/website/src/repl/components/panel/SoundsTab.jsx index 7b91f4cf2..de22fb3e8 100644 --- a/website/src/repl/components/panel/SoundsTab.jsx +++ b/website/src/repl/components/panel/SoundsTab.jsx @@ -79,7 +79,7 @@ export function SoundsTab() { }} >
- { + {soundsFilter === soundFilterType.USER && soundEntries.length > 0 && ( { @@ -95,7 +95,7 @@ export function SoundsTab() { } }} /> - } + )}
{soundEntries.map(([name, { data, onTrigger }]) => { @@ -174,7 +174,7 @@ export function SoundsTab() { ) : ( '' )} - {!soundEntries.length && soundsFilter !== 'importSounds' ? 'No custom sounds loaded (yet).' : ''} + {!soundEntries.length && soundsFilter !== 'importSounds' ? 'No sounds loaded' : ''}
); From a96854545823a3d8a546d3590c07797df539316d Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Sun, 7 Sep 2025 22:41:03 -0400 Subject: [PATCH 80/95] change sound background to distinguish action buttons --- website/src/repl/components/panel/SoundsTab.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/repl/components/panel/SoundsTab.jsx b/website/src/repl/components/panel/SoundsTab.jsx index de22fb3e8..d1845bfa8 100644 --- a/website/src/repl/components/panel/SoundsTab.jsx +++ b/website/src/repl/components/panel/SoundsTab.jsx @@ -97,7 +97,7 @@ export function SoundsTab() { /> )} -
+
{soundEntries.map(([name, { data, onTrigger }]) => { return ( Date: Sun, 7 Sep 2025 22:43:13 -0400 Subject: [PATCH 81/95] fix formatting --- .../src/repl/components/panel/SoundsTab.jsx | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/website/src/repl/components/panel/SoundsTab.jsx b/website/src/repl/components/panel/SoundsTab.jsx index d1845bfa8..92f30070e 100644 --- a/website/src/repl/components/panel/SoundsTab.jsx +++ b/website/src/repl/components/panel/SoundsTab.jsx @@ -79,23 +79,26 @@ export function SoundsTab() { }} >
- {soundsFilter === soundFilterType.USER && soundEntries.length > 0 && ( - { - try { - const confirmed = await confirmDialog('Delete all imported user samples?'); - if (confirmed) { - clearIDB(userSamplesDBConfig.dbName); - soundMap.set({}); - await prebake(); +
+ {soundsFilter === soundFilterType.USER && soundEntries.length > 0 && ( + { + try { + const confirmed = await confirmDialog('Delete all imported user samples?'); + if (confirmed) { + clearIDB(userSamplesDBConfig.dbName); + soundMap.set({}); + await prebake(); + } + } catch (e) { + console.error(e); } - } catch (e) { - console.error(e); - } - }} - /> - )} + }} + /> + )} +
{soundEntries.map(([name, { data, onTrigger }]) => { From 70e776e799e586f1ffb787db6d15fae871f08399 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Sun, 7 Sep 2025 22:44:09 -0400 Subject: [PATCH 82/95] cleanup div --- .../src/repl/components/panel/SoundsTab.jsx | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/website/src/repl/components/panel/SoundsTab.jsx b/website/src/repl/components/panel/SoundsTab.jsx index 92f30070e..433d53712 100644 --- a/website/src/repl/components/panel/SoundsTab.jsx +++ b/website/src/repl/components/panel/SoundsTab.jsx @@ -79,26 +79,26 @@ export function SoundsTab() { }} >
-
- {soundsFilter === soundFilterType.USER && soundEntries.length > 0 && ( - { - try { - const confirmed = await confirmDialog('Delete all imported user samples?'); - if (confirmed) { - clearIDB(userSamplesDBConfig.dbName); - soundMap.set({}); - await prebake(); - } - } catch (e) { - console.error(e); + + {soundsFilter === soundFilterType.USER && soundEntries.length > 0 && ( + { + try { + const confirmed = await confirmDialog('Delete all imported user samples?'); + if (confirmed) { + clearIDB(userSamplesDBConfig.dbName); + soundMap.set({}); + await prebake(); } - }} - /> - )} -
+ } catch (e) { + console.error(e); + } + }} + /> + )} +
{soundEntries.map(([name, { data, onTrigger }]) => { From 99bb227cf480e67a59bac1432908e8a77c8be0f6 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Sun, 7 Sep 2025 22:48:04 -0400 Subject: [PATCH 83/95] format --- website/src/repl/components/panel/SoundsTab.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/website/src/repl/components/panel/SoundsTab.jsx b/website/src/repl/components/panel/SoundsTab.jsx index 433d53712..0484da02d 100644 --- a/website/src/repl/components/panel/SoundsTab.jsx +++ b/website/src/repl/components/panel/SoundsTab.jsx @@ -99,7 +99,6 @@ export function SoundsTab() { /> )} -
{soundEntries.map(([name, { data, onTrigger }]) => { return ( From 89e7d5230885e2ca372e6df2f4ba4343f08eb648 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Sun, 7 Sep 2025 23:28:18 -0400 Subject: [PATCH 84/95] fixed --- packages/osc/superdirtoutput.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/osc/superdirtoutput.js b/packages/osc/superdirtoutput.js index e8fef0301..a317af8f1 100644 --- a/packages/osc/superdirtoutput.js +++ b/packages/osc/superdirtoutput.js @@ -6,5 +6,5 @@ const trigger = /* isTauri() ? oscTriggerTauri : */ oscTrigger; export const superdirtOutput = (hap, deadline, hapDuration, cps, targetTime) => { const currentTime = performance.now() / 1000; - return trigger(null, hap, currentTime, cps, targetTime); + return trigger(hap, currentTime, cps, targetTime); }; From 5e5e7730c1cd2ccc133766e369998e672d0021dd Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Sun, 7 Sep 2025 23:29:24 -0400 Subject: [PATCH 85/95] rm errorlogger console --- packages/core/logger.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/logger.mjs b/packages/core/logger.mjs index 488bce5e8..4f2002319 100644 --- a/packages/core/logger.mjs +++ b/packages/core/logger.mjs @@ -6,7 +6,7 @@ let debounce = 1000, export function errorLogger(e, origin = 'cyclist') { //TODO: add some kind of debug flag that enables this while in dev mode - console.error(e); + // console.error(e); logger(`[${origin}] error: ${e.message}`); } From d0ce82e3cd37e6299329fefec23c5af9e5441d0e Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Wed, 10 Sep 2025 23:08:16 +0200 Subject: [PATCH 86/95] Publish - @strudel/codemirror@1.2.4 - @strudel/core@1.2.4 - @strudel/csound@1.2.5 - @strudel/draw@1.2.4 - @strudel/gamepad@1.2.4 - @strudel/hydra@1.2.4 - @strudel/midi@1.2.5 - @strudel/mini@1.2.4 - @strudel/mondo@1.1.2 - @strudel/motion@1.2.4 - @strudel/mqtt@1.2.4 - @strudel/osc@1.2.4 - @strudel/repl@1.2.5 - @strudel/sampler@0.2.3 - @strudel/serial@1.2.4 - @strudel/soundfonts@1.2.5 - superdough@1.2.5 - @strudel/tonal@1.2.4 - @strudel/transpiler@1.2.4 - @strudel/web@1.2.5 - @strudel/webaudio@1.2.5 - @strudel/xen@1.2.4 --- packages/codemirror/package.json | 2 +- packages/core/package.json | 2 +- packages/csound/package.json | 2 +- packages/draw/package.json | 2 +- packages/gamepad/package.json | 2 +- packages/hydra/package.json | 2 +- packages/midi/package.json | 2 +- packages/mini/package.json | 2 +- packages/mondough/package.json | 2 +- packages/motion/package.json | 2 +- packages/mqtt/package.json | 2 +- packages/osc/package.json | 2 +- packages/repl/package.json | 2 +- packages/sampler/package.json | 2 +- packages/serial/package.json | 2 +- packages/soundfonts/package.json | 2 +- packages/superdough/package.json | 2 +- packages/tonal/package.json | 2 +- packages/transpiler/package.json | 2 +- packages/web/package.json | 2 +- packages/webaudio/package.json | 2 +- packages/xen/package.json | 2 +- 22 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/codemirror/package.json b/packages/codemirror/package.json index 797a4312b..609df6ede 100644 --- a/packages/codemirror/package.json +++ b/packages/codemirror/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/codemirror", - "version": "1.2.3", + "version": "1.2.4", "description": "Codemirror Extensions for Strudel", "main": "index.mjs", "publishConfig": { diff --git a/packages/core/package.json b/packages/core/package.json index 33540c8bf..7cf20cea7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/core", - "version": "1.2.3", + "version": "1.2.4", "description": "Port of Tidal Cycles to JavaScript", "main": "index.mjs", "type": "module", diff --git a/packages/csound/package.json b/packages/csound/package.json index 19b1dd6fd..90130a101 100644 --- a/packages/csound/package.json +++ b/packages/csound/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/csound", - "version": "1.2.4", + "version": "1.2.5", "description": "csound bindings for strudel", "main": "index.mjs", "type": "module", diff --git a/packages/draw/package.json b/packages/draw/package.json index f63367402..6a4c57540 100644 --- a/packages/draw/package.json +++ b/packages/draw/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/draw", - "version": "1.2.3", + "version": "1.2.4", "description": "Helpers for drawing with Strudel", "main": "index.mjs", "type": "module", diff --git a/packages/gamepad/package.json b/packages/gamepad/package.json index 53fad18a6..555eac03f 100644 --- a/packages/gamepad/package.json +++ b/packages/gamepad/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/gamepad", - "version": "1.2.3", + "version": "1.2.4", "description": "Gamepad Inputs for strudel", "main": "index.mjs", "type": "module", diff --git a/packages/hydra/package.json b/packages/hydra/package.json index 1553264d4..7ba79d5d0 100644 --- a/packages/hydra/package.json +++ b/packages/hydra/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/hydra", - "version": "1.2.3", + "version": "1.2.4", "description": "Hydra integration for strudel", "main": "hydra.mjs", "type": "module", diff --git a/packages/midi/package.json b/packages/midi/package.json index 8dde35984..2342cf7e9 100644 --- a/packages/midi/package.json +++ b/packages/midi/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/midi", - "version": "1.2.4", + "version": "1.2.5", "description": "Midi API for strudel", "main": "index.mjs", "type": "module", diff --git a/packages/mini/package.json b/packages/mini/package.json index e8fca4ac6..6eeaab0da 100644 --- a/packages/mini/package.json +++ b/packages/mini/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/mini", - "version": "1.2.3", + "version": "1.2.4", "description": "Mini notation for strudel", "main": "index.mjs", "type": "module", diff --git a/packages/mondough/package.json b/packages/mondough/package.json index 99a87f098..f26281490 100644 --- a/packages/mondough/package.json +++ b/packages/mondough/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/mondo", - "version": "1.1.1", + "version": "1.1.2", "description": "mondo notation for strudel", "main": "mondough.mjs", "type": "module", diff --git a/packages/motion/package.json b/packages/motion/package.json index acacc154f..57cac9cc8 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/motion", - "version": "1.2.3", + "version": "1.2.4", "description": "DeviceMotion API for strudel", "main": "index.mjs", "type": "module", diff --git a/packages/mqtt/package.json b/packages/mqtt/package.json index d5ac6dd71..2e32825fe 100644 --- a/packages/mqtt/package.json +++ b/packages/mqtt/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/mqtt", - "version": "1.2.3", + "version": "1.2.4", "description": "MQTT API for strudel", "main": "mqtt.mjs", "type": "module", diff --git a/packages/osc/package.json b/packages/osc/package.json index 6e8b57813..d5a272328 100644 --- a/packages/osc/package.json +++ b/packages/osc/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/osc", - "version": "1.2.3", + "version": "1.2.4", "description": "OSC messaging for strudel", "main": "osc.mjs", "type": "module", diff --git a/packages/repl/package.json b/packages/repl/package.json index aabd1b8c7..547ec979f 100644 --- a/packages/repl/package.json +++ b/packages/repl/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/repl", - "version": "1.2.4", + "version": "1.2.5", "description": "Strudel REPL as a Web Component", "module": "index.mjs", "publishConfig": { diff --git a/packages/sampler/package.json b/packages/sampler/package.json index dd701bcfb..2bf0607b1 100644 --- a/packages/sampler/package.json +++ b/packages/sampler/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/sampler", - "version": "0.2.2", + "version": "0.2.3", "description": "", "keywords": [ "tidalcycles", diff --git a/packages/serial/package.json b/packages/serial/package.json index 8abbe7692..9ed89cf2a 100644 --- a/packages/serial/package.json +++ b/packages/serial/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/serial", - "version": "1.2.3", + "version": "1.2.4", "description": "Webserial API for strudel", "main": "serial.mjs", "type": "module", diff --git a/packages/soundfonts/package.json b/packages/soundfonts/package.json index e5c1780ed..07e35674b 100644 --- a/packages/soundfonts/package.json +++ b/packages/soundfonts/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/soundfonts", - "version": "1.2.4", + "version": "1.2.5", "description": "Soundsfont support for strudel", "main": "index.mjs", "publishConfig": { diff --git a/packages/superdough/package.json b/packages/superdough/package.json index 809da0227..a82117774 100644 --- a/packages/superdough/package.json +++ b/packages/superdough/package.json @@ -1,6 +1,6 @@ { "name": "superdough", - "version": "1.2.4", + "version": "1.2.5", "description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.", "main": "index.mjs", "type": "module", diff --git a/packages/tonal/package.json b/packages/tonal/package.json index de02e4b1f..1461bdc8e 100644 --- a/packages/tonal/package.json +++ b/packages/tonal/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/tonal", - "version": "1.2.3", + "version": "1.2.4", "description": "Tonal functions for strudel", "main": "index.mjs", "publishConfig": { diff --git a/packages/transpiler/package.json b/packages/transpiler/package.json index f9ebdfde1..18722bdc2 100644 --- a/packages/transpiler/package.json +++ b/packages/transpiler/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/transpiler", - "version": "1.2.3", + "version": "1.2.4", "description": "Transpiler for strudel user code. Converts syntactically correct but semantically meaningless JS into evaluatable strudel code.", "main": "index.mjs", "type": "module", diff --git a/packages/web/package.json b/packages/web/package.json index 3f9cc50d1..df21f4055 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/web", - "version": "1.2.4", + "version": "1.2.5", "description": "Easy to setup, opiniated bundle of Strudel for the browser.", "module": "web.mjs", "publishConfig": { diff --git a/packages/webaudio/package.json b/packages/webaudio/package.json index 78988340a..cbe673a5a 100644 --- a/packages/webaudio/package.json +++ b/packages/webaudio/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/webaudio", - "version": "1.2.4", + "version": "1.2.5", "description": "Web Audio helpers for Strudel", "main": "index.mjs", "type": "module", diff --git a/packages/xen/package.json b/packages/xen/package.json index 1cb751157..0a6736d9c 100644 --- a/packages/xen/package.json +++ b/packages/xen/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/xen", - "version": "1.2.3", + "version": "1.2.4", "description": "Xenharmonic API for strudel", "main": "index.mjs", "type": "module", From 5e2e6411e5e67cb2458132f3e643ebe1d265c5cd Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 11 Sep 2025 08:26:20 +0200 Subject: [PATCH 87/95] Publish - @strudel/codemirror@1.2.5 - @strudel/repl@1.2.6 --- packages/codemirror/package.json | 2 +- packages/repl/package.json | 2 +- pnpm-lock.yaml | 18 ++---------------- 3 files changed, 4 insertions(+), 18 deletions(-) diff --git a/packages/codemirror/package.json b/packages/codemirror/package.json index be240a338..803b33f3d 100644 --- a/packages/codemirror/package.json +++ b/packages/codemirror/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/codemirror", - "version": "1.2.4", + "version": "1.2.5", "description": "Codemirror Extensions for Strudel", "main": "index.mjs", "publishConfig": { diff --git a/packages/repl/package.json b/packages/repl/package.json index 547ec979f..41a165c34 100644 --- a/packages/repl/package.json +++ b/packages/repl/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/repl", - "version": "1.2.5", + "version": "1.2.6", "description": "Strudel REPL as a Web Component", "module": "index.mjs", "publishConfig": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 810ffb9d5..f4a24f61c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,9 +215,6 @@ importers: '@strudel/transpiler': specifier: workspace:* version: link:../transpiler - codemirror: - specifier: ^6.0.2 - version: 6.0.2 nanostores: specifier: ^0.11.3 version: 0.11.3 @@ -3448,9 +3445,6 @@ packages: resolution: {integrity: sha512-FMabTRlc5t5zjdenF6mS0MBeFZm0XqHqeOkcskKFb/LYCcRQ5fVgLOHVc4Lq9CqABd9zhjwPjMBCJvMCziSVtA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - codemirror@6.0.2: - resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} - collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -5720,6 +5714,7 @@ packages: node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead node-fetch-native@1.6.6: resolution: {integrity: sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==} @@ -6809,6 +6804,7 @@ packages: source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions sourcemap-codec@1.4.8: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} @@ -11050,16 +11046,6 @@ snapshots: cmd-shim@6.0.3: {} - codemirror@6.0.2: - dependencies: - '@codemirror/autocomplete': 6.18.4 - '@codemirror/commands': 6.8.0 - '@codemirror/language': 6.10.8 - '@codemirror/lint': 6.8.4 - '@codemirror/search': 6.5.8 - '@codemirror/state': 6.5.1 - '@codemirror/view': 6.36.2 - collapse-white-space@2.1.0: {} color-convert@2.0.1: From 27339715b77c57d30a6bc5841ff7f5103a6c876b Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 11 Sep 2025 17:22:06 +0200 Subject: [PATCH 88/95] fix: exclude mondough dependencies --- packages/mondough/vite.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mondough/vite.config.js b/packages/mondough/vite.config.js index c46972e96..06c8c4559 100644 --- a/packages/mondough/vite.config.js +++ b/packages/mondough/vite.config.js @@ -1,5 +1,5 @@ import { defineConfig } from 'vite'; -//import { dependencies } from './package.json'; +import { dependencies } from './package.json'; import { resolve } from 'path'; // https://vitejs.dev/config/ @@ -12,7 +12,7 @@ export default defineConfig({ fileName: (ext) => ({ es: 'mondough.mjs' })[ext], }, rollupOptions: { - // external: [...Object.keys(dependencies)], + external: [...Object.keys(dependencies)], }, target: 'esnext', }, From 702e558d1a76813e71e143bef25bec68a81ecc04 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 11 Sep 2025 17:30:38 +0200 Subject: [PATCH 89/95] Publish - @strudel/mondo@1.1.3 --- packages/mondough/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mondough/package.json b/packages/mondough/package.json index f26281490..7cec4fd02 100644 --- a/packages/mondough/package.json +++ b/packages/mondough/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/mondo", - "version": "1.1.2", + "version": "1.1.3", "description": "mondo notation for strudel", "main": "mondough.mjs", "type": "module", From c42d00f04866a19f31ded90ed9e582b09f79b813 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 11 Sep 2025 19:11:45 +0200 Subject: [PATCH 90/95] hotfix: export mondo getLocations --- 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 15feb5d86..e409d5a3b 100644 --- a/packages/mondough/mondough.mjs +++ b/packages/mondough/mondough.mjs @@ -108,7 +108,7 @@ export function mondo(code, offset = 0) { return pat.markcss('color: var(--caret,--foreground);text-decoration:underline'); } -let getLocations = (code, offset) => runner.parser.get_locations(code, offset); +export let getLocations = (code, offset) => runner.parser.get_locations(code, offset); export const mondi = (str, offset) => { const code = `[${str}]`; From 637d714ab27e5bf29499c2cce0a15b7cadb58f71 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 11 Sep 2025 19:14:00 +0200 Subject: [PATCH 91/95] Publish - @strudel/mondo@1.1.4 --- packages/mondough/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mondough/package.json b/packages/mondough/package.json index 7cec4fd02..c81d76cb6 100644 --- a/packages/mondough/package.json +++ b/packages/mondough/package.json @@ -1,6 +1,6 @@ { "name": "@strudel/mondo", - "version": "1.1.3", + "version": "1.1.4", "description": "mondo notation for strudel", "main": "mondough.mjs", "type": "module", From c0bf86d11e3d795558b5dd01821f700a5e962070 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 11 Sep 2025 23:19:22 +0200 Subject: [PATCH 92/95] Revert "Merge pull request 'supradough poc' (#1362) from supradough into main" This reverts commit 6f06683d707ed5d04464af94d1a978ef21df84bd, reversing changes made to 637d714ab27e5bf29499c2cce0a15b7cadb58f71. --- eslint.config.mjs | 1 - packages/core/controls.mjs | 30 +- packages/superdough/sampler.mjs | 91 +- packages/superdough/superdough.mjs | 10 +- packages/supradough/.gitignore | 1 - packages/supradough/README.md | 3 - packages/supradough/dough-export.mjs | 123 --- packages/supradough/dough-worklet.mjs | 39 - packages/supradough/dough.mjs | 976 ---------------------- packages/supradough/index.mjs | 4 - packages/supradough/package.json | 37 - packages/webaudio/index.mjs | 1 - packages/webaudio/package.json | 3 +- packages/webaudio/supradough.mjs | 130 --- packages/webaudio/webaudio.mjs | 7 +- pnpm-lock.yaml | 20 - test/__snapshots__/examples.test.mjs.snap | 80 +- 17 files changed, 63 insertions(+), 1493 deletions(-) delete mode 100644 packages/supradough/.gitignore delete mode 100644 packages/supradough/README.md delete mode 100644 packages/supradough/dough-export.mjs delete mode 100644 packages/supradough/dough-worklet.mjs delete mode 100644 packages/supradough/dough.mjs delete mode 100644 packages/supradough/index.mjs delete mode 100644 packages/supradough/package.json delete mode 100644 packages/webaudio/supradough.mjs diff --git a/eslint.config.mjs b/eslint.config.mjs index c9ff40ca1..e30b8e8a6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -42,7 +42,6 @@ export default [ '**/hydra.mjs', '**/jsdoc-synonyms.js', 'packages/hs2js/src/hs2js.mjs', - 'packages/supradough/dough-export.mjs', '**/samples', ], }, diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 473ea82d1..58a30652c 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -309,17 +309,6 @@ export const { fmvelocity } = registerControl('fmvelocity'); */ export const { bank } = registerControl('bank'); -/** - * mix control for the chorus effect - * - * @name chorus - * @param {string | Pattern} chorus mix amount between 0 and 1 - * @example - * note("d d a# a").s("sawtooth").chorus(.5) - * - */ -export const { chorus } = registerControl('chorus'); - // analyser node send amount 0 - 1 (used by scope) export const { analyze } = registerControl('analyze'); // fftSize of analyser @@ -1108,27 +1097,14 @@ export const { delay } = registerControl(['delay', 'delaytime', 'delayfeedback'] * */ export const { delayfeedback, delayfb, dfb } = registerControl('delayfeedback', 'delayfb', 'dfb'); - -/** - * Sets the level of the signal that is fed back into the delay. - * Caution: Values >= 1 will result in a signal that gets louder and louder! Don't do it - * - * @name delayfeedback - * @param {number | Pattern} feedback between 0 and 1 - * @synonyms delayfb, dfb - * @example - * s("bd").delay(.25).delayfeedback("<.25 .5 .75 1>") - * - */ -export const { delayspeed } = registerControl('delayspeed'); /** * Sets the time of the delay effect. * - * @name delayspeed - * @param {number | Pattern} delayspeed controls the pitch of the delay feedback + * @name delaytime + * @param {number | Pattern} seconds between 0 and Infinity * @synonyms delayt, dt * @example - * note("d d a# a".fast(2)).s("sawtooth").delay(.8).delaytime(1/2).delayspeed("<2 .5 -1 -2>") + * s("bd bd").delay(.25).delaytime("<.125 .25 .5 1>") * */ export const { delaytime, delayt, dt } = registerControl('delaytime', 'delayt', 'dt'); diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index 9188c17c3..18d1b7797 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -196,52 +196,6 @@ function getSamplesPrefixHandler(url) { return; } -export async function fetchSampleMap(url) { - // check if custom prefix handler - const handler = getSamplesPrefixHandler(url); - if (handler) { - return handler(url); - } - url = resolveSpecialPaths(url); - if (url.startsWith('github:')) { - url = githubPath(url, 'strudel.json'); - } - if (url.startsWith('local:')) { - url = `http://localhost:5432`; - } - if (url.startsWith('shabda:')) { - let [_, path] = url.split('shabda:'); - url = `https://shabda.ndre.gr/${path}.json?strudel=1`; - } - if (url.startsWith('shabda/speech')) { - let [_, path] = url.split('shabda/speech'); - path = path.startsWith('/') ? path.substring(1) : path; - let [params, words] = path.split(':'); - let gender = 'f'; - let language = 'en-GB'; - if (params) { - [language, gender] = params.split('/'); - } - url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`; - } - 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; - } - const json = await fetch(url) - .then((res) => res.json()) - .catch((error) => { - console.error(error); - throw new Error(`error loading "${url}"`); - }); - return [json, json._base || base]; -} - /** * Loads a collection of samples to use with `s` * @example @@ -263,8 +217,49 @@ export async function fetchSampleMap(url) { export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => { if (typeof sampleMap === 'string') { - const [json, base] = await fetchSampleMap(sampleMap); - return samples(json, baseUrl || base, options); + // check if custom prefix handler + const handler = getSamplesPrefixHandler(sampleMap); + if (handler) { + return handler(sampleMap); + } + sampleMap = resolveSpecialPaths(sampleMap); + if (sampleMap.startsWith('github:')) { + sampleMap = githubPath(sampleMap, 'strudel.json'); + } + if (sampleMap.startsWith('local:')) { + sampleMap = `http://localhost:5432`; + } + if (sampleMap.startsWith('shabda:')) { + let [_, path] = sampleMap.split('shabda:'); + sampleMap = `https://shabda.ndre.gr/${path}.json?strudel=1`; + } + if (sampleMap.startsWith('shabda/speech')) { + let [_, path] = sampleMap.split('shabda/speech'); + path = path.startsWith('/') ? path.substring(1) : path; + let [params, words] = path.split(':'); + let gender = 'f'; + let language = 'en-GB'; + if (params) { + [language, gender] = params.split('/'); + } + sampleMap = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`; + } + if (typeof fetch !== 'function') { + // not a browser + return; + } + const base = sampleMap.split('/').slice(0, -1).join('/'); + if (typeof fetch === 'undefined') { + // skip fetch when in node / testing + return; + } + return fetch(sampleMap) + .then((res) => res.json()) + .then((json) => samples(json, baseUrl || json._base || base, options)) + .catch((error) => { + console.error(error); + throw new Error(`error loading "${sampleMap}"`); + }); } const { prebake, tag } = options; processSampleMap( diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 0c084e3e9..792e5a28d 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -196,7 +196,7 @@ export const resetLoadedSounds = () => soundMap.set({}); let audioContext; export const setDefaultAudioContext = () => { - audioContext = new AudioContext({ latencyHint: 'playback' }); + audioContext = new AudioContext(); return audioContext; }; @@ -212,17 +212,11 @@ export function getAudioContextCurrentTime() { return getAudioContext().currentTime; } -let externalWorklets = []; -export function registerWorklet(url) { - externalWorklets.push(url); -} - let workletsLoading; function loadWorklets() { if (!workletsLoading) { const audioCtx = getAudioContext(); - const allWorkletURLs = externalWorklets.concat([workletsUrl]); - workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL))); + workletsLoading = audioCtx.audioWorklet.addModule(workletsUrl); } return workletsLoading; diff --git a/packages/supradough/.gitignore b/packages/supradough/.gitignore deleted file mode 100644 index d21cbdf3e..000000000 --- a/packages/supradough/.gitignore +++ /dev/null @@ -1 +0,0 @@ -pattern.wav diff --git a/packages/supradough/README.md b/packages/supradough/README.md deleted file mode 100644 index a8cfa84b3..000000000 --- a/packages/supradough/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# supradough - -platform agnostic synth and sampler intended for live coding. a reimplementation of superdough. \ No newline at end of file diff --git a/packages/supradough/dough-export.mjs b/packages/supradough/dough-export.mjs deleted file mode 100644 index bd4b530b3..000000000 --- a/packages/supradough/dough-export.mjs +++ /dev/null @@ -1,123 +0,0 @@ -// this is a poc of how a pattern can be rendered as a wav file using node -// run via: node dough-export.mjs -import fs from 'node:fs'; -import WavEncoder from 'wav-encoder'; -import { evalScope } from '@strudel/core'; -import { miniAllStrings } from '@strudel/mini'; -import { Dough } from './dough.mjs'; - -await evalScope( - import('@strudel/core'), - import('@strudel/mini'), - import('@strudel/tonal'), - // import('@strudel/tonal'), -); - -miniAllStrings(); // allows using single quotes for mini notation / skip transpilation - -let sampleRate = 48000, - cps = 0.4; - -/* await doughsamples('github:eddyflux/crate'); -await doughsamples('github:eddyflux/wax'); */ - -let pat = note('c,eb,g,') - .s('sine') - .press() - .add(note(24)) - .fmi(3) - .fmh(5.01) - .dec(0.4) - .delay('.6:<.12 .22>:.8') - .jux(press) - .rarely(add(note('12'))) - .lpf(400) - .lpq(0.2) - .lpd(0.4) - .lpenv(3) - .fmdecay(0.4) - .fmenv(1) - .postgain(0.6) - .stack(s('*8').dec(0.07).rarely(ply('2')).delay(0.5).hpf(sine.range(200, 2000).slow(4)).hpq(0.2)) - .stack( - s('[- white@3]*2') - .dec(0.4) - .hpf('<2000!3 <4000 8000>>*4') - .hpq(0.6) - .ply('<1 2>*4') - .postgain(0.5) - .delay(0.5) - .jux(rev) - .lpf(5000), - ) - .stack( - note('*2') - .s('square') - .lpf(sine.range(100, 300).slow(4)) - .lpe(1) - .segment(8) - .lpd(0.3) - .lpq(0.2) - .dec(0.2) - .speed('<1 2>') - .ply('<1 2>') - .postgain(1), - ) - .stack( - chord('') - .voicing() - .s('') - .clip(1) - .rel(0.4) - .vib('4:.2') - .gain(0.7) - .hpf(1200) - .fm(0.5) - .att(1) - .lpa(0.5) - .lpf(200) - .lpenv(4) - .chorus(0.8), - ) - .slow(1 / cps); - -let cycles = 30; -let seconds = cycles + 1; // 1s release tail -const haps = pat.queryArc(0, cycles); - -const dough = new Dough(sampleRate); - -console.log('spawn voices...'); -haps.forEach((hap) => { - hap.value._begin = Number(hap.whole.begin); - hap.value._duration = hap.duration /* / cps */; - dough.scheduleSpawn(hap.value); -}); -console.log(`render ${seconds}s long buffer, each dot is 1 second:`); -const buffers = [new Float32Array(seconds * sampleRate), new Float32Array(seconds * sampleRate)]; -let t = performance.now(); -while (dough.t <= buffers[0].length) { - dough.update(); - buffers[0][dough.t] = dough.out[0]; - buffers[1][dough.t] = dough.out[1]; - if (dough.t % sampleRate === 0) { - process.stdout.write('.'); - } -} -const took = (performance.now() - t) / 1000; -const load = (took / seconds) * 100; -const speed = (seconds / took).toFixed(2); -console.log(''); -console.log(`done! -rendered ${seconds}s in ${took.toFixed(2)}s -speed: ${speed}x -load: ${load.toFixed(2)}%`); - -const patternAudio = { - sampleRate, - channelData: buffers, -}; - -WavEncoder.encode(patternAudio).then((buffer) => { - fs.writeFileSync('pattern.wav', new Float32Array(buffer)); -}); diff --git a/packages/supradough/dough-worklet.mjs b/packages/supradough/dough-worklet.mjs deleted file mode 100644 index 18c316b2d..000000000 --- a/packages/supradough/dough-worklet.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import { Dough } from './dough.mjs'; - -const clamp = (num, min, max) => Math.min(Math.max(num, min), max); - -class DoughProcessor extends AudioWorkletProcessor { - constructor() { - super(); - this.dough = new Dough(sampleRate, currentTime); - this.port.onmessage = (event) => { - if (event.data.spawn) { - this.dough.scheduleSpawn(event.data.spawn); - } else if (event.data.sample) { - this.dough.loadSample(event.data.sample, event.data.channels, event.data.sampleRate); - } else if (event.data.samples) { - event.data.samples.forEach(([name, channels, sampleRate]) => { - this.dough.loadSample(name, channels, sampleRate); - }); - } else { - console.log('unrecognized event type', event.data); - } - }; - } - process(inputs, outputs, params) { - if (this.disconnected) { - return false; - } - const output = outputs[0]; - for (let i = 0; i < output[0].length; i++) { - this.dough.update(); - for (let c = 0; c < output.length; c++) { - //prevent speaker blowout via clipping if threshold exceeds - output[c][i] = clamp(this.dough.out[c], -1, 1); - } - } - return true; // keep the audio processing going - } -} - -registerProcessor('dough-processor', DoughProcessor); diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs deleted file mode 100644 index 711922b63..000000000 --- a/packages/supradough/dough.mjs +++ /dev/null @@ -1,976 +0,0 @@ -// this is dough, the superdough without dependencies -const SAMPLE_RATE = typeof sampleRate !== 'undefined' ? sampleRate : 48000; -const PI_DIV_SR = Math.PI / SAMPLE_RATE; -const ISR = 1 / SAMPLE_RATE; - -let gainCurveFunc = (val) => Math.pow(val, 2); - -function applyGainCurve(val) { - return gainCurveFunc(val); -} - -/** - * Equal Power Crossfade function. - * Smoothly transitions between signals A and B, maintaining consistent perceived loudness. - * - * @param {number} a - Signal A (can be a single value or an array value in buffer processing). - * @param {number} b - Signal B (can be a single value or an array value in buffer processing). - * @param {number} m - Crossfade parameter (0.0 = all A, 1.0 = all B, 0.5 = equal mix). - * @returns {number} Crossfaded output value. - */ -function crossfade(a, b, m) { - const aGain = Math.sin((1 - m) * 0.5 * Math.PI); - const bGain = Math.sin(m * 0.5 * Math.PI); - return a * aGain + b * bGain; -} - -// function setGainCurve(newGainCurveFunc) { -// gainCurveFunc = newGainCurveFunc; -// } -// https://garten.salat.dev/audio-DSP/oscillators.html -export class SineOsc { - phase = 0; - update(freq) { - const value = Math.sin(this.phase * 2 * Math.PI); - this.phase = (this.phase + freq / SAMPLE_RATE) % 1; - return value; - } -} - -export class ZawOsc { - phase = 0; - update(freq) { - this.phase += ISR * freq; - return (this.phase % 1) * 2 - 1; - } -} - -function polyBlep(t, dt) { - // 0 <= t < 1 - if (t < dt) { - t /= dt; - // 2 * (t - t^2/2 - 0.5) - return t + t - t * t - 1; - } - // -1 < t < 0 - if (t > 1 - dt) { - t = (t - 1) / dt; - // 2 * (t^2/2 + t + 0.5) - return t * t + t + t + 1; - } - // 0 otherwise - return 0; -} - -export class SawOsc { - constructor(props = {}) { - this.phase = props.phase ?? 0; - } - update(freq) { - const dt = freq / SAMPLE_RATE; - let p = polyBlep(this.phase, dt); - let s = 2 * this.phase - 1 - p; - this.phase += dt; - if (this.phase > 1) { - this.phase -= 1; - } - return s; - } -} - -function getUnisonDetune(unison, detune, voiceIndex) { - if (unison < 2) { - return 0; - } - const lerp = (a, b, n) => { - return n * (b - a) + a; - }; - return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1)); -} -function applySemitoneDetuneToFrequency(frequency, detune) { - return frequency * Math.pow(2, detune / 12); -} -export class SupersawOsc { - constructor(props = {}) { - //TODO: figure out a good way to pass in these params - this.voices = props.voices ?? 5; - this.freqspread = props.freqspread ?? 0.2; - this.panspread = props.panspread ?? 0.4; - this.phase = new Float32Array(this.voices).map(() => Math.random()); - } - update(freq) { - const gain1 = Math.sqrt(1 - this.panspread); - const gain2 = Math.sqrt(this.panspread); - let sl = 0; - let sr = 0; - for (let n = 0; n < this.voices; n++) { - const freqAdjusted = applySemitoneDetuneToFrequency(freq, getUnisonDetune(this.voices, this.freqspread, n)); - const dt = freqAdjusted / SAMPLE_RATE; - const isOdd = (n & 1) == 1; - let gainL = gain1; - let gainR = gain2; - // invert right and left gain - if (isOdd) { - gainL = gain2; - gainR = gain1; - } - let p = polyBlep(this.phase[n], dt); - let s = 2 * this.phase[n] - 1 - p; - sl = sl + s * gainL; - sr = sr + s * gainL; - - this.phase[n] += dt; - if (this.phase[n] > 1) { - this.phase[n] -= 1; - } - } - - return sl + sr; - //TODO: make stereo - // return [sl, sr]; - } -} - -export class TriOsc { - phase = 0; - update(freq) { - this.phase += ISR * freq; - let phase = this.phase % 1; - let value = phase < 0.5 ? 2 * phase : 1 - 2 * (phase - 0.5); - return value * 2 - 1; - } -} - -export class TwoPoleFilter { - s0 = 0; - s1 = 0; - update(s, cutoff, resonance = 0) { - // Out of bound values can produce NaNs - resonance = Math.max(resonance, 0); - - cutoff = Math.min(cutoff, 20000); - const c = 2 * Math.sin(cutoff * PI_DIV_SR); - - const r = Math.pow(0.5, (resonance + 0.125) / 0.125); - const mrc = 1 - r * c; - - this.s0 = mrc * this.s0 - c * this.s1 + c * s; // bpf - this.s1 = mrc * this.s1 + c * this.s0; // lpf - return this.s1; // return lpf by default - } -} - -class PulseOsc { - constructor(phase = 0) { - this.phase = phase; - } - saw(offset, dt) { - let phase = (this.phase + offset) % 1; - let p = polyBlep(phase, dt); - return 2 * phase - 1 - p; - } - update(freq, pw = 0.5) { - const dt = freq / SAMPLE_RATE; - let pulse = this.saw(0, dt) - this.saw(pw, dt); - this.phase = (this.phase + dt) % 1; - return pulse + pw * 2 - 1; - } -} - -// non bandlimited (has aliasing) -export class PulzeOsc { - phase = 0; - update(freq, duty = 0.5) { - this.phase += ISR * freq; - let cyclePos = this.phase % 1; - return cyclePos < duty ? 1 : -1; - } -} - -export class Dust { - update = (density) => (Math.random() < density * ISR ? Math.random() : 0); -} - -export class WhiteNoise { - update() { - return Math.random() * 2 - 1; - } -} - -export class BrownNoise { - constructor() { - this.out = 0; - } - update() { - let white = Math.random() * 2 - 1; - this.out = (this.out + 0.02 * white) / 1.02; - return this.out; - } -} - -export class PinkNoise { - constructor() { - this.b0 = 0; - this.b1 = 0; - this.b2 = 0; - this.b3 = 0; - this.b4 = 0; - this.b5 = 0; - this.b6 = 0; - } - - update() { - const white = Math.random() * 2 - 1; - - this.b0 = 0.99886 * this.b0 + white * 0.0555179; - this.b1 = 0.99332 * this.b1 + white * 0.0750759; - this.b2 = 0.969 * this.b2 + white * 0.153852; - this.b3 = 0.8665 * this.b3 + white * 0.3104856; - this.b4 = 0.55 * this.b4 + white * 0.5329522; - this.b5 = -0.7616 * this.b5 - white * 0.016898; - - const pink = this.b0 + this.b1 + this.b2 + this.b3 + this.b4 + this.b5 + this.b6 + white * 0.5362; - this.b6 = white * 0.115926; - - return pink * 0.11; - } -} - -export class Impulse { - phase = 1; - update(freq) { - this.phase += ISR * freq; - let v = this.phase >= 1 ? 1 : 0; - this.phase = this.phase % 1; - return v; - } -} - -export class ClockDiv { - inSgn = true; - outSgn = true; - clockCnt = 0; - update(clock, factor) { - let curSgn = clock > 0; - if (this.inSgn != curSgn) { - this.clockCnt++; - if (this.clockCnt >= factor) { - this.clockCnt = 0; - this.outSgn = !this.outSgn; - } - } - - this.inSgn = curSgn; - return this.outSgn ? 1 : -1; - } -} - -export class Hold { - value = 0; - trigSgn = false; - update(input, trig) { - if (!this.trigSgn && trig > 0) this.value = input; - this.trigSgn = trig > 0; - return this.value; - } -} - -function lerp(x, y0, y1, exponent = 1) { - if (x <= 0) return y0; - if (x >= 1) return y1; - - let curvedX; - - if (exponent === 0) { - curvedX = x; // linear - } else if (exponent > 0) { - curvedX = Math.pow(x, exponent); // ease-in - } else { - curvedX = 1 - Math.pow(1 - x, -exponent); // ease-out - } - - return y0 + (y1 - y0) * curvedX; -} - -export class ADSR { - constructor(props = {}) { - this.state = 'off'; - this.startTime = 0; - this.startVal = 0; - this.decayCurve = props.decayCurve ?? 1; - } - - update(curTime, gate, attack, decay, susVal, release) { - switch (this.state) { - case 'off': { - if (gate > 0) { - this.state = 'attack'; - this.startTime = curTime; - this.startVal = 0; - } - return 0; - } - case 'attack': { - let time = curTime - this.startTime; - if (time > attack) { - this.state = 'decay'; - this.startTime = curTime; - return 1; - } - return lerp(time / attack, this.startVal, 1, 1); - } - case 'decay': { - let time = curTime - this.startTime; - let curVal = lerp(time / decay, 1, susVal, -this.decayCurve); - if (gate <= 0) { - this.state = 'release'; - this.startTime = curTime; - this.startVal = curVal; - return curVal; - } - if (time > decay) { - this.state = 'sustain'; - this.startTime = curTime; - return susVal; - } - return curVal; - } - case 'sustain': { - if (gate <= 0) { - this.state = 'release'; - this.startTime = curTime; - this.startVal = susVal; - } - return susVal; - } - case 'release': { - let time = curTime - this.startTime; - - if (time > release) { - this.state = 'off'; - return 0; - } - let curVal = lerp(time / release, this.startVal, 0, -this.decayCurve); - if (gate > 0) { - this.state = 'attack'; - this.startTime = curTime; - this.startVal = curVal; - } - return curVal; - } - } - throw 'invalid envelope state'; - } -} - -/* - impulse(1).ad(.1).mul(sine(200)) -.add(x=>x.delay(.1).mul(.8)) -.out()*/ -const MAX_DELAY_TIME = 10; -export class PitchDelay { - lpf = new TwoPoleFilter(); - constructor(_props = {}) { - this.buffer = new Float32Array(MAX_DELAY_TIME * SAMPLE_RATE); - this.writeIdx = 0; - this.readIdx = 0; - this.numSamples = 0; - } - write(s, delayTime) { - // Calculate how far in the past to read - this.numSamples = Math.min(Math.floor(SAMPLE_RATE * delayTime), this.buffer.length - 1); - this.writeIdx = (this.writeIdx + 1) % this.numSamples; - this.buffer[this.writeIdx] = s; - this.readIdx = this.writeIdx - this.numSamples + 1; - - // If past the start of the buffer, wrap around (Q: is this possible?) - if (this.readIdx < 0) this.readIdx += this.numSamples; - } - update(input, delayTime, speed = 1) { - this.write(input, delayTime); - let index = this.readIdx; - if (speed < 0) { - index = this.numSamples - Math.floor(Math.abs(this.readIdx * speed) % this.numSamples); - } else { - index = Math.floor(this.readIdx * speed) % this.numSamples; - } - const s = this.lpf.update(this.buffer[index], 0.9, 0); - - return s; - } -} - -export class Delay { - writeIdx = 0; - readIdx = 0; - buffer = new Float32Array(MAX_DELAY_TIME * SAMPLE_RATE); //.fill(0) - write(s, delayTime) { - this.writeIdx = (this.writeIdx + 1) % this.buffer.length; - this.buffer[this.writeIdx] = s; - // Calculate how far in the past to read - let numSamples = Math.min(Math.floor(SAMPLE_RATE * delayTime), this.buffer.length - 1); - this.readIdx = this.writeIdx - numSamples; - // If past the start of the buffer, wrap around - if (this.readIdx < 0) this.readIdx += this.buffer.length; - } - update(input, delayTime) { - this.write(input, delayTime); - return this.buffer[this.readIdx]; - } -} -//TODO: Figure out why clicking at the start off the buffer -export class Chorus { - delay = new Delay(); - modulator = new TriOsc(); - update(input, mix, delayTime, modulationFreq, modulationDepth) { - const m = this.modulator.update(modulationFreq) * modulationDepth; - const c = this.delay.update(input, delayTime * (1 + m)); - return crossfade(input, c, mix); - } -} - -export class Fold { - update(input = 0, rate = 0) { - if (rate < 0) rate = 0; - rate = rate + 1; - input = input * rate; - return 4 * (Math.abs(0.25 * input + 0.25 - Math.round(0.25 * input + 0.25)) - 0.25); - } -} - -export class Lag { - lagUnit = 4410; - s = 0; - update(input, rate) { - // Remap so the useful range is around [0, 1] - rate = rate * this.lagUnit; - if (rate < 1) rate = 1; - this.s += (1 / rate) * (input - this.s); - return this.s; - } -} - -export class Slew { - last = 0; - update(input, up, dn) { - const upStep = up * ISR; - const downStep = dn * ISR; - let delta = input - this.last; - if (delta > upStep) { - delta = upStep; - } else if (delta < -downStep) { - delta = -downStep; - } - this.last += delta; - return this.last; - } -} - -// overdrive style distortion (adapted from noisecraft) currently unused -export function applyDistortion(x, amount) { - amount = Math.min(Math.max(amount, 0), 1); - amount -= 0.01; - var k = (2 * amount) / (1 - amount); - var y = ((1 + k) * x) / (1 + k * Math.abs(x)); - return y; -} - -export class Sequence { - clockSgn = true; - step = 0; - first = true; - update(clock, ...ins) { - if (!this.clockSgn && clock > 0) { - this.step = (this.step + 1) % ins.length; - this.clockSgn = clock > 0; - return 0; // set first sample to zero to retrigger gates on step change... - } - this.clockSgn = clock > 0; - return ins[this.step]; - } -} - -// sample rate bit crusher -export class Coarse { - hold = 0; - t = 0; - update(input, coarse) { - if (this.t++ % coarse === 0) { - this.t = 0; - this.hold = input; - } - return this.hold; - } -} - -// amplitude bit crusher -export class Crush { - update(input, crush) { - crush = Math.max(1, crush); - const x = Math.pow(2, crush - 1); - return Math.round(input * x) / x; - } -} - -// this is the distort from superdough -export class Distort { - update(input, distort = 0, postgain = 1) { - postgain = Math.max(0.001, Math.min(1, postgain)); - const shape = Math.expm1(distort); - return (((1 + shape) * input) / (1 + shape * Math.abs(input))) * postgain; - } -} -// distortion could be expressed as a function, because it's stateless - -export class BufferPlayer { - static samples = new Map(); // string -> { channels, sampleRate } - buffer; // Float32Array - sampleRate; - pos = 0; - sampleFreq = note2freq(); - constructor(buffer, sampleRate, normalize) { - this.buffer = buffer; - this.sampleRate = sampleRate; - this.duration = this.buffer.length / this.sampleRate; - this.speed = SAMPLE_RATE / this.sampleRate; - if (normalize) { - // this will make the buffer last 1s if freq = sampleFreq - // it's useful to loop samples (e.g. fit function) - this.speed *= this.duration; - } - } - update(freq) { - if (this.pos >= this.buffer.length) { - return 0; - } - const speed = (freq / this.sampleFreq) * this.speed; - let s = this.buffer[Math.floor(this.pos)]; - this.pos = this.pos + speed; - return s; - } -} - -export function _rangex(sig, min, max) { - let logmin = Math.log(min); - let range = Math.log(max) - logmin; - const unipolar = (sig + 1) / 2; - return Math.exp(unipolar * range + logmin); -} - -// duplicate -export const getADSR = (params, curve = 'linear', defaultValues) => { - const envmin = curve === 'exponential' ? 0.001 : 0.001; - const releaseMin = 0.01; - const envmax = 1; - const [a, d, s, r] = params; - if (a == null && d == null && s == null && r == null) { - return defaultValues ?? [envmin, envmin, envmax, releaseMin]; - } - const sustain = s != null ? s : (a != null && d == null) || (a == null && d == null) ? envmax : envmin; - return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)]; -}; - -let shapes = { - sine: SineOsc, - saw: SawOsc, - zaw: ZawOsc, - sawtooth: SawOsc, - zawtooth: ZawOsc, - supersaw: SupersawOsc, - tri: TriOsc, - triangle: TriOsc, - pulse: PulseOsc, - square: PulseOsc, - pulze: PulzeOsc, - dust: Dust, - crackle: Dust, - impulse: Impulse, - white: WhiteNoise, - brown: BrownNoise, - pink: PinkNoise, -}; - -const defaultDefaultValues = { - chorus: 0, - note: 48, - s: 'triangle', - bank: '', - gain: 1, - postgain: 1, - velocity: 1, - density: '.03', - ftype: '12db', - fanchor: 0, - //resonance: 1, // superdough resonance is scaled differently - resonance: 0, - //hresonance: 1, // superdough resonance is scaled differently - hresonance: 0, - // bandq: 1, // superdough resonance is scaled differently - bandq: 0, - channels: [1, 2], - phaserdepth: 0.75, - shapevol: 1, - distortvol: 1, - delay: 0, - byteBeatExpression: '0', - delayfeedback: 0.5, - delayspeed: 1, - delaytime: 0.25, - orbit: 1, - i: 1, - fft: 8, - z: 'triangle', - pan: 0.5, - fmh: 1, - fmenv: 0, // differs from superdough - speed: 1, - pw: 0.5, -}; - -let getDefaultValue = (key) => defaultDefaultValues[key]; - -const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; -const accs = { '#': 1, b: -1, s: 1, f: -1 }; -const note2midi = (note, defaultOctave = 3) => { - let [pc, acc = '', oct = ''] = - String(note) - .match(/^([a-gA-G])([#bsf]*)([0-9]*)$/) - ?.slice(1) || []; - if (!pc) { - throw new Error('not a note: "' + note + '"'); - } - const chroma = chromas[pc.toLowerCase()]; - const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0; - oct = Number(oct || defaultOctave); - return (oct + 1) * 12 + chroma + offset; -}; -const midi2freq = (midi) => Math.pow(2, (midi - 69) / 12) * 440; -const note2freq = (note) => { - note = note || getDefaultValue('note'); - if (typeof note === 'string') { - note = note2midi(note, 3); // e.g. c3 => 48 - } - return midi2freq(note); -}; - -export class DoughVoice { - out = [0, 0]; - constructor(value) { - value.freq ??= note2freq(value.note); - let $ = this; - Object.assign($, value); - $.s = $.s ?? getDefaultValue('s'); - $.gain = applyGainCurve($.gain ?? getDefaultValue('gain')); - $.velocity = applyGainCurve($.velocity ?? getDefaultValue('velocity')); - $.postgain = applyGainCurve($.postgain ?? getDefaultValue('postgain')); - $.density = $.density ?? getDefaultValue('density'); - $.fanchor = $.fanchor ?? getDefaultValue('fanchor'); - $.drive = $.drive ?? 0.69; - $.phaserdepth = $.phaserdepth ?? getDefaultValue('phaserdepth'); - $.shapevol = applyGainCurve($.shapevol ?? getDefaultValue('shapevol')); - $.distortvol = applyGainCurve($.distortvol ?? getDefaultValue('distortvol')); - $.i = $.i ?? getDefaultValue('i'); - $.chorus = $.chorus ?? getDefaultValue('chorus'); - $.fft = $.fft ?? getDefaultValue('fft'); - $.pan = $.pan ?? getDefaultValue('pan'); - $.orbit = $.orbit ?? getDefaultValue('orbit'); - $.fmenv = $.fmenv ?? getDefaultValue('fmenv'); - $.resonance = $.resonance ?? getDefaultValue('resonance'); - $.hresonance = $.hresonance ?? getDefaultValue('hresonance'); - $.bandq = $.bandq ?? getDefaultValue('bandq'); - $.speed = $.speed ?? getDefaultValue('speed'); - $.pw = $.pw ?? getDefaultValue('pw'); - - [$.attack, $.decay, $.sustain, $.release] = getADSR([$.attack, $.decay, $.sustain, $.release]); - - $._holdEnd = $._begin + $._duration; // needed for gate - $._end = $._holdEnd + $.release + 0.01; // needed for despawn - - if ($.fmi && ($.s === 'saw' || $.s === 'sawtooth')) { - $.s = 'zaw'; // polyblepped saw when fm is applied - } - - if (shapes[$.s]) { - const SourceClass = shapes[$.s]; - $._sound = new SourceClass(); - $._channels = 1; - } else if (BufferPlayer.samples.has($.s)) { - const sample = BufferPlayer.samples.get($.s); - $._buffers = []; - $._channels = sample.channels.length; - for (let i = 0; i < $._channels; i++) { - $._buffers.push(new BufferPlayer(sample.channels[i], sample.sampleRate, $.unit === 'c')); // tbd unit === 'c' - } - } else { - console.warn('sound not loaded', $.s); - } - - if ($.penv) { - $._penv = new ADSR({ decayCurve: 4 }); - [$.pattack, $.pdecay, $.psustain, $.prelease] = getADSR([$.pattack, $.pdecay, $.psustain, $.prelease]); - } - - if ($.vib) { - $._vib = new SineOsc(); - $.vibmod = $.vibmod ?? getDefaultValue('vibmod'); - } - - if ($.fmi) { - $._fm = new SineOsc(); - $.fmh = $.fmh ?? getDefaultValue('fmh'); - if ($.fmenv) { - $._fmenv = new ADSR({ decayCurve: 2 }); - [$.fmattack, $.fmdecay, $.fmsustain, $.fmrelease] = getADSR([$.fmattack, $.fmdecay, $.fmsustain, $.fmrelease]); - } - } - - // gain envelope - $._adsr = new ADSR({ decayCurve: 2 }); - // delay - $.delay = applyGainCurve($.delay ?? getDefaultValue('delay')); - $.delayfeedback = $.delayfeedback ?? getDefaultValue('delayfeedback'); - $.delayspeed = $.delayspeed ?? getDefaultValue('delayspeed'); - $.delaytime = $.delaytime ?? getDefaultValue('delaytime'); - - // filter setup - if ($.lpenv) { - $._lpenv = new ADSR({ decayCurve: 4 }); - [$.lpattack, $.lpdecay, $.lpsustain, $.lprelease] = getADSR([$.lpattack, $.lpdecay, $.lpsustain, $.lprelease]); - } - if ($.hpenv) { - $._hpenv = new ADSR({ decayCurve: 4 }); - [$.hpattack, $.hpdecay, $.hpsustain, $.hprelease] = getADSR([$.hpattack, $.hpdecay, $.hpsustain, $.hprelease]); - } - if ($.bpenv) { - $._bpenv = new ADSR({ decayCurve: 4 }); - [$.bpattack, $.bpdecay, $.bpsustain, $.bprelease] = getADSR([$.bpattack, $.bpdecay, $.bpsustain, $.bprelease]); - } - - // channelwise effects setup - $._chorus = $.chorus ? [] : null; - $._lpf = $.cutoff ? [] : null; - $._hpf = $.hcutoff ? [] : null; - $._bpf = $.bandf ? [] : null; - $._coarse = $.coarse ? [] : null; - $._crush = $.crush ? [] : null; - $._distort = $.distort ? [] : null; - for (let i = 0; i < this._channels; i++) { - $._lpf?.push(new TwoPoleFilter()); - $._hpf?.push(new TwoPoleFilter()); - $._bpf?.push(new TwoPoleFilter()); - $._chorus?.push(new Chorus()); - $._coarse?.push(new Coarse()); - $._crush?.push(new Crush()); - $._distort?.push(new Distort()); - } - } - update(t) { - if (!this._sound && !this._buffers) { - return 0; - } - let gate = Number(t >= this._begin && t <= this._holdEnd); - - let freq = this.freq * this.speed; - - // frequency modulation - if (this._fm) { - let fmi = this.fmi; - if (this._fmenv) { - const env = this._fmenv.update(t, gate, this.fmattack, this.fmdecay, this.fmsustain, this.fmrelease); - fmi = this.fmenv * env * fmi; - } - const modfreq = freq * this.fmh; - const modgain = modfreq * fmi; - freq = freq + this._fm.update(modfreq) * modgain; - } - - // vibrato - if (this._vib) { - freq = freq * 2 ** ((this._vib.update(this.vib) * this.vibmod) / 12); - } - - // pitch envelope - if (this._penv) { - const env = this._penv.update(t, gate, this.pattack, this.pdecay, this.psustain, this.prelease); - freq = freq + env * this.penv; - } - - // filters - let lpf = this.cutoff; - if (this._lpf) { - if (this._lpenv) { - const env = this._lpenv.update(t, gate, this.lpattack, this.lpdecay, this.lpsustain, this.lprelease); - lpf = this.lpenv * env * lpf + lpf; - } - } - let hpf = this.hcutoff; - if (this._hpf) { - if (this._hpenv) { - const env = this._hpenv.update(t, gate, this.hpattack, this.hpdecay, this.hpsustain, this.hprelease); - hpf = 2 ** this.hpenv * env * hpf + hpf; - } - } - let bpf = this.bandf; - if (this._bpf) { - if (this._bpenv) { - const env = this._bpenv.update(t, gate, this.bpattack, this.bpdecay, this.bpsustain, this.bprelease); - bpf = 2 ** this.bpenv * env * bpf + bpf; - } - } - // gain envelope - const env = this._adsr.update(t, gate, this.attack, this.decay, this.sustain, this.release); - - // channelwise dsp - for (let i = 0; i < this._channels; i++) { - // sound source - if (this._sound && this.s === 'pulse') { - this.out[i] = this._sound.update(freq, this.pw); - } else if (this._sound) { - this.out[i] = this._sound.update(freq); - } else if (this._buffers) { - this.out[i] = this._buffers[i].update(freq); - } - this.out[i] = this.out[i] * this.gain * this.velocity; - if (this._chorus) { - const c = this._chorus[i].update(this.out[i], this.chorus, 0.03 + 0.05 * i, 1, 0.11); - this.out[i] = c + this.out[i]; - } - - if (this._lpf) { - this._lpf[i].update(this.out[i], lpf, this.resonance); - this.out[i] = this._lpf[i].s1; - } - if (this._hpf) { - this._hpf[i].update(this.out[i], hpf, this.hresonance); - this.out[i] = this.out[i] - this._hpf[i].s1; - } - if (this._bpf) { - this._bpf[i].update(this.out[i], bpf, this.bandq); - this.out[i] = this._bpf[i].s0; - } - if (this._coarse) { - this.out[i] = this._coarse[i].update(this.out[i], this.coarse); - } - if (this._crush) { - this.out[i] = this._crush[i].update(this.out[i], this.crush); - } - if (this._distort) { - this.out[i] = this._distort[i].update(this.out[i], this.distort, this.distortvol); - } - this.out[i] = this.out[i] * env; - this.out[i] = this.out[i] * this.postgain; - if (!this._buffers) { - this.out[i] = this.out[i] * 0.2; // turn down waveform - } - } - if (this._channels === 1) { - this.out[1] = this.out[0]; - } - if (this.pan !== 0.5) { - const panpos = (this.pan * Math.PI) / 2; - this.out[0] = this.out[0] * Math.cos(panpos); - this.out[1] = this.out[1] * Math.sin(panpos); - } - } -} - -// this class is the interface to the "outer world" -// it handles spawning and despawning of DoughVoice's -export class Dough { - voices = []; // DoughVoice[] - vid = 0; - q = []; - out = [0, 0]; - delaysend = [0, 0]; - delaytime = getDefaultValue('delaytime'); - delayfeedback = getDefaultValue('delayfeedback'); - delayspeed = getDefaultValue('delayspeed'); - t = 0; - // sampleRate: number, currentTime: number (seconds) - constructor(sampleRate = 48000, currentTime = 0) { - this.sampleRate = sampleRate; - this.t = Math.floor(currentTime * sampleRate); // samples - // console.log('init dough', this.sampleRate, this.t); - this._delayL = new PitchDelay(); - this._delayR = new PitchDelay(); - } - loadSample(name, channels, sampleRate) { - BufferPlayer.samples.set(name, { channels, sampleRate }); - } - scheduleSpawn(value) { - if (value._begin === undefined) { - throw new Error('[dough]: scheduleSpawn expected _begin to be set'); - } - if (value._duration === undefined) { - throw new Error('[dough]: scheduleSpawn expected _duration to be set'); - } - value.sampleRate = this.sampleRate; - // convert seconds to samples - const time = Math.floor(value._begin * this.sampleRate); // set from supradough.mjs - this.schedule({ time, type: 'spawn', arg: value }); - } - spawn(value) { - value.id = this.vid++; - const voice = new DoughVoice(value); - this.voices.push(voice); - // console.log('spawn', voice.id, 'voices:', this.voices.length); - // schedule removal - const endTime = Math.ceil(voice._end * this.sampleRate); - this.schedule({ time: endTime /* + 48000 */, type: 'despawn', arg: voice.id }); - } - despawn(vid) { - this.voices = this.voices.filter((v) => v.id !== vid); - // console.log('despawn', vid, 'voices:', this.voices.length); - } - // schedules a function call with a single argument - // msg = {time:number,type:string, arg: any} - // the Dough method "type" will be called with "arg" at "time" - schedule(msg) { - if (!this.q.length) { - // if empty, just push - this.q.push(msg); - return; - } - // not empty - // find index where msg.time fits in - let i = 0; - while (i < this.q.length && this.q[i].time < msg.time) { - i++; - } - // this ensures q stays sorted by time, so we only need to check q[0] - this.q.splice(i, 0, msg); - } - // maybe update should be called once per block instead for perf reasons? - update() { - // go over q - while (this.q.length > 0 && this.q[0].time <= this.t) { - // console.log('schedule', this.q[0]); - // trigger due messages. q is sorted, so we only need to check q[0] - this[this.q[0].type](this.q[0].arg); // type is expected to be a Dough method - this.q.shift(); - } - // add active voices - this.out[0] = 0; - this.out[1] = 0; - for (let v = 0; v < this.voices.length; v++) { - this.voices[v].update(this.t / this.sampleRate); - this.out[0] += this.voices[v].out[0]; - this.out[1] += this.voices[v].out[1]; - if (this.voices[v].delay) { - this.delaysend[0] += this.voices[v].out[0] * this.voices[v].delay; - this.delaysend[1] += this.voices[v].out[1] * this.voices[v].delay; - this.delaytime = this.voices[v].delaytime; // we trust that these are initialized in the voice - this.delayspeed = this.voices[v].delayspeed; // we trust that these are initialized in the voice - this.delayfeedback = this.voices[v].delayfeedback; - } - } - // todo: how to change delaytime / delayfeedback from a voice? - const delayL = this._delayL.update(this.delaysend[0], this.delaytime, this.delayspeed); - const delayR = this._delayR.update(this.delaysend[1], this.delaytime, this.delayspeed); - this.delaysend[0] = delayL * this.delayfeedback; - this.delaysend[1] = delayR * this.delayfeedback; - this.out[0] += delayL; - this.out[1] += delayR; - this.t++; - } -} diff --git a/packages/supradough/index.mjs b/packages/supradough/index.mjs deleted file mode 100644 index 54a835495..000000000 --- a/packages/supradough/index.mjs +++ /dev/null @@ -1,4 +0,0 @@ -import _workletUrl from './dough-worklet.mjs?url'; // todo: change ?url to ?audioworklet before build (?audioworklet doesn't hot reload) - -export * from './dough.mjs'; -export const workletUrl = _workletUrl; diff --git a/packages/supradough/package.json b/packages/supradough/package.json deleted file mode 100644 index 7e465c0a9..000000000 --- a/packages/supradough/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "supradough", - "version": "1.2.3", - "description": "platform agnostic synth and sampler intended for live coding. a reimplementation of superdough.", - "main": "index.mjs", - "type": "module", - "publishConfig": { - "main": "dist/index.mjs" - }, - "scripts": { - "build": "vite build", - "prepublishOnly": "npm run build" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/tidalcycles/strudel.git" - }, - "keywords": [ - "tidalcycles", - "strudel", - "pattern", - "livecoding", - "algorave" - ], - "author": "Felix Roos ", - "license": "AGPL-3.0-or-later", - "bugs": { - "url": "https://github.com/tidalcycles/strudel/issues" - }, - "homepage": "https://github.com/tidalcycles/strudel#readme", - "devDependencies": { - "vite": "^6.0.11", - "vite-plugin-bundle-audioworklet": "workspace:*", - "wav-encoder": "^1.3.0" - }, - "dependencies": {} -} diff --git a/packages/webaudio/index.mjs b/packages/webaudio/index.mjs index 4933b7a01..362e61c44 100644 --- a/packages/webaudio/index.mjs +++ b/packages/webaudio/index.mjs @@ -7,5 +7,4 @@ This program is free software: you can redistribute it and/or modify it under th export * from './webaudio.mjs'; export * from './scope.mjs'; export * from './spectrum.mjs'; -export * from './supradough.mjs'; export * from 'superdough'; diff --git a/packages/webaudio/package.json b/packages/webaudio/package.json index 49da00f23..cbe673a5a 100644 --- a/packages/webaudio/package.json +++ b/packages/webaudio/package.json @@ -35,8 +35,7 @@ "dependencies": { "@strudel/core": "workspace:*", "@strudel/draw": "workspace:*", - "superdough": "workspace:*", - "supradough": "workspace:*" + "superdough": "workspace:*" }, "devDependencies": { "vite": "^6.0.11" diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs deleted file mode 100644 index f97251a07..000000000 --- a/packages/webaudio/supradough.mjs +++ /dev/null @@ -1,130 +0,0 @@ -import { Pattern } from '@strudel/core'; -import { connectToDestination, getAudioContext, getWorklet } from 'superdough'; - -let doughWorklet; - -function initDoughWorklet() { - const ac = getAudioContext(); - doughWorklet = getWorklet( - ac, - 'dough-processor', - {}, - { - outputChannelCount: [2], - }, - ); - connectToDestination(doughWorklet); // channels? -} - -const soundMap = new Map(); -const loadedSounds = new Map(); - -Pattern.prototype.supradough = function () { - return this.onTrigger((hap, __, cps, begin) => { - hap.value._begin = begin; - hap.value._duration = hap.duration / cps; - !doughWorklet && initDoughWorklet(); - const s = (hap.value.bank ? hap.value.bank + '_' : '') + hap.value.s; - const n = hap.value.n ?? 0; - const soundKey = `${s}:${n}`; - if (soundMap.has(s)) { - hap.value.s = soundKey; // dough.mjs is unaware of bank and n (only maps keys to buffers) - } - if (soundMap.has(s) && !loadedSounds.has(soundKey)) { - const urls = soundMap.get(s); - const url = urls[n % urls.length]; - console.log(`load ${soundKey} from ${url}`); - const loadSample = fetchSample(url); - loadedSounds.set(soundKey, loadSample); - loadSample.then(({ channels, sampleRate }) => - doughWorklet.port.postMessage({ - sample: soundKey, - channels, - sampleRate, - }), - ); - } - - doughWorklet.port.postMessage({ spawn: hap.value }); - }, 1); -}; - -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}`; -} -export async function fetchSampleMap(url) { - if (url.startsWith('github:')) { - url = githubPath(url, 'strudel.json'); - } - if (url.startsWith('local:')) { - url = `http://localhost:5432`; - } - if (url.startsWith('shabda:')) { - let [_, path] = url.split('shabda:'); - url = `https://shabda.ndre.gr/${path}.json?strudel=1`; - } - if (url.startsWith('shabda/speech')) { - let [_, path] = url.split('shabda/speech'); - path = path.startsWith('/') ? path.substring(1) : path; - let [params, words] = path.split(':'); - let gender = 'f'; - let language = 'en-GB'; - if (params) { - [language, gender] = params.split('/'); - } - url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`; - } - 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; - } - const json = await fetch(url) - .then((res) => res.json()) - .catch((error) => { - console.error(error); - throw new Error(`error loading "${url}"`); - }); - return [json, json._base || base]; -} - -// for some reason, only piano and flute work.. is it because mp3?? - -async function fetchSample(url) { - const buffer = await fetch(url) - .then((res) => res.arrayBuffer()) - .then((buf) => getAudioContext().decodeAudioData(buf)); - let channels = []; - for (let i = 0; i < buffer.numberOfChannels; i++) { - channels.push(buffer.getChannelData(i)); - } - return { channels, sampleRate: buffer.sampleRate }; -} - -export async function doughsamples(sampleMap, baseUrl) { - if (typeof sampleMap === 'string') { - const [json, base] = await fetchSampleMap(sampleMap); - // console.log('json', json, 'base', base); - return doughsamples(json, base); - } - Object.entries(sampleMap).map(async ([key, urls]) => { - if (key !== '_base') { - urls = urls.map((url) => baseUrl + url); - // console.log('set', key, urls); - soundMap.set(key, urls); - } - }); -} diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index 383e87f87..429d2a26b 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -5,12 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th */ import * as strudel from '@strudel/core'; -import { superdough, getAudioContext, setLogger, doughTrigger, registerWorklet } from 'superdough'; -import './supradough.mjs'; -import { workletUrl } from 'supradough'; - -registerWorklet(workletUrl); - +import { superdough, getAudioContext, setLogger, doughTrigger } from 'superdough'; const { Pattern, logger, repl } = strudel; setLogger(logger); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c225057f3..f4a24f61c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -517,18 +517,6 @@ importers: specifier: workspace:* version: link:../vite-plugin-bundle-audioworklet - packages/supradough: - devDependencies: - vite: - specifier: ^6.0.11 - version: 6.0.11(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0) - vite-plugin-bundle-audioworklet: - specifier: workspace:* - version: link:../vite-plugin-bundle-audioworklet - wav-encoder: - specifier: ^1.3.0 - version: 1.3.0 - packages/tidal: dependencies: '@strudel/core': @@ -637,9 +625,6 @@ importers: superdough: specifier: workspace:* version: link:../superdough - supradough: - specifier: workspace:* - version: link:../supradough devDependencies: vite: specifier: ^6.0.11 @@ -7552,9 +7537,6 @@ packages: walk-up-path@3.0.1: resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==} - wav-encoder@1.3.0: - resolution: {integrity: sha512-FXJdEu2qDOI+wbVYZpu21CS1vPEg5NaxNskBr4SaULpOJMrLE6xkH8dECa7PiS+ZoeyvP7GllWUAxPN3AvFSEw==} - wav@1.0.2: resolution: {integrity: sha512-viHtz3cDd/Tcr/HbNqzQCofKdF6kWUymH9LGDdskfWFoIy/HJ+RTihgjEcHfnsy1PO4e9B+y4HwgTwMrByquhg==} @@ -15976,8 +15958,6 @@ snapshots: walk-up-path@3.0.1: {} - wav-encoder@1.3.0: {} - wav@1.0.2: dependencies: buffer-alloc: 1.2.0 diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index ecbc33eac..41207620f 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -1847,27 +1847,6 @@ exports[`runs examples > example "chop" example index 0 1`] = ` ] `; -exports[`runs examples > example "chorus" example index 0 1`] = ` -[ - "[ 0/1 → 1/4 | note:d s:sawtooth chorus:0.5 ]", - "[ 1/4 → 1/2 | note:d s:sawtooth chorus:0.5 ]", - "[ 1/2 → 3/4 | note:a# s:sawtooth chorus:0.5 ]", - "[ 3/4 → 1/1 | note:a s:sawtooth chorus:0.5 ]", - "[ 1/1 → 5/4 | note:d s:sawtooth chorus:0.5 ]", - "[ 5/4 → 3/2 | note:d s:sawtooth chorus:0.5 ]", - "[ 3/2 → 7/4 | note:a# s:sawtooth chorus:0.5 ]", - "[ 7/4 → 2/1 | note:a s:sawtooth chorus:0.5 ]", - "[ 2/1 → 9/4 | note:d s:sawtooth chorus:0.5 ]", - "[ 9/4 → 5/2 | note:d s:sawtooth chorus:0.5 ]", - "[ 5/2 → 11/4 | note:a# s:sawtooth chorus:0.5 ]", - "[ 11/4 → 3/1 | note:a s:sawtooth chorus:0.5 ]", - "[ 3/1 → 13/4 | note:d s:sawtooth chorus:0.5 ]", - "[ 13/4 → 7/2 | note:d s:sawtooth chorus:0.5 ]", - "[ 7/2 → 15/4 | note:a# s:sawtooth chorus:0.5 ]", - "[ 15/4 → 4/1 | note:a s:sawtooth chorus:0.5 ]", -] -`; - exports[`runs examples > example "chunk" example index 0 1`] = ` [ "[ 0/1 → 1/4 | note:A4 ]", @@ -2606,52 +2585,6 @@ exports[`runs examples > example "delayfeedback" example index 0 1`] = ` ] `; -exports[`runs examples > example "delayfeedback" example index 0 2`] = ` -[ - "[ 0/1 → 1/1 | s:bd delay:0.25 delayfeedback:0.25 ]", - "[ 1/1 → 2/1 | s:bd delay:0.25 delayfeedback:0.5 ]", - "[ 2/1 → 3/1 | s:bd delay:0.25 delayfeedback:0.75 ]", - "[ 3/1 → 4/1 | s:bd delay:0.25 delayfeedback:1 ]", -] -`; - -exports[`runs examples > example "delayspeed" example index 0 1`] = ` -[ - "[ 0/1 → 1/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", - "[ 1/8 → 1/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", - "[ 1/4 → 3/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", - "[ 3/8 → 1/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", - "[ 1/2 → 5/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", - "[ 5/8 → 3/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", - "[ 3/4 → 7/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", - "[ 7/8 → 1/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", - "[ 1/1 → 9/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", - "[ 9/8 → 5/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", - "[ 5/4 → 11/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", - "[ 11/8 → 3/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", - "[ 3/2 → 13/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", - "[ 13/8 → 7/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", - "[ 7/4 → 15/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", - "[ 15/8 → 2/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", - "[ 2/1 → 17/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", - "[ 17/8 → 9/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", - "[ 9/4 → 19/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", - "[ 19/8 → 5/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", - "[ 5/2 → 21/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", - "[ 21/8 → 11/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", - "[ 11/4 → 23/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", - "[ 23/8 → 3/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", - "[ 3/1 → 25/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", - "[ 25/8 → 13/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", - "[ 13/4 → 27/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", - "[ 27/8 → 7/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", - "[ 7/2 → 29/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", - "[ 29/8 → 15/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", - "[ 15/4 → 31/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", - "[ 31/8 → 4/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", -] -`; - exports[`runs examples > example "delaysync" example index 0 1`] = ` [ "[ 0/1 → 1/2 | s:bd delay:0.25 delaysync:0.125 ]", @@ -2665,6 +2598,19 @@ exports[`runs examples > example "delaysync" example index 0 1`] = ` ] `; +exports[`runs examples > example "delaytime" example index 0 1`] = ` +[ + "[ 0/1 → 1/2 | s:bd delay:0.25 delaytime:0.125 ]", + "[ 1/2 → 1/1 | s:bd delay:0.25 delaytime:0.125 ]", + "[ 1/1 → 3/2 | s:bd delay:0.25 delaytime:0.25 ]", + "[ 3/2 → 2/1 | s:bd delay:0.25 delaytime:0.25 ]", + "[ 2/1 → 5/2 | s:bd delay:0.25 delaytime:0.5 ]", + "[ 5/2 → 3/1 | s:bd delay:0.25 delaytime:0.5 ]", + "[ 3/1 → 7/2 | s:bd delay:0.25 delaytime:1 ]", + "[ 7/2 → 4/1 | s:bd delay:0.25 delaytime:1 ]", +] +`; + exports[`runs examples > example "density" example index 0 1`] = ` [ "[ 0/1 → 1/4 | s:crackle density:0.01 ]", From d164b5e7f5694f10bdd40c849878400b6275a3fe Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 12 Sep 2025 10:08:55 +0200 Subject: [PATCH 93/95] Revert "Revert "Merge pull request 'supradough poc' (#1362) from supradough into main"" This reverts commit c0bf86d11e3d795558b5dd01821f700a5e962070. --- eslint.config.mjs | 1 + packages/core/controls.mjs | 30 +- packages/superdough/sampler.mjs | 91 +- packages/superdough/superdough.mjs | 10 +- packages/supradough/.gitignore | 1 + packages/supradough/README.md | 3 + packages/supradough/dough-export.mjs | 123 +++ packages/supradough/dough-worklet.mjs | 39 + packages/supradough/dough.mjs | 976 ++++++++++++++++++++++ packages/supradough/index.mjs | 4 + packages/supradough/package.json | 37 + packages/webaudio/index.mjs | 1 + packages/webaudio/package.json | 3 +- packages/webaudio/supradough.mjs | 130 +++ packages/webaudio/webaudio.mjs | 7 +- pnpm-lock.yaml | 20 + test/__snapshots__/examples.test.mjs.snap | 80 +- 17 files changed, 1493 insertions(+), 63 deletions(-) create mode 100644 packages/supradough/.gitignore create mode 100644 packages/supradough/README.md create mode 100644 packages/supradough/dough-export.mjs create mode 100644 packages/supradough/dough-worklet.mjs create mode 100644 packages/supradough/dough.mjs create mode 100644 packages/supradough/index.mjs create mode 100644 packages/supradough/package.json create mode 100644 packages/webaudio/supradough.mjs diff --git a/eslint.config.mjs b/eslint.config.mjs index e30b8e8a6..c9ff40ca1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -42,6 +42,7 @@ export default [ '**/hydra.mjs', '**/jsdoc-synonyms.js', 'packages/hs2js/src/hs2js.mjs', + 'packages/supradough/dough-export.mjs', '**/samples', ], }, diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 58a30652c..473ea82d1 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -309,6 +309,17 @@ export const { fmvelocity } = registerControl('fmvelocity'); */ export const { bank } = registerControl('bank'); +/** + * mix control for the chorus effect + * + * @name chorus + * @param {string | Pattern} chorus mix amount between 0 and 1 + * @example + * note("d d a# a").s("sawtooth").chorus(.5) + * + */ +export const { chorus } = registerControl('chorus'); + // analyser node send amount 0 - 1 (used by scope) export const { analyze } = registerControl('analyze'); // fftSize of analyser @@ -1097,14 +1108,27 @@ export const { delay } = registerControl(['delay', 'delaytime', 'delayfeedback'] * */ export const { delayfeedback, delayfb, dfb } = registerControl('delayfeedback', 'delayfb', 'dfb'); + +/** + * Sets the level of the signal that is fed back into the delay. + * Caution: Values >= 1 will result in a signal that gets louder and louder! Don't do it + * + * @name delayfeedback + * @param {number | Pattern} feedback between 0 and 1 + * @synonyms delayfb, dfb + * @example + * s("bd").delay(.25).delayfeedback("<.25 .5 .75 1>") + * + */ +export const { delayspeed } = registerControl('delayspeed'); /** * Sets the time of the delay effect. * - * @name delaytime - * @param {number | Pattern} seconds between 0 and Infinity + * @name delayspeed + * @param {number | Pattern} delayspeed controls the pitch of the delay feedback * @synonyms delayt, dt * @example - * s("bd bd").delay(.25).delaytime("<.125 .25 .5 1>") + * note("d d a# a".fast(2)).s("sawtooth").delay(.8).delaytime(1/2).delayspeed("<2 .5 -1 -2>") * */ export const { delaytime, delayt, dt } = registerControl('delaytime', 'delayt', 'dt'); diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index 18d1b7797..9188c17c3 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -196,6 +196,52 @@ function getSamplesPrefixHandler(url) { return; } +export async function fetchSampleMap(url) { + // check if custom prefix handler + const handler = getSamplesPrefixHandler(url); + if (handler) { + return handler(url); + } + url = resolveSpecialPaths(url); + if (url.startsWith('github:')) { + url = githubPath(url, 'strudel.json'); + } + if (url.startsWith('local:')) { + url = `http://localhost:5432`; + } + if (url.startsWith('shabda:')) { + let [_, path] = url.split('shabda:'); + url = `https://shabda.ndre.gr/${path}.json?strudel=1`; + } + if (url.startsWith('shabda/speech')) { + let [_, path] = url.split('shabda/speech'); + path = path.startsWith('/') ? path.substring(1) : path; + let [params, words] = path.split(':'); + let gender = 'f'; + let language = 'en-GB'; + if (params) { + [language, gender] = params.split('/'); + } + url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`; + } + 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; + } + const json = await fetch(url) + .then((res) => res.json()) + .catch((error) => { + console.error(error); + throw new Error(`error loading "${url}"`); + }); + return [json, json._base || base]; +} + /** * Loads a collection of samples to use with `s` * @example @@ -217,49 +263,8 @@ function getSamplesPrefixHandler(url) { export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => { if (typeof sampleMap === 'string') { - // check if custom prefix handler - const handler = getSamplesPrefixHandler(sampleMap); - if (handler) { - return handler(sampleMap); - } - sampleMap = resolveSpecialPaths(sampleMap); - if (sampleMap.startsWith('github:')) { - sampleMap = githubPath(sampleMap, 'strudel.json'); - } - if (sampleMap.startsWith('local:')) { - sampleMap = `http://localhost:5432`; - } - if (sampleMap.startsWith('shabda:')) { - let [_, path] = sampleMap.split('shabda:'); - sampleMap = `https://shabda.ndre.gr/${path}.json?strudel=1`; - } - if (sampleMap.startsWith('shabda/speech')) { - let [_, path] = sampleMap.split('shabda/speech'); - path = path.startsWith('/') ? path.substring(1) : path; - let [params, words] = path.split(':'); - let gender = 'f'; - let language = 'en-GB'; - if (params) { - [language, gender] = params.split('/'); - } - sampleMap = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`; - } - if (typeof fetch !== 'function') { - // not a browser - return; - } - const base = sampleMap.split('/').slice(0, -1).join('/'); - if (typeof fetch === 'undefined') { - // skip fetch when in node / testing - return; - } - return fetch(sampleMap) - .then((res) => res.json()) - .then((json) => samples(json, baseUrl || json._base || base, options)) - .catch((error) => { - console.error(error); - throw new Error(`error loading "${sampleMap}"`); - }); + const [json, base] = await fetchSampleMap(sampleMap); + return samples(json, baseUrl || base, options); } const { prebake, tag } = options; processSampleMap( diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 792e5a28d..0c084e3e9 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -196,7 +196,7 @@ export const resetLoadedSounds = () => soundMap.set({}); let audioContext; export const setDefaultAudioContext = () => { - audioContext = new AudioContext(); + audioContext = new AudioContext({ latencyHint: 'playback' }); return audioContext; }; @@ -212,11 +212,17 @@ export function getAudioContextCurrentTime() { return getAudioContext().currentTime; } +let externalWorklets = []; +export function registerWorklet(url) { + externalWorklets.push(url); +} + let workletsLoading; function loadWorklets() { if (!workletsLoading) { const audioCtx = getAudioContext(); - workletsLoading = audioCtx.audioWorklet.addModule(workletsUrl); + const allWorkletURLs = externalWorklets.concat([workletsUrl]); + workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL))); } return workletsLoading; diff --git a/packages/supradough/.gitignore b/packages/supradough/.gitignore new file mode 100644 index 000000000..d21cbdf3e --- /dev/null +++ b/packages/supradough/.gitignore @@ -0,0 +1 @@ +pattern.wav diff --git a/packages/supradough/README.md b/packages/supradough/README.md new file mode 100644 index 000000000..a8cfa84b3 --- /dev/null +++ b/packages/supradough/README.md @@ -0,0 +1,3 @@ +# supradough + +platform agnostic synth and sampler intended for live coding. a reimplementation of superdough. \ No newline at end of file diff --git a/packages/supradough/dough-export.mjs b/packages/supradough/dough-export.mjs new file mode 100644 index 000000000..bd4b530b3 --- /dev/null +++ b/packages/supradough/dough-export.mjs @@ -0,0 +1,123 @@ +// this is a poc of how a pattern can be rendered as a wav file using node +// run via: node dough-export.mjs +import fs from 'node:fs'; +import WavEncoder from 'wav-encoder'; +import { evalScope } from '@strudel/core'; +import { miniAllStrings } from '@strudel/mini'; +import { Dough } from './dough.mjs'; + +await evalScope( + import('@strudel/core'), + import('@strudel/mini'), + import('@strudel/tonal'), + // import('@strudel/tonal'), +); + +miniAllStrings(); // allows using single quotes for mini notation / skip transpilation + +let sampleRate = 48000, + cps = 0.4; + +/* await doughsamples('github:eddyflux/crate'); +await doughsamples('github:eddyflux/wax'); */ + +let pat = note('c,eb,g,') + .s('sine') + .press() + .add(note(24)) + .fmi(3) + .fmh(5.01) + .dec(0.4) + .delay('.6:<.12 .22>:.8') + .jux(press) + .rarely(add(note('12'))) + .lpf(400) + .lpq(0.2) + .lpd(0.4) + .lpenv(3) + .fmdecay(0.4) + .fmenv(1) + .postgain(0.6) + .stack(s('*8').dec(0.07).rarely(ply('2')).delay(0.5).hpf(sine.range(200, 2000).slow(4)).hpq(0.2)) + .stack( + s('[- white@3]*2') + .dec(0.4) + .hpf('<2000!3 <4000 8000>>*4') + .hpq(0.6) + .ply('<1 2>*4') + .postgain(0.5) + .delay(0.5) + .jux(rev) + .lpf(5000), + ) + .stack( + note('*2') + .s('square') + .lpf(sine.range(100, 300).slow(4)) + .lpe(1) + .segment(8) + .lpd(0.3) + .lpq(0.2) + .dec(0.2) + .speed('<1 2>') + .ply('<1 2>') + .postgain(1), + ) + .stack( + chord('') + .voicing() + .s('') + .clip(1) + .rel(0.4) + .vib('4:.2') + .gain(0.7) + .hpf(1200) + .fm(0.5) + .att(1) + .lpa(0.5) + .lpf(200) + .lpenv(4) + .chorus(0.8), + ) + .slow(1 / cps); + +let cycles = 30; +let seconds = cycles + 1; // 1s release tail +const haps = pat.queryArc(0, cycles); + +const dough = new Dough(sampleRate); + +console.log('spawn voices...'); +haps.forEach((hap) => { + hap.value._begin = Number(hap.whole.begin); + hap.value._duration = hap.duration /* / cps */; + dough.scheduleSpawn(hap.value); +}); +console.log(`render ${seconds}s long buffer, each dot is 1 second:`); +const buffers = [new Float32Array(seconds * sampleRate), new Float32Array(seconds * sampleRate)]; +let t = performance.now(); +while (dough.t <= buffers[0].length) { + dough.update(); + buffers[0][dough.t] = dough.out[0]; + buffers[1][dough.t] = dough.out[1]; + if (dough.t % sampleRate === 0) { + process.stdout.write('.'); + } +} +const took = (performance.now() - t) / 1000; +const load = (took / seconds) * 100; +const speed = (seconds / took).toFixed(2); +console.log(''); +console.log(`done! +rendered ${seconds}s in ${took.toFixed(2)}s +speed: ${speed}x +load: ${load.toFixed(2)}%`); + +const patternAudio = { + sampleRate, + channelData: buffers, +}; + +WavEncoder.encode(patternAudio).then((buffer) => { + fs.writeFileSync('pattern.wav', new Float32Array(buffer)); +}); diff --git a/packages/supradough/dough-worklet.mjs b/packages/supradough/dough-worklet.mjs new file mode 100644 index 000000000..18c316b2d --- /dev/null +++ b/packages/supradough/dough-worklet.mjs @@ -0,0 +1,39 @@ +import { Dough } from './dough.mjs'; + +const clamp = (num, min, max) => Math.min(Math.max(num, min), max); + +class DoughProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.dough = new Dough(sampleRate, currentTime); + this.port.onmessage = (event) => { + if (event.data.spawn) { + this.dough.scheduleSpawn(event.data.spawn); + } else if (event.data.sample) { + this.dough.loadSample(event.data.sample, event.data.channels, event.data.sampleRate); + } else if (event.data.samples) { + event.data.samples.forEach(([name, channels, sampleRate]) => { + this.dough.loadSample(name, channels, sampleRate); + }); + } else { + console.log('unrecognized event type', event.data); + } + }; + } + process(inputs, outputs, params) { + if (this.disconnected) { + return false; + } + const output = outputs[0]; + for (let i = 0; i < output[0].length; i++) { + this.dough.update(); + for (let c = 0; c < output.length; c++) { + //prevent speaker blowout via clipping if threshold exceeds + output[c][i] = clamp(this.dough.out[c], -1, 1); + } + } + return true; // keep the audio processing going + } +} + +registerProcessor('dough-processor', DoughProcessor); diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs new file mode 100644 index 000000000..711922b63 --- /dev/null +++ b/packages/supradough/dough.mjs @@ -0,0 +1,976 @@ +// this is dough, the superdough without dependencies +const SAMPLE_RATE = typeof sampleRate !== 'undefined' ? sampleRate : 48000; +const PI_DIV_SR = Math.PI / SAMPLE_RATE; +const ISR = 1 / SAMPLE_RATE; + +let gainCurveFunc = (val) => Math.pow(val, 2); + +function applyGainCurve(val) { + return gainCurveFunc(val); +} + +/** + * Equal Power Crossfade function. + * Smoothly transitions between signals A and B, maintaining consistent perceived loudness. + * + * @param {number} a - Signal A (can be a single value or an array value in buffer processing). + * @param {number} b - Signal B (can be a single value or an array value in buffer processing). + * @param {number} m - Crossfade parameter (0.0 = all A, 1.0 = all B, 0.5 = equal mix). + * @returns {number} Crossfaded output value. + */ +function crossfade(a, b, m) { + const aGain = Math.sin((1 - m) * 0.5 * Math.PI); + const bGain = Math.sin(m * 0.5 * Math.PI); + return a * aGain + b * bGain; +} + +// function setGainCurve(newGainCurveFunc) { +// gainCurveFunc = newGainCurveFunc; +// } +// https://garten.salat.dev/audio-DSP/oscillators.html +export class SineOsc { + phase = 0; + update(freq) { + const value = Math.sin(this.phase * 2 * Math.PI); + this.phase = (this.phase + freq / SAMPLE_RATE) % 1; + return value; + } +} + +export class ZawOsc { + phase = 0; + update(freq) { + this.phase += ISR * freq; + return (this.phase % 1) * 2 - 1; + } +} + +function polyBlep(t, dt) { + // 0 <= t < 1 + if (t < dt) { + t /= dt; + // 2 * (t - t^2/2 - 0.5) + return t + t - t * t - 1; + } + // -1 < t < 0 + if (t > 1 - dt) { + t = (t - 1) / dt; + // 2 * (t^2/2 + t + 0.5) + return t * t + t + t + 1; + } + // 0 otherwise + return 0; +} + +export class SawOsc { + constructor(props = {}) { + this.phase = props.phase ?? 0; + } + update(freq) { + const dt = freq / SAMPLE_RATE; + let p = polyBlep(this.phase, dt); + let s = 2 * this.phase - 1 - p; + this.phase += dt; + if (this.phase > 1) { + this.phase -= 1; + } + return s; + } +} + +function getUnisonDetune(unison, detune, voiceIndex) { + if (unison < 2) { + return 0; + } + const lerp = (a, b, n) => { + return n * (b - a) + a; + }; + return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1)); +} +function applySemitoneDetuneToFrequency(frequency, detune) { + return frequency * Math.pow(2, detune / 12); +} +export class SupersawOsc { + constructor(props = {}) { + //TODO: figure out a good way to pass in these params + this.voices = props.voices ?? 5; + this.freqspread = props.freqspread ?? 0.2; + this.panspread = props.panspread ?? 0.4; + this.phase = new Float32Array(this.voices).map(() => Math.random()); + } + update(freq) { + const gain1 = Math.sqrt(1 - this.panspread); + const gain2 = Math.sqrt(this.panspread); + let sl = 0; + let sr = 0; + for (let n = 0; n < this.voices; n++) { + const freqAdjusted = applySemitoneDetuneToFrequency(freq, getUnisonDetune(this.voices, this.freqspread, n)); + const dt = freqAdjusted / SAMPLE_RATE; + const isOdd = (n & 1) == 1; + let gainL = gain1; + let gainR = gain2; + // invert right and left gain + if (isOdd) { + gainL = gain2; + gainR = gain1; + } + let p = polyBlep(this.phase[n], dt); + let s = 2 * this.phase[n] - 1 - p; + sl = sl + s * gainL; + sr = sr + s * gainL; + + this.phase[n] += dt; + if (this.phase[n] > 1) { + this.phase[n] -= 1; + } + } + + return sl + sr; + //TODO: make stereo + // return [sl, sr]; + } +} + +export class TriOsc { + phase = 0; + update(freq) { + this.phase += ISR * freq; + let phase = this.phase % 1; + let value = phase < 0.5 ? 2 * phase : 1 - 2 * (phase - 0.5); + return value * 2 - 1; + } +} + +export class TwoPoleFilter { + s0 = 0; + s1 = 0; + update(s, cutoff, resonance = 0) { + // Out of bound values can produce NaNs + resonance = Math.max(resonance, 0); + + cutoff = Math.min(cutoff, 20000); + const c = 2 * Math.sin(cutoff * PI_DIV_SR); + + const r = Math.pow(0.5, (resonance + 0.125) / 0.125); + const mrc = 1 - r * c; + + this.s0 = mrc * this.s0 - c * this.s1 + c * s; // bpf + this.s1 = mrc * this.s1 + c * this.s0; // lpf + return this.s1; // return lpf by default + } +} + +class PulseOsc { + constructor(phase = 0) { + this.phase = phase; + } + saw(offset, dt) { + let phase = (this.phase + offset) % 1; + let p = polyBlep(phase, dt); + return 2 * phase - 1 - p; + } + update(freq, pw = 0.5) { + const dt = freq / SAMPLE_RATE; + let pulse = this.saw(0, dt) - this.saw(pw, dt); + this.phase = (this.phase + dt) % 1; + return pulse + pw * 2 - 1; + } +} + +// non bandlimited (has aliasing) +export class PulzeOsc { + phase = 0; + update(freq, duty = 0.5) { + this.phase += ISR * freq; + let cyclePos = this.phase % 1; + return cyclePos < duty ? 1 : -1; + } +} + +export class Dust { + update = (density) => (Math.random() < density * ISR ? Math.random() : 0); +} + +export class WhiteNoise { + update() { + return Math.random() * 2 - 1; + } +} + +export class BrownNoise { + constructor() { + this.out = 0; + } + update() { + let white = Math.random() * 2 - 1; + this.out = (this.out + 0.02 * white) / 1.02; + return this.out; + } +} + +export class PinkNoise { + constructor() { + this.b0 = 0; + this.b1 = 0; + this.b2 = 0; + this.b3 = 0; + this.b4 = 0; + this.b5 = 0; + this.b6 = 0; + } + + update() { + const white = Math.random() * 2 - 1; + + this.b0 = 0.99886 * this.b0 + white * 0.0555179; + this.b1 = 0.99332 * this.b1 + white * 0.0750759; + this.b2 = 0.969 * this.b2 + white * 0.153852; + this.b3 = 0.8665 * this.b3 + white * 0.3104856; + this.b4 = 0.55 * this.b4 + white * 0.5329522; + this.b5 = -0.7616 * this.b5 - white * 0.016898; + + const pink = this.b0 + this.b1 + this.b2 + this.b3 + this.b4 + this.b5 + this.b6 + white * 0.5362; + this.b6 = white * 0.115926; + + return pink * 0.11; + } +} + +export class Impulse { + phase = 1; + update(freq) { + this.phase += ISR * freq; + let v = this.phase >= 1 ? 1 : 0; + this.phase = this.phase % 1; + return v; + } +} + +export class ClockDiv { + inSgn = true; + outSgn = true; + clockCnt = 0; + update(clock, factor) { + let curSgn = clock > 0; + if (this.inSgn != curSgn) { + this.clockCnt++; + if (this.clockCnt >= factor) { + this.clockCnt = 0; + this.outSgn = !this.outSgn; + } + } + + this.inSgn = curSgn; + return this.outSgn ? 1 : -1; + } +} + +export class Hold { + value = 0; + trigSgn = false; + update(input, trig) { + if (!this.trigSgn && trig > 0) this.value = input; + this.trigSgn = trig > 0; + return this.value; + } +} + +function lerp(x, y0, y1, exponent = 1) { + if (x <= 0) return y0; + if (x >= 1) return y1; + + let curvedX; + + if (exponent === 0) { + curvedX = x; // linear + } else if (exponent > 0) { + curvedX = Math.pow(x, exponent); // ease-in + } else { + curvedX = 1 - Math.pow(1 - x, -exponent); // ease-out + } + + return y0 + (y1 - y0) * curvedX; +} + +export class ADSR { + constructor(props = {}) { + this.state = 'off'; + this.startTime = 0; + this.startVal = 0; + this.decayCurve = props.decayCurve ?? 1; + } + + update(curTime, gate, attack, decay, susVal, release) { + switch (this.state) { + case 'off': { + if (gate > 0) { + this.state = 'attack'; + this.startTime = curTime; + this.startVal = 0; + } + return 0; + } + case 'attack': { + let time = curTime - this.startTime; + if (time > attack) { + this.state = 'decay'; + this.startTime = curTime; + return 1; + } + return lerp(time / attack, this.startVal, 1, 1); + } + case 'decay': { + let time = curTime - this.startTime; + let curVal = lerp(time / decay, 1, susVal, -this.decayCurve); + if (gate <= 0) { + this.state = 'release'; + this.startTime = curTime; + this.startVal = curVal; + return curVal; + } + if (time > decay) { + this.state = 'sustain'; + this.startTime = curTime; + return susVal; + } + return curVal; + } + case 'sustain': { + if (gate <= 0) { + this.state = 'release'; + this.startTime = curTime; + this.startVal = susVal; + } + return susVal; + } + case 'release': { + let time = curTime - this.startTime; + + if (time > release) { + this.state = 'off'; + return 0; + } + let curVal = lerp(time / release, this.startVal, 0, -this.decayCurve); + if (gate > 0) { + this.state = 'attack'; + this.startTime = curTime; + this.startVal = curVal; + } + return curVal; + } + } + throw 'invalid envelope state'; + } +} + +/* + impulse(1).ad(.1).mul(sine(200)) +.add(x=>x.delay(.1).mul(.8)) +.out()*/ +const MAX_DELAY_TIME = 10; +export class PitchDelay { + lpf = new TwoPoleFilter(); + constructor(_props = {}) { + this.buffer = new Float32Array(MAX_DELAY_TIME * SAMPLE_RATE); + this.writeIdx = 0; + this.readIdx = 0; + this.numSamples = 0; + } + write(s, delayTime) { + // Calculate how far in the past to read + this.numSamples = Math.min(Math.floor(SAMPLE_RATE * delayTime), this.buffer.length - 1); + this.writeIdx = (this.writeIdx + 1) % this.numSamples; + this.buffer[this.writeIdx] = s; + this.readIdx = this.writeIdx - this.numSamples + 1; + + // If past the start of the buffer, wrap around (Q: is this possible?) + if (this.readIdx < 0) this.readIdx += this.numSamples; + } + update(input, delayTime, speed = 1) { + this.write(input, delayTime); + let index = this.readIdx; + if (speed < 0) { + index = this.numSamples - Math.floor(Math.abs(this.readIdx * speed) % this.numSamples); + } else { + index = Math.floor(this.readIdx * speed) % this.numSamples; + } + const s = this.lpf.update(this.buffer[index], 0.9, 0); + + return s; + } +} + +export class Delay { + writeIdx = 0; + readIdx = 0; + buffer = new Float32Array(MAX_DELAY_TIME * SAMPLE_RATE); //.fill(0) + write(s, delayTime) { + this.writeIdx = (this.writeIdx + 1) % this.buffer.length; + this.buffer[this.writeIdx] = s; + // Calculate how far in the past to read + let numSamples = Math.min(Math.floor(SAMPLE_RATE * delayTime), this.buffer.length - 1); + this.readIdx = this.writeIdx - numSamples; + // If past the start of the buffer, wrap around + if (this.readIdx < 0) this.readIdx += this.buffer.length; + } + update(input, delayTime) { + this.write(input, delayTime); + return this.buffer[this.readIdx]; + } +} +//TODO: Figure out why clicking at the start off the buffer +export class Chorus { + delay = new Delay(); + modulator = new TriOsc(); + update(input, mix, delayTime, modulationFreq, modulationDepth) { + const m = this.modulator.update(modulationFreq) * modulationDepth; + const c = this.delay.update(input, delayTime * (1 + m)); + return crossfade(input, c, mix); + } +} + +export class Fold { + update(input = 0, rate = 0) { + if (rate < 0) rate = 0; + rate = rate + 1; + input = input * rate; + return 4 * (Math.abs(0.25 * input + 0.25 - Math.round(0.25 * input + 0.25)) - 0.25); + } +} + +export class Lag { + lagUnit = 4410; + s = 0; + update(input, rate) { + // Remap so the useful range is around [0, 1] + rate = rate * this.lagUnit; + if (rate < 1) rate = 1; + this.s += (1 / rate) * (input - this.s); + return this.s; + } +} + +export class Slew { + last = 0; + update(input, up, dn) { + const upStep = up * ISR; + const downStep = dn * ISR; + let delta = input - this.last; + if (delta > upStep) { + delta = upStep; + } else if (delta < -downStep) { + delta = -downStep; + } + this.last += delta; + return this.last; + } +} + +// overdrive style distortion (adapted from noisecraft) currently unused +export function applyDistortion(x, amount) { + amount = Math.min(Math.max(amount, 0), 1); + amount -= 0.01; + var k = (2 * amount) / (1 - amount); + var y = ((1 + k) * x) / (1 + k * Math.abs(x)); + return y; +} + +export class Sequence { + clockSgn = true; + step = 0; + first = true; + update(clock, ...ins) { + if (!this.clockSgn && clock > 0) { + this.step = (this.step + 1) % ins.length; + this.clockSgn = clock > 0; + return 0; // set first sample to zero to retrigger gates on step change... + } + this.clockSgn = clock > 0; + return ins[this.step]; + } +} + +// sample rate bit crusher +export class Coarse { + hold = 0; + t = 0; + update(input, coarse) { + if (this.t++ % coarse === 0) { + this.t = 0; + this.hold = input; + } + return this.hold; + } +} + +// amplitude bit crusher +export class Crush { + update(input, crush) { + crush = Math.max(1, crush); + const x = Math.pow(2, crush - 1); + return Math.round(input * x) / x; + } +} + +// this is the distort from superdough +export class Distort { + update(input, distort = 0, postgain = 1) { + postgain = Math.max(0.001, Math.min(1, postgain)); + const shape = Math.expm1(distort); + return (((1 + shape) * input) / (1 + shape * Math.abs(input))) * postgain; + } +} +// distortion could be expressed as a function, because it's stateless + +export class BufferPlayer { + static samples = new Map(); // string -> { channels, sampleRate } + buffer; // Float32Array + sampleRate; + pos = 0; + sampleFreq = note2freq(); + constructor(buffer, sampleRate, normalize) { + this.buffer = buffer; + this.sampleRate = sampleRate; + this.duration = this.buffer.length / this.sampleRate; + this.speed = SAMPLE_RATE / this.sampleRate; + if (normalize) { + // this will make the buffer last 1s if freq = sampleFreq + // it's useful to loop samples (e.g. fit function) + this.speed *= this.duration; + } + } + update(freq) { + if (this.pos >= this.buffer.length) { + return 0; + } + const speed = (freq / this.sampleFreq) * this.speed; + let s = this.buffer[Math.floor(this.pos)]; + this.pos = this.pos + speed; + return s; + } +} + +export function _rangex(sig, min, max) { + let logmin = Math.log(min); + let range = Math.log(max) - logmin; + const unipolar = (sig + 1) / 2; + return Math.exp(unipolar * range + logmin); +} + +// duplicate +export const getADSR = (params, curve = 'linear', defaultValues) => { + const envmin = curve === 'exponential' ? 0.001 : 0.001; + const releaseMin = 0.01; + const envmax = 1; + const [a, d, s, r] = params; + if (a == null && d == null && s == null && r == null) { + return defaultValues ?? [envmin, envmin, envmax, releaseMin]; + } + const sustain = s != null ? s : (a != null && d == null) || (a == null && d == null) ? envmax : envmin; + return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)]; +}; + +let shapes = { + sine: SineOsc, + saw: SawOsc, + zaw: ZawOsc, + sawtooth: SawOsc, + zawtooth: ZawOsc, + supersaw: SupersawOsc, + tri: TriOsc, + triangle: TriOsc, + pulse: PulseOsc, + square: PulseOsc, + pulze: PulzeOsc, + dust: Dust, + crackle: Dust, + impulse: Impulse, + white: WhiteNoise, + brown: BrownNoise, + pink: PinkNoise, +}; + +const defaultDefaultValues = { + chorus: 0, + note: 48, + s: 'triangle', + bank: '', + gain: 1, + postgain: 1, + velocity: 1, + density: '.03', + ftype: '12db', + fanchor: 0, + //resonance: 1, // superdough resonance is scaled differently + resonance: 0, + //hresonance: 1, // superdough resonance is scaled differently + hresonance: 0, + // bandq: 1, // superdough resonance is scaled differently + bandq: 0, + channels: [1, 2], + phaserdepth: 0.75, + shapevol: 1, + distortvol: 1, + delay: 0, + byteBeatExpression: '0', + delayfeedback: 0.5, + delayspeed: 1, + delaytime: 0.25, + orbit: 1, + i: 1, + fft: 8, + z: 'triangle', + pan: 0.5, + fmh: 1, + fmenv: 0, // differs from superdough + speed: 1, + pw: 0.5, +}; + +let getDefaultValue = (key) => defaultDefaultValues[key]; + +const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +const accs = { '#': 1, b: -1, s: 1, f: -1 }; +const note2midi = (note, defaultOctave = 3) => { + let [pc, acc = '', oct = ''] = + String(note) + .match(/^([a-gA-G])([#bsf]*)([0-9]*)$/) + ?.slice(1) || []; + if (!pc) { + throw new Error('not a note: "' + note + '"'); + } + const chroma = chromas[pc.toLowerCase()]; + const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0; + oct = Number(oct || defaultOctave); + return (oct + 1) * 12 + chroma + offset; +}; +const midi2freq = (midi) => Math.pow(2, (midi - 69) / 12) * 440; +const note2freq = (note) => { + note = note || getDefaultValue('note'); + if (typeof note === 'string') { + note = note2midi(note, 3); // e.g. c3 => 48 + } + return midi2freq(note); +}; + +export class DoughVoice { + out = [0, 0]; + constructor(value) { + value.freq ??= note2freq(value.note); + let $ = this; + Object.assign($, value); + $.s = $.s ?? getDefaultValue('s'); + $.gain = applyGainCurve($.gain ?? getDefaultValue('gain')); + $.velocity = applyGainCurve($.velocity ?? getDefaultValue('velocity')); + $.postgain = applyGainCurve($.postgain ?? getDefaultValue('postgain')); + $.density = $.density ?? getDefaultValue('density'); + $.fanchor = $.fanchor ?? getDefaultValue('fanchor'); + $.drive = $.drive ?? 0.69; + $.phaserdepth = $.phaserdepth ?? getDefaultValue('phaserdepth'); + $.shapevol = applyGainCurve($.shapevol ?? getDefaultValue('shapevol')); + $.distortvol = applyGainCurve($.distortvol ?? getDefaultValue('distortvol')); + $.i = $.i ?? getDefaultValue('i'); + $.chorus = $.chorus ?? getDefaultValue('chorus'); + $.fft = $.fft ?? getDefaultValue('fft'); + $.pan = $.pan ?? getDefaultValue('pan'); + $.orbit = $.orbit ?? getDefaultValue('orbit'); + $.fmenv = $.fmenv ?? getDefaultValue('fmenv'); + $.resonance = $.resonance ?? getDefaultValue('resonance'); + $.hresonance = $.hresonance ?? getDefaultValue('hresonance'); + $.bandq = $.bandq ?? getDefaultValue('bandq'); + $.speed = $.speed ?? getDefaultValue('speed'); + $.pw = $.pw ?? getDefaultValue('pw'); + + [$.attack, $.decay, $.sustain, $.release] = getADSR([$.attack, $.decay, $.sustain, $.release]); + + $._holdEnd = $._begin + $._duration; // needed for gate + $._end = $._holdEnd + $.release + 0.01; // needed for despawn + + if ($.fmi && ($.s === 'saw' || $.s === 'sawtooth')) { + $.s = 'zaw'; // polyblepped saw when fm is applied + } + + if (shapes[$.s]) { + const SourceClass = shapes[$.s]; + $._sound = new SourceClass(); + $._channels = 1; + } else if (BufferPlayer.samples.has($.s)) { + const sample = BufferPlayer.samples.get($.s); + $._buffers = []; + $._channels = sample.channels.length; + for (let i = 0; i < $._channels; i++) { + $._buffers.push(new BufferPlayer(sample.channels[i], sample.sampleRate, $.unit === 'c')); // tbd unit === 'c' + } + } else { + console.warn('sound not loaded', $.s); + } + + if ($.penv) { + $._penv = new ADSR({ decayCurve: 4 }); + [$.pattack, $.pdecay, $.psustain, $.prelease] = getADSR([$.pattack, $.pdecay, $.psustain, $.prelease]); + } + + if ($.vib) { + $._vib = new SineOsc(); + $.vibmod = $.vibmod ?? getDefaultValue('vibmod'); + } + + if ($.fmi) { + $._fm = new SineOsc(); + $.fmh = $.fmh ?? getDefaultValue('fmh'); + if ($.fmenv) { + $._fmenv = new ADSR({ decayCurve: 2 }); + [$.fmattack, $.fmdecay, $.fmsustain, $.fmrelease] = getADSR([$.fmattack, $.fmdecay, $.fmsustain, $.fmrelease]); + } + } + + // gain envelope + $._adsr = new ADSR({ decayCurve: 2 }); + // delay + $.delay = applyGainCurve($.delay ?? getDefaultValue('delay')); + $.delayfeedback = $.delayfeedback ?? getDefaultValue('delayfeedback'); + $.delayspeed = $.delayspeed ?? getDefaultValue('delayspeed'); + $.delaytime = $.delaytime ?? getDefaultValue('delaytime'); + + // filter setup + if ($.lpenv) { + $._lpenv = new ADSR({ decayCurve: 4 }); + [$.lpattack, $.lpdecay, $.lpsustain, $.lprelease] = getADSR([$.lpattack, $.lpdecay, $.lpsustain, $.lprelease]); + } + if ($.hpenv) { + $._hpenv = new ADSR({ decayCurve: 4 }); + [$.hpattack, $.hpdecay, $.hpsustain, $.hprelease] = getADSR([$.hpattack, $.hpdecay, $.hpsustain, $.hprelease]); + } + if ($.bpenv) { + $._bpenv = new ADSR({ decayCurve: 4 }); + [$.bpattack, $.bpdecay, $.bpsustain, $.bprelease] = getADSR([$.bpattack, $.bpdecay, $.bpsustain, $.bprelease]); + } + + // channelwise effects setup + $._chorus = $.chorus ? [] : null; + $._lpf = $.cutoff ? [] : null; + $._hpf = $.hcutoff ? [] : null; + $._bpf = $.bandf ? [] : null; + $._coarse = $.coarse ? [] : null; + $._crush = $.crush ? [] : null; + $._distort = $.distort ? [] : null; + for (let i = 0; i < this._channels; i++) { + $._lpf?.push(new TwoPoleFilter()); + $._hpf?.push(new TwoPoleFilter()); + $._bpf?.push(new TwoPoleFilter()); + $._chorus?.push(new Chorus()); + $._coarse?.push(new Coarse()); + $._crush?.push(new Crush()); + $._distort?.push(new Distort()); + } + } + update(t) { + if (!this._sound && !this._buffers) { + return 0; + } + let gate = Number(t >= this._begin && t <= this._holdEnd); + + let freq = this.freq * this.speed; + + // frequency modulation + if (this._fm) { + let fmi = this.fmi; + if (this._fmenv) { + const env = this._fmenv.update(t, gate, this.fmattack, this.fmdecay, this.fmsustain, this.fmrelease); + fmi = this.fmenv * env * fmi; + } + const modfreq = freq * this.fmh; + const modgain = modfreq * fmi; + freq = freq + this._fm.update(modfreq) * modgain; + } + + // vibrato + if (this._vib) { + freq = freq * 2 ** ((this._vib.update(this.vib) * this.vibmod) / 12); + } + + // pitch envelope + if (this._penv) { + const env = this._penv.update(t, gate, this.pattack, this.pdecay, this.psustain, this.prelease); + freq = freq + env * this.penv; + } + + // filters + let lpf = this.cutoff; + if (this._lpf) { + if (this._lpenv) { + const env = this._lpenv.update(t, gate, this.lpattack, this.lpdecay, this.lpsustain, this.lprelease); + lpf = this.lpenv * env * lpf + lpf; + } + } + let hpf = this.hcutoff; + if (this._hpf) { + if (this._hpenv) { + const env = this._hpenv.update(t, gate, this.hpattack, this.hpdecay, this.hpsustain, this.hprelease); + hpf = 2 ** this.hpenv * env * hpf + hpf; + } + } + let bpf = this.bandf; + if (this._bpf) { + if (this._bpenv) { + const env = this._bpenv.update(t, gate, this.bpattack, this.bpdecay, this.bpsustain, this.bprelease); + bpf = 2 ** this.bpenv * env * bpf + bpf; + } + } + // gain envelope + const env = this._adsr.update(t, gate, this.attack, this.decay, this.sustain, this.release); + + // channelwise dsp + for (let i = 0; i < this._channels; i++) { + // sound source + if (this._sound && this.s === 'pulse') { + this.out[i] = this._sound.update(freq, this.pw); + } else if (this._sound) { + this.out[i] = this._sound.update(freq); + } else if (this._buffers) { + this.out[i] = this._buffers[i].update(freq); + } + this.out[i] = this.out[i] * this.gain * this.velocity; + if (this._chorus) { + const c = this._chorus[i].update(this.out[i], this.chorus, 0.03 + 0.05 * i, 1, 0.11); + this.out[i] = c + this.out[i]; + } + + if (this._lpf) { + this._lpf[i].update(this.out[i], lpf, this.resonance); + this.out[i] = this._lpf[i].s1; + } + if (this._hpf) { + this._hpf[i].update(this.out[i], hpf, this.hresonance); + this.out[i] = this.out[i] - this._hpf[i].s1; + } + if (this._bpf) { + this._bpf[i].update(this.out[i], bpf, this.bandq); + this.out[i] = this._bpf[i].s0; + } + if (this._coarse) { + this.out[i] = this._coarse[i].update(this.out[i], this.coarse); + } + if (this._crush) { + this.out[i] = this._crush[i].update(this.out[i], this.crush); + } + if (this._distort) { + this.out[i] = this._distort[i].update(this.out[i], this.distort, this.distortvol); + } + this.out[i] = this.out[i] * env; + this.out[i] = this.out[i] * this.postgain; + if (!this._buffers) { + this.out[i] = this.out[i] * 0.2; // turn down waveform + } + } + if (this._channels === 1) { + this.out[1] = this.out[0]; + } + if (this.pan !== 0.5) { + const panpos = (this.pan * Math.PI) / 2; + this.out[0] = this.out[0] * Math.cos(panpos); + this.out[1] = this.out[1] * Math.sin(panpos); + } + } +} + +// this class is the interface to the "outer world" +// it handles spawning and despawning of DoughVoice's +export class Dough { + voices = []; // DoughVoice[] + vid = 0; + q = []; + out = [0, 0]; + delaysend = [0, 0]; + delaytime = getDefaultValue('delaytime'); + delayfeedback = getDefaultValue('delayfeedback'); + delayspeed = getDefaultValue('delayspeed'); + t = 0; + // sampleRate: number, currentTime: number (seconds) + constructor(sampleRate = 48000, currentTime = 0) { + this.sampleRate = sampleRate; + this.t = Math.floor(currentTime * sampleRate); // samples + // console.log('init dough', this.sampleRate, this.t); + this._delayL = new PitchDelay(); + this._delayR = new PitchDelay(); + } + loadSample(name, channels, sampleRate) { + BufferPlayer.samples.set(name, { channels, sampleRate }); + } + scheduleSpawn(value) { + if (value._begin === undefined) { + throw new Error('[dough]: scheduleSpawn expected _begin to be set'); + } + if (value._duration === undefined) { + throw new Error('[dough]: scheduleSpawn expected _duration to be set'); + } + value.sampleRate = this.sampleRate; + // convert seconds to samples + const time = Math.floor(value._begin * this.sampleRate); // set from supradough.mjs + this.schedule({ time, type: 'spawn', arg: value }); + } + spawn(value) { + value.id = this.vid++; + const voice = new DoughVoice(value); + this.voices.push(voice); + // console.log('spawn', voice.id, 'voices:', this.voices.length); + // schedule removal + const endTime = Math.ceil(voice._end * this.sampleRate); + this.schedule({ time: endTime /* + 48000 */, type: 'despawn', arg: voice.id }); + } + despawn(vid) { + this.voices = this.voices.filter((v) => v.id !== vid); + // console.log('despawn', vid, 'voices:', this.voices.length); + } + // schedules a function call with a single argument + // msg = {time:number,type:string, arg: any} + // the Dough method "type" will be called with "arg" at "time" + schedule(msg) { + if (!this.q.length) { + // if empty, just push + this.q.push(msg); + return; + } + // not empty + // find index where msg.time fits in + let i = 0; + while (i < this.q.length && this.q[i].time < msg.time) { + i++; + } + // this ensures q stays sorted by time, so we only need to check q[0] + this.q.splice(i, 0, msg); + } + // maybe update should be called once per block instead for perf reasons? + update() { + // go over q + while (this.q.length > 0 && this.q[0].time <= this.t) { + // console.log('schedule', this.q[0]); + // trigger due messages. q is sorted, so we only need to check q[0] + this[this.q[0].type](this.q[0].arg); // type is expected to be a Dough method + this.q.shift(); + } + // add active voices + this.out[0] = 0; + this.out[1] = 0; + for (let v = 0; v < this.voices.length; v++) { + this.voices[v].update(this.t / this.sampleRate); + this.out[0] += this.voices[v].out[0]; + this.out[1] += this.voices[v].out[1]; + if (this.voices[v].delay) { + this.delaysend[0] += this.voices[v].out[0] * this.voices[v].delay; + this.delaysend[1] += this.voices[v].out[1] * this.voices[v].delay; + this.delaytime = this.voices[v].delaytime; // we trust that these are initialized in the voice + this.delayspeed = this.voices[v].delayspeed; // we trust that these are initialized in the voice + this.delayfeedback = this.voices[v].delayfeedback; + } + } + // todo: how to change delaytime / delayfeedback from a voice? + const delayL = this._delayL.update(this.delaysend[0], this.delaytime, this.delayspeed); + const delayR = this._delayR.update(this.delaysend[1], this.delaytime, this.delayspeed); + this.delaysend[0] = delayL * this.delayfeedback; + this.delaysend[1] = delayR * this.delayfeedback; + this.out[0] += delayL; + this.out[1] += delayR; + this.t++; + } +} diff --git a/packages/supradough/index.mjs b/packages/supradough/index.mjs new file mode 100644 index 000000000..54a835495 --- /dev/null +++ b/packages/supradough/index.mjs @@ -0,0 +1,4 @@ +import _workletUrl from './dough-worklet.mjs?url'; // todo: change ?url to ?audioworklet before build (?audioworklet doesn't hot reload) + +export * from './dough.mjs'; +export const workletUrl = _workletUrl; diff --git a/packages/supradough/package.json b/packages/supradough/package.json new file mode 100644 index 000000000..7e465c0a9 --- /dev/null +++ b/packages/supradough/package.json @@ -0,0 +1,37 @@ +{ + "name": "supradough", + "version": "1.2.3", + "description": "platform agnostic synth and sampler intended for live coding. a reimplementation of superdough.", + "main": "index.mjs", + "type": "module", + "publishConfig": { + "main": "dist/index.mjs" + }, + "scripts": { + "build": "vite build", + "prepublishOnly": "npm run build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/tidalcycles/strudel.git" + }, + "keywords": [ + "tidalcycles", + "strudel", + "pattern", + "livecoding", + "algorave" + ], + "author": "Felix Roos ", + "license": "AGPL-3.0-or-later", + "bugs": { + "url": "https://github.com/tidalcycles/strudel/issues" + }, + "homepage": "https://github.com/tidalcycles/strudel#readme", + "devDependencies": { + "vite": "^6.0.11", + "vite-plugin-bundle-audioworklet": "workspace:*", + "wav-encoder": "^1.3.0" + }, + "dependencies": {} +} diff --git a/packages/webaudio/index.mjs b/packages/webaudio/index.mjs index 362e61c44..4933b7a01 100644 --- a/packages/webaudio/index.mjs +++ b/packages/webaudio/index.mjs @@ -7,4 +7,5 @@ This program is free software: you can redistribute it and/or modify it under th export * from './webaudio.mjs'; export * from './scope.mjs'; export * from './spectrum.mjs'; +export * from './supradough.mjs'; export * from 'superdough'; diff --git a/packages/webaudio/package.json b/packages/webaudio/package.json index cbe673a5a..49da00f23 100644 --- a/packages/webaudio/package.json +++ b/packages/webaudio/package.json @@ -35,7 +35,8 @@ "dependencies": { "@strudel/core": "workspace:*", "@strudel/draw": "workspace:*", - "superdough": "workspace:*" + "superdough": "workspace:*", + "supradough": "workspace:*" }, "devDependencies": { "vite": "^6.0.11" diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs new file mode 100644 index 000000000..f97251a07 --- /dev/null +++ b/packages/webaudio/supradough.mjs @@ -0,0 +1,130 @@ +import { Pattern } from '@strudel/core'; +import { connectToDestination, getAudioContext, getWorklet } from 'superdough'; + +let doughWorklet; + +function initDoughWorklet() { + const ac = getAudioContext(); + doughWorklet = getWorklet( + ac, + 'dough-processor', + {}, + { + outputChannelCount: [2], + }, + ); + connectToDestination(doughWorklet); // channels? +} + +const soundMap = new Map(); +const loadedSounds = new Map(); + +Pattern.prototype.supradough = function () { + return this.onTrigger((hap, __, cps, begin) => { + hap.value._begin = begin; + hap.value._duration = hap.duration / cps; + !doughWorklet && initDoughWorklet(); + const s = (hap.value.bank ? hap.value.bank + '_' : '') + hap.value.s; + const n = hap.value.n ?? 0; + const soundKey = `${s}:${n}`; + if (soundMap.has(s)) { + hap.value.s = soundKey; // dough.mjs is unaware of bank and n (only maps keys to buffers) + } + if (soundMap.has(s) && !loadedSounds.has(soundKey)) { + const urls = soundMap.get(s); + const url = urls[n % urls.length]; + console.log(`load ${soundKey} from ${url}`); + const loadSample = fetchSample(url); + loadedSounds.set(soundKey, loadSample); + loadSample.then(({ channels, sampleRate }) => + doughWorklet.port.postMessage({ + sample: soundKey, + channels, + sampleRate, + }), + ); + } + + doughWorklet.port.postMessage({ spawn: hap.value }); + }, 1); +}; + +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}`; +} +export async function fetchSampleMap(url) { + if (url.startsWith('github:')) { + url = githubPath(url, 'strudel.json'); + } + if (url.startsWith('local:')) { + url = `http://localhost:5432`; + } + if (url.startsWith('shabda:')) { + let [_, path] = url.split('shabda:'); + url = `https://shabda.ndre.gr/${path}.json?strudel=1`; + } + if (url.startsWith('shabda/speech')) { + let [_, path] = url.split('shabda/speech'); + path = path.startsWith('/') ? path.substring(1) : path; + let [params, words] = path.split(':'); + let gender = 'f'; + let language = 'en-GB'; + if (params) { + [language, gender] = params.split('/'); + } + url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`; + } + 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; + } + const json = await fetch(url) + .then((res) => res.json()) + .catch((error) => { + console.error(error); + throw new Error(`error loading "${url}"`); + }); + return [json, json._base || base]; +} + +// for some reason, only piano and flute work.. is it because mp3?? + +async function fetchSample(url) { + const buffer = await fetch(url) + .then((res) => res.arrayBuffer()) + .then((buf) => getAudioContext().decodeAudioData(buf)); + let channels = []; + for (let i = 0; i < buffer.numberOfChannels; i++) { + channels.push(buffer.getChannelData(i)); + } + return { channels, sampleRate: buffer.sampleRate }; +} + +export async function doughsamples(sampleMap, baseUrl) { + if (typeof sampleMap === 'string') { + const [json, base] = await fetchSampleMap(sampleMap); + // console.log('json', json, 'base', base); + return doughsamples(json, base); + } + Object.entries(sampleMap).map(async ([key, urls]) => { + if (key !== '_base') { + urls = urls.map((url) => baseUrl + url); + // console.log('set', key, urls); + soundMap.set(key, urls); + } + }); +} diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index 429d2a26b..383e87f87 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -5,7 +5,12 @@ This program is free software: you can redistribute it and/or modify it under th */ import * as strudel from '@strudel/core'; -import { superdough, getAudioContext, setLogger, doughTrigger } from 'superdough'; +import { superdough, getAudioContext, setLogger, doughTrigger, registerWorklet } from 'superdough'; +import './supradough.mjs'; +import { workletUrl } from 'supradough'; + +registerWorklet(workletUrl); + const { Pattern, logger, repl } = strudel; setLogger(logger); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4a24f61c..c225057f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -517,6 +517,18 @@ importers: specifier: workspace:* version: link:../vite-plugin-bundle-audioworklet + packages/supradough: + devDependencies: + vite: + specifier: ^6.0.11 + version: 6.0.11(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0) + vite-plugin-bundle-audioworklet: + specifier: workspace:* + version: link:../vite-plugin-bundle-audioworklet + wav-encoder: + specifier: ^1.3.0 + version: 1.3.0 + packages/tidal: dependencies: '@strudel/core': @@ -625,6 +637,9 @@ importers: superdough: specifier: workspace:* version: link:../superdough + supradough: + specifier: workspace:* + version: link:../supradough devDependencies: vite: specifier: ^6.0.11 @@ -7537,6 +7552,9 @@ packages: walk-up-path@3.0.1: resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==} + wav-encoder@1.3.0: + resolution: {integrity: sha512-FXJdEu2qDOI+wbVYZpu21CS1vPEg5NaxNskBr4SaULpOJMrLE6xkH8dECa7PiS+ZoeyvP7GllWUAxPN3AvFSEw==} + wav@1.0.2: resolution: {integrity: sha512-viHtz3cDd/Tcr/HbNqzQCofKdF6kWUymH9LGDdskfWFoIy/HJ+RTihgjEcHfnsy1PO4e9B+y4HwgTwMrByquhg==} @@ -15958,6 +15976,8 @@ snapshots: walk-up-path@3.0.1: {} + wav-encoder@1.3.0: {} + wav@1.0.2: dependencies: buffer-alloc: 1.2.0 diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 41207620f..ecbc33eac 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -1847,6 +1847,27 @@ exports[`runs examples > example "chop" example index 0 1`] = ` ] `; +exports[`runs examples > example "chorus" example index 0 1`] = ` +[ + "[ 0/1 → 1/4 | note:d s:sawtooth chorus:0.5 ]", + "[ 1/4 → 1/2 | note:d s:sawtooth chorus:0.5 ]", + "[ 1/2 → 3/4 | note:a# s:sawtooth chorus:0.5 ]", + "[ 3/4 → 1/1 | note:a s:sawtooth chorus:0.5 ]", + "[ 1/1 → 5/4 | note:d s:sawtooth chorus:0.5 ]", + "[ 5/4 → 3/2 | note:d s:sawtooth chorus:0.5 ]", + "[ 3/2 → 7/4 | note:a# s:sawtooth chorus:0.5 ]", + "[ 7/4 → 2/1 | note:a s:sawtooth chorus:0.5 ]", + "[ 2/1 → 9/4 | note:d s:sawtooth chorus:0.5 ]", + "[ 9/4 → 5/2 | note:d s:sawtooth chorus:0.5 ]", + "[ 5/2 → 11/4 | note:a# s:sawtooth chorus:0.5 ]", + "[ 11/4 → 3/1 | note:a s:sawtooth chorus:0.5 ]", + "[ 3/1 → 13/4 | note:d s:sawtooth chorus:0.5 ]", + "[ 13/4 → 7/2 | note:d s:sawtooth chorus:0.5 ]", + "[ 7/2 → 15/4 | note:a# s:sawtooth chorus:0.5 ]", + "[ 15/4 → 4/1 | note:a s:sawtooth chorus:0.5 ]", +] +`; + exports[`runs examples > example "chunk" example index 0 1`] = ` [ "[ 0/1 → 1/4 | note:A4 ]", @@ -2585,6 +2606,52 @@ exports[`runs examples > example "delayfeedback" example index 0 1`] = ` ] `; +exports[`runs examples > example "delayfeedback" example index 0 2`] = ` +[ + "[ 0/1 → 1/1 | s:bd delay:0.25 delayfeedback:0.25 ]", + "[ 1/1 → 2/1 | s:bd delay:0.25 delayfeedback:0.5 ]", + "[ 2/1 → 3/1 | s:bd delay:0.25 delayfeedback:0.75 ]", + "[ 3/1 → 4/1 | s:bd delay:0.25 delayfeedback:1 ]", +] +`; + +exports[`runs examples > example "delayspeed" example index 0 1`] = ` +[ + "[ 0/1 → 1/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", + "[ 1/8 → 1/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", + "[ 1/4 → 3/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", + "[ 3/8 → 1/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", + "[ 1/2 → 5/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", + "[ 5/8 → 3/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", + "[ 3/4 → 7/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", + "[ 7/8 → 1/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]", + "[ 1/1 → 9/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", + "[ 9/8 → 5/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", + "[ 5/4 → 11/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", + "[ 11/8 → 3/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", + "[ 3/2 → 13/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", + "[ 13/8 → 7/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", + "[ 7/4 → 15/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", + "[ 15/8 → 2/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]", + "[ 2/1 → 17/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", + "[ 17/8 → 9/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", + "[ 9/4 → 19/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", + "[ 19/8 → 5/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", + "[ 5/2 → 21/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", + "[ 21/8 → 11/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", + "[ 11/4 → 23/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", + "[ 23/8 → 3/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]", + "[ 3/1 → 25/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", + "[ 25/8 → 13/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", + "[ 13/4 → 27/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", + "[ 27/8 → 7/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", + "[ 7/2 → 29/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", + "[ 29/8 → 15/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", + "[ 15/4 → 31/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", + "[ 31/8 → 4/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]", +] +`; + exports[`runs examples > example "delaysync" example index 0 1`] = ` [ "[ 0/1 → 1/2 | s:bd delay:0.25 delaysync:0.125 ]", @@ -2598,19 +2665,6 @@ exports[`runs examples > example "delaysync" example index 0 1`] = ` ] `; -exports[`runs examples > example "delaytime" example index 0 1`] = ` -[ - "[ 0/1 → 1/2 | s:bd delay:0.25 delaytime:0.125 ]", - "[ 1/2 → 1/1 | s:bd delay:0.25 delaytime:0.125 ]", - "[ 1/1 → 3/2 | s:bd delay:0.25 delaytime:0.25 ]", - "[ 3/2 → 2/1 | s:bd delay:0.25 delaytime:0.25 ]", - "[ 2/1 → 5/2 | s:bd delay:0.25 delaytime:0.5 ]", - "[ 5/2 → 3/1 | s:bd delay:0.25 delaytime:0.5 ]", - "[ 3/1 → 7/2 | s:bd delay:0.25 delaytime:1 ]", - "[ 7/2 → 4/1 | s:bd delay:0.25 delaytime:1 ]", -] -`; - exports[`runs examples > example "density" example index 0 1`] = ` [ "[ 0/1 → 1/4 | s:crackle density:0.01 ]", From a46617f3f0898ae417b993bbfbee8bfa5daf0765 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 12 Sep 2025 10:16:37 +0200 Subject: [PATCH 94/95] hotfix: use ?audioworklet for supradough worklet import --- packages/supradough/index.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/supradough/index.mjs b/packages/supradough/index.mjs index 54a835495..cb132fb3f 100644 --- a/packages/supradough/index.mjs +++ b/packages/supradough/index.mjs @@ -1,4 +1,5 @@ -import _workletUrl from './dough-worklet.mjs?url'; // todo: change ?url to ?audioworklet before build (?audioworklet doesn't hot reload) +// import _workletUrl from './dough-worklet.mjs?url'; // only for dev (breaks for production build) +import _workletUrl from './dough-worklet.mjs?audioworklet'; // only for prod (breaks in development?!) export * from './dough.mjs'; export const workletUrl = _workletUrl; From e18296e76095b18a883b977979b4cf18c8139df8 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sat, 13 Sep 2025 19:52:07 +0200 Subject: [PATCH 95/95] hotfix: word --- website/src/pages/learn/code.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/pages/learn/code.mdx b/website/src/pages/learn/code.mdx index 7057c4576..f9c6b3a25 100644 --- a/website/src/pages/learn/code.mdx +++ b/website/src/pages/learn/code.mdx @@ -44,7 +44,7 @@ xxx("foo").yyy("bar") Generally, `xxx` and `yyy` are called [_functions_](), while `foo` and `bar` are called function [_arguments_ or _parameters_](). So far, we've used the functions to declare which aspect of the sound we want to control, and their arguments for the actual data. -The `yyy` function is called a [_chained_ function](https://en.wikipedia.org/wiki/Method_chaining), because it is prepended with a dot (`.`). +The `yyy` function is called a [_chained_ function](https://en.wikipedia.org/wiki/Method_chaining), because it is preceded with a dot (`.`). Generally, the idea with chaining is that code such as `a("this").b("that").c("other")` allows `a`, `b` and `c` functions to happen in a specified order, without needing to write them as three separate lines of code. You can think of this as being similar to chaining audio effects together using guitar pedals or digital audio effects.