Compare commits

...

85 Commits

Author SHA1 Message Date
Felix Roos d65b24db8f rename old dough function to rawdsp + mock dough in test runtime 2026-08-18 20:44:35 +02:00
Felix Roos f1dd209749 fix ci check errors 2026-08-18 20:29:23 +02:00
Felix Roos d6f0b863f0 avoid "doughsamples" name collision with old supradough 2026-08-18 20:26:29 +02:00
Felix Roos 5dbbc7b9e1 basic dough integration with onTrigger 2026-08-18 13:19:47 +02:00
froos c7db8eaad6 Merge pull request 'fix: accurate midi scheduling on chromium' (#2097) from midi-timing into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2097
2026-08-16 11:31:32 +02:00
Felix Roos 723cd5da04 lower noteOffsetMs on chromium + update comment 2026-08-16 10:59:10 +02:00
Felix Roos 98d19a6643 format 2026-08-16 10:21:31 +02:00
Felix Roos ef106b356f make smoothing rate independent (thanks freya) 2026-08-16 09:41:45 +02:00
Felix Roos ee460dc980 fix longer running midi-only drift with a dummy node 2026-08-15 17:10:18 +02:00
froos d5d6bc6f34 Merge pull request 'Don't run sample server if outputting JSON' (#2094) from muffinista/strudel:no-server-when-json-output into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2094
2026-08-14 00:47:58 +02:00
froos d9336aba3a Merge pull request 'Add target to remote links in reference section.' (#2089) from emily_s/strudel:add-target-to-remote-links into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2089
2026-08-14 00:22:47 +02:00
froos 263c9c0253 Merge pull request 'osc: remove bank+sound concatenation since SuperDirt does it now' (#2077) from pulu/strudel:remove-osc-bank-sound-concat into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2077
2026-08-13 23:40:54 +02:00
froos 6ecdc739b4 Merge pull request 'transpose: default octave' (#2033) from tyow/localstrudel:transpose-default-note into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2033
2026-08-13 23:35:07 +02:00
froos f67e81a03f Merge pull request 'mode-above-fix' (#2062) from eefano/strudel:mode-above-fix into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2062
2026-08-13 22:40:41 +02:00
froos 5cf7571b77 Merge branch 'main' into mode-above-fix 2026-08-13 22:37:20 +02:00
froos b2aeaa56c1 Merge pull request 'Fix falsy value check in getFreqeuncy' (#2054) from jouae/strudel:fix-falsy-check into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2054
2026-08-13 21:54:47 +02:00
froos 9e3792f6a6 Merge pull request 'Remove outdated continuation link' (#2053) from ethancrawford/strudel:update_csound_docs into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2053
2026-08-13 21:34:28 +02:00
froos f8ed9c3a7c Merge pull request 'Fix wav export on firefox' (#2101) from tzwaan/fix-firefox-export into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2101
2026-08-13 19:58:42 +02:00
Tijmen Zwaan c5b42b5555 Fix webAudioTimeout connecting the wrong AudioContext after export 2026-08-13 19:26:52 +02:00
Tijmen Zwaan 75f58bc875 Fix wav export on firefox 2026-08-13 19:26:05 +02:00
Alex McLean 3b037deb10 Merge pull request 'learn/lfx: Add missing word' (#2096) from wjt/strudel:lfo-missing-word into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2096
2026-08-13 08:52:30 +02:00
Alex McLean 362b2df390 Merge pull request 'Add tests for randrun and negative time test for run' (#2098) from tzwaan/add-randrun-test into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2098
2026-08-13 08:51:30 +02:00
Tijmen Zwaan 20d925c10c Add tests for randrun and negative time test for run 2026-08-13 04:13:28 +02:00
Alex McLean 91f37accdd Merge pull request 'Fix multiple cases where % was used instead of _mod()' (#2092) from tzwaan/saw-modulo-bug into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2092
2026-08-13 01:13:29 +02:00
Tijmen Zwaan 07f01c237f Add tests for slowcat, slowcatPrime and arp 2026-08-13 01:04:48 +02:00
Tijmen Zwaan 76fa8264cd Remove old check in slowcat and add empty check at the start 2026-08-13 01:04:25 +02:00
Tijmen Zwaan 08dc5e2c5c Add tests for all signals using modulo, including negative time 2026-08-13 01:03:14 +02:00
Tijmen Zwaan bd54c8003a Introduce isquare and isquare2 for signal parity 2026-08-13 01:00:56 +02:00
Tijmen Zwaan 9e946f1d14 Merge branch 'main' into saw-modulo-bug 2026-08-12 23:37:14 +02:00
Felix Roos f1a541caec fix: accurate midi scheduling on chromium 2026-08-12 20:48:01 +02:00
froos 47cdf4561d Merge pull request 'Improve wav render export performance' (#2093) from tzwaan/wav-export-performance into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2093
2026-08-12 13:48:59 +02:00
wjt 6c8b707d2e learn/lfx: Add missing word 2026-08-10 17:01:43 +02:00
Cass Mitchell adcbf9240e Don't run server if outputting JSON 2026-08-05 19:19:06 -04:00
Tijmen Zwaan f4b3fdaacd Update juxUndTollerei 1 tune snapshot 2026-08-05 00:39:38 +02:00
Tijmen Zwaan 9bf3249e82 slowcatPrime pat can no longer be undefined 2026-08-05 00:25:08 +02:00
Tijmen Zwaan 0a0afcf73f Change renderPatternAudio so it renders the audio in chunks 2026-08-04 23:29:01 +02:00
Tijmen Zwaan 6ebf506070 Fix node pool reusing incompatible audio nodes when audiocontext changes 2026-08-04 23:24:52 +02:00
Tijmen Zwaan eafa2f17ec Fix the same modulo bug in arp and slowcatPrime 2026-08-04 18:03:12 +02:00
Tijmen Zwaan 429682735d Formatting 2026-08-04 17:25:45 +02:00
Tijmen Zwaan 04232370c2 Apply the same fix to square and randrun 2026-08-04 17:11:52 +02:00
Tijmen Zwaan 4484ee4e80 Fix saw and isaw returning negative numbers on negative t 2026-08-04 16:52:47 +02:00
Emily S 57598f2aa1 formatting 2026-07-31 21:34:32 +01:00
Alex McLean ebc25467c0 Merge pull request 'Added some advice for creating a new project using strudel' (#2090) from name into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2090
2026-07-29 16:48:34 +02:00
alex a866cb2189 format 2026-07-29 15:06:56 +01:00
alex 890d69b23e tweaks from feedback 2026-07-29 12:25:30 +01:00
alex 8e187abf24 add copyright notice 2026-07-29 10:48:11 +01:00
alex 2d014b47c8 add advice for creating a new project to faq 2026-07-29 09:20:08 +01:00
alex daafee7527 advice for creating a new project using strudel 2026-07-29 09:11:41 +01:00
Emily S c9522a4e49 add target to remote links 2026-07-24 08:39:07 +01:00
miranda 25d1179985 osc: remove bank+sound concatenation since SuperDirt does it now 2026-06-27 16:09:15 +03:00
yaxu c150752372 revert 85e6d436ef
revert motors workshop page
(committed to main by mistake!)
2026-06-21 20:47:04 +02:00
Alex McLean e248bf85f3 Merge pull request 'Creates a pattern of numbers in base x from a number or pattern of numbers @stretchyboy' (#2075) from stretchyboy-base-function into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2075
2026-06-21 20:45:28 +02:00
Alex McLean 6c51c0261a Merge branch 'main' into stretchyboy-base-function 2026-06-21 20:45:05 +02:00
Felix Roos deefde7b50 hotfix: codeformat 2026-06-21 15:41:50 +02:00
froos d4f63e8de3 Merge pull request 'fix: cache bunny cdn urls #2057' (#2076) from cache-bunny into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2076
2026-06-21 15:29:56 +02:00
alex 19cb3dedc2 snapshot 2026-06-21 14:02:34 +01:00
alex a7c3407da7 codeformat 2026-06-21 13:54:38 +01:00
Martyn Eggleton 6870b04fb2 Pretty code now 2026-06-21 13:49:11 +01:00
Alex McLean 2b2646a768 Merge branch 'main' into base-function 2026-06-21 14:34:18 +02:00
alex 97ef5bc335 Merge branch 'main' of ssh://codeberg.org/uzu/strudel 2026-06-19 21:30:17 +01:00
alex 85e6d436ef motors workshop page 2026-06-19 21:29:58 +01:00
alex d3e2b7c7b4 motors workshop page 2026-06-19 21:29:49 +01:00
alex def1738259 shortcuts for workshop 2026-06-19 21:29:25 +01:00
Alex McLean 0feeb1e701 Merge pull request 'Add growlist' (#2073) from growlist into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2073
2026-06-18 13:25:38 +02:00
alex 813cc6c190 format 2026-06-18 12:04:03 +01:00
alex 62d4f84e69 add growlist 2026-06-18 10:55:34 +01:00
stretchyboy 49a1e11cd8 Merge branch 'main' into base-function 2026-06-17 13:58:30 +02:00
Felix Roos 941c97da0d fix: cache bunny cdn urls #2057 2026-06-15 09:15:06 +02:00
Alex McLean 95a9d301a1 Merge pull request 'midikeys-with-channel' (#2063) from eefano/strudel:midikeys-with-channel into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2063
2026-06-10 13:38:02 +02:00
eefano e38811d325 oldabove and oldroot, documentation 2026-06-08 21:09:23 +02:00
Martyn Eggleton 475f17ddfd base now accepts Arrays which improve compatibility with rockstar-strudel 2026-06-03 21:56:26 +01:00
Martyn Eggleton bd68c6a0a7 Documentation tweak 2026-06-03 12:36:41 +01:00
eefano 2635716c87 added midikeys third test 2026-06-02 14:56:55 +02:00
eefano 58f956b57e prettier fix 2026-06-02 14:29:28 +02:00
eefano 0ae2c120b3 updated documentation for midikeys 2026-06-02 13:28:23 +02:00
eefano 05a43ef687 included midichan value in midikeys haps 2026-06-02 13:14:30 +02:00
Martyn Eggleton f21aeb55bd The base param can be a pattern. We also add a max number of digits to produce for each n 2026-06-01 14:02:20 +01:00
eefano 422b03699a fixes 'above' and 'root' modes in renderVoicing() 2026-06-01 02:22:03 +02:00
eefano cb046a3548 fixes 'above' and 'root' modes in renderVoiciing() 2026-06-01 02:16:48 +02:00
Martyn Eggleton d83139980b Creates a pattern of numbers in base x from a number or pattern of numbers 2026-05-27 21:23:55 +01:00
Alex McLean 0c61cd7670 Merge pull request 'Don't force SSL for mqtt websocket connections' (#2058) from mqtt-ws into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2058
2026-05-27 13:35:28 +02:00
jouae fe0be6bc53 Fix falsy value check in getFreqeuncy
|| operator, which causes 0 to be treated as falsy and thus not passed
to getFreq, resulting in incorrect frequency calculation for MIDI note
0.

Replace || with ?? to allow 0 to be correctly passed to getFreq.
2026-05-17 11:49:09 +08:00
Ethan Crawford 4747fab4ed Remove outdated continuation link
Previously, the docs were designed to work as a series of consecutively read
documents.
This linear flow is no longer intended, and as such the link at the end of the
csound document has become obsolete. This commit removes it, as suggested by a
member of the dev team.
2026-05-15 17:24:10 +08:00
tyow 896ec4c643 edgecase 2026-04-01 19:09:45 -04:00
tyow 1723125099 bugfix: transpose now has default octave of 3 2026-04-01 18:51:59 -04:00
37 changed files with 884 additions and 148 deletions
+11 -1
View File
@@ -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
+9 -2
View File
@@ -2352,11 +2352,18 @@ export const { offset } = registerControl('offset');
**/
export const { octaves } = registerControl('octaves');
/**
* Remove anchor note from the voicing. Useful for melody harmonization
* How the voicing is aligned to the anchor
* - `below`: top note <= anchor
* - `duck`: top note <= anchor, anchor excluded
* - `above`: bottom note >= anchor
* - `root`: bottom note is the lowest root of the chord >= anchor
*
* - `oldabove` : old (buggy) behavior of above, kept for legacy reason
* - `oldroot` : old (buggy) behavior of root, kept for legacy reason
*
* @name mode
* @tags tonal
* @param {string | Pattern} modeName one of {below | above | duck | root}
* @param {string | Pattern} modeName one of {below | above | duck | root | oldabove | oldroot}
* @example
* mode("<below above duck root>").chord("C").voicing()
*
+71 -9
View File
@@ -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();
};
+21 -4
View File
@@ -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);
};
+49
View File
@@ -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', () => {
+66 -6
View File
@@ -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', () => {
+1 -1
View File
@@ -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);
+75
View File
@@ -0,0 +1,75 @@
/*
dough.mjs
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/osc/osc.mjs>
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 <https://www.gnu.org/licenses/>.
*/
import { register, noteToMidi } from '@strudel/core';
import { Dough, doughsamples } from 'dough-synth';
import { getAudioContext, ensureMinimalOutput } 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("<Dm9 Dm11 Dm7>").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("<Dm9 Dm11 Dm7>").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);
});
+42
View File
@@ -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 <flix91@gmail.com>",
"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"
}
}
+87 -64
View File
@@ -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';
@@ -171,6 +171,19 @@ 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 +195,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 +203,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 +230,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 +242,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 +275,15 @@ 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 }));
}
// 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);
}
/**
@@ -287,8 +293,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 = {}) {
@@ -314,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
@@ -337,11 +341,29 @@ Pattern.prototype.midi = function (midiport, options = {}) {
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
});
return this.onTrigger((hap, _currentTime, cps, targetTime) => {
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?');
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();
// midi event values from hap with configurable defaults
@@ -379,7 +401,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(', ')}`);
@@ -387,16 +409,20 @@ 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;
// 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;
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 +432,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);
}
}
}
@@ -537,6 +553,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 +571,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 +656,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) {
+10 -1
View File
@@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
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 <https://www.gnu.org/licenses/>.
*/
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);
};
-1
View File
@@ -45,7 +45,6 @@ export function parseControlsFromHap(hap, cps) {
controls.midinote = parseNumeral(controls.note);
}
}
controls.bank && (controls.s = controls.bank + controls.s);
controls.roomsize && (controls.size = parseNumeral(controls.roomsize));
// speed adjustment for CPS is handled on the DSP side in superdirt and pattern side in Strudel,
// so we need to undo the adjustment before sending the message to superdirt.
+1
View File
@@ -98,6 +98,7 @@ if (args.includes('--json')) {
}
writeFileSync(outFile, json, 'utf8');
console.log(`Wrote json to ${outFile}`);
process.exit(0);
}
console.log(
+9 -2
View File
@@ -7,14 +7,21 @@ Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/
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 <https://www.gnu.org/licenses/>.
*/
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;
};
+2 -2
View File
@@ -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 });
}
+18 -3
View File
@@ -6,12 +6,27 @@ 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;
}
// 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);
@@ -374,7 +389,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);
+13
View File
@@ -5,11 +5,24 @@ Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/
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 <https://www.gnu.org/licenses/>.
*/
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;
};
+8 -1
View File
@@ -137,7 +137,14 @@ export const { transpose, trans } = register(['transpose', 'trans'], function tr
const interval = !isNaN(Number(intervalOrSemitones))
? Interval.fromSemitones(intervalOrSemitones)
: String(intervalOrSemitones);
const targetNote = Note.transpose(note, interval);
let n = Note.get(note);
if (n.oct == undefined) {
n.oct = 3;
let n1 = Note.get(Note.transpose(n, interval));
n.oct = n1.oct == 3 ? undefined : 3;
}
const targetNote = Note.transpose(n, interval);
if (typeof hap.value === 'object') {
return hap.withValue(() => ({ ...hap.value, note: targetNote }));
}
+14 -3
View File
@@ -134,6 +134,16 @@ let modeTarget = {
duck: (v) => v.slice(-1)[0],
above: (v) => v[0],
root: (v) => v[0],
oldabove: (v) => v[0],
oldroot: (v) => v[0],
};
let modeMult = {
below: 1,
duck: 1,
above: -1,
root: -1,
oldabove: 1,
oldroot: 1,
};
export function renderVoicing({ chord, dictionary, offset = 0, n, mode = 'below', anchor = 'c5', octaves = 1 }) {
@@ -144,19 +154,20 @@ export function renderVoicing({ chord, dictionary, offset = 0, n, mode = 'below'
const voicings = dictionary[symbol].map((voicing) =>
(typeof voicing === 'string' ? voicing.split(' ') : voicing).map(step2semitones),
);
const mult = modeMult[mode];
let minDistance, bestIndex;
// calculate distances up from voicing top notes
let chromaDiffs = voicings.map((v, i) => {
const targetStep = modeTarget[mode](v);
const diff = _mod(anchorChroma - targetStep - rootChroma, 12);
const diff = _mod((anchorChroma - targetStep - rootChroma) * mult, 12);
if (minDistance === undefined || diff < minDistance) {
minDistance = diff;
bestIndex = i;
}
return diff;
return diff * mult;
});
if (mode === 'root') {
if (mode === 'root' || mode === 'oldroot') {
bestIndex = 0;
}
+3
View File
@@ -185,6 +185,9 @@ export const rootNotes = register('rootNotes', function (octave, pat) {
* - `below`: top note <= anchor
* - `duck`: top note <= anchor, anchor excluded
* - `above`: bottom note >= anchor
* - `root`: bottom note is the lowest root of the chord >= anchor
* - `oldabove` : old (buggy) behavior of above, kept for legacy reason
* - `oldroot` : old (buggy) behavior of root, kept for legacy reason
* - `offset`: whole number that shifts the voicing up or down to the next voicing
* - `n`: if set, the voicing is played like a scale. Overshooting numbers will be octaved
*
+2 -2
View File
@@ -114,11 +114,11 @@ 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);
return doughsamples(json, base);
return supradoughsamples(json, base);
}
Object.entries(sampleMap).map(async ([key, urls]) => {
if (key !== '_base') {
+88 -28
View File
@@ -9,7 +9,7 @@ import {
superdough,
getAudioContext,
setLogger,
doughTrigger,
rawdspTrigger,
registerWorklet,
setAudioContext,
initAudio,
@@ -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);
@@ -113,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) {
+37 -1
View File
@@ -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:
+87 -8
View File
@@ -639,30 +639,30 @@ exports[`runs examples > example "K" example index 0 1`] = `
"[ 7/4 → 11/6 | note:C5 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[16 0.1 1 0.2 0.013] ]",
"[ 11/6 → 23/12 | note:Eb4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[16 0.1 1 0.2 0.013] ]",
"[ 23/12 → 2/1 | note:G4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[16 0.1 1 0.2 0.013] ]",
"[ 2/1 → 25/12 | note:Db4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.3 0.03] ]",
"[ 2/1 → 25/12 | note:Db5 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.3 0.03] ]",
"[ 25/12 → 13/6 | note:F4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.3 0.03] ]",
"[ 13/6 → 9/4 | note:Ab4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.3 0.03] ]",
"[ 9/4 → 7/3 | note:Db4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.2 0.08] ]",
"[ 9/4 → 7/3 | note:Db5 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.2 0.08] ]",
"[ (7/3 → 19/8) ⇝ 29/12 | note:F4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.2 0.08] ]",
"[ 7/3 ⇜ (19/8 → 29/12) | note:F4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.2 0.01] ]",
"[ 29/12 → 5/2 | note:Ab4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.2 0.01] ]",
"[ 5/2 → 31/12 | note:Db4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.3 0.01] ]",
"[ 5/2 → 31/12 | note:Db5 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.3 0.01] ]",
"[ 31/12 → 8/3 | note:F4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.3 0.01] ]",
"[ 8/3 → 11/4 | note:Ab4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.3 0.01] ]",
"[ 11/4 → 17/6 | note:Db4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[16 0.1 1 0.2 0.013] ]",
"[ 11/4 → 17/6 | note:Db5 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[16 0.1 1 0.2 0.013] ]",
"[ 17/6 → 35/12 | note:F4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[16 0.1 1 0.2 0.013] ]",
"[ 35/12 → 3/1 | note:Ab4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[16 0.1 1 0.2 0.013] ]",
"[ 3/1 → 37/12 | note:Eb4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.3 0.03] ]",
"[ 3/1 → 37/12 | note:Eb5 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.3 0.03] ]",
"[ 37/12 → 19/6 | note:G4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.3 0.03] ]",
"[ 19/6 → 13/4 | note:Bb4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.3 0.03] ]",
"[ 13/4 → 10/3 | note:Eb4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.2 0.08] ]",
"[ 13/4 → 10/3 | note:Eb5 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.2 0.08] ]",
"[ (10/3 → 27/8) ⇝ 41/12 | note:G4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.2 0.08] ]",
"[ 10/3 ⇜ (27/8 → 41/12) | note:G4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.2 0.01] ]",
"[ 41/12 → 7/2 | note:Bb4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.2 0.01] ]",
"[ 7/2 → 43/12 | note:Eb4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.3 0.01] ]",
"[ 7/2 → 43/12 | note:Eb5 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.3 0.01] ]",
"[ 43/12 → 11/3 | note:G4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.3 0.01] ]",
"[ 11/3 → 15/4 | note:Bb4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[2 0 0.1 0.3 0.01] ]",
"[ 15/4 → 23/6 | note:Eb4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[16 0.1 1 0.2 0.013] ]",
"[ 15/4 → 23/6 | note:Eb5 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[16 0.1 1 0.2 0.013] ]",
"[ 23/6 → 47/12 | note:G4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[16 0.1 1 0.2 0.013] ]",
"[ 47/12 → 4/1 | note:Bb4 s:saw workletSrc:saw(saw(sFreq / pat[0]).mul(8).add(sFreq).lag(pat[1])).mul(0.3).mul(sGate.adsr(0 0.15 0.5 pat[2])).lpf(sGate.adsr(0 0.2 0.3 0.2).mul(1).add(0)).add(x => x.delay(pat[3]).mul(0.7)).add(x => x.delay(pat[4]).mul(0.77)).mul(0.7).add(x => x.delay(0.13).mul(0.7)).out() workletInputs:[16 0.1 1 0.2 0.013] ]",
]
@@ -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 ]",
@@ -3393,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`] = `
@@ -5901,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 ]",
@@ -7927,6 +8004,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 ]",
+4
View File
@@ -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 ]",
+2
View File
@@ -99,6 +99,7 @@ const toneHelpersMocked = {
'_spectrum',
'markcss',
'p',
'dough',
].forEach((mock) => {
strudel.Pattern.prototype[mock] = function () {
return this;
@@ -174,6 +175,7 @@ evalScope(
getDuration,
setcps: id,
setcpm: id,
initDough: id,
Clock: {}, // whatever
},
);
+2 -2
View File
@@ -597,8 +597,8 @@
],
"/packages/superdough/dspworklet.mjs": [
"dspWorklet",
"dough",
"doughTrigger"
"rawdsp",
"rawdspTrigger"
],
"/packages/superdough/index.mjs": [],
"/packages/tonal/tonleiter.mjs": [
+10
View File
@@ -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,
@@ -144,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',
},
},
});
+1
View File
@@ -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:*",
-2
View File
@@ -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)
+9
View File
@@ -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.
+1 -1
View File
@@ -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.
+4 -1
View File
@@ -287,7 +287,10 @@ The modes are:
- `below`: the top note of the voicing is lower than or equal to the anchor (default)
- `above`: the bottom note of the voicing is higher than or equal to the anchor
- `duck`: the top note of the voicing is lower than the anchor
- `root`: the bottom note of the voicing is always the root note closest to the anchor
- `root`: the bottom note of the voicing is always the lowest root note higher than or equal to the anchor
- `oldabove` : old (buggy) behavior of above, kept for legacy reasons
- `oldroot` : old (buggy) behavior of root, kept for legacy reasons
The `anchor` can also be set from within the `mode` function:
+117
View File
@@ -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.
![image](https://shop.pimoroni.com/cdn/shop/products/Inventor2040_1of3_1500x1500_crop_center.jpg?v=1656927927)
## Technical details
Feel free to gloss over these!
- The Inventor 2040W connects to the internet wirelessly, and it can power from a battery or USB. Hopefully the batteries last!
- It's running [some code](https://github.com/patternclub/alpacalab/blob/main/course/main.py) that listens for messages using an "Internet of Things" network protocol (called MQTT). When it receives a message, it moves a motor.
- It connects to a small server (running software called 'mosquitto') on Alex's laptop.
- Strudel can send these messages instead of triggering sounds - that's how we use it to pattern movement.
## First movement
Let's get a motor running!
1. Note the letter drawn on a label on the back of the microcontroller.
2. Plug a battery into your microcontroller.
3. Plug a motor into 'servo' (not motor) plug numbered 1, with the yellow (lightest) cable closest to the '1', and the brown (darkest) cable outward
4. Run the below to set up some values, changing the `x` in 'robot('x')` to the letter on your microcontroller.
<MiniRepl
client:visible
tune={`
$: move("-60 80").motor("0").robot('x');
`}
/>
<Box>
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.
</Box>
## 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:
<MiniRepl
client:visible
tune={`
$: move("-10 0 10 [20 30]*2").motor("0").slow(2).robot('x')
`}
/>
The move instructions are in the range from -90 to 90.
<box>
If your motors stop working at some point, and your code looks right, try pressing the 'reset' button on the
microcontroller.
</box>
It's possible to make smooth movements based on different 'waveforms', for example a smooth sinewave:
<MiniRepl
client:visible
tune={`
$: move(sine.range(-30, 30).segment(16)).motor("0").slow(2).robot('x');
`}
/>
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!
<box>
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).
</box>
## Patterning more than one motor
You can pattern the `motor` command separately from the `move` one:
<MiniRepl
client:visible
tune={`
$: move("-10 0 10 [20 30]*2").motor("0 1").slow(2).robot('x');
`}
/>
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:
<MiniRepl
client:visible
tune={`
$: move("-10 0 10 [20 30]\*2").motor("0").slow(2).robot('x');
$: move("-10 0 10 [20 30]\*2").motor("1").slow(3).robot('x');
`}
/>
@@ -214,7 +214,12 @@ export const Reference = memo(function Reference() {
</p>
)}
<p dangerouslySetInnerHTML={{ __html: entry.description }}></p>
<p
dangerouslySetInnerHTML={{
// If we have an external url make sure it opens in another tab to not interrupt the player
__html: entry.description.replaceAll(/<a href="https:\/\//g, `<a target="_blank" href="https://`),
}}
></p>
<ul>
{entry.params?.map(({ name, type, description }, i) => (
<li key={i}>
@@ -36,8 +36,9 @@ export function WelcomeTab({ context }) {
<a href="https://tidalcycles.org/" target="_blank">
tidalcycles
</a>
, 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{' '}
<a href="https://codeberg.org/uzu/strudel/src/branch/main/LICENSE" target="_blank">
GNU Affero General Public License
</a>
+1
View File
@@ -85,6 +85,7 @@ export function loadModules() {
import('@strudel/motion'),
import('@strudel/mqtt'),
import('@strudel/mondo'),
import('@strudel/dough'),
];
if (isTauri()) {
modules = modules.concat([