From 70d6f3000a60073196ae1672f252f89569fa8baa Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 6 Jul 2023 09:58:48 +0200 Subject: [PATCH] move tonleiter --- packages/tonal/test/tonleiter.test.mjs | 112 ++++++++++++++++++++++ packages/tonal/tonleiter.mjs | 126 +++++++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 packages/tonal/test/tonleiter.test.mjs create mode 100644 packages/tonal/tonleiter.mjs diff --git a/packages/tonal/test/tonleiter.test.mjs b/packages/tonal/test/tonleiter.test.mjs new file mode 100644 index 00000000..cfca5289 --- /dev/null +++ b/packages/tonal/test/tonleiter.test.mjs @@ -0,0 +1,112 @@ +/* +tonleiter.test.mjs - +Copyright (C) 2022 Strudel contributors - see +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 . +*/ + +import { describe, test, expect } from 'vitest'; +import { + Step, + Note, + transpose, + pc2chroma, + rotateChroma, + chroma2pc, + tokenizeChord, + note2pc, + note2oct, + note2midi, + midi2note, + voiceBelow, +} from '../tonleiter.mjs'; + +describe('tonleiter', () => { + test('Step ', () => { + expect(Step.tokenize('#11')).toEqual(['#', 11]); + expect(Step.tokenize('b13')).toEqual(['b', 13]); + expect(Step.tokenize('bb6')).toEqual(['bb', 6]); + expect(Step.tokenize('b3')).toEqual(['b', 3]); + expect(Step.tokenize('3')).toEqual(['', 3]); + expect(Step.tokenize('10')).toEqual(['', 10]); + // expect(Step.tokenize('asdasd')).toThrow(); + expect(Step.accidentals('b3')).toEqual(-1); + expect(Step.accidentals('#11')).toEqual(1); + }); + test('Note', () => { + expect(Note.tokenize('C##')).toEqual(['C', '##']); + expect(Note.tokenize('Bb')).toEqual(['B', 'b']); + expect(Note.accidentals('C#')).toEqual(1); + expect(Note.accidentals('C##')).toEqual(2); + expect(Note.accidentals('Eb')).toEqual(-1); + expect(Note.accidentals('Bbb')).toEqual(-2); + }); + test('transpose', () => { + expect(transpose('F#', '3')).toEqual('A#'); + expect(transpose('C', '3')).toEqual('E'); + expect(transpose('D', '3')).toEqual('F#'); + expect(transpose('E', '3')).toEqual('G#'); + expect(transpose('Eb', '3')).toEqual('G'); + expect(transpose('Ebb', '3')).toEqual('Gb'); + }); + test('pc2chroma', () => { + expect(pc2chroma('C')).toBe(0); + expect(pc2chroma('C#')).toBe(1); + expect(pc2chroma('C##')).toBe(2); + expect(pc2chroma('D')).toBe(2); + expect(pc2chroma('Db')).toBe(1); + expect(pc2chroma('Dbb')).toBe(0); + }); + test('rotateChroma', () => { + expect(rotateChroma(0, 1)).toBe(1); + expect(rotateChroma(0, -1)).toBe(-1); // this is wrong... + //expect(rotateChroma(0, -1)).toBe(11); // <-- TODO + expect(rotateChroma(11, 1)).toBe(0); + expect(rotateChroma(11, 13)).toBe(0); + }); + test('chroma2pc', () => { + expect(chroma2pc(0)).toBe('C'); + expect(chroma2pc(1)).toBe('Db'); + expect(chroma2pc(1, true)).toBe('C#'); + expect(chroma2pc(2)).toBe('D'); + expect(chroma2pc(3)).toBe('Eb'); + }); + test('tokenizeChord', () => { + expect(tokenizeChord('Cm7')).toEqual(['C', 'm7', undefined]); + expect(tokenizeChord('C#m7')).toEqual(['C#', 'm7', undefined]); + expect(tokenizeChord('Bb^7')).toEqual(['Bb', '^7', undefined]); + expect(tokenizeChord('Bb^7/F')).toEqual(['Bb', '^7', 'F']); + }); + test('note2pc', () => { + expect(note2pc('C5')).toBe('C'); + // expect(note2pc('C52')).toBe('C'); // <- 2 digits fail + expect(note2pc('Bb3')).toBe('Bb'); + }); + test('note2oct', () => { + expect(note2oct('C5')).toBe(5); + expect(note2oct('Bb3')).toBe(3); + expect(note2oct('C7')).toBe(7); + //expect(note2oct('C10')).toBe(10); // <- 2 digits fail + }); + test('note2midi', () => { + expect(note2midi('C4')).toBe(60); + expect(note2midi('C#4')).toBe(61); + expect(note2midi('Cb4')).toBe(59); + expect(note2midi('Bb3')).toBe(58); + // expect(note2midi('C10')).toBe(58); // <- 2 digits fail + }); + test('midi2note', () => { + expect(midi2note(60)).toBe('C4'); + expect(midi2note(61)).toBe('Db4'); + expect(midi2note(61, true)).toBe('C#4'); + }); + test('voiceBelow', () => { + const voicingDictionary = { + m7: [ + '3 7 10 14', // b3 5 b7 9 + '10 14 5 19', // b7 9 b3 5 + ], + }; + expect(voiceBelow('Bb4', 'Em7', voicingDictionary)).toEqual(['G3', 'B3', 'D4', 'Gb4']); + expect(voiceBelow('D5', 'Cm7', voicingDictionary)).toEqual(['Eb4', 'G4', 'Bb4', 'D5']); + }); +}); diff --git a/packages/tonal/tonleiter.mjs b/packages/tonal/tonleiter.mjs new file mode 100644 index 00000000..0c89dd40 --- /dev/null +++ b/packages/tonal/tonleiter.mjs @@ -0,0 +1,126 @@ +// https://codesandbox.io/s/stateless-voicings-g2tmz0?file=/src/lib.js:0-2515 + +const flats = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B']; +const sharps = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; +const accs = { b: -1, '#': 1 }; + +export const pc2chroma = (pc) => { + const [letter, ...rest] = pc.split(''); + return flats.indexOf(letter) + rest.reduce((sum, sign) => sum + accs[sign], 0); +}; + +export const rotateChroma = (chroma, steps) => (chroma + steps) % 12; + +export const chroma2pc = (chroma, sharp = false) => { + return (sharp ? sharps : flats)[chroma]; +}; + +export function tokenizeChord(chord) { + const match = (chord || '').match(/^([A-G][b#]*)([^/]*)[/]?([A-G][b#]*)?$/); + if (!match) { + // console.warn('could not tokenize chord', chord); + return []; + } + return match.slice(1); +} +export const note2pc = (note) => note.slice(0, -1); +export const note2oct = (note) => Number(note.slice(-1)); + +export const note2midi = (note) => { + const [pc, oct] = [note2pc(note), note2oct(note)]; + return pc2chroma(pc) + oct * 12 + 12; +}; + +export const midi2note = (midi, sharp = false) => { + const oct = Math.floor(midi / 12) - 1; + const pc = (sharp ? sharps : flats)[midi % 12]; + return pc + oct; +}; + +export function voiceBelow(maxNote, chord, voicingDictionary) { + const [root, symbol] = tokenizeChord(chord); + const maxPc = note2pc(maxNote); + const maxChroma = pc2chroma(maxPc); + const rootChroma = pc2chroma(root); + const voicings = voicingDictionary[symbol].map((voicing) => + typeof voicing === 'string' ? voicing.split(' ').map((n) => parseInt(n, 10)) : voicing, + ); + + let minDistance, bestIndex; + voicings.forEach((voicing, i) => { + // get chroma of topnote + const topChroma = rotateChroma(voicing[voicing.length - 1], rootChroma); + // calculate distance up + const diff = (maxChroma - topChroma + 12) % 12; + if (minDistance === undefined || diff < minDistance) { + minDistance = diff; + bestIndex = i; + } + }); + const voicing = voicings[bestIndex]; + const maxMidi = note2midi(maxNote); + const topMidi = maxMidi - minDistance; + + const voicingMidi = voicing.map((v) => topMidi - voicing[voicing.length - 1] + v); + return voicingMidi.map((n) => midi2note(n)); +} + +// https://github.com/tidalcycles/strudel/blob/14184993d0ee7d69c47df57ac864a1a0f99a893f/packages/tonal/tonleiter.mjs +const steps = [1, 0, 2, 0, 3, 4, 0, 5, 0, 6, 0, 7]; +const notes = ['C', '', 'D', '', 'E', 'F', '', 'G', '', 'A', '', 'B']; +const noteLetters = ['C', 'D', 'E', 'F', 'G', 'A', 'B']; + +export const accidentalOffset = (accidentals) => { + return accidentals.split('#').length - accidentals.split('b').length; +}; + +const accidentalString = (offset) => { + if (offset < 0) { + return 'b'.repeat(-offset); + } + if (offset > 0) { + return '#'.repeat(offset); + } + return ''; +}; + +export const Step = { + tokenize(step) { + const matches = step.match(/^([#b]*)([1-9][0-9]*)$/); + if (!matches) { + throw new Error(`Step.tokenize: not a valid step: ${step}`); + } + const [accidentals, stepNumber] = matches.slice(1); + return [accidentals, parseInt(stepNumber)]; + }, + accidentals(step) { + return accidentalOffset(Step.tokenize(step)[0]); + }, +}; + +export const Note = { + // TODO: support octave numbers + tokenize(note) { + return [note[0], note.slice(1)]; + }, + accidentals(note) { + return accidentalOffset(this.tokenize(note)[1]); + }, +}; + +// TODO: support octave numbers +export function transpose(note, step) { + // example: E, 3 + const stepNumber = Step.tokenize(step)[1]; // 3 + const noteLetter = Note.tokenize(note)[0]; // E + const noteIndex = noteLetters.indexOf(noteLetter); // 2 "E is C+2" + const targetNote = noteLetters[(noteIndex + stepNumber - 1) % 8]; // G "G is a third above E" + const rootIndex = notes.indexOf(noteLetter); // 4 "E is 4 semitones above C" + const targetIndex = notes.indexOf(targetNote); // 7 "G is 7 semitones above C" + const indexOffset = targetIndex - rootIndex; // 3 (E to G is normally a 3 semitones) + const stepIndex = steps.indexOf(stepNumber); // 4 ("3" is normally 4 semitones) + 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" + return [targetNote, offsetAccidentals].join(''); +} + +//Note("Bb3").transpose("c3")