Merge branch 'feat-undo-menu-settings'
This commit is contained in:
@@ -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
|
||||
+170
-7
@@ -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(){
|
||||
<div style={{width:6,height:6,borderRadius:"50%",
|
||||
background:state.connected?T.green:T.red,boxShadow:`0 0 6px ${state.connected?T.green:T.red}`}}/>
|
||||
<span className="mono" style={{fontSize:9,color:T.textSec}}>{state.connected?"CONNECTED":"OFFLINE"}</span>
|
||||
{/* Undo/Redo */}
|
||||
<button className="btn-icon" style={{width:20,height:20,fontSize:11,opacity:undoStack.length>0?1:0.3}}
|
||||
onClick={handleUndo} disabled={undoStack.length===0} title="Undo (Ctrl+Z)">
|
||||
↶
|
||||
</button>
|
||||
<button className="btn-icon" style={{width:20,height:20,fontSize:11,opacity:redoStack.length>0?1:0.3}}
|
||||
onClick={handleRedo} disabled={redoStack.length===0} title="Redo (Ctrl+Shift+Z)">
|
||||
↷
|
||||
</button>
|
||||
<div style={{width:1,height:12,background:T.border}}/>
|
||||
<span className="mono" style={{fontSize:10,color:T.textPrimary,fontWeight:600}}>
|
||||
{state.current_preset?.name||(presets.find(p=>p.num===currentPreset)?.name||"—")}
|
||||
@@ -696,6 +830,11 @@ export default function App(){
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div style={{width:1,height:16,background:T.border}}/>
|
||||
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}
|
||||
onClick={()=>setMenuOpen(true)} title="Main Menu">
|
||||
<span style={{fontSize:14,color:T.textSec,lineHeight:1}}>☰</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -858,6 +997,30 @@ export default function App(){
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ── Main Menu ── */}
|
||||
{menuOpen&&(
|
||||
<MainMenu
|
||||
onClose={()=>setMenuOpen(false)}
|
||||
onItemSelect={handleMenuSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Global Settings ── */}
|
||||
{settingsView==="settings"&&(
|
||||
<GlobalSettings
|
||||
onClose={()=>setSettingsView(null)}
|
||||
onMasterVolume={(v)=>API.masterVolume(v).catch(()=>{})}
|
||||
onAudioProfile={(rate, period)=>API.setAudioProfile(rate, period).catch(()=>{})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Global EQ ── */}
|
||||
{settingsView==="eq"&&(
|
||||
<GlobalEQ
|
||||
onClose={()=>setSettingsView(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div></>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
display: "flex", flexDirection: "column", alignItems: "center", gap: 4,
|
||||
cursor: "pointer", minWidth: 0,
|
||||
}}>
|
||||
{/* Knob */}
|
||||
<div style={{ width: size, height: size, position: "relative", touchAction: "none" }}
|
||||
onMouseDown={handleStart} onTouchStart={handleStart}>
|
||||
<svg style={{ position: "absolute", inset: -8, width: svgSize, height: svgSize, overflow: "visible" }}>
|
||||
<circle cx={cx} cy={cy} r={r} fill="none" stroke={T.border} strokeWidth="3" strokeLinecap="round" opacity=".5" />
|
||||
{norm > 0 && (
|
||||
<path d={`M ${lx} ${ly} A ${r} ${r} 0 ${large} 1 ${ex} ${ey}`}
|
||||
fill="none" stroke={color} strokeWidth="3" strokeLinecap="round"
|
||||
style={{ filter: `drop-shadow(0 0 4px ${color})` }} />
|
||||
)}
|
||||
</svg>
|
||||
<div style={{
|
||||
borderRadius: "50%", width: "100%", height: "100%", position: "relative",
|
||||
background: "radial-gradient(circle at 35% 30%, #3A3A48, #1A1A22)",
|
||||
boxShadow: "0 3px 12px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.07)",
|
||||
}}>
|
||||
<div style={{
|
||||
position: "absolute", width: 4, borderRadius: 2, background: color,
|
||||
left: "50%", transformOrigin: "bottom center",
|
||||
height: dotH, top: dotTop,
|
||||
transform: `translateX(-50%) rotate(${angle}deg)`,
|
||||
transformOrigin: `50% ${dotH}px`,
|
||||
boxShadow: `0 0 6px ${color}`,
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<div style={{
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: 14, fontWeight: 700, color,
|
||||
}}>{value > 0 ? `+${value}` : value} dB</div>
|
||||
|
||||
{/* Label */}
|
||||
<div style={{ fontSize: 8, color: T.textDim, letterSpacing: ".08em", textTransform: "uppercase" }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: 8, color: T.textDim, fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{freq}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={320}
|
||||
height={160}
|
||||
style={{
|
||||
width: "100%", height: 140, borderRadius: 8,
|
||||
background: T.panel, border: `1px solid ${T.border}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div style={{
|
||||
position: "fixed", inset: 0, zIndex: 998,
|
||||
display: "flex", flexDirection: "column",
|
||||
background: T.bg, color: T.textPrimary,
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "10px 14px",
|
||||
borderBottom: `1px solid ${T.border}`,
|
||||
background: T.panel, flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<button onClick={onClose}
|
||||
style={{
|
||||
width: 34, height: 34, borderRadius: 8,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
background: T.surface, border: `1px solid ${T.border}`,
|
||||
color: T.textPrimary, fontSize: 16, cursor: "pointer",
|
||||
}}>
|
||||
✕
|
||||
</button>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600 }}>Global EQ</div>
|
||||
<div style={{ fontSize: 10, color: T.textSec, marginTop: 1 }}>
|
||||
3-band shelving equalizer
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{
|
||||
flex: 1, overflowY: "auto", overflowX: "hidden",
|
||||
padding: "8px 14px",
|
||||
display: "flex", flexDirection: "column", gap: 12,
|
||||
}}>
|
||||
{/* Frequency response graph */}
|
||||
<FrequencyGraph bands={EQ_BANDS} values={values} />
|
||||
|
||||
{/* EQ band knobs */}
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-around", gap: 8,
|
||||
background: T.surface, borderRadius: 8,
|
||||
border: `1px solid ${T.border}`,
|
||||
padding: "16px 8px",
|
||||
}}>
|
||||
{EQ_BANDS.map(band => (
|
||||
<EqKnob
|
||||
key={band.key}
|
||||
label={band.label}
|
||||
freq={band.freq}
|
||||
value={values[band.key]}
|
||||
min={-12}
|
||||
max={12}
|
||||
color={band.color}
|
||||
onChange={v => update(band.key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reset button */}
|
||||
<button onClick={() => setValues({ low: 0, mid: 0, high: 0 })}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 14px", borderRadius: 8,
|
||||
background: T.surface, border: `1px solid ${T.border}`,
|
||||
color: T.textSec, fontSize: 11, fontWeight: 600,
|
||||
cursor: "pointer", letterSpacing: ".06em",
|
||||
}}>
|
||||
Reset EQ to Flat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||
<button style={{
|
||||
width: 26, height: 26, borderRadius: "50%", border: `1px solid ${T.border}`,
|
||||
background: T.panel, color: T.textPrimary, fontSize: 14, fontWeight: 700,
|
||||
display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", lineHeight: 1,
|
||||
}} onMouseDown={quick(-1)} onTouchStart={quick(-1)}>−</button>
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: 14, fontWeight: 700, color: T.amber,
|
||||
minWidth: 36, textAlign: "center",
|
||||
}}>{value}{unit}</span>
|
||||
<button style={{
|
||||
width: 26, height: 26, borderRadius: "50%", border: `1px solid ${T.border}`,
|
||||
background: T.panel, color: T.textPrimary, fontSize: 14, fontWeight: 700,
|
||||
display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", lineHeight: 1,
|
||||
}} onMouseDown={quick(1)} onTouchStart={quick(1)}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={trackRef}
|
||||
onMouseDown={handleStart} onTouchStart={handleStart}
|
||||
style={{
|
||||
position: "relative", height: 32, display: "flex", alignItems: "center",
|
||||
cursor: "pointer", touchAction: "none",
|
||||
}}>
|
||||
<div style={{
|
||||
width: "100%", height: 6, background: T.border, borderRadius: 3,
|
||||
position: "relative", overflow: "hidden",
|
||||
}}>
|
||||
<div style={{
|
||||
height: "100%", borderRadius: 3, position: "absolute", top: 0, left: 0,
|
||||
width: `${pct}%`, background: T.amber,
|
||||
boxShadow: `0 0 6px ${T.amber}44`,
|
||||
transition: "width .03s",
|
||||
}} />
|
||||
</div>
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: "50%", position: "absolute",
|
||||
top: "50%", left: `${pct}%`,
|
||||
transform: "translate(-50%,-50%)",
|
||||
background: T.amber, border: `3px solid ${T.bg}`,
|
||||
boxShadow: `0 1px 6px rgba(0,0,0,.5)`,
|
||||
pointerEvents: "none",
|
||||
transition: "left .03s",
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Select Widget ──
|
||||
function SettingsSelect({ label, value, options, onChange, icon }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
marginBottom: 6,
|
||||
}}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex", gap: 4, flexWrap: "wrap",
|
||||
background: T.panel, borderRadius: 6, padding: 4,
|
||||
}}>
|
||||
{options.map(opt => (
|
||||
<button key={opt.value}
|
||||
onClick={() => onChange?.(opt.value)}
|
||||
style={{
|
||||
flex: 1, padding: "6px 8px", borderRadius: 4, border: "none",
|
||||
fontSize: 10, fontWeight: 600, cursor: "pointer",
|
||||
background: value === opt.value ? T.amber : "transparent",
|
||||
color: value === opt.value ? "#000" : T.textSec,
|
||||
transition: "all .1s", letterSpacing: ".03em",
|
||||
whiteSpace: "nowrap",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Toggle Widget ──
|
||||
function SettingsToggle({ label, value, onChange }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "4px 0",
|
||||
}}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
|
||||
<button
|
||||
onClick={() => onChange?.(!value)}
|
||||
style={{
|
||||
width: 44, height: 24, borderRadius: 12,
|
||||
background: value ? T.amber : T.border,
|
||||
border: "none", cursor: "pointer", position: "relative",
|
||||
transition: "background .15s",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: "50%",
|
||||
background: "#fff",
|
||||
position: "absolute", top: 3, left: value ? 23 : 3,
|
||||
transition: "left .15s",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,.3)",
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
marginBottom: 6,
|
||||
}}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex", gap: 8, alignItems: "center",
|
||||
}}>
|
||||
<button onClick={handleTap}
|
||||
style={{
|
||||
padding: "8px 20px", borderRadius: 8, border: `1px solid ${T.amber}66`,
|
||||
background: `${T.amber}15`, color: T.amber,
|
||||
fontSize: 12, fontWeight: 700, cursor: "pointer",
|
||||
letterSpacing: ".05em",
|
||||
}}>
|
||||
TAP
|
||||
</button>
|
||||
<div style={{
|
||||
flex: 1, display: "flex", alignItems: "center", gap: 4,
|
||||
background: T.surface, borderRadius: 6, border: `1px solid ${T.border}`,
|
||||
padding: "6px 10px",
|
||||
}}>
|
||||
<button onClick={() => onChange?.(Math.max(20, value - 1))}
|
||||
style={{
|
||||
background: "none", border: "none", color: T.textDim,
|
||||
fontSize: 14, cursor: "pointer", padding: "0 4px",
|
||||
}}>−</button>
|
||||
<span style={{
|
||||
flex: 1, textAlign: "center",
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: 16, fontWeight: 700, color: T.amber,
|
||||
}}>{value} BPM</span>
|
||||
<button onClick={() => onChange?.(Math.min(300, value + 1))}
|
||||
style={{
|
||||
background: "none", border: "none", color: T.textDim,
|
||||
fontSize: 14, cursor: "pointer", padding: "0 4px",
|
||||
}}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div style={{
|
||||
position: "fixed", inset: 0, zIndex: 998,
|
||||
display: "flex", flexDirection: "column",
|
||||
background: T.bg, color: T.textPrimary,
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "10px 14px",
|
||||
borderBottom: `1px solid ${T.border}`,
|
||||
background: T.panel, flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<button onClick={onClose}
|
||||
style={{
|
||||
width: 34, height: 34, borderRadius: 8,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
background: T.surface, border: `1px solid ${T.border}`,
|
||||
color: T.textPrimary, fontSize: 16, cursor: "pointer",
|
||||
}}>
|
||||
✕
|
||||
</button>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600 }}>Global Settings</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{
|
||||
flex: 1, overflowY: "auto", overflowX: "hidden",
|
||||
padding: "6px 14px 20px",
|
||||
}}>
|
||||
{groups.map(([groupName, settings]) => (
|
||||
<div key={groupName}>
|
||||
{/* Group header */}
|
||||
<div style={{
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: ".1em",
|
||||
textTransform: "uppercase", color: T.textDim,
|
||||
padding: "14px 0 8px",
|
||||
}}>
|
||||
{groupName}
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<div style={{
|
||||
display: "flex", flexDirection: "column", gap: 12,
|
||||
background: T.surface, borderRadius: 8,
|
||||
border: `1px solid ${T.border}`,
|
||||
padding: "12px 14px",
|
||||
}}>
|
||||
{settings.map(s => (
|
||||
<div key={s.key} style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 10,
|
||||
}}>
|
||||
{/* Icon */}
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: 6, flexShrink: 0,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
background: T.panel, fontSize: 13,
|
||||
}}>
|
||||
{s.icon}
|
||||
</div>
|
||||
{/* Control */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{s.type === "slider" && (
|
||||
<SettingsSlider
|
||||
label={s.label}
|
||||
value={values[s.key] ?? s.def}
|
||||
min={s.min} max={s.max} unit={s.unit}
|
||||
onChange={v => update(s.key, v)}
|
||||
/>
|
||||
)}
|
||||
{s.type === "select" && (
|
||||
<SettingsSelect
|
||||
label={s.label}
|
||||
value={values[s.key] ?? s.def}
|
||||
options={s.options}
|
||||
onChange={v => update(s.key, v)}
|
||||
/>
|
||||
)}
|
||||
{s.type === "toggle" && (
|
||||
<SettingsToggle
|
||||
label={s.label}
|
||||
value={values[s.key] ?? s.def}
|
||||
onChange={v => update(s.key, v)}
|
||||
/>
|
||||
)}
|
||||
{s.type === "bpm" && (
|
||||
<BpmWidget
|
||||
label={s.label}
|
||||
value={values[s.key] ?? s.def}
|
||||
onChange={v => update(s.key, v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleBackdropClick}
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 999,
|
||||
display: "flex",
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
transition: "background .2s ease",
|
||||
}}
|
||||
>
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
style={{
|
||||
width: 260, maxWidth: "75vw", height: "100%",
|
||||
background: T.panel,
|
||||
borderRight: `1px solid ${T.border}`,
|
||||
display: "flex", flexDirection: "column",
|
||||
boxShadow: "4px 0 24px rgba(0,0,0,0.5)",
|
||||
transform: "translateX(-100%)",
|
||||
transition: "transform .22s cubic-bezier(.22,.61,.36,1)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: "14px 16px",
|
||||
borderBottom: `1px solid ${T.border}`,
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: T.textPrimary }}>
|
||||
☰ Menu
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: 28, height: 28, borderRadius: 6,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
background: T.surface, border: `1px solid ${T.border}`,
|
||||
color: T.textSec, fontSize: 13, cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Menu items */}
|
||||
<div style={{
|
||||
flex: 1, overflowY: "auto", padding: "8px 0",
|
||||
}}>
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onItemSelect?.(item.id)}
|
||||
style={{
|
||||
width: "100%", display: "flex", alignItems: "center", gap: 12,
|
||||
padding: "12px 16px",
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
textAlign: "left", color: T.textPrimary,
|
||||
transition: "background .1s",
|
||||
borderBottom: `1px solid ${T.border}33`,
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = T.surface; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = "none"; }}
|
||||
>
|
||||
<div style={{
|
||||
width: 34, height: 34, borderRadius: 8,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
background: T.surface, border: `1px solid ${T.border}`,
|
||||
fontSize: 16, flexShrink: 0,
|
||||
}}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 13, fontWeight: 600,
|
||||
color: T.textPrimary,
|
||||
}}>
|
||||
{item.label}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 10, color: T.textDim, marginTop: 1,
|
||||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
||||
}}>
|
||||
{item.desc}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ color: T.textDim, fontSize: 11 }}>›</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: "12px 16px",
|
||||
borderTop: `1px solid ${T.border}`,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 9, color: T.textDim, textAlign: "center",
|
||||
letterSpacing: ".06em",
|
||||
}}>
|
||||
Helix Stadium v0.1
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user