mirror of
https://github.com/eliasstepanik/strudel.git
synced 2026-01-11 21:58:37 +00:00
move tonleiter
This commit is contained in:
parent
d47fd0cf18
commit
70d6f3000a
112
packages/tonal/test/tonleiter.test.mjs
Normal file
112
packages/tonal/test/tonleiter.test.mjs
Normal file
@ -0,0 +1,112 @@
|
||||
/*
|
||||
tonleiter.test.mjs - <short description TODO>
|
||||
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/tonal/test/tonleiter.test.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/>.
|
||||
*/
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
126
packages/tonal/tonleiter.mjs
Normal file
126
packages/tonal/tonleiter.mjs
Normal file
@ -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")
|
||||
Loading…
x
Reference in New Issue
Block a user