@audiofab-io/easy-spin-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/dist/assets/pedal.png +0 -0
- package/dist/audio/binary.d.ts +7 -0
- package/dist/components/ClipSelector.d.ts +20 -0
- package/dist/components/Knob.d.ts +11 -0
- package/dist/components/PedalFace.d.ts +47 -0
- package/dist/components/ProgramSelector.d.ts +15 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +764 -0
- package/dist/index.js.map +1 -0
- package/dist/simulator/useSimulator.d.ts +57 -0
- package/dist/styles.css +811 -0
- package/dist/worklet/fv1-processor.js +8 -0
- package/dist/worklet/fv1-processor.js.map +1 -0
- package/package.json +57 -0
|
@@ -0,0 +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\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 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 const payload = e.dataTransfer.getData('text/plain')\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 && 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 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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"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { FV1AssemblerProblem } from '@audiofab-io/fv1-core';
|
|
2
|
+
export type ChannelMode = 'mono' | 'stereo';
|
|
3
|
+
export interface SimulatorState {
|
|
4
|
+
/** True when audio is currently playing. */
|
|
5
|
+
playing: boolean;
|
|
6
|
+
/** True when the bypass footswitch is engaged (signal passes through dry). */
|
|
7
|
+
bypassed: boolean;
|
|
8
|
+
/** Output channel mode — see ChannelMode. */
|
|
9
|
+
channelMode: ChannelMode;
|
|
10
|
+
/** Sample rate of the AudioContext (set after init). */
|
|
11
|
+
sampleRate: number | null;
|
|
12
|
+
}
|
|
13
|
+
export interface UseSimulatorOptions {
|
|
14
|
+
/**
|
|
15
|
+
* URL of the bundled FV-1 audio worklet processor. Required.
|
|
16
|
+
*
|
|
17
|
+
* In Vite-based consumers, prefer:
|
|
18
|
+
* import workletUrl from '@audiofab-io/easy-spin-ui/worklet?url'
|
|
19
|
+
*
|
|
20
|
+
* In VS Code webview consumers, derive the URL via
|
|
21
|
+
* webview.asWebviewUri(...)
|
|
22
|
+
* after copying `node_modules/@audiofab-io/easy-spin-ui/dist/worklet/fv1-processor.js`
|
|
23
|
+
* into your webview's resource directory.
|
|
24
|
+
*/
|
|
25
|
+
workletUrl: string;
|
|
26
|
+
/** AudioContext sample rate. Defaults to 32 768 Hz to match the FV-1. */
|
|
27
|
+
sampleRate?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface AssembleResult {
|
|
30
|
+
success: boolean;
|
|
31
|
+
errors?: FV1AssemblerProblem[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Headless hook that drives an off-DOM AudioContext + FV-1 audio worklet.
|
|
35
|
+
*
|
|
36
|
+
* The hook is deliberately decoupled from any "where do programs come from"
|
|
37
|
+
* concern — consumers fetch / compile / load binaries themselves and pass
|
|
38
|
+
* them in via `loadProgram`. The same applies to audio clips.
|
|
39
|
+
*/
|
|
40
|
+
export declare function useSimulator({ workletUrl, sampleRate }: UseSimulatorOptions): {
|
|
41
|
+
loadProgram: (binary: Uint8Array) => Promise<void>;
|
|
42
|
+
loadProgramFromSource: (spnSource: string) => Promise<AssembleResult>;
|
|
43
|
+
loadClipBuffer: (clipBytes: ArrayBuffer | Uint8Array) => Promise<void>;
|
|
44
|
+
play: () => Promise<void>;
|
|
45
|
+
pause: () => void;
|
|
46
|
+
setPot: (index: number, value: number) => void;
|
|
47
|
+
setBypass: (active: boolean) => void;
|
|
48
|
+
setChannelMode: (mode: ChannelMode) => void;
|
|
49
|
+
/** True when audio is currently playing. */
|
|
50
|
+
playing: boolean;
|
|
51
|
+
/** True when the bypass footswitch is engaged (signal passes through dry). */
|
|
52
|
+
bypassed: boolean;
|
|
53
|
+
/** Output channel mode — see ChannelMode. */
|
|
54
|
+
channelMode: ChannelMode;
|
|
55
|
+
/** Sample rate of the AudioContext (set after init). */
|
|
56
|
+
sampleRate: number | null;
|
|
57
|
+
};
|