From 4747fab4ed4c9b139304fb501d7f0ac24e8b16ff Mon Sep 17 00:00:00 2001 From: Ethan Crawford Date: Fri, 15 May 2026 17:24:10 +0800 Subject: [PATCH 01/42] Remove outdated continuation link Previously, the docs were designed to work as a series of consecutively read documents. This linear flow is no longer intended, and as such the link at the end of the csound document has become obsolete. This commit removes it, as suggested by a member of the dev team. --- website/src/pages/learn/csound.mdx | 2 -- 1 file changed, 2 deletions(-) diff --git a/website/src/pages/learn/csound.mdx b/website/src/pages/learn/csound.mdx index 4ef04419a..072b116ef 100644 --- a/website/src/pages/learn/csound.mdx +++ b/website/src/pages/learn/csound.mdx @@ -150,5 +150,3 @@ In the future, the integration could be improved by passing all patterned contro This could work by a unique [channel](https://kunstmusik.github.io/icsc2022-csound-web/tutorial2-interacting-with-csound/#step-4---writing-continuous-data-channels) for each value. Channels could be read [like this](https://github.com/csound/csound/blob/master/Android/CsoundForAndroid/CsoundAndroidExamples/src/main/res/raw/multitouch_xy.csd). Also, it might make sense to have a standard library of csound instruments for strudel's effects. - -Now, let's dive into the [Functional JavaScript API](/functions/intro) From fe0be6bc53320b59422871205fa9ad69e39608f6 Mon Sep 17 00:00:00 2001 From: jouae Date: Sun, 17 May 2026 01:42:46 +0800 Subject: [PATCH 02/42] Fix falsy value check in getFreqeuncy || operator, which causes 0 to be treated as falsy and thus not passed to getFreq, resulting in incorrect frequency calculation for MIDI note 0. Replace || with ?? to allow 0 to be correctly passed to getFreq. --- packages/core/util.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/util.mjs b/packages/core/util.mjs index c38c8e05f..caf2bc054 100644 --- a/packages/core/util.mjs +++ b/packages/core/util.mjs @@ -137,7 +137,7 @@ export const getFrequency = (hap) => { if (value.freq) { return value.freq; } - return getFreq(value.note || value.n || value.value); + return getFreq(value.note ?? value.n ?? value.value); } if (typeof value === 'number' && context.type !== 'frequency') { value = midiToFreq(hap.value); From d83139980b3373d26295fa6f49cfe7ca68dfe87c Mon Sep 17 00:00:00 2001 From: Martyn Eggleton Date: Wed, 27 May 2026 21:23:55 +0100 Subject: [PATCH 03/42] Creates a pattern of numbers in base x from a number or pattern of numbers --- packages/core/pattern.mjs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index bf9d8ce0b..e9bc47127 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -4127,3 +4127,30 @@ Pattern.prototype.worklet = function (src, ...inputs) { }; export const worklet = (...args) => pure({}).worklet(...args); + + +/** + * Creates a pattern of numbers in base x from a number or pattern of numbers. + * + * @name base + * @tags generators + * @param {number} n - input number to convert + * @param {number} x - base to convert to defaults to 10 + * @example + * $: note(base("175 543", 10)).scale("c:major").s("saw") + * // $: note("1 7 5 5 4 3").scale("c:major").s("saw") + */ +export const base = (n, x = 10) => { + n = reify(n); + //console.log("base", n, x) + return n.withValue(v => { + let digits = []; + let value = v; + while (value > 0) { + digits.unshift(value % x); + value = Math.floor(value / x); + } + const length = digits.length; + return sequence(digits); + }).squeezeJoin() +}; From f21aeb55bd4c29518d23d7607532a4b19808f4b2 Mon Sep 17 00:00:00 2001 From: Martyn Eggleton Date: Mon, 1 Jun 2026 14:02:20 +0100 Subject: [PATCH 04/42] The base param can be a pattern. We also add a max number of digits to produce for each n --- packages/core/pattern.mjs | 58 +++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index e9bc47127..d0d9404b8 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -2028,9 +2028,9 @@ export const { fastGap, fastgap } = register(['fastGap', 'fastgap'], function (f const newWhole = !hap.whole ? undefined : new TimeSpan( - newPart.begin.sub(begin.sub(hap.whole.begin).div(factor)), - newPart.end.add(hap.whole.end.sub(end).div(factor)), - ); + newPart.begin.sub(begin.sub(hap.whole.begin).div(factor)), + newPart.end.add(hap.whole.end.sub(end).div(factor)), + ); return new Hap(newWhole, newPart, hap.value, hap.context); }; return pat.withQuerySpanMaybe(qf).withHap(ef).splitQueries(); @@ -4130,27 +4130,49 @@ export const worklet = (...args) => pure({}).worklet(...args); /** - * Creates a pattern of numbers in base x from a number or pattern of numbers. + * Creates a pattern of numbers in base b from a number or pattern of numbers + * padded & limited to d digits long * * @name base * @tags generators - * @param {number} n - input number to convert - * @param {number} x - base to convert to defaults to 10 + * @param {number} n - number to convert (can be a pattern) + * @param {number} b - base to convert to (defaults to 10) (can be a pattern) + * @param {number} d - max number of digits to produce for each n (defaults to 0 for all) (can be a pattern) * @example - * $: note(base("175 543", 10)).scale("c:major").s("saw") + * $: note(base("7175 543", 10, 3)).scale("c:major").s("saw") * // $: note("1 7 5 5 4 3").scale("c:major").s("saw") */ -export const base = (n, x = 10) => { +export const base = (n, b = 10, d=0) => { n = reify(n); - //console.log("base", n, x) - return n.withValue(v => { - let digits = []; - let value = v; - while (value > 0) { - digits.unshift(value % x); - value = Math.floor(value / x); - } - const length = digits.length; - return sequence(digits); + b = reify(b); + d = reify(d); + + return d.withValue(e => { + return b.withValue(c => { + //console.log("base", n, c) + return n.withValue(v => { + let digits = []; + let value = v; + while (value > 0) { + digits.unshift(value % c); + value = Math.floor(value / c); + } + if (e){ + const l = digits.length + if (l > e){ + digits = digits.slice(-1 * e) + } + /* + if (l < e){ + for (let i = l; i < e; i++) { + digits.unshift("~");//0); //Would like to be padding this but ~- doesn't work + } + console.log("digits", digits) + } + */ + } + return sequence(digits); + }).squeezeJoin() + }).squeezeJoin() }).squeezeJoin() }; From 05a43ef687729766524dde006964ef31cf045fce Mon Sep 17 00:00:00 2001 From: eefano <77832+eefano@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:14:30 +0200 Subject: [PATCH 05/42] included midichan value in midikeys haps --- packages/midi/midi.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index bd40b743e..51a077115 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -633,7 +633,7 @@ export async function midikeys(input) { */ return; } else { - value = { ...value, note: Math.round(note), velocity: velocity / 127 }; + value = { ...value, note: Math.round(note), velocity: velocity / 127, midichan: message.channel }; } kHaps[input].push(new Hap(span, span, value, {})); if (!noteoff && triggerAvailable) { From 0ae2c120b33ea5613d631625403505ad51c8cff6 Mon Sep 17 00:00:00 2001 From: eefano <77832+eefano@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:28:23 +0200 Subject: [PATCH 06/42] updated documentation for midikeys --- packages/midi/midi.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 51a077115..73383a378 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -536,6 +536,9 @@ export async function midin(input) { * * The note length is fixed as Superdough is not currently set up for undetermined * note durations + * + * The 'midichan' control value contains the number of the channel the note is coming from + * so it could be filtered or manipulated further in the chain. * * @name midikeys * @tags external_io, midi @@ -552,6 +555,10 @@ export async function midin(input) { * .s("saw") * .add(note(rand.mul(0.3))) * .lpf(1000).lpe(2).room(0.5) + * @example + * // discard all notes not coming out from midi channel 2 + * const kb = await midikeys('Arturia KeyStep 32') + * kb().filterValues(v=>v.midichan==2).s("tri") */ const kHaps = {}; const kListeners = {}; From 58f956b57e5f9f94be10e14c3d8ba954187637e9 Mon Sep 17 00:00:00 2001 From: eefano <77832+eefano@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:29:28 +0200 Subject: [PATCH 07/42] prettier fix --- packages/midi/midi.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 73383a378..5880ca268 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -536,7 +536,7 @@ export async function midin(input) { * * The note length is fixed as Superdough is not currently set up for undetermined * note durations - * + * * The 'midichan' control value contains the number of the channel the note is coming from * so it could be filtered or manipulated further in the chain. * From 2635716c878059ef5c5ee89898841a50fae9cc0f Mon Sep 17 00:00:00 2001 From: eefano <77832+eefano@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:56:55 +0200 Subject: [PATCH 08/42] added midikeys third test --- test/__snapshots__/examples.test.mjs.snap | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index f7989dc7e..f465f06bf 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -7927,6 +7927,8 @@ exports[`runs examples > example "midikeys" example index 0 1`] = `[]`; exports[`runs examples > example "midikeys" example index 1 1`] = `[]`; +exports[`runs examples > example "midikeys" example index 2 1`] = `[]`; + exports[`runs examples > example "midin" example index 0 1`] = ` [ "[ 0/1 → 1/4 | note:c cutoff:0 resonance:0 s:sawtooth ]", From bd68c6a0a7395447282388772c2896080e87333a Mon Sep 17 00:00:00 2001 From: Martyn Eggleton Date: Wed, 3 Jun 2026 12:36:41 +0100 Subject: [PATCH 09/42] Documentation tweak --- packages/core/pattern.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index d0d9404b8..11ae75f08 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -4131,7 +4131,7 @@ export const worklet = (...args) => pure({}).worklet(...args); /** * Creates a pattern of numbers in base b from a number or pattern of numbers - * padded & limited to d digits long + * limited to d digits long from the right * * @name base * @tags generators From 475f17ddfd85691546d5e428f27b1f61283746b0 Mon Sep 17 00:00:00 2001 From: Martyn Eggleton Date: Wed, 3 Jun 2026 21:56:26 +0100 Subject: [PATCH 10/42] base now accepts Arrays which improve compatibility with rockstar-strudel --- packages/core/pattern.mjs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 11ae75f08..d69a04a69 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -4135,7 +4135,7 @@ export const worklet = (...args) => pure({}).worklet(...args); * * @name base * @tags generators - * @param {number} n - number to convert (can be a pattern) + * @param {number} n - number to convert (can be a pattern or array) * @param {number} b - base to convert to (defaults to 10) (can be a pattern) * @param {number} d - max number of digits to produce for each n (defaults to 0 for all) (can be a pattern) * @example @@ -4143,13 +4143,15 @@ export const worklet = (...args) => pure({}).worklet(...args); * // $: note("1 7 5 5 4 3").scale("c:major").s("saw") */ export const base = (n, b = 10, d=0) => { + if(Array.isArray(n)){ + n = sequence(n); + } n = reify(n); b = reify(b); d = reify(d); return d.withValue(e => { return b.withValue(c => { - //console.log("base", n, c) return n.withValue(v => { let digits = []; let value = v; @@ -4158,21 +4160,21 @@ export const base = (n, b = 10, d=0) => { value = Math.floor(value / c); } if (e){ - const l = digits.length + const l = digits.length; if (l > e){ - digits = digits.slice(-1 * e) + digits = digits.slice(-1 * e); } /* if (l < e){ for (let i = l; i < e; i++) { digits.unshift("~");//0); //Would like to be padding this but ~- doesn't work } - console.log("digits", digits) + console.log("digits", digits); } */ } return sequence(digits); - }).squeezeJoin() - }).squeezeJoin() - }).squeezeJoin() + }).squeezeJoin(); + }).squeezeJoin(); + }).squeezeJoin(); }; From 941c97da0d2156db41869f78c2bdff462b726390 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Mon, 15 Jun 2026 09:15:06 +0200 Subject: [PATCH 11/42] fix: cache bunny cdn urls #2057 --- website/astro.config.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 867635883..e62f13c7f 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -78,6 +78,7 @@ export default defineConfig({ urlPattern: ({ url }) => [ /^https:\/\/raw\.githubusercontent\.com\/.*/i, + /^https:\/\/strudel\.b-cdn\.net\/.*/i, /^https:\/\/freesound\.org\/.*/i, /^https:\/\/cdn\.freesound\.org\/.*/i, /^https:\/\/shabda\.ndre\.gr\/.*/i, From 62d4f84e698abcfddc33d65d62f5a640472f7bf4 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 18 Jun 2026 10:55:34 +0100 Subject: [PATCH 12/42] add growlist --- packages/core/pattern.mjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index bf9d8ce0b..76568fd2f 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -2028,9 +2028,9 @@ export const { fastGap, fastgap } = register(['fastGap', 'fastgap'], function (f const newWhole = !hap.whole ? undefined : new TimeSpan( - newPart.begin.sub(begin.sub(hap.whole.begin).div(factor)), - newPart.end.add(hap.whole.end.sub(end).div(factor)), - ); + newPart.begin.sub(begin.sub(hap.whole.begin).div(factor)), + newPart.end.add(hap.whole.end.sub(end).div(factor)), + ); return new Hap(newWhole, newPart, hap.value, hap.context); }; return pat.withQuerySpanMaybe(qf).withHap(ef).splitQueries(); @@ -3359,6 +3359,11 @@ Pattern.prototype.shrinklist = function (amount) { export const shrinklist = (amount, pat) => pat.shrinklist(amount); +Pattern.prototype.growlist = function (amount) { + return this.shrinklist(amount).reverse(); +} +export const growlist = (amount, pat) => pat.growlist(amount); + /** * *Experimental* * From 813cc6c1904f73f4f2afd373681a3a5d16f613cd Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 18 Jun 2026 12:04:03 +0100 Subject: [PATCH 13/42] format --- packages/core/pattern.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 76568fd2f..ff808ac4a 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -2028,9 +2028,9 @@ export const { fastGap, fastgap } = register(['fastGap', 'fastgap'], function (f const newWhole = !hap.whole ? undefined : new TimeSpan( - newPart.begin.sub(begin.sub(hap.whole.begin).div(factor)), - newPart.end.add(hap.whole.end.sub(end).div(factor)), - ); + newPart.begin.sub(begin.sub(hap.whole.begin).div(factor)), + newPart.end.add(hap.whole.end.sub(end).div(factor)), + ); return new Hap(newWhole, newPart, hap.value, hap.context); }; return pat.withQuerySpanMaybe(qf).withHap(ef).splitQueries(); @@ -3361,7 +3361,7 @@ export const shrinklist = (amount, pat) => pat.shrinklist(amount); Pattern.prototype.growlist = function (amount) { return this.shrinklist(amount).reverse(); -} +}; export const growlist = (amount, pat) => pat.growlist(amount); /** From def17382599fd998ce8cf25c1b66f22bb8ec6f8b Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 19 Jun 2026 21:29:25 +0100 Subject: [PATCH 14/42] shortcuts for workshop --- packages/mqtt/mqtt.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/mqtt/mqtt.mjs b/packages/mqtt/mqtt.mjs index 96659c67b..a84628567 100644 --- a/packages/mqtt/mqtt.mjs +++ b/packages/mqtt/mqtt.mjs @@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see . */ -import { Pattern, isPattern } from '@strudel/core'; +import { Pattern, isPattern, createParams } from '@strudel/core'; import Paho from 'paho-mqtt'; const connections = {}; @@ -118,3 +118,11 @@ Pattern.prototype.mqtt = function ( return hap.setContext({ ...hap.context, onTrigger, dominantTrigger: true }); }); }; + + +// This adds the 'move' and 'motor' commands to strudel +export const { move, motor } = createParams('move', 'motor'); window.move = move; window.motor = motor; +// This adds the 'robot' command +Pattern.prototype.robot = function (robot_id, address = 'ws://192.168.8.248:9001/mqtt') { + return this.mqtt(undefined, undefined, '/move/' + robot_id, address); +}; From d3e2b7c7b4740636032f500547bf36221f9e8bd3 Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 19 Jun 2026 21:29:49 +0100 Subject: [PATCH 15/42] motors workshop page --- website/src/pages/workshop/motors.mdx | 119 ++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 website/src/pages/workshop/motors.mdx diff --git a/website/src/pages/workshop/motors.mdx b/website/src/pages/workshop/motors.mdx new file mode 100644 index 000000000..9d679dee9 --- /dev/null +++ b/website/src/pages/workshop/motors.mdx @@ -0,0 +1,119 @@ +--- +title: Movement with Strudel +layout: ../../layouts/MainLayout.astro +--- + +import { MiniRepl } from '@src/docs/MiniRepl'; +import Box from '@components/Box.astro'; +import QA from '@components/QA'; + +# Controlling motors with Strudel + +Strudel is mainly made for making music, but it's possible to pattern other things with it, including motors. + +We're going to use a microcontroller for this, called an "[Inventor 2040W](https://shop.pimoroni.com/products/inventor-2040-w)", which is +a [Pico W](https://shop.pimoroni.com/products/inventor-2040-w?variant=40053063155795) with extra ports added including some for controlling motors. + +![image](https://shop.pimoroni.com/cdn/shop/products/Inventor2040_1of3_1500x1500_crop_center.jpg?v=1656927927) + +## Technical details + +Feel free to gloss over these! + +- The Inventor 2040W connects to the internet wirelessly, and it can power from a battery or USB. Hopefully the batteries last! +- It's running [some code](https://github.com/patternclub/alpacalab/blob/main/course/main.py) that listens for messages using an "Internet of Things" network protocol (called MQTT). When it receives a message, it moves a motor. +- It connects to a small server (running software called 'mosquitto') on Alex's laptop. +- Strudel can send these messages instead of triggering sounds - that's how we use it to pattern movement. + +## First movement + +Let's get a motor running! + +1. Note the letter drawn on a label on the back of the microcontroller. + +2. Plug a battery into your microcontroller. + +3. Plug a motor into 'servo' (not motor) plug numbered 1, with the yellow (lightest) cable closest to the '1', and the brown (darkest) cable outward + +4. Run the below to set up some values, changing the `x` in 'robot('x')` to the letter on your microcontroller. + + + + + +If you refresh the page, you'll need to change the letter to match your robot again. + +If your motor starts moving unexpectedly, someone else might have put your letter in by mistake! + +Note that in the above, we start counting motors from '0', so motor 1 on the board is motor 0 in the code. + + + +## Patterning movement + +Many strudel features for playing with sound patterns will work when +playing with motor patterns. Try playing with the mininotation in the +`move` command: + + + +The move instructions are in the range from -90 to 90. + + + If your motors stop working at some point, and your code looks right, try pressing the 'reset' button on the + microcontroller. + + +It's possible to make smooth movements based on different 'waveforms', for example a smooth sinewave: + + + +The movement is still quite jerky, because the 'segment' command is +only taking 16 positions from the sinewave. Try increasing it to 32 or 64. It's best not too much higher than that, as the microcontroller +might get overwhelmed with a backlog of instructions! + + + Try replacing `sine` with other waveforms: `saw` (sawtooth wave), `tri` (triangular wave) are good, and there is also + `rand` (random wave) and `perlin` (a kind of smoothed-out randomness). + + +## Patterning more than one motor + +You can pattern the `motor` command separately from the `move` one: + + + +Alternatively, you can pattern two motors in separate patterns. The below sends the same pattern for the first two motors, but with the second one running slower: + + + + From 85e6d436ef6409f7708409d98135e65575cf0936 Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 19 Jun 2026 21:29:58 +0100 Subject: [PATCH 16/42] motors workshop page --- website/src/config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/website/src/config.ts b/website/src/config.ts index eb098d009..12272dbcb 100644 --- a/website/src/config.ts +++ b/website/src/config.ts @@ -68,6 +68,7 @@ export const SIDEBAR: Sidebar = { { text: 'Pattern Effects', link: 'workshop/pattern-effects' }, { text: 'Recap', link: 'workshop/recap' }, { text: 'Workshop in German', link: 'de/workshop/getting-started' }, + { text: 'Tanglebot workshop', link: 'workshop/motors' }, ], 'Making Sound': [ { text: 'Samples', link: 'learn/samples' }, From 6870b04fb2af8ce73a0af441d8a3ae35038290f7 Mon Sep 17 00:00:00 2001 From: Martyn Eggleton Date: Sun, 21 Jun 2026 13:49:11 +0100 Subject: [PATCH 17/42] Pretty code now --- packages/core/pattern.mjs | 57 +++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 5aea0c11a..aa5e1060f 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -2028,9 +2028,9 @@ export const { fastGap, fastgap } = register(['fastGap', 'fastgap'], function (f const newWhole = !hap.whole ? undefined : new TimeSpan( - newPart.begin.sub(begin.sub(hap.whole.begin).div(factor)), - newPart.end.add(hap.whole.end.sub(end).div(factor)), - ); + newPart.begin.sub(begin.sub(hap.whole.begin).div(factor)), + newPart.end.add(hap.whole.end.sub(end).div(factor)), + ); return new Hap(newWhole, newPart, hap.value, hap.context); }; return pat.withQuerySpanMaybe(qf).withHap(ef).splitQueries(); @@ -4133,7 +4133,6 @@ Pattern.prototype.worklet = function (src, ...inputs) { export const worklet = (...args) => pure({}).worklet(...args); - /** * Creates a pattern of numbers in base b from a number or pattern of numbers * limited to d digits long from the right @@ -4147,29 +4146,32 @@ export const worklet = (...args) => pure({}).worklet(...args); * $: note(base("7175 543", 10, 3)).scale("c:major").s("saw") * // $: note("1 7 5 5 4 3").scale("c:major").s("saw") */ -export const base = (n, b = 10, d=0) => { - if(Array.isArray(n)){ +export const base = (n, b = 10, d = 0) => { + if (Array.isArray(n)) { n = sequence(n); } n = reify(n); b = reify(b); d = reify(d); - return d.withValue(e => { - return b.withValue(c => { - return n.withValue(v => { - let digits = []; - let value = v; - while (value > 0) { - digits.unshift(value % c); - value = Math.floor(value / c); - } - if (e){ - const l = digits.length; - if (l > e){ - digits = digits.slice(-1 * e); - } - /* + return d + .withValue((e) => { + return b + .withValue((c) => { + return n + .withValue((v) => { + let digits = []; + let value = v; + while (value > 0) { + digits.unshift(value % c); + value = Math.floor(value / c); + } + if (e) { + const l = digits.length; + if (l > e) { + digits = digits.slice(-1 * e); + } + /* if (l < e){ for (let i = l; i < e; i++) { digits.unshift("~");//0); //Would like to be padding this but ~- doesn't work @@ -4177,9 +4179,12 @@ export const base = (n, b = 10, d=0) => { console.log("digits", digits); } */ - } - return sequence(digits); - }).squeezeJoin(); - }).squeezeJoin(); - }).squeezeJoin(); + } + return sequence(digits); + }) + .squeezeJoin(); + }) + .squeezeJoin(); + }) + .squeezeJoin(); }; From a7c3407da7056a35c3a90ecabd4498f970373561 Mon Sep 17 00:00:00 2001 From: alex Date: Sun, 21 Jun 2026 13:54:38 +0100 Subject: [PATCH 18/42] codeformat --- packages/mqtt/mqtt.mjs | 5 +++-- website/src/pages/workshop/motors.mdx | 10 ++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/mqtt/mqtt.mjs b/packages/mqtt/mqtt.mjs index a84628567..c0a958cfd 100644 --- a/packages/mqtt/mqtt.mjs +++ b/packages/mqtt/mqtt.mjs @@ -119,9 +119,10 @@ Pattern.prototype.mqtt = function ( }); }; - // This adds the 'move' and 'motor' commands to strudel -export const { move, motor } = createParams('move', 'motor'); window.move = move; window.motor = motor; +export const { move, motor } = createParams('move', 'motor'); +window.move = move; +window.motor = motor; // This adds the 'robot' command Pattern.prototype.robot = function (robot_id, address = 'ws://192.168.8.248:9001/mqtt') { return this.mqtt(undefined, undefined, '/move/' + robot_id, address); diff --git a/website/src/pages/workshop/motors.mdx b/website/src/pages/workshop/motors.mdx index 9d679dee9..761763be8 100644 --- a/website/src/pages/workshop/motors.mdx +++ b/website/src/pages/workshop/motors.mdx @@ -35,7 +35,7 @@ Let's get a motor running! 3. Plug a motor into 'servo' (not motor) plug numbered 1, with the yellow (lightest) cable closest to the '1', and the brown (darkest) cable outward -4. Run the below to set up some values, changing the `x` in 'robot('x')` to the letter on your microcontroller. +4. Run the below to set up some values, changing the `x` in 'robot('x')` to the letter on your microcontroller. - - From 19cb3dedc22c486367b748e7ab3635522274c357 Mon Sep 17 00:00:00 2001 From: alex Date: Sun, 21 Jun 2026 14:02:34 +0100 Subject: [PATCH 19/42] snapshot --- test/__snapshots__/examples.test.mjs.snap | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index f465f06bf..9ff81763c 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -1242,6 +1242,35 @@ exports[`runs examples > example "bank" example index 0 1`] = ` ] `; +exports[`runs examples > example "base" example index 0 1`] = ` +[ + "[ 0/1 → 1/6 | note:D3 s:saw ]", + "[ 1/6 → 1/3 | note:C4 s:saw ]", + "[ 1/3 → 1/2 | note:A3 s:saw ]", + "[ 1/2 → 2/3 | note:A3 s:saw ]", + "[ 2/3 → 5/6 | note:G3 s:saw ]", + "[ 5/6 → 1/1 | note:F3 s:saw ]", + "[ 1/1 → 7/6 | note:D3 s:saw ]", + "[ 7/6 → 4/3 | note:C4 s:saw ]", + "[ 4/3 → 3/2 | note:A3 s:saw ]", + "[ 3/2 → 5/3 | note:A3 s:saw ]", + "[ 5/3 → 11/6 | note:G3 s:saw ]", + "[ 11/6 → 2/1 | note:F3 s:saw ]", + "[ 2/1 → 13/6 | note:D3 s:saw ]", + "[ 13/6 → 7/3 | note:C4 s:saw ]", + "[ 7/3 → 5/2 | note:A3 s:saw ]", + "[ 5/2 → 8/3 | note:A3 s:saw ]", + "[ 8/3 → 17/6 | note:G3 s:saw ]", + "[ 17/6 → 3/1 | note:F3 s:saw ]", + "[ 3/1 → 19/6 | note:D3 s:saw ]", + "[ 19/6 → 10/3 | note:C4 s:saw ]", + "[ 10/3 → 7/2 | note:A3 s:saw ]", + "[ 7/2 → 11/3 | note:A3 s:saw ]", + "[ 11/3 → 23/6 | note:G3 s:saw ]", + "[ 23/6 → 4/1 | note:F3 s:saw ]", +] +`; + exports[`runs examples > example "beat" example index 0 1`] = ` [ "[ 0/1 → 1/16 | s:bd ]", From deefde7b5004707054445183b269face53ec94df Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 21 Jun 2026 15:41:50 +0200 Subject: [PATCH 20/42] hotfix: codeformat --- packages/mqtt/mqtt.mjs | 5 +++-- website/src/pages/workshop/motors.mdx | 10 ++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/mqtt/mqtt.mjs b/packages/mqtt/mqtt.mjs index a84628567..c0a958cfd 100644 --- a/packages/mqtt/mqtt.mjs +++ b/packages/mqtt/mqtt.mjs @@ -119,9 +119,10 @@ Pattern.prototype.mqtt = function ( }); }; - // This adds the 'move' and 'motor' commands to strudel -export const { move, motor } = createParams('move', 'motor'); window.move = move; window.motor = motor; +export const { move, motor } = createParams('move', 'motor'); +window.move = move; +window.motor = motor; // This adds the 'robot' command Pattern.prototype.robot = function (robot_id, address = 'ws://192.168.8.248:9001/mqtt') { return this.mqtt(undefined, undefined, '/move/' + robot_id, address); diff --git a/website/src/pages/workshop/motors.mdx b/website/src/pages/workshop/motors.mdx index 9d679dee9..761763be8 100644 --- a/website/src/pages/workshop/motors.mdx +++ b/website/src/pages/workshop/motors.mdx @@ -35,7 +35,7 @@ Let's get a motor running! 3. Plug a motor into 'servo' (not motor) plug numbered 1, with the yellow (lightest) cable closest to the '1', and the brown (darkest) cable outward -4. Run the below to set up some values, changing the `x` in 'robot('x')` to the letter on your microcontroller. +4. Run the below to set up some values, changing the `x` in 'robot('x')` to the letter on your microcontroller. - - From c15075237263c39e516301a1ff0fdc0b4c1bc04f Mon Sep 17 00:00:00 2001 From: yaxu Date: Sun, 21 Jun 2026 20:47:04 +0200 Subject: [PATCH 21/42] revert 85e6d436ef6409f7708409d98135e65575cf0936 revert motors workshop page (committed to main by mistake!) --- website/src/config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/website/src/config.ts b/website/src/config.ts index 12272dbcb..eb098d009 100644 --- a/website/src/config.ts +++ b/website/src/config.ts @@ -68,7 +68,6 @@ export const SIDEBAR: Sidebar = { { text: 'Pattern Effects', link: 'workshop/pattern-effects' }, { text: 'Recap', link: 'workshop/recap' }, { text: 'Workshop in German', link: 'de/workshop/getting-started' }, - { text: 'Tanglebot workshop', link: 'workshop/motors' }, ], 'Making Sound': [ { text: 'Samples', link: 'learn/samples' }, From daafee7527dd7df5c6ad9ecf68b891961e8fddba Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 29 Jul 2026 09:11:41 +0100 Subject: [PATCH 22/42] advice for creating a new project using strudel --- CONTRIBUTING.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b8a941fc1..2d93002cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,6 +54,16 @@ If you have used LLMs (so called 'AI'), please detail that in the pull request. There are #llm-chat and #llm-share channels on our discord. Please do not discuss or share LLM-related things outside of those channels. +## Creating and sharing a new project using strudel + +Strudel is free/open source software, and we are also happy to see people making use of it within the following terms. + +Please don't use 'strudel' in the name of your project, so people know it's not an official strudel project. (If you'd like it to be an official strudel project, please check in with the community, e.g. on the discord.) + +Please respect our AGPL license, which e.g. requires you to share/link to the source code of strudel, any modifications you've made to it, and the source code for the rest of your project if it integrates with strudel. You are also required to maintain Strudel's copyright notices in the source code, and include Strudel's copyright notice in your user interface. This is an ad-hoc summary - please [refer to the license](https://codeberg.org/uzu/strudel/src/branch/main/LICENSE) for full details. + +You are also encouraged to connect with the community and align with our aims and values. + ## 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). From 2d014b47c80cbb7f011180a40b339bbd8b8db440 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 29 Jul 2026 09:20:08 +0100 Subject: [PATCH 23/42] add advice for creating a new project to faq --- CONTRIBUTING.md | 4 ++-- website/src/pages/learn/faq.mdx | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d93002cd..03bcae675 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,13 +52,13 @@ Strudel is a project handmade by humans, with thought and nuance. 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. +There are #llm-chat and #llm-share channels on [our discord](https://discord.com/invite/HGEdXmRkzT). Please do not discuss or share LLM-related things outside of those channels. ## Creating and sharing a new project using strudel Strudel is free/open source software, and we are also happy to see people making use of it within the following terms. -Please don't use 'strudel' in the name of your project, so people know it's not an official strudel project. (If you'd like it to be an official strudel project, please check in with the community, e.g. on the discord.) +Please don't use 'strudel' in the name of your project, so people know it's not an official strudel project. (If you'd like it to be an official strudel project, please check in with the community, e.g. on [the discord](https://discord.com/invite/HGEdXmRkzT).) Please respect our AGPL license, which e.g. requires you to share/link to the source code of strudel, any modifications you've made to it, and the source code for the rest of your project if it integrates with strudel. You are also required to maintain Strudel's copyright notices in the source code, and include Strudel's copyright notice in your user interface. This is an ad-hoc summary - please [refer to the license](https://codeberg.org/uzu/strudel/src/branch/main/LICENSE) for full details. diff --git a/website/src/pages/learn/faq.mdx b/website/src/pages/learn/faq.mdx index fc3bcdb2d..c59efe11d 100644 --- a/website/src/pages/learn/faq.mdx +++ b/website/src/pages/learn/faq.mdx @@ -52,6 +52,15 @@ There are multiple ways to load your sample collection. Some methods are good fo - Serve a folder of samples locally using the [strudel 'sampler' commandline tool](https://strudel.cc/learn/samples/#from-disk-via-strudelsampler). This can be most reliable method, but requires [nodejs](https://nodejs.org) to be installed. - Host your sound library online on the web and [load them from an URL](/learn/samples/#loading-custom-samples) +## Can I create a new project based on Strudel? + +Strudel is free/open source software, and we are always happy to see people making use of it within the following terms: + +* Please don't use 'strudel' in the name of your project (e.g. strudel2000, foo-strudel), so people know it's not an official strudel project. (If you'd like it to be an official strudel project, please check in with the community, e.g. on the [discord chat](https://discord.com/invite/HGEdXmRkzT).) +* Please respect our AGPL license, which e.g. requires you to share/link to the source code of strudel, any modifications you've made to it, and the source code for the rest of your project if it integrates with strudel. You are also required to maintain Strudel's copyright notices in the source code, and include Strudel's copyright notice in your user interface. This is an ad-hoc summary - please [refer to the license](https://codeberg.org/uzu/strudel/src/branch/main/LICENSE) for full details. + +You are also encouraged to connect with [the community](https://discord.com/invite/HGEdXmRkzT) to understand our aims and values. + ## Can I use Strudel with AI/LLM tools? You are free to do what you like with Strudel, within the terms of the free/open source AGPLv3 license. From 8e187abf24fc2543c8a7565cfcacacbb2bdcb8d3 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 29 Jul 2026 10:48:11 +0100 Subject: [PATCH 24/42] add copyright notice --- website/src/repl/components/panel/WelcomeTab.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/repl/components/panel/WelcomeTab.jsx b/website/src/repl/components/panel/WelcomeTab.jsx index 9d78edf85..ad86a5ceb 100644 --- a/website/src/repl/components/panel/WelcomeTab.jsx +++ b/website/src/repl/components/panel/WelcomeTab.jsx @@ -36,8 +36,8 @@ export function WelcomeTab({ context }) { tidalcycles - , which is a popular live coding language for music, written in Haskell. Strudel is free/open source software: - you can redistribute and/or modify it under the terms of the{' '} + , which is a popular live coding language for music, written in Haskell. Strudel is free/open source software, with copyright owned by its [contributors](https://codeberg.org/uzu/strudel/activity/contributors). + You can redistribute and/or modify it under the terms of the{' '} GNU Affero General Public License From 890d69b23e7480922688c5e69a1ec94f518274a9 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 29 Jul 2026 12:25:30 +0100 Subject: [PATCH 25/42] tweaks from feedback --- CONTRIBUTING.md | 4 ++-- website/src/pages/learn/faq.mdx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03bcae675..5a70ce2db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,11 +58,11 @@ There are #llm-chat and #llm-share channels on [our discord](https://discord.com Strudel is free/open source software, and we are also happy to see people making use of it within the following terms. -Please don't use 'strudel' in the name of your project, so people know it's not an official strudel project. (If you'd like it to be an official strudel project, please check in with the community, e.g. on [the discord](https://discord.com/invite/HGEdXmRkzT).) +Please don't use 'strudel' in the name of your project, so people don't assume it's official strudel project. (If you'd like it to be an official strudel project, please check in with the community, e.g. on [the discord](https://discord.com/invite/HGEdXmRkzT).) Please respect our AGPL license, which e.g. requires you to share/link to the source code of strudel, any modifications you've made to it, and the source code for the rest of your project if it integrates with strudel. You are also required to maintain Strudel's copyright notices in the source code, and include Strudel's copyright notice in your user interface. This is an ad-hoc summary - please [refer to the license](https://codeberg.org/uzu/strudel/src/branch/main/LICENSE) for full details. -You are also encouraged to connect with the community and align with our aims and values. +You are also encouraged to connect with the community and understand our aims and values. ## Report a Bug diff --git a/website/src/pages/learn/faq.mdx b/website/src/pages/learn/faq.mdx index c59efe11d..cecabe063 100644 --- a/website/src/pages/learn/faq.mdx +++ b/website/src/pages/learn/faq.mdx @@ -56,7 +56,7 @@ There are multiple ways to load your sample collection. Some methods are good fo Strudel is free/open source software, and we are always happy to see people making use of it within the following terms: -* Please don't use 'strudel' in the name of your project (e.g. strudel2000, foo-strudel), so people know it's not an official strudel project. (If you'd like it to be an official strudel project, please check in with the community, e.g. on the [discord chat](https://discord.com/invite/HGEdXmRkzT).) +* Please don't use 'strudel' in the name of your project (e.g. strudel2000, foo-strudel), so people don't assume it's official strudel project. (If you'd like it to be an official strudel project, please check in with the community, e.g. on the [discord chat](https://discord.com/invite/HGEdXmRkzT).) * Please respect our AGPL license, which e.g. requires you to share/link to the source code of strudel, any modifications you've made to it, and the source code for the rest of your project if it integrates with strudel. You are also required to maintain Strudel's copyright notices in the source code, and include Strudel's copyright notice in your user interface. This is an ad-hoc summary - please [refer to the license](https://codeberg.org/uzu/strudel/src/branch/main/LICENSE) for full details. You are also encouraged to connect with [the community](https://discord.com/invite/HGEdXmRkzT) to understand our aims and values. From a866cb218977be8fe86d44e673da95587c15b8d4 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 29 Jul 2026 15:06:56 +0100 Subject: [PATCH 26/42] format --- website/src/pages/learn/faq.mdx | 4 ++-- website/src/repl/components/panel/WelcomeTab.jsx | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/website/src/pages/learn/faq.mdx b/website/src/pages/learn/faq.mdx index cecabe063..3f9dc730b 100644 --- a/website/src/pages/learn/faq.mdx +++ b/website/src/pages/learn/faq.mdx @@ -56,8 +56,8 @@ There are multiple ways to load your sample collection. Some methods are good fo Strudel is free/open source software, and we are always happy to see people making use of it within the following terms: -* Please don't use 'strudel' in the name of your project (e.g. strudel2000, foo-strudel), so people don't assume it's official strudel project. (If you'd like it to be an official strudel project, please check in with the community, e.g. on the [discord chat](https://discord.com/invite/HGEdXmRkzT).) -* Please respect our AGPL license, which e.g. requires you to share/link to the source code of strudel, any modifications you've made to it, and the source code for the rest of your project if it integrates with strudel. You are also required to maintain Strudel's copyright notices in the source code, and include Strudel's copyright notice in your user interface. This is an ad-hoc summary - please [refer to the license](https://codeberg.org/uzu/strudel/src/branch/main/LICENSE) for full details. +- Please don't use 'strudel' in the name of your project (e.g. strudel2000, foo-strudel), so people don't assume it's official strudel project. (If you'd like it to be an official strudel project, please check in with the community, e.g. on the [discord chat](https://discord.com/invite/HGEdXmRkzT).) +- Please respect our AGPL license, which e.g. requires you to share/link to the source code of strudel, any modifications you've made to it, and the source code for the rest of your project if it integrates with strudel. You are also required to maintain Strudel's copyright notices in the source code, and include Strudel's copyright notice in your user interface. This is an ad-hoc summary - please [refer to the license](https://codeberg.org/uzu/strudel/src/branch/main/LICENSE) for full details. You are also encouraged to connect with [the community](https://discord.com/invite/HGEdXmRkzT) to understand our aims and values. diff --git a/website/src/repl/components/panel/WelcomeTab.jsx b/website/src/repl/components/panel/WelcomeTab.jsx index ad86a5ceb..55416eaab 100644 --- a/website/src/repl/components/panel/WelcomeTab.jsx +++ b/website/src/repl/components/panel/WelcomeTab.jsx @@ -36,8 +36,9 @@ export function WelcomeTab({ context }) { tidalcycles - , which is a popular live coding language for music, written in Haskell. Strudel is free/open source software, with copyright owned by its [contributors](https://codeberg.org/uzu/strudel/activity/contributors). - You can redistribute and/or modify it under the terms of the{' '} + , which is a popular live coding language for music, written in Haskell. Strudel is free/open source software, + with copyright owned by its [contributors](https://codeberg.org/uzu/strudel/activity/contributors). You can + redistribute and/or modify it under the terms of the{' '} GNU Affero General Public License From 4484ee4e8003fb89e2a39b5f81de8f454811fe8e Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Tue, 4 Aug 2026 16:52:47 +0200 Subject: [PATCH 27/42] Fix saw and isaw returning negative numbers on negative t --- packages/core/signal.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index 4398e2ab7..35fccb8f1 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -6,6 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th import { Hap } from './hap.mjs'; import { Pattern, fastcat, pure, register, reify, silence, stack, sequenceP } from './pattern.mjs'; +import { _mod } from './util.mjs'; import Fraction from './fraction.mjs'; import { id, keyAlias, getCurrentKeyboardState } from './util.mjs'; @@ -33,7 +34,7 @@ export const signal = (func) => { * .scale('C major') * */ -export const saw = signal((t) => t % 1); +export const saw = signal((t) => _mod(t, 1)); /** * A sawtooth signal between -1 and 1 (like `saw`, but bipolar). @@ -56,7 +57,7 @@ export const saw2 = saw.toBipolar(); * .scale('C major') * */ -export const isaw = signal((t) => 1 - (t % 1)); +export const isaw = signal((t) => 1 - _mod(t, 1)); /** * A sawtooth signal between 1 and -1 (like `saw2`, but flipped). From 04232370c27fe8e1b0c6ebbdf57e31195ab4ddf0 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Tue, 4 Aug 2026 17:11:52 +0200 Subject: [PATCH 28/42] Apply the same fix to square and randrun --- packages/core/signal.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index 35fccb8f1..7fef18762 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -114,7 +114,7 @@ export const cosine2 = sine2._early(Fraction(1).div(4)); * n(square.segment(4).range(0,7)).scale("C:minor") * */ -export const square = signal((t) => Math.floor((t * 2) % 2)); +export const square = signal((t) => Math.floor(_mod((t * 2), 2))); /** * A square signal between -1 and 1 (like `square`, but bipolar). @@ -397,7 +397,7 @@ export const randrun = (n) => { .map((n, i) => [n, i]) .sort((a, b) => (a[0] > b[0]) - (a[0] < b[0])) .map((x) => x[1]); - const i = t.cyclePos().mul(n).floor() % n; + const i = _mod(t.cyclePos().mul(n).floor(), n); return nums[i]; })._segment(n); }; From 429682735d97fdd5747f3f7b87a3001043458617 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Tue, 4 Aug 2026 17:25:45 +0200 Subject: [PATCH 29/42] Formatting --- packages/core/signal.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index 7fef18762..51e8b5a4e 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -114,7 +114,7 @@ export const cosine2 = sine2._early(Fraction(1).div(4)); * n(square.segment(4).range(0,7)).scale("C:minor") * */ -export const square = signal((t) => Math.floor(_mod((t * 2), 2))); +export const square = signal((t) => Math.floor(_mod(t * 2, 2))); /** * A square signal between -1 and 1 (like `square`, but bipolar). From eafa2f17ecf716600b232a64130c6b62624f801f Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Tue, 4 Aug 2026 18:03:12 +0200 Subject: [PATCH 30/42] Fix the same modulo bug in arp and slowcatPrime --- packages/core/pattern.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index aa5e1060f..f61c90452 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -1002,7 +1002,7 @@ export const arpWith = register('arpWith', (func, pat) => { * */ export const arp = register( 'arp', - (indices, pat) => pat.arpWith((haps) => reify(indices).fmap((i) => haps[i % haps.length])), + (indices, pat) => pat.arpWith((haps) => reify(indices).fmap((i) => haps[_mod(i, haps.length)])), false, ); @@ -1559,7 +1559,7 @@ export function slowcat(...pats) { export function slowcatPrime(...pats) { pats = pats.map(reify); const query = function (state) { - const pat_n = Math.floor(state.span.begin) % pats.length; + const pat_n = _mod(Math.floor(state.span.begin), pats.length); const pat = pats[pat_n]; // can be undefined for same cases e.g. /#cHVyZSg0MikKICAuZXZlcnkoMyxhZGQoNykpCiAgLmxhdGUoLjUp return pat?.query(state) || []; }; From 6ebf50607017b5eace7100b406cc21aac8658530 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Tue, 4 Aug 2026 23:14:28 +0200 Subject: [PATCH 31/42] Fix node pool reusing incompatible audio nodes when audiocontext changes --- packages/superdough/audioContext.mjs | 8 ++++++-- packages/superdough/nodePools.mjs | 13 +++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/superdough/audioContext.mjs b/packages/superdough/audioContext.mjs index 94ec32d15..1c708e814 100644 --- a/packages/superdough/audioContext.mjs +++ b/packages/superdough/audioContext.mjs @@ -7,14 +7,18 @@ Copyright (C) 2025 Strudel contributors - see . */ +import { clearNodePool } from './nodePools.mjs'; + let audioContext; export const setDefaultAudioContext = () => { - audioContext = new AudioContext(); - return audioContext; + return setAudioContext(new AudioContext()); }; export const setAudioContext = (context) => { + // Existing nodes in the node pool contain references to the previous AudioContext, + // so all the nodes in the pool must be cleared when we set a new AudioContext. + clearNodePool(); audioContext = context; return audioContext; }; diff --git a/packages/superdough/nodePools.mjs b/packages/superdough/nodePools.mjs index 90b4c0ae7..66319e8c6 100644 --- a/packages/superdough/nodePools.mjs +++ b/packages/superdough/nodePools.mjs @@ -5,11 +5,24 @@ Copyright (C) 2025 Strudel contributors - see . */ +import { releaseAudioNode } from './helpers.mjs'; + const nodePools = new Map(); const POOL_KEY = Symbol('nodePoolKey'); export const isPoolable = (node) => !!node[POOL_KEY]; +export const clearNodePool = () => { + for (const pool of nodePools) { + for (const node of pool) { + if (node instanceof AudioNode) { + releaseAudioNode(node); + } + } + } + nodePools.clear(); +}; + const getNodeTime = (node) => { return node.context?.currentTime ?? 0; }; From 0a0afcf73fe74ca4600fd566180d1af165dec827 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Tue, 4 Aug 2026 23:29:01 +0200 Subject: [PATCH 32/42] Change renderPatternAudio so it renders the audio in chunks --- packages/webaudio/webaudio.mjs | 90 ++++++++++++++++++++++++---------- 1 file changed, 65 insertions(+), 25 deletions(-) diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index 5377013e2..c9bedc26d 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -56,32 +56,8 @@ export async function renderPatternAudio( maxPolyphony, multiChannelOrbits, }); - logger('[webaudio] preloading'); - // Calling superdough(...) in ascending onset time order is important - // for controls that depend on the audio graph state like `cut` - let haps = pattern - .queryArc(begin, end, { _cps: cps }) - .sort((a, b) => a.whole.begin.valueOf() - b.whole.begin.valueOf()); - for (const hap of haps) { - if (hap.hasOnset()) { - try { - await superdough( - hap2value(hap), - (hap.whole.begin.valueOf() - begin) / cps, - hap.duration / cps, - cps, - (hap.whole?.begin.valueOf() - begin) / cps, - ); - } catch (err) { - errorLogger(err, 'webaudio'); - } - } - } - logger('[webaudio] start rendering'); - - return audioContext - .startRendering() + return renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, 1) .then((renderedBuffer) => { const wavBuffer = audioBufferToWav(renderedBuffer); const blob = new Blob([wavBuffer], { type: 'audio/wav' }); @@ -102,6 +78,70 @@ export async function renderPatternAudio( }); } +async function renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, chunkSizeInCycles) { + let currentCycle = begin; + let renderPromise = null; + + logger('[webaudio] start rendering'); + + while (currentCycle <= end) { + const chunkStart = currentCycle; + const chunkEnd = Math.min(currentCycle + chunkSizeInCycles, end); + + logger(`[webaudio] preloading cycles ${chunkStart} - ${chunkEnd}`); + + // Calling superdough(...) in ascending onset time order is important + // for controls that depend on the audio graph state like `cut` + let haps = pattern + .queryArc(chunkStart, chunkEnd, { _cps: cps }) + .sort((a, b) => a.whole.begin.valueOf() - b.whole.begin.valueOf()); + + for (const hap of haps) { + if (hap.hasOnset()) { + try { + await superdough( + hap2value(hap), + (hap.whole.begin.valueOf() - begin) / cps, + hap.duration / cps, + cps, + (hap.whole?.begin.valueOf() - begin) / cps, + ); + } catch (err) { + errorLogger(err, 'webaudio'); + } + } + } + + logger(`[webaudio] rendering cycles ${chunkStart} - ${chunkEnd}`); + + currentCycle += chunkSizeInCycles; + + // According to the MDN docs, suspends should be scheduled while + // the audioContext is not currently running for better precision. + // So we schedule the suspend first, and await after resuming. + var suspendPromise; + if (currentCycle < end) { + // Make sure to suspend one cycle before the next currentCycle + // so the next haps can be scheduled on time. + suspendPromise = audioContext.suspend((currentCycle - begin - 1) / cps); + } + + if (renderPromise === null) { + renderPromise = audioContext.startRendering(); + } else { + await audioContext.resume(); + } + + if (currentCycle < end) { + await suspendPromise; + } + } + + logger('[webaudio] finish rendering'); + + return renderPromise; +} + export function webaudioRepl(options = {}) { const audioContext = options.audioContext ?? getAudioContext(); setAudioContext(audioContext); From 9bf3249e82c37640a76683108e9c47edc2a42c80 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Wed, 5 Aug 2026 00:25:08 +0200 Subject: [PATCH 33/42] slowcatPrime pat can no longer be undefined --- packages/core/pattern.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index f61c90452..300544b29 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -1557,11 +1557,14 @@ export function slowcat(...pats) { * @return {Pattern} */ export function slowcatPrime(...pats) { + if (!pats.length) { + return silence; + } pats = pats.map(reify); const query = function (state) { const pat_n = _mod(Math.floor(state.span.begin), pats.length); - const pat = pats[pat_n]; // can be undefined for same cases e.g. /#cHVyZSg0MikKICAuZXZlcnkoMyxhZGQoNykpCiAgLmxhdGUoLjUp - return pat?.query(state) || []; + const pat = pats[pat_n]; + return pat.query(state); }; return new Pattern(query).splitQueries(); } From f4b3fdaacdc689eba0494c1deb7828dfbc7ec8fc Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Wed, 5 Aug 2026 00:39:38 +0200 Subject: [PATCH 34/42] Update juxUndTollerei 1 tune snapshot --- test/__snapshots__/tunes.test.mjs.snap | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/__snapshots__/tunes.test.mjs.snap b/test/__snapshots__/tunes.test.mjs.snap index fc77616c4..1fb178801 100644 --- a/test/__snapshots__/tunes.test.mjs.snap +++ b/test/__snapshots__/tunes.test.mjs.snap @@ -7203,8 +7203,12 @@ exports[`renders tunes > tune: holyflute 1`] = ` exports[`renders tunes > tune: juxUndTollerei 1`] = ` [ + "[ -99/200 ⇜ (0/1 → 1/200) | note:63 s:triangle pan:0 cutoff:758.852817928549 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", + "[ -99/200 ⇜ (0/1 → 1/200) | note:67 s:triangle pan:1 color:green cutoff:758.852817928549 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 0/1 → 1/4 | note:c3 s:sawtooth pan:0 cutoff:1100 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 0/1 → 1/4 | note:bb3 s:sawtooth pan:1 color:green cutoff:1100 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", + "[ 1/200 → 101/200 | note:55 s:triangle pan:0 cutoff:1103.534282651425 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", + "[ 1/200 → 101/200 | note:65 s:triangle pan:1 color:green cutoff:1103.534282651425 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 1/4 → 1/2 | note:eb3 s:sawtooth pan:0 cutoff:1275.581289814515 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 1/4 → 1/2 | note:g3 s:sawtooth pan:1 color:green cutoff:1275.581289814515 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", "[ 1/2 → 3/4 | note:g3 s:sawtooth pan:0 cutoff:1444.415089128581 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]", From 6c8b707d2ee3a870d19f61161cb394c0ba61fa4e Mon Sep 17 00:00:00 2001 From: wjt Date: Mon, 10 Aug 2026 17:01:43 +0200 Subject: [PATCH 35/42] learn/lfx: Add missing word --- website/src/pages/learn/lfo.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/pages/learn/lfo.mdx b/website/src/pages/learn/lfo.mdx index 2a97d9970..39fca67cf 100644 --- a/website/src/pages/learn/lfo.mdx +++ b/website/src/pages/learn/lfo.mdx @@ -45,7 +45,7 @@ Here, the LFO will modulate the low pass filter `.lpf`. ## Moving away from the default -The following sections explain how pass parameters to `.lfo`. Similar to `._spectrum` above, almost all the configuration of `lfo` lives inside a json object, starting with `{` and ending with `}`. +The following sections explain how to pass parameters to `.lfo`. Similar to `._spectrum` above, almost all the configuration of `lfo` lives inside a json object, starting with `{` and ending with `}`. All the parameters (except `id`) are written as `key: value` inside and separated by `,`. The reference refers to them as `config.key`, i.e. for the following one as `config.control` but you use them like below. From bd54c8003a9287af513dbad3243c5f2b9e8c2dd8 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Thu, 13 Aug 2026 01:00:33 +0200 Subject: [PATCH 36/42] Introduce isquare and isquare2 for signal parity --- packages/core/signal.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index 51e8b5a4e..b86f16e8a 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -124,6 +124,22 @@ export const square = signal((t) => Math.floor(_mod(t * 2, 2))); */ export const square2 = square.toBipolar(); +/** + * A square signal between 1 and 0 (like `square` but flipped). + * + * @return {Pattern} + * @tags generators + */ +export const isquare = signal((t) => 1 - Math.floor(_mod(t * 2, 2))); + +/** + * A square signal between 1 and -1 (like `isquare`, but bipolar). + * + * @return {Pattern} + * @tags generators + */ +export const isquare2 = isquare.toBipolar(); + /** * A triangle signal between 0 and 1. * From 08dc5e2c5cb37cf764931344fe5bc12510dcd1e7 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Thu, 13 Aug 2026 01:03:14 +0200 Subject: [PATCH 37/42] Add tests for all signals using modulo, including negative time --- packages/core/test/signal.test.mjs | 72 +++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/core/test/signal.test.mjs b/packages/core/test/signal.test.mjs index 26c1d656a..628864c79 100644 --- a/packages/core/test/signal.test.mjs +++ b/packages/core/test/signal.test.mjs @@ -8,7 +8,23 @@ import Fraction from 'fraction.js'; import { describe, it, expect, vi } from 'vitest'; -import { saw, saw2, isaw, isaw2, per, perx, cyclesPer } from '../signal.mjs'; +import { + saw, + saw2, + isaw, + isaw2, + tri, + tri2, + itri, + itri2, + square, + square2, + isquare, + isquare2, + per, + perx, + cyclesPer, +} from '../signal.mjs'; import { fastcat, sequence, State, TimeSpan, Hap, note } from '../index.mjs'; const st = (begin, end) => new State(ts(begin, end)); @@ -24,17 +40,61 @@ const sameFirst = (a, b) => { describe('signal()', () => { it('Can make saw/saw2', () => { - expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual( - sequence(0, 1 / 4, 1 / 2, 3 / 4).firstCycle(), - ); - + expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0.25, 0.5, 0.75).firstCycle()); expect(saw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -0.5, 0, 0.5).firstCycle()); }); it('Can make isaw/isaw2', () => { expect(isaw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.75, 0.5, 0.25).firstCycle()); - expect(isaw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, -0.5).firstCycle()); }); + it('Can make tri/tri2', () => { + expect(tri.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0.5, 1, 0.5).firstCycle()); + expect(tri2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, 0, 1, 0).firstCycle()); + }); + it('Can make itri/itri2', () => { + expect(itri.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, 0.5).firstCycle()); + expect(itri2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0, -1, 0).firstCycle()); + }); + it('Can make square/square2', () => { + expect(square.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0, 1, 1).firstCycle()); + expect(square2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -1, 1, 1).firstCycle()); + }); + it('Can make isquare/isquare2', () => { + expect(isquare.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 1, 0, 0).firstCycle()); + expect(isquare2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 1, -1, -1).firstCycle()); + }); + it('Can go into negative time', () => { + expect(saw.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(0, 0.25, 0.5, 0.75).firstCycle(), + ); + expect(saw2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(-1, -0.5, 0, 0.5).firstCycle(), + ); + expect(isaw.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(1, 0.75, 0.5, 0.25).firstCycle(), + ); + expect(isaw2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(1, 0.5, 0, -0.5).firstCycle(), + ); + expect(tri.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(0, 0.5, 1, 0.5).firstCycle(), + ); + expect(tri2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, 0, 1, 0).firstCycle()); + expect(itri.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(1, 0.5, 0, 0.5).firstCycle(), + ); + expect(itri2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0, -1, 0).firstCycle()); + expect(square.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0, 1, 1).firstCycle()); + expect(square2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(-1, -1, 1, 1).firstCycle(), + ); + expect(isquare.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(1, 1, 0, 0).firstCycle(), + ); + expect(isquare2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual( + sequence(1, 1, -1, -1).firstCycle(), + ); + }); }); describe('cyclesPer', () => { From 76fa8264cdc2f2ffa7e9912e5d7fa600846b5d95 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Thu, 13 Aug 2026 01:04:25 +0200 Subject: [PATCH 38/42] Remove old check in slowcat and add empty check at the start --- packages/core/pattern.mjs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 300544b29..0f5083901 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -1529,7 +1529,9 @@ export function slowcat(...pats) { // Array test here is to avoid infinite recursions.. pats = pats.map((pat) => (Array.isArray(pat) ? fastcat(...pat) : reify(pat))); - if (pats.length == 1) { + if (!pats.length) { + return silence; + } else if (pats.length == 1) { return pats[0]; } @@ -1537,10 +1539,6 @@ export function slowcat(...pats) { const span = state.span; const pat_n = _mod(span.begin.sam(), pats.length); const pat = pats[pat_n]; - if (!pat) { - // pat_n can be negative, if the span is in the past.. - return []; - } // A bit of maths to make sure that cycles from constituent patterns aren't skipped. // For example if three patterns are slowcat-ed, the fourth cycle of the result should // be the second (rather than fourth) cycle from the first pattern. From 07f01c237f71b87eec5514d5a8743a32604f1518 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Thu, 13 Aug 2026 01:04:48 +0200 Subject: [PATCH 39/42] Add tests for slowcat, slowcatPrime and arp --- packages/core/test/pattern.test.mjs | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/core/test/pattern.test.mjs b/packages/core/test/pattern.test.mjs index aaa14bd27..cb5345610 100644 --- a/packages/core/test/pattern.test.mjs +++ b/packages/core/test/pattern.test.mjs @@ -18,6 +18,7 @@ import { fastcat, firstOf, slowcat, + slowcatPrime, cat, sequence, palindrome, @@ -53,6 +54,7 @@ import { stepcat, sometimes, expand, + arp, } from '../index.mjs'; import { log, logValues } from '../pattern.mjs'; @@ -544,6 +546,9 @@ describe('Pattern', () => { }); }); describe('slowcat()', () => { + it('Can be empty', () => { + expect(slowcat().firstCycle()).toStrictEqual([]); + }); it('Can concatenate things slowly', () => { expect( slowcat('a', 'b') @@ -576,6 +581,38 @@ describe('Pattern', () => { sameFirst(slowcat('a', ['b', 'c']).fast(4), sequence('a', ['b', 'c']).fast(2)); }); }); + describe('slowcatPrime()', () => { + it('Can be empty', () => { + expect(slowcatPrime().firstCycle()).toStrictEqual([]); + }); + it('Can slowcat patterns swapping back and forth skipping the expected notes', () => { + expect( + slowcatPrime(fastcat(0, 1, 2, 3).slow(2), fastcat(4, 5, 6, 7).slow(2)) + .fast(4) + .firstCycle() + .map((a) => a.value), + ).toStrictEqual([0, 1, 6, 7, 0, 1, 6, 7]); + }); + it('Can go into negative time', () => { + expect( + slowcatPrime(fastcat(0, 1, 2, 3).slow(2), fastcat(4, 5, 6, 7).slow(2)) + .fast(4) + .late(8) + .firstCycle() + .map((a) => a.value), + ).toStrictEqual([0, 1, 6, 7, 0, 1, 6, 7]); + }); + }); + describe('arp()', () => { + it('It wraps around with both positive and negative indices', () => { + expect( + stack('a', 'b', 'c') + .arp(fastcat(-3, -2, -1, 0, 1, 2, 3, 4)) + .firstCycle() + .map((a) => a.value), + ).toStrictEqual(['a', 'b', 'c', 'a', 'b', 'c', 'a', 'b']); + }); + }); describe('rev()', () => { it('Can reverse things', () => { expect( From 20d925c10c6577a2df39ec6e772b93001a80fc02 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Thu, 13 Aug 2026 04:13:21 +0200 Subject: [PATCH 40/42] Add tests for randrun and negative time test for run --- packages/core/test/pattern.test.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/core/test/pattern.test.mjs b/packages/core/test/pattern.test.mjs index cb5345610..4eb18c647 100644 --- a/packages/core/test/pattern.test.mjs +++ b/packages/core/test/pattern.test.mjs @@ -46,6 +46,7 @@ import { rev, time, run, + randrun, binaryN, pick, stackLeft, @@ -982,6 +983,17 @@ describe('Pattern', () => { it('Can run', () => { expect(run(4).firstCycle()).toStrictEqual(sequence(0, 1, 2, 3).firstCycle()); }); + it('Can go into negative time', () => { + expect(run(4).late(1).firstCycle()).toStrictEqual(sequence(0, 1, 2, 3).firstCycle()); + }); + }); + describe('randrun', () => { + it('Can randrun', () => { + expect(randrun(4).firstCycle()).toStrictEqual(sequence(2, 1, 3, 0).firstCycle()); + }); + it('Can go into negative time', () => { + expect(randrun(4).late(1).firstCycle()).toStrictEqual(sequence(1, 2, 0, 3).firstCycle()); + }); }); describe('binaryN', () => { it('Can make a binary pattern from a decimal', () => { From 75f58bc875fb7f8d8ed555543c94e89e78f6e72c Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Thu, 13 Aug 2026 19:24:53 +0200 Subject: [PATCH 41/42] Fix wav export on firefox --- packages/webaudio/webaudio.mjs | 64 ++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index c9bedc26d..54ee9bcb3 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -57,7 +57,13 @@ export async function renderPatternAudio( multiChannelOrbits, }); - return renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, 1) + // Firefox currently doesn't support suspending an OfflineAudioContext, + // so no chunked rendering. Bad performance, but at least it works. + return ( + audioContext.suspend === undefined + ? renderPatternAudioWhole(audioContext, pattern, cps, begin, end) + : renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, 1) + ) .then((renderedBuffer) => { const wavBuffer = audioBufferToWav(renderedBuffer); const blob = new Blob([wavBuffer], { type: 'audio/wav' }); @@ -78,6 +84,16 @@ export async function renderPatternAudio( }); } +async function renderPatternAudioWhole(audioContext, pattern, cps, begin, end) { + logger(`[webaudio] preloading`); + + await scheduleHapsChunk(pattern, cps, begin, begin, end); + + logger('[webaudio] start rendering'); + + return audioContext.startRendering(); +} + async function renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, chunkSizeInCycles) { let currentCycle = begin; let renderPromise = null; @@ -90,27 +106,7 @@ async function renderPatternAudioInChunks(audioContext, pattern, cps, begin, end logger(`[webaudio] preloading cycles ${chunkStart} - ${chunkEnd}`); - // Calling superdough(...) in ascending onset time order is important - // for controls that depend on the audio graph state like `cut` - let haps = pattern - .queryArc(chunkStart, chunkEnd, { _cps: cps }) - .sort((a, b) => a.whole.begin.valueOf() - b.whole.begin.valueOf()); - - for (const hap of haps) { - if (hap.hasOnset()) { - try { - await superdough( - hap2value(hap), - (hap.whole.begin.valueOf() - begin) / cps, - hap.duration / cps, - cps, - (hap.whole?.begin.valueOf() - begin) / cps, - ); - } catch (err) { - errorLogger(err, 'webaudio'); - } - } - } + await scheduleHapsChunk(pattern, cps, begin, chunkStart, chunkEnd); logger(`[webaudio] rendering cycles ${chunkStart} - ${chunkEnd}`); @@ -142,6 +138,30 @@ async function renderPatternAudioInChunks(audioContext, pattern, cps, begin, end return renderPromise; } +async function scheduleHapsChunk(pattern, cps, begin, chunkStart, chunkEnd) { + // Calling superdough(...) in ascending onset time order is important + // for controls that depend on the audio graph state like `cut` + let haps = pattern + .queryArc(chunkStart, chunkEnd, { _cps: cps }) + .sort((a, b) => a.whole.begin.valueOf() - b.whole.begin.valueOf()); + + for (const hap of haps) { + if (hap.hasOnset()) { + try { + await superdough( + hap2value(hap), + (hap.whole.begin.valueOf() - begin) / cps, + hap.duration / cps, + cps, + (hap.whole?.begin.valueOf() - begin) / cps, + ); + } catch (err) { + errorLogger(err, 'webaudio'); + } + } + } +} + export function webaudioRepl(options = {}) { const audioContext = options.audioContext ?? getAudioContext(); setAudioContext(audioContext); From c5b42b55557a812922ab0ac7ab9b54beecd9c661 Mon Sep 17 00:00:00 2001 From: Tijmen Zwaan Date: Thu, 13 Aug 2026 19:26:52 +0200 Subject: [PATCH 42/42] Fix webAudioTimeout connecting the wrong AudioContext after export --- packages/superdough/audioContext.mjs | 3 +++ packages/superdough/helpers.mjs | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/superdough/audioContext.mjs b/packages/superdough/audioContext.mjs index 1c708e814..6a701d9ad 100644 --- a/packages/superdough/audioContext.mjs +++ b/packages/superdough/audioContext.mjs @@ -19,6 +19,9 @@ export const setAudioContext = (context) => { // Existing nodes in the node pool contain references to the previous AudioContext, // so all the nodes in the pool must be cleared when we set a new AudioContext. clearNodePool(); + if (audioContext && audioContext.state !== 'closed') { + audioContext.close(); + } audioContext = context; return audioContext; }; diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 07199aff6..7431bf530 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -6,8 +6,8 @@ import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs'; export const noises = ['pink', 'white', 'brown', 'crackle']; -export function gainNode(value) { - const node = getAudioContext().createGain(); +export function gainNode(value, audioContext = getAudioContext()) { + const node = audioContext.createGain(); node.gain.value = value; return node; } @@ -374,7 +374,7 @@ export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) { // Certain browsers requires audio nodes to be connected in order for their onended events // to fire, so we _mute it_ and then connect it to the destination - const zeroGain = gainNode(0); + const zeroGain = gainNode(0, audioContext); zeroGain.connect(audioContext.destination); constantNode.connect(zeroGain);