jevstrudel.git / packages / tonal / tonal.mjs
tonal.mjsannotatedtonal.mjssource329 lines · 12.0 KB · raw
1/*
2tonal.mjs - <short description TODO>
3Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/tonal/tonal.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 { Note, Interval, Scale } from '@tonaljs/tonal';
8import {
9  _mod,
10  errorLogger,
11  getAccidentalsOffset,
12  isNote,
13  logger,
14  noteToMidi,
15  register,
16  removeUndefineds,
17} from '@strudel/core';
18import { stepInNamedScale, nearestNumberIndex } from './tonleiter.mjs';
19
20const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P';
21
22function getScale(scaleName) {
23  scaleName = scaleName.replaceAll(':', ' ');
24  const scale = Scale.get(scaleName);
25  const { tonic, empty } = scale;
26  if ((empty && isNote(scaleName)) || (empty && !tonic)) {
27    throw new Error(
28      `Scale name ${scaleName} is incomplete. Make sure to use ":" instead of spaces, example: .scale("C:major")`,
29    );
30  } else if (empty) {
31    throw new Error(`Invalid scale name "${scaleName}"`);
32  }
33  return scale;
34}
35
36function scaleStep(step, scale) {
37  step = Math.ceil(step);
38  let { intervals, tonic } = getScale(scale);
39  tonic = tonic || 'C';
40  const { pc, oct = 3 } = Note.get(tonic);
41  const octaveOffset = Math.floor(step / intervals.length);
42  const scaleStep = _mod(step, intervals.length);
43  const interval = Interval.add(intervals[scaleStep], octavesInterval(octaveOffset));
44  return Note.transpose(pc + oct, interval);
45}
46
47// transpose note inside scale by offset steps
48// function scaleOffset(scale: string, offset: number, note: string) {
49function scaleOffset(scale, offset, note) {
50  let { notes } = getScale(scale);
51  notes = notes.map((note) => Note.get(note).pc); // use only pc!
52  offset = Number(offset);
53  if (isNaN(offset)) {
54    throw new Error(`scale offset "${offset}" not a number`);
55  }
56  const { pc: fromPc, oct = 3 } = Note.get(note);
57  const noteIndex = notes.indexOf(fromPc);
58  if (noteIndex === -1) {
59    throw new Error(`note "${note}" is not in scale "${scale}"`);
60  }
61  let i = noteIndex,
62    o = oct,
63    n = fromPc;
64  const direction = Math.sign(offset);
65  // TODO: find way to do this smarter
66  while (Math.abs(i - noteIndex) < Math.abs(offset)) {
67    i += direction;
68    const index = _mod(i, notes.length);
69    if (direction < 0 && n[0] === 'C') {
70      o += direction;
71    }
72    n = notes[index];
73    if (direction > 0 && n[0] === 'C') {
74      o += direction;
75    }
76  }
77  return n + o;
78}
79
80// Pattern.prototype._transpose = function (intervalOrSemitones: string | number) {
81/**
82 * Change the pitch of each value by the given amount. Expects numbers or note strings as values.
83 * The amount can be given as a number of semitones or as a string in interval short notation.
84 * If you don't care about enharmonic correctness, just use numbers. Otherwise, pass the interval of
85 * the form: ST where S is the degree number and T the type of interval with
86 *
87 * - M = major
88 * - m = minor
89 * - P = perfect
90 * - A = augmented
91 * - d = diminished
92 *
93 * Examples intervals:
94 *
95 * - 1P = unison
96 * - 3M = major third
97 * - 3m = minor third
98 * - 4P = perfect fourth
99 * - 4A = augmented fourth
100 * - 5P = perfect fifth
101 * - 5d = diminished fifth
102 *
103 * @tags tonal
104 * @param {string | number} amount Either number of semitones or interval string.
105 * @returns Pattern
106 * @memberof Pattern
107 * @name transpose
108 * @synonyms trans
109 * @example
110 * "c2 c3".fast(2).transpose("<0 -2 5 3>".slow(2)).note()
111 * @example
112 * "c2 c3".fast(2).transpose("<1P -2M 4P 3m>".slow(2)).note()
113 */
114
115export const { transpose, trans } = register(['transpose', 'trans'], function transposeFn(intervalOrSemitones, pat) {
116  return pat.withHap((hap) => {
117    const note = hap.value.note ?? hap.value;
118    if (typeof note === 'number') {
119      // note is a number, so just add the number semitones of the interval
120      let semitones;
121      if (typeof intervalOrSemitones === 'number') {
122        semitones = intervalOrSemitones;
123      } else if (typeof intervalOrSemitones === 'string') {
124        semitones = Interval.semitones(intervalOrSemitones) || 0;
125      }
126      const targetNote = note + semitones;
127      if (typeof hap.value === 'object') {
128        return hap.withValue(() => ({ ...hap.value, note: targetNote }));
129      }
130      return hap.withValue(() => targetNote);
131    }
132    if (typeof note !== 'string' || !isNote(note)) {
133      logger(`[tonal] transpose: not a note "${note}"`, 'warning');
134      return hap;
135    }
136    // note is a string, so we might be able to preserve harmonics if interval is a string as well
137    const interval = !isNaN(Number(intervalOrSemitones))
138      ? Interval.fromSemitones(intervalOrSemitones)
139      : String(intervalOrSemitones);
140
141    let n = Note.get(note);
142    if (n.oct == undefined) {
143      n.oct = 3;
144      let n1 = Note.get(Note.transpose(n, interval));
145      n.oct = n1.oct == 3 ? undefined : 3;
146    }
147    const targetNote = Note.transpose(n, interval);
148    if (typeof hap.value === 'object') {
149      return hap.withValue(() => ({ ...hap.value, note: targetNote }));
150    }
151    return hap.withValue(() => targetNote);
152  });
153});
154
155// example: transpose(3).late(0.2) will be equivalent to compose(transpose(3), late(0.2))
156// e.g. `stack(c3).superimpose(transpose(slowcat(7, 5)))` or
157// or even `stack(c3).superimpose(transpose.slowcat(7, 5))` or
158
159/**
160 * Transposes notes inside the scale by the number of steps.
161 * Expected to be called on a Pattern which already has a {@link Pattern#scale}
162 *
163 * @memberof Pattern
164 * @name scaleTranspose
165 * @tags tonal
166 * @param {offset} offset number of steps inside the scale
167 * @returns Pattern
168 * @synonyms scaleTrans, strans
169 * @example
170 * "-8 [2,4,6]"
171 * .scale('C4 bebop major')
172 * .scaleTranspose("<0 -1 -2 -3 -4 -5 -6 -4>")
173 * .note()
174 */
175
176export const { scaleTranspose, scaleTrans, strans } = register(
177  ['scaleTranspose', 'scaleTrans', 'strans'],
178  function (offset /* : number | string */, pat) {
179    return pat.withHap((hap) => {
180      if (!hap.context.scale) {
181        throw new Error('can only use scaleTranspose after .scale');
182      }
183      if (typeof hap.value === 'object')
184        return hap.withValue(() => ({
185          ...hap.value,
186          note: scaleOffset(hap.context.scale, Number(offset), hap.value.note),
187        }));
188      if (typeof hap.value !== 'string') {
189        throw new Error('can only use scaleTranspose with notes');
190      }
191      return hap.withValue(() => scaleOffset(hap.context.scale, Number(offset), hap.value));
192    });
193  },
194);
195
196// Converts a step value, which is a number optionally decorated with sharps and flats,
197// to a number and an `offset` number of semitones
198function _convertStepToNumberAndOffset(step) {
199  let asNumber = Number(step);
200  let offset = 0;
201  if (isNaN(asNumber)) {
202    step = String(step);
203    // Check to see if the step matches the expected format:
204    // - A number (possibly negative)
205    // - Some number of sharps or flats
206    const match = /^(-?\d+)([#bsf]*)$/.exec(step);
207
208    if (!match) {
209      throw new Error(`invalid scale step "${step}", expected number or integer with optional # b suffixes`);
210    }
211    asNumber = Number(match[1]);
212    const accidentals = match[2] || '';
213    offset = getAccidentalsOffset(accidentals);
214  }
215  return [asNumber, offset];
216}
217
218let scaleToMidisAndNotes = {};
219// Finds the nearest scale note to `note`
220function _getNearestScaleNote(scaleName, note, preferHigher = true) {
221  let noteMidi = typeof note === 'string' ? noteToMidi(note) : note;
222  if (scaleToMidisAndNotes[scaleName] === undefined) {
223    const { intervals, tonic } = getScale(scaleName);
224    const { pc } = Note.get(tonic);
225    const expandedIntervals = intervals.concat('8P'); // add the octave for wrapping
226    const sNotes = expandedIntervals.map((interval) => Note.transpose(pc + '0', interval));
227    const sMidi = sNotes.map(noteToMidi);
228    // Cache
229    scaleToMidisAndNotes[scaleName] = [sMidi, sNotes];
230  }
231  const [scaleMidis, scaleNotes] = scaleToMidisAndNotes[scaleName];
232  const rootMidi = scaleMidis[0];
233  const octaveDiff = Math.floor((noteMidi - rootMidi) / 12);
234  const alignedMidis = scaleMidis.map((m) => m + 12 * octaveDiff);
235  const noteIdx = nearestNumberIndex(noteMidi, alignedMidis, preferHigher);
236  const noteMatch = scaleNotes[noteIdx];
237  return Note.transpose(noteMatch, Interval.fromSemitones(12 * octaveDiff));
238}
239
240/**
241 * Turns numbers into notes in the scale (zero indexed) or quantizes notes to a scale.
242 *
243 * When describing notes via numbers, note that negative numbers can be used to wrap backwards
244 * in the scale as well as sharps or flats to produce notes outside of the scale.
245 *
246 * Also sets scale for other scale operations, like {@link Pattern#scaleTranspose}.
247 *
248 * A scale consists of a root note (e.g. `c4`, `c`, `f#`, `bb4`) followed by semicolon (':') and then a [scale type](https://github.com/tonaljs/tonal/blob/main/packages/scale-type/data.ts).
249 *
250 * The scale name must be written without spaces (because it would be interpreted as a multi-step pattern otherwise).
251 * If your scale name includes spaces, replace them with colons.
252 *
253 * The root note defaults to octave 3, if no octave number is given.
254 *
255 * @name scale
256 * @tags tonal
257 * @param {string} scale Name of scale
258 * @returns Pattern
259 * @example
260 * n("0 2 4 6 4 2").scale("C:major")
261 * @example
262 * n("[0,7] 4 [2,7] 4")
263 * .scale("C:<major minor>/2")
264 * .s("piano")
265 * @example
266 * n(rand.range(0,12).segment(8))
267 * .scale("C:ritusen")
268 * .s("piano")
269 * @example
270 * n("<[0,7b] [-4# -4] [-2,7##] 4 [0,7] [-4# -4b] [-2,7###] 4b>*4")
271 * .scale("C:<major minor>/2")
272 * .s("piano")
273 * @example
274 * note("C1*16").transpose(irand(36)).scale('Cb2 major').scaleTranspose(3)
275 * @example
276 * n("[0 0] [1 2] [3 4] [5 6]").scale("C:major:blues")
277 */
278export const scale = register(
279  'scale',
280  function (scale, pat) {
281    // Supports ':' list syntax in mininotation
282    if (Array.isArray(scale)) {
283      scale = scale.flat().join(' ');
284    }
285    return pat.withHaps((haps) => {
286      haps = haps.map((hap) => {
287        let hVal = hap.value;
288        const isObject = typeof hVal === 'object';
289        // If hVal is a pure value, place it on `n` so that we interpret it as a scale degree
290        hVal = isObject ? hVal : { n: hVal };
291        const { note, n, value, ...otherValues } = hVal;
292        const noteOrStep = note ?? n ?? value;
293        if (noteOrStep === undefined) {
294          logger(
295            `[tonal] Invalid value format for 'scale'. Value must contain n, note, or value but received keys [${Object.keys(hVal).join(', ')}]`,
296            'error',
297          );
298          return hap; // pass the value through unchanged
299        }
300        let scaleNote;
301        if (isNote(noteOrStep)) {
302          // Note case (quantize to scale)
303          scaleNote = _getNearestScaleNote(scale, noteOrStep);
304          hap.value = { ...otherValues, note: scaleNote };
305        } else {
306          // Step case (convert to note in scale)
307          try {
308            const [number, offset] = _convertStepToNumberAndOffset(noteOrStep);
309            if (otherValues.anchor) {
310              scaleNote = stepInNamedScale(number, scale, otherValues.anchor);
311            } else {
312              scaleNote = scaleStep(number, scale);
313            }
314            if (offset != 0) scaleNote = Note.transpose(scaleNote, Interval.fromSemitones(offset));
315          } catch (err) {
316            errorLogger(err, 'tonal');
317            return; // will be removed
318          }
319        }
320        hap.value = isObject ? { ...otherValues, note: scaleNote } : scaleNote;
321        // Tag with scale for downsteam scale-aware operations
322        return hap.setContext({ ...hap.context, scale });
323      });
324      return removeUndefineds(haps);
325    });
326  },
327  true,
328  true, // preserve step count
329);