diff --git a/examples/codemirror-repl/main.js b/examples/codemirror-repl/main.js index 5b5714fb7..5b5f20289 100644 --- a/examples/codemirror-repl/main.js +++ b/examples/codemirror-repl/main.js @@ -29,6 +29,7 @@ const editor = new StrudelMirror({ import('@strudel/core'), import('@strudel/draw'), import('@strudel/mini'), + import('@strudel/edo'), import('@strudel/tonal'), import('@strudel/webaudio'), ); diff --git a/examples/codemirror-repl/package.json b/examples/codemirror-repl/package.json index 0a65eb71c..22fbde0c8 100644 --- a/examples/codemirror-repl/package.json +++ b/examples/codemirror-repl/package.json @@ -15,6 +15,7 @@ "@strudel/codemirror": "workspace:*", "@strudel/core": "workspace:*", "@strudel/draw": "workspace:*", + "@strudel/edo": "workspace:*", "@strudel/mini": "workspace:*", "@strudel/soundfonts": "workspace:*", "@strudel/tonal": "workspace:*", diff --git a/index.mjs b/index.mjs index 08339bdea..6576be47f 100644 --- a/index.mjs +++ b/index.mjs @@ -4,6 +4,7 @@ export * from './packages/core/index.mjs'; export * from './packages/csound/index.mjs'; export * from './packages/desktopbridge/index.mjs'; export * from './packages/draw/index.mjs'; +export * from './packages/edo/index.mjs'; export * from './packages/embed/index.mjs'; export * from './packages/hydra/index.mjs'; export * from './packages/midi/index.mjs'; diff --git a/package.json b/package.json index b74c85753..26a20aec3 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "homepage": "https://strudel.cc", "dependencies": { "@strudel/core": "workspace:*", + "@strudel/edo": "workspace:*", "@strudel/mini": "workspace:*", "@strudel/tonal": "workspace:*", "@strudel/transpiler": "workspace:*", diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 6014b9b0a..6aac0c8a4 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -230,7 +230,7 @@ export class StrudelMirror { afterEval: (options) => { // remember for when highlighting is toggled on this.miniLocations = options.meta?.miniLocations || []; - this.widgets = options.meta?.widgets; + this.widgets = options.meta?.widgets || []; const sliders = this.widgets.filter((w) => w.type === 'slider'); const widgets = this.widgets.filter((w) => w.type !== 'slider'); @@ -293,6 +293,9 @@ export class StrudelMirror { // Handle global evaluation requests (e.g., from Vim :w) this.onEvaluateRequest = (e) => { try { + if (e.detail.view !== this.editor) { + return; // ignore events from other editors + } // Evaluate current editor on repl-evaluate logger('[repl] evaluate via event'); this.evaluate(); @@ -307,6 +310,9 @@ export class StrudelMirror { // Toggle comments requested from Vim (gc) this.onToggleComment = (e) => { try { + if (e.detail.view !== this.editor) { + return; // ignore events from other editors + } // Honor selections; toggleLineComment handles both selections and // single line toggleLineComment(this.editor); @@ -347,6 +353,9 @@ export class StrudelMirror { // Listen for global stop requests (e.g., from Vim :q) onStopRequest = (e) => { try { + if (e.detail.view !== this.editor) { + return; // ignore events from other editors + } this.stop(); e?.cancelable && e.preventDefault?.(); } catch (err) { diff --git a/packages/codemirror/index.mjs b/packages/codemirror/index.mjs index 2d5b3de21..31de6accb 100644 --- a/packages/codemirror/index.mjs +++ b/packages/codemirror/index.mjs @@ -5,3 +5,4 @@ export * from './slider.mjs'; export * from './themes.mjs'; export * from './widget.mjs'; export { Vim } from './keybindings.mjs'; +export * from './basicSetup.mjs'; diff --git a/packages/codemirror/keybindings.mjs b/packages/codemirror/keybindings.mjs index b8d9a6167..16c7ec99e 100644 --- a/packages/codemirror/keybindings.mjs +++ b/packages/codemirror/keybindings.mjs @@ -96,7 +96,7 @@ try { // internal actions and works with current selections/visual mode. try { Vim.defineAction('strudelToggleComment', (cm) => { - const view = cm?.view || cm; + const view = cm.cm6; try { const ev = new CustomEvent('repl-toggle-comment', { detail: { source: 'vim', view }, cancelable: true }); document.dispatchEvent(ev); @@ -112,15 +112,16 @@ try { // :q to pause/stop Vim.defineEx('quit', 'q', (cm) => { - const view = cm?.view || cm; + const view = cm.cm6; + logger('[vim] :q — stopping repl'); replStop(view); }); // :w to evaluate Vim.defineEx('write', 'w', (cm) => { - const view = cm?.view || cm; // CM6 Vim passes either an object with view or the view itself + const view = cm.cm6; try { - view?.focus?.(); + view.focus?.(); // Let the app know this came from Vim :w try { logger('[vim] :w — evaluating code'); diff --git a/packages/codemirror/package.json b/packages/codemirror/package.json index 268be3061..d2432ef83 100644 --- a/packages/codemirror/package.json +++ b/packages/codemirror/package.json @@ -32,13 +32,13 @@ }, "homepage": "https://codeberg.org/uzu/strudel#readme", "dependencies": { - "@codemirror/autocomplete": "^6.18.4", - "@codemirror/commands": "^6.8.0", - "@codemirror/lang-javascript": "^6.2.2", - "@codemirror/language": "^6.10.8", - "@codemirror/search": "^6.5.8", - "@codemirror/state": "^6.5.1", - "@codemirror/view": "^6.36.2", + "@codemirror/autocomplete": "catalog:", + "@codemirror/commands": "catalog:", + "@codemirror/lang-javascript": "catalog:", + "@codemirror/language": "catalog:", + "@codemirror/search": "catalog:", + "@codemirror/state": "catalog:", + "@codemirror/view": "catalog:", "@lezer/highlight": "^1.2.1", "@nanostores/persistent": "^0.10.2", "@replit/codemirror-emacs": "^6.1.0", diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index d5bb275bf..7971f1ed9 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -1906,13 +1906,19 @@ export const { delayfeedback, delayfb, dfb } = registerControl('delayfeedback', */ export const { delayspeed } = registerControl('delayspeed'); -/* +/** + * Sets the time of the delay effect in seconds. + * * @name delaytime * @tags orbit, superdough, supradough - * @param {number | Pattern} delaytime sets the time of the delay effect. + * @param {number | Pattern} delay in seconds * @synonyms delayt, dt * @example - * note("d d a# a".fast(2)).s("sawtooth").delay(.8).delaytime(1/2).delayspeed("<2 .5 -1 -2>") + * 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'); diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index b63a702f8..c5b7953c9 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -1056,7 +1056,55 @@ function _composeOp(a, b, func) { // pattern composers const COMPOSERS = { + /** + * When called on a pattern `a`, with a input pattern `b` (`a.set(b)`), + * combines `a` and `b` such that anything defined in `b` + * and anything defined in `a` that is *not* defined in `b` + * will be in the resulting pattern. + * + * The structure is maintained from `a`, + * because the default pattern alignment is `in`, + * see the section on `Pattern Alignment` + * in the technical manual in the docs + * + * This is the inverse of `keep` + * + * See examples below + * @name set + * @param {Pattern} pat + * @returns {Pattern} + * @memberof Pattern + * @tags internal, combiners + * @example + * // because input pattern has `s` set, + * // it overrides the "sine" declared earlier + * note("c a f e").s("sine").set(s("triangle")) + */ set: [(a, b) => b], + /** + * When called on a pattern `a`, with a input pattern `b` (`a.keep(b)`), + * combines `a` and `b` such that anything defined in `a`, + * and anything defined in `b` that is *not* defined in `a` + * will be in the resulting pattern + * + * The structure is maintained from `a`, + * because the default pattern alignment is `in`, + * see the section on `Pattern Alignment` + * in the technical manual in the docs + * + * This is the inverse of `set` + * + * See examples below + * @name keep + * @param {Pattern} pat + * @memberof Pattern + * @returns {Pattern} + * @tags internal, combiners + * @example + * // notes, already defined, will stay "c a f e", + * // while "s", not defined, will be set to "piano" + * note("c a f e").keep(note("e f a c").s("piano")) + */ keep: [(a) => a], keepif: [(a, b) => (b ? a : undefined)], @@ -1110,7 +1158,6 @@ const COMPOSERS = { div: [numeralArgs((a, b) => a / b)], mod: [numeralArgs(_mod)], pow: [numeralArgs(Math.pow)], - log2: [numeralArgs(Math.log2)], band: [numeralArgs((a, b) => a & b)], bor: [numeralArgs((a, b) => a | b)], bxor: [numeralArgs((a, b) => a ^ b)], @@ -1816,6 +1863,8 @@ export const floor = register('floor', function (pat) { return pat.asNumber().fmap((v) => Math.floor(v)); }); +export const log2 = register('log2', (pat) => pat.asNumber().fmap((v) => Math.log2(v))); + /** * Assumes a numerical pattern. Returns a new pattern with all values set to * their mathematical ceiling. E.g. `3.2` replaced with `4`, and `-4.2` diff --git a/packages/core/signal.mjs b/packages/core/signal.mjs index 2f9fe4b18..4398e2ab7 100644 --- a/packages/core/signal.mjs +++ b/packages/core/signal.mjs @@ -316,7 +316,7 @@ export const run = (n) => saw.range(0, n).round().segment(n); * // "hh".s().struct("1 0 1") */ export const binary = (n) => { - const nBits = reify(n).log2(0).floor().add(1); + const nBits = reify(n).log2().floor().add(1); return binaryN(n, nBits); }; @@ -348,7 +348,7 @@ export const binaryN = (n, nBits = 16) => { * .partials(binaryL(irand(4096).add(1))) */ export const binaryL = (n) => { - const nBits = reify(n).log2(0).floor().add(1); + const nBits = reify(n).log2().floor().add(1); return binaryNL(n, nBits); }; @@ -389,7 +389,9 @@ export const randL = (n) => { export const randrun = (n) => { return signal((t, controls) => { // Without adding 0.5, the first cycle is always 0,1,2,3,... - const rands = getRandsAtTime(t.floor().add(0.5), n, controls.randSeed); + let rands = getRandsAtTime(t.floor().add(0.5), n, controls.randSeed); + // Support n = 1 + if (!Array.isArray(rands)) rands = [rands]; const nums = rands .map((n, i) => [n, i]) .sort((a, b) => (a[0] > b[0]) - (a[0] < b[0])) diff --git a/packages/core/test/signal.test.mjs b/packages/core/test/signal.test.mjs index b02f8a08b..26c1d656a 100644 --- a/packages/core/test/signal.test.mjs +++ b/packages/core/test/signal.test.mjs @@ -9,7 +9,7 @@ import Fraction from 'fraction.js'; import { describe, it, expect, vi } from 'vitest'; import { saw, saw2, isaw, isaw2, per, perx, cyclesPer } from '../signal.mjs'; -import { fastcat, sequence, State, TimeSpan, Hap } from '../index.mjs'; +import { fastcat, sequence, State, TimeSpan, Hap, note } from '../index.mjs'; const st = (begin, end) => new State(ts(begin, end)); const ts = (begin, end) => new TimeSpan(Fraction(begin), Fraction(end)); @@ -59,3 +59,9 @@ describe('perx', () => { ); }); }); + +describe('shuffle', () => { + it('returns original pattern if input is 1', () => { + expect(note('c d e f').sound('piano').shuffle(1).firstCycle()).toEqual(note('c d e f').sound('piano').firstCycle()); + }); +}); diff --git a/packages/draw/pitchwheel.mjs b/packages/draw/pitchwheel.mjs index f9dacc53f..aa9cd21c6 100644 --- a/packages/draw/pitchwheel.mjs +++ b/packages/draw/pitchwheel.mjs @@ -54,11 +54,42 @@ export function pitchwheel({ } if (edo) { + edo = haps.length >= 1 && haps[0].value && haps[0].value.edo ? haps[0].value.edo : edo; + root = haps.length >= 1 && haps[0].value && haps[0].value.root ? haps[0].value.root : root; + const degreeIndexes = + haps.length >= 1 && haps[0].value && haps[0].value.degreeIndexes ? haps[0].value.degreeIndexes : null; + const intLabels = haps.length >= 1 && haps[0].value && haps[0].value.intLabels ? haps[0].value.intLabels : null; + ctx.font = '20px sans-serif'; + const label = `${edo} EDO`; + // Draw EDO label: + ctx.fillText(label, centerX + radius - ctx.measureText(label).width + 15, centerY + radius); Array.from({ length: edo }, (_, i) => { const angle = freq2angle(root * Math.pow(2, i / edo), root); + const [x, y] = circlePos(centerX, centerY, radius, angle); ctx.beginPath(); - ctx.arc(x, y, hapRadius, 0, 2 * Math.PI); + // Draw interval label for degree i when it exists: + if (degreeIndexes === null || degreeIndexes.includes(i)) { + ctx.globalAlpha = 1; + ctx.arc(x, y, hapRadius, 0, 2 * Math.PI); + if (intLabels !== null) { + const degree = degreeIndexes.indexOf(i); + if (intLabels[degree]) { + if (angle < 0.32 && angle > 0.125) { + ctx.fillText(intLabels[degree], x - 34, y); + } else { + if (angle < 0.1 && angle > -1.125) { + ctx.fillText(intLabels[degree], x - 7, y - 12); + } else { + ctx.fillText(intLabels[degree], x + 9, y); + } + } + } + } + } else { + ctx.globalAlpha = 0.15; + ctx.arc(x, y, hapRadius, 0, 2 * Math.PI); + } ctx.fill(); }); ctx.stroke(); diff --git a/packages/edo/README.md b/packages/edo/README.md new file mode 100644 index 000000000..482ad9c13 --- /dev/null +++ b/packages/edo/README.md @@ -0,0 +1,41 @@ +# @strudel/edo + +This package adds EDO scale functions to strudel Patterns. + +## Install + +```sh +npm i @strudel/edo --save +``` + +## Example + +```js +import { n } from '@strudel/core'; +import '@strudel/edo'; + +// E.g. edoScale for Gorgo-6 scale, 16 EDO, LLsLLLs +// base note C3, large step size 3, small step size 1: +// C3:LLsLLL:3:1 +const [baseNote, sequence, largeStep, smallStep] = ['C3', 'LLsLLL', 3, 1] +const pattern = n("0 2 4 6 4 2").edoScale([baseNote, sequence, largeStep, smallStep]); + +const events = pattern.firstCycle().map((e) => e.show()); +console.log(events); +``` + +yields: +``` +[ + "[ 0/1 → 1/1 | + { + \"degree\":1, + \"degreeIndexes\":[0,3,6,7,10,13], + \"intLabels\":[null,\"S2\",\"d4\",\"N4\",\"s6\",\"s7\",\"P8\"], + \"root\":\"130.8128\", + \"freq\":130.813, + \"edo\":16 + } + ]" +] +``` diff --git a/packages/edo/edo.mjs b/packages/edo/edo.mjs new file mode 100644 index 000000000..1e99c2434 --- /dev/null +++ b/packages/edo/edo.mjs @@ -0,0 +1,114 @@ +/* +edo.mjs - Equal division of the octave (EDO) scale functions for strudel +Copyright (C) 2025 Rob McKinnon and Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +import { register, pure, noteToMidi, isNote, tokenizeNote } from '@strudel/core'; +import { EdoScale } from './edoscale.mjs'; +import { Intervals } from './intervals.mjs'; +import { Pitches } from './pitches.mjs'; + +const pitchesCache = new Map(); + +/** + * Turns numbers into notes in the given EDO scale (zero indexed). + * + * An EDO scale definition looks like this: + * + * e.g. C:LLsLLLs:2:1 <- this is the C major scale, 12 EDO + * + * e.g. C:LLsLLL:3:1 <- this is the Gorgo 6 note scale, 16 EDO + * + * An EDO scale, e.g. C:LLsLLLs:2:1, consists of a root note (e.g. C) + * followed by semicolon (':') + * and then a [Large/small step notation sequence](https://en.xen.wiki/w/MOS_scale) + * (e.g. LLsLLLs) + * followed by semicolon, then the large step size (e.g. 2) + * followed by semicolon, then the small step size (e.g. 1). + * + * The number of divisions of the octave is calculated as the sum + * of the steps in the EDO scale definition. + * + * e.g. C:LLsLLLs:2:1 is 2+2+1+2+2+2+1 = 12 EDO, 7 note scale + * + * e.g. C:LLsLLL:3:1 is 3+3+1+3+3+3 = 16 EDO, 6 note scale + * + * The root note defaults to octave 3, if no octave number is given. + * + * @name edoScale + * @param {string} scale Definition of EDO scale. + * @returns Pattern + * @example + * n("0 2 4 6 4 2").edoScale("C:LLsLLLs:2:1") + * @example + * n("[0,7] 4 [2,7] 4") + * .edoScale("G2::3:1") + * .s("piano")._pitchwheel() + * @example + * n(rand.range(0,5).segment(6)) + * .edoScale(":LLsLL:3:1") + * .s("piano")._pitchwheel() + */ +export const edoScale = register( + 'edoScale', + function (scaleDefinition, pat) { + // console.log(scaleDefinition); + + // if (Array.isArray(scale)) { + const key = scaleDefinition.flat().join(':'); + // } + // console.log(scaleDefinition); + let pitches; + if (pitchesCache.has(key)) { + pitches = pitchesCache.get(key); + } else { + // console.log({ key }); + const [base_note, sequence, large, small] = scaleDefinition; + const root_octave = tokenizeNote(base_note)[2] || 3; + // console.log({ root_octave }); + const scale = new EdoScale(large, small, sequence); + const intervals = new Intervals(scale); + // console.log({ intervals }); + pitches = new Pitches(scale, intervals, 440, noteToMidi(base_note), root_octave); + pitchesCache.set(key, pitches); + // console.log({ base_note: noteToMidi(base_note) }); + // console.log({ sequence }); + // console.log({ edivisions: scale.edivisions }); + // console.log({ intervals }); + // console.log({ pat }); + // console.log({ pitches: pitches }); + } + return pat + .fmap((value) => { + const isObject = typeof value === 'object'; + const n = isObject ? value.n : value; + if (isObject) { + delete value.n; // remove n so it won't cause trouble + } + if (isNote(n)) { + // legacy.. + return pure(n); + } + const deg = (typeof n === 'string' ? parseInt(n, 10) : Number.isInteger(n) ? n : Math.round(n)) + 1; + + const [oct, degree] = pitches.octdeg(deg); + const freq = pitches.octdegfreq(oct, degree); + const note = pitches.octdegmidi(oct, degree); + const edo = pitches.scale.edivisions; + const root = pitches.base_freq; + const degreeIndexes = pitches.scale.divisions; + const intLabels = pitches.intervals.intLabels; + // const color = 'red'; + value = pure(isObject ? { ...value, degree, degreeIndexes, intLabels, root, freq, edo } : note); + // value = pure(isObject ? { ...value, key } : note); + // value = pure(isObject ? { ...value, edo } : note); + // console.log({ value }); + return value; + }) + .outerJoin() + .withHap((hap) => hap.setContext({ ...hap.context, scaleDefinition })); + }, + true, + true, // preserve tactus +); diff --git a/packages/edo/edoscale.mjs b/packages/edo/edoscale.mjs new file mode 100644 index 000000000..032054089 --- /dev/null +++ b/packages/edo/edoscale.mjs @@ -0,0 +1,213 @@ +/* +edoscale.mjs - EdoScale defines equal division of the octave (EDO) scale in Ls notation + - Port of pitfalls/lib/Scale.lua - see +Copyright (C) 2025 Rob McKinnon and Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ +const M = 2; +const L = 1; +const S = 0; +const LABELS = ['s', 'L', 'M']; + +export class EdoScale { + constructor(large, small, sequence, medium) { + this.stepbackup = [L, L, S, L, L, L, S, L, L, L, S, L, L, L, S, L]; + this.large = large; + this.medium = medium || large; + this.small = small; + this.divisions = []; + this.edivisions = null; + this.sequence = null; + this.tonic = 1; + this.mode = 1; + this.max_steps = 12; + this.min_steps = 3; + this.setSequence(sequence); + } + + hasMedium() { + return this.step.some((_, i) => this.step[this.offset(i)] === M); + } + + stepSize(i) { + return LABELS[this.step[this.offset(i)]]; + } + + sequence() { + return this.step.map((_, i) => this.stepSize(i)).join(''); + } + + stepValue(i) { + const step = this.step[this.offset(i)]; + return step === L ? this.large : step === M ? this.medium : this.small; + } + + offset(i) { + if (this.mode === 1) { + return i; + } else { + const offset = (this.mode - 1 + i) % this.length; + return offset === 0 ? this.length : offset; + } + } + + static setMaxSteps(max) { + this.max_steps = max; + } + + static setMinSteps(min) { + this.min_steps = min; + } + + setSequence(sequence) { + if (this.sequence !== sequence) { + this.sequence = sequence; + this.length = sequence.length; + this.step = []; + for (let i = 0; i < sequence.length; i++) { + const char = sequence[i]; + this.step[i] = char === 'L' ? L : char === 'M' ? M : S; + } + this.updateEdo(); + } else { + return false; + } + } + + setLarge(l) { + if (this.large !== l) { + this.large = l; + this.updateEdo(); + } else { + return false; + } + } + + setMedium(m) { + if (this.medium !== m) { + this.medium = m; + this.updateEdo(); + } else { + return false; + } + } + + setSmall(s) { + if (this.small !== s) { + this.small = s; + this.updateEdo(); + } else { + return false; + } + } + + setMode(mode) { + this.mode = mode; + } + + setTonic(tonic) { + this.tonic = tonic; + } + + changeMode(d) { + const orig = this.mode; + this.mode = Math.max(1, Math.min(this.mode + d, this.length)); + return orig !== this.mode; + } + + changeTonic(d) { + const orig = this.tonic; + this.tonic = Math.max(1, Math.min(this.tonic + d, this.edivisions)); + return orig !== this.tonic; + } + + updateEdo() { + const orig = this.edivisions; + this.edivisions = this.step.reduce((sum, _, i) => { + this.divisions[i] = sum; + return sum + this.stepValue(i); + }, 0); + // console.log(this.divisions); + const changed = orig !== this.edivisions; + if (changed) { + this.tonic = Math.max(1, Math.min(this.tonic, this.edivisions)); + } + return changed; + } + + changeStep(d, i) { + const index = this.offset(i); + const orig = this.step[index]; + this.step[index] = Math.max(S, Math.min(this.step[index] + d, M)); + this.stepbackup[index] = this.step[index]; + const changed = orig !== this.step[index]; + if (changed) { + this.updateEdo(); + } + return changed; + } + + changeLarge(d) { + const orig = this.large; + this.setLarge(Math.max(this.small + 1, Math.min(this.large + d, this.large + 1))); + const changed = this.large !== orig; + if (changed) { + if (this.large <= this.medium) { + this.setMedium(Math.max(this.small + 1, Math.min(this.large - 1, this.large))); + } + this.updateEdo(); + } + return changed; + } + + changeMedium(d) { + const orig = this.medium; + this.setMedium(Math.max(this.small + 1, Math.min(this.medium + d, this.large - 1))); + const changed = this.medium !== orig; + if (changed) { + this.updateEdo(); + } + return changed; + } + + changeSmall(d) { + const orig = this.small; + const value = this.small + d; + + if (this.hasMedium()) { + this.setSmall(Math.max(1, Math.min(value, this.medium - 1))); + } else { + this.setSmall(Math.max(1, Math.min(value, this.large - 1))); + } + + const changed = this.small !== orig; + if (changed) { + if (this.small >= this.medium) { + this.setMedium(Math.max(this.small + 1, Math.min(this.large))); + } + this.updateEdo(); + } + return changed; + } + + changeLength(d) { + const orig = this.length; + if (d === 1) { + this.length = Math.min(this.length + 1, this.max_steps); + } else if (d === -1) { + this.length = Math.max(this.length - 1, this.min_steps); + } + + const changed = this.length !== orig; + if (changed) { + this.mode = 1; + if (d === 1) { + this.step[this.length] = this.stepbackup[this.length] || L; + } else if (d === -1 && this.length >= this.min_steps) { + this.step.pop(); + } + this.updateEdo(); + } + return changed; + } +} diff --git a/packages/edo/index.mjs b/packages/edo/index.mjs new file mode 100644 index 000000000..0bd5a41a9 --- /dev/null +++ b/packages/edo/index.mjs @@ -0,0 +1,5 @@ +import './edo.mjs'; + +export * from './edo.mjs'; + +export const packageName = '@strudel/edo'; diff --git a/packages/edo/intervals.mjs b/packages/edo/intervals.mjs new file mode 100644 index 000000000..e79afe3d2 --- /dev/null +++ b/packages/edo/intervals.mjs @@ -0,0 +1,103 @@ +/* +intervals.mjs - defines Intervals for equal division of the octave (EDO) scale + - Port of pitfalls/lib/Intervals.lua - see +Copyright (C) 2025 Rob McKinnon and Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ +import ratiointervals from './ratios.mjs'; + +function ratio(division, edivisions) { + return division === 0 ? 1 : Math.pow(2, division / edivisions); +} + +export class Intervals { + constructor(scale) { + this.scale = scale; + this.intLabels = []; + this.intNoms = []; + this.intRatios = []; + this.uniqLabels = []; + this.intErrors = []; + this.ratios = []; + + const BLANK = ''; + let division = 0; + const labToErr = {}; + const labToInd = {}; + + this.ratios[0] = 1; + + for (let i = 0; i < scale.length; i++) { + division += scale.stepValue(i); + this.ratios[i + 1] = ratio(division, scale.edivisions); + + if (i < scale.length) { + const nearest = ratiointervals.nearestInterval(this.ratios[i + 1]); + const closeness = nearest[0]; + const ratio = nearest[1]; + const intLabel = ratiointervals.key(ratio); + this.intLabels[i + 1] = intLabel; + this.intErrors[i + 1] = closeness; + this.intNoms[i + 1] = ratio ? ratiointervals.nom(ratio) : 0; + this.intRatios[i + 1] = ratio ? `${ratiointervals.nom(ratio)}/${ratiointervals.denom(ratio)}` : ''; + this.uniqLabels[i + 1] = BLANK; + + if (intLabel && intLabel !== 'P1' && intLabel !== 'P8') { + if (!labToErr[intLabel]) { + this.uniqLabels[i + 1] = intLabel; + labToInd[intLabel] = i + 1; + labToErr[intLabel] = closeness; + } else if (closeness < labToErr[intLabel]) { + this.uniqLabels[labToInd[intLabel]] = BLANK; + this.uniqLabels[i + 1] = intLabel; + labToInd[intLabel] = i + 1; + labToErr[intLabel] = closeness; + } + } + } + } + } + + ratio(i) { + return this.ratios[i]; + } + + intervalLabel(i) { + return this.intLabels[i]; + } + + intervalNominator(i) { + return this.intNoms[i]; + } + + intervalRatio(i) { + return this.intRatios[i]; + } + + uniqIntervalLabel(i) { + return this.uniqLabels[i]; + } + + intervalError(i) { + return this.intErrors[i]; + } + + nearestDegreeTo(r, threshold) { + let min = 1; + let degree = null; + + for (const [i, v] of Object.entries(this.ratios)) { + const diff = Math.abs((r - v) / r); + if (diff < min) { + min = diff; + degree = parseInt(i, 10); + } + } + + if (threshold == null) { + return degree; + } else { + return min < threshold ? degree : 1; + } + } +} diff --git a/packages/edo/package.json b/packages/edo/package.json new file mode 100644 index 000000000..42097279e --- /dev/null +++ b/packages/edo/package.json @@ -0,0 +1,42 @@ +{ + "name": "@strudel/edo", + "version": "0.1.0", + "description": "Equal division of the octave (EDO) scale functions for strudel", + "main": "index.mjs", + "publishConfig": { + "main": "dist/index.mjs" + }, + "scripts": { + "build": "vite build", + "test": "vitest run", + "prepublishOnly": "npm run build" + }, + "type": "module", + "repository": { + "type": "git", + "url": "git+https://codeberg.org/uzu/strudel.git" + }, + "keywords": [ + "tidalcycles", + "strudel", + "pattern", + "livecoding", + "algorave" + ], + "author": "Rob McKinnon ", + "license": "AGPL-3.0-or-later", + "bugs": { + "url": "https://codeberg.org/uzu/strudel/issues" + }, + "homepage": "https://codeberg.org/uzu/strudel#readme", + "dependencies": { + "@strudel/core": "workspace:*", + "@tonaljs/tonal": "^4.10.0", + "chord-voicings": "^0.0.1", + "webmidi": "^3.1.12" + }, + "devDependencies": { + "vite": "^6.0.11", + "vitest": "^3.0.4" + } +} diff --git a/packages/edo/pitches.mjs b/packages/edo/pitches.mjs new file mode 100644 index 000000000..003e15b72 --- /dev/null +++ b/packages/edo/pitches.mjs @@ -0,0 +1,98 @@ +/* +pitches.mjs - defines Pitches for equal division of the octave (EDO) scale + - Port of pitfalls/lib/Pitches.lua - see +Copyright (C) 2025 Rob McKinnon and Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +function ratio(division, edivisions) { + return division === 0 ? 1 : Math.pow(2, division / edivisions); +} + +// This function gets frequency based on index, edo, octave, and base frequency +function get_freq(base_freq, edo, index, oct, base_octave) { + let f = base_freq * ratio(index - 1, edo); + if (oct < base_octave) { + f /= Math.pow(2, base_octave - oct); + } else if (oct > base_octave) { + f *= Math.pow(2, oct - base_octave); + } + return f; +} + +// Convert MIDI number to Hz using a given tuning multiplier +function midi_to_hz(n, tuning) { + return tuning * Math.pow(2, (n - 69) / 12.0); +} + +const denom = Math.log(2); +function hz_to_midi(freq, tuning) { + return 12.0 * (Math.log(freq / tuning) / denom) + 69; +} + +export class Pitches { + constructor(scale, intervals, tuning, midi_start, root_octave) { + this.scale = scale; + this.intervals = intervals; + this.base_freq = midi_to_hz(midi_start, tuning); + this.root_octave = root_octave; + this.freqs = {}; + this.midis = {}; + this.degrees = {}; + this.octdegfreqs = {}; + this.octdegmidis = {}; + let index = 0; + let f = null; + + for (let oct = 0; oct <= 8; oct++) { + this.octdegfreqs[oct] = {}; + this.octdegmidis[oct] = {}; + f = get_freq(this.base_freq, scale.edivisions, scale.tonic, oct, this.root_octave); + for (let deg = 0; deg < scale.length; deg++) { + index = index + 1; + this.freqs[index] = parseFloat((f * intervals.ratio(deg)).toFixed(3)); + this.midis[index] = parseFloat(hz_to_midi(f * intervals.ratio(deg), tuning).toFixed(4)); + this.degrees[index] = deg; + this.octdegfreqs[oct][deg + 1] = this.freqs[index]; + this.octdegmidis[oct][deg + 1] = this.midis[index]; + } + } + this.base_freq = parseFloat(this.base_freq).toFixed(4); + } + + base_freq() { + return this.base_freq; + } + + degree(index) { + return this.degrees[index]; + } + + freq(index) { + return this.freqs[index]; + } + + octdeg(deg) { + const higherOcatve = deg > this.scale.length; + const octave = this.root_octave + (higherOcatve ? Math.floor((deg - 1) / this.scale.length) : 0); + const degree = higherOcatve ? (deg % this.scale.length === 0 ? this.scale.length : deg % this.scale.length) : deg; + // console.log([octave, degree]); + return [octave, degree]; + } + + octdegfreq(oct, deg) { + if (this.octdegfreqs[oct]) { + return this.octdegfreqs[oct][deg]; + } else { + return null; + } + } + + octdegmidi(oct, deg) { + if (this.octdegmidis[oct]) { + return this.octdegmidis[oct][deg]; + } else { + return null; + } + } +} diff --git a/packages/edo/ratios.mjs b/packages/edo/ratios.mjs new file mode 100644 index 000000000..b431a237d --- /dev/null +++ b/packages/edo/ratios.mjs @@ -0,0 +1,95 @@ +/* +ratios.mjs - lists whole number ratios for pitch intervals + - Port of pitfalls/lib/ratios.lua - see +Copyright (C) 2025 Rob McKinnon and Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ +const ratiointervals = {}; + +// FJS Calculators - https://misotanni.github.io/fjs/en/calc.html +// List of pitch intervals - https://en.wikipedia.org/wiki/List_of_pitch_intervals +// Gallery of just intervals - https://en.xen.wiki/w/Gallery_of_just_intervals +// Two letter codes changed to make different interval labels more unique. +ratiointervals.list = new Map([ + [1, ['P1', 'P1', 'P1', 1, 1]], // unison P1 + [16 / 15, ['m2', 'm2', 'm2_5', 16, 15]], // minor second m2 + [15 / 14, ['A1', 'A1', 'A1^5_7', 15, 14]], // augmented unison + [13 / 12, ['t2', 'm2', 'm2^13', 13, 12]], // tridecimal neutral second + [12 / 11, ['N2', 'M2', 'M2_11', 12, 11]], // undecimal neutral second + [11 / 10, ['n2', 'm2', 'm2^11_5', 11, 10]], // undecimal submajor second + [10 / 9, ['T2', 'M2', 'M2^5', 10, 9]], // classic (whole) tone + [9 / 8, ['M2', 'M2', 'M2', 9, 8]], // major second M2 + [8 / 7, ['S2', 'M2', 'M2_7', 8, 7]], // septimal major second + [7 / 6, ['s3', 'm3', 'm3^7', 7, 6]], // septimal minor third + [19 / 16, ['o3', 'm3', 'm3^19', 19, 16]], // otonal minor third + [6 / 5, ['m3', 'm3', 'm3_5', 6, 5]], // minor third m3 + [17 / 14, ['t3', 'm3', 'm3^17_7', 17, 14]], // septendecimal supraminor third + [11 / 9, ['n3', 'm3', 'm3^11', 11, 9]], // undecimal neutral third + [5 / 4, ['M3', 'M3', 'M3^5', 5, 4]], // major third M3 + [9 / 7, ['S3', 'M3', 'M3_7', 9, 7]], // septimal major third SM3 + [13 / 10, ['d4', 'd4', 'd4^13_5', 13, 10]], // Barbados third + [4 / 3, ['P4', 'P4', 'P4', 4, 3]], // perfect fourth P4 + [19 / 14, ['N4', 'P4', 'P4^19_7', 19, 14]], // undevicesimal wide fourth + [11 / 8, ['n4', 'P4', 'P4^11', 11, 8]], // super-fourth + [25 / 18, ['a4', 'A4', 'A4^5,5', 25, 18]], // classic augmented fourth + [7 / 5, ['sT', 'd5', 'd5^7_5', 7, 5]], // lesser septimal tritone + [45 / 32, ['A4', 'A4', 'A4^5', 45, 32]], // just augmented fourth + [17 / 12, ['d5', 'd5', 'd5^17', 17, 12]], // larger septendecimal tritone + [10 / 7, ['ST', 'A4', 'A4^5_7', 10, 7]], // greater septimal tritone + [13 / 9, ['t5', 'd5', 'd5^13', 13, 9]], // tridecimal diminished fifth + [3 / 2, ['P5', 'P5', 'P5', 3, 2]], // perfect fifth P5 + [14 / 9, ['s6', 'M6', 'm6^7', 14, 9]], // subminor sixth or septimal sixth + [25 / 16, ['a5', 'A5', 'A5^5,5', 25, 16]], // classic augmented fifth + [11 / 7, ['A5', 'P5', 'P5^11_7', 11, 7]], // undecimal minor sixth + [8 / 5, ['m6', 'm6', 'm6_5', 8, 5]], // minor sixth m6 + [13 / 8, ['N6', 'm6', 'm6^13', 13, 8]], // tridecimal neutral sixth + [18 / 11, ['n6', 'M6', 'M6_11', 18, 11]], // undecimal neutral sixth + [5 / 3, ['M6', 'M6', 'M6^5', 5, 3]], // just major sixth M6 + [128 / 75, ['d7', 'd7', 'd7_5,5', 128, 75]], // diminished seventh + [17 / 10, ['T6', 'd7', 'd7^17_5', 17, 10]], // septendecimal diminished seventh + [12 / 7, ['S6', 'M6', 'M6_7', 12, 7]], // septimal major sixth + [7 / 4, ['s7', 'm7', 'm7^7', 7, 4]], // septimal minor seventh + [16 / 9, ['m7', 'm7', 'm7', 16, 9]], // lesser minor seventh + [9 / 5, ['g7', 'm7', 'm7_5', 9, 5]], // greater just minor seventh + [11 / 6, ['n7', 'm7', 'm7^11', 11, 6]], // undecimal neutral seventh + [13 / 7, ['N7', 'm7', 'm7^13_7', 13, 7]], // tridecimal neutral seventh + [15 / 8, ['M7', 'M7', 'M7^5', 15, 8]], // major seventh + [17 / 9, ['T7', 'd8', 'd8^17', 17, 9]], // large septendecimal major seventh + [19 / 10, ['d8', 'd8', 'd8^19_5', 19, 10]], // large undevicesimal major seventh + [2, ['P8', 'P8', 'P8', 2, 1]], // octave P8 +]); + +ratiointervals.key = function (ratio) { + return ratio == null ? '' : ratiointervals.list.get(ratio)?.[0] || ''; +}; + +ratiointervals.label = function (ratio) { + return ratio == null ? '' : ratiointervals.list.get(ratio)?.[1] || ''; +}; + +ratiointervals.fjs = function (ratio) { + return ratio == null ? '' : ratiointervals.list.get(ratio)?.[2] || ''; +}; + +ratiointervals.nom = function (ratio) { + return ratio == null ? null : ratiointervals.list.get(ratio)?.[3] || null; +}; + +ratiointervals.denom = function (ratio) { + return ratio == null ? null : ratiointervals.list.get(ratio)?.[4] || null; +}; + +ratiointervals.nearestInterval = function (v) { + let min = 1; + let match = null; + for (const [ratio, _labels] of ratiointervals.list) { + const diff = Math.abs((ratio - v) / ratio); + if (diff < min) { + min = diff; + match = ratio; + } + } + return min < 0.01 ? [min, match] : [null, null]; +}; + +export default ratiointervals; diff --git a/packages/edo/test/edo.test.mjs b/packages/edo/test/edo.test.mjs new file mode 100644 index 000000000..7cebc34b8 --- /dev/null +++ b/packages/edo/test/edo.test.mjs @@ -0,0 +1,34 @@ +/* +edo.test.mjs - tests of edo.mjs +Copyright (C) 2025 Rob McKinnon and Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ +import '../edo.mjs'; +import { n } from '@strudel/core'; +import { describe, it, expect } from 'vitest'; + +describe('edoScale', () => { + it('Should run tonal functions ', () => { + // base note A3 = 220Hz + let baseNote = 'A3'; + let root = 220; + let freq = 220; + let pattern = n('0').edoScale([baseNote, 'LLsLLLs', 2, 1]); + let cycle = pattern.firstCycleValues[0]; + expect(cycle.freq).toEqual(freq); + expect(cycle.edo).toEqual(12); + expect(cycle.degree).toEqual(1); + expect(parseFloat(cycle.root).toFixed(0)).toEqual(root.toFixed(0)); + + // base note A4 = 440Hz + baseNote = 'A4'; + root = 440; + freq = 880; + pattern = n('7').edoScale([baseNote, 'LLsLLLs', 2, 1]); + cycle = pattern.firstCycleValues[0]; + expect(cycle.freq).toEqual(freq); + expect(cycle.edo).toEqual(12); + expect(cycle.degree).toEqual(1); + expect(parseFloat(cycle.root).toFixed(0)).toEqual(root.toFixed(0)); + }); +}); diff --git a/packages/edo/test/edoscale.test.mjs b/packages/edo/test/edoscale.test.mjs new file mode 100644 index 000000000..531a1b57c --- /dev/null +++ b/packages/edo/test/edoscale.test.mjs @@ -0,0 +1,21 @@ +/* +edoscale.test.mjs - tests of EdoScale from edoscale.mjs +Copyright (C) 2025 Rob McKinnon and Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ +import '../edoscale.mjs'; // need to import this to add prototypes +import { EdoScale } from '../edoscale.mjs'; +import { describe, it, expect } from 'vitest'; + +describe('EdoScale', () => { + it('updates edivisions', () => { + let scale = new EdoScale(2, 1, ['L', 'L', 's', 'L', 'L', 'L', 's']); + expect(scale.stepSize(0)).toEqual('L'); + expect(scale.stepValue(0)).toEqual(2); + + expect(scale.stepSize(2)).toEqual('s'); + expect(scale.stepValue(2)).toEqual(1); + + expect(scale.edivisions).toEqual(12); + }); +}); diff --git a/packages/edo/test/ratios.test.mjs b/packages/edo/test/ratios.test.mjs new file mode 100644 index 000000000..f833177d0 --- /dev/null +++ b/packages/edo/test/ratios.test.mjs @@ -0,0 +1,18 @@ +/* +ratios.test.mjs - tests of ratios.mjs +Copyright (C) 2025 Rob McKinnon and Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ +import ratiointervals from '../ratios.mjs'; +import { describe, it, expect } from 'vitest'; + +describe('ratiointervals', () => { + it('updates edivisions', () => { + let ratio = 9 / 7; + expect(ratiointervals.key(ratio)).toEqual('S3'); + expect(ratiointervals.label(ratio)).toEqual('M3'); + expect(ratiointervals.fjs(ratio)).toEqual('M3_7'); + expect(ratiointervals.nom(ratio)).toEqual(9); + expect(ratiointervals.denom(ratio)).toEqual(7); + }); +}); diff --git a/packages/edo/vite.config.js b/packages/edo/vite.config.js new file mode 100644 index 000000000..5df3edc1b --- /dev/null +++ b/packages/edo/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import { dependencies } from './package.json'; +import { resolve } from 'path'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [], + build: { + lib: { + entry: resolve(__dirname, 'index.mjs'), + formats: ['es'], + fileName: (ext) => ({ es: 'index.mjs' })[ext], + }, + rollupOptions: { + external: [...Object.keys(dependencies)], + }, + target: 'esnext', + }, +}); diff --git a/packages/repl/package.json b/packages/repl/package.json index 1aff2bbe7..81bf9b024 100644 --- a/packages/repl/package.json +++ b/packages/repl/package.json @@ -36,6 +36,7 @@ "@strudel/codemirror": "workspace:*", "@strudel/core": "workspace:*", "@strudel/draw": "workspace:*", + "@strudel/edo": "workspace:*", "@strudel/hydra": "workspace:*", "@strudel/midi": "workspace:*", "@strudel/mini": "workspace:*", diff --git a/packages/repl/prebake.mjs b/packages/repl/prebake.mjs index 26d875c2b..d863ed091 100644 --- a/packages/repl/prebake.mjs +++ b/packages/repl/prebake.mjs @@ -8,6 +8,7 @@ export async function prebake() { core, import('@strudel/draw'), import('@strudel/mini'), + import('@strudel/edo'), import('@strudel/tonal'), import('@strudel/webaudio'), import('@strudel/codemirror'), diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index e7aee294f..d45e1050b 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -1,5 +1,5 @@ import { getBaseURL, getCommonSampleInfo } from './util.mjs'; -import { registerSound, registerWaveTable } from './index.mjs'; +import { registerSound, registerWaveTable, soundMap } from './index.mjs'; import { getAudioContext } from './audioContext.mjs'; import { getADSRValues, @@ -30,6 +30,29 @@ function humanFileSize(bytes, si) { return bytes.toFixed(1) + ' ' + units[u]; } +/** + * Returns the duration, in seconds, of the given sample. + * Has optional param `n` (for instance, the `2` in `s("casio:2")`) + * + * Note: `must` be called with await, otherwise you'll get a pending Promise object. + * + * @name getDuration,getDur + * @tag samples + * @param {string} sampleName + * @param {number} (optional) n + * + * @example + * // Set a patterns cycle length to exactly the length of the sample + * samples('github:tidalcycles/dirt-samples') + * let k = await getDuration('sax') + * s("sax").cps(1/k) + */ +export const getDuration = (s, n = 0) => { + return getSampleBufferSource({ s, n }, soundMap.get(s)[s].data.samples).then((x) => x.bufferDuration); +}; + +export const getDur = getDuration; + export function getSampleInfo(hapValue, bank) { const { speed = 1.0 } = hapValue; const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank); diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 4ce8164bc..98d1fb67b 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -25,7 +25,7 @@ import { import { map } from 'nanostores'; import { logger } from './logger.mjs'; import { connectLFO, connectEnvelope, connectBusModulator } from './modulators.mjs'; -import { loadBuffer } from './sampler.mjs'; +import { getSampleBufferSource, loadBuffer } from './sampler.mjs'; import { getAudioContext } from './audioContext.mjs'; import { SuperdoughAudioController } from './superdoughoutput.mjs'; import { resetSeenKeys } from './wavetable.mjs'; diff --git a/packages/transpiler/README.md b/packages/transpiler/README.md index 9914174a7..44519f6ae 100644 --- a/packages/transpiler/README.md +++ b/packages/transpiler/README.md @@ -1,10 +1,31 @@ # @strudel/transpiler -This package contains a JS code transpiler with the following features: +This package contains a JS code transpiler with a plugin system that can be used to transform the code. + +A plugin can be registered via a `registerTranspilerPlugin` call : + +```js +registerTranspilerPlugin({ + walk: (context) => ({ + enter: function(node, parent, prop, index) {}, + leave: function(node, parent, prop, index) {} + }) +}) +``` + +where the enter/leave functions (both optional) follow the `estree-walker` walk API. + +4 plugins are currently hosted inside `@strudel/transpiler` + +- mini: add locations of mini notation strings (double quoted or backticked) for highlighting +- widgets: add handling of sliders & draw widgets +- sample: make it possible to call a sample without await +- kabelsalat: transform the code to handle the `K(..)` kabelsalat notation + +it also -- add locations of mini notation strings (double quoted or backticked) for highlighting -- converts pseudo note variables to note strings - adds return statement to the last expression +- handles label capturing for block-based eval ## Install diff --git a/packages/transpiler/index.mjs b/packages/transpiler/index.mjs index 484c3e70c..3baf1593a 100644 --- a/packages/transpiler/index.mjs +++ b/packages/transpiler/index.mjs @@ -1,5 +1,17 @@ +/* +index.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ import { evaluate as _evaluate } from '@strudel/core'; import { transpiler } from './transpiler.mjs'; + export * from './transpiler.mjs'; -export const evaluate = (code) => _evaluate(code, transpiler); +import './plugin-kabelsalat.mjs'; +import './plugin-mini.mjs'; +import './plugin-sample.mjs'; +import './plugin-widgets.mjs'; + +export { registerWidgetType, getWidgetID } from './plugin-widgets.mjs'; +export const evaluate = (code, transpilerOptions) => _evaluate(code, transpiler, transpilerOptions); diff --git a/packages/transpiler/plugin-kabelsalat.mjs b/packages/transpiler/plugin-kabelsalat.mjs new file mode 100644 index 000000000..69b8c453c --- /dev/null +++ b/packages/transpiler/plugin-kabelsalat.mjs @@ -0,0 +1,235 @@ +/* +plugin-kabelsalat.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +import { walk } from 'estree-walker'; +import escodegen from 'escodegen'; +import { getLanguages, registerTranspilerPlugin } from './transpiler.mjs'; + +export function genExprSource(expr) { + return escodegen.generate(expr, { format: { semicolons: false } }); +} + +function isStrudelPatternWrap(node) { + if (node.type !== 'CallExpression') { + return false; + } + const callee = node.callee; + if (callee.type === 'Identifier') { + return callee.name === 'S'; + } + if (callee.type === 'MemberExpression' && !callee.computed) { + return callee.property?.name === 'S'; + } + return false; +} + +// Used to identify transpiled `m(...)` calls for proper conversion +// to, say, kabelsalat placeholders +function isMiniCall(node) { + if (node.type !== 'CallExpression') { + return false; + } + const callee = node.callee; + if (callee.type !== 'Identifier') { + return false; + } + if (callee.name !== getMinilangName()) { + return false; + } + const firstArg = node.arguments?.[0]; + return firstArg?.type === 'Literal' && typeof firstArg.value === 'string'; +} + +function getMinilangName() { + const minilang = getLanguages().get('minilang'); + return minilang?.name || 'm'; +} + +function replaceNode(node, replacement, parents, currentRoot) { + const info = parents.get(node); + if (!info || !info.parent) { + return replacement; + } + + const { parent, prop, index } = info; + if (Array.isArray(parent[prop])) { + parent[prop][index] = replacement; + } else { + parent[prop] = replacement; + } + parents.set(replacement, { parent, prop, index }); + return currentRoot; +} + +// If `start` is available, we use it. If it's already been transpiled +// to `m(...)`, use the provided offset +function getPatternNodeOrder(node) { + if (typeof node.start === 'number') { + return node.start; + } + if (isMiniCall(node)) { + const offsetArg = node.arguments?.[1]; + if (offsetArg?.type === 'Literal' && typeof offsetArg.value === 'number') { + return offsetArg.value; + } + } + return 0; +} + +function placeholderAst(index) { + return { + type: 'MemberExpression', + object: { type: 'Identifier', name: 'pat' }, + property: { type: 'Literal', value: index }, + computed: true, + optional: false, + }; +} + +function getStrudelPatternExpr(node) { + if (isStrudelPatternWrap(node)) { + const arg = node.arguments?.[0]; + if (!arg) { + throw new Error('S(...) requires an argument'); + } + return arg; + } + if (isMiniCall(node)) { + return node; + } + return null; +} + +function cloneNode(node) { + return JSON.parse(JSON.stringify(node)); +} + +export function extractPatternPlaceholders(expr) { + const templateExpr = cloneNode(expr); + const parents = new Map(); + const targets = []; + + walk(templateExpr, { + enter(node, parent, prop, index) { + parents.set(node, { parent, prop, index }); + const patternExpr = getStrudelPatternExpr(node); + if (patternExpr) { + targets.push({ node, patternExpr }); + this.skip(); + } + }, + }); + + if (!targets.length) { + return { template: genExprSource(templateExpr), patternExprs: [] }; + } + + targets.sort((a, b) => getPatternNodeOrder(a.node) - getPatternNodeOrder(b.node)); + + const patternExprs = targets.map(({ patternExpr }) => cloneNode(patternExpr)); + + let currentExpr = templateExpr; + targets.forEach(({ node }, index) => { + currentExpr = replaceNode(node, placeholderAst(index), parents, currentExpr); + }); + + const template = genExprSource(currentExpr); + return { template, patternExprs }; +} + +const transpilerPlugin = { + walk: (context) => ({ + leave: function (node, parent, prop, index) { + if (!isKabelCall(node)) return; + let [expr, ...rest] = node.arguments; + if (!expr) throw new Error('K(...) requires an expression'); + if (shouldCallKabelExpression(expr)) { + expr = { + type: 'CallExpression', + callee: expr, + arguments: [], + optional: false, + }; + } + const language = 'kabelsalat'; + const { template, patternExprs } = extractPatternPlaceholders(expr); + if (patternExprs.length) { + const workletArgs = [ + /*{ type: 'Literal', value: language },*/ + { type: 'Literal', value: template }, + ...patternExprs, + ...rest, + ]; + let callee = node.callee; + if (callee.type === 'ChainExpression') callee = callee.expression; + if (callee.type === 'MemberExpression') { + return this.replace({ + type: 'CallExpression', + callee: workletMemberAst(callee.object), + arguments: workletArgs, + optional: false, + }); + } + return this.replace({ + type: 'CallExpression', + callee: { type: 'Identifier', name: 'worklet' }, + arguments: workletArgs, + optional: false, + }); + } + + const kabelSrc = genExprSource(expr); + const workletArgs = [/*{ type: 'Literal', value: language },*/ { type: 'Literal', value: kabelSrc }, ...rest]; + + let callee = node.callee; + if (callee.type === 'ChainExpression') callee = callee.expression; + if (callee.type === 'MemberExpression') { + return this.replace({ + type: 'CallExpression', + callee: workletMemberAst(callee.object), + arguments: workletArgs, + optional: false, + }); + } + return this.replace({ + type: 'CallExpression', + callee: { type: 'Identifier', name: 'worklet' }, + arguments: workletArgs, + optional: false, + }); + }, + }), +}; + +registerTranspilerPlugin(transpilerPlugin); + +function isKabelCall(node) { + if (node.type !== 'CallExpression') return false; + let callee = node.callee; + if (callee.type === 'ChainExpression') callee = callee.expression; + if (callee.type === 'MemberExpression') return !callee.computed && callee.property?.name === 'K'; + return callee.type === 'Identifier' && callee.name === 'K'; +} + +function shouldCallKabelExpression(expr) { + if (expr.type !== 'ArrowFunctionExpression' && expr.type !== 'FunctionExpression') { + return false; + } + if (expr.params.length) { + return false; + } + return expr.body?.type === 'BlockStatement'; +} + +function workletMemberAst(objectExpr) { + return { + type: 'MemberExpression', + object: objectExpr, + property: { type: 'Identifier', name: 'worklet' }, + computed: false, + optional: false, + }; +} diff --git a/packages/transpiler/plugin-mini.mjs b/packages/transpiler/plugin-mini.mjs new file mode 100644 index 000000000..0653ffef0 --- /dev/null +++ b/packages/transpiler/plugin-mini.mjs @@ -0,0 +1,200 @@ +/* +plugin-mini.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ +import { getLeafLocations } from '@strudel/mini'; +import { getLanguages, registerTranspilerPlugin } from './transpiler.mjs'; + +const languageLiteral = { + walk: (context) => ({ + enter: function (node, parent, prop, index) { + if (!isLanguageLiteral(node)) return; + context.miniLocations ??= []; + const { options, miniLocations } = context; + const { emitMiniLocations } = options; + const { name } = node.tag; + const language = getLanguages().get(name); + const code = node.quasi.quasis[0].value.raw; + const offset = node.quasi.start + 1; + if (emitMiniLocations) { + const locs = language.getLocations(code, offset); + miniLocations.push(...locs); + } + this.skip(); + return this.replace(languageWithLocation(name, code, offset)); + }, + }), +}; + +const tidal = { + walk: (context) => ({ + enter: function (node, parent, prop, index) { + if (!isTemplateLiteral(node, 'tidal')) return; + context.miniLocations ??= []; + const { options, miniLocations } = context; + const { emitMiniLocations } = options; + const raw = node.quasi.quasis[0].value.raw; + const offset = node.quasi.start + 1; + if (emitMiniLocations) { + const stringLocs = collectHaskellMiniLocations(raw, offset); + miniLocations.push(...stringLocs); + } + this.skip(); + return this.replace(tidalWithLocation(raw, offset)); + }, + }), +}; + +const backtick = { + walk: (context) => ({ + enter: function (node, parent, prop, index) { + if (!isBackTickString(node, parent)) return; + context.miniLocations ??= []; + const { options, input, miniDisableRanges, miniLocations } = context; + const { emitMiniLocations } = options; + if (isMiniDisabled(node.start, miniDisableRanges)) { + return; + } + const { quasis } = node; + const { raw } = quasis[0].value; + this.skip(); + emitMiniLocations && collectMiniLocations(raw, node, miniLocations, input); + return this.replace(miniWithLocation(raw, node)); + }, + }), +}; + +const doublequotes = { + walk: (context) => ({ + enter: function (node, parent, prop, index) { + if (!isStringWithDoubleQuotes(node)) return; + context.miniLocations ??= []; + const { options, input, miniDisableRanges, miniLocations } = context; + const { emitMiniLocations } = options; + if (isMiniDisabled(node.start, miniDisableRanges)) { + return; + } + const { value } = node; + this.skip(); + emitMiniLocations && collectMiniLocations(value, node, miniLocations, input); + return this.replace(miniWithLocation(value, node)); + }, + }), +}; + +function isLanguageLiteral(node) { + return node.type === 'TaggedTemplateExpression' && getLanguages().has(node.tag.name); +} + +function languageWithLocation(name, value, offset) { + return { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: name, + }, + arguments: [ + { type: 'Literal', value }, + { type: 'Literal', value: offset }, + ], + optional: false, + }; +} + +function collectHaskellMiniLocations(haskellCode, offset) { + return haskellCode + .split('') + .reduce((acc, char, i) => { + if (char !== '"') { + return acc; + } + if (!acc.length || acc[acc.length - 1].length > 1) { + acc.push([i + 1]); + } else { + acc[acc.length - 1].push(i); + } + return acc; + }, []) + .map(([start, end]) => { + const miniString = haskellCode.slice(start, end); + return getLeafLocations(`"${miniString}"`, offset + start - 1); + }) + .flat(); +} + +function isTemplateLiteral(node, value) { + return node.type === 'TaggedTemplateExpression' && node.tag.name === value; +} + +function tidalWithLocation(value, offset) { + return { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: 'tidal', + }, + arguments: [ + { type: 'Literal', value }, + { type: 'Literal', value: offset }, + ], + optional: false, + }; +} + +function isBackTickString(node, parent) { + return node.type === 'TemplateLiteral' && parent.type !== 'TaggedTemplateExpression'; +} + +function isMiniDisabled(offset, miniDisableRanges) { + for (const [start, end] of miniDisableRanges) { + if (offset >= start && offset < end) { + return true; + } + } + return false; +} + +function miniWithLocation(value, node) { + const { start: fromOffset } = node; + + const minilang = getLanguages().get('minilang'); + let name = 'm'; + if (minilang && minilang.name) { + name = minilang.name; // name is expected to be exported from the package of the minilang + } + + return { + type: 'CallExpression', + callee: { + type: 'Identifier', + name, + }, + arguments: [ + { type: 'Literal', value }, + { type: 'Literal', value: fromOffset }, + ], + optional: false, + }; +} + +const collectMiniLocations = (value, node, miniLocations, input) => { + const minilang = getLanguages().get('minilang'); + if (minilang) { + const code = `[${value}]`; + const locs = minilang.getLocations(code, node.start); + miniLocations.push(...locs); + } else { + const leafLocs = getLeafLocations(`"${value}"`, node.start, input); + miniLocations.push(...leafLocs); + } +}; + +function isStringWithDoubleQuotes(node, locations, code) { + if (node.type !== 'Literal') { + return false; + } + return node.raw[0] === '"'; +} + +registerTranspilerPlugin([languageLiteral, tidal, backtick, doublequotes]); diff --git a/packages/transpiler/plugin-sample.mjs b/packages/transpiler/plugin-sample.mjs new file mode 100644 index 000000000..0b129af05 --- /dev/null +++ b/packages/transpiler/plugin-sample.mjs @@ -0,0 +1,29 @@ +/* +plugin-sample.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +import { registerTranspilerPlugin } from './transpiler.mjs'; + +export function withAwait(node) { + return { + type: 'AwaitExpression', + argument: node, + }; +} + +const bareSample = { + walk: (context) => ({ + enter: function (node, parent, prop, index) { + if (!isBareSamplesCall(node, parent)) return; + this.replace(withAwait(node)); + }, + }), +}; + +function isBareSamplesCall(node, parent) { + return node.type === 'CallExpression' && node.callee.name === 'samples' && parent.type !== 'AwaitExpression'; +} + +registerTranspilerPlugin(bareSample); diff --git a/packages/transpiler/plugin-widgets.mjs b/packages/transpiler/plugin-widgets.mjs new file mode 100644 index 000000000..a6a46eb3b --- /dev/null +++ b/packages/transpiler/plugin-widgets.mjs @@ -0,0 +1,128 @@ +/* +plugin-widgets.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +import { registerTranspilerPlugin } from './transpiler.mjs'; + +let widgetMethods = []; +export function registerWidgetType(type) { + widgetMethods.push(type); +} + +const widgetTranspilerPlugin = { + walk: (context) => ({ + enter: function (node, parent, prop, index) { + if (!isWidgetMethod(node)) return; + context.widgets ??= []; + const { widgets, options } = context; + const { emitWidgets } = options; + const type = node.callee.property.name; + const idx = widgets.filter((w) => w.type === type).length; + const widgetConfig = { + from: node.start, + to: node.end, + index: idx, + type, + id: options.id, + }; + emitWidgets && widgets.push(widgetConfig); + return this.replace(widgetWithLocation(node, widgetConfig)); + }, + }), +}; + +const sliderTranspilerPlugin = { + walk: (context) => ({ + enter: function (node, parent, prop, index) { + if (!isSliderFunction(node)) return; + context.widgets ??= []; + context.sliders ??= []; + const { options, widgets, sliders, nodeOffset } = context; + const { emitWidgets } = options; + const from = node.arguments[0].start + nodeOffset; + const to = node.arguments[0].end + nodeOffset; + const id = `${from}:${to}`; // Range-based ID for stability + const sliderConfig = { + from, + to, + id, + value: node.arguments[0].raw, // don't use value! + min: node.arguments[1]?.value ?? 0, + max: node.arguments[2]?.value ?? 1, + step: node.arguments[3]?.value, + type: 'slider', + }; + emitWidgets && widgets.push(sliderConfig); + sliders.push(sliderConfig); + return this.replace(sliderWithLocation(node, nodeOffset)); + }, + }), +}; + +export const widgetTranspilerPlugins = [sliderTranspilerPlugin, widgetTranspilerPlugin]; + +// these functions are connected to @strudel/codemirror -> slider.mjs +// maybe someday there will be pluggable transpiler functions, then move this there +function isSliderFunction(node) { + return node.type === 'CallExpression' && node.callee.name === 'slider'; +} + +function isWidgetMethod(node) { + return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name); +} + +function sliderWithLocation(node, nodeOffset = 0) { + // Apply nodeOffset for block-based evaluation to generate correct range + const from = node.arguments[0].start + nodeOffset; + const to = node.arguments[0].end + nodeOffset; + + // Use range-based ID for stability during block evaluation + const id = `${from}:${to}`; + + // add loc as identifier to first argument + // the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?) + node.arguments.unshift({ + type: 'Literal', + value: id, + raw: id, + }); + node.callee.name = 'sliderWithID'; + return node; +} + +export function getWidgetID(widgetConfig) { + // the widget id is used as id for the dom element + as key for eventual resources + // for example, for each scope widget, a new analyser + buffer (large) is created + // Update: use range-based ID generation for better stability during block evaluation + // When we have both from and to, use them together for stability + // Otherwise fall back to position-based ID for backward compatibility + let uniqueIdentifier; + if (widgetConfig.from !== undefined && widgetConfig.to !== undefined) { + // Use range for more stable identification + uniqueIdentifier = `${widgetConfig.from}-${widgetConfig.to}`; + } else { + // Fallback to single position (for backward compatibility) + uniqueIdentifier = widgetConfig.to || widgetConfig.from || 0; + } + const baseId = `${widgetConfig.id || ''}_widget_${widgetConfig.type}`; + return `${baseId}_${widgetConfig.index}_${uniqueIdentifier}`; +} + +function widgetWithLocation(node, widgetConfig) { + const id = getWidgetID(widgetConfig); + // Store the unique ID back into the config so it's available for widget management + // This is critical for block-based evaluation to match existing widgets with new ones + widgetConfig.id = id; + // add loc as identifier to first argument + // the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?) + node.arguments.unshift({ + type: 'Literal', + value: id, + raw: id, + }); + return node; +} + +registerTranspilerPlugin(widgetTranspilerPlugins); diff --git a/packages/transpiler/test/transpiler.test.mjs b/packages/transpiler/test/transpiler.test.mjs index 986635beb..74bfaa911 100644 --- a/packages/transpiler/test/transpiler.test.mjs +++ b/packages/transpiler/test/transpiler.test.mjs @@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th */ import { describe, it, expect } from 'vitest'; -import { transpiler } from '../transpiler.mjs'; +import { transpiler } from '../index.mjs'; const simple = { wrapAsync: false, addReturn: false, simpleLocs: true }; diff --git a/packages/transpiler/transpiler.mjs b/packages/transpiler/transpiler.mjs index 210d40852..142f17c77 100644 --- a/packages/transpiler/transpiler.mjs +++ b/packages/transpiler/transpiler.mjs @@ -1,13 +1,12 @@ -import { getLeafLocations } from '@strudel/mini'; +/* +transpiler.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ import { parse } from 'acorn'; import escodegen from 'escodegen'; import { walk } from 'estree-walker'; -let widgetMethods = []; -export function registerWidgetType(type) { - widgetMethods.push(type); -} - let languages = new Map(); // config = { getLocations: (code: string, offset?: number) => number[][] } // see mondough.mjs for example use @@ -17,16 +16,31 @@ let languages = new Map(); export function registerLanguage(type, config) { languages.set(type, config); } +export function getLanguages() { + return languages; +} + +const plugins = []; + +export function registerTranspilerPlugin(plugin) { + plugins.push(plugin); +} +export function getPlugins() { + return plugins.flat(Infinity); +} export function transpiler(input, options = {}) { - const { - wrapAsync = false, - addReturn = true, - emitMiniLocations = true, - emitWidgets = true, - blockBased = false, - range = [], - } = options; + options = { + wrapAsync: false, + addReturn: true, + emitMiniLocations: true, + emitWidgets: true, + blockBased: false, + range: [], + ...options, + }; + + const { wrapAsync, addReturn, emitMiniLocations, emitWidgets, blockBased, range } = options; const comments = []; let ast = parse(input, { @@ -36,8 +50,7 @@ export function transpiler(input, options = {}) { onComment: comments, }); - const miniDisableRanges = findMiniDisableRanges(comments, input.length); - let miniLocations = []; + let miniDisableRanges = findMiniDisableRanges(comments, input.length); // Position offset for block-based evaluation let nodeOffset = range && range.length > 0 ? range[0] : 0; @@ -45,23 +58,13 @@ export function transpiler(input, options = {}) { // Track declarations to add to strudelScope for block-based eval let scopeDeclarations = []; - const collectMiniLocations = (value, node) => { - const minilang = languages.get('minilang'); - if (minilang) { - const code = `[${value}]`; - const locs = minilang.getLocations(code, node.start); - miniLocations = miniLocations.concat(locs); - } else { - const leafLocs = getLeafLocations(`"${value}"`, node.start, input); - miniLocations = miniLocations.concat(leafLocs); - } - }; - let widgets = []; - let sliders = []; let labels = []; + const context = { options, input, nodeOffset, miniDisableRanges, labels }; + const plugins = getPlugins().map((plugin) => plugin.walk?.(context)); + walk(ast, { - enter(node, parent /* , prop, index */) { + enter(node, parent, prop, index) { // Apply position offset for block-based evaluation if (blockBased && node.start !== undefined) { node.start = node.start + nodeOffset; @@ -79,82 +82,12 @@ export function transpiler(input, options = {}) { scopeDeclarations.push(node.id.name); } } - if (isLanguageLiteral(node)) { - const { name } = node.tag; - const language = languages.get(name); - const code = node.quasi.quasis[0].value.raw; - const offset = node.quasi.start + 1; - if (emitMiniLocations) { - const locs = language.getLocations(code, offset); - miniLocations = miniLocations.concat(locs); - } - this.skip(); - return this.replace(languageWithLocation(name, code, offset)); - } - if (isTemplateLiteral(node, 'tidal')) { - const raw = node.quasi.quasis[0].value.raw; - const offset = node.quasi.start + 1; - if (emitMiniLocations) { - const stringLocs = collectHaskellMiniLocations(raw, offset); - miniLocations = miniLocations.concat(stringLocs); - } - this.skip(); - return this.replace(tidalWithLocation(raw, offset)); - } - if (isBackTickString(node, parent)) { - if (isMiniDisabled(node.start, miniDisableRanges)) { - return; - } - const { quasis } = node; - const { raw } = quasis[0].value; - this.skip(); - emitMiniLocations && collectMiniLocations(raw, node); - return this.replace(miniWithLocation(raw, node)); - } - if (isStringWithDoubleQuotes(node)) { - if (isMiniDisabled(node.start, miniDisableRanges)) { - return; - } - const { value } = node; - this.skip(); - emitMiniLocations && collectMiniLocations(value, node); - return this.replace(miniWithLocation(value, node)); - } - if (isSliderFunction(node)) { - const from = node.arguments[0].start + nodeOffset; - const to = node.arguments[0].end + nodeOffset; - const id = `${from}:${to}`; // Range-based ID for stability - const sliderConfig = { - from, - to, - id, - value: node.arguments[0].raw, // don't use value! - min: node.arguments[1]?.value ?? 0, - max: node.arguments[2]?.value ?? 1, - step: node.arguments[3]?.value, - type: 'slider', - }; - emitWidgets && widgets.push(sliderConfig); - sliders.push(sliderConfig); - return this.replace(sliderWithLocation(node, nodeOffset)); - } - if (isWidgetMethod(node)) { - const type = node.callee.property.name; - const index = widgets.filter((w) => w.type === type).length; - const widgetConfig = { - from: node.start, - to: node.end, - index, - type, - id: options.id, - }; - emitWidgets && widgets.push(widgetConfig); - return this.replace(widgetWithLocation(node, widgetConfig)); - } - if (isBareSamplesCall(node, parent)) { - return this.replace(withAwait(node)); + for (const plugin of plugins) { + if (!plugin?.enter?.call(this, node, parent, prop, index)) continue; + return; } + if (isLabelStatement(node)) { // Collect label info for block-based evaluation // Store positions WITHOUT offset so repl can slice the transpiler output correctly @@ -183,61 +116,10 @@ export function transpiler(input, options = {}) { }, leave(node, parent, prop, index) { - if (!isKabelCall(node)) return; - - let [expr, ...rest] = node.arguments; - if (!expr) throw new Error('K(...) requires an expression'); - - if (shouldCallKabelExpression(expr)) { - expr = { - type: 'CallExpression', - callee: expr, - arguments: [], - optional: false, - }; + for (const plugin of plugins) { + if (!plugin?.leave?.call(this, node, parent, prop, index)) continue; + return; } - - const { template, patternExprs } = extractPatternPlaceholders(expr); - if (patternExprs.length) { - const workletArgs = [{ type: 'Literal', value: template }, ...patternExprs, ...rest]; - - let callee = node.callee; - if (callee.type === 'ChainExpression') callee = callee.expression; - if (callee.type === 'MemberExpression') { - return this.replace({ - type: 'CallExpression', - callee: workletMemberAst(callee.object), - arguments: workletArgs, - optional: false, - }); - } - return this.replace({ - type: 'CallExpression', - callee: { type: 'Identifier', name: 'worklet' }, - arguments: workletArgs, - optional: false, - }); - } - - const kabelSrc = genExprSource(expr); - const workletArgs = [{ type: 'Literal', value: kabelSrc }, ...rest]; - - let callee = node.callee; - if (callee.type === 'ChainExpression') callee = callee.expression; - if (callee.type === 'MemberExpression') { - return this.replace({ - type: 'CallExpression', - callee: workletMemberAst(callee.object), - arguments: workletArgs, - optional: false, - }); - } - return this.replace({ - type: 'CallExpression', - callee: { type: 'Identifier', name: 'worklet' }, - arguments: workletArgs, - optional: false, - }); }, }); @@ -256,12 +138,7 @@ export function transpiler(input, options = {}) { body.push(silenceExpression); } else if (!body?.[body.length - 1]?.expression) { // Last statement is not an expression (e.g., VariableDeclaration, FunctionDeclaration) - if (blockBased) { - // For block-based eval, add silence as the return value when block ends with declaration - body.push(silenceExpression); - } else { - throw new Error('unexpected ast format without body expression'); - } + body.push(silenceExpression); } // For block-based eval, add scope assignments before the return statement @@ -287,280 +164,17 @@ export function transpiler(input, options = {}) { if (!emitMiniLocations) { return { output }; } - return { output, miniLocations, widgets, sliders, labels }; -} -function isKabelCall(node) { - if (node.type !== 'CallExpression') return false; - let callee = node.callee; - if (callee.type === 'ChainExpression') callee = callee.expression; - if (callee.type === 'MemberExpression') return !callee.computed && callee.property?.name === 'K'; - return callee.type === 'Identifier' && callee.name === 'K'; -} + let pluginContext; + ({ options, input, miniDisableRanges, nodeOffset, ...pluginContext } = context); -function shouldCallKabelExpression(expr) { - if (expr.type !== 'ArrowFunctionExpression' && expr.type !== 'FunctionExpression') { - return false; - } - if (expr.params.length) { - return false; - } - return expr.body?.type === 'BlockStatement'; -} - -function genExprSource(expr) { - return escodegen.generate(expr, { format: { semicolons: false } }); -} - -function extractPatternPlaceholders(expr) { - const templateExpr = cloneNode(expr); - const parents = new Map(); - const targets = []; - - walk(templateExpr, { - enter(node, parent, prop, index) { - parents.set(node, { parent, prop, index }); - const patternExpr = getStrudelPatternExpr(node); - if (patternExpr) { - targets.push({ node, patternExpr }); - this.skip(); - } - }, - }); - - if (!targets.length) { - return { template: genExprSource(templateExpr), patternExprs: [] }; - } - - targets.sort((a, b) => getPatternNodeOrder(a.node) - getPatternNodeOrder(b.node)); - - const patternExprs = targets.map(({ patternExpr }) => cloneNode(patternExpr)); - - let currentExpr = templateExpr; - targets.forEach(({ node }, index) => { - currentExpr = replaceNode(node, placeholderAst(index), parents, currentExpr); - }); - - const template = genExprSource(currentExpr); - return { template, patternExprs }; -} - -function getStrudelPatternExpr(node) { - if (isStrudelPatternWrap(node)) { - const arg = node.arguments?.[0]; - if (!arg) { - throw new Error('S(...) requires an argument'); - } - return arg; - } - if (isMiniCall(node)) { - return node; - } - return null; -} - -function isStrudelPatternWrap(node) { - if (node.type !== 'CallExpression') { - return false; - } - const callee = node.callee; - if (callee.type === 'Identifier') { - return callee.name === 'S'; - } - if (callee.type === 'MemberExpression' && !callee.computed) { - return callee.property?.name === 'S'; - } - return false; -} - -function getMinilangName() { - const minilang = languages.get('minilang'); - return minilang?.name || 'm'; -} - -// Used to identify transpiled `m(...)` calls for proper conversion -// to, say, kabelsalat placeholders -function isMiniCall(node) { - if (node.type !== 'CallExpression') { - return false; - } - const callee = node.callee; - if (callee.type !== 'Identifier') { - return false; - } - if (callee.name !== getMinilangName()) { - return false; - } - const firstArg = node.arguments?.[0]; - return firstArg?.type === 'Literal' && typeof firstArg.value === 'string'; -} - -// If `start` is available, we use it. If it's already been transpiled -// to `m(...)`, use the provided offset -function getPatternNodeOrder(node) { - if (typeof node.start === 'number') { - return node.start; - } - if (isMiniCall(node)) { - const offsetArg = node.arguments?.[1]; - if (offsetArg?.type === 'Literal' && typeof offsetArg.value === 'number') { - return offsetArg.value; - } - } - return 0; -} - -function placeholderAst(index) { - return { - type: 'MemberExpression', - object: { type: 'Identifier', name: 'pat' }, - property: { type: 'Literal', value: index }, - computed: true, - optional: false, - }; -} - -function replaceNode(node, replacement, parents, currentRoot) { - const info = parents.get(node); - if (!info || !info.parent) { - return replacement; - } - - const { parent, prop, index } = info; - if (Array.isArray(parent[prop])) { - parent[prop][index] = replacement; - } else { - parent[prop] = replacement; - } - parents.set(replacement, { parent, prop, index }); - return currentRoot; -} - -function cloneNode(node) { - return JSON.parse(JSON.stringify(node)); -} - -function workletMemberAst(objectExpr) { - return { - type: 'MemberExpression', - object: objectExpr, - property: { type: 'Identifier', name: 'worklet' }, - computed: false, - optional: false, - }; -} - -function isStringWithDoubleQuotes(node, locations, code) { - if (node.type !== 'Literal') { - return false; - } - return node.raw[0] === '"'; -} - -function isBackTickString(node, parent) { - return node.type === 'TemplateLiteral' && parent.type !== 'TaggedTemplateExpression'; -} - -function miniWithLocation(value, node) { - const { start: fromOffset } = node; - - const minilang = languages.get('minilang'); - let name = 'm'; - if (minilang && minilang.name) { - name = minilang.name; // name is expected to be exported from the package of the minilang - } - - return { - type: 'CallExpression', - callee: { - type: 'Identifier', - name, - }, - arguments: [ - { type: 'Literal', value }, - { type: 'Literal', value: fromOffset }, - ], - optional: false, - }; -} - -// these functions are connected to @strudel/codemirror -> slider.mjs -// maybe someday there will be pluggable transpiler functions, then move this there -function isSliderFunction(node) { - return node.type === 'CallExpression' && node.callee.name === 'slider'; -} - -function isWidgetMethod(node) { - return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name); -} - -function sliderWithLocation(node, nodeOffset = 0) { - // Apply nodeOffset for block-based evaluation to generate correct range - const from = node.arguments[0].start + nodeOffset; - const to = node.arguments[0].end + nodeOffset; - - // Use range-based ID for stability during block evaluation - const id = `${from}:${to}`; - - // add loc as identifier to first argument - // the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?) - node.arguments.unshift({ - type: 'Literal', - value: id, - raw: id, - }); - node.callee.name = 'sliderWithID'; - return node; -} - -export function getWidgetID(widgetConfig) { - // the widget id is used as id for the dom element + as key for eventual resources - // for example, for each scope widget, a new analyser + buffer (large) is created - // Update: use range-based ID generation for better stability during block evaluation - // When we have both from and to, use them together for stability - // Otherwise fall back to position-based ID for backward compatibility - let uniqueIdentifier; - if (widgetConfig.from !== undefined && widgetConfig.to !== undefined) { - // Use range for more stable identification - uniqueIdentifier = `${widgetConfig.from}-${widgetConfig.to}`; - } else { - // Fallback to single position (for backward compatibility) - uniqueIdentifier = widgetConfig.to || widgetConfig.from || 0; - } - const baseId = `${widgetConfig.id || ''}_widget_${widgetConfig.type}`; - return `${baseId}_${widgetConfig.index}_${uniqueIdentifier}`; -} - -function widgetWithLocation(node, widgetConfig) { - const id = getWidgetID(widgetConfig); - // Store the unique ID back into the config so it's available for widget management - // This is critical for block-based evaluation to match existing widgets with new ones - widgetConfig.id = id; - // add loc as identifier to first argument - // the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?) - node.arguments.unshift({ - type: 'Literal', - value: id, - raw: id, - }); - return node; -} - -function isBareSamplesCall(node, parent) { - return node.type === 'CallExpression' && node.callee.name === 'samples' && parent.type !== 'AwaitExpression'; + return { output, ...pluginContext }; } function isAllCall(node) { return node.type === 'CallExpression' && node.callee.name === 'all'; } -function withAwait(node) { - return { - type: 'AwaitExpression', - argument: node, - }; -} - function isLabelStatement(node) { return node.type === 'LabeledStatement'; } @@ -591,69 +205,6 @@ function labelToP(node) { }; } -function isLanguageLiteral(node) { - return node.type === 'TaggedTemplateExpression' && languages.has(node.tag.name); -} - -// tidal highlighting -// this feels kind of stupid, when we also know the location inside the string op (tidal.mjs) -// but maybe it's the only way - -function isTemplateLiteral(node, value) { - return node.type === 'TaggedTemplateExpression' && node.tag.name === value; -} - -function collectHaskellMiniLocations(haskellCode, offset) { - return haskellCode - .split('') - .reduce((acc, char, i) => { - if (char !== '"') { - return acc; - } - if (!acc.length || acc[acc.length - 1].length > 1) { - acc.push([i + 1]); - } else { - acc[acc.length - 1].push(i); - } - return acc; - }, []) - .map(([start, end]) => { - const miniString = haskellCode.slice(start, end); - return getLeafLocations(`"${miniString}"`, offset + start - 1); - }) - .flat(); -} - -function tidalWithLocation(value, offset) { - return { - type: 'CallExpression', - callee: { - type: 'Identifier', - name: 'tidal', - }, - arguments: [ - { type: 'Literal', value }, - { type: 'Literal', value: offset }, - ], - optional: false, - }; -} - -function languageWithLocation(name, value, offset) { - return { - type: 'CallExpression', - callee: { - type: 'Identifier', - name: name, - }, - arguments: [ - { type: 'Literal', value }, - { type: 'Literal', value: offset }, - ], - optional: false, - }; -} - // List of non-inline widgets that need cleanup // These are Pattern.prototype methods that create persistent visualizations // (should be repalced by a function call producing an actual list of registered widgets) @@ -795,12 +346,3 @@ function findMiniDisableRanges(comments, codeEnd) { } return ranges; } - -function isMiniDisabled(offset, miniDisableRanges) { - for (const [start, end] of miniDisableRanges) { - if (offset >= start && offset < end) { - return true; - } - } - return false; -} diff --git a/packages/web/package.json b/packages/web/package.json index 29c478c55..cac1cfca7 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -34,6 +34,7 @@ "homepage": "https://codeberg.org/uzu/strudel#readme", "dependencies": { "@strudel/core": "workspace:*", + "@strudel/edo": "workspace:*", "@strudel/mini": "workspace:*", "@strudel/tonal": "workspace:*", "@strudel/transpiler": "workspace:*", diff --git a/packages/web/web.mjs b/packages/web/web.mjs index 2e2df84da..eebe6bdc1 100644 --- a/packages/web/web.mjs +++ b/packages/web/web.mjs @@ -3,6 +3,7 @@ export * from '@strudel/webaudio'; //export * from '@strudel/soundfonts'; export * from '@strudel/transpiler'; export * from '@strudel/mini'; +export * from '@strudel/edo'; export * from '@strudel/tonal'; export * from '@strudel/webaudio'; import { Pattern, evalScope, setTime } from '@strudel/core'; @@ -17,6 +18,7 @@ export async function defaultPrebake() { evalScope, import('@strudel/core'), import('@strudel/mini'), + import('@strudel/edo'), import('@strudel/tonal'), import('@strudel/webaudio'), { hush, evaluate }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cc84b0e3..1b086a3d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,30 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + '@codemirror/autocomplete': + specifier: ^6.18.4 + version: 6.18.4 + '@codemirror/commands': + specifier: ^6.8.0 + version: 6.8.0 + '@codemirror/lang-javascript': + specifier: ^6.2.2 + version: 6.2.2 + '@codemirror/language': + specifier: ^6.10.8 + version: 6.10.8 + '@codemirror/search': + specifier: ^6.5.8 + version: 6.5.8 + '@codemirror/state': + specifier: ^6.5.1 + version: 6.5.1 + '@codemirror/view': + specifier: ^6.36.2 + version: 6.36.2 + importers: .: @@ -11,6 +35,9 @@ importers: '@strudel/core': specifier: workspace:* version: link:packages/core + '@strudel/edo': + specifier: workspace:* + version: link:packages/edo '@strudel/mini': specifier: workspace:* version: link:packages/mini @@ -93,6 +120,9 @@ importers: '@strudel/draw': specifier: workspace:* version: link:../../packages/draw + '@strudel/edo': + specifier: workspace:* + version: link:../../packages/edo '@strudel/mini': specifier: workspace:* version: link:../../packages/mini @@ -171,25 +201,25 @@ importers: packages/codemirror: dependencies: '@codemirror/autocomplete': - specifier: ^6.18.4 + specifier: 'catalog:' version: 6.18.4 '@codemirror/commands': - specifier: ^6.8.0 + specifier: 'catalog:' version: 6.8.0 '@codemirror/lang-javascript': - specifier: ^6.2.2 + specifier: 'catalog:' version: 6.2.2 '@codemirror/language': - specifier: ^6.10.8 + specifier: 'catalog:' version: 6.10.8 '@codemirror/search': - specifier: ^6.5.8 + specifier: 'catalog:' version: 6.5.8 '@codemirror/state': - specifier: ^6.5.1 + specifier: 'catalog:' version: 6.5.1 '@codemirror/view': - specifier: ^6.36.2 + specifier: 'catalog:' version: 6.36.2 '@lezer/highlight': specifier: ^1.2.1 @@ -283,6 +313,28 @@ importers: specifier: ^6.0.11 version: 6.0.11(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0) + packages/edo: + dependencies: + '@strudel/core': + specifier: workspace:* + version: link:../core + '@tonaljs/tonal': + specifier: ^4.10.0 + version: 4.10.0 + chord-voicings: + specifier: ^0.0.1 + version: 0.0.1 + webmidi: + specifier: ^3.1.12 + version: 3.1.12 + 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) + vitest: + specifier: ^3.0.4 + version: 3.0.4(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0) + packages/embed: {} packages/gamepad: @@ -449,6 +501,9 @@ importers: '@strudel/draw': specifier: workspace:* version: link:../draw + '@strudel/edo': + specifier: workspace:* + version: link:../edo '@strudel/hydra': specifier: workspace:* version: link:../hydra @@ -624,6 +679,9 @@ importers: '@strudel/core': specifier: workspace:* version: link:../core + '@strudel/edo': + specifier: workspace:* + version: link:../edo '@strudel/mini': specifier: workspace:* version: link:../mini @@ -705,6 +763,21 @@ importers: '@astrojs/tailwind': specifier: ^5.1.5 version: 5.1.5(astro@5.1.9(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(rollup@2.79.2)(terser@5.37.0)(typescript@5.7.3)(yaml@2.7.0))(tailwindcss@3.4.17) + '@codemirror/commands': + specifier: 'catalog:' + version: 6.8.0 + '@codemirror/lang-javascript': + specifier: 'catalog:' + version: 6.2.2 + '@codemirror/language': + specifier: 'catalog:' + version: 6.10.8 + '@codemirror/state': + specifier: 'catalog:' + version: 6.5.1 + '@codemirror/view': + specifier: 'catalog:' + version: 6.36.2 '@docsearch/css': specifier: ^3.8.3 version: 3.8.3 @@ -738,6 +811,9 @@ importers: '@strudel/draw': specifier: workspace:* version: link:../packages/draw + '@strudel/edo': + specifier: workspace:* + version: link:../packages/edo '@strudel/gamepad': specifier: workspace:* version: link:../packages/gamepad diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a4fca9ff7..c8be1ac16 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,3 +10,12 @@ onlyBuiltDependencies: - sharp - tree-sitter - tree-sitter-haskell + +catalog: + "@codemirror/commands": "^6.8.0" + "@codemirror/language": "^6.10.8" + "@codemirror/lang-javascript": "^6.2.2" + "@codemirror/state": "^6.5.1" + "@codemirror/view": "^6.36.2" + "@codemirror/autocomplete": "^6.18.4" + "@codemirror/search": "^6.5.8" diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 831e48767..458904baf 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -3012,15 +3012,6 @@ 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 ]", @@ -3071,6 +3062,43 @@ exports[`runs examples > example "delaysync" example index 0 1`] = ` ] `; +exports[`runs examples > example "delaytime" 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 "density" example index 0 1`] = ` [ "[ 0/1 → 1/4 | s:crackle density:0.01 ]", @@ -3959,6 +3987,93 @@ exports[`runs examples > example "echoWith" example index 0 1`] = ` ] `; +exports[`runs examples > example "edoScale" example index 0 1`] = ` +[ + "[ 0/1 → 1/6 | degree:1 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:130.813 edo:12 ]", + "[ 1/6 → 1/3 | degree:3 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:164.814 edo:12 ]", + "[ 1/3 → 1/2 | degree:5 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:195.998 edo:12 ]", + "[ 1/2 → 2/3 | degree:7 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:246.942 edo:12 ]", + "[ 2/3 → 5/6 | degree:5 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:195.998 edo:12 ]", + "[ 5/6 → 1/1 | degree:3 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:164.814 edo:12 ]", + "[ 1/1 → 7/6 | degree:1 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:130.813 edo:12 ]", + "[ 7/6 → 4/3 | degree:3 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:164.814 edo:12 ]", + "[ 4/3 → 3/2 | degree:5 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:195.998 edo:12 ]", + "[ 3/2 → 5/3 | degree:7 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:246.942 edo:12 ]", + "[ 5/3 → 11/6 | degree:5 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:195.998 edo:12 ]", + "[ 11/6 → 2/1 | degree:3 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:164.814 edo:12 ]", + "[ 2/1 → 13/6 | degree:1 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:130.813 edo:12 ]", + "[ 13/6 → 7/3 | degree:3 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:164.814 edo:12 ]", + "[ 7/3 → 5/2 | degree:5 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:195.998 edo:12 ]", + "[ 5/2 → 8/3 | degree:7 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:246.942 edo:12 ]", + "[ 8/3 → 17/6 | degree:5 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:195.998 edo:12 ]", + "[ 17/6 → 3/1 | degree:3 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:164.814 edo:12 ]", + "[ 3/1 → 19/6 | degree:1 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:130.813 edo:12 ]", + "[ 19/6 → 10/3 | degree:3 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:164.814 edo:12 ]", + "[ 10/3 → 7/2 | degree:5 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:195.998 edo:12 ]", + "[ 7/2 → 11/3 | degree:7 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:246.942 edo:12 ]", + "[ 11/3 → 23/6 | degree:5 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:195.998 edo:12 ]", + "[ 23/6 → 4/1 | degree:3 degreeIndexes:[0 2 4 5 7 9 11] intLabels:[null M2 M3 P4 P5 M6 T7 P8] root:130.8128 freq:164.814 edo:12 ]", +] +`; + +exports[`runs examples > example "edoScale" example index 1 1`] = ` +[ + "[ 0/1 → 1/4 | degree:1 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:97.999 edo:16 s:piano ]", + "[ 0/1 → 1/4 | degree:2 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:223.2 edo:16 s:piano ]", + "[ 1/4 → 1/2 | degree:5 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:151.135 edo:16 s:piano ]", + "[ 1/2 → 3/4 | degree:3 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:127.089 edo:16 s:piano ]", + "[ 1/2 → 3/4 | degree:2 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:223.2 edo:16 s:piano ]", + "[ 3/4 → 1/1 | degree:5 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:151.135 edo:16 s:piano ]", + "[ 1/1 → 5/4 | degree:1 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:97.999 edo:16 s:piano ]", + "[ 1/1 → 5/4 | degree:2 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:223.2 edo:16 s:piano ]", + "[ 5/4 → 3/2 | degree:5 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:164.814 edo:16 s:piano ]", + "[ 3/2 → 7/4 | degree:3 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:127.089 edo:16 s:piano ]", + "[ 3/2 → 7/4 | degree:2 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:223.2 edo:16 s:piano ]", + "[ 7/4 → 2/1 | degree:5 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:164.814 edo:16 s:piano ]", + "[ 2/1 → 9/4 | degree:1 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:97.999 edo:16 s:piano ]", + "[ 2/1 → 9/4 | degree:2 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:223.2 edo:16 s:piano ]", + "[ 9/4 → 5/2 | degree:5 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:151.135 edo:16 s:piano ]", + "[ 5/2 → 11/4 | degree:3 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:127.089 edo:16 s:piano ]", + "[ 5/2 → 11/4 | degree:2 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:223.2 edo:16 s:piano ]", + "[ 11/4 → 3/1 | degree:5 degreeIndexes:[0 3 6 7 10 13] intLabels:[null S2 d4 N4 s6 s7 P8] root:97.9989 freq:151.135 edo:16 s:piano ]", + "[ 3/1 → 13/4 | degree:1 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:97.999 edo:16 s:piano ]", + "[ 3/1 → 13/4 | degree:2 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:223.2 edo:16 s:piano ]", + "[ 13/4 → 7/2 | degree:5 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:164.814 edo:16 s:piano ]", + "[ 7/2 → 15/4 | degree:3 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:127.089 edo:16 s:piano ]", + "[ 7/2 → 15/4 | degree:2 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:223.2 edo:16 s:piano ]", + "[ 15/4 → 4/1 | degree:5 degreeIndexes:[0 3 6 9 12 13] intLabels:[null S2 d4 M6 s7 P8] root:97.9989 freq:164.814 edo:16 s:piano ]", +] +`; + +exports[`runs examples > example "edoScale" example index 2 1`] = ` +[ + "[ 0/1 → 1/6 | degree:1 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:97.999 edo:13 s:piano ]", + "[ 1/6 → 1/3 | degree:5 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:167.025 edo:13 s:piano ]", + "[ 1/3 → 1/2 | degree:3 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:134.945 edo:13 s:piano ]", + "[ 1/2 → 2/3 | degree:2 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:114.998 edo:13 s:piano ]", + "[ 2/3 → 5/6 | degree:3 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:134.945 edo:13 s:piano ]", + "[ 5/6 → 1/1 | degree:1 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:97.999 edo:13 s:piano ]", + "[ 1/1 → 7/6 | degree:4 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:189.995 edo:13 s:piano ]", + "[ 7/6 → 4/3 | degree:2 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:153.504 edo:13 s:piano ]", + "[ 4/3 → 3/2 | degree:3 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:180.13 edo:13 s:piano ]", + "[ 3/2 → 5/3 | degree:4 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:189.995 edo:13 s:piano ]", + "[ 5/3 → 11/6 | degree:1 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:130.813 edo:13 s:piano ]", + "[ 11/6 → 2/1 | degree:5 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:222.952 edo:13 s:piano ]", + "[ 2/1 → 13/6 | degree:1 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:195.998 edo:13 s:piano ]", + "[ 13/6 → 7/3 | degree:4 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:142.336 edo:13 s:piano ]", + "[ 7/3 → 5/2 | degree:3 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:134.945 edo:13 s:piano ]", + "[ 5/2 → 8/3 | degree:3 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:134.945 edo:13 s:piano ]", + "[ 8/3 → 17/6 | degree:4 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:142.336 edo:13 s:piano ]", + "[ 17/6 → 3/1 | degree:2 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:97.9989 freq:114.998 edo:13 s:piano ]", + "[ 3/1 → 19/6 | degree:2 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:153.504 edo:13 s:piano ]", + "[ 19/6 → 10/3 | degree:2 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:153.504 edo:13 s:piano ]", + "[ 10/3 → 7/2 | degree:1 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:261.626 edo:13 s:piano ]", + "[ 7/2 → 11/3 | degree:3 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:180.13 edo:13 s:piano ]", + "[ 11/3 → 23/6 | degree:3 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:180.13 edo:13 s:piano ]", + "[ 23/6 → 4/1 | degree:4 degreeIndexes:[0 3 6 7 10] intLabels:[null s3 n4 t5 d7 P8] root:130.8128 freq:189.995 edo:13 s:piano ]", +] +`; + exports[`runs examples > example "end" example index 0 1`] = ` [ "[ 0/1 → 1/8 | s:oh end:0.1 ]", @@ -5191,6 +5306,15 @@ exports[`runs examples > example "gain" example index 0 1`] = ` exports[`runs examples > example "gap" example index 0 1`] = `[]`; +exports[`runs examples > example "getDur" example index 0 1`] = ` +[ + "[ 0/1 → 1/1 | s:sax cps:null ]", + "[ 1/1 → 2/1 | s:sax cps:null ]", + "[ 2/1 → 3/1 | s:sax cps:null ]", + "[ 3/1 → 4/1 | s:sax cps:null ]", +] +`; + exports[`runs examples > example "grow" example index 0 1`] = ` [ "[ 0/1 → 1/10 | s:tha bank:mridangam ]", @@ -6403,6 +6527,27 @@ exports[`runs examples > example "juxBy" example index 0 1`] = ` ] `; +exports[`runs examples > example "keep" example index 0 1`] = ` +[ + "[ 0/1 → 1/4 | note:c s:piano ]", + "[ 1/4 → 1/2 | note:a s:piano ]", + "[ 1/2 → 3/4 | note:f s:piano ]", + "[ 3/4 → 1/1 | note:e s:piano ]", + "[ 1/1 → 5/4 | note:c s:piano ]", + "[ 5/4 → 3/2 | note:a s:piano ]", + "[ 3/2 → 7/4 | note:f s:piano ]", + "[ 7/4 → 2/1 | note:e s:piano ]", + "[ 2/1 → 9/4 | note:c s:piano ]", + "[ 9/4 → 5/2 | note:a s:piano ]", + "[ 5/2 → 11/4 | note:f s:piano ]", + "[ 11/4 → 3/1 | note:e s:piano ]", + "[ 3/1 → 13/4 | note:c s:piano ]", + "[ 13/4 → 7/2 | note:a s:piano ]", + "[ 7/2 → 15/4 | note:f s:piano ]", + "[ 15/4 → 4/1 | note:e s:piano ]", +] +`; + exports[`runs examples > example "keyDown" example index 0 1`] = `[]`; exports[`runs examples > example "lastOf" example index 0 1`] = ` @@ -11110,6 +11255,27 @@ exports[`runs examples > example "seqPLoop" example index 0 1`] = ` ] `; +exports[`runs examples > example "set" example index 0 1`] = ` +[ + "[ 0/1 → 1/4 | note:c s:triangle ]", + "[ 1/4 → 1/2 | note:a s:triangle ]", + "[ 1/2 → 3/4 | note:f s:triangle ]", + "[ 3/4 → 1/1 | note:e s:triangle ]", + "[ 1/1 → 5/4 | note:c s:triangle ]", + "[ 5/4 → 3/2 | note:a s:triangle ]", + "[ 3/2 → 7/4 | note:f s:triangle ]", + "[ 7/4 → 2/1 | note:e s:triangle ]", + "[ 2/1 → 9/4 | note:c s:triangle ]", + "[ 9/4 → 5/2 | note:a s:triangle ]", + "[ 5/2 → 11/4 | note:f s:triangle ]", + "[ 11/4 → 3/1 | note:e s:triangle ]", + "[ 3/1 → 13/4 | note:c s:triangle ]", + "[ 13/4 → 7/2 | note:a s:triangle ]", + "[ 7/2 → 15/4 | note:f s:triangle ]", + "[ 15/4 → 4/1 | note:e s:triangle ]", +] +`; + exports[`runs examples > example "setDefaultJoin" example index 0 1`] = ` [ "[ 0/1 → 1/4 | s:saw velocity:1 note:F delay:0 ]", diff --git a/test/runtime.mjs b/test/runtime.mjs index 5a64913cf..e06f91620 100644 --- a/test/runtime.mjs +++ b/test/runtime.mjs @@ -13,6 +13,7 @@ import { mini, m } from '@strudel/mini/mini.mjs'; // import euclid from '@strudel/core/euclid.mjs'; //import '@strudel/midi/midi.mjs'; import * as tonalHelpers from '@strudel/tonal'; +import * as edoHelpers from '@strudel/edo'; import '@strudel/xen/xen.mjs'; // import '@strudel/xen/tune.mjs'; // import '@strudel/core/euclid.mjs'; @@ -126,6 +127,7 @@ const loadSoundfont = () => {}; const loadCsound = () => {}; const loadCSound = () => {}; const loadcsound = () => {}; +const getDuration = () => {}; const midin = () => { return (ccNum) => strudel.ref(() => 0); // returns ref with default value 0 @@ -144,6 +146,7 @@ evalScope( toneHelpersMocked, uiHelpersMocked, webaudio, + edoHelpers, tonalHelpers, gamepadHelpers, /* @@ -168,6 +171,7 @@ evalScope( loadCSound, loadCsound, loadcsound, + getDuration, setcps: id, setcpm: id, Clock: {}, // whatever diff --git a/website/package.json b/website/package.json index 6f5539dd6..7258f150e 100644 --- a/website/package.json +++ b/website/package.json @@ -17,6 +17,11 @@ "@astrojs/react": "^4.1.6", "@astrojs/rss": "^4.0.11", "@astrojs/tailwind": "^5.1.5", + "@codemirror/commands": "catalog:", + "@codemirror/language": "catalog:", + "@codemirror/lang-javascript": "catalog:", + "@codemirror/state": "catalog:", + "@codemirror/view": "catalog:", "@docsearch/css": "^3.8.3", "@docsearch/react": "^3.8.3", "@headlessui/react": "^2.2.0", @@ -28,6 +33,7 @@ "@strudel/csound": "workspace:*", "@strudel/desktopbridge": "workspace:*", "@strudel/draw": "workspace:*", + "@strudel/edo": "workspace:*", "@strudel/gamepad": "workspace:*", "@strudel/hydra": "workspace:*", "@strudel/midi": "workspace:*", diff --git a/website/src/pages/technical-manual/alignment.mdx b/website/src/pages/technical-manual/alignment.mdx index dd3a6db5f..c2a70f1a9 100644 --- a/website/src/pages/technical-manual/alignment.mdx +++ b/website/src/pages/technical-manual/alignment.mdx @@ -1,11 +1,11 @@ --- -title: Pattern Aligment +title: Pattern Alignment layout: ../../layouts/MainLayout.astro --- import { MiniRepl } from '../../docs/MiniRepl'; -# Pattern Aligment & Combination +# Pattern Alignment & Combination One core aspect of Strudel, inherited from Tidal, is the flexible way that patterns can be combined, irrespective of their structure. Its declarative approach means a live coder does not have to think about the details of _how_ this is done, only _what_ is to be done. diff --git a/website/src/repl/Repl.css b/website/src/repl/Repl.css index a00600ae6..3907ae248 100644 --- a/website/src/repl/Repl.css +++ b/website/src/repl/Repl.css @@ -10,50 +10,50 @@ opacity: 0.5; } -#code .cm-scroller { +.code-container .cm-scroller { padding-top: 10px !important; height: 100%; font-family: inherit; } -#code .cm-content { +.code-container .cm-content { padding-bottom: 50vh; } -#code .cm-line > * { +.code-container .cm-line > * { background: var(--lineBackground); } -#code .cm-editor { +.code-container .cm-editor { background-color: transparent !important; height: 100%; z-index: 11; } -#code .cm-theme { +.code-container .cm-theme { width: 100%; height: 100%; } -#code .cm-theme-light { +.code-container .cm-theme-light { width: 100%; } -#code .cm-cursorLayer { +.code-container .cm-cursorLayer { animation-name: inherit !important; } -#code .cm-cursor { +.code-container .cm-cursor { border-left: 2px solid currentcolor !important; } -#code .cm-foldGutter { +.code-container .cm-foldGutter { display: none !important; } -#code .cm-focused { +.code-container .cm-focused { outline: none; } -#code .cm-matchingBracket { +.code-container .cm-matchingBracket { text-decoration: underline 0.18rem; text-underline-offset: 0.22rem; } diff --git a/website/src/repl/components/Code.jsx b/website/src/repl/components/Code.jsx index e8cccb86f..161db1020 100644 --- a/website/src/repl/components/Code.jsx +++ b/website/src/repl/components/Code.jsx @@ -8,8 +8,7 @@ export function Code(Props) { return (
{ containerRef.current = el; if (!editorRef.current) { diff --git a/website/src/repl/components/button/action-button.jsx b/website/src/repl/components/button/action-button.jsx index 245cf961e..52ae2f84c 100644 --- a/website/src/repl/components/button/action-button.jsx +++ b/website/src/repl/components/button/action-button.jsx @@ -8,31 +8,3 @@ export function ActionButton({ children, label, labelIsHidden, className, ...but ); } - -export function SpecialActionButton(props) { - const { className, ...buttonProps } = props; - - return ( - - ); -} - -export function ActionInput({ label, className, ...props }) { - return ( - - ); -} - -export function SpecialActionInput({ className, ...props }) { - return ( - {props.label}} - /> - ); -} diff --git a/website/src/repl/components/panel/ImportPrebakeScriptButton.jsx b/website/src/repl/components/panel/ImportPrebakeScriptButton.jsx index 64ce198be..3b22dad3c 100644 --- a/website/src/repl/components/panel/ImportPrebakeScriptButton.jsx +++ b/website/src/repl/components/panel/ImportPrebakeScriptButton.jsx @@ -1,42 +1,50 @@ import { errorLogger } from '@strudel/core'; -import { useSettings, storePrebakeScript } from '../../../settings.mjs'; -import { SpecialActionInput } from '../button/action-button'; -import { confirmDialog, SETTING_CHANGE_RELOAD_MSG } from '@src/repl/util.mjs'; +import { storePrebakeScript } from '../../../settings.mjs'; +import { confirmDialog } from '@src/repl/util.mjs'; -async function importScript(script) { +async function importScript(script, updateEditor) { const reader = new FileReader(); reader.readAsText(script); reader.onload = () => { const text = reader.result; storePrebakeScript(text); + updateEditor && updateEditor(text); }; reader.onerror = () => { errorLogger(new Error('failed to import prebake script'), 'importScript'); }; } -export function ImportPrebakeScriptButton() { - const settings = useSettings(); +export async function exportScript(script) { + const blob = new Blob([script], { type: 'application/javascript' }); + const downloadLink = document.createElement('a'); + downloadLink.href = window.URL.createObjectURL(blob); + const date = new Date().toISOString().split('T')[0]; + downloadLink.download = `prebake_${date}.strudel`; + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); +} + +export function ImportPrebakeScriptButton({ updateEditor }) { + const handleChange = async (e) => { + const file = e.target.files[0]; + const confirmed = await confirmDialog('Warning: This will overwrite the current prebake.\nContinue?'); + if (!confirmed) { + return; + } + try { + await importScript(file, updateEditor); + } catch (e) { + errorLogger(e); + } + }; return ( - { - const file = e.target.files[0]; - const confirmed = await confirmDialog(SETTING_CHANGE_RELOAD_MSG); - if (!confirmed) { - return; - } - try { - await importScript(file); - window.location.reload(); - } catch (e) { - errorLogger(e); - } - }} - /> + ); } diff --git a/website/src/repl/components/panel/SettingsTab.jsx b/website/src/repl/components/panel/SettingsTab.jsx index e20f8420c..a385fd48a 100644 --- a/website/src/repl/components/panel/SettingsTab.jsx +++ b/website/src/repl/components/panel/SettingsTab.jsx @@ -1,18 +1,16 @@ -import { defaultSettings, settingsMap, useSettings } from '../../../settings.mjs'; +import { defaultSettings, settingsMap, useSettings, storePrebakeScript, setSettingsTab } from '../../../settings.mjs'; import { themes } from '@strudel/codemirror'; +import { PrebakeCodeMirror } from '../../../repl/prebakeCodeMirror.mjs'; import { confirmAndReloadPage, isUdels } from '../../util.mjs'; import { ButtonGroup } from './Forms.jsx'; import { AudioDeviceSelector } from './AudioDeviceSelector.jsx'; import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx'; import { confirmDialog } from '../../util.mjs'; import { DEFAULT_MAX_POLYPHONY, setMaxPolyphony, setMultiChannelOrbits } from '@strudel/webaudio'; -import { SpecialActionButton } from '../button/action-button.jsx'; -import { ImportPrebakeScriptButton } from './ImportPrebakeScriptButton.jsx'; - -function cx(...classes) { - // : Array - return classes.filter(Boolean).join(' '); -} +import { ActionButton } from '../button/action-button.jsx'; +import { exportScript, ImportPrebakeScriptButton } from './ImportPrebakeScriptButton.jsx'; +import { useEffect, useRef } from 'react'; +import cx from '@src/cx.mjs'; const inputClass = 'bg-background text-xs h-8 max-h-8 border border-box rounded-0 text-foreground border-muted placeholder-muted focus:outline-none focus:ring-0 focus:border-foreground'; @@ -123,7 +121,7 @@ const fontFamilyOptions = { galactico: 'galactico', }; -export function SettingsTab({ started }) { +function MainSettingsContent({ started }) { const { theme, keybindings, @@ -149,13 +147,12 @@ export function SettingsTab({ started }) { isTabIndentationEnabled, isMultiCursorEnabled, patternAutoStart, - includePrebakeScriptInShare, isBlockBasedEvalEnabled, } = useSettings(); const shouldAlwaysSync = isUdels(); const canChangeAudioDevice = AudioContext.prototype.setSinkId != null; return ( -
+
{canChangeAudioDevice && (
- - - settingsMap.setKey('includePrebakeScriptInShare', cbEvent.target.checked)} - value={includePrebakeScriptInShare} - /> - Try clicking the logo in the top left! - { confirmDialog('Sure?').then((r) => { if (r) { @@ -355,10 +344,70 @@ export function SettingsTab({ started }) { } }); }} + className="bg-background p-2 max-w-[300px] hover:opacity-50" > restore default settings - +
); } + +function PrebakeSettingsContent() { + const { fontFamily, includePrebakeScriptInShare, prebakeScript } = useSettings(); + const editorRef = useRef(); + useEffect(() => { + return () => { + editorRef.current?.cleanup(); + }; + }); + + return ( +
+
+
{ + if (editorRef.current) { + return; + } + editorRef.current = new PrebakeCodeMirror(prebakeScript, (code) => storePrebakeScript(code), el); + }} + >
+
+
+ settingsMap.setKey('includePrebakeScriptInShare', cbEvent.target.checked)} + value={includePrebakeScriptInShare} + /> +
+ editorRef?.current.setCode(code)} /> + exportScript(prebakeScript)}>export + editorRef.current?.savePrebake()}>save +
+
+
+ ); +} +export function SettingsTab({ started }) { + const { settingsTab } = useSettings(); + return ( +
+
+ setSettingsTab(value)} + items={{ + settings: 'settings', + prebake: 'prebake', + }} + > +
+ {settingsTab === 'settings' && } + {settingsTab === 'prebake' && } +
+ ); +} diff --git a/website/src/repl/prebakeCodeMirror.mjs b/website/src/repl/prebakeCodeMirror.mjs new file mode 100644 index 000000000..42b913ae6 --- /dev/null +++ b/website/src/repl/prebakeCodeMirror.mjs @@ -0,0 +1,115 @@ +import { toggleLineComment } from '@codemirror/commands'; +import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; +import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { Compartment, EditorState, Prec } from '@codemirror/state'; +import { drawSelection, EditorView, keymap } from '@codemirror/view'; +import { logger } from '@strudel/core'; +import { basicSetup, flash, initTheme, extensions, parseBooleans, codemirrorSettings } from '@strudel/codemirror'; +import { evaluate } from '@strudel/transpiler'; + +export class PrebakeCodeMirror { + constructor(initialCode, storePrebake, container) { + const settings = codemirrorSettings.get(); + this.storePrebake = storePrebake; + const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()])); + const initialSettings = Object.keys(compartments).map((key) => + compartments[key].of(extensions[key](parseBooleans(settings[key]))), + ); + initTheme(settings.theme); + let state = EditorState.create({ + doc: initialCode, + extensions: [ + ...initialSettings, + basicSetup, + javascript(), + javascriptLanguage.data.of({ + closeBrackets: { brackets: ['(', '[', '{', "'", '"', '<'] }, + bracketMatching: { brackets: ['(', '[', '{', "'", '"', '<'] }, + }), + syntaxHighlighting(defaultHighlightStyle), + EditorView.updateListener.of((v) => { + if (v.docChanged) { + this.code = v.state.doc.toString(); + } + }), + drawSelection({ cursorBlinkRate: 0 }), + Prec.highest( + keymap.of([ + { + mac: 'Meta-Enter', + run: () => { + this.savePrebake(); + }, + }, + { + key: 'Ctrl-Enter', + run: () => { + this.savePrebake(); + }, + }, + { + key: 'Alt-Enter', + run: () => { + this.savePrebake(); + }, + }, + ]), + ), + ], + }); + + this.code = initialCode; + this.view = new EditorView({ + state, + parent: container, + }); + + const handleSaveEvent = async (e) => { + if (e.detail.view !== this.view) { + return; // ignore events from other editors + } + await this.savePrebake(); + e?.cancelable && e.preventDefault?.(); + }; + const handleToggleComment = (e) => { + if (e.detail.view !== this.view) { + return; // ignore events from other editors + } + this.toggleComment(); + e?.cancelable && e.preventDefault?.(); + }; + + document.addEventListener('repl-evaluate', handleSaveEvent); + document.addEventListener('repl-toggle-comment', handleToggleComment); + this.cleanup = () => { + document.removeEventListener('prebake-evaluate', handleSaveEvent); + document.removeEventListener('prebake-toggle-comment', handleToggleComment); + }; + } + + async savePrebake() { + flash(this.view); + this.storePrebake(this.code); + evaluate(this.code, { addReturn: false }); // run prebake + logger('[prebake] prebake saved'); + } + + toggleComment() { + try { + // Honor selections; toggleLineComment handles both selections and + // single line + toggleLineComment(this.view); + } catch (err) { + console.error('Error handling repl-toggle-comment event', err); + } + } + + setCode(code) { + const changes = { + from: 0, + to: this.view.state.doc.length, + insert: code, + }; + this.view.dispatch({ changes }); + } +} diff --git a/website/src/repl/useReplContext.jsx b/website/src/repl/useReplContext.jsx index 65bd4c2d5..852c63f88 100644 --- a/website/src/repl/useReplContext.jsx +++ b/website/src/repl/useReplContext.jsx @@ -6,7 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th import { code2hash, getPerformanceTimeSeconds, logger, silence } from '@strudel/core'; import { getDrawContext } from '@strudel/draw'; -import { evaluate, transpiler } from '@strudel/transpiler'; +import { transpiler, evaluate } from '@strudel/transpiler'; import { getAudioContextCurrentTime, renderPatternAudio, @@ -87,12 +87,12 @@ export function useReplContext() { pattern: silence, drawTime, drawContext, - prebake: async () => - Promise.all([modulesLoading, presets]).then(() => { - if (prebakeScript?.length) { - return evaluate(prebakeScript ?? ''); - } - }), + prebake: async () => { + await Promise.all([modulesLoading, presets]); + if (prebakeScript) { + return evaluate(prebakeScript, { addReturn: false }); + } + }, onUpdateState: (state) => { setReplState({ ...state }); }, diff --git a/website/src/repl/util.mjs b/website/src/repl/util.mjs index 06dad2467..c141257f9 100644 --- a/website/src/repl/util.mjs +++ b/website/src/repl/util.mjs @@ -70,6 +70,7 @@ export function loadModules() { let modules = [ import('@strudel/core'), import('@strudel/draw'), + import('@strudel/edo'), import('@strudel/tonal'), import('@strudel/mini'), import('@strudel/xen'), @@ -108,6 +109,7 @@ export function confirmDialog(msg) { }); } export const SETTING_CHANGE_RELOAD_MSG = 'Changing this setting requires the window to reload itself. OK?'; + export function confirmAndReloadPage(onSuccess) { confirmDialog(SETTING_CHANGE_RELOAD_MSG).then((r) => { if (r == true) { diff --git a/website/src/settings.mjs b/website/src/settings.mjs index 6acf8e2e2..d2f57afc9 100644 --- a/website/src/settings.mjs +++ b/website/src/settings.mjs @@ -18,6 +18,20 @@ export const soundFilterType = { ALL: 'all', }; +const initialPrebakeScript = `// Prebake script +// +// This is code that is loaded before your pattern is run. +// You can use it to define custom functions to use in any pattern. +// +// This is an initial example script. You can edit it to add +// your own funtions. +// +// To use a script shared by some other user you can use +// the import-button or paste the script in this editor. + +const ratchet = register('ratchet', (pat) => pat.sometimes(ply(2))) +`; + export const defaultSettings = { activeFooter: 'intro', keybindings: 'codemirror', @@ -47,13 +61,14 @@ export const defaultSettings = { isPanelPinned: false, isPanelOpen: true, userPatterns: '{}', - prebakeScript: '', + prebakeScript: initialPrebakeScript, audioEngineTarget: audioEngineTargets.webaudio, isButtonRowHidden: false, isCSSAnimationDisabled: false, maxPolyphony: 128, multiChannelOrbits: false, includePrebakeScriptInShare: true, + settingsTab: 'settings', }; let search = null; @@ -115,6 +130,7 @@ export function useSettings() { export const setActiveFooter = (tab) => settingsMap.setKey('activeFooter', tab); export const setPanelPinned = (bool) => settingsMap.setKey('isPanelPinned', bool); export const setIsPanelOpened = (bool) => settingsMap.setKey('isPanelOpen', bool); +export const setSettingsTab = (tab) => settingsMap.setKey('settingsTab', tab); export const storePrebakeScript = (script) => settingsMap.setKey('prebakeScript', script);