util.mjsannotatedutil.mjssource508 lines · 14.8 KB · raw

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};
23const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
24const accs = { '#': 1, b: -1, s: 1, f: -1 };
25
26export const getAccidentalsOffset = (accidentals) => {
27  return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0;
28};

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

69export const getEventOffsetMs = (targetTimeSeconds, currentTimeSeconds) => {
70  return (targetTimeSeconds - currentTimeSeconds) * 1000;
71};

@deprecated does not appear to be referenced or invoked anywhere in the codebase @noAutocomplete

77export const getFreq = (noteOrMidi) => {
78  if (typeof noteOrMidi === 'number') {
79    return midiToFreq(noteOrMidi);
80  }
81  return midiToFreq(noteToMidi(noteOrMidi));
82};
84const pcs = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];

@deprecated only used in workshop (first-notes) @noAutocomplete

89export const midi2note = (n) => {
90  const oct = Math.floor(n / 12) - 1;
91  const pc = pcs[n % 12];
92  return pc + oct;
93};

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;
101export function nanFallback(value, fallback = 0) {
102  if (isNaN(Number(value))) {
103    logger(`"${value}" is not a number, falling back to ${fallback}`, 'warning');
104    return fallback;
105  }
106  return value;
107}

round to nearest int, negative numbers will output a subtracted index

109export const getSoundIndex = (n, numSounds) => {
110  return _mod(Math.round(nanFallback(n ?? 0, 0)), numSounds);
111};
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));
155export const pipe = (...funcs) => {
156  return funcs.reduce(
157    (f, g) =>
158      (...args) =>
159        f(g(...args)),
160    (x) => x,
161  );
162};
163
164export const compose = (...funcs) => pipe(...funcs.reverse());

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]));
248export const pairs = function (xs) {
249  const result = [];
250  for (let i = 0; i < xs.length - 1; ++i) {
251    result.push([xs[i], xs[i + 1]]);
252  }
253  return result;
254};
255
256export const clamp = (num, min, max) => Math.min(Math.max(num, min), max);

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

315export function uniq(a) {
316  var seen = {};
317  return a.filter(function (item) {
318    return seen.hasOwn(item) ? false : (seen[item] = true);
319  });
320}

Remove duplicates from list, sorting in the process. Mutates argument!

323export function uniqsort(a) {
324  return a.sort().filter(function (item, pos, ary) {
325    return !pos || item != ary[pos - 1];
326  });
327}

rational version

330export function uniqsortr(a) {
331  return a
332    .sort((x, y) => x.compare(y))
333    .filter(function (item, pos, ary) {
334      return !pos || item.ne(ary[pos - 1]);
335    });
336}

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

472    window.addEventListener('keyup', (event) => {
473      keyState[event.key] = false; // Mark the key as released
474    });
475  }
477  return { ...keyState }; // Return a shallow copy of the key state object
478}

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

502export function stringifyValues(value, compact = false) {
503  return typeof value === 'object'
504    ? compact
505      ? JSON.stringify(value).slice(1, -1).replaceAll('"', '').replaceAll(',', ' ')
506      : JSON.stringify(value)
507    : value;
508}