diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..ddaea8d69 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +**/node_modules +**/.angular +**/dist +.astro/ +.forgejo/ +.idea/ +.vscode/ +test/ +.gitignore +Dockerfile +.dockerignore diff --git a/.forgejo/workflows/deploy-warm-beta.yml b/.forgejo/workflows/deploy-warm-beta.yml index e6636a729..0265fcebd 100644 --- a/.forgejo/workflows/deploy-warm-beta.yml +++ b/.forgejo/workflows/deploy-warm-beta.yml @@ -1,4 +1,4 @@ -name: Build and Deploy to beta (warm.strudel.cc) +name: Build and Deploy to warm (strudel.cc) on: push: @@ -37,4 +37,4 @@ jobs: apt update && apt install -y rsync mkdir ~/.ssh ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts - rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/warm.strudel.cc \ No newline at end of file + rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/strudel.cc \ No newline at end of file diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 0643f9be1..e102618e4 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Build and Deploy to live (strudel.cc) +name: Build and Deploy to cold (cold.strudel.cc) on: [workflow_dispatch] @@ -34,4 +34,4 @@ jobs: apt update && apt install -y rsync mkdir ~/.ssh ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts - rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/strudel.cc \ No newline at end of file + rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/cold.strudel.cc \ No newline at end of file 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/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 6aac0c8a4..71e5c49ab 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -444,14 +444,16 @@ export class StrudelMirror { this.setFontSize(value); } } - setCode(code) { - const changes = { - from: 0, - to: this.editor.state.doc.length, - insert: code, - }; + replaceCode(code, from, to) { + const changes = { from, to, insert: code }; this.editor.dispatch({ changes }); } + insertCode(code, position) { + this.replaceCode(code, position, position); + } + setCode(code) { + this.replaceCode(code, 0, this.editor.state.doc.length); + } // used for debugging but could serve other purposes getActiveWidgets() { return getActiveWidgets(this.editor); diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 2a13c4df2..e6d5c89e5 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -2351,11 +2351,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("").chord("C").voicing() * @@ -2581,13 +2588,17 @@ export const { compressorRelease } = registerControl('compressorRelease'); export const { speed } = registerControl('speed'); /** - * Changes the speed of sample playback, i.e. a cheap way of changing pitch. + * Changes the pitch of the sample without changing its speed. + * The frequencies are multiplied by (factor + 1) for positive numbers + * and by max(factor / 4 + 1, 0) for negative numbers. + * So tuning up by octaves can be done with 1, 3, 7, ... + * and tuning down by octaves with -2, -3, -3.5... * * @name stretch * @tags pitch, samples - * @param {number | Pattern} factor -inf to inf, negative numbers play the sample backwards. + * @param {number | Pattern} factor between `-4` and `inf`. Positive increases pitch, 0 does nothing, negative decreases the pitch. * @example - * s("gm_flute").stretch("1 2 .5") + * s("gm_flute").stretch("<2 1 0 -2>") * */ export const { stretch } = registerControl('stretch'); diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 5a981dd9d..8968b508f 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -1046,7 +1046,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, ); @@ -1632,7 +1632,19 @@ export function sequenceP(pats) { export function stack(...pats) { // Array test here is to avoid infinite recursions.. pats = pats.map((pat) => (Array.isArray(pat) ? sequence(...pat) : reify(pat))); - const query = (state) => flatten(pats.map((pat) => pat.query(state))); + const query = (state) => + flatten( + pats.map((pat) => { + try { + // An error occurring in one part of the stack should not break + // playback of the other parts of the stack. + return pat.query(state); + } catch (e) { + errorLogger(e); + return []; + } + }), + ); const result = new Pattern(query); if (__steps) { result._steps = lcm(...pats.map((pat) => pat._steps)); @@ -1767,7 +1779,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]; } @@ -1775,10 +1789,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. @@ -1795,11 +1805,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(); } @@ -1979,6 +1992,12 @@ export const func = curry((a, b) => reify(b).func(a)); * */ export function register(name, func, patternify = true, preserveSteps = false, join = (x) => x.innerJoin()) { + if (isPattern(name)) { + throw new Error( + 'Name argument for register is a pattern, try using single quotes (\'name\') instead of double quotes ("name")', + ); + } + if (Array.isArray(name)) { const result = {}; for (const name_item of name) { @@ -2788,6 +2807,20 @@ export const { juxBy, juxby } = register(['juxBy', 'juxby'], function (by, func, return stack(left, right).setSteps(__steps ? lcm(left._steps, right._steps) : undefined); }); +/** + * Like juxBy, except it flips the ears each cycle. + * @name juxFlipBy + * @synonyms juxflipby, fluxBy, fluxby + * @example + * s("bd lt [~ ht] mt cp ~ bd hh").juxFlipBy(".8", rev) + */ +export const { juxFlipBy, juxflipby, fluxBy, fluxby } = register( + ['juxFlipBy', 'juxflipby', 'fluxBy', 'fluxby'], + function (by, func, pat) { + return pat.juxBy(slowcat(by, -by), func); + }, +); + /** * The jux function creates strange stereo effects, by applying a function to a pattern, but only in the right-hand channel. * @tags temporal, superdough @@ -2802,6 +2835,21 @@ export const jux = register('jux', function (func, pat) { return pat._juxBy(1, func, pat); }); +/** + * Like jux, but flips the ears each cycle. + * @name juxFlip + * @synonyms juxflip, flux + * @example + * s("bd lt [~ ht] mt cp ~ bd hh").juxFlip(rev) + * @example + * s("bd lt [~ ht] mt cp ~ bd hh").juxFlip(press) + * @example + * s("bd lt [~ ht] mt cp ~ bd hh").juxFlip(iter(4)) + */ +export const { juxFlip, flux } = register(['juxFlip', 'juxflip', 'flux'], function (func, pat) { + return pat._juxFlipBy(1, func, pat); +}); + /** * Superimpose and offset multiple times, applying the given function each time. * @tags temporal, functional @@ -3582,6 +3630,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* * @@ -4350,3 +4403,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/repl.mjs b/packages/core/repl.mjs index c1b539819..ff2734967 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -307,8 +307,12 @@ export function repl({ let allTransforms = []; /** - * Applies a function to all the running patterns. Note that the patterns are groups together into a single `stack` before the function is applied. This is probably what you want, but see `each` for + * Applies a function to all the running patterns. Note that the patterns are grouped together into a single `stack` before the function is applied. This is probably what you want, but see `each` for * a version that applies the function to each pattern separately. + * + * **Note:** Patterns must be labeled (e.g. with `$:`) to be picked up by `all`. An unlabeled + * pattern such as `note("c4")` is not registered and will produce no audio when `all` is present. + * Use `$: note("c4")` instead. * ``` * $: sound("bd - cp sd") * $: sound("hh*8") @@ -328,6 +332,9 @@ export function repl({ }; /** Applies a function to each of the running patterns separately. This is intended for future use with upcoming 'stepwise' features. See `all` for a version that applies the function to all the patterns stacked together into a single pattern. * + * **Note:** Patterns must be labeled (e.g. with `$:`) to be picked up by `each`. An unlabeled + * pattern such as `note("c4")` is not registered and will produce no audio when `each` is present. + * Use `$: note("c4")` instead. * ``` * $: sound("bd - cp sd") * $: sound("hh*8") diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index c43366ebd..83a35d677 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -51,7 +51,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). @@ -74,7 +74,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). @@ -131,7 +131,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). @@ -141,6 +141,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. * @@ -416,7 +432,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 4442aa4a7..d2b4df21a 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, stackCenter } 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 6a8936e55..c4dd22d3b 100644 --- a/packages/core/util.mjs +++ b/packages/core/util.mjs @@ -149,11 +149,7 @@ export const getFrequency = (hap) => { if (value.freq) { return value.freq; } - let v = value.note || value.n || value.value; - if (typeof v === 'number') { - return midiToFreq(v); - } - return midiToFreq(noteToMidi(v)); + return getFreq(value.note ?? value.n ?? value.value); } if (typeof value === 'number' && context.type !== 'frequency') { value = midiToFreq(value); diff --git a/packages/dough/dough.mjs b/packages/dough/dough.mjs new file mode 100644 index 000000000..908138bae --- /dev/null +++ b/packages/dough/dough.mjs @@ -0,0 +1,75 @@ +/* +dough.mjs +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +import { register, noteToMidi } from '@strudel/core'; +import { Dough, doughsamples } from 'dough-synth'; +import { getAudioContext, 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("").offset("<-1 0 1 0>").voicing() + * .phaser(3).fm(1.4).fmh(1.01) + * .dough() + */ +export function initDough() { + if (!D || D.audioContext !== getAudioContext()) { + D = new Dough({ + wasm, + workletCode, + audioContext: getAudioContext(), + }); + } + return D.ready; +} + +export async function doughTrigger(hap, _currentTime, cps = 1, targetTime) { + const offset = D.context_offset?.[0]; + if (!offset) { + return; // not ready + } + hap.ensureObjectValue(); + const event = { + dough: 'play', + ...hap.value, + time: targetTime - offset, + duration: hap.duration / cps, + }; + if (typeof event.note === 'string') { + event.note = noteToMidi(event.note); + } + D.evaluate(event); +} + +/** + * + * Uses dough as the audio engine. more info at https://dough.strudel.cc + * + * @name dough + * @tags external_io + * @memberof Pattern + * @returns Pattern + * @example + * $: chord("").offset("<-1 0 1 0>").voicing() + * .phaser(3).fm(1.4).fmh(1.01) + * .dough() + */ +export const dough = register('dough', (pat) => { + initDough(); + ensureMinimalOutput(); + return pat.onTrigger(doughTrigger); +}); diff --git a/packages/dough/package.json b/packages/dough/package.json new file mode 100644 index 000000000..abbb543be --- /dev/null +++ b/packages/dough/package.json @@ -0,0 +1,42 @@ +{ + "name": "@strudel/dough", + "version": "1.3.2", + "description": "dough synth integration for strudel", + "main": "dough.mjs", + "type": "module", + "publishConfig": { + "main": "dist/index.mjs" + }, + "scripts": { + "build": "vite build", + "prepublishOnly": "npm run build" + }, + "repository": { + "type": "git", + "url": "git+https://codeberg.org/uzu/strudel.git" + }, + "keywords": [ + "tidalcycles", + "strudel", + "pattern", + "livecoding", + "algorave" + ], + "author": "Felix Roos ", + "license": "AGPL-3.0-or-later", + "bugs": { + "url": "https://codeberg.org/uzu/strudel/issues" + }, + "homepage": "https://codeberg.org/uzu/strudel#readme", + "dependencies": { + "@strudel/core": "workspace:*", + "@strudel/webaudio": "workspace:*", + "dough-synth": "0.2.4" + }, + "devDependencies": { + "vite": "^6.0.11" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index bd40b743e..68a0d744f 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -21,8 +21,8 @@ import { } from '@strudel/core'; import { noteToMidi, getControlName } from '@strudel/core'; import { Note } from 'webmidi'; -import { getAudioContext } from '@strudel/webaudio'; -import { scheduleAtTime } from '../superdough/helpers.mjs'; +import { getAudioContext, getClockBridge } from '@strudel/webaudio'; +import { scheduleAtTime, ensureMinimalOutput } from '../superdough/helpers.mjs'; import { getMidiDeviceNamesString, getDevice } from './util.mjs'; import { MidiInput } from './input.mjs'; @@ -171,6 +171,23 @@ 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 = getClockBridge().getAudioContextTime(timeMs); + if (!audioTime) { + logger('[midi]: skip event, not ready'); + return; + } + scheduleAtTime(() => fn(undefined), audioTime); + } else { + fn(timeMs); + } +} + function mapCC(mapping, value) { return Object.keys(value) .filter((key) => !!mapping[getControlName(key)]) @@ -182,7 +199,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 +207,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 +234,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 +246,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 +279,10 @@ function sendNote(note, velocity, duration, device, midichan, targetTime) { throw new Error('duration must be a positive number'); } const midiNumber = typeof note === 'number' ? note : noteToMidi(note); - const midiNote = new Note(midiNumber, { attack: velocity, duration }); + const midiNote = new Note(midiNumber, { attack: velocity }); - scheduleAtTime(() => { - device.playNote(midiNote, midichan); - }, targetTime); + timedSend(timeMs, (timeMs) => device.sendNoteOn(midiNote, { channels: midichan, time: timeMs })); + timedSend(timeMs + duration, (timeMs) => device.sendNoteOff(midiNote, { channels: midichan, time: timeMs })); } /** @@ -287,8 +292,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 +317,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 +340,19 @@ Pattern.prototype.midi = function (midiport, options = {}) { logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`), }); - return this.onTrigger((hap, _currentTime, cps, targetTime) => { + ensureMinimalOutput(); + + return this.sortHapsByPart().onTrigger((hap, _currentTime, cps, targetTime) => { if (!WebMidi.enabled) { logger('Midi not enabled'); return; } + const timeMs = getClockBridge().getPerformanceTime(targetTime); + if (!timeMs) { + logger('[midi] clockbridge not ready'); + return; + } + hap.ensureObjectValue(); // midi event values from hap with configurable defaults @@ -379,7 +390,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 +398,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 +421,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 +542,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 +560,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 +645,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 d74de342d..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 = {}; @@ -61,7 +61,6 @@ Pattern.prototype.mqtt = function ( const props = { onSuccess: onConnect, onFailure: onFailure, - useSSL: true, }; if (username) { @@ -119,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/osc/osc.mjs b/packages/osc/osc.mjs index 0744cb8c9..2c0619b4a 100644 --- a/packages/osc/osc.mjs +++ b/packages/osc/osc.mjs @@ -4,7 +4,8 @@ Copyright (C) 2022 Strudel contributors - see . */ -import { logger, parseNumeral, register, isNote, noteToMidi, ClockCollator } from '@strudel/core'; +import { logger, parseNumeral, register, isNote, noteToMidi } from '@strudel/core'; +import { ensureMinimalOutput, getClockBridge } from '@strudel/webaudio'; let connection; // Promise function connect() { @@ -42,10 +43,9 @@ export function parseControlsFromHap(hap, cps) { if (isNote(controls.note)) { controls.midinote = noteToMidi(controls.note, controls.octave || 3); } else { - controls.note = parseNumeral(controls.note); + controls.midinote = parseNumeral(controls.note); } } - controls.bank && (controls.s = controls.bank + controls.s); 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. @@ -55,13 +55,18 @@ export function parseControlsFromHap(hap, cps) { return controls; } -const collator = new ClockCollator({}); - export async function oscTrigger(hap, currentTime, cps = 1, targetTime) { const ws = await connect(); + + const timeMs = getClockBridge().getPerformanceTime(targetTime); + if (!timeMs) { + logger('[osc] clockbridge not ready'); + return; + } + const ts = performance.timeOrigin + timeMs; + const controls = parseControlsFromHap(hap, cps); const keyvals = Object.entries(controls).flat(); - const ts = collator.calculateTimestamp(currentTime, targetTime) * 1000; const msg = { address: '/dirt/play', args: keyvals, timestamp: ts }; if ('oschost' in hap.value) { @@ -83,4 +88,7 @@ export async function oscTrigger(hap, currentTime, cps = 1, targetTime) { * @memberof Pattern * @returns Pattern */ -export const osc = register('osc', (pat) => pat.onTrigger(oscTrigger)); +export const osc = register('osc', (pat) => { + ensureMinimalOutput(); + return pat.onTrigger(oscTrigger); +}); diff --git a/packages/osc/package.json b/packages/osc/package.json index 569242252..de4d2c96c 100644 --- a/packages/osc/package.json +++ b/packages/osc/package.json @@ -38,6 +38,7 @@ "homepage": "https://codeberg.org/uzu/strudel#readme", "dependencies": { "@strudel/core": "workspace:*", + "@strudel/webaudio": "workspace:*", "osc": "^2.4.5", "ws": "^8.18.3" }, diff --git a/packages/sampler/sample-server.mjs b/packages/sampler/sample-server.mjs index 1d5a8e7a6..e78efd9cc 100644 --- a/packages/sampler/sample-server.mjs +++ b/packages/sampler/sample-server.mjs @@ -98,6 +98,7 @@ if (args.includes('--json')) { } writeFileSync(outFile, json, 'utf8'); console.log(`Wrote json to ${outFile}`); + process.exit(0); } console.log( diff --git a/packages/superdough/audioContext.mjs b/packages/superdough/audioContext.mjs index 94ec32d15..27a1b25d1 100644 --- a/packages/superdough/audioContext.mjs +++ b/packages/superdough/audioContext.mjs @@ -7,15 +7,24 @@ Copyright (C) 2025 Strudel contributors - see . */ -let audioContext; +import { clearNodePool } from './nodePools.mjs'; +import { ClockBridge } from './clockbridge.mjs'; + +let audioContext, clockBridge; 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; + clockBridge = new ClockBridge(audioContext); return audioContext; }; @@ -27,6 +36,13 @@ export const getAudioContext = () => { return audioContext; }; +export function getClockBridge() { + if (!clockBridge) { + getAudioContext(); // creates clockBridge + } + return clockBridge; +} + export function getAudioContextCurrentTime() { return getAudioContext().currentTime; } diff --git a/packages/superdough/clockbridge.mjs b/packages/superdough/clockbridge.mjs new file mode 100644 index 000000000..b2bbedb62 --- /dev/null +++ b/packages/superdough/clockbridge.mjs @@ -0,0 +1,41 @@ +/* +clockbridge.mjs +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +// thanks freya https://youtu.be/LSNQuFEDOyQ?si=ukZI2IGgWV_NDZzP&t=2979 +export function expDecay(a, b, decay, dt) { + return b + (a - b) * Math.exp(-decay * dt); +} + +// translates between audio and performance clock +export class ClockBridge { + p; // smoothed clock offset + lastTime; + audioContext; + constructor(audioContext) { + this.audioContext = audioContext; + } + // delta between audio and performance time in ms + getOffset() { + const { contextTime, performanceTime } = this.audioContext.getOutputTimestamp(); + if (!contextTime || !performanceTime) { + return; + } + const offset = performanceTime - contextTime * 1000; // clock offset in ms + const dt = performanceTime - (this.lastTime ?? performanceTime); // delta time since last hap + const decay = 1 / 10000; // how fast offset changes have an effect + this.p = expDecay(this.p ?? offset, offset, decay, dt); // smooth clock offset + this.lastTime = performanceTime; + return this.p; + } + getPerformanceTime(audioContextTime) { + const offset = this.getOffset(); + return audioContextTime * 1000 + offset; // this is now correct in performance time (ms) + } + getAudioContextTime(performanceTime) { + const offset = this.getOffset(); + return (performanceTime - offset) / 1000; // this is now correct in audio context time (seconds) + } +} diff --git a/packages/superdough/dspworklet.mjs b/packages/superdough/dspworklet.mjs index aac08061e..ed6d0c7b3 100644 --- a/packages/superdough/dspworklet.mjs +++ b/packages/superdough/dspworklet.mjs @@ -67,13 +67,13 @@ if (typeof window !== 'undefined') { }); } -export const dough = async (code) => { +export const rawdsp = async (code) => { const ac = getAudioContext(); stop(); worklet = await dspWorklet(ac, code); worklet.node.connect(ac.destination); }; -export function doughTrigger(hap, currentTime, cps, targetTime) { +export function rawdspTrigger(hap, currentTime, cps, targetTime) { window.postMessage({ time: targetTime, dough: hap.value, currentTime, duration: hap.duration, cps }); } diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 07199aff6..e01dd793b 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -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); diff --git a/packages/superdough/index.mjs b/packages/superdough/index.mjs index c3d2f6c52..1a3923c58 100644 --- a/packages/superdough/index.mjs +++ b/packages/superdough/index.mjs @@ -14,3 +14,4 @@ export * from './modulators.mjs'; export * from './dspworklet.mjs'; export * from './audioContext.mjs'; export * from './wavetable.mjs'; +export * from './clockbridge.mjs'; diff --git a/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/superdough/synth.mjs b/packages/superdough/synth.mjs index 38ad643de..224936914 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -485,8 +485,10 @@ export function waveformN(partials, phases, type) { if (phase !== 0) { const c = Math.cos(PI2 * phase); const s = Math.sin(PI2 * phase); - R = c * R - s * I; - I = s * R + c * I; + const R0 = R; + const I0 = I; + R = c * R0 - s * I0; + I = s * R0 + c * I0; } real[n + 1] = R; imag[n + 1] = I; diff --git a/packages/tonal/tonal.mjs b/packages/tonal/tonal.mjs index 4bba4561a..9edca180c 100644 --- a/packages/tonal/tonal.mjs +++ b/packages/tonal/tonal.mjs @@ -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 })); } diff --git a/packages/tonal/tonleiter.mjs b/packages/tonal/tonleiter.mjs index 233129641..41f5e7c7d 100644 --- a/packages/tonal/tonleiter.mjs +++ b/packages/tonal/tonleiter.mjs @@ -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; } diff --git a/packages/tonal/voicings.mjs b/packages/tonal/voicings.mjs index 7dcc6a77b..de0e2595a 100644 --- a/packages/tonal/voicings.mjs +++ b/packages/tonal/voicings.mjs @@ -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 * diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs index f97251a07..d55957ced 100644 --- a/packages/webaudio/supradough.mjs +++ b/packages/webaudio/supradough.mjs @@ -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') { diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index 5377013e2..4f7256fe7 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -9,7 +9,7 @@ import { superdough, getAudioContext, setLogger, - doughTrigger, + rawdspTrigger, registerWorklet, setAudioContext, initAudio, @@ -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) { diff --git a/packages/xen/xen.mjs b/packages/xen/xen.mjs index 828cab6ea..3f940c89a 100644 --- a/packages/xen/xen.mjs +++ b/packages/xen/xen.mjs @@ -149,6 +149,7 @@ export const withBase = register('withBase', (b, pat) => { let hVal = hap.value; const isObject = typeof hVal === 'object'; let freq = isObject ? hVal.freq : hVal; + if (!freq) return hap; freq = (freq * base) / originalBase; hap.value = isObject ? { ...hap.value, freq } : { freq }; return hap; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b086a3d0..c667981ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -303,6 +303,22 @@ importers: specifier: ^2.2.0 version: 2.2.0 + packages/dough: + dependencies: + '@strudel/core': + specifier: workspace:* + version: link:../core + '@strudel/webaudio': + specifier: workspace:* + version: link:../webaudio + dough-synth: + specifier: 0.2.4 + version: 0.2.4 + devDependencies: + vite: + specifier: ^6.0.11 + version: 6.0.11(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0) + packages/draw: dependencies: '@strudel/core': @@ -470,6 +486,9 @@ importers: '@strudel/core': specifier: workspace:* version: link:../core + '@strudel/webaudio': + specifier: workspace:* + version: link:../webaudio osc: specifier: ^2.4.5 version: 2.4.5 @@ -808,6 +827,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 +2088,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 +3088,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 +3740,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 +4072,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 +4608,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 +4618,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 +4646,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 +6439,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 +7264,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 +7392,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 +7672,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 +11794,8 @@ snapshots: dotenv@16.4.7: {} + dough-synth@0.2.4: {} + dset@3.1.4: {} dunder-proto@1.0.1: diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 89af4be4e..1d8d69922 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -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 ]", @@ -3490,6 +3519,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`] = ` @@ -6035,6 +6088,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 ]", @@ -6661,6 +6738,250 @@ exports[`runs examples > example "juxBy" example index 0 1`] = ` ] `; +exports[`runs examples > example "juxFlip" example index 0 1`] = ` +[ + "[ 0/1 → 1/8 | s:bd pan:0 ]", + "[ 0/1 → 1/8 | s:hh pan:1 ]", + "[ 1/8 → 1/4 | s:lt pan:0 ]", + "[ 1/8 → 1/4 | s:bd pan:1 ]", + "[ 5/16 → 3/8 | s:ht pan:0 ]", + "[ 3/8 → 1/2 | s:mt pan:0 ]", + "[ 3/8 → 1/2 | s:cp pan:1 ]", + "[ 1/2 → 5/8 | s:cp pan:0 ]", + "[ 1/2 → 5/8 | s:mt pan:1 ]", + "[ 5/8 → 11/16 | s:ht pan:1 ]", + "[ 3/4 → 7/8 | s:bd pan:0 ]", + "[ 3/4 → 7/8 | s:lt pan:1 ]", + "[ 7/8 → 1/1 | s:hh pan:0 ]", + "[ 7/8 → 1/1 | s:bd pan:1 ]", + "[ 1/1 → 9/8 | s:bd pan:1 ]", + "[ 1/1 → 9/8 | s:hh pan:0 ]", + "[ 9/8 → 5/4 | s:lt pan:1 ]", + "[ 9/8 → 5/4 | s:bd pan:0 ]", + "[ 21/16 → 11/8 | s:ht pan:1 ]", + "[ 11/8 → 3/2 | s:mt pan:1 ]", + "[ 11/8 → 3/2 | s:cp pan:0 ]", + "[ 3/2 → 13/8 | s:cp pan:1 ]", + "[ 3/2 → 13/8 | s:mt pan:0 ]", + "[ 13/8 → 27/16 | s:ht pan:0 ]", + "[ 7/4 → 15/8 | s:bd pan:1 ]", + "[ 7/4 → 15/8 | s:lt pan:0 ]", + "[ 15/8 → 2/1 | s:hh pan:1 ]", + "[ 15/8 → 2/1 | s:bd pan:0 ]", + "[ 2/1 → 17/8 | s:bd pan:0 ]", + "[ 2/1 → 17/8 | s:hh pan:1 ]", + "[ 17/8 → 9/4 | s:lt pan:0 ]", + "[ 17/8 → 9/4 | s:bd pan:1 ]", + "[ 37/16 → 19/8 | s:ht pan:0 ]", + "[ 19/8 → 5/2 | s:mt pan:0 ]", + "[ 19/8 → 5/2 | s:cp pan:1 ]", + "[ 5/2 → 21/8 | s:cp pan:0 ]", + "[ 5/2 → 21/8 | s:mt pan:1 ]", + "[ 21/8 → 43/16 | s:ht pan:1 ]", + "[ 11/4 → 23/8 | s:bd pan:0 ]", + "[ 11/4 → 23/8 | s:lt pan:1 ]", + "[ 23/8 → 3/1 | s:hh pan:0 ]", + "[ 23/8 → 3/1 | s:bd pan:1 ]", + "[ 3/1 → 25/8 | s:bd pan:1 ]", + "[ 3/1 → 25/8 | s:hh pan:0 ]", + "[ 25/8 → 13/4 | s:lt pan:1 ]", + "[ 25/8 → 13/4 | s:bd pan:0 ]", + "[ 53/16 → 27/8 | s:ht pan:1 ]", + "[ 27/8 → 7/2 | s:mt pan:1 ]", + "[ 27/8 → 7/2 | s:cp pan:0 ]", + "[ 7/2 → 29/8 | s:cp pan:1 ]", + "[ 7/2 → 29/8 | s:mt pan:0 ]", + "[ 29/8 → 59/16 | s:ht pan:0 ]", + "[ 15/4 → 31/8 | s:bd pan:1 ]", + "[ 15/4 → 31/8 | s:lt pan:0 ]", + "[ 31/8 → 4/1 | s:hh pan:1 ]", + "[ 31/8 → 4/1 | s:bd pan:0 ]", +] +`; + +exports[`runs examples > example "juxFlip" example index 1 1`] = ` +[ + "[ 0/1 → 1/8 | s:bd pan:0 ]", + "[ 1/16 → 1/8 | s:bd pan:1 ]", + "[ 1/8 → 1/4 | s:lt pan:0 ]", + "[ 3/16 → 1/4 | s:lt pan:1 ]", + "[ 5/16 → 3/8 | s:ht pan:0 ]", + "[ 11/32 → 3/8 | s:ht pan:1 ]", + "[ 3/8 → 1/2 | s:mt pan:0 ]", + "[ 7/16 → 1/2 | s:mt pan:1 ]", + "[ 1/2 → 5/8 | s:cp pan:0 ]", + "[ 9/16 → 5/8 | s:cp pan:1 ]", + "[ 3/4 → 7/8 | s:bd pan:0 ]", + "[ 13/16 → 7/8 | s:bd pan:1 ]", + "[ 7/8 → 1/1 | s:hh pan:0 ]", + "[ 15/16 → 1/1 | s:hh pan:1 ]", + "[ 1/1 → 9/8 | s:bd pan:1 ]", + "[ 17/16 → 9/8 | s:bd pan:0 ]", + "[ 9/8 → 5/4 | s:lt pan:1 ]", + "[ 19/16 → 5/4 | s:lt pan:0 ]", + "[ 21/16 → 11/8 | s:ht pan:1 ]", + "[ 43/32 → 11/8 | s:ht pan:0 ]", + "[ 11/8 → 3/2 | s:mt pan:1 ]", + "[ 23/16 → 3/2 | s:mt pan:0 ]", + "[ 3/2 → 13/8 | s:cp pan:1 ]", + "[ 25/16 → 13/8 | s:cp pan:0 ]", + "[ 7/4 → 15/8 | s:bd pan:1 ]", + "[ 29/16 → 15/8 | s:bd pan:0 ]", + "[ 15/8 → 2/1 | s:hh pan:1 ]", + "[ 31/16 → 2/1 | s:hh pan:0 ]", + "[ 2/1 → 17/8 | s:bd pan:0 ]", + "[ 33/16 → 17/8 | s:bd pan:1 ]", + "[ 17/8 → 9/4 | s:lt pan:0 ]", + "[ 35/16 → 9/4 | s:lt pan:1 ]", + "[ 37/16 → 19/8 | s:ht pan:0 ]", + "[ 75/32 → 19/8 | s:ht pan:1 ]", + "[ 19/8 → 5/2 | s:mt pan:0 ]", + "[ 39/16 → 5/2 | s:mt pan:1 ]", + "[ 5/2 → 21/8 | s:cp pan:0 ]", + "[ 41/16 → 21/8 | s:cp pan:1 ]", + "[ 11/4 → 23/8 | s:bd pan:0 ]", + "[ 45/16 → 23/8 | s:bd pan:1 ]", + "[ 23/8 → 3/1 | s:hh pan:0 ]", + "[ 47/16 → 3/1 | s:hh pan:1 ]", + "[ 3/1 → 25/8 | s:bd pan:1 ]", + "[ 49/16 → 25/8 | s:bd pan:0 ]", + "[ 25/8 → 13/4 | s:lt pan:1 ]", + "[ 51/16 → 13/4 | s:lt pan:0 ]", + "[ 53/16 → 27/8 | s:ht pan:1 ]", + "[ 107/32 → 27/8 | s:ht pan:0 ]", + "[ 27/8 → 7/2 | s:mt pan:1 ]", + "[ 55/16 → 7/2 | s:mt pan:0 ]", + "[ 7/2 → 29/8 | s:cp pan:1 ]", + "[ 57/16 → 29/8 | s:cp pan:0 ]", + "[ 15/4 → 31/8 | s:bd pan:1 ]", + "[ 61/16 → 31/8 | s:bd pan:0 ]", + "[ 31/8 → 4/1 | s:hh pan:1 ]", + "[ 63/16 → 4/1 | s:hh pan:0 ]", +] +`; + +exports[`runs examples > example "juxFlip" example index 2 1`] = ` +[ + "[ 0/1 → 1/8 | s:bd pan:0 ]", + "[ 0/1 → 1/8 | s:bd pan:1 ]", + "[ 1/8 → 1/4 | s:lt pan:0 ]", + "[ 1/8 → 1/4 | s:lt pan:1 ]", + "[ 5/16 → 3/8 | s:ht pan:0 ]", + "[ 5/16 → 3/8 | s:ht pan:1 ]", + "[ 3/8 → 1/2 | s:mt pan:0 ]", + "[ 3/8 → 1/2 | s:mt pan:1 ]", + "[ 1/2 → 5/8 | s:cp pan:0 ]", + "[ 1/2 → 5/8 | s:cp pan:1 ]", + "[ 3/4 → 7/8 | s:bd pan:0 ]", + "[ 3/4 → 7/8 | s:bd pan:1 ]", + "[ 7/8 → 1/1 | s:hh pan:0 ]", + "[ 7/8 → 1/1 | s:hh pan:1 ]", + "[ 1/1 → 9/8 | s:bd pan:1 ]", + "[ 17/16 → 9/8 | s:ht pan:0 ]", + "[ 9/8 → 5/4 | s:lt pan:1 ]", + "[ 9/8 → 5/4 | s:mt pan:0 ]", + "[ 5/4 → 11/8 | s:cp pan:0 ]", + "[ 21/16 → 11/8 | s:ht pan:1 ]", + "[ 11/8 → 3/2 | s:mt pan:1 ]", + "[ 3/2 → 13/8 | s:cp pan:1 ]", + "[ 3/2 → 13/8 | s:bd pan:0 ]", + "[ 13/8 → 7/4 | s:hh pan:0 ]", + "[ 7/4 → 15/8 | s:bd pan:1 ]", + "[ 7/4 → 15/8 | s:bd pan:0 ]", + "[ 15/8 → 2/1 | s:hh pan:1 ]", + "[ 15/8 → 2/1 | s:lt pan:0 ]", + "[ 2/1 → 17/8 | s:bd pan:0 ]", + "[ 2/1 → 17/8 | s:cp pan:1 ]", + "[ 17/8 → 9/4 | s:lt pan:0 ]", + "[ 9/4 → 19/8 | s:bd pan:1 ]", + "[ 37/16 → 19/8 | s:ht pan:0 ]", + "[ 19/8 → 5/2 | s:mt pan:0 ]", + "[ 19/8 → 5/2 | s:hh pan:1 ]", + "[ 5/2 → 21/8 | s:cp pan:0 ]", + "[ 5/2 → 21/8 | s:bd pan:1 ]", + "[ 21/8 → 11/4 | s:lt pan:1 ]", + "[ 11/4 → 23/8 | s:bd pan:0 ]", + "[ 45/16 → 23/8 | s:ht pan:1 ]", + "[ 23/8 → 3/1 | s:hh pan:0 ]", + "[ 23/8 → 3/1 | s:mt pan:1 ]", + "[ 3/1 → 25/8 | s:bd pan:1 ]", + "[ 3/1 → 25/8 | s:bd pan:0 ]", + "[ 25/8 → 13/4 | s:lt pan:1 ]", + "[ 25/8 → 13/4 | s:hh pan:0 ]", + "[ 13/4 → 27/8 | s:bd pan:0 ]", + "[ 53/16 → 27/8 | s:ht pan:1 ]", + "[ 27/8 → 7/2 | s:mt pan:1 ]", + "[ 27/8 → 7/2 | s:lt pan:0 ]", + "[ 7/2 → 29/8 | s:cp pan:1 ]", + "[ 57/16 → 29/8 | s:ht pan:0 ]", + "[ 29/8 → 15/4 | s:mt pan:0 ]", + "[ 15/4 → 31/8 | s:bd pan:1 ]", + "[ 15/4 → 31/8 | s:cp pan:0 ]", + "[ 31/8 → 4/1 | s:hh pan:1 ]", +] +`; + +exports[`runs examples > example "juxFlipBy" example index 0 1`] = ` +[ + "[ 0/1 → 1/8 | s:bd pan:0.09999999999999998 ]", + "[ 0/1 → 1/8 | s:hh pan:0.9 ]", + "[ 1/8 → 1/4 | s:lt pan:0.09999999999999998 ]", + "[ 1/8 → 1/4 | s:bd pan:0.9 ]", + "[ 5/16 → 3/8 | s:ht pan:0.09999999999999998 ]", + "[ 3/8 → 1/2 | s:mt pan:0.09999999999999998 ]", + "[ 3/8 → 1/2 | s:cp pan:0.9 ]", + "[ 1/2 → 5/8 | s:cp pan:0.09999999999999998 ]", + "[ 1/2 → 5/8 | s:mt pan:0.9 ]", + "[ 5/8 → 11/16 | s:ht pan:0.9 ]", + "[ 3/4 → 7/8 | s:bd pan:0.09999999999999998 ]", + "[ 3/4 → 7/8 | s:lt pan:0.9 ]", + "[ 7/8 → 1/1 | s:hh pan:0.09999999999999998 ]", + "[ 7/8 → 1/1 | s:bd pan:0.9 ]", + "[ 1/1 → 9/8 | s:bd pan:0.9 ]", + "[ 1/1 → 9/8 | s:hh pan:0.09999999999999998 ]", + "[ 9/8 → 5/4 | s:lt pan:0.9 ]", + "[ 9/8 → 5/4 | s:bd pan:0.09999999999999998 ]", + "[ 21/16 → 11/8 | s:ht pan:0.9 ]", + "[ 11/8 → 3/2 | s:mt pan:0.9 ]", + "[ 11/8 → 3/2 | s:cp pan:0.09999999999999998 ]", + "[ 3/2 → 13/8 | s:cp pan:0.9 ]", + "[ 3/2 → 13/8 | s:mt pan:0.09999999999999998 ]", + "[ 13/8 → 27/16 | s:ht pan:0.09999999999999998 ]", + "[ 7/4 → 15/8 | s:bd pan:0.9 ]", + "[ 7/4 → 15/8 | s:lt pan:0.09999999999999998 ]", + "[ 15/8 → 2/1 | s:hh pan:0.9 ]", + "[ 15/8 → 2/1 | s:bd pan:0.09999999999999998 ]", + "[ 2/1 → 17/8 | s:bd pan:0.09999999999999998 ]", + "[ 2/1 → 17/8 | s:hh pan:0.9 ]", + "[ 17/8 → 9/4 | s:lt pan:0.09999999999999998 ]", + "[ 17/8 → 9/4 | s:bd pan:0.9 ]", + "[ 37/16 → 19/8 | s:ht pan:0.09999999999999998 ]", + "[ 19/8 → 5/2 | s:mt pan:0.09999999999999998 ]", + "[ 19/8 → 5/2 | s:cp pan:0.9 ]", + "[ 5/2 → 21/8 | s:cp pan:0.09999999999999998 ]", + "[ 5/2 → 21/8 | s:mt pan:0.9 ]", + "[ 21/8 → 43/16 | s:ht pan:0.9 ]", + "[ 11/4 → 23/8 | s:bd pan:0.09999999999999998 ]", + "[ 11/4 → 23/8 | s:lt pan:0.9 ]", + "[ 23/8 → 3/1 | s:hh pan:0.09999999999999998 ]", + "[ 23/8 → 3/1 | s:bd pan:0.9 ]", + "[ 3/1 → 25/8 | s:bd pan:0.9 ]", + "[ 3/1 → 25/8 | s:hh pan:0.09999999999999998 ]", + "[ 25/8 → 13/4 | s:lt pan:0.9 ]", + "[ 25/8 → 13/4 | s:bd pan:0.09999999999999998 ]", + "[ 53/16 → 27/8 | s:ht pan:0.9 ]", + "[ 27/8 → 7/2 | s:mt pan:0.9 ]", + "[ 27/8 → 7/2 | s:cp pan:0.09999999999999998 ]", + "[ 7/2 → 29/8 | s:cp pan:0.9 ]", + "[ 7/2 → 29/8 | s:mt pan:0.09999999999999998 ]", + "[ 29/8 → 59/16 | s:ht pan:0.09999999999999998 ]", + "[ 15/4 → 31/8 | s:bd pan:0.9 ]", + "[ 15/4 → 31/8 | s:lt pan:0.09999999999999998 ]", + "[ 31/8 → 4/1 | s:hh pan:0.9 ]", + "[ 31/8 → 4/1 | s:bd pan:0.09999999999999998 ]", +] +`; + exports[`runs examples > example "keep" example index 0 1`] = ` [ "[ 0/1 → 1/4 | note:c s:piano ]", @@ -7817,6 +8138,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 ]", @@ -13374,18 +13697,10 @@ exports[`runs examples > example "stepcat" example index 1 1`] = ` exports[`runs examples > example "stretch" example index 0 1`] = ` [ - "[ (0/1 → 1/3) ⇝ 1/1 | s:gm_flute stretch:1 ]", - "[ 0/1 ⇜ (1/3 → 2/3) ⇝ 1/1 | s:gm_flute stretch:2 ]", - "[ 0/1 ⇜ (2/3 → 1/1) | s:gm_flute stretch:0.5 ]", - "[ (1/1 → 4/3) ⇝ 2/1 | s:gm_flute stretch:1 ]", - "[ 1/1 ⇜ (4/3 → 5/3) ⇝ 2/1 | s:gm_flute stretch:2 ]", - "[ 1/1 ⇜ (5/3 → 2/1) | s:gm_flute stretch:0.5 ]", - "[ (2/1 → 7/3) ⇝ 3/1 | s:gm_flute stretch:1 ]", - "[ 2/1 ⇜ (7/3 → 8/3) ⇝ 3/1 | s:gm_flute stretch:2 ]", - "[ 2/1 ⇜ (8/3 → 3/1) | s:gm_flute stretch:0.5 ]", - "[ (3/1 → 10/3) ⇝ 4/1 | s:gm_flute stretch:1 ]", - "[ 3/1 ⇜ (10/3 → 11/3) ⇝ 4/1 | s:gm_flute stretch:2 ]", - "[ 3/1 ⇜ (11/3 → 4/1) | s:gm_flute stretch:0.5 ]", + "[ 0/1 → 1/1 | s:gm_flute stretch:2 ]", + "[ 1/1 → 2/1 | s:gm_flute stretch:1 ]", + "[ 2/1 → 3/1 | s:gm_flute stretch:0 ]", + "[ 3/1 → 4/1 | s:gm_flute stretch:-2 ]", ] `; 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/test/runtime.mjs b/test/runtime.mjs index e06f91620..e8f346df7 100644 --- a/test/runtime.mjs +++ b/test/runtime.mjs @@ -99,6 +99,7 @@ const toneHelpersMocked = { '_spectrum', 'markcss', 'p', + 'dough', ].forEach((mock) => { strudel.Pattern.prototype[mock] = function () { return this; @@ -174,6 +175,7 @@ evalScope( getDuration, setcps: id, setcpm: id, + initDough: id, Clock: {}, // whatever }, ); diff --git a/undocumented.json b/undocumented.json index 3f5a3be30..5e4116871 100644 --- a/undocumented.json +++ b/undocumented.json @@ -597,8 +597,8 @@ ], "/packages/superdough/dspworklet.mjs": [ "dspWorklet", - "dough", - "doughTrigger" + "rawdsp", + "rawdspTrigger" ], "/packages/superdough/index.mjs": [], "/packages/tonal/tonleiter.mjs": [ diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 867635883..de08502dd 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, @@ -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', + }, + }, }); diff --git a/website/package.json b/website/package.json index 7258f150e..3bc8b7bce 100644 --- a/website/package.json +++ b/website/package.json @@ -41,6 +41,7 @@ "@strudel/motion": "workspace:*", "@strudel/mqtt": "workspace:*", "@strudel/osc": "workspace:*", + "@strudel/dough": "workspace:*", "@strudel/serial": "workspace:*", "@strudel/soundfonts": "workspace:*", "@strudel/tidal": "workspace:*", diff --git a/website/src/components/HeadCommon.astro b/website/src/components/HeadCommon.astro index 550222e44..57a425f3f 100644 --- a/website/src/components/HeadCommon.astro +++ b/website/src/components/HeadCommon.astro @@ -24,7 +24,7 @@ const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL - + diff --git a/website/src/components/HeadCommonNext.astro b/website/src/components/HeadCommonNext.astro index f477cd8f0..852c2051d 100644 --- a/website/src/components/HeadCommonNext.astro +++ b/website/src/components/HeadCommonNext.astro @@ -23,7 +23,7 @@ const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL - + diff --git a/website/src/components/HeadSEO.astro b/website/src/components/HeadSEO.astro index 413668c16..ecaedd134 100644 --- a/website/src/components/HeadSEO.astro +++ b/website/src/components/HeadSEO.astro @@ -16,7 +16,7 @@ const imageAlt = frontmatter.image?.alt ?? OPEN_GRAPH.image.alt; - + diff --git a/website/src/config.ts b/website/src/config.ts index eb8bd1af8..eb098d009 100644 --- a/website/src/config.ts +++ b/website/src/config.ts @@ -98,6 +98,7 @@ export const SIDEBAR: Sidebar = { { text: 'Random Modifiers', link: 'learn/random-modifiers' }, { text: 'Conditional Modifiers', link: 'learn/conditional-modifiers' }, { text: 'Accumulation', link: 'learn/accumulation' }, + { text: 'LFOs', link: 'learn/lfo' }, { text: 'Tonal Functions', link: 'learn/tonal' }, { text: 'Stepwise Functions', link: 'learn/stepwise' }, ], 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..d136408f0 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. @@ -337,3 +346,11 @@ s increase by one semitone, i.e. sharp, works for steps of scales, note names $: at the start of a line, defines a member of the stack. is the only stack name that should occur multiple names _ before a stack name: mutes the stack, i.e. hush(), for example _$: s("bd"), see above for a different usage. ``` + +## What is a 'hap'? + +A 'hap' is a name for a thing that happens, e.g. `note("c3 -")` playing a note and then stopping after a while. Haps have a start and an end. Every separate sound that is produced by strudel is a hap on its own. + +We would have called it 'event' if this particular name wasn't used by JavaScript to describe things that happen on html pages, like clicking on a button or hovering over a line of text. + +Since strudel is written in JavaScript, this confusion of having the same name for different things is avoided by calling events 'haps'. diff --git a/website/src/pages/learn/lfo.mdx b/website/src/pages/learn/lfo.mdx new file mode 100644 index 000000000..39fca67cf --- /dev/null +++ b/website/src/pages/learn/lfo.mdx @@ -0,0 +1,301 @@ +--- +title: Low-frequency oscillators (LFO) +layout: ../../layouts/MainLayout.astro +--- + +import { MiniRepl } from '../../docs/MiniRepl'; +import { JsDoc } from '../../docs/JsDoc'; + +# Low frequency oscillators (LFO) + +A low frequency oscillator (or short LFO) is a common way on synthesizers to continuously modulate various signals. + +This documentation is an interactive version of glossing's tutorial https://www.youtube.com/watch?v=11frBA9L638 + +## Signals vs LFOs + +In Strudel, there are two ways to modulate: + +1. [signals](/learn/signals/) for pattern-level modulation +2. `lfo` (this page) for audio-level modulation + +## Applying an LFO + +Here, the LFO will change the frequency of the `saw`. Put a comment like this `//.lfo()` to see and hear how it changes, and remove the comment again. + + + +By default, the LFO will modulate the control parameter which is right before `.lfo()`: + + + +Here, the LFO will modulate the low pass filter `.lpf`. + +## Moving away from the default + +The following sections explain how 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. + +## Control + +`control` determines which parameter will be modulated. This allows you to place your `lfo` at different places, +not necessarily immediately after the controlled parameter. + +Here, we place `lfo` after the `s`, but it modulates the low pass filter. + + +You can even influence parameters which are always present, even if you haven't explicitly written them, like `gain`. + + + +`control` has an alias `c`. + +## Rate + +`rate` determines how often the `lfo` oscillates per second: + +"}) + .lpf(800) + ._spectrum({height: 300, width: 800})`} +/> +The alias of `rate` is `r`.{' '} + +## Sync + +Instead of controlling the frequency with `rate` by setting a frequency in Hz, you can use `sync` +to snychronize your lfo with your other patterns. + +`sync` expresses this frequency as "times per cycle" + +Try removing the `sync: "<2 4 8 0.5 >"` from the pattern and notice that there is something not in sync. + +"}) + ._spectrum({height: 300, width: 800})`} +/> + +You can put patterns into the parameters of `lfo` if you want them to change over time, as seen above. + +## Relative Depth + +`.depth` is relative depth, a value of 1 (the default) means that +the value goes above and below by half of the value which is being modulated + +E.g. for depth `1`, it modulates the frequency to oscillate between 32 = (64/2) and 96 (= 64 + 64/2). + +"}) + .lpf(800) + ._spectrum({height: 300, width: 800})`} +/> +The `freq` is not needed here, as this is the default frequency, it's just for instructive purposes. + +The aliases of `depth` are `dr` and `dep`. + +## Absolute Depth + +`depthabs` controls the absolute modulation depth. For example you can modulate +the low pass filter by exactly 250 Hz up and below: + + + +The alias of `depthabs` is `da`. + +## DC offset + +If you don't want to go up or down by the same amount with your modulation, then you can shift the center of the modulation with `dcoffset`. +The default value is -0.5, which is the middle point between: +"dcoffset = 0: All modulations increase the control parameter (or keep it constant)" +"dcoffset = -1: All modulations decrease the control parameter (or keep it constant)" + +Other values will work as well. + +"})`} +/> +The marvellous alias of `dcoffset` is `dc`. + +## Shape + +You can change the shape of the modulation with `shape`. The default is `triangle`, but other shapes are available too: + +"}) `} +/> +You can add `._spectrum()` to see the shape of the modulations in the spectrum. + +You can also get these shapes by using numbers: + +| Shape | number | +| -------- | ------ | +| triangle | 0 | +| sine | 1 | +| ramp | 2 | +| saw | 3 | +| square | 4 | + +this way, you can use a function like `irand` to generate numbers: + + + +The alias of `shape` is `sh`. + +## Skewing some shapes + +You can influence some of the shapes (`triangle` and `square`) in more detail. + +The default skew is 0.5, it does different things for these two. + +For `triangle`, it skews the top of the triangle to the left or right, +where 0 makes it look like `saw` and 1 makes it look like `ramp`. + +For `square`, the skew influences the pulse width (see reference for `pulse` and `.pw`): + +/5", + skew: "<0 0.25 0.5 0.75 1>"}) `} +/> +The alias of `skew` is `sk`.{' '} + +## Curve + +You can change the curves of your lfo and can make it more intense. +The default value is 1. Writing numbers greater than one can make it more intense, +numbers between 0 and 1 will make it less intense. +This will raise the lfo to the power of curve, so larger numbers (such as 10) can have very unexpected results. + +", curve: "<1.3 1 0.75 1>"}) `} +/> +## Referencing your lfos with id + +All the lfos are numbered from the first starting with 0, the second having an `id` of 1 and so on. + +You can refer to this in a later call if you want to modify a specific `lfo`. + +The `id` is outside of the config json (which is different from the other parameters of `lfo()` + +Try out how the sound changes when you replace the 0 with a 1. + +"}) +.sometimes(x => x.lfo({dr: "4"},0))`} +/> + +You can also name your lfos and refer to them by name, using the `id` parameter + +"}, "lfo_lpf") +.sometimes(x => x.lfo({dr: "4"},""))`} +/> + +## FX index + +If you are using `FX()` to reorder your effects, you dont need to write your lfos inside the `FX`, +but instead can refer to them by their FX index (starting with 0) + + + +## Modulating other LFOs with Sub-control + +LFOs can modulate other lfos and will modulate their frequency (given by `r` or `s`): + + +To modulate other parameters of the first lfo (like `skew`, `depth` and so on), we can specify this with `subControl`or +its alias `sc` + + diff --git a/website/src/pages/understand/voicings.mdx b/website/src/pages/understand/voicings.mdx index 7d9c13d90..8711f0637 100644 --- a/website/src/pages/understand/voicings.mdx +++ b/website/src/pages/understand/voicings.mdx @@ -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: 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. + +![image](https://shop.pimoroni.com/cdn/shop/products/Inventor2040_1of3_1500x1500_crop_center.jpg?v=1656927927) + +## Technical details + +Feel free to gloss over these! + +- The Inventor 2040W connects to the internet wirelessly, and it can power from a battery or USB. Hopefully the batteries last! +- It's running [some code](https://github.com/patternclub/alpacalab/blob/main/course/main.py) that listens for messages using an "Internet of Things" network protocol (called MQTT). When it receives a message, it moves a motor. +- It connects to a small server (running software called 'mosquitto') on Alex's laptop. +- Strudel can send these messages instead of triggering sounds - that's how we use it to pattern movement. + +## First movement + +Let's get a motor running! + +1. Note the letter drawn on a label on the back of the microcontroller. + +2. Plug a battery into your microcontroller. + +3. Plug a motor into 'servo' (not motor) plug numbered 1, with the yellow (lightest) cable closest to the '1', and the brown (darkest) cable outward + +4. Run the below to set up some values, changing the `x` in 'robot('x')` to the letter on your microcontroller. + + + + + +If you refresh the page, you'll need to change the letter to match your robot again. + +If your motor starts moving unexpectedly, someone else might have put your letter in by mistake! + +Note that in the above, we start counting motors from '0', so motor 1 on the board is motor 0 in the code. + + + +## Patterning movement + +Many strudel features for playing with sound patterns will work when +playing with motor patterns. Try playing with the mininotation in the +`move` command: + + + +The move instructions are in the range from -90 to 90. + + + If your motors stop working at some point, and your code looks right, try pressing the 'reset' button on the + microcontroller. + + +It's possible to make smooth movements based on different 'waveforms', for example a smooth sinewave: + + + +The movement is still quite jerky, because the 'segment' command is +only taking 16 positions from the sinewave. Try increasing it to 32 or 64. It's best not too much higher than that, as the microcontroller +might get overwhelmed with a backlog of instructions! + + + Try replacing `sine` with other waveforms: `saw` (sawtooth wave), `tri` (triangular wave) are good, and there is also + `rand` (random wave) and `perlin` (a kind of smoothed-out randomness). + + +## Patterning more than one motor + +You can pattern the `motor` command separately from the `move` one: + + + +Alternatively, you can pattern two motors in separate patterns. The below sends the same pattern for the first two motors, but with the second one running slower: + + diff --git a/website/src/repl/components/panel/Panel.jsx b/website/src/repl/components/panel/Panel.jsx index 3b4dd5482..b36ae3eef 100644 --- a/website/src/repl/components/panel/Panel.jsx +++ b/website/src/repl/components/panel/Panel.jsx @@ -44,6 +44,25 @@ export function LogoButton({ context, isEmbedded }) { export function MainPanel({ context, isEmbedded = false, className }) { const { isZen, isButtonRowHidden, fontFamily } = useSettings(); + let loc = window.location; + let ver = 'unofficial'; + let hot = false; + let b = loc.hostname.match(/^(.+)\.(strudel)/); + if (/(strudel.cc$)/.test(loc.hostname)) { + // if there's no text before 'strudel', it's warm, otherwise use the text before strudel + ver = b ? b[1] : 'warm'; + } else { + // match both versions of localhost + if (/(localhost)|(127.0.0.1)/.test(loc.hostname)) ver = 'dev'; + } + let pr = ver.match(/pr-([0-9]+)/); + if (pr) { + pr = pr[1]; + ver = `hot: ${pr}`; + hot = true; + pr = `https://codeberg.org/uzu/strudel/pulls/${pr}`; + } + return (