From 439a7dc5e6b825a5270958ff04b869d52b37387b Mon Sep 17 00:00:00 2001 From: Aria Date: Tue, 2 Sep 2025 16:05:40 -0500 Subject: [PATCH 01/16] Include synonyms in autocomplete --- packages/codemirror/autocomplete.mjs | 29 +++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/codemirror/autocomplete.mjs b/packages/codemirror/autocomplete.mjs index 59ca8adf2..bfbf7b333 100644 --- a/packages/codemirror/autocomplete.mjs +++ b/packages/codemirror/autocomplete.mjs @@ -74,15 +74,26 @@ const isValidDoc = (doc) => { const hasExcludedTags = (doc) => ['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag)); -const jsdocCompletions = jsdoc.docs - .filter((doc) => isValidDoc(doc) && !hasExcludedTags(doc)) - // https://codemirror.net/docs/ref/#autocomplete.Completion - .map((doc) => ({ - label: getDocLabel(doc), - // detail: 'xxx', // An optional short piece of information to show (with a different style) after the label. - info: () => Autocomplete({ doc }), - type: 'function', // https://codemirror.net/docs/ref/#autocomplete.Completion.type - })); +const jsdocCompletions = (() => { + const seen = new Set(); // avoid repetition + const completions = []; + for (const doc of jsdoc.docs) { + if (!isValidDoc(doc) || hasExcludedTags(doc)) continue; + let labels = [getDocLabel(doc), ...(doc.synonyms || [])]; + for (const label of labels) { + // https://codemirror.net/docs/ref/#autocomplete.Completion + if (label && !seen.has(label)) { + seen.add(label); + completions.push({ + label, + info: () => Autocomplete({ doc }), + type: 'function', // https://codemirror.net/docs/ref/#autocomplete.Completion.type + }); + } + } + } + return completions; +})(); export const strudelAutocomplete = (context) => { const word = context.matchBefore(/\w*/); From 052d09e892f2ec8022381e72d690a4c3905b23ea Mon Sep 17 00:00:00 2001 From: Aria Date: Tue, 2 Sep 2025 16:14:38 -0500 Subject: [PATCH 02/16] Add synonyms to reference --- .../src/repl/components/panel/Reference.jsx | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/website/src/repl/components/panel/Reference.jsx b/website/src/repl/components/panel/Reference.jsx index 1b617341b..2e2ef4cbc 100644 --- a/website/src/repl/components/panel/Reference.jsx +++ b/website/src/repl/components/panel/Reference.jsx @@ -2,9 +2,28 @@ import { useMemo, useState } from 'react'; import jsdocJson from '../../../../../doc.json'; import { Textbox } from '../textbox/Textbox'; -const availableFunctions = jsdocJson.docs - .filter(({ name, description }) => name && !name.startsWith('_') && !!description) - .sort((a, b) => /* a.meta.filename.localeCompare(b.meta.filename) + */ a.name.localeCompare(b.name)); + +const isValid = ({ name, description }) => + name && !name.startsWith('_') && !!description; + +const availableFunctions = (() => { + const seen = new Set(); // avoid repetition + const functions = []; + for (const doc of jsdocJson.docs) { + if (!isValid(doc)) continue; + let docAndSynonyms = [doc.name, ...(doc.synonyms || [])]; + for (const s of docAndSynonyms) { + if (!s || seen.has(s)) continue; + seen.add(s); + functions.push({ + ...doc, + name: s, // update names for the synonym + longname: s, + }); + } + } + return functions.sort((a, b) => /* a.meta.filename.localeCompare(b.meta.filename) + */ a.name.localeCompare(b.name)); +})(); const getInnerText = (html) => { var div = document.createElement('div'); From 8d2a368da91d84307779e8e969bb0828c649c2a2 Mon Sep 17 00:00:00 2001 From: Aria Date: Tue, 2 Sep 2025 16:14:50 -0500 Subject: [PATCH 03/16] Add some more synonyms to controls docs --- packages/core/controls.mjs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 58a30652c..1c025313d 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -91,6 +91,7 @@ export const { s, sound } = registerControl(['s', 'n', 'gain'], 'sound'); * Define a custom webaudio node to use as a sound source. * * @name source + * @synonyms src * @param {function} getSource * @synonyms src * @@ -525,6 +526,7 @@ export const { tremolophase } = registerControl('tremolophase', 'tremphase'); * shape of amplitude modulation * * @name tremoloshape + * @synonyms tremshape * @param {number | Pattern} shape tri | square | sine | saw | ramp * @example * note("{f g c d}%16").tremsync(4).tremoloshape("").s("sawtooth") @@ -540,11 +542,13 @@ export const { tremoloshape } = registerControl('tremoloshape', 'tremshape'); * note("{f g g c d a a#}%16".sub(17)).s("supersaw").lpenv(8).lpf(150).lpq(.8).ftype('ladder').drive("<.5 4>") * */ +export const { drive } = registerControl('drive'); /** * modulate the amplitude of an orbit to create a "sidechain" like effect * * @name duckorbit + * @synonyms duck * @param {number | Pattern} orbit target orbit * @example * $: n(run(16)).scale("c:minor:pentatonic").s("sawtooth").delay(.7).orbit(2) @@ -569,6 +573,7 @@ export const { duckdepth } = registerControl('duckdepth'); * the attack time of the duck effect * * @name duckattack + * @synonyms duckatt * @param {number | Pattern} time * @example * stack( n(run(8)).scale("c:minor").s("sawtooth").delay(.7).orbit(2), s("bd:4!4").beat("0,4,8,11,14",16).duckorbit(2).duckattack("<0.2 0 0.4>").duckdepth(1)) @@ -576,8 +581,6 @@ export const { duckdepth } = registerControl('duckdepth'); */ export const { duckattack } = registerControl('duckattack', 'duckatt'); -export const { drive } = registerControl('drive'); - /** * Create byte beats with custom expressions * @@ -700,7 +703,7 @@ export const { phasercenter, phc } = registerControl('phasercenter', 'phc'); * The amount the signal is affected by the phaser effect. Defaults to 0.75 * * @name phaserdepth - * @synonyms phd + * @synonyms phd, phasdp * @param {number | Pattern} depth number between 0 and 1 * @example * n(run(8)).scale("D:pentatonic").s("sawtooth").release(0.5) @@ -1182,6 +1185,7 @@ export const { dry } = registerControl('dry'); * Used when using `begin`/`end` or `chop`/`striate` and friends, to change the fade out time of the 'grain' envelope. * * @name fadeTime + * @synonyms fadeOutTime * @param {number | Pattern} time between 0 and 1 * @example * s("oh*4").end(.1).fadeTime("<0 .2 .4 .8>").osc() From c199b51645f899940fa5e5a63e72b57877086e82 Mon Sep 17 00:00:00 2001 From: Aria Date: Tue, 2 Sep 2025 16:29:01 -0500 Subject: [PATCH 04/16] Update autocomplete name to be the label and update ref to use main name as a synonym --- packages/codemirror/autocomplete.mjs | 2 +- website/src/repl/components/panel/Reference.jsx | 13 +++++++++---- website/src/repl/components/panel/SettingsTab.jsx | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/codemirror/autocomplete.mjs b/packages/codemirror/autocomplete.mjs index bfbf7b333..b56cab826 100644 --- a/packages/codemirror/autocomplete.mjs +++ b/packages/codemirror/autocomplete.mjs @@ -86,7 +86,7 @@ const jsdocCompletions = (() => { seen.add(label); completions.push({ label, - info: () => Autocomplete({ doc }), + info: () => Autocomplete({ doc, label }), type: 'function', // https://codemirror.net/docs/ref/#autocomplete.Completion.type }); } diff --git a/website/src/repl/components/panel/Reference.jsx b/website/src/repl/components/panel/Reference.jsx index 2e2ef4cbc..81826aca2 100644 --- a/website/src/repl/components/panel/Reference.jsx +++ b/website/src/repl/components/panel/Reference.jsx @@ -3,22 +3,27 @@ import { useMemo, useState } from 'react'; import jsdocJson from '../../../../../doc.json'; import { Textbox } from '../textbox/Textbox'; -const isValid = ({ name, description }) => - name && !name.startsWith('_') && !!description; +const isValid = ({ name, description }) => name && !name.startsWith('_') && !!description; const availableFunctions = (() => { const seen = new Set(); // avoid repetition const functions = []; for (const doc of jsdocJson.docs) { if (!isValid(doc)) continue; - let docAndSynonyms = [doc.name, ...(doc.synonyms || [])]; - for (const s of docAndSynonyms) { + functions.push(doc); + const synonyms = doc.synonyms || []; + for (const s of synonyms) { if (!s || seen.has(s)) continue; seen.add(s); + // Swap `doc.name` in for `s` in the list of synonyms + const notS = synonyms.filter((x) => x && x !== s); + const synonymsWithDoc = Array.from(new Set([doc.name, ...notS])); functions.push({ ...doc, name: s, // update names for the synonym longname: s, + synonyms: synonymsWithDoc, + synonyms_text: synonymsWithDoc.join(', '), }); } } diff --git a/website/src/repl/components/panel/SettingsTab.jsx b/website/src/repl/components/panel/SettingsTab.jsx index 8f02ca8b8..26c9ae287 100644 --- a/website/src/repl/components/panel/SettingsTab.jsx +++ b/website/src/repl/components/panel/SettingsTab.jsx @@ -312,7 +312,7 @@ export function SettingsTab({ started }) { confirmDialog('Sure?').then((r) => { if (r) { const { userPatterns } = settingsMap.get(); // keep current patterns - settingsMap.set({...defaultSettings, userPatterns}); + settingsMap.set({ ...defaultSettings, userPatterns }); } }); }} From 56b1ea3fa50b7a8921f3159f4e1c715aa502bf71 Mon Sep 17 00:00:00 2001 From: Antipathie Date: Wed, 3 Sep 2025 00:15:47 +0200 Subject: [PATCH 05/16] Add soundAlias function --- packages/superdough/superdough.mjs | 13 +++++++++++++ website/src/pages/learn/samples.mdx | 8 ++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index a518f49b6..6f8717d05 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -113,6 +113,19 @@ export async function aliasBank(...args) { } } +/** + * Register an alias for a sound. + * @param {string} original - The original sound name + * @param {string} alias - The alias to use for the sound + */ +export function soundAlias(original, alias) { + if (getSound(original) == null) { + logger('soundAlias: original sound not found'); + return; + } + soundMap.setKey(alias, getSound(original)); +} + export function getSound(s) { if (typeof s !== 'string') { console.warn(`getSound: expected string got "${s}". fall back to triangle`); diff --git a/website/src/pages/learn/samples.mdx b/website/src/pages/learn/samples.mdx index a87b8f7dd..eb79cccf7 100644 --- a/website/src/pages/learn/samples.mdx +++ b/website/src/pages/learn/samples.mdx @@ -59,6 +59,14 @@ Furthermore, strudel also loads instrument samples from [VCSL](https://github.co To see which sample names are available, open the `sounds` tab in the [REPL](https://strudel.cc/). +You can also create custom aliases for existing sounds using the `soundAlias` function: + + + Note that only the sample maps (mapping names to URLs) are loaded initially, while the audio samples themselves are not loaded until they are actually played. This behaviour of loading things only when they are needed is also called `lazy loading`. While it saves resources, it can also lead to sounds not being audible the first time they are triggered, because the sound is still loading. From 38ce593c602659a594b81047dd9633790ef7c010 Mon Sep 17 00:00:00 2001 From: Aria Date: Tue, 9 Sep 2025 20:43:05 -0500 Subject: [PATCH 06/16] Allow penv values to be falsy --- packages/superdough/helpers.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 81bec9399..69e7e8560 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -174,7 +174,7 @@ let curves = ['linear', 'exponential']; export function getPitchEnvelope(param, value, t, holdEnd) { // envelope is active when any of these values is set const hasEnvelope = value.pattack ?? value.pdecay ?? value.psustain ?? value.prelease ?? value.penv; - if (!hasEnvelope) { + if (hasEnvelope === undefined) { return; } const penv = nanFallback(value.penv, 1, true); From 352a3c39d7fd050348825d752239bc9e39f05f5e Mon Sep 17 00:00:00 2001 From: fesmith Date: Sat, 13 Sep 2025 00:00:36 +0200 Subject: [PATCH 07/16] Update website/src/pages/workshop/first-sounds.mdx --- website/src/pages/workshop/first-sounds.mdx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/website/src/pages/workshop/first-sounds.mdx b/website/src/pages/workshop/first-sounds.mdx index 74daf4bab..f120f332a 100644 --- a/website/src/pages/workshop/first-sounds.mdx +++ b/website/src/pages/workshop/first-sounds.mdx @@ -260,16 +260,16 @@ It is quite common that there are many ways to express the same idea. punchcard /> -**selecting sample numbers separately** +**selecting sample numbers separately** -Instead of using ":", we can also use the `n` function to select sample numbers: - - - -This is shorter and more readable than: +Instead of selecting sample numbers one by one: +We can also use the `n` function to make it shorter and more readable: + + + ## Recap Now we've learned the basics of the so called Mini-Notation, the rhythm language of Tidal. From 709b654ed39927f1c5f5ea8ae7bbde54f22e0f11 Mon Sep 17 00:00:00 2001 From: Aria Date: Sat, 13 Sep 2025 16:21:09 -0500 Subject: [PATCH 08/16] Add synonyms to autocomplete --- packages/codemirror/autocomplete.mjs | 26 ++++++++++++++++--- packages/codemirror/tooltip.mjs | 9 ++++--- packages/core/pattern.mjs | 12 +++++---- website/src/repl/Repl.css | 7 +++++ .../src/repl/components/panel/Reference.jsx | 3 +-- 5 files changed, 42 insertions(+), 15 deletions(-) diff --git a/packages/codemirror/autocomplete.mjs b/packages/codemirror/autocomplete.mjs index b56cab826..69aa3bc59 100644 --- a/packages/codemirror/autocomplete.mjs +++ b/packages/codemirror/autocomplete.mjs @@ -54,11 +54,12 @@ const buildExamples = (examples) => ` : ''; -export const Autocomplete = ({ doc, label }) => +export const Autocomplete = (doc) => h`
-

${label || getDocLabel(doc)}

+

${getDocLabel(doc)}

+ ${doc.synonyms_text ? `
Synonyms: ${doc.synonyms_text}
` : ''} ${doc.description ? `
${doc.description}
` : ''} ${buildParamsList(doc.params)} ${buildExamples(doc.examples)} @@ -74,19 +75,36 @@ const isValidDoc = (doc) => { const hasExcludedTags = (doc) => ['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag)); +export const getSynonymDoc = (doc, synonym) => { + const synonyms = doc.synonyms || []; + const docLabel = getDocLabel(doc); + // Swap `doc.name` in for `s` in the list of synonyms + const synonymsWithDoc = [docLabel, ...synonyms].filter((x) => x && x !== synonym); + return { + ...doc, + name: synonym, + longname: synonym, + synonyms: synonymsWithDoc, + synonyms_text: synonymsWithDoc.join(', '), + }; +}; + const jsdocCompletions = (() => { const seen = new Set(); // avoid repetition const completions = []; for (const doc of jsdoc.docs) { if (!isValidDoc(doc) || hasExcludedTags(doc)) continue; - let labels = [getDocLabel(doc), ...(doc.synonyms || [])]; + const docLabel = getDocLabel(doc); + // Remove duplicates + const synonyms = doc.synonyms || []; + let labels = [docLabel, ...synonyms]; for (const label of labels) { // https://codemirror.net/docs/ref/#autocomplete.Completion if (label && !seen.has(label)) { seen.add(label); completions.push({ label, - info: () => Autocomplete({ doc, label }), + info: () => Autocomplete(getSynonymDoc(doc, label)), type: 'function', // https://codemirror.net/docs/ref/#autocomplete.Completion.type }); } diff --git a/packages/codemirror/tooltip.mjs b/packages/codemirror/tooltip.mjs index f67e6d14a..d1d0479b2 100644 --- a/packages/codemirror/tooltip.mjs +++ b/packages/codemirror/tooltip.mjs @@ -1,6 +1,6 @@ import { hoverTooltip } from '@codemirror/view'; import jsdoc from '../../doc.json'; -import { Autocomplete } from './autocomplete.mjs'; +import { Autocomplete, getSynonymDoc } from './autocomplete.mjs'; const getDocLabel = (doc) => doc.name || doc.longname; @@ -52,10 +52,11 @@ export const strudelTooltip = hoverTooltip( let entry = jsdoc.docs.filter((doc) => getDocLabel(doc) === word)[0]; if (!entry) { // Try for synonyms - entry = jsdoc.docs.filter((doc) => doc.synonyms && doc.synonyms.includes(word))[0]; - if (!entry) { + const doc = jsdoc.docs.filter((doc) => doc.synonyms && doc.synonyms.includes(word))[0]; + if (!doc) { return null; } + entry = getSynonymDoc(doc, word); } return { @@ -66,7 +67,7 @@ export const strudelTooltip = hoverTooltip( create(view) { let dom = document.createElement('div'); dom.className = 'strudel-tooltip'; - const ac = Autocomplete({ doc: entry, label: word }); + const ac = Autocomplete(entry); dom.appendChild(ac); return { dom }; }, diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index addc301d5..39790f8a0 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -1246,7 +1246,8 @@ export const silence = gap(1); /* Like silence, but with a 'steps' (relative duration) of 0 */ export const nothing = gap(0); -/** A discrete value that repeats once per cycle. +/** + * A discrete value that repeats once per cycle. * * @returns {Pattern} * @example @@ -1299,7 +1300,8 @@ export function sequenceP(pats) { return result; } -/** The given items are played at the same time at the same length. +/** + * The given items are played at the same time at the same length. * * @return {Pattern} * @synonyms polyrhythm, pr @@ -1382,11 +1384,11 @@ export function stackBy(by, ...pats) { .setSteps(steps); } -/** Concatenation: combines a list of patterns, switching between them successively, one per cycle: - * - * synonyms: `cat` +/** + * Concatenation: combines a list of patterns, switching between them successively, one per cycle. * * @return {Pattern} + * @synonyms cat * @example * slowcat("e5", "b4", ["d5", "c5"]) * diff --git a/website/src/repl/Repl.css b/website/src/repl/Repl.css index 498679090..9cf51ff85 100644 --- a/website/src/repl/Repl.css +++ b/website/src/repl/Repl.css @@ -112,6 +112,13 @@ margin: 0 0 8px 0; } +.autocomplete-info-function-synonyms { + margin: 0 0 12px 0; + color: var(--foreground); + line-height: 1.5; + opacity: 0.8; +} + .autocomplete-info-function-description { margin: 0 0 12px 0; color: var(--foreground); diff --git a/website/src/repl/components/panel/Reference.jsx b/website/src/repl/components/panel/Reference.jsx index 81826aca2..6007667fa 100644 --- a/website/src/repl/components/panel/Reference.jsx +++ b/website/src/repl/components/panel/Reference.jsx @@ -16,8 +16,7 @@ const availableFunctions = (() => { if (!s || seen.has(s)) continue; seen.add(s); // Swap `doc.name` in for `s` in the list of synonyms - const notS = synonyms.filter((x) => x && x !== s); - const synonymsWithDoc = Array.from(new Set([doc.name, ...notS])); + const synonymsWithDoc = [doc.name, ...synonyms].filter((x) => x && x !== s); functions.push({ ...doc, name: s, // update names for the synonym From a9f7d825c8456641b46451e48411d479d4b3e938 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 14 Sep 2025 00:40:54 +0200 Subject: [PATCH 09/16] hotfix: format --- website/src/pages/learn/xen.mdx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/website/src/pages/learn/xen.mdx b/website/src/pages/learn/xen.mdx index 32e2c0b43..b8b27ac7f 100644 --- a/website/src/pages/learn/xen.mdx +++ b/website/src/pages/learn/xen.mdx @@ -16,10 +16,7 @@ These functions allow the use of scales other than your typical chromatic 12 bas Here's an example of how to configure a basic hexany scale: - + Try other scales like `hexany1`, `iraq`, `gumbeng`, `gunkali`, or `tranh3` @@ -67,6 +64,7 @@ Another helpful trick when exploring new tunings is to strum them. Many have a much more enchanting sound that was chosen over many generations of musicians for being strummed. Take the `sanza` tuning: + + This quality is often due to how the tunings were formed with instruments that were played differently than a piano. As such, some tunings are much better strummed, with the subtle clash of the detuned notes actually making the sound much more magical: From 2d2b238da9a3246a5af994584fc585a74890213c Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 14 Sep 2025 01:03:14 +0200 Subject: [PATCH 10/16] fix: use template element for string to html --- packages/codemirror/html.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/codemirror/html.mjs b/packages/codemirror/html.mjs index 527275ef6..f240059d3 100644 --- a/packages/codemirror/html.mjs +++ b/packages/codemirror/html.mjs @@ -1,6 +1,7 @@ -const parser = typeof DOMParser !== 'undefined' ? new DOMParser() : null; export let html = (string) => { - return parser?.parseFromString(string, 'text/html').querySelectorAll('*'); + const template = document.createElement('template'); + template.innerHTML = string.trim(); + return template.content.childNodes; }; let parseChunk = (chunk) => { if (Array.isArray(chunk)) return chunk.flat().join(''); From 9782795761648595fc05deacf863a7bf1a0348ab Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 14 Sep 2025 01:04:48 +0200 Subject: [PATCH 11/16] fix: autocomplete container style --- website/src/repl/Repl.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/src/repl/Repl.css b/website/src/repl/Repl.css index 9cf51ff85..b5cd34d81 100644 --- a/website/src/repl/Repl.css +++ b/website/src/repl/Repl.css @@ -80,6 +80,8 @@ min-width: 300px !important; max-height: 400px !important; background-color: var(--lineHighlight) !important; + overflow: auto; + background: var(--background) !important; } /* Main tooltip container */ @@ -91,7 +93,7 @@ font-size: var(--font-size, 13px); line-height: 1.4; max-width: 600px; - max-height: 400px; + height: 100%; min-width: 400px; white-space: normal !important; overflow-y: auto !important; From d6254294b087af1008efc1943a110a36cc2afdb3 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 14 Sep 2025 01:09:30 +0200 Subject: [PATCH 12/16] fix: bring back tests on external PRs (push doesn't cut it for forks) --- .forgejo/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml index 90cb7e258..6b3759571 100644 --- a/.forgejo/workflows/test.yml +++ b/.forgejo/workflows/test.yml @@ -1,6 +1,6 @@ name: Strudel tests -on: [push] +on: [push, pull_request] jobs: build: @@ -19,7 +19,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - cache: 'pnpm' + cache: "pnpm" - run: pnpm install - run: pnpm run format-check - run: pnpm run lint From 9da027d377e278b5b2bb173139c03dc2de7b72f8 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 14 Sep 2025 01:11:34 +0200 Subject: [PATCH 13/16] hotfix: format --- website/src/pages/workshop/first-sounds.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/pages/workshop/first-sounds.mdx b/website/src/pages/workshop/first-sounds.mdx index f120f332a..1d659972b 100644 --- a/website/src/pages/workshop/first-sounds.mdx +++ b/website/src/pages/workshop/first-sounds.mdx @@ -260,7 +260,7 @@ It is quite common that there are many ways to express the same idea. punchcard /> -**selecting sample numbers separately** +**selecting sample numbers separately** Instead of selecting sample numbers one by one: From f5c4373f996c5e8a699b4c850baa310d16666021 Mon Sep 17 00:00:00 2001 From: Dsm0 Date: Sat, 13 Sep 2025 17:21:19 -0700 Subject: [PATCH 14/16] added plyWith/plyWithClassic functions --- packages/core/pattern.mjs | 50 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 39790f8a0..a88a1eaea 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -2399,6 +2399,56 @@ export const stut = register('stut', function (times, feedback, time, pat) { return pat._echoWith(times, time, (pat, i) => pat.gain(Math.pow(feedback, i))); }); +export const applyN = register('applyN', function (n, func, p) { + let result = p; + for (let i = 0; i < n; i++) { + result = func(result); + } + return result; +}); + +/** + * The plyWithClassic function repeats each event the given number of times, applying the given function to each event.\n + * Here the function does not take the iteration index as an argument. + * @name plyWithClassic + * @param {number} factor how many times to repeat + * @param {function} func function to apply, given the pattern + * @example + * "<0 [2 4]>" + * .plyWith(4, (p) => p.add(2)) + * .scale("C:minor").note() + */ +export const plyWithClassic = register('plyWithClassic', function (factor, func, pat) { + const result = pat + .fmap((x) => cat(...listRange(0, factor - 1).map((i) => applyN(i, func, x)))._fast(factor)) + .squeezeJoin(); + if (__steps) { + result._steps = Fraction(factor).mulmaybe(pat._steps); + } + return result; +}); + +/** + * The plyWith function repeats each event the given number of times, applying the given function to each event. + * @name plyWith + * @synonyms plywith + * @param {number} factor how many times to repeat + * @param {function} func function to apply, given the pattern and the iteration index + * @example + * "<0 [2 4]>" + * .plyWith(4, (p,n) => p.add(n*2)) + * .scale("C:minor").note() + */ +export const plyWith = register(['plyWith', 'plywith'], function (factor, func, pat) { + const result = pat + .fmap((x) => cat(cat(pure(x), ...listRange(1, factor - 1).map((i) => func(pure(x), i))))._fast(factor)) + .squeezeJoin(); + if (__steps) { + result._steps = Fraction(factor).mulmaybe(pat._steps); + } + return result; +}); + /** * Divides a pattern into a given number of subdivisions, plays the subdivisions in order, but increments the starting subdivision each cycle. The pattern wraps to the first subdivision after the last subdivision is played. * @name iter From addb6db1c76ed9de1246c8f3f617f6d8220d0892 Mon Sep 17 00:00:00 2001 From: Dsm0 Date: Sun, 14 Sep 2025 02:21:25 -0700 Subject: [PATCH 15/16] updated names + snapshot --- packages/core/pattern.mjs | 19 ++++---- test/__snapshots__/examples.test.mjs.snap | 58 +++++++++++++++++++++++ 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index a88a1eaea..262b92bb3 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -2408,9 +2408,9 @@ export const applyN = register('applyN', function (n, func, p) { }); /** - * The plyWithClassic function repeats each event the given number of times, applying the given function to each event.\n - * Here the function does not take the iteration index as an argument. - * @name plyWithClassic + * The plyWith function repeats each event the given number of times, applying the given function to each event.\n + * @name plyWith + * @synonyms plywith * @param {number} factor how many times to repeat * @param {function} func function to apply, given the pattern * @example @@ -2418,7 +2418,7 @@ export const applyN = register('applyN', function (n, func, p) { * .plyWith(4, (p) => p.add(2)) * .scale("C:minor").note() */ -export const plyWithClassic = register('plyWithClassic', function (factor, func, pat) { +export const plyWith = register(['plyWith', 'plywith'], function (factor, func, pat) { const result = pat .fmap((x) => cat(...listRange(0, factor - 1).map((i) => applyN(i, func, x)))._fast(factor)) .squeezeJoin(); @@ -2429,17 +2429,18 @@ export const plyWithClassic = register('plyWithClassic', function (factor, func, }); /** - * The plyWith function repeats each event the given number of times, applying the given function to each event. - * @name plyWith - * @synonyms plywith + * The plyForEach function repeats each event the given number of times, applying the given function to each event. + * This version of ply uses the iteration index as an argument to the function, similar to echoWith. + * @name plyForEach + * @synonyms plyforeach * @param {number} factor how many times to repeat * @param {function} func function to apply, given the pattern and the iteration index * @example * "<0 [2 4]>" - * .plyWith(4, (p,n) => p.add(n*2)) + * .plyForEach(4, (p,n) => p.add(n*2)) * .scale("C:minor").note() */ -export const plyWith = register(['plyWith', 'plywith'], function (factor, func, pat) { +export const plyForEach = register(['plyForEach', 'plyforeach'], function (factor, func, pat) { const result = pat .fmap((x) => cat(cat(pure(x), ...listRange(1, factor - 1).map((i) => func(pure(x), i))))._fast(factor)) .squeezeJoin(); diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index ecbc33eac..46359b620 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -7365,6 +7365,64 @@ exports[`runs examples > example "ply" example index 0 1`] = ` ] `; +exports[`runs examples > example "plyForEach" example index 0 1`] = ` +[ + "[ 0/1 → 1/4 | note:C3 ]", + "[ 1/4 → 1/2 | note:Eb3 ]", + "[ 1/2 → 3/4 | note:G3 ]", + "[ 3/4 → 1/1 | note:Bb3 ]", + "[ 1/1 → 9/8 | note:Eb3 ]", + "[ 9/8 → 5/4 | note:G3 ]", + "[ 5/4 → 11/8 | note:Bb3 ]", + "[ 11/8 → 3/2 | note:D4 ]", + "[ 3/2 → 13/8 | note:G3 ]", + "[ 13/8 → 7/4 | note:Bb3 ]", + "[ 7/4 → 15/8 | note:D4 ]", + "[ 15/8 → 2/1 | note:F4 ]", + "[ 2/1 → 9/4 | note:C3 ]", + "[ 9/4 → 5/2 | note:Eb3 ]", + "[ 5/2 → 11/4 | note:G3 ]", + "[ 11/4 → 3/1 | note:Bb3 ]", + "[ 3/1 → 25/8 | note:Eb3 ]", + "[ 25/8 → 13/4 | note:G3 ]", + "[ 13/4 → 27/8 | note:Bb3 ]", + "[ 27/8 → 7/2 | note:D4 ]", + "[ 7/2 → 29/8 | note:G3 ]", + "[ 29/8 → 15/4 | note:Bb3 ]", + "[ 15/4 → 31/8 | note:D4 ]", + "[ 31/8 → 4/1 | note:F4 ]", +] +`; + +exports[`runs examples > example "plyWith" example index 0 1`] = ` +[ + "[ 0/1 → 1/4 | note:C3 ]", + "[ 1/4 → 1/2 | note:Eb3 ]", + "[ 1/2 → 3/4 | note:G3 ]", + "[ 3/4 → 1/1 | note:Bb3 ]", + "[ 1/1 → 9/8 | note:Eb3 ]", + "[ 9/8 → 5/4 | note:G3 ]", + "[ 5/4 → 11/8 | note:Bb3 ]", + "[ 11/8 → 3/2 | note:D4 ]", + "[ 3/2 → 13/8 | note:G3 ]", + "[ 13/8 → 7/4 | note:Bb3 ]", + "[ 7/4 → 15/8 | note:D4 ]", + "[ 15/8 → 2/1 | note:F4 ]", + "[ 2/1 → 9/4 | note:C3 ]", + "[ 9/4 → 5/2 | note:Eb3 ]", + "[ 5/2 → 11/4 | note:G3 ]", + "[ 11/4 → 3/1 | note:Bb3 ]", + "[ 3/1 → 25/8 | note:Eb3 ]", + "[ 25/8 → 13/4 | note:G3 ]", + "[ 13/4 → 27/8 | note:Bb3 ]", + "[ 27/8 → 7/2 | note:D4 ]", + "[ 7/2 → 29/8 | note:G3 ]", + "[ 29/8 → 15/4 | note:Bb3 ]", + "[ 15/4 → 31/8 | note:D4 ]", + "[ 31/8 → 4/1 | note:F4 ]", +] +`; + exports[`runs examples > example "polymeter" example index 0 1`] = ` [ "[ 0/1 → 1/6 | note:c ]", From df1934f87b41e722fb8567ee051902b95e31b2b9 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 14 Sep 2025 23:54:51 +0200 Subject: [PATCH 16/16] add some typechecking + fix delay --- packages/supradough/dough.mjs | 190 +++++++++++++++++++++++++++++----- 1 file changed, 165 insertions(+), 25 deletions(-) diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs index 711922b63..1cb9241ad 100644 --- a/packages/supradough/dough.mjs +++ b/packages/supradough/dough.mjs @@ -1,4 +1,6 @@ // this is dough, the superdough without dependencies +// @ts-check +// @ts-ignore ignore next line because sampleRate is unknown const SAMPLE_RATE = typeof sampleRate !== 'undefined' ? sampleRate : 48000; const PI_DIV_SR = Math.PI / SAMPLE_RATE; const ISR = 1 / SAMPLE_RATE; @@ -641,8 +643,8 @@ const note2midi = (note, defaultOctave = 3) => { } const chroma = chromas[pc.toLowerCase()]; const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0; - oct = Number(oct || defaultOctave); - return (oct + 1) * 12 + chroma + offset; + const octave = Number(oct || defaultOctave); + return (octave + 1) * 12 + chroma + offset; }; const midi2freq = (midi) => Math.pow(2, (midi - 69) / 12) * 440; const note2freq = (note) => { @@ -654,9 +656,152 @@ const note2freq = (note) => { }; export class DoughVoice { + /** @type {number} */ + id = 0; + /** @type {number[]} */ out = [0, 0]; + + /** @type {number | undefined} */ + attack; + /** @type {number | undefined} */ + decay; + /** @type {number | undefined} */ + sustain; + /** @type {number} */ + release; + /** @type {number} */ + _begin; + /** @type {number} */ + _duration; + + /** @type {any} */ + _sound; + /** @type {number} */ + _channels = 1; + /** @type {BufferPlayer[] | undefined} */ + _buffers; + /** @type {string | undefined} */ + unit; + + /** @type {ADSR | undefined} */ + _penv; + /** @type {number | undefined} */ + penv; + /** @type {number | undefined} */ + pattack; + /** @type {number | undefined} */ + pdecay; + /** @type {number | undefined} */ + psustain; + /** @type {number | undefined} */ + prelease; + + /** @type {number | undefined} */ + vib; + + _vib; + /** @type {number | undefined} */ + vibmod; + + /** @type {SineOsc | undefined} */ + _fm; + /** @type {number | undefined} */ + fmh; + /** @type {number | undefined} */ + fmi; + + /** @type {ADSR | undefined} */ + _fmenv; + /** @type {number | undefined} */ + fmattack; + /** @type {number | undefined} */ + fmdecay; + /** @type {number | undefined} */ + fmsustain; + /** @type {number | undefined} */ + fmrelease; + + /** @type {ADSR | undefined} */ + _lpenv; + lpenv; + /** @type {number | undefined} */ + lpattack; + /** @type {number | undefined} */ + lpdecay; + /** @type {number | undefined} */ + lpsustain; + /** @type {number | undefined} */ + lprelease; + + /** @type {ADSR | undefined} */ + _hpenv; + /** @type {number | undefined} */ + hpenv; + /** @type {number | undefined} */ + hpattack; + /** @type {number | undefined} */ + hpdecay; + /** @type {number | undefined} */ + hpsustain; + /** @type {number | undefined} */ + hprelease; + + /** @type {ADSR | undefined} */ + _bpenv; + /** @type {number | undefined} */ + bpenv; + /** @type {number | undefined} */ + bpattack; + /** @type {number | undefined} */ + bpdecay; + /** @type {number | undefined} */ + bpsustain; + /** @type {number | undefined} */ + bprelease; + + /** @type {number | undefined} */ + cutoff; + /** @type {number | undefined} */ + hcutoff; + /** @type {number | undefined} */ + bandf; + /** @type {number | undefined} */ + coarse; + /** @type {number | undefined} */ + crush; + /** @type {number | undefined} */ + distort; + + /** @type {number} */ + freq; + /** @type {string | undefined} */ + note; + + /** @type {TwoPoleFilter[] | null | undefined} */ + _lpf; + /** @type {TwoPoleFilter[] | null | undefined} */ + _hpf; + /** @type {TwoPoleFilter[] | null | undefined} */ + _bpf; + /** @type {Chorus[] | null | undefined} */ + _chorus; + /** @type {Coarse[] | null | undefined} */ + _coarse; + /** @type {Crush[] | null | undefined} */ + _crush; + /** @type {Distort[] | null | undefined} */ + _distort; + + /** + * @param {DoughVoice} value + */ constructor(value) { - value.freq ??= note2freq(value.note); + // mandatory controls + this.freq ??= note2freq(value.note); + this._begin = value._begin; + this._duration = value._duration; + this.release = value.release ?? 0; + // the rest.. we use $ for readability let $ = this; Object.assign($, value); $.s = $.s ?? getDefaultValue('s'); @@ -773,7 +918,7 @@ export class DoughVoice { let freq = this.freq * this.speed; // frequency modulation - if (this._fm) { + if (this._fm && this.fmh !== undefined && this.fmi !== undefined) { let fmi = this.fmi; if (this._fmenv) { const env = this._fmenv.update(t, gate, this.fmattack, this.fmdecay, this.fmsustain, this.fmrelease); @@ -785,37 +930,31 @@ export class DoughVoice { } // vibrato - if (this._vib) { + if (this._vib && this.vibmod !== undefined) { freq = freq * 2 ** ((this._vib.update(this.vib) * this.vibmod) / 12); } // pitch envelope - if (this._penv) { + if (this._penv && this.penv !== undefined) { const env = this._penv.update(t, gate, this.pattack, this.pdecay, this.psustain, this.prelease); freq = freq + env * this.penv; } // filters let lpf = this.cutoff; - if (this._lpf) { - if (this._lpenv) { - const env = this._lpenv.update(t, gate, this.lpattack, this.lpdecay, this.lpsustain, this.lprelease); - lpf = this.lpenv * env * lpf + lpf; - } + if (lpf !== undefined && this._lpenv) { + const env = this._lpenv.update(t, gate, this.lpattack, this.lpdecay, this.lpsustain, this.lprelease); + lpf = this.lpenv * env * lpf + lpf; } let hpf = this.hcutoff; - if (this._hpf) { - if (this._hpenv) { - const env = this._hpenv.update(t, gate, this.hpattack, this.hpdecay, this.hpsustain, this.hprelease); - hpf = 2 ** this.hpenv * env * hpf + hpf; - } + if (hpf !== undefined && this._hpenv && this.hpenv !== undefined) { + const env = this._hpenv.update(t, gate, this.hpattack, this.hpdecay, this.hpsustain, this.hprelease); + hpf = 2 ** this.hpenv * env * hpf + hpf; } let bpf = this.bandf; - if (this._bpf) { - if (this._bpenv) { - const env = this._bpenv.update(t, gate, this.bpattack, this.bpdecay, this.bpsustain, this.bprelease); - bpf = 2 ** this.bpenv * env * bpf + bpf; - } + if (bpf !== undefined && this._bpenv && this.bpenv !== undefined) { + const env = this._bpenv.update(t, gate, this.bpattack, this.bpdecay, this.bpsustain, this.bprelease); + bpf = 2 ** this.bpenv * env * bpf + bpf; } // gain envelope const env = this._adsr.update(t, gate, this.attack, this.decay, this.sustain, this.release); @@ -891,8 +1030,8 @@ export class Dough { this.sampleRate = sampleRate; this.t = Math.floor(currentTime * sampleRate); // samples // console.log('init dough', this.sampleRate, this.t); - this._delayL = new PitchDelay(); - this._delayR = new PitchDelay(); + this._delayL = new Delay(); + this._delayR = new Delay(); } loadSample(name, channels, sampleRate) { BufferPlayer.samples.set(name, { channels, sampleRate }); @@ -965,8 +1104,9 @@ export class Dough { } } // todo: how to change delaytime / delayfeedback from a voice? - const delayL = this._delayL.update(this.delaysend[0], this.delaytime, this.delayspeed); - const delayR = this._delayR.update(this.delaysend[1], this.delaytime, this.delayspeed); + const delayL = this._delayL.update(this.delaysend[0], this.delaytime); + const delayR = this._delayR.update(this.delaysend[1], this.delaytime); + this.delaysend[0] = delayL * this.delayfeedback; this.delaysend[1] = delayR * this.delayfeedback; this.out[0] += delayL;