-
${label || getDocLabel(doc)}
+
${getDocLabel(doc)}
+ ${doc.synonyms_text ? `
Synonyms: ${doc.synonyms_text}
` : ''}
${doc.description ? `
${doc.description}
` : ''}
${buildParamsList(doc.params)}
${buildExamples(doc.examples)}
@@ -74,15 +75,43 @@ 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
- }));
+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;
+ 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(getSynonymDoc(doc, label)),
+ type: 'function', // https://codemirror.net/docs/ref/#autocomplete.Completion.type
+ });
+ }
+ }
+ }
+ return completions;
+})();
export const strudelAutocomplete = (context) => {
const word = context.matchBefore(/\w*/);
diff --git a/packages/codemirror/basicSetup.mjs b/packages/codemirror/basicSetup.mjs
new file mode 100644
index 000000000..02294b93a
--- /dev/null
+++ b/packages/codemirror/basicSetup.mjs
@@ -0,0 +1,63 @@
+import {
+ keymap,
+ highlightSpecialChars,
+ drawSelection,
+ highlightActiveLine,
+ dropCursor,
+ rectangularSelection,
+ crosshairCursor,
+ lineNumbers,
+ highlightActiveLineGutter,
+} from '@codemirror/view';
+import {
+ defaultHighlightStyle,
+ syntaxHighlighting,
+ bracketMatching,
+ foldGutter,
+ foldKeymap,
+} from '@codemirror/language';
+import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
+import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
+import { completionKeymap, closeBracketsKeymap } from '@codemirror/autocomplete';
+
+// Taken + slightly modified from https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
+
+export const basicSetup = (() => [
+ // lineNumbers(),
+ // highlightActiveLineGutter(),
+ highlightSpecialChars(),
+ history(),
+ // foldGutter(),
+ // drawSelection(),
+ dropCursor(),
+ // EditorState.allowMultipleSelections.of(true),
+ // indentOnInput(),
+ // syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
+ // autocompletion(),
+ rectangularSelection(),
+ crosshairCursor(),
+ // highlightActiveLine(),
+ // highlightSelectionMatches(),
+ keymap.of([
+ ...closeBracketsKeymap,
+ ...defaultKeymap,
+ // ...searchKeymap,
+ ...historyKeymap,
+ // ...foldKeymap,
+ // ...completionKeymap,
+ ]),
+])();
+
+/// A minimal set of extensions to create a functional editor. Only
+/// includes [the default keymap](#commands.defaultKeymap), [undo
+/// history](#commands.history), [special character
+/// highlighting](#view.highlightSpecialChars), [custom selection
+/// drawing](#view.drawSelection), and [default highlight
+/// style](#language.defaultHighlightStyle).
+export const minimalSetup = (() => [
+ highlightSpecialChars(),
+ history(),
+ drawSelection(),
+ syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
+ keymap.of([...defaultKeymap, ...historyKeymap]),
+])();
diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs
index f47f6eba5..4dc23996f 100644
--- a/packages/codemirror/codemirror.mjs
+++ b/packages/codemirror/codemirror.mjs
@@ -1,8 +1,8 @@
import { closeBrackets } from '@codemirror/autocomplete';
export { toggleComment, toggleBlockComment, toggleLineComment, toggleBlockCommentByLine } from '@codemirror/commands';
// import { search, highlightSelectionMatches } from '@codemirror/search';
-import { history, indentWithTab } from '@codemirror/commands';
-import { javascript } from '@codemirror/lang-javascript';
+import { indentWithTab } from '@codemirror/commands';
+import { javascript, javascriptLanguage } from '@codemirror/lang-javascript';
import { defaultHighlightStyle, syntaxHighlighting, bracketMatching } from '@codemirror/language';
import { Compartment, EditorState, Prec } from '@codemirror/state';
import {
@@ -24,6 +24,7 @@ import { initTheme, activateTheme, theme } from './themes.mjs';
import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
import { widgetPlugin, updateWidgets } from './widget.mjs';
import { persistentAtom } from '@nanostores/persistent';
+import { basicSetup } from './basicSetup.mjs';
const extensions = {
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
@@ -85,13 +86,17 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
/* search(),
highlightSelectionMatches(), */
...initialSettings,
+ basicSetup,
mondo ? [] : javascript(),
+ javascriptLanguage.data.of({
+ closeBrackets: { brackets: ['(', '[', '{', "'", '"', '<'] },
+ bracketMatching: { brackets: ['(', '[', '{', "'", '"', '<'] },
+ }),
sliderPlugin,
widgetPlugin,
// indentOnInput(), // works without. already brought with javascript extension?
// bracketMatching(), // does not do anything
syntaxHighlighting(defaultHighlightStyle),
- history(),
EditorView.updateListener.of((v) => onChange(v)),
drawSelection({ cursorBlinkRate: 0 }),
Prec.highest(
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('');
diff --git a/packages/codemirror/keybindings.mjs b/packages/codemirror/keybindings.mjs
index 1201959a2..ca5f34f4c 100644
--- a/packages/codemirror/keybindings.mjs
+++ b/packages/codemirror/keybindings.mjs
@@ -3,8 +3,9 @@ import { keymap, ViewPlugin } from '@codemirror/view';
// import { searchKeymap } from '@codemirror/search';
import { emacs } from '@replit/codemirror-emacs';
import { vim } from '@replit/codemirror-vim';
+// import { vim } from './vim_test.mjs';
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
-import { defaultKeymap, historyKeymap } from '@codemirror/commands';
+import { defaultKeymap } from '@codemirror/commands';
const vscodePlugin = ViewPlugin.fromClass(
class {
@@ -27,5 +28,5 @@ const keymaps = {
export function keybindings(name) {
const active = keymaps[name];
- return [active ? active() : [], keymap.of(historyKeymap)];
+ return [active ? Prec.high(active()) : []];
}
diff --git a/packages/codemirror/package.json b/packages/codemirror/package.json
index 797a4312b..803b33f3d 100644
--- a/packages/codemirror/package.json
+++ b/packages/codemirror/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/codemirror",
- "version": "1.2.3",
+ "version": "1.2.5",
"description": "Codemirror Extensions for Strudel",
"main": "index.mjs",
"publishConfig": {
@@ -42,7 +42,7 @@
"@lezer/highlight": "^1.2.1",
"@nanostores/persistent": "^0.10.2",
"@replit/codemirror-emacs": "^6.1.0",
- "@replit/codemirror-vim": "^6.2.1",
+ "@replit/codemirror-vim": "^6.3.0",
"@replit/codemirror-vscode-keymap": "^6.0.2",
"@strudel/core": "workspace:*",
"@strudel/draw": "workspace:*",
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/controls.mjs b/packages/core/controls.mjs
index 5d5438ff6..7bf554577 100644
--- a/packages/core/controls.mjs
+++ b/packages/core/controls.mjs
@@ -124,6 +124,7 @@ export const { wtWarpMode, wavetableWarpMode } = registerControl('wtWarpMode', '
* Define a custom webaudio node to use as a sound source.
*
* @name source
+ * @synonyms src
* @param {function} getSource
* @synonyms src
*
@@ -146,7 +147,7 @@ export const { n } = registerControl('n');
*
* - a letter (a-g or A-G)
* - optional accidentals (b or #)
- * - optional octave number (0-9). Defaults to 3
+ * - optional (possibly negative) octave number (0-9). Defaults to 3
*
* Examples of valid note names: `c`, `bb`, `Bb`, `f#`, `c3`, `A4`, `Eb2`, `c#5`
*
@@ -159,6 +160,8 @@ export const { n } = registerControl('n');
* note("c4 a4 f4 e4")
* @example
* note("60 69 65 64")
+ * @example
+ * note("fbb1 a#0 cbbb-1 e##-2").sound("saw")
*/
export const { note } = registerControl(['note', 'n']);
@@ -174,8 +177,8 @@ export const { note } = registerControl(['note', 'n']);
*/
export const { accelerate } = registerControl('accelerate');
/**
- *
* Sets the velocity from 0 to 1. Is multiplied together with gain.
+ *
* @name velocity
* @example
* s("hh*8")
@@ -287,7 +290,7 @@ export const { fmenv } = registerControl('fmenv');
export const { fmattack } = registerControl('fmattack');
/**
- * waveform of the fm modulator
+ * Waveform of the fm modulator
*
* @name fmwave
* @param {number | Pattern} wave waveform
@@ -342,6 +345,17 @@ export const { fmvelocity } = registerControl('fmvelocity');
*/
export const { bank } = registerControl('bank');
+/**
+ * mix control for the chorus effect
+ *
+ * @name chorus
+ * @param {string | Pattern} chorus mix amount between 0 and 1
+ * @example
+ * note("d d a# a").s("sawtooth").chorus(.5)
+ *
+ */
+export const { chorus } = registerControl('chorus');
+
// analyser node send amount 0 - 1 (used by scope)
export const { analyze } = registerControl('analyze');
// fftSize of analyser
@@ -410,7 +424,7 @@ export const { bandf, bpf, bp } = registerControl(['bandf', 'bandq', 'bpenv'], '
// ['bpq'],
export const { bandq, bpq } = registerControl('bandq', 'bpq');
/**
- * a pattern of numbers from 0 to 1. Skips the beginning of each sample, e.g. `0.25` to cut off the first quarter from each sample.
+ * A pattern of numbers from 0 to 1. Skips the beginning of each sample, e.g. `0.25` to cut off the first quarter from each sample.
*
* @memberof Pattern
* @name begin
@@ -471,7 +485,7 @@ export const { loopBegin, loopb } = registerControl('loopBegin', 'loopb');
*/
export const { loopEnd, loope } = registerControl('loopEnd', 'loope');
/**
- * bit crusher effect.
+ * Bit crusher effect.
*
* @name crush
* @param {number | Pattern} depth between 1 (for drastic reduction in bit-depth) to 16 (for barely no reduction).
@@ -482,7 +496,7 @@ export const { loopEnd, loope } = registerControl('loopEnd', 'loope');
// ['clhatdecay'],
export const { crush } = registerControl('crush');
/**
- * fake-resampling for lowering the sample rate. Caution: This effect seems to only work in chromium based browsers
+ * Fake-resampling for lowering the sample rate. Caution: This effect seems to only work in chromium based browsers
*
* @name coarse
* @param {number | Pattern} factor 1 for original 2 for half, 3 for a third and so on.
@@ -493,7 +507,7 @@ export const { crush } = registerControl('crush');
export const { coarse } = registerControl('coarse');
/**
- * modulate the amplitude of a sound with a continuous waveform
+ * Modulate the amplitude of a sound with a continuous waveform
*
* @name tremolo
* @synonyms trem
@@ -505,7 +519,7 @@ export const { coarse } = registerControl('coarse');
export const { tremolo } = registerControl(['tremolo', 'tremolodepth', 'tremoloskew', 'tremolophase'], 'trem');
/**
- * modulate the amplitude of a sound with a continuous waveform
+ * Modulate the amplitude of a sound with a continuous waveform
*
* @name tremolosync
* @synonyms tremsync
@@ -520,7 +534,7 @@ export const { tremolosync } = registerControl(
);
/**
- * depth of amplitude modulation
+ * Depth of amplitude modulation
*
* @name tremolodepth
* @synonyms tremdepth
@@ -531,7 +545,7 @@ export const { tremolosync } = registerControl(
*/
export const { tremolodepth } = registerControl('tremolodepth', 'tremdepth');
/**
- * alter the shape of the modulation waveform
+ * Alter the shape of the modulation waveform
*
* @name tremoloskew
* @synonyms tremskew
@@ -543,7 +557,7 @@ export const { tremolodepth } = registerControl('tremolodepth', 'tremdepth');
export const { tremoloskew } = registerControl('tremoloskew', 'tremskew');
/**
- * alter the phase of the modulation waveform
+ * Alter the phase of the modulation waveform
*
* @name tremolophase
* @synonyms tremphase
@@ -555,9 +569,10 @@ export const { tremoloskew } = registerControl('tremoloskew', 'tremskew');
export const { tremolophase } = registerControl('tremolophase', 'tremphase');
/**
- * shape of amplitude modulation
+ * 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")
@@ -565,7 +580,7 @@ export const { tremolophase } = registerControl('tremolophase', 'tremphase');
*/
export const { tremoloshape } = registerControl('tremoloshape', 'tremshape');
/**
- * filter overdrive for supported filter types
+ * Filter overdrive for supported filter types
*
* @name drive
* @param {number | Pattern} amount
@@ -573,44 +588,94 @@ 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
+ * Modulate the amplitude of an orbit to create a "sidechain" like effect.
+ *
+ * Can be applied to multiple orbits with the ':' mininotation, e.g. `duckorbit("2:3")`
*
* @name duckorbit
+ * @synonyms duck
* @param {number | Pattern} orbit target orbit
* @example
* $: n(run(16)).scale("c:minor:pentatonic").s("sawtooth").delay(.7).orbit(2)
* $: s("bd:4!4").beat("0,4,8,11,14",16).duckorbit(2).duckattack(0.2).duckdepth(1)
+ * @example
+ * $: n(run(16)).scale("c:minor:pentatonic").s("sawtooth").delay(.7).orbit(2)
+ * $: s("hh*16").orbit(3)
+ * $: s("bd:4!4").beat("0,4,8,11,14",16).duckorbit("2:3").duckattack(0.2).duckdepth(1)
*
*/
export const { duck } = registerControl('duckorbit', 'duck');
/**
- * the amount of ducking applied to target orbit
+ * The amount of ducking applied to target orbit
+ *
+ * Can vary across orbits with the ':' mininotation, e.g. `duckdepth("0.3:0.1")`.
+ * Note: this requires first applying the effect to multiple orbits with e.g. `duckorbit("2:3")`.
*
* @name duckdepth
* @param {number | Pattern} depth depth of modulation from 0 to 1
* @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).duckdepth("<1 .9 .6 0>"))
+ * @example
+ * $: n(run(16)).scale("c:minor:pentatonic").s("sawtooth").delay(.7).orbit(2)
+ * $: s("hh*16").orbit(3)
+ * $: s("bd:4!4").beat("0,4,8,11,14",16).duckorbit("2:3").duckattack(0.2).duckdepth("1:0.5")
*
*/
-
export const { duckdepth } = registerControl('duckdepth');
/**
- * the attack time of the duck effect
+ * The time required for the ducked signal(s) to reach their lowest volume.
+ * Can be used to prevent clicking or for creative rhythmic effects.
+ *
+ * Can vary across orbits with the ':' mininotation, e.g. `duckonset("0:0.003")`.
+ * Note: this requires first applying the effect to multiple orbits with e.g. `duckorbit("2:3")`.
+ *
+ * @name duckonset
+ * @synonyms duckons
+ *
+ * @param {number | Pattern} time The onset time in seconds
+ * @example
+ * // Clicks
+ * sound: freq("63.2388").s("sine").orbit(2).gain(4)
+ * duckerWithClick: s("bd*4").duckorbit(2).duckattack(0.3).duckonset(0).postgain(0)
+ * @example
+ * // No clicks
+ * sound: freq("63.2388").s("sine").orbit(2).gain(4)
+ * duckerWithoutClick: s("bd*4").duckorbit(2).duckattack(0.3).duckonset(0.01).postgain(0)
+ * @example
+ * // Rhythmic
+ * noise: s("pink").distort("2:1").orbit(4) // used rhythmically with 0.3 onset below
+ * hhat: s("hh*16").orbit(7)
+ * ducker: s("bd*4").bank("tr909").duckorbit("4:7").duckonset("0.3:0.003").duckattack(0.25)
+ *
+ */
+export const { duckonset } = registerControl('duckonset', 'duckons');
+
+/**
+ * The time required for the ducked signal(s) to return to their normal volume.
+ *
+ * Can vary across orbits with the ':' mininotation, e.g. `duckonset("0:0.003")`.
+ * Note: this requires first applying the effect to multiple orbits with e.g. `duckorbit("2:3")`.
*
* @name duckattack
- * @param {number | Pattern} time
+ * @synonyms duckatt
+ *
+ * @param {number | Pattern} time The attack time in seconds
* @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))
+ * sound: n(run(8)).scale("c:minor").s("sawtooth").delay(.7).orbit(2)
+ * ducker: s("bd:4!4").beat("0,4,8,11,14",16).duckorbit(2).duckattack("<0.2 0 0.4>").duckdepth(1)
+ * @example
+ * moreduck: n(run(8)).scale("c:minor").s("sawtooth").delay(.7).orbit(2)
+ * lessduck: s("hh*16").orbit(5)
+ * ducker: s("bd:4!4").beat("0,4,8,11,14",16).duckorbit("2:5").duckattack("0.4:0.1")
*
*/
export const { duckattack } = registerControl('duckattack', 'duckatt');
-export const { drive } = registerControl('drive');
-
/**
* Create byte beats with custom expressions
*
@@ -651,7 +716,7 @@ export const { byteBeatStartTime, bbst } = registerControl('byteBeatStartTime',
export const { channels, ch } = registerControl('channels', 'ch');
/**
- * controls the pulsewidth of the pulse oscillator
+ * Controls the pulsewidth of the pulse oscillator
*
* @name pw
* @param {number | Pattern} pulsewidth
@@ -663,7 +728,7 @@ export const { channels, ch } = registerControl('channels', 'ch');
export const { pw } = registerControl(['pw', 'pwrate', 'pwsweep']);
/**
- * controls the lfo rate for the pulsewidth of the pulse oscillator
+ * Controls the lfo rate for the pulsewidth of the pulse oscillator
*
* @name pwrate
* @param {number | Pattern} rate
@@ -675,7 +740,7 @@ export const { pw } = registerControl(['pw', 'pwrate', 'pwsweep']);
export const { pwrate } = registerControl('pwrate');
/**
- * controls the lfo sweep for the pulsewidth of the pulse oscillator
+ * Controls the lfo sweep for the pulsewidth of the pulse oscillator
*
* @name pwsweep
* @param {number | Pattern} sweep
@@ -716,7 +781,7 @@ export const { phaserrate, ph, phaser } = registerControl(
export const { phasersweep, phs } = registerControl('phasersweep', 'phs');
/**
- * The center frequency of the phaser in HZ. Defaults to 1000
+ * The center frequency of the phaser in HZ. Defaults to 1000
*
* @name phasercenter
* @synonyms phc
@@ -733,7 +798,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)
@@ -744,7 +809,7 @@ export const { phasercenter, phc } = registerControl('phasercenter', 'phc');
export const { phaserdepth, phd, phasdp } = registerControl('phaserdepth', 'phd', 'phasdp');
/**
- * choose the channel the pattern is sent to in superdirt
+ * Choose the channel the pattern is sent to in superdirt
*
* @name channel
* @param {number | Pattern} channel channel number
@@ -1130,14 +1195,27 @@ export const { delay } = registerControl(['delay', 'delaytime', 'delayfeedback']
*
*/
export const { delayfeedback, delayfb, dfb } = registerControl('delayfeedback', 'delayfb', 'dfb');
+
+/**
+ * Sets the level of the signal that is fed back into the delay.
+ * Caution: Values >= 1 will result in a signal that gets louder and louder! Don't do it
+ *
+ * @name delayfeedback
+ * @param {number | Pattern} feedback between 0 and 1
+ * @synonyms delayfb, dfb
+ * @example
+ * s("bd").delay(.25).delayfeedback("<.25 .5 .75 1>")
+ *
+ */
+export const { delayspeed } = registerControl('delayspeed');
/**
* Sets the time of the delay effect.
*
- * @name delaytime
- * @param {number | Pattern} seconds between 0 and Infinity
+ * @name delayspeed
+ * @param {number | Pattern} delayspeed controls the pitch of the delay feedback
* @synonyms delayt, dt
* @example
- * s("bd bd").delay(.25).delaytime("<.125 .25 .5 1>")
+ * note("d d a# a".fast(2)).s("sawtooth").delay(.8).delaytime(1/2).delayspeed("<2 .5 -1 -2>")
*
*/
export const { delaytime, delayt, dt } = registerControl('delaytime', 'delayt', 'dt');
@@ -1215,6 +1293,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()
diff --git a/packages/core/fraction.mjs b/packages/core/fraction.mjs
index 2e3bc68ea..076fbadf6 100644
--- a/packages/core/fraction.mjs
+++ b/packages/core/fraction.mjs
@@ -126,6 +126,8 @@ export const lcm = (...fractions) => {
);
};
+export const isFraction = (x) => x instanceof Fraction;
+
fraction._original = Fraction;
export default fraction;
diff --git a/packages/core/logger.mjs b/packages/core/logger.mjs
index 488bce5e8..6727c2422 100644
--- a/packages/core/logger.mjs
+++ b/packages/core/logger.mjs
@@ -5,8 +5,9 @@ let debounce = 1000,
lastTime;
export function errorLogger(e, origin = 'cyclist') {
- //TODO: add some kind of debug flag that enables this while in dev mode
- console.error(e);
+ if (process.env.NODE_ENV === 'development') {
+ console.error(e);
+ }
logger(`[${origin}] error: ${e.message}`);
}
diff --git a/packages/core/package.json b/packages/core/package.json
index 33540c8bf..7cf20cea7 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/core",
- "version": "1.2.3",
+ "version": "1.2.4",
"description": "Port of Tidal Cycles to JavaScript",
"main": "index.mjs",
"type": "module",
diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs
index addc301d5..d0be3f599 100644
--- a/packages/core/pattern.mjs
+++ b/packages/core/pattern.mjs
@@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import TimeSpan from './timespan.mjs';
-import Fraction, { lcm } from './fraction.mjs';
+import Fraction, { isFraction, lcm } from './fraction.mjs';
import Hap from './hap.mjs';
import State from './state.mjs';
import { unionWithObj } from './value.mjs';
@@ -999,7 +999,7 @@ addToPrototype('weaveWith', function (t, ...funcs) {
// compose matrix functions
function _nonArrayObject(x) {
- return !Array.isArray(x) && typeof x === 'object';
+ return !Array.isArray(x) && typeof x === 'object' && !isFraction(x);
}
function _composeOp(a, b, func) {
if (_nonArrayObject(a) || _nonArrayObject(b)) {
@@ -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"])
*
@@ -2397,6 +2399,57 @@ 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 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
+ * "<0 [2 4]>"
+ * .plyWith(4, (p) => p.add(2))
+ * .scale("C:minor").note()
+ */
+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();
+ if (__steps) {
+ result._steps = Fraction(factor).mulmaybe(pat._steps);
+ }
+ return result;
+});
+
+/**
+ * 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]>"
+ * .plyForEach(4, (p,n) => p.add(n*2))
+ * .scale("C:minor").note()
+ */
+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();
+ 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
diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs
index 47231af4d..53562f216 100644
--- a/packages/core/repl.mjs
+++ b/packages/core/repl.mjs
@@ -74,6 +74,14 @@ export function repl({
return silence;
};
+ // helper to get a patternified pure value out
+ function unpure(pat) {
+ if (pat._Pattern) {
+ return pat.__pure;
+ }
+ return pat;
+ }
+
const setPattern = async (pattern, autostart = true) => {
pattern = editPattern?.(pattern) || pattern;
await scheduler.setPattern(pattern, autostart);
@@ -85,7 +93,10 @@ export function repl({
const start = () => scheduler.start();
const pause = () => scheduler.pause();
const toggle = () => scheduler.toggle();
- const setCps = (cps) => scheduler.setCps(cps);
+ const setCps = (cps) => {
+ scheduler.setCps(unpure(cps));
+ return silence;
+ };
/**
* Changes the global tempo to the given cycles per minute
@@ -97,7 +108,10 @@ export function repl({
* setcpm(140/4) // =140 bpm in 4/4
* $: s("bd*4,[- sd]*2").bank('tr707')
*/
- const setCpm = (cpm) => scheduler.setCps(cpm / 60);
+ const setCpm = (cpm) => {
+ scheduler.setCps(unpure(cpm) / 60);
+ return silence;
+ };
// TODO - not documented as jsdoc examples as the test framework doesn't simulate enough context for `each` and `all`..
diff --git a/packages/core/util.mjs b/packages/core/util.mjs
index 2e7c6e026..ef3f1e961 100644
--- a/packages/core/util.mjs
+++ b/packages/core/util.mjs
@@ -8,12 +8,12 @@ import { logger } from './logger.mjs';
// returns true if the given string is a note
export const isNoteWithOctave = (name) => /^[a-gA-G][#bs]*[0-9]$/.test(name);
-export const isNote = (name) => /^[a-gA-G][#bsf]*[0-9]?$/.test(name);
+export const isNote = (name) => /^[a-gA-G][#bsf]*-?[0-9]?$/.test(name);
export const tokenizeNote = (note) => {
if (typeof note !== 'string') {
return [];
}
- const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)([0-9]*)$/)?.slice(1) || [];
+ const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)(-?[0-9]*)$/)?.slice(1) || [];
if (!pc) {
return [];
}
diff --git a/packages/csound/package.json b/packages/csound/package.json
index 19b1dd6fd..90130a101 100644
--- a/packages/csound/package.json
+++ b/packages/csound/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/csound",
- "version": "1.2.4",
+ "version": "1.2.5",
"description": "csound bindings for strudel",
"main": "index.mjs",
"type": "module",
diff --git a/packages/draw/package.json b/packages/draw/package.json
index f63367402..6a4c57540 100644
--- a/packages/draw/package.json
+++ b/packages/draw/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/draw",
- "version": "1.2.3",
+ "version": "1.2.4",
"description": "Helpers for drawing with Strudel",
"main": "index.mjs",
"type": "module",
diff --git a/packages/gamepad/package.json b/packages/gamepad/package.json
index 53fad18a6..555eac03f 100644
--- a/packages/gamepad/package.json
+++ b/packages/gamepad/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/gamepad",
- "version": "1.2.3",
+ "version": "1.2.4",
"description": "Gamepad Inputs for strudel",
"main": "index.mjs",
"type": "module",
diff --git a/packages/hydra/package.json b/packages/hydra/package.json
index 1553264d4..7ba79d5d0 100644
--- a/packages/hydra/package.json
+++ b/packages/hydra/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/hydra",
- "version": "1.2.3",
+ "version": "1.2.4",
"description": "Hydra integration for strudel",
"main": "hydra.mjs",
"type": "module",
diff --git a/packages/midi/package.json b/packages/midi/package.json
index 8dde35984..2342cf7e9 100644
--- a/packages/midi/package.json
+++ b/packages/midi/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/midi",
- "version": "1.2.4",
+ "version": "1.2.5",
"description": "Midi API for strudel",
"main": "index.mjs",
"type": "module",
diff --git a/packages/mini/package.json b/packages/mini/package.json
index e8fca4ac6..6eeaab0da 100644
--- a/packages/mini/package.json
+++ b/packages/mini/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/mini",
- "version": "1.2.3",
+ "version": "1.2.4",
"description": "Mini notation for strudel",
"main": "index.mjs",
"type": "module",
diff --git a/packages/mondough/mondough.mjs b/packages/mondough/mondough.mjs
index 15feb5d86..b7ee83787 100644
--- a/packages/mondough/mondough.mjs
+++ b/packages/mondough/mondough.mjs
@@ -85,7 +85,7 @@ function evaluator(node, scope) {
let pat;
if (type === 'plain' && typeof variable !== 'undefined') {
// some function names are not patternable, so we skip reification here
- if (['!', 'extend', '@', 'expand', 'square', 'angle'].includes(value)) {
+ if (['!', 'extend', '@', 'expand', 'square', 'angle', 'all', 'setcpm', 'setcps'].includes(value)) {
return variable;
}
pat = reify(variable);
@@ -108,7 +108,7 @@ export function mondo(code, offset = 0) {
return pat.markcss('color: var(--caret,--foreground);text-decoration:underline');
}
-let getLocations = (code, offset) => runner.parser.get_locations(code, offset);
+export let getLocations = (code, offset) => runner.parser.get_locations(code, offset);
export const mondi = (str, offset) => {
const code = `[${str}]`;
diff --git a/packages/mondough/package.json b/packages/mondough/package.json
index 99a87f098..c81d76cb6 100644
--- a/packages/mondough/package.json
+++ b/packages/mondough/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/mondo",
- "version": "1.1.1",
+ "version": "1.1.4",
"description": "mondo notation for strudel",
"main": "mondough.mjs",
"type": "module",
diff --git a/packages/mondough/vite.config.js b/packages/mondough/vite.config.js
index c46972e96..06c8c4559 100644
--- a/packages/mondough/vite.config.js
+++ b/packages/mondough/vite.config.js
@@ -1,5 +1,5 @@
import { defineConfig } from 'vite';
-//import { dependencies } from './package.json';
+import { dependencies } from './package.json';
import { resolve } from 'path';
// https://vitejs.dev/config/
@@ -12,7 +12,7 @@ export default defineConfig({
fileName: (ext) => ({ es: 'mondough.mjs' })[ext],
},
rollupOptions: {
- // external: [...Object.keys(dependencies)],
+ external: [...Object.keys(dependencies)],
},
target: 'esnext',
},
diff --git a/packages/motion/package.json b/packages/motion/package.json
index acacc154f..57cac9cc8 100644
--- a/packages/motion/package.json
+++ b/packages/motion/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/motion",
- "version": "1.2.3",
+ "version": "1.2.4",
"description": "DeviceMotion API for strudel",
"main": "index.mjs",
"type": "module",
diff --git a/packages/mqtt/package.json b/packages/mqtt/package.json
index d5ac6dd71..2e32825fe 100644
--- a/packages/mqtt/package.json
+++ b/packages/mqtt/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/mqtt",
- "version": "1.2.3",
+ "version": "1.2.4",
"description": "MQTT API for strudel",
"main": "mqtt.mjs",
"type": "module",
diff --git a/packages/osc/README.md b/packages/osc/README.md
index 3dc65e16e..0a7bfedcf 100644
--- a/packages/osc/README.md
+++ b/packages/osc/README.md
@@ -4,21 +4,13 @@ OSC output for strudel patterns! Currently only tested with super collider / sup
## Usage
-OSC will only work if you run the REPL locally + the OSC server besides it:
+Assuming you have [node.js](https://nodejs.org/) installed, you can run the osc bridge server via:
-From the project root:
-
-```js
-npm run repl
+```sh
+npx @strudel/osc
```
-and in a seperate shell:
-
-```js
-npm run osc
-```
-
-This should give you
+You should see something like:
```log
osc client running on port 57120
@@ -26,14 +18,32 @@ osc server running on port 57121
websocket server running on port 8080
```
-Now open Supercollider (with the super dirt startup file)
+### --port
-Now open the REPL and type:
+By default it will use port 57120 for the osc client, which is what [superdirt](https://github.com/musikinformatik/SuperDirt) uses. You can change it via the `--port` option:
-```js
-s(" hh").osc()
+```sh
+npx @strudel/osc --port 7771 # classic dirt
```
-or just [click here](https://strudel.cc/#cygiPGJkIHNkPiBoaCIpLm9zYygp)...
+### --debug
+
+To log all incoming osc messages, add the `--debug` flag:
+
+```sh
+npx @strudel/osc --debug
+```
+
+## Usage in Strudel
+
+To test it in strudel, you have can use `all(osc)` to send all events through osc:
+
+```js
+$: s("bd*4")
+
+all(osc)
+```
+
+[open in repl](https://strudel.cc/#JDogcygiYmQqNCIpCgphbGwob3NjKQ%3D%3D)
You can read more about [how to use Superdirt with Strudel the Tutorial](https://strudel.cc/learn/input-output/#superdirt-api)
diff --git a/packages/osc/osc.mjs b/packages/osc/osc.mjs
index 8262e5569..fe0691522 100644
--- a/packages/osc/osc.mjs
+++ b/packages/osc/osc.mjs
@@ -6,7 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th
import OSC from 'osc-js';
-import { logger, parseNumeral, Pattern, isNote, noteToMidi, ClockCollator } from '@strudel/core';
+import { logger, parseNumeral, register, isNote, noteToMidi, ClockCollator } from '@strudel/core';
let connection; // Promise
function connect() {
@@ -81,6 +81,4 @@ export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
* @memberof Pattern
* @returns Pattern
*/
-Pattern.prototype.osc = function () {
- return this.onTrigger(oscTrigger);
-};
+export const osc = register('osc', (pat) => pat.onTrigger(oscTrigger));
diff --git a/packages/osc/package.json b/packages/osc/package.json
index 6e8b57813..bc828d798 100644
--- a/packages/osc/package.json
+++ b/packages/osc/package.json
@@ -1,8 +1,9 @@
{
"name": "@strudel/osc",
- "version": "1.2.3",
+ "version": "1.2.10",
"description": "OSC messaging for strudel",
"main": "osc.mjs",
+ "bin": "./server.js",
"type": "module",
"publishConfig": {
"main": "dist/index.mjs"
diff --git a/packages/osc/server.js b/packages/osc/server.js
index 75fc5b1c0..d7ec21c4b 100644
--- a/packages/osc/server.js
+++ b/packages/osc/server.js
@@ -1,3 +1,5 @@
+#!/usr/bin/env node
+
/*
server.js -
Copyright (C) 2022 Strudel contributors - see
@@ -6,6 +8,19 @@ This program is free software: you can redistribute it and/or modify it under th
import OSC from 'osc-js';
+const args = process.argv.slice(2);
+function getArgValue(flag) {
+ const i = args.indexOf(flag);
+ if (i !== -1) {
+ const nextIsFlag = args[i + 1]?.startsWith('--') ?? true;
+ if (nextIsFlag) return true;
+ return args[i + 1];
+ }
+}
+
+let udpClientPort = Number(getArgValue('--port')) || 57120;
+let debug = Number(getArgValue('--debug')) || 0;
+
const config = {
receiver: 'ws', // @param {string} Where messages sent via 'send' method will be delivered to, 'ws' for Websocket clients, 'udp' for udp client
udpServer: {
@@ -17,7 +32,7 @@ const config = {
},
udpClient: {
host: 'localhost', // @param {string} Hostname of udp client for messaging
- port: 57120, // @param {number} Port of udp client for messaging
+ port: udpClientPort, // @param {number} Port of udp client for messaging
},
wsServer: {
host: 'localhost', // @param {string} Hostname of WebSocket server
@@ -27,8 +42,34 @@ const config = {
const osc = new OSC({ plugin: new OSC.BridgePlugin(config) });
-osc.open(); // start a WebSocket server on port 8080
+if (debug) {
+ osc.on('*', (message) => {
+ const { address, args } = message;
+ let str = '';
+ for (let i = 0; i < args.length; i += 2) {
+ str += `${args[i]}: ${args[i + 1]} `;
+ }
+ console.log(`${address} ${str}`);
+ });
+}
+
+osc.on('error', (message) => {
+ if (message.toString().includes('EADDRINUSE')) {
+ console.log(`------ ERROR -------
+a server is already running on port 57121! to stop it:
+1. run "lsof -ti :57121 | xargs kill -9" (macos / linux)
+2. re-run the osc server
+`);
+ } else {
+ console.log(message);
+ }
+});
+
+osc.open();
console.log('osc client running on port', config.udpClient.port);
console.log('osc server running on port', config.udpServer.port);
console.log('websocket server running on port', config.wsServer.port);
+if (debug) {
+ console.log('debug logs enabled. incoming messages will appear below');
+}
diff --git a/packages/osc/superdirtoutput.js b/packages/osc/superdirtoutput.js
index 3f48e66bd..a317af8f1 100644
--- a/packages/osc/superdirtoutput.js
+++ b/packages/osc/superdirtoutput.js
@@ -1,10 +1,10 @@
-import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs';
-import { isTauri } from '../desktopbridge/utils.mjs';
+/* import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs';
+import { isTauri } from '../desktopbridge/utils.mjs'; */
import { oscTrigger } from './osc.mjs';
-const trigger = isTauri() ? oscTriggerTauri : oscTrigger;
+const trigger = /* isTauri() ? oscTriggerTauri : */ oscTrigger;
export const superdirtOutput = (hap, deadline, hapDuration, cps, targetTime) => {
const currentTime = performance.now() / 1000;
- return trigger(null, hap, currentTime, cps, targetTime);
+ return trigger(hap, currentTime, cps, targetTime);
};
diff --git a/packages/repl/package.json b/packages/repl/package.json
index aabd1b8c7..41a165c34 100644
--- a/packages/repl/package.json
+++ b/packages/repl/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/repl",
- "version": "1.2.4",
+ "version": "1.2.6",
"description": "Strudel REPL as a Web Component",
"module": "index.mjs",
"publishConfig": {
diff --git a/packages/sampler/package.json b/packages/sampler/package.json
index dd701bcfb..2bf0607b1 100644
--- a/packages/sampler/package.json
+++ b/packages/sampler/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/sampler",
- "version": "0.2.2",
+ "version": "0.2.3",
"description": "",
"keywords": [
"tidalcycles",
diff --git a/packages/sampler/sample-server.mjs b/packages/sampler/sample-server.mjs
index 08456add9..31b83f5a3 100644
--- a/packages/sampler/sample-server.mjs
+++ b/packages/sampler/sample-server.mjs
@@ -1,14 +1,20 @@
#!/usr/bin/env node
import cowsay from 'cowsay';
-import { createReadStream, existsSync } from 'fs';
+import { createReadStream, existsSync, writeFileSync } from 'fs';
import { readdir } from 'fs/promises';
import http from 'http';
-import { join, sep } from 'path';
+import { join, resolve, sep } from 'path';
+import readline from 'readline';
import os from 'os';
-// eslint-disable-next-line
const LOG = !!process.env.LOG || false;
+const VALID_AUDIO_EXTENSIONS = ['wav', 'mp3', 'ogg'];
+
+const isAudioFile = (f) => {
+ const ext = f.split('.').slice(-1)[0].toLowerCase();
+ return VALID_AUDIO_EXTENSIONS.includes(ext);
+};
async function getFilesInDirectory(directory) {
let files = [];
@@ -21,32 +27,32 @@ async function getFilesInDirectory(directory) {
continue;
}
try {
- const subFiles = (await getFilesInDirectory(fullPath)).filter((f) =>
- ['wav', 'mp3', 'ogg'].includes(f.split('.').slice(-1)[0].toLowerCase()),
- );
+ const subFiles = (await getFilesInDirectory(fullPath)).filter(isAudioFile);
files = files.concat(subFiles);
LOG && console.log(`${dirent.name} (${subFiles.length})`);
} catch (err) {
LOG && console.warn(`skipped due to error: ${fullPath}`);
}
} else {
- files.push(fullPath);
+ isAudioFile(fullPath) && files.push(fullPath);
}
}
return files;
}
-async function getBanks(directory) {
+async function getBanks(directory, flat = false) {
let files = await getFilesInDirectory(directory);
let banks = {};
directory = directory.split(sep).join('/');
files = files.map((path) => {
path = path.split(sep).join('/');
- const [bank] = path.split('/').slice(-2);
+ const subDir = path.replace(directory, '');
+ const subDirFlat = subDir.replaceAll('/', '_').slice(1); // remove initial underscore
+ const subDirFlatStem = subDirFlat.replace(/\.[^.]+$/, ''); // remove extension
+ let bank = flat ? subDirFlatStem : path.split('/').slice(-2)[0];
banks[bank] = banks[bank] || [];
- const relativeUrl = path.replace(directory, '');
- banks[bank].push(relativeUrl);
- return relativeUrl;
+ banks[bank].push(subDir);
+ return subDir;
});
banks._base = `http://localhost:5432`;
return { banks, files };
@@ -54,14 +60,44 @@ async function getBanks(directory) {
const args = process.argv.slice(2);
-// eslint-disable-next-line
-const directory = process.cwd();
+function getArgValue(flag) {
+ const i = args.indexOf(flag);
+ if (i !== -1) {
+ const nextIsFlag = args[i + 1]?.startsWith('--') ?? true;
+ if (nextIsFlag) return true;
+ return args[i + 1];
+ }
+}
+function getInput(query) {
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+
+ return new Promise((resolve) =>
+ rl.question(query, (response) => {
+ rl.close();
+ resolve(response);
+ }),
+ );
+}
+
+let directory = getArgValue('--dir') || process.cwd();
+directory = resolve(directory);
if (args.includes('--json')) {
- const { banks, files } = await getBanks(directory);
+ const { banks } = await getBanks(directory, getArgValue('--flat'));
const json = JSON.stringify(banks);
- console.log(json);
- process.exit(0);
+ const outFile = resolve(directory, 'strudel.json');
+ if (existsSync(outFile)) {
+ const answer = await getInput(`Warning: File already exists at ${outFile}. Overwrite? (y/N): `);
+ if (answer.toLowerCase() !== 'y') {
+ console.log('Aborted.');
+ process.exit(0);
+ }
+ }
+ writeFileSync(outFile, json, 'utf8');
+ console.log(`Wrote json to ${outFile}`);
}
console.log(
@@ -74,7 +110,7 @@ console.log(
const server = http.createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
- const { banks, files } = await getBanks(directory);
+ const { banks, files } = await getBanks(directory, getArgValue('--flat'));
if (req.url === '/') {
res.setHeader('Content-Type', 'application/json');
return res.end(JSON.stringify(banks));
@@ -82,7 +118,7 @@ const server = http.createServer(async (req, res) => {
let subpath = decodeURIComponent(req.url);
const filePath = join(directory, subpath.split('/').join(sep));
- //console.log('GET:', filePath);
+ // console.log('GET:', filePath);
const isFound = existsSync(filePath);
if (!isFound) {
res.statusCode = 404;
diff --git a/packages/serial/package.json b/packages/serial/package.json
index 8abbe7692..9ed89cf2a 100644
--- a/packages/serial/package.json
+++ b/packages/serial/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/serial",
- "version": "1.2.3",
+ "version": "1.2.4",
"description": "Webserial API for strudel",
"main": "serial.mjs",
"type": "module",
diff --git a/packages/soundfonts/package.json b/packages/soundfonts/package.json
index e5c1780ed..07e35674b 100644
--- a/packages/soundfonts/package.json
+++ b/packages/soundfonts/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/soundfonts",
- "version": "1.2.4",
+ "version": "1.2.5",
"description": "Soundsfont support for strudel",
"main": "index.mjs",
"publishConfig": {
diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs
index 7879cff00..36c3c0fbc 100644
--- a/packages/superdough/helpers.mjs
+++ b/packages/superdough/helpers.mjs
@@ -176,7 +176,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);
diff --git a/packages/superdough/logger.mjs b/packages/superdough/logger.mjs
index b3c9c34f3..99fd0cf39 100644
--- a/packages/superdough/logger.mjs
+++ b/packages/superdough/logger.mjs
@@ -1,8 +1,9 @@
let log = (msg) => console.log(msg);
-export function errorLogger(e, origin = 'cyclist') {
- //TODO: add some kind of debug flag that enables this while in dev mode
- // console.error(e);
+export function errorLogger(e, origin = 'superdough') {
+ if (process.env.NODE_ENV === 'development') {
+ console.error(e);
+ }
logger(`[${origin}] error: ${e.message}`);
}
diff --git a/packages/superdough/package.json b/packages/superdough/package.json
index 809da0227..a82117774 100644
--- a/packages/superdough/package.json
+++ b/packages/superdough/package.json
@@ -1,6 +1,6 @@
{
"name": "superdough",
- "version": "1.2.4",
+ "version": "1.2.5",
"description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.",
"main": "index.mjs",
"type": "module",
diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs
index 18d1b7797..9188c17c3 100644
--- a/packages/superdough/sampler.mjs
+++ b/packages/superdough/sampler.mjs
@@ -196,6 +196,52 @@ function getSamplesPrefixHandler(url) {
return;
}
+export async function fetchSampleMap(url) {
+ // check if custom prefix handler
+ const handler = getSamplesPrefixHandler(url);
+ if (handler) {
+ return handler(url);
+ }
+ url = resolveSpecialPaths(url);
+ if (url.startsWith('github:')) {
+ url = githubPath(url, 'strudel.json');
+ }
+ if (url.startsWith('local:')) {
+ url = `http://localhost:5432`;
+ }
+ if (url.startsWith('shabda:')) {
+ let [_, path] = url.split('shabda:');
+ url = `https://shabda.ndre.gr/${path}.json?strudel=1`;
+ }
+ if (url.startsWith('shabda/speech')) {
+ let [_, path] = url.split('shabda/speech');
+ path = path.startsWith('/') ? path.substring(1) : path;
+ let [params, words] = path.split(':');
+ let gender = 'f';
+ let language = 'en-GB';
+ if (params) {
+ [language, gender] = params.split('/');
+ }
+ url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`;
+ }
+ if (typeof fetch !== 'function') {
+ // not a browser
+ return;
+ }
+ const base = url.split('/').slice(0, -1).join('/');
+ if (typeof fetch === 'undefined') {
+ // skip fetch when in node / testing
+ return;
+ }
+ const json = await fetch(url)
+ .then((res) => res.json())
+ .catch((error) => {
+ console.error(error);
+ throw new Error(`error loading "${url}"`);
+ });
+ return [json, json._base || base];
+}
+
/**
* Loads a collection of samples to use with `s`
* @example
@@ -217,49 +263,8 @@ function getSamplesPrefixHandler(url) {
export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => {
if (typeof sampleMap === 'string') {
- // check if custom prefix handler
- const handler = getSamplesPrefixHandler(sampleMap);
- if (handler) {
- return handler(sampleMap);
- }
- sampleMap = resolveSpecialPaths(sampleMap);
- if (sampleMap.startsWith('github:')) {
- sampleMap = githubPath(sampleMap, 'strudel.json');
- }
- if (sampleMap.startsWith('local:')) {
- sampleMap = `http://localhost:5432`;
- }
- if (sampleMap.startsWith('shabda:')) {
- let [_, path] = sampleMap.split('shabda:');
- sampleMap = `https://shabda.ndre.gr/${path}.json?strudel=1`;
- }
- if (sampleMap.startsWith('shabda/speech')) {
- let [_, path] = sampleMap.split('shabda/speech');
- path = path.startsWith('/') ? path.substring(1) : path;
- let [params, words] = path.split(':');
- let gender = 'f';
- let language = 'en-GB';
- if (params) {
- [language, gender] = params.split('/');
- }
- sampleMap = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`;
- }
- if (typeof fetch !== 'function') {
- // not a browser
- return;
- }
- const base = sampleMap.split('/').slice(0, -1).join('/');
- if (typeof fetch === 'undefined') {
- // skip fetch when in node / testing
- return;
- }
- return fetch(sampleMap)
- .then((res) => res.json())
- .then((json) => samples(json, baseUrl || json._base || base, options))
- .catch((error) => {
- console.error(error);
- throw new Error(`error loading "${sampleMap}"`);
- });
+ const [json, base] = await fetchSampleMap(sampleMap);
+ return samples(json, baseUrl || base, options);
}
const { prebake, tag } = options;
processSampleMap(
diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs
index d436a816a..b2b5c8d67 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`);
@@ -196,7 +209,7 @@ export const resetLoadedSounds = () => soundMap.set({});
let audioContext;
export const setDefaultAudioContext = () => {
- audioContext = new AudioContext();
+ audioContext = new AudioContext({ latencyHint: 'playback' });
return audioContext;
};
@@ -212,11 +225,17 @@ export function getAudioContextCurrentTime() {
return getAudioContext().currentTime;
}
+let externalWorklets = [];
+export function registerWorklet(url) {
+ externalWorklets.push(url);
+}
+
let workletsLoading;
function loadWorklets() {
if (!workletsLoading) {
const audioCtx = getAudioContext();
- workletsLoading = audioCtx.audioWorklet.addModule(workletsUrl);
+ const allWorkletURLs = externalWorklets.concat([workletsUrl]);
+ workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL)));
}
return workletsLoading;
@@ -330,18 +349,17 @@ function getDelay(orbit, delaytime, delayfeedback, t) {
//logger(`delayfeedback was clamped to ${maxfeedback} to save your ears`);
}
delayfeedback = clamp(delayfeedback, 0, 0.98);
- if (!orbits[orbit].delayNode) {
+ let delayNode = orbits[orbit].delayNode;
+ if (delayNode === undefined) {
const ac = getAudioContext();
- const dly = ac.createFeedbackDelay(1, delaytime, delayfeedback);
- dly.start?.(t); // for some reason, this throws when audion extension is installed..
- connectToOrbit(dly, orbit);
- orbits[orbit].delayNode = dly;
+ delayNode = ac.createFeedbackDelay(1, delaytime, delayfeedback);
+ delayNode.start?.(t); // for some reason, this throws when audion extension is installed..
+ connectToOrbit(delayNode, orbit);
+ orbits[orbit].delayNode = delayNode;
}
- orbits[orbit].delayNode.delayTime.value !== delaytime &&
- orbits[orbit].delayNode.delayTime.setValueAtTime(delaytime, t);
- orbits[orbit].delayNode.feedback.value !== delayfeedback &&
- orbits[orbit].delayNode.feedback.setValueAtTime(delayfeedback, t);
- return orbits[orbit].delayNode;
+ delayNode.delayTime.value !== delaytime && delayNode.delayTime.setValueAtTime(delaytime, t);
+ delayNode.feedback.value !== delayfeedback && delayNode.feedback.setValueAtTime(delayfeedback, t);
+ return delayNode;
}
export function getLfo(audioContext, begin, end, properties = {}) {
@@ -397,42 +415,60 @@ function getFilterType(ftype) {
return typeof ftype === 'number' ? filterTypes[Math.floor(_mod(ftype, filterTypes.length))] : ftype;
}
-//type orbit {
-// gain: number,
-// reverbNode: reverbNode
-// delayNode: delayNode
-//}
+// type orbit {
+// output: GainNode,
+// reverbNode: ConvolverNode
+// delayNode: FeedbackDelayNode
+// }
let orbits = {};
function connectToOrbit(node, orbit) {
if (orbits[orbit] == null) {
errorLogger(new Error('target orbit does not exist'), 'superdough');
}
- node.connect(orbits[orbit].gain);
+ node.connect(orbits[orbit].output);
}
function setOrbit(audioContext, orbit, channels) {
if (orbits[orbit] == null) {
orbits[orbit] = {
- gain: new GainNode(audioContext, { gain: 1 }),
+ // Setup output node through which all audio filters prior to hitting
+ // the destination (and thus allows for global volume automation)
+ output: new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }),
};
- connectToDestination(orbits[orbit].gain, channels);
+ connectToDestination(orbits[orbit].output, channels);
}
}
-function duckOrbit(audioContext, targetOrbit, t, attacktime = 0.1, duckdepth = 1) {
- const targetArr = [targetOrbit].flat();
- targetArr.forEach((target) => {
+function duckOrbit(audioContext, targetOrbit, t, onsettime = 0, attacktime = 0.1, duckdepth = 1) {
+ const targetArr = [targetOrbit].flat();
+ const onsetArr = [onsettime].flat();
+ const attackArr = [attacktime].flat();
+ const depthArr = [duckdepth].flat();
+
+ targetArr.forEach((target, idx) => {
if (orbits[target] == null) {
errorLogger(new Error(`duck target orbit ${target} does not exist`), 'superdough');
return;
}
+ const onset = onsetArr[idx] ?? onsetArr[0];
+ const attack = Math.max(attackArr[idx] ?? attackArr[0], 0.002);
+ const depth = depthArr[idx] ?? depthArr[0];
+ const gainParam = orbits[target].output.gain;
webAudioTimeout(
audioContext,
() => {
- orbits[target].gain.gain.cancelScheduledValues(t);
- const currVal = orbits[target].gain.gain.value;
- orbits[target].gain.gain.linearRampToValueAtTime(clamp(1 - Math.pow(duckdepth, 0.5), 0.01, currVal), t);
- orbits[target].gain.gain.exponentialRampToValueAtTime(1, t + Math.max(0.002, attacktime));
+ const now = audioContext.currentTime;
+
+ // cancelScheduledValues and setValueAtTime together emulate cancelAndHoldAtTime
+ // on browsers which lack that method
+ const currVal = gainParam.value;
+ gainParam.cancelScheduledValues(now);
+ gainParam.setValueAtTime(currVal, now);
+
+ const t0 = Math.max(t, now); // guard against now > t
+ const duckedVal = clamp(1 - Math.sqrt(depth), 0.01, currVal);
+ gainParam.exponentialRampToValueAtTime(duckedVal, t0 + onset);
+ gainParam.exponentialRampToValueAtTime(1, t0 + onset + attack);
},
0,
t - 0.01,
@@ -443,30 +479,31 @@ function duckOrbit(audioContext, targetOrbit, t, attacktime = 0.1, duckdepth = 1
let hasChanged = (now, before) => now !== undefined && now !== before;
function getReverb(orbit, duration, fade, lp, dim, ir, irspeed, irbegin) {
// If no reverb has been created for a given orbit, create one
- if (!orbits[orbit].reverbNode) {
+ let reverbNode = orbits[orbit].reverbNode;
+ if (reverbNode === undefined) {
const ac = getAudioContext();
- const reverb = ac.createReverb(duration, fade, lp, dim, ir, irspeed, irbegin);
- connectToOrbit(reverb, orbit);
- orbits[orbit].reverbNode = reverb;
+ reverbNode = ac.createReverb(duration, fade, lp, dim, ir, irspeed, irbegin);
+ connectToOrbit(reverbNode, orbit);
+ orbits[orbit].reverbNode = reverbNode;
}
if (
- hasChanged(duration, orbits[orbit].reverbNode.duration) ||
- hasChanged(fade, orbits[orbit].reverbNode.fade) ||
- hasChanged(lp, orbits[orbit].reverbNode.lp) ||
- hasChanged(dim, orbits[orbit].reverbNode.dim) ||
- hasChanged(irspeed, orbits[orbit].reverbNode.irspeed) ||
- hasChanged(irbegin, orbits[orbit].reverbNode.irbegin) ||
- orbits[orbit].reverbNode.ir !== ir
+ hasChanged(duration, reverbNode.duration) ||
+ hasChanged(fade, reverbNode.fade) ||
+ hasChanged(lp, reverbNode.lp) ||
+ hasChanged(dim, reverbNode.dim) ||
+ hasChanged(irspeed, reverbNode.irspeed) ||
+ hasChanged(irbegin, reverbNode.irbegin) ||
+ reverbNode.ir !== ir
) {
// only regenerate when something has changed
// avoids endless regeneration on things like
// stack(s("a"), s("b").rsize(8)).room(.5)
// this only works when args may stay undefined until here
// setting default values breaks this
- orbits[orbit].reverbNode.generate(duration, fade, lp, dim, ir, irspeed, irbegin);
+ reverbNode.generate(duration, fade, lp, dim, ir, irspeed, irbegin);
}
- return orbits[orbit].reverbNode;
+ return reverbNode;
}
export let analysers = {},
@@ -564,6 +601,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
postgain = getDefaultValue('postgain'),
density = getDefaultValue('density'),
duckorbit,
+ duckonset,
duckattack,
duckdepth,
// filters
@@ -645,7 +683,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
setOrbit(ac, orbit, channels, t, cycle, cps);
if (duckorbit != null) {
- duckOrbit(ac, duckorbit, t, duckattack, duckdepth);
+ duckOrbit(ac, duckorbit, t, duckonset, duckattack, duckdepth);
}
gain = applyGainCurve(nanFallback(gain, 1));
diff --git a/packages/superdough/util.mjs b/packages/superdough/util.mjs
index f4d59024e..764ebb43e 100644
--- a/packages/superdough/util.mjs
+++ b/packages/superdough/util.mjs
@@ -7,7 +7,7 @@ export const tokenizeNote = (note) => {
if (typeof note !== 'string') {
return [];
}
- const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)([0-9]*)$/)?.slice(1) || [];
+ const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)(-?[0-9]*)$/)?.slice(1) || [];
if (!pc) {
return [];
}
diff --git a/packages/supradough/.gitignore b/packages/supradough/.gitignore
new file mode 100644
index 000000000..d21cbdf3e
--- /dev/null
+++ b/packages/supradough/.gitignore
@@ -0,0 +1 @@
+pattern.wav
diff --git a/packages/supradough/README.md b/packages/supradough/README.md
new file mode 100644
index 000000000..a8cfa84b3
--- /dev/null
+++ b/packages/supradough/README.md
@@ -0,0 +1,3 @@
+# supradough
+
+platform agnostic synth and sampler intended for live coding. a reimplementation of superdough.
\ No newline at end of file
diff --git a/packages/supradough/dough-export.mjs b/packages/supradough/dough-export.mjs
new file mode 100644
index 000000000..bd4b530b3
--- /dev/null
+++ b/packages/supradough/dough-export.mjs
@@ -0,0 +1,123 @@
+// this is a poc of how a pattern can be rendered as a wav file using node
+// run via: node dough-export.mjs
+import fs from 'node:fs';
+import WavEncoder from 'wav-encoder';
+import { evalScope } from '@strudel/core';
+import { miniAllStrings } from '@strudel/mini';
+import { Dough } from './dough.mjs';
+
+await evalScope(
+ import('@strudel/core'),
+ import('@strudel/mini'),
+ import('@strudel/tonal'),
+ // import('@strudel/tonal'),
+);
+
+miniAllStrings(); // allows using single quotes for mini notation / skip transpilation
+
+let sampleRate = 48000,
+ cps = 0.4;
+
+/* await doughsamples('github:eddyflux/crate');
+await doughsamples('github:eddyflux/wax'); */
+
+let pat = note('c,eb,g,')
+ .s('sine')
+ .press()
+ .add(note(24))
+ .fmi(3)
+ .fmh(5.01)
+ .dec(0.4)
+ .delay('.6:<.12 .22>:.8')
+ .jux(press)
+ .rarely(add(note('12')))
+ .lpf(400)
+ .lpq(0.2)
+ .lpd(0.4)
+ .lpenv(3)
+ .fmdecay(0.4)
+ .fmenv(1)
+ .postgain(0.6)
+ .stack(s('*8').dec(0.07).rarely(ply('2')).delay(0.5).hpf(sine.range(200, 2000).slow(4)).hpq(0.2))
+ .stack(
+ s('[- white@3]*2')
+ .dec(0.4)
+ .hpf('<2000!3 <4000 8000>>*4')
+ .hpq(0.6)
+ .ply('<1 2>*4')
+ .postgain(0.5)
+ .delay(0.5)
+ .jux(rev)
+ .lpf(5000),
+ )
+ .stack(
+ note('*2')
+ .s('square')
+ .lpf(sine.range(100, 300).slow(4))
+ .lpe(1)
+ .segment(8)
+ .lpd(0.3)
+ .lpq(0.2)
+ .dec(0.2)
+ .speed('<1 2>')
+ .ply('<1 2>')
+ .postgain(1),
+ )
+ .stack(
+ chord('')
+ .voicing()
+ .s('')
+ .clip(1)
+ .rel(0.4)
+ .vib('4:.2')
+ .gain(0.7)
+ .hpf(1200)
+ .fm(0.5)
+ .att(1)
+ .lpa(0.5)
+ .lpf(200)
+ .lpenv(4)
+ .chorus(0.8),
+ )
+ .slow(1 / cps);
+
+let cycles = 30;
+let seconds = cycles + 1; // 1s release tail
+const haps = pat.queryArc(0, cycles);
+
+const dough = new Dough(sampleRate);
+
+console.log('spawn voices...');
+haps.forEach((hap) => {
+ hap.value._begin = Number(hap.whole.begin);
+ hap.value._duration = hap.duration /* / cps */;
+ dough.scheduleSpawn(hap.value);
+});
+console.log(`render ${seconds}s long buffer, each dot is 1 second:`);
+const buffers = [new Float32Array(seconds * sampleRate), new Float32Array(seconds * sampleRate)];
+let t = performance.now();
+while (dough.t <= buffers[0].length) {
+ dough.update();
+ buffers[0][dough.t] = dough.out[0];
+ buffers[1][dough.t] = dough.out[1];
+ if (dough.t % sampleRate === 0) {
+ process.stdout.write('.');
+ }
+}
+const took = (performance.now() - t) / 1000;
+const load = (took / seconds) * 100;
+const speed = (seconds / took).toFixed(2);
+console.log('');
+console.log(`done!
+rendered ${seconds}s in ${took.toFixed(2)}s
+speed: ${speed}x
+load: ${load.toFixed(2)}%`);
+
+const patternAudio = {
+ sampleRate,
+ channelData: buffers,
+};
+
+WavEncoder.encode(patternAudio).then((buffer) => {
+ fs.writeFileSync('pattern.wav', new Float32Array(buffer));
+});
diff --git a/packages/supradough/dough-worklet.mjs b/packages/supradough/dough-worklet.mjs
new file mode 100644
index 000000000..18c316b2d
--- /dev/null
+++ b/packages/supradough/dough-worklet.mjs
@@ -0,0 +1,39 @@
+import { Dough } from './dough.mjs';
+
+const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
+
+class DoughProcessor extends AudioWorkletProcessor {
+ constructor() {
+ super();
+ this.dough = new Dough(sampleRate, currentTime);
+ this.port.onmessage = (event) => {
+ if (event.data.spawn) {
+ this.dough.scheduleSpawn(event.data.spawn);
+ } else if (event.data.sample) {
+ this.dough.loadSample(event.data.sample, event.data.channels, event.data.sampleRate);
+ } else if (event.data.samples) {
+ event.data.samples.forEach(([name, channels, sampleRate]) => {
+ this.dough.loadSample(name, channels, sampleRate);
+ });
+ } else {
+ console.log('unrecognized event type', event.data);
+ }
+ };
+ }
+ process(inputs, outputs, params) {
+ if (this.disconnected) {
+ return false;
+ }
+ const output = outputs[0];
+ for (let i = 0; i < output[0].length; i++) {
+ this.dough.update();
+ for (let c = 0; c < output.length; c++) {
+ //prevent speaker blowout via clipping if threshold exceeds
+ output[c][i] = clamp(this.dough.out[c], -1, 1);
+ }
+ }
+ return true; // keep the audio processing going
+ }
+}
+
+registerProcessor('dough-processor', DoughProcessor);
diff --git a/packages/supradough/dough.mjs b/packages/supradough/dough.mjs
new file mode 100644
index 000000000..1cb9241ad
--- /dev/null
+++ b/packages/supradough/dough.mjs
@@ -0,0 +1,1116 @@
+// 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;
+
+let gainCurveFunc = (val) => Math.pow(val, 2);
+
+function applyGainCurve(val) {
+ return gainCurveFunc(val);
+}
+
+/**
+ * Equal Power Crossfade function.
+ * Smoothly transitions between signals A and B, maintaining consistent perceived loudness.
+ *
+ * @param {number} a - Signal A (can be a single value or an array value in buffer processing).
+ * @param {number} b - Signal B (can be a single value or an array value in buffer processing).
+ * @param {number} m - Crossfade parameter (0.0 = all A, 1.0 = all B, 0.5 = equal mix).
+ * @returns {number} Crossfaded output value.
+ */
+function crossfade(a, b, m) {
+ const aGain = Math.sin((1 - m) * 0.5 * Math.PI);
+ const bGain = Math.sin(m * 0.5 * Math.PI);
+ return a * aGain + b * bGain;
+}
+
+// function setGainCurve(newGainCurveFunc) {
+// gainCurveFunc = newGainCurveFunc;
+// }
+// https://garten.salat.dev/audio-DSP/oscillators.html
+export class SineOsc {
+ phase = 0;
+ update(freq) {
+ const value = Math.sin(this.phase * 2 * Math.PI);
+ this.phase = (this.phase + freq / SAMPLE_RATE) % 1;
+ return value;
+ }
+}
+
+export class ZawOsc {
+ phase = 0;
+ update(freq) {
+ this.phase += ISR * freq;
+ return (this.phase % 1) * 2 - 1;
+ }
+}
+
+function polyBlep(t, dt) {
+ // 0 <= t < 1
+ if (t < dt) {
+ t /= dt;
+ // 2 * (t - t^2/2 - 0.5)
+ return t + t - t * t - 1;
+ }
+ // -1 < t < 0
+ if (t > 1 - dt) {
+ t = (t - 1) / dt;
+ // 2 * (t^2/2 + t + 0.5)
+ return t * t + t + t + 1;
+ }
+ // 0 otherwise
+ return 0;
+}
+
+export class SawOsc {
+ constructor(props = {}) {
+ this.phase = props.phase ?? 0;
+ }
+ update(freq) {
+ const dt = freq / SAMPLE_RATE;
+ let p = polyBlep(this.phase, dt);
+ let s = 2 * this.phase - 1 - p;
+ this.phase += dt;
+ if (this.phase > 1) {
+ this.phase -= 1;
+ }
+ return s;
+ }
+}
+
+function getUnisonDetune(unison, detune, voiceIndex) {
+ if (unison < 2) {
+ return 0;
+ }
+ const lerp = (a, b, n) => {
+ return n * (b - a) + a;
+ };
+ return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1));
+}
+function applySemitoneDetuneToFrequency(frequency, detune) {
+ return frequency * Math.pow(2, detune / 12);
+}
+export class SupersawOsc {
+ constructor(props = {}) {
+ //TODO: figure out a good way to pass in these params
+ this.voices = props.voices ?? 5;
+ this.freqspread = props.freqspread ?? 0.2;
+ this.panspread = props.panspread ?? 0.4;
+ this.phase = new Float32Array(this.voices).map(() => Math.random());
+ }
+ update(freq) {
+ const gain1 = Math.sqrt(1 - this.panspread);
+ const gain2 = Math.sqrt(this.panspread);
+ let sl = 0;
+ let sr = 0;
+ for (let n = 0; n < this.voices; n++) {
+ const freqAdjusted = applySemitoneDetuneToFrequency(freq, getUnisonDetune(this.voices, this.freqspread, n));
+ const dt = freqAdjusted / SAMPLE_RATE;
+ const isOdd = (n & 1) == 1;
+ let gainL = gain1;
+ let gainR = gain2;
+ // invert right and left gain
+ if (isOdd) {
+ gainL = gain2;
+ gainR = gain1;
+ }
+ let p = polyBlep(this.phase[n], dt);
+ let s = 2 * this.phase[n] - 1 - p;
+ sl = sl + s * gainL;
+ sr = sr + s * gainL;
+
+ this.phase[n] += dt;
+ if (this.phase[n] > 1) {
+ this.phase[n] -= 1;
+ }
+ }
+
+ return sl + sr;
+ //TODO: make stereo
+ // return [sl, sr];
+ }
+}
+
+export class TriOsc {
+ phase = 0;
+ update(freq) {
+ this.phase += ISR * freq;
+ let phase = this.phase % 1;
+ let value = phase < 0.5 ? 2 * phase : 1 - 2 * (phase - 0.5);
+ return value * 2 - 1;
+ }
+}
+
+export class TwoPoleFilter {
+ s0 = 0;
+ s1 = 0;
+ update(s, cutoff, resonance = 0) {
+ // Out of bound values can produce NaNs
+ resonance = Math.max(resonance, 0);
+
+ cutoff = Math.min(cutoff, 20000);
+ const c = 2 * Math.sin(cutoff * PI_DIV_SR);
+
+ const r = Math.pow(0.5, (resonance + 0.125) / 0.125);
+ const mrc = 1 - r * c;
+
+ this.s0 = mrc * this.s0 - c * this.s1 + c * s; // bpf
+ this.s1 = mrc * this.s1 + c * this.s0; // lpf
+ return this.s1; // return lpf by default
+ }
+}
+
+class PulseOsc {
+ constructor(phase = 0) {
+ this.phase = phase;
+ }
+ saw(offset, dt) {
+ let phase = (this.phase + offset) % 1;
+ let p = polyBlep(phase, dt);
+ return 2 * phase - 1 - p;
+ }
+ update(freq, pw = 0.5) {
+ const dt = freq / SAMPLE_RATE;
+ let pulse = this.saw(0, dt) - this.saw(pw, dt);
+ this.phase = (this.phase + dt) % 1;
+ return pulse + pw * 2 - 1;
+ }
+}
+
+// non bandlimited (has aliasing)
+export class PulzeOsc {
+ phase = 0;
+ update(freq, duty = 0.5) {
+ this.phase += ISR * freq;
+ let cyclePos = this.phase % 1;
+ return cyclePos < duty ? 1 : -1;
+ }
+}
+
+export class Dust {
+ update = (density) => (Math.random() < density * ISR ? Math.random() : 0);
+}
+
+export class WhiteNoise {
+ update() {
+ return Math.random() * 2 - 1;
+ }
+}
+
+export class BrownNoise {
+ constructor() {
+ this.out = 0;
+ }
+ update() {
+ let white = Math.random() * 2 - 1;
+ this.out = (this.out + 0.02 * white) / 1.02;
+ return this.out;
+ }
+}
+
+export class PinkNoise {
+ constructor() {
+ this.b0 = 0;
+ this.b1 = 0;
+ this.b2 = 0;
+ this.b3 = 0;
+ this.b4 = 0;
+ this.b5 = 0;
+ this.b6 = 0;
+ }
+
+ update() {
+ const white = Math.random() * 2 - 1;
+
+ this.b0 = 0.99886 * this.b0 + white * 0.0555179;
+ this.b1 = 0.99332 * this.b1 + white * 0.0750759;
+ this.b2 = 0.969 * this.b2 + white * 0.153852;
+ this.b3 = 0.8665 * this.b3 + white * 0.3104856;
+ this.b4 = 0.55 * this.b4 + white * 0.5329522;
+ this.b5 = -0.7616 * this.b5 - white * 0.016898;
+
+ const pink = this.b0 + this.b1 + this.b2 + this.b3 + this.b4 + this.b5 + this.b6 + white * 0.5362;
+ this.b6 = white * 0.115926;
+
+ return pink * 0.11;
+ }
+}
+
+export class Impulse {
+ phase = 1;
+ update(freq) {
+ this.phase += ISR * freq;
+ let v = this.phase >= 1 ? 1 : 0;
+ this.phase = this.phase % 1;
+ return v;
+ }
+}
+
+export class ClockDiv {
+ inSgn = true;
+ outSgn = true;
+ clockCnt = 0;
+ update(clock, factor) {
+ let curSgn = clock > 0;
+ if (this.inSgn != curSgn) {
+ this.clockCnt++;
+ if (this.clockCnt >= factor) {
+ this.clockCnt = 0;
+ this.outSgn = !this.outSgn;
+ }
+ }
+
+ this.inSgn = curSgn;
+ return this.outSgn ? 1 : -1;
+ }
+}
+
+export class Hold {
+ value = 0;
+ trigSgn = false;
+ update(input, trig) {
+ if (!this.trigSgn && trig > 0) this.value = input;
+ this.trigSgn = trig > 0;
+ return this.value;
+ }
+}
+
+function lerp(x, y0, y1, exponent = 1) {
+ if (x <= 0) return y0;
+ if (x >= 1) return y1;
+
+ let curvedX;
+
+ if (exponent === 0) {
+ curvedX = x; // linear
+ } else if (exponent > 0) {
+ curvedX = Math.pow(x, exponent); // ease-in
+ } else {
+ curvedX = 1 - Math.pow(1 - x, -exponent); // ease-out
+ }
+
+ return y0 + (y1 - y0) * curvedX;
+}
+
+export class ADSR {
+ constructor(props = {}) {
+ this.state = 'off';
+ this.startTime = 0;
+ this.startVal = 0;
+ this.decayCurve = props.decayCurve ?? 1;
+ }
+
+ update(curTime, gate, attack, decay, susVal, release) {
+ switch (this.state) {
+ case 'off': {
+ if (gate > 0) {
+ this.state = 'attack';
+ this.startTime = curTime;
+ this.startVal = 0;
+ }
+ return 0;
+ }
+ case 'attack': {
+ let time = curTime - this.startTime;
+ if (time > attack) {
+ this.state = 'decay';
+ this.startTime = curTime;
+ return 1;
+ }
+ return lerp(time / attack, this.startVal, 1, 1);
+ }
+ case 'decay': {
+ let time = curTime - this.startTime;
+ let curVal = lerp(time / decay, 1, susVal, -this.decayCurve);
+ if (gate <= 0) {
+ this.state = 'release';
+ this.startTime = curTime;
+ this.startVal = curVal;
+ return curVal;
+ }
+ if (time > decay) {
+ this.state = 'sustain';
+ this.startTime = curTime;
+ return susVal;
+ }
+ return curVal;
+ }
+ case 'sustain': {
+ if (gate <= 0) {
+ this.state = 'release';
+ this.startTime = curTime;
+ this.startVal = susVal;
+ }
+ return susVal;
+ }
+ case 'release': {
+ let time = curTime - this.startTime;
+
+ if (time > release) {
+ this.state = 'off';
+ return 0;
+ }
+ let curVal = lerp(time / release, this.startVal, 0, -this.decayCurve);
+ if (gate > 0) {
+ this.state = 'attack';
+ this.startTime = curTime;
+ this.startVal = curVal;
+ }
+ return curVal;
+ }
+ }
+ throw 'invalid envelope state';
+ }
+}
+
+/*
+ impulse(1).ad(.1).mul(sine(200))
+.add(x=>x.delay(.1).mul(.8))
+.out()*/
+const MAX_DELAY_TIME = 10;
+export class PitchDelay {
+ lpf = new TwoPoleFilter();
+ constructor(_props = {}) {
+ this.buffer = new Float32Array(MAX_DELAY_TIME * SAMPLE_RATE);
+ this.writeIdx = 0;
+ this.readIdx = 0;
+ this.numSamples = 0;
+ }
+ write(s, delayTime) {
+ // Calculate how far in the past to read
+ this.numSamples = Math.min(Math.floor(SAMPLE_RATE * delayTime), this.buffer.length - 1);
+ this.writeIdx = (this.writeIdx + 1) % this.numSamples;
+ this.buffer[this.writeIdx] = s;
+ this.readIdx = this.writeIdx - this.numSamples + 1;
+
+ // If past the start of the buffer, wrap around (Q: is this possible?)
+ if (this.readIdx < 0) this.readIdx += this.numSamples;
+ }
+ update(input, delayTime, speed = 1) {
+ this.write(input, delayTime);
+ let index = this.readIdx;
+ if (speed < 0) {
+ index = this.numSamples - Math.floor(Math.abs(this.readIdx * speed) % this.numSamples);
+ } else {
+ index = Math.floor(this.readIdx * speed) % this.numSamples;
+ }
+ const s = this.lpf.update(this.buffer[index], 0.9, 0);
+
+ return s;
+ }
+}
+
+export class Delay {
+ writeIdx = 0;
+ readIdx = 0;
+ buffer = new Float32Array(MAX_DELAY_TIME * SAMPLE_RATE); //.fill(0)
+ write(s, delayTime) {
+ this.writeIdx = (this.writeIdx + 1) % this.buffer.length;
+ this.buffer[this.writeIdx] = s;
+ // Calculate how far in the past to read
+ let numSamples = Math.min(Math.floor(SAMPLE_RATE * delayTime), this.buffer.length - 1);
+ this.readIdx = this.writeIdx - numSamples;
+ // If past the start of the buffer, wrap around
+ if (this.readIdx < 0) this.readIdx += this.buffer.length;
+ }
+ update(input, delayTime) {
+ this.write(input, delayTime);
+ return this.buffer[this.readIdx];
+ }
+}
+//TODO: Figure out why clicking at the start off the buffer
+export class Chorus {
+ delay = new Delay();
+ modulator = new TriOsc();
+ update(input, mix, delayTime, modulationFreq, modulationDepth) {
+ const m = this.modulator.update(modulationFreq) * modulationDepth;
+ const c = this.delay.update(input, delayTime * (1 + m));
+ return crossfade(input, c, mix);
+ }
+}
+
+export class Fold {
+ update(input = 0, rate = 0) {
+ if (rate < 0) rate = 0;
+ rate = rate + 1;
+ input = input * rate;
+ return 4 * (Math.abs(0.25 * input + 0.25 - Math.round(0.25 * input + 0.25)) - 0.25);
+ }
+}
+
+export class Lag {
+ lagUnit = 4410;
+ s = 0;
+ update(input, rate) {
+ // Remap so the useful range is around [0, 1]
+ rate = rate * this.lagUnit;
+ if (rate < 1) rate = 1;
+ this.s += (1 / rate) * (input - this.s);
+ return this.s;
+ }
+}
+
+export class Slew {
+ last = 0;
+ update(input, up, dn) {
+ const upStep = up * ISR;
+ const downStep = dn * ISR;
+ let delta = input - this.last;
+ if (delta > upStep) {
+ delta = upStep;
+ } else if (delta < -downStep) {
+ delta = -downStep;
+ }
+ this.last += delta;
+ return this.last;
+ }
+}
+
+// overdrive style distortion (adapted from noisecraft) currently unused
+export function applyDistortion(x, amount) {
+ amount = Math.min(Math.max(amount, 0), 1);
+ amount -= 0.01;
+ var k = (2 * amount) / (1 - amount);
+ var y = ((1 + k) * x) / (1 + k * Math.abs(x));
+ return y;
+}
+
+export class Sequence {
+ clockSgn = true;
+ step = 0;
+ first = true;
+ update(clock, ...ins) {
+ if (!this.clockSgn && clock > 0) {
+ this.step = (this.step + 1) % ins.length;
+ this.clockSgn = clock > 0;
+ return 0; // set first sample to zero to retrigger gates on step change...
+ }
+ this.clockSgn = clock > 0;
+ return ins[this.step];
+ }
+}
+
+// sample rate bit crusher
+export class Coarse {
+ hold = 0;
+ t = 0;
+ update(input, coarse) {
+ if (this.t++ % coarse === 0) {
+ this.t = 0;
+ this.hold = input;
+ }
+ return this.hold;
+ }
+}
+
+// amplitude bit crusher
+export class Crush {
+ update(input, crush) {
+ crush = Math.max(1, crush);
+ const x = Math.pow(2, crush - 1);
+ return Math.round(input * x) / x;
+ }
+}
+
+// this is the distort from superdough
+export class Distort {
+ update(input, distort = 0, postgain = 1) {
+ postgain = Math.max(0.001, Math.min(1, postgain));
+ const shape = Math.expm1(distort);
+ return (((1 + shape) * input) / (1 + shape * Math.abs(input))) * postgain;
+ }
+}
+// distortion could be expressed as a function, because it's stateless
+
+export class BufferPlayer {
+ static samples = new Map(); // string -> { channels, sampleRate }
+ buffer; // Float32Array
+ sampleRate;
+ pos = 0;
+ sampleFreq = note2freq();
+ constructor(buffer, sampleRate, normalize) {
+ this.buffer = buffer;
+ this.sampleRate = sampleRate;
+ this.duration = this.buffer.length / this.sampleRate;
+ this.speed = SAMPLE_RATE / this.sampleRate;
+ if (normalize) {
+ // this will make the buffer last 1s if freq = sampleFreq
+ // it's useful to loop samples (e.g. fit function)
+ this.speed *= this.duration;
+ }
+ }
+ update(freq) {
+ if (this.pos >= this.buffer.length) {
+ return 0;
+ }
+ const speed = (freq / this.sampleFreq) * this.speed;
+ let s = this.buffer[Math.floor(this.pos)];
+ this.pos = this.pos + speed;
+ return s;
+ }
+}
+
+export function _rangex(sig, min, max) {
+ let logmin = Math.log(min);
+ let range = Math.log(max) - logmin;
+ const unipolar = (sig + 1) / 2;
+ return Math.exp(unipolar * range + logmin);
+}
+
+// duplicate
+export const getADSR = (params, curve = 'linear', defaultValues) => {
+ const envmin = curve === 'exponential' ? 0.001 : 0.001;
+ const releaseMin = 0.01;
+ const envmax = 1;
+ const [a, d, s, r] = params;
+ if (a == null && d == null && s == null && r == null) {
+ return defaultValues ?? [envmin, envmin, envmax, releaseMin];
+ }
+ const sustain = s != null ? s : (a != null && d == null) || (a == null && d == null) ? envmax : envmin;
+ return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)];
+};
+
+let shapes = {
+ sine: SineOsc,
+ saw: SawOsc,
+ zaw: ZawOsc,
+ sawtooth: SawOsc,
+ zawtooth: ZawOsc,
+ supersaw: SupersawOsc,
+ tri: TriOsc,
+ triangle: TriOsc,
+ pulse: PulseOsc,
+ square: PulseOsc,
+ pulze: PulzeOsc,
+ dust: Dust,
+ crackle: Dust,
+ impulse: Impulse,
+ white: WhiteNoise,
+ brown: BrownNoise,
+ pink: PinkNoise,
+};
+
+const defaultDefaultValues = {
+ chorus: 0,
+ note: 48,
+ s: 'triangle',
+ bank: '',
+ gain: 1,
+ postgain: 1,
+ velocity: 1,
+ density: '.03',
+ ftype: '12db',
+ fanchor: 0,
+ //resonance: 1, // superdough resonance is scaled differently
+ resonance: 0,
+ //hresonance: 1, // superdough resonance is scaled differently
+ hresonance: 0,
+ // bandq: 1, // superdough resonance is scaled differently
+ bandq: 0,
+ channels: [1, 2],
+ phaserdepth: 0.75,
+ shapevol: 1,
+ distortvol: 1,
+ delay: 0,
+ byteBeatExpression: '0',
+ delayfeedback: 0.5,
+ delayspeed: 1,
+ delaytime: 0.25,
+ orbit: 1,
+ i: 1,
+ fft: 8,
+ z: 'triangle',
+ pan: 0.5,
+ fmh: 1,
+ fmenv: 0, // differs from superdough
+ speed: 1,
+ pw: 0.5,
+};
+
+let getDefaultValue = (key) => defaultDefaultValues[key];
+
+const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
+const accs = { '#': 1, b: -1, s: 1, f: -1 };
+const note2midi = (note, defaultOctave = 3) => {
+ let [pc, acc = '', oct = ''] =
+ String(note)
+ .match(/^([a-gA-G])([#bsf]*)([0-9]*)$/)
+ ?.slice(1) || [];
+ if (!pc) {
+ throw new Error('not a note: "' + note + '"');
+ }
+ const chroma = chromas[pc.toLowerCase()];
+ const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0;
+ const octave = Number(oct || defaultOctave);
+ return (octave + 1) * 12 + chroma + offset;
+};
+const midi2freq = (midi) => Math.pow(2, (midi - 69) / 12) * 440;
+const note2freq = (note) => {
+ note = note || getDefaultValue('note');
+ if (typeof note === 'string') {
+ note = note2midi(note, 3); // e.g. c3 => 48
+ }
+ return midi2freq(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) {
+ // 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');
+ $.gain = applyGainCurve($.gain ?? getDefaultValue('gain'));
+ $.velocity = applyGainCurve($.velocity ?? getDefaultValue('velocity'));
+ $.postgain = applyGainCurve($.postgain ?? getDefaultValue('postgain'));
+ $.density = $.density ?? getDefaultValue('density');
+ $.fanchor = $.fanchor ?? getDefaultValue('fanchor');
+ $.drive = $.drive ?? 0.69;
+ $.phaserdepth = $.phaserdepth ?? getDefaultValue('phaserdepth');
+ $.shapevol = applyGainCurve($.shapevol ?? getDefaultValue('shapevol'));
+ $.distortvol = applyGainCurve($.distortvol ?? getDefaultValue('distortvol'));
+ $.i = $.i ?? getDefaultValue('i');
+ $.chorus = $.chorus ?? getDefaultValue('chorus');
+ $.fft = $.fft ?? getDefaultValue('fft');
+ $.pan = $.pan ?? getDefaultValue('pan');
+ $.orbit = $.orbit ?? getDefaultValue('orbit');
+ $.fmenv = $.fmenv ?? getDefaultValue('fmenv');
+ $.resonance = $.resonance ?? getDefaultValue('resonance');
+ $.hresonance = $.hresonance ?? getDefaultValue('hresonance');
+ $.bandq = $.bandq ?? getDefaultValue('bandq');
+ $.speed = $.speed ?? getDefaultValue('speed');
+ $.pw = $.pw ?? getDefaultValue('pw');
+
+ [$.attack, $.decay, $.sustain, $.release] = getADSR([$.attack, $.decay, $.sustain, $.release]);
+
+ $._holdEnd = $._begin + $._duration; // needed for gate
+ $._end = $._holdEnd + $.release + 0.01; // needed for despawn
+
+ if ($.fmi && ($.s === 'saw' || $.s === 'sawtooth')) {
+ $.s = 'zaw'; // polyblepped saw when fm is applied
+ }
+
+ if (shapes[$.s]) {
+ const SourceClass = shapes[$.s];
+ $._sound = new SourceClass();
+ $._channels = 1;
+ } else if (BufferPlayer.samples.has($.s)) {
+ const sample = BufferPlayer.samples.get($.s);
+ $._buffers = [];
+ $._channels = sample.channels.length;
+ for (let i = 0; i < $._channels; i++) {
+ $._buffers.push(new BufferPlayer(sample.channels[i], sample.sampleRate, $.unit === 'c')); // tbd unit === 'c'
+ }
+ } else {
+ console.warn('sound not loaded', $.s);
+ }
+
+ if ($.penv) {
+ $._penv = new ADSR({ decayCurve: 4 });
+ [$.pattack, $.pdecay, $.psustain, $.prelease] = getADSR([$.pattack, $.pdecay, $.psustain, $.prelease]);
+ }
+
+ if ($.vib) {
+ $._vib = new SineOsc();
+ $.vibmod = $.vibmod ?? getDefaultValue('vibmod');
+ }
+
+ if ($.fmi) {
+ $._fm = new SineOsc();
+ $.fmh = $.fmh ?? getDefaultValue('fmh');
+ if ($.fmenv) {
+ $._fmenv = new ADSR({ decayCurve: 2 });
+ [$.fmattack, $.fmdecay, $.fmsustain, $.fmrelease] = getADSR([$.fmattack, $.fmdecay, $.fmsustain, $.fmrelease]);
+ }
+ }
+
+ // gain envelope
+ $._adsr = new ADSR({ decayCurve: 2 });
+ // delay
+ $.delay = applyGainCurve($.delay ?? getDefaultValue('delay'));
+ $.delayfeedback = $.delayfeedback ?? getDefaultValue('delayfeedback');
+ $.delayspeed = $.delayspeed ?? getDefaultValue('delayspeed');
+ $.delaytime = $.delaytime ?? getDefaultValue('delaytime');
+
+ // filter setup
+ if ($.lpenv) {
+ $._lpenv = new ADSR({ decayCurve: 4 });
+ [$.lpattack, $.lpdecay, $.lpsustain, $.lprelease] = getADSR([$.lpattack, $.lpdecay, $.lpsustain, $.lprelease]);
+ }
+ if ($.hpenv) {
+ $._hpenv = new ADSR({ decayCurve: 4 });
+ [$.hpattack, $.hpdecay, $.hpsustain, $.hprelease] = getADSR([$.hpattack, $.hpdecay, $.hpsustain, $.hprelease]);
+ }
+ if ($.bpenv) {
+ $._bpenv = new ADSR({ decayCurve: 4 });
+ [$.bpattack, $.bpdecay, $.bpsustain, $.bprelease] = getADSR([$.bpattack, $.bpdecay, $.bpsustain, $.bprelease]);
+ }
+
+ // channelwise effects setup
+ $._chorus = $.chorus ? [] : null;
+ $._lpf = $.cutoff ? [] : null;
+ $._hpf = $.hcutoff ? [] : null;
+ $._bpf = $.bandf ? [] : null;
+ $._coarse = $.coarse ? [] : null;
+ $._crush = $.crush ? [] : null;
+ $._distort = $.distort ? [] : null;
+ for (let i = 0; i < this._channels; i++) {
+ $._lpf?.push(new TwoPoleFilter());
+ $._hpf?.push(new TwoPoleFilter());
+ $._bpf?.push(new TwoPoleFilter());
+ $._chorus?.push(new Chorus());
+ $._coarse?.push(new Coarse());
+ $._crush?.push(new Crush());
+ $._distort?.push(new Distort());
+ }
+ }
+ update(t) {
+ if (!this._sound && !this._buffers) {
+ return 0;
+ }
+ let gate = Number(t >= this._begin && t <= this._holdEnd);
+
+ let freq = this.freq * this.speed;
+
+ // frequency modulation
+ 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);
+ fmi = this.fmenv * env * fmi;
+ }
+ const modfreq = freq * this.fmh;
+ const modgain = modfreq * fmi;
+ freq = freq + this._fm.update(modfreq) * modgain;
+ }
+
+ // vibrato
+ if (this._vib && this.vibmod !== undefined) {
+ freq = freq * 2 ** ((this._vib.update(this.vib) * this.vibmod) / 12);
+ }
+
+ // pitch envelope
+ 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 (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 (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 (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);
+
+ // channelwise dsp
+ for (let i = 0; i < this._channels; i++) {
+ // sound source
+ if (this._sound && this.s === 'pulse') {
+ this.out[i] = this._sound.update(freq, this.pw);
+ } else if (this._sound) {
+ this.out[i] = this._sound.update(freq);
+ } else if (this._buffers) {
+ this.out[i] = this._buffers[i].update(freq);
+ }
+ this.out[i] = this.out[i] * this.gain * this.velocity;
+ if (this._chorus) {
+ const c = this._chorus[i].update(this.out[i], this.chorus, 0.03 + 0.05 * i, 1, 0.11);
+ this.out[i] = c + this.out[i];
+ }
+
+ if (this._lpf) {
+ this._lpf[i].update(this.out[i], lpf, this.resonance);
+ this.out[i] = this._lpf[i].s1;
+ }
+ if (this._hpf) {
+ this._hpf[i].update(this.out[i], hpf, this.hresonance);
+ this.out[i] = this.out[i] - this._hpf[i].s1;
+ }
+ if (this._bpf) {
+ this._bpf[i].update(this.out[i], bpf, this.bandq);
+ this.out[i] = this._bpf[i].s0;
+ }
+ if (this._coarse) {
+ this.out[i] = this._coarse[i].update(this.out[i], this.coarse);
+ }
+ if (this._crush) {
+ this.out[i] = this._crush[i].update(this.out[i], this.crush);
+ }
+ if (this._distort) {
+ this.out[i] = this._distort[i].update(this.out[i], this.distort, this.distortvol);
+ }
+ this.out[i] = this.out[i] * env;
+ this.out[i] = this.out[i] * this.postgain;
+ if (!this._buffers) {
+ this.out[i] = this.out[i] * 0.2; // turn down waveform
+ }
+ }
+ if (this._channels === 1) {
+ this.out[1] = this.out[0];
+ }
+ if (this.pan !== 0.5) {
+ const panpos = (this.pan * Math.PI) / 2;
+ this.out[0] = this.out[0] * Math.cos(panpos);
+ this.out[1] = this.out[1] * Math.sin(panpos);
+ }
+ }
+}
+
+// this class is the interface to the "outer world"
+// it handles spawning and despawning of DoughVoice's
+export class Dough {
+ voices = []; // DoughVoice[]
+ vid = 0;
+ q = [];
+ out = [0, 0];
+ delaysend = [0, 0];
+ delaytime = getDefaultValue('delaytime');
+ delayfeedback = getDefaultValue('delayfeedback');
+ delayspeed = getDefaultValue('delayspeed');
+ t = 0;
+ // sampleRate: number, currentTime: number (seconds)
+ constructor(sampleRate = 48000, currentTime = 0) {
+ this.sampleRate = sampleRate;
+ this.t = Math.floor(currentTime * sampleRate); // samples
+ // console.log('init dough', this.sampleRate, this.t);
+ this._delayL = new Delay();
+ this._delayR = new Delay();
+ }
+ loadSample(name, channels, sampleRate) {
+ BufferPlayer.samples.set(name, { channels, sampleRate });
+ }
+ scheduleSpawn(value) {
+ if (value._begin === undefined) {
+ throw new Error('[dough]: scheduleSpawn expected _begin to be set');
+ }
+ if (value._duration === undefined) {
+ throw new Error('[dough]: scheduleSpawn expected _duration to be set');
+ }
+ value.sampleRate = this.sampleRate;
+ // convert seconds to samples
+ const time = Math.floor(value._begin * this.sampleRate); // set from supradough.mjs
+ this.schedule({ time, type: 'spawn', arg: value });
+ }
+ spawn(value) {
+ value.id = this.vid++;
+ const voice = new DoughVoice(value);
+ this.voices.push(voice);
+ // console.log('spawn', voice.id, 'voices:', this.voices.length);
+ // schedule removal
+ const endTime = Math.ceil(voice._end * this.sampleRate);
+ this.schedule({ time: endTime /* + 48000 */, type: 'despawn', arg: voice.id });
+ }
+ despawn(vid) {
+ this.voices = this.voices.filter((v) => v.id !== vid);
+ // console.log('despawn', vid, 'voices:', this.voices.length);
+ }
+ // schedules a function call with a single argument
+ // msg = {time:number,type:string, arg: any}
+ // the Dough method "type" will be called with "arg" at "time"
+ schedule(msg) {
+ if (!this.q.length) {
+ // if empty, just push
+ this.q.push(msg);
+ return;
+ }
+ // not empty
+ // find index where msg.time fits in
+ let i = 0;
+ while (i < this.q.length && this.q[i].time < msg.time) {
+ i++;
+ }
+ // this ensures q stays sorted by time, so we only need to check q[0]
+ this.q.splice(i, 0, msg);
+ }
+ // maybe update should be called once per block instead for perf reasons?
+ update() {
+ // go over q
+ while (this.q.length > 0 && this.q[0].time <= this.t) {
+ // console.log('schedule', this.q[0]);
+ // trigger due messages. q is sorted, so we only need to check q[0]
+ this[this.q[0].type](this.q[0].arg); // type is expected to be a Dough method
+ this.q.shift();
+ }
+ // add active voices
+ this.out[0] = 0;
+ this.out[1] = 0;
+ for (let v = 0; v < this.voices.length; v++) {
+ this.voices[v].update(this.t / this.sampleRate);
+ this.out[0] += this.voices[v].out[0];
+ this.out[1] += this.voices[v].out[1];
+ if (this.voices[v].delay) {
+ this.delaysend[0] += this.voices[v].out[0] * this.voices[v].delay;
+ this.delaysend[1] += this.voices[v].out[1] * this.voices[v].delay;
+ this.delaytime = this.voices[v].delaytime; // we trust that these are initialized in the voice
+ this.delayspeed = this.voices[v].delayspeed; // we trust that these are initialized in the voice
+ this.delayfeedback = this.voices[v].delayfeedback;
+ }
+ }
+ // todo: how to change delaytime / delayfeedback from a voice?
+ 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;
+ this.out[1] += delayR;
+ this.t++;
+ }
+}
diff --git a/packages/supradough/index.mjs b/packages/supradough/index.mjs
new file mode 100644
index 000000000..cb132fb3f
--- /dev/null
+++ b/packages/supradough/index.mjs
@@ -0,0 +1,5 @@
+// import _workletUrl from './dough-worklet.mjs?url'; // only for dev (breaks for production build)
+import _workletUrl from './dough-worklet.mjs?audioworklet'; // only for prod (breaks in development?!)
+
+export * from './dough.mjs';
+export const workletUrl = _workletUrl;
diff --git a/packages/supradough/package.json b/packages/supradough/package.json
new file mode 100644
index 000000000..7e465c0a9
--- /dev/null
+++ b/packages/supradough/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "supradough",
+ "version": "1.2.3",
+ "description": "platform agnostic synth and sampler intended for live coding. a reimplementation of superdough.",
+ "main": "index.mjs",
+ "type": "module",
+ "publishConfig": {
+ "main": "dist/index.mjs"
+ },
+ "scripts": {
+ "build": "vite build",
+ "prepublishOnly": "npm run build"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/tidalcycles/strudel.git"
+ },
+ "keywords": [
+ "tidalcycles",
+ "strudel",
+ "pattern",
+ "livecoding",
+ "algorave"
+ ],
+ "author": "Felix Roos ",
+ "license": "AGPL-3.0-or-later",
+ "bugs": {
+ "url": "https://github.com/tidalcycles/strudel/issues"
+ },
+ "homepage": "https://github.com/tidalcycles/strudel#readme",
+ "devDependencies": {
+ "vite": "^6.0.11",
+ "vite-plugin-bundle-audioworklet": "workspace:*",
+ "wav-encoder": "^1.3.0"
+ },
+ "dependencies": {}
+}
diff --git a/packages/tonal/package.json b/packages/tonal/package.json
index de02e4b1f..1461bdc8e 100644
--- a/packages/tonal/package.json
+++ b/packages/tonal/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/tonal",
- "version": "1.2.3",
+ "version": "1.2.4",
"description": "Tonal functions for strudel",
"main": "index.mjs",
"publishConfig": {
diff --git a/packages/tonal/test/tonal.test.mjs b/packages/tonal/test/tonal.test.mjs
index cd8b3c88b..5f61b05a9 100644
--- a/packages/tonal/test/tonal.test.mjs
+++ b/packages/tonal/test/tonal.test.mjs
@@ -7,80 +7,130 @@ This program is free software: you can redistribute it and/or modify it under th
// import { strict as assert } from 'assert';
import '../tonal.mjs'; // need to import this to add prototypes
-import { pure, n, seq, note } from '@strudel/core';
+import { pure, n, seq, note, noteToMidi } from '@strudel/core';
import { describe, it, expect } from 'vitest';
import { mini } from '../../mini/mini.mjs';
describe('tonal', () => {
- it('Should run tonal functions ', () => {
- expect(pure('c3').scale('C major').scaleTranspose(1).firstCycleValues).toEqual(['D3']);
+ describe('scaleTranspose', () => {
+ it('transposes notes by scale degrees', () => {
+ expect(pure('c3').scale('C major').scaleTranspose(1).firstCycleValues).toEqual(['D3']);
+ });
});
- it('scale with plain values', () => {
- expect(
- seq(0, 1, 2)
- .scale('C major')
- .note()
- .firstCycleValues.map((h) => h.note),
- ).toEqual(['C3', 'D3', 'E3']);
+ describe('scale', () => {
+ it('converts plain values', () => {
+ expect(
+ seq(0, 1, 2)
+ .scale('C major')
+ .note()
+ .firstCycleValues.map((h) => h.note),
+ ).toEqual(['C3', 'D3', 'E3']);
+ });
+ it('converts n values', () => {
+ expect(
+ n(seq(0, 1, 2))
+ .scale('C major')
+ .firstCycleValues.map((h) => h.note),
+ ).toEqual(['C3', 'D3', 'E3']);
+ });
+ it('converts n values (mini notation)', () => {
+ expect(
+ n(seq(0, 1, 2))
+ .scale('C:major')
+ .firstCycleValues.map((h) => h.note),
+ ).toEqual(['C3', 'D3', 'E3']);
+ });
+ it('converts n values (no tonic)', () => {
+ expect(
+ n(seq(0, 1, 2))
+ .scale('major')
+ .firstCycleValues.map((h) => h.note),
+ ).toEqual(['C3', 'D3', 'E3']);
+ });
+ it('converts n values (explicit mini notation)', () => {
+ expect(
+ n(seq(0, 1, 2))
+ .scale(mini('C:major'))
+ .firstCycleValues.map((h) => h.note),
+ ).toEqual(['C3', 'D3', 'E3']);
+ });
+ it('converts decorated n values', () => {
+ expect(
+ n(seq('0b', '1#', '-2', '3##', '4bb'))
+ .scale('C major')
+ .firstCycleValues.map((h) => h.note),
+ ).toEqual(['B2', 'Eb3', 'A2', 'G3', 'F3']);
+ });
+ it('produces silence for mixed sharps and flats', () => {
+ expect(
+ n(seq('0b#', '1#b', '2#b#'))
+ .scale('C major')
+ .firstCycleValues.map((h) => h.note),
+ ).toEqual([]);
+ });
+ it('snaps notes (upwards) to scale', () => {
+ const inputNotes = ['Cb', 'Eb', 'G', 'A#', 'Bb'];
+ const expectedNotes = ['B2', 'E3', 'G3', 'B3', 'B3'];
+
+ expect(
+ note(seq(inputNotes))
+ .scale('C major')
+ .firstCycleValues.map((h) => h.note),
+ ).toEqual(expectedNotes);
+ });
+ it('snaps notes to the correct octave', () => {
+ const inputNotes = ['Cb0', 'Eb4', 'G1', 'A#19', 'Bb8'];
+ const expectedNotes = ['B#-1', 'D#4', 'G#1', 'A#19', 'A#8'];
+
+ expect(
+ note(seq(inputNotes))
+ .scale('A# minor') // A#, B#, C#, D#, E#, F#, G#
+ .firstCycleValues.map((h) => h.note),
+ ).toEqual(expectedNotes);
+ });
+ it('handles scale names provided with colons', () => {
+ const inputNotes = ['Cb', 'E', 'G', 'A#', 'Bb'];
+ const expectedNotes = ['A#2', 'D#3', 'G#3', 'A#3', 'A#3'];
+
+ expect(
+ note(seq(inputNotes))
+ .scale('F#:pentatonic') // F#, G#, A#, C#, and D#
+ .firstCycleValues.map((h) => h.note),
+ ).toEqual(expectedNotes);
+ });
});
- it('scale with n values', () => {
- expect(
- n(seq(0, 1, 2))
- .scale('C major')
- .firstCycleValues.map((h) => h.note),
- ).toEqual(['C3', 'D3', 'E3']);
- });
- it('scale with colon', () => {
- expect(
- n(seq(0, 1, 2))
- .scale('C:major')
- .firstCycleValues.map((h) => h.note),
- ).toEqual(['C3', 'D3', 'E3']);
- });
- it('scale without tonic', () => {
- expect(
- n(seq(0, 1, 2))
- .scale('major')
- .firstCycleValues.map((h) => h.note),
- ).toEqual(['C3', 'D3', 'E3']);
- });
- it('scale with mininotation colon', () => {
- expect(
- n(seq(0, 1, 2))
- .scale(mini('C:major'))
- .firstCycleValues.map((h) => h.note),
- ).toEqual(['C3', 'D3', 'E3']);
- });
- it('transposes note numbers with interval numbers', () => {
- expect(
- note(seq(40, 40, 40))
- .transpose(0, 1, 2)
- .firstCycleValues.map((h) => h.note),
- ).toEqual([40, 41, 42]);
- expect(seq(40, 40, 40).transpose(0, 1, 2).firstCycleValues).toEqual([40, 41, 42]);
- });
- it('transposes note numbers with interval strings', () => {
- expect(
- note(seq(40, 40, 40))
- .transpose('1P', '2M', '3m')
- .firstCycleValues.map((h) => h.note),
- ).toEqual([40, 42, 43]);
- expect(seq(40, 40, 40).transpose('1P', '2M', '3m').firstCycleValues).toEqual([40, 42, 43]);
- });
- it('transposes note strings with interval numbers', () => {
- expect(
- note(seq('c', 'c', 'c'))
- .transpose(0, 1, 2)
- .firstCycleValues.map((h) => h.note),
- ).toEqual(['C', 'Db', 'D']);
- expect(seq('c', 'c', 'c').transpose(0, 1, 2).firstCycleValues).toEqual(['C', 'Db', 'D']);
- });
- it('transposes note strings with interval strings', () => {
- expect(
- note(seq('c', 'c', 'c'))
- .transpose('1P', '2M', '3m')
- .firstCycleValues.map((h) => h.note),
- ).toEqual(['C', 'D', 'Eb']);
- expect(seq('c', 'c', 'c').transpose('1P', '2M', '3m').firstCycleValues).toEqual(['C', 'D', 'Eb']);
+ describe('transpose', () => {
+ it('transposes note numbers with interval numbers', () => {
+ expect(
+ note(seq(40, 40, 40))
+ .transpose(0, 1, 2)
+ .firstCycleValues.map((h) => h.note),
+ ).toEqual([40, 41, 42]);
+ expect(seq(40, 40, 40).transpose(0, 1, 2).firstCycleValues).toEqual([40, 41, 42]);
+ });
+ it('transposes note numbers with interval strings', () => {
+ expect(
+ note(seq(40, 40, 40))
+ .transpose('1P', '2M', '3m')
+ .firstCycleValues.map((h) => h.note),
+ ).toEqual([40, 42, 43]);
+ expect(seq(40, 40, 40).transpose('1P', '2M', '3m').firstCycleValues).toEqual([40, 42, 43]);
+ });
+ it('transposes note strings with interval numbers', () => {
+ expect(
+ note(seq('c', 'c', 'c'))
+ .transpose(0, 1, 2)
+ .firstCycleValues.map((h) => h.note),
+ ).toEqual(['C', 'Db', 'D']);
+ expect(seq('c', 'c', 'c').transpose(0, 1, 2).firstCycleValues).toEqual(['C', 'Db', 'D']);
+ });
+ it('transposes note strings with interval strings', () => {
+ expect(
+ note(seq('c', 'c', 'c'))
+ .transpose('1P', '2M', '3m')
+ .firstCycleValues.map((h) => h.note),
+ ).toEqual(['C', 'D', 'Eb']);
+ expect(seq('c', 'c', 'c').transpose('1P', '2M', '3m').firstCycleValues).toEqual(['C', 'D', 'Eb']);
+ });
});
});
diff --git a/packages/tonal/tonal.mjs b/packages/tonal/tonal.mjs
index c4dd54880..ae75ab690 100644
--- a/packages/tonal/tonal.mjs
+++ b/packages/tonal/tonal.mjs
@@ -6,19 +6,28 @@ This program is free software: you can redistribute it and/or modify it under th
import { Note, Interval, Scale } from '@tonaljs/tonal';
import { register, _mod, silence, logger, pure, isNote } from '@strudel/core';
-import { stepInNamedScale } from './tonleiter.mjs';
+import { stepInNamedScale, nearestNumberIndex } from './tonleiter.mjs';
+import { noteToMidi } from '../core/util.mjs';
const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P';
-function scaleStep(step, scale) {
- scale = scale.replaceAll(':', ' ');
- step = Math.ceil(step);
- let { intervals, tonic, empty } = Scale.get(scale);
- if ((empty && isNote(scale)) || (empty && !tonic)) {
- throw new Error(`incomplete scale. Make sure to use ":" instead of spaces, example: .scale("C:major")`);
+function getScale(scaleName) {
+ scaleName = scaleName.replaceAll(':', ' ');
+ const scale = Scale.get(scaleName);
+ const { tonic, empty } = scale;
+ if ((empty && isNote(scaleName)) || (empty && !tonic)) {
+ throw new Error(
+ `Scale name ${scaleName} is incomplete. Make sure to use ":" instead of spaces, example: .scale("C:major")`,
+ );
} else if (empty) {
- throw new Error(`invalid scale "${scale}"`);
+ throw new Error(`Invalid scale name "${scaleName}"`);
}
+ return scale;
+}
+
+function scaleStep(step, scale) {
+ step = Math.ceil(step);
+ let { intervals, tonic } = getScale(scale);
tonic = tonic || 'C';
const { pc, oct = 3 } = Note.get(tonic);
const octaveOffset = Math.floor(step / intervals.length);
@@ -30,8 +39,7 @@ function scaleStep(step, scale) {
// transpose note inside scale by offset steps
// function scaleOffset(scale: string, offset: number, note: string) {
function scaleOffset(scale, offset, note) {
- let [tonic, scaleName] = Scale.tokenize(scale);
- let { notes } = Scale.get(`${tonic} ${scaleName}`);
+ let { notes } = getScale(scale);
notes = notes.map((note) => Note.get(note).pc); // use only pc!
offset = Number(offset);
if (isNaN(offset)) {
@@ -120,10 +128,7 @@ export const { transpose, trans } = register(['transpose', 'trans'], function tr
const interval = !isNaN(Number(intervalOrSemitones))
? Interval.fromSemitones(intervalOrSemitones)
: String(intervalOrSemitones);
- // TODO: move simplify to player to preserve enharmonics
- // tone.js doesn't understand multiple sharps flats e.g. F##3 has to be turned into G3
- // TODO: check if this is still relevant..
- const targetNote = Note.simplify(Note.transpose(note, interval));
+ const targetNote = Note.transpose(note, interval);
if (typeof hap.value === 'object') {
return hap.withValue(() => ({ ...hap.value, note: targetNote }));
}
@@ -171,8 +176,59 @@ export const { scaleTranspose, scaleTrans, strans } = register(
},
);
+// Converts a step value, which is a number optionally decorated with sharps and flats,
+// to a number and an `offset` number of semitones
+function _convertStepToNumberAndOffset(step) {
+ let asNumber = Number(step);
+ let offset = 0;
+ if (isNaN(asNumber)) {
+ step = String(step);
+ // Check to see if the step matches the expected format:
+ // - A number (possibly negative)
+ // - Some number of sharps or flats (but not both)
+ const match = /^(-?\d+)(#+|b+)?$/.exec(step);
+
+ if (!match) {
+ throw new Error(`invalid scale step "${step}", expected number or integer with optional # b suffixes`);
+ }
+ asNumber = Number(match[1]);
+ // These decorations will determine the semitone offset based on the number of
+ // sharps or flats
+ const decorations = match[2] || '';
+ offset = decorations[0] === '#' ? decorations.length : -decorations.length;
+ }
+ return [asNumber, offset];
+}
+
+let scaleToMidisAndNotes = {};
+// Finds the nearest scale note to `note`
+function _getNearestScaleNote(scaleName, note, preferHigher = true) {
+ let noteMidi = typeof note === 'string' ? noteToMidi(note) : note;
+ if (scaleToMidisAndNotes[scaleName] === undefined) {
+ const { intervals, tonic } = getScale(scaleName);
+ const { pc } = Note.get(tonic);
+ const expandedIntervals = intervals.concat('8P'); // add the octave for wrapping
+ const sNotes = expandedIntervals.map((interval) => Note.transpose(pc + '0', interval));
+ const sMidi = sNotes.map(noteToMidi);
+ // Cache
+ scaleToMidisAndNotes[scaleName] = [sMidi, sNotes];
+ }
+ const [scaleMidis, scaleNotes] = scaleToMidisAndNotes[scaleName];
+ const rootMidi = scaleMidis[0];
+ const octaveDiff = Math.floor((noteMidi - rootMidi) / 12);
+ const alignedMidis = scaleMidis.map((m) => m + 12 * octaveDiff);
+ const noteIdx = nearestNumberIndex(noteMidi, alignedMidis, preferHigher);
+ const noteMatch = scaleNotes[noteIdx];
+ return Note.transpose(noteMatch, Interval.fromSemitones(12 * octaveDiff));
+}
+
/**
- * Turns numbers into notes in the scale (zero indexed). Also sets scale for other scale operations, like {@link Pattern#scaleTranspose}.
+ * Turns numbers into notes in the scale (zero indexed) or quantizes notes to a scale.
+ *
+ * When describing notes via numbers, note that negative numbers can be used to wrap backwards
+ * in the scale as well as sharps or flats (but not both) to produce notes outside of the scale.
+ *
+ * Also sets scale for other scale operations, like {@link Pattern#scaleTranspose}.
*
* A scale consists of a root note (e.g. `c4`, `c`, `f#`, `bb4`) followed by semicolon (':') and then a [scale type](https://github.com/tonaljs/tonal/blob/main/packages/scale-type/data.ts).
*
@@ -191,6 +247,12 @@ export const { scaleTranspose, scaleTrans, strans } = register(
* n(rand.range(0,12).segment(8))
* .scale("C:ritusen")
* .s("piano")
+ * @example
+ * n("<[0,7b] [-4# -4] [-2,7##] 4 [0,7] [-4# -4b] [-2,7###] 4b>*4")
+ * .scale("C:/2")
+ * .s("piano")
+ * @example
+ * note("C1*16").transpose(irand(36)).scale('Cb2 major').scaleTranspose(3)
*/
export const scale = register(
@@ -204,49 +266,35 @@ export const scale = register(
pat
.fmap((value) => {
const isObject = typeof value === 'object';
- let step = isObject ? value.n : value;
- if (isObject) {
+ // The case where the note has been defined via `n` or `pure`
+ if (!isObject || (isObject && ('n' in value || 'value' in value))) {
+ const step = isObject ? (value.n ?? value.value) : value;
delete value.n; // remove n so it won't cause trouble
- }
- if (isNote(step)) {
- // legacy..
- return pure(step);
- }
- let asNumber = Number(step);
- let semitones = 0;
- if (isNaN(asNumber)) {
- step = String(step);
- if (!/^[-+]?\d+(#*|b*){1}$/.test(step)) {
- logger(
- `[tonal] invalid scale step "${step}", expected number or integer with optional # b suffixes`,
- 'error',
- );
+ if (isNote(step)) {
+ // legacy..
+ return pure(step);
+ }
+ try {
+ const [number, offset] = _convertStepToNumberAndOffset(step);
+ let note;
+ if (isObject && value.anchor) {
+ note = stepInNamedScale(number, scale, value.anchor);
+ } else {
+ note = scaleStep(number, scale);
+ }
+ if (offset != 0) note = Note.transpose(note, Interval.fromSemitones(offset));
+ value = pure(isObject ? { ...value, note } : note);
+ } catch (err) {
+ logger(`[tonal] ${err.message}`, 'error');
return silence;
}
- const isharp = step.indexOf('#');
- if (isharp >= 0) {
- asNumber = Number(step.substring(0, isharp));
- semitones = step.length - isharp;
- } else {
- const iflat = step.indexOf('b');
- asNumber = Number(step.substring(0, iflat));
- semitones = iflat - step.length;
- }
+ return value;
}
- try {
- let note;
- if (isObject && value.anchor) {
- note = stepInNamedScale(asNumber, scale, value.anchor);
- } else {
- note = scaleStep(asNumber, scale);
- }
- if (semitones != 0) note = Note.transpose(note, Interval.fromSemitones(semitones));
- value = pure(isObject ? { ...value, note } : note);
- } catch (err) {
- logger(`[tonal] ${err.message}`, 'error');
- value = silence;
+ // The case where the note has been defined via `note`
+ else {
+ const note = _getNearestScaleNote(scale, value.note);
+ return pure(isObject ? { ...value, note } : note);
}
- return value;
})
.outerJoin()
// legacy:
diff --git a/packages/tonal/tonleiter.mjs b/packages/tonal/tonleiter.mjs
index 3814394f6..233129641 100644
--- a/packages/tonal/tonleiter.mjs
+++ b/packages/tonal/tonleiter.mjs
@@ -101,11 +101,11 @@ export function nearestNumberIndex(target, numbers, preferHigher) {
let scaleSteps = {}; // [scaleName]: semitones[]
export function stepInNamedScale(step, scale, anchor, preferHigher) {
- let [root, scaleName] = Scale.tokenize(scale);
+ const [root, scaleName] = Scale.tokenize(scale);
const rootMidi = x2midi(root);
const rootChroma = midi2chroma(rootMidi);
if (!scaleSteps[scaleName]) {
- let { intervals } = Scale.get(`C ${scaleName}`);
+ const { intervals } = Scale.get(`C ${scaleName}`);
// cache result
scaleSteps[scaleName] = intervals.map(step2semitones);
}
@@ -222,6 +222,7 @@ export const Note = {
};
// TODO: support octave numbers
+// Example: Note("Bb3").transpose("c3")
export function transpose(note, step) {
// example: E, 3
const stepNumber = Step.tokenize(step)[1]; // 3
@@ -235,5 +236,3 @@ export function transpose(note, step) {
const offsetAccidentals = accidentalString(Step.accidentals(step) + Note.accidentals(note) + stepIndex - indexOffset); // "we need to add a # to to the G to make it a major third from E"
return [targetNote, offsetAccidentals].join('');
}
-
-//Note("Bb3").transpose("c3")
diff --git a/packages/transpiler/package.json b/packages/transpiler/package.json
index f9ebdfde1..18722bdc2 100644
--- a/packages/transpiler/package.json
+++ b/packages/transpiler/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/transpiler",
- "version": "1.2.3",
+ "version": "1.2.4",
"description": "Transpiler for strudel user code. Converts syntactically correct but semantically meaningless JS into evaluatable strudel code.",
"main": "index.mjs",
"type": "module",
diff --git a/packages/web/package.json b/packages/web/package.json
index 3f9cc50d1..df21f4055 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/web",
- "version": "1.2.4",
+ "version": "1.2.5",
"description": "Easy to setup, opiniated bundle of Strudel for the browser.",
"module": "web.mjs",
"publishConfig": {
diff --git a/packages/webaudio/index.mjs b/packages/webaudio/index.mjs
index 362e61c44..4933b7a01 100644
--- a/packages/webaudio/index.mjs
+++ b/packages/webaudio/index.mjs
@@ -7,4 +7,5 @@ This program is free software: you can redistribute it and/or modify it under th
export * from './webaudio.mjs';
export * from './scope.mjs';
export * from './spectrum.mjs';
+export * from './supradough.mjs';
export * from 'superdough';
diff --git a/packages/webaudio/package.json b/packages/webaudio/package.json
index 78988340a..49da00f23 100644
--- a/packages/webaudio/package.json
+++ b/packages/webaudio/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/webaudio",
- "version": "1.2.4",
+ "version": "1.2.5",
"description": "Web Audio helpers for Strudel",
"main": "index.mjs",
"type": "module",
@@ -35,7 +35,8 @@
"dependencies": {
"@strudel/core": "workspace:*",
"@strudel/draw": "workspace:*",
- "superdough": "workspace:*"
+ "superdough": "workspace:*",
+ "supradough": "workspace:*"
},
"devDependencies": {
"vite": "^6.0.11"
diff --git a/packages/webaudio/supradough.mjs b/packages/webaudio/supradough.mjs
new file mode 100644
index 000000000..f97251a07
--- /dev/null
+++ b/packages/webaudio/supradough.mjs
@@ -0,0 +1,130 @@
+import { Pattern } from '@strudel/core';
+import { connectToDestination, getAudioContext, getWorklet } from 'superdough';
+
+let doughWorklet;
+
+function initDoughWorklet() {
+ const ac = getAudioContext();
+ doughWorklet = getWorklet(
+ ac,
+ 'dough-processor',
+ {},
+ {
+ outputChannelCount: [2],
+ },
+ );
+ connectToDestination(doughWorklet); // channels?
+}
+
+const soundMap = new Map();
+const loadedSounds = new Map();
+
+Pattern.prototype.supradough = function () {
+ return this.onTrigger((hap, __, cps, begin) => {
+ hap.value._begin = begin;
+ hap.value._duration = hap.duration / cps;
+ !doughWorklet && initDoughWorklet();
+ const s = (hap.value.bank ? hap.value.bank + '_' : '') + hap.value.s;
+ const n = hap.value.n ?? 0;
+ const soundKey = `${s}:${n}`;
+ if (soundMap.has(s)) {
+ hap.value.s = soundKey; // dough.mjs is unaware of bank and n (only maps keys to buffers)
+ }
+ if (soundMap.has(s) && !loadedSounds.has(soundKey)) {
+ const urls = soundMap.get(s);
+ const url = urls[n % urls.length];
+ console.log(`load ${soundKey} from ${url}`);
+ const loadSample = fetchSample(url);
+ loadedSounds.set(soundKey, loadSample);
+ loadSample.then(({ channels, sampleRate }) =>
+ doughWorklet.port.postMessage({
+ sample: soundKey,
+ channels,
+ sampleRate,
+ }),
+ );
+ }
+
+ doughWorklet.port.postMessage({ spawn: hap.value });
+ }, 1);
+};
+
+function githubPath(base, subpath = '') {
+ if (!base.startsWith('github:')) {
+ throw new Error('expected "github:" at the start of pseudoUrl');
+ }
+ let [_, path] = base.split('github:');
+ path = path.endsWith('/') ? path.slice(0, -1) : path;
+ if (path.split('/').length === 2) {
+ // assume main as default branch if none set
+ path += '/main';
+ }
+ return `https://raw.githubusercontent.com/${path}/${subpath}`;
+}
+export async function fetchSampleMap(url) {
+ if (url.startsWith('github:')) {
+ url = githubPath(url, 'strudel.json');
+ }
+ if (url.startsWith('local:')) {
+ url = `http://localhost:5432`;
+ }
+ if (url.startsWith('shabda:')) {
+ let [_, path] = url.split('shabda:');
+ url = `https://shabda.ndre.gr/${path}.json?strudel=1`;
+ }
+ if (url.startsWith('shabda/speech')) {
+ let [_, path] = url.split('shabda/speech');
+ path = path.startsWith('/') ? path.substring(1) : path;
+ let [params, words] = path.split(':');
+ let gender = 'f';
+ let language = 'en-GB';
+ if (params) {
+ [language, gender] = params.split('/');
+ }
+ url = `https://shabda.ndre.gr/speech/${words}.json?gender=${gender}&language=${language}&strudel=1'`;
+ }
+ if (typeof fetch !== 'function') {
+ // not a browser
+ return;
+ }
+ const base = url.split('/').slice(0, -1).join('/');
+ if (typeof fetch === 'undefined') {
+ // skip fetch when in node / testing
+ return;
+ }
+ const json = await fetch(url)
+ .then((res) => res.json())
+ .catch((error) => {
+ console.error(error);
+ throw new Error(`error loading "${url}"`);
+ });
+ return [json, json._base || base];
+}
+
+// for some reason, only piano and flute work.. is it because mp3??
+
+async function fetchSample(url) {
+ const buffer = await fetch(url)
+ .then((res) => res.arrayBuffer())
+ .then((buf) => getAudioContext().decodeAudioData(buf));
+ let channels = [];
+ for (let i = 0; i < buffer.numberOfChannels; i++) {
+ channels.push(buffer.getChannelData(i));
+ }
+ return { channels, sampleRate: buffer.sampleRate };
+}
+
+export async function doughsamples(sampleMap, baseUrl) {
+ if (typeof sampleMap === 'string') {
+ const [json, base] = await fetchSampleMap(sampleMap);
+ // console.log('json', json, 'base', base);
+ return doughsamples(json, base);
+ }
+ Object.entries(sampleMap).map(async ([key, urls]) => {
+ if (key !== '_base') {
+ urls = urls.map((url) => baseUrl + url);
+ // console.log('set', key, urls);
+ soundMap.set(key, urls);
+ }
+ });
+}
diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs
index 429d2a26b..383e87f87 100644
--- a/packages/webaudio/webaudio.mjs
+++ b/packages/webaudio/webaudio.mjs
@@ -5,7 +5,12 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import * as strudel from '@strudel/core';
-import { superdough, getAudioContext, setLogger, doughTrigger } from 'superdough';
+import { superdough, getAudioContext, setLogger, doughTrigger, registerWorklet } from 'superdough';
+import './supradough.mjs';
+import { workletUrl } from 'supradough';
+
+registerWorklet(workletUrl);
+
const { Pattern, logger, repl } = strudel;
setLogger(logger);
diff --git a/packages/xen/package.json b/packages/xen/package.json
index 1cb751157..0a6736d9c 100644
--- a/packages/xen/package.json
+++ b/packages/xen/package.json
@@ -1,6 +1,6 @@
{
"name": "@strudel/xen",
- "version": "1.2.3",
+ "version": "1.2.4",
"description": "Xenharmonic API for strudel",
"main": "index.mjs",
"type": "module",
diff --git a/packages/xen/tunejs.js b/packages/xen/tunejs.js
index 6b5e7cb7c..5806cf596 100644
--- a/packages/xen/tunejs.js
+++ b/packages/xen/tunejs.js
@@ -139,10 +139,10 @@ Tune.prototype.MIDI = function(stepIn,octaveIn) {
/* Load a new scale */
-Tune.prototype.loadScale = function(name){
+Tune.prototype.loadScale = function(scale){
/* load the scale */
- var freqs = TuningList[name].frequencies
+ var freqs = isArrayOfNumbers(scale) ? scale : TuningList[scale].frequencies
this.scale = []
for (var i=0;i 0 && arg.every(item => typeof item === 'number' && !isNaN(item));
+}
+
+/* allow an array of values too */
+Tune.prototype.isValidScale = function(scale) {
+ return !!TuningList[scale] || isArrayOfNumbers(scale) ;
}
/* Return a collection of notes as an array */
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7aecd35bd..c225057f3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -201,8 +201,8 @@ importers:
specifier: ^6.1.0
version: 6.1.0(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2)
'@replit/codemirror-vim':
- specifier: ^6.2.1
- version: 6.2.1(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2)
+ specifier: ^6.3.0
+ version: 6.3.0(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2)
'@replit/codemirror-vscode-keymap':
specifier: ^6.0.2
version: 6.0.2(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2)
@@ -517,6 +517,18 @@ importers:
specifier: workspace:*
version: link:../vite-plugin-bundle-audioworklet
+ packages/supradough:
+ 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)
+ vite-plugin-bundle-audioworklet:
+ specifier: workspace:*
+ version: link:../vite-plugin-bundle-audioworklet
+ wav-encoder:
+ specifier: ^1.3.0
+ version: 1.3.0
+
packages/tidal:
dependencies:
'@strudel/core':
@@ -625,6 +637,9 @@ importers:
superdough:
specifier: workspace:*
version: link:../superdough
+ supradough:
+ specifier: workspace:*
+ version: link:../supradough
devDependencies:
vite:
specifier: ^6.0.11
@@ -2232,14 +2247,14 @@ packages:
'@codemirror/state': ^6.0.1
'@codemirror/view': ^6.3.0
- '@replit/codemirror-vim@6.2.1':
- resolution: {integrity: sha512-qDAcGSHBYU5RrdO//qCmD8K9t6vbP327iCj/iqrkVnjbrpFhrjOt92weGXGHmTNRh16cUtkUZ7Xq7rZf+8HVow==}
+ '@replit/codemirror-vim@6.3.0':
+ resolution: {integrity: sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==}
peerDependencies:
- '@codemirror/commands': ^6.0.0
- '@codemirror/language': ^6.1.0
- '@codemirror/search': ^6.2.0
- '@codemirror/state': ^6.0.1
- '@codemirror/view': ^6.0.3
+ '@codemirror/commands': 6.x.x
+ '@codemirror/language': 6.x.x
+ '@codemirror/search': 6.x.x
+ '@codemirror/state': 6.x.x
+ '@codemirror/view': 6.x.x
'@replit/codemirror-vscode-keymap@6.0.2':
resolution: {integrity: sha512-j45qTwGxzpsv82lMD/NreGDORFKSctMDVkGRopaP+OrzSzv+pXDQuU3LnFvKpasyjVT0lf+PKG1v2DSCn/vxxg==}
@@ -5714,6 +5729,7 @@ packages:
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
+ deprecated: Use your platform's native DOMException instead
node-fetch-native@1.6.6:
resolution: {integrity: sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==}
@@ -6803,6 +6819,7 @@ packages:
source-map@0.8.0-beta.0:
resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
engines: {node: '>= 8'}
+ deprecated: The work that was done in this beta branch won't be included in future versions
sourcemap-codec@1.4.8:
resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
@@ -7535,6 +7552,9 @@ packages:
walk-up-path@3.0.1:
resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==}
+ wav-encoder@1.3.0:
+ resolution: {integrity: sha512-FXJdEu2qDOI+wbVYZpu21CS1vPEg5NaxNskBr4SaULpOJMrLE6xkH8dECa7PiS+ZoeyvP7GllWUAxPN3AvFSEw==}
+
wav@1.0.2:
resolution: {integrity: sha512-viHtz3cDd/Tcr/HbNqzQCofKdF6kWUymH9LGDdskfWFoIy/HJ+RTihgjEcHfnsy1PO4e9B+y4HwgTwMrByquhg==}
@@ -7657,6 +7677,7 @@ packages:
workbox-google-analytics@7.0.0:
resolution: {integrity: sha512-MEYM1JTn/qiC3DbpvP2BVhyIH+dV/5BjHk756u9VbwuAhu0QHyKscTnisQuz21lfRpOwiS9z4XdqeVAKol0bzg==}
+ deprecated: It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained
workbox-navigation-preload@7.0.0:
resolution: {integrity: sha512-juWCSrxo/fiMz3RsvDspeSLGmbgC0U9tKqcUPZBCf35s64wlaLXyn2KdHHXVQrb2cqF7I0Hc9siQalainmnXJA==}
@@ -9595,7 +9616,7 @@ snapshots:
'@codemirror/state': 6.5.1
'@codemirror/view': 6.36.2
- '@replit/codemirror-vim@6.2.1(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2)':
+ '@replit/codemirror-vim@6.3.0(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2)':
dependencies:
'@codemirror/commands': 6.8.0
'@codemirror/language': 6.10.8
@@ -15955,6 +15976,8 @@ snapshots:
walk-up-path@3.0.1: {}
+ wav-encoder@1.3.0: {}
+
wav@1.0.2:
dependencies:
buffer-alloc: 1.2.0
diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap
index 41207620f..f91503ca2 100644
--- a/test/__snapshots__/examples.test.mjs.snap
+++ b/test/__snapshots__/examples.test.mjs.snap
@@ -1847,6 +1847,27 @@ exports[`runs examples > example "chop" example index 0 1`] = `
]
`;
+exports[`runs examples > example "chorus" example index 0 1`] = `
+[
+ "[ 0/1 → 1/4 | note:d s:sawtooth chorus:0.5 ]",
+ "[ 1/4 → 1/2 | note:d s:sawtooth chorus:0.5 ]",
+ "[ 1/2 → 3/4 | note:a# s:sawtooth chorus:0.5 ]",
+ "[ 3/4 → 1/1 | note:a s:sawtooth chorus:0.5 ]",
+ "[ 1/1 → 5/4 | note:d s:sawtooth chorus:0.5 ]",
+ "[ 5/4 → 3/2 | note:d s:sawtooth chorus:0.5 ]",
+ "[ 3/2 → 7/4 | note:a# s:sawtooth chorus:0.5 ]",
+ "[ 7/4 → 2/1 | note:a s:sawtooth chorus:0.5 ]",
+ "[ 2/1 → 9/4 | note:d s:sawtooth chorus:0.5 ]",
+ "[ 9/4 → 5/2 | note:d s:sawtooth chorus:0.5 ]",
+ "[ 5/2 → 11/4 | note:a# s:sawtooth chorus:0.5 ]",
+ "[ 11/4 → 3/1 | note:a s:sawtooth chorus:0.5 ]",
+ "[ 3/1 → 13/4 | note:d s:sawtooth chorus:0.5 ]",
+ "[ 13/4 → 7/2 | note:d s:sawtooth chorus:0.5 ]",
+ "[ 7/2 → 15/4 | note:a# s:sawtooth chorus:0.5 ]",
+ "[ 15/4 → 4/1 | note:a s:sawtooth chorus:0.5 ]",
+]
+`;
+
exports[`runs examples > example "chunk" example index 0 1`] = `
[
"[ 0/1 → 1/4 | note:A4 ]",
@@ -2585,6 +2606,52 @@ exports[`runs examples > example "delayfeedback" example index 0 1`] = `
]
`;
+exports[`runs examples > example "delayfeedback" example index 0 2`] = `
+[
+ "[ 0/1 → 1/1 | s:bd delay:0.25 delayfeedback:0.25 ]",
+ "[ 1/1 → 2/1 | s:bd delay:0.25 delayfeedback:0.5 ]",
+ "[ 2/1 → 3/1 | s:bd delay:0.25 delayfeedback:0.75 ]",
+ "[ 3/1 → 4/1 | s:bd delay:0.25 delayfeedback:1 ]",
+]
+`;
+
+exports[`runs examples > example "delayspeed" example index 0 1`] = `
+[
+ "[ 0/1 → 1/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]",
+ "[ 1/8 → 1/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]",
+ "[ 1/4 → 3/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]",
+ "[ 3/8 → 1/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]",
+ "[ 1/2 → 5/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]",
+ "[ 5/8 → 3/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]",
+ "[ 3/4 → 7/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]",
+ "[ 7/8 → 1/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:2 ]",
+ "[ 1/1 → 9/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]",
+ "[ 9/8 → 5/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]",
+ "[ 5/4 → 11/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]",
+ "[ 11/8 → 3/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]",
+ "[ 3/2 → 13/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]",
+ "[ 13/8 → 7/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]",
+ "[ 7/4 → 15/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]",
+ "[ 15/8 → 2/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:0.5 ]",
+ "[ 2/1 → 17/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]",
+ "[ 17/8 → 9/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]",
+ "[ 9/4 → 19/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]",
+ "[ 19/8 → 5/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]",
+ "[ 5/2 → 21/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]",
+ "[ 21/8 → 11/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]",
+ "[ 11/4 → 23/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]",
+ "[ 23/8 → 3/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-1 ]",
+ "[ 3/1 → 25/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]",
+ "[ 25/8 → 13/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]",
+ "[ 13/4 → 27/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]",
+ "[ 27/8 → 7/2 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]",
+ "[ 7/2 → 29/8 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]",
+ "[ 29/8 → 15/4 | note:d s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]",
+ "[ 15/4 → 31/8 | note:a# s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]",
+ "[ 31/8 → 4/1 | note:a s:sawtooth delay:0.8 delaytime:0.5 delayspeed:-2 ]",
+]
+`;
+
exports[`runs examples > example "delaysync" example index 0 1`] = `
[
"[ 0/1 → 1/2 | s:bd delay:0.25 delaysync:0.125 ]",
@@ -2598,19 +2665,6 @@ exports[`runs examples > example "delaysync" example index 0 1`] = `
]
`;
-exports[`runs examples > example "delaytime" example index 0 1`] = `
-[
- "[ 0/1 → 1/2 | s:bd delay:0.25 delaytime:0.125 ]",
- "[ 1/2 → 1/1 | s:bd delay:0.25 delaytime:0.125 ]",
- "[ 1/1 → 3/2 | s:bd delay:0.25 delaytime:0.25 ]",
- "[ 3/2 → 2/1 | s:bd delay:0.25 delaytime:0.25 ]",
- "[ 2/1 → 5/2 | s:bd delay:0.25 delaytime:0.5 ]",
- "[ 5/2 → 3/1 | s:bd delay:0.25 delaytime:0.5 ]",
- "[ 3/1 → 7/2 | s:bd delay:0.25 delaytime:1 ]",
- "[ 7/2 → 4/1 | s:bd delay:0.25 delaytime:1 ]",
-]
-`;
-
exports[`runs examples > example "density" example index 0 1`] = `
[
"[ 0/1 → 1/4 | s:crackle density:0.01 ]",
@@ -3039,57 +3093,50 @@ exports[`runs examples > example "dry" example index 0 1`] = `
exports[`runs examples > example "duckattack" example index 0 1`] = `
[
"[ 0/1 → 1/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]",
- "[ 0/1 → 1/8 | note:C3 s:sawtooth delay:0.7 orbit:2 ]",
- "[ 1/8 → 1/4 | note:D3 s:sawtooth delay:0.7 orbit:2 ]",
"[ 1/4 → 5/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]",
- "[ 1/4 → 3/8 | note:Eb3 s:sawtooth delay:0.7 orbit:2 ]",
- "[ 3/8 → 1/2 | note:F3 s:sawtooth delay:0.7 orbit:2 ]",
"[ 1/2 → 9/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]",
- "[ 1/2 → 5/8 | note:G3 s:sawtooth delay:0.7 orbit:2 ]",
- "[ 5/8 → 3/4 | note:Ab3 s:sawtooth delay:0.7 orbit:2 ]",
"[ 11/16 → 3/4 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]",
- "[ 3/4 → 7/8 | note:Bb3 s:sawtooth delay:0.7 orbit:2 ]",
"[ 7/8 → 15/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]",
- "[ 7/8 → 1/1 | note:C4 s:sawtooth delay:0.7 orbit:2 ]",
"[ 1/1 → 17/16 | s:bd n:4 duckorbit:2 duckattack:0 duckdepth:1 ]",
- "[ 1/1 → 9/8 | note:C3 s:sawtooth delay:0.7 orbit:2 ]",
- "[ 9/8 → 5/4 | note:D3 s:sawtooth delay:0.7 orbit:2 ]",
"[ 5/4 → 21/16 | s:bd n:4 duckorbit:2 duckattack:0 duckdepth:1 ]",
- "[ 5/4 → 11/8 | note:Eb3 s:sawtooth delay:0.7 orbit:2 ]",
- "[ 11/8 → 3/2 | note:F3 s:sawtooth delay:0.7 orbit:2 ]",
"[ 3/2 → 25/16 | s:bd n:4 duckorbit:2 duckattack:0 duckdepth:1 ]",
- "[ 3/2 → 13/8 | note:G3 s:sawtooth delay:0.7 orbit:2 ]",
- "[ 13/8 → 7/4 | note:Ab3 s:sawtooth delay:0.7 orbit:2 ]",
"[ 27/16 → 7/4 | s:bd n:4 duckorbit:2 duckattack:0 duckdepth:1 ]",
- "[ 7/4 → 15/8 | note:Bb3 s:sawtooth delay:0.7 orbit:2 ]",
"[ 15/8 → 31/16 | s:bd n:4 duckorbit:2 duckattack:0 duckdepth:1 ]",
- "[ 15/8 → 2/1 | note:C4 s:sawtooth delay:0.7 orbit:2 ]",
"[ 2/1 → 33/16 | s:bd n:4 duckorbit:2 duckattack:0.4 duckdepth:1 ]",
- "[ 2/1 → 17/8 | note:C3 s:sawtooth delay:0.7 orbit:2 ]",
- "[ 17/8 → 9/4 | note:D3 s:sawtooth delay:0.7 orbit:2 ]",
"[ 9/4 → 37/16 | s:bd n:4 duckorbit:2 duckattack:0.4 duckdepth:1 ]",
- "[ 9/4 → 19/8 | note:Eb3 s:sawtooth delay:0.7 orbit:2 ]",
- "[ 19/8 → 5/2 | note:F3 s:sawtooth delay:0.7 orbit:2 ]",
"[ 5/2 → 41/16 | s:bd n:4 duckorbit:2 duckattack:0.4 duckdepth:1 ]",
- "[ 5/2 → 21/8 | note:G3 s:sawtooth delay:0.7 orbit:2 ]",
- "[ 21/8 → 11/4 | note:Ab3 s:sawtooth delay:0.7 orbit:2 ]",
"[ 43/16 → 11/4 | s:bd n:4 duckorbit:2 duckattack:0.4 duckdepth:1 ]",
- "[ 11/4 → 23/8 | note:Bb3 s:sawtooth delay:0.7 orbit:2 ]",
"[ 23/8 → 47/16 | s:bd n:4 duckorbit:2 duckattack:0.4 duckdepth:1 ]",
- "[ 23/8 → 3/1 | note:C4 s:sawtooth delay:0.7 orbit:2 ]",
"[ 3/1 → 49/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]",
- "[ 3/1 → 25/8 | note:C3 s:sawtooth delay:0.7 orbit:2 ]",
- "[ 25/8 → 13/4 | note:D3 s:sawtooth delay:0.7 orbit:2 ]",
"[ 13/4 → 53/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]",
- "[ 13/4 → 27/8 | note:Eb3 s:sawtooth delay:0.7 orbit:2 ]",
- "[ 27/8 → 7/2 | note:F3 s:sawtooth delay:0.7 orbit:2 ]",
"[ 7/2 → 57/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]",
- "[ 7/2 → 29/8 | note:G3 s:sawtooth delay:0.7 orbit:2 ]",
- "[ 29/8 → 15/4 | note:Ab3 s:sawtooth delay:0.7 orbit:2 ]",
"[ 59/16 → 15/4 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]",
- "[ 15/4 → 31/8 | note:Bb3 s:sawtooth delay:0.7 orbit:2 ]",
"[ 31/8 → 63/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]",
- "[ 31/8 → 4/1 | note:C4 s:sawtooth delay:0.7 orbit:2 ]",
+]
+`;
+
+exports[`runs examples > example "duckattack" example index 1 1`] = `
+[
+ "[ 0/1 → 1/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 1/4 → 5/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 1/2 → 9/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 11/16 → 3/4 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 7/8 → 15/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 1/1 → 17/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 5/4 → 21/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 3/2 → 25/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 27/16 → 7/4 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 15/8 → 31/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 2/1 → 33/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 9/4 → 37/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 5/2 → 41/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 43/16 → 11/4 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 23/8 → 47/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 3/1 → 49/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 13/4 → 53/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 7/2 → 57/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 59/16 → 15/4 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
+ "[ 31/8 → 63/16 | s:bd n:4 duckorbit:[2 5] duckattack:[0.4 0.1] ]",
]
`;
@@ -3150,6 +3197,94 @@ exports[`runs examples > example "duckdepth" example index 0 1`] = `
]
`;
+exports[`runs examples > example "duckdepth" example index 1 1`] = `
+[
+ "[ 0/1 → 1/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 1/4 → 5/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 1/2 → 9/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 11/16 → 3/4 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 7/8 → 15/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 1/1 → 17/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 5/4 → 21/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 3/2 → 25/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 27/16 → 7/4 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 15/8 → 31/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 2/1 → 33/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 9/4 → 37/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 5/2 → 41/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 43/16 → 11/4 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 23/8 → 47/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 3/1 → 49/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 13/4 → 53/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 7/2 → 57/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 59/16 → 15/4 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+ "[ 31/8 → 63/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:[1 0.5] ]",
+]
+`;
+
+exports[`runs examples > example "duckonset" example index 0 1`] = `
+[
+ "[ 0/1 → 1/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]",
+ "[ 1/4 → 1/2 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]",
+ "[ 1/2 → 3/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]",
+ "[ 3/4 → 1/1 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]",
+ "[ 1/1 → 5/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]",
+ "[ 5/4 → 3/2 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]",
+ "[ 3/2 → 7/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]",
+ "[ 7/4 → 2/1 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]",
+ "[ 2/1 → 9/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]",
+ "[ 9/4 → 5/2 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]",
+ "[ 5/2 → 11/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]",
+ "[ 11/4 → 3/1 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]",
+ "[ 3/1 → 13/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]",
+ "[ 13/4 → 7/2 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]",
+ "[ 7/2 → 15/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]",
+ "[ 15/4 → 4/1 | s:bd duckorbit:2 duckattack:0.3 duckonset:0 postgain:0 ]",
+]
+`;
+
+exports[`runs examples > example "duckonset" example index 1 1`] = `
+[
+ "[ 0/1 → 1/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]",
+ "[ 1/4 → 1/2 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]",
+ "[ 1/2 → 3/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]",
+ "[ 3/4 → 1/1 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]",
+ "[ 1/1 → 5/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]",
+ "[ 5/4 → 3/2 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]",
+ "[ 3/2 → 7/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]",
+ "[ 7/4 → 2/1 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]",
+ "[ 2/1 → 9/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]",
+ "[ 9/4 → 5/2 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]",
+ "[ 5/2 → 11/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]",
+ "[ 11/4 → 3/1 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]",
+ "[ 3/1 → 13/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]",
+ "[ 13/4 → 7/2 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]",
+ "[ 7/2 → 15/4 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]",
+ "[ 15/4 → 4/1 | s:bd duckorbit:2 duckattack:0.3 duckonset:0.01 postgain:0 ]",
+]
+`;
+
+exports[`runs examples > example "duckonset" example index 2 1`] = `
+[
+ "[ 0/1 → 1/4 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]",
+ "[ 1/4 → 1/2 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]",
+ "[ 1/2 → 3/4 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]",
+ "[ 3/4 → 1/1 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]",
+ "[ 1/1 → 5/4 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]",
+ "[ 5/4 → 3/2 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]",
+ "[ 3/2 → 7/4 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]",
+ "[ 7/4 → 2/1 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]",
+ "[ 2/1 → 9/4 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]",
+ "[ 9/4 → 5/2 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]",
+ "[ 5/2 → 11/4 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]",
+ "[ 11/4 → 3/1 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]",
+ "[ 3/1 → 13/4 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]",
+ "[ 13/4 → 7/2 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]",
+ "[ 7/2 → 15/4 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]",
+ "[ 15/4 → 4/1 | s:bd bank:tr909 duckorbit:[4 7] duckonset:[0.3 0.003] duckattack:0.25 ]",
+]
+`;
+
exports[`runs examples > example "duckorbit" example index 0 1`] = `
[
"[ 0/1 → 1/16 | s:bd n:4 duckorbit:2 duckattack:0.2 duckdepth:1 ]",
@@ -3175,6 +3310,31 @@ exports[`runs examples > example "duckorbit" example index 0 1`] = `
]
`;
+exports[`runs examples > example "duckorbit" example index 1 1`] = `
+[
+ "[ 0/1 → 1/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 1/4 → 5/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 1/2 → 9/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 11/16 → 3/4 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 7/8 → 15/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 1/1 → 17/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 5/4 → 21/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 3/2 → 25/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 27/16 → 7/4 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 15/8 → 31/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 2/1 → 33/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 9/4 → 37/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 5/2 → 41/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 43/16 → 11/4 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 23/8 → 47/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 3/1 → 49/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 13/4 → 53/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 7/2 → 57/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 59/16 → 15/4 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+ "[ 31/8 → 63/16 | s:bd n:4 duckorbit:[2 3] duckattack:0.2 duckdepth:1 ]",
+]
+`;
+
exports[`runs examples > example "duration" example index 0 1`] = `
[
"[ 0/1 → 1/4 | note:c s:piano duration:0.5 ]",
@@ -3590,6 +3750,8 @@ exports[`runs examples > example "fast" example index 0 1`] = `
exports[`runs examples > example "fastChunk" example index 0 1`] = `
[
+ "[ 0/1 → 1/4 | color:red note:0 ]",
+ "[ 1/4 → 1/2 | color:red note:1 ]",
"[ 1/2 → 3/4 | note:E2 ]",
"[ 3/4 → 1/1 | note:F2 ]",
"[ 1/1 → 5/4 | note:G2 ]",
@@ -3598,6 +3760,8 @@ exports[`runs examples > example "fastChunk" example index 0 1`] = `
"[ 7/4 → 2/1 | note:C3 ]",
"[ 2/1 → 9/4 | note:D3 ]",
"[ 9/4 → 5/2 | note:D2 ]",
+ "[ 5/2 → 11/4 | color:red note:2 ]",
+ "[ 11/4 → 3/1 | color:red note:3 ]",
"[ 3/1 → 13/4 | note:G2 ]",
"[ 13/4 → 7/2 | note:A2 ]",
"[ 7/2 → 15/4 | note:B2 ]",
@@ -6506,6 +6670,27 @@ exports[`runs examples > example "note" example index 2 1`] = `
]
`;
+exports[`runs examples > example "note" example index 3 1`] = `
+[
+ "[ 0/1 → 1/4 | note:fbb1 s:saw ]",
+ "[ 1/4 → 1/2 | note:a#0 s:saw ]",
+ "[ 1/2 → 3/4 | note:cbbb-1 s:saw ]",
+ "[ 3/4 → 1/1 | note:e##-2 s:saw ]",
+ "[ 1/1 → 5/4 | note:fbb1 s:saw ]",
+ "[ 5/4 → 3/2 | note:a#0 s:saw ]",
+ "[ 3/2 → 7/4 | note:cbbb-1 s:saw ]",
+ "[ 7/4 → 2/1 | note:e##-2 s:saw ]",
+ "[ 2/1 → 9/4 | note:fbb1 s:saw ]",
+ "[ 9/4 → 5/2 | note:a#0 s:saw ]",
+ "[ 5/2 → 11/4 | note:cbbb-1 s:saw ]",
+ "[ 11/4 → 3/1 | note:e##-2 s:saw ]",
+ "[ 3/1 → 13/4 | note:fbb1 s:saw ]",
+ "[ 13/4 → 7/2 | note:a#0 s:saw ]",
+ "[ 7/2 → 15/4 | note:cbbb-1 s:saw ]",
+ "[ 15/4 → 4/1 | note:e##-2 s:saw ]",
+]
+`;
+
exports[`runs examples > example "nrpnn" example index 0 1`] = `
[
"[ 0/1 → 1/1 | note:c4 nrpnn:[1 8] nrpv:123 midichan:1 ]",
@@ -7311,6 +7496,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 ]",
@@ -8736,6 +8979,108 @@ exports[`runs examples > example "scale" example index 2 1`] = `
]
`;
+exports[`runs examples > example "scale" example index 3 1`] = `
+[
+ "[ 0/1 → 1/4 | note:C3 s:piano ]",
+ "[ 0/1 → 1/4 | note:B3 s:piano ]",
+ "[ 1/4 → 3/8 | note:Gb2 s:piano ]",
+ "[ 3/8 → 1/2 | note:F2 s:piano ]",
+ "[ 1/2 → 3/4 | note:A2 s:piano ]",
+ "[ 1/2 → 3/4 | note:D4 s:piano ]",
+ "[ 3/4 → 1/1 | note:G3 s:piano ]",
+ "[ 1/1 → 5/4 | note:C3 s:piano ]",
+ "[ 1/1 → 5/4 | note:C4 s:piano ]",
+ "[ 5/4 → 11/8 | note:Gb2 s:piano ]",
+ "[ 11/8 → 3/2 | note:E2 s:piano ]",
+ "[ 3/2 → 7/4 | note:A2 s:piano ]",
+ "[ 3/2 → 7/4 | note:Eb4 s:piano ]",
+ "[ 7/4 → 2/1 | note:F#3 s:piano ]",
+ "[ 2/1 → 9/4 | note:C3 s:piano ]",
+ "[ 2/1 → 9/4 | note:B3 s:piano ]",
+ "[ 9/4 → 19/8 | note:Gb2 s:piano ]",
+ "[ 19/8 → 5/2 | note:F2 s:piano ]",
+ "[ 5/2 → 11/4 | note:Ab2 s:piano ]",
+ "[ 5/2 → 11/4 | note:D4 s:piano ]",
+ "[ 11/4 → 3/1 | note:G3 s:piano ]",
+ "[ 3/1 → 13/4 | note:C3 s:piano ]",
+ "[ 3/1 → 13/4 | note:C4 s:piano ]",
+ "[ 13/4 → 27/8 | note:Gb2 s:piano ]",
+ "[ 27/8 → 7/2 | note:E2 s:piano ]",
+ "[ 7/2 → 15/4 | note:Ab2 s:piano ]",
+ "[ 7/2 → 15/4 | note:Eb4 s:piano ]",
+ "[ 15/4 → 4/1 | note:F#3 s:piano ]",
+]
+`;
+
+exports[`runs examples > example "scale" example index 4 1`] = `
+[
+ "[ 0/1 → 1/16 | note:Gb1 ]",
+ "[ 1/16 → 1/8 | note:Gb1 ]",
+ "[ 1/8 → 3/16 | note:Gb1 ]",
+ "[ 3/16 → 1/4 | note:Gb1 ]",
+ "[ 1/4 → 5/16 | note:Gb1 ]",
+ "[ 5/16 → 3/8 | note:Gb1 ]",
+ "[ 3/8 → 7/16 | note:Gb1 ]",
+ "[ 7/16 → 1/2 | note:Gb1 ]",
+ "[ 1/2 → 9/16 | note:Gb1 ]",
+ "[ 9/16 → 5/8 | note:Gb1 ]",
+ "[ 5/8 → 11/16 | note:Gb1 ]",
+ "[ 11/16 → 3/4 | note:Gb1 ]",
+ "[ 3/4 → 13/16 | note:Gb1 ]",
+ "[ 13/16 → 7/8 | note:Gb1 ]",
+ "[ 7/8 → 15/16 | note:Gb1 ]",
+ "[ 15/16 → 1/1 | note:Gb1 ]",
+ "[ 1/1 → 17/16 | note:Cb3 ]",
+ "[ 17/16 → 9/8 | note:Cb3 ]",
+ "[ 9/8 → 19/16 | note:Cb3 ]",
+ "[ 19/16 → 5/4 | note:Cb3 ]",
+ "[ 5/4 → 21/16 | note:Cb3 ]",
+ "[ 21/16 → 11/8 | note:Cb3 ]",
+ "[ 11/8 → 23/16 | note:Cb3 ]",
+ "[ 23/16 → 3/2 | note:Cb3 ]",
+ "[ 3/2 → 25/16 | note:Cb3 ]",
+ "[ 25/16 → 13/8 | note:Cb3 ]",
+ "[ 13/8 → 27/16 | note:Cb3 ]",
+ "[ 27/16 → 7/4 | note:Cb3 ]",
+ "[ 7/4 → 29/16 | note:Cb3 ]",
+ "[ 29/16 → 15/8 | note:Cb3 ]",
+ "[ 15/8 → 31/16 | note:Cb3 ]",
+ "[ 31/16 → 2/1 | note:Cb3 ]",
+ "[ 2/1 → 33/16 | note:Eb4 ]",
+ "[ 33/16 → 17/8 | note:Eb4 ]",
+ "[ 17/8 → 35/16 | note:Eb4 ]",
+ "[ 35/16 → 9/4 | note:Eb4 ]",
+ "[ 9/4 → 37/16 | note:Eb4 ]",
+ "[ 37/16 → 19/8 | note:Eb4 ]",
+ "[ 19/8 → 39/16 | note:Eb4 ]",
+ "[ 39/16 → 5/2 | note:Eb4 ]",
+ "[ 5/2 → 41/16 | note:Eb4 ]",
+ "[ 41/16 → 21/8 | note:Eb4 ]",
+ "[ 21/8 → 43/16 | note:Eb4 ]",
+ "[ 43/16 → 11/4 | note:Eb4 ]",
+ "[ 11/4 → 45/16 | note:Eb4 ]",
+ "[ 45/16 → 23/8 | note:Eb4 ]",
+ "[ 23/8 → 47/16 | note:Eb4 ]",
+ "[ 47/16 → 3/1 | note:Eb4 ]",
+ "[ 3/1 → 49/16 | note:Db2 ]",
+ "[ 49/16 → 25/8 | note:Db2 ]",
+ "[ 25/8 → 51/16 | note:Db2 ]",
+ "[ 51/16 → 13/4 | note:Db2 ]",
+ "[ 13/4 → 53/16 | note:Db2 ]",
+ "[ 53/16 → 27/8 | note:Db2 ]",
+ "[ 27/8 → 55/16 | note:Db2 ]",
+ "[ 55/16 → 7/2 | note:Db2 ]",
+ "[ 7/2 → 57/16 | note:Db2 ]",
+ "[ 57/16 → 29/8 | note:Db2 ]",
+ "[ 29/8 → 59/16 | note:Db2 ]",
+ "[ 59/16 → 15/4 | note:Db2 ]",
+ "[ 15/4 → 61/16 | note:Db2 ]",
+ "[ 61/16 → 31/8 | note:Db2 ]",
+ "[ 31/8 → 63/16 | note:Db2 ]",
+ "[ 63/16 → 4/1 | note:Db2 ]",
+]
+`;
+
exports[`runs examples > example "scaleTranspose" example index 0 1`] = `
[
"[ 0/1 → 1/2 | note:C3 ]",
diff --git a/website/public/fonts/tic80/license.txt b/website/public/fonts/tic80/license.txt
new file mode 100644
index 000000000..e42ba4eb9
--- /dev/null
+++ b/website/public/fonts/tic80/license.txt
@@ -0,0 +1,5 @@
+The FontStruction “TIC-80 wide font”
+(https://fontstruct.com/fontstructions/show/1388526) by “nesbox” is licensed
+under a Creative Commons CC0 Public Domain Dedication license
+(http://creativecommons.org/publicdomain/zero/1.0/).
+[ancestry]
\ No newline at end of file
diff --git a/website/public/fonts/tic80/readme.txt b/website/public/fonts/tic80/readme.txt
new file mode 100644
index 000000000..b3aaff55c
--- /dev/null
+++ b/website/public/fonts/tic80/readme.txt
@@ -0,0 +1,16 @@
+The font file in this archive was created using Fontstruct the free, online
+font-building tool.
+This font was created by “nesbox”.
+This font has a homepage where this archive and other versions may be found:
+https://fontstruct.com/fontstructions/show/1388526
+[ancestry]
+Try Fontstruct at https://fontstruct.com
+It’s easy and it’s fun.
+
+Fontstruct is copyright ©2017-2025 Rob Meek
+
+LEGAL NOTICE:
+In using this font you must comply with the licensing terms described in the
+file “license.txt” included with this archive.
+If you redistribute the font file in this archive, it must be accompanied by all
+the other files from this archive, including this one.
diff --git a/website/public/fonts/tic80/tic-80-wide-font.otf b/website/public/fonts/tic80/tic-80-wide-font.otf
new file mode 100644
index 000000000..ba1c1caf4
Binary files /dev/null and b/website/public/fonts/tic80/tic-80-wide-font.otf differ
diff --git a/website/public/img/strudel-signal-flow.png b/website/public/img/strudel-signal-flow.png
new file mode 100644
index 000000000..c5099c917
Binary files /dev/null and b/website/public/img/strudel-signal-flow.png differ
diff --git a/website/src/config.ts b/website/src/config.ts
index 2e2e264da..f335b272e 100644
--- a/website/src/config.ts
+++ b/website/src/config.ts
@@ -103,6 +103,7 @@ export const SIDEBAR: Sidebar = {
Understand: [
{ text: 'Coding syntax', link: 'learn/code' },
{ text: 'Pitch', link: 'understand/pitch' },
+ { text: 'Xen Harmonic Functions', link: 'learn/xen' },
{ text: 'Cycles', link: 'understand/cycles' },
{ text: 'Voicings', link: 'understand/voicings' },
{ text: 'Pattern Alignment', link: 'technical-manual/alignment' },
diff --git a/website/src/pages/learn/code.mdx b/website/src/pages/learn/code.mdx
index ca2564dec..f9c6b3a25 100644
--- a/website/src/pages/learn/code.mdx
+++ b/website/src/pages/learn/code.mdx
@@ -44,7 +44,7 @@ xxx("foo").yyy("bar")
Generally, `xxx` and `yyy` are called [_functions_](), while `foo` and `bar` are called function [_arguments_ or _parameters_]().
So far, we've used the functions to declare which aspect of the sound we want to control, and their arguments for the actual data.
-The `yyy` function is called a [_chained_ function](https://en.wikipedia.org/wiki/Method_chaining), because it is appended with a dot (`.`).
+The `yyy` function is called a [_chained_ function](https://en.wikipedia.org/wiki/Method_chaining), because it is preceded with a dot (`.`).
Generally, the idea with chaining is that code such as `a("this").b("that").c("other")` allows `a`, `b` and `c` functions to happen in a specified order, without needing to write them as three separate lines of code.
You can think of this as being similar to chaining audio effects together using guitar pedals or digital audio effects.
diff --git a/website/src/pages/learn/effects.mdx b/website/src/pages/learn/effects.mdx
index 89f36fe96..517eb658f 100644
--- a/website/src/pages/learn/effects.mdx
+++ b/website/src/pages/learn/effects.mdx
@@ -11,6 +11,129 @@ import { JsDoc } from '../../docs/JsDoc';
Whether you're using a synth or a sample, you can apply any of the following built-in audio effects.
As you might suspect, the effects can be chained together, and they accept a pattern string as their argument.
+# Signal chain
+
+
+
+The signal chain in Strudel is as follows:
+
+- An sound-generating event is triggered by a pattern
+ - This has a start time and a duration, which is usually
+ controlled by the note length and ADSR parameters
+ - If we exceed the max polyphony, old sounds begin to die off
+ - Muted sounds (one whose `s` value is `-`, `~`, or `_`) are skipped
+- A sound is produced (through, say, a sample or an oscillator)
+ - This is where detune-based effects (like `detune`, `penv`, etc. occur)
+- The following will occur _in order_ and only if they've been called in the pattern. Note that all of these are
+ single use effects, meaning that multiple occurrences of them in a pattern will simply override the values
+ (e.g. you can't do `s("bd").lpf(100).distort(2).lpf(800)` to lowpass, distort, and then lowpass
+ again)
+ - Phase vocoder (`stretch`)
+ - Gain is applied (`gain`)
+ - This is where the main (volume) ADSR happens
+ - A lowpass filter (`lpf`)
+ - A highpass filter (`hpf`)
+ - A bandpass filter (`bandpass`)
+ - A vowel filter (`vowel`)
+ - Sample rate reduction (`coarse`)
+ - Bit crushing (`crush`)
+ - Waveshape distortion (`shape`)
+ - Normal distortion (`distort`)
+ - Tremolo (`tremolo`)
+ - Compressor (`compressor`)
+ - Panning (`pan`)
+ - Phaser (`phaser`)
+ - Postgain (`post`)
+- The sound is then split into multiple destinations
+ - Dry output (amount controlled by `dry` parameter)
+ - The sends
+ - Analyzers
+ - These are used for tooling like `scope` and `spectrum` and their setup usually happens behind the scenes
+ - Delay (amount controlled by `delay` parameter)
+ - Reverb (amount controlled by `room` parameter)
+- The dry output, delay, and reverb are joined into what is called the "orbit" of the pattern (see more in the section below)
+ - The `duck` effect affects the volume of all signals in the orbit
+ - The orbit is then sent to the mixer
+
+## Orbits
+
+Orbits are the way in which outputs are handled in Strudel. They also prescribe which delay and reverb to associate with the dry signal.
+By default, all orbits are mixed down to channels `1` and `2` in stereo, however with the "Multi Channel Orbits" setting
+(under Settings at the right) you can use them as individual 2 channel stereo outs (orbit `i` will be mapped to
+to channels `2i` and `2i + 1`). You can then use routers like Blackhole 16 to retrieve and record all of the channels in a DAW for later processing.
+
+The default orbit is `1` and it is set with `orbit`. You may send a sound to multiple orbits via mininotation
+
+
+
+but please be careful as this will create three copies of the sound behind the scenes, meaning that if they are mixed
+down to a single output, they will triple the volume. We've reduced the gain here to save your ears.
+
+⚠️ There is only one delay and reverb per orbit, so please be aware that if you attempt to change the parameters on two
+patterns pointing to the same orbit, it can lead to unpredictable results. Compare, for example, this pretty pluck
+with a large reverb:
+
+
+
+versus the same pluck with a muted kick drum coming in and overwriting the `roomsize` value:
+
+
+
+This is due to them sharing the same orbit: the default of `1`. It can be corrected simply by updating the orbits to be
+distinct:
+
+
+
+## Continuous changes
+
+As all of the above is triggered by a _sound occurring_, it is often the case that parameters may not be
+modified continuously in time. For example,
+
+
+
+Will not produce a continually LFO'd low-pass filter due to the `tri` only being sampled every time the note hits
+(in this case the default of once per cycle). You can fake it by introducing more sound-generating events, e.g.:
+
+
+
+Some parameters _do_ induce continuous variations in time, though:
+
+- The ADSR curve (governed by `attack`, `sustain`, `decay`, `release`)
+- The pitch envelope curve (governed by `penv` and its associated ADSR)
+- The FM curve (`fmenv`)
+- The filter envelopes (`lpenv`, `hpenv`, `bpenv`)
+- Tremolo (`tremolo`)
+- Phaser (`phaser`)
+- Vibrato (`vib`)
+- Ducking (`duckorbit`)
+
# Filters
Filters are an essential building block of [subtractive synthesis](https://en.wikipedia.org/wiki/Subtractive_synthesis).
diff --git a/website/src/pages/learn/mini-notation.mdx b/website/src/pages/learn/mini-notation.mdx
index 7dbf67ce2..02ead7434 100644
--- a/website/src/pages/learn/mini-notation.mdx
+++ b/website/src/pages/learn/mini-notation.mdx
@@ -168,6 +168,20 @@ Using "!" we can repeat without speeding up:
*2")`} punchcard />
+## Randomness
+
+Events with a "?" placed after them will have a 50% chance of being removed from the pattern:
+
+
+
+Adding a number between 0 and 1 after the "?" will affect the likelihood of the event being removed. For example, events with "?0.1" placed after them will have a 10% chance of being removed:
+
+
+
+Events separated by a "|" will be chosen from at random:
+
+
+
## Mini-notation review
To recap what we've learned so far, compare the following patterns:
@@ -179,6 +193,8 @@ To recap what we've learned so far, compare the following patterns:
*2")`} />
*2")`} />
*2")`} />
+*2")`} />
+*2")`} />
## Euclidian rhythms
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.
diff --git a/website/src/pages/learn/xen.mdx b/website/src/pages/learn/xen.mdx
new file mode 100644
index 000000000..b8b27ac7f
--- /dev/null
+++ b/website/src/pages/learn/xen.mdx
@@ -0,0 +1,95 @@
+---
+title: Xen Harmonic Functions
+layout: ../../layouts/MainLayout.astro
+---
+
+import { MiniRepl } from '../../docs/MiniRepl';
+import { JsDoc } from '../../docs/JsDoc';
+
+# Xen Harmonic Functions
+
+These functions allow the use of scales other than your typical chromatic 12 based ones.
+
+### tune(scale)
+
+
+
+Here's an example of how to configure a basic hexany scale:
+
+
+
+Try other scales like `hexany1`, `iraq`, `gumbeng`, `gunkali`, or `tranh3`
+
+For a full list of available scales from tunejs, see http://abbernie.github.io/tune/scales.html
+
+You can set your root to be a particular note with `getFreq`
+
+
+
+Some tunings become more pronounced with a longer reverb decay:
+
+ -".tune("gumbeng")
+ .mul(getFreq('c3'))
+ .freq().clip(.8).room("3:10").rdim(10000).rfade(5)`}
+/>
+
+Additionally, you can combo this with `fmap` so that the base note changes:
+
+".fmap(getFreq))
+ .freq().legato("2 .7").room("1:15").rdim(8500).rlp(14000).rfade(8)`}
+/>
+
+Combining this with various polyrhythm tricks can become very evocative:
+
+ ~ ~,<-4 -5>"
+ .transpose(4)
+ .tune("iraq")
+ .mul("".fmap(getFreq))
+ .freq().clip(.5).room(1).rfade(9)`}
+/>
+
+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:
+
+
+
+Notes 7 and 9 will clash quite a bit if you arp them normally. Many tunings will have this sort of sound, and it can feel distracting on its own.
+See how close they are on the pitch wheel?
+
+
+
+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:
+
+")
+ .tune("sanza")
+ .mul(getFreq('c3')).freq()
+ .legato("3").room(1).rfade(5)`}
+/>
+
+Note the legato and reverb effects make sure the sound of the strumming gets to wash together. Alternating the direction of the strum can make the
+tones sound even more alive, too.
+
+The `tranh3` tuning has a similar set of notes, with two clashing. You might trying plugging that in above and see if you find a favorite strumming pattern.
diff --git a/website/src/pages/technical-manual/repl.mdx b/website/src/pages/technical-manual/repl.mdx
index f53efac41..8c4287af5 100644
--- a/website/src/pages/technical-manual/repl.mdx
+++ b/website/src/pages/technical-manual/repl.mdx
@@ -9,7 +9,9 @@ import { MiniRepl } from '../../docs/MiniRepl';
{/* The [REPL](https://strudel.cc/) is the place where all packages come together to form a live coding system. It can also be seen as a reference implementation for users of the library. */}
-While Strudel can be used as a library in any JavaScript codebase, its main, reference user interface is the Strudel REPL^[REPL stands for read, evaluate, print/play, loop. It is friendly jargon for an interactive programming interface from computing heritage, usually for a commandline interface but also applied to live coding editors.], which is a browser-based live coding environment. This live code editor is dedicated to manipulating Strudel patterns while they play. The REPL features built-in visual feedback, highlighting which elements in the patterned (mini-notation) sequences are influencing the event that is currently being played. This feedback is designed to support both learning and live use of Strudel.
+While Strudel can be used as a library in any JavaScript codebase, its main, reference user interface is the Strudel REPL[^1], which is a browser-based live coding environment. This live code editor is dedicated to manipulating Strudel patterns while they play. The REPL features built-in visual feedback, highlighting which elements in the patterned (mini-notation) sequences are influencing the event that is currently being played. This feedback is designed to support both learning and live use of Strudel.
+
+[^1]: REPL stands for read, evaluate, print/play, loop. It is friendly jargon for an interactive programming interface from computing heritage, usually for a commandline interface but also applied to live coding editors.
Besides a UI for playback control and meta information, the main part of the REPL interface is the code editor powered by CodeMirror. In it, the user can edit and evaluate pattern code live, using one of the available synthesis outputs to create music and/or sound art. The control flow of the REPL follows 3 basic steps:
diff --git a/website/src/pages/workshop/first-sounds.mdx b/website/src/pages/workshop/first-sounds.mdx
index 74daf4bab..1d659972b 100644
--- a/website/src/pages/workshop/first-sounds.mdx
+++ b/website/src/pages/workshop/first-sounds.mdx
@@ -262,14 +262,14 @@ It is quite common that there are many ways to express the same idea.
**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.
diff --git a/website/src/repl/Repl.css b/website/src/repl/Repl.css
index 498679090..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;
@@ -112,6 +114,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/button/action-button.jsx b/website/src/repl/components/button/action-button.jsx
new file mode 100644
index 000000000..d589b7bed
--- /dev/null
+++ b/website/src/repl/components/button/action-button.jsx
@@ -0,0 +1,10 @@
+import cx from '@src/cx.mjs';
+
+export function ActionButton({ children, label, labelIsHidden, className, ...buttonProps }) {
+ return (
+
+ );
+}
diff --git a/website/src/repl/components/panel/PatternsTab.jsx b/website/src/repl/components/panel/PatternsTab.jsx
index 8a43e4a1b..8e2b75b96 100644
--- a/website/src/repl/components/panel/PatternsTab.jsx
+++ b/website/src/repl/components/panel/PatternsTab.jsx
@@ -12,8 +12,8 @@ import { useMemo } from 'react';
import { getMetadata } from '../../../metadata_parser.js';
import { useExamplePatterns } from '../../useExamplePatterns.jsx';
import { parseJSON, isUdels } from '../../util.mjs';
-import { ButtonGroup } from './Forms.jsx';
-import { settingsMap, useSettings } from '../../../settings.mjs';
+import { useSettings } from '../../../settings.mjs';
+import { ActionButton } from '../button/action-button.jsx';
import { Pagination } from '../pagination/Pagination.jsx';
import { useState } from 'react';
import { useDebounce } from '../usedebounce.jsx';
@@ -75,15 +75,6 @@ function PatternButtons({ patterns, activePattern, onClick, started }) {
);
}
-function ActionButton({ children, onClick, label, labelIsHidden }) {
- return (
-
- );
-}
-
const updateCodeWindow = (context, patternData, reset = false) => {
context.handleUpdate(patternData, reset);
};
diff --git a/website/src/repl/components/panel/Reference.jsx b/website/src/repl/components/panel/Reference.jsx
index 1b617341b..6007667fa 100644
--- a/website/src/repl/components/panel/Reference.jsx
+++ b/website/src/repl/components/panel/Reference.jsx
@@ -2,9 +2,32 @@ 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;
+ 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 synonymsWithDoc = [doc.name, ...synonyms].filter((x) => x && x !== s);
+ functions.push({
+ ...doc,
+ name: s, // update names for the synonym
+ longname: s,
+ synonyms: synonymsWithDoc,
+ synonyms_text: synonymsWithDoc.join(', '),
+ });
+ }
+ }
+ 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');
diff --git a/website/src/repl/components/panel/SettingsTab.jsx b/website/src/repl/components/panel/SettingsTab.jsx
index 26c9ae287..80daef018 100644
--- a/website/src/repl/components/panel/SettingsTab.jsx
+++ b/website/src/repl/components/panel/SettingsTab.jsx
@@ -74,6 +74,7 @@ const fontFamilyOptions = {
FiraCode: 'FiraCode',
'FiraCode-SemiBold': 'FiraCode SemiBold',
teletext: 'teletext',
+ tic80: 'tic80',
mode7: 'mode7',
BigBlueTerminal: 'BigBlueTerminal',
x3270: 'x3270',
diff --git a/website/src/repl/components/panel/SoundsTab.jsx b/website/src/repl/components/panel/SoundsTab.jsx
index a976eb3d2..0484da02d 100644
--- a/website/src/repl/components/panel/SoundsTab.jsx
+++ b/website/src/repl/components/panel/SoundsTab.jsx
@@ -2,16 +2,21 @@ import useEvent from '@src/useEvent.mjs';
import { useStore } from '@nanostores/react';
import { getAudioContext, soundMap, connectToDestination } from '@strudel/webaudio';
import { useMemo, useRef, useState } from 'react';
-import { settingsMap, useSettings } from '../../../settings.mjs';
+import { settingsMap, soundFilterType, useSettings } from '../../../settings.mjs';
import { ButtonGroup } from './Forms.jsx';
import ImportSoundsButton from './ImportSoundsButton.jsx';
import { Textbox } from '../textbox/Textbox.jsx';
+import { ActionButton } from '../button/action-button.jsx';
+import { confirmDialog } from '@src/repl/util.mjs';
+import { clearIDB, userSamplesDBConfig } from '@src/repl/idbutils.mjs';
+import { prebake } from '@src/repl/prebake.mjs';
const getSamples = (samples) =>
Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1;
export function SoundsTab() {
const sounds = useStore(soundMap);
+
const { soundsFilter } = useSettings();
const [search, setSearch] = useState('');
const { BASE_URL } = import.meta.env;
@@ -27,18 +32,19 @@ export function SoundsTab() {
.sort((a, b) => a[0].localeCompare(b[0]))
.filter(([name]) => name.toLowerCase().includes(search.toLowerCase()));
- if (soundsFilter === 'user') {
+ if (soundsFilter === soundFilterType.USER) {
return filtered.filter(([_, { data }]) => !data.prebake);
}
- if (soundsFilter === 'drums') {
+ if (soundsFilter === soundFilterType.DRUMS) {
return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag === 'drum-machines');
}
- if (soundsFilter === 'samples') {
+ if (soundsFilter === soundFilterType.SAMPLES) {
return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag !== 'drum-machines');
}
- if (soundsFilter === 'synths') {
+ if (soundsFilter === soundFilterType.SYNTHS) {
return filtered.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type));
}
+ //TODO: tidy this up, it does not need to be saved in settings
if (soundsFilter === 'importSounds') {
return [];
}
@@ -57,10 +63,10 @@ export function SoundsTab() {
});
});
return (
-
+
setSearch(v)} />
-
+
settingsMap.setKey('soundsFilter', value)}
@@ -74,7 +80,26 @@ export function SoundsTab() {
>
-
+ {soundsFilter === soundFilterType.USER && soundEntries.length > 0 && (
+
{
+ try {
+ const confirmed = await confirmDialog('Delete all imported user samples?');
+ if (confirmed) {
+ clearIDB(userSamplesDBConfig.dbName);
+ soundMap.set({});
+ await prebake();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }}
+ />
+ )}
+
+
{soundEntries.map(([name, { data, onTrigger }]) => {
return (
);
diff --git a/website/src/repl/idbutils.mjs b/website/src/repl/idbutils.mjs
index 5fc62c576..f26ee0479 100644
--- a/website/src/repl/idbutils.mjs
+++ b/website/src/repl/idbutils.mjs
@@ -12,17 +12,21 @@ export const userSamplesDBConfig = {
};
// deletes all of the databases, useful for debugging
-function clearIDB() {
+function clearAllIDB() {
window.indexedDB
.databases()
.then((r) => {
- for (var i = 0; i < r.length; i++) window.indexedDB.deleteDatabase(r[i].name);
+ for (var i = 0; i < r.length; i++) clearIDB(r[i].name);
})
.then(() => {
alert('All data cleared.');
});
}
+export function clearIDB(dbName) {
+ return window.indexedDB.deleteDatabase(dbName);
+}
+
// queries the DB, and registers the sounds so they can be played
export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete = () => {}) {
openDB(config, (objectStore) => {
diff --git a/website/src/settings.mjs b/website/src/settings.mjs
index 3d99b656c..9c3d78146 100644
--- a/website/src/settings.mjs
+++ b/website/src/settings.mjs
@@ -8,6 +8,14 @@ export const audioEngineTargets = {
osc: 'osc',
};
+export const soundFilterType = {
+ USER: 'user',
+ DRUMS: 'drums',
+ SAMPLES: 'samples',
+ SYNTHS: 'synths',
+ ALL: 'all',
+};
+
export const defaultSettings = {
activeFooter: 'intro',
keybindings: 'codemirror',
@@ -28,7 +36,7 @@ export const defaultSettings = {
fontSize: 18,
latestCode: '',
isZen: false,
- soundsFilter: 'all',
+ soundsFilter: soundFilterType.ALL,
patternFilter: 'community',
// panelPosition: window.innerWidth > 1000 ? 'right' : 'bottom', //FIX: does not work on astro
panelPosition: 'right',
diff --git a/website/src/styles/index.css b/website/src/styles/index.css
index 7fa4b2df8..41b282ff4 100644
--- a/website/src/styles/index.css
+++ b/website/src/styles/index.css
@@ -50,6 +50,11 @@
src: url('/fonts/teletext/EuropeanTeletext.ttf');
size-adjust: 90%;
}
+@font-face {
+ font-family: 'tic80';
+ src: url('/fonts/tic80/tic-80-wide-font.otf');
+ size-adjust: 60%;
+}
@font-face {
font-family: 'mode7';
src: url('/fonts/mode7/MODE7GX3.TTF');