From 73965a3a39e5aef8d6b310d5ca7f9b3b9fa81382 Mon Sep 17 00:00:00 2001 From: robmckinnon Date: Sat, 28 Jun 2025 18:24:05 +0100 Subject: [PATCH 01/42] Add packages/edo for Equal Division of the Octave scales --- packages/edo/README.md | 41 ++++++ packages/edo/edo.mjs | 75 ++++++++++ packages/edo/edoscale.mjs | 213 ++++++++++++++++++++++++++++ packages/edo/index.mjs | 5 + packages/edo/intervals.mjs | 103 ++++++++++++++ packages/edo/package.json | 42 ++++++ packages/edo/pitches.mjs | 98 +++++++++++++ packages/edo/ratios.mjs | 95 +++++++++++++ packages/edo/test/edo.test.mjs | 34 +++++ packages/edo/test/edoscale.test.mjs | 21 +++ packages/edo/test/ratios.test.mjs | 18 +++ packages/edo/vite.config.js | 19 +++ 12 files changed, 764 insertions(+) create mode 100644 packages/edo/README.md create mode 100644 packages/edo/edo.mjs create mode 100644 packages/edo/edoscale.mjs create mode 100644 packages/edo/index.mjs create mode 100644 packages/edo/intervals.mjs create mode 100644 packages/edo/package.json create mode 100644 packages/edo/pitches.mjs create mode 100644 packages/edo/ratios.mjs create mode 100644 packages/edo/test/edo.test.mjs create mode 100644 packages/edo/test/edoscale.test.mjs create mode 100644 packages/edo/test/ratios.test.mjs create mode 100644 packages/edo/vite.config.js 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..22e44be68 --- /dev/null +++ b/packages/edo/edo.mjs @@ -0,0 +1,75 @@ +/* +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(); + +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) : 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..1cd5187bc --- /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', + }, +}); From 43293dc80f203932e17e841eb6e866862d15fdee Mon Sep 17 00:00:00 2001 From: robmckinnon Date: Sat, 28 Jun 2025 19:13:41 +0100 Subject: [PATCH 02/42] Import packages/edo into strudel --- examples/codemirror-repl/main.js | 1 + examples/codemirror-repl/package.json | 1 + index.mjs | 1 + package.json | 1 + packages/edo/edo.mjs | 41 ++++++++++- packages/edo/pitches.mjs | 2 +- packages/repl/package.json | 1 + packages/repl/prebake.mjs | 1 + packages/web/package.json | 1 + packages/web/web.mjs | 2 + pnpm-lock.yaml | 38 ++++++++++ test/__snapshots__/examples.test.mjs.snap | 87 +++++++++++++++++++++++ test/runtime.mjs | 2 + website/package.json | 1 + website/src/repl/util.mjs | 1 + 15 files changed, 179 insertions(+), 2 deletions(-) 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 e0ce35b8a..55b152f36 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 d18291eb2..399bd7b10 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/edo/edo.mjs b/packages/edo/edo.mjs index 22e44be68..1e99c2434 100644 --- a/packages/edo/edo.mjs +++ b/packages/edo/edo.mjs @@ -11,6 +11,45 @@ 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) { @@ -51,7 +90,7 @@ export const edoScale = register( // legacy.. return pure(n); } - const deg = (typeof n === 'string' ? parseInt(n, 10) : n) + 1; + 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); diff --git a/packages/edo/pitches.mjs b/packages/edo/pitches.mjs index 1cd5187bc..003e15b72 100644 --- a/packages/edo/pitches.mjs +++ b/packages/edo/pitches.mjs @@ -76,7 +76,7 @@ export class Pitches { 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]); + // console.log([octave, degree]); return [octave, degree]; } diff --git a/packages/repl/package.json b/packages/repl/package.json index bfa404c75..8fae08f44 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 dd0023fb5..0c60101a5 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/web/package.json b/packages/web/package.json index 0feddc82d..6d9e04300 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 7aecd35bd..c88da0784 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,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 +96,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 @@ -271,6 +277,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: @@ -434,6 +462,9 @@ importers: '@strudel/draw': specifier: workspace:* version: link:../draw + '@strudel/edo': + specifier: workspace:* + version: link:../edo '@strudel/hydra': specifier: workspace:* version: link:../hydra @@ -591,6 +622,9 @@ importers: '@strudel/core': specifier: workspace:* version: link:../core + '@strudel/edo': + specifier: workspace:* + version: link:../edo '@strudel/mini': specifier: workspace:* version: link:../mini @@ -702,6 +736,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 @@ -7657,6 +7694,7 @@ packages: workbox-google-analytics@7.0.0: resolution: {integrity: sha512-MEYM1JTn/qiC3DbpvP2BVhyIH+dV/5BjHk756u9VbwuAhu0QHyKscTnisQuz21lfRpOwiS9z4XdqeVAKol0bzg==} + deprecated: It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained workbox-navigation-preload@7.0.0: resolution: {integrity: sha512-juWCSrxo/fiMz3RsvDspeSLGmbgC0U9tKqcUPZBCf35s64wlaLXyn2KdHHXVQrb2cqF7I0Hc9siQalainmnXJA==} diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index cc45848bf..806b9a1d9 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -3149,6 +3149,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 ]", diff --git a/test/runtime.mjs b/test/runtime.mjs index 2a29de3f5..1125550ff 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'; @@ -140,6 +141,7 @@ evalScope( toneHelpersMocked, uiHelpersMocked, webaudio, + edoHelpers, tonalHelpers, gamepadHelpers, /* diff --git a/website/package.json b/website/package.json index 499758147..f3cd6b719 100644 --- a/website/package.json +++ b/website/package.json @@ -29,6 +29,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/repl/util.mjs b/website/src/repl/util.mjs index ac27158f4..5ea823f67 100644 --- a/website/src/repl/util.mjs +++ b/website/src/repl/util.mjs @@ -71,6 +71,7 @@ export function loadModules() { let modules = [ import('@strudel/core'), import('@strudel/draw'), + import('@strudel/edo'), import('@strudel/tonal'), import('@strudel/mini'), import('@strudel/xen'), From ad331c8c2eb1a78ddcc0fcb0359422e0ef6b0a78 Mon Sep 17 00:00:00 2001 From: robmckinnon Date: Sat, 28 Jun 2025 19:14:20 +0100 Subject: [PATCH 03/42] Show EDO intervals in _pitchwheel() display --- packages/draw/pitchwheel.mjs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/draw/pitchwheel.mjs b/packages/draw/pitchwheel.mjs index ba76df079..c151da40c 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(); From 9f2237e11d00b8d46f473540bdc487d804f5e9d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Bj=C3=B6rk?= Date: Fri, 30 Jan 2026 20:52:15 +0100 Subject: [PATCH 04/42] Prebake in settings Update prebake without reload --- packages/codemirror/codemirror.mjs | 85 ++++++++++++- packages/codemirror/index.mjs | 2 +- packages/codemirror/keybindings.mjs | 28 ++++- packages/transpiler/index.mjs | 1 + packages/transpiler/transpiler.mjs | 7 +- .../repl/components/button/action-button.jsx | 29 ++++- .../panel/ImportPrebakeScriptButton.jsx | 30 +++-- .../src/repl/components/panel/SettingsTab.jsx | 114 +++++++++++++++--- website/src/repl/useReplContext.jsx | 14 ++- website/src/repl/util.mjs | 1 + website/src/settings.mjs | 18 ++- 11 files changed, 292 insertions(+), 37 deletions(-) diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 6014b9b0a..d6763700e 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -19,7 +19,7 @@ import { basicSetup } from './basicSetup.mjs'; import { evalBlock } from './block_utilities.mjs'; import { flash, isFlashEnabled } from './flash.mjs'; import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs'; -import { keybindings } from './keybindings.mjs'; +import { keybindings, prebakeField } from './keybindings.mjs'; import { jumpToCharacter } from './labelJump.mjs'; import { getSliderWidgets, sliderPlugin, updateSliderWidgets } from './slider.mjs'; import { activateTheme, initTheme, theme } from './themes.mjs'; @@ -412,6 +412,10 @@ export class StrudelMirror { setAutocompletionEnabled(enabled) { this.reconfigureExtension('isAutoCompletionEnabled', enabled); } + updatePrebake(prebake) { + logger('[repl] prebake updated'); + this.prebaked = prebake(); + } updateSettings(settings) { this.setFontSize(settings.fontSize); this.setFontFamily(settings.fontFamily); @@ -472,6 +476,85 @@ export class StrudelMirror { } } +export class PrebakeCodeMirror { + constructor(initialCode, storePrebake, containerRef, editorRef, settings) { + 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([ + { + key: 'Ctrl-Enter', + mac: 'Meta-Enter', + run: () => { + this.savePrebake(); + }, + }, + { + key: 'Alt-Enter', + run: () => { + this.savePrebake(); + }, + }, + ]), + ), + prebakeField, + ], + }); + editorRef.current = state; + this.code = initialCode; + this.view = new EditorView({ + state, + parent: containerRef.current, + }); + } + + async savePrebake() { + flash(this.view); + this.storePrebake(this.code); + 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 }); + } +} + export function parseBooleans(value) { return { true: true, false: false }[value] ?? value; } diff --git a/packages/codemirror/index.mjs b/packages/codemirror/index.mjs index 2d5b3de21..ea78f8221 100644 --- a/packages/codemirror/index.mjs +++ b/packages/codemirror/index.mjs @@ -4,4 +4,4 @@ export * from './flash.mjs'; export * from './slider.mjs'; export * from './themes.mjs'; export * from './widget.mjs'; -export { Vim } from './keybindings.mjs'; +export { Vim, prebakeField } from './keybindings.mjs'; diff --git a/packages/codemirror/keybindings.mjs b/packages/codemirror/keybindings.mjs index b8d9a6167..674216fb1 100644 --- a/packages/codemirror/keybindings.mjs +++ b/packages/codemirror/keybindings.mjs @@ -1,5 +1,5 @@ import { defaultKeymap } from '@codemirror/commands'; -import { Prec, EditorState } from '@codemirror/state'; +import { Prec, EditorState, StateField } from '@codemirror/state'; import { keymap, ViewPlugin } from '@codemirror/view'; // import { searchKeymap } from '@codemirror/search'; import { emacs } from '@replit/codemirror-emacs'; @@ -9,6 +9,15 @@ import { vscodeKeymap } from '@replit/codemirror-vscode-keymap'; import { helix, commands } from 'codemirror-helix'; import { logger } from '@strudel/core'; +export const prebakeField = StateField.define({ + create() { + return 'PrebakeEditor'; + }, + update(value, _tr) { + return value; + }, +}); + const vscodePlugin = ViewPlugin.fromClass( class { constructor() {} @@ -98,7 +107,11 @@ try { Vim.defineAction('strudelToggleComment', (cm) => { const view = cm?.view || cm; try { - const ev = new CustomEvent('repl-toggle-comment', { detail: { source: 'vim', view }, cancelable: true }); + const toggleEventName = + view?.cm6?.state?.field(prebakeField, false) !== undefined + ? 'prebake-toggle-comment' + : 'repl-toggle-comment'; + const ev = new CustomEvent(toggleEventName, { detail: { source: 'vim', view }, cancelable: true }); document.dispatchEvent(ev); } catch (e) { console.error('strudelToggleComment dispatch failed', e); @@ -119,6 +132,17 @@ try { // :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 isPrebake = view?.cm6?.state?.field(prebakeField, false) !== undefined; + if (isPrebake) { + let prebakeEventHandled = false; + try { + const ev = new CustomEvent('prebake-evaluate', { detail: { source: 'vim', view }, cancelable: true }); + prebakeEventHandled = document.dispatchEvent(ev) === false; // false means preventDefault was called + return; + } catch (e) { + console.error('Error dispatching repl-evaluate event', e); + } + } try { view?.focus?.(); // Let the app know this came from Vim :w diff --git a/packages/transpiler/index.mjs b/packages/transpiler/index.mjs index 484c3e70c..9ce11a9ff 100644 --- a/packages/transpiler/index.mjs +++ b/packages/transpiler/index.mjs @@ -3,3 +3,4 @@ import { transpiler } from './transpiler.mjs'; export * from './transpiler.mjs'; export const evaluate = (code) => _evaluate(code, transpiler); +export const evaluateUserPrebake = (code) => _evaluate(code, transpiler, { prebake: true }); diff --git a/packages/transpiler/transpiler.mjs b/packages/transpiler/transpiler.mjs index 210d40852..031a7e99f 100644 --- a/packages/transpiler/transpiler.mjs +++ b/packages/transpiler/transpiler.mjs @@ -25,6 +25,7 @@ export function transpiler(input, options = {}) { emitMiniLocations = true, emitWidgets = true, blockBased = false, + prebake = false, range = [], } = options; @@ -260,7 +261,9 @@ export function transpiler(input, options = {}) { // 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'); + if (!prebake) { + throw new Error('unexpected ast format without body expression'); + } } } @@ -273,7 +276,7 @@ export function transpiler(input, options = {}) { } // add return to last statement - if (addReturn) { + if (addReturn && !prebake) { const { expression } = body[body.length - 1]; body[body.length - 1] = { type: 'ReturnStatement', diff --git a/website/src/repl/components/button/action-button.jsx b/website/src/repl/components/button/action-button.jsx index 245cf961e..0143e5f90 100644 --- a/website/src/repl/components/button/action-button.jsx +++ b/website/src/repl/components/button/action-button.jsx @@ -17,22 +17,39 @@ export function SpecialActionButton(props) { ); } -export function ActionInput({ label, className, ...props }) { - return ( -