@tremolo-ui/functions 0.5.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 +330 -98
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +163 -42
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +163 -42
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +325 -94
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/index.ts +20 -3
- package/src/math.ts +53 -5
- package/src/midi.ts +35 -15
- package/src/modifiers.ts +141 -0
- package/src/piano.ts +35 -21
- package/src/scales.ts +79 -25
- package/src/unit.ts +182 -51
- package/src/util.ts +1 -36
- package/src/types.ts +0 -4
package/dist/index.cjs
CHANGED
|
@@ -25,17 +25,55 @@ function rawValue(normalizedValue, min, max) {
|
|
|
25
25
|
if (min >= max) throw new RangeError("requirements: min < max");
|
|
26
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
|
|
31
|
-
|
|
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,10 +97,98 @@ function gainToDb(gain) {
|
|
|
59
97
|
return 20 * (Math.log(gain) / Math.LN10);
|
|
60
98
|
}
|
|
61
99
|
//#endregion
|
|
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;
|
|
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
|
|
62
185
|
//#region src/scales.ts
|
|
63
186
|
function assertRange(min, max) {
|
|
64
187
|
if (min >= max) throw new RangeError("requirements: min < max");
|
|
65
188
|
}
|
|
189
|
+
function assertPositiveFinite(value, name) {
|
|
190
|
+
if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name}: requirements: finite and greater than 0`);
|
|
191
|
+
}
|
|
66
192
|
/**
|
|
67
193
|
* Equal travel gives an equal change in value.
|
|
68
194
|
*
|
|
@@ -90,6 +216,7 @@ const linearScale = {
|
|
|
90
216
|
* @param skew the JUCE skew factor
|
|
91
217
|
*/
|
|
92
218
|
function skewScale(skew) {
|
|
219
|
+
assertPositiveFinite(skew, "skewScale");
|
|
93
220
|
return {
|
|
94
221
|
normalize: (value, min, max) => Math.pow(normalizeValue(value, min, max), skew),
|
|
95
222
|
denormalize: (position, min, max) => rawValue(skew === 1 ? position : Math.exp(Math.log(clamp(position, 0, 1)) / skew), min, max)
|
|
@@ -100,7 +227,8 @@ function skewScale(skew) {
|
|
|
100
227
|
* of the travel — JUCE's `NormalisableRange::setSkewForCentre`.
|
|
101
228
|
*/
|
|
102
229
|
function skewWithCenterValue(centerValue, min, max) {
|
|
103
|
-
|
|
230
|
+
assertRange(min, max);
|
|
231
|
+
if (!(min < centerValue && centerValue < max)) throw new RangeError("requirements: min < centerValue < max");
|
|
104
232
|
return Math.log(.5) / Math.log((centerValue - min) / (max - min));
|
|
105
233
|
}
|
|
106
234
|
/**
|
|
@@ -117,11 +245,15 @@ function skewWithCenterValue(centerValue, min, max) {
|
|
|
117
245
|
const exponentialScale = {
|
|
118
246
|
normalize: (value, min, max) => {
|
|
119
247
|
assertExponentialRange(min, max);
|
|
120
|
-
|
|
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);
|
|
121
251
|
},
|
|
122
252
|
denormalize: (position, min, max) => {
|
|
123
253
|
assertExponentialRange(min, max);
|
|
124
|
-
|
|
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;
|
|
125
257
|
}
|
|
126
258
|
};
|
|
127
259
|
function assertExponentialRange(min, max) {
|
|
@@ -150,25 +282,23 @@ function assertExponentialRange(min, max) {
|
|
|
150
282
|
* @param curve how hard the curve bends, and in which direction
|
|
151
283
|
*/
|
|
152
284
|
function curveScale(curve) {
|
|
285
|
+
if (!Number.isFinite(curve) || Math.abs(curve) > 32) throw new RangeError("curveScale: requirements: finite curve from -32 to 32");
|
|
153
286
|
if (Math.abs(curve) < .001) return linearScale;
|
|
154
|
-
const grow = Math.exp(curve);
|
|
155
|
-
const coefficients = (min, max) => {
|
|
156
|
-
const a = (max - min) / (1 - grow);
|
|
157
|
-
return {
|
|
158
|
-
a,
|
|
159
|
-
b: min + a
|
|
160
|
-
};
|
|
161
|
-
};
|
|
162
287
|
return {
|
|
163
288
|
normalize: (value, min, max) => {
|
|
164
289
|
assertRange(min, max);
|
|
165
|
-
const
|
|
166
|
-
|
|
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;
|
|
167
294
|
},
|
|
168
295
|
denormalize: (position, min, max) => {
|
|
169
296
|
assertRange(min, max);
|
|
170
|
-
const
|
|
171
|
-
|
|
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;
|
|
172
302
|
}
|
|
173
303
|
};
|
|
174
304
|
}
|
|
@@ -185,6 +315,7 @@ function curveScale(curve) {
|
|
|
185
315
|
* @param skew the JUCE skew factor
|
|
186
316
|
*/
|
|
187
317
|
function symmetricSkewScale(skew) {
|
|
318
|
+
assertPositiveFinite(skew, "symmetricSkewScale");
|
|
188
319
|
return {
|
|
189
320
|
normalize: (value, min, max) => {
|
|
190
321
|
assertRange(min, max);
|
|
@@ -221,35 +352,33 @@ function curveWithCenterValue(centerValue, min, max) {
|
|
|
221
352
|
* @param direction which way, and how many times, to apply the option. The
|
|
222
353
|
* size of one step is `option[1]`, so this is normally `1` or `-1`.
|
|
223
354
|
*
|
|
355
|
+
* @param modifiers the event, for `options` that name a modifier key. See
|
|
356
|
+
* {@link selectInputEvent}.
|
|
357
|
+
*
|
|
224
358
|
* @example
|
|
225
359
|
* // ArrowDown on a slider whose keyboard option is ['raw', 1]
|
|
226
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)
|
|
227
365
|
*/
|
|
228
|
-
function applyDelta(value, direction,
|
|
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);
|
|
229
370
|
const x = direction * amount;
|
|
230
|
-
const next = mode
|
|
231
|
-
|
|
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);
|
|
232
374
|
}
|
|
233
375
|
//#endregion
|
|
234
376
|
//#region src/util.ts
|
|
235
|
-
function styleHelper(value, op, influencer) {
|
|
236
|
-
if (op && influencer) if (typeof value == "number") {
|
|
237
|
-
if (op == "+") return `${value + influencer}px`;
|
|
238
|
-
if (op == "-") return `${value - influencer}px`;
|
|
239
|
-
if (op == "*") return `${value * influencer}px`;
|
|
240
|
-
if (op == "/") return `${value / influencer}px`;
|
|
241
|
-
} else return `calc(${value}px ${op} ${influencer})`;
|
|
242
|
-
else if (typeof value == "number") return `${value}px`;
|
|
243
|
-
else return value;
|
|
244
|
-
}
|
|
245
|
-
function isEmpty(obj) {
|
|
246
|
-
return Object.keys(obj).length == 0;
|
|
247
|
-
}
|
|
248
377
|
function mod(n, m) {
|
|
249
378
|
return (n % m + m) % m;
|
|
250
379
|
}
|
|
251
380
|
function xor(a = false, b = false) {
|
|
252
|
-
return (a || b) && a
|
|
381
|
+
return (a || b) && a !== b;
|
|
253
382
|
}
|
|
254
383
|
//#endregion
|
|
255
384
|
//#region src/midi.ts
|
|
@@ -276,14 +405,19 @@ const noteKeys = [
|
|
|
276
405
|
"A#",
|
|
277
406
|
"B"
|
|
278
407
|
];
|
|
408
|
+
function assertSafeInteger(value, name) {
|
|
409
|
+
if (!Number.isSafeInteger(value)) throw new RangeError(`${name}: requirements: a safe integer`);
|
|
410
|
+
}
|
|
279
411
|
function parseNoteName(noteName) {
|
|
280
412
|
const m = noteName.match(/^([a-g])(#{0,2}|b{0,2})(-?\d+)$/i);
|
|
281
413
|
if (!m) throw new Error("Invalid note name");
|
|
282
414
|
const [, letter, accidental, octave] = m;
|
|
415
|
+
const parsedOctave = Number(octave);
|
|
416
|
+
assertSafeInteger(parsedOctave, "octave");
|
|
283
417
|
return {
|
|
284
418
|
letter: letter.toLocaleUpperCase(),
|
|
285
419
|
accidental,
|
|
286
|
-
octave:
|
|
420
|
+
octave: parsedOctave
|
|
287
421
|
};
|
|
288
422
|
}
|
|
289
423
|
/**
|
|
@@ -292,8 +426,10 @@ function parseNoteName(noteName) {
|
|
|
292
426
|
function noteNumber(noteName) {
|
|
293
427
|
const { letter, accidental, octave } = parseNoteName(noteName);
|
|
294
428
|
const noteIndex = noteKeys.indexOf(letter.toLocaleUpperCase());
|
|
295
|
-
const accidentalValue = (accidental[0]
|
|
296
|
-
|
|
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;
|
|
297
433
|
}
|
|
298
434
|
/**
|
|
299
435
|
* Convert noteNumber to noteName
|
|
@@ -302,6 +438,7 @@ function noteNumber(noteName) {
|
|
|
302
438
|
* @param noteNumber noteNumber
|
|
303
439
|
*/
|
|
304
440
|
function noteName(noteNumber) {
|
|
441
|
+
assertSafeInteger(noteNumber, "note number");
|
|
305
442
|
const noteIndex = mod(noteNumber, 12);
|
|
306
443
|
const octave = Math.floor(noteNumber / 12) - 1;
|
|
307
444
|
return `${noteKeys[noteIndex]}${octave}`;
|
|
@@ -310,14 +447,16 @@ function noteName(noteNumber) {
|
|
|
310
447
|
* Convert noteNumber to noteKey
|
|
311
448
|
*/
|
|
312
449
|
function noteKey(noteNumber) {
|
|
450
|
+
assertSafeInteger(noteNumber, "note number");
|
|
313
451
|
return noteKeys[mod(noteNumber, 12)];
|
|
314
452
|
}
|
|
315
453
|
/**
|
|
316
454
|
* @param note noteNumber: 0 ~ 127 or noteName e.g. 'C3'
|
|
317
455
|
*/
|
|
318
456
|
function isWhiteKey(note) {
|
|
319
|
-
const n = typeof note
|
|
320
|
-
|
|
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;
|
|
321
460
|
}
|
|
322
461
|
/**
|
|
323
462
|
* @param note noteNumber: 0 ~ 127 or noteName e.g. 'C3'
|
|
@@ -332,7 +471,8 @@ function isBlackKey(note) {
|
|
|
332
471
|
* @returns frequency [Hz]
|
|
333
472
|
*/
|
|
334
473
|
function noteToFrequency(note, detune = 0, a4 = 440) {
|
|
335
|
-
const n = typeof note
|
|
474
|
+
const n = typeof note === "string" ? noteNumber(note) : note;
|
|
475
|
+
assertSafeInteger(n, "note number");
|
|
336
476
|
return a4 / 32 * 2 ** ((n - 9 + detune / 100) / 12);
|
|
337
477
|
}
|
|
338
478
|
/**
|
|
@@ -503,8 +643,10 @@ const scaleIntervals = {
|
|
|
503
643
|
* ```
|
|
504
644
|
*/
|
|
505
645
|
function inScale(note, root, name) {
|
|
506
|
-
const n = typeof note
|
|
507
|
-
const r = typeof root
|
|
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");
|
|
508
650
|
return scaleIntervals[name].includes(mod(n - r, 12));
|
|
509
651
|
}
|
|
510
652
|
/**
|
|
@@ -522,7 +664,9 @@ function inScale(note, root, name) {
|
|
|
522
664
|
* ```
|
|
523
665
|
*/
|
|
524
666
|
function scaleNotes(root, name, octaves = 1) {
|
|
525
|
-
const r = typeof root
|
|
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");
|
|
526
670
|
const intervals = scaleIntervals[name];
|
|
527
671
|
return Array.from({ length: octaves }, (_, octave) => intervals.map((interval) => r + octave * 12 + interval)).flat();
|
|
528
672
|
}
|
|
@@ -562,10 +706,39 @@ const whiteKeysBefore = {
|
|
|
562
706
|
function blackKeyWidth(layout) {
|
|
563
707
|
return layout.whiteKeyWidth * (layout.blackKeyWidthRatio ?? DEFAULT_BLACK_KEY_WIDTH_RATIO);
|
|
564
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
|
+
}
|
|
565
738
|
/** Width of the whole keyboard in pixels. */
|
|
566
739
|
function pianoWidth(layout) {
|
|
567
|
-
const
|
|
568
|
-
return
|
|
740
|
+
const { left, right } = pianoBounds(layout);
|
|
741
|
+
return right - left;
|
|
569
742
|
}
|
|
570
743
|
/**
|
|
571
744
|
* Offset of the left edge of a key from the left edge of the keyboard, in
|
|
@@ -575,13 +748,7 @@ function pianoWidth(layout) {
|
|
|
575
748
|
* `noteRange.first`.
|
|
576
749
|
*/
|
|
577
750
|
function notePosition(note, layout) {
|
|
578
|
-
|
|
579
|
-
const target = noteKey(note);
|
|
580
|
-
const first = noteKey(layout.noteRange.first);
|
|
581
|
-
const octave = Math.floor((note - layout.noteRange.first) / 12);
|
|
582
|
-
const octaveOffset = noteKeys.indexOf(first) > noteKeys.indexOf(target) ? 1 : 0;
|
|
583
|
-
const whiteKeysIn = whiteKeysBefore[target] - whiteKeysBefore[first] + (octave + octaveOffset) * 7;
|
|
584
|
-
return isBlackKey(note) ? whiteKeysIn * slot - blackKeyWidth(layout) / 2 : whiteKeysIn * slot;
|
|
751
|
+
return rawNotePosition(note, layout) - pianoBounds(layout).left;
|
|
585
752
|
}
|
|
586
753
|
/**
|
|
587
754
|
* The note drawn at a point, or null where there is none.
|
|
@@ -595,7 +762,7 @@ function notePosition(note, layout) {
|
|
|
595
762
|
* @param height height of the keyboard, in pixels
|
|
596
763
|
*/
|
|
597
764
|
function noteAt(x, y, height, layout) {
|
|
598
|
-
if (y < 0 || y >= height) return null;
|
|
765
|
+
if (x < 0 || x >= pianoWidth(layout) || y < 0 || y >= height) return null;
|
|
599
766
|
const notes = getNoteRangeArray(layout.noteRange);
|
|
600
767
|
if (y < height * (layout.blackKeyHeightRatio ?? DEFAULT_BLACK_KEY_HEIGHT_RATIO)) for (const note of notes) {
|
|
601
768
|
if (isWhiteKey(note)) continue;
|
|
@@ -612,56 +779,121 @@ function noteAt(x, y, height, layout) {
|
|
|
612
779
|
}
|
|
613
780
|
//#endregion
|
|
614
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);
|
|
615
794
|
/**
|
|
616
|
-
*
|
|
617
|
-
*
|
|
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.
|
|
618
799
|
*/
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
}
|
|
800
|
+
const MICRO_ALIASES = {
|
|
801
|
+
μ: "µ",
|
|
802
|
+
u: "µ"
|
|
803
|
+
};
|
|
624
804
|
/**
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
* @param units a single symbol appended as-is, or a list to choose from.
|
|
628
|
-
* @param digit digits after the decimal point. Left as-is when omitted.
|
|
805
|
+
* Divide by a prefix scale without showing the result of doing so in binary.
|
|
629
806
|
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
* formatValue(1.5, 'Hz') // '1.5Hz'
|
|
807
|
+
* `0.0005 / 1e-6` is 500.00000000000006, and with no `digits` to round it that
|
|
808
|
+
* lands in the input as written.
|
|
633
809
|
*/
|
|
634
|
-
function
|
|
635
|
-
|
|
636
|
-
if (!units || typeof units == "string") return fixed(value) + (units ?? "");
|
|
637
|
-
const [unit, scale] = selectUnit(units, value);
|
|
638
|
-
return fixed(value / scale) + unit;
|
|
810
|
+
function scaleBy(value, scale) {
|
|
811
|
+
return toPrecision(value / scale);
|
|
639
812
|
}
|
|
813
|
+
/** A number, then whatever followed it. */
|
|
814
|
+
const NUMBER_THEN_REST = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(.*)$/;
|
|
640
815
|
/**
|
|
641
|
-
*
|
|
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.
|
|
642
821
|
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
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'
|
|
647
827
|
*
|
|
648
828
|
* @example
|
|
649
|
-
*
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
+
};
|
|
658
854
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
+
};
|
|
663
894
|
}
|
|
664
895
|
//#endregion
|
|
896
|
+
exports.SIGNIFICANT_DIGITS = SIGNIFICANT_DIGITS;
|
|
665
897
|
exports.applyDelta = applyDelta;
|
|
666
898
|
exports.blackKeyWidth = blackKeyWidth;
|
|
667
899
|
exports.clamp = clamp;
|
|
@@ -671,15 +903,14 @@ exports.dbToGain = dbToGain;
|
|
|
671
903
|
exports.decimalPart = decimalPart;
|
|
672
904
|
exports.degree = degree;
|
|
673
905
|
exports.exponentialScale = exponentialScale;
|
|
674
|
-
exports.formatValue = formatValue;
|
|
675
906
|
exports.gainToDb = gainToDb;
|
|
676
907
|
exports.getNoteRangeArray = getNoteRangeArray;
|
|
677
908
|
exports.inScale = inScale;
|
|
678
909
|
exports.integerPart = integerPart;
|
|
679
910
|
exports.isBlackKey = isBlackKey;
|
|
680
|
-
exports.isEmpty = isEmpty;
|
|
681
911
|
exports.isWhiteKey = isWhiteKey;
|
|
682
912
|
exports.linearScale = linearScale;
|
|
913
|
+
exports.mapModifier = mapModifier;
|
|
683
914
|
exports.mapValue = mapValue;
|
|
684
915
|
exports.mod = mod;
|
|
685
916
|
exports.normalizeValue = normalizeValue;
|
|
@@ -691,19 +922,20 @@ exports.noteNumber = noteNumber;
|
|
|
691
922
|
exports.notePosition = notePosition;
|
|
692
923
|
exports.noteToFrequency = noteToFrequency;
|
|
693
924
|
exports.parseNoteName = parseNoteName;
|
|
694
|
-
exports.parseValue = parseValue;
|
|
695
925
|
exports.pianoWidth = pianoWidth;
|
|
696
926
|
exports.radian = radian;
|
|
697
927
|
exports.rawValue = rawValue;
|
|
698
928
|
exports.scaleIntervals = scaleIntervals;
|
|
699
929
|
exports.scaleNotes = scaleNotes;
|
|
700
|
-
exports.
|
|
930
|
+
exports.selectInputEvent = selectInputEvent;
|
|
931
|
+
exports.selectModifier = selectModifier;
|
|
701
932
|
exports.skewScale = skewScale;
|
|
702
933
|
exports.skewWithCenterValue = skewWithCenterValue;
|
|
703
934
|
exports.stepValue = stepValue;
|
|
704
|
-
exports.styleHelper = styleHelper;
|
|
705
935
|
exports.symmetricSkewScale = symmetricSkewScale;
|
|
706
936
|
exports.toFixed = toFixed;
|
|
937
|
+
exports.toPrecision = toPrecision;
|
|
938
|
+
exports.unitFormat = unitFormat;
|
|
707
939
|
exports.whiteKeys = whiteKeys;
|
|
708
940
|
exports.xor = xor;
|
|
709
941
|
|