util.mjs - <short description TODO> Copyright (C) 2022 Strudel contributors - see https://codeberg.org/uzu/strudel/src/branch/main/packages/core/util.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/.
7import { logger } from './logger.mjs';
returns true if the given string is a note
10export const isNoteWithOctave = (name) => /^[a-gA-G][#bsf]*[0-9]*$/.test(name); 11export const isNote = (name) => /^[a-gA-G][#bsf]*-?[0-9]*$/.test(name); 12export const tokenizeNote = (note) => { 13 if (typeof note !== 'string') { 14 return []; 15 } 16 const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)(-?[0-9]*)$/)?.slice(1) || []; 17 if (!pc) { 18 return []; 19 } 20 return [pc, acc, oct ? Number(oct) : undefined]; 21};
turns the given note into its midi number representation
31export const noteToMidi = (note, defaultOctave = 3) => { 32 const [pc, acc, oct = defaultOctave] = tokenizeNote(note); 33 if (!pc) { 34 throw new Error('not a note: "' + note + '"'); 35 } 36 const chroma = chromas[pc.toLowerCase()]; 37 const offset = getAccidentalsOffset(acc); 38 return (Number(oct) + 1) * 12 + chroma + offset; 39}; 40export const midiToFreq = (n) => { 41 return Math.pow(2, (n - 69) / 12) * 440; 42};
44export const freqToMidi = (freq) => { 45 return (12 * Math.log(freq / 440)) / Math.LN2 + 69; 46}; 47 48export const valueToMidi = (value, fallbackValue) => { 49 if (typeof value !== 'object') { 50 throw new Error('valueToMidi: expected object value'); 51 } 52 let { freq, note } = value; 53 if (typeof freq === 'number') { 54 return freqToMidi(freq); 55 } 56 if (typeof note === 'string') { 57 return noteToMidi(note); 58 } 59 if (typeof note === 'number') { 60 return note; 61 } 62 if (!fallbackValue) { 63 throw new Error('valueToMidi: expected freq or note to be set'); 64 } 65 return fallbackValue; 66};
used to schedule external event like midi and osc out
@deprecated does not appear to be referenced or invoked anywhere in the codebase @noAutocomplete
84const pcs = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
@deprecated only used in workshop (first-notes) @noAutocomplete
modulo that works with negative numbers e.g. _mod(-1, 3) = 2. Works on numbers (rather than patterns of numbers, as @mod@ from pattern.mjs does)
96export const _mod = (n, m) => ((n % m) + m) % m;
average numbers in an array
99export const averageArray = (arr) => arr.reduce((a, b) => a + b) / arr.length;
round to nearest int, negative numbers will output a subtracted index
113export const getPlayableNoteValue = (hap) => { 114 let { value, context } = hap; 115 let note = value; 116 if (typeof note === 'object' && !Array.isArray(note)) { 117 note = note.note || note.n || note.value; 118 if (note === undefined) { 119 throw new Error(`cannot find a playable note for ${JSON.stringify(value)}`); 120 } 121 } 122 // if value is number => interpret as midi number as long as its not marked as frequency 123 if (typeof note === 'number' && context.type !== 'frequency') { 124 note = midiToFreq(hap.value); 125 } else if (typeof note === 'number' && context.type === 'frequency') { 126 note = hap.value; // legacy workaround.. will be removed in the future 127 } else if (typeof note !== 'string' || !isNote(note)) { 128 throw new Error('not a note: ' + JSON.stringify(note)); 129 } 130 return note; 131}; 132 133export const getFrequency = (hap) => { 134 let { value, context } = hap; 135 // if value is number => interpret as midi number as long as its not marked as frequency 136 if (typeof value === 'object') { 137 if (value.freq) { 138 return value.freq; 139 } 140 return getFreq(value.note ?? value.n ?? value.value); 141 } 142 if (typeof value === 'number' && context.type !== 'frequency') { 143 value = midiToFreq(hap.value); 144 } else if (typeof value === 'string' && isNote(value)) { 145 value = midiToFreq(noteToMidi(hap.value)); 146 } else if (typeof value !== 'number') { 147 throw new Error('not a note or frequency: ' + value); 148 } 149 return value; 150};
rotate array by n steps (to the left)
153export const rotate = (arr, n) => arr.slice(n).concat(arr.slice(0, n));
Removes 'None' values from given list
167export const removeUndefineds = (xs) => xs.filter((x) => x != undefined);
flattens by one level
170export const flatten = (arr) => [].concat(...arr);
172export const id = (a) => a; 173export const constant = (a, b) => a; 174 175export const listRange = (min, max) => Array.from({ length: max - min + 1 }, (_, i) => i + min); 176 177export function curry(func, overload, arity = func.length) { 178 const fn = function curried(...args) { 179 if (args.length >= arity) { 180 return func.apply(this, args); 181 } else { 182 const partial = function (...args2) { 183 return curried.apply(this, args.concat(args2)); 184 }; 185 if (overload) { 186 overload(partial, args); 187 } 188 return partial; 189 } 190 }; 191 if (overload) { 192 // overload function without args... needed for chordBass.transpose(2) 193 overload(fn, []); 194 } 195 return fn; 196} 197 198export function parseNumeral(numOrString) { 199 const asNumber = Number(numOrString); 200 if (!isNaN(asNumber)) { 201 return asNumber; 202 } 203 if (isNote(numOrString)) { 204 return noteToMidi(numOrString); 205 } 206 throw new Error(`cannot parse as numeral: "${numOrString}"`); 207} 208 209export function mapArgs(fn, mapFn) { 210 return (...args) => fn(...args.map(mapFn)); 211} 212 213export function numeralArgs(fn) { 214 return mapArgs(fn, parseNumeral); 215} 216 217export function parseFractional(numOrString) { 218 const asNumber = Number(numOrString); 219 if (!isNaN(asNumber)) { 220 return asNumber; 221 } 222 const specialValue = { 223 pi: Math.PI, 224 w: 1, 225 h: 0.5, 226 q: 0.25, 227 e: 0.125, 228 s: 0.0625, 229 t: 1 / 3, 230 f: 0.2, 231 x: 1 / 6, 232 }[numOrString]; 233 if (typeof specialValue !== 'undefined') { 234 return specialValue; 235 } 236 throw new Error(`cannot parse as fractional: "${numOrString}"`); 237} 238 239export const fractionalArgs = (fn) => mapArgs(fn, parseFractional); 240 241export const splitAt = function (index, value) { 242 return [value.slice(0, index), value.slice(index)]; 243};
Uses the function f to combine the arrays xs, ys element-wise
246export const zipWith = (f, xs, ys) => xs.map((n, i) => f(n, ys[i]));
solmization, not used yet
259const solfeggio = ['Do', 'Reb', 'Re', 'Mib', 'Mi', 'Fa', 'Solb', 'Sol', 'Lab', 'La', 'Sib', 'Si']; /*solffegio notes*/ 260const indian = [ 261 'Sa', 262 'Re', 263 'Ga', 264 'Ma', 265 'Pa', 266 'Dha', 267 'Ni', 268]; /*indian musical notes, seems like they do not use flats or sharps*/ 269const german = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Hb', 'H']; /*german & dutch musical notes*/ 270const byzantine = [ 271 'Ni', 272 'Pab', 273 'Pa', 274 'Voub', 275 'Vou', 276 'Ga', 277 'Dib', 278 'Di', 279 'Keb', 280 'Ke', 281 'Zob', 282 'Zo', 283]; /*byzantine musical notes*/ 284const japanese = [ 285 'I', 286 'Ro', 287 'Ha', 288 'Ni', 289 'Ho', 290 'He', 291 'To', 292]; /*traditional japanese musical notes, seems like they do not use falts or sharps*/
294const english = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B']; 295 296export const sol2note = (n, notation = 'letters') => { 297 const pc = 298 notation === 'solfeggio' 299 ? solfeggio /*check if its is any of the following*/ 300 : notation === 'indian' 301 ? indian 302 : notation === 'german' 303 ? german 304 : notation === 'byzantine' 305 ? byzantine 306 : notation === 'japanese' 307 ? japanese 308 : english; /*if not use standard version*/ 309 const note = pc[n % 12]; /*calculating the midi value to the note*/ 310 const oct = Math.floor(n / 12) - 1; 311 return note + oct; 312};
Remove duplicates from list
Remove duplicates from list, sorting in the process. Mutates argument!
rational version
code hashing helpers
340export function unicodeToBase64(text) { 341 const utf8Bytes = new TextEncoder().encode(text); 342 let binaryString = ''; 343 const chunkSize = 0x8000; 344 for (let i = 0; i < utf8Bytes.length; i += chunkSize) { 345 const chunk = utf8Bytes.subarray(i, i + chunkSize); 346 binaryString += String.fromCharCode.apply(null, chunk); 347 } 348 return btoa(binaryString); 349}
351export function base64ToUnicode(base64String) { 352 const utf8Bytes = new Uint8Array( 353 atob(base64String) 354 .split('') 355 .map((char) => char.charCodeAt(0)), 356 ); 357 const decodedText = new TextDecoder().decode(utf8Bytes); 358 return decodedText; 359} 360 361export function code2hash(code) { 362 return encodeURIComponent(unicodeToBase64(code)); 363 //return '#' + encodeURIComponent(btoa(code)); 364} 365 366export function hash2code(hash) { 367 return base64ToUnicode(decodeURIComponent(hash)); 368 //return atob(decodeURIComponent(codeParam || '')); 369} 370 371export function objectMap(obj, fn) { 372 if (Array.isArray(obj)) { 373 return obj.map(fn); 374 } 375 return Object.fromEntries(Object.entries(obj).map(([k, v], i) => [k, fn(v, k, i)])); 376} 377export function cycleToSeconds(cycle, cps) { 378 return cycle / cps; 379}
utility for averaging two clocks together to account for drift
382export class ClockCollator { 383 constructor({ 384 getTargetClockTime = getUnixTimeSeconds, 385 weight = 16, 386 offsetDelta = 0.005, 387 checkAfterTime = 2, 388 resetAfterTime = 8, 389 }) { 390 this.offsetTime; 391 this.timeAtPrevOffsetSample; 392 this.prevOffsetTimes = []; 393 this.getTargetClockTime = getTargetClockTime; 394 this.weight = weight; 395 this.offsetDelta = offsetDelta; 396 this.checkAfterTime = checkAfterTime; 397 this.resetAfterTime = resetAfterTime; 398 this.reset = () => { 399 this.prevOffsetTimes = []; 400 this.offsetTime = null; 401 this.timeAtPrevOffsetSample = null; 402 }; 403 } 404 calculateOffset(currentTime) { 405 const targetClockTime = this.getTargetClockTime(); 406 const diffBetweenTimeSamples = targetClockTime - this.timeAtPrevOffsetSample; 407 const newOffsetTime = targetClockTime - currentTime; 408 // recalcuate the diff from scratch if the clock has been paused for some time. 409 if (diffBetweenTimeSamples > this.resetAfterTime) { 410 this.reset(); 411 } 412 413 if (this.offsetTime == null) { 414 this.offsetTime = newOffsetTime; 415 } 416 this.prevOffsetTimes.push(newOffsetTime); 417 if (this.prevOffsetTimes.length > this.weight) { 418 this.prevOffsetTimes.shift(); 419 }
after X time has passed, the average of the previous weight offset times is calculated and used as a stable reference for calculating the timestamp
423 if (this.timeAtPrevOffsetSample == null || diffBetweenTimeSamples > this.checkAfterTime) { 424 this.timeAtPrevOffsetSample = targetClockTime; 425 const rollingOffsetTime = averageArray(this.prevOffsetTimes); 426 //when the clock offsets surpass the delta, set the new reference time 427 if (Math.abs(rollingOffsetTime - this.offsetTime) > this.offsetDelta) { 428 this.offsetTime = rollingOffsetTime; 429 } 430 }
432 return this.offsetTime; 433 } 434 435 calculateTimestamp(currentTime, targetTime) { 436 return this.calculateOffset(currentTime) + targetTime; 437 } 438} 439 440export function getPerformanceTimeSeconds() { 441 return performance.now() * 0.001; 442} 443 444function getUnixTimeSeconds() { 445 return Date.now() * 0.001; 446} 447 448export const keyAlias = new Map([ 449 ['control', 'Control'], 450 ['ctrl', 'Control'], 451 ['alt', 'Alt'], 452 ['shift', 'Shift'], 453 ['down', 'ArrowDown'], 454 ['up', 'ArrowUp'], 455 ['left', 'ArrowLeft'], 456 ['right', 'ArrowRight'], 457]); 458let keyState; 459 460export function getCurrentKeyboardState() { 461 if (keyState == null) { 462 if (typeof window === 'undefined') { 463 return; 464 } 465 keyState = {}; 466 // Listen for the keydown event to mark the key as pressed 467 window.addEventListener('keydown', (event) => { 468 keyState[event.key] = true; // Mark the key as pressed 469 });
Listen for the keyup event to mark the key as released
Floating point versions, see Fraction for rational versions // greatest common divisor export const gcd = function (x, y, ...z) { if (!y && z.length > 0) { return gcd(x, ...z); } if (!y) { return x; } return gcd(y, x % y, ...z); };
// lowest common multiple export const lcm = function (x, y, ...z) { if (z.length == 0) { return (x * y) / gcd(x, y); } return lcm((x * y) / gcd(x, y), ...z); };
Takes values -- typically derived from events, i.e. haps -- and renders them
into a readable format