Working version with note names

This commit is contained in:
Aria 2025-08-22 11:25:13 -05:00
parent 938c92fda2
commit b08890bd9e
3 changed files with 75 additions and 46 deletions

View file

@ -70,10 +70,7 @@ describe('tonal', () => {
}); });
it('snaps notes (upwards) to scale', () => { it('snaps notes (upwards) to scale', () => {
const inputNotes = ['Cb', 'Eb', 'G', 'A#', 'Bb']; const inputNotes = ['Cb', 'Eb', 'G', 'A#', 'Bb'];
let expectedNotes = ['B2', 'E3', 'G3', 'B3', 'B3']; const expectedNotes = ['B2', 'E3', 'G3', 'B3', 'B3'];
// Notes are converted to midi by scale
expectedNotes = expectedNotes.map((note) => noteToMidi(note));
expect( expect(
note(seq(inputNotes)) note(seq(inputNotes))
@ -81,6 +78,26 @@ describe('tonal', () => {
.firstCycleValues.map((h) => h.note), .firstCycleValues.map((h) => h.note),
).toEqual(expectedNotes); ).toEqual(expectedNotes);
}); });
it('snaps notes to the correct octave', () => {
const inputNotes = ['Cb0', 'Eb4', 'G1', 'A#19', 'Bb8'];
const expectedNotes = ['B#0', 'D#4', 'G#1', 'A#19', 'A#8'];
expect(
note(seq(inputNotes))
.scale('A# minor') // A#, B#, C#, D#, E#, F#, G#
.firstCycleValues.map((h) => h.note),
).toEqual(expectedNotes);
});
it('handles scale names provided with colons', () => {
const inputNotes = ['Cb', 'E', 'G', 'A#', 'Bb'];
const expectedNotes = ['A#2', 'D#3', 'G#3', 'A#3', 'A#3'];
expect(
note(seq(inputNotes))
.scale('F#:pentatonic') // F#, G#, A#, C#, and D#
.firstCycleValues.map((h) => h.note),
).toEqual(expectedNotes);
});
}); });
describe('transpose', () => { describe('transpose', () => {
it('transposes note numbers with interval numbers', () => { it('transposes note numbers with interval numbers', () => {

View file

@ -6,20 +6,28 @@ This program is free software: you can redistribute it and/or modify it under th
import { Note, Interval, Scale } from '@tonaljs/tonal'; import { Note, Interval, Scale } from '@tonaljs/tonal';
import { register, _mod, silence, logger, pure, isNote } from '@strudel/core'; import { register, _mod, silence, logger, pure, isNote } from '@strudel/core';
import { stepInNamedScale, scaleToChromas } from './tonleiter.mjs'; import { stepInNamedScale, nearestNumberIndex } from './tonleiter.mjs';
import { noteToMidi } from '../core/util.mjs'; import { noteToMidi } from '../core/util.mjs';
const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P'; const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P';
function scaleStep(step, scale) { function getScale(scaleName) {
scale = scale.replaceAll(':', ' '); scaleName = scaleName.replaceAll(':', ' ');
step = Math.ceil(step); const scale = Scale.get(scaleName);
let { intervals, tonic, empty } = Scale.get(scale); const { tonic, empty } = scale;
if ((empty && isNote(scale)) || (empty && !tonic)) { if ((empty && isNote(scaleName)) || (empty && !tonic)) {
throw new Error(`incomplete scale. Make sure to use ":" instead of spaces, example: .scale("C:major")`); throw new Error(
`Scale name ${scaleName} is incomplete. Make sure to use ":" instead of spaces, example: .scale("C:major")`,
);
} else if (empty) { } else if (empty) {
throw new Error(`invalid scale "${scale}"`); throw new Error(`Invalid scale name "${scaleName}"`);
} }
return scale;
}
function scaleStep(step, scale) {
step = Math.ceil(step);
let { intervals, tonic } = getScale(scale);
tonic = tonic || 'C'; tonic = tonic || 'C';
const { pc, oct = 3 } = Note.get(tonic); const { pc, oct = 3 } = Note.get(tonic);
const octaveOffset = Math.floor(step / intervals.length); const octaveOffset = Math.floor(step / intervals.length);
@ -31,8 +39,7 @@ function scaleStep(step, scale) {
// transpose note inside scale by offset steps // transpose note inside scale by offset steps
// function scaleOffset(scale: string, offset: number, note: string) { // function scaleOffset(scale: string, offset: number, note: string) {
function scaleOffset(scale, offset, note) { function scaleOffset(scale, offset, note) {
let [tonic, scaleName] = Scale.tokenize(scale); let { notes } = getScale(scale);
let { notes } = Scale.get(`${tonic} ${scaleName}`);
notes = notes.map((note) => Note.get(note).pc); // use only pc! notes = notes.map((note) => Note.get(note).pc); // use only pc!
offset = Number(offset); offset = Number(offset);
if (isNaN(offset)) { if (isNaN(offset)) {
@ -197,19 +204,37 @@ function _convertStepToNumberAndOffset(step) {
return [asNumber, offset]; return [asNumber, offset];
} }
let scaleToMidisAndNotes = {};
// Finds the nearest scale note to `note` // Finds the nearest scale note to `note`
function _getNearestScaleNote(scaleName, note) { function _getNearestScaleNote(scaleName, note, preferHigher = true) {
let midiNote = typeof note === 'string' ? noteToMidi(note) : note; let noteMidi = typeof note === 'string' ? noteToMidi(note) : note;
const octave = (midiNote / 12) >> 0; noteMidi = Math.max(noteMidi, 24); // we will not play notes below C0
const targetChroma = midiNote % 12; if (scaleToMidisAndNotes[scaleName] === undefined) {
const scaleChromas = scaleToChromas(scaleName); const { intervals, tonic } = getScale(scaleName);
return ( const { pc } = Note.get(tonic);
scaleChromas.reduce((prev, curr) => { const expandedIntervals = intervals.concat('8P'); // add the octave for wrapping
// Include equality so ties are broken upwards const sNotes = expandedIntervals.map((interval) => Note.transpose(pc + '0', interval));
return Math.abs(curr - targetChroma) <= Math.abs(prev - targetChroma) ? curr : prev; const sMidi = sNotes.map(noteToMidi);
}) + // Cache
octave * 12 scaleToMidisAndNotes[scaleName] = [sMidi, sNotes];
); }
const [scaleMidis, scaleNotes] = scaleToMidisAndNotes[scaleName];
const rootMidi = scaleMidis[0];
const octaveDiff = Math.floor((noteMidi - rootMidi) / 12);
let filteredNotes = []; // we must filter the notes to avoid negative octave values
let filteredMidis = [];
for (let i = 0; i < scaleMidis.length; i++) {
const newMidi = scaleMidis[i] + 12 * octaveDiff;
if (newMidi < 24) {
continue;
}
filteredMidis.push(newMidi);
const oldNote = scaleNotes[i];
const newNote = Note.transpose(oldNote, Interval.fromSemitones(12 * octaveDiff));
filteredNotes.push(newNote);
}
const noteIdx = nearestNumberIndex(noteMidi, filteredMidis, preferHigher);
return filteredNotes[noteIdx];
} }
/** /**
@ -254,15 +279,15 @@ export const scale = register(
pat pat
.fmap((value) => { .fmap((value) => {
const isObject = typeof value === 'object'; const isObject = typeof value === 'object';
// The case where the note has been defined via `n` // The case where the note has been defined via `n` or `pure`
if ((isObject && 'n' in value) || !isObject) { if (!isObject || (isObject && ('n' in value || 'value' in value))) {
let step = isObject ? value.n : value; const step = isObject ? (value.n ?? value.value) : value;
delete value.n; // remove n so it won't cause trouble delete value.n; // remove n so it won't cause trouble
if (isNote(step)) { if (isNote(step)) {
// legacy.. // legacy..
return pure(step); return pure(step);
} }
let [number, offset] = _convertStepToNumberAndOffset(step); const [number, offset] = _convertStepToNumberAndOffset(step);
try { try {
let note; let note;
if (isObject && value.anchor) { if (isObject && value.anchor) {
@ -280,7 +305,7 @@ export const scale = register(
} }
// The case where the note has been defined via `note` // The case where the note has been defined via `note`
else { else {
let note = _getNearestScaleNote(scale, value.note); const note = _getNearestScaleNote(scale, value.note);
return pure(isObject ? { ...value, note } : note); return pure(isObject ? { ...value, note } : note);
} }
}) })

View file

@ -101,11 +101,11 @@ export function nearestNumberIndex(target, numbers, preferHigher) {
let scaleSteps = {}; // [scaleName]: semitones[] let scaleSteps = {}; // [scaleName]: semitones[]
export function stepInNamedScale(step, scale, anchor, preferHigher) { export function stepInNamedScale(step, scale, anchor, preferHigher) {
let [root, scaleName] = Scale.tokenize(scale); const [root, scaleName] = Scale.tokenize(scale);
const rootMidi = x2midi(root); const rootMidi = x2midi(root);
const rootChroma = midi2chroma(rootMidi); const rootChroma = midi2chroma(rootMidi);
if (!scaleSteps[scaleName]) { if (!scaleSteps[scaleName]) {
let { intervals } = Scale.get(`C ${scaleName}`); const { intervals } = Scale.get(`C ${scaleName}`);
// cache result // cache result
scaleSteps[scaleName] = intervals.map(step2semitones); scaleSteps[scaleName] = intervals.map(step2semitones);
} }
@ -236,16 +236,3 @@ export function transpose(note, step) {
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" 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(''); return [targetNote, offsetAccidentals].join('');
} }
// Converts a `scaleName` into a corresponding list of chromas between 0 and 12
export function scaleToChromas(scaleName) {
if (Array.isArray(scaleName)) {
scaleName = scaleName.flat().join(' ');
}
const [tonic, name] = Scale.tokenize(scaleName);
const rootMidi = noteToMidi(tonic);
const chroma = rootMidi % 12;
const intervals = Scale.get(name).intervals;
const scaleSteps = intervals.map(Interval.semitones);
return scaleSteps.map((s) => (s + chroma) % 12);
}