diff --git a/packages/tonal/test/tonleiter.test.mjs b/packages/tonal/test/tonleiter.test.mjs new file mode 100644 index 000000000..56e179685 --- /dev/null +++ b/packages/tonal/test/tonleiter.test.mjs @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; +import { Note } from '../tonleiter.mjs'; + +describe('tonleiter', () => { + it('Should tokenize notes', () => { + expect(Note.tokenize('c')).toEqual(['c', '']); + expect(Note.tokenize('C')).toEqual(['C', '']); + expect(Note.tokenize('c#')).toEqual(['c', '#']); + expect(Note.tokenize('Bb')).toEqual(['B', 'b']); + }); +}); diff --git a/packages/tonal/tonleiter.mjs b/packages/tonal/tonleiter.mjs new file mode 100644 index 000000000..2bea87a80 --- /dev/null +++ b/packages/tonal/tonleiter.mjs @@ -0,0 +1,71 @@ +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']; + +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 [accidentals, stepNumber] = step.match(/^([#b]*)([1-9]+)$/).slice(1); + return [accidentals, parseInt(stepNumber)]; + }, + accidentals(step) { + return accidentalOffset(Step.tokenize(step)[0]); + }, +}; + +Step.tokenize('#11'); +Step.tokenize('b13'); +Step.tokenize('bb6'); +Step.accidentals('3'); +Step.accidentals('b3'); + +export const Note = { + // TODO: support octave numbers + tokenize(note) { + return [note[0], note.slice(1)]; + }, + accidentals(note) { + return accidentalOffset(this.tokenize(note)[1]); + }, +}; + +Note.tokenize('C##'); +Note.accidentals('C#'); +Note.accidentals('C##'); +Note.accidentals('Eb'); +Note.accidentals('Bbb'); + +// TODO: support octave numbers +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(''); +} + +transpose('F#', '3'); +transpose('C', '3'); +transpose('D', '3'); +transpose('E', '3'); +transpose('Eb', '3'); +transpose('Ebb', '3');