From e2cdce5ac9454f9c242fcb0e410a6888c89c496f Mon Sep 17 00:00:00 2001
From: "Lu[ke] Wilson"
Date: Fri, 29 Aug 2025 15:35:41 +0100
Subject: [PATCH 01/91] add flux function
---
packages/core/pattern.mjs | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs
index f1ebbe3e9..154643c74 100644
--- a/packages/core/pattern.mjs
+++ b/packages/core/pattern.mjs
@@ -2337,6 +2337,17 @@ export const { juxBy, juxby } = register(['juxBy', 'juxby'], function (by, func,
return stack(left, right).setSteps(__steps ? lcm(left._steps, right._steps) : undefined);
});
+/**
+ * Like juxBy, except it flips the ears each cycle.
+ * @name fluxBy
+ * @synonyms fluxby
+ * @example
+ * s("bd lt [~ ht] mt cp ~ bd hh").fluxBy(".8", rev)
+ */
+export const { fluxBy, fluxby } = register(['fluxBy', 'fluxby'], function (by, func, pat) {
+ return slowcat(pat._juxBy(by, func, pat), pat._juxBy(-by, func, pat));
+});
+
/**
* The jux function creates strange stereo effects, by applying a function to a pattern, but only in the right-hand channel.
* @example
@@ -2350,6 +2361,19 @@ export const jux = register('jux', function (func, pat) {
return pat._juxBy(1, func, pat);
});
+/**
+ * Like jux, but flips the ears each cycle.
+ * @example
+ * s("bd lt [~ ht] mt cp ~ bd hh").flux(rev)
+ * @example
+ * s("bd lt [~ ht] mt cp ~ bd hh").flux(press)
+ * @example
+ * s("bd lt [~ ht] mt cp ~ bd hh").flux(iter(4))
+ */
+export const flux = register('flux', function (func, pat) {
+ return pat._fluxBy(1, func, pat);
+});
+
/**
* Superimpose and offset multiple times, applying the given function each time.
* @name echoWith
From 781c477078f1429a7dabe4e628fa287a3bf7ef5f Mon Sep 17 00:00:00 2001
From: "Lu[ke] Wilson"
Date: Fri, 29 Aug 2025 17:20:18 +0100
Subject: [PATCH 02/91] rename to juxFlip
---
packages/core/pattern.mjs | 27 ++++++++++++++++-----------
1 file changed, 16 insertions(+), 11 deletions(-)
diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs
index 154643c74..c68d45104 100644
--- a/packages/core/pattern.mjs
+++ b/packages/core/pattern.mjs
@@ -2339,14 +2339,17 @@ export const { juxBy, juxby } = register(['juxBy', 'juxby'], function (by, func,
/**
* Like juxBy, except it flips the ears each cycle.
- * @name fluxBy
- * @synonyms fluxby
+ * @name juxFlipBy
+ * @synonyms juxflipby, fluxBy, fluxby
* @example
- * s("bd lt [~ ht] mt cp ~ bd hh").fluxBy(".8", rev)
+ * s("bd lt [~ ht] mt cp ~ bd hh").juxFlipBy(".8", rev)
*/
-export const { fluxBy, fluxby } = register(['fluxBy', 'fluxby'], function (by, func, pat) {
- return slowcat(pat._juxBy(by, func, pat), pat._juxBy(-by, func, pat));
-});
+export const { juxFlipBy, juxflipby, fluxBy, fluxby } = register(
+ ['juxFlipBy', 'juxflipby', 'fluxBy', 'fluxby'],
+ function (by, func, pat) {
+ return slowcat(pat._juxBy(by, func, pat), pat._juxBy(-by, func, pat));
+ },
+);
/**
* The jux function creates strange stereo effects, by applying a function to a pattern, but only in the right-hand channel.
@@ -2363,15 +2366,17 @@ export const jux = register('jux', function (func, pat) {
/**
* Like jux, but flips the ears each cycle.
+ * @name juxFlip
+ * @synonyms flux
* @example
- * s("bd lt [~ ht] mt cp ~ bd hh").flux(rev)
+ * s("bd lt [~ ht] mt cp ~ bd hh").juxFlip(rev)
* @example
- * s("bd lt [~ ht] mt cp ~ bd hh").flux(press)
+ * s("bd lt [~ ht] mt cp ~ bd hh").juxFlip(press)
* @example
- * s("bd lt [~ ht] mt cp ~ bd hh").flux(iter(4))
+ * s("bd lt [~ ht] mt cp ~ bd hh").juxFlip(iter(4))
*/
-export const flux = register('flux', function (func, pat) {
- return pat._fluxBy(1, func, pat);
+export const { juxFlip, flux } = register(['juxFlip', 'flux'], function (func, pat) {
+ return pat._jux(1, func, pat);
});
/**
From 3373fa39a6be2d2d3765dcbd52b63c30e4f3b10a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Va=CC=81clav=20Volhejn?=
Date: Sat, 18 Oct 2025 12:14:39 +0200
Subject: [PATCH 03/91] Fix the documentation of `stretch`
---
packages/core/controls.mjs | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs
index 7e3c2a8e2..130608447 100644
--- a/packages/core/controls.mjs
+++ b/packages/core/controls.mjs
@@ -1935,12 +1935,16 @@ export const { compressorRelease } = registerControl('compressorRelease');
export const { speed } = registerControl('speed');
/**
- * Changes the speed of sample playback, i.e. a cheap way of changing pitch.
+ * Changes the pitch of the sample without changing its speed.
+ * The frequencies are multiplied by (factor + 1) for positive numbers
+ * and by max(factor / 4 + 1, 0) for negative numbers.
+ * So tuning up by octaves can be done with 1, 3, 7, ...
+ * and tuning down by octaves with -2, -3, -3.5...
*
* @name stretch
- * @param {number | Pattern} factor -inf to inf, negative numbers play the sample backwards.
+ * @param {number | Pattern} factor between `-4` and `inf`. Positive increases pitch, 0 does nothing, negative decreases the pitch.
* @example
- * s("gm_flute").stretch("1 2 .5")
+ * s("gm_flute").stretch("<3 2 1 0 -2>")
*
*/
export const { stretch } = registerControl('stretch');
From 7d948fd1a76cb92d0da8a29a8710a977fa010083 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Va=CC=81clav=20Volhejn?=
Date: Sat, 18 Oct 2025 12:25:15 +0200
Subject: [PATCH 04/91] Fix example snapshot
---
packages/core/controls.mjs | 2 +-
test/__snapshots__/examples.test.mjs.snap | 16 ++++------------
2 files changed, 5 insertions(+), 13 deletions(-)
diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs
index 130608447..03de38666 100644
--- a/packages/core/controls.mjs
+++ b/packages/core/controls.mjs
@@ -1944,7 +1944,7 @@ export const { speed } = registerControl('speed');
* @name stretch
* @param {number | Pattern} factor between `-4` and `inf`. Positive increases pitch, 0 does nothing, negative decreases the pitch.
* @example
- * s("gm_flute").stretch("<3 2 1 0 -2>")
+ * s("gm_flute").stretch("<2 1 0 -2>")
*
*/
export const { stretch } = registerControl('stretch');
diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap
index 73278947d..a23806c1c 100644
--- a/test/__snapshots__/examples.test.mjs.snap
+++ b/test/__snapshots__/examples.test.mjs.snap
@@ -10439,18 +10439,10 @@ exports[`runs examples > example "stepcat" example index 1 1`] = `
exports[`runs examples > example "stretch" example index 0 1`] = `
[
- "[ (0/1 → 1/3) ⇝ 1/1 | s:gm_flute stretch:1 ]",
- "[ 0/1 ⇜ (1/3 → 2/3) ⇝ 1/1 | s:gm_flute stretch:2 ]",
- "[ 0/1 ⇜ (2/3 → 1/1) | s:gm_flute stretch:0.5 ]",
- "[ (1/1 → 4/3) ⇝ 2/1 | s:gm_flute stretch:1 ]",
- "[ 1/1 ⇜ (4/3 → 5/3) ⇝ 2/1 | s:gm_flute stretch:2 ]",
- "[ 1/1 ⇜ (5/3 → 2/1) | s:gm_flute stretch:0.5 ]",
- "[ (2/1 → 7/3) ⇝ 3/1 | s:gm_flute stretch:1 ]",
- "[ 2/1 ⇜ (7/3 → 8/3) ⇝ 3/1 | s:gm_flute stretch:2 ]",
- "[ 2/1 ⇜ (8/3 → 3/1) | s:gm_flute stretch:0.5 ]",
- "[ (3/1 → 10/3) ⇝ 4/1 | s:gm_flute stretch:1 ]",
- "[ 3/1 ⇜ (10/3 → 11/3) ⇝ 4/1 | s:gm_flute stretch:2 ]",
- "[ 3/1 ⇜ (11/3 → 4/1) | s:gm_flute stretch:0.5 ]",
+ "[ 0/1 → 1/1 | s:gm_flute stretch:3 ]",
+ "[ 1/1 → 2/1 | s:gm_flute stretch:2 ]",
+ "[ 2/1 → 3/1 | s:gm_flute stretch:1 ]",
+ "[ 3/1 → 4/1 | s:gm_flute stretch:-2 ]",
]
`;
From 54fd877be1272031972f9b0df183874c11f3e32f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Va=CC=81clav=20Volhejn?=
Date: Sat, 18 Oct 2025 13:19:18 +0200
Subject: [PATCH 05/91] Update snapshot
---
test/__snapshots__/examples.test.mjs.snap | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap
index a23806c1c..9eebfed35 100644
--- a/test/__snapshots__/examples.test.mjs.snap
+++ b/test/__snapshots__/examples.test.mjs.snap
@@ -10439,9 +10439,9 @@ exports[`runs examples > example "stepcat" example index 1 1`] = `
exports[`runs examples > example "stretch" example index 0 1`] = `
[
- "[ 0/1 → 1/1 | s:gm_flute stretch:3 ]",
- "[ 1/1 → 2/1 | s:gm_flute stretch:2 ]",
- "[ 2/1 → 3/1 | s:gm_flute stretch:1 ]",
+ "[ 0/1 → 1/1 | s:gm_flute stretch:2 ]",
+ "[ 1/1 → 2/1 | s:gm_flute stretch:1 ]",
+ "[ 2/1 → 3/1 | s:gm_flute stretch:0 ]",
"[ 3/1 → 4/1 | s:gm_flute stretch:-2 ]",
]
`;
From c77675c492c194bc2db8020c5c6ba5824969d0b6 Mon Sep 17 00:00:00 2001
From: "Lu[ke] Wilson"
Date: Sat, 29 Nov 2025 18:01:27 +0000
Subject: [PATCH 06/91] add another synonym
---
packages/core/pattern.mjs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs
index 8019a3eab..11304060b 100644
--- a/packages/core/pattern.mjs
+++ b/packages/core/pattern.mjs
@@ -2383,7 +2383,7 @@ export const jux = register('jux', function (func, pat) {
/**
* Like jux, but flips the ears each cycle.
* @name juxFlip
- * @synonyms flux
+ * @synonyms juxflip, flux
* @example
* s("bd lt [~ ht] mt cp ~ bd hh").juxFlip(rev)
* @example
@@ -2391,8 +2391,8 @@ export const jux = register('jux', function (func, pat) {
* @example
* s("bd lt [~ ht] mt cp ~ bd hh").juxFlip(iter(4))
*/
-export const { juxFlip, flux } = register(['juxFlip', 'flux'], function (func, pat) {
- return pat._jux(1, func, pat);
+export const { juxFlip, flux } = register(['juxFlip', 'juxflip', 'flux'], function (func, pat) {
+ return pat._juxFlipBy(1, func, pat);
});
/**
From cf845ddbab3da24ea23bb83ec90af15d513bba95 Mon Sep 17 00:00:00 2001
From: Aria
Date: Thu, 12 Feb 2026 18:30:50 -0600
Subject: [PATCH 07/91] Fix typo in phase computation
---
packages/superdough/synth.mjs | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs
index 38ad643de..224936914 100644
--- a/packages/superdough/synth.mjs
+++ b/packages/superdough/synth.mjs
@@ -485,8 +485,10 @@ export function waveformN(partials, phases, type) {
if (phase !== 0) {
const c = Math.cos(PI2 * phase);
const s = Math.sin(PI2 * phase);
- R = c * R - s * I;
- I = s * R + c * I;
+ const R0 = R;
+ const I0 = I;
+ R = c * R0 - s * I0;
+ I = s * R0 + c * I0;
}
real[n + 1] = R;
imag[n + 1] = I;
From 8520044f3eff00a9cf6c3ac7eb55fda75a5764e0 Mon Sep 17 00:00:00 2001
From: tyow
Date: Fri, 20 Feb 2026 13:10:16 -0500
Subject: [PATCH 08/91] fixed bug where withBase would silence a pattern if
freq was unset
---
packages/xen/xen.mjs | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/xen/xen.mjs b/packages/xen/xen.mjs
index 828cab6ea..3f940c89a 100644
--- a/packages/xen/xen.mjs
+++ b/packages/xen/xen.mjs
@@ -149,6 +149,7 @@ export const withBase = register('withBase', (b, pat) => {
let hVal = hap.value;
const isObject = typeof hVal === 'object';
let freq = isObject ? hVal.freq : hVal;
+ if (!freq) return hap;
freq = (freq * base) / originalBase;
hap.value = isObject ? { ...hap.value, freq } : { freq };
return hap;
From daf27ed971bd3fa54e15a231ec36529c1d6d6ca1 Mon Sep 17 00:00:00 2001
From: scrappy_fiddler
Date: Mon, 23 Feb 2026 00:11:17 +0100
Subject: [PATCH 09/91] added lfo documentation
---
website/src/config.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/website/src/config.ts b/website/src/config.ts
index eb8bd1af8..eb098d009 100644
--- a/website/src/config.ts
+++ b/website/src/config.ts
@@ -98,6 +98,7 @@ export const SIDEBAR: Sidebar = {
{ text: 'Random Modifiers', link: 'learn/random-modifiers' },
{ text: 'Conditional Modifiers', link: 'learn/conditional-modifiers' },
{ text: 'Accumulation', link: 'learn/accumulation' },
+ { text: 'LFOs', link: 'learn/lfo' },
{ text: 'Tonal Functions', link: 'learn/tonal' },
{ text: 'Stepwise Functions', link: 'learn/stepwise' },
],
From d930057000bff98866bdb4aa9b6b0f3f37b26241 Mon Sep 17 00:00:00 2001
From: scrappy_fiddler
Date: Mon, 23 Feb 2026 00:18:42 +0100
Subject: [PATCH 10/91] lfo documentation
---
website/src/pages/learn/lfo.mdx | 294 ++++++++++++++++++++++++++++++++
1 file changed, 294 insertions(+)
create mode 100644 website/src/pages/learn/lfo.mdx
diff --git a/website/src/pages/learn/lfo.mdx b/website/src/pages/learn/lfo.mdx
new file mode 100644
index 000000000..d4dfd273f
--- /dev/null
+++ b/website/src/pages/learn/lfo.mdx
@@ -0,0 +1,294 @@
+---
+title: Low-frequency oscillators (LFO)
+layout: ../../layouts/MainLayout.astro
+---
+
+import { MiniRepl } from '../../docs/MiniRepl';
+import { JsDoc } from '../../docs/JsDoc';
+
+# Low frequency oscillators (LFO)
+
+A low frequency oscillator (or short LFO) is a common way on synthesizers to continuously modulate various signals.
+
+This documentation is an interactive version of glossing's tutorial https://www.youtube.com/watch?v=11frBA9L638
+
+## Applying an LFO
+
+Here, the LFO will change the frequency of the `saw`. Put a comment like this `//.lfo()` to see and hear how it changes, and remove the comment again.
+
+
+
+By default, the LFO will modulate the control parameter which is right before `.lfo()`:
+
+
+
+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 `}`.
+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.
+
+## Control
+
+`control` determines which parameter will be modulated. This allows you to place your `lfo` at different places,
+not necessarily immediately after the controlled parameter.
+
+Here, we place `lfo` after the `s`, but it modulates the low pass filter.
+
+
+You can even influence parameters which are always present, even if you haven't explicitly written them, like `gain`.
+
+
+
+`control` has an alias `c`.
+
+## Rate
+
+`rate` determines how often the `lfo` oscillates per second:
+
+"})
+ .lpf(800)
+ ._spectrum({height: 300, width: 800})`}
+/>
+The alias of `rate` is `r`.{' '}
+
+## Sync
+
+Instead of controlling the frequency with `rate` by setting a frequency in Hz, you can use `sync`
+to snychronize your lfo with your other patterns.
+
+`sync` expresses this frequency as "times per cycle"
+
+Try removing the `sync: "<2 4 8 0.5 >"` from the pattern and notice that there is something not in sync.
+
+"})
+ ._spectrum({height: 300, width: 800})`}
+/>
+
+You can put patterns into the parameters of `lfo` if you want them to change over time, as seen above.
+
+## Relative Depth
+
+`.depth` is relative depth, a value of 1 (the default) means that
+the value goes above and below by half of the value which is being modulated
+
+E.g. for depth `1`, it modulates the frequency to oscillate between 32 = (64/2) and 96 (= 64 + 64/2).
+
+"})
+ .lpf(800)
+ ._spectrum({height: 300, width: 800})`}
+/>
+The `freq` is not needed here, as this is the default frequency, it's just for instructive purposes.
+
+The aliases of `depth` are `dr` and `dep`.
+
+## Absolute Depth
+
+`depthabs` controls the absolute modulation depth. For example you can modulate
+the low pass filter by exactly 250 Hz up and below:
+
+
+
+The alias of `depthabs` is `da`.
+
+## DC offset
+
+If you don't want to go up or down by the same amount with your modulation, then you can shift the center of the modulation with `dcoffset`.
+The default value is -0.5, which is the middle point between:
+"dcoffset = 0: All modulations increase the control parameter (or keep it constant)"
+"dcoffset = -1: All modulations decrease the control parameter (or keep it constant)"
+
+Other values will work as well.
+
+"})`}
+/>
+The marvellous alias of `dcoffset` is `dc`.
+
+## Shape
+
+You can change the shape of the modulation with `shape`. The default is `triangle`, but other shapes are available too:
+
+"}) `}
+/>
+You can add `._spectrum()` to see the shape of the modulations in the spectrum.
+
+You can also get these shapes by using numbers:
+
+| Shape | number |
+| -------- | ------ |
+| triangle | 0 |
+| sine | 1 |
+| ramp | 2 |
+| saw | 3 |
+| square | 4 |
+
+this way, you can use a function like `irand` to generate numbers:
+
+
+
+The alias of `shape` is `sh`.
+
+## Skewing some shapes
+
+You can influence some of the shapes (`triangle` and `square`) in more detail.
+
+The default skew is 0.5, it does different things for these two.
+
+For `triangle`, it skews the top of the triangle to the left or right,
+where 0 makes it look like `saw` and 1 makes it look like `ramp`.
+
+For `square`, the skew influences the pulse width (see reference for `pulse` and `.pw`):
+
+/5",
+ skew: "<0 0.25 0.5 0.75 1>"}) `}
+/>
+The alias of `skew` is `sk`.{' '}
+
+## Curve
+
+You can change the curves of your lfo and can make it more intense.
+The default value is 1. Writing numbers greater than one can make it more intense,
+numbers between 0 and 1 will make it less intense.
+This will raise the lfo to the power of curve, so larger numbers (such as 10) can have very unexpected results.
+
+", curve: "<1.3 1 0.75 1>"}) `}
+/>
+## Referencing your lfos with id
+
+All the lfos are numbered from the first starting with 0, the second having an `id` of 1 and so on.
+
+You can refer to this in a later call if you want to modify a specific `lfo`.
+
+The ´id´ is outside of the config json (which is different from the other parameters of `lfo()`
+
+Try out how the sound changes when you replace the 0 with a 1.
+
+"})
+.sometimes(x => x.lfo({dr: "4"},0))`}
+/>
+
+You can also name your lfos and refer to them by name, using the `id` parameter
+
+"}, "lfo_lpf")
+.sometimes(x => x.lfo({dr: "4"},""))`}
+/>
+
+## FX index
+
+If you are using `FX()` to reorder your effects, you dont need to write your lfos inside the `FX`,
+but instead can refer to them by their FX index (starting with 0)
+
+
+
+## Modulating other LFOs with Sub-control
+
+LFOs can modulate other lfos and will modulate their frequency (given by `r` or `s`):
+
+
+To modulate other parameters of the first lfo (like `skew`, `depth` and so on), we can specify this with `subControl`or
+its alias `sc`
+
+
From 5cd45d5ea1002e33df8d7e844fa76def08a15bac Mon Sep 17 00:00:00 2001
From: scrappy_fiddler
Date: Mon, 23 Feb 2026 00:22:30 +0100
Subject: [PATCH 11/91] typo in lfo docs
---
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 d4dfd273f..a75584d9f 100644
--- a/website/src/pages/learn/lfo.mdx
+++ b/website/src/pages/learn/lfo.mdx
@@ -234,7 +234,7 @@ All the lfos are numbered from the first starting with 0, the second having an `
You can refer to this in a later call if you want to modify a specific `lfo`.
-The ´id´ is outside of the config json (which is different from the other parameters of `lfo()`
+The `id` is outside of the config json (which is different from the other parameters of `lfo()`
Try out how the sound changes when you replace the 0 with a 1.
From 82e88d4a28303d51565168f69e994a5b406f67b5 Mon Sep 17 00:00:00 2001
From: jagen31
Date: Mon, 23 Feb 2026 01:24:53 +0100
Subject: [PATCH 12/91] fix midi numbers as notes for osc backend
note -> midinote
---
packages/osc/osc.mjs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/osc/osc.mjs b/packages/osc/osc.mjs
index 0744cb8c9..9eda56149 100644
--- a/packages/osc/osc.mjs
+++ b/packages/osc/osc.mjs
@@ -42,7 +42,7 @@ export function parseControlsFromHap(hap, cps) {
if (isNote(controls.note)) {
controls.midinote = noteToMidi(controls.note, controls.octave || 3);
} else {
- controls.note = parseNumeral(controls.note);
+ controls.midinote = parseNumeral(controls.note);
}
}
controls.bank && (controls.s = controls.bank + controls.s);
From 3805ba8f76c5dee1c6e31f0fe0135265dc4ba16d Mon Sep 17 00:00:00 2001
From: KuboOrk
Date: Mon, 9 Feb 2026 19:39:26 +0300
Subject: [PATCH 13/91] Added .dockerignore
---
.dockerignore | 11 +++++++++++
1 file changed, 11 insertions(+)
create mode 100644 .dockerignore
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..ddaea8d69
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+**/node_modules
+**/.angular
+**/dist
+.astro/
+.forgejo/
+.idea/
+.vscode/
+test/
+.gitignore
+Dockerfile
+.dockerignore
From 63f42faca85bc87153eef9d77ada7523a08edce3 Mon Sep 17 00:00:00 2001
From: TodePond
Date: Tue, 3 Mar 2026 11:27:05 +0100
Subject: [PATCH 14/91] Update packages/core/pattern.mjs
---
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 11304060b..491ca84d3 100644
--- a/packages/core/pattern.mjs
+++ b/packages/core/pattern.mjs
@@ -2363,7 +2363,7 @@ export const { juxBy, juxby } = register(['juxBy', 'juxby'], function (by, func,
export const { juxFlipBy, juxflipby, fluxBy, fluxby } = register(
['juxFlipBy', 'juxflipby', 'fluxBy', 'fluxby'],
function (by, func, pat) {
- return slowcat(pat._juxBy(by, func, pat), pat._juxBy(-by, func, pat));
+ return pat.juxBy(slowcat(by, -by), func);
},
);
From cdae661b24fd141e0b5d95c9e60979ddc6a4cbff Mon Sep 17 00:00:00 2001
From: Bernhard Wagner
Date: Mon, 9 Mar 2026 11:15:14 +0100
Subject: [PATCH 15/91] docs: note that all/each require labeled patterns
---
packages/core/repl.mjs | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs
index c1b539819..ff2734967 100644
--- a/packages/core/repl.mjs
+++ b/packages/core/repl.mjs
@@ -307,8 +307,12 @@ export function repl({
let allTransforms = [];
/**
- * Applies a function to all the running patterns. Note that the patterns are groups together into a single `stack` before the function is applied. This is probably what you want, but see `each` for
+ * Applies a function to all the running patterns. Note that the patterns are grouped together into a single `stack` before the function is applied. This is probably what you want, but see `each` for
* a version that applies the function to each pattern separately.
+ *
+ * **Note:** Patterns must be labeled (e.g. with `$:`) to be picked up by `all`. An unlabeled
+ * pattern such as `note("c4")` is not registered and will produce no audio when `all` is present.
+ * Use `$: note("c4")` instead.
* ```
* $: sound("bd - cp sd")
* $: sound("hh*8")
@@ -328,6 +332,9 @@ export function repl({
};
/** Applies a function to each of the running patterns separately. This is intended for future use with upcoming 'stepwise' features. See `all` for a version that applies the function to all the patterns stacked together into a single pattern.
*
+ * **Note:** Patterns must be labeled (e.g. with `$:`) to be picked up by `each`. An unlabeled
+ * pattern such as `note("c4")` is not registered and will produce no audio when `each` is present.
+ * Use `$: note("c4")` instead.
* ```
* $: sound("bd - cp sd")
* $: sound("hh*8")
From 7294cd54430fa9455c86d3dc5aec67a7f64932ac Mon Sep 17 00:00:00 2001
From: tyow
Date: Fri, 13 Mar 2026 14:11:19 -0400
Subject: [PATCH 16/91] Add error for when name arg in register is a pattern
---
packages/core/pattern.mjs | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs
index c5b7953c9..0612c10f1 100644
--- a/packages/core/pattern.mjs
+++ b/packages/core/pattern.mjs
@@ -1741,6 +1741,10 @@ export const func = curry((a, b) => reify(b).func(a));
*
*/
export function register(name, func, patternify = true, preserveSteps = false, join = (x) => x.innerJoin()) {
+ if (isPattern(name)) {
+ throw new Error("Name argument for register is a pattern, try using single quotes ('name') instead of double quotes (\"name\")");
+ }
+
if (Array.isArray(name)) {
const result = {};
for (const name_item of name) {
From ae32743f12904c4a54c4492049860cfe56164e83 Mon Sep 17 00:00:00 2001
From: tyow
Date: Fri, 13 Mar 2026 14:11:38 -0400
Subject: [PATCH 17/91] format
---
packages/core/pattern.mjs | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs
index 0612c10f1..305dd5bb2 100644
--- a/packages/core/pattern.mjs
+++ b/packages/core/pattern.mjs
@@ -1742,9 +1742,11 @@ export const func = curry((a, b) => reify(b).func(a));
*/
export function register(name, func, patternify = true, preserveSteps = false, join = (x) => x.innerJoin()) {
if (isPattern(name)) {
- throw new Error("Name argument for register is a pattern, try using single quotes ('name') instead of double quotes (\"name\")");
+ throw new Error(
+ 'Name argument for register is a pattern, try using single quotes (\'name\') instead of double quotes ("name")',
+ );
}
-
+
if (Array.isArray(name)) {
const result = {};
for (const name_item of name) {
From e15ce7d402af1e856088b3e37b813c0d0f6bfc15 Mon Sep 17 00:00:00 2001
From: Aria
Date: Sat, 14 Mar 2026 14:48:57 -0500
Subject: [PATCH 18/91] Add replace and insert code helpers to codemirror
---
packages/codemirror/codemirror.mjs | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs
index 6aac0c8a4..71e5c49ab 100644
--- a/packages/codemirror/codemirror.mjs
+++ b/packages/codemirror/codemirror.mjs
@@ -444,14 +444,16 @@ export class StrudelMirror {
this.setFontSize(value);
}
}
- setCode(code) {
- const changes = {
- from: 0,
- to: this.editor.state.doc.length,
- insert: code,
- };
+ replaceCode(code, from, to) {
+ const changes = { from, to, insert: code };
this.editor.dispatch({ changes });
}
+ insertCode(code, position) {
+ this.replaceCode(code, position, position);
+ }
+ setCode(code) {
+ this.replaceCode(code, 0, this.editor.state.doc.length);
+ }
// used for debugging but could serve other purposes
getActiveWidgets() {
return getActiveWidgets(this.editor);
From 86d65c72f056b5f31f7a3558e8dcb64bd41b9c00 Mon Sep 17 00:00:00 2001
From: tyow
Date: Mon, 30 Mar 2026 14:37:26 -0400
Subject: [PATCH 19/91] colorless version display implementation
---
website/src/repl/components/panel/Panel.jsx | 26 +++++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/website/src/repl/components/panel/Panel.jsx b/website/src/repl/components/panel/Panel.jsx
index 3b4dd5482..76987d1a4 100644
--- a/website/src/repl/components/panel/Panel.jsx
+++ b/website/src/repl/components/panel/Panel.jsx
@@ -44,6 +44,25 @@ export function LogoButton({ context, isEmbedded }) {
export function MainPanel({ context, isEmbedded = false, className }) {
const { isZen, isButtonRowHidden, fontFamily } = useSettings();
+ let loc = window.location;
+ let ver = 'unofficial';
+ let hot = false;
+ let b = loc.hostname.match(/^(.+)\.(strudel)/);
+ if (/(strudel)/.test(loc.hostname)) {
+ // if there's no text before 'strudel', it's warm, otherwise use the text before strudel
+ ver = b ? b[1] : 'warm';
+ } else {
+ // match both versions of localhost
+ if (/(localhost)|(127.0.0.1)/.test(loc.hostname)) ver = 'dev';
+ }
+ let pr = ver.match(/pr-([0-9]+)/);
+ if (pr) {
+ pr = pr[1];
+ ver = `hot: ${pr}`;
+ hot = true;
+ pr = `https://codeberg.org/uzu/strudel/pulls/${pr}`;
+ }
+
return (
)}
-
+
Date: Wed, 29 Jul 2026 09:11:41 +0100
Subject: [PATCH 56/91] 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 57/91] 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 58/91] 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 59/91] 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 60/91] 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 57598f2aa1095564c988af40346b52c5b5c0c1e6 Mon Sep 17 00:00:00 2001
From: Emily S
Date: Fri, 31 Jul 2026 21:34:32 +0100
Subject: [PATCH 61/91] formatting
---
website/src/repl/components/panel/Reference.jsx | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/website/src/repl/components/panel/Reference.jsx b/website/src/repl/components/panel/Reference.jsx
index 42c43e645..05f25eefd 100644
--- a/website/src/repl/components/panel/Reference.jsx
+++ b/website/src/repl/components/panel/Reference.jsx
@@ -214,10 +214,12 @@ export const Reference = memo(function Reference() {
)}
-
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 64/91] 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 65/91] 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 66/91] 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 67/91] 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 68/91] 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 69/91] 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 adcbf9240ed90d390fa39714c3c0221f5928da6f Mon Sep 17 00:00:00 2001
From: Cass Mitchell
Date: Wed, 5 Aug 2026 19:19:06 -0400
Subject: [PATCH 70/91] Don't run server if outputting JSON
---
packages/sampler/sample-server.mjs | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/sampler/sample-server.mjs b/packages/sampler/sample-server.mjs
index 1d5a8e7a6..e78efd9cc 100644
--- a/packages/sampler/sample-server.mjs
+++ b/packages/sampler/sample-server.mjs
@@ -98,6 +98,7 @@ if (args.includes('--json')) {
}
writeFileSync(outFile, json, 'utf8');
console.log(`Wrote json to ${outFile}`);
+ process.exit(0);
}
console.log(
From 6c8b707d2ee3a870d19f61161cb394c0ba61fa4e Mon Sep 17 00:00:00 2001
From: wjt
Date: Mon, 10 Aug 2026 17:01:43 +0200
Subject: [PATCH 71/91] 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 f1a541caecb4aff9bded26022190c696f133a1bb Mon Sep 17 00:00:00 2001
From: Felix Roos
Date: Wed, 12 Aug 2026 20:48:01 +0200
Subject: [PATCH 72/91] fix: accurate midi scheduling on chromium
---
packages/midi/midi.mjs | 130 ++++++++++++++++++++++-------------------
1 file changed, 70 insertions(+), 60 deletions(-)
diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs
index 5880ca268..59249601b 100644
--- a/packages/midi/midi.mjs
+++ b/packages/midi/midi.mjs
@@ -171,6 +171,21 @@ function normalize(value = 0, min = 0, max = 1, exp = 1) {
return Math.pow(normalized, exp);
}
+const isFirefox = navigator?.userAgent?.includes('Firefox');
+// call fn either directly with given time (non-firefox) or after scheduleAtTime with undefined (firefox)
+// the scheduleAtTime approach is still jittery, but the best we can be on firefox
+// firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=2062997
+function timedSend(timeMs, fn) {
+ if (isFirefox) {
+ const audioTime = getAudioContext().currentTime + (timeMs - performance.now()) / 1000;
+ scheduleAtTime(() => {
+ fn(undefined);
+ }, audioTime);
+ } else {
+ fn(timeMs);
+ }
+}
+
function mapCC(mapping, value) {
return Object.keys(value)
.filter((key) => !!mapping[getControlName(key)])
@@ -182,7 +197,7 @@ function mapCC(mapping, value) {
}
// sends a cc message to the given device on the given channel
-function sendCC(ccn, ccv, device, midichan, targetTime) {
+function sendCC(ccn, ccv, device, midichan, timeMs) {
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
throw new Error('expected ccv to be a number between 0 and 1');
}
@@ -190,23 +205,19 @@ function sendCC(ccn, ccv, device, midichan, targetTime) {
throw new Error('expected ccn to be a number or a string');
}
const scaled = Math.round(ccv * 127);
- scheduleAtTime(() => {
- device.sendControlChange(ccn, scaled, midichan);
- }, targetTime);
+ timedSend(timeMs, (timeMs) => device.sendControlChange(ccn, scaled, { channels: midichan, time: timeMs }));
}
// sends a program change message to the given device on the given channel
-function sendProgramChange(progNum, device, midichan, targetTime) {
+function sendProgramChange(progNum, device, midichan, timeMs) {
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
throw new Error('expected progNum (program change) to be a number between 0 and 127');
}
- scheduleAtTime(() => {
- device.sendProgramChange(progNum, midichan);
- }, targetTime);
+ timedSend(timeMs, (timeMs) => device.sendProgramChange(progNum, { channels: midichan, time: timeMs }));
}
// sends a sysex message to the given device on the given channel
-function sendSysex(sysexid, sysexdata, device, targetTime) {
+function sendSysex(sysexid, sysexdata, device, timeMs) {
if (Array.isArray(sysexid)) {
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all sysexid bytes must be integers between 0 and 255');
@@ -221,13 +232,11 @@ function sendSysex(sysexid, sysexdata, device, targetTime) {
if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all sysex bytes must be integers between 0 and 255');
}
- scheduleAtTime(() => {
- device.sendSysex(sysexid, sysexdata);
- }, targetTime);
+ timedSend(timeMs, (timeMs) => device.sendSysex(sysexid, sysexdata, { time: timeMs }));
}
// sends a NRPN message to the given device on the given channel
-function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
+function sendNRPN(nrpnn, nrpv, device, midichan, timeMs) {
if (Array.isArray(nrpnn)) {
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all nrpnn bytes must be integers between 0 and 255');
@@ -235,34 +244,29 @@ function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
} else if (!Number.isInteger(nrpv) || nrpv < 0 || nrpv > 255) {
throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
}
- scheduleAtTime(() => {
- device.sendNRPN(nrpnn, nrpv, midichan);
- }, targetTime);
+
+ timedSend(timeMs, (timeMs) => device.sendNrpnValue(nrpnn, nrpv, { channels: midichan, time: timeMs }));
}
// sends a pitch bend message to the given device on the given channel
-function sendPitchBend(midibend, device, midichan, targetTime) {
+function sendPitchBend(midibend, device, midichan, timeMs) {
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
throw new Error('expected midibend to be a number between -1 and 1');
}
- scheduleAtTime(() => {
- device.sendPitchBend(midibend, midichan);
- }, targetTime);
+ timedSend(timeMs, (timeMs) => device.sendPitchBend(midibend, { channels: midichan, time: timeMs }));
}
// sends a channel aftertouch message to the given device on the given channel
-function sendAftertouch(miditouch, device, midichan, targetTime) {
+function sendAftertouch(miditouch, device, midichan, timeMs) {
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
throw new Error('expected miditouch to be a number between 0 and 1');
}
- scheduleAtTime(() => {
- device.sendChannelAftertouch(miditouch, midichan);
- }, targetTime);
+ timedSend(timeMs, (timeMs) => device.sendChannelAftertouch(miditouch, { channels: midichan, time: timeMs }));
}
// sends a note message to the given device on the given channel
-function sendNote(note, velocity, duration, device, midichan, targetTime) {
+function sendNote(note, velocity, duration, device, midichan, timeMs) {
if (note == null || note === '') {
throw new Error('note cannot be null or empty');
}
@@ -273,11 +277,10 @@ function sendNote(note, velocity, duration, device, midichan, targetTime) {
throw new Error('duration must be a positive number');
}
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
- const midiNote = new Note(midiNumber, { attack: velocity, duration });
+ const midiNote = new Note(midiNumber, { attack: velocity });
- scheduleAtTime(() => {
- device.playNote(midiNote, midichan);
- }, targetTime);
+ timedSend(timeMs, (timeMs) => device.sendNoteOn(midiNote, { channels: midichan, time: timeMs }));
+ timedSend(timeMs + duration, (timeMs) => device.sendNoteOff(midiNote, { channels: midichan, time: timeMs }));
}
/**
@@ -287,8 +290,6 @@ function sendNote(note, velocity, duration, device, midichan, targetTime) {
* @param {object} options Additional MIDI configuration options
* @example
* note("c4").midichan(1).midi('IAC Driver Bus 1')
- * @example
- * note("c4").midichan(1).midi('IAC Driver Bus 1', { controller: true, latency: 50 })
*/
Pattern.prototype.midi = function (midiport, options = {}) {
@@ -337,11 +338,28 @@ Pattern.prototype.midi = function (midiport, options = {}) {
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
});
- return this.onTrigger((hap, _currentTime, cps, targetTime) => {
+ let p; // filtered clock offset
+ let lastOffset;
+
+ return this.sortHapsByPart().onTrigger((hap, _currentTime, cps, targetTime) => {
if (!WebMidi.enabled) {
logger('Midi not enabled');
return;
}
+ const { contextTime, performanceTime } = getAudioContext().getOutputTimestamp();
+ if (!contextTime || !performanceTime) {
+ logger('[midi] skip midi event: not ready yet?');
+ return;
+ }
+ const cutoff = 0.01;
+ const offset = performanceTime - contextTime * 1000;
+ p ??= offset; // first input is initial offset
+ if (offset !== lastOffset) {
+ // update filter only when offset changes
+ p = offset * cutoff + p * (1 - cutoff); // onepole iir filter to smooth drift :)
+ }
+ lastOffset = offset;
+ const timeMs = targetTime * 1000 + p; // this is now correct in performance time
hap.ensureObjectValue();
// midi event values from hap with configurable defaults
@@ -379,7 +397,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// if midimap is set, send a cc messages from defined controls
if (midicontrolMap.has(midimap)) {
const ccs = mapCC(midicontrolMap.get(midimap), hap.value);
- ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, targetTime));
+ ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, timeMs));
} else if (midimap !== 'default') {
// Add warning when a non-existent midimap is specified
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
@@ -388,15 +406,17 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// Handle note
if (note !== undefined && !midiConfig.isController) {
// note off messages will often a few ms arrive late,
- // try to prevent glitching by subtracting noteOffsetMs from the duration length
- const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs;
+ // try to prevent glitching by subtracting at max noteOffsetMs from the duration length
+ const hapDuration = (hap.duration.valueOf() / cps) * 1000;
+ const offset = Math.min(midiConfig.noteOffsetMs, hapDuration / 2);
+ const duration = hapDuration - offset;
- sendNote(note, velocity, duration, device, midichan, targetTime);
+ sendNote(note, velocity, duration, device, midichan, timeMs);
}
// Handle program change
if (progNum !== undefined) {
- sendProgramChange(progNum, device, midichan, targetTime);
+ sendProgramChange(progNum, device, midichan, timeMs);
}
// Handle sysex
@@ -406,63 +426,53 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// if sysexid is an array the first byte is 0x00
if (sysexid !== undefined && sysexdata !== undefined) {
- sendSysex(sysexid, sysexdata, device, targetTime);
+ sendSysex(sysexid, sysexdata, device, timeMs);
}
// Handle control change
if (ccv !== undefined && ccn !== undefined) {
- sendCC(ccn, ccv, device, midichan, targetTime);
+ sendCC(ccn, ccv, device, midichan, timeMs);
}
// Handle NRPN non-registered parameter number
if (nrpnn !== undefined && nrpv !== undefined) {
- sendNRPN(nrpnn, nrpv, device, midichan, targetTime);
+ sendNRPN(nrpnn, nrpv, device, midichan, timeMs);
}
// Handle midibend
if (midibend !== undefined) {
- sendPitchBend(midibend, device, midichan, targetTime);
+ sendPitchBend(midibend, device, midichan, timeMs);
}
// Handle miditouch
if (miditouch !== undefined) {
- sendAftertouch(miditouch, device, midichan, targetTime);
+ sendAftertouch(miditouch, device, midichan, timeMs);
}
// Handle midicmd
if (hap.whole.begin + 0 === 0) {
// we need to start here because we have the timing info
- scheduleAtTime(() => {
- device.sendStart();
- }, targetTime);
+ timedSend(timeMs, (timeMs) => device.sendStart({ time: timeMs }));
}
if (['clock', 'midiClock'].includes(midicmd)) {
- scheduleAtTime(() => {
- device.sendClock();
- }, targetTime);
+ timedSend(timeMs, (timeMs) => device.sendClock({ time: timeMs }));
} else if (['start'].includes(midicmd)) {
- scheduleAtTime(() => {
- device.sendStart();
- }, targetTime);
+ timedSend(timeMs, (timeMs) => device.sendStart({ time: timeMs }));
} else if (['stop'].includes(midicmd)) {
- scheduleAtTime(() => {
- device.sendStop();
- }, targetTime);
+ timedSend(timeMs, (timeMs) => device.sendStop({ time: timeMs }));
} else if (['continue'].includes(midicmd)) {
- scheduleAtTime(() => {
- device.sendContinue();
- }, targetTime);
+ timedSend(timeMs, (timeMs) => device.sendContinue({ time: timeMs }));
} else if (Array.isArray(midicmd)) {
if (midicmd[0] === 'progNum') {
- sendProgramChange(midicmd[1], device, midichan, targetTime);
+ sendProgramChange(midicmd[1], device, midichan, timeMs);
} else if (midicmd[0] === 'cc') {
if (midicmd.length === 2) {
- sendCC(midicmd[0], midicmd[1] / 127, device, midichan, targetTime);
+ sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeMs);
}
} else if (midicmd[0] === 'sysex') {
if (midicmd.length === 3) {
const [_, id, data] = midicmd;
- sendSysex(id, data, device, targetTime);
+ sendSysex(id, data, device, timeMs);
}
}
}
From bd54c8003a9287af513dbad3243c5f2b9e8c2dd8 Mon Sep 17 00:00:00 2001
From: Tijmen Zwaan
Date: Thu, 13 Aug 2026 01:00:33 +0200
Subject: [PATCH 73/91] 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 74/91] 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 75/91] 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 76/91] 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 77/91] 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 78/91] 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 79/91] 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);
From ee460dc9802dddef5ba03b7d6a57c3c30a11ceea Mon Sep 17 00:00:00 2001
From: Felix Roos
Date: Sat, 15 Aug 2026 17:10:18 +0200
Subject: [PATCH 80/91] fix longer running midi-only drift with a dummy node
---
packages/midi/midi.mjs | 4 +++-
packages/superdough/helpers.mjs | 15 +++++++++++++++
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs
index 59249601b..0ea9d75b7 100644
--- a/packages/midi/midi.mjs
+++ b/packages/midi/midi.mjs
@@ -22,7 +22,7 @@ import {
import { noteToMidi, getControlName } from '@strudel/core';
import { Note } from 'webmidi';
import { getAudioContext } from '@strudel/webaudio';
-import { scheduleAtTime } from '../superdough/helpers.mjs';
+import { scheduleAtTime, ensureMinimalOutput } from '../superdough/helpers.mjs';
import { getMidiDeviceNamesString, getDevice } from './util.mjs';
import { MidiInput } from './input.mjs';
@@ -338,6 +338,8 @@ Pattern.prototype.midi = function (midiport, options = {}) {
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
});
+ ensureMinimalOutput();
+
let p; // filtered clock offset
let lastOffset;
diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs
index 07199aff6..bc39cc43f 100644
--- a/packages/superdough/helpers.mjs
+++ b/packages/superdough/helpers.mjs
@@ -12,6 +12,21 @@ export function gainNode(value) {
return node;
}
+// this helper makes sure the audio context is "used", meaning it outputs something
+// this prevents the browser from throttling timing accuracy
+// it happened when only midi was running, the clock got more drifty without this
+let constantNode, constantNodeAudioContext;
+export function ensureMinimalOutput() {
+ if (constantNode && constantNodeAudioContext === getAudioContext()) {
+ return;
+ }
+ constantNodeAudioContext = getAudioContext();
+ constantNode = new ConstantSourceNode(constantNodeAudioContext);
+ constantNode.offset.value = 1e-7;
+ constantNode.connect(constantNodeAudioContext.destination);
+ constantNode.start();
+}
+
export function effectSend(input, effect, wet) {
const send = gainNode(wet);
input.connect(send);
From ef106b356f55cf45d1a8dda6e3d6c6977cf82446 Mon Sep 17 00:00:00 2001
From: Felix Roos
Date: Sun, 16 Aug 2026 09:41:45 +0200
Subject: [PATCH 81/91] make smoothing rate independent (thanks freya)
---
packages/midi/midi.mjs | 22 +++++++++++++---------
1 file changed, 13 insertions(+), 9 deletions(-)
diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs
index 0ea9d75b7..b302aa8fa 100644
--- a/packages/midi/midi.mjs
+++ b/packages/midi/midi.mjs
@@ -283,6 +283,11 @@ function sendNote(note, velocity, duration, device, midichan, timeMs) {
timedSend(timeMs + duration, (timeMs) => device.sendNoteOff(midiNote, { channels: midichan, time: timeMs }));
}
+// thanks freya https://youtu.be/LSNQuFEDOyQ?si=ukZI2IGgWV_NDZzP&t=2979
+function expDecay(a, b, decay, dt) {
+ return b + (a - b) * Math.exp(-decay * dt);
+}
+
/**
* MIDI output: Opens a MIDI output port.
* @tags external_io
@@ -341,7 +346,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
ensureMinimalOutput();
let p; // filtered clock offset
- let lastOffset;
+ let lastTime;
return this.sortHapsByPart().onTrigger((hap, _currentTime, cps, targetTime) => {
if (!WebMidi.enabled) {
@@ -353,15 +358,14 @@ Pattern.prototype.midi = function (midiport, options = {}) {
logger('[midi] skip midi event: not ready yet?');
return;
}
- const cutoff = 0.01;
- const offset = performanceTime - contextTime * 1000;
- p ??= offset; // first input is initial offset
- if (offset !== lastOffset) {
- // update filter only when offset changes
- p = offset * cutoff + p * (1 - cutoff); // onepole iir filter to smooth drift :)
- }
- lastOffset = offset;
+ // time conversion from audio context time (targetTime) to performance time (what midi needs)
+ const offset = performanceTime - contextTime * 1000; // clock offset in ms
+ const dt = performanceTime - (lastTime ?? performanceTime); // delta time since last midi hap
+ const decay = 1 / 10000; // how fast offset changes have an effect
+ p = expDecay(p ?? offset, offset, decay, dt); // smooth clock offset
+ lastTime = performanceTime;
const timeMs = targetTime * 1000 + p; // this is now correct in performance time
+
hap.ensureObjectValue();
// midi event values from hap with configurable defaults
From 98d19a6643ffedd5b7ff42ae30c9c2a39e73113e Mon Sep 17 00:00:00 2001
From: Felix Roos
Date: Sun, 16 Aug 2026 10:21:31 +0200
Subject: [PATCH 82/91] format
---
packages/midi/midi.mjs | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs
index b302aa8fa..a7e1e3af2 100644
--- a/packages/midi/midi.mjs
+++ b/packages/midi/midi.mjs
@@ -178,9 +178,7 @@ const isFirefox = navigator?.userAgent?.includes('Firefox');
function timedSend(timeMs, fn) {
if (isFirefox) {
const audioTime = getAudioContext().currentTime + (timeMs - performance.now()) / 1000;
- scheduleAtTime(() => {
- fn(undefined);
- }, audioTime);
+ scheduleAtTime(() => fn(undefined), audioTime);
} else {
fn(timeMs);
}
From 723cd5da04df66cbbcdb12d7c336f2f336850224 Mon Sep 17 00:00:00 2001
From: Felix Roos
Date: Sun, 16 Aug 2026 10:59:10 +0200
Subject: [PATCH 83/91] lower noteOffsetMs on chromium + update comment
---
packages/midi/midi.mjs | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs
index a7e1e3af2..fa5e69ec8 100644
--- a/packages/midi/midi.mjs
+++ b/packages/midi/midi.mjs
@@ -318,7 +318,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
let midiConfig = {
// Default configuration values
isController: false, // Disable sending notes for midi controllers
- noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms
+ noteOffsetMs: isFirefox ? 10 : 1, // Default note-off offset to prevent glitching in ms. firefox needs more slack
midichannel: 1, // Default MIDI channel
velocity: 0.9, // Default velocity
gain: 1, // Default gain
@@ -409,8 +409,10 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// Handle note
if (note !== undefined && !midiConfig.isController) {
- // note off messages will often a few ms arrive late,
- // try to prevent glitching by subtracting at max noteOffsetMs from the duration length
+ // note off time is calculated early, together with note on time
+ // when the note off is due, the clock might have drifted, and the next note on message might happen before the note off
+ // this would lead to the next note being cut off
+ // this is why we make notes shorter by noteOffsetMs, so note offs happen earlier than the note ons after
const hapDuration = (hap.duration.valueOf() / cps) * 1000;
const offset = Math.min(midiConfig.noteOffsetMs, hapDuration / 2);
const duration = hapDuration - offset;
From f2be5e4f5ed7fad7952f5ad0e6633dc35f9c23d0 Mon Sep 17 00:00:00 2001
From: Felix Roos
Date: Sun, 16 Aug 2026 22:15:39 +0200
Subject: [PATCH 84/91] refactor: new clockbridge class to fix midi and osc
timing
---
packages/midi/midi.mjs | 29 ++++++--------------
packages/osc/osc.mjs | 19 +++++++++----
packages/osc/package.json | 1 +
packages/superdough/audioContext.mjs | 11 +++++++-
packages/superdough/clockbridge.mjs | 41 ++++++++++++++++++++++++++++
packages/superdough/index.mjs | 1 +
pnpm-lock.yaml | 3 ++
7 files changed, 79 insertions(+), 26 deletions(-)
create mode 100644 packages/superdough/clockbridge.mjs
diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs
index fa5e69ec8..68a0d744f 100644
--- a/packages/midi/midi.mjs
+++ b/packages/midi/midi.mjs
@@ -21,7 +21,7 @@ import {
} from '@strudel/core';
import { noteToMidi, getControlName } from '@strudel/core';
import { Note } from 'webmidi';
-import { getAudioContext } from '@strudel/webaudio';
+import { getAudioContext, getClockBridge } from '@strudel/webaudio';
import { scheduleAtTime, ensureMinimalOutput } from '../superdough/helpers.mjs';
import { getMidiDeviceNamesString, getDevice } from './util.mjs';
import { MidiInput } from './input.mjs';
@@ -177,7 +177,11 @@ const isFirefox = navigator?.userAgent?.includes('Firefox');
// firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=2062997
function timedSend(timeMs, fn) {
if (isFirefox) {
- const audioTime = getAudioContext().currentTime + (timeMs - performance.now()) / 1000;
+ const audioTime = getClockBridge().getAudioContextTime(timeMs);
+ if (!audioTime) {
+ logger('[midi]: skip event, not ready');
+ return;
+ }
scheduleAtTime(() => fn(undefined), audioTime);
} else {
fn(timeMs);
@@ -281,11 +285,6 @@ function sendNote(note, velocity, duration, device, midichan, timeMs) {
timedSend(timeMs + duration, (timeMs) => device.sendNoteOff(midiNote, { channels: midichan, time: timeMs }));
}
-// thanks freya https://youtu.be/LSNQuFEDOyQ?si=ukZI2IGgWV_NDZzP&t=2979
-function expDecay(a, b, decay, dt) {
- return b + (a - b) * Math.exp(-decay * dt);
-}
-
/**
* MIDI output: Opens a MIDI output port.
* @tags external_io
@@ -343,26 +342,16 @@ Pattern.prototype.midi = function (midiport, options = {}) {
ensureMinimalOutput();
- let p; // filtered clock offset
- let lastTime;
-
return this.sortHapsByPart().onTrigger((hap, _currentTime, cps, targetTime) => {
if (!WebMidi.enabled) {
logger('Midi not enabled');
return;
}
- const { contextTime, performanceTime } = getAudioContext().getOutputTimestamp();
- if (!contextTime || !performanceTime) {
- logger('[midi] skip midi event: not ready yet?');
+ const timeMs = getClockBridge().getPerformanceTime(targetTime);
+ if (!timeMs) {
+ logger('[midi] clockbridge not ready');
return;
}
- // time conversion from audio context time (targetTime) to performance time (what midi needs)
- const offset = performanceTime - contextTime * 1000; // clock offset in ms
- const dt = performanceTime - (lastTime ?? performanceTime); // delta time since last midi hap
- const decay = 1 / 10000; // how fast offset changes have an effect
- p = expDecay(p ?? offset, offset, decay, dt); // smooth clock offset
- lastTime = performanceTime;
- const timeMs = targetTime * 1000 + p; // this is now correct in performance time
hap.ensureObjectValue();
diff --git a/packages/osc/osc.mjs b/packages/osc/osc.mjs
index b94be0d9d..2c0619b4a 100644
--- a/packages/osc/osc.mjs
+++ b/packages/osc/osc.mjs
@@ -4,7 +4,8 @@ Copyright (C) 2022 Strudel contributors - see .
*/
-import { logger, parseNumeral, register, isNote, noteToMidi, ClockCollator } from '@strudel/core';
+import { logger, parseNumeral, register, isNote, noteToMidi } from '@strudel/core';
+import { ensureMinimalOutput, getClockBridge } from '@strudel/webaudio';
let connection; // Promise
function connect() {
@@ -54,13 +55,18 @@ export function parseControlsFromHap(hap, cps) {
return controls;
}
-const collator = new ClockCollator({});
-
export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
const ws = await connect();
+
+ const timeMs = getClockBridge().getPerformanceTime(targetTime);
+ if (!timeMs) {
+ logger('[osc] clockbridge not ready');
+ return;
+ }
+ const ts = performance.timeOrigin + timeMs;
+
const controls = parseControlsFromHap(hap, cps);
const keyvals = Object.entries(controls).flat();
- const ts = collator.calculateTimestamp(currentTime, targetTime) * 1000;
const msg = { address: '/dirt/play', args: keyvals, timestamp: ts };
if ('oschost' in hap.value) {
@@ -82,4 +88,7 @@ export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
* @memberof Pattern
* @returns Pattern
*/
-export const osc = register('osc', (pat) => pat.onTrigger(oscTrigger));
+export const osc = register('osc', (pat) => {
+ ensureMinimalOutput();
+ return pat.onTrigger(oscTrigger);
+});
diff --git a/packages/osc/package.json b/packages/osc/package.json
index 569242252..de4d2c96c 100644
--- a/packages/osc/package.json
+++ b/packages/osc/package.json
@@ -38,6 +38,7 @@
"homepage": "https://codeberg.org/uzu/strudel#readme",
"dependencies": {
"@strudel/core": "workspace:*",
+ "@strudel/webaudio": "workspace:*",
"osc": "^2.4.5",
"ws": "^8.18.3"
},
diff --git a/packages/superdough/audioContext.mjs b/packages/superdough/audioContext.mjs
index 6a701d9ad..27a1b25d1 100644
--- a/packages/superdough/audioContext.mjs
+++ b/packages/superdough/audioContext.mjs
@@ -8,8 +8,9 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import { clearNodePool } from './nodePools.mjs';
+import { ClockBridge } from './clockbridge.mjs';
-let audioContext;
+let audioContext, clockBridge;
export const setDefaultAudioContext = () => {
return setAudioContext(new AudioContext());
@@ -23,6 +24,7 @@ export const setAudioContext = (context) => {
audioContext.close();
}
audioContext = context;
+ clockBridge = new ClockBridge(audioContext);
return audioContext;
};
@@ -34,6 +36,13 @@ export const getAudioContext = () => {
return audioContext;
};
+export function getClockBridge() {
+ if (!clockBridge) {
+ getAudioContext(); // creates clockBridge
+ }
+ return clockBridge;
+}
+
export function getAudioContextCurrentTime() {
return getAudioContext().currentTime;
}
diff --git a/packages/superdough/clockbridge.mjs b/packages/superdough/clockbridge.mjs
new file mode 100644
index 000000000..b2bbedb62
--- /dev/null
+++ b/packages/superdough/clockbridge.mjs
@@ -0,0 +1,41 @@
+/*
+clockbridge.mjs
+Copyright (C) 2022 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 .
+*/
+
+// thanks freya https://youtu.be/LSNQuFEDOyQ?si=ukZI2IGgWV_NDZzP&t=2979
+export function expDecay(a, b, decay, dt) {
+ return b + (a - b) * Math.exp(-decay * dt);
+}
+
+// translates between audio and performance clock
+export class ClockBridge {
+ p; // smoothed clock offset
+ lastTime;
+ audioContext;
+ constructor(audioContext) {
+ this.audioContext = audioContext;
+ }
+ // delta between audio and performance time in ms
+ getOffset() {
+ const { contextTime, performanceTime } = this.audioContext.getOutputTimestamp();
+ if (!contextTime || !performanceTime) {
+ return;
+ }
+ const offset = performanceTime - contextTime * 1000; // clock offset in ms
+ const dt = performanceTime - (this.lastTime ?? performanceTime); // delta time since last hap
+ const decay = 1 / 10000; // how fast offset changes have an effect
+ this.p = expDecay(this.p ?? offset, offset, decay, dt); // smooth clock offset
+ this.lastTime = performanceTime;
+ return this.p;
+ }
+ getPerformanceTime(audioContextTime) {
+ const offset = this.getOffset();
+ return audioContextTime * 1000 + offset; // this is now correct in performance time (ms)
+ }
+ getAudioContextTime(performanceTime) {
+ const offset = this.getOffset();
+ return (performanceTime - offset) / 1000; // this is now correct in audio context time (seconds)
+ }
+}
diff --git a/packages/superdough/index.mjs b/packages/superdough/index.mjs
index c3d2f6c52..1a3923c58 100644
--- a/packages/superdough/index.mjs
+++ b/packages/superdough/index.mjs
@@ -14,3 +14,4 @@ export * from './modulators.mjs';
export * from './dspworklet.mjs';
export * from './audioContext.mjs';
export * from './wavetable.mjs';
+export * from './clockbridge.mjs';
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1b086a3d0..faadf8c2c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -470,6 +470,9 @@ importers:
'@strudel/core':
specifier: workspace:*
version: link:../core
+ '@strudel/webaudio':
+ specifier: workspace:*
+ version: link:../webaudio
osc:
specifier: ^2.4.5
version: 2.4.5
From 5dbbc7b9e164215347abe58aae8da3383da36af2 Mon Sep 17 00:00:00 2001
From: Felix Roos
Date: Tue, 18 Aug 2026 13:19:47 +0200
Subject: [PATCH 85/91] basic dough integration with onTrigger
---
packages/dough/dough.mjs | 75 +++++++++++++++++++++++++++++++++++++
packages/dough/package.json | 42 +++++++++++++++++++++
pnpm-lock.yaml | 38 ++++++++++++++++++-
website/astro.config.mjs | 9 +++++
website/package.json | 1 +
website/src/repl/util.mjs | 1 +
6 files changed, 165 insertions(+), 1 deletion(-)
create mode 100644 packages/dough/dough.mjs
create mode 100644 packages/dough/package.json
diff --git a/packages/dough/dough.mjs b/packages/dough/dough.mjs
new file mode 100644
index 000000000..02d657d6f
--- /dev/null
+++ b/packages/dough/dough.mjs
@@ -0,0 +1,75 @@
+/*
+dough.mjs
+Copyright (C) 2022 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, noteToMidi } from '@strudel/core';
+import { Dough, doughsamples } from 'dough-synth';
+import { getAudioContext } from '@strudel/webaudio';
+import wasm from 'dough-synth/dough.wasm?url';
+import workletCode from 'dough-synth/dough.js?raw';
+
+Object.assign(globalThis, { doughsamples });
+
+let D;
+/**
+ *
+ * initializes dough ahead of time. you don't need to use this, but if you do, you can await it, so dough is ready before the pattern starts.
+ *
+ * @name initDough
+ * @tags external_io
+ * @memberof Pattern
+ * @example
+ * await initDough()
+ * $: chord("").offset("<-1 0 1 0>").voicing()
+ * .phaser(3).fm(1.4).fmh(1.01)
+ * .dough()
+ */
+export function initDough() {
+ if (!D || D.audioContext !== getAudioContext()) {
+ D = new Dough({
+ wasm,
+ workletCode,
+ audioContext: getAudioContext(),
+ });
+ }
+ return D.ready;
+}
+
+export async function doughTrigger(hap, _currentTime, cps = 1, targetTime) {
+ const offset = D.context_offset?.[0];
+ if (!offset) {
+ return; // not ready
+ }
+ hap.ensureObjectValue();
+ const event = {
+ dough: 'play',
+ ...hap.value,
+ time: targetTime - offset,
+ duration: hap.duration / cps,
+ };
+ if (typeof event.note === 'string') {
+ event.note = noteToMidi(event.note);
+ }
+ D.evaluate(event);
+}
+
+/**
+ *
+ * Uses dough as the audio engine. more info at https://dough.strudel.cc
+ *
+ * @name dough
+ * @tags external_io
+ * @memberof Pattern
+ * @returns Pattern
+ * @example
+ * $: chord("").offset("<-1 0 1 0>").voicing()
+ * .phaser(3).fm(1.4).fmh(1.01)
+ * .dough()
+ */
+export const dough = register('dough', (pat) => {
+ initDough();
+ ensureMinimalOutput();
+ return pat.onTrigger(doughTrigger);
+});
diff --git a/packages/dough/package.json b/packages/dough/package.json
new file mode 100644
index 000000000..abbb543be
--- /dev/null
+++ b/packages/dough/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "@strudel/dough",
+ "version": "1.3.2",
+ "description": "dough synth integration for strudel",
+ "main": "dough.mjs",
+ "type": "module",
+ "publishConfig": {
+ "main": "dist/index.mjs"
+ },
+ "scripts": {
+ "build": "vite build",
+ "prepublishOnly": "npm run build"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://codeberg.org/uzu/strudel.git"
+ },
+ "keywords": [
+ "tidalcycles",
+ "strudel",
+ "pattern",
+ "livecoding",
+ "algorave"
+ ],
+ "author": "Felix Roos ",
+ "license": "AGPL-3.0-or-later",
+ "bugs": {
+ "url": "https://codeberg.org/uzu/strudel/issues"
+ },
+ "homepage": "https://codeberg.org/uzu/strudel#readme",
+ "dependencies": {
+ "@strudel/core": "workspace:*",
+ "@strudel/webaudio": "workspace:*",
+ "dough-synth": "0.2.4"
+ },
+ "devDependencies": {
+ "vite": "^6.0.11"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1b086a3d0..f7911b435 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -303,6 +303,22 @@ importers:
specifier: ^2.2.0
version: 2.2.0
+ packages/dough:
+ dependencies:
+ '@strudel/core':
+ specifier: workspace:*
+ version: link:../core
+ '@strudel/webaudio':
+ specifier: workspace:*
+ version: link:../webaudio
+ dough-synth:
+ specifier: 0.2.4
+ version: 0.2.4
+ devDependencies:
+ vite:
+ specifier: ^6.0.11
+ version: 6.0.11(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0)
+
packages/draw:
dependencies:
'@strudel/core':
@@ -808,6 +824,9 @@ importers:
'@strudel/desktopbridge':
specifier: workspace:*
version: link:../packages/desktopbridge
+ '@strudel/dough':
+ specifier: workspace:*
+ version: link:../packages/dough
'@strudel/draw':
specifier: workspace:*
version: link:../packages/draw
@@ -2066,6 +2085,7 @@ packages:
'@lerna/create@8.1.9':
resolution: {integrity: sha512-DPnl5lPX4v49eVxEbJnAizrpMdMTBz1qykZrAbBul9rfgk531v8oAt+Pm6O/rpAleRombNM7FJb5rYGzBJatOQ==}
engines: {node: '>=18.0.0'}
+ deprecated: This package is an implementation detail of Lerna and is no longer published separately.
'@lezer/common@1.2.3':
resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==}
@@ -3065,6 +3085,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+ deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@vite-pwa/astro@0.5.0':
resolution: {integrity: sha512-Yd3Pug/c1EUQJXWvzYh6eTtoqzmSKcdCqWCcNquZeaD13tLWpBb2FIPJ4HMULVY6+GfxMvrT+OBuMrbHQCvftw==}
@@ -3716,6 +3737,7 @@ packages:
conventional-changelog-core@5.0.1:
resolution: {integrity: sha512-Rvi5pH+LvgsqGwZPZ3Cq/tz4ty7mjijhr3qR4m9IBXNbxGGYgTVVO+duXzz9aArmHxFtwZ+LRkrNIMDQzgoY4A==}
engines: {node: '>=14'}
+ deprecated: Deprecated and no longer maintained. Please use conventional-changelog instead.
conventional-changelog-preset-loader@3.0.0:
resolution: {integrity: sha512-qy9XbdSLmVnwnvzEisjxdDiLA4OmV3o8db+Zdg4WiFw14fP3B6XNz98X0swPPpkTd/pc1K7+adKgEDM1JCUMiA==}
@@ -4047,6 +4069,10 @@ packages:
resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==}
engines: {node: '>=12'}
+ dough-synth@0.2.4:
+ resolution: {integrity: sha512-qMnMKVj1B2ivUgobNCwqJqNzZHOMN+5339OmCOk07zIDEK7iCYdufa+ZDE2BEgmKrWDyhGS7oTNFjHtTwzEv9w==}
+ hasBin: true
+
dset@3.1.4:
resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==}
engines: {node: '>=4'}
@@ -4579,6 +4605,7 @@ packages:
git-raw-commits@3.0.0:
resolution: {integrity: sha512-b5OHmZ3vAgGrDn/X0kS+9qCfNKWe4K/jFnhwzVWWg0/k5eLa3060tZShrRg8Dja5kPc+YjS0Gc6y7cRr44Lpjw==}
engines: {node: '>=14'}
+ deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.
hasBin: true
git-remote-origin-url@2.0.0:
@@ -4588,6 +4615,7 @@ packages:
git-semver-tags@5.0.1:
resolution: {integrity: sha512-hIvOeZwRbQ+7YEUmCkHqo8FOLQZCEn18yevLHADlFPZY02KJGsu5FZt9YW/lybfK2uhWFI7Qg/07LekJiTv7iA==}
engines: {node: '>=14'}
+ deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.
hasBin: true
git-up@7.0.0:
@@ -4615,15 +4643,17 @@ packages:
glob@10.4.5:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
- deprecated: Glob versions prior to v9 are no longer supported
+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
glob@9.3.5:
resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==}
engines: {node: '>=16 || 14 >=14.17'}
+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
globalize@0.1.1:
resolution: {integrity: sha512-5e01v8eLGfuQSOvx2MsDMOWS0GFtCx1wPzQSmcHw4hkxFzrQDBO3Xwg/m8Hr/7qXMrHeOIE29qWVzyv06u1TZA==}
@@ -6406,6 +6436,7 @@ packages:
prebuild-install@7.1.1:
resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==}
engines: {node: '>=10'}
+ deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
precinct@12.1.2:
@@ -7230,6 +7261,7 @@ packages:
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
+ deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
temp-dir@1.0.0:
resolution: {integrity: sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==}
@@ -7357,6 +7389,7 @@ packages:
tsconfck@3.1.4:
resolution: {integrity: sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==}
engines: {node: ^18 || >=20}
+ deprecated: unmaintained
hasBin: true
peerDependencies:
typescript: ^5.0.0
@@ -7636,6 +7669,7 @@ packages:
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
+ deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
validate-npm-package-license@3.0.4:
@@ -11757,6 +11791,8 @@ snapshots:
dotenv@16.4.7: {}
+ dough-synth@0.2.4: {}
+
dset@3.1.4: {}
dunder-proto@1.0.1:
diff --git a/website/astro.config.mjs b/website/astro.config.mjs
index e62f13c7f..de08502dd 100644
--- a/website/astro.config.mjs
+++ b/website/astro.config.mjs
@@ -145,4 +145,13 @@ export default defineConfig({
// external: ['fraction.js'], // https://github.com/infusion/Fraction.js/issues/51
},
},
+ server: {
+ // these are needed for dough, which uses shared memory
+ // see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements
+ // this is only for the dev server, so the server strudel runs on, also needs those
+ headers: {
+ 'Cross-Origin-Opener-Policy': 'same-origin',
+ 'Cross-Origin-Embedder-Policy': 'credentialless',
+ },
+ },
});
diff --git a/website/package.json b/website/package.json
index 7258f150e..3bc8b7bce 100644
--- a/website/package.json
+++ b/website/package.json
@@ -41,6 +41,7 @@
"@strudel/motion": "workspace:*",
"@strudel/mqtt": "workspace:*",
"@strudel/osc": "workspace:*",
+ "@strudel/dough": "workspace:*",
"@strudel/serial": "workspace:*",
"@strudel/soundfonts": "workspace:*",
"@strudel/tidal": "workspace:*",
diff --git a/website/src/repl/util.mjs b/website/src/repl/util.mjs
index c141257f9..bd1a85f24 100644
--- a/website/src/repl/util.mjs
+++ b/website/src/repl/util.mjs
@@ -85,6 +85,7 @@ export function loadModules() {
import('@strudel/motion'),
import('@strudel/mqtt'),
import('@strudel/mondo'),
+ import('@strudel/dough'),
];
if (isTauri()) {
modules = modules.concat([
From d6f0b863f06ee3840a99b7f57f938eae8c59c69c Mon Sep 17 00:00:00 2001
From: Felix Roos
Date: Tue, 18 Aug 2026 20:26:29 +0200
Subject: [PATCH 86/91] avoid "doughsamples" name collision with old supradough
---
packages/webaudio/supradough.mjs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs
index f97251a07..042a002c5 100644
--- a/packages/webaudio/supradough.mjs
+++ b/packages/webaudio/supradough.mjs
@@ -114,7 +114,7 @@ async function fetchSample(url) {
return { channels, sampleRate: buffer.sampleRate };
}
-export async function doughsamples(sampleMap, baseUrl) {
+export async function supradoughsamples(sampleMap, baseUrl) {
if (typeof sampleMap === 'string') {
const [json, base] = await fetchSampleMap(sampleMap);
// console.log('json', json, 'base', base);
From f1dd2097494d0cb519c6e384c92fca0a76f6ff8d Mon Sep 17 00:00:00 2001
From: Felix Roos
Date: Tue, 18 Aug 2026 20:29:23 +0200
Subject: [PATCH 87/91] fix ci check errors
---
packages/dough/dough.mjs | 2 +-
packages/webaudio/supradough.mjs | 2 +-
test/__snapshots__/examples.test.mjs.snap | 48 +++++++++++++++++++++++
test/runtime.mjs | 1 +
4 files changed, 51 insertions(+), 2 deletions(-)
diff --git a/packages/dough/dough.mjs b/packages/dough/dough.mjs
index 02d657d6f..908138bae 100644
--- a/packages/dough/dough.mjs
+++ b/packages/dough/dough.mjs
@@ -6,7 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th
import { register, noteToMidi } from '@strudel/core';
import { Dough, doughsamples } from 'dough-synth';
-import { getAudioContext } from '@strudel/webaudio';
+import { getAudioContext, ensureMinimalOutput } from '@strudel/webaudio';
import wasm from 'dough-synth/dough.wasm?url';
import workletCode from 'dough-synth/dough.js?raw';
diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs
index 042a002c5..d55957ced 100644
--- a/packages/webaudio/supradough.mjs
+++ b/packages/webaudio/supradough.mjs
@@ -118,7 +118,7 @@ export async function supradoughsamples(sampleMap, baseUrl) {
if (typeof sampleMap === 'string') {
const [json, base] = await fetchSampleMap(sampleMap);
// console.log('json', json, 'base', base);
- return doughsamples(json, base);
+ return supradoughsamples(json, base);
}
Object.entries(sampleMap).map(async ([key, urls]) => {
if (key !== '_base') {
diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap
index 539843f49..ccb91377f 100644
--- a/test/__snapshots__/examples.test.mjs.snap
+++ b/test/__snapshots__/examples.test.mjs.snap
@@ -3422,6 +3422,30 @@ exports[`runs examples > example "djf" example index 0 1`] = `
]
`;
+exports[`runs examples > example "dough" example index 0 1`] = `
+[
+ "[ 0/1 → 1/1 | note:F3 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 0/1 → 1/1 | note:C4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 0/1 → 1/1 | note:D4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 0/1 → 1/1 | note:E4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 0/1 → 1/1 | note:A4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 1/1 → 2/1 | note:A3 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 1/1 → 2/1 | note:D4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 1/1 → 2/1 | note:G4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 1/1 → 2/1 | note:C5 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 2/1 → 3/1 | note:A4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 2/1 → 3/1 | note:C5 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 2/1 → 3/1 | note:D5 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 2/1 → 3/1 | note:F5 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 2/1 → 3/1 | note:C6 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 3/1 → 4/1 | note:A3 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 3/1 → 4/1 | note:D4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 3/1 → 4/1 | note:E4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 3/1 → 4/1 | note:F4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 3/1 → 4/1 | note:C5 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+]
+`;
+
exports[`runs examples > example "drawLine" example index 0 1`] = `[]`;
exports[`runs examples > example "drive" example index 0 1`] = `
@@ -5930,6 +5954,30 @@ exports[`runs examples > example "inhabit" example index 1 1`] = `
]
`;
+exports[`runs examples > example "initDough" example index 0 1`] = `
+[
+ "[ 0/1 → 1/1 | note:F3 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 0/1 → 1/1 | note:C4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 0/1 → 1/1 | note:D4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 0/1 → 1/1 | note:E4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 0/1 → 1/1 | note:A4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 1/1 → 2/1 | note:A3 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 1/1 → 2/1 | note:D4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 1/1 → 2/1 | note:G4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 1/1 → 2/1 | note:C5 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 2/1 → 3/1 | note:A4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 2/1 → 3/1 | note:C5 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 2/1 → 3/1 | note:D5 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 2/1 → 3/1 | note:F5 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 2/1 → 3/1 | note:C6 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 3/1 → 4/1 | note:A3 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 3/1 → 4/1 | note:D4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 3/1 → 4/1 | note:E4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 3/1 → 4/1 | note:F4 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+ "[ 3/1 → 4/1 | note:C5 phaserrate:3 fmi:1.4 fmh:1.01 ]",
+]
+`;
+
exports[`runs examples > example "inside" example index 0 1`] = `
[
"[ 0/1 → 1/8 | note:D3 ]",
diff --git a/test/runtime.mjs b/test/runtime.mjs
index e06f91620..521fb6474 100644
--- a/test/runtime.mjs
+++ b/test/runtime.mjs
@@ -174,6 +174,7 @@ evalScope(
getDuration,
setcps: id,
setcpm: id,
+ initDough: id,
Clock: {}, // whatever
},
);
From d65b24db8f09ed15ddc354dcf50a643333e7b9f6 Mon Sep 17 00:00:00 2001
From: Felix Roos
Date: Tue, 18 Aug 2026 20:44:35 +0200
Subject: [PATCH 88/91] rename old dough function to rawdsp + mock dough in
test runtime
---
packages/superdough/dspworklet.mjs | 4 ++--
packages/webaudio/webaudio.mjs | 6 +++---
test/runtime.mjs | 1 +
undocumented.json | 4 ++--
4 files changed, 8 insertions(+), 7 deletions(-)
diff --git a/packages/superdough/dspworklet.mjs b/packages/superdough/dspworklet.mjs
index aac08061e..ed6d0c7b3 100644
--- a/packages/superdough/dspworklet.mjs
+++ b/packages/superdough/dspworklet.mjs
@@ -67,13 +67,13 @@ if (typeof window !== 'undefined') {
});
}
-export const dough = async (code) => {
+export const rawdsp = async (code) => {
const ac = getAudioContext();
stop();
worklet = await dspWorklet(ac, code);
worklet.node.connect(ac.destination);
};
-export function doughTrigger(hap, currentTime, cps, targetTime) {
+export function rawdspTrigger(hap, currentTime, cps, targetTime) {
window.postMessage({ time: targetTime, dough: hap.value, currentTime, duration: hap.duration, cps });
}
diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs
index 54ee9bcb3..4f7256fe7 100644
--- a/packages/webaudio/webaudio.mjs
+++ b/packages/webaudio/webaudio.mjs
@@ -9,7 +9,7 @@ import {
superdough,
getAudioContext,
setLogger,
- doughTrigger,
+ rawdspTrigger,
registerWorklet,
setAudioContext,
initAudio,
@@ -173,8 +173,8 @@ export function webaudioRepl(options = {}) {
return repl(options);
}
-Pattern.prototype.dough = function () {
- return this.onTrigger(doughTrigger, 1);
+Pattern.prototype.rawdsp = function () {
+ return this.onTrigger(rawdspTrigger, 1);
};
function audioBufferToWav(buffer, opt) {
diff --git a/test/runtime.mjs b/test/runtime.mjs
index 521fb6474..e8f346df7 100644
--- a/test/runtime.mjs
+++ b/test/runtime.mjs
@@ -99,6 +99,7 @@ const toneHelpersMocked = {
'_spectrum',
'markcss',
'p',
+ 'dough',
].forEach((mock) => {
strudel.Pattern.prototype[mock] = function () {
return this;
diff --git a/undocumented.json b/undocumented.json
index 3f5a3be30..5e4116871 100644
--- a/undocumented.json
+++ b/undocumented.json
@@ -597,8 +597,8 @@
],
"/packages/superdough/dspworklet.mjs": [
"dspWorklet",
- "dough",
- "doughTrigger"
+ "rawdsp",
+ "rawdspTrigger"
],
"/packages/superdough/index.mjs": [],
"/packages/tonal/tonleiter.mjs": [
From 8120fc72ab47df9e1cffdf6a886d1daecd02ee9b Mon Sep 17 00:00:00 2001
From: Felix Roos
Date: Wed, 19 Aug 2026 18:09:05 +0200
Subject: [PATCH 89/91] dummy commit to force service worker refresh
---
website/src/components/HeadCommon.astro | 2 +-
website/src/components/HeadCommonNext.astro | 2 +-
website/src/components/HeadSEO.astro | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/website/src/components/HeadCommon.astro b/website/src/components/HeadCommon.astro
index 550222e44..57a425f3f 100644
--- a/website/src/components/HeadCommon.astro
+++ b/website/src/components/HeadCommon.astro
@@ -24,7 +24,7 @@ const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL
-
+
diff --git a/website/src/components/HeadCommonNext.astro b/website/src/components/HeadCommonNext.astro
index f477cd8f0..852c2051d 100644
--- a/website/src/components/HeadCommonNext.astro
+++ b/website/src/components/HeadCommonNext.astro
@@ -23,7 +23,7 @@ const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL
-
+
diff --git a/website/src/components/HeadSEO.astro b/website/src/components/HeadSEO.astro
index 413668c16..ecaedd134 100644
--- a/website/src/components/HeadSEO.astro
+++ b/website/src/components/HeadSEO.astro
@@ -16,7 +16,7 @@ const imageAlt = frontmatter.image?.alt ?? OPEN_GRAPH.image.alt;
-
+
From 38956e4d160975c863ee2aa135bd933c1f02610a Mon Sep 17 00:00:00 2001
From: scrappy_fiddler
Date: Sun, 30 Aug 2026 11:17:51 +0200
Subject: [PATCH 90/91] now with prettier FAQ
---
website/src/pages/learn/faq.mdx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/website/src/pages/learn/faq.mdx b/website/src/pages/learn/faq.mdx
index 7764d91e2..231b12650 100644
--- a/website/src/pages/learn/faq.mdx
+++ b/website/src/pages/learn/faq.mdx
@@ -340,8 +340,8 @@ _ before a stack name: mutes the stack, i.e. hush(), for example _$: s("bd"),
## What is a 'hap'?
-A 'hap' is a name for a thing that happens, e.g. ```note("c3 -")``` playing a note and then stopping after a while. Haps have a start and an end. Every separate sound that is produced by strudel is a hap on its own.
+A 'hap' is a name for a thing that happens, e.g. `note("c3 -")` playing a note and then stopping after a while. Haps have a start and an end. Every separate sound that is produced by strudel is a hap on its own.
We would have called it 'event' if this particular name wasn't used by JavaScript to describe things that happen on html pages, like clicking on a button or hovering over a line of text.
-Since strudel is written in JavaScript, this confusion of having the same name for different things is avoided by calling events 'haps'.
\ No newline at end of file
+Since strudel is written in JavaScript, this confusion of having the same name for different things is avoided by calling events 'haps'.
From a1be663b157532759ed243be7aea98d3788ce890 Mon Sep 17 00:00:00 2001
From: Tijmen Zwaan
Date: Mon, 28 Sep 2026 23:42:51 +0200
Subject: [PATCH 91/91] Add error handling to stack so non-broken patterns
continue playing
---
packages/core/pattern.mjs | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs
index 0f5083901..55e7ad73c 100644
--- a/packages/core/pattern.mjs
+++ b/packages/core/pattern.mjs
@@ -1449,7 +1449,19 @@ export function sequenceP(pats) {
export function stack(...pats) {
// Array test here is to avoid infinite recursions..
pats = pats.map((pat) => (Array.isArray(pat) ? sequence(...pat) : reify(pat)));
- const query = (state) => flatten(pats.map((pat) => pat.query(state)));
+ const query = (state) =>
+ flatten(
+ pats.map((pat) => {
+ try {
+ // An error occurring in one part of the stack should not break
+ // playback of the other parts of the stack.
+ return pat.query(state);
+ } catch (e) {
+ errorLogger(e);
+ return [];
+ }
+ }),
+ );
const result = new Pattern(query);
if (__steps) {
result._steps = lcm(...pats.map((pat) => pat._steps));