jevstrudel.git / packages / tonal / tonleiter.mjs
1import { isNote, isNoteWithOctave, _mod, noteToMidi, tokenizeNote } from '@strudel/core';
2import { Interval, Scale } from '@tonaljs/tonal';

https://codesandbox.io/s/stateless-voicings-g2tmz0?file=/src/lib.js:0-2515

6const flats = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
7const pcs = ['c', 'db', 'd', 'eb', 'e', 'f', 'gb', 'g', 'ab', 'a', 'bb', 'b'];
8const sharps = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
9const accs = { b: -1, '#': 1 };
11export const pc2chroma = (pc) => {
12  const [letter, ...rest] = pc.split('');
13  return pcs.indexOf(letter.toLowerCase()) + rest.reduce((sum, sign) => sum + accs[sign], 0);
14};
15
16export const rotateChroma = (chroma, steps) => (chroma + (steps % 12) + 12) % 12;
17
18export const chroma2pc = (chroma, sharp = false) => {
19  return (sharp ? sharps : flats)[chroma];
20};
21
22export function tokenizeChord(chord) {
23  const match = (chord || '').match(/^([A-G][b#]*)([^/]*)[/]?([A-G][b#]*)?$/);
24  if (!match) {
25    // console.warn('could not tokenize chord', chord);
26    return [];
27  }
28  return match.slice(1);
29}
30export const note2pc = (note) => note.match(/^[A-G][#b]?/i)[0];
31export const note2oct = (note) => tokenizeNote(note)[2];
32export const note2midi = noteToMidi;
33
34export const note2chroma = (note) => {
35  return pc2chroma(note2pc(note));
36};

TODO: test

39export const midi2chroma = (midi) => midi % 12;

TODO: test and use in voicing function

42export const pitch2chroma = (x, defaultOctave) => {
43  if (isNoteWithOctave(x)) {
44    return note2chroma(x);
45  }
46  if (isNote(x)) {
47    //pc
48    return pc2chroma(x, defaultOctave);
49  }
50  if (typeof x === 'number') {
51    // expect midi
52    return midi2chroma(x);
53  }
54};
56export const step2semitones = (x) => {
57  let num = Number(x);
58  if (!isNaN(num)) {
59    return num;
60  }
61  return Interval.semitones(x);
62};
63
64export const x2midi = (x, defaultOctave) => {
65  if (typeof x === 'number') {
66    return x;
67  }
68  if (typeof x === 'string') {
69    return noteToMidi(x, defaultOctave);
70  }
71};

duplicate: util.mjs (does not support sharp flag)

74export const midi2note = (midi, sharp = false) => {
75  const oct = Math.floor(midi / 12) - 1;
76  const pc = (sharp ? sharps : flats)[midi % 12];
77  return pc + oct;
78};
80export function scaleStep(notes, offset, octaves = 1) {
81  notes = notes.map((note) => (typeof note === 'string' ? noteToMidi(note) : note));
82  const octOffset = Math.floor(offset / notes.length) * octaves * 12;
83  offset = _mod(offset, notes.length);
84  return notes[offset] + octOffset;
85}
86
87export function nearestNumberIndex(target, numbers, preferHigher) {
88  let bestIndex = 0,
89    bestDiff = Infinity;
90  numbers.forEach((s, i) => {
91    const diff = Math.abs(s - target);
92    // preferHigher only works if numbers are sorted in ascending order!
93    if ((!preferHigher && diff < bestDiff) || (preferHigher && diff <= bestDiff)) {
94      bestIndex = i;
95      bestDiff = diff;
96    }
97  });
98  return bestIndex;
99}
100
101let scaleSteps = {}; // [scaleName]: semitones[]
102
103export function stepInNamedScale(step, scale, anchor, preferHigher) {
104  const [root, scaleName] = Scale.tokenize(scale);
105  const rootMidi = x2midi(root);
106  const rootChroma = midi2chroma(rootMidi);
107  if (!scaleSteps[scaleName]) {
108    const { intervals } = Scale.get(`C ${scaleName}`);
109    // cache result
110    scaleSteps[scaleName] = intervals.map(step2semitones);
111  }
112  const steps = scaleSteps[scaleName];
113  if (!steps) {
114    return null;
115  }
116  let transpose = rootMidi;
117  if (anchor) {
118    anchor = x2midi(anchor, 3);
119    const anchorChroma = midi2chroma(anchor);
120    const anchorDiff = _mod(anchorChroma - rootChroma, 12);
121    const zeroIndex = nearestNumberIndex(anchorDiff, steps, preferHigher);
122    step = step + zeroIndex;
123    transpose = anchor - anchorDiff;
124  }
125  const octOffset = Math.floor(step / steps.length) * 12;
126  step = _mod(step, steps.length);
127  const targetMidi = steps[step] + transpose;
128  return targetMidi + octOffset;
129}

different ways to resolve the note to compare the anchor to (see renderVoicing)

132let modeTarget = {
133  below: (v) => v.slice(-1)[0],
134  duck: (v) => v.slice(-1)[0],
135  above: (v) => v[0],
136  root: (v) => v[0],
137  oldabove: (v) => v[0],
138  oldroot: (v) => v[0],
139};
140let modeMult = {
141  below: 1,
142  duck: 1,
143  above: -1,
144  root: -1,
145  oldabove: 1,
146  oldroot: 1,
147};
149export function renderVoicing({ chord, dictionary, offset = 0, n, mode = 'below', anchor = 'c5', octaves = 1 }) {
150  const [root, symbol] = tokenizeChord(chord);
151  const rootChroma = pc2chroma(root);
152  anchor = x2midi(anchor?.note || anchor, 4);
153  const anchorChroma = midi2chroma(anchor);
154  const voicings = dictionary[symbol].map((voicing) =>
155    (typeof voicing === 'string' ? voicing.split(' ') : voicing).map(step2semitones),
156  );
157  const mult = modeMult[mode];
158
159  let minDistance, bestIndex;
160  // calculate distances up from voicing top notes
161  let chromaDiffs = voicings.map((v, i) => {
162    const targetStep = modeTarget[mode](v);
163    const diff = _mod((anchorChroma - targetStep - rootChroma) * mult, 12);
164    if (minDistance === undefined || diff < minDistance) {
165      minDistance = diff;
166      bestIndex = i;
167    }
168    return diff * mult;
169  });
170  if (mode === 'root' || mode === 'oldroot') {
171    bestIndex = 0;
172  }
173
174  const octDiff = Math.ceil(offset / voicings.length) * 12;
175  const indexWithOffset = _mod(bestIndex + offset, voicings.length);
176  const voicing = voicings[indexWithOffset];
177  const targetStep = modeTarget[mode](voicing);
178  const anchorMidi = anchor - chromaDiffs[indexWithOffset] + octDiff;
179
180  const voicingMidi = voicing.map((v) => anchorMidi - targetStep + v);
181  let notes = voicingMidi.map((n) => midi2note(n));
182
183  if (mode === 'duck') {
184    notes = notes.filter((_, i) => voicingMidi[i] !== anchor);
185  }
186  if (n !== undefined) {
187    return [scaleStep(notes, n, octaves)];
188  }
189  return notes;
190}

https://codeberg.org/uzu/strudel/blob/14184993d0ee7d69c47df57ac864a1a0f99a893f/packages/tonal/tonleiter.mjs

193const steps = [1, 0, 2, 0, 3, 4, 0, 5, 0, 6, 0, 7];
194const notes = ['C', '', 'D', '', 'E', 'F', '', 'G', '', 'A', '', 'B'];
195const noteLetters = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];
197export const accidentalOffset = (accidentals) => {
198  return accidentals.split('#').length - accidentals.split('b').length;
199};
200
201const accidentalString = (offset) => {
202  if (offset < 0) {
203    return 'b'.repeat(-offset);
204  }
205  if (offset > 0) {
206    return '#'.repeat(offset);
207  }
208  return '';
209};
210
211export const Step = {
212  tokenize(step) {
213    const matches = step.match(/^([#b]*)([1-9][0-9]*)$/);
214    if (!matches) {
215      throw new Error(`Step.tokenize: not a valid step: ${step}`);
216    }
217    const [accidentals, stepNumber] = matches.slice(1);
218    return [accidentals, parseInt(stepNumber)];
219  },
220  accidentals(step) {
221    return accidentalOffset(Step.tokenize(step)[0]);
222  },
223};
224
225export const Note = {
226  // TODO: support octave numbers
227  tokenize(note) {
228    return [note[0], note.slice(1)];
229  },
230  accidentals(note) {
231    return accidentalOffset(this.tokenize(note)[1]);
232  },
233};

TODO: support octave numbers Example: Note("Bb3").transpose("c3")

237export function transpose(note, step) {
238  // example: E, 3
239  const stepNumber = Step.tokenize(step)[1]; // 3
240  const noteLetter = Note.tokenize(note)[0]; // E
241  const noteIndex = noteLetters.indexOf(noteLetter); // 2 "E is C+2"
242  const targetNote = noteLetters[(noteIndex + stepNumber - 1) % 8]; // G "G is a third above E"
243  const rootIndex = notes.indexOf(noteLetter); // 4 "E is 4 semitones above C"
244  const targetIndex = notes.indexOf(targetNote); // 7 "G is 7 semitones above C"
245  const indexOffset = targetIndex - rootIndex; // 3 (E to G is normally a 3 semitones)
246  const stepIndex = steps.indexOf(stepNumber); // 4 ("3" is normally 4 semitones)
247  const offsetAccidentals = accidentalString(Step.accidentals(step) + Note.accidentals(note) + stepIndex - indexOffset); // "we need to add a # to to the G to make it a major third from E"
248  return [targetNote, offsetAccidentals].join('');
249}