@audiofab-io/easy-spin-ui 0.2.0 → 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.
@@ -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: _, clips: T, selectedClipId: P, onSelectClip: F, playing: I, onPlay: L, onPause: R, bypassed: z, onToggleBypass: B, channelMode: V, onChannelModeChange: H, pedalConnected: U = !1, onConnectPedal: W, onReadPedal: G, onWritePedal: K, pedalReading: q = !1, pedalWriting: J = !1 }) {
244
- let Y = q || J, [X, Z] = r(null), Q = !z, $ = d?.some((e) => e) ?? !1, ee = U && !Y;
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: Q ? "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: Q ? "0 0 10px rgba(255, 60, 60, 0.75)" : "inset 0 1px 2px rgba(0,0,0,0.25)",
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
- (W || G || K) && /* @__PURE__ */ a("div", {
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: U ? /* @__PURE__ */ o(i, { children: [G && /* @__PURE__ */ a(O, {
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: Y,
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(j, {})
378
- })] }) : W && /* @__PURE__ */ a("button", {
377
+ children: /* @__PURE__ */ a(A, {})
378
+ })] }) : G && /* @__PURE__ */ a("button", {
379
379
  type: "button",
380
- onClick: W,
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: B,
387
- "aria-label": z ? "Bypassed — click to engage" : "Engaged — click to bypass",
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: I ? R : L,
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: I ? "Pause" : "Play"
421
+ children: L ? "Pause" : "Play"
422
422
  }),
423
- T && T.length > 0 && /* @__PURE__ */ a(y, {
424
- clips: T,
425
- selectedClipId: P,
426
- onSelect: F
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": V === e,
437
- onClick: () => H(e),
438
- className: `px-3 py-1.5 font-semibold transition-colors ${V === e ? "bg-gold text-surface" : "text-text-secondary hover:text-text-primary"}`,
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
- }), $ && m && /* @__PURE__ */ a("button", {
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 = X === t, i = d?.[t], s = !i;
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: () => u(t),
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 = "copy", X !== t && Z(t));
479
+ f && (e.preventDefault(), e.dataTransfer.dropEffect = j(e.dataTransfer.effectAllowed), Z !== t && Q(t));
471
480
  },
472
- onDragLeave: () => Z(null),
481
+ onDragLeave: () => Q(null),
473
482
  onDrop: (e) => {
474
483
  if (!f) return;
475
- e.preventDefault(), Z(null);
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 && ee && /* @__PURE__ */ a("button", {
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 : j, { small: !0 })
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 A() {
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 j({ small: e = !1 }) {
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.0",
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",
@@ -37,7 +37,7 @@
37
37
  "prepublishOnly": "npm run build"
38
38
  },
39
39
  "peerDependencies": {
40
- "@audiofab-io/fv1-core": "^0.4.0 || ^0.5.0",
40
+ "@audiofab-io/fv1-core": ">=0.4.0 <1.0.0",
41
41
  "react": "^19.0.0",
42
42
  "react-dom": "^19.0.0"
43
43
  },