jevstrudel.git / packages / tonal / tonal.mjs
tonal.mjsannotatedtonal.mjssource329 lines · 12.0 KB · raw

tonal.mjs - <short description TODO> Copyright (C) 2022 Strudel contributors - see https://codeberg.org/uzu/strudel/src/branch/main/packages/tonal/tonal.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/.

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';
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}

transpose note inside scale by offset steps 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}

Pattern.prototype._transpose = function (intervalOrSemitones: string | number) {

Change the pitch of each value by the given amount. Expects numbers or note strings as values. The amount can be given as a number of semitones or as a string in interval short notation. If you don't care about enharmonic correctness, just use numbers. Otherwise, pass the interval of the form: ST where S is the degree number and T the type of interval with

  • M = major
  • m = minor
  • P = perfect
  • A = augmented
  • d = diminished

Examples intervals:

  • 1P = unison
  • 3M = major third
  • 3m = minor third
  • 4P = perfect fourth
  • 4A = augmented fourth
  • 5P = perfect fifth
  • 5d = diminished fifth

@tags tonal @param {string | number} amount Either number of semitones or interval string. @returns Pattern @memberof Pattern @name transpose @synonyms trans @example "c2 c3".fast(2).transpose("<0 -2 5 3>".slow(2)).note() @example "c2 c3".fast(2).transpose("<1P -2M 4P 3m>".slow(2)).note()

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});

example: transpose(3).late(0.2) will be equivalent to compose(transpose(3), late(0.2)) e.g. stack(c3).superimpose(transpose(slowcat(7, 5))) or or even stack(c3).superimpose(transpose.slowcat(7, 5)) or

Transposes notes inside the scale by the number of steps. Expected to be called on a Pattern which already has a {@link Pattern#scale}

@memberof Pattern @name scaleTranspose @tags tonal @param {offset} offset number of steps inside the scale @returns Pattern @synonyms scaleTrans, strans @example "-8 [2,4,6]" .scale('C4 bebop major') .scaleTranspose("<0 -1 -2 -3 -4 -5 -6 -4>") .note()

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);

Converts a step value, which is a number optionally decorated with sharps and flats, 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}
218let scaleToMidisAndNotes = {};

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}

Turns numbers into notes in the scale (zero indexed) or quantizes notes to a scale.

When describing notes via numbers, note that negative numbers can be used to wrap backwards in the scale as well as sharps or flats to produce notes outside of the scale.

Also sets scale for other scale operations, like {@link Pattern#scaleTranspose}.

A scale consists of a root note (e.g. c4, c, f#, bb4) followed by semicolon (':') and then a scale type.

The scale name must be written without spaces (because it would be interpreted as a multi-step pattern otherwise). If your scale name includes spaces, replace them with colons.

The root note defaults to octave 3, if no octave number is given.

@name scale @tags tonal @param {string} scale Name of scale @returns Pattern @example n("0 2 4 6 4 2").scale("C:major") @example n("[0,7] 4 [2,7] 4") .scale("C:<major minor>/2") .s("piano") @example n(rand.range(0,12).segment(8)) .scale("C:ritusen") .s("piano") @example n("<[0,7b] [-4# -4] [-2,7##] 4 [0,7] [-4# -4b] [-2,7###] 4b>4") .scale("C:<major minor>/2") .s("piano") @example note("C116").transpose(irand(36)).scale('Cb2 major').scaleTranspose(3) @example n("[0 0] [1 2] [3 4] [5 6]").scale("C:major:blues")

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);