diff --git a/docs/helix-parity-audit.md b/docs/helix-parity-audit.md new file mode 100644 index 0000000..6a11c0f --- /dev/null +++ b/docs/helix-parity-audit.md @@ -0,0 +1,97 @@ +# Helix Stadium Feature Parity Audit — 2026-06-12 + +Based on manual Rev D v1.3 at https://manuals.line6.com/en/helix-stadium/live + +## ✅ IMPLEMENTED & DEPLOYED + +### Signal Path & Blocks +- Horizontal block chain with IN->OUT signal path +- SVG icons per block type (38 pipedal icons mapped) +- Block type labels & display names +- Bypass LED per block (green=active, dim=bypassed) +- Drag-to-reorder blocks +- Parameter panel with sliders & knobs (toggleable) +- Focus View (full-screen block editor) — in codebase +- Split/Merge blocks with 4 split types +- Parallel signal paths with dual-row layout +- IN/OUT terminators + +### Presets +- Preset sidebar (Bank + 4 programs) +- Preset loading via POST /api/presets/{bank}/{program}/activate +- Bank navigation +- Current preset indicator + +### Footswitch Modes +- Stomp mode — block toggle footswitches +- Preset mode — bank up/down + preset selection +- Scribble strips per footswitch +- Global bypass +- Mode selector (SM/PR toggle in status bar) +- Double-tap gesture support + +### Tuner +- Visual tuner with mute +- Status bar toggle + +### Status Bar +- Connection indicator, CPU, sample rate +- Master volume, routing mode, preset name +- Tuner badge, mode selector +- Captures/Presets view buttons +- Fullscreen toggle + +### UI Design +- Dark theme, Helix-inspired layout +- Large touch targets, landscape +- Custom SVG icons +- Fullscreen mode + +## ⚠️ PARTIALLY IMPLEMENTED + +### Snapshots +- SnapshotPanel reference extracted (docs/snapshot-reference.jsx) +- Snapshot mode in TS reference project +- NOT wired into main JSX UI +- No snapshot footswitch mode in FootswitchBar +- No snapshot bypass toggle or per-parameter control + +### Model Browser +- Currently being built by coder (running) + +## ❌ NOT IMPLEMENTED + +### P3: Undo/Redo + Menu + Global Settings (todo) +- Undo/Redo for block operations +- Main menu (New Preset, Save, Command Center, etc.) +- Global Settings UI +- Global EQ +- Save preset + +### P3: Looper (todo) +- Record/Play/Overdub/Stop + +### Block Action Panel +- Copy/Paste/Clear blocks +- User/Factory Defaults +- Snapshot Bypass toggle +- Assign to footswitch + +### Command Center (MIDI) +- MIDI CC/Note/Program +- Ext amp switching, Hotkeys +- Instant commands, Dual-command + +### Song View +- Setlist management +- Per-song presets + +### Other +- Drag off-screen delete +- Block copy/paste cross-preset +- Parameter page swipe (8+ params) +- Favorites, User Defaults +- Tempo control +- Preset save with naming +- Discard Edits toggle +- MIDI recall info diff --git a/src/App.jsx b/src/App.jsx index 628484e..8f6b419 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,6 +4,9 @@ import FootswitchBar from "./FootswitchBar.jsx"; import TunerScreen from "./Tuner.jsx"; import FocusView from "./FocusView.jsx"; import ModelBrowser from "./ModelBrowser.jsx"; +import MainMenu from "./MainMenu.jsx"; +import GlobalSettings from "./GlobalSettings.jsx"; +import GlobalEQ from "./GlobalEQ.jsx"; // ── API Layer ────────────────────────────────────────────────────── const API_BASE = ""; @@ -29,6 +32,7 @@ const API = { getPreset: (bank, program) => api("GET", `/api/presets/${bank}/${program}`), activatePreset:(bank, program) => api("POST", `/api/presets/${bank}/${program}/activate`), savePreset: (bank, program, name, tags) => api("PUT", "/api/presets", { bank, program, name, tags }), + setAudioProfile:(rate, period) => api("POST", "/api/audio/profile", { rate: parseInt(rate), period: parseInt(period) }), searchModels: (q) => api("GET", `/api/tonehub/search?q=${encodeURIComponent(q)}`), searchIrs: (q) => api("GET", `/api/tonehub/search/irs?q=${encodeURIComponent(q)}`), installModel: (url, name) => api("POST", "/api/models/tonedownload/install", { download_url: url, name }), @@ -457,6 +461,103 @@ export default function App(){ const[modelBrowserBlockId,setModelBrowserBlockId]=useState(null); const[footswitchMode,setFootswitchMode]=useState("stomp"); + // ── Undo/Redo ── + const [undoStack, setUndoStack] = useState([]); + const [redoStack, setRedoStack] = useState([]); + const stateRef = useRef({ blocks: MOCK_BLOCKS, blockParams: {}, selectedBlockId: "gate" }); + const paramSnapRef = useRef(null); + + // Keep ref in sync + useEffect(() => { + stateRef.current = { blocks, blockParams, selectedBlockId }; + }, [blocks, blockParams, selectedBlockId]); + + // Take a full snapshot of blocks + params + selection + const takeSnapshot = useCallback(() => { + const s = stateRef.current; + const snapshot = { + blocks: s.blocks.map(b => ({ ...b, params: { ...b.params } })), + blockParams: Object.fromEntries( + Object.entries(s.blockParams).map(([k, v]) => [k, { ...v }]) + ), + selectedBlockId: s.selectedBlockId, + }; + setUndoStack(prev => [...prev.slice(-49), snapshot]); + setRedoStack([]); + }, []); + + // Debounced param snapshot (only on first change in a burst) + const takeParamSnapshot = useCallback(() => { + if (paramSnapRef.current) return; // already queued + takeSnapshot(); + paramSnapRef.current = setTimeout(() => { + paramSnapRef.current = null; + }, 400); + }, [takeSnapshot]); + + const handleUndo = useCallback(() => { + setUndoStack(prev => { + if (prev.length === 0) return prev; + // Save current state to redo + const current = stateRef.current; + const snap = { + blocks: current.blocks.map(b => ({ ...b, params: { ...b.params } })), + blockParams: Object.fromEntries( + Object.entries(current.blockParams).map(([k, v]) => [k, { ...v }]) + ), + selectedBlockId: current.selectedBlockId, + }; + setRedoStack(r => [...r, snap]); + // Restore undo snapshot + const restore = prev[prev.length - 1]; + setBlocks(restore.blocks); + setBlockParams(restore.blockParams); + setSelectedBlockId(restore.selectedBlockId); + return prev.slice(0, -1); + }); + }, []); + + const handleRedo = useCallback(() => { + setRedoStack(prev => { + if (prev.length === 0) return prev; + // Save current state to undo + const current = stateRef.current; + const snap = { + blocks: current.blocks.map(b => ({ ...b, params: { ...b.params } })), + blockParams: Object.fromEntries( + Object.entries(current.blockParams).map(([k, v]) => [k, { ...v }]) + ), + selectedBlockId: current.selectedBlockId, + }; + setUndoStack(u => [...u, snap]); + // Restore redo snapshot + const restore = prev[prev.length - 1]; + setBlocks(restore.blocks); + setBlockParams(restore.blockParams); + setSelectedBlockId(restore.selectedBlockId); + return prev.slice(0, -1); + }); + }, []); + + // ── Main Menu & Settings ── + const [menuOpen, setMenuOpen] = useState(false); + const [settingsView, setSettingsView] = useState(null); // "settings" | "eq" | null + + const handleMenuSelect = useCallback((itemId) => { + setMenuOpen(false); + switch (itemId) { + case "settings": setSettingsView("settings"); break; + case "eq": setSettingsView("eq"); break; + case "tuner": + setState(prev => ({ ...prev, tuner_enabled: true })); + API.tunerToggle(true).catch(() => {}); + break; + default: + // save, bypass, wifi — placeholder for now + break; + } + }, []); + // ── Poll pedal state from API ── const userChangedPreset = useRef(false); const userChangedBlock = useRef(false); @@ -486,7 +587,7 @@ export default function App(){ API.listPresets().then(data=>{ if(data?.banks){ const all=[]; - data.banks.forEach(b=>{(b.presets||[]).forEach((p,i)=>{if(p)all.push({num:i+1,bank:b.number,name:p.name,chain:p.chain});});}); + data.banks.forEach(b=>{(b.presets||[]).forEach((p,i)=>{if(p)all.push({num:i+1,bank:b.number+1,name:p.name,chain:p.chain});});}); if(all.length>0)setPresets(all); } }).catch(()=>{}); @@ -559,12 +660,35 @@ export default function App(){ },[selectedBlockId,selectedBlock,blockParams]); const handleSelectBlock=useCallback((id)=>{setSelectedBlockId(prev=>prev===id?null:id);},[]); - const handleToggleBlock=useCallback((id)=>{userChangedBlock.current=true;setBlocks(prev=>prev.map(b=>b.id===id?{...b,bypassed:!b.bypassed}:b));API.toggleBlock(id,false).catch(()=>{});setTimeout(()=>{userChangedBlock.current=false;},3000);},[]); - const handleParamChange=useCallback((id,key,value)=>{setBlockParams(prev=>({...prev,[id]:{...(prev[id]||{}),[key]:value}}));},[]); + const handleParamChange=useCallback((id,key,value)=>{ + takeParamSnapshot(); + setBlockParams(prev=>({...prev,[id]:{...(prev[id]||{}),[key]:value}})); + },[takeParamSnapshot]); + const handleToggleBlock=useCallback((id)=>{ + takeSnapshot(); + userChangedBlock.current=true; + setBlocks(prev=>prev.map(b=>b.id===id?{...b,bypassed:!b.bypassed}:b)); + API.toggleBlock(id,false).catch(()=>{}); + setTimeout(()=>{userChangedBlock.current=false;},3000); + },[takeSnapshot]); const handleParamChangeEnd=useCallback((id,key,value)=>{API.updateBlock(id,{[key]:value}).catch(()=>{});},[]); - const handleReorder=useCallback((fromIdx,toIdx)=>{setBlocks(prev=>{const arr=[...prev];const[m]=arr.splice(fromIdx,1);arr.splice(toIdx,0,m);return arr;});},[]); - const handleAddBlock=useCallback(()=>{userChangedBlock.current=true;const id="block_"+Date.now();setBlocks(prev=>[...prev,{id,type:"overdrive",name:"New Drive",bypassed:false,params:{drive:30,tone:50,level:70}}]);setTimeout(()=>{userChangedBlock.current=false;},3000);},[]); - const handleRemoveBlock=useCallback((id)=>{userChangedBlock.current=true;setBlocks(prev=>prev.filter(b=>b.id!==id));if(selectedBlockId===id)setSelectedBlockId(null);setTimeout(()=>{userChangedBlock.current=false;},3000);},[selectedBlockId]); + const handleReorder=useCallback((fromIdx,toIdx)=>{ + takeSnapshot(); + setBlocks(prev=>{const arr=[...prev];const[m]=arr.splice(fromIdx,1);arr.splice(toIdx,0,m);return arr;}); + },[takeSnapshot]); + const handleAddBlock=useCallback(()=>{ + takeSnapshot(); + userChangedBlock.current=true;const id="block_"+Date.now(); + setBlocks(prev=>[...prev,{id,type:"overdrive",name:"New Drive",bypassed:false,params:{drive:30,tone:50,level:70}}]); + setTimeout(()=>{userChangedBlock.current=false;},3000); + },[takeSnapshot]); + const handleRemoveBlock=useCallback((id)=>{ + takeSnapshot(); + userChangedBlock.current=true; + setBlocks(prev=>prev.filter(b=>b.id!==id)); + if(selectedBlockId===id)setSelectedBlockId(null); + setTimeout(()=>{userChangedBlock.current=false;},3000); + },[takeSnapshot,selectedBlockId]); // ── Bank navigation ── const currentBankPresets=useMemo(()=>presets.filter(p=>p.bank===currentBank),[presets,currentBank]); @@ -617,6 +741,7 @@ export default function App(){ const handleSelectModel=useCallback(({type, name, modelPath, isBuiltin})=>{ const id=modelBrowserBlockId; if(!id)return; + takeSnapshot(); userChangedBlock.current=true; setBlocks(prev=>prev.map(b=>{ if(b.id!==id)return b; @@ -639,7 +764,7 @@ export default function App(){ return next; }); setTimeout(()=>{userChangedBlock.current=false;},3000); - },[modelBrowserBlockId]); + },[modelBrowserBlockId,takeSnapshot]); // ── Render ── return( @@ -658,6 +783,15 @@ export default function App(){
{state.connected?"CONNECTED":"OFFLINE"} + {/* Undo/Redo */} + +
{state.current_preset?.name||(presets.find(p=>p.num===currentPreset)?.name||"—")} @@ -696,6 +830,11 @@ export default function App(){ +
+
@@ -858,6 +997,30 @@ export default function App(){ ); })()} + {/* ── Main Menu ── */} + {menuOpen&&( + setMenuOpen(false)} + onItemSelect={handleMenuSelect} + /> + )} + + {/* ── Global Settings ── */} + {settingsView==="settings"&&( + setSettingsView(null)} + onMasterVolume={(v)=>API.masterVolume(v).catch(()=>{})} + onAudioProfile={(rate, period)=>API.setAudioProfile(rate, period).catch(()=>{})} + /> + )} + + {/* ── Global EQ ── */} + {settingsView==="eq"&&( + setSettingsView(null)} + /> + )} +
); } diff --git a/src/GlobalEQ.jsx b/src/GlobalEQ.jsx new file mode 100644 index 0000000..529ae31 --- /dev/null +++ b/src/GlobalEQ.jsx @@ -0,0 +1,340 @@ +import { useState, useRef, useCallback, useEffect } from "react"; + +const T = { + bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32", + amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060", + green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458", +}; + +// ── EQ Band definitions ── +const EQ_BANDS = [ + { key: "low", label: "Low", freq: "80 Hz", color: "#60A0E0", shadow: "#60A0E044" }, + { key: "mid", label: "Mid", freq: "800 Hz", color: T.amber, shadow: `${T.amber}44` }, + { key: "high", label: "High", freq: "6.4 kHz",color: "#60D080", shadow: "#60D08044" }, +]; + +// ── Knob Widget ── +function EqKnob({ label, freq, value, min, max, color, onChange }) { + const startRef = useRef(null); + const norm = (value - min) / (max - min); + const angle = -140 + norm * 280; + const size = 80; + const dotH = size * 0.38; + const dotTop = size * 0.5 - dotH; + + const handleStart = (e) => { + e.preventDefault(); + const clientY = e.touches ? e.touches[0].clientY : e.clientY; + startRef.current = { y: clientY, val: value }; + const move = (ev) => { + const cy = ev.touches ? ev.touches[0].clientY : ev.clientY; + const delta = (startRef.current.y - cy) / 120; + const next = Math.max(min, Math.min(max, startRef.current.val + delta * (max - min))); + onChange?.(Math.round(next * 10) / 10); + }; + const up = () => { + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", up); + window.removeEventListener("touchmove", move); + window.removeEventListener("touchend", up); + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + window.addEventListener("touchmove", move, { passive: false }); + window.addEventListener("touchend", up); + }; + + const svgSize = size + 16, cx = svgSize / 2, cy = svgSize / 2, r = size / 2 + 4; + const lx = cx + r * Math.cos((-140 - 90) * (Math.PI / 180)); + const ly = cy + r * Math.sin((-140 - 90) * (Math.PI / 180)); + const ex = cx + r * Math.cos((angle - 90) * (Math.PI / 180)); + const ey = cy + r * Math.sin((angle - 90) * (Math.PI / 180)); + const large = norm * 280 > 180 ? 1 : 0; + + return ( +
+ {/* Knob */} +
+ + + {norm > 0 && ( + + )} + +
+
+
+
+ + {/* Value */} +
{value > 0 ? `+${value}` : value} dB
+ + {/* Label */} +
+ {label} +
+
+ {freq} +
+
+ ); +} + +// ── Frequency Response Graph ── +function FrequencyGraph({ bands, values }) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + const w = canvas.width; + const h = canvas.height; + const pad = { top: 10, bottom: 10, left: 20, right: 10 }; + const gw = w - pad.left - pad.right; + const gh = h - pad.top - pad.bottom; + + // Clear + ctx.clearRect(0, 0, w, h); + + // Grid + ctx.strokeStyle = T.border; + ctx.lineWidth = 0.5; + // Horizontal lines (dB) + for (let db = -12; db <= 12; db += 4) { + const y = pad.top + gh * (1 - (db + 12) / 24); + ctx.beginPath(); + ctx.moveTo(pad.left, y); + ctx.lineTo(w - pad.right, y); + ctx.stroke(); + ctx.fillStyle = T.textDim; + ctx.font = "7px 'JetBrains Mono', monospace"; + ctx.textAlign = "right"; + ctx.fillText(`${db > 0 ? "+" : ""}${db}`, pad.left - 2, y + 2); + } + + // Log frequency positions (80, 800, 6400 Hz on 3 bands) + const freqPositions = { + low: pad.left + gw * 0.1, + mid: pad.left + gw * 0.5, + high: pad.left + gw * 0.9, + }; + + // Draw band markers + const bandMap = {}; + bands.forEach((b, i) => { bandMap[b.key] = i; }); + + // Draw response curve (smooth interpolated) + const bandVals = bands.map(b => values[b.key] ?? 0); + // Simple interpolation between bands with 100 sample points + const curveX = []; + const curveY = []; + const samplePoints = 100; + for (let i = 0; i <= samplePoints; i++) { + const t = i / samplePoints; + const x = pad.left + gw * t; + // Map t (0-1) to frequency position space + // Use the 3 bands as control points + const bandPoints = bands.map((b, idx) => ({ + x: freqPositions[b.key], + y: -(bandVals[idx] || 0), + })); + // Nearest-neighbor interpolation with smoothing + let v = 0; + if (t < 0.1) { + v = bandVals[0] * (t / 0.1); + } else if (t > 0.9) { + v = bandVals[2] * ((1 - t) / 0.1); + } else { + // Map t (0.1-0.9) to position between bands + const bt = (t - 0.1) / 0.8; // 0-1 between first and last band + // Quadratic bezier through mid band + const b0 = bandVals[0]; + const b1 = bandVals[1]; + const b2 = bandVals[2]; + v = (1 - bt) * (1 - bt) * b0 + 2 * (1 - bt) * bt * b1 + bt * bt * b2; + } + const y = pad.top + gh * (0.5 - v / 24); + curveX.push(x); + curveY.push(y); + } + + // Fill area under curve + ctx.beginPath(); + ctx.moveTo(curveX[0], pad.top + gh); + for (let i = 0; i < curveX.length; i++) { + ctx.lineTo(curveX[i], curveY[i]); + } + ctx.lineTo(curveX[curveX.length - 1], pad.top + gh); + ctx.closePath(); + ctx.fillStyle = `${T.amber}15`; + ctx.fill(); + + // Draw curve line + ctx.beginPath(); + for (let i = 0; i < curveX.length; i++) { + if (i === 0) ctx.moveTo(curveX[i], curveY[i]); + else ctx.lineTo(curveX[i], curveY[i]); + } + ctx.strokeStyle = T.amber; + ctx.lineWidth = 2; + ctx.stroke(); + + // Center line (0 dB) + const centerY = pad.top + gh / 2; + ctx.strokeStyle = `${T.textDim}44`; + ctx.lineWidth = 1; + ctx.setLineDash([2, 3]); + ctx.beginPath(); + ctx.moveTo(pad.left, centerY); + ctx.lineTo(w - pad.right, centerY); + ctx.stroke(); + ctx.setLineDash([]); + + // Band markers + bands.forEach((b) => { + const x = freqPositions[b.key]; + const val = bandVals[bandMap[b.key]] || 0; + const y = pad.top + gh * (0.5 - val / 24); + // Dot at band position + ctx.beginPath(); + ctx.arc(x, y, 5, 0, Math.PI * 2); + ctx.fillStyle = b.color; + ctx.fill(); + ctx.strokeStyle = T.bg; + ctx.lineWidth = 2; + ctx.stroke(); + }); + + // Frequency labels + ctx.fillStyle = T.textDim; + ctx.font = "7px 'JetBrains Mono', monospace"; + ctx.textAlign = "center"; + ctx.fillText("80Hz", freqPositions.low, h - 2); + ctx.fillText("800Hz", freqPositions.mid, h - 2); + ctx.fillText("6.4kHz", freqPositions.high, h - 2); + + }, [bands, values]); + + return ( + + ); +} + +// ── Global EQ ── +export default function GlobalEQ({ onClose }) { + const [values, setValues] = useState({ + low: 0, + mid: 0, + high: 0, + }); + + const update = useCallback((key, val) => { + setValues(prev => ({ ...prev, [key]: val })); + }, []); + + return ( +
+ {/* Header */} +
+
+ +
+
Global EQ
+
+ 3-band shelving equalizer +
+
+
+
+ + {/* Body */} +
+ {/* Frequency response graph */} + + + {/* EQ band knobs */} +
+ {EQ_BANDS.map(band => ( + update(band.key, v)} + /> + ))} +
+ + {/* Reset button */} + +
+
+ ); +} diff --git a/src/GlobalSettings.jsx b/src/GlobalSettings.jsx new file mode 100644 index 0000000..a1fb152 --- /dev/null +++ b/src/GlobalSettings.jsx @@ -0,0 +1,431 @@ +import { useState, useCallback, useRef, useEffect } from "react"; + +const T = { + bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32", + amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060", + green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458", +}; + +// ── Settings definitions ── +const SETTINGS = { + "Audio": [ + { key: "master_volume", label: "Master Volume", type: "slider", min: 0, max: 100, def: 72, unit: "%", icon: "🔊" }, + { key: "sample_rate", label: "Sample Rate", type: "select", def: "48000", + options: [ + { value: "22050", label: "22.05 kHz" }, + { value: "44100", label: "44.1 kHz" }, + { value: "48000", label: "48 kHz" }, + { value: "88200", label: "88.2 kHz" }, + { value: "96000", label: "96 kHz" }, + ], icon: "📊" }, + { key: "buffer_size", label: "Buffer Size", type: "select", def: "512", + options: [ + { value: "64", label: "64 (1.3ms)" }, + { value: "128", label: "128 (2.7ms)" }, + { value: "256", label: "256 (5.3ms)" }, + { value: "512", label: "512 (10.6ms)" }, + { value: "1024",label: "1024 (21.3ms)" }, + ], icon: "📦" }, + { key: "input_pad", label: "Input Pad", type: "select", def: "off", + options: [ + { value: "off", label: "Off (0 dB)" }, + { value: "-6", label: "-6 dB" }, + { value: "-12", label: "-12 dB" }, + { value: "-18", label: "-18 dB" }, + ], icon: "📥" }, + { key: "input_impedance", label: "Input Impedance", type: "select", def: "1m", + options: [ + { value: "1m", label: "1 MΩ" }, + { value: "500k", label: "500 kΩ" }, + { value: "250k", label: "250 kΩ" }, + { value: "150k", label: "150 kΩ" }, + { value: "68k", label: "68 kΩ" }, + ], icon: "⚡" }, + ], + "MIDI": [ + { key: "midi_channel", label: "MIDI Channel", type: "select", def: "omni", + options: [ + { value: "omni", label: "Omni" }, + ...Array.from({ length: 16 }, (_, i) => ({ + value: String(i + 1), label: `Channel ${i + 1}` + })), + ], icon: "🎹" }, + { key: "midi_clock", label: "MIDI Clock", type: "toggle", def: false, icon: "⏱" }, + { key: "midi_thru", label: "MIDI Thru", type: "toggle", def: true, icon: "↔" }, + ], + "Display": [ + { key: "brightness", label: "Display Brightness", type: "slider", min: 1, max: 10, def: 8, unit: "", icon: "☀️" }, + { key: "auto_dim", label: "Auto Dim", type: "toggle", def: true, icon: "🌙" }, + { key: "screen_saver", label: "Screen Saver", type: "select", def: "5m", + options: [ + { value: "off", label: "Off" }, + { value: "1m", label: "1 min" }, + { value: "5m", label: "5 min" }, + { value: "15m", label: "15 min" }, + { value: "30m", label: "30 min" }, + ], icon: "💤" }, + ], + "Tempo": [ + { key: "tap_tempo", label: "Tap Tempo", type: "bpm", def: 120, icon: "🔄" }, + { key: "tempo_division", label: "Tempo Division", type: "select", def: "quarter", + options: [ + { value: "half", label: "½ Note" }, + { value: "quarter", label: "¼ Note" }, + { value: "eighth", label: "⅛ Note" }, + { value: "triplet", label: "⅛ Triplet" }, + { value: "sixteenth", label: "¹⁄₁₆ Note" }, + { value: "dotted_eighth", label: "Dotted ⅛" }, + ], icon: "🎶" }, + { key: "tap_tempo_mute", label: "Mute on Tap", type: "toggle", def: false, icon: "🔇" }, + ], +}; + +// ── Slider Widget ── +function SettingsSlider({ label, value, min, max, unit, onChange }) { + const trackRef = useRef(null); + const dragRef = useRef(false); + const pct = ((value - min) / (max - min)) * 100; + + const valFromEvent = useCallback((e) => { + const t = trackRef.current; if (!t) return value; + const r = t.getBoundingClientRect(); + const x = (e.touches ? e.touches[0].clientX : e.clientX) - r.left; + return Math.round(min + Math.max(0, Math.min(1, x / r.width)) * (max - min)); + }, [min, max, value]); + + useEffect(() => { + const move = (e) => { if (!dragRef.current) return; e.preventDefault(); onChange?.(valFromEvent(e)); }; + const up = () => { dragRef.current = false; }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + window.addEventListener("touchmove", move, { passive: false }); + window.addEventListener("touchend", up); + return () => { + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", up); + window.removeEventListener("touchmove", move); + window.removeEventListener("touchend", up); + }; + }, [valFromEvent, onChange]); + + const handleStart = (e) => { e.preventDefault(); dragRef.current = true; onChange?.(valFromEvent(e)); }; + const quick = (delta) => (e) => { e.preventDefault(); e.stopPropagation(); onChange?.(Math.max(min, Math.min(max, value + delta))); }; + + return ( +
+
+ {label} +
+ + {value}{unit} + +
+
+
+
+
+
+
+
+
+ ); +} + +// ── Select Widget ── +function SettingsSelect({ label, value, options, onChange, icon }) { + return ( +
+
+ {label} +
+
+ {options.map(opt => ( + + ))} +
+
+ ); +} + +// ── Toggle Widget ── +function SettingsToggle({ label, value, onChange }) { + return ( +
+ {label} + +
+ ); +} + +// ── BPM/Tap Tempo Widget ── +function BpmWidget({ label, value, onChange }) { + const tapTimes = useRef([]); + + const handleTap = useCallback(() => { + const now = Date.now(); + tapTimes.current.push(now); + if (tapTimes.current.length > 4) tapTimes.current.shift(); + if (tapTimes.current.length < 2) return; + const intervals = []; + for (let i = 1; i < tapTimes.current.length; i++) { + intervals.push(tapTimes.current[i] - tapTimes.current[i - 1]); + } + const avg = intervals.reduce((a, b) => a + b, 0) / intervals.length; + const bpm = Math.max(20, Math.min(300, Math.round(60000 / avg))); + onChange?.(bpm); + }, [onChange]); + + return ( +
+
+ {label} +
+
+ +
+ + {value} BPM + +
+
+
+ ); +} + +// ── Global Settings ── +export default function GlobalSettings({ onClose, onMasterVolume, onAudioProfile }) { + const [values, setValues] = useState(() => { + const init = {}; + for (const group of Object.values(SETTINGS)) { + for (const s of group) { + init[s.key] = s.def; + } + } + return init; + }); + + const groups = Object.entries(SETTINGS); + + const update = useCallback((key, val) => { + setValues(prev => ({ ...prev, [key]: val })); + // Fire external callback for master volume + if (key === "master_volume") { + onMasterVolume?.(val); + } + // Fire external callback for audio profile changes + if (key === "sample_rate" || key === "buffer_size") { + // Get the latest values — use the new val for the changed key + setValues(prev => { + const rate = key === "sample_rate" ? val : (prev.sample_rate || "48000"); + const period = key === "buffer_size" ? val : (prev.buffer_size || "512"); + onAudioProfile?.(parseInt(rate), parseInt(period)); + return prev; + }); + } + }, [onMasterVolume, onAudioProfile]); + + return ( +
+ {/* Header */} +
+
+ +
+
Global Settings
+
+
+
+ + {/* Body */} +
+ {groups.map(([groupName, settings]) => ( +
+ {/* Group header */} +
+ {groupName} +
+ + {/* Settings */} +
+ {settings.map(s => ( +
+ {/* Icon */} +
+ {s.icon} +
+ {/* Control */} +
+ {s.type === "slider" && ( + update(s.key, v)} + /> + )} + {s.type === "select" && ( + update(s.key, v)} + /> + )} + {s.type === "toggle" && ( + update(s.key, v)} + /> + )} + {s.type === "bpm" && ( + update(s.key, v)} + /> + )} +
+
+ ))} +
+
+ ))} +
+
+ ); +} diff --git a/src/MainMenu.jsx b/src/MainMenu.jsx new file mode 100644 index 0000000..fd240bc --- /dev/null +++ b/src/MainMenu.jsx @@ -0,0 +1,140 @@ +import { useRef, useEffect } from "react"; + +const T = { + bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32", + amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060", + green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458", +}; + +const MENU_ITEMS = [ + { id: "save", label: "Save Preset", icon: "💾", desc: "Save current preset to bank" }, + { id: "bypass", label: "Bypass/Control", icon: "🔌", desc: "Footswitch & pedal assignment" }, + { id: "settings", label: "Global Settings",icon: "⚙", desc: "Master volume, input, MIDI, display" }, + { id: "eq", label: "Global EQ", icon: "📊", desc: "Low, Mid, High shelving EQ" }, + { id: "tuner", label: "Tuner", icon: "🎵", desc: "Instrument tuner" }, + { id: "wifi", label: "Wi-Fi / Bluetooth",icon: "📶", desc: "Wireless & network settings" }, +]; + +export default function MainMenu({ onClose, onItemSelect }) { + const overlayRef = useRef(null); + + useEffect(() => { + const el = overlayRef.current; + if (!el) return; + // Trigger enter animation + requestAnimationFrame(() => { el.style.transform = "translateX(0)"; }); + }, []); + + const handleBackdropClick = (e) => { + if (e.target === e.currentTarget) onClose?.(); + }; + + return ( +
+ {/* Sidebar */} +
+ {/* Header */} +
+
+ ☰ Menu +
+ +
+ + {/* Menu items */} +
+ {MENU_ITEMS.map((item) => ( + + ))} +
+ + {/* Footer */} +
+
+ Helix Stadium v0.1 +
+
+
+
+ ); +}