@tremolo-ui/functions 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,35 +6,73 @@ function clamp(value, min, max) {
6
6
  return Math.max(min, Math.min(value, max));
7
7
  }
8
8
  /**
9
- * Normalize the value from 0 to 1
9
+ * Normalize the value from 0 to 1, spreading the range evenly.
10
+ *
11
+ * This is the linear mapping and takes no curve of its own; a `Scale` builds
12
+ * whatever curve it needs on top of it.
10
13
  */
11
- function normalizeValue(rawValue, min, max, skew = 1) {
14
+ function normalizeValue(value, min, max) {
12
15
  if (min >= max) throw new RangeError("requirements: min < max");
13
- const v = clamp((rawValue - min) / (max - min), 0, 1);
14
- return Math.pow(v, skew);
16
+ return clamp((value - min) / (max - min), 0, 1);
15
17
  }
16
18
  /**
17
- * Convert normalized values back to raw values.
19
+ * Convert normalized values back to raw values, spreading the range evenly.
20
+ *
21
+ * The inverse of {@link normalizeValue}.
18
22
  */
19
- function rawValue(normalizedValue, min, max, skew = 1) {
23
+ function rawValue(normalizedValue, min, max) {
20
24
  if (min >= max) throw new RangeError("requirements: min < max");
21
- return min + (skew == 1 ? clamp(normalizedValue, 0, 1) : Math.exp(Math.log(clamp(normalizedValue, 0, 1)) / skew)) * (max - min);
22
- }
23
- function skewWithCenterValue(centerValue, min, max) {
24
- if (!(min <= centerValue && centerValue <= max)) throw new RangeError("requirements: min <= centerValue <= max");
25
- return Math.log(.5) / Math.log((centerValue - min) / (max - min));
25
+ return min + clamp(normalizedValue, 0, 1) * (max - min);
26
26
  }
27
+ /**
28
+ * Put a value on the grid the caller asked for, rounding a half step upwards.
29
+ *
30
+ * The rounding is done on the quotient rather than by comparing the distance
31
+ * to the two neighbours, because both of those carry error of their own. The
32
+ * quotient is cleared of its artefact first: `0.15 / 0.1` is 1.4999999999999998,
33
+ * and a value sitting exactly on a half step would otherwise fall to whichever
34
+ * side the last bit happened to land on — 0.25 rounded up while 0.15 and 0.35
35
+ * rounded down.
36
+ */
27
37
  function stepValue(value, step) {
28
38
  if (step <= 0) throw new RangeError("requirements: step > 0");
29
- const quotient = Math.floor(value / step);
30
- const decimalDigits = decimalPart(step)?.length;
31
- const v = toFixed(quotient * step, decimalDigits);
32
- const next = toFixed((quotient + 1) * step, decimalDigits);
33
- return Math.abs(value - v) < Math.abs(value - next) ? v : next;
39
+ const stepped = toPrecision(Math.round(toPrecision(value / step)) * step);
40
+ return stepped === 0 ? 0 : stepped;
34
41
  }
35
42
  function toFixed(x, fractionDigits) {
36
43
  return Number(x.toFixed(fractionDigits));
37
44
  }
45
+ /**
46
+ * The significant decimal digits a double actually carries. A double holds a
47
+ * little under 16, so anything past this is the binary representation showing
48
+ * through rather than information.
49
+ */
50
+ const SIGNIFICANT_DIGITS = 15;
51
+ /**
52
+ * Drop the binary artefact from a computed value.
53
+ *
54
+ * Arithmetic on doubles leaves debris in the last couple of digits, and it
55
+ * accumulates: adding 0.1 to 5 twelve times gives 5.699999999999998 rather
56
+ * than 5.7, and the display of a control shows exactly that. Rounding to the
57
+ * digits a double can carry removes it, and adds nothing back — the value was
58
+ * already the result of a calculation whose own error is that size or larger.
59
+ *
60
+ * This is not the same as rounding to a `step`. {@link stepValue} puts a value
61
+ * on a grid the caller asked for and is a decision about the value; this only
62
+ * removes what was never in the value to begin with.
63
+ *
64
+ * @param significantDigits how many digits to keep. The default is the only
65
+ * one that is purely artefact removal; a smaller number starts discarding real
66
+ * precision.
67
+ *
68
+ * @example
69
+ * toPrecision(5.1 + 0.1) // 5.2, rather than 5.199999999999999
70
+ */
71
+ function toPrecision(x, significantDigits = 15) {
72
+ if (x === 0 || !Number.isFinite(x)) return x;
73
+ const rounded = Number(x.toPrecision(significantDigits));
74
+ return Number.isFinite(rounded) ? rounded : x;
75
+ }
38
76
  function integerPart(x) {
39
77
  if (Number.isNaN(x)) return;
40
78
  return String(x).split(".")[0];
@@ -58,25 +96,288 @@ function gainToDb(gain) {
58
96
  return 20 * (Math.log(gain) / Math.LN10);
59
97
  }
60
98
  //#endregion
61
- //#region src/util.ts
62
- function styleHelper(value, op, influencer) {
63
- if (op && influencer) if (typeof value == "number") {
64
- if (op == "+") return `${value + influencer}px`;
65
- if (op == "-") return `${value - influencer}px`;
66
- if (op == "*") return `${value * influencer}px`;
67
- if (op == "/") return `${value / influencer}px`;
68
- } else return `calc(${value}px ${op} ${influencer})`;
69
- else if (typeof value == "number") return `${value}px`;
70
- else return value;
71
- }
72
- function isEmpty(obj) {
73
- return Object.keys(obj).length == 0;
99
+ //#region src/modifiers.ts
100
+ /**
101
+ * Checked in this order, and the first one that is both held and configured
102
+ * wins. Fixing an order is what keeps two modifiers held at once from
103
+ * behaving differently between browsers.
104
+ */
105
+ const MODIFIER_ORDER = [
106
+ "meta",
107
+ "ctrl",
108
+ "alt",
109
+ "shift"
110
+ ];
111
+ const MODIFIER_FLAG = {
112
+ meta: "metaKey",
113
+ ctrl: "ctrlKey",
114
+ alt: "altKey",
115
+ shift: "shiftKey"
116
+ };
117
+ /**
118
+ * A map is the only form with a `default` key, which is what tells it apart
119
+ * from a bare setting. Tuples are arrays, so they never match.
120
+ */
121
+ function isModifierMap(value) {
122
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "default" in value;
123
+ }
124
+ /**
125
+ * Pick the setting that applies, given the modifier keys being held.
126
+ *
127
+ * @example
128
+ * selectModifier({ default: 1, shift: 0.1 }, event)
129
+ */
130
+ function selectModifier(options, modifiers) {
131
+ if (!isModifierMap(options)) return {
132
+ value: options,
133
+ modifier: null
134
+ };
135
+ if (modifiers) for (const modifier of MODIFIER_ORDER) {
136
+ const value = options[modifier];
137
+ if (value !== void 0 && modifiers[MODIFIER_FLAG[modifier]]) return {
138
+ value,
139
+ modifier
140
+ };
141
+ }
142
+ return {
143
+ value: options.default,
144
+ modifier: null
145
+ };
146
+ }
147
+ /**
148
+ * Turn every entry of a setting into another kind of setting, keeping which
149
+ * modifier each belongs to.
150
+ *
151
+ * A drag sensitivity is a number and a keyboard amount is a tuple, but the two
152
+ * describe the same thing from the caller's side. This carries one over to the
153
+ * other so that a component can hand a sensitivity to {@link applyDelta}
154
+ * without unpicking the modifier map itself — which matters, since naming a
155
+ * modifier is also what takes `step` out of the pipeline.
156
+ *
157
+ * @example
158
+ * mapModifier({ default: 1, shift: 0.1 }, (f) => ['raw', step * f])
159
+ * // { default: ['raw', 1], shift: ['raw', 0.1] }
160
+ */
161
+ function mapModifier(options, fn) {
162
+ if (!isModifierMap(options)) return fn(options);
163
+ const mapped = { default: fn(options.default) };
164
+ for (const modifier of MODIFIER_ORDER) {
165
+ const value = options[modifier];
166
+ if (value !== void 0) mapped[modifier] = fn(value);
167
+ }
168
+ return mapped;
74
169
  }
170
+ /**
171
+ * Pick the amount that applies, given the modifier keys being held.
172
+ *
173
+ * @example
174
+ * selectInputEvent({ default: ['raw', 1], shift: ['raw', 0.1] }, event)
175
+ */
176
+ function selectInputEvent(options, modifiers) {
177
+ const { value, modifier } = selectModifier(options, modifiers);
178
+ return {
179
+ option: value,
180
+ modifier
181
+ };
182
+ }
183
+ //#endregion
184
+ //#region src/scales.ts
185
+ function assertRange(min, max) {
186
+ if (min >= max) throw new RangeError("requirements: min < max");
187
+ }
188
+ function assertPositiveFinite(value, name) {
189
+ if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name}: requirements: finite and greater than 0`);
190
+ }
191
+ /**
192
+ * Equal travel gives an equal change in value.
193
+ *
194
+ * The right default for anything already linear in perception: dB values,
195
+ * pan, percentages, MIDI note numbers, semitones.
196
+ */
197
+ const linearScale = {
198
+ normalize: (value, min, max) => normalizeValue(value, min, max),
199
+ denormalize: (position, min, max) => rawValue(position, min, max)
200
+ };
201
+ /**
202
+ * The power law of JUCE's `NormalisableRange::skew`, applied to `value - min`.
203
+ *
204
+ * Use it when the value has to agree with a JUCE or iPlug2 parameter — a
205
+ * plugin UI in a WebView, say, where the knob must sit exactly where the
206
+ * host's automation curve puts it. {@link skewWithCenterValue} gives the
207
+ * factor that places a chosen value at the middle of the travel.
208
+ *
209
+ * `skew < 1` gives the lower end more travel, `skew > 1` the upper end.
210
+ *
211
+ * For new designs prefer {@link exponentialScale} or {@link curveScale}: the
212
+ * slope of this curve is either zero or infinite at `min`, so the bottom of
213
+ * the range is a dead zone or jumps.
214
+ *
215
+ * @param skew the JUCE skew factor
216
+ */
217
+ function skewScale(skew) {
218
+ assertPositiveFinite(skew, "skewScale");
219
+ return {
220
+ normalize: (value, min, max) => Math.pow(normalizeValue(value, min, max), skew),
221
+ denormalize: (position, min, max) => rawValue(skew === 1 ? position : Math.exp(Math.log(clamp(position, 0, 1)) / skew), min, max)
222
+ };
223
+ }
224
+ /**
225
+ * The skew factor for {@link skewScale} that puts `centerValue` at the middle
226
+ * of the travel — JUCE's `NormalisableRange::setSkewForCentre`.
227
+ */
228
+ function skewWithCenterValue(centerValue, min, max) {
229
+ assertRange(min, max);
230
+ if (!(min < centerValue && centerValue < max)) throw new RangeError("requirements: min < centerValue < max");
231
+ return Math.log(.5) / Math.log((centerValue - min) / (max - min));
232
+ }
233
+ /**
234
+ * Equal travel gives an equal *ratio*, so an octave — or a percentage — takes
235
+ * the same distance wherever it falls.
236
+ *
237
+ * The scale for frequency (a filter cutoff over 20-20000 Hz), free running
238
+ * rates, and delay times.
239
+ *
240
+ * Requires `min` and `max` to be non-zero and of the same sign, since no
241
+ * ratio reaches zero or crosses it. Use {@link curveScale} for a range that
242
+ * starts at 0.
243
+ */
244
+ const exponentialScale = {
245
+ normalize: (value, min, max) => {
246
+ assertExponentialRange(min, max);
247
+ const start = Math.log(Math.abs(min));
248
+ const end = Math.log(Math.abs(max));
249
+ return clamp((Math.log(Math.abs(clamp(value, min, max))) - start) / (end - start), 0, 1);
250
+ },
251
+ denormalize: (position, min, max) => {
252
+ assertExponentialRange(min, max);
253
+ const start = Math.log(Math.abs(min));
254
+ const magnitude = Math.exp(start + (Math.log(Math.abs(max)) - start) * clamp(position, 0, 1));
255
+ return Math.sign(min) * magnitude;
256
+ }
257
+ };
258
+ function assertExponentialRange(min, max) {
259
+ assertRange(min, max);
260
+ if (min === 0 || max === 0 || Math.sign(min) !== Math.sign(max)) throw new RangeError("exponentialScale: requirements: min and max are non-zero and have the same sign");
261
+ }
262
+ /**
263
+ * An exponential bend that still passes exactly through `min` and `max`, so
264
+ * unlike {@link exponentialScale} it works on a range that starts at 0 or
265
+ * crosses it, and unlike {@link skewScale} its slope is neither zero nor
266
+ * infinite at either end.
267
+ *
268
+ * The general purpose taper, and the same family as the curve of an envelope
269
+ * segment (SuperCollider's `CurveWarp`).
270
+ *
271
+ * - `curve > 0` gives the lower end more travel — envelope times from 0 ms,
272
+ * delay times, anything that wants fine control near the bottom
273
+ * - `curve < 0` gives the upper end more travel — a volume fader over
274
+ * -60..+6 dB that should be precise around 0 dB
275
+ * - near 0 it is indistinguishable from {@link linearScale}, and is treated
276
+ * as linear to avoid dividing by zero
277
+ *
278
+ * {@link curveWithCenterValue} gives the curve that places a chosen value at
279
+ * the middle of the travel.
280
+ *
281
+ * @param curve how hard the curve bends, and in which direction
282
+ */
283
+ function curveScale(curve) {
284
+ if (!Number.isFinite(curve) || Math.abs(curve) > 32) throw new RangeError("curveScale: requirements: finite curve from -32 to 32");
285
+ if (Math.abs(curve) < .001) return linearScale;
286
+ return {
287
+ normalize: (value, min, max) => {
288
+ assertRange(min, max);
289
+ const proportion = clamp((value - min) / (max - min), 0, 1);
290
+ if (proportion === 0 || proportion === 1) return proportion;
291
+ if (curve > 0) return 1 + Math.log(proportion + (1 - proportion) * Math.exp(-curve)) / curve;
292
+ return Math.log1p(proportion * Math.expm1(curve)) / curve;
293
+ },
294
+ denormalize: (position, min, max) => {
295
+ assertRange(min, max);
296
+ const p = clamp(position, 0, 1);
297
+ if (p === 0) return min;
298
+ if (p === 1) return max;
299
+ const proportion = curve > 0 ? Math.exp(curve * (p - 1)) * (1 - Math.exp(-curve * p)) / (1 - Math.exp(-curve)) : Math.expm1(curve * p) / Math.expm1(curve);
300
+ return min + (max - min) * proportion;
301
+ }
302
+ };
303
+ }
304
+ /**
305
+ * {@link skewScale} mirrored about the middle of the range, so both halves
306
+ * bend the same way — JUCE's `symmetricSkew`.
307
+ *
308
+ * For a bipolar control whose centre matters: detune over -100..+100 cents,
309
+ * or a bipolar filter envelope amount, where the fine adjustment is around 0
310
+ * rather than at either end.
311
+ *
312
+ * `skew < 1` gives the middle more travel, `skew > 1` the two ends.
313
+ *
314
+ * @param skew the JUCE skew factor
315
+ */
316
+ function symmetricSkewScale(skew) {
317
+ assertPositiveFinite(skew, "symmetricSkewScale");
318
+ return {
319
+ normalize: (value, min, max) => {
320
+ assertRange(min, max);
321
+ const proportion = clamp((value - min) / (max - min), 0, 1);
322
+ if (skew === 1) return proportion;
323
+ const distanceFromMiddle = 2 * proportion - 1;
324
+ return (1 + Math.pow(Math.abs(distanceFromMiddle), skew) * Math.sign(distanceFromMiddle)) / 2;
325
+ },
326
+ denormalize: (position, min, max) => {
327
+ assertRange(min, max);
328
+ let distanceFromMiddle = 2 * clamp(position, 0, 1) - 1;
329
+ if (skew !== 1 && distanceFromMiddle !== 0) distanceFromMiddle = Math.pow(Math.abs(distanceFromMiddle), 1 / skew) * Math.sign(distanceFromMiddle);
330
+ return min + (max - min) / 2 * (1 + distanceFromMiddle);
331
+ }
332
+ };
333
+ }
334
+ /**
335
+ * The curve for {@link curveScale} that puts `centerValue` at the middle of
336
+ * the travel — the counterpart of {@link skewWithCenterValue}.
337
+ */
338
+ function curveWithCenterValue(centerValue, min, max) {
339
+ assertRange(min, max);
340
+ if (!(min < centerValue && centerValue < max)) throw new RangeError("requirements: min < centerValue < max");
341
+ const proportion = (centerValue - min) / (max - min);
342
+ return 2 * Math.log(1 / proportion - 1);
343
+ }
344
+ /**
345
+ * Move a value by an amount of input, as reported by a wheel or an arrow key.
346
+ *
347
+ * The pipeline matches `createDragValue` of `@tremolo-ui/dom`: scale, then
348
+ * step, then clamp. Which key or which sign of `deltaY` counts as which
349
+ * direction is left to the caller, since it differs per component.
350
+ *
351
+ * @param direction which way, and how many times, to apply the option. The
352
+ * size of one step is `option[1]`, so this is normally `1` or `-1`.
353
+ *
354
+ * @param modifiers the event, for `options` that name a modifier key. See
355
+ * {@link selectInputEvent}.
356
+ *
357
+ * @example
358
+ * // ArrowDown on a slider whose keyboard option is ['raw', 1]
359
+ * applyDelta(value, -1, keyboard, { min, max, step, scale })
360
+ *
361
+ * @example
362
+ * // Shift+ArrowDown, where `keyboard` is { default: …, shift: ['raw', 0.1] }
363
+ * applyDelta(value, -1, keyboard, range, event)
364
+ */
365
+ function applyDelta(value, direction, options, { min, max, step, scale = linearScale }, modifiers) {
366
+ assertRange(min, max);
367
+ if (step !== void 0) assertPositiveFinite(step, "applyDelta step");
368
+ const { option: [mode, amount], modifier } = selectInputEvent(options, modifiers);
369
+ const x = direction * amount;
370
+ const next = mode === "normalized" ? scale.denormalize(scale.normalize(value, min, max) + x, min, max) : value + x;
371
+ const quantum = modifier === null ? step : void 0;
372
+ return clamp(toPrecision(quantum !== void 0 ? stepValue(next, quantum) : next), min, max);
373
+ }
374
+ //#endregion
375
+ //#region src/util.ts
75
376
  function mod(n, m) {
76
377
  return (n % m + m) % m;
77
378
  }
78
379
  function xor(a = false, b = false) {
79
- return (a || b) && a != b;
380
+ return (a || b) && a !== b;
80
381
  }
81
382
  //#endregion
82
383
  //#region src/midi.ts
@@ -103,14 +404,19 @@ const noteKeys = [
103
404
  "A#",
104
405
  "B"
105
406
  ];
407
+ function assertSafeInteger(value, name) {
408
+ if (!Number.isSafeInteger(value)) throw new RangeError(`${name}: requirements: a safe integer`);
409
+ }
106
410
  function parseNoteName(noteName) {
107
411
  const m = noteName.match(/^([a-g])(#{0,2}|b{0,2})(-?\d+)$/i);
108
412
  if (!m) throw new Error("Invalid note name");
109
413
  const [, letter, accidental, octave] = m;
414
+ const parsedOctave = Number(octave);
415
+ assertSafeInteger(parsedOctave, "octave");
110
416
  return {
111
417
  letter: letter.toLocaleUpperCase(),
112
418
  accidental,
113
- octave: Number(octave)
419
+ octave: parsedOctave
114
420
  };
115
421
  }
116
422
  /**
@@ -119,8 +425,10 @@ function parseNoteName(noteName) {
119
425
  function noteNumber(noteName) {
120
426
  const { letter, accidental, octave } = parseNoteName(noteName);
121
427
  const noteIndex = noteKeys.indexOf(letter.toLocaleUpperCase());
122
- const accidentalValue = (accidental[0] == "b" ? -1 : 1) * accidental.length;
123
- return noteIndex + 12 * (Number(octave) + 1) + accidentalValue;
428
+ const accidentalValue = (accidental[0] === "b" ? -1 : 1) * accidental.length;
429
+ const result = noteIndex + 12 * (octave + 1) + accidentalValue;
430
+ assertSafeInteger(result, "note number");
431
+ return result;
124
432
  }
125
433
  /**
126
434
  * Convert noteNumber to noteName
@@ -129,6 +437,7 @@ function noteNumber(noteName) {
129
437
  * @param noteNumber noteNumber
130
438
  */
131
439
  function noteName(noteNumber) {
440
+ assertSafeInteger(noteNumber, "note number");
132
441
  const noteIndex = mod(noteNumber, 12);
133
442
  const octave = Math.floor(noteNumber / 12) - 1;
134
443
  return `${noteKeys[noteIndex]}${octave}`;
@@ -137,14 +446,16 @@ function noteName(noteNumber) {
137
446
  * Convert noteNumber to noteKey
138
447
  */
139
448
  function noteKey(noteNumber) {
449
+ assertSafeInteger(noteNumber, "note number");
140
450
  return noteKeys[mod(noteNumber, 12)];
141
451
  }
142
452
  /**
143
453
  * @param note noteNumber: 0 ~ 127 or noteName e.g. 'C3'
144
454
  */
145
455
  function isWhiteKey(note) {
146
- const n = typeof note == "string" ? noteNumber(note) : note;
147
- return mod(n, 12) == 0 || mod(n, 12) == 2 || mod(n, 12) == 4 || mod(n, 12) == 5 || mod(n, 12) == 7 || mod(n, 12) == 9 || mod(n, 12) == 11;
456
+ const n = typeof note === "string" ? noteNumber(note) : note;
457
+ assertSafeInteger(n, "note number");
458
+ return mod(n, 12) === 0 || mod(n, 12) === 2 || mod(n, 12) === 4 || mod(n, 12) === 5 || mod(n, 12) === 7 || mod(n, 12) === 9 || mod(n, 12) === 11;
148
459
  }
149
460
  /**
150
461
  * @param note noteNumber: 0 ~ 127 or noteName e.g. 'C3'
@@ -159,10 +470,428 @@ function isBlackKey(note) {
159
470
  * @returns frequency [Hz]
160
471
  */
161
472
  function noteToFrequency(note, detune = 0, a4 = 440) {
162
- const n = typeof note == "string" ? noteNumber(note) : note;
473
+ const n = typeof note === "string" ? noteNumber(note) : note;
474
+ assertSafeInteger(n, "note number");
163
475
  return a4 / 32 * 2 ** ((n - 9 + detune / 100) / 12);
164
476
  }
477
+ /**
478
+ * Semitones above the root, for each supported scale.
479
+ *
480
+ * Every entry starts at 0 and stays inside one octave, so a scale is a set of
481
+ * pitch classes rather than a set of notes: {@link inScale} compares against
482
+ * it with the octave taken out.
483
+ *
484
+ * `ionian` and `aeolian` are the same sets as `major` and `naturalMinor`; both
485
+ * spellings are here because both are what someone reaches for depending on
486
+ * whether they are thinking in keys or in modes.
487
+ */
488
+ const scaleIntervals = {
489
+ major: [
490
+ 0,
491
+ 2,
492
+ 4,
493
+ 5,
494
+ 7,
495
+ 9,
496
+ 11
497
+ ],
498
+ naturalMinor: [
499
+ 0,
500
+ 2,
501
+ 3,
502
+ 5,
503
+ 7,
504
+ 8,
505
+ 10
506
+ ],
507
+ harmonicMinor: [
508
+ 0,
509
+ 2,
510
+ 3,
511
+ 5,
512
+ 7,
513
+ 8,
514
+ 11
515
+ ],
516
+ melodicMinor: [
517
+ 0,
518
+ 2,
519
+ 3,
520
+ 5,
521
+ 7,
522
+ 9,
523
+ 11
524
+ ],
525
+ ionian: [
526
+ 0,
527
+ 2,
528
+ 4,
529
+ 5,
530
+ 7,
531
+ 9,
532
+ 11
533
+ ],
534
+ dorian: [
535
+ 0,
536
+ 2,
537
+ 3,
538
+ 5,
539
+ 7,
540
+ 9,
541
+ 10
542
+ ],
543
+ phrygian: [
544
+ 0,
545
+ 1,
546
+ 3,
547
+ 5,
548
+ 7,
549
+ 8,
550
+ 10
551
+ ],
552
+ lydian: [
553
+ 0,
554
+ 2,
555
+ 4,
556
+ 6,
557
+ 7,
558
+ 9,
559
+ 11
560
+ ],
561
+ mixolydian: [
562
+ 0,
563
+ 2,
564
+ 4,
565
+ 5,
566
+ 7,
567
+ 9,
568
+ 10
569
+ ],
570
+ aeolian: [
571
+ 0,
572
+ 2,
573
+ 3,
574
+ 5,
575
+ 7,
576
+ 8,
577
+ 10
578
+ ],
579
+ locrian: [
580
+ 0,
581
+ 1,
582
+ 3,
583
+ 5,
584
+ 6,
585
+ 8,
586
+ 10
587
+ ],
588
+ majorPentatonic: [
589
+ 0,
590
+ 2,
591
+ 4,
592
+ 7,
593
+ 9
594
+ ],
595
+ minorPentatonic: [
596
+ 0,
597
+ 3,
598
+ 5,
599
+ 7,
600
+ 10
601
+ ],
602
+ blues: [
603
+ 0,
604
+ 3,
605
+ 5,
606
+ 6,
607
+ 7,
608
+ 10
609
+ ],
610
+ wholeTone: [
611
+ 0,
612
+ 2,
613
+ 4,
614
+ 6,
615
+ 8,
616
+ 10
617
+ ],
618
+ chromatic: [
619
+ 0,
620
+ 1,
621
+ 2,
622
+ 3,
623
+ 4,
624
+ 5,
625
+ 6,
626
+ 7,
627
+ 8,
628
+ 9,
629
+ 10,
630
+ 11
631
+ ]
632
+ };
633
+ /**
634
+ * Whether a note belongs to a scale, regardless of the octave either sits in.
635
+ *
636
+ * @param note noteNumber: 0 ~ 127 or noteName e.g. 'C3'
637
+ * @param root the note the scale is built on, in the same two forms
638
+ *
639
+ * @example
640
+ * ```ts
641
+ * inScale('F#4', 'D3', 'major') // true
642
+ * ```
643
+ */
644
+ function inScale(note, root, name) {
645
+ const n = typeof note === "string" ? noteNumber(note) : note;
646
+ const r = typeof root === "string" ? noteNumber(root) : root;
647
+ assertSafeInteger(n, "note number");
648
+ assertSafeInteger(r, "root note number");
649
+ return scaleIntervals[name].includes(mod(n - r, 12));
650
+ }
651
+ /**
652
+ * The notes of a scale, ascending from `root`.
653
+ *
654
+ * The octave above the root is not included: ask for more `octaves` instead, so
655
+ * that concatenating the result of two calls does not repeat a note.
656
+ *
657
+ * @param root noteNumber: 0 ~ 127 or noteName e.g. 'C3'
658
+ * @param octaves how many octaves to cover
659
+ *
660
+ * @example
661
+ * ```ts
662
+ * scaleNotes('C3', 'majorPentatonic') // [48, 50, 52, 55, 57]
663
+ * ```
664
+ */
665
+ function scaleNotes(root, name, octaves = 1) {
666
+ const r = typeof root === "string" ? noteNumber(root) : root;
667
+ assertSafeInteger(r, "root note number");
668
+ if (!Number.isSafeInteger(octaves) || octaves < 0) throw new RangeError("octaves: requirements: a non-negative safe integer");
669
+ const intervals = scaleIntervals[name];
670
+ return Array.from({ length: octaves }, (_, octave) => intervals.map((interval) => r + octave * 12 + interval)).flat();
671
+ }
672
+ //#endregion
673
+ //#region src/piano.ts
674
+ /**
675
+ * `[noteRange.first, noteRange.first + 1, ..., noteRange.last]`
676
+ */
677
+ function getNoteRangeArray(noteRange) {
678
+ return Array.from({ length: noteRange.last - noteRange.first + 1 }, (_, i) => i + noteRange.first);
679
+ }
680
+ const DEFAULT_KEY_GAP = 1;
681
+ const DEFAULT_BLACK_KEY_WIDTH_RATIO = .65;
682
+ const DEFAULT_BLACK_KEY_HEIGHT_RATIO = .6;
683
+ /**
684
+ * How many white keys sit at or before each pitch class, counting from C.
685
+ *
686
+ * A black key shares the number of the white key to its left plus one, which
687
+ * puts it on the boundary between the two; {@link notePosition} then shifts it
688
+ * back by half its width to centre it there.
689
+ */
690
+ const whiteKeysBefore = {
691
+ C: 0,
692
+ "C#": 1,
693
+ D: 1,
694
+ "D#": 2,
695
+ E: 2,
696
+ F: 3,
697
+ "F#": 4,
698
+ G: 4,
699
+ "G#": 5,
700
+ A: 5,
701
+ "A#": 6,
702
+ B: 6
703
+ };
704
+ /** Width of a black key in pixels. */
705
+ function blackKeyWidth(layout) {
706
+ return layout.whiteKeyWidth * (layout.blackKeyWidthRatio ?? DEFAULT_BLACK_KEY_WIDTH_RATIO);
707
+ }
708
+ function rawNotePosition(note, layout) {
709
+ const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP);
710
+ const target = noteKey(note);
711
+ const first = noteKey(layout.noteRange.first);
712
+ const octave = Math.floor((note - layout.noteRange.first) / 12);
713
+ const octaveOffset = noteKeys.indexOf(first) > noteKeys.indexOf(target) ? 1 : 0;
714
+ const whiteKeysIn = whiteKeysBefore[target] - whiteKeysBefore[first] + (octave + octaveOffset) * 7;
715
+ return isBlackKey(note) ? whiteKeysIn * slot - blackKeyWidth(layout) / 2 : whiteKeysIn * slot;
716
+ }
717
+ function pianoBounds(layout) {
718
+ const notes = getNoteRangeArray(layout.noteRange);
719
+ if (notes.length === 0) return {
720
+ left: 0,
721
+ right: 0
722
+ };
723
+ let left = Infinity;
724
+ let right = -Infinity;
725
+ const whiteWidth = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP);
726
+ for (const note of notes) {
727
+ const noteLeft = rawNotePosition(note, layout);
728
+ const width = isBlackKey(note) ? blackKeyWidth(layout) : whiteWidth;
729
+ left = Math.min(left, noteLeft);
730
+ right = Math.max(right, noteLeft + width);
731
+ }
732
+ return {
733
+ left,
734
+ right
735
+ };
736
+ }
737
+ /** Width of the whole keyboard in pixels. */
738
+ function pianoWidth(layout) {
739
+ const { left, right } = pianoBounds(layout);
740
+ return right - left;
741
+ }
742
+ /**
743
+ * Offset of the left edge of a key from the left edge of the keyboard, in
744
+ * pixels.
745
+ *
746
+ * Notes outside `noteRange` are placed too, so the value is negative below
747
+ * `noteRange.first`.
748
+ */
749
+ function notePosition(note, layout) {
750
+ return rawNotePosition(note, layout) - pianoBounds(layout).left;
751
+ }
752
+ /**
753
+ * The note drawn at a point, or null where there is none.
754
+ *
755
+ * Black keys are tested first, so they win where they overlap a white one. A
756
+ * white key covers its gap as well as its width, so the whole width of the
757
+ * keyboard belongs to some key and a click cannot fall between two.
758
+ *
759
+ * @param x offset from the left edge of the keyboard, in pixels
760
+ * @param y offset from its top edge, in pixels
761
+ * @param height height of the keyboard, in pixels
762
+ */
763
+ function noteAt(x, y, height, layout) {
764
+ if (x < 0 || x >= pianoWidth(layout) || y < 0 || y >= height) return null;
765
+ const notes = getNoteRangeArray(layout.noteRange);
766
+ if (y < height * (layout.blackKeyHeightRatio ?? DEFAULT_BLACK_KEY_HEIGHT_RATIO)) for (const note of notes) {
767
+ if (isWhiteKey(note)) continue;
768
+ const left = notePosition(note, layout);
769
+ if (left <= x && x < left + blackKeyWidth(layout)) return note;
770
+ }
771
+ const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP);
772
+ for (const note of notes) {
773
+ if (isBlackKey(note)) continue;
774
+ const left = notePosition(note, layout);
775
+ if (left <= x && x < left + slot) return note;
776
+ }
777
+ return null;
778
+ }
779
+ //#endregion
780
+ //#region src/unit.ts
781
+ /** Ordered small to large. The empty symbol is the base unit. */
782
+ const PREFIXES = [
783
+ ["p", 1e-12],
784
+ ["n", 1e-9],
785
+ ["µ", 1e-6],
786
+ ["m", .001],
787
+ ["", 1],
788
+ ["k", 1e3],
789
+ ["M", 1e6],
790
+ ["G", 1e9]
791
+ ];
792
+ const PREFIX_SCALE = new Map(PREFIXES);
793
+ /**
794
+ * Micro is written three ways. `µ` (U+00B5 MICRO SIGN) is what `format`
795
+ * writes and what d3-format uses, `μ` (U+03BC GREEK SMALL LETTER MU) looks
796
+ * identical and is what a Greek keyboard produces, and `u` is what everyone
797
+ * actually types. All three read back the same.
798
+ */
799
+ const MICRO_ALIASES = {
800
+ μ: "µ",
801
+ u: "µ"
802
+ };
803
+ /**
804
+ * Divide by a prefix scale without showing the result of doing so in binary.
805
+ *
806
+ * `0.0005 / 1e-6` is 500.00000000000006, and with no `digits` to round it that
807
+ * lands in the input as written.
808
+ */
809
+ function scaleBy(value, scale) {
810
+ return toPrecision(value / scale);
811
+ }
812
+ /** A number, then whatever followed it. */
813
+ const NUMBER_THEN_REST = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(.*)$/;
814
+ /**
815
+ * Build the `format` and `parse` of a unit, as one pair.
816
+ *
817
+ * They are returned together because they have to agree: a `format` that
818
+ * writes `1.23kHz` is only useful next to a `parse` that reads it back as
819
+ * 1230. Spread the result into the input.
820
+ *
821
+ * @example
822
+ * unitFormat('Hz') // 1234 -> '1.23kHz'
823
+ * unitFormat('s', { base: 'm' }) // value in ms. 1500 -> '1.5s'
824
+ * unitFormat('s', { base: 'm', digits: 2 }) // 1500 -> '1.50s'
825
+ * unitFormat('dB', { prefixes: false, digits: 1 }) // -6.25 -> '-6.3dB'
826
+ *
827
+ * @example
828
+ * <NumberInput.Root {...unitFormat('Hz', { digits: 2 })} value={v} onChange={setV}>
829
+ */
830
+ function unitFormat(unit, options = {}) {
831
+ const { base = "", prefixes = true, digits, separator = "" } = options;
832
+ if (unit === "" && base !== "") throw new RangeError("unitFormat: base requires a non-empty unit");
833
+ const baseScale = PREFIX_SCALE.get(base) ?? 1;
834
+ /**
835
+ * `toFixed` renders anything that rounds to zero from below as `-0`, which
836
+ * is never what a control should show.
837
+ */
838
+ const fixed = (value) => {
839
+ const text = digits !== void 0 ? value.toFixed(digits) : String(value);
840
+ return Number(text) === 0 ? text.replace("-", "") : text;
841
+ };
842
+ if (!prefixes) {
843
+ const symbol = base + unit;
844
+ return {
845
+ format: (value) => Number.isFinite(value) ? fixed(value) + separator + symbol : String(value),
846
+ parse: (text) => {
847
+ const match = text.trim().match(NUMBER_THEN_REST);
848
+ if (!match) return NaN;
849
+ const value = Number(match[1]);
850
+ return Number.isFinite(value) ? value : NaN;
851
+ }
852
+ };
853
+ }
854
+ return {
855
+ format: (value) => {
856
+ if (!Number.isFinite(value)) return String(value);
857
+ const si = value * baseScale;
858
+ let index = PREFIXES.findIndex(([, scale]) => scale === 1);
859
+ if (si !== 0) {
860
+ const magnitude = Math.abs(si);
861
+ index = 0;
862
+ for (let i = PREFIXES.length - 1; i >= 0; i--) if (magnitude >= PREFIXES[i][1]) {
863
+ index = i;
864
+ break;
865
+ }
866
+ }
867
+ let text = fixed(scaleBy(si, PREFIXES[index][1]));
868
+ if (Math.abs(Number(text)) >= 1e3 && index < PREFIXES.length - 1) {
869
+ index += 1;
870
+ text = fixed(scaleBy(si, PREFIXES[index][1]));
871
+ }
872
+ return text + separator + PREFIXES[index][0] + unit;
873
+ },
874
+ parse: (text) => {
875
+ const match = text.trim().match(NUMBER_THEN_REST);
876
+ if (!match) return NaN;
877
+ const number = Number(match[1]);
878
+ if (!Number.isFinite(number)) return NaN;
879
+ let suffix = match[2].trim();
880
+ const separatorText = separator.trim();
881
+ if (separatorText !== "" && suffix.startsWith(separatorText)) suffix = suffix.slice(separatorText.length).trim();
882
+ if (suffix === "") return number;
883
+ let prefix = null;
884
+ if (unit !== "" && suffix.endsWith(unit)) prefix = suffix.slice(0, suffix.length - unit.length);
885
+ else if (suffix.length <= 1) prefix = suffix;
886
+ if (prefix === null) return number;
887
+ const normalized = MICRO_ALIASES[prefix] ?? prefix;
888
+ const scale = PREFIX_SCALE.get(normalized);
889
+ if (scale === void 0) return number;
890
+ return number * scale / baseScale;
891
+ }
892
+ };
893
+ }
165
894
  //#endregion
166
- export { clamp, dbToGain, decimalPart, degree, gainToDb, integerPart, isBlackKey, isEmpty, isWhiteKey, mapValue, mod, normalizeValue, noteKey, noteKeys, noteName, noteNumber, noteToFrequency, parseNoteName, radian, rawValue, skewWithCenterValue, stepValue, styleHelper, toFixed, whiteKeys, xor };
895
+ export { SIGNIFICANT_DIGITS, applyDelta, blackKeyWidth, clamp, curveScale, curveWithCenterValue, dbToGain, decimalPart, degree, exponentialScale, gainToDb, getNoteRangeArray, inScale, integerPart, isBlackKey, isWhiteKey, linearScale, mapModifier, mapValue, mod, normalizeValue, noteAt, noteKey, noteKeys, noteName, noteNumber, notePosition, noteToFrequency, parseNoteName, pianoWidth, radian, rawValue, scaleIntervals, scaleNotes, selectInputEvent, selectModifier, skewScale, skewWithCenterValue, stepValue, symmetricSkewScale, toFixed, toPrecision, unitFormat, whiteKeys, xor };
167
896
 
168
897
  //# sourceMappingURL=index.js.map