@jadujoel/web-audio-clip-node 0.1.0 → 0.1.1

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.
Files changed (51) hide show
  1. package/dist/audio/ClipNode.js +312 -0
  2. package/dist/audio/processor-code.js +2 -0
  3. package/dist/audio/processor-kernel.js +861 -0
  4. package/dist/audio/processor.js +80 -0
  5. package/dist/audio/types.js +9 -0
  6. package/dist/audio/utils.js +128 -0
  7. package/dist/audio/version.d.ts +1 -0
  8. package/dist/audio/version.js +2 -0
  9. package/dist/audio/workletUrl.js +17 -0
  10. package/dist/components/AudioControl.js +99 -0
  11. package/dist/components/ContextMenu.js +73 -0
  12. package/dist/components/ControlSection.js +74 -0
  13. package/dist/components/DetuneControl.js +44 -0
  14. package/dist/components/DisplayPanel.js +6 -0
  15. package/dist/components/FilterControl.js +48 -0
  16. package/dist/components/GainControl.js +44 -0
  17. package/dist/components/PanControl.js +50 -0
  18. package/dist/components/PlaybackRateControl.js +44 -0
  19. package/dist/components/PlayheadSlider.js +20 -0
  20. package/dist/components/SnappableSlider.js +174 -0
  21. package/dist/components/TransportButtons.js +9 -0
  22. package/dist/controls/controlDefs.js +211 -0
  23. package/dist/controls/formatValueText.js +80 -0
  24. package/dist/controls/linkedControlPairs.js +51 -0
  25. package/dist/data/cache.js +17 -0
  26. package/dist/data/fileStore.js +39 -0
  27. package/dist/hooks/useClipNode.js +338 -0
  28. package/dist/lib-react.js +17 -19
  29. package/dist/lib.js +16 -44
  30. package/dist/store/clipStore.js +71 -0
  31. package/examples/README.md +10 -0
  32. package/examples/cdn-vanilla/README.md +13 -0
  33. package/examples/cdn-vanilla/index.html +61 -0
  34. package/examples/esm-bundler/README.md +8 -0
  35. package/examples/esm-bundler/index.html +12 -0
  36. package/examples/esm-bundler/package.json +15 -0
  37. package/examples/esm-bundler/src/main.ts +43 -0
  38. package/examples/react/README.md +10 -0
  39. package/examples/react/index.html +12 -0
  40. package/examples/react/package.json +21 -0
  41. package/examples/react/src/App.tsx +20 -0
  42. package/examples/react/src/main.tsx +9 -0
  43. package/examples/react/vite.config.ts +6 -0
  44. package/examples/self-hosted/README.md +11 -0
  45. package/examples/self-hosted/index.html +12 -0
  46. package/examples/self-hosted/package.json +16 -0
  47. package/examples/self-hosted/public/.gitkeep +1 -0
  48. package/examples/self-hosted/src/main.ts +46 -0
  49. package/package.json +3 -2
  50. package/dist/lib-react.js.map +0 -9
  51. package/dist/lib.js.map +0 -9
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo, useCallback, useId, useRef, useState } from "react";
3
+ import { presets } from "../audio/utils";
4
+ import { SnappableSlider } from "./SnappableSlider";
5
+ function formatPan(value) {
6
+ if (Math.abs(value) < 0.005)
7
+ return "C";
8
+ const pct = Math.round(Math.abs(value) * 100);
9
+ return value < 0 ? `L${pct}` : `R${pct}`;
10
+ }
11
+ function PanControlInner({ value, defaultValue, enabled, onChange, onToggle, }) {
12
+ const preset = presets.pan;
13
+ const min = preset.min ?? -1;
14
+ const max = preset.max ?? 1;
15
+ const labelId = useId();
16
+ const [isEditing, setIsEditing] = useState(false);
17
+ const [editText, setEditText] = useState("");
18
+ const inputRef = useRef(null);
19
+ const displayValue = formatPan(value);
20
+ const handleChange = useCallback((v) => {
21
+ if (!enabled)
22
+ return;
23
+ onChange(v);
24
+ }, [enabled, onChange]);
25
+ const startEditing = useCallback(() => {
26
+ setEditText(String(value));
27
+ setIsEditing(true);
28
+ queueMicrotask(() => inputRef.current?.select());
29
+ }, [value]);
30
+ const commitEdit = useCallback(() => {
31
+ setIsEditing(false);
32
+ const parsed = Number.parseFloat(editText);
33
+ if (Number.isFinite(parsed)) {
34
+ onChange(Math.min(Math.max(parsed, min), max));
35
+ }
36
+ }, [editText, min, max, onChange]);
37
+ const handleEditKeyDown = useCallback((e) => {
38
+ if (e.key === "Enter") {
39
+ e.preventDefault();
40
+ commitEdit();
41
+ }
42
+ else if (e.key === "Escape") {
43
+ e.preventDefault();
44
+ setIsEditing(false);
45
+ }
46
+ }, [commitEdit]);
47
+ const disabled = !enabled;
48
+ return (_jsxs("div", { className: `audio-control${disabled ? " audio-control--disabled" : ""}`, title: "-1 full left, 1 full right.", children: [_jsx("input", { type: "checkbox", className: "control-toggle", checked: enabled, onChange: (e) => onToggle(e.target.checked) }), _jsx("span", { className: "control-label", id: labelId, children: "Pan" }), _jsx(SnappableSlider, { min: min, max: max, value: value, skew: preset.skew ?? 1, defaultValue: defaultValue, ticks: preset.ticks ?? [], disabled: disabled, labelId: labelId, valueText: displayValue, onChange: handleChange }), isEditing ? (_jsx("input", { ref: inputRef, type: "text", className: "control-output control-output--editing", value: editText, onChange: (e) => setEditText(e.target.value), onBlur: commitEdit, onKeyDown: handleEditKeyDown })) : (_jsx("button", { type: "button", className: "control-output", onClick: startEditing, children: displayValue }))] }));
49
+ }
50
+ export const PanControl = memo(PanControlInner);
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo, useCallback, useId, useRef, useState } from "react";
3
+ import { presets } from "../audio/utils";
4
+ import { SnappableSlider } from "./SnappableSlider";
5
+ function PlaybackRateControlInner({ value, defaultValue, enabled, onChange, onToggle, }) {
6
+ const preset = presets.playbackRate;
7
+ const min = preset.min ?? -4;
8
+ const max = preset.max ?? 4;
9
+ const labelId = useId();
10
+ const [isEditing, setIsEditing] = useState(false);
11
+ const [editText, setEditText] = useState("");
12
+ const inputRef = useRef(null);
13
+ const displayValue = `${value.toFixed(2)}x`;
14
+ const handleChange = useCallback((v) => {
15
+ if (!enabled)
16
+ return;
17
+ onChange(v);
18
+ }, [enabled, onChange]);
19
+ const startEditing = useCallback(() => {
20
+ setEditText(String(value));
21
+ setIsEditing(true);
22
+ queueMicrotask(() => inputRef.current?.select());
23
+ }, [value]);
24
+ const commitEdit = useCallback(() => {
25
+ setIsEditing(false);
26
+ const parsed = Number.parseFloat(editText);
27
+ if (Number.isFinite(parsed)) {
28
+ onChange(Math.min(Math.max(parsed, min), max));
29
+ }
30
+ }, [editText, min, max, onChange]);
31
+ const handleEditKeyDown = useCallback((e) => {
32
+ if (e.key === "Enter") {
33
+ e.preventDefault();
34
+ commitEdit();
35
+ }
36
+ else if (e.key === "Escape") {
37
+ e.preventDefault();
38
+ setIsEditing(false);
39
+ }
40
+ }, [commitEdit]);
41
+ const disabled = !enabled;
42
+ return (_jsxs("div", { className: `audio-control${disabled ? " audio-control--disabled" : ""}`, title: "Playback speed. Negative for reverse.", children: [_jsx("input", { type: "checkbox", className: "control-toggle", checked: enabled, onChange: (e) => onToggle(e.target.checked) }), _jsx("span", { className: "control-label", id: labelId, children: "Rate" }), _jsx(SnappableSlider, { min: min, max: max, value: value, skew: preset.skew ?? 1, defaultValue: defaultValue, enableSnap: true, snaps: preset.snaps ?? [], ticks: preset.ticks ?? [], disabled: disabled, labelId: labelId, valueText: displayValue, onChange: handleChange }), isEditing ? (_jsx("input", { ref: inputRef, type: "text", className: "control-output control-output--editing", value: editText, onChange: (e) => setEditText(e.target.value), onBlur: commitEdit, onKeyDown: handleEditKeyDown })) : (_jsx("button", { type: "button", className: "control-output", onClick: startEditing, children: displayValue }))] }));
43
+ }
44
+ export const PlaybackRateControl = memo(PlaybackRateControlInner);
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo, useCallback, useId } from "react";
3
+ import { SAMPLE_RATE } from "../controls/controlDefs";
4
+ import { SnappableSlider } from "./SnappableSlider";
5
+ function formatTime(seconds) {
6
+ const m = Math.floor(seconds / 60);
7
+ const s = seconds % 60;
8
+ return `${m}:${s.toFixed(2).padStart(5, "0")}`;
9
+ }
10
+ function PlayheadSliderInner({ value, audioDuration, disabled = false, onChange, }) {
11
+ const labelId = useId();
12
+ const maxSamples = audioDuration != null ? audioDuration * SAMPLE_RATE : 0;
13
+ const currentSeconds = value / SAMPLE_RATE;
14
+ const durationSeconds = audioDuration ?? 0;
15
+ const handleChange = useCallback((v) => {
16
+ onChange(Math.floor(v));
17
+ }, [onChange]);
18
+ return (_jsxs("div", { className: "playhead-slider", children: [_jsx("span", { className: "playhead-label", id: labelId, children: "Playhead" }), _jsx(SnappableSlider, { min: 0, max: maxSamples, value: value, disabled: disabled || maxSamples === 0, labelId: labelId, valueText: formatTime(currentSeconds), onChange: handleChange }), _jsxs("span", { className: "playhead-time", children: [formatTime(currentSeconds), " / ", formatTime(durationSeconds)] })] }));
19
+ }
20
+ export const PlayheadSlider = memo(PlayheadSliderInner);
@@ -0,0 +1,174 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useMemo, useRef, useState } from "react";
3
+ export function SnappableSlider({ min, max, value, skew = 1, step, defaultValue, enableSnap = false, snaps = [], ticks = [], logarithmic = false, disabled = false, labelId, valueText, formatTick, onChange, }) {
4
+ const containerRef = useRef(null);
5
+ const isDragging = useRef(false);
6
+ const isOptionKeyHeld = useRef(false);
7
+ const dragBoundsRef = useRef(null);
8
+ const [altHeld, setAltHeld] = useState(false);
9
+ const resolvedStep = step ?? (max - min) / 100;
10
+ const getRatioFromValue = useCallback((v) => {
11
+ if (logarithmic) {
12
+ if (min <= 0 || max <= 0)
13
+ return 0;
14
+ const clamped = Math.max(v, min);
15
+ return Math.log(clamped / min) / Math.log(max / min);
16
+ }
17
+ const range = max - min;
18
+ if (range === 0)
19
+ return 0;
20
+ const normalized = (v - min) / range;
21
+ return skew === 1 ? normalized : Math.max(normalized, 0) ** skew;
22
+ }, [min, max, skew, logarithmic]);
23
+ const getValueFromRatio = useCallback((ratio) => {
24
+ if (logarithmic) {
25
+ if (min <= 0 || max <= 0)
26
+ return min;
27
+ return min * (max / min) ** ratio;
28
+ }
29
+ const adjusted = skew === 1 ? ratio : ratio ** (1 / skew);
30
+ return adjusted * (max - min) + min;
31
+ }, [min, max, skew, logarithmic]);
32
+ const getSnapped = useCallback((v) => {
33
+ if (!enableSnap || snaps.length === 0 || isOptionKeyHeld.current)
34
+ return v;
35
+ return snaps.reduce((closest, snap) => Math.abs(snap - v) < Math.abs(closest - v) ? snap : closest);
36
+ }, [enableSnap, snaps]);
37
+ const clampAndEmit = useCallback((raw) => {
38
+ const clamped = Math.min(Math.max(raw, min), max);
39
+ const snapped = getSnapped(clamped);
40
+ onChange?.(snapped);
41
+ }, [min, max, getSnapped, onChange]);
42
+ const updateFromClientX = useCallback((clientX) => {
43
+ const el = containerRef.current;
44
+ if (!el)
45
+ return;
46
+ const bounds = dragBoundsRef.current ?? el.getBoundingClientRect();
47
+ const { left, width } = bounds;
48
+ if (width <= 0)
49
+ return;
50
+ const ratio = Math.min(Math.max((clientX - left) / width, 0), 1);
51
+ const raw = getValueFromRatio(ratio);
52
+ const snapped = getSnapped(raw);
53
+ onChange?.(snapped);
54
+ }, [getValueFromRatio, getSnapped, onChange]);
55
+ const startDrag = useCallback((clientX, altKey) => {
56
+ if (disabled)
57
+ return;
58
+ const el = containerRef.current;
59
+ if (!el)
60
+ return;
61
+ dragBoundsRef.current = el.getBoundingClientRect();
62
+ isDragging.current = true;
63
+ isOptionKeyHeld.current = altKey;
64
+ setAltHeld(altKey);
65
+ updateFromClientX(clientX);
66
+ const handleMove = (e) => {
67
+ if (isOptionKeyHeld.current !== e.altKey) {
68
+ isOptionKeyHeld.current = e.altKey;
69
+ setAltHeld(e.altKey);
70
+ }
71
+ updateFromClientX(e.clientX);
72
+ };
73
+ const handleTouchMove = (e) => updateFromClientX(e.touches[0].clientX);
74
+ const handleUp = () => {
75
+ isDragging.current = false;
76
+ isOptionKeyHeld.current = false;
77
+ dragBoundsRef.current = null;
78
+ setAltHeld(false);
79
+ document.removeEventListener("mousemove", handleMove);
80
+ document.removeEventListener("mouseup", handleUp);
81
+ document.removeEventListener("touchmove", handleTouchMove);
82
+ document.removeEventListener("touchend", handleUp);
83
+ };
84
+ document.addEventListener("mousemove", handleMove);
85
+ document.addEventListener("mouseup", handleUp);
86
+ document.addEventListener("touchmove", handleTouchMove);
87
+ document.addEventListener("touchend", handleUp);
88
+ }, [updateFromClientX, disabled]);
89
+ const handleKeyDown = useCallback((e) => {
90
+ if (e.key === "Alt") {
91
+ isOptionKeyHeld.current = true;
92
+ setAltHeld(true);
93
+ return;
94
+ }
95
+ if (disabled)
96
+ return;
97
+ let newValue = value;
98
+ const s = resolvedStep;
99
+ const bigStep = s * 10;
100
+ switch (e.key) {
101
+ case "ArrowRight":
102
+ case "ArrowUp":
103
+ newValue = value + s;
104
+ break;
105
+ case "ArrowLeft":
106
+ case "ArrowDown":
107
+ newValue = value - s;
108
+ break;
109
+ case "PageUp":
110
+ newValue = value + bigStep;
111
+ break;
112
+ case "PageDown":
113
+ newValue = value - bigStep;
114
+ break;
115
+ case "Home":
116
+ newValue = min;
117
+ break;
118
+ case "End":
119
+ newValue = max;
120
+ break;
121
+ default:
122
+ return;
123
+ }
124
+ e.preventDefault();
125
+ clampAndEmit(newValue);
126
+ }, [value, resolvedStep, min, max, clampAndEmit, disabled]);
127
+ const handleKeyUp = useCallback((e) => {
128
+ if (e.key === "Alt") {
129
+ isOptionKeyHeld.current = false;
130
+ setAltHeld(false);
131
+ }
132
+ }, []);
133
+ const handleDoubleClick = useCallback(() => {
134
+ if (disabled)
135
+ return;
136
+ if (defaultValue !== undefined) {
137
+ onChange?.(defaultValue);
138
+ }
139
+ }, [defaultValue, onChange, disabled]);
140
+ const ratio = getRatioFromValue(value);
141
+ const pct = `${ratio * 100}%`;
142
+ const snapElements = useMemo(() => {
143
+ let prevRatio = -1;
144
+ return snaps.map((snap, i) => {
145
+ const r = getRatioFromValue(snap);
146
+ const show = i === 0 || r - prevRatio >= 0.05;
147
+ if (show)
148
+ prevRatio = r;
149
+ return (_jsxs("span", { children: [_jsx("span", { className: "slider-snap", style: { left: `${r * 100}%` } }), _jsx("span", { className: "slider-xval", style: { left: `${r * 100}%`, display: show ? "block" : "none" }, children: formatTick ? formatTick(snap) : snap })] }, snap));
150
+ });
151
+ }, [snaps, getRatioFromValue, formatTick]);
152
+ const tickElements = useMemo(() => {
153
+ let prevRatio = -1;
154
+ return ticks.map((tick) => {
155
+ const r = getRatioFromValue(tick);
156
+ const show = prevRatio < 0 || r - prevRatio >= 0.05;
157
+ if (show)
158
+ prevRatio = r;
159
+ return (_jsxs("span", { children: [_jsx("span", { className: "slider-tick", style: { left: `${r * 100}%` } }), _jsx("span", { className: "slider-xval", style: { left: `${r * 100}%`, display: show ? "block" : "none" }, children: formatTick ? formatTick(tick) : tick })] }, tick));
160
+ });
161
+ }, [ticks, getRatioFromValue, formatTick]);
162
+ const sliderClass = [
163
+ "snappable-slider",
164
+ disabled ? "snappable-slider--disabled" : "",
165
+ altHeld ? "snappable-slider--fine" : "",
166
+ ]
167
+ .filter(Boolean)
168
+ .join(" ");
169
+ return (_jsxs("div", { role: "slider", "aria-valuemin": min, "aria-valuemax": max, "aria-valuenow": value, "aria-valuetext": valueText, "aria-labelledby": labelId, "aria-disabled": disabled || undefined, tabIndex: disabled ? -1 : 0, ref: containerRef, className: sliderClass, onMouseDown: (e) => {
170
+ if (e.button !== 0)
171
+ return;
172
+ startDrag(e.clientX, e.altKey);
173
+ }, onTouchStart: (e) => startDrag(e.touches[0].clientX, false), onKeyDown: handleKeyDown, onKeyUp: handleKeyUp, onDoubleClick: handleDoubleClick, children: [_jsx("span", { className: "slider-track" }), _jsx("span", { className: "slider-fill", style: { width: pct } }), _jsx("span", { className: "slider-thumb", style: { left: pct } }), snapElements, tickElements] }));
174
+ }
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo } from "react";
3
+ function TransportButtonsInner({ nodeState, onStart, onStop, onPause, onResume, onDispose, onLog, onLoadSound, }) {
4
+ const cantStop = nodeState === "initial" ||
5
+ nodeState === "disposed" ||
6
+ nodeState === "ended";
7
+ return (_jsxs("section", { id: "buttons", children: [_jsxs("div", { className: "btn-group-primary", children: [_jsx("button", { type: "button", onClick: onLoadSound, children: "Load Sound" }), _jsx("button", { type: "button", onClick: onStart, disabled: nodeState === "started", "aria-disabled": nodeState === "started", children: "Start" }), _jsx("button", { type: "button", onClick: onStop, disabled: cantStop, "aria-disabled": cantStop, children: "Stop" }), _jsx("button", { type: "button", onClick: onPause, disabled: nodeState !== "started" && nodeState !== "resumed", "aria-disabled": nodeState !== "started" && nodeState !== "resumed", children: "Pause" }), _jsx("button", { type: "button", onClick: onResume, disabled: nodeState !== "paused", "aria-disabled": nodeState !== "paused", children: "Resume" })] }), _jsxs("div", { className: "btn-group-secondary", children: [_jsx("button", { type: "button", className: "btn-secondary", onClick: onLog, children: "Log State" }), _jsx("button", { type: "button", className: "btn-secondary", onClick: onDispose, children: "Dispose" })] })] }));
8
+ }
9
+ export const TransportButtons = memo(TransportButtonsInner);
@@ -0,0 +1,211 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Control definitions — shared configuration for all audio controls
3
+ // ---------------------------------------------------------------------------
4
+ export const DEFAULT_TEMPO = 120;
5
+ export const SAMPLE_RATE = 48000;
6
+ export const controlDefs = [
7
+ {
8
+ key: "offset",
9
+ label: "Offset",
10
+ min: 0,
11
+ max: 60,
12
+ defaultValue: 0,
13
+ snap: "bar",
14
+ hasSnap: true,
15
+ hasToggle: true,
16
+ hasMaxLock: true,
17
+ maxLockedByDefault: true,
18
+ title: "Start position in the buffer (seconds).",
19
+ },
20
+ {
21
+ key: "duration",
22
+ label: "Duration",
23
+ min: -1,
24
+ max: 60,
25
+ defaultValue: -1,
26
+ hasSnap: true,
27
+ hasToggle: true,
28
+ hasMaxLock: true,
29
+ maxLockedByDefault: true,
30
+ title: "How long to play before auto-stopping (seconds). -1 for full length.",
31
+ },
32
+ {
33
+ key: "startDelay",
34
+ label: "StartDelay",
35
+ min: 0,
36
+ max: 4,
37
+ defaultValue: 0,
38
+ snap: "beat",
39
+ hasSnap: true,
40
+ hasToggle: true,
41
+ title: "Delay before starting (seconds).",
42
+ },
43
+ {
44
+ key: "stopDelay",
45
+ label: "StopDelay",
46
+ min: 0,
47
+ max: 4,
48
+ defaultValue: 0,
49
+ snap: "beat",
50
+ hasSnap: true,
51
+ hasToggle: true,
52
+ title: "Delay before stopping (seconds).",
53
+ },
54
+ {
55
+ key: "fadeIn",
56
+ label: "FadeIn",
57
+ min: 0,
58
+ max: 60,
59
+ defaultValue: 0,
60
+ snap: "beat",
61
+ hasSnap: true,
62
+ hasToggle: true,
63
+ title: "Fade-in duration (seconds).",
64
+ },
65
+ {
66
+ key: "fadeOut",
67
+ label: "FadeOut",
68
+ min: 0,
69
+ max: 60,
70
+ defaultValue: 0,
71
+ snap: "beat",
72
+ hasSnap: true,
73
+ hasToggle: true,
74
+ title: "Fade-out duration (seconds).",
75
+ },
76
+ ];
77
+ export const loopControlDefs = [
78
+ {
79
+ key: "loopStart",
80
+ label: "Start",
81
+ min: 0,
82
+ max: 60,
83
+ defaultValue: 0,
84
+ snap: "bar",
85
+ hasSnap: true,
86
+ hasToggle: true,
87
+ hasMaxLock: true,
88
+ maxLockedByDefault: true,
89
+ },
90
+ {
91
+ key: "loopEnd",
92
+ label: "End",
93
+ min: 0,
94
+ max: 60,
95
+ defaultValue: 0,
96
+ snap: "bar",
97
+ hasSnap: true,
98
+ hasToggle: true,
99
+ hasMaxLock: true,
100
+ maxLockedByDefault: true,
101
+ },
102
+ {
103
+ key: "loopCrossfade",
104
+ label: "Crossfade",
105
+ min: 0,
106
+ max: 1,
107
+ defaultValue: 0,
108
+ snap: "beat",
109
+ hasSnap: true,
110
+ hasToggle: true,
111
+ },
112
+ ];
113
+ export const paramDefs = [
114
+ {
115
+ key: "playbackRate",
116
+ label: "PlaybackRate",
117
+ min: -2,
118
+ max: 2,
119
+ defaultValue: 1,
120
+ precision: 2,
121
+ preset: "playbackRate",
122
+ hasToggle: true,
123
+ title: "Playback speed. Negative for reverse.",
124
+ },
125
+ {
126
+ key: "detune",
127
+ label: "Detune",
128
+ min: -2400,
129
+ max: 2400,
130
+ defaultValue: 0,
131
+ precision: 4,
132
+ preset: "cents",
133
+ hasToggle: true,
134
+ title: "Pitch shift in cents.",
135
+ },
136
+ {
137
+ key: "gain",
138
+ label: "Gain",
139
+ min: -100,
140
+ max: 0,
141
+ defaultValue: 0,
142
+ precision: 3,
143
+ preset: "gain",
144
+ hasToggle: true,
145
+ title: "Amplitude in dB.",
146
+ },
147
+ {
148
+ key: "pan",
149
+ label: "Pan",
150
+ min: -1,
151
+ max: 1,
152
+ defaultValue: 0,
153
+ preset: "pan",
154
+ hasToggle: true,
155
+ title: "-1 full left, 1 full right.",
156
+ },
157
+ {
158
+ key: "lowpass",
159
+ label: "Lowpass",
160
+ min: 32,
161
+ max: 16384,
162
+ defaultValue: 16384,
163
+ preset: "hertz",
164
+ hasToggle: true,
165
+ title: "Lowpass cutoff frequency.",
166
+ },
167
+ {
168
+ key: "highpass",
169
+ label: "Highpass",
170
+ min: 32,
171
+ max: 16384,
172
+ defaultValue: 32,
173
+ preset: "hertz",
174
+ hasToggle: true,
175
+ title: "Highpass cutoff frequency.",
176
+ },
177
+ ];
178
+ /** Internal-only definition for playhead (not shown in UI). */
179
+ const playheadDef = {
180
+ key: "playhead",
181
+ label: "Playhead",
182
+ min: 0,
183
+ max: 480000,
184
+ defaultValue: 0,
185
+ precision: 1,
186
+ snap: "int",
187
+ title: "Current sample position of buffer playback.",
188
+ };
189
+ export const allDefs = [
190
+ playheadDef,
191
+ ...controlDefs,
192
+ ...loopControlDefs,
193
+ ...paramDefs,
194
+ ];
195
+ export function buildDefaults() {
196
+ const values = {};
197
+ const snaps = {};
198
+ const enabled = {};
199
+ const mins = {};
200
+ const maxs = {};
201
+ const maxLocked = {};
202
+ for (const d of allDefs) {
203
+ values[d.key] = d.defaultValue;
204
+ snaps[d.key] = d.snap ?? "none";
205
+ enabled[d.key] = true;
206
+ mins[d.key] = d.min;
207
+ maxs[d.key] = d.max;
208
+ maxLocked[d.key] = d.maxLockedByDefault ?? false;
209
+ }
210
+ return { values, snaps, enabled, mins, maxs, maxLocked };
211
+ }
@@ -0,0 +1,80 @@
1
+ export function formatValueText(value, key, snap, tempo) {
2
+ switch (key) {
3
+ case "gain":
4
+ return `${value.toFixed(1)} dB`;
5
+ case "lowpass":
6
+ case "highpass":
7
+ return `${Math.round(value)} Hz`;
8
+ case "detune":
9
+ return `${Math.round(value)} cents`;
10
+ case "pan":
11
+ if (value === 0)
12
+ return "center";
13
+ return value < 0
14
+ ? `${Math.abs(value).toFixed(2)} left`
15
+ : `${value.toFixed(2)} right`;
16
+ case "playbackRate":
17
+ return `${value.toFixed(2)}x`;
18
+ case "playhead":
19
+ return `sample ${Math.round(value)}`;
20
+ default:
21
+ break;
22
+ }
23
+ if (snap === "beat" || snap === "bar" || snap === "8th" || snap === "16th") {
24
+ const spb = 60 / tempo;
25
+ if (snap === "bar") {
26
+ const bars = value / (spb * 4);
27
+ return `${Math.round(bars)} bars`;
28
+ }
29
+ if (snap === "8th") {
30
+ const eighths = value / (spb / 2);
31
+ return `${Math.round(eighths)} 8ths`;
32
+ }
33
+ if (snap === "16th") {
34
+ const sixteenths = value / (spb / 4);
35
+ return `${Math.round(sixteenths)} 16ths`;
36
+ }
37
+ const beats = value / spb;
38
+ return `${Math.round(beats)} beats`;
39
+ }
40
+ if (snap === "integer") {
41
+ return `${Math.round(value)} s`;
42
+ }
43
+ return `${value.toPrecision(4)} s`;
44
+ }
45
+ export function formatTickLabel(value, key, snap, tempo) {
46
+ switch (key) {
47
+ case "gain":
48
+ return value.toFixed(1);
49
+ case "lowpass":
50
+ case "highpass":
51
+ return `${Math.round(value)}`;
52
+ case "detune":
53
+ return `${Math.round(value)}`;
54
+ case "pan":
55
+ if (value === 0)
56
+ return "C";
57
+ return value < 0
58
+ ? `${Math.abs(value).toFixed(2)}L`
59
+ : `${value.toFixed(2)}R`;
60
+ case "playbackRate":
61
+ return `${value.toFixed(2)}x`;
62
+ case "playhead":
63
+ return `${Math.round(value)}`;
64
+ default:
65
+ break;
66
+ }
67
+ if (snap === "beat" || snap === "bar" || snap === "8th" || snap === "16th") {
68
+ const spb = 60 / tempo;
69
+ if (snap === "bar")
70
+ return `${Math.round(value / (spb * 4))}`;
71
+ if (snap === "8th")
72
+ return `${Math.round(value / (spb / 2))}`;
73
+ if (snap === "16th")
74
+ return `${Math.round(value / (spb / 4))}`;
75
+ return `${Math.round(value / spb)}`;
76
+ }
77
+ if (snap === "integer")
78
+ return `${Math.round(value)}`;
79
+ return value.toPrecision(4);
80
+ }
@@ -0,0 +1,51 @@
1
+ export const transportLinkedControlPairs = [
2
+ {
3
+ key: "fadeOutStopDelay",
4
+ label: "Link StopDelay and FadeOut",
5
+ controls: ["stopDelay", "fadeOut"],
6
+ },
7
+ ];
8
+ export const loopLinkedControlPairs = [
9
+ {
10
+ key: "loopStartEnd",
11
+ label: "Link Start and End",
12
+ controls: ["loopStart", "loopEnd"],
13
+ },
14
+ ];
15
+ const allLinkedControlPairs = [
16
+ ...transportLinkedControlPairs,
17
+ ...loopLinkedControlPairs,
18
+ ];
19
+ export function buildLinkedControlPairDefaults() {
20
+ return {
21
+ fadeOutStopDelay: false,
22
+ loopStartEnd: false,
23
+ };
24
+ }
25
+ export function getLinkedControlPairForControl(controlKey) {
26
+ return allLinkedControlPairs.find((pair) => pair.controls[0] === controlKey || pair.controls[1] === controlKey);
27
+ }
28
+ export function getActiveLinkedControls(controlKey, linkedPairs) {
29
+ const pair = getLinkedControlPairForControl(controlKey);
30
+ if (pair && linkedPairs[pair.key]) {
31
+ return pair.controls;
32
+ }
33
+ return [controlKey];
34
+ }
35
+ export function getLinkedControlUpdates({ pair, changedKey, nextValue, values, mins, maxs, }) {
36
+ const [firstKey, secondKey] = pair.controls;
37
+ if (changedKey !== firstKey && changedKey !== secondKey) {
38
+ return { [changedKey]: nextValue };
39
+ }
40
+ const otherKey = changedKey === firstKey ? secondKey : firstKey;
41
+ const currentChanged = values[changedKey];
42
+ const currentOther = values[otherKey];
43
+ const requestedShift = nextValue - currentChanged;
44
+ const minShift = Math.max(mins[changedKey] - currentChanged, mins[otherKey] - currentOther);
45
+ const maxShift = Math.min(maxs[changedKey] - currentChanged, maxs[otherKey] - currentOther);
46
+ const appliedShift = Math.min(Math.max(requestedShift, minShift), maxShift);
47
+ return {
48
+ [changedKey]: currentChanged + appliedShift,
49
+ [otherKey]: currentOther + appliedShift,
50
+ };
51
+ }
@@ -0,0 +1,17 @@
1
+ const cachePromise = caches.open("sound-files");
2
+ export async function loadFromCache(url) {
3
+ const startTime = performance.now();
4
+ const cache = await cachePromise;
5
+ const response = await cache.match(url);
6
+ if (response) {
7
+ console.log(`[cache] Loaded ${url} from CacheStorage in ${(performance.now() - startTime).toFixed(0)}ms`);
8
+ return response.arrayBuffer();
9
+ }
10
+ const fetched = await fetch(url);
11
+ if (fetched.ok) {
12
+ cache.put(url, fetched.clone()).catch(() => { });
13
+ console.log(`[cache] Loaded ${url} from network in ${(performance.now() - startTime).toFixed(0)}ms`);
14
+ return fetched.arrayBuffer();
15
+ }
16
+ return undefined;
17
+ }