diff --git a/repl/src/evaluate.ts b/repl/src/evaluate.ts index 87191bd8..a4e8e6f1 100644 --- a/repl/src/evaluate.ts +++ b/repl/src/evaluate.ts @@ -2,7 +2,7 @@ import * as strudel from '../../strudel.mjs'; import './tone'; import './midi'; import './voicings'; -import './tonal'; +import './tonal.mjs'; import shapeshifter from './shapeshifter'; import { minify } from './parse'; import * as Tone from 'tone'; diff --git a/repl/src/tonal.ts b/repl/src/tonal.mjs similarity index 70% rename from repl/src/tonal.ts rename to repl/src/tonal.mjs index 0273e30f..66cce4ad 100644 --- a/repl/src/tonal.ts +++ b/repl/src/tonal.mjs @@ -1,37 +1,24 @@ import { Note, Interval, Scale } from '@tonaljs/tonal'; import { Pattern as _Pattern } from '../../strudel.mjs'; +import { mod, tokenizeNote } from '../../util.mjs'; -const Pattern = _Pattern as any; +const Pattern = _Pattern; // as any; -// modulo that works with negative numbers e.g. mod(-1, 3) = 2 -const mod = (n: number, m: number): number => (n < 0 ? mod(n + m, m) : n % m); - -export function intervalDirection(from, to, direction = 1) { - const sign = Math.sign(direction); - const interval = sign < 0 ? Interval.distance(to, from) : Interval.distance(from, to); - return (sign < 0 ? '-' : '') + interval; -} - -// transpose note inside scale by offset steps -function scaleTranspose(scale: string, offset: number, note: string) { +export function scaleOffset(scale, offset, index = 0) { let [tonic, scaleName] = Scale.tokenize(scale); + const [pc, acc, oct = 3] = tokenizeNote(tonic); let { notes } = Scale.get(`${tonic} ${scaleName}`); notes = notes.map((note) => Note.get(note).pc); // use only pc! offset = Number(offset); if (isNaN(offset)) { throw new Error(`scale offset "${offset}" not a number`); } - const { pc: fromPc, oct = 3 } = Note.get(note); - const noteIndex = notes.indexOf(fromPc); - if (noteIndex === -1) { - throw new Error(`note "${note}" is not in scale "${scale}"`); - } - let i = noteIndex, + let i = index, o = oct, - n = fromPc; + n = notes[0]; const direction = Math.sign(offset); // TODO: find way to do this smarter - while (Math.abs(i - noteIndex) < Math.abs(offset)) { + while (Math.abs(i) < Math.abs(offset)) { i += direction; const index = mod(i, notes.length); if (direction < 0 && n === 'C') { @@ -44,11 +31,25 @@ function scaleTranspose(scale: string, offset: number, note: string) { } return n + o; } +// transpose note inside scale by offset steps +// function scaleTranspose(scale: string, offset: number, note: string) { +export function scaleTranspose(scale, offset, note) { + let [tonic, scaleName] = Scale.tokenize(scale); + const { pc: fromPc } = Note.get(note); + let { notes } = Scale.get(`${tonic} ${scaleName}`); + const scalePcs = notes.map((n) => Note.get(n).pc); + const noteIndex = scalePcs.indexOf(fromPc); + if (noteIndex === -1) { + throw new Error(`note "${fromPc}" is not in scale "${scale}". Use one of ${scalePcs.join('|')}`); + } + return scaleOffset(scale, offset, noteIndex); +} -Pattern.prototype._transpose = function (intervalOrSemitones: string | number) { +// Pattern.prototype._transpose = function (intervalOrSemitones: string | number) { +Pattern.prototype._transpose = function (intervalOrSemitones) { return this._withEvent((event) => { const interval = !isNaN(Number(intervalOrSemitones)) - ? Interval.fromSemitones(intervalOrSemitones as number) + ? Interval.fromSemitones(intervalOrSemitones /* as number */) : String(intervalOrSemitones); if (typeof event.value === 'number') { const semitones = typeof interval === 'string' ? Interval.semitones(interval) || 0 : interval; @@ -64,7 +65,7 @@ Pattern.prototype._transpose = function (intervalOrSemitones: string | number) { // e.g. `stack(c3).superimpose(transpose(slowcat(7, 5)))` or // or even `stack(c3).superimpose(transpose.slowcat(7, 5))` or -Pattern.prototype._scaleTranspose = function (offset: number | string) { +Pattern.prototype._scaleTranspose = function (offset /* : number | string */) { return this._withEvent((event) => { if (!event.context.scale) { throw new Error('can only use scaleTranspose after .scale'); @@ -75,7 +76,7 @@ Pattern.prototype._scaleTranspose = function (offset: number | string) { return event.withValue(() => scaleTranspose(event.context.scale, Number(offset), event.value)); }); }; -Pattern.prototype._scale = function (scale: string) { +Pattern.prototype._scale = function (scale /* : string */) { return this._withEvent((event) => { let note = event.value; const asNumber = Number(note); diff --git a/test/tonal.test.mjs b/test/tonal.test.mjs new file mode 100644 index 00000000..cc70449b --- /dev/null +++ b/test/tonal.test.mjs @@ -0,0 +1,39 @@ +import { strict as assert } from 'assert'; +import { scaleTranspose, scaleOffset } from '../repl/src/tonal.mjs'; + +describe('scaleOffset', () => { + it('should transpose positive numbers', () => { + const c3Minor = ['C3', 'D3', 'Eb3', 'F3', 'G3', 'Ab3', 'Bb3', 'C4', 'D4', 'Eb4', 'F4', 'G4', 'Ab4', 'Bb4']; + c3Minor.forEach((n, i) => { + assert.equal(scaleOffset('C minor', i), n); + }); + const gMinor = ['G3', 'A3', 'Bb3', 'C4', 'D4', 'Eb4', 'F4', 'G4', 'A4', 'Bb4', 'C5', 'D5', 'Eb5', 'F5', 'G5']; + gMinor.forEach((n, i) => { + assert.equal(scaleOffset('G minor', i), n); + }); + }); + it('should transpose negative numbers', () => { + const c3MinorDown = ['C3', 'Bb2', 'Ab2', 'G2', 'F2', 'Eb2', 'D2', 'C2']; + c3MinorDown.forEach((n, i) => { + assert.equal(scaleOffset('C minor', -i), n); + }); + }); + it('should transpose scales with octave', () => { + const c4Minor = ['C4', 'D4', 'Eb4', 'F4', 'G4', 'Ab4', 'Bb4', 'C5', 'D5', 'Eb5', 'F5', 'G5', 'Ab5', 'Bb5']; + c4Minor.forEach((n, i) => { + assert.equal(scaleOffset('C4 minor', i), n); + }); + }); +}); + +describe('scaleTranspose', () => { + it('should transpose inside scale', () => { + scaleTranspose('C minor', 0, 'C3'); + scaleTranspose('C minor', 1, 'D3'); + scaleTranspose('C minor', -1, 'Bb2'); + scaleTranspose('C minor', 8, 'C4'); + scaleTranspose('C4 minor', 8, 'C5'); + }); +}); + +// TODO: test tonal Pattern methods diff --git a/test/util.test.mjs b/test/util.test.mjs index 679c922b..69ee2cc3 100644 --- a/test/util.test.mjs +++ b/test/util.test.mjs @@ -1,18 +1,18 @@ import { strict as assert } from 'assert'; -import { isNote, tokenizeNote, toMidi } from '../util.mjs'; +import { isNote, tokenizeNote, toMidi, mod } from '../util.mjs'; describe('isNote', () => { - it('should recognize notes without accidentals', function () { + it('should recognize notes without accidentals', () => { 'C3 D3 E3 F3 G3 A3 B3 C4 D5 c5 d5 e5'.split(' ').forEach((note) => { assert.equal(isNote(note), true); }); }); - it('should recognize notes with accidentals', function () { + it('should recognize notes with accidentals', () => { 'C#3 D##3 Eb3 Fbb3 Bb5'.split(' ').forEach((note) => { assert.equal(isNote(note), true); }); }); - it('should not recognize invalid notes', function () { + it('should not recognize invalid notes', () => { assert.equal(isNote('H5'), false); assert.equal(isNote('C'), false); assert.equal(isNote('X'), false); @@ -21,32 +21,38 @@ describe('isNote', () => { }); describe('isNote', () => { - it('should tokenize notes without accidentals', function () { - assert.deepStrictEqual(tokenizeNote('C3'), ['C', '', '3']); - assert.deepStrictEqual(tokenizeNote('D3'), ['D', '', '3']); - assert.deepStrictEqual(tokenizeNote('E3'), ['E', '', '3']); - assert.deepStrictEqual(tokenizeNote('F3'), ['F', '', '3']); - assert.deepStrictEqual(tokenizeNote('G3'), ['G', '', '3']); - assert.deepStrictEqual(tokenizeNote('A3'), ['A', '', '3']); - assert.deepStrictEqual(tokenizeNote('B3'), ['B', '', '3']); - assert.deepStrictEqual(tokenizeNote('C4'), ['C', '', '4']); - assert.deepStrictEqual(tokenizeNote('D5'), ['D', '', '5']); + it('should tokenize notes without accidentals', () => { + assert.deepStrictEqual(tokenizeNote('C3'), ['C', '', 3]); + assert.deepStrictEqual(tokenizeNote('D3'), ['D', '', 3]); + assert.deepStrictEqual(tokenizeNote('E3'), ['E', '', 3]); + assert.deepStrictEqual(tokenizeNote('F3'), ['F', '', 3]); + assert.deepStrictEqual(tokenizeNote('G3'), ['G', '', 3]); + assert.deepStrictEqual(tokenizeNote('A3'), ['A', '', 3]); + assert.deepStrictEqual(tokenizeNote('B3'), ['B', '', 3]); + assert.deepStrictEqual(tokenizeNote('C4'), ['C', '', 4]); + assert.deepStrictEqual(tokenizeNote('D5'), ['D', '', 5]); }); - it('should tokenize notes with accidentals', function () { - assert.deepStrictEqual(tokenizeNote('C#3'), ['C', '#', '3']); - assert.deepStrictEqual(tokenizeNote('D##3'), ['D', '##', '3']); - assert.deepStrictEqual(tokenizeNote('Eb3'), ['E', 'b', '3']); - assert.deepStrictEqual(tokenizeNote('Fbb3'), ['F', 'bb', '3']); - assert.deepStrictEqual(tokenizeNote('Bb5'), ['B', 'b', '5']); + it('should tokenize notes with accidentals', () => { + assert.deepStrictEqual(tokenizeNote('C#3'), ['C', '#', 3]); + assert.deepStrictEqual(tokenizeNote('D##3'), ['D', '##', 3]); + assert.deepStrictEqual(tokenizeNote('Eb3'), ['E', 'b', 3]); + assert.deepStrictEqual(tokenizeNote('Fbb3'), ['F', 'bb', 3]); + assert.deepStrictEqual(tokenizeNote('Bb5'), ['B', 'b', 5]); }); - it('should note tokenize invalid notes', function () { + it('should tokenize notes without octave', () => { + assert.deepStrictEqual(tokenizeNote('C'), ['C', '', undefined]); + assert.deepStrictEqual(tokenizeNote('C#'), ['C', '#', undefined]); + assert.deepStrictEqual(tokenizeNote('Bb'), ['B', 'b', undefined]); + assert.deepStrictEqual(tokenizeNote('Bbb'), ['B', 'bb', undefined]); + }); + it('should not tokenize invalid notes', () => { assert.deepStrictEqual(tokenizeNote('X'), []); assert.deepStrictEqual(tokenizeNote('asfasf'), []); assert.deepStrictEqual(tokenizeNote(123), []); }); }); describe('toMidi', () => { - it('should turn notes into midi', function () { + it('should turn notes into midi', () => { assert.equal(toMidi('A4'), 69); assert.equal(toMidi('C4'), 60); assert.equal(toMidi('Db4'), 61); @@ -58,3 +64,22 @@ describe('toMidi', () => { assert.equal(toMidi('C##3'), 50); }); }); + +describe('mod', () => { + it('should work like regular modulo with positive numbers', () => { + assert.equal(mod(0, 3), 0); + assert.equal(mod(1, 3), 1); + assert.equal(mod(2, 3), 2); + assert.equal(mod(3, 3), 0); + assert.equal(mod(4, 3), 1); + assert.equal(mod(4, 2), 0); + }); + it('should work with negative numbers', () => { + assert.equal(mod(-1, 3), 2); + assert.equal(mod(-2, 3), 1); + assert.equal(mod(-3, 3), 0); + assert.equal(mod(-4, 3), 2); + assert.equal(mod(-5, 3), 1); + assert.equal(mod(-3, 2), 1); + }); +}); diff --git a/util.mjs b/util.mjs index 0f501a91..0b357e69 100644 --- a/util.mjs +++ b/util.mjs @@ -1,11 +1,17 @@ +// returns true if the given string is a note export const isNote = (name) => /^[a-gA-G][#b]*[0-9]$/.test(name); -export const tokenizeNote = (note) => - typeof note === 'string' - ? note - .match(/^([a-gA-G])([#b]*)([0-9])?$/) - ?.slice(1) - ?.map((x) => (x === undefined ? '' : x)) || [] - : []; +export const tokenizeNote = (note) => { + if (typeof note !== 'string') { + return []; + } + const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#b]*)([0-9])?$/)?.slice(1) || []; + if (!pc) { + return []; + } + return [pc, acc, oct ? Number(oct) : undefined]; +}; + +// turns the given note into its midi number representation export const toMidi = (note) => { const [pc, acc, oct] = tokenizeNote(note); if (!pc) { @@ -18,3 +24,7 @@ export const toMidi = (note) => { export const fromMidi = (n) => { return Math.pow(2, (n - 69) / 12) * 440; }; + +// modulo that works with negative numbers e.g. mod(-1, 3) = 2 +// const mod = (n: number, m: number): number => (n < 0 ? mod(n + m, m) : n % m); +export const mod = (n, m) => (n < 0 ? mod(n + m, m) : n % m);