1/* 2tonal.mjs - <short description TODO> 3Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/tonal/tonal.mjs> 4This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. 5*/ 6 7import { Note, Interval, Scale } from '@tonaljs/tonal'; 8import { 9 _mod, 10 errorLogger, 11 getAccidentalsOffset, 12 isNote, 13 logger, 14 noteToMidi, 15 register, 16 removeUndefineds, 17} from '@strudel/core'; 18import { stepInNamedScale, nearestNumberIndex } from './tonleiter.mjs'; 19 20const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P'; 21 22function getScale(scaleName) { 23 scaleName = scaleName.replaceAll(':', ' '); 24 const scale = Scale.get(scaleName); 25 const { tonic, empty } = scale; 26 if ((empty && isNote(scaleName)) || (empty && !tonic)) { 27 throw new Error( 28 `Scale name ${scaleName} is incomplete. Make sure to use ":" instead of spaces, example: .scale("C:major")`, 29 ); 30 } else if (empty) { 31 throw new Error(`Invalid scale name "${scaleName}"`); 32 } 33 return scale; 34} 35 36function scaleStep(step, scale) { 37 step = Math.ceil(step); 38 let { intervals, tonic } = getScale(scale); 39 tonic = tonic || 'C'; 40 const { pc, oct = 3 } = Note.get(tonic); 41 const octaveOffset = Math.floor(step / intervals.length); 42 const scaleStep = _mod(step, intervals.length); 43 const interval = Interval.add(intervals[scaleStep], octavesInterval(octaveOffset)); 44 return Note.transpose(pc + oct, interval); 45} 46 47// transpose note inside scale by offset steps 48// function scaleOffset(scale: string, offset: number, note: string) { 49function scaleOffset(scale, offset, note) { 50 let { notes } = getScale(scale); 51 notes = notes.map((note) => Note.get(note).pc); // use only pc! 52 offset = Number(offset); 53 if (isNaN(offset)) { 54 throw new Error(`scale offset "${offset}" not a number`); 55 } 56 const { pc: fromPc, oct = 3 } = Note.get(note); 57 const noteIndex = notes.indexOf(fromPc); 58 if (noteIndex === -1) { 59 throw new Error(`note "${note}" is not in scale "${scale}"`); 60 } 61 let i = noteIndex, 62 o = oct, 63 n = fromPc; 64 const direction = Math.sign(offset); 65 // TODO: find way to do this smarter 66 while (Math.abs(i - noteIndex) < Math.abs(offset)) { 67 i += direction; 68 const index = _mod(i, notes.length); 69 if (direction < 0 && n[0] === 'C') { 70 o += direction; 71 } 72 n = notes[index]; 73 if (direction > 0 && n[0] === 'C') { 74 o += direction; 75 } 76 } 77 return n + o; 78} 79 80// Pattern.prototype._transpose = function (intervalOrSemitones: string | number) { 81/** 82 * Change the pitch of each value by the given amount. Expects numbers or note strings as values. 83 * The amount can be given as a number of semitones or as a string in interval short notation. 84 * If you don't care about enharmonic correctness, just use numbers. Otherwise, pass the interval of 85 * the form: ST where S is the degree number and T the type of interval with 86 * 87 * - M = major 88 * - m = minor 89 * - P = perfect 90 * - A = augmented 91 * - d = diminished 92 * 93 * Examples intervals: 94 * 95 * - 1P = unison 96 * - 3M = major third 97 * - 3m = minor third 98 * - 4P = perfect fourth 99 * - 4A = augmented fourth 100 * - 5P = perfect fifth 101 * - 5d = diminished fifth 102 * 103 * @tags tonal 104 * @param {string | number} amount Either number of semitones or interval string. 105 * @returns Pattern 106 * @memberof Pattern 107 * @name transpose 108 * @synonyms trans 109 * @example 110 * "c2 c3".fast(2).transpose("<0 -2 5 3>".slow(2)).note() 111 * @example 112 * "c2 c3".fast(2).transpose("<1P -2M 4P 3m>".slow(2)).note() 113 */ 114 115export const { transpose, trans } = register(['transpose', 'trans'], function transposeFn(intervalOrSemitones, pat) { 116 return pat.withHap((hap) => { 117 const note = hap.value.note ?? hap.value; 118 if (typeof note === 'number') { 119 // note is a number, so just add the number semitones of the interval 120 let semitones; 121 if (typeof intervalOrSemitones === 'number') { 122 semitones = intervalOrSemitones; 123 } else if (typeof intervalOrSemitones === 'string') { 124 semitones = Interval.semitones(intervalOrSemitones) || 0; 125 } 126 const targetNote = note + semitones; 127 if (typeof hap.value === 'object') { 128 return hap.withValue(() => ({ ...hap.value, note: targetNote })); 129 } 130 return hap.withValue(() => targetNote); 131 } 132 if (typeof note !== 'string' || !isNote(note)) { 133 logger(`[tonal] transpose: not a note "${note}"`, 'warning'); 134 return hap; 135 } 136 // note is a string, so we might be able to preserve harmonics if interval is a string as well 137 const interval = !isNaN(Number(intervalOrSemitones)) 138 ? Interval.fromSemitones(intervalOrSemitones) 139 : String(intervalOrSemitones); 140 141 let n = Note.get(note); 142 if (n.oct == undefined) { 143 n.oct = 3; 144 let n1 = Note.get(Note.transpose(n, interval)); 145 n.oct = n1.oct == 3 ? undefined : 3; 146 } 147 const targetNote = Note.transpose(n, interval); 148 if (typeof hap.value === 'object') { 149 return hap.withValue(() => ({ ...hap.value, note: targetNote })); 150 } 151 return hap.withValue(() => targetNote); 152 }); 153}); 154 155// example: transpose(3).late(0.2) will be equivalent to compose(transpose(3), late(0.2)) 156// e.g. `stack(c3).superimpose(transpose(slowcat(7, 5)))` or 157// or even `stack(c3).superimpose(transpose.slowcat(7, 5))` or 158 159/** 160 * Transposes notes inside the scale by the number of steps. 161 * Expected to be called on a Pattern which already has a {@link Pattern#scale} 162 * 163 * @memberof Pattern 164 * @name scaleTranspose 165 * @tags tonal 166 * @param {offset} offset number of steps inside the scale 167 * @returns Pattern 168 * @synonyms scaleTrans, strans 169 * @example 170 * "-8 [2,4,6]" 171 * .scale('C4 bebop major') 172 * .scaleTranspose("<0 -1 -2 -3 -4 -5 -6 -4>") 173 * .note() 174 */ 175 176export const { scaleTranspose, scaleTrans, strans } = register( 177 ['scaleTranspose', 'scaleTrans', 'strans'], 178 function (offset /* : number | string */, pat) { 179 return pat.withHap((hap) => { 180 if (!hap.context.scale) { 181 throw new Error('can only use scaleTranspose after .scale'); 182 } 183 if (typeof hap.value === 'object') 184 return hap.withValue(() => ({ 185 ...hap.value, 186 note: scaleOffset(hap.context.scale, Number(offset), hap.value.note), 187 })); 188 if (typeof hap.value !== 'string') { 189 throw new Error('can only use scaleTranspose with notes'); 190 } 191 return hap.withValue(() => scaleOffset(hap.context.scale, Number(offset), hap.value)); 192 }); 193 }, 194); 195 196// Converts a step value, which is a number optionally decorated with sharps and flats, 197// to a number and an `offset` number of semitones 198function _convertStepToNumberAndOffset(step) { 199 let asNumber = Number(step); 200 let offset = 0; 201 if (isNaN(asNumber)) { 202 step = String(step); 203 // Check to see if the step matches the expected format: 204 // - A number (possibly negative) 205 // - Some number of sharps or flats 206 const match = /^(-?\d+)([#bsf]*)$/.exec(step); 207 208 if (!match) { 209 throw new Error(`invalid scale step "${step}", expected number or integer with optional # b suffixes`); 210 } 211 asNumber = Number(match[1]); 212 const accidentals = match[2] || ''; 213 offset = getAccidentalsOffset(accidentals); 214 } 215 return [asNumber, offset]; 216} 217 218let scaleToMidisAndNotes = {}; 219// Finds the nearest scale note to `note` 220function _getNearestScaleNote(scaleName, note, preferHigher = true) { 221 let noteMidi = typeof note === 'string' ? noteToMidi(note) : note; 222 if (scaleToMidisAndNotes[scaleName] === undefined) { 223 const { intervals, tonic } = getScale(scaleName); 224 const { pc } = Note.get(tonic); 225 const expandedIntervals = intervals.concat('8P'); // add the octave for wrapping 226 const sNotes = expandedIntervals.map((interval) => Note.transpose(pc + '0', interval)); 227 const sMidi = sNotes.map(noteToMidi); 228 // Cache 229 scaleToMidisAndNotes[scaleName] = [sMidi, sNotes]; 230 } 231 const [scaleMidis, scaleNotes] = scaleToMidisAndNotes[scaleName]; 232 const rootMidi = scaleMidis[0]; 233 const octaveDiff = Math.floor((noteMidi - rootMidi) / 12); 234 const alignedMidis = scaleMidis.map((m) => m + 12 * octaveDiff); 235 const noteIdx = nearestNumberIndex(noteMidi, alignedMidis, preferHigher); 236 const noteMatch = scaleNotes[noteIdx]; 237 return Note.transpose(noteMatch, Interval.fromSemitones(12 * octaveDiff)); 238} 239 240/** 241 * Turns numbers into notes in the scale (zero indexed) or quantizes notes to a scale. 242 * 243 * When describing notes via numbers, note that negative numbers can be used to wrap backwards 244 * in the scale as well as sharps or flats to produce notes outside of the scale. 245 * 246 * Also sets scale for other scale operations, like {@link Pattern#scaleTranspose}. 247 * 248 * A scale consists of a root note (e.g. `c4`, `c`, `f#`, `bb4`) followed by semicolon (':') and then a [scale type](https://github.com/tonaljs/tonal/blob/main/packages/scale-type/data.ts). 249 * 250 * The scale name must be written without spaces (because it would be interpreted as a multi-step pattern otherwise). 251 * If your scale name includes spaces, replace them with colons. 252 * 253 * The root note defaults to octave 3, if no octave number is given. 254 * 255 * @name scale 256 * @tags tonal 257 * @param {string} scale Name of scale 258 * @returns Pattern 259 * @example 260 * n("0 2 4 6 4 2").scale("C:major") 261 * @example 262 * n("[0,7] 4 [2,7] 4") 263 * .scale("C:<major minor>/2") 264 * .s("piano") 265 * @example 266 * n(rand.range(0,12).segment(8)) 267 * .scale("C:ritusen") 268 * .s("piano") 269 * @example 270 * n("<[0,7b] [-4# -4] [-2,7##] 4 [0,7] [-4# -4b] [-2,7###] 4b>*4") 271 * .scale("C:<major minor>/2") 272 * .s("piano") 273 * @example 274 * note("C1*16").transpose(irand(36)).scale('Cb2 major').scaleTranspose(3) 275 * @example 276 * n("[0 0] [1 2] [3 4] [5 6]").scale("C:major:blues") 277 */ 278export const scale = register( 279 'scale', 280 function (scale, pat) { 281 // Supports ':' list syntax in mininotation 282 if (Array.isArray(scale)) { 283 scale = scale.flat().join(' '); 284 } 285 return pat.withHaps((haps) => { 286 haps = haps.map((hap) => { 287 let hVal = hap.value; 288 const isObject = typeof hVal === 'object'; 289 // If hVal is a pure value, place it on `n` so that we interpret it as a scale degree 290 hVal = isObject ? hVal : { n: hVal }; 291 const { note, n, value, ...otherValues } = hVal; 292 const noteOrStep = note ?? n ?? value; 293 if (noteOrStep === undefined) { 294 logger( 295 `[tonal] Invalid value format for 'scale'. Value must contain n, note, or value but received keys [${Object.keys(hVal).join(', ')}]`, 296 'error', 297 ); 298 return hap; // pass the value through unchanged 299 } 300 let scaleNote; 301 if (isNote(noteOrStep)) { 302 // Note case (quantize to scale) 303 scaleNote = _getNearestScaleNote(scale, noteOrStep); 304 hap.value = { ...otherValues, note: scaleNote }; 305 } else { 306 // Step case (convert to note in scale) 307 try { 308 const [number, offset] = _convertStepToNumberAndOffset(noteOrStep); 309 if (otherValues.anchor) { 310 scaleNote = stepInNamedScale(number, scale, otherValues.anchor); 311 } else { 312 scaleNote = scaleStep(number, scale); 313 } 314 if (offset != 0) scaleNote = Note.transpose(scaleNote, Interval.fromSemitones(offset)); 315 } catch (err) { 316 errorLogger(err, 'tonal'); 317 return; // will be removed 318 } 319 } 320 hap.value = isObject ? { ...otherValues, note: scaleNote } : scaleNote; 321 // Tag with scale for downsteam scale-aware operations 322 return hap.setContext({ ...hap.context, scale }); 323 }); 324 return removeUndefineds(haps); 325 }); 326 }, 327 true, 328 true, // preserve step count 329);