diff --git a/packages/codemirror/autocomplete.mjs b/packages/codemirror/autocomplete.mjs
index c9ec3d7a..203ab855 100644
--- a/packages/codemirror/autocomplete.mjs
+++ b/packages/codemirror/autocomplete.mjs
@@ -3,6 +3,12 @@ import jsdoc from '../../doc.json';
import { autocompletion } from '@codemirror/autocomplete';
import { h } from './html';
+function plaintext(str) {
+ const div = document.createElement('div');
+ div.innerText = str;
+ return div.innerHTML;
+}
+
const getDocLabel = (doc) => doc.name || doc.longname;
const getInnerText = (html) => {
var div = document.createElement('div');
@@ -21,7 +27,7 @@ ${doc.description}
)}
- ${doc.examples?.map((example) => `
`)}
+ ${doc.examples?.map((example) => `
`)}
`[0];
/*
diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs
index 8a887036..0d095a4e 100644
--- a/packages/codemirror/codemirror.mjs
+++ b/packages/codemirror/codemirror.mjs
@@ -4,7 +4,14 @@ import { history } from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { Compartment, EditorState, Prec } from '@codemirror/state';
-import { EditorView, highlightActiveLineGutter, highlightActiveLine, keymap, lineNumbers } from '@codemirror/view';
+import {
+ EditorView,
+ highlightActiveLineGutter,
+ highlightActiveLine,
+ keymap,
+ lineNumbers,
+ drawSelection,
+} from '@codemirror/view';
import { Pattern, Drawer, repl, cleanupDraw } from '@strudel.cycles/core';
import { isAutoCompletionEnabled } from './autocomplete.mjs';
import { isTooltipEnabled } from './tooltip.mjs';
@@ -68,6 +75,7 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
syntaxHighlighting(defaultHighlightStyle),
history(),
EditorView.updateListener.of((v) => onChange(v)),
+ drawSelection({ cursorBlinkRate: 0 }),
Prec.highest(
keymap.of([
{
diff --git a/packages/tonal/test/tonleiter.test.mjs b/packages/tonal/test/tonleiter.test.mjs
index 6c28f37a..e1a69357 100644
--- a/packages/tonal/test/tonleiter.test.mjs
+++ b/packages/tonal/test/tonleiter.test.mjs
@@ -18,6 +18,9 @@ import {
midi2note,
renderVoicing,
scaleStep,
+ stepInNamedScale,
+ nearestNumberIndex,
+ note2midi,
} from '../tonleiter.mjs';
describe('tonleiter', () => {
@@ -149,4 +152,34 @@ describe('tonleiter', () => {
// expect(voiceBelow('G4', 'Cm7', voicingDictionary)).toEqual(['Bb3', 'D4', 'Eb4', 'G4']);
// TODO: test with offset
});
+ test('nearestNumber', () => {
+ expect(nearestNumberIndex(0, [0, 2, 4, 5])).toEqual(0);
+ expect(nearestNumberIndex(1, [0, 2, 4, 5])).toEqual(0);
+ expect(nearestNumberIndex(1, [0, 2, 4, 5], true)).toEqual(1);
+ expect(nearestNumberIndex(2, [0, 2, 4, 5])).toEqual(1);
+ expect(nearestNumberIndex(2, [0, 2, 4, 5]), true).toEqual(1);
+ expect(nearestNumberIndex(3, [0, 2, 4, 5])).toEqual(1);
+ expect(nearestNumberIndex(3, [0, 2, 4, 5], true)).toEqual(2);
+ expect(nearestNumberIndex(4, [0, 2, 4, 5])).toEqual(2);
+ });
+ test('stepInNamedScale', () => {
+ expect(stepInNamedScale(1, 'D major')).toEqual(note2midi('E3'));
+ expect(stepInNamedScale(2, 'E major')).toEqual(note2midi('G#3'));
+ expect(stepInNamedScale(0, 'D major', 'E3')).toEqual(note2midi('E3'));
+ expect(stepInNamedScale(0, 'D major', 'E4')).toEqual(note2midi('E4'));
+ expect(stepInNamedScale(0, 'D major', 'Eb4')).toEqual(note2midi('D4'));
+ expect(stepInNamedScale(0, 'D major', 'F4')).toEqual(note2midi('E4'));
+ expect(stepInNamedScale(0, 'D major', 'F#4')).toEqual(note2midi('F#4'));
+ expect(stepInNamedScale(0, 'D major', 'G4')).toEqual(note2midi('G4'));
+
+ expect(stepInNamedScale(0, 'F major', 'F4')).toEqual(note2midi('F4'));
+ expect(stepInNamedScale(0, 'F major', 'G4')).toEqual(note2midi('G4'));
+ expect(stepInNamedScale(0, 'F major', 'A4')).toEqual(note2midi('A4'));
+ expect(stepInNamedScale(0, 'F major', 'Bb4')).toEqual(note2midi('Bb4'));
+ expect(stepInNamedScale(0, 'F major', 'C4')).toEqual(note2midi('C4'));
+
+ expect(stepInNamedScale(1, 'F major', 'C4')).toEqual(note2midi('D4'));
+ expect(stepInNamedScale(1, 'F major', 'C3')).toEqual(note2midi('D3'));
+ expect(stepInNamedScale(1, 'F minor', 'D4')).toEqual(note2midi('Eb4'));
+ });
});
diff --git a/packages/tonal/tonal.mjs b/packages/tonal/tonal.mjs
index 76bda827..6b831d47 100644
--- a/packages/tonal/tonal.mjs
+++ b/packages/tonal/tonal.mjs
@@ -6,6 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th
import { Note, Interval, Scale } from '@tonaljs/tonal';
import { register, _mod, silence, logger, pure, isNote } from '@strudel.cycles/core';
+import { stepInNamedScale } from './tonleiter.mjs';
const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P';
@@ -184,7 +185,12 @@ export const scale = register('scale', function (scale, pat) {
return silence;
}
try {
- const note = scaleStep(asNumber, scale);
+ let note;
+ if (value.anchor) {
+ note = stepInNamedScale(asNumber, scale, value.anchor);
+ } else {
+ note = scaleStep(asNumber, scale);
+ }
value = pure(isObject ? { ...value, note } : note);
} catch (err) {
logger(`[tonal] ${err.message}`, 'error');
diff --git a/packages/tonal/tonleiter.mjs b/packages/tonal/tonleiter.mjs
index e2d24b27..c8f4bdef 100644
--- a/packages/tonal/tonleiter.mjs
+++ b/packages/tonal/tonleiter.mjs
@@ -1,5 +1,5 @@
import { isNote, isNoteWithOctave, _mod, noteToMidi, tokenizeNote } from '@strudel.cycles/core';
-import { Interval } from '@tonaljs/tonal';
+import { Interval, Scale } from '@tonaljs/tonal';
// https://codesandbox.io/s/stateless-voicings-g2tmz0?file=/src/lib.js:0-2515
@@ -29,6 +29,7 @@ export function tokenizeChord(chord) {
}
export const note2pc = (note) => note.match(/^[A-G][#b]?/i)[0];
export const note2oct = (note) => tokenizeNote(note)[2];
+export const note2midi = noteToMidi;
export const note2chroma = (note) => {
return pc2chroma(note2pc(note));
@@ -60,12 +61,12 @@ export const step2semitones = (x) => {
return Interval.semitones(x);
};
-export const x2midi = (x) => {
+export const x2midi = (x, defaultOctave) => {
if (typeof x === 'number') {
return x;
}
if (typeof x === 'string') {
- return noteToMidi(x);
+ return noteToMidi(x, defaultOctave);
}
};
@@ -83,18 +84,63 @@ export function scaleStep(notes, offset, octaves = 1) {
return notes[offset] + octOffset;
}
+export function nearestNumberIndex(target, numbers, preferHigher) {
+ let bestIndex = 0,
+ bestDiff = Infinity;
+ numbers.forEach((s, i) => {
+ const diff = Math.abs(s - target);
+ // preferHigher only works if numbers are sorted in ascending order!
+ if ((!preferHigher && diff < bestDiff) || (preferHigher && diff <= bestDiff)) {
+ bestIndex = i;
+ bestDiff = diff;
+ }
+ });
+ return bestIndex;
+}
+
+let scaleSteps = {}; // [scaleName]: semitones[]
+
+export function stepInNamedScale(step, scale, anchor, preferHigher) {
+ let [root, scaleName] = Scale.tokenize(scale);
+ const rootMidi = x2midi(root);
+ const rootChroma = midi2chroma(rootMidi);
+ if (!scaleSteps[scaleName]) {
+ let { intervals } = Scale.get(`C ${scaleName}`);
+ // cache result
+ scaleSteps[scaleName] = intervals.map(step2semitones);
+ }
+ const steps = scaleSteps[scaleName];
+ if (!steps) {
+ return null;
+ }
+ let transpose = rootMidi;
+ if (anchor) {
+ anchor = x2midi(anchor, 3);
+ const anchorChroma = midi2chroma(anchor);
+ const anchorDiff = _mod(anchorChroma - rootChroma, 12);
+ const zeroIndex = nearestNumberIndex(anchorDiff, steps, preferHigher);
+ step = step + zeroIndex;
+ transpose = anchor - anchorDiff;
+ }
+ const octOffset = Math.floor(step / steps.length) * 12;
+ step = _mod(step, steps.length);
+ const targetMidi = steps[step] + transpose;
+ return targetMidi + octOffset;
+}
+
// different ways to resolve the note to compare the anchor to (see renderVoicing)
let modeTarget = {
below: (v) => v.slice(-1)[0],
duck: (v) => v.slice(-1)[0],
above: (v) => v[0],
+ root: (v) => v[0],
};
export function renderVoicing({ chord, dictionary, offset = 0, n, mode = 'below', anchor = 'c5', octaves = 1 }) {
const [root, symbol] = tokenizeChord(chord);
const rootChroma = pc2chroma(root);
- anchor = anchor?.note || anchor;
- const anchorChroma = pitch2chroma(anchor);
+ anchor = x2midi(anchor?.note || anchor, 4);
+ const anchorChroma = midi2chroma(anchor);
const voicings = dictionary[symbol].map((voicing) =>
(typeof voicing === 'string' ? voicing.split(' ') : voicing).map(step2semitones),
);
@@ -110,18 +156,21 @@ export function renderVoicing({ chord, dictionary, offset = 0, n, mode = 'below'
}
return diff;
});
+ if (mode === 'root') {
+ bestIndex = 0;
+ }
const octDiff = Math.ceil(offset / voicings.length) * 12;
const indexWithOffset = _mod(bestIndex + offset, voicings.length);
const voicing = voicings[indexWithOffset];
const targetStep = modeTarget[mode](voicing);
- const anchorMidi = noteToMidi(anchor, 4) - chromaDiffs[indexWithOffset] + octDiff;
+ const anchorMidi = anchor - chromaDiffs[indexWithOffset] + octDiff;
const voicingMidi = voicing.map((v) => anchorMidi - targetStep + v);
let notes = voicingMidi.map((n) => midi2note(n));
if (mode === 'duck') {
- notes = notes.filter((_, i) => voicingMidi[i] !== noteToMidi(anchor));
+ notes = notes.filter((_, i) => voicingMidi[i] !== anchor);
}
if (n !== undefined) {
return [scaleStep(notes, n, octaves)];
diff --git a/website/src/components/Showcase.jsx b/website/src/components/Showcase.jsx
index 2cd0317e..bbdf7785 100644
--- a/website/src/components/Showcase.jsx
+++ b/website/src/components/Showcase.jsx
@@ -71,7 +71,7 @@ let _videos = [
params: 'start=1278',
},
{
- title: 'Zach B @ (Algo|Afro) Futures 2023',
+ title: 'Zach B-B @ (Algo|Afro) Futures 2023',
id: 'zUoZvkZ3J7Q',
params: 'start=2547',
},