@audiofab-io/easy-spin-ui 0.2.1 → 0.2.2
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/components/PedalFace.d.ts +5 -1
- package/dist/index.js +59 -37
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -28,6 +28,10 @@ export interface PedalFaceProps {
|
|
|
28
28
|
* used by hosts that have a notion of a "currently tracked / active"
|
|
29
29
|
* program the user can drop into a slot. */
|
|
30
30
|
onAssignTrackedToSlot?: (slotIndex: number) => void;
|
|
31
|
+
/** When set, Ctrl/Cmd+click and right-click on an assigned slot fires
|
|
32
|
+
* this callback so hosts can "open the source file" — e.g. open the
|
|
33
|
+
* underlying .spn / .spndiagram in an editor tab. */
|
|
34
|
+
onOpenSlotSource?: (slotIndex: number) => void;
|
|
31
35
|
/** Audio clip selection — pass an empty array to hide the selector. */
|
|
32
36
|
clips?: ClipInfo[];
|
|
33
37
|
selectedClipId: string | null;
|
|
@@ -48,4 +52,4 @@ export interface PedalFaceProps {
|
|
|
48
52
|
pedalReading?: boolean;
|
|
49
53
|
pedalWriting?: boolean;
|
|
50
54
|
}
|
|
51
|
-
export declare function PedalFace({ pedalImageUrl, pots, onPotChange, potLabels, selectedSlot, onSelectSlot, slotLabels, onAssignSlot, onUnassignSlot, onUnassignAllSlots, onProgramSlot, programmingSlot, onAssignTrackedToSlot, clips, selectedClipId, onSelectClip, playing, onPlay, onPause, bypassed, onToggleBypass, channelMode, onChannelModeChange, pedalConnected, onConnectPedal, onReadPedal, onWritePedal, pedalReading, pedalWriting, }: PedalFaceProps): import("react/jsx-runtime").JSX.Element;
|
|
55
|
+
export declare function PedalFace({ pedalImageUrl, pots, onPotChange, potLabels, selectedSlot, onSelectSlot, slotLabels, onAssignSlot, onUnassignSlot, onUnassignAllSlots, onProgramSlot, programmingSlot, onAssignTrackedToSlot, onOpenSlotSource, clips, selectedClipId, onSelectClip, playing, onPlay, onPause, bypassed, onToggleBypass, channelMode, onChannelModeChange, pedalConnected, onConnectPedal, onReadPedal, onWritePedal, pedalReading, pedalWriting, }: PedalFaceProps): import("react/jsx-runtime").JSX.Element;
|
package/dist/index.js
CHANGED
|
@@ -240,8 +240,8 @@ function T({ pedalImageUrl: e, pots: t, onPotChange: n, potLabels: s = [
|
|
|
240
240
|
"Pot 0",
|
|
241
241
|
"Pot 1",
|
|
242
242
|
"Pot 2"
|
|
243
|
-
], selectedSlot: l, onSelectSlot: u, slotLabels: d, onAssignSlot: f, onUnassignSlot: p, onUnassignAllSlots: m, onProgramSlot: h, programmingSlot: g = null, onAssignTrackedToSlot: _,
|
|
244
|
-
let
|
|
243
|
+
], selectedSlot: l, onSelectSlot: u, slotLabels: d, onAssignSlot: f, onUnassignSlot: p, onUnassignAllSlots: m, onProgramSlot: h, programmingSlot: g = null, onAssignTrackedToSlot: _, onOpenSlotSource: T, clips: P, selectedClipId: F, onSelectClip: I, playing: L, onPlay: R, onPause: z, bypassed: B, onToggleBypass: V, channelMode: H, onChannelModeChange: U, pedalConnected: W = !1, onConnectPedal: G, onReadPedal: K, onWritePedal: q, pedalReading: J = !1, pedalWriting: Y = !1 }) {
|
|
244
|
+
let X = J || Y, [Z, Q] = r(null), $ = !B, te = d?.some((e) => e) ?? !1, ne = W && !X;
|
|
245
245
|
return /* @__PURE__ */ o("div", {
|
|
246
246
|
className: "flex flex-col items-center gap-5 w-full max-w-[460px] mx-auto",
|
|
247
247
|
children: [
|
|
@@ -312,8 +312,8 @@ function T({ pedalImageUrl: e, pots: t, onPotChange: n, potLabels: s = [
|
|
|
312
312
|
width: `${w}%`,
|
|
313
313
|
aspectRatio: "1 / 1",
|
|
314
314
|
transform: "translate(-50%, -50%)",
|
|
315
|
-
background:
|
|
316
|
-
boxShadow:
|
|
315
|
+
background: $ ? "radial-gradient(circle at 35% 30%, #ff8a8a 0%, #e11 55%, #800 100%)" : "radial-gradient(circle at 35% 30%, #f2f2f2 0%, #bbb 60%, #777 100%)",
|
|
316
|
+
boxShadow: $ ? "0 0 10px rgba(255, 60, 60, 0.75)" : "inset 0 1px 2px rgba(0,0,0,0.25)",
|
|
317
317
|
border: "1px solid #333"
|
|
318
318
|
},
|
|
319
319
|
"aria-label": "Status LED"
|
|
@@ -356,35 +356,35 @@ function T({ pedalImageUrl: e, pots: t, onPotChange: n, potLabels: s = [
|
|
|
356
356
|
strokeLinejoin: "round"
|
|
357
357
|
})]
|
|
358
358
|
}),
|
|
359
|
-
(
|
|
359
|
+
(G || K || q) && /* @__PURE__ */ a("div", {
|
|
360
360
|
className: "absolute flex items-center gap-1",
|
|
361
361
|
style: {
|
|
362
362
|
left: b.pedalIO.left,
|
|
363
363
|
top: b.pedalIO.top,
|
|
364
364
|
transform: "translate(-50%, -50%)"
|
|
365
365
|
},
|
|
366
|
-
children:
|
|
367
|
-
onClick: G,
|
|
368
|
-
disabled: Y,
|
|
369
|
-
busy: q,
|
|
370
|
-
label: "Read programs from pedal",
|
|
371
|
-
children: /* @__PURE__ */ a(A, {})
|
|
372
|
-
}), K && /* @__PURE__ */ a(O, {
|
|
366
|
+
children: W ? /* @__PURE__ */ o(i, { children: [K && /* @__PURE__ */ a(O, {
|
|
373
367
|
onClick: K,
|
|
374
|
-
disabled:
|
|
368
|
+
disabled: X,
|
|
375
369
|
busy: J,
|
|
370
|
+
label: "Read programs from pedal",
|
|
371
|
+
children: /* @__PURE__ */ a(ee, {})
|
|
372
|
+
}), q && /* @__PURE__ */ a(O, {
|
|
373
|
+
onClick: q,
|
|
374
|
+
disabled: X,
|
|
375
|
+
busy: Y,
|
|
376
376
|
label: "Write programs to pedal",
|
|
377
|
-
children: /* @__PURE__ */ a(
|
|
378
|
-
})] }) :
|
|
377
|
+
children: /* @__PURE__ */ a(A, {})
|
|
378
|
+
})] }) : G && /* @__PURE__ */ a("button", {
|
|
379
379
|
type: "button",
|
|
380
|
-
onClick:
|
|
380
|
+
onClick: G,
|
|
381
381
|
className: "px-2 py-1 rounded border border-border bg-surface-card text-[10px] font-semibold text-text-primary hover:border-gold-dim transition-colors whitespace-nowrap",
|
|
382
382
|
children: "Connect"
|
|
383
383
|
})
|
|
384
384
|
}),
|
|
385
385
|
/* @__PURE__ */ a("button", {
|
|
386
|
-
onClick:
|
|
387
|
-
"aria-label":
|
|
386
|
+
onClick: V,
|
|
387
|
+
"aria-label": B ? "Bypassed — click to engage" : "Engaged — click to bypass",
|
|
388
388
|
className: "absolute rounded-full cursor-pointer",
|
|
389
389
|
style: {
|
|
390
390
|
left: b.footswitch.left,
|
|
@@ -416,14 +416,14 @@ function T({ pedalImageUrl: e, pots: t, onPotChange: n, potLabels: s = [
|
|
|
416
416
|
className: "flex items-center gap-3 flex-wrap justify-center",
|
|
417
417
|
children: [
|
|
418
418
|
/* @__PURE__ */ a("button", {
|
|
419
|
-
onClick:
|
|
419
|
+
onClick: L ? z : R,
|
|
420
420
|
className: "px-4 py-1.5 rounded border border-border bg-surface-card text-sm font-semibold text-text-primary hover:border-gold-dim transition-colors",
|
|
421
|
-
children:
|
|
421
|
+
children: L ? "Pause" : "Play"
|
|
422
422
|
}),
|
|
423
|
-
|
|
424
|
-
clips:
|
|
425
|
-
selectedClipId:
|
|
426
|
-
onSelect:
|
|
423
|
+
P && P.length > 0 && /* @__PURE__ */ a(y, {
|
|
424
|
+
clips: P,
|
|
425
|
+
selectedClipId: F,
|
|
426
|
+
onSelect: I
|
|
427
427
|
}),
|
|
428
428
|
/* @__PURE__ */ a("div", {
|
|
429
429
|
role: "radiogroup",
|
|
@@ -433,9 +433,9 @@ function T({ pedalImageUrl: e, pots: t, onPotChange: n, potLabels: s = [
|
|
|
433
433
|
children: ["mono", "stereo"].map((e) => /* @__PURE__ */ a("button", {
|
|
434
434
|
type: "button",
|
|
435
435
|
role: "radio",
|
|
436
|
-
"aria-checked":
|
|
437
|
-
onClick: () =>
|
|
438
|
-
className: `px-3 py-1.5 font-semibold transition-colors ${
|
|
436
|
+
"aria-checked": H === e,
|
|
437
|
+
onClick: () => U(e),
|
|
438
|
+
className: `px-3 py-1.5 font-semibold transition-colors ${H === e ? "bg-gold text-surface" : "text-text-secondary hover:text-text-primary"}`,
|
|
439
439
|
children: e === "mono" ? "Mono" : "Stereo"
|
|
440
440
|
}, e))
|
|
441
441
|
})
|
|
@@ -448,7 +448,7 @@ function T({ pedalImageUrl: e, pots: t, onPotChange: n, potLabels: s = [
|
|
|
448
448
|
children: [/* @__PURE__ */ a("h3", {
|
|
449
449
|
className: "text-xs font-semibold uppercase tracking-wider text-text-muted",
|
|
450
450
|
children: "Programs on Pedal"
|
|
451
|
-
}),
|
|
451
|
+
}), te && m && /* @__PURE__ */ a("button", {
|
|
452
452
|
type: "button",
|
|
453
453
|
onClick: m,
|
|
454
454
|
className: "text-[11px] text-text-muted hover:text-red-400 transition-colors",
|
|
@@ -458,21 +458,30 @@ function T({ pedalImageUrl: e, pots: t, onPotChange: n, potLabels: s = [
|
|
|
458
458
|
}), /* @__PURE__ */ a("div", {
|
|
459
459
|
className: "grid grid-cols-2 gap-2",
|
|
460
460
|
children: Array.from({ length: c }, (e, t) => {
|
|
461
|
-
let n = l === t, r =
|
|
461
|
+
let n = l === t, r = Z === t, i = d?.[t], s = !i;
|
|
462
462
|
return /* @__PURE__ */ o("div", {
|
|
463
463
|
role: "button",
|
|
464
464
|
tabIndex: 0,
|
|
465
|
-
onClick: () =>
|
|
465
|
+
onClick: (e) => {
|
|
466
|
+
if ((e.ctrlKey || e.metaKey) && !s && T) {
|
|
467
|
+
e.preventDefault(), T(t);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
u(t);
|
|
471
|
+
},
|
|
472
|
+
onContextMenu: (e) => {
|
|
473
|
+
!s && T && (e.preventDefault(), T(t));
|
|
474
|
+
},
|
|
466
475
|
onKeyDown: (e) => {
|
|
467
476
|
(e.key === "Enter" || e.key === " ") && (e.preventDefault(), u(t));
|
|
468
477
|
},
|
|
469
478
|
onDragOver: (e) => {
|
|
470
|
-
f && (e.preventDefault(), e.dataTransfer.dropEffect =
|
|
479
|
+
f && (e.preventDefault(), e.dataTransfer.dropEffect = j(e.dataTransfer.effectAllowed), Z !== t && Q(t));
|
|
471
480
|
},
|
|
472
|
-
onDragLeave: () =>
|
|
481
|
+
onDragLeave: () => Q(null),
|
|
473
482
|
onDrop: (e) => {
|
|
474
483
|
if (!f) return;
|
|
475
|
-
e.preventDefault(),
|
|
484
|
+
e.preventDefault(), Q(null);
|
|
476
485
|
let n = e.dataTransfer.getData("text/plain");
|
|
477
486
|
if (!n) {
|
|
478
487
|
let t = e.dataTransfer.getData("text/uri-list");
|
|
@@ -505,7 +514,7 @@ function T({ pedalImageUrl: e, pots: t, onPotChange: n, potLabels: s = [
|
|
|
505
514
|
className: "flex-none p-1 rounded text-text-muted hover:text-gold-dim hover:bg-black/20 transition-colors",
|
|
506
515
|
children: /* @__PURE__ */ a(M, {})
|
|
507
516
|
}),
|
|
508
|
-
!s && h &&
|
|
517
|
+
!s && h && ne && /* @__PURE__ */ a("button", {
|
|
509
518
|
type: "button",
|
|
510
519
|
onClick: (e) => {
|
|
511
520
|
e.stopPropagation(), h(t);
|
|
@@ -514,7 +523,7 @@ function T({ pedalImageUrl: e, pots: t, onPotChange: n, potLabels: s = [
|
|
|
514
523
|
"aria-label": `Write slot ${t + 1} to pedal`,
|
|
515
524
|
title: "Write this slot to pedal",
|
|
516
525
|
className: "flex-none p-1 rounded text-text-muted hover:text-gold-dim hover:bg-black/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors",
|
|
517
|
-
children: a(g === t ? k :
|
|
526
|
+
children: a(g === t ? k : A, { small: !0 })
|
|
518
527
|
}),
|
|
519
528
|
!s && p && /* @__PURE__ */ a("button", {
|
|
520
529
|
type: "button",
|
|
@@ -586,7 +595,7 @@ function k({ small: e = !1 }) {
|
|
|
586
595
|
children: /* @__PURE__ */ a("path", { d: "M21 12a9 9 0 1 1-6.219-8.56" })
|
|
587
596
|
});
|
|
588
597
|
}
|
|
589
|
-
function
|
|
598
|
+
function ee() {
|
|
590
599
|
return /* @__PURE__ */ o("svg", {
|
|
591
600
|
viewBox: "0 0 24 24",
|
|
592
601
|
fill: "none",
|
|
@@ -607,7 +616,7 @@ function A() {
|
|
|
607
616
|
]
|
|
608
617
|
});
|
|
609
618
|
}
|
|
610
|
-
function
|
|
619
|
+
function A({ small: e = !1 }) {
|
|
611
620
|
return /* @__PURE__ */ o("svg", {
|
|
612
621
|
viewBox: "0 0 24 24",
|
|
613
622
|
fill: "none",
|
|
@@ -628,6 +637,19 @@ function j({ small: e = !1 }) {
|
|
|
628
637
|
]
|
|
629
638
|
});
|
|
630
639
|
}
|
|
640
|
+
function j(e) {
|
|
641
|
+
switch (e) {
|
|
642
|
+
case "copy":
|
|
643
|
+
case "copyLink":
|
|
644
|
+
case "copyMove":
|
|
645
|
+
case "all":
|
|
646
|
+
case "uninitialized": return "copy";
|
|
647
|
+
case "move":
|
|
648
|
+
case "linkMove": return "move";
|
|
649
|
+
case "link": return "link";
|
|
650
|
+
default: return "none";
|
|
651
|
+
}
|
|
652
|
+
}
|
|
631
653
|
function M() {
|
|
632
654
|
return /* @__PURE__ */ o("svg", {
|
|
633
655
|
viewBox: "0 0 24 24",
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/components/Knob.tsx","../src/components/ProgramSelector.tsx","../src/components/ClipSelector.tsx","../src/components/PedalFace.tsx","../src/audio/binary.ts","../src/simulator/useSimulator.ts"],"sourcesContent":["import { useRef, useCallback } from 'react'\n\ninterface KnobProps {\n value: number // 0.0 – 1.0\n label: string\n onChange: (value: number) => void\n size?: number\n /** When true, render without a visible body — just a pointer indicator\n * (for overlaying on top of a pre-drawn dial graphic). */\n overlay?: boolean\n}\n\nconst SWEEP_DEG = 300\nconst MIN_DEG = -SWEEP_DEG / 2\n\nexport function Knob({ value, label, onChange, size = 56, overlay = false }: KnobProps) {\n const knobRef = useRef<SVGSVGElement>(null)\n const dragging = useRef(false)\n const startAngle = useRef(0)\n const startValue = useRef(0)\n\n const getAngleFromEvent = useCallback((e: PointerEvent) => {\n const el = knobRef.current\n if (!el) return 0\n const rect = el.getBoundingClientRect()\n const cx = rect.left + rect.width / 2\n const cy = rect.top + rect.height / 2\n return Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI)\n }, [])\n\n const onPointerDown = useCallback((e: React.PointerEvent) => {\n dragging.current = true\n startAngle.current = getAngleFromEvent(e.nativeEvent)\n startValue.current = value\n ;(e.target as Element).setPointerCapture(e.pointerId)\n }, [getAngleFromEvent, value])\n\n const onPointerMove = useCallback((e: React.PointerEvent) => {\n if (!dragging.current) return\n const angle = getAngleFromEvent(e.nativeEvent)\n const delta = angle - startAngle.current\n const norm = ((delta + 540) % 360) - 180\n const valueDelta = norm / SWEEP_DEG\n const newValue = Math.max(0, Math.min(1, startValue.current + valueDelta))\n onChange(newValue)\n }, [getAngleFromEvent, onChange])\n\n const onPointerUp = useCallback(() => {\n dragging.current = false\n }, [])\n\n const rotation = MIN_DEG + value * SWEEP_DEG\n\n const svg = (\n <svg\n ref={knobRef}\n width={overlay ? '100%' : size}\n height={overlay ? '100%' : size}\n viewBox=\"0 0 56 56\"\n className=\"cursor-grab active:cursor-grabbing select-none touch-none\"\n role=\"slider\"\n aria-valuenow={Math.round(value * 100)}\n aria-valuemin={0}\n aria-valuemax={100}\n aria-label={label}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n >\n <circle cx=\"28\" cy=\"28\" r=\"28\" fill=\"transparent\" />\n {overlay ? (\n <circle cx=\"28\" cy=\"28\" r=\"24\" fill=\"none\" stroke=\"#111\" strokeWidth=\"1.5\" />\n ) : (\n <>\n <circle cx=\"28\" cy=\"28\" r=\"26\" fill=\"none\" stroke=\"#888\" strokeWidth=\"1.5\" />\n <circle cx=\"28\" cy=\"28\" r=\"22\" fill=\"#f0f0f0\" stroke=\"#aaa\" strokeWidth=\"1\" />\n </>\n )}\n <g transform={`rotate(${rotation} 28 28)`}>\n <line\n x1=\"28\"\n y1=\"28\"\n x2=\"28\"\n y2={overlay ? 10 : 8}\n stroke={overlay ? '#111' : '#B8942C'}\n strokeWidth={overlay ? 2.5 : 2}\n strokeLinecap=\"round\"\n />\n </g>\n </svg>\n )\n\n if (overlay) {\n return svg\n }\n\n return (\n <div className=\"flex flex-col items-center gap-1\">\n {svg}\n <span className=\"text-[10px] text-text-secondary text-center leading-tight max-w-[80px] truncate\">\n {label}\n </span>\n </div>\n )\n}\n","import { useRef, useCallback } from 'react'\nimport { PROGRAM_SLOT_COUNT } from '@audiofab-io/fv1-core/pedal'\n\ninterface ProgramSelectorProps {\n selectedSlot: number\n onSelectSlot: (slot: number) => void\n slotLabels?: string[]\n /** When true, render as a rotary dial with a pointer indicator (no visible body). */\n overlay?: boolean\n}\n\nconst POS_COUNT = PROGRAM_SLOT_COUNT\nconst START_ANGLE_DEG = 180 // position 1 (lower-left)\nconst END_ANGLE_DEG = 30 + 360 // position 8 (lower-right, via the top)\nconst STEP_DEG = (END_ANGLE_DEG - START_ANGLE_DEG) / (POS_COUNT - 1)\n\nfunction angleForPosition(index0: number): number {\n return START_ANGLE_DEG + index0 * STEP_DEG\n}\n\nfunction nearestSlotFromPointerAngle(pointerDeg: number): number {\n const a = ((pointerDeg % 360) + 360) % 360\n let bestSlot = 0\n let bestDist = Infinity\n for (let i = 0; i < POS_COUNT; i++) {\n const slotAngle = ((angleForPosition(i) % 360) + 360) % 360\n let diff = Math.abs(a - slotAngle)\n if (diff > 180) diff = 360 - diff\n if (diff < bestDist) {\n bestDist = diff\n bestSlot = i\n }\n }\n return bestSlot\n}\n\n/**\n * Rotary program selector. In overlay mode it renders as a pot-style knob\n * (black circular outline + pointer) that points at the currently selected\n * slot. The knob can be dragged to change program — it snaps to the nearest\n * of the 8 discrete positions.\n */\nexport function ProgramSelector({\n selectedSlot,\n onSelectSlot,\n slotLabels,\n overlay = false,\n}: ProgramSelectorProps) {\n const knobRef = useRef<SVGSVGElement>(null)\n const dragging = useRef(false)\n\n const getAngleFromEvent = useCallback((e: PointerEvent) => {\n const el = knobRef.current\n if (!el) return 0\n const rect = el.getBoundingClientRect()\n const cx = rect.left + rect.width / 2\n const cy = rect.top + rect.height / 2\n const raw = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI)\n return ((raw % 360) + 360) % 360\n }, [])\n\n const onPointerDown = useCallback((e: React.PointerEvent) => {\n dragging.current = true\n ;(e.target as Element).setPointerCapture(e.pointerId)\n const angle = getAngleFromEvent(e.nativeEvent)\n const nearest = nearestSlotFromPointerAngle(angle)\n if (nearest !== selectedSlot) onSelectSlot(nearest)\n }, [getAngleFromEvent, selectedSlot, onSelectSlot])\n\n const onPointerMove = useCallback((e: React.PointerEvent) => {\n if (!dragging.current) return\n const angle = getAngleFromEvent(e.nativeEvent)\n const nearest = nearestSlotFromPointerAngle(angle)\n if (nearest !== selectedSlot) onSelectSlot(nearest)\n }, [getAngleFromEvent, selectedSlot, onSelectSlot])\n\n const onPointerUp = useCallback(() => {\n dragging.current = false\n }, [])\n\n if (!overlay) {\n return (\n <div className=\"grid grid-cols-4 gap-1\">\n {Array.from({ length: POS_COUNT }, (_, i) => (\n <button\n key={i}\n onClick={() => onSelectSlot(i)}\n title={slotLabels?.[i] ?? `Slot ${i + 1}`}\n className={`w-7 h-7 rounded text-xs font-bold ${selectedSlot === i\n ? 'bg-gold text-surface'\n : 'bg-surface-hover text-text-secondary hover:bg-surface-card border border-border'\n }`}\n >\n {i + 1}\n </button>\n ))}\n </div>\n )\n }\n\n const rotation = angleForPosition(selectedSlot) - 270\n\n return (\n <svg\n ref={knobRef}\n width=\"100%\"\n height=\"100%\"\n viewBox=\"0 0 56 56\"\n className=\"cursor-grab active:cursor-grabbing select-none touch-none\"\n role=\"listbox\"\n aria-label=\"Program selector\"\n aria-activedescendant={`prog-slot-${selectedSlot}`}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n >\n <circle cx=\"28\" cy=\"28\" r=\"28\" fill=\"transparent\" />\n <circle cx=\"28\" cy=\"28\" r=\"24\" fill=\"none\" stroke=\"#111\" strokeWidth=\"1.5\" />\n <g transform={`rotate(${rotation} 28 28)`}>\n <line\n x1=\"28\"\n y1=\"28\"\n x2=\"28\"\n y2=\"10\"\n stroke=\"#111\"\n strokeWidth=\"2.5\"\n strokeLinecap=\"round\"\n />\n </g>\n </svg>\n )\n}\n","export interface ClipInfo {\n id: string\n name: string\n description?: string\n}\n\ninterface ClipSelectorProps {\n /** List of clips the consumer wants to expose. */\n clips: ClipInfo[]\n selectedClipId: string | null\n onSelect: (clipId: string) => void\n /** When true, the selector is visually disabled (e.g. while a list is loading). */\n disabled?: boolean\n}\n\n/**\n * Pure presentational dropdown for picking an audio clip. The shared package\n * has no opinion about where clips come from — consumers fetch / bundle their\n * own list and pass it in.\n */\nexport function ClipSelector({ clips, selectedClipId, onSelect, disabled = false }: ClipSelectorProps) {\n return (\n <select\n value={selectedClipId ?? ''}\n onChange={e => onSelect(e.target.value)}\n disabled={disabled}\n className=\"px-2 py-1 rounded bg-surface-hover border border-border text-xs text-text-primary focus:outline-none focus:border-gold-dim disabled:opacity-50\"\n >\n <option value=\"\" disabled>\n {disabled ? 'Loading…' : 'Select clip'}\n </option>\n {clips.map(clip => (\n <option key={clip.id} value={clip.id}>\n {clip.name}\n </option>\n ))}\n </select>\n )\n}\n","import { useState } from 'react'\nimport { Knob } from './Knob'\nimport { ProgramSelector } from './ProgramSelector'\nimport { ClipSelector, type ClipInfo } from './ClipSelector'\nimport { PROGRAM_SLOT_COUNT } from '@audiofab-io/fv1-core/pedal'\n\nexport type ChannelMode = 'mono' | 'stereo'\n\nexport interface PedalFaceProps {\n /** URL of the pedal outline graphic. Required — see assets/pedal.png in\n * this package, or bring your own. */\n pedalImageUrl: string\n\n /** Current values of the three pots, normalised 0..1. */\n pots: [number, number, number]\n onPotChange: (index: number, value: number) => void\n /** Per-pot display labels — typically the effect's `controls[].name`,\n * or `Pot 0/1/2` when the active program is unknown. */\n potLabels?: [string, string, string]\n\n /** Currently selected slot (0-based). */\n selectedSlot: number\n onSelectSlot: (slot: number) => void\n /** Display labels for each of the 8 slots. Empty / undefined means empty. */\n slotLabels?: string[]\n /** Drop handler for slot N. The data string is the platform-specific\n * payload (e.g. an effect id, a file URI). The consumer interprets it. */\n onAssignSlot?: (slotIndex: number, payload: string) => void\n onUnassignSlot?: (slotIndex: number) => void\n onUnassignAllSlots?: () => void\n /** Called when the user clicks the per-slot \"send to pedal\" icon. */\n onProgramSlot?: (slotIndex: number) => void\n /** Index of the slot currently being written to the pedal, or null. */\n programmingSlot?: number | null\n /** When set, empty slots show a \"+\" button that calls this callback —\n * used by hosts that have a notion of a \"currently tracked / active\"\n * program the user can drop into a slot. */\n onAssignTrackedToSlot?: (slotIndex: number) => void\n\n /** Audio clip selection — pass an empty array to hide the selector. */\n clips?: ClipInfo[]\n selectedClipId: string | null\n onSelectClip: (clipId: string) => void\n\n playing: boolean\n onPlay: () => void\n onPause: () => void\n bypassed: boolean\n onToggleBypass: () => void\n channelMode: ChannelMode\n onChannelModeChange: (mode: ChannelMode) => void\n\n /** Pedal connection state — the I/O button cluster only renders when\n * these are wired up. */\n pedalConnected?: boolean\n onConnectPedal?: () => void\n onReadPedal?: () => void\n onWritePedal?: () => void\n pedalReading?: boolean\n pedalWriting?: boolean\n}\n\n/**\n * Overlay positions as percentages of the pedal image (1573 × 2627).\n *\n * pot0 / pot1 / pot2 — potentiometer centres\n * programSelector — rotary program selector knob centre\n * led — status LED\n * footswitch — bypass footswitch centre\n */\nconst POS = {\n pot0: { left: '22.85%', top: '14%' },\n pot1: { left: '77.15%', top: '14%' },\n pot2: { left: '22.85%', top: '33.75%' },\n programSelector: { left: '76%', top: '33.75%' },\n led: { left: '50%', top: '42%' },\n footswitch: { left: '50%', top: '68.5%' },\n pot0Label: { left: '22.85%', top: '2.5%' },\n pot1Label: { left: '77.15%', top: '2.5%' },\n pot2Label: { left: '22.85%', top: '44%' },\n pedalIO: { left: '105%', top: '70%' },\n}\n\nconst KNOB_SIZE_PCT = 20\nconst FOOTSWITCH_SIZE_PCT = 14\nconst HEX_NUT_SIZE_PCT = 20\nconst LED_SIZE_PCT = 5\n\nexport function PedalFace({\n pedalImageUrl,\n pots,\n onPotChange,\n potLabels = ['Pot 0', 'Pot 1', 'Pot 2'],\n selectedSlot,\n onSelectSlot,\n slotLabels,\n onAssignSlot,\n onUnassignSlot,\n onUnassignAllSlots,\n onProgramSlot,\n programmingSlot = null,\n onAssignTrackedToSlot,\n clips,\n selectedClipId,\n onSelectClip,\n playing,\n onPlay,\n onPause,\n bypassed,\n onToggleBypass,\n channelMode,\n onChannelModeChange,\n pedalConnected = false,\n onConnectPedal,\n onReadPedal,\n onWritePedal,\n pedalReading = false,\n pedalWriting = false,\n}: PedalFaceProps) {\n const pedalBusy = pedalReading || pedalWriting\n const [dragOverSlot, setDragOverSlot] = useState<number | null>(null)\n\n const ledOn = !bypassed\n const anyAssigned = slotLabels?.some(l => l) ?? false\n const slotProgrammable = pedalConnected && !pedalBusy\n\n return (\n <div className=\"flex flex-col items-center gap-5 w-full max-w-[460px] mx-auto\">\n <div\n className=\"relative w-full max-w-[360px] pedal-outline\"\n style={{\n aspectRatio: '1573 / 2627',\n backgroundImage: `url(${pedalImageUrl})`,\n backgroundSize: 'contain',\n backgroundRepeat: 'no-repeat',\n backgroundPosition: 'center top',\n }}\n >\n <KnobOverlay\n posStyle={POS.pot0}\n sizePct={KNOB_SIZE_PCT}\n value={pots[0]}\n label={potLabels[0]}\n onChange={v => onPotChange(0, v)}\n />\n <KnobOverlay\n posStyle={POS.pot1}\n sizePct={KNOB_SIZE_PCT}\n value={pots[1]}\n label={potLabels[1]}\n onChange={v => onPotChange(1, v)}\n />\n <KnobOverlay\n posStyle={POS.pot2}\n sizePct={KNOB_SIZE_PCT}\n value={pots[2]}\n label={potLabels[2]}\n onChange={v => onPotChange(2, v)}\n />\n\n <PotLabel posStyle={POS.pot0Label} text={potLabels[0]} />\n <PotLabel posStyle={POS.pot1Label} text={potLabels[1]} />\n <PotLabel posStyle={POS.pot2Label} text={potLabels[2]} />\n\n {/* Program Selector knob */}\n <div\n className=\"absolute\"\n style={{\n left: POS.programSelector.left,\n top: POS.programSelector.top,\n width: `${KNOB_SIZE_PCT}%`,\n aspectRatio: '1 / 1',\n transform: 'translate(-50%, -50%)',\n }}\n >\n <ProgramSelector\n overlay\n selectedSlot={selectedSlot}\n onSelectSlot={onSelectSlot}\n slotLabels={slotLabels}\n />\n </div>\n\n {/* LED */}\n <div\n className=\"absolute rounded-full transition-all\"\n style={{\n left: POS.led.left,\n top: POS.led.top,\n width: `${LED_SIZE_PCT}%`,\n aspectRatio: '1 / 1',\n transform: 'translate(-50%, -50%)',\n background: ledOn\n ? 'radial-gradient(circle at 35% 30%, #ff8a8a 0%, #e11 55%, #800 100%)'\n : 'radial-gradient(circle at 35% 30%, #f2f2f2 0%, #bbb 60%, #777 100%)',\n boxShadow: ledOn\n ? '0 0 10px rgba(255, 60, 60, 0.75)'\n : 'inset 0 1px 2px rgba(0,0,0,0.25)',\n border: '1px solid #333',\n }}\n aria-label=\"Status LED\"\n />\n\n {/* Hex-shaped silver retaining nut frames the footswitch */}\n <svg\n viewBox=\"0 0 100 100\"\n className=\"absolute pointer-events-none\"\n style={{\n left: POS.footswitch.left,\n top: POS.footswitch.top,\n width: `${HEX_NUT_SIZE_PCT}%`,\n aspectRatio: '1 / 1',\n transform: 'translate(-50%, -50%)',\n filter: 'drop-shadow(0 2px 2px rgba(0,0,0,0.45))',\n }}\n >\n <defs>\n <radialGradient id=\"silverHexNut\" cx=\"40%\" cy=\"35%\" r=\"65%\">\n <stop offset=\"0%\" stopColor=\"#f8f8f8\" />\n <stop offset=\"45%\" stopColor=\"#c8c8c8\" />\n <stop offset=\"100%\" stopColor=\"#707070\" />\n </radialGradient>\n </defs>\n <polygon\n points=\"5,50 27.5,11 72.5,11 95,50 72.5,89 27.5,89\"\n fill=\"url(#silverHexNut)\"\n stroke=\"#333\"\n strokeWidth=\"2\"\n strokeLinejoin=\"round\"\n />\n </svg>\n\n {/* Pedal I/O cluster — only rendered when consumer wires it up */}\n {(onConnectPedal || onReadPedal || onWritePedal) && (\n <div\n className=\"absolute flex items-center gap-1\"\n style={{\n left: POS.pedalIO.left,\n top: POS.pedalIO.top,\n transform: 'translate(-50%, -50%)',\n }}\n >\n {!pedalConnected ? (\n onConnectPedal && (\n <button\n type=\"button\"\n onClick={onConnectPedal}\n className=\"px-2 py-1 rounded border border-border bg-surface-card text-[10px] font-semibold text-text-primary hover:border-gold-dim transition-colors whitespace-nowrap\"\n >\n Connect\n </button>\n )\n ) : (\n <>\n {onReadPedal && (\n <IconButton\n onClick={onReadPedal}\n disabled={pedalBusy}\n busy={pedalReading}\n label=\"Read programs from pedal\"\n >\n <ArrowUpFromBox />\n </IconButton>\n )}\n {onWritePedal && (\n <IconButton\n onClick={onWritePedal}\n disabled={pedalBusy}\n busy={pedalWriting}\n label=\"Write programs to pedal\"\n >\n <ArrowDownToBox />\n </IconButton>\n )}\n </>\n )}\n </div>\n )}\n\n {/* Footswitch — silver stompbox-style */}\n <button\n onClick={onToggleBypass}\n aria-label={bypassed ? 'Bypassed — click to engage' : 'Engaged — click to bypass'}\n className=\"absolute rounded-full cursor-pointer\"\n style={{\n left: POS.footswitch.left,\n top: POS.footswitch.top,\n width: `${FOOTSWITCH_SIZE_PCT}%`,\n aspectRatio: '1 / 1',\n transform: 'translate(-50%, -50%)',\n background: 'radial-gradient(circle at 35% 30%, #fafafa 0%, #cfcfcf 45%, #888 90%, #555 100%)',\n border: '1.5px solid #222',\n boxShadow: '0 2px 4px rgba(0,0,0,0.4), inset 0 1px 1px rgba(255,255,255,0.6), inset 0 -2px 3px rgba(0,0,0,0.25)',\n }}\n >\n <span\n className=\"absolute rounded-full pointer-events-none\"\n style={{\n left: '50%',\n top: '50%',\n width: '80%',\n aspectRatio: '1 / 1',\n transform: 'translate(-50%, -50%)',\n background: 'radial-gradient(circle at 40% 35%, #f0f0f0 0%, #b0b0b0 60%, #707070 100%)',\n border: '1px solid #444',\n boxShadow: 'inset 0 1px 1px rgba(255,255,255,0.5), inset 0 -1px 2px rgba(0,0,0,0.35)',\n }}\n />\n </button>\n </div>\n\n {/* Web/extension playback controls (not part of the physical pedal) */}\n <div className=\"flex items-center gap-3 flex-wrap justify-center\">\n <button\n onClick={playing ? onPause : onPlay}\n className=\"px-4 py-1.5 rounded border border-border bg-surface-card text-sm font-semibold text-text-primary hover:border-gold-dim transition-colors\"\n >\n {playing ? 'Pause' : 'Play'}\n </button>\n {clips && clips.length > 0 && (\n <ClipSelector\n clips={clips}\n selectedClipId={selectedClipId}\n onSelect={onSelectClip}\n />\n )}\n <div\n role=\"radiogroup\"\n aria-label=\"Output channel mode\"\n title=\"Mono matches the current Easy Spin hardware (DACL to both jacks). Stereo plays the FV-1's native left/right outputs.\"\n className=\"flex items-center rounded border border-border bg-surface-card text-sm overflow-hidden\"\n >\n {(['mono', 'stereo'] as const).map(mode => (\n <button\n key={mode}\n type=\"button\"\n role=\"radio\"\n aria-checked={channelMode === mode}\n onClick={() => onChannelModeChange(mode)}\n className={`px-3 py-1.5 font-semibold transition-colors ${\n channelMode === mode\n ? 'bg-gold text-surface'\n : 'text-text-secondary hover:text-text-primary'\n }`}\n >\n {mode === 'mono' ? 'Mono' : 'Stereo'}\n </button>\n ))}\n </div>\n </div>\n\n {/* Program slots — drop a payload onto a slot to assign it; click a\n slot to make it the active program. */}\n <div className=\"w-full space-y-2\">\n <div className=\"flex items-center justify-between\">\n <h3 className=\"text-xs font-semibold uppercase tracking-wider text-text-muted\">\n Programs on Pedal\n </h3>\n {anyAssigned && onUnassignAllSlots && (\n <button\n type=\"button\"\n onClick={onUnassignAllSlots}\n className=\"text-[11px] text-text-muted hover:text-red-400 transition-colors\"\n title=\"Clear all local slot assignments\"\n >\n Unassign all\n </button>\n )}\n </div>\n <div className=\"grid grid-cols-2 gap-2\">\n {Array.from({ length: PROGRAM_SLOT_COUNT }, (_, i) => {\n const isActive = selectedSlot === i\n const isDragOver = dragOverSlot === i\n const label = slotLabels?.[i]\n const isEmpty = !label\n return (\n <div\n key={i}\n role=\"button\"\n tabIndex={0}\n onClick={() => onSelectSlot(i)}\n onKeyDown={e => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault()\n onSelectSlot(i)\n }\n }}\n onDragOver={e => {\n if (!onAssignSlot) return\n e.preventDefault()\n e.dataTransfer.dropEffect = 'copy'\n if (dragOverSlot !== i) setDragOverSlot(i)\n }}\n onDragLeave={() => setDragOverSlot(null)}\n onDrop={e => {\n if (!onAssignSlot) return\n e.preventDefault()\n setDragOverSlot(null)\n // Try text/plain first (web-app effect-id pattern), then\n // text/uri-list (VS Code / OS file drags). uri-list payloads\n // are newline-separated; we take the first URI.\n let payload = e.dataTransfer.getData('text/plain')\n if (!payload) {\n const uriList = e.dataTransfer.getData('text/uri-list')\n if (uriList) {\n payload = uriList.split('\\n').map(s => s.trim()).find(s => s && !s.startsWith('#')) ?? ''\n }\n }\n if (payload) onAssignSlot(i, payload)\n }}\n className={[\n 'flex items-center gap-2 px-2 py-2 rounded border text-left transition-colors cursor-pointer',\n 'bg-surface-card text-sm outline-none focus-visible:ring-1 focus-visible:ring-gold-dim',\n isActive\n ? 'border-gold-dim ring-1 ring-gold-dim/60 text-text-primary'\n : 'border-border text-text-primary hover:border-gold-dim',\n isDragOver ? 'border-dashed border-gold-dim bg-gold-dim/10' : '',\n ].join(' ')}\n >\n <span\n className={[\n 'flex-none w-6 h-6 inline-flex items-center justify-center rounded-full text-xs font-bold',\n isActive ? 'bg-gold-dim text-black' : 'bg-black/30 text-text-primary',\n ].join(' ')}\n >\n {i + 1}\n </span>\n <span\n className={[\n 'flex-1 min-w-0 truncate',\n isEmpty ? 'italic text-text-muted' : 'font-medium',\n ].join(' ')}\n >\n {isEmpty ? 'Empty' : label}\n </span>\n {isEmpty && onAssignTrackedToSlot && (\n <button\n type=\"button\"\n onClick={e => {\n e.stopPropagation()\n onAssignTrackedToSlot(i)\n }}\n aria-label={`Assign currently tracked program to slot ${i + 1}`}\n title=\"Assign currently tracked program here\"\n className=\"flex-none p-1 rounded text-text-muted hover:text-gold-dim hover:bg-black/20 transition-colors\"\n >\n <PlusIcon />\n </button>\n )}\n {!isEmpty && onProgramSlot && slotProgrammable && (\n <button\n type=\"button\"\n onClick={e => {\n e.stopPropagation()\n onProgramSlot(i)\n }}\n disabled={programmingSlot !== null}\n aria-label={`Write slot ${i + 1} to pedal`}\n title=\"Write this slot to pedal\"\n className=\"flex-none p-1 rounded text-text-muted hover:text-gold-dim hover:bg-black/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\n >\n {programmingSlot === i ? <Spinner small /> : <ArrowDownToBox small />}\n </button>\n )}\n {!isEmpty && onUnassignSlot && (\n <button\n type=\"button\"\n onClick={e => {\n e.stopPropagation()\n onUnassignSlot(i)\n }}\n aria-label={`Unassign slot ${i + 1}`}\n title=\"Unassign\"\n className=\"flex-none p-1 rounded text-text-muted hover:text-red-400 hover:bg-black/20 transition-colors\"\n >\n <TrashIcon />\n </button>\n )}\n </div>\n )\n })}\n </div>\n </div>\n </div>\n )\n}\n\ninterface KnobOverlayProps {\n posStyle: { left: string; top: string }\n sizePct: number\n value: number\n label: string\n onChange: (value: number) => void\n}\n\nfunction KnobOverlay({ posStyle, sizePct, value, label, onChange }: KnobOverlayProps) {\n return (\n <div\n className=\"absolute\"\n style={{\n left: posStyle.left,\n top: posStyle.top,\n width: `${sizePct}%`,\n aspectRatio: '1 / 1',\n transform: 'translate(-50%, -50%)',\n }}\n >\n <Knob overlay value={value} label={label} onChange={onChange} />\n </div>\n )\n}\n\ninterface PotLabelProps {\n posStyle: { left: string; top: string }\n text: string\n}\n\nfunction PotLabel({ posStyle, text }: PotLabelProps) {\n return (\n <div\n className=\"absolute text-center text-[11px] sm:text-xs font-semibold text-black whitespace-nowrap pointer-events-none\"\n style={{\n left: posStyle.left,\n top: posStyle.top,\n transform: 'translate(-50%, -50%)',\n }}\n >\n {text}\n </div>\n )\n}\n\ninterface IconButtonProps {\n onClick?: () => void\n disabled?: boolean\n busy?: boolean\n label: string\n children: React.ReactNode\n}\n\nfunction IconButton({ onClick, disabled, busy, label, children }: IconButtonProps) {\n return (\n <button\n type=\"button\"\n onClick={onClick}\n disabled={disabled}\n aria-label={label}\n title={label}\n className=\"p-1 rounded border border-border bg-surface-card text-text-primary hover:border-gold-dim hover:text-gold-dim disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\n >\n {busy ? <Spinner /> : children}\n </button>\n )\n}\n\nfunction Spinner({ small = false }: { small?: boolean }) {\n const cls = small ? 'w-3.5 h-3.5 animate-spin' : 'w-4 h-4 animate-spin'\n return (\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={cls}>\n <path d=\"M21 12a9 9 0 1 1-6.219-8.56\" />\n </svg>\n )\n}\n\nfunction ArrowUpFromBox() {\n return (\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"w-4 h-4\">\n <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />\n <polyline points=\"17 8 12 3 7 8\" />\n <line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\" />\n </svg>\n )\n}\n\nfunction ArrowDownToBox({ small = false }: { small?: boolean }) {\n const cls = small ? 'w-3.5 h-3.5' : 'w-4 h-4'\n return (\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={cls}>\n <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />\n <polyline points=\"7 10 12 15 17 10\" />\n <line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\" />\n </svg>\n )\n}\n\nfunction PlusIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"w-3.5 h-3.5\">\n <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\" />\n <line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\" />\n </svg>\n )\n}\n\nfunction TrashIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"w-3.5 h-3.5\">\n <path d=\"M3 6h18\" />\n <path d=\"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\" />\n <path d=\"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6\" />\n <path d=\"M10 11v6M14 11v6\" />\n </svg>\n )\n}\n","/**\n * Converts a compiled FV-1 binary (Uint8Array, big-endian) to an array of\n * 32-bit machine code words suitable for FV1Simulator.loadProgram().\n *\n * An FV-1 program is 128 instructions × 4 bytes = 512 bytes.\n */\nexport function binaryToMachineCode(binary: Uint8Array): number[] {\n const words: number[] = []\n const view = new DataView(binary.buffer, binary.byteOffset, binary.byteLength)\n for (let i = 0; i < binary.length; i += 4) {\n words.push(view.getUint32(i, false)) // big-endian\n }\n return words\n}\n","import { useRef, useState, useCallback, useEffect } from 'react'\nimport { FV1Assembler } from '@audiofab-io/fv1-core'\nimport type { FV1AssemblerProblem } from '@audiofab-io/fv1-core'\nimport { binaryToMachineCode } from '../audio/binary'\n\nexport type ChannelMode = 'mono' | 'stereo'\n\nexport interface SimulatorState {\n /** True when audio is currently playing. */\n playing: boolean\n /** True when the bypass footswitch is engaged (signal passes through dry). */\n bypassed: boolean\n /** Output channel mode — see ChannelMode. */\n channelMode: ChannelMode\n /** Sample rate of the AudioContext (set after init). */\n sampleRate: number | null\n}\n\nexport interface UseSimulatorOptions {\n /**\n * URL of the bundled FV-1 audio worklet processor. Required.\n *\n * In Vite-based consumers, prefer:\n * import workletUrl from '@audiofab-io/easy-spin-ui/worklet?url'\n *\n * In VS Code webview consumers, derive the URL via\n * webview.asWebviewUri(...)\n * after copying `node_modules/@audiofab-io/easy-spin-ui/dist/worklet/fv1-processor.js`\n * into your webview's resource directory.\n */\n workletUrl: string\n /** AudioContext sample rate. Defaults to 32 768 Hz to match the FV-1. */\n sampleRate?: number\n}\n\nexport interface AssembleResult {\n success: boolean\n errors?: FV1AssemblerProblem[]\n}\n\n/**\n * Headless hook that drives an off-DOM AudioContext + FV-1 audio worklet.\n *\n * The hook is deliberately decoupled from any \"where do programs come from\"\n * concern — consumers fetch / compile / load binaries themselves and pass\n * them in via `loadProgram`. The same applies to audio clips.\n */\nexport function useSimulator({ workletUrl, sampleRate = 32768 }: UseSimulatorOptions) {\n const ctxRef = useRef<AudioContext | null>(null)\n const workletRef = useRef<AudioWorkletNode | null>(null)\n const sourceRef = useRef<AudioBufferSourceNode | null>(null)\n const clipBufferRef = useRef<AudioBuffer | null>(null)\n const initPromiseRef = useRef<Promise<void> | null>(null)\n\n const [state, setState] = useState<SimulatorState>({\n playing: false,\n bypassed: false,\n channelMode: 'mono',\n sampleRate: null,\n })\n\n const init = useCallback(async () => {\n if (ctxRef.current) return\n if (initPromiseRef.current) return initPromiseRef.current\n\n initPromiseRef.current = (async () => {\n const ctx = new AudioContext({ sampleRate })\n await ctx.audioWorklet.addModule(workletUrl)\n const worklet = new AudioWorkletNode(ctx, 'fv1-processor', {\n outputChannelCount: [2],\n })\n worklet.connect(ctx.destination)\n ctxRef.current = ctx\n workletRef.current = worklet\n setState(s => ({ ...s, sampleRate: ctx.sampleRate }))\n })()\n\n return initPromiseRef.current\n }, [workletUrl, sampleRate])\n\n /** Load a pre-compiled FV-1 binary (512 bytes, big-endian) into the simulator. */\n const loadProgram = useCallback(async (binary: Uint8Array) => {\n await init()\n const code = binaryToMachineCode(binary)\n workletRef.current!.port.postMessage({\n type: 'loadProgram',\n machineCode: code,\n })\n }, [init])\n\n /**\n * Convenience: assemble FV-1 source and load the result.\n *\n * Returns `{ success: false, errors }` on fatal assembler errors so callers\n * can surface them. Non-fatal warnings are ignored here.\n */\n const loadProgramFromSource = useCallback(async (spnSource: string): Promise<AssembleResult> => {\n await init()\n const assembler = new FV1Assembler()\n const result = assembler.assemble(spnSource)\n const fatal = result.problems.filter(p => p.isfatal)\n if (fatal.length > 0) {\n return { success: false, errors: fatal }\n }\n const binary = FV1Assembler.toUint8Array(result.machineCode)\n const code = binaryToMachineCode(binary)\n workletRef.current!.port.postMessage({\n type: 'loadProgram',\n machineCode: code,\n })\n return { success: true }\n }, [init])\n\n /**\n * Decode an audio clip from raw bytes and stage it as the simulator's input.\n * If audio is currently playing, the new clip starts immediately; otherwise\n * it's just held until `play()` is called.\n */\n const loadClipBuffer = useCallback(async (clipBytes: ArrayBuffer | Uint8Array) => {\n await init()\n const ctx = ctxRef.current!\n const wasPlaying = sourceRef.current !== null\n\n if (sourceRef.current) {\n try { sourceRef.current.stop() } catch { /* already stopped */ }\n sourceRef.current.disconnect()\n sourceRef.current = null\n }\n\n const ab = clipBytes instanceof ArrayBuffer\n ? clipBytes\n : (clipBytes.buffer.slice(clipBytes.byteOffset, clipBytes.byteOffset + clipBytes.byteLength) as ArrayBuffer)\n const buffer = await ctx.decodeAudioData(ab)\n clipBufferRef.current = buffer\n\n if (wasPlaying) {\n const source = ctx.createBufferSource()\n source.buffer = buffer\n source.loop = true\n source.connect(workletRef.current!)\n source.start()\n sourceRef.current = source\n await ctx.resume()\n }\n }, [init])\n\n const play = useCallback(async () => {\n await init()\n const ctx = ctxRef.current!\n const buffer = clipBufferRef.current\n if (!buffer) return\n\n // AudioBufferSourceNode is single-use — create a fresh one each time.\n if (sourceRef.current) {\n try { sourceRef.current.stop() } catch { /* already stopped */ }\n sourceRef.current.disconnect()\n }\n\n const source = ctx.createBufferSource()\n source.buffer = buffer\n source.loop = true\n source.connect(workletRef.current!)\n source.start()\n sourceRef.current = source\n await ctx.resume()\n\n setState(s => ({ ...s, playing: true }))\n }, [init])\n\n const pause = useCallback(() => {\n if (sourceRef.current) {\n try { sourceRef.current.stop() } catch { /* already stopped */ }\n sourceRef.current.disconnect()\n sourceRef.current = null\n }\n ctxRef.current?.suspend()\n setState(s => ({ ...s, playing: false }))\n }, [])\n\n const setPot = useCallback((index: number, value: number) => {\n workletRef.current?.port.postMessage({ type: 'setPot', index, value })\n }, [])\n\n const setBypass = useCallback((active: boolean) => {\n workletRef.current?.port.postMessage({ type: 'bypass', active })\n setState(s => ({ ...s, bypassed: active }))\n }, [])\n\n const setChannelMode = useCallback((mode: ChannelMode) => {\n workletRef.current?.port.postMessage({ type: 'setChannelMode', mode })\n setState(s => ({ ...s, channelMode: mode }))\n }, [])\n\n // Tear down the AudioContext on unmount to avoid leaking audio threads.\n useEffect(() => {\n return () => {\n if (sourceRef.current) {\n try { sourceRef.current.stop() } catch { /* already stopped */ }\n sourceRef.current.disconnect()\n }\n ctxRef.current?.close().catch(() => { /* already closed */ })\n }\n }, [])\n\n return {\n ...state,\n loadProgram,\n loadProgramFromSource,\n loadClipBuffer,\n play,\n pause,\n setPot,\n setBypass,\n setChannelMode,\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/components/Knob.tsx","../src/components/ProgramSelector.tsx","../src/components/ClipSelector.tsx","../src/components/PedalFace.tsx","../src/audio/binary.ts","../src/simulator/useSimulator.ts"],"sourcesContent":["import { useRef, useCallback } from 'react'\n\ninterface KnobProps {\n value: number // 0.0 – 1.0\n label: string\n onChange: (value: number) => void\n size?: number\n /** When true, render without a visible body — just a pointer indicator\n * (for overlaying on top of a pre-drawn dial graphic). */\n overlay?: boolean\n}\n\nconst SWEEP_DEG = 300\nconst MIN_DEG = -SWEEP_DEG / 2\n\nexport function Knob({ value, label, onChange, size = 56, overlay = false }: KnobProps) {\n const knobRef = useRef<SVGSVGElement>(null)\n const dragging = useRef(false)\n const startAngle = useRef(0)\n const startValue = useRef(0)\n\n const getAngleFromEvent = useCallback((e: PointerEvent) => {\n const el = knobRef.current\n if (!el) return 0\n const rect = el.getBoundingClientRect()\n const cx = rect.left + rect.width / 2\n const cy = rect.top + rect.height / 2\n return Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI)\n }, [])\n\n const onPointerDown = useCallback((e: React.PointerEvent) => {\n dragging.current = true\n startAngle.current = getAngleFromEvent(e.nativeEvent)\n startValue.current = value\n ;(e.target as Element).setPointerCapture(e.pointerId)\n }, [getAngleFromEvent, value])\n\n const onPointerMove = useCallback((e: React.PointerEvent) => {\n if (!dragging.current) return\n const angle = getAngleFromEvent(e.nativeEvent)\n const delta = angle - startAngle.current\n const norm = ((delta + 540) % 360) - 180\n const valueDelta = norm / SWEEP_DEG\n const newValue = Math.max(0, Math.min(1, startValue.current + valueDelta))\n onChange(newValue)\n }, [getAngleFromEvent, onChange])\n\n const onPointerUp = useCallback(() => {\n dragging.current = false\n }, [])\n\n const rotation = MIN_DEG + value * SWEEP_DEG\n\n const svg = (\n <svg\n ref={knobRef}\n width={overlay ? '100%' : size}\n height={overlay ? '100%' : size}\n viewBox=\"0 0 56 56\"\n className=\"cursor-grab active:cursor-grabbing select-none touch-none\"\n role=\"slider\"\n aria-valuenow={Math.round(value * 100)}\n aria-valuemin={0}\n aria-valuemax={100}\n aria-label={label}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n >\n <circle cx=\"28\" cy=\"28\" r=\"28\" fill=\"transparent\" />\n {overlay ? (\n <circle cx=\"28\" cy=\"28\" r=\"24\" fill=\"none\" stroke=\"#111\" strokeWidth=\"1.5\" />\n ) : (\n <>\n <circle cx=\"28\" cy=\"28\" r=\"26\" fill=\"none\" stroke=\"#888\" strokeWidth=\"1.5\" />\n <circle cx=\"28\" cy=\"28\" r=\"22\" fill=\"#f0f0f0\" stroke=\"#aaa\" strokeWidth=\"1\" />\n </>\n )}\n <g transform={`rotate(${rotation} 28 28)`}>\n <line\n x1=\"28\"\n y1=\"28\"\n x2=\"28\"\n y2={overlay ? 10 : 8}\n stroke={overlay ? '#111' : '#B8942C'}\n strokeWidth={overlay ? 2.5 : 2}\n strokeLinecap=\"round\"\n />\n </g>\n </svg>\n )\n\n if (overlay) {\n return svg\n }\n\n return (\n <div className=\"flex flex-col items-center gap-1\">\n {svg}\n <span className=\"text-[10px] text-text-secondary text-center leading-tight max-w-[80px] truncate\">\n {label}\n </span>\n </div>\n )\n}\n","import { useRef, useCallback } from 'react'\nimport { PROGRAM_SLOT_COUNT } from '@audiofab-io/fv1-core/pedal'\n\ninterface ProgramSelectorProps {\n selectedSlot: number\n onSelectSlot: (slot: number) => void\n slotLabels?: string[]\n /** When true, render as a rotary dial with a pointer indicator (no visible body). */\n overlay?: boolean\n}\n\nconst POS_COUNT = PROGRAM_SLOT_COUNT\nconst START_ANGLE_DEG = 180 // position 1 (lower-left)\nconst END_ANGLE_DEG = 30 + 360 // position 8 (lower-right, via the top)\nconst STEP_DEG = (END_ANGLE_DEG - START_ANGLE_DEG) / (POS_COUNT - 1)\n\nfunction angleForPosition(index0: number): number {\n return START_ANGLE_DEG + index0 * STEP_DEG\n}\n\nfunction nearestSlotFromPointerAngle(pointerDeg: number): number {\n const a = ((pointerDeg % 360) + 360) % 360\n let bestSlot = 0\n let bestDist = Infinity\n for (let i = 0; i < POS_COUNT; i++) {\n const slotAngle = ((angleForPosition(i) % 360) + 360) % 360\n let diff = Math.abs(a - slotAngle)\n if (diff > 180) diff = 360 - diff\n if (diff < bestDist) {\n bestDist = diff\n bestSlot = i\n }\n }\n return bestSlot\n}\n\n/**\n * Rotary program selector. In overlay mode it renders as a pot-style knob\n * (black circular outline + pointer) that points at the currently selected\n * slot. The knob can be dragged to change program — it snaps to the nearest\n * of the 8 discrete positions.\n */\nexport function ProgramSelector({\n selectedSlot,\n onSelectSlot,\n slotLabels,\n overlay = false,\n}: ProgramSelectorProps) {\n const knobRef = useRef<SVGSVGElement>(null)\n const dragging = useRef(false)\n\n const getAngleFromEvent = useCallback((e: PointerEvent) => {\n const el = knobRef.current\n if (!el) return 0\n const rect = el.getBoundingClientRect()\n const cx = rect.left + rect.width / 2\n const cy = rect.top + rect.height / 2\n const raw = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI)\n return ((raw % 360) + 360) % 360\n }, [])\n\n const onPointerDown = useCallback((e: React.PointerEvent) => {\n dragging.current = true\n ;(e.target as Element).setPointerCapture(e.pointerId)\n const angle = getAngleFromEvent(e.nativeEvent)\n const nearest = nearestSlotFromPointerAngle(angle)\n if (nearest !== selectedSlot) onSelectSlot(nearest)\n }, [getAngleFromEvent, selectedSlot, onSelectSlot])\n\n const onPointerMove = useCallback((e: React.PointerEvent) => {\n if (!dragging.current) return\n const angle = getAngleFromEvent(e.nativeEvent)\n const nearest = nearestSlotFromPointerAngle(angle)\n if (nearest !== selectedSlot) onSelectSlot(nearest)\n }, [getAngleFromEvent, selectedSlot, onSelectSlot])\n\n const onPointerUp = useCallback(() => {\n dragging.current = false\n }, [])\n\n if (!overlay) {\n return (\n <div className=\"grid grid-cols-4 gap-1\">\n {Array.from({ length: POS_COUNT }, (_, i) => (\n <button\n key={i}\n onClick={() => onSelectSlot(i)}\n title={slotLabels?.[i] ?? `Slot ${i + 1}`}\n className={`w-7 h-7 rounded text-xs font-bold ${selectedSlot === i\n ? 'bg-gold text-surface'\n : 'bg-surface-hover text-text-secondary hover:bg-surface-card border border-border'\n }`}\n >\n {i + 1}\n </button>\n ))}\n </div>\n )\n }\n\n const rotation = angleForPosition(selectedSlot) - 270\n\n return (\n <svg\n ref={knobRef}\n width=\"100%\"\n height=\"100%\"\n viewBox=\"0 0 56 56\"\n className=\"cursor-grab active:cursor-grabbing select-none touch-none\"\n role=\"listbox\"\n aria-label=\"Program selector\"\n aria-activedescendant={`prog-slot-${selectedSlot}`}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n >\n <circle cx=\"28\" cy=\"28\" r=\"28\" fill=\"transparent\" />\n <circle cx=\"28\" cy=\"28\" r=\"24\" fill=\"none\" stroke=\"#111\" strokeWidth=\"1.5\" />\n <g transform={`rotate(${rotation} 28 28)`}>\n <line\n x1=\"28\"\n y1=\"28\"\n x2=\"28\"\n y2=\"10\"\n stroke=\"#111\"\n strokeWidth=\"2.5\"\n strokeLinecap=\"round\"\n />\n </g>\n </svg>\n )\n}\n","export interface ClipInfo {\n id: string\n name: string\n description?: string\n}\n\ninterface ClipSelectorProps {\n /** List of clips the consumer wants to expose. */\n clips: ClipInfo[]\n selectedClipId: string | null\n onSelect: (clipId: string) => void\n /** When true, the selector is visually disabled (e.g. while a list is loading). */\n disabled?: boolean\n}\n\n/**\n * Pure presentational dropdown for picking an audio clip. The shared package\n * has no opinion about where clips come from — consumers fetch / bundle their\n * own list and pass it in.\n */\nexport function ClipSelector({ clips, selectedClipId, onSelect, disabled = false }: ClipSelectorProps) {\n return (\n <select\n value={selectedClipId ?? ''}\n onChange={e => onSelect(e.target.value)}\n disabled={disabled}\n className=\"px-2 py-1 rounded bg-surface-hover border border-border text-xs text-text-primary focus:outline-none focus:border-gold-dim disabled:opacity-50\"\n >\n <option value=\"\" disabled>\n {disabled ? 'Loading…' : 'Select clip'}\n </option>\n {clips.map(clip => (\n <option key={clip.id} value={clip.id}>\n {clip.name}\n </option>\n ))}\n </select>\n )\n}\n","import { useState } from 'react'\nimport { Knob } from './Knob'\nimport { ProgramSelector } from './ProgramSelector'\nimport { ClipSelector, type ClipInfo } from './ClipSelector'\nimport { PROGRAM_SLOT_COUNT } from '@audiofab-io/fv1-core/pedal'\n\nexport type ChannelMode = 'mono' | 'stereo'\n\nexport interface PedalFaceProps {\n /** URL of the pedal outline graphic. Required — see assets/pedal.png in\n * this package, or bring your own. */\n pedalImageUrl: string\n\n /** Current values of the three pots, normalised 0..1. */\n pots: [number, number, number]\n onPotChange: (index: number, value: number) => void\n /** Per-pot display labels — typically the effect's `controls[].name`,\n * or `Pot 0/1/2` when the active program is unknown. */\n potLabels?: [string, string, string]\n\n /** Currently selected slot (0-based). */\n selectedSlot: number\n onSelectSlot: (slot: number) => void\n /** Display labels for each of the 8 slots. Empty / undefined means empty. */\n slotLabels?: string[]\n /** Drop handler for slot N. The data string is the platform-specific\n * payload (e.g. an effect id, a file URI). The consumer interprets it. */\n onAssignSlot?: (slotIndex: number, payload: string) => void\n onUnassignSlot?: (slotIndex: number) => void\n onUnassignAllSlots?: () => void\n /** Called when the user clicks the per-slot \"send to pedal\" icon. */\n onProgramSlot?: (slotIndex: number) => void\n /** Index of the slot currently being written to the pedal, or null. */\n programmingSlot?: number | null\n /** When set, empty slots show a \"+\" button that calls this callback —\n * used by hosts that have a notion of a \"currently tracked / active\"\n * program the user can drop into a slot. */\n onAssignTrackedToSlot?: (slotIndex: number) => void\n /** When set, Ctrl/Cmd+click and right-click on an assigned slot fires\n * this callback so hosts can \"open the source file\" — e.g. open the\n * underlying .spn / .spndiagram in an editor tab. */\n onOpenSlotSource?: (slotIndex: number) => void\n\n /** Audio clip selection — pass an empty array to hide the selector. */\n clips?: ClipInfo[]\n selectedClipId: string | null\n onSelectClip: (clipId: string) => void\n\n playing: boolean\n onPlay: () => void\n onPause: () => void\n bypassed: boolean\n onToggleBypass: () => void\n channelMode: ChannelMode\n onChannelModeChange: (mode: ChannelMode) => void\n\n /** Pedal connection state — the I/O button cluster only renders when\n * these are wired up. */\n pedalConnected?: boolean\n onConnectPedal?: () => void\n onReadPedal?: () => void\n onWritePedal?: () => void\n pedalReading?: boolean\n pedalWriting?: boolean\n}\n\n/**\n * Overlay positions as percentages of the pedal image (1573 × 2627).\n *\n * pot0 / pot1 / pot2 — potentiometer centres\n * programSelector — rotary program selector knob centre\n * led — status LED\n * footswitch — bypass footswitch centre\n */\nconst POS = {\n pot0: { left: '22.85%', top: '14%' },\n pot1: { left: '77.15%', top: '14%' },\n pot2: { left: '22.85%', top: '33.75%' },\n programSelector: { left: '76%', top: '33.75%' },\n led: { left: '50%', top: '42%' },\n footswitch: { left: '50%', top: '68.5%' },\n pot0Label: { left: '22.85%', top: '2.5%' },\n pot1Label: { left: '77.15%', top: '2.5%' },\n pot2Label: { left: '22.85%', top: '44%' },\n pedalIO: { left: '105%', top: '70%' },\n}\n\nconst KNOB_SIZE_PCT = 20\nconst FOOTSWITCH_SIZE_PCT = 14\nconst HEX_NUT_SIZE_PCT = 20\nconst LED_SIZE_PCT = 5\n\nexport function PedalFace({\n pedalImageUrl,\n pots,\n onPotChange,\n potLabels = ['Pot 0', 'Pot 1', 'Pot 2'],\n selectedSlot,\n onSelectSlot,\n slotLabels,\n onAssignSlot,\n onUnassignSlot,\n onUnassignAllSlots,\n onProgramSlot,\n programmingSlot = null,\n onAssignTrackedToSlot,\n onOpenSlotSource,\n clips,\n selectedClipId,\n onSelectClip,\n playing,\n onPlay,\n onPause,\n bypassed,\n onToggleBypass,\n channelMode,\n onChannelModeChange,\n pedalConnected = false,\n onConnectPedal,\n onReadPedal,\n onWritePedal,\n pedalReading = false,\n pedalWriting = false,\n}: PedalFaceProps) {\n const pedalBusy = pedalReading || pedalWriting\n const [dragOverSlot, setDragOverSlot] = useState<number | null>(null)\n\n const ledOn = !bypassed\n const anyAssigned = slotLabels?.some(l => l) ?? false\n const slotProgrammable = pedalConnected && !pedalBusy\n\n return (\n <div className=\"flex flex-col items-center gap-5 w-full max-w-[460px] mx-auto\">\n <div\n className=\"relative w-full max-w-[360px] pedal-outline\"\n style={{\n aspectRatio: '1573 / 2627',\n backgroundImage: `url(${pedalImageUrl})`,\n backgroundSize: 'contain',\n backgroundRepeat: 'no-repeat',\n backgroundPosition: 'center top',\n }}\n >\n <KnobOverlay\n posStyle={POS.pot0}\n sizePct={KNOB_SIZE_PCT}\n value={pots[0]}\n label={potLabels[0]}\n onChange={v => onPotChange(0, v)}\n />\n <KnobOverlay\n posStyle={POS.pot1}\n sizePct={KNOB_SIZE_PCT}\n value={pots[1]}\n label={potLabels[1]}\n onChange={v => onPotChange(1, v)}\n />\n <KnobOverlay\n posStyle={POS.pot2}\n sizePct={KNOB_SIZE_PCT}\n value={pots[2]}\n label={potLabels[2]}\n onChange={v => onPotChange(2, v)}\n />\n\n <PotLabel posStyle={POS.pot0Label} text={potLabels[0]} />\n <PotLabel posStyle={POS.pot1Label} text={potLabels[1]} />\n <PotLabel posStyle={POS.pot2Label} text={potLabels[2]} />\n\n {/* Program Selector knob */}\n <div\n className=\"absolute\"\n style={{\n left: POS.programSelector.left,\n top: POS.programSelector.top,\n width: `${KNOB_SIZE_PCT}%`,\n aspectRatio: '1 / 1',\n transform: 'translate(-50%, -50%)',\n }}\n >\n <ProgramSelector\n overlay\n selectedSlot={selectedSlot}\n onSelectSlot={onSelectSlot}\n slotLabels={slotLabels}\n />\n </div>\n\n {/* LED */}\n <div\n className=\"absolute rounded-full transition-all\"\n style={{\n left: POS.led.left,\n top: POS.led.top,\n width: `${LED_SIZE_PCT}%`,\n aspectRatio: '1 / 1',\n transform: 'translate(-50%, -50%)',\n background: ledOn\n ? 'radial-gradient(circle at 35% 30%, #ff8a8a 0%, #e11 55%, #800 100%)'\n : 'radial-gradient(circle at 35% 30%, #f2f2f2 0%, #bbb 60%, #777 100%)',\n boxShadow: ledOn\n ? '0 0 10px rgba(255, 60, 60, 0.75)'\n : 'inset 0 1px 2px rgba(0,0,0,0.25)',\n border: '1px solid #333',\n }}\n aria-label=\"Status LED\"\n />\n\n {/* Hex-shaped silver retaining nut frames the footswitch */}\n <svg\n viewBox=\"0 0 100 100\"\n className=\"absolute pointer-events-none\"\n style={{\n left: POS.footswitch.left,\n top: POS.footswitch.top,\n width: `${HEX_NUT_SIZE_PCT}%`,\n aspectRatio: '1 / 1',\n transform: 'translate(-50%, -50%)',\n filter: 'drop-shadow(0 2px 2px rgba(0,0,0,0.45))',\n }}\n >\n <defs>\n <radialGradient id=\"silverHexNut\" cx=\"40%\" cy=\"35%\" r=\"65%\">\n <stop offset=\"0%\" stopColor=\"#f8f8f8\" />\n <stop offset=\"45%\" stopColor=\"#c8c8c8\" />\n <stop offset=\"100%\" stopColor=\"#707070\" />\n </radialGradient>\n </defs>\n <polygon\n points=\"5,50 27.5,11 72.5,11 95,50 72.5,89 27.5,89\"\n fill=\"url(#silverHexNut)\"\n stroke=\"#333\"\n strokeWidth=\"2\"\n strokeLinejoin=\"round\"\n />\n </svg>\n\n {/* Pedal I/O cluster — only rendered when consumer wires it up */}\n {(onConnectPedal || onReadPedal || onWritePedal) && (\n <div\n className=\"absolute flex items-center gap-1\"\n style={{\n left: POS.pedalIO.left,\n top: POS.pedalIO.top,\n transform: 'translate(-50%, -50%)',\n }}\n >\n {!pedalConnected ? (\n onConnectPedal && (\n <button\n type=\"button\"\n onClick={onConnectPedal}\n className=\"px-2 py-1 rounded border border-border bg-surface-card text-[10px] font-semibold text-text-primary hover:border-gold-dim transition-colors whitespace-nowrap\"\n >\n Connect\n </button>\n )\n ) : (\n <>\n {onReadPedal && (\n <IconButton\n onClick={onReadPedal}\n disabled={pedalBusy}\n busy={pedalReading}\n label=\"Read programs from pedal\"\n >\n <ArrowUpFromBox />\n </IconButton>\n )}\n {onWritePedal && (\n <IconButton\n onClick={onWritePedal}\n disabled={pedalBusy}\n busy={pedalWriting}\n label=\"Write programs to pedal\"\n >\n <ArrowDownToBox />\n </IconButton>\n )}\n </>\n )}\n </div>\n )}\n\n {/* Footswitch — silver stompbox-style */}\n <button\n onClick={onToggleBypass}\n aria-label={bypassed ? 'Bypassed — click to engage' : 'Engaged — click to bypass'}\n className=\"absolute rounded-full cursor-pointer\"\n style={{\n left: POS.footswitch.left,\n top: POS.footswitch.top,\n width: `${FOOTSWITCH_SIZE_PCT}%`,\n aspectRatio: '1 / 1',\n transform: 'translate(-50%, -50%)',\n background: 'radial-gradient(circle at 35% 30%, #fafafa 0%, #cfcfcf 45%, #888 90%, #555 100%)',\n border: '1.5px solid #222',\n boxShadow: '0 2px 4px rgba(0,0,0,0.4), inset 0 1px 1px rgba(255,255,255,0.6), inset 0 -2px 3px rgba(0,0,0,0.25)',\n }}\n >\n <span\n className=\"absolute rounded-full pointer-events-none\"\n style={{\n left: '50%',\n top: '50%',\n width: '80%',\n aspectRatio: '1 / 1',\n transform: 'translate(-50%, -50%)',\n background: 'radial-gradient(circle at 40% 35%, #f0f0f0 0%, #b0b0b0 60%, #707070 100%)',\n border: '1px solid #444',\n boxShadow: 'inset 0 1px 1px rgba(255,255,255,0.5), inset 0 -1px 2px rgba(0,0,0,0.35)',\n }}\n />\n </button>\n </div>\n\n {/* Web/extension playback controls (not part of the physical pedal) */}\n <div className=\"flex items-center gap-3 flex-wrap justify-center\">\n <button\n onClick={playing ? onPause : onPlay}\n className=\"px-4 py-1.5 rounded border border-border bg-surface-card text-sm font-semibold text-text-primary hover:border-gold-dim transition-colors\"\n >\n {playing ? 'Pause' : 'Play'}\n </button>\n {clips && clips.length > 0 && (\n <ClipSelector\n clips={clips}\n selectedClipId={selectedClipId}\n onSelect={onSelectClip}\n />\n )}\n <div\n role=\"radiogroup\"\n aria-label=\"Output channel mode\"\n title=\"Mono matches the current Easy Spin hardware (DACL to both jacks). Stereo plays the FV-1's native left/right outputs.\"\n className=\"flex items-center rounded border border-border bg-surface-card text-sm overflow-hidden\"\n >\n {(['mono', 'stereo'] as const).map(mode => (\n <button\n key={mode}\n type=\"button\"\n role=\"radio\"\n aria-checked={channelMode === mode}\n onClick={() => onChannelModeChange(mode)}\n className={`px-3 py-1.5 font-semibold transition-colors ${\n channelMode === mode\n ? 'bg-gold text-surface'\n : 'text-text-secondary hover:text-text-primary'\n }`}\n >\n {mode === 'mono' ? 'Mono' : 'Stereo'}\n </button>\n ))}\n </div>\n </div>\n\n {/* Program slots — drop a payload onto a slot to assign it; click a\n slot to make it the active program. */}\n <div className=\"w-full space-y-2\">\n <div className=\"flex items-center justify-between\">\n <h3 className=\"text-xs font-semibold uppercase tracking-wider text-text-muted\">\n Programs on Pedal\n </h3>\n {anyAssigned && onUnassignAllSlots && (\n <button\n type=\"button\"\n onClick={onUnassignAllSlots}\n className=\"text-[11px] text-text-muted hover:text-red-400 transition-colors\"\n title=\"Clear all local slot assignments\"\n >\n Unassign all\n </button>\n )}\n </div>\n <div className=\"grid grid-cols-2 gap-2\">\n {Array.from({ length: PROGRAM_SLOT_COUNT }, (_, i) => {\n const isActive = selectedSlot === i\n const isDragOver = dragOverSlot === i\n const label = slotLabels?.[i]\n const isEmpty = !label\n return (\n <div\n key={i}\n role=\"button\"\n tabIndex={0}\n onClick={e => {\n // Ctrl/Cmd+click on an assigned slot opens the source file\n // (consumer-defined). Plain click selects the slot.\n if ((e.ctrlKey || e.metaKey) && !isEmpty && onOpenSlotSource) {\n e.preventDefault()\n onOpenSlotSource(i)\n return\n }\n onSelectSlot(i)\n }}\n onContextMenu={e => {\n if (!isEmpty && onOpenSlotSource) {\n e.preventDefault()\n onOpenSlotSource(i)\n }\n }}\n onKeyDown={e => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault()\n onSelectSlot(i)\n }\n }}\n onDragOver={e => {\n if (!onAssignSlot) return\n e.preventDefault()\n // Pick a dropEffect that's compatible with whatever the\n // drag source set as its allowed effects. Without this,\n // sources that don't allow 'copy' (e.g. VS Code's Explorer)\n // require Ctrl to drop, because the OS only upgrades to\n // 'copy' when a modifier key is held. Matching the source's\n // allowed effects lets the drop fire without modifiers.\n e.dataTransfer.dropEffect = pickDropEffect(e.dataTransfer.effectAllowed)\n if (dragOverSlot !== i) setDragOverSlot(i)\n }}\n onDragLeave={() => setDragOverSlot(null)}\n onDrop={e => {\n if (!onAssignSlot) return\n e.preventDefault()\n setDragOverSlot(null)\n // Try text/plain first (web-app effect-id pattern), then\n // text/uri-list (VS Code / OS file drags). uri-list payloads\n // are newline-separated; we take the first URI.\n let payload = e.dataTransfer.getData('text/plain')\n if (!payload) {\n const uriList = e.dataTransfer.getData('text/uri-list')\n if (uriList) {\n payload = uriList.split('\\n').map(s => s.trim()).find(s => s && !s.startsWith('#')) ?? ''\n }\n }\n if (payload) onAssignSlot(i, payload)\n }}\n className={[\n 'flex items-center gap-2 px-2 py-2 rounded border text-left transition-colors cursor-pointer',\n 'bg-surface-card text-sm outline-none focus-visible:ring-1 focus-visible:ring-gold-dim',\n isActive\n ? 'border-gold-dim ring-1 ring-gold-dim/60 text-text-primary'\n : 'border-border text-text-primary hover:border-gold-dim',\n isDragOver ? 'border-dashed border-gold-dim bg-gold-dim/10' : '',\n ].join(' ')}\n >\n <span\n className={[\n 'flex-none w-6 h-6 inline-flex items-center justify-center rounded-full text-xs font-bold',\n isActive ? 'bg-gold-dim text-black' : 'bg-black/30 text-text-primary',\n ].join(' ')}\n >\n {i + 1}\n </span>\n <span\n className={[\n 'flex-1 min-w-0 truncate',\n isEmpty ? 'italic text-text-muted' : 'font-medium',\n ].join(' ')}\n >\n {isEmpty ? 'Empty' : label}\n </span>\n {isEmpty && onAssignTrackedToSlot && (\n <button\n type=\"button\"\n onClick={e => {\n e.stopPropagation()\n onAssignTrackedToSlot(i)\n }}\n aria-label={`Assign currently tracked program to slot ${i + 1}`}\n title=\"Assign currently tracked program here\"\n className=\"flex-none p-1 rounded text-text-muted hover:text-gold-dim hover:bg-black/20 transition-colors\"\n >\n <PlusIcon />\n </button>\n )}\n {!isEmpty && onProgramSlot && slotProgrammable && (\n <button\n type=\"button\"\n onClick={e => {\n e.stopPropagation()\n onProgramSlot(i)\n }}\n disabled={programmingSlot !== null}\n aria-label={`Write slot ${i + 1} to pedal`}\n title=\"Write this slot to pedal\"\n className=\"flex-none p-1 rounded text-text-muted hover:text-gold-dim hover:bg-black/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\n >\n {programmingSlot === i ? <Spinner small /> : <ArrowDownToBox small />}\n </button>\n )}\n {!isEmpty && onUnassignSlot && (\n <button\n type=\"button\"\n onClick={e => {\n e.stopPropagation()\n onUnassignSlot(i)\n }}\n aria-label={`Unassign slot ${i + 1}`}\n title=\"Unassign\"\n className=\"flex-none p-1 rounded text-text-muted hover:text-red-400 hover:bg-black/20 transition-colors\"\n >\n <TrashIcon />\n </button>\n )}\n </div>\n )\n })}\n </div>\n </div>\n </div>\n )\n}\n\ninterface KnobOverlayProps {\n posStyle: { left: string; top: string }\n sizePct: number\n value: number\n label: string\n onChange: (value: number) => void\n}\n\nfunction KnobOverlay({ posStyle, sizePct, value, label, onChange }: KnobOverlayProps) {\n return (\n <div\n className=\"absolute\"\n style={{\n left: posStyle.left,\n top: posStyle.top,\n width: `${sizePct}%`,\n aspectRatio: '1 / 1',\n transform: 'translate(-50%, -50%)',\n }}\n >\n <Knob overlay value={value} label={label} onChange={onChange} />\n </div>\n )\n}\n\ninterface PotLabelProps {\n posStyle: { left: string; top: string }\n text: string\n}\n\nfunction PotLabel({ posStyle, text }: PotLabelProps) {\n return (\n <div\n className=\"absolute text-center text-[11px] sm:text-xs font-semibold text-black whitespace-nowrap pointer-events-none\"\n style={{\n left: posStyle.left,\n top: posStyle.top,\n transform: 'translate(-50%, -50%)',\n }}\n >\n {text}\n </div>\n )\n}\n\ninterface IconButtonProps {\n onClick?: () => void\n disabled?: boolean\n busy?: boolean\n label: string\n children: React.ReactNode\n}\n\nfunction IconButton({ onClick, disabled, busy, label, children }: IconButtonProps) {\n return (\n <button\n type=\"button\"\n onClick={onClick}\n disabled={disabled}\n aria-label={label}\n title={label}\n className=\"p-1 rounded border border-border bg-surface-card text-text-primary hover:border-gold-dim hover:text-gold-dim disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\n >\n {busy ? <Spinner /> : children}\n </button>\n )\n}\n\nfunction Spinner({ small = false }: { small?: boolean }) {\n const cls = small ? 'w-3.5 h-3.5 animate-spin' : 'w-4 h-4 animate-spin'\n return (\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={cls}>\n <path d=\"M21 12a9 9 0 1 1-6.219-8.56\" />\n </svg>\n )\n}\n\nfunction ArrowUpFromBox() {\n return (\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"w-4 h-4\">\n <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />\n <polyline points=\"17 8 12 3 7 8\" />\n <line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\" />\n </svg>\n )\n}\n\nfunction ArrowDownToBox({ small = false }: { small?: boolean }) {\n const cls = small ? 'w-3.5 h-3.5' : 'w-4 h-4'\n return (\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={cls}>\n <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />\n <polyline points=\"7 10 12 15 17 10\" />\n <line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\" />\n </svg>\n )\n}\n\nfunction pickDropEffect(allowed: DataTransfer['effectAllowed']): 'copy' | 'move' | 'link' | 'none' {\n // Choose something the source explicitly allows so the drop fires without\n // requiring modifier keys. Prefer 'copy' for clarity (sliding cursor with +)\n // when it's allowed, otherwise fall back through the other modes.\n switch (allowed) {\n case 'copy':\n case 'copyLink':\n case 'copyMove':\n case 'all':\n case 'uninitialized':\n return 'copy'\n case 'move':\n case 'linkMove':\n return 'move'\n case 'link':\n return 'link'\n case 'none':\n default:\n return 'none'\n }\n}\n\nfunction PlusIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"w-3.5 h-3.5\">\n <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\" />\n <line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\" />\n </svg>\n )\n}\n\nfunction TrashIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"w-3.5 h-3.5\">\n <path d=\"M3 6h18\" />\n <path d=\"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\" />\n <path d=\"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6\" />\n <path d=\"M10 11v6M14 11v6\" />\n </svg>\n )\n}\n","/**\n * Converts a compiled FV-1 binary (Uint8Array, big-endian) to an array of\n * 32-bit machine code words suitable for FV1Simulator.loadProgram().\n *\n * An FV-1 program is 128 instructions × 4 bytes = 512 bytes.\n */\nexport function binaryToMachineCode(binary: Uint8Array): number[] {\n const words: number[] = []\n const view = new DataView(binary.buffer, binary.byteOffset, binary.byteLength)\n for (let i = 0; i < binary.length; i += 4) {\n words.push(view.getUint32(i, false)) // big-endian\n }\n return words\n}\n","import { useRef, useState, useCallback, useEffect } from 'react'\nimport { FV1Assembler } from '@audiofab-io/fv1-core'\nimport type { FV1AssemblerProblem } from '@audiofab-io/fv1-core'\nimport { binaryToMachineCode } from '../audio/binary'\n\nexport type ChannelMode = 'mono' | 'stereo'\n\nexport interface SimulatorState {\n /** True when audio is currently playing. */\n playing: boolean\n /** True when the bypass footswitch is engaged (signal passes through dry). */\n bypassed: boolean\n /** Output channel mode — see ChannelMode. */\n channelMode: ChannelMode\n /** Sample rate of the AudioContext (set after init). */\n sampleRate: number | null\n}\n\nexport interface UseSimulatorOptions {\n /**\n * URL of the bundled FV-1 audio worklet processor. Required.\n *\n * In Vite-based consumers, prefer:\n * import workletUrl from '@audiofab-io/easy-spin-ui/worklet?url'\n *\n * In VS Code webview consumers, derive the URL via\n * webview.asWebviewUri(...)\n * after copying `node_modules/@audiofab-io/easy-spin-ui/dist/worklet/fv1-processor.js`\n * into your webview's resource directory.\n */\n workletUrl: string\n /** AudioContext sample rate. Defaults to 32 768 Hz to match the FV-1. */\n sampleRate?: number\n}\n\nexport interface AssembleResult {\n success: boolean\n errors?: FV1AssemblerProblem[]\n}\n\n/**\n * Headless hook that drives an off-DOM AudioContext + FV-1 audio worklet.\n *\n * The hook is deliberately decoupled from any \"where do programs come from\"\n * concern — consumers fetch / compile / load binaries themselves and pass\n * them in via `loadProgram`. The same applies to audio clips.\n */\nexport function useSimulator({ workletUrl, sampleRate = 32768 }: UseSimulatorOptions) {\n const ctxRef = useRef<AudioContext | null>(null)\n const workletRef = useRef<AudioWorkletNode | null>(null)\n const sourceRef = useRef<AudioBufferSourceNode | null>(null)\n const clipBufferRef = useRef<AudioBuffer | null>(null)\n const initPromiseRef = useRef<Promise<void> | null>(null)\n\n const [state, setState] = useState<SimulatorState>({\n playing: false,\n bypassed: false,\n channelMode: 'mono',\n sampleRate: null,\n })\n\n const init = useCallback(async () => {\n if (ctxRef.current) return\n if (initPromiseRef.current) return initPromiseRef.current\n\n initPromiseRef.current = (async () => {\n const ctx = new AudioContext({ sampleRate })\n await ctx.audioWorklet.addModule(workletUrl)\n const worklet = new AudioWorkletNode(ctx, 'fv1-processor', {\n outputChannelCount: [2],\n })\n worklet.connect(ctx.destination)\n ctxRef.current = ctx\n workletRef.current = worklet\n setState(s => ({ ...s, sampleRate: ctx.sampleRate }))\n })()\n\n return initPromiseRef.current\n }, [workletUrl, sampleRate])\n\n /** Load a pre-compiled FV-1 binary (512 bytes, big-endian) into the simulator. */\n const loadProgram = useCallback(async (binary: Uint8Array) => {\n await init()\n const code = binaryToMachineCode(binary)\n workletRef.current!.port.postMessage({\n type: 'loadProgram',\n machineCode: code,\n })\n }, [init])\n\n /**\n * Convenience: assemble FV-1 source and load the result.\n *\n * Returns `{ success: false, errors }` on fatal assembler errors so callers\n * can surface them. Non-fatal warnings are ignored here.\n */\n const loadProgramFromSource = useCallback(async (spnSource: string): Promise<AssembleResult> => {\n await init()\n const assembler = new FV1Assembler()\n const result = assembler.assemble(spnSource)\n const fatal = result.problems.filter(p => p.isfatal)\n if (fatal.length > 0) {\n return { success: false, errors: fatal }\n }\n const binary = FV1Assembler.toUint8Array(result.machineCode)\n const code = binaryToMachineCode(binary)\n workletRef.current!.port.postMessage({\n type: 'loadProgram',\n machineCode: code,\n })\n return { success: true }\n }, [init])\n\n /**\n * Decode an audio clip from raw bytes and stage it as the simulator's input.\n * If audio is currently playing, the new clip starts immediately; otherwise\n * it's just held until `play()` is called.\n */\n const loadClipBuffer = useCallback(async (clipBytes: ArrayBuffer | Uint8Array) => {\n await init()\n const ctx = ctxRef.current!\n const wasPlaying = sourceRef.current !== null\n\n if (sourceRef.current) {\n try { sourceRef.current.stop() } catch { /* already stopped */ }\n sourceRef.current.disconnect()\n sourceRef.current = null\n }\n\n const ab = clipBytes instanceof ArrayBuffer\n ? clipBytes\n : (clipBytes.buffer.slice(clipBytes.byteOffset, clipBytes.byteOffset + clipBytes.byteLength) as ArrayBuffer)\n const buffer = await ctx.decodeAudioData(ab)\n clipBufferRef.current = buffer\n\n if (wasPlaying) {\n const source = ctx.createBufferSource()\n source.buffer = buffer\n source.loop = true\n source.connect(workletRef.current!)\n source.start()\n sourceRef.current = source\n await ctx.resume()\n }\n }, [init])\n\n const play = useCallback(async () => {\n await init()\n const ctx = ctxRef.current!\n const buffer = clipBufferRef.current\n if (!buffer) return\n\n // AudioBufferSourceNode is single-use — create a fresh one each time.\n if (sourceRef.current) {\n try { sourceRef.current.stop() } catch { /* already stopped */ }\n sourceRef.current.disconnect()\n }\n\n const source = ctx.createBufferSource()\n source.buffer = buffer\n source.loop = true\n source.connect(workletRef.current!)\n source.start()\n sourceRef.current = source\n await ctx.resume()\n\n setState(s => ({ ...s, playing: true }))\n }, [init])\n\n const pause = useCallback(() => {\n if (sourceRef.current) {\n try { sourceRef.current.stop() } catch { /* already stopped */ }\n sourceRef.current.disconnect()\n sourceRef.current = null\n }\n ctxRef.current?.suspend()\n setState(s => ({ ...s, playing: false }))\n }, [])\n\n const setPot = useCallback((index: number, value: number) => {\n workletRef.current?.port.postMessage({ type: 'setPot', index, value })\n }, [])\n\n const setBypass = useCallback((active: boolean) => {\n workletRef.current?.port.postMessage({ type: 'bypass', active })\n setState(s => ({ ...s, bypassed: active }))\n }, [])\n\n const setChannelMode = useCallback((mode: ChannelMode) => {\n workletRef.current?.port.postMessage({ type: 'setChannelMode', mode })\n setState(s => ({ ...s, channelMode: mode }))\n }, [])\n\n // Tear down the AudioContext on unmount to avoid leaking audio threads.\n useEffect(() => {\n return () => {\n if (sourceRef.current) {\n try { sourceRef.current.stop() } catch { /* already stopped */ }\n sourceRef.current.disconnect()\n }\n ctxRef.current?.close().catch(() => { /* already closed */ })\n }\n }, [])\n\n return {\n ...state,\n loadProgram,\n loadProgramFromSource,\n loadClipBuffer,\n play,\n pause,\n setPot,\n setBypass,\n setChannelMode,\n }\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@audiofab-io/easy-spin-ui",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Shared React components and FV-1 simulation runtime for Audiofab's Easy Spin pedal — used by the Easy Spin web tool and the FV-1 VS Code extension.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|