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
+21 -4
View File
@@ -70,10 +70,7 @@ describe('tonal', () => {
});
it('snaps notes (upwards) to scale', () => {
const inputNotes = ['Cb', 'Eb', 'G', 'A#', 'Bb'];
let expectedNotes = ['B2', 'E3', 'G3', 'B3', 'B3'];
// Notes are converted to midi by scale
expectedNotes = expectedNotes.map((note) => noteToMidi(note));
const expectedNotes = ['B2', 'E3', 'G3', 'B3', 'B3'];
expect(
note(seq(inputNotes))
@@ -81,6 +78,26 @@ describe('tonal', () => {
.firstCycleValues.map((h) => h.note),
).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', () => {
it('transposes note numbers with interval numbers', () => {
+52 -27
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 { 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';
const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P';
function scaleStep(step, scale) {
scale = scale.replaceAll(':', ' ');
step = Math.ceil(step);
let { intervals, tonic, empty } = Scale.get(scale);
if ((empty && isNote(scale)) || (empty && !tonic)) {
throw new Error(`incomplete scale. Make sure to use ":" instead of spaces, example: .scale("C:major")`);
function getScale(scaleName) {
scaleName = scaleName.replaceAll(':', ' ');
const scale = Scale.get(scaleName);
const { tonic, empty } = scale;
if ((empty && isNote(scaleName)) || (empty && !tonic)) {
throw new Error(
`Scale name ${scaleName} is incomplete. Make sure to use ":" instead of spaces, example: .scale("C:major")`,
);
} 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';
const { pc, oct = 3 } = Note.get(tonic);
const octaveOffset = Math.floor(step / intervals.length);
@@ -31,8 +39,7 @@ function scaleStep(step, scale) {
// transpose note inside scale by offset steps
// function scaleOffset(scale: string, offset: number, note: string) {
function scaleOffset(scale, offset, note) {
let [tonic, scaleName] = Scale.tokenize(scale);
let { notes } = Scale.get(`${tonic} ${scaleName}`);
let { notes } = getScale(scale);
notes = notes.map((note) => Note.get(note).pc); // use only pc!
offset = Number(offset);
if (isNaN(offset)) {
@@ -197,19 +204,37 @@ function _convertStepToNumberAndOffset(step) {
return [asNumber, offset];
}
let scaleToMidisAndNotes = {};
// Finds the nearest scale note to `note`
function _getNearestScaleNote(scaleName, note) {
let midiNote = typeof note === 'string' ? noteToMidi(note) : note;
const octave = (midiNote / 12) >> 0;
const targetChroma = midiNote % 12;
const scaleChromas = scaleToChromas(scaleName);
return (
scaleChromas.reduce((prev, curr) => {
// Include equality so ties are broken upwards
return Math.abs(curr - targetChroma) <= Math.abs(prev - targetChroma) ? curr : prev;
}) +
octave * 12
);
function _getNearestScaleNote(scaleName, note, preferHigher = true) {
let noteMidi = typeof note === 'string' ? noteToMidi(note) : note;
noteMidi = Math.max(noteMidi, 24); // we will not play notes below C0
if (scaleToMidisAndNotes[scaleName] === undefined) {
const { intervals, tonic } = getScale(scaleName);
const { pc } = Note.get(tonic);
const expandedIntervals = intervals.concat('8P'); // add the octave for wrapping
const sNotes = expandedIntervals.map((interval) => Note.transpose(pc + '0', interval));
const sMidi = sNotes.map(noteToMidi);
// Cache
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
.fmap((value) => {
const isObject = typeof value === 'object';
// The case where the note has been defined via `n`
if ((isObject && 'n' in value) || !isObject) {
let step = isObject ? value.n : value;
// The case where the note has been defined via `n` or `pure`
if (!isObject || (isObject && ('n' in value || 'value' in value))) {
const step = isObject ? (value.n ?? value.value) : value;
delete value.n; // remove n so it won't cause trouble
if (isNote(step)) {
// legacy..
return pure(step);
}
let [number, offset] = _convertStepToNumberAndOffset(step);
const [number, offset] = _convertStepToNumberAndOffset(step);
try {
let note;
if (isObject && value.anchor) {
@@ -280,7 +305,7 @@ export const scale = register(
}
// The case where the note has been defined via `note`
else {
let note = _getNearestScaleNote(scale, value.note);
const note = _getNearestScaleNote(scale, value.note);
return pure(isObject ? { ...value, note } : note);
}
})
+2 -15
View File
@@ -101,11 +101,11 @@ export function nearestNumberIndex(target, numbers, preferHigher) {
let scaleSteps = {}; // [scaleName]: semitones[]
export function stepInNamedScale(step, scale, anchor, preferHigher) {
let [root, scaleName] = Scale.tokenize(scale);
const [root, scaleName] = Scale.tokenize(scale);
const rootMidi = x2midi(root);
const rootChroma = midi2chroma(rootMidi);
if (!scaleSteps[scaleName]) {
let { intervals } = Scale.get(`C ${scaleName}`);
const { intervals } = Scale.get(`C ${scaleName}`);
// cache result
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"
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);
}