xen.mjsannotatedxen.mjssource232 lines · 8.2 KB · raw
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});