Add packages/edo for Equal Division of the Octave scales
This commit is contained in:
parent
db413881db
commit
73965a3a39
12 changed files with 764 additions and 0 deletions
41
packages/edo/README.md
Normal file
41
packages/edo/README.md
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
]"
|
||||||
|
]
|
||||||
|
```
|
||||||
75
packages/edo/edo.mjs
Normal file
75
packages/edo/edo.mjs
Normal file
|
|
@ -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 <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/edo.mjs>
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
213
packages/edo/edoscale.mjs
Normal file
213
packages/edo/edoscale.mjs
Normal file
|
|
@ -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 <https://github.com/robmckinnon/pitfalls/blob/main/lib/Scale.lua>
|
||||||
|
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/edoscale.mjs>
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
packages/edo/index.mjs
Normal file
5
packages/edo/index.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import './edo.mjs';
|
||||||
|
|
||||||
|
export * from './edo.mjs';
|
||||||
|
|
||||||
|
export const packageName = '@strudel/edo';
|
||||||
103
packages/edo/intervals.mjs
Normal file
103
packages/edo/intervals.mjs
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
/*
|
||||||
|
intervals.mjs - defines Intervals for equal division of the octave (EDO) scale
|
||||||
|
- Port of pitfalls/lib/Intervals.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/Intervals.lua>
|
||||||
|
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/intervals.mjs>
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
packages/edo/package.json
Normal file
42
packages/edo/package.json
Normal file
|
|
@ -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 <rob@movingflow>",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
98
packages/edo/pitches.mjs
Normal file
98
packages/edo/pitches.mjs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
/*
|
||||||
|
pitches.mjs - defines Pitches for equal division of the octave (EDO) scale
|
||||||
|
- Port of pitfalls/lib/Pitches.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/Pitches.lua>
|
||||||
|
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/pitches.mjs>
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
packages/edo/ratios.mjs
Normal file
95
packages/edo/ratios.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
/*
|
||||||
|
ratios.mjs - lists whole number ratios for pitch intervals
|
||||||
|
- Port of pitfalls/lib/ratios.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/ratios.lua>
|
||||||
|
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/ratios.mjs>
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
34
packages/edo/test/edo.test.mjs
Normal file
34
packages/edo/test/edo.test.mjs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
edo.test.mjs - tests of edo.mjs
|
||||||
|
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/tonal/test/tonal.test.mjs>
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
21
packages/edo/test/edoscale.test.mjs
Normal file
21
packages/edo/test/edoscale.test.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
edoscale.test.mjs - tests of EdoScale from edoscale.mjs
|
||||||
|
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/test/edoscale.test.mjs>
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
18
packages/edo/test/ratios.test.mjs
Normal file
18
packages/edo/test/ratios.test.mjs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
ratios.test.mjs - tests of ratios.mjs
|
||||||
|
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/test/ratios.test.mjs>
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
packages/edo/vite.config.js
Normal file
19
packages/edo/vite.config.js
Normal file
|
|
@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue