From 73965a3a39e5aef8d6b310d5ca7f9b3b9fa81382 Mon Sep 17 00:00:00 2001 From: robmckinnon Date: Sat, 28 Jun 2025 18:24:05 +0100 Subject: [PATCH] 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', + }, +});