diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b8a941fc1..5a70ce2db 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -52,7 +52,17 @@ 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 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 understand our aims and values.
## Report a Bug
diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs
index bf9d8ce0b..0f5083901 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,
);
@@ -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.
@@ -1557,11 +1555,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 = 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_n = _mod(Math.floor(state.span.begin), pats.length);
+ const pat = pats[pat_n];
+ return pat.query(state);
};
return new Pattern(query).splitQueries();
}
@@ -3359,6 +3360,11 @@ Pattern.prototype.shrinklist = function (amount) {
export const shrinklist = (amount, pat) => pat.shrinklist(amount);
+Pattern.prototype.growlist = function (amount) {
+ return this.shrinklist(amount).reverse();
+};
+export const growlist = (amount, pat) => pat.growlist(amount);
+
/**
* *Experimental*
*
@@ -4127,3 +4133,59 @@ Pattern.prototype.worklet = function (src, ...inputs) {
};
export const worklet = (...args) => pure({}).worklet(...args);
+
+/**
+ * Creates a pattern of numbers in base b from a number or pattern of numbers
+ * limited to d digits long from the right
+ *
+ * @name base
+ * @tags generators
+ * @param {number} n - number to convert (can be a pattern or array)
+ * @param {number} b - base to convert to (defaults to 10) (can be a pattern)
+ * @param {number} d - max number of digits to produce for each n (defaults to 0 for all) (can be a pattern)
+ * @example
+ * $: note(base("7175 543", 10, 3)).scale("c:major").s("saw")
+ * // $: note("1 7 5 5 4 3").scale("c:major").s("saw")
+ */
+export const base = (n, b = 10, d = 0) => {
+ if (Array.isArray(n)) {
+ n = sequence(n);
+ }
+ n = reify(n);
+ b = reify(b);
+ d = reify(d);
+
+ return d
+ .withValue((e) => {
+ return b
+ .withValue((c) => {
+ return n
+ .withValue((v) => {
+ let digits = [];
+ let value = v;
+ while (value > 0) {
+ digits.unshift(value % c);
+ value = Math.floor(value / c);
+ }
+ if (e) {
+ const l = digits.length;
+ if (l > e) {
+ digits = digits.slice(-1 * e);
+ }
+ /*
+ if (l < e){
+ for (let i = l; i < e; i++) {
+ digits.unshift("~");//0); //Would like to be padding this but ~- doesn't work
+ }
+ console.log("digits", digits);
+ }
+ */
+ }
+ return sequence(digits);
+ })
+ .squeezeJoin();
+ })
+ .squeezeJoin();
+ })
+ .squeezeJoin();
+};
diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs
index 4398e2ab7..b86f16e8a 100644
--- a/packages/core/signal.mjs
+++ b/packages/core/signal.mjs
@@ -6,6 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th
import { Hap } from './hap.mjs';
import { Pattern, fastcat, pure, register, reify, silence, stack, sequenceP } from './pattern.mjs';
+import { _mod } from './util.mjs';
import Fraction from './fraction.mjs';
import { id, keyAlias, getCurrentKeyboardState } from './util.mjs';
@@ -33,7 +34,7 @@ export const signal = (func) => {
* .scale('C major')
*
*/
-export const saw = signal((t) => t % 1);
+export const saw = signal((t) => _mod(t, 1));
/**
* A sawtooth signal between -1 and 1 (like `saw`, but bipolar).
@@ -56,7 +57,7 @@ export const saw2 = saw.toBipolar();
* .scale('C major')
*
*/
-export const isaw = signal((t) => 1 - (t % 1));
+export const isaw = signal((t) => 1 - _mod(t, 1));
/**
* A sawtooth signal between 1 and -1 (like `saw2`, but flipped).
@@ -113,7 +114,7 @@ export const cosine2 = sine2._early(Fraction(1).div(4));
* n(square.segment(4).range(0,7)).scale("C:minor")
*
*/
-export const square = signal((t) => Math.floor((t * 2) % 2));
+export const square = signal((t) => Math.floor(_mod(t * 2, 2)));
/**
* A square signal between -1 and 1 (like `square`, but bipolar).
@@ -123,6 +124,22 @@ export const square = signal((t) => Math.floor((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.
*
@@ -396,7 +413,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);
};
diff --git a/packages/core/test/pattern.test.mjs b/packages/core/test/pattern.test.mjs
index aaa14bd27..4eb18c647 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,
@@ -45,6 +46,7 @@ import {
rev,
time,
run,
+ randrun,
binaryN,
pick,
stackLeft,
@@ -53,6 +55,7 @@ import {
stepcat,
sometimes,
expand,
+ arp,
} from '../index.mjs';
import { log, logValues } from '../pattern.mjs';
@@ -544,6 +547,9 @@ describe('Pattern', () => {
});
});
describe('slowcat()', () => {
+ it('Can be empty', () => {
+ expect(slowcat().firstCycle()).toStrictEqual([]);
+ });
it('Can concatenate things slowly', () => {
expect(
slowcat('a', 'b')
@@ -576,6 +582,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(
@@ -945,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', () => {
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', () => {
diff --git a/packages/core/util.mjs b/packages/core/util.mjs
index c38c8e05f..caf2bc054 100644
--- a/packages/core/util.mjs
+++ b/packages/core/util.mjs
@@ -137,7 +137,7 @@ export const getFrequency = (hap) => {
if (value.freq) {
return value.freq;
}
- return getFreq(value.note || value.n || value.value);
+ return getFreq(value.note ?? value.n ?? value.value);
}
if (typeof value === 'number' && context.type !== 'frequency') {
value = midiToFreq(hap.value);
diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs
index bd40b743e..5880ca268 100644
--- a/packages/midi/midi.mjs
+++ b/packages/midi/midi.mjs
@@ -537,6 +537,9 @@ export async function midin(input) {
* The note length is fixed as Superdough is not currently set up for undetermined
* note durations
*
+ * The 'midichan' control value contains the number of the channel the note is coming from
+ * so it could be filtered or manipulated further in the chain.
+ *
* @name midikeys
* @tags external_io, midi
* @param {string | number} input MIDI device name or index defaulting to 0
@@ -552,6 +555,10 @@ export async function midin(input) {
* .s("saw")
* .add(note(rand.mul(0.3)))
* .lpf(1000).lpe(2).room(0.5)
+ * @example
+ * // discard all notes not coming out from midi channel 2
+ * const kb = await midikeys('Arturia KeyStep 32')
+ * kb().filterValues(v=>v.midichan==2).s("tri")
*/
const kHaps = {};
const kListeners = {};
@@ -633,7 +640,7 @@ export async function midikeys(input) {
*/
return;
} else {
- value = { ...value, note: Math.round(note), velocity: velocity / 127 };
+ value = { ...value, note: Math.round(note), velocity: velocity / 127, midichan: message.channel };
}
kHaps[input].push(new Hap(span, span, value, {}));
if (!noteoff && triggerAvailable) {
diff --git a/packages/mqtt/mqtt.mjs b/packages/mqtt/mqtt.mjs
index 96659c67b..c0a958cfd 100644
--- a/packages/mqtt/mqtt.mjs
+++ b/packages/mqtt/mqtt.mjs
@@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see .
*/
-import { Pattern, isPattern } from '@strudel/core';
+import { Pattern, isPattern, createParams } from '@strudel/core';
import Paho from 'paho-mqtt';
const connections = {};
@@ -118,3 +118,12 @@ Pattern.prototype.mqtt = function (
return hap.setContext({ ...hap.context, onTrigger, dominantTrigger: true });
});
};
+
+// This adds the 'move' and 'motor' commands to strudel
+export const { move, motor } = createParams('move', 'motor');
+window.move = move;
+window.motor = motor;
+// This adds the 'robot' command
+Pattern.prototype.robot = function (robot_id, address = 'ws://192.168.8.248:9001/mqtt') {
+ return this.mqtt(undefined, undefined, '/move/' + robot_id, address);
+};
diff --git a/packages/superdough/audioContext.mjs b/packages/superdough/audioContext.mjs
index 94ec32d15..6a701d9ad 100644
--- a/packages/superdough/audioContext.mjs
+++ b/packages/superdough/audioContext.mjs
@@ -7,14 +7,21 @@ 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();
+ 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);
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;
};
diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs
index 5377013e2..54ee9bcb3 100644
--- a/packages/webaudio/webaudio.mjs
+++ b/packages/webaudio/webaudio.mjs
@@ -56,32 +56,14 @@ 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()
+ // 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' });
@@ -102,6 +84,84 @@ 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;
+
+ logger('[webaudio] start rendering');
+
+ while (currentCycle <= end) {
+ const chunkStart = currentCycle;
+ const chunkEnd = Math.min(currentCycle + chunkSizeInCycles, end);
+
+ logger(`[webaudio] preloading cycles ${chunkStart} - ${chunkEnd}`);
+
+ await scheduleHapsChunk(pattern, cps, begin, chunkStart, chunkEnd);
+
+ 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;
+}
+
+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);
diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap
index f7989dc7e..9ff81763c 100644
--- a/test/__snapshots__/examples.test.mjs.snap
+++ b/test/__snapshots__/examples.test.mjs.snap
@@ -1242,6 +1242,35 @@ exports[`runs examples > example "bank" example index 0 1`] = `
]
`;
+exports[`runs examples > example "base" example index 0 1`] = `
+[
+ "[ 0/1 → 1/6 | note:D3 s:saw ]",
+ "[ 1/6 → 1/3 | note:C4 s:saw ]",
+ "[ 1/3 → 1/2 | note:A3 s:saw ]",
+ "[ 1/2 → 2/3 | note:A3 s:saw ]",
+ "[ 2/3 → 5/6 | note:G3 s:saw ]",
+ "[ 5/6 → 1/1 | note:F3 s:saw ]",
+ "[ 1/1 → 7/6 | note:D3 s:saw ]",
+ "[ 7/6 → 4/3 | note:C4 s:saw ]",
+ "[ 4/3 → 3/2 | note:A3 s:saw ]",
+ "[ 3/2 → 5/3 | note:A3 s:saw ]",
+ "[ 5/3 → 11/6 | note:G3 s:saw ]",
+ "[ 11/6 → 2/1 | note:F3 s:saw ]",
+ "[ 2/1 → 13/6 | note:D3 s:saw ]",
+ "[ 13/6 → 7/3 | note:C4 s:saw ]",
+ "[ 7/3 → 5/2 | note:A3 s:saw ]",
+ "[ 5/2 → 8/3 | note:A3 s:saw ]",
+ "[ 8/3 → 17/6 | note:G3 s:saw ]",
+ "[ 17/6 → 3/1 | note:F3 s:saw ]",
+ "[ 3/1 → 19/6 | note:D3 s:saw ]",
+ "[ 19/6 → 10/3 | note:C4 s:saw ]",
+ "[ 10/3 → 7/2 | note:A3 s:saw ]",
+ "[ 7/2 → 11/3 | note:A3 s:saw ]",
+ "[ 11/3 → 23/6 | note:G3 s:saw ]",
+ "[ 23/6 → 4/1 | note:F3 s:saw ]",
+]
+`;
+
exports[`runs examples > example "beat" example index 0 1`] = `
[
"[ 0/1 → 1/16 | s:bd ]",
@@ -7927,6 +7956,8 @@ exports[`runs examples > example "midikeys" example index 0 1`] = `[]`;
exports[`runs examples > example "midikeys" example index 1 1`] = `[]`;
+exports[`runs examples > example "midikeys" example index 2 1`] = `[]`;
+
exports[`runs examples > example "midin" example index 0 1`] = `
[
"[ 0/1 → 1/4 | note:c cutoff:0 resonance:0 s:sawtooth ]",
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 ]",
diff --git a/website/astro.config.mjs b/website/astro.config.mjs
index 867635883..e62f13c7f 100644
--- a/website/astro.config.mjs
+++ b/website/astro.config.mjs
@@ -78,6 +78,7 @@ export default defineConfig({
urlPattern: ({ url }) =>
[
/^https:\/\/raw\.githubusercontent\.com\/.*/i,
+ /^https:\/\/strudel\.b-cdn\.net\/.*/i,
/^https:\/\/freesound\.org\/.*/i,
/^https:\/\/cdn\.freesound\.org\/.*/i,
/^https:\/\/shabda\.ndre\.gr\/.*/i,
diff --git a/website/src/pages/learn/csound.mdx b/website/src/pages/learn/csound.mdx
index 4ef04419a..072b116ef 100644
--- a/website/src/pages/learn/csound.mdx
+++ b/website/src/pages/learn/csound.mdx
@@ -150,5 +150,3 @@ In the future, the integration could be improved by passing all patterned contro
This could work by a unique [channel](https://kunstmusik.github.io/icsc2022-csound-web/tutorial2-interacting-with-csound/#step-4---writing-continuous-data-channels)
for each value. Channels could be read [like this](https://github.com/csound/csound/blob/master/Android/CsoundForAndroid/CsoundAndroidExamples/src/main/res/raw/multitouch_xy.csd).
Also, it might make sense to have a standard library of csound instruments for strudel's effects.
-
-Now, let's dive into the [Functional JavaScript API](/functions/intro)
diff --git a/website/src/pages/learn/faq.mdx b/website/src/pages/learn/faq.mdx
index fc3bcdb2d..3f9dc730b 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 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.
+
## 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.
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.
diff --git a/website/src/pages/workshop/motors.mdx b/website/src/pages/workshop/motors.mdx
new file mode 100644
index 000000000..761763be8
--- /dev/null
+++ b/website/src/pages/workshop/motors.mdx
@@ -0,0 +1,117 @@
+---
+title: Movement with Strudel
+layout: ../../layouts/MainLayout.astro
+---
+
+import { MiniRepl } from '@src/docs/MiniRepl';
+import Box from '@components/Box.astro';
+import QA from '@components/QA';
+
+# Controlling motors with Strudel
+
+Strudel is mainly made for making music, but it's possible to pattern other things with it, including motors.
+
+We're going to use a microcontroller for this, called an "[Inventor 2040W](https://shop.pimoroni.com/products/inventor-2040-w)", which is
+a [Pico W](https://shop.pimoroni.com/products/inventor-2040-w?variant=40053063155795) with extra ports added including some for controlling motors.
+
+
+
+## Technical details
+
+Feel free to gloss over these!
+
+- The Inventor 2040W connects to the internet wirelessly, and it can power from a battery or USB. Hopefully the batteries last!
+- It's running [some code](https://github.com/patternclub/alpacalab/blob/main/course/main.py) that listens for messages using an "Internet of Things" network protocol (called MQTT). When it receives a message, it moves a motor.
+- It connects to a small server (running software called 'mosquitto') on Alex's laptop.
+- Strudel can send these messages instead of triggering sounds - that's how we use it to pattern movement.
+
+## First movement
+
+Let's get a motor running!
+
+1. Note the letter drawn on a label on the back of the microcontroller.
+
+2. Plug a battery into your microcontroller.
+
+3. Plug a motor into 'servo' (not motor) plug numbered 1, with the yellow (lightest) cable closest to the '1', and the brown (darkest) cable outward
+
+4. Run the below to set up some values, changing the `x` in 'robot('x')` to the letter on your microcontroller.
+
+
+
+
+
+If you refresh the page, you'll need to change the letter to match your robot again.
+
+If your motor starts moving unexpectedly, someone else might have put your letter in by mistake!
+
+Note that in the above, we start counting motors from '0', so motor 1 on the board is motor 0 in the code.
+
+
+
+## Patterning movement
+
+Many strudel features for playing with sound patterns will work when
+playing with motor patterns. Try playing with the mininotation in the
+`move` command:
+
+
+
+The move instructions are in the range from -90 to 90.
+
+
+ If your motors stop working at some point, and your code looks right, try pressing the 'reset' button on the
+ microcontroller.
+
+
+It's possible to make smooth movements based on different 'waveforms', for example a smooth sinewave:
+
+
+
+The movement is still quite jerky, because the 'segment' command is
+only taking 16 positions from the sinewave. Try increasing it to 32 or 64. It's best not too much higher than that, as the microcontroller
+might get overwhelmed with a backlog of instructions!
+
+
+ Try replacing `sine` with other waveforms: `saw` (sawtooth wave), `tri` (triangular wave) are good, and there is also
+ `rand` (random wave) and `perlin` (a kind of smoothed-out randomness).
+
+
+## Patterning more than one motor
+
+You can pattern the `motor` command separately from the `move` one:
+
+
+
+Alternatively, you can pattern two motors in separate patterns. The below sends the same pattern for the first two motors, but with the second one running slower:
+
+
diff --git a/website/src/repl/components/panel/WelcomeTab.jsx b/website/src/repl/components/panel/WelcomeTab.jsx
index 9d78edf85..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:
- 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