tonal.mjs - <short description TODO> Copyright (C) 2022 Strudel contributors - see https://codeberg.org/uzu/strudel/src/branch/main/packages/tonal/tonal.mjs This 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/.
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}
transpose note inside scale by offset steps 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}
Pattern.prototype._transpose = function (intervalOrSemitones: string | number) {
Change the pitch of each value by the given amount. Expects numbers or note strings as values. The amount can be given as a number of semitones or as a string in interval short notation. If you don't care about enharmonic correctness, just use numbers. Otherwise, pass the interval of the form: ST where S is the degree number and T the type of interval with
- M = major
- m = minor
- P = perfect
- A = augmented
- d = diminished
Examples intervals:
- 1P = unison
- 3M = major third
- 3m = minor third
- 4P = perfect fourth
- 4A = augmented fourth
- 5P = perfect fifth
- 5d = diminished fifth
@tags tonal @param {string | number} amount Either number of semitones or interval string. @returns Pattern @memberof Pattern @name transpose @synonyms trans @example "c2 c3".fast(2).transpose("<0 -2 5 3>".slow(2)).note() @example "c2 c3".fast(2).transpose("<1P -2M 4P 3m>".slow(2)).note()
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});
example: transpose(3).late(0.2) will be equivalent to compose(transpose(3), late(0.2))
e.g. stack(c3).superimpose(transpose(slowcat(7, 5))) or
or even stack(c3).superimpose(transpose.slowcat(7, 5)) or
Transposes notes inside the scale by the number of steps. Expected to be called on a Pattern which already has a {@link Pattern#scale}
@memberof Pattern @name scaleTranspose @tags tonal @param {offset} offset number of steps inside the scale @returns Pattern @synonyms scaleTrans, strans @example "-8 [2,4,6]" .scale('C4 bebop major') .scaleTranspose("<0 -1 -2 -3 -4 -5 -6 -4>") .note()
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);
Converts a step value, which is a number optionally decorated with sharps and flats,
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}
218let scaleToMidisAndNotes = {};
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}
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 to produce notes outside of the scale.
Also sets scale for other scale operations, like {@link Pattern#scaleTranspose}.
A scale consists of a root note (e.g. c4, c, f#, bb4) followed by semicolon (':') and then a scale type.
The scale name must be written without spaces (because it would be interpreted as a multi-step pattern otherwise). If your scale name includes spaces, replace them with colons.
The root note defaults to octave 3, if no octave number is given.
@name scale @tags tonal @param {string} scale Name of scale @returns Pattern @example n("0 2 4 6 4 2").scale("C:major") @example n("[0,7] 4 [2,7] 4") .scale("C:<major minor>/2") .s("piano") @example n(rand.range(0,12).segment(8)) .scale("C:ritusen") .s("piano") @example n("<[0,7b] [-4# -4] [-2,7##] 4 [0,7] [-4# -4b] [-2,7###] 4b>4") .scale("C:<major minor>/2") .s("piano") @example note("C116").transpose(irand(36)).scale('Cb2 major').scaleTranspose(3) @example n("[0 0] [1 2] [3 4] [5 6]").scale("C:major:blues")
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);