@kidlib/web-audio 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Kristinn Roach Gunnarsson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # @kidlib/web-audio
2
+
3
+ High-level Web Audio primitives for creating musical instruments and tools.
4
+ This package requires a browser with Web Audio and AudioWorklet support.
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ pnpm add @kidlib/web-audio
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ```ts
15
+ import { createSamplePlayer } from '@kidlib/web-audio';
16
+ import { inputController } from '@kidlib/web-audio/io';
17
+ import { registerKnobElement } from '@kidlib/web-audio/components';
18
+
19
+ const player = await createSamplePlayer();
20
+ player.play(60);
21
+ ```
22
+
23
+ UI elements such as `KnobElement` are available only from the optional
24
+ `@kidlib/web-audio/components` entry point.
@@ -0,0 +1,94 @@
1
+ export declare type AudiolibElement = (typeof ELEMENTS)[keyof typeof ELEMENTS];
2
+
3
+ export declare function defineElement(tagName: AudiolibElement, elementClass: CustomElementConstructor): void;
4
+
5
+ declare const ELEMENTS: {
6
+ readonly KNOB: "knob-element";
7
+ };
8
+
9
+ export declare type KnobChangeEventDetail = {
10
+ value: number;
11
+ rotation: number;
12
+ percentage: number;
13
+ source: 'user' | 'programmatic';
14
+ };
15
+
16
+ export declare class KnobElement extends HTMLElement {
17
+ private pathElement;
18
+ private static stylesInjected;
19
+ private config;
20
+ private currentValue;
21
+ private currentRotation;
22
+ private rotationToValue;
23
+ private valueToRotation;
24
+ private applySnapping;
25
+ private static mapRange;
26
+ private static clamp;
27
+ static get observedAttributes(): string[];
28
+ constructor();
29
+ connectedCallback(): void;
30
+ disconnectedCallback(): void;
31
+ attributeChangedCallback(name: string, oldValue: string, newValue: string): void;
32
+ private injectGlobalStyles;
33
+ private updateConfigFromAttributes;
34
+ private updateDimensions;
35
+ private updateColorFromAttribute;
36
+ private render;
37
+ private cleanup;
38
+ private createUtilityFunctions;
39
+ private dragHandlers?;
40
+ private lastClickTime;
41
+ private readonly DOUBLE_CLICK_THRESHOLD;
42
+ private createDraggable;
43
+ private updateBorder;
44
+ private dispatchChangeEvent;
45
+ setValue(value: number): void;
46
+ /**
47
+ * Sets the knob value using a normalized 0-1 input, automatically handling
48
+ * the knob's range and curve transformation.
49
+ * @param normalizedValue - Value between 0 and 1
50
+ */
51
+ setValueNormalized(normalizedValue: number): void;
52
+ /**
53
+ * Gets the current knob value as a normalized 0-1 value, accounting for
54
+ * the knob's range and curve. Inverse of setValueNormalized().
55
+ * @returns Normalized value between 0 and 1
56
+ */
57
+ getValueNormalized(): number;
58
+ resetToDefault(): void;
59
+ getValue(): number;
60
+ setCurve(curve: number): void;
61
+ getCurve(): number;
62
+ setDisabled(disabled: boolean): void;
63
+ isDisabled(): boolean;
64
+ getPercentage(): number;
65
+ get value(): number;
66
+ set value(val: number);
67
+ get disabled(): boolean;
68
+ set disabled(val: boolean);
69
+ }
70
+
71
+ export declare type KnobElementOptions = {
72
+ minValue: number;
73
+ maxValue: number;
74
+ defaultValue: number;
75
+ minRotation: number;
76
+ maxRotation: number;
77
+ snapIncrement: number;
78
+ allowedValues?: number[];
79
+ curve?: number;
80
+ snapThresholds?: Array<{
81
+ maxValue: number;
82
+ increment: number;
83
+ }>;
84
+ disabled?: boolean;
85
+ borderStyle?: 'currentState' | 'fullCircle';
86
+ width?: number;
87
+ height?: number;
88
+ color?: string;
89
+ value?: number;
90
+ };
91
+
92
+ export declare function registerKnobElement(): void;
93
+
94
+ export { }
@@ -0,0 +1,398 @@
1
+ var k = Object.defineProperty;
2
+ var y = (m, d, t) => d in m ? k(m, d, { enumerable: !0, configurable: !0, writable: !0, value: t }) : m[d] = t;
3
+ var h = (m, d, t) => y(m, typeof d != "symbol" ? d + "" : d, t);
4
+ const u = class u extends HTMLElement {
5
+ constructor() {
6
+ super();
7
+ h(this, "pathElement");
8
+ h(this, "config", {
9
+ minValue: 0,
10
+ maxValue: 100,
11
+ defaultValue: 0,
12
+ minRotation: -170,
13
+ maxRotation: 170,
14
+ snapIncrement: 1,
15
+ curve: 1,
16
+ disabled: !1,
17
+ borderStyle: "currentState"
18
+ });
19
+ h(this, "currentValue", 0);
20
+ h(this, "currentRotation", 0);
21
+ h(this, "rotationToValue");
22
+ h(this, "valueToRotation");
23
+ h(this, "applySnapping");
24
+ h(this, "dragHandlers");
25
+ h(this, "lastClickTime", 0);
26
+ h(this, "DOUBLE_CLICK_THRESHOLD", 300);
27
+ }
28
+ static mapRange(t, e, n, i, s) {
29
+ return e === t ? n : (s - t) * (i - n) / (e - t) + n;
30
+ }
31
+ static clamp(t, e, n) {
32
+ return Math.min(Math.max(t, e), n);
33
+ }
34
+ // Observed attributes
35
+ static get observedAttributes() {
36
+ return [
37
+ "min-value",
38
+ "max-value",
39
+ "default-value",
40
+ "min-rotation",
41
+ "max-rotation",
42
+ "snap-increment",
43
+ "allowed-values",
44
+ "value",
45
+ "disabled",
46
+ "width",
47
+ "height",
48
+ "border-style",
49
+ "curve",
50
+ "color"
51
+ ];
52
+ }
53
+ connectedCallback() {
54
+ this.injectGlobalStyles(), this.createUtilityFunctions(), this.render(), this.updateColorFromAttribute(), this.setValue(this.config.defaultValue ?? this.config.minValue), this.createDraggable();
55
+ }
56
+ disconnectedCallback() {
57
+ this.cleanup();
58
+ }
59
+ attributeChangedCallback(t, e, n) {
60
+ if (e !== n) {
61
+ if (t === "max-value" || t === "min-value") {
62
+ const i = this.config.minValue, s = this.config.maxValue;
63
+ this.updateConfigFromAttributes(), this.updateBorder();
64
+ let o;
65
+ t === "max-value" ? o = u.mapRange(
66
+ i,
67
+ // old min
68
+ parseFloat(e),
69
+ // old max
70
+ this.config.minValue,
71
+ // new min
72
+ this.config.maxValue,
73
+ // new max
74
+ this.currentValue
75
+ ) : o = u.mapRange(
76
+ parseFloat(e),
77
+ // old min
78
+ s,
79
+ // old max
80
+ this.config.minValue,
81
+ // new min
82
+ this.config.maxValue,
83
+ // new max
84
+ this.currentValue
85
+ ), this.createUtilityFunctions(), this.setValue(o);
86
+ return;
87
+ }
88
+ if (this.updateConfigFromAttributes(), this.updateBorder(), t === "width" || t === "height" || t === "border-style") return;
89
+ if (t === "color") {
90
+ this.updateColorFromAttribute();
91
+ return;
92
+ }
93
+ if (t === "curve") {
94
+ this.createUtilityFunctions(), this.setValue(this.currentValue);
95
+ return;
96
+ }
97
+ }
98
+ }
99
+ injectGlobalStyles() {
100
+ if (u.stylesInjected) return;
101
+ const t = document.createElement("style");
102
+ t.id = "knob-element-styles", t.textContent = `
103
+ knob-element {
104
+ display: block;
105
+ box-sizing: border-box;
106
+ --knob-size: 120px;
107
+ --knob-stroke: rgb(234, 234, 234);
108
+
109
+ width: var(--knob-size, 120px);
110
+ height: var(--knob-size, 120px);
111
+
112
+ touch-action: none; /* Prevents browser touch gestures */
113
+ user-select: none; /* Prevents text selection during drag */
114
+ border-radius: 50%;
115
+ cursor: grab;
116
+ }
117
+
118
+ knob-element[disabled] {
119
+ opacity: 0.5;
120
+ pointer-events: none;
121
+ }
122
+
123
+ knob-element:active {
124
+ cursor: grabbing;
125
+ }
126
+ `, document.head.appendChild(t), u.stylesInjected = !0;
127
+ }
128
+ updateConfigFromAttributes() {
129
+ const t = (r, a) => {
130
+ const c = this.getAttribute(r);
131
+ return c !== null ? parseFloat(c) : a;
132
+ }, e = (r, a) => this.getAttribute(r) || a, n = (r) => {
133
+ const a = this.getAttribute(r);
134
+ if (a)
135
+ try {
136
+ return JSON.parse(a);
137
+ } catch {
138
+ console.warn(`KnobElement: Invalid ${r} JSON:`, a);
139
+ return;
140
+ }
141
+ }, i = n("allowed-values");
142
+ let s = t("min-value", 0), o = t("max-value", 100);
143
+ if (i && i.length > 0) {
144
+ const r = [...i].sort((f, l) => f - l), a = r[0], c = r[r.length - 1];
145
+ this.hasAttribute("min-value") && s !== a && console.debug(
146
+ `KnobElement: min-value (${s}) doesn't match first allowedValue (${a}). Using ${a}.`
147
+ ), this.hasAttribute("max-value") && o !== c && console.debug(
148
+ `KnobElement: max-value (${o}) doesn't match last allowedValue (${c}). Using ${c}.`
149
+ ), this.hasAttribute("snap-thresholds") && console.debug(
150
+ "KnobElement: allowedValues overrides snap-increment and snap-thresholds."
151
+ ), s = a, o = c;
152
+ }
153
+ this.config = {
154
+ minValue: s,
155
+ maxValue: o,
156
+ defaultValue: t("default-value", 0),
157
+ minRotation: t("min-rotation", -150),
158
+ maxRotation: t("max-rotation", 150),
159
+ snapIncrement: t("snap-increment", 1),
160
+ curve: t("curve", 1),
161
+ borderStyle: e(
162
+ "border-style",
163
+ "currentState"
164
+ ),
165
+ allowedValues: i ? [...i].sort((r, a) => r - a) : void 0,
166
+ snapThresholds: n(
167
+ "snap-thresholds"
168
+ ),
169
+ disabled: this.hasAttribute("disabled")
170
+ }, this.updateDimensions();
171
+ }
172
+ updateDimensions() {
173
+ const t = this.getAttribute("width"), e = this.getAttribute("height");
174
+ if (t || e) {
175
+ const n = t || e || "120";
176
+ this.style.setProperty("--knob-size", `${n}px`);
177
+ }
178
+ }
179
+ updateColorFromAttribute() {
180
+ const t = this.getAttribute("color");
181
+ t && this.style.setProperty("--knob-stroke", t);
182
+ }
183
+ render() {
184
+ this.innerHTML = `
185
+ <svg class="ac-knob" width="100%" height="100%" viewBox="0 0 100 100">
186
+ <path class="knob-path"
187
+ fill="none"
188
+ stroke="var(--knob-stroke)"
189
+ stroke-width="5"
190
+ stroke-linecap="round"
191
+ stroke-linejoin="round"
192
+ d="M50,50 L50,2"
193
+ />
194
+ </svg>
195
+ `, this.pathElement = this.querySelector(".knob-path");
196
+ }
197
+ cleanup() {
198
+ this.dragHandlers && (this.removeEventListener("mousedown", this.dragHandlers.start), this.removeEventListener("touchstart", this.dragHandlers.start), document.removeEventListener("mousemove", this.dragHandlers.move), document.removeEventListener("mouseup", this.dragHandlers.end), document.removeEventListener("touchmove", this.dragHandlers.move), document.removeEventListener("touchend", this.dragHandlers.end));
199
+ }
200
+ createUtilityFunctions() {
201
+ const t = this.config.curve || 1;
202
+ this.rotationToValue = (e) => {
203
+ const n = u.mapRange(
204
+ this.config.minRotation,
205
+ this.config.maxRotation,
206
+ 0,
207
+ 1,
208
+ e
209
+ ), i = Math.pow(n, t);
210
+ return u.mapRange(
211
+ 0,
212
+ 1,
213
+ this.config.minValue,
214
+ this.config.maxValue,
215
+ i
216
+ );
217
+ }, this.valueToRotation = (e) => {
218
+ const n = u.mapRange(
219
+ this.config.minValue,
220
+ this.config.maxValue,
221
+ 0,
222
+ 1,
223
+ e
224
+ ), i = Math.pow(n, 1 / t);
225
+ return u.mapRange(
226
+ 0,
227
+ 1,
228
+ this.config.minRotation,
229
+ this.config.maxRotation,
230
+ i
231
+ );
232
+ }, this.applySnapping = (e) => {
233
+ if (this.config.allowedValues && this.config.allowedValues.length > 0)
234
+ return this.config.allowedValues.reduce((i, s) => Math.abs(s - e) < Math.abs(i - e) ? s : i);
235
+ if (this.config.snapIncrement <= 0) return e;
236
+ let n = this.config.snapIncrement;
237
+ if (this.config.snapThresholds) {
238
+ for (const { maxValue: i, increment: s } of this.config.snapThresholds)
239
+ if (e < i) {
240
+ n = s;
241
+ break;
242
+ }
243
+ }
244
+ return Math.round(e / n) * n;
245
+ };
246
+ }
247
+ // TODO(audiolib): Add keyboard controls and slider ARIA semantics in the planned accessibility pass.
248
+ createDraggable() {
249
+ const t = "pointerLockElement" in document && "requestPointerLock" in HTMLElement.prototype;
250
+ let e = !1, n = 0, i = 0, s = 0, o = !1;
251
+ const r = (l) => {
252
+ n = l.clientY, o = document.pointerLockElement === this, !(!t || document.pointerLockElement) && this.requestPointerLock().then(
253
+ () => {
254
+ o = document.pointerLockElement === this;
255
+ },
256
+ () => {
257
+ o = !1;
258
+ }
259
+ );
260
+ }, a = (l) => {
261
+ if (this.config.disabled) return;
262
+ const g = Date.now(), p = g - this.lastClickTime;
263
+ if (p < this.DOUBLE_CLICK_THRESHOLD && p > 0) {
264
+ this.resetToDefault();
265
+ return;
266
+ }
267
+ this.lastClickTime = g, e = !0, i = this.currentRotation, s = 0, "touches" in l ? (n = l.touches[0].clientY, o = !1) : r(l);
268
+ }, c = (l) => {
269
+ if (!e) return;
270
+ let g;
271
+ const p = 2;
272
+ if (o && document.pointerLockElement)
273
+ s += l.movementY, g = -s * p;
274
+ else {
275
+ const x = "touches" in l ? l.touches[0].clientY : l.clientY;
276
+ g = (n - x) * p;
277
+ }
278
+ const V = i + g, E = u.clamp(
279
+ V,
280
+ this.config.minRotation,
281
+ this.config.maxRotation
282
+ ), R = this.rotationToValue(E), b = this.applySnapping(R);
283
+ this.currentValue = b, b !== R ? this.currentRotation = this.valueToRotation(b) : this.currentRotation = E, this.updateBorder(), this.dispatchChangeEvent("user"), l.preventDefault();
284
+ }, f = () => {
285
+ e = !1, o && document.pointerLockElement && document.exitPointerLock(), o = !1;
286
+ };
287
+ this.dragHandlers = {
288
+ start: a,
289
+ move: c,
290
+ end: f
291
+ }, this.addEventListener("mousedown", a), this.addEventListener("touchstart", a, { passive: !1 }), document.addEventListener("mousemove", c), document.addEventListener("mouseup", f), document.addEventListener("touchmove", c, { passive: !1 }), document.addEventListener("touchend", f);
292
+ }
293
+ updateBorder() {
294
+ if (!this.pathElement) return;
295
+ if ((this.getAttribute("border-style") || "currentState") === "currentState") {
296
+ const s = (this.config.minRotation - 90) * Math.PI / 180, o = (this.currentRotation - 90) * Math.PI / 180, r = 48 * Math.cos(s) + 50, a = 48 * Math.sin(s) + 50, c = 48 * Math.cos(o) + 50, f = 48 * Math.sin(o) + 50, l = this.currentRotation - this.config.minRotation, g = Math.abs(l) > 180 ? 1 : 0, p = `M50,50 L${r},${a} A48,48,0,${g},1,${c},${f} Z`;
297
+ this.pathElement.setAttribute("d", p);
298
+ } else
299
+ this.pathElement.setAttribute("d", "M50,2 A48,48,0,1,1,49.9,2 Z");
300
+ }
301
+ dispatchChangeEvent(t = "programmatic") {
302
+ const e = u.mapRange(
303
+ this.config.minValue,
304
+ this.config.maxValue,
305
+ 0,
306
+ 100,
307
+ this.currentValue
308
+ ), n = new CustomEvent("knob-change", {
309
+ detail: {
310
+ value: this.currentValue,
311
+ rotation: this.currentRotation,
312
+ percentage: e,
313
+ source: t
314
+ },
315
+ bubbles: !0
316
+ });
317
+ this.dispatchEvent(n);
318
+ }
319
+ // Public API
320
+ setValue(t) {
321
+ !this.valueToRotation || !this.pathElement || (this.currentValue = u.clamp(
322
+ t,
323
+ this.config.minValue,
324
+ this.config.maxValue
325
+ ), this.currentRotation = this.valueToRotation(this.currentValue), this.updateBorder(), this.dispatchChangeEvent());
326
+ }
327
+ /**
328
+ * Sets the knob value using a normalized 0-1 input, automatically handling
329
+ * the knob's range and curve transformation.
330
+ * @param normalizedValue - Value between 0 and 1
331
+ */
332
+ setValueNormalized(t) {
333
+ const { minRotation: e, maxRotation: n } = this.config, i = Math.max(0, Math.min(1, t)), s = e + i * (n - e), o = this.rotationToValue(s);
334
+ this.setValue(o);
335
+ }
336
+ /**
337
+ * Gets the current knob value as a normalized 0-1 value, accounting for
338
+ * the knob's range and curve. Inverse of setValueNormalized().
339
+ * @returns Normalized value between 0 and 1
340
+ */
341
+ getValueNormalized() {
342
+ return (this.currentRotation - this.config.minRotation) / (this.config.maxRotation - this.config.minRotation);
343
+ }
344
+ resetToDefault() {
345
+ this.setValue(this.config.defaultValue);
346
+ }
347
+ getValue() {
348
+ return this.currentValue;
349
+ }
350
+ setCurve(t) {
351
+ this.config.curve = t, this.createUtilityFunctions(), this.setValue(this.currentValue);
352
+ }
353
+ getCurve() {
354
+ return this.config.curve || 1;
355
+ }
356
+ setDisabled(t) {
357
+ t ? this.setAttribute("disabled", "") : this.removeAttribute("disabled");
358
+ }
359
+ isDisabled() {
360
+ return this.hasAttribute("disabled");
361
+ }
362
+ getPercentage() {
363
+ return u.mapRange(
364
+ this.config.minValue,
365
+ this.config.maxValue,
366
+ 0,
367
+ 100,
368
+ this.currentValue
369
+ );
370
+ }
371
+ // Property getters/setters for easier JS usage
372
+ get value() {
373
+ return this.getValue();
374
+ }
375
+ set value(t) {
376
+ this.setValue(t);
377
+ }
378
+ get disabled() {
379
+ return this.isDisabled();
380
+ }
381
+ set disabled(t) {
382
+ this.setDisabled(t);
383
+ }
384
+ };
385
+ h(u, "stylesInjected", !1);
386
+ let v = u;
387
+ const L = { KNOB: "knob-element" };
388
+ function w(m, d) {
389
+ typeof customElements > "u" || customElements.get(m) || customElements.define(m, d);
390
+ }
391
+ function C() {
392
+ w(L.KNOB, v);
393
+ }
394
+ export {
395
+ v as KnobElement,
396
+ w as defineElement,
397
+ C as registerKnobElement
398
+ };