diff --git a/src/App.jsx b/src/App.jsx
index 36df4c6..bd614e1 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 = "";
@@ -458,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);
@@ -560,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]);
@@ -618,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;
@@ -640,7 +764,7 @@ export default function App(){
return next;
});
setTimeout(()=>{userChangedBlock.current=false;},3000);
- },[modelBrowserBlockId]);
+ },[modelBrowserBlockId,takeSnapshot]);
// ── Render ──
return(
@@ -659,6 +783,15 @@ export default function App(){
{state.connected?"CONNECTED":"OFFLINE"}
+ {/* Undo/Redo */}
+
+
{state.current_preset?.name||(presets.find(p=>p.num===currentPreset)?.name||"—")}
@@ -697,6 +830,11 @@ export default function App(){
+
+
@@ -859,6 +997,29 @@ export default function App(){
);
})()}
+ {/* ── Main Menu ── */}
+ {menuOpen&&(
+ setMenuOpen(false)}
+ onItemSelect={handleMenuSelect}
+ />
+ )}
+
+ {/* ── Global Settings ── */}
+ {settingsView==="settings"&&(
+ setSettingsView(null)}
+ onMasterVolume={(v)=>API.masterVolume(v).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 */}
+
+
+
+
+
+ {/* 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..ff3c84c
--- /dev/null
+++ b/src/GlobalSettings.jsx
@@ -0,0 +1,405 @@
+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: "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 }) {
+ 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);
+ }
+ }, [onMasterVolume]);
+
+ return (
+
+ {/* Header */}
+
+
+ {/* 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 items */}
+
+ {MENU_ITEMS.map((item) => (
+
+ ))}
+
+
+ {/* Footer */}
+
+
+ Helix Stadium v0.1
+
+
+
+
+ );
+}