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