Merge pull request 'Bug Fix: Handle scale-for-notes when n also supplied' (#1625) from glossing/strudel:glossing/scale-with-note-and-n into main

Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1625
This commit is contained in:
froos 2025-10-28 22:58:24 +01:00
commit 4c35f6a77b
6 changed files with 62 additions and 62 deletions

View file

@ -2574,8 +2574,8 @@ export const { chunkBack, chunkback } = register(
* @returns Pattern * @returns Pattern
* @example * @example
* "<0 8> 1 2 3 4 5 6 7" * "<0 8> 1 2 3 4 5 6 7"
* .fastChunk(4, x => x.color('red')).slow(2)
* .scale("C2:major").note() * .scale("C2:major").note()
* .fastChunk(4, x => x.color('red')).slow(2)
*/ */
export const { fastchunk, fastChunk } = register( export const { fastchunk, fastChunk } = register(
['fastchunk', 'fastChunk'], ['fastchunk', 'fastChunk'],

View file

@ -7,8 +7,8 @@ This program is free software: you can redistribute it and/or modify it under th
import { logger } from './logger.mjs'; import { logger } from './logger.mjs';
// returns true if the given string is a note // returns true if the given string is a note
export const isNoteWithOctave = (name) => /^[a-gA-G][#bs]*[0-9]$/.test(name); export const isNoteWithOctave = (name) => /^[a-gA-G][#bsf]*[0-9]*$/.test(name);
export const isNote = (name) => /^[a-gA-G][#bsf]*-?[0-9]?$/.test(name); export const isNote = (name) => /^[a-gA-G][#bsf]*-?[0-9]*$/.test(name);
export const tokenizeNote = (note) => { export const tokenizeNote = (note) => {
if (typeof note !== 'string') { if (typeof note !== 'string') {
return []; return [];
@ -23,6 +23,10 @@ export const tokenizeNote = (note) => {
const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
const accs = { '#': 1, b: -1, s: 1, f: -1 }; const accs = { '#': 1, b: -1, s: 1, f: -1 };
export const getAccidentalsOffset = (accidentals) => {
return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0;
};
// turns the given note into its midi number representation // turns the given note into its midi number representation
export const noteToMidi = (note, defaultOctave = 3) => { export const noteToMidi = (note, defaultOctave = 3) => {
const [pc, acc, oct = defaultOctave] = tokenizeNote(note); const [pc, acc, oct = defaultOctave] = tokenizeNote(note);
@ -30,7 +34,7 @@ export const noteToMidi = (note, defaultOctave = 3) => {
throw new Error('not a note: "' + note + '"'); throw new Error('not a note: "' + note + '"');
} }
const chroma = chromas[pc.toLowerCase()]; const chroma = chromas[pc.toLowerCase()];
const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0; const offset = getAccidentalsOffset(acc);
return (Number(oct) + 1) * 12 + chroma + offset; return (Number(oct) + 1) * 12 + chroma + offset;
}; };
export const midiToFreq = (n) => { export const midiToFreq = (n) => {

View file

@ -16,13 +16,17 @@ export const tokenizeNote = (note) => {
const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
const accs = { '#': 1, b: -1, s: 1, f: -1 }; const accs = { '#': 1, b: -1, s: 1, f: -1 };
export const getAccidentalsOffset = (accidentals) => {
return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0;
};
export const noteToMidi = (note, defaultOctave = 3) => { export const noteToMidi = (note, defaultOctave = 3) => {
const [pc, acc, oct = defaultOctave] = tokenizeNote(note); const [pc, acc, oct = defaultOctave] = tokenizeNote(note);
if (!pc) { if (!pc) {
throw new Error('not a note: "' + note + '"'); throw new Error('not a note: "' + note + '"');
} }
const chroma = chromas[pc.toLowerCase()]; const chroma = chromas[pc.toLowerCase()];
const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0; const offset = getAccidentalsOffset(acc);
return (Number(oct) + 1) * 12 + chroma + offset; return (Number(oct) + 1) * 12 + chroma + offset;
}; };
export const midiToFreq = (n) => { export const midiToFreq = (n) => {

View file

@ -61,13 +61,6 @@ describe('tonal', () => {
.firstCycleValues.map((h) => h.note), .firstCycleValues.map((h) => h.note),
).toEqual(['B2', 'Eb3', 'A2', 'G3', 'F3']); ).toEqual(['B2', 'Eb3', 'A2', 'G3', 'F3']);
}); });
it('produces silence for mixed sharps and flats', () => {
expect(
n(seq('0b#', '1#b', '2#b#'))
.scale('C major')
.firstCycleValues.map((h) => h.note),
).toEqual([]);
});
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'];
const expectedNotes = ['B2', 'E3', 'G3', 'B3', 'B3']; const expectedNotes = ['B2', 'E3', 'G3', 'B3', 'B3'];

View file

@ -5,9 +5,8 @@ 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, logger, isNote, noteToMidi, removeUndefineds, getAccidentalsOffset } from '@strudel/core';
import { stepInNamedScale, nearestNumberIndex } 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'; const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P';
@ -185,17 +184,15 @@ function _convertStepToNumberAndOffset(step) {
step = String(step); step = String(step);
// Check to see if the step matches the expected format: // Check to see if the step matches the expected format:
// - A number (possibly negative) // - A number (possibly negative)
// - Some number of sharps or flats (but not both) // - Some number of sharps or flats
const match = /^(-?\d+)(#+|b+)?$/.exec(step); const match = /^(-?\d+)([#bsf]*)$/.exec(step);
if (!match) { if (!match) {
throw new Error(`invalid scale step "${step}", expected number or integer with optional # b suffixes`); throw new Error(`invalid scale step "${step}", expected number or integer with optional # b suffixes`);
} }
asNumber = Number(match[1]); asNumber = Number(match[1]);
// These decorations will determine the semitone offset based on the number of const accidentals = match[2] || '';
// sharps or flats offset = getAccidentalsOffset(accidentals);
const decorations = match[2] || '';
offset = decorations[0] === '#' ? decorations.length : -decorations.length;
} }
return [asNumber, offset]; return [asNumber, offset];
} }
@ -226,7 +223,7 @@ function _getNearestScaleNote(scaleName, note, preferHigher = true) {
* Turns numbers into notes in the scale (zero indexed) or quantizes notes to a scale. * Turns numbers into notes in the scale (zero indexed) or quantizes notes to a scale.
* *
* When describing notes via numbers, note that negative numbers can be used to wrap backwards * When describing notes via numbers, note that negative numbers can be used to wrap backwards
* in the scale as well as sharps or flats (but not both) to produce notes outside of the scale. * in the scale as well as sharps or flats to produce notes outside of the scale.
* *
* Also sets scale for other scale operations, like {@link Pattern#scaleTranspose}. * Also sets scale for other scale operations, like {@link Pattern#scaleTranspose}.
* *
@ -254,7 +251,6 @@ function _getNearestScaleNote(scaleName, note, preferHigher = true) {
* @example * @example
* note("C1*16").transpose(irand(36)).scale('Cb2 major').scaleTranspose(3) * note("C1*16").transpose(irand(36)).scale('Cb2 major').scaleTranspose(3)
*/ */
export const scale = register( export const scale = register(
'scale', 'scale',
function (scale, pat) { function (scale, pat) {
@ -262,44 +258,47 @@ export const scale = register(
if (Array.isArray(scale)) { if (Array.isArray(scale)) {
scale = scale.flat().join(' '); scale = scale.flat().join(' ');
} }
return ( return pat.withHaps((haps) => {
pat haps = haps.map((hap) => {
.fmap((value) => { let hVal = hap.value;
const isObject = typeof value === 'object'; const isObject = typeof hVal === 'object';
// The case where the note has been defined via `n` or `pure` // If hVal is a pure value, place it on `n` so that we interpret it as a scale degree
if (!isObject || (isObject && ('n' in value || 'value' in value))) { hVal = isObject ? hVal : { n: hVal };
const step = isObject ? (value.n ?? value.value) : value; const { note, n, value, ...otherValues } = hVal;
delete value.n; // remove n so it won't cause trouble const noteOrStep = note ?? n ?? value;
if (isNote(step)) { if (noteOrStep === undefined) {
// legacy.. logger(
return pure(step); `[tonal] Invalid value format for 'scale'. Value must contain n, note, or value but received keys [${Object.keys(hVal).join(', ')}]`,
'error',
);
return hap; // pass the value through unchanged
}
let scaleNote;
if (isNote(noteOrStep)) {
// Note case (quantize to scale)
scaleNote = _getNearestScaleNote(scale, noteOrStep);
hap.value = { ...otherValues, note: scaleNote };
} else {
// Step case (convert to note in scale)
try {
const [number, offset] = _convertStepToNumberAndOffset(noteOrStep);
if (otherValues.anchor) {
scaleNote = stepInNamedScale(number, scale, otherValues.anchor);
} else {
scaleNote = scaleStep(number, scale);
} }
try { if (offset != 0) scaleNote = Note.transpose(scaleNote, Interval.fromSemitones(offset));
const [number, offset] = _convertStepToNumberAndOffset(step); } catch (err) {
let note; logger(`[tonal] ${err.message}`, 'error');
if (isObject && value.anchor) { return; // will be removed
note = stepInNamedScale(number, scale, value.anchor);
} else {
note = scaleStep(number, scale);
}
if (offset != 0) note = Note.transpose(note, Interval.fromSemitones(offset));
value = pure(isObject ? { ...value, note } : note);
} catch (err) {
logger(`[tonal] ${err.message}`, 'error');
return silence;
}
return value;
} }
// The case where the note has been defined via `note` }
else { hap.value = isObject ? { ...otherValues, note: scaleNote } : scaleNote;
const note = _getNearestScaleNote(scale, value.note); // Tag with scale for downsteam scale-aware operations
return pure(isObject ? { ...value, note } : note); return hap.setContext({ ...hap.context, scale });
} });
}) return removeUndefineds(haps);
.outerJoin() });
// legacy:
.withHap((hap) => hap.setContext({ ...hap.context, scale }))
);
}, },
true, true,
true, // preserve step count true, // preserve step count

View file

@ -3838,8 +3838,8 @@ exports[`runs examples > example "fast" example index 0 1`] = `
exports[`runs examples > example "fastChunk" example index 0 1`] = ` exports[`runs examples > example "fastChunk" example index 0 1`] = `
[ [
"[ 0/1 → 1/4 | color:red note:0 ]", "[ 0/1 → 1/4 | note:C2 color:red ]",
"[ 1/4 → 1/2 | color:red note:1 ]", "[ 1/4 → 1/2 | note:D2 color:red ]",
"[ 1/2 → 3/4 | note:E2 ]", "[ 1/2 → 3/4 | note:E2 ]",
"[ 3/4 → 1/1 | note:F2 ]", "[ 3/4 → 1/1 | note:F2 ]",
"[ 1/1 → 5/4 | note:G2 ]", "[ 1/1 → 5/4 | note:G2 ]",
@ -3848,8 +3848,8 @@ exports[`runs examples > example "fastChunk" example index 0 1`] = `
"[ 7/4 → 2/1 | note:C3 ]", "[ 7/4 → 2/1 | note:C3 ]",
"[ 2/1 → 9/4 | note:D3 ]", "[ 2/1 → 9/4 | note:D3 ]",
"[ 9/4 → 5/2 | note:D2 ]", "[ 9/4 → 5/2 | note:D2 ]",
"[ 5/2 → 11/4 | color:red note:2 ]", "[ 5/2 → 11/4 | note:E2 color:red ]",
"[ 11/4 → 3/1 | color:red note:3 ]", "[ 11/4 → 3/1 | note:F2 color:red ]",
"[ 3/1 → 13/4 | note:G2 ]", "[ 3/1 → 13/4 | note:G2 ]",
"[ 13/4 → 7/2 | note:A2 ]", "[ 13/4 → 7/2 | note:A2 ]",
"[ 7/2 → 15/4 | note:B2 ]", "[ 7/2 → 15/4 | note:B2 ]",