From 1be9f40574aa213d3ef501024e5a0e18cb04fef9 Mon Sep 17 00:00:00 2001 From: alex Date: Sat, 18 Oct 2025 10:42:35 +0100 Subject: [PATCH 01/32] timeline feature --- packages/core/impure.mjs | 60 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 packages/core/impure.mjs diff --git a/packages/core/impure.mjs b/packages/core/impure.mjs new file mode 100644 index 000000000..66fc1dd5f --- /dev/null +++ b/packages/core/impure.mjs @@ -0,0 +1,60 @@ +/* +stateful.mjs - File of shame for stateful, impure and otherwise illegal pattern methods +Copyright (C) 2025 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +import { register, reify } from './pattern.mjs'; + +let timelines = {}; + +export const reset_timelines = function () { + timelines = {}; +}; + +export const timeline = register( + 'timeline', + function (tpat, pat) { + tpat = reify(tpat); + const f = function (state) { + let scheduler = !!state.controls._cps; + + const timehaps = tpat.query(state); + const result = []; + for (const timehap of timehaps) { + const tlid = timehap.value; + const ignore = false; + let offset; + if (tlid in timelines) { + offset = timelines[tlid]; + } else { + const timearc = timehap.wholeOrPart(); + if (!scheduler || state.span.begin.lt(timearc.midpoint())) { + offset = timearc.begin; + } else { + // Sync to end of timearc if we first see it over halfway into its + // timespan. Allows 'cuing up' next timeline when live coding. + offset = timearc.end; + } + } + if (scheduler) { + // update state + timelines[tlid] = offset; + const negative = 0 - tlid; + if (negative in timelines) { + delete timelines[negative]; + } + } + + const pathaps = pat + .late(offset) + .query(state.setSpan(timehap.part)) + .map((h) => h.setContext(h.combineContext(timehap))); + result.push(...pathaps); + } + return result; + }; + return new Pattern(f, pat._steps); + }, + false, +); From b2ce4591d9bd4260511bb08a06f791f515452456 Mon Sep 17 00:00:00 2001 From: alex Date: Sat, 18 Oct 2025 10:42:55 +0100 Subject: [PATCH 02/32] sort exports and add impure.mjs --- packages/core/index.mjs | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/core/index.mjs b/packages/core/index.mjs index e4daf445a..9260e3671 100644 --- a/packages/core/index.mjs +++ b/packages/core/index.mjs @@ -1,6 +1,6 @@ /* index.mjs - -Copyright (C) 2022 Strudel contributors - see +Copyright (C) 2025 Strudel contributors - see This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ @@ -11,20 +11,21 @@ import createClock from './zyklus.mjs'; import { logger } from './logger.mjs'; export { Fraction, controls, createClock }; export * from './controls.mjs'; -export * from './hap.mjs'; -export * from './pattern.mjs'; -export * from './signal.mjs'; -export * from './pick.mjs'; -export * from './state.mjs'; -export * from './timespan.mjs'; -export * from './util.mjs'; -export * from './speak.mjs'; -export * from './evaluate.mjs'; -export * from './repl.mjs'; export * from './cyclist.mjs'; +export * from './evaluate.mjs'; +export * from './hap.mjs'; +export * from './impure.mjs'; export * from './logger.mjs'; +export * from './pattern.mjs'; +export * from './pick.mjs'; +export * from './repl.mjs'; +export * from './signal.mjs'; +export * from './speak.mjs'; +export * from './state.mjs'; export * from './time.mjs'; +export * from './timespan.mjs'; export * from './ui.mjs'; +export * from './util.mjs'; export { default as drawLine } from './drawLine.mjs'; // below won't work with runtime.mjs (json import fails) /* import * as p from './package.json'; From 92a82ce4bdf951ae80a34c0e609aa3515951fa6f Mon Sep 17 00:00:00 2001 From: alex Date: Sat, 18 Oct 2025 10:49:15 +0100 Subject: [PATCH 03/32] delint --- packages/core/impure.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/impure.mjs b/packages/core/impure.mjs index 66fc1dd5f..e16cfbe45 100644 --- a/packages/core/impure.mjs +++ b/packages/core/impure.mjs @@ -4,7 +4,7 @@ Copyright (C) 2025 Strudel contributors - see . */ -import { register, reify } from './pattern.mjs'; +import { register, reify, Pattern } from './pattern.mjs'; let timelines = {}; From 7bc6fcbf2a56a8b0f12aed9d9c386fbe43dbfa6c Mon Sep 17 00:00:00 2001 From: Aria Date: Sun, 23 Nov 2025 18:24:48 -0600 Subject: [PATCH 04/32] Add channel support to midi in --- packages/midi/midi.mjs | 34 +++++++++++++++++------ test/__snapshots__/examples.test.mjs.snap | 21 ++++++++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 32f030125..0fee380c6 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -479,14 +479,23 @@ Pattern.prototype.midi = function (midiport, options = {}) { let listeners = {}; const refs = {}; +const refsByChan = {}; /** * MIDI input: Opens a MIDI input port to receive MIDI control change messages. + * + * The output is a function that accepts a midi cc value to query as well as (optionally) a midi channel * @param {string | number} input MIDI device name or index defaulting to 0 - * @returns {Function} + * @returns {function(number, number=): function(): number} A function from (cc, channel?) to + * the most recently received midi cc value through the input (normalized to 0 to 1) * @example - * let cc = await midin('IAC Driver Bus 1') + * const cc = await midin('IAC Driver Bus 1') * note("c a f e").lpf(cc(0).range(0, 1000)).lpq(cc(1).range(0, 10)).sound("sawtooth") + * @example + * const allCC = await midin('IAC Driver Bus 1') + * const cc = (ccNum) => allCC(ccNum, 2) // just channel 2 + * note("c a f e").s("saw") + * .when(cc(0).gt(0), x => x.postgain(0)) */ export async function midin(input) { if (isPattern(input)) { @@ -511,17 +520,24 @@ export async function midin(input) { }`, ); } - // ensure refs for this input are initialized - if (!refs[input]) { - refs[input] = {}; - } - const cc = (cc) => ref(() => refs[input][cc] || 0); + refs[input] ??= {}; + refsByChan[input] ??= {}; + const cc = (cc, chan) => { + if (chan !== undefined) { + return ref(() => refsByChan[input][cc]?.[chan] || 0); + } + return ref(() => refs[input][cc] || 0); + }; listeners[input] && device.removeListener('midimessage', listeners[input]); listeners[input] = (e) => { - const cc = e.dataBytes[0]; + const ccNum = e.dataBytes[0]; const v = e.dataBytes[1]; - refs[input] && (refs[input][cc] = v / 127); + const chan = e.message.channel; + const scaled = v / 127; + refsByChan[input][ccNum] ??= {}; + refsByChan[input][ccNum][chan] = scaled; + refs[input][ccNum] = scaled; }; device.addListener('midimessage', listeners[input]); return cc; diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 31e4bddd3..783fee998 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -6533,6 +6533,27 @@ exports[`runs examples > example "midin" example index 0 1`] = ` ] `; +exports[`runs examples > example "midin" example index 1 1`] = ` +[ + "[ 0/1 → 1/4 | note:c s:saw ]", + "[ 1/4 → 1/2 | note:a s:saw ]", + "[ 1/2 → 3/4 | note:f s:saw ]", + "[ 3/4 → 1/1 | note:e s:saw ]", + "[ 1/1 → 5/4 | note:c s:saw ]", + "[ 5/4 → 3/2 | note:a s:saw ]", + "[ 3/2 → 7/4 | note:f s:saw ]", + "[ 7/4 → 2/1 | note:e s:saw ]", + "[ 2/1 → 9/4 | note:c s:saw ]", + "[ 9/4 → 5/2 | note:a s:saw ]", + "[ 5/2 → 11/4 | note:f s:saw ]", + "[ 11/4 → 3/1 | note:e s:saw ]", + "[ 3/1 → 13/4 | note:c s:saw ]", + "[ 13/4 → 7/2 | note:a s:saw ]", + "[ 7/2 → 15/4 | note:f s:saw ]", + "[ 15/4 → 4/1 | note:e s:saw ]", +] +`; + exports[`runs examples > example "midiport" example index 0 1`] = ` [ "[ 0/1 → 1/4 | note:c midiport:0 ]", From 020a6bc75fb1467a518c32c2d132298747693d8a Mon Sep 17 00:00:00 2001 From: Aria Date: Sun, 23 Nov 2025 20:31:31 -0600 Subject: [PATCH 05/32] First pass at transient processor --- packages/core/controls.mjs | 2 + packages/superdough/worklets.mjs | 70 ++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 809a45ab2..80a244efc 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -2584,3 +2584,5 @@ export const scrub = register( }, false, ); + +export const { transient } = registerControl(['transient', 'transsustain', 'transattack']); diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index bf633a63b..334e2d829 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -11,6 +11,9 @@ const PI = Math.PI; const TWO_PI = 2 * PI; const INVSR = 1 / sampleRate; +const timeToCoeff = (t) => Math.exp(-INVSR / t); +const dbToLin = (db) => Math.pow(10, db / 20); + const clamp = (num, min, max) => Math.min(Math.max(num, min), max); const mod = (n, m) => ((n % m) + m) % m; const lerp = (a, b, n) => n * (b - a) + a; @@ -1383,3 +1386,70 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { } registerProcessor('wavetable-oscillator-processor', WavetableOscillatorProcessor); + +class TransientProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return []; + } + + constructor(options) { + super(); + this.gainCoeff = timeToCoeff(0.2); + this.avgGain = 1; + let { + attackTime = 0.003, + sustainTime = 0.08, + attack = 0, + sustain = 0, + sensitivity = 0.1, + mix = 1, + } = options.processorOptions; + attackTime = clamp(attackTime, 0.0005, 0.05); + sustainTime = clamp(sustainTime, 0.01, 0.5); + this.attackCoeff = timeToCoeff(attackTime); + this.sustainCoeff = timeToCoeff(sustainTime); + this.attackAmt = clamp(attack, -1, 1); + this.sustainAmt = clamp(sustain, -1, 1); + this.scaling = 0.5 + 5 * clamp(sensitivity, 0, 1); + this.mix = clamp(mix, 0, 1); + } + + process(inputs, outputs, _params) { + const input = inputs[0]; + const output = outputs[0]; + if (!input || !output) { + return true; + } + const channels = output.length; + this.attackEnv ??= new Float32Array(channels); + this.sustainEnv ??= new Float32Array(channels); + let avgGain = this.avgGain; + for (let ch = 0; ch < channels; ch++) { + const inCh = input[ch]; + const outCh = output[ch]; + let attEnv = this.attackEnv[ch]; + let susEnv = this.sustainEnv[ch]; + for (let i = 0; i < blockSize; i++) { + const x = Math.abs(inCh[i]); + attEnv = lerp(attEnv, x, this.attackCoeff); + susEnv = lerp(susEnv, x, this.sustainCoeff); + const peakiness = clamp((this.scaling * (attEnv - susEnv)) / (susEnv + 1e-6), -1.5, 1.5); + const attScale = Math.max(0, peakiness); + const susScale = Math.max(0, -peakiness); + const attackGain = dbToLin(this.attackAmt * attScale * 18); // max 18db + const sustainGain = dbToLin(this.sustainAmt * susScale * 36); // max 36db + const gain = clamp(attackGain * sustainGain, 0, 8); + avgGain = lerp(avgGain, gain, this.gainCoeff); + const makeup = avgGain > 1e-3 ? 1 / avgGain : 1; + const wet = x * gain * makeup; + outCh[i] = lerp(x, wet, this.mix); + } + this.attackEnv[ch] = attEnv; + this.sustainEnv[ch] = susEnv; + } + this.avgGain = avgGain; + return true; + } +} + +registerProcessor('transient-processor', TransientProcessor); From 68840de188622e302276aa7dd31f8c895e6c6887 Mon Sep 17 00:00:00 2001 From: Aria Date: Sun, 23 Nov 2025 20:58:28 -0600 Subject: [PATCH 06/32] Working version --- packages/superdough/superdough.mjs | 19 +++++++++++++++++++ packages/superdough/worklets.mjs | 13 ++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 650f9c2ce..acf6edef2 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -451,6 +451,9 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) compressorKnee, compressorAttack, compressorRelease, + transient, + transsustain, + transattack, } = value; delaytime = delaytime ?? cycleToSeconds(delaysync, cps); @@ -532,6 +535,22 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) chain.push(sourceNode); stretch !== undefined && chain.push(getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: stretch })); + transient !== undefined && + chain.push( + getWorklet( + ac, + 'transient-processor', + {}, + { + processorOptions: { + attack: transient, + sustain: transsustain, + attackTime: transattack, + }, + }, + ), + ); + // gain stage chain.push(gainNode(gain)); diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 334e2d829..6af7dffc1 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -11,7 +11,7 @@ const PI = Math.PI; const TWO_PI = 2 * PI; const INVSR = 1 / sampleRate; -const timeToCoeff = (t) => Math.exp(-INVSR / t); +const timeToCoeff = (t) => 1 - Math.exp(-INVSR / t); const dbToLin = (db) => Math.pow(10, db / 20); const clamp = (num, min, max) => Math.min(Math.max(num, min), max); @@ -1420,17 +1420,16 @@ class TransientProcessor extends AudioWorkletProcessor { if (!input || !output) { return true; } - const channels = output.length; + const channels = input.length; this.attackEnv ??= new Float32Array(channels); this.sustainEnv ??= new Float32Array(channels); let avgGain = this.avgGain; for (let ch = 0; ch < channels; ch++) { - const inCh = input[ch]; - const outCh = output[ch]; let attEnv = this.attackEnv[ch]; let susEnv = this.sustainEnv[ch]; - for (let i = 0; i < blockSize; i++) { - const x = Math.abs(inCh[i]); + for (let n = 0; n < blockSize; n++) { + const sample = input[ch][n]; + const x = Math.abs(sample); attEnv = lerp(attEnv, x, this.attackCoeff); susEnv = lerp(susEnv, x, this.sustainCoeff); const peakiness = clamp((this.scaling * (attEnv - susEnv)) / (susEnv + 1e-6), -1.5, 1.5); @@ -1442,7 +1441,7 @@ class TransientProcessor extends AudioWorkletProcessor { avgGain = lerp(avgGain, gain, this.gainCoeff); const makeup = avgGain > 1e-3 ? 1 / avgGain : 1; const wet = x * gain * makeup; - outCh[i] = lerp(x, wet, this.mix); + output[ch][n] = lerp(sample, wet, this.mix); } this.attackEnv[ch] = attEnv; this.sustainEnv[ch] = susEnv; From df047fd48dbb4df27ca596f2ca085eeb2fb86c4d Mon Sep 17 00:00:00 2001 From: Aria Date: Sun, 23 Nov 2025 22:28:10 -0600 Subject: [PATCH 07/32] Optimizations --- packages/core/controls.mjs | 14 +++++++++++++- packages/superdough/superdough.mjs | 2 -- packages/superdough/worklets.mjs | 16 +++++++++++----- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 80a244efc..bca7036ad 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -2585,4 +2585,16 @@ export const scrub = register( false, ); -export const { transient } = registerControl(['transient', 'transsustain', 'transattack']); +/** + * Transient shaper. Gives independent control over the emphasis on transients + * and sustains + * + * @name transient + * @param {number | Pattern} attack Emphasis on transients; between -1 (deaccentuate) and 1 (accentuate) + * @param {number | Pattern} sustain Emphasis on the sustains; between -1 (deaccentuate) and 1 (accentuate) + * @example + * s("bd").transient("<-1 -0.5 0 0.5 1>") + * @example + * s("hh*16").bank("tr909").transient("<-1:1 1:-1>") + */ +export const { transient } = registerControl(['transient', 'transsustain']); diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index acf6edef2..ac6ca7322 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -453,7 +453,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) compressorRelease, transient, transsustain, - transattack, } = value; delaytime = delaytime ?? cycleToSeconds(delaysync, cps); @@ -545,7 +544,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) processorOptions: { attack: transient, sustain: transsustain, - attackTime: transattack, }, }, ), diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 6af7dffc1..6b370166c 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -1412,6 +1412,10 @@ class TransientProcessor extends AudioWorkletProcessor { this.sustainAmt = clamp(sustain, -1, 1); this.scaling = 0.5 + 5 * clamp(sensitivity, 0, 1); this.mix = clamp(mix, 0, 1); + const maxAttackDb = this.attackAmt * 6; + const maxSustainDb = this.sustainAmt * 36; + this.attackMaxGain = dbToLin(maxAttackDb) - 1; // increasing from 1 + this.sustainMaxGain = dbToLin(maxSustainDb) - 1; } process(inputs, outputs, _params) { @@ -1433,15 +1437,17 @@ class TransientProcessor extends AudioWorkletProcessor { attEnv = lerp(attEnv, x, this.attackCoeff); susEnv = lerp(susEnv, x, this.sustainCoeff); const peakiness = clamp((this.scaling * (attEnv - susEnv)) / (susEnv + 1e-6), -1.5, 1.5); - const attScale = Math.max(0, peakiness); - const susScale = Math.max(0, -peakiness); - const attackGain = dbToLin(this.attackAmt * attScale * 18); // max 18db - const sustainGain = dbToLin(this.sustainAmt * susScale * 36); // max 36db + const attScale = peakiness > 0 ? peakiness : 0; + const susScale = peakiness < 0 ? -peakiness : 0; + const attackGain = 1 + attScale * this.attackMaxGain; + const sustainGain = 1 + susScale * this.sustainMaxGain; const gain = clamp(attackGain * sustainGain, 0, 8); avgGain = lerp(avgGain, gain, this.gainCoeff); const makeup = avgGain > 1e-3 ? 1 / avgGain : 1; const wet = x * gain * makeup; - output[ch][n] = lerp(sample, wet, this.mix); + let y = lerp(sample, wet, this.mix); + y /= (1 + Math.abs(y)); // soft clip + output[ch][n] = y; } this.attackEnv[ch] = attEnv; this.sustainEnv[ch] = susEnv; From be36898803a59f201d3d3db4e79bf35a2cc53edb Mon Sep 17 00:00:00 2001 From: Aria Date: Sun, 23 Nov 2025 22:29:33 -0600 Subject: [PATCH 08/32] Formatting and examples --- packages/superdough/worklets.mjs | 2 +- test/__snapshots__/examples.test.mjs.snap | 78 +++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 6b370166c..e5dd0a0d7 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -1446,7 +1446,7 @@ class TransientProcessor extends AudioWorkletProcessor { const makeup = avgGain > 1e-3 ? 1 / avgGain : 1; const wet = x * gain * makeup; let y = lerp(sample, wet, this.mix); - y /= (1 + Math.abs(y)); // soft clip + y /= 1 + Math.abs(y); // soft clip output[ch][n] = y; } this.attackEnv[ch] = attEnv; diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 31e4bddd3..66777d5e1 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -11410,6 +11410,84 @@ exports[`runs examples > example "tour" example index 0 1`] = ` ] `; +exports[`runs examples > example "transient" example index 0 1`] = ` +[ + "[ 0/1 → 1/1 | s:bd transient:-1 ]", + "[ 1/1 → 2/1 | s:bd transient:-0.5 ]", + "[ 2/1 → 3/1 | s:bd transient:0 ]", + "[ 3/1 → 4/1 | s:bd transient:0.5 ]", +] +`; + +exports[`runs examples > example "transient" example index 1 1`] = ` +[ + "[ 0/1 → 1/16 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 1/16 → 1/8 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 1/8 → 3/16 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 3/16 → 1/4 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 1/4 → 5/16 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 5/16 → 3/8 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 3/8 → 7/16 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 7/16 → 1/2 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 1/2 → 9/16 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 9/16 → 5/8 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 5/8 → 11/16 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 11/16 → 3/4 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 3/4 → 13/16 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 13/16 → 7/8 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 7/8 → 15/16 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 15/16 → 1/1 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 1/1 → 17/16 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 17/16 → 9/8 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 9/8 → 19/16 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 19/16 → 5/4 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 5/4 → 21/16 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 21/16 → 11/8 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 11/8 → 23/16 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 23/16 → 3/2 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 3/2 → 25/16 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 25/16 → 13/8 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 13/8 → 27/16 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 27/16 → 7/4 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 7/4 → 29/16 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 29/16 → 15/8 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 15/8 → 31/16 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 31/16 → 2/1 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 2/1 → 33/16 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 33/16 → 17/8 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 17/8 → 35/16 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 35/16 → 9/4 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 9/4 → 37/16 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 37/16 → 19/8 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 19/8 → 39/16 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 39/16 → 5/2 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 5/2 → 41/16 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 41/16 → 21/8 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 21/8 → 43/16 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 43/16 → 11/4 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 11/4 → 45/16 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 45/16 → 23/8 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 23/8 → 47/16 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 47/16 → 3/1 | s:hh bank:tr909 transient:-1 transsustain:1 ]", + "[ 3/1 → 49/16 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 49/16 → 25/8 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 25/8 → 51/16 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 51/16 → 13/4 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 13/4 → 53/16 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 53/16 → 27/8 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 27/8 → 55/16 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 55/16 → 7/2 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 7/2 → 57/16 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 57/16 → 29/8 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 29/8 → 59/16 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 59/16 → 15/4 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 15/4 → 61/16 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 61/16 → 31/8 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 31/8 → 63/16 | s:hh bank:tr909 transient:1 transsustain:-1 ]", + "[ 63/16 → 4/1 | s:hh bank:tr909 transient:1 transsustain:-1 ]", +] +`; + exports[`runs examples > example "transpose" example index 0 1`] = ` [ "[ 0/1 → 1/4 | note:C2 ]", From 6c01c16a0cbd4dda8f4fe6081746824d79972701 Mon Sep 17 00:00:00 2001 From: Aria Date: Sun, 23 Nov 2025 22:39:21 -0600 Subject: [PATCH 09/32] Fix typo --- packages/superdough/worklets.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index e5dd0a0d7..67d5e1641 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -1436,7 +1436,7 @@ class TransientProcessor extends AudioWorkletProcessor { const x = Math.abs(sample); attEnv = lerp(attEnv, x, this.attackCoeff); susEnv = lerp(susEnv, x, this.sustainCoeff); - const peakiness = clamp((this.scaling * (attEnv - susEnv)) / (susEnv + 1e-6), -1.5, 1.5); + const peakiness = clamp((this.scaling * (attEnv - susEnv)) / (susEnv + 1e-6), -1, 1); const attScale = peakiness > 0 ? peakiness : 0; const susScale = peakiness < 0 ? -peakiness : 0; const attackGain = 1 + attScale * this.attackMaxGain; @@ -1444,7 +1444,7 @@ class TransientProcessor extends AudioWorkletProcessor { const gain = clamp(attackGain * sustainGain, 0, 8); avgGain = lerp(avgGain, gain, this.gainCoeff); const makeup = avgGain > 1e-3 ? 1 / avgGain : 1; - const wet = x * gain * makeup; + const wet = sample * gain * makeup; let y = lerp(sample, wet, this.mix); y /= 1 + Math.abs(y); // soft clip output[ch][n] = y; From 36587ae74732fa69f58704b2a0f3a0ebfbf8e96a Mon Sep 17 00:00:00 2001 From: Aria Date: Sun, 23 Nov 2025 22:55:36 -0600 Subject: [PATCH 10/32] Switch back to original version --- packages/superdough/worklets.mjs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 67d5e1641..d88e9dea2 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -1412,10 +1412,6 @@ class TransientProcessor extends AudioWorkletProcessor { this.sustainAmt = clamp(sustain, -1, 1); this.scaling = 0.5 + 5 * clamp(sensitivity, 0, 1); this.mix = clamp(mix, 0, 1); - const maxAttackDb = this.attackAmt * 6; - const maxSustainDb = this.sustainAmt * 36; - this.attackMaxGain = dbToLin(maxAttackDb) - 1; // increasing from 1 - this.sustainMaxGain = dbToLin(maxSustainDb) - 1; } process(inputs, outputs, _params) { @@ -1436,11 +1432,11 @@ class TransientProcessor extends AudioWorkletProcessor { const x = Math.abs(sample); attEnv = lerp(attEnv, x, this.attackCoeff); susEnv = lerp(susEnv, x, this.sustainCoeff); - const peakiness = clamp((this.scaling * (attEnv - susEnv)) / (susEnv + 1e-6), -1, 1); + const peakiness = clamp((this.scaling * (attEnv - susEnv)) / (susEnv + 1e-6), -1.5, 1.5); const attScale = peakiness > 0 ? peakiness : 0; const susScale = peakiness < 0 ? -peakiness : 0; - const attackGain = 1 + attScale * this.attackMaxGain; - const sustainGain = 1 + susScale * this.sustainMaxGain; + const attackGain = dbToLin(this.attackAmt * attScale * 18); + const sustainGain = dbToLin(this.sustainAmt * susScale * 36); const gain = clamp(attackGain * sustainGain, 0, 8); avgGain = lerp(avgGain, gain, this.gainCoeff); const makeup = avgGain > 1e-3 ? 1 / avgGain : 1; From a278f21f1b0a8923d3d454c0e0f0b70de828845b Mon Sep 17 00:00:00 2001 From: Aria Date: Mon, 24 Nov 2025 17:52:00 -0600 Subject: [PATCH 11/32] Allow register in autocomplete, add vel as synonym for velocity, fix some controls --- packages/core/controls.mjs | 20 +++++++++----------- packages/core/pattern.mjs | 1 - 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index a9d7cc266..a59b30d1a 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -380,12 +380,13 @@ export const { accelerate } = registerControl('accelerate'); * Sets the velocity from 0 to 1. Is multiplied together with gain. * * @name velocity + * @synonyms vel * @example * s("hh*8") * .gain(".4!2 1 .4!2 1 .4 1") * .velocity(".4 1") */ -export const { velocity } = registerControl('velocity'); +export const { velocity, vel } = registerControl('velocity', 'vel'); /** * Controls the gain by an exponential amount. * @@ -1323,14 +1324,13 @@ export const { lpdepth } = registerControl('lpdepth'); * Depth of the LFO for the lowpass filter, in HZ * * @name lpdepthfrequency - * @synonyms - * lpdethfreq + * @synonyms lpdepthfreq * @param {number | Pattern} depth depth of modulation * @example * note("*16").s("sawtooth").lpf(600).lpdepthfrequency("<200 500 100 0>") */ -export const { lpdepthfrequency } = registerControl('lpdepthfrequency', 'lpdepthfreq'); +export const { lpdepthfrequency, lpdepthfreq } = registerControl('lpdepthfrequency', 'lpdepthfreq'); /** * Shape of the LFO for the lowpass filter @@ -1384,14 +1384,13 @@ export const { bpdepth } = registerControl('bpdepth'); * Depth of the LFO for the bandpass filter, in HZ * * @name bpdepthfrequency - * @synonyms - * bpdethfreq + * @synonyms bpdepthfreq * @param {number | Pattern} depth depth of modulation * @example * note("*16").s("sawtooth").lpf(600).bpdepthfrequency("<200 500 100 0>") */ -export const { bpdepthfrequency } = registerControl('bpdepthfrequency', 'bpdepthfreq'); +export const { bpdepthfrequency, bpdepthfreq } = registerControl('bpdepthfrequency', 'bpdepthfreq'); /** * Shape of the LFO for the bandpass filter @@ -1439,20 +1438,19 @@ export const { hpsync } = registerControl('hpsync'); * @name hpdepth * @param {number | Pattern} depth depth of modulation */ -export const { hpdepth, hpdepthfreq } = registerControl('hpdepth'); +export const { hpdepth } = registerControl('hpdepth'); /** * Depth of the LFO for the hipass filter, in hz * * @name hpdepthfrequency - * @synonyms - * hpdethfreq + * @synonyms hpdepthfreq * @param {number | Pattern} depth depth of modulation * @example * note("*16").s("sawtooth").lpf(600).hpdepthfrequency("<200 500 100 0>") */ -export const { hpdepthfrequency } = registerControl('hpdepthfrequency', 'hpdepthfreq'); +export const { hpdepthfrequency, hpdepthfreq } = registerControl('hpdepthfrequency', 'hpdepthfreq'); /** * Shape of the LFO for the highpass filter diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 4737f0db0..01a7efbd1 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -1587,7 +1587,6 @@ export const func = curry((a, b) => reify(b).func(a)); * * @param {string | string[]} name name of the function, or an array of names to be used as synonyms * @param {function} func function with 1 or more params, where last is the current pattern - * @noAutocomplete * */ export function register(name, func, patternify = true, preserveSteps = false, join = (x) => x.innerJoin()) { From d7afb8a6c097de11a34fdde58a9f976e38a3ba18 Mon Sep 17 00:00:00 2001 From: Aria Date: Mon, 24 Nov 2025 18:01:20 -0600 Subject: [PATCH 12/32] Add register example --- packages/core/pattern.mjs | 7 +++++ test/__snapshots__/examples.test.mjs.snap | 37 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 01a7efbd1..4753ebfd9 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -1587,6 +1587,13 @@ export const func = curry((a, b) => reify(b).func(a)); * * @param {string | string[]} name name of the function, or an array of names to be used as synonyms * @param {function} func function with 1 or more params, where last is the current pattern + * @param {bool} patternify defaults to true; if set to false, you will have more control over the arguments to `func` as they will be + * in their raw form and it will be up to you to patternify them and/or query them for values + * @example + * const vlpf = register('vlpf', (freq, pat) => { + * return pat.fmap((v) => ({...v, cutoff: freq * (v.velocity ?? 1) })); + * }) + * s("saw").seg(8).velocity(rand).vlpf(800) * */ export function register(name, func, patternify = true, preserveSteps = false, join = (x) => x.innerJoin()) { diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 8cbee39da..db1b62288 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -9064,6 +9064,43 @@ exports[`runs examples > example "ratio" example index 0 1`] = ` ] `; +exports[`runs examples > example "register" example index 0 1`] = ` +[ + "[ 0/1 → 1/8 | s:saw velocity:0 cutoff:0 ]", + "[ 1/8 → 1/4 | s:saw velocity:0.6852155700325966 cutoff:548.1724560260773 ]", + "[ 1/4 → 3/8 | s:saw velocity:0.36975969187915325 cutoff:295.8077535033226 ]", + "[ 3/8 → 1/2 | s:saw velocity:0.40139251574873924 cutoff:321.1140125989914 ]", + "[ 1/2 → 5/8 | s:saw velocity:0.2604806162416935 cutoff:208.3844929933548 ]", + "[ 5/8 → 3/4 | s:saw velocity:0.1356358677148819 cutoff:108.50869417190552 ]", + "[ 3/4 → 7/8 | s:saw velocity:0.19582648016512394 cutoff:156.66118413209915 ]", + "[ 7/8 → 1/1 | s:saw velocity:0.3976310808211565 cutoff:318.1048646569252 ]", + "[ 1/1 → 9/8 | s:saw velocity:0.5195421651005745 cutoff:415.6337320804596 ]", + "[ 9/8 → 5/4 | s:saw velocity:0.6724895145744085 cutoff:537.9916116595268 ]", + "[ 5/4 → 11/8 | s:saw velocity:0.7287282031029463 cutoff:582.982562482357 ]", + "[ 11/8 → 3/2 | s:saw velocity:0.18280375190079212 cutoff:146.2430015206337 ]", + "[ 3/2 → 13/8 | s:saw velocity:0.6084080748260021 cutoff:486.7264598608017 ]", + "[ 13/8 → 7/4 | s:saw velocity:0.49675471149384975 cutoff:397.4037691950798 ]", + "[ 7/4 → 15/8 | s:saw velocity:0.20473789609968662 cutoff:163.7903168797493 ]", + "[ 15/8 → 2/1 | s:saw velocity:0.5945571791380644 cutoff:475.6457433104515 ]", + "[ 2/1 → 17/8 | s:saw velocity:0.9595271199941635 cutoff:767.6216959953308 ]", + "[ 17/8 → 9/4 | s:saw velocity:0.9033902939409018 cutoff:722.7122351527214 ]", + "[ 9/4 → 19/8 | s:saw velocity:0.34548251144587994 cutoff:276.38600915670395 ]", + "[ 19/8 → 5/2 | s:saw velocity:0.8365066405385733 cutoff:669.2053124308586 ]", + "[ 5/2 → 21/8 | s:saw velocity:0.45861607417464256 cutoff:366.89285933971405 ]", + "[ 21/8 → 11/4 | s:saw velocity:0.16019348427653313 cutoff:128.1547874212265 ]", + "[ 11/4 → 23/8 | s:saw velocity:0.6332327704876661 cutoff:506.5862163901329 ]", + "[ 23/8 → 3/1 | s:saw velocity:0.01625417172908783 cutoff:13.003337383270264 ]", + "[ 3/1 → 25/8 | s:saw velocity:0.21728911064565182 cutoff:173.83128851652145 ]", + "[ 25/8 → 13/4 | s:saw velocity:0.34324387833476067 cutoff:274.59510266780853 ]", + "[ 13/4 → 27/8 | s:saw velocity:0.9923650715500116 cutoff:793.8920572400093 ]", + "[ 27/8 → 7/2 | s:saw velocity:0.40869180113077164 cutoff:326.9534409046173 ]", + "[ 7/2 → 29/8 | s:saw velocity:0.40994881466031075 cutoff:327.9590517282486 ]", + "[ 29/8 → 15/4 | s:saw velocity:0.9391485787928104 cutoff:751.3188630342484 ]", + "[ 15/4 → 31/8 | s:saw velocity:0.8121673222631216 cutoff:649.7338578104973 ]", + "[ 31/8 → 4/1 | s:saw velocity:0.7361665386706591 cutoff:588.9332309365273 ]", +] +`; + exports[`runs examples > example "release" example index 0 1`] = ` [ "[ 0/1 → 1/4 | note:c3 release:0 ]", From 7e5a907ce57210783375268e76b482b31835c1bb Mon Sep 17 00:00:00 2001 From: Aria Date: Mon, 24 Nov 2025 18:41:25 -0600 Subject: [PATCH 13/32] Add a few more functions to autocomplete --- packages/core/pattern.mjs | 27 ++++- test/__snapshots__/examples.test.mjs.snap | 133 ++++++++++++++++++++++ 2 files changed, 155 insertions(+), 5 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 4753ebfd9..14b2b11e6 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -574,7 +574,8 @@ export class Pattern { * Returns a new Pattern, which only returns haps that meet the given test. * @param {Function} hap_test - a function which returns false for haps to be removed from the pattern * @returns Pattern - * @noAutocomplete + * @example + * s("bd*8").velocity(rand).filterHaps((h) => (h.whole.begin % 1) < h.value.velocity) */ filterHaps(hap_test) { return new Pattern((state) => this.query(state).filter(hap_test)); @@ -585,12 +586,22 @@ export class Pattern { * inside haps. * @param {Function} value_test * @returns Pattern - * @noAutocomplete + * @synonyms filtval + * @example + * const drums = s("bd sd bd sd") + * kick: drums.filterValues((v) => v.s === 'bd').duck(2) + * snare: drums.filterValues((v) => v.s === 'sd') + * bass: s("saw!4").note("G#1").lpf(80).lpenv(4).orbit(2) */ filterValues(value_test) { return new Pattern((state) => this.query(state).filter((hap) => value_test(hap.value))).setSteps(this._steps); } + // synonym + filtval(value_test) { + this.filterValues(value_test); + } + /** * Returns a new pattern, with haps containing undefined values removed from * query results. @@ -2665,8 +2676,13 @@ export const hsl = register('hsl', (h, s, l, pat) => { /** * Tags each Hap with an identifier. Good for filtering. The function populates Hap.context.tags (Array). * @name tag - * @noAutocomplete * @param {string} tag anything unique + * @example + * s("saw!16").note("F1") + * .lpf(tri.range(40, 80).slow(4)).lpenv(5).lpq(4).lpd(0.15) + * .when(rand.late(0.1).gte(0.5), x => x.transpose("12").tag('altered')) + * .when(rand.late(0.2).gte(0.5), x => x.s("square").tag('altered')) + * .when("<0 1>", x => x.filter((hap) => hap.hasTag('altered'))) */ Pattern.prototype.tag = function (tag) { return this.withContext((ctx) => ({ ...ctx, tags: (ctx.tags || []).concat([tag]) })); @@ -2677,15 +2693,16 @@ Pattern.prototype.tag = function (tag) { * @name filter * @param {Function} test function to test Hap * @example - * s("hh!7 oh").filter(hap => hap.value.s==='hh') + * s("hh!7 oh").filter(hap => hap.value.s === 'hh') */ export const filter = register('filter', (test, pat) => pat.withHaps((haps) => haps.filter(test))); /** * Filters haps by their begin time * @name filterWhen - * @noAutocomplete * @param {Function} test function to test Hap.whole.begin + * @example + * oneCycle: s("bd*4").filterWhen((t) => t < 1) */ export const filterWhen = register('filterWhen', (test, pat) => pat.filter((h) => test(h.whole.begin))); diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index db1b62288..68eae8243 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -4043,6 +4043,70 @@ exports[`runs examples > example "filter" example index 0 1`] = ` ] `; +exports[`runs examples > example "filterHaps" example index 0 1`] = ` +[ + "[ 1/8 → 1/4 | s:bd velocity:0.6852155700325966 ]", + "[ 1/4 → 3/8 | s:bd velocity:0.36975969187915325 ]", + "[ 3/8 → 1/2 | s:bd velocity:0.40139251574873924 ]", + "[ 1/1 → 9/8 | s:bd velocity:0.5195421651005745 ]", + "[ 9/8 → 5/4 | s:bd velocity:0.6724895145744085 ]", + "[ 5/4 → 11/8 | s:bd velocity:0.7287282031029463 ]", + "[ 3/2 → 13/8 | s:bd velocity:0.6084080748260021 ]", + "[ 2/1 → 17/8 | s:bd velocity:0.9595271199941635 ]", + "[ 17/8 → 9/4 | s:bd velocity:0.9033902939409018 ]", + "[ 9/4 → 19/8 | s:bd velocity:0.34548251144587994 ]", + "[ 19/8 → 5/2 | s:bd velocity:0.8365066405385733 ]", + "[ 3/1 → 25/8 | s:bd velocity:0.21728911064565182 ]", + "[ 25/8 → 13/4 | s:bd velocity:0.34324387833476067 ]", + "[ 13/4 → 27/8 | s:bd velocity:0.9923650715500116 ]", + "[ 27/8 → 7/2 | s:bd velocity:0.40869180113077164 ]", + "[ 29/8 → 15/4 | s:bd velocity:0.9391485787928104 ]", + "[ 15/4 → 31/8 | s:bd velocity:0.8121673222631216 ]", +] +`; + +exports[`runs examples > example "filterValues" example index 0 1`] = ` +[ + "[ 0/1 → 1/4 | s:saw note:G#1 cutoff:80 lpenv:4 orbit:2 ]", + "[ 1/4 → 1/2 | s:saw note:G#1 cutoff:80 lpenv:4 orbit:2 ]", + "[ 1/2 → 3/4 | s:saw note:G#1 cutoff:80 lpenv:4 orbit:2 ]", + "[ 3/4 → 1/1 | s:saw note:G#1 cutoff:80 lpenv:4 orbit:2 ]", + "[ 1/1 → 5/4 | s:saw note:G#1 cutoff:80 lpenv:4 orbit:2 ]", + "[ 5/4 → 3/2 | s:saw note:G#1 cutoff:80 lpenv:4 orbit:2 ]", + "[ 3/2 → 7/4 | s:saw note:G#1 cutoff:80 lpenv:4 orbit:2 ]", + "[ 7/4 → 2/1 | s:saw note:G#1 cutoff:80 lpenv:4 orbit:2 ]", + "[ 2/1 → 9/4 | s:saw note:G#1 cutoff:80 lpenv:4 orbit:2 ]", + "[ 9/4 → 5/2 | s:saw note:G#1 cutoff:80 lpenv:4 orbit:2 ]", + "[ 5/2 → 11/4 | s:saw note:G#1 cutoff:80 lpenv:4 orbit:2 ]", + "[ 11/4 → 3/1 | s:saw note:G#1 cutoff:80 lpenv:4 orbit:2 ]", + "[ 3/1 → 13/4 | s:saw note:G#1 cutoff:80 lpenv:4 orbit:2 ]", + "[ 13/4 → 7/2 | s:saw note:G#1 cutoff:80 lpenv:4 orbit:2 ]", + "[ 7/2 → 15/4 | s:saw note:G#1 cutoff:80 lpenv:4 orbit:2 ]", + "[ 15/4 → 4/1 | s:saw note:G#1 cutoff:80 lpenv:4 orbit:2 ]", +] +`; + +exports[`runs examples > example "filterWhen" example index 0 1`] = ` +[ + "[ 0/1 → 1/4 | s:bd ]", + "[ 1/4 → 1/2 | s:bd ]", + "[ 1/2 → 3/4 | s:bd ]", + "[ 3/4 → 1/1 | s:bd ]", + "[ 1/1 → 5/4 | s:bd ]", + "[ 5/4 → 3/2 | s:bd ]", + "[ 3/2 → 7/4 | s:bd ]", + "[ 7/4 → 2/1 | s:bd ]", + "[ 2/1 → 9/4 | s:bd ]", + "[ 9/4 → 5/2 | s:bd ]", + "[ 5/2 → 11/4 | s:bd ]", + "[ 11/4 → 3/1 | s:bd ]", + "[ 3/1 → 13/4 | s:bd ]", + "[ 13/4 → 7/2 | s:bd ]", + "[ 7/2 → 15/4 | s:bd ]", + "[ 15/4 → 4/1 | s:bd ]", +] +`; + exports[`runs examples > example "firstOf" example index 0 1`] = ` [ "[ 0/1 → 1/4 | note:g3 ]", @@ -11749,6 +11813,75 @@ exports[`runs examples > example "sysexid" example index 0 1`] = ` ] `; +exports[`runs examples > example "tag" example index 0 1`] = ` +[ + "[ 0/1 → 1/16 | s:square note:F1 cutoff:40 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 1/16 → 1/8 | s:square note:F1 cutoff:41.25 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 1/8 → 3/16 | s:square note:F1 cutoff:42.5 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 3/16 → 1/4 | s:square note:F1 cutoff:43.75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 1/4 → 5/16 | s:square note:F1 cutoff:45 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 5/16 → 3/8 | s:square note:F1 cutoff:46.25 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 3/8 → 7/16 | s:square note:F1 cutoff:47.5 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 7/16 → 1/2 | s:square note:F1 cutoff:48.75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 1/2 → 9/16 | s:square note:F1 cutoff:50 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 9/16 → 5/8 | s:square note:F1 cutoff:51.25 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 5/8 → 11/16 | s:square note:F1 cutoff:52.5 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 11/16 → 3/4 | s:square note:F1 cutoff:53.75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 3/4 → 13/16 | s:square note:F1 cutoff:55 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 13/16 → 7/8 | s:square note:F1 cutoff:56.25 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 7/8 → 15/16 | s:square note:F1 cutoff:57.5 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 15/16 → 1/1 | s:square note:F1 cutoff:58.75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 1/1 → 17/16 | s:saw note:F2 cutoff:60 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 17/16 → 9/8 | s:saw note:F2 cutoff:61.25 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 9/8 → 19/16 | s:saw note:F2 cutoff:62.5 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 19/16 → 5/4 | s:saw note:F2 cutoff:63.75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 5/4 → 21/16 | s:saw note:F2 cutoff:65 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 21/16 → 11/8 | s:saw note:F2 cutoff:66.25 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 11/8 → 23/16 | s:saw note:F2 cutoff:67.5 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 23/16 → 3/2 | s:saw note:F2 cutoff:68.75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 3/2 → 25/16 | s:saw note:F2 cutoff:70 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 25/16 → 13/8 | s:saw note:F2 cutoff:71.25 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 13/8 → 27/16 | s:saw note:F2 cutoff:72.5 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 27/16 → 7/4 | s:saw note:F2 cutoff:73.75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 7/4 → 29/16 | s:saw note:F2 cutoff:75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 29/16 → 15/8 | s:saw note:F2 cutoff:76.25 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 15/8 → 31/16 | s:saw note:F2 cutoff:77.5 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 31/16 → 2/1 | s:saw note:F2 cutoff:78.75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 2/1 → 33/16 | s:saw note:F1 cutoff:80 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 33/16 → 17/8 | s:saw note:F1 cutoff:78.75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 17/8 → 35/16 | s:saw note:F1 cutoff:77.5 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 35/16 → 9/4 | s:saw note:F1 cutoff:76.25 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 9/4 → 37/16 | s:saw note:F1 cutoff:75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 37/16 → 19/8 | s:saw note:F1 cutoff:73.75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 19/8 → 39/16 | s:saw note:F1 cutoff:72.5 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 39/16 → 5/2 | s:saw note:F1 cutoff:71.25 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 5/2 → 41/16 | s:saw note:F1 cutoff:70 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 41/16 → 21/8 | s:saw note:F1 cutoff:68.75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 21/8 → 43/16 | s:saw note:F1 cutoff:67.5 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 43/16 → 11/4 | s:saw note:F1 cutoff:66.25 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 11/4 → 45/16 | s:saw note:F1 cutoff:65 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 45/16 → 23/8 | s:saw note:F1 cutoff:63.75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 23/8 → 47/16 | s:saw note:F1 cutoff:62.5 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 47/16 → 3/1 | s:saw note:F1 cutoff:61.25 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 3/1 → 49/16 | s:square note:F1 cutoff:60 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 49/16 → 25/8 | s:square note:F1 cutoff:58.75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 25/8 → 51/16 | s:square note:F1 cutoff:57.5 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 51/16 → 13/4 | s:square note:F1 cutoff:56.25 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 13/4 → 53/16 | s:square note:F1 cutoff:55 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 53/16 → 27/8 | s:square note:F1 cutoff:53.75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 27/8 → 55/16 | s:square note:F1 cutoff:52.5 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 55/16 → 7/2 | s:square note:F1 cutoff:51.25 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 7/2 → 57/16 | s:square note:F1 cutoff:50 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 57/16 → 29/8 | s:square note:F1 cutoff:48.75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 29/8 → 59/16 | s:square note:F1 cutoff:47.5 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 59/16 → 15/4 | s:square note:F1 cutoff:46.25 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 15/4 → 61/16 | s:square note:F1 cutoff:45 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 61/16 → 31/8 | s:square note:F1 cutoff:43.75 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 31/8 → 63/16 | s:square note:F1 cutoff:42.5 lpenv:5 resonance:4 lpdecay:0.15 ]", + "[ 63/16 → 4/1 | s:square note:F1 cutoff:41.25 lpenv:5 resonance:4 lpdecay:0.15 ]", +] +`; + exports[`runs examples > example "take" example index 0 1`] = ` [ "[ 0/1 → 1/2 | s:bd ]", From 39e7f700791bc92b3949a1ca721e64d002f94842 Mon Sep 17 00:00:00 2001 From: Aria Date: Mon, 24 Nov 2025 18:43:47 -0600 Subject: [PATCH 14/32] Remove synonym --- packages/core/pattern.mjs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 14b2b11e6..9f5929a6d 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -586,7 +586,6 @@ export class Pattern { * inside haps. * @param {Function} value_test * @returns Pattern - * @synonyms filtval * @example * const drums = s("bd sd bd sd") * kick: drums.filterValues((v) => v.s === 'bd').duck(2) @@ -597,11 +596,6 @@ export class Pattern { return new Pattern((state) => this.query(state).filter((hap) => value_test(hap.value))).setSteps(this._steps); } - // synonym - filtval(value_test) { - this.filterValues(value_test); - } - /** * Returns a new pattern, with haps containing undefined values removed from * query results. From d84db1e663b2d89867700b60788f3ca54ffc4081 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 28 Nov 2025 10:02:57 +0100 Subject: [PATCH 15/32] hotfix: auto deploy to warm.strudel.cc when main changes --- .forgejo/workflows/deploy-warm-beta.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/deploy-warm-beta.yml b/.forgejo/workflows/deploy-warm-beta.yml index 2de6670c8..e6636a729 100644 --- a/.forgejo/workflows/deploy-warm-beta.yml +++ b/.forgejo/workflows/deploy-warm-beta.yml @@ -1,6 +1,9 @@ name: Build and Deploy to beta (warm.strudel.cc) -on: [workflow_dispatch] +on: + push: + branches: + - main # Allow one concurrent deployment concurrency: From b65cc2128feabf0a5909e652fe59058303aadbf1 Mon Sep 17 00:00:00 2001 From: jeromew Date: Sat, 29 Nov 2025 14:47:03 +0000 Subject: [PATCH 16/32] [perf] fix phaser leak of unused biquads --- packages/superdough/superdough.mjs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 6f610d514..514c492cf 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -290,7 +290,7 @@ function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000 const lfo = getLfo(ac, time, end, { frequency, depth: sweep * 2 }); //filters - const numStages = 2; //num of filters in series + const numStages = 1; //num of filters in series let fOffset = 0; const filterChain = []; for (let i = 0; i < numStages; i++) { @@ -302,12 +302,9 @@ function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000 lfo.connect(filter.detune); fOffset += 282; - if (i > 0) { - filterChain[i - 1].connect(filter); - } filterChain.push(filter); } - return { phaser: filterChain[filterChain.length - 1], lfo }; + return { phaser: filterChain, lfo }; } function getFilterType(ftype) { @@ -699,7 +696,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) if (phaserrate !== undefined && phaserdepth > 0) { const { phaser, lfo } = getPhaser(t, endWithRelease, phaserrate, phaserdepth, phasercenter, phasersweep); audioNodes.push(lfo); - chain.push(phaser); + Array.prototype.push.apply(chain, phaser); } // last gain From 0b711e879c767ab1c4bfba7af0c655d28da70f8c Mon Sep 17 00:00:00 2001 From: jeromew Date: Thu, 4 Dec 2025 08:23:36 +0000 Subject: [PATCH 17/32] Improve naming & backward compat --- packages/superdough/superdough.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 514c492cf..191665498 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -291,7 +291,7 @@ function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000 //filters const numStages = 1; //num of filters in series - let fOffset = 0; + let fOffset = 282; //for backward compat in #1800 const filterChain = []; for (let i = 0; i < numStages; i++) { const filter = ac.createBiquadFilter(); @@ -304,7 +304,7 @@ function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000 fOffset += 282; filterChain.push(filter); } - return { phaser: filterChain, lfo }; + return { filterChain, lfo }; } function getFilterType(ftype) { @@ -694,9 +694,9 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) } // phaser if (phaserrate !== undefined && phaserdepth > 0) { - const { phaser, lfo } = getPhaser(t, endWithRelease, phaserrate, phaserdepth, phasercenter, phasersweep); + const { filterChain, lfo } = getPhaser(t, endWithRelease, phaserrate, phaserdepth, phasercenter, phasersweep); audioNodes.push(lfo); - Array.prototype.push.apply(chain, phaser); + chain.push(...filterChain); } // last gain From 46291bf85ca0cf583568266f02f7370c6eb67e82 Mon Sep 17 00:00:00 2001 From: jeromew Date: Thu, 4 Dec 2025 13:17:20 +0000 Subject: [PATCH 18/32] Difuse the `onceEnded` mechanism --- packages/superdough/helpers.mjs | 4 ++-- packages/superdough/noise.mjs | 4 ++-- packages/superdough/synth.mjs | 13 +++++++------ 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 0b696df9b..6fe1e5b72 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -324,10 +324,10 @@ export function getVibratoOscillator(param, value, t) { gain.gain.value = vibmod * 100; vibratoOscillator.connect(gain); gain.connect(param); - vibratoOscillator.onended = () => { + onceEnded(vibratoOscillator, () => { gain.disconnect(param); vibratoOscillator.disconnect(gain); - }; + }); vibratoOscillator.start(t); return vibratoOscillator; } diff --git a/packages/superdough/noise.mjs b/packages/superdough/noise.mjs index 3e41515df..da60e87a1 100644 --- a/packages/superdough/noise.mjs +++ b/packages/superdough/noise.mjs @@ -1,4 +1,4 @@ -import { drywet } from './helpers.mjs'; +import { drywet, onceEnded } from './helpers.mjs'; import { getAudioContext } from './audioContext.mjs'; let noiseCache = {}; @@ -65,7 +65,7 @@ export function getNoiseOscillator(type = 'white', t, density = 0.02) { export function getNoiseMix(inputNode, wet, t) { const noiseOscillator = getNoiseOscillator('pink', t); const noiseMix = drywet(inputNode, noiseOscillator.node, wet); - noiseOscillator.node.onended = () => noiseMix.onended(); + onceEnded(noiseOscillator.node, () => noiseMix.onended()); return { node: noiseMix.node, stop: (time) => noiseOscillator?.stop(time), diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index b02354465..a2d3aeca0 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -1,5 +1,6 @@ import { clamp } from './util.mjs'; import { registerSound, soundMap } from './superdough.mjs'; +import { onceEnded } from './helpers.mjs'; import { getAudioContext } from './audioContext.mjs'; import { applyFM, @@ -110,7 +111,7 @@ export function registerSynthSounds() { const mix = gainNode(mixGain); - o.onended = () => { + onceEnded(o, () => { o.disconnect(); g.disconnect(); sat.disconnect(); @@ -118,7 +119,7 @@ export function registerSynthSounds() { noiseGain.disconnect(); mix.disconnect(); onended(); - }; + }); const node = o.connect(sat).connect(g).connect(mix); noise.node.connect(noiseGain).connect(mix); @@ -384,11 +385,11 @@ export function registerSynthSounds() { const { duration } = value; - o.onended = () => { + onceEnded(o, () => { o.disconnect(); g.disconnect(); onended(); - }; + }); const envGain = gainNode(1); let node = o.connect(g).connect(envGain); @@ -489,11 +490,11 @@ export function getOscillator(s, t, value, onended) { noiseMix = getNoiseMix(o, noise, t); } - o.onended = () => { + onceEnded(o, () => { noiseMix || o.disconnect(); noiseMix?.node.disconnect(); onended(); - }; + }); o.start(t); return { From c7586e96ff8ff1bbeb7b8d6b965d4a8a6b8fce3b Mon Sep 17 00:00:00 2001 From: jeromew Date: Thu, 4 Dec 2025 13:20:17 +0000 Subject: [PATCH 19/32] Add comment on `end` worklet param --- packages/superdough/helpers.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 6fe1e5b72..f000db63e 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -613,6 +613,8 @@ export const releaseAudioNode = (node) => { // returns true and either its active source flag is true or // any AudioNode connected to one of its inputs is actively processing. if (node instanceof AudioWorkletNode) { + // while `end` is not native to the web audio API, it is common practice in superdough + // to use that param in the worklets to trigger returning false from the processor node.parameters.get('end')?.setValueAtTime(0, 0); } }; From f6f507dd291ccdaca4ca5b2d32dc456fd48edbe2 Mon Sep 17 00:00:00 2001 From: jeromew Date: Thu, 4 Dec 2025 15:26:59 +0000 Subject: [PATCH 20/32] propagate `releaseAudioNode` in packages --- packages/soundfonts/fontloader.mjs | 6 +++--- packages/superdough/helpers.mjs | 6 +++--- packages/superdough/sampler.mjs | 8 ++++---- packages/superdough/superdough.mjs | 4 ++-- packages/superdough/synth.mjs | 24 ++++++++++++------------ packages/superdough/wavetable.mjs | 6 +++--- 6 files changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/soundfonts/fontloader.mjs b/packages/soundfonts/fontloader.mjs index ca9e45d51..95e210890 100644 --- a/packages/soundfonts/fontloader.mjs +++ b/packages/soundfonts/fontloader.mjs @@ -7,6 +7,7 @@ import { getPitchEnvelope, getVibratoOscillator, onceEnded, + releaseAudioNode, } from '@strudel/webaudio'; import gm from './gm.mjs'; @@ -172,9 +173,8 @@ export function registerSoundfonts() { bufferSource.stop(envEnd); const stop = (releaseTime) => {}; onceEnded(bufferSource, () => { - bufferSource.disconnect(); - vibratoOscillator?.stop(); - node.disconnect(); + releaseAudioNode(bufferSource); + releaseAudioNode(vibratoOscillator); onended(); }); return { node, stop }; diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index f000db63e..5202dbdfe 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -189,7 +189,7 @@ export function applyParameterModulators(audioContext, param, start, end, envelo getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve); } const lfo = getParamLfo(audioContext, param, start, end, lfoValues); - return { lfo, disconnect: () => lfo?.disconnect() }; + return lfo } export function createFilter(context, start, end, params, cps, cycle) { let { @@ -325,8 +325,8 @@ export function getVibratoOscillator(param, value, t) { vibratoOscillator.connect(gain); gain.connect(param); onceEnded(vibratoOscillator, () => { - gain.disconnect(param); - vibratoOscillator.disconnect(gain); + releaseAudioNode(gain); + releaseAudioNode(vibratoOscillator); }); vibratoOscillator.start(t); return vibratoOscillator; diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index 1e8c786bf..0e1e291ff 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -329,10 +329,10 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { const out = ac.createGain(); // we need a separate gain for the cutgroups because firefox... node.connect(out); onceEnded(bufferSource, function () { - bufferSource.disconnect(); - vibratoOscillator?.stop(); - node.disconnect(); - out.disconnect(); + releaseAudioNode(bufferSource); + releaseAudioNode(vibratoOscillator); + releaseAudioNode(node); + releaseAudioNode(out); onended(); }); let envEnd = holdEnd + release + 0.01; diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 6f610d514..bfcf3ab5c 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -9,7 +9,7 @@ import './reverb.mjs'; import './vowel.mjs'; import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs'; import workletsUrl from './worklets.mjs?audioworklet'; -import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend } from './helpers.mjs'; +import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend, releaseAudioNode } from './helpers.mjs'; import { map } from 'nanostores'; import { logger } from './logger.mjs'; import { loadBuffer } from './sampler.mjs'; @@ -506,7 +506,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) } else if (getSound(s)) { const { onTrigger } = getSound(s); const onEnded = () => { - audioNodes.forEach((n) => n?.disconnect()); + audioNodes.forEach((n) => releaseAudioNode(n)); activeSoundSources.delete(chainID); }; const soundHandle = await onTrigger(t, value, onEnded, cps); diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index a2d3aeca0..e82c889ac 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -1,6 +1,5 @@ import { clamp } from './util.mjs'; import { registerSound, soundMap } from './superdough.mjs'; -import { onceEnded } from './helpers.mjs'; import { getAudioContext } from './audioContext.mjs'; import { applyFM, @@ -13,6 +12,7 @@ import { getVibratoOscillator, getWorklet, noises, + onceEnded, releaseAudioNode, webAudioTimeout, } from './helpers.mjs'; @@ -53,7 +53,7 @@ export function registerSynthSounds() { const g = gainNode(0.3); let sound = getOscillator(s, t, value, () => { - g.disconnect(); + releaseAudioNode(g); onended(); }); @@ -112,12 +112,12 @@ export function registerSynthSounds() { const mix = gainNode(mixGain); onceEnded(o, () => { - o.disconnect(); - g.disconnect(); - sat.disconnect(); - noise.node.disconnect(); - noiseGain.disconnect(); - mix.disconnect(); + releaseAudioNode(o); + releaseAudioNode(g); + releaseAudioNode(sat); + releaseAudioNode(noise.node); + releaseAudioNode(noiseGain); + releaseAudioNode(mix); onended(); }); @@ -386,8 +386,8 @@ export function registerSynthSounds() { const { duration } = value; onceEnded(o, () => { - o.disconnect(); - g.disconnect(); + releaseAudioNode(o); + releaseAudioNode(g); onended(); }); @@ -491,8 +491,8 @@ export function getOscillator(s, t, value, onended) { } onceEnded(o, () => { - noiseMix || o.disconnect(); - noiseMix?.node.disconnect(); + noiseMix || releaseAudioNode(o); + releaseAudioNode(noiseMix?.node); onended(); }); o.start(t); diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index e9d810414..4311c5cab 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -320,10 +320,10 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { ac, () => { releaseAudioNode(source); - vibratoOscillator?.stop(); + releaseAudioNode(vibratoOscillator); fm?.stop(); - wtPosModulators?.disconnect(); - wtWarpModulators?.disconnect(); + releaseAudioNode(wtPosModulators); + releaseAudioNode(wtWarpModulators); onended(); }, t, From 1225d0443748b04c1a84b077c71f88b5f66c7428 Mon Sep 17 00:00:00 2001 From: jeromew Date: Thu, 4 Dec 2025 15:29:21 +0000 Subject: [PATCH 21/32] codeformat --- packages/superdough/helpers.mjs | 2 +- packages/superdough/superdough.mjs | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 5202dbdfe..a5f199fb1 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -189,7 +189,7 @@ export function applyParameterModulators(audioContext, param, start, end, envelo getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve); } const lfo = getParamLfo(audioContext, param, start, end, lfoValues); - return lfo + return lfo; } export function createFilter(context, start, end, params, cps, cycle) { let { diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index bfcf3ab5c..98a875130 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -9,7 +9,16 @@ import './reverb.mjs'; import './vowel.mjs'; import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs'; import workletsUrl from './worklets.mjs?audioworklet'; -import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend, releaseAudioNode } from './helpers.mjs'; +import { + createFilter, + gainNode, + getCompressor, + getDistortion, + getLfo, + getWorklet, + effectSend, + releaseAudioNode, +} from './helpers.mjs'; import { map } from 'nanostores'; import { logger } from './logger.mjs'; import { loadBuffer } from './sampler.mjs'; From 16b5faf3331ce18a9a791059a7a71995815ecb54 Mon Sep 17 00:00:00 2001 From: Aria Date: Sun, 7 Dec 2025 11:23:55 -0600 Subject: [PATCH 22/32] Removing failing tests due to shabda removal --- packages/superdough/sampler.mjs | 6 ---- test/__snapshots__/examples.test.mjs.snap | 36 ----------------------- 2 files changed, 42 deletions(-) diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index 1e8c786bf..bdf524d10 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -243,12 +243,6 @@ export async function fetchSampleMap(url) { * sd: '808sd/SD0010.WAV' * }, 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/'); * s("[bd ~]*2, [~ hh]*2, ~ sd") - * @example - * samples('shabda:noise,chimp:2') - * s("noise ") - * @example - * samples('shabda/speech/fr-FR/f:chocolat') - * s("chocolat*4") */ export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => { diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 5c76f4a89..7ecaf97ba 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -9722,42 +9722,6 @@ exports[`runs examples > example "samples" example index 1 1`] = ` ] `; -exports[`runs examples > example "samples" example index 2 1`] = ` -[ - "[ 0/1 → 1/2 | s:noise ]", - "[ 1/2 → 3/4 | s:chimp n:0 ]", - "[ 3/4 → 1/1 | s:chimp n:0 ]", - "[ 1/1 → 3/2 | s:noise ]", - "[ 3/2 → 2/1 | s:chimp n:1 ]", - "[ 2/1 → 5/2 | s:noise ]", - "[ 5/2 → 11/4 | s:chimp n:0 ]", - "[ 11/4 → 3/1 | s:chimp n:0 ]", - "[ 3/1 → 7/2 | s:noise ]", - "[ 7/2 → 4/1 | s:chimp n:1 ]", -] -`; - -exports[`runs examples > example "samples" example index 3 1`] = ` -[ - "[ 0/1 → 1/4 | s:chocolat ]", - "[ 1/4 → 1/2 | s:chocolat ]", - "[ 1/2 → 3/4 | s:chocolat ]", - "[ 3/4 → 1/1 | s:chocolat ]", - "[ 1/1 → 5/4 | s:chocolat ]", - "[ 5/4 → 3/2 | s:chocolat ]", - "[ 3/2 → 7/4 | s:chocolat ]", - "[ 7/4 → 2/1 | s:chocolat ]", - "[ 2/1 → 9/4 | s:chocolat ]", - "[ 9/4 → 5/2 | s:chocolat ]", - "[ 5/2 → 11/4 | s:chocolat ]", - "[ 11/4 → 3/1 | s:chocolat ]", - "[ 3/1 → 13/4 | s:chocolat ]", - "[ 13/4 → 7/2 | s:chocolat ]", - "[ 7/2 → 15/4 | s:chocolat ]", - "[ 15/4 → 4/1 | s:chocolat ]", -] -`; - exports[`runs examples > example "saw" example index 0 1`] = ` [ "[ 0/1 → 1/8 | note:c3 clip:0 ]", From fe7e8f7add4b45216416d1f65038a6bb65f4ec04 Mon Sep 17 00:00:00 2001 From: Aria Date: Sun, 7 Dec 2025 13:21:06 -0600 Subject: [PATCH 23/32] Clearer docstring --- packages/midi/midi.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 0fee380c6..cd38d3587 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -486,8 +486,9 @@ const refsByChan = {}; * * The output is a function that accepts a midi cc value to query as well as (optionally) a midi channel * @param {string | number} input MIDI device name or index defaulting to 0 - * @returns {function(number, number=): function(): number} A function from (cc, channel?) to - * the most recently received midi cc value through the input (normalized to 0 to 1) + * @returns {function(number, number=): Pattern} A function from (cc, channel?) to a pattern. + * When queried, the pattern will produces the most recently received midi value (normalized to 0 to 1) + * that came through that cc number (and channel, if provided) * @example * const cc = await midin('IAC Driver Bus 1') * note("c a f e").lpf(cc(0).range(0, 1000)).lpq(cc(1).range(0, 10)).sound("sawtooth") From 358b1e3f563ffe6bafd33689b1fec8e7c02c4ecc Mon Sep 17 00:00:00 2001 From: Aria Date: Sun, 7 Dec 2025 13:28:57 -0600 Subject: [PATCH 24/32] Make sure processor turns off; fix array sizes for JIT --- packages/superdough/superdough.mjs | 2 ++ packages/superdough/worklets.mjs | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 52bf332e3..b4df1f734 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -544,6 +544,8 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) processorOptions: { attack: transient, sustain: transsustain, + begin: t, + end: endWithRelease, }, }, ), diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index d190cf41f..a59aeb756 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -1404,6 +1404,8 @@ class TransientProcessor extends AudioWorkletProcessor { sustain = 0, sensitivity = 0.1, mix = 1, + begin = 0, + end = 0, } = options.processorOptions; attackTime = clamp(attackTime, 0.0005, 0.05); sustainTime = clamp(sustainTime, 0.01, 0.5); @@ -1413,17 +1415,26 @@ class TransientProcessor extends AudioWorkletProcessor { this.sustainAmt = clamp(sustain, -1, 1); this.scaling = 0.5 + 5 * clamp(sensitivity, 0, 1); this.mix = clamp(mix, 0, 1); + this.begin = begin; + this.end = end; + this.attackEnv = new Float32Array(2); // assume stereo + this.sustainEnv = new Float32Array(2); } process(inputs, outputs, _params) { const input = inputs[0]; const output = outputs[0]; - if (!input || !output) { + if (currentTime >= this.end) { + return false; + } + if (currentTime <= this.begin) { return true; } const channels = input.length; - this.attackEnv ??= new Float32Array(channels); - this.sustainEnv ??= new Float32Array(channels); + if (channels > this.attackEnv.length) { + this.attackEnv = new Float32Array(channels); + this.sustainEnv = new Float32Array(channels); + } let avgGain = this.avgGain; for (let ch = 0; ch < channels; ch++) { let attEnv = this.attackEnv[ch]; From 71d36b79edfdf85cb035764b418662210226d245 Mon Sep 17 00:00:00 2001 From: jeromew Date: Mon, 8 Dec 2025 17:31:34 +0000 Subject: [PATCH 25/32] Improve getOscillator/noiseMix release --- packages/superdough/helpers.mjs | 9 ++++++--- packages/superdough/noise.mjs | 7 +++++-- packages/superdough/synth.mjs | 3 ++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index a5f199fb1..5abc2bad1 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -283,9 +283,12 @@ export function drywet(dry, wet, wetAmount = 0) { wet_gain.connect(mix); return { node: mix, - onended: () => { - dry_gain.disconnect(mix); - wet_gain.disconnect(mix); + teardown: () => { + releaseAudioNode(dry_gain); + releaseAudioNode(wet_gain); + // it is not the responsability of drywet + // to call `releaseAudioNode` on + // the 2 external args dry and wet dry.disconnect(dry_gain); wet.disconnect(wet_gain); }, diff --git a/packages/superdough/noise.mjs b/packages/superdough/noise.mjs index da60e87a1..52d9306a1 100644 --- a/packages/superdough/noise.mjs +++ b/packages/superdough/noise.mjs @@ -1,4 +1,4 @@ -import { drywet, onceEnded } from './helpers.mjs'; +import { drywet, onceEnded, releaseAudioNode } from './helpers.mjs'; import { getAudioContext } from './audioContext.mjs'; let noiseCache = {}; @@ -65,9 +65,12 @@ export function getNoiseOscillator(type = 'white', t, density = 0.02) { export function getNoiseMix(inputNode, wet, t) { const noiseOscillator = getNoiseOscillator('pink', t); const noiseMix = drywet(inputNode, noiseOscillator.node, wet); - onceEnded(noiseOscillator.node, () => noiseMix.onended()); + onceEnded(noiseOscillator.node, () => { + releaseAudioNode(noiseOscillator.node); + }); return { node: noiseMix.node, stop: (time) => noiseOscillator?.stop(time), + teardown: noiseMix.teardown, }; } diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index e82c889ac..d9ee0d936 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -491,7 +491,8 @@ export function getOscillator(s, t, value, onended) { } onceEnded(o, () => { - noiseMix || releaseAudioNode(o); + noiseMix?.teardown(); + releaseAudioNode(o); releaseAudioNode(noiseMix?.node); onended(); }); From e89414e400362ebe064858e4c0b1cc3441b56dca Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 10 Dec 2025 09:19:12 +0000 Subject: [PATCH 26/32] fix .as so it doesn't set undefined values --- packages/core/controls.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index c7edef74f..f28d13f62 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -2753,8 +2753,13 @@ export const as = register('as', (mapping, pat) => { mapping = Array.isArray(mapping) ? mapping : [mapping]; return pat.fmap((v) => { v = Array.isArray(v) ? v : [v]; - v = Object.fromEntries(mapping.map((prop, i) => [getControlName(prop), v[i]])); - return v; + const entries = []; + for (let i = 0; i < mapping.length; ++i) { + if (v[i] !== undefined) { + entries.push([getControlName(mapping[i]), v[i]]); + } + } + return Object.fromEntries(entries); }); }); From 12884cba3a0db216fa14a1c1c3d2521ab38fcccf Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 11 Dec 2025 12:09:26 +0000 Subject: [PATCH 27/32] Updates relating to LLM, github, etc --- CONTRIBUTING.md | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7f65b5bf..a4cf5d9e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,20 +4,13 @@ Thanks for wanting to contribute!!! There are many ways you can add value to thi ## Move to codeberg -We are currently in the process of moving from github to codeberg -- not everything is working, please bear with us. - -To update your local clone, you can run this command: - -``` -git remote set-url origin git@codeberg.org:uzu/strudel.git -``` - +Along with many other live coding projects, we have moved from Microsoft's Github platform to Codeberg for ethical reasons. Please don't fork the project back to github. ## Communication Channels -To get in touch with the contributors, either +To get in touch with the community, either -- [join the Tidal Discord Channel](https://discord.com/invite/HGEdXmRkzT) and go to the #strudel channel +- [join the Tidal Discord Channel](https://discord.com/invite/HGEdXmRkzT) and go to the #strudel channels - Find related discussions on the [tidal club forum](https://club.tidalcycles.org/) ## Ask a Question @@ -36,22 +29,33 @@ Use one of the Communication Channels listed above and drop us a line or two! ## Share Music If you made some music with strudel, you can give back some love and share what you've done! -Your creation could also be part of the random selection in the REPL if you want. Use one of the Communication Channels listed above. +(There used to be a random selection of contributed patterns in the REPL, but that is unfortunately disabled for now due to abuse) ## Improve the Docs -If you find some weak spots in the [docs](https://strudel.cc/workshop/getting-started/), -you can edit each file directly on codeburg. (we are currently fixing the "Edit this page" links in the right sidebar) +If you find some weak spots in the [docs](https://strudel.cc/workshop/getting-started/), you can edit each file directly on codeburg. There are "Edit this page" links in the right sidebar that take you to the right place. ## Propose a Feature -If you want a specific feature that is not part of strudel yet, feel free to use one of the communication channels above. -Maybe you even want to help with the implementation of that feature! +If you want a specific feature that is not part of strudel yet, feel free to use one of the communication channels above. Please bear in mind that this is a free/open source project, oriented around collaborative discussion. Maybe you even want to help with the implementation of that feature! + +## Contribute a feature + +It's generally best to start with discussion rather than do loads of work on something and then find it doesn't fit the collective goals of the project, or something like that. + +## AI/LLM policy + +Strudel is a project handmade by humans, with a lot of thought and nuance. We are still developing our response to the onslaught of LLM technology. + +If you have used LLMs (so called 'AI'), please make that clear in the pull request. If code is LLM-generated/isn't your own work, then the PR will not be merged, for practical and legal reasons. + +There are #llm-chat and #llm-share channels on our discord. Please do not discuss or share LLM-related things outside of those channels. ## Report a Bug If you've found a bug, or some behaviour that does not seem right, you are welcome to file an [issue](https://codeberg.org/uzu/strudel/issues). + Please check that it has not been reported before. ## Fix a Bug @@ -118,8 +122,7 @@ There are also eslint extensions / plugins for most editors. ## Running all CI Checks -When opening a PR, the CI runner will automatically check the code style and eslint, as well as run all tests. -You can run the same check with `pnpm check` +When opening a PR, the CI runner will (once approved by a human) check the code style and eslint, as well as run all tests. You can run the same check with `pnpm check`. ## Package Workflow From 960f1de9d2d125c95737b73abaf382fcfe4b5ab7 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 11 Dec 2025 12:16:54 +0000 Subject: [PATCH 28/32] tweaks --- CONTRIBUTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4cf5d9e0..10146bec2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ Along with many other live coding projects, we have moved from Microsoft's Githu To get in touch with the community, either -- [join the Tidal Discord Channel](https://discord.com/invite/HGEdXmRkzT) and go to the #strudel channels +- [join the Uzulang Discord Server](https://discord.com/invite/HGEdXmRkzT) and go to the strudel channels (Uzulangs are a family of live coding languages inspired by each other, including TidalCycles as well as Strudel) - Find related discussions on the [tidal club forum](https://club.tidalcycles.org/) ## Ask a Question @@ -42,9 +42,9 @@ If you want a specific feature that is not part of strudel yet, feel free to use ## Contribute a feature -It's generally best to start with discussion rather than do loads of work on something and then find it doesn't fit the collective goals of the project, or something like that. +Pull requests welcome! Consider starting with discussion or proof-of-concept first, rather than do loads of work on something and then find it doesn't fit the collective goals of the project, or something like that. -## AI/LLM policy +### AI/LLM policy Strudel is a project handmade by humans, with a lot of thought and nuance. We are still developing our response to the onslaught of LLM technology. From 96835be056e626020b6917ab8fad2cbcbcae1232 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 11 Dec 2025 15:07:57 +0000 Subject: [PATCH 29/32] tweaks --- CONTRIBUTING.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10146bec2..b8a941fc1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,7 @@ Use one of the Communication Channels listed above. ## Improve the Docs -If you find some weak spots in the [docs](https://strudel.cc/workshop/getting-started/), you can edit each file directly on codeburg. There are "Edit this page" links in the right sidebar that take you to the right place. +If you find some weak spots in the [docs](https://strudel.cc/workshop/getting-started/), you can edit each file directly on codeberg. There are "Edit this page" links in the right sidebar that take you to the right place. ## Propose a Feature @@ -44,11 +44,13 @@ If you want a specific feature that is not part of strudel yet, feel free to use Pull requests welcome! Consider starting with discussion or proof-of-concept first, rather than do loads of work on something and then find it doesn't fit the collective goals of the project, or something like that. +At the time of writing we have a PR backlog, and generally prioritise bugfixes and contributions that have arisen through community discussion. + ### AI/LLM policy -Strudel is a project handmade by humans, with a lot of thought and nuance. We are still developing our response to the onslaught of LLM technology. +Strudel is a project handmade by humans, with thought and nuance. -If you have used LLMs (so called 'AI'), please make that clear in the pull request. If code is LLM-generated/isn't your own work, then the PR will not be merged, for practical and legal reasons. +If you have used LLMs (so called 'AI'), please detail that in the pull request. We are still developing our response to the onslaught of LLM technology, but for practical and legal reasons are currently not accepting wholly LLM-generated code. We are also not accepting PRs that add LLM features to strudel itself. There are #llm-chat and #llm-share channels on our discord. Please do not discuss or share LLM-related things outside of those channels. From 8cbe4b945cfbd050ddbd85755c459a65135a90f0 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 11 Dec 2025 16:39:08 +0000 Subject: [PATCH 30/32] Respond to glossing's review --- packages/core/impure.mjs | 35 +++++++++++++++++++++++++++++------ packages/core/repl.mjs | 4 ++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/core/impure.mjs b/packages/core/impure.mjs index e16cfbe45..84886aa49 100644 --- a/packages/core/impure.mjs +++ b/packages/core/impure.mjs @@ -8,24 +8,48 @@ import { register, reify, Pattern } from './pattern.mjs'; let timelines = {}; +export const reset_state = function () { + reset_timelines(); +}; + export const reset_timelines = function () { timelines = {}; }; +/*** + * Allows you to switch a pattern between different 'timelines'. This is particularly useful when + * live coding, for example when you want to cue a pattern up to play from its start. + * + * Timelines are specified by number, so that if you had a pattern like + * `n("<0 1 2 3>").s("num").timeline(1)` playing, then changed the '1' + * to '2', it would always align '0' to the nearest cycle. You will likely want to trigger + * an evaluation a little bit before the cycle starts, to avoid missing events. + * + * If you change and evaluate a pattern without changing the timeline, it will stay on that timeline + * without resetting. + * + * Rather than incrementing a timeline to reset it, it's easier to negate it, e.g. by switching between `-2` + * and `2`. This is because when you negate a timeline it will always reset. + * + * You can also pattern the timeline if you want, to create strange resetting patterns. + */ + export const timeline = register( 'timeline', function (tpat, pat) { tpat = reify(tpat); const f = function (state) { - let scheduler = !!state.controls._cps; + // Is this called from the scheduler? (rather than from e.g. the visualiser) + const scheduler = !!state.controls._cyclist; const timehaps = tpat.query(state); const result = []; for (const timehap of timehaps) { const tlid = timehap.value; - const ignore = false; let offset; - if (tlid in timelines) { + if (tlid === 0) { + offset = 0; + } else if (tlid in timelines) { offset = timelines[tlid]; } else { const timearc = timehap.wholeOrPart(); @@ -40,9 +64,8 @@ export const timeline = register( if (scheduler) { // update state timelines[tlid] = offset; - const negative = 0 - tlid; - if (negative in timelines) { - delete timelines[negative]; + if (tlid !== 0) { + delete timelines[-tlid]; } } diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index 171697eb9..cd78cca48 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -5,6 +5,7 @@ import { errorLogger, logger } from './logger.mjs'; import { setTime } from './time.mjs'; import { evalScope } from './evaluate.mjs'; import { register, Pattern, isPattern, silence, stack } from './pattern.mjs'; +import { reset_state } from './impure.mjs'; export function repl({ defaultOutput, @@ -52,6 +53,9 @@ export function repl({ onToggle: (started) => { updateState({ started }); onToggle?.(started); + if (!started) { + reset_state(); + } }, setInterval, clearInterval, From 37990b459f3571d4b254ca99030e21d4cf836104 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 11 Dec 2025 16:51:25 +0000 Subject: [PATCH 31/32] bugfix and tweak doc --- packages/core/impure.mjs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/core/impure.mjs b/packages/core/impure.mjs index 84886aa49..304f801f5 100644 --- a/packages/core/impure.mjs +++ b/packages/core/impure.mjs @@ -25,13 +25,21 @@ export const reset_timelines = function () { * to '2', it would always align '0' to the nearest cycle. You will likely want to trigger * an evaluation a little bit before the cycle starts, to avoid missing events. * - * If you change and evaluate a pattern without changing the timeline, it will stay on that timeline - * without resetting. + * After the first use, a timeline will continue with the same 'offset'. That is, if you change + * a pattern without changing its timeline number, it will stay on that timeline without resetting. * * Rather than incrementing a timeline to reset it, it's easier to negate it, e.g. by switching between `-2` * and `2`. This is because when you negate a timeline it will always reset. * * You can also pattern the timeline if you want, to create strange resetting patterns. + * @param {number | Pattern} timeline The timeline that the pattern should play on. + * @example + * n("<0 1 2 3>(3,8)") + * .sound("num") + * // resets the timeline every two cycles, by negating the timeline. + * // in a lot of cases this will be edited by a human live coder + * // rather than patterned! + * .timeline("<2 -2>".slow(2)) */ export const timeline = register( @@ -40,8 +48,7 @@ export const timeline = register( tpat = reify(tpat); const f = function (state) { // Is this called from the scheduler? (rather than from e.g. the visualiser) - const scheduler = !!state.controls._cyclist; - + const scheduler = !!state.controls.cyclist; const timehaps = tpat.query(state); const result = []; for (const timehap of timehaps) { From c6e0f0533e927a878339a9828c32d21ac9115a7c Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 11 Dec 2025 23:32:10 +0000 Subject: [PATCH 32/32] add delta --- packages/core/signal.mjs | 9 ++++ test/__snapshots__/examples.test.mjs.snap | 53 +++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index 62fc26ad8..9ac768951 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -882,3 +882,12 @@ export const whenKey = register('whenKey', function (input, func, pat) { export const keyDown = register('keyDown', function (pat) { return pat.fmap(_keyDown); }); + +/** + * A pattern that gives the duration of events that are combined with it. + * @example + * sound("bd sd [bd bd] sd*4 [- sd] [bd [bd bd]]").note(delta.withValue(x => 1/x + 20)) + */ +export const delta = new Pattern(function (state) { + return [new Hap(undefined, state.span, state.span.duration)]; +}); diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 09d285b5d..17dcb08ba 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -2784,6 +2784,59 @@ exports[`runs examples > example "delaysync" example index 0 1`] = ` ] `; +exports[`runs examples > example "delta" example index 0 1`] = ` +[ + "[ 0/1 → 1/6 | s:bd note:26 ]", + "[ 1/6 → 1/3 | s:sd note:26 ]", + "[ 1/3 → 5/12 | s:bd note:32 ]", + "[ 5/12 → 1/2 | s:bd note:32 ]", + "[ 1/2 → 13/24 | s:sd note:44 ]", + "[ 13/24 → 7/12 | s:sd note:44 ]", + "[ 7/12 → 5/8 | s:sd note:44 ]", + "[ 5/8 → 2/3 | s:sd note:44 ]", + "[ 3/4 → 5/6 | s:sd note:32 ]", + "[ 5/6 → 11/12 | s:bd note:32 ]", + "[ 11/12 → 23/24 | s:bd note:44 ]", + "[ 23/24 → 1/1 | s:bd note:44 ]", + "[ 1/1 → 7/6 | s:bd note:26 ]", + "[ 7/6 → 4/3 | s:sd note:26 ]", + "[ 4/3 → 17/12 | s:bd note:32 ]", + "[ 17/12 → 3/2 | s:bd note:32 ]", + "[ 3/2 → 37/24 | s:sd note:44 ]", + "[ 37/24 → 19/12 | s:sd note:44 ]", + "[ 19/12 → 13/8 | s:sd note:44 ]", + "[ 13/8 → 5/3 | s:sd note:44 ]", + "[ 7/4 → 11/6 | s:sd note:32 ]", + "[ 11/6 → 23/12 | s:bd note:32 ]", + "[ 23/12 → 47/24 | s:bd note:44 ]", + "[ 47/24 → 2/1 | s:bd note:44 ]", + "[ 2/1 → 13/6 | s:bd note:26 ]", + "[ 13/6 → 7/3 | s:sd note:26 ]", + "[ 7/3 → 29/12 | s:bd note:32 ]", + "[ 29/12 → 5/2 | s:bd note:32 ]", + "[ 5/2 → 61/24 | s:sd note:44 ]", + "[ 61/24 → 31/12 | s:sd note:44 ]", + "[ 31/12 → 21/8 | s:sd note:44 ]", + "[ 21/8 → 8/3 | s:sd note:44 ]", + "[ 11/4 → 17/6 | s:sd note:32 ]", + "[ 17/6 → 35/12 | s:bd note:32 ]", + "[ 35/12 → 71/24 | s:bd note:44 ]", + "[ 71/24 → 3/1 | s:bd note:44 ]", + "[ 3/1 → 19/6 | s:bd note:26 ]", + "[ 19/6 → 10/3 | s:sd note:26 ]", + "[ 10/3 → 41/12 | s:bd note:32 ]", + "[ 41/12 → 7/2 | s:bd note:32 ]", + "[ 7/2 → 85/24 | s:sd note:44 ]", + "[ 85/24 → 43/12 | s:sd note:44 ]", + "[ 43/12 → 29/8 | s:sd note:44 ]", + "[ 29/8 → 11/3 | s:sd note:44 ]", + "[ 15/4 → 23/6 | s:sd note:32 ]", + "[ 23/6 → 47/12 | s:bd note:32 ]", + "[ 47/12 → 95/24 | s:bd note:44 ]", + "[ 95/24 → 4/1 | s:bd note:44 ]", +] +`; + exports[`runs examples > example "density" example index 0 1`] = ` [ "[ 0/1 → 1/4 | s:crackle density:0.01 ]",