@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,80 @@
1
+ // AudioWorklet processor — runs in AudioWorkletGlobalScope
2
+ // Bundled separately and served at /processor.js
3
+ // This is a thin shell — all DSP logic lives in processor-kernel.ts
4
+ import { createFilterState, getProperties, handleProcessorMessage, processBlock, } from "./processor-kernel";
5
+ import { State } from "./types";
6
+ class ClipProcessor extends AudioWorkletProcessor {
7
+ static get parameterDescriptors() {
8
+ return [
9
+ {
10
+ name: "playbackRate",
11
+ automationRate: "a-rate",
12
+ defaultValue: 1.0,
13
+ },
14
+ { name: "detune", automationRate: "a-rate", defaultValue: 0 },
15
+ {
16
+ name: "gain",
17
+ automationRate: "a-rate",
18
+ defaultValue: 1,
19
+ minValue: 0,
20
+ },
21
+ { name: "pan", automationRate: "a-rate", defaultValue: 0 },
22
+ {
23
+ name: "highpass",
24
+ automationRate: "a-rate",
25
+ defaultValue: 20,
26
+ minValue: 20,
27
+ maxValue: 20000,
28
+ },
29
+ {
30
+ name: "lowpass",
31
+ automationRate: "a-rate",
32
+ defaultValue: 20000,
33
+ minValue: 20,
34
+ maxValue: 20000,
35
+ },
36
+ ];
37
+ }
38
+ properties;
39
+ filterState = {
40
+ lowpass: createFilterState(),
41
+ highpass: createFilterState(),
42
+ };
43
+ lastFrameTime = 0;
44
+ constructor(options) {
45
+ super(options);
46
+ this.properties = getProperties(options?.processorOptions, sampleRate);
47
+ this.port.onmessage = (ev) => {
48
+ const messages = handleProcessorMessage(this.properties, ev.data, currentTime, sampleRate);
49
+ for (const msg of messages)
50
+ this.port.postMessage(msg);
51
+ if (this.properties.state === State.Disposed)
52
+ this.port.close();
53
+ };
54
+ }
55
+ process(_inputs, outputs, parameters) {
56
+ try {
57
+ const result = processBlock(this.properties, outputs, parameters, { currentTime, currentFrame, sampleRate }, this.filterState);
58
+ for (const msg of result.messages)
59
+ this.port.postMessage(msg);
60
+ // Frame reporting
61
+ const timeTaken = currentTime - this.lastFrameTime;
62
+ this.lastFrameTime = currentTime;
63
+ this.port.postMessage({
64
+ type: "frame",
65
+ data: [
66
+ currentTime,
67
+ currentFrame,
68
+ Math.floor(this.properties.playhead),
69
+ timeTaken * 1000,
70
+ ],
71
+ });
72
+ return result.keepAlive;
73
+ }
74
+ catch (e) {
75
+ console.log(e);
76
+ return true;
77
+ }
78
+ }
79
+ }
80
+ registerProcessor("ClipProcessor", ClipProcessor);
@@ -0,0 +1,9 @@
1
+ export const State = {
2
+ Initial: 0,
3
+ Started: 1,
4
+ Stopped: 2,
5
+ Paused: 3,
6
+ Scheduled: 4,
7
+ Ended: 5,
8
+ Disposed: 6,
9
+ };
@@ -0,0 +1,128 @@
1
+ export function dbFromLin(lin) {
2
+ return Math.max(20 * Math.log10(lin), -1000);
3
+ }
4
+ export function linFromDb(db) {
5
+ return 10 ** (db / 20);
6
+ }
7
+ const TEMPO_RELATIVE_SNAPS = ["beat", "bar", "8th", "16th"];
8
+ function clamp(value, min, max) {
9
+ return Math.min(Math.max(value, min), max);
10
+ }
11
+ export function isTempoRelativeSnap(snap) {
12
+ return TEMPO_RELATIVE_SNAPS.includes(snap);
13
+ }
14
+ export function getTempoSnapInterval(snap, tempo) {
15
+ if (!Number.isFinite(tempo) || tempo <= 0)
16
+ return null;
17
+ const secondsPerBeat = 60 / tempo;
18
+ switch (snap) {
19
+ case "beat":
20
+ return secondsPerBeat;
21
+ case "bar":
22
+ return secondsPerBeat * 4;
23
+ case "8th":
24
+ return secondsPerBeat / 2;
25
+ case "16th":
26
+ return secondsPerBeat / 4;
27
+ default:
28
+ return null;
29
+ }
30
+ }
31
+ export function remapTempoRelativeValue(value, snap, oldTempo, newTempo, min, max) {
32
+ if (!isTempoRelativeSnap(snap)) {
33
+ return clamp(value, min, max);
34
+ }
35
+ if (value < 0) {
36
+ return clamp(value, min, max);
37
+ }
38
+ const oldInterval = getTempoSnapInterval(snap, oldTempo);
39
+ const newInterval = getTempoSnapInterval(snap, newTempo);
40
+ if (oldInterval == null || newInterval == null) {
41
+ return clamp(value, min, max);
42
+ }
43
+ const count = Math.round(value / oldInterval);
44
+ return clamp(count * newInterval, min, max);
45
+ }
46
+ export function getSnappedValue(value, snap, tempo) {
47
+ const interval = getTempoSnapInterval(snap, tempo);
48
+ if (interval != null) {
49
+ return Math.round(value / interval) * interval;
50
+ }
51
+ switch (snap) {
52
+ case "int":
53
+ return Math.round(value);
54
+ default:
55
+ return value;
56
+ }
57
+ }
58
+ export const presets = {
59
+ hertz: {
60
+ snaps: [32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384],
61
+ ticks: [64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384],
62
+ min: 32,
63
+ max: 16384,
64
+ logarithmic: true,
65
+ },
66
+ decibel: {
67
+ ticks: [-48, -24, -12, -6, -3, 0],
68
+ min: -60,
69
+ max: 0,
70
+ skew: 1,
71
+ },
72
+ cents: {
73
+ snaps: Array.from({ length: 49 }, (_, i) => (i - 24) * 100), // semitones: -2400..2400 by 100
74
+ ticks: [-2400, -1200, 0, 1200, 2400],
75
+ min: -2400,
76
+ max: 2400,
77
+ skew: 1,
78
+ step: 1,
79
+ },
80
+ playbackRate: {
81
+ snaps: [-2, -1, -0.5, 0, 0.5, 1, 1.5, 2],
82
+ ticks: [-2, -1, 0, 1, 2],
83
+ min: -2,
84
+ max: 2,
85
+ skew: 1,
86
+ },
87
+ gain: {
88
+ snaps: [-60, -48, -36, -24, -18, -12, -9, -6, -3, -1, 0],
89
+ ticks: [-48, -24, -12, -6, -3, 0],
90
+ min: -100,
91
+ max: 0,
92
+ skew: 6,
93
+ },
94
+ pan: {
95
+ snaps: [-1, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1],
96
+ ticks: [-1, -0.5, 0, 0.5, 1],
97
+ min: -1,
98
+ max: 1,
99
+ skew: 1,
100
+ },
101
+ };
102
+ export function float32ArrayFromAudioBuffer(buffer) {
103
+ return buffer.numberOfChannels === 1
104
+ ? [buffer.getChannelData(0)]
105
+ : [buffer.getChannelData(0), buffer.getChannelData(1)];
106
+ }
107
+ export function audioBufferFromFloat32Array(context, data) {
108
+ if (!data || data.length === 0)
109
+ return undefined;
110
+ const buffer = context.createBuffer(data.length, data[0].length, context.sampleRate);
111
+ for (let i = 0; i < data.length; i++) {
112
+ buffer.copyToChannel(new Float32Array(data[i]), i);
113
+ }
114
+ return buffer;
115
+ }
116
+ export function generateSnapPoints(snap, tempo, min, max) {
117
+ const interval = getTempoSnapInterval(snap, tempo) ?? (snap === "int" ? 1 : null);
118
+ if (interval == null)
119
+ return [];
120
+ if (interval <= 0)
121
+ return [];
122
+ const points = [];
123
+ const start = Math.ceil(min / interval) * interval;
124
+ for (let v = start; v <= max; v += interval) {
125
+ points.push(Math.round(v * 1e10) / 1e10);
126
+ }
127
+ return points;
128
+ }
@@ -0,0 +1 @@
1
+ export declare const VERSION = "0.1.1";
@@ -0,0 +1,2 @@
1
+ // AUTO-GENERATED — do not edit. Run 'bun run build:lib' to regenerate.
2
+ export const VERSION = "0.1.1";
@@ -0,0 +1,17 @@
1
+ import { processorCode } from "./processor-code";
2
+ import { VERSION } from "./version";
3
+ const PACKAGE_NAME = "@jadujoel/web-audio-clip-node";
4
+ const PACKAGE_VERSION = VERSION;
5
+ /** Blob URL from embedded processor code. Zero-config, default for npm users. */
6
+ export function getProcessorBlobUrl() {
7
+ const blob = new Blob([processorCode], { type: "text/javascript" });
8
+ return URL.createObjectURL(blob);
9
+ }
10
+ /** jsDelivr CDN URL. For script-tag / no-bundler usage. */
11
+ export function getProcessorCdnUrl(version = PACKAGE_VERSION) {
12
+ return `https://cdn.jsdelivr.net/npm/${PACKAGE_NAME}@${version}/dist/processor.js`;
13
+ }
14
+ /** Custom URL relative to a base. For self-hosted processor.js. */
15
+ export function getProcessorModuleUrl(baseUrl = document.baseURI) {
16
+ return new URL("./processor.js", baseUrl).toString();
17
+ }
@@ -0,0 +1,99 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo, useCallback, useId, useMemo, useRef, useState } from "react";
3
+ import { generateSnapPoints, getSnappedValue, presets } from "../audio/utils";
4
+ import { formatTickLabel, formatValueText } from "../controls/formatValueText";
5
+ import { ContextMenu } from "./ContextMenu";
6
+ import { SnappableSlider } from "./SnappableSlider";
7
+ function AudioControlInner({ label, controlKey, min: propMin, max: propMax, value, defaultValue, step, tempo = 120, snap = "none", preset, title, enabled = true, hasToggle = false, hasSnap = false, hasMaxLock = false, audioDuration, maxLocked = false, onChange, onToggle, onSnapChange, onMinChange, onMaxChange, onMaxLockedChange, }) {
8
+ const [isEditing, setIsEditing] = useState(false);
9
+ const [editText, setEditText] = useState("");
10
+ const [ctxMenu, setCtxMenu] = useState(null);
11
+ const inputRef = useRef(null);
12
+ const labelId = useId();
13
+ const presetConfig = preset ? presets[preset] : undefined;
14
+ const resolvedMin = presetConfig?.min ?? propMin;
15
+ const resolvedMax = presetConfig?.max ?? propMax;
16
+ const tempoSnaps = snap !== "none" && !preset
17
+ ? generateSnapPoints(snap, tempo, resolvedMin, resolvedMax)
18
+ : [];
19
+ const resolvedSnaps = presetConfig?.snaps ?? tempoSnaps;
20
+ const resolvedTicks = presetConfig?.ticks ?? [];
21
+ const resolvedSkew = presetConfig?.skew ?? 1;
22
+ const resolvedStep = step ?? presetConfig?.step;
23
+ const resolvedLogarithmic = presetConfig?.logarithmic ?? false;
24
+ const handleSliderChange = useCallback((rawValue) => {
25
+ if (!enabled)
26
+ return;
27
+ const snapped = getSnappedValue(rawValue, snap, tempo);
28
+ onChange?.(snapped);
29
+ }, [snap, tempo, onChange, enabled]);
30
+ const displayValue = formatValueText(value, controlKey, snap, tempo);
31
+ const tickFormatter = useMemo(() => {
32
+ if (preset || !controlKey)
33
+ return undefined;
34
+ return (v) => formatTickLabel(v, controlKey, snap, tempo);
35
+ }, [preset, controlKey, snap, tempo]);
36
+ const startEditing = useCallback(() => {
37
+ setEditText(String(value));
38
+ setIsEditing(true);
39
+ queueMicrotask(() => {
40
+ inputRef.current?.select();
41
+ });
42
+ }, [value]);
43
+ const commitEdit = useCallback(() => {
44
+ setIsEditing(false);
45
+ const parsed = Number.parseFloat(editText);
46
+ if (Number.isFinite(parsed)) {
47
+ const clamped = Math.min(Math.max(parsed, resolvedMin), resolvedMax);
48
+ onChange?.(clamped);
49
+ }
50
+ }, [editText, resolvedMin, resolvedMax, onChange]);
51
+ const cancelEdit = useCallback(() => {
52
+ setIsEditing(false);
53
+ }, []);
54
+ const handleEditKeyDown = useCallback((e) => {
55
+ if (e.key === "Enter") {
56
+ e.preventDefault();
57
+ commitEdit();
58
+ }
59
+ else if (e.key === "Escape") {
60
+ e.preventDefault();
61
+ cancelEdit();
62
+ }
63
+ }, [commitEdit, cancelEdit]);
64
+ const handleContextMenu = useCallback((e) => {
65
+ if (!hasSnap)
66
+ return;
67
+ e.preventDefault();
68
+ setCtxMenu({ x: e.clientX, y: e.clientY });
69
+ }, [hasSnap]);
70
+ return (_jsxs("div", { className: `audio-control${hasToggle && !enabled ? " audio-control--disabled" : ""}`, title: title, onContextMenu: handleContextMenu, children: [hasToggle && (_jsx("input", { type: "checkbox", className: "control-toggle", checked: enabled, onChange: (e) => onToggle?.(e.target.checked) })), !hasToggle && _jsx("span", { className: "control-toggle-placeholder" }), _jsx("span", { className: "control-label", id: labelId, children: label }), _jsx(SnappableSlider, { min: resolvedMin, max: resolvedMax, value: value, skew: resolvedSkew, step: resolvedStep, defaultValue: defaultValue, enableSnap: snap !== "none", snaps: resolvedSnaps, ticks: resolvedTicks, logarithmic: resolvedLogarithmic, disabled: hasToggle && !enabled, labelId: labelId, valueText: displayValue, formatTick: tickFormatter, onChange: handleSliderChange }), 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 })), ctxMenu && (_jsx(ContextMenu, { x: ctxMenu.x, y: ctxMenu.y, snap: snap, snapMode: preset ? "preset" : "tempo", min: propMin, max: propMax, maxLocked: maxLocked, showMaxLock: hasMaxLock, audioDuration: audioDuration ?? null, onSnapChange: (s) => {
71
+ onSnapChange?.(s);
72
+ }, onMinChange: (v) => onMinChange?.(v), onMaxChange: (v) => onMaxChange?.(v), onMaxLockedChange: (locked) => onMaxLockedChange?.(locked), onClose: () => setCtxMenu(null) }))] }));
73
+ }
74
+ function areAudioControlPropsEqual(prev, next) {
75
+ return (prev.label === next.label &&
76
+ prev.controlKey === next.controlKey &&
77
+ prev.min === next.min &&
78
+ prev.max === next.max &&
79
+ prev.value === next.value &&
80
+ prev.defaultValue === next.defaultValue &&
81
+ prev.step === next.step &&
82
+ prev.tempo === next.tempo &&
83
+ prev.snap === next.snap &&
84
+ prev.preset === next.preset &&
85
+ prev.title === next.title &&
86
+ prev.enabled === next.enabled &&
87
+ prev.hasToggle === next.hasToggle &&
88
+ prev.hasSnap === next.hasSnap &&
89
+ prev.hasMaxLock === next.hasMaxLock &&
90
+ prev.audioDuration === next.audioDuration &&
91
+ prev.maxLocked === next.maxLocked &&
92
+ prev.onChange === next.onChange &&
93
+ prev.onToggle === next.onToggle &&
94
+ prev.onSnapChange === next.onSnapChange &&
95
+ prev.onMinChange === next.onMinChange &&
96
+ prev.onMaxChange === next.onMaxChange &&
97
+ prev.onMaxLockedChange === next.onMaxLockedChange);
98
+ }
99
+ export const AudioControl = memo(AudioControlInner, areAudioControlPropsEqual);
@@ -0,0 +1,73 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef } from "react";
3
+ import { createPortal } from "react-dom";
4
+ const SNAP_OPTIONS = [
5
+ { value: "none", label: "None" },
6
+ { value: "beat", label: "Beat" },
7
+ { value: "bar", label: "Bar" },
8
+ { value: "8th", label: "8th" },
9
+ { value: "16th", label: "16th" },
10
+ { value: "int", label: "Integer" },
11
+ ];
12
+ export function ContextMenu({ x, y, snap, snapMode = "tempo", min, max, maxLocked = false, showMaxLock = true, audioDuration, onSnapChange, onMinChange, onMaxChange, onMaxLockedChange, onClose, }) {
13
+ const menuRef = useRef(null);
14
+ // Close on click outside or Escape
15
+ useEffect(() => {
16
+ const handleClick = (e) => {
17
+ if (menuRef.current && !menuRef.current.contains(e.target)) {
18
+ onClose();
19
+ }
20
+ };
21
+ const handleKey = (e) => {
22
+ if (e.key === "Escape")
23
+ onClose();
24
+ };
25
+ document.addEventListener("mousedown", handleClick);
26
+ document.addEventListener("keydown", handleKey);
27
+ return () => {
28
+ document.removeEventListener("mousedown", handleClick);
29
+ document.removeEventListener("keydown", handleKey);
30
+ };
31
+ }, [onClose]);
32
+ // Adjust position to stay within viewport
33
+ useEffect(() => {
34
+ const el = menuRef.current;
35
+ if (!el)
36
+ return;
37
+ const rect = el.getBoundingClientRect();
38
+ if (rect.right > window.innerWidth) {
39
+ el.style.left = `${window.innerWidth - rect.width - 8}px`;
40
+ }
41
+ if (rect.bottom > window.innerHeight) {
42
+ el.style.top = `${window.innerHeight - rect.height - 8}px`;
43
+ }
44
+ }, []);
45
+ const handleSnapClick = useCallback((value) => {
46
+ onSnapChange(value);
47
+ }, [onSnapChange]);
48
+ const handleMinCommit = useCallback((e) => {
49
+ const input = e.currentTarget;
50
+ const parsed = Number.parseFloat(input.value);
51
+ if (Number.isFinite(parsed)) {
52
+ onMinChange(parsed);
53
+ }
54
+ }, [onMinChange]);
55
+ const handleMaxCommit = useCallback((e) => {
56
+ const input = e.currentTarget;
57
+ const parsed = Number.parseFloat(input.value);
58
+ if (Number.isFinite(parsed)) {
59
+ onMaxChange(parsed);
60
+ }
61
+ }, [onMaxChange]);
62
+ const handleInputKeyDown = useCallback((commit) => (e) => {
63
+ if (e.key === "Enter") {
64
+ e.preventDefault();
65
+ commit(e);
66
+ }
67
+ else if (e.key === "Escape") {
68
+ e.preventDefault();
69
+ onClose();
70
+ }
71
+ }, [onClose]);
72
+ return createPortal(_jsxs("div", { ref: menuRef, className: "context-menu", style: { left: x, top: y }, role: "menu", children: [_jsx("div", { className: "context-menu__section-label", children: "Snap" }), snapMode === "preset" ? (_jsxs("label", { className: "context-menu__field", children: [_jsx("input", { type: "checkbox", className: "control-toggle", checked: snap !== "none", onChange: (e) => onSnapChange(e.target.checked ? "preset" : "none") }), "Enable snap"] })) : (SNAP_OPTIONS.map((opt) => (_jsxs("button", { type: "button", className: `context-menu__item${snap === opt.value ? " context-menu__item--active" : ""}`, role: "menuitemradio", "aria-checked": snap === opt.value, onClick: () => handleSnapClick(opt.value), children: [_jsx("span", { className: "context-menu__radio", children: snap === opt.value ? "●" : "○" }), opt.label] }, opt.value)))), _jsx("div", { className: "context-menu__divider" }), _jsx("div", { className: "context-menu__section-label", children: "Range" }), _jsxs("label", { className: "context-menu__field", children: ["Min:", _jsx("input", { type: "number", className: "context-menu__input", defaultValue: min, step: "any", onBlur: handleMinCommit, onKeyDown: handleInputKeyDown(handleMinCommit) })] }), _jsxs("label", { className: "context-menu__field", children: ["Max:", _jsx("input", { type: "number", className: "context-menu__input", defaultValue: max, step: "any", disabled: maxLocked && audioDuration != null, onBlur: handleMaxCommit, onKeyDown: handleInputKeyDown(handleMaxCommit) })] }), showMaxLock && (_jsxs("label", { className: "context-menu__field", children: [_jsx("input", { type: "checkbox", className: "control-toggle", checked: maxLocked, onChange: (e) => onMaxLockedChange?.(e.target.checked) }), "Max = file length"] }))] }), document.body);
73
+ }
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo, useId } from "react";
3
+ import { AudioControl } from "./AudioControl";
4
+ /** Build a set of control keys that belong to a linked pair,
5
+ * and a map from the first control key to its pair def. */
6
+ function buildPairMaps(pairs) {
7
+ const pairByFirst = new Map();
8
+ const pairedKeys = new Set();
9
+ if (pairs) {
10
+ for (const pair of pairs) {
11
+ pairByFirst.set(pair.controls[0], pair);
12
+ pairedKeys.add(pair.controls[0]);
13
+ pairedKeys.add(pair.controls[1]);
14
+ }
15
+ }
16
+ return { pairByFirst, pairedKeys };
17
+ }
18
+ function renderAudioControl(def, props) {
19
+ const { mins, maxs, maxLocked, audioDuration, values, tempo, snaps, enabled, onValueChange, onToggle, onSnapChange, onMinChange, onMaxChange, onMaxLockedChange, } = props;
20
+ return (_jsx(AudioControl, { label: def.label, controlKey: def.key, min: mins[def.key] ?? def.min, max: maxLocked[def.key] && audioDuration != null
21
+ ? audioDuration
22
+ : (maxs[def.key] ?? def.max), value: values[def.key], defaultValue: def.defaultValue, tempo: tempo, snap: snaps[def.key], preset: def.preset, title: def.title, enabled: enabled[def.key], hasToggle: def.hasToggle, hasSnap: def.hasSnap, hasMaxLock: def.hasMaxLock, audioDuration: audioDuration, maxLocked: maxLocked[def.key] ?? true, onChange: (v) => onValueChange(def.key, v), onToggle: (on) => onToggle(def.key, on), onSnapChange: (s) => onSnapChange(def.key, s), onMinChange: (v) => onMinChange(def.key, v), onMaxChange: (v) => onMaxChange(def.key, v), onMaxLockedChange: (locked) => onMaxLockedChange(def.key, locked) }, def.key));
23
+ }
24
+ function ControlSectionInner({ legend, defs, linked, linkedPairs, onLinkedChange, ...controlProps }) {
25
+ const sectionId = useId();
26
+ const { pairByFirst, pairedKeys } = buildPairMaps(linkedPairs);
27
+ const items = [];
28
+ let i = 0;
29
+ while (i < defs.length) {
30
+ const def = defs[i];
31
+ const pair = pairByFirst.get(def.key);
32
+ if (pair) {
33
+ // Find the second control in the pair
34
+ const secondDef = defs.find((d) => d.key === pair.controls[1]);
35
+ const isLinked = linked?.[pair.key] ?? false;
36
+ const inputId = `${sectionId}-${pair.key}`;
37
+ items.push(_jsxs("div", { className: `control-link-group${isLinked ? " control-link-group--active" : ""}`, children: [_jsxs("div", { className: "control-link-bracket", children: [_jsx("span", { className: "control-link-line" }), _jsx("button", { type: "button", id: inputId, className: "control-link-btn", "aria-pressed": isLinked, "aria-label": pair.label, title: pair.label, onClick: () => onLinkedChange?.(pair.key, !isLinked), children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M6.5 4.5h-1A2.5 2.5 0 0 0 3 7v2a2.5 2.5 0 0 0 2.5 2.5h1m3-7h1A2.5 2.5 0 0 1 13 7v2a2.5 2.5 0 0 1-2.5 2.5h-1M5.5 8h5", stroke: "currentColor", strokeWidth: "1.3", strokeLinecap: "round" }) }) }), _jsx("span", { className: "control-link-line" })] }), _jsxs("div", { className: "control-link-controls", children: [renderAudioControl(def, controlProps), secondDef && renderAudioControl(secondDef, controlProps)] })] }, `link-${pair.key}`));
38
+ // Skip past both controls in the pair
39
+ i += 1;
40
+ if (secondDef && defs[i]?.key === secondDef.key) {
41
+ i += 1;
42
+ }
43
+ continue;
44
+ }
45
+ // Not part of a pair (or is a second control already rendered above)
46
+ if (!pairedKeys.has(def.key)) {
47
+ items.push(renderAudioControl(def, controlProps));
48
+ }
49
+ i += 1;
50
+ }
51
+ return (_jsxs("fieldset", { className: "control-group", children: [_jsx("legend", { children: legend }), items] }));
52
+ }
53
+ function areControlSectionPropsEqual(prev, next) {
54
+ return (prev.legend === next.legend &&
55
+ prev.defs === next.defs &&
56
+ prev.values === next.values &&
57
+ prev.snaps === next.snaps &&
58
+ prev.enabled === next.enabled &&
59
+ prev.mins === next.mins &&
60
+ prev.maxs === next.maxs &&
61
+ prev.maxLocked === next.maxLocked &&
62
+ prev.linked === next.linked &&
63
+ prev.linkedPairs === next.linkedPairs &&
64
+ prev.tempo === next.tempo &&
65
+ prev.audioDuration === next.audioDuration &&
66
+ prev.onValueChange === next.onValueChange &&
67
+ prev.onToggle === next.onToggle &&
68
+ prev.onLinkedChange === next.onLinkedChange &&
69
+ prev.onSnapChange === next.onSnapChange &&
70
+ prev.onMinChange === next.onMinChange &&
71
+ prev.onMaxChange === next.onMaxChange &&
72
+ prev.onMaxLockedChange === next.onMaxLockedChange);
73
+ }
74
+ export const ControlSection = memo(ControlSectionInner, areControlSectionPropsEqual);
@@ -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 DetuneControlInner({ value, defaultValue, enabled, onChange, onToggle, }) {
6
+ const preset = presets.cents;
7
+ const min = preset.min ?? -2400;
8
+ const max = preset.max ?? 2400;
9
+ const labelId = useId();
10
+ const [isEditing, setIsEditing] = useState(false);
11
+ const [editText, setEditText] = useState("");
12
+ const inputRef = useRef(null);
13
+ const displayValue = `${Math.round(value)} cents`;
14
+ const handleChange = useCallback((v) => {
15
+ if (!enabled)
16
+ return;
17
+ onChange(v);
18
+ }, [enabled, onChange]);
19
+ const startEditing = useCallback(() => {
20
+ setEditText(String(Math.round(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: "Pitch shift in cents.", children: [_jsx("input", { type: "checkbox", className: "control-toggle", checked: enabled, onChange: (e) => onToggle(e.target.checked) }), _jsx("span", { className: "control-label", id: labelId, children: "Detune" }), _jsx(SnappableSlider, { min: min, max: max, value: value, skew: preset.skew ?? 1, step: preset.step ?? 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 }))] }));
43
+ }
44
+ export const DetuneControl = memo(DetuneControlInner);
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo } from "react";
3
+ function DisplayPanelInner({ nodeState, statusMessage, soundName, currentTime, currentFrame, timesLooped, latency, timeTaken, }) {
4
+ return (_jsxs("section", { id: "display", children: [statusMessage && (_jsx("div", { className: "status-message", role: "alert", children: statusMessage })), _jsx("code", { children: "Sound:" }), _jsx("output", { children: soundName ?? "none" }), _jsx("code", { children: "State:" }), _jsx("output", { children: nodeState }), _jsx("code", { children: "Time:" }), _jsx("output", { children: currentTime }), _jsx("code", { children: "Loops:" }), _jsx("output", { children: timesLooped }), _jsxs("details", { className: "display-details", children: [_jsx("summary", { children: "Debug" }), _jsxs("div", { className: "display-details__row", children: [_jsx("code", { children: "Frame:" }), _jsx("output", { children: currentFrame }), _jsx("code", { children: "Latency:" }), _jsx("output", { children: latency }), _jsx("code", { children: "TimeTaken:" }), _jsx("output", { children: timeTaken })] })] })] }));
5
+ }
6
+ export const DisplayPanel = memo(DisplayPanelInner);
@@ -0,0 +1,48 @@
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 formatHz(value) {
6
+ if (value >= 1000)
7
+ return `${(value / 1000).toFixed(1)} kHz`;
8
+ return `${Math.round(value)} Hz`;
9
+ }
10
+ function FilterControlInner({ label, controlKey: _controlKey, value, defaultValue, enabled, onChange, onToggle, }) {
11
+ const preset = presets.hertz;
12
+ const min = preset.min ?? 32;
13
+ const max = preset.max ?? 16384;
14
+ const labelId = useId();
15
+ const [isEditing, setIsEditing] = useState(false);
16
+ const [editText, setEditText] = useState("");
17
+ const inputRef = useRef(null);
18
+ const handleChange = useCallback((v) => {
19
+ if (!enabled)
20
+ return;
21
+ onChange(v);
22
+ }, [enabled, onChange]);
23
+ const startEditing = useCallback(() => {
24
+ setEditText(String(Math.round(value)));
25
+ setIsEditing(true);
26
+ queueMicrotask(() => inputRef.current?.select());
27
+ }, [value]);
28
+ const commitEdit = useCallback(() => {
29
+ setIsEditing(false);
30
+ const parsed = Number.parseFloat(editText);
31
+ if (Number.isFinite(parsed)) {
32
+ onChange(Math.min(Math.max(parsed, min), max));
33
+ }
34
+ }, [editText, min, max, onChange]);
35
+ const handleEditKeyDown = useCallback((e) => {
36
+ if (e.key === "Enter") {
37
+ e.preventDefault();
38
+ commitEdit();
39
+ }
40
+ else if (e.key === "Escape") {
41
+ e.preventDefault();
42
+ setIsEditing(false);
43
+ }
44
+ }, [commitEdit]);
45
+ const disabled = !enabled;
46
+ return (_jsxs("div", { className: `audio-control${disabled ? " audio-control--disabled" : ""}`, title: `${label} cutoff frequency.`, children: [_jsx("input", { type: "checkbox", className: "control-toggle", checked: enabled, onChange: (e) => onToggle(e.target.checked) }), _jsx("span", { className: "control-label", id: labelId, children: label }), _jsx(SnappableSlider, { min: min, max: max, value: value, logarithmic: true, defaultValue: defaultValue, enableSnap: true, snaps: preset.snaps ?? [], ticks: preset.ticks ?? [], disabled: disabled, labelId: labelId, valueText: formatHz(value), 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: formatHz(value) }))] }));
47
+ }
48
+ export const FilterControl = memo(FilterControlInner);
@@ -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 GainControlInner({ value, defaultValue, enabled, onChange, onToggle, }) {
6
+ const preset = presets.gain;
7
+ const min = preset.min ?? -100;
8
+ const max = preset.max ?? 0;
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(1)} dB`;
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: "Amplitude in dB.", children: [_jsx("input", { type: "checkbox", className: "control-toggle", checked: enabled, onChange: (e) => onToggle(e.target.checked) }), _jsx("span", { className: "control-label", id: labelId, children: "Gain" }), _jsx(SnappableSlider, { min: min, max: max, value: value, skew: preset.skew ?? 6, 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 }))] }));
43
+ }
44
+ export const GainControl = memo(GainControlInner);