From c02def03b683a1f37a5b6cd7f53ab51b721fdb26 Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 16 Oct 2025 16:26:33 -0500 Subject: [PATCH] Don't tag when scale fails, allow mixed sharps and flats, simplify scale function --- packages/core/pattern.mjs | 2 +- packages/core/test/pattern.test.mjs | 4 +- packages/core/util.mjs | 10 ++- packages/superdough/util.mjs | 6 +- packages/tonal/test/tonal.test.mjs | 7 -- packages/tonal/tonal.mjs | 96 +++++++++++------------ test/__snapshots__/examples.test.mjs.snap | 8 +- website/src/components/Header/Search.css | 4 +- 8 files changed, 67 insertions(+), 70 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index a85226a8f..51d525f74 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -2574,8 +2574,8 @@ export const { chunkBack, chunkback } = register( * @returns Pattern * @example * "<0 8> 1 2 3 4 5 6 7" - * .fastChunk(4, x => x.color('red')).slow(2) * .scale("C2:major").note() + * .fastChunk(4, x => x.color('red')).slow(2) */ export const { fastchunk, fastChunk } = register( ['fastchunk', 'fastChunk'], diff --git a/packages/core/test/pattern.test.mjs b/packages/core/test/pattern.test.mjs index 625f40756..1df5c8776 100644 --- a/packages/core/test/pattern.test.mjs +++ b/packages/core/test/pattern.test.mjs @@ -796,7 +796,7 @@ describe('Pattern', () => { }); }); describe('apply', () => { - (it('Can apply a function', () => { + it('Can apply a function', () => { expect(sequence('a', 'b').apply(fast(2)).firstCycle()).toStrictEqual(sequence('a', 'b').fast(2).firstCycle()); }), it('Can apply a pattern of functions', () => { @@ -804,7 +804,7 @@ describe('Pattern', () => { expect(sequence('a', 'b').apply(fast(2), fast(3)).firstCycle()).toStrictEqual( sequence('a', 'b').fast(2, 3).firstCycle(), ); - })); + }); }); describe('layer', () => { it('Can layer up multiple functions', () => { diff --git a/packages/core/util.mjs b/packages/core/util.mjs index ef3f1e961..915a2cb77 100644 --- a/packages/core/util.mjs +++ b/packages/core/util.mjs @@ -7,8 +7,8 @@ This program is free software: you can redistribute it and/or modify it under th import { logger } from './logger.mjs'; // returns true if the given string is a note -export const isNoteWithOctave = (name) => /^[a-gA-G][#bs]*[0-9]$/.test(name); -export const isNote = (name) => /^[a-gA-G][#bsf]*-?[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 tokenizeNote = (note) => { if (typeof note !== 'string') { 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 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 export const noteToMidi = (note, defaultOctave = 3) => { const [pc, acc, oct = defaultOctave] = tokenizeNote(note); @@ -30,7 +34,7 @@ export const noteToMidi = (note, defaultOctave = 3) => { throw new Error('not a note: "' + note + '"'); } 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; }; export const midiToFreq = (n) => { diff --git a/packages/superdough/util.mjs b/packages/superdough/util.mjs index 475c05f63..418b7af0a 100644 --- a/packages/superdough/util.mjs +++ b/packages/superdough/util.mjs @@ -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 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) => { const [pc, acc, oct = defaultOctave] = tokenizeNote(note); if (!pc) { throw new Error('not a note: "' + note + '"'); } 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; }; export const midiToFreq = (n) => { diff --git a/packages/tonal/test/tonal.test.mjs b/packages/tonal/test/tonal.test.mjs index 5f61b05a9..06aa91188 100644 --- a/packages/tonal/test/tonal.test.mjs +++ b/packages/tonal/test/tonal.test.mjs @@ -61,13 +61,6 @@ describe('tonal', () => { .firstCycleValues.map((h) => h.note), ).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', () => { const inputNotes = ['Cb', 'Eb', 'G', 'A#', 'Bb']; const expectedNotes = ['B2', 'E3', 'G3', 'B3', 'B3']; diff --git a/packages/tonal/tonal.mjs b/packages/tonal/tonal.mjs index e745a33a0..0dc468b20 100644 --- a/packages/tonal/tonal.mjs +++ b/packages/tonal/tonal.mjs @@ -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 { 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 { noteToMidi } from '../core/util.mjs'; const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P'; @@ -185,17 +184,15 @@ function _convertStepToNumberAndOffset(step) { step = String(step); // Check to see if the step matches the expected format: // - A number (possibly negative) - // - Some number of sharps or flats (but not both) - const match = /^(-?\d+)(#+|b+)?$/.exec(step); + // - Some number of sharps or flats + const match = /^(-?\d+)([#bsf]*)$/.exec(step); if (!match) { throw new Error(`invalid scale step "${step}", expected number or integer with optional # b suffixes`); } asNumber = Number(match[1]); - // These decorations will determine the semitone offset based on the number of - // sharps or flats - const decorations = match[2] || ''; - offset = decorations[0] === '#' ? decorations.length : -decorations.length; + const accidentals = match[2] || ''; + offset = getAccidentalsOffset(accidentals); } 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. * * 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}. * @@ -254,7 +251,6 @@ function _getNearestScaleNote(scaleName, note, preferHigher = true) { * @example * note("C1*16").transpose(irand(36)).scale('Cb2 major').scaleTranspose(3) */ - export const scale = register( 'scale', function (scale, pat) { @@ -262,47 +258,47 @@ export const scale = register( if (Array.isArray(scale)) { scale = scale.flat().join(' '); } - return ( - pat - .fmap((value) => { - const isObject = typeof value === 'object'; - // If value is a pure value, place it on `n` so that we interpret it as a scale - // degree - value = isObject ? value : { n: value }; - if ('note' in value) { - const note = _getNearestScaleNote(scale, value.note); - return pure({ ...value, note }); - } else if ('n' in value || 'value' in value) { - const step = value.n ?? value.value; - delete value.n; // remove n so it won't cause trouble - if (isNote(step)) { - // legacy.. - return pure(step); - } - try { - const [number, offset] = _convertStepToNumberAndOffset(step); - let note; - if (value.anchor) { - 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; - } - throw new Error( - `Invalid value format for 'scale'. Value must contain 'n' or 'note' but received ${Object.keys(value)}`, + return pat.withHaps((haps) => { + haps = haps.map((hap) => { + let hVal = hap.value; + const isObject = typeof hVal === 'object'; + // If hVal is a pure value, place it on `n` so that we interpret it as a scale degree + hVal = isObject ? hVal : { n: hVal }; + const { note, n, value, ...otherValues } = hVal; + const noteOrStep = note ?? n ?? value; + if (noteOrStep === undefined) { + logger( + `[tonal] Invalid value format for 'scale'. Value must contain n, note, or value but received keys [${Object.keys(hVal).join(', ')}]`, + 'error', ); - }) - .outerJoin() - // legacy: - .withHap((hap) => hap.setContext({ ...hap.context, scale })) - ); + 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); + } + if (offset != 0) scaleNote = Note.transpose(scaleNote, Interval.fromSemitones(offset)); + } catch (err) { + logger(`[tonal] ${err.message}`, 'error'); + return; // will be removed + } + } + hap.value = isObject ? { ...otherValues, note: scaleNote } : scaleNote; + // Tag with scale for downsteam scale-aware operations + return hap.setContext({ ...hap.context, scale }); + }); + return removeUndefineds(haps); + }); }, true, true, // preserve step count diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 937d42797..fd0a1bb5d 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -3838,8 +3838,8 @@ exports[`runs examples > example "fast" example index 0 1`] = ` exports[`runs examples > example "fastChunk" example index 0 1`] = ` [ - "[ 0/1 → 1/4 | color:red note:0 ]", - "[ 1/4 → 1/2 | color:red note:1 ]", + "[ 0/1 → 1/4 | note:C2 color:red ]", + "[ 1/4 → 1/2 | note:D2 color:red ]", "[ 1/2 → 3/4 | note:E2 ]", "[ 3/4 → 1/1 | note:F2 ]", "[ 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 ]", "[ 2/1 → 9/4 | note:D3 ]", "[ 9/4 → 5/2 | note:D2 ]", - "[ 5/2 → 11/4 | color:red note:2 ]", - "[ 11/4 → 3/1 | color:red note:3 ]", + "[ 5/2 → 11/4 | note:E2 color:red ]", + "[ 11/4 → 3/1 | note:F2 color:red ]", "[ 3/1 → 13/4 | note:G2 ]", "[ 13/4 → 7/2 | note:A2 ]", "[ 7/2 → 15/4 | note:B2 ]", diff --git a/website/src/components/Header/Search.css b/website/src/components/Header/Search.css index b14146cbd..456ef9f6b 100644 --- a/website/src/components/Header/Search.css +++ b/website/src/components/Header/Search.css @@ -18,8 +18,8 @@ --docsearch-modal-background: var(--background); --docsearch-muted-color: color-mix(in srgb, var(--foreground), #fff 30%); --docsearch-key-gradient: var(--foreground); - --docsearch-key-shadow: - inset 0 -2px 0 0 var(--gutterForeground), inset 0 0 1px 1px var(--foreground), 0 1px 2px 1px var(--gutterBackground); + --docsearch-key-shadow: inset 0 -2px 0 0 var(--gutterForeground), inset 0 0 1px 1px var(--foreground), + 0 1px 2px 1px var(--gutterBackground); } .dark { --docsearch-muted-color: color-mix(in srgb, var(--foreground), #000 30%);