1import { isNote, isNoteWithOctave, _mod, noteToMidi, tokenizeNote } from '@strudel/core'; 2import { Interval, Scale } from '@tonaljs/tonal'; 3 4// https://codesandbox.io/s/stateless-voicings-g2tmz0?file=/src/lib.js:0-2515 5 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 }; 10 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}; 37 38// TODO: test 39export const midi2chroma = (midi) => midi % 12; 40 41// 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}; 55 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}; 72 73// 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}; 79 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} 130 131// 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}; 148 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} 191 192// 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']; 196 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}; 234 235// TODO: support octave numbers 236// 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}