From 0c8d4a9671f709adf419d38011e9ec6cc38a01ad Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 13 Feb 2022 21:27:20 +0100 Subject: [PATCH] add scaleTranspose --- repl/src/App.tsx | 9 +++-- repl/src/tonal.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++--- repl/src/tunes.ts | 15 ++++++++ 3 files changed, 110 insertions(+), 10 deletions(-) diff --git a/repl/src/App.tsx b/repl/src/App.tsx index 7882df99..0e3f278f 100644 --- a/repl/src/App.tsx +++ b/repl/src/App.tsx @@ -56,11 +56,12 @@ function App() { const cycle = useCycle({ onEvent: useCallback((time, event) => { try { - if (typeof event.value === 'string') { - if (!isNote(event.value)) { - throw new Error('not a note: ' + event.value); + if (!event.value?.onTrigger) { + const note = event.value?.value || event.value; + if (!isNote(note)) { + throw new Error('not a note: ' + note); } - defaultSynth.triggerAttackRelease(event.value, event.duration, time); + defaultSynth.triggerAttackRelease(note, event.duration, time); /* console.warn('no instrument chosen', event); throw new Error(`no instrument chosen for ${JSON.stringify(event)}`); */ } else { diff --git a/repl/src/tonal.ts b/repl/src/tonal.ts index 52443536..6f3bfe35 100644 --- a/repl/src/tonal.ts +++ b/repl/src/tonal.ts @@ -1,15 +1,99 @@ -import { Note, Interval } from '@tonaljs/tonal'; +import { Note, Interval, Scale } from '@tonaljs/tonal'; import { Pattern as _Pattern } from '../../strudel.mjs'; const Pattern = _Pattern as any; -Pattern.prototype._transpose = function (intervalOrSemitones: string | number) { - const interval = !isNaN(Number(intervalOrSemitones)) - ? Interval.fromSemitones(intervalOrSemitones as number) - : String(intervalOrSemitones); - return this.fmap((note) => Note.transpose(note, interval)); +export declare interface NoteEvent { + value: string; + scale?: string; +} + +function toNoteEvent(event: string | NoteEvent): NoteEvent { + if (typeof event === 'string') { + return { value: event }; + } + if (event.value) { + return event; + } + throw new Error('not a valid note event: ' + JSON.stringify(event)); +} + +// 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) { + let [tonic, scaleName] = Scale.tokenize(scale); + const { notes } = Scale.get(`${tonic} ${scaleName}`); + 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, + o = oct, + n = fromPc; + const direction = Math.sign(offset); + // TODO: find way to do this smarter + while (Math.abs(i - noteIndex) < Math.abs(offset)) { + i += direction; + const index = mod(i, notes.length); + if (direction < 0 && n === 'C') { + o += direction; + } + n = notes[index]; + if (direction > 0 && n === 'C') { + o += direction; + } + } + return n + o; +} + +Pattern.prototype._mapNotes = function (func: (note: NoteEvent) => NoteEvent) { + return this.fmap((event: string | NoteEvent) => { + const noteEvent = toNoteEvent(event); + return func(noteEvent); + }); }; +Pattern.prototype._transpose = function (intervalOrSemitones: string | number) { + return this._mapNotes(({ value, scale }: NoteEvent) => { + const interval = !isNaN(Number(intervalOrSemitones)) + ? Interval.fromSemitones(intervalOrSemitones as number) + : String(intervalOrSemitones); + return { value: Note.transpose(value, interval), scale }; + }); +}; Pattern.prototype.transpose = function (intervalOrSemitones: string | number) { return this._patternify(Pattern.prototype._transpose)(intervalOrSemitones); }; + +Pattern.prototype._scaleTranspose = function (offset: number | string) { + return this._mapNotes(({ value, scale }: NoteEvent) => { + if (!scale) { + throw new Error('can only use scaleOffset after .scale'); + } + return { value: scaleTranspose(scale, Number(offset), value), scale }; + }); +}; +Pattern.prototype.scaleTranspose = function (offset: number | string) { + return this._patternify(Pattern.prototype._scaleTranspose)(offset); +}; + +Pattern.prototype._scale = function (scale: string) { + return this._mapNotes((value) => ({ ...value, scale })); +}; + +Pattern.prototype.scale = function (scale: string) { + return this._patternify(Pattern.prototype._scale)(scale); +}; diff --git a/repl/src/tunes.ts b/repl/src/tunes.ts index b9d90180..751a753c 100644 --- a/repl/src/tunes.ts +++ b/repl/src/tunes.ts @@ -278,4 +278,19 @@ export const giantSteps = `stack( ) ).slow(20);`; +export const transposedChords = `stack( + m('c2 eb2 g2'), + m('Cm7').voicings(['g2','c4']).slow(2) +).transpose( + slowcat(1, 2, 3, 2).slow(2) +).transpose(5)`; + +export const scaleTranspose = `stack(f2, f3, c4, ab4) +.scale(sequence('F minor', 'F harmonic minor').slow(4)) +.scaleTranspose(sequence(0, -1, -2, -3).slow(4)) +.transpose(sequence(0, 1).slow(16)) +.synth('sawtooth') +.filter(800) +.gain(0.5)`; + export default swimming;