1/* 2xen.mjs - <short description TODO> 3Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/xen/xen.mjs> 4This 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/>. 5*/ 6 7import { register, _mod, parseNumeral, removeUndefineds } from '@strudel/core'; 8import Tune from './tunejs.js'; 9 10// returns a list of frequency ratios for given edo scale 11export function edo(name) { 12 if (!/^[1-9]+[0-9]*edo$/.test(name)) { 13 throw new Error('not an edo scale: "' + name + '"'); 14 } 15 const [_, divisions] = name.match(/^([1-9]+[0-9]*)edo$/); 16 return Array.from({ length: divisions }, (_, i) => Math.pow(2, i / divisions)); 17} 18 19const presets = { 20 '12ji': [1 / 1, 16 / 15, 9 / 8, 6 / 5, 5 / 4, 4 / 3, 45 / 32, 3 / 2, 8 / 5, 5 / 3, 16 / 9, 15 / 8], 21}; 22 23// Given a base frequency such as 220 and an edo scale, returns 24// an array of frequencies representing the given edo scale in that base 25function _withBase(freq, scale) { 26 return scale.map((r) => r * freq); 27} 28 29const defaultBase = 220; 30 31const isEdo = (scale) => /^[1-9]+[0-9]*edo$/.test(scale); 32 33// Assumes a base of 220. Returns a filtered scale based on 'indices' 34// NOTE: indices functionality is unused 35function getXenScale(scale, indices) { 36 let tune = new Tune(); 37 if (typeof scale === 'string') { 38 if (isEdo(scale)) { 39 scale = edo(scale); 40 } else if (presets[scale]) { 41 scale = presets[scale]; 42 } else if (tune.isValidScale(scale)) { 43 tune.loadScale(scale); 44 scale = tune.scale; 45 } else { 46 throw new Error('unknown scale name: "' + scale + '"'); 47 } 48 } 49 scale = _withBase(defaultBase, scale); 50 if (!indices) { 51 return scale; 52 } 53 return scale.filter((_, i) => indices.includes(i)); 54} 55 56function xenOffset(xenScale, offset, index = 0) { 57 const i = _mod(index + offset, xenScale.length); 58 const oct = Math.floor(offset / xenScale.length); 59 return xenScale[i] * Math.pow(2, oct); 60} 61 62const trimFreq = (freq) => parseFloat(freq.toPrecision(10)); 63 64// accepts a scale name such as 31edo, and a pattern 65// pattern expected to follow format such that a value can be mapped 66// to an edostep within the scale. Returns the pattern with 67// values mapped to the frequencies associated with the given edosteps 68// scaleNameOrRatios: string || number[], steps?: number 69 70/** 71 * Assumes a numerical pattern of scale steps, and a scale. Scales accepted are all preset scale names of `tune`, arbitrary edos such as 31edo, or an array of frequency ratios. Assumes scales repeat at octave (2/1). Returns a new pattern with all values mapped to their associated frequency, assuming a base frequency of 220hz. 72 * 73 * @name xen 74 * @returns Pattern 75 * @memberof Pattern 76 * @param {(string | number[] )} scaleNameOrRatios 77 * @tags tonal 78 * @example 79 * // A minor triad in 31edo: 80 * i("0 8 18").xen("31edo").piano() 81 * @example 82 * // You can also use xen with frequency ratios. 83 * // This is equivalent to the above: 84 * i("0 1 2").xen([ 85 * Math.pow(2, 0/31), 86 * Math.pow(2, 8/31), 87 * Math.pow(2, 18/31), 88 * ]).piano() 89 * @example 90 * // xen also supports all scale names that 91 * // tune does: 92 * i("0 1 2 3 4 5").xen("hexany15") 93 * // equiv to: 94 * // "0 1 2 3 4 5".tune("hexany15").mul("220").freq() 95 * @example 96 * i("0 1 2 3 4 5 6 7").xen("<5edo 10edo 15edo hexany15>") 97 */ 98 99export const xen = register('xen', function (scaleNameOrRatios, pat) { 100 return pat.withHaps((haps) => { 101 haps = haps.map((hap) => { 102 let hVal = hap.value; 103 const isObject = typeof hVal === 'object'; 104 if (!isObject) { 105 throw new Error(`Expected hap to have control 'i' set, but received ${hap.value.i}, try wrapping input in i()`); 106 } 107 const { i, ...otherValues } = hVal; 108 const scale = getXenScale(scaleNameOrRatios); 109 let freq = xenOffset(scale, parseNumeral(hVal.i)); 110 // 10 is somewhat arbitrary 111 freq = trimFreq(freq); 112 hap.value = { ...otherValues, freq }; 113 return isEdo(scaleNameOrRatios) 114 ? hap.setContext({ ...hap.context, edoSize: scaleNameOrRatios.match(/^([1-9]+[0-9]*)edo$/)[1] }) 115 : hap; 116 }); 117 return removeUndefineds(haps); 118 }); 119}); 120 121/** 122 * Assumes pattern of frequencies tuned to some `base` frequency, such as the output of `xen` 123 * Because `xen` defaults to `220Hz`, so will `withBase`. 124 * but you can specify a different original base with the standard optional array syntax '`:`' 125 * @name withBase 126 * @param {number} base 127 * @param {number} (optional) originalBase 128 * @tags tonal 129 * 130 * @example 131 * i("[0 1 2 3] [3 4] [4 3 2 1]").xen("hexany23").withBase("<220 [300 200]>") 132 * @example 133 * mini([1 / 1, 16 / 15, 9 / 8, 6 / 5, 5 / 4].join(' ')).withBase("220:1") 134 * // mini([1 / 1, 16 / 15, 9 / 8, 6 / 5, 5 / 4].join(' ')).mul(220).freq() 135 * 136 * @returns Pattern 137 */ 138export const withBase = register('withBase', (b, pat) => { 139 let base; 140 let originalBase = 220; 141 if (Array.isArray(b)) { 142 base = b[0]; 143 originalBase = b[1]; 144 } else { 145 base = b; 146 } 147 return pat.withHaps((haps) => { 148 haps = haps.map((hap) => { 149 let hVal = hap.value; 150 const isObject = typeof hVal === 'object'; 151 let freq = isObject ? hVal.freq : hVal; 152 if (!freq) return hap; 153 freq = (freq * base) / originalBase; 154 hap.value = isObject ? { ...hap.value, freq } : { freq }; 155 return hap; 156 }); 157 return removeUndefineds(haps); 158 }); 159}); 160 161/** 162 * Frequency transpose. Assumes pattern either has `freq` set, or has values that can be interpreted as frequencies 163 * amt has optional `edoSize` param, defaults to 12. 164 * If haps have edoSize param set, such as from the output of `xen("31edo")`, 165 * `ftrans` will fallback to that instead of 12 as the default. 166 * 167 * Transposes the frequency by `amt` edoSteps 168 * @name ftranspose 169 * @synonyms ftrans, fTrans, ftranspose, fTranspose 170 * @tags tonal 171 * @param {number} amt 172 * @param {number} edoSize (optional) 173 * @returns {Pattern} 174 * 175 * @example 176 * i("0 1 2").xen("12edo").ftrans("7") 177 * // n("0 1 2").scale("A:chromatic").trans("7") 178 * @example 179 * i("0 8 18").xen("31edo").ftrans("<8 -8>") 180 * @example 181 * // to transpose by steps of an edo, use "step:edo" : 182 * i("0 7 8 18").xen("31edo").ftrans("<0 1:31 1:12>") 183 * @example 184 * // it can also work with frequency values directly 185 * freq("200 300 400").ftrans("<0 7:31 7>") 186 */ 187 188/* f = frequency (Hz) 189 n = edo (steps per octave) 190 x = number of steps 191 if 0\n = f, then x\n = f * 2^(x/n) 192 example: 5edo, 0\5 = 220 Hz, then 2\5 = 220*2^(2/5) = 290.29 Hz */ 193 194export const { ftrans, fTrans, ftranspose, fTranspose } = register( 195 ['ftrans', 'fTrans', 'ftranspose', 'fTranspose'], 196 (amt, pat) => { 197 let edoSize; 198 let numSteps; 199 if (Array.isArray(amt)) { 200 edoSize = amt[1]; 201 numSteps = amt[0]; 202 } else { 203 numSteps = amt; 204 } 205 return pat.withHaps((haps) => { 206 haps = haps.map((hap) => { 207 let hVal = hap.value; 208 const isObject = typeof hVal === 'object'; 209 hVal = isObject ? hVal : { freq: hVal }; 210 let { freq, ...otherValues } = hVal; 211 if (edoSize == undefined && hap.context.edoSize != undefined) { 212 edoSize = hap.context.edoSize; 213 } else if (edoSize == undefined) { 214 edoSize = 12; 215 } 216 freq = freq * Math.pow(2, numSteps / edoSize); 217 freq = trimFreq(freq); 218 hap.value = isObject ? { ...otherValues, freq } : freq; 219 return hap.setContext({ ...hap.context, edoSize }); 220 }); 221 return removeUndefineds(haps); 222 }); 223 }, 224); 225 226// not sure there's a point to having this and the above, seems like a proto version of the above. 227const tuning = register('tuning', function (ratios, pat) { 228 return pat.withHap((hap) => { 229 const frequency = xenOffset(ratios, parseNumeral(hap.value)); 230 return hap.withValue(() => frequency); 231 }); 232});