import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import BlockChain from "./BlockChain.jsx"; import FootswitchBar from "./FootswitchBar.jsx"; import TunerScreen from "./Tuner.jsx"; import FocusView from "./FocusView.jsx"; import ModelBrowser from "./ModelBrowser.jsx"; // ── API Layer ────────────────────────────────────────────────────── const API_BASE = ""; async function api(method, path, body) { const opts = { method, headers: { "Content-Type": "application/json" } }; if (body) opts.body = JSON.stringify(body); const res = await fetch(`${API_BASE}${path}`, opts); if (!res.ok) throw new Error(`API ${method} ${path}: ${res.status}`); return res.json(); } const API = { getState: () => api("GET", "/api/state"), getVu: () => api("GET", "/api/vu"), updateBlock: (id, params) => api("PATCH", "/api/block-params", { id, ...params }), toggleBlock: (id, enabled) => api("PATCH", "/api/blocks", { id, enabled }), bypassToggle: () => api("POST", "/api/bypass/toggle"), masterVolume: (v) => api("POST", "/api/volume", { volume: v / 100 }), listModels: () => api("GET", "/api/models"), loadModel: (path) => api("POST", "/api/models/load", { path }), listPresets: () => api("GET", "/api/presets"), 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 }), 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 }), installIr: (url, name) => api("POST", "/api/irs/tonedownload/install", { download_url: url, name }), tunerToggle: (enabled) => api("POST", "/api/tuner", { enabled }), }; // ── Design tokens ────────────────────────────────────────────── 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", }; // ── Block parameter definitions ───────────────────────────────── const SPLIT_TYPES = [ { value:'y', label:'Y — Split evenly', pan:true }, { value:'ab', label:'A/B — Route to A or B', pan:false }, { value:'cascade', label:'Cascade — A feeds B', pan:true }, { value:'a_to_b', label:'A→B — Serial extension', pan:false }, ]; const BLOCK_PARAMS = { overdrive: [{ key:'drive', label:'Drive', min:0, max:100, def:30 }, { key:'tone', label:'Tone', min:0, max:100, def:50 }, { key:'level', label:'Level', min:0, max:100, def:70 }], distortion: [{ key:'drive', label:'Drive', min:0, max:100, def:50 }, { key:'tone', label:'Tone', min:0, max:100, def:40 }, { key:'level', label:'Level', min:0, max:100, def:60 }], fuzz: [{ key:'fuzz', label:'Fuzz', min:0, max:100, def:60 }, { key:'tone', label:'Tone', min:0, max:100, def:30 }, { key:'level', label:'Level', min:0, max:100, def:60 }], delay: [{ key:'time', label:'Time', min:0, max:100, def:40 }, { key:'feedback', label:'Feedback', min:0, max:100, def:30 }, { key:'mix', label:'Mix', min:0, max:100, def:35 }], echo: [{ key:'time', label:'Time', min:0, max:100, def:40 }, { key:'feedback', label:'Feedback', min:0, max:100, def:30 }, { key:'mix', label:'Mix', min:0, max:100, def:35 }], reverb: [{ key:'decay', label:'Decay', min:0, max:100, def:50 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }, { key:'tone', label:'Tone', min:0, max:100, def:50 }], chorus: [{ key:'rate', label:'Rate', min:0, max:100, def:40 }, { key:'depth', label:'Depth', min:0, max:100, def:40 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }], flanger: [{ key:'rate', label:'Rate', min:0, max:100, def:30 }, { key:'depth', label:'Depth', min:0, max:100, def:50 }, { key:'feedback', label:'Feedback', min:0, max:100, def:40 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }], phaser: [{ key:'rate', label:'Rate', min:0, max:100, def:30 }, { key:'depth', label:'Depth', min:0, max:100, def:50 }, { key:'feedback', label:'Feedback', min:0, max:100, def:40 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }], compressor: [{ key:'threshold', label:'Thresh', min:0, max:100, def:50 }, { key:'ratio', label:'Ratio', min:1, max:20, def:4 }, { key:'gain', label:'Gain', min:0, max:100, def:50 }], gate: [{ key:'threshold', label:'Thresh', min:0, max:100, def:30 }, { key:'release', label:'Release', min:0, max:100, def:50 }], eq: [{ key:'low', label:'Low', min:-12, max:12, def:0 }, { key:'mid', label:'Mid', min:-12, max:12, def:0 }, { key:'high', label:'High', min:-12, max:12, def:0 }, { key:'level', label:'Level', min:0, max:100, def:70 }], cabinet: [{ key:'level', label:'Level', min:0, max:100, def:75 }], ir: [{ key:'level', label:'Level', min:0, max:100, def:75 }], split: [{ key:'splitType', label:'Split Type', type:'select', options:SPLIT_TYPES, def:'y' }, { key:'panA', label:'Pan A', min:0, max:100, def:50, conditional: (p) => p.splitType !== 'ab' }, { key:'panB', label:'Pan B', min:0, max:100, def:50, conditional: (p) => p.splitType !== 'ab' }, { key:'activePath', label:'Active Path', type:'select', options:[{value:'A',label:'A'},{value:'B',label:'B'}], def:'A', conditional: (p) => p.splitType === 'ab' }], merge: [{ key:'blend', label:'Blend', min:0, max:100, def:100 }, { key:'levelA', label:'Level A', min:0, max:100, def:100 }, { key:'panA', label:'Pan A', min:-100, max:100, def:0 }, { key:'levelB', label:'Level B', min:0, max:100, def:100 }, { key:'panB', label:'Pan B', min:-100, max:100, def:0 }], }; const BLOCK_COLORS = { overdrive: T.amber, distortion: T.red, fuzz: '#C07030', delay: T.blue, echo: T.blue, reverb: '#60B0D0', chorus: T.green, flanger: '#50C080', phaser: '#80C050', tremolo: T.green, compressor: '#D09040', gate: T.textSec, eq: '#7080D0', cabinet: '#D07090', ir: '#D07090', nam: '#E0A040', boost: T.amber, wah: '#D0A060', volume: T.textSec, tuner: '#80D0A0', split: '#B080C0', merge: '#80B0C0', }; const BLOCK_DISPLAY_NAMES = { overdrive: "OD-808", distortion: "RAT", fuzz: "Big Muff", delay: "Digital Delay", echo: "Tape Echo", reverb: "Hall Reverb", chorus: "Chorus", flanger: "Flanger", phaser: "Phase 90", tremolo: "Tremolo", compressor: "Compressor", gate: "Noise Gate", eq: "EQ", cabinet: "Cabinet", ir: "IR Loader", nam: "NAM Model", boost: "Clean Boost", wah: "Wah", volume: "Volume", tuner: "Tuner", loop: "FX Loop", split: "Split", merge: "Merge", }; function getBlockParams(type) { return BLOCK_PARAMS[(type || '').toLowerCase()] || [{ key:'level', label:'Level', min:0, max:100, def:50 }]; } function getBlockColor(type) { return BLOCK_COLORS[(type || '').toLowerCase()] || T.amber; } function getBlockDisplayName(block) { const key = (block.type || '').toLowerCase(); // If it has a NAM model path, show the model filename if (block.nam_model_path) { const parts = block.nam_model_path.split('/').pop()?.replace(/\.(nam|wav|aiff?)$/i, '') || ''; return parts || 'NAM Model'; } // If it has an IR file path, show the filename if (block.ir_file_path) { const parts = block.ir_file_path.split('/').pop()?.replace(/\.(wav|aiff?|ir)$/i, '') || ''; return parts || 'IR Loader'; } return BLOCK_DISPLAY_NAMES[key] || key.charAt(0).toUpperCase() + key.slice(1); } // ── CSS ───────────────────────────────────────────────────── const css = ` @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@300;400;500;600&display=swap'); * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; user-select: none; } html, body { height: 100%; overflow: hidden; background: ${T.bg}; } body { color: ${T.textPrimary}; font-family: 'Inter', sans-serif; } :root { --amber: ${T.amber}; --blue: ${T.blue}; --green: ${T.green}; } .mono { font-family: 'JetBrains Mono', monospace; } ::-webkit-scrollbar { width: 4px; height: 4px; } ::-webkit-scrollbar-track { background: ${T.panel}; } ::-webkit-scrollbar-thumb { background: ${T.border}; border-radius: 2px; } /* ── Knob ── */ .knob-wrap { display: flex; flex-direction: column; align-items: center; gap: 4px; cursor: pointer; } .knob { border-radius: 50%; position: relative; touch-action: none; } .knob-inner { border-radius: 50%; width: 100%; height: 100%; position: relative; background: radial-gradient(circle at 35% 30%, #3A3A48, #1A1A22); box-shadow: 0 2px 8px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.07); } .knob-dot { position: absolute; width: 3px; border-radius: 2px; background: var(--amber); left: 50%; transform-origin: bottom center; box-shadow: 0 0 6px var(--amber); } .knob-label { font-size: 9px; color: ${T.textSec}; letter-spacing: .06em; text-transform: uppercase; text-align: center; } .knob-value { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: ${T.amber}; } /* ── Buttons ── */ .btn { padding: 8px 14px; border-radius: 6px; font-size: 12px; font-weight: 600; letter-spacing: .04em; border: none; cursor: pointer; transition: all .12s; } .btn-primary { background: ${T.amber}; color: #000; } .btn-primary:active { filter: brightness(.85); transform: scale(.97); } .btn-ghost { background: ${T.surface}; color: ${T.textPrimary}; border: 1px solid ${T.border}; } .btn-ghost:active { background: ${T.border}; } .btn-icon { width: 32px; height: 32px; border-radius: 7px; display: flex; align-items: center; justify-content: center; background: ${T.surface}; border: 1px solid ${T.border}; cursor: pointer; font-size: 15px; transition: all .12s; flex-shrink: 0; } .btn-icon:active { transform: scale(.93); } .btn-icon.active { background: rgba(232,160,48,.15); border-color: ${T.amber}; color: ${T.amber}; } /* ── Badge ── */ .badge { display: inline-flex; align-items: center; justify-content: center; padding: 2px 7px; border-radius: 4px; font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } .badge-amber { background: rgba(232,160,48,.2); color: ${T.amber}; } .badge-green { background: rgba(58,184,122,.2); color: ${T.green}; } .badge-blue { background: rgba(58,123,168,.2); color: ${T.blue}; } /* ── Param slider ── */ .param-panel { background: ${T.surface}; padding: 8px 12px 6px; border-top: 1px solid ${T.border}; flex-shrink: 0; } .param-panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2px; } .param-panel-title { font-size: 10px; font-weight: 600; letter-spacing: .07em; text-transform: uppercase; color: ${T.textSec}; } .param-row { display: flex; flex-direction: column; gap: 1px; padding: 1px 0; } .param-header { display: flex; justify-content: space-between; align-items: center; } .param-label { font-size: 9px; font-weight: 500; text-transform: uppercase; letter-spacing: .06em; color: ${T.textSec}; } .param-val { font-family: 'JetBrains Mono',monospace; font-size: 11px; font-weight: 600; } .param-track-wrap { position: relative; height: 30px; display: flex; align-items: center; cursor: pointer; touch-action: none; } .param-track { width: 100%; height: 6px; background: ${T.border}; border-radius: 3px; position: relative; overflow: hidden; } .param-fill { height: 100%; border-radius: 3px; position: absolute; top: 0; left: 0; transition: width .04s; } .param-thumb { width: 22px; height: 22px; border-radius: 50%; position: absolute; top: 50%; transform: translate(-50%,-50%); box-shadow: 0 1px 6px rgba(0,0,0,.6); pointer-events: none; border: 3px solid ${T.bg}; } .param-btn { width: 28px; height: 28px; border-radius: 50%; border: 1px solid ${T.border}; background: ${T.panel}; color: ${T.textPrimary}; font-size: 14px; font-weight: 700; display: flex; align-items: center; justify-content: center; cursor: pointer; line-height: 1; } .param-btn:active { background: ${T.border}; transform: scale(.92); } .param-knobs { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; padding: 4px 0; } /* ── Screen header (overlays) ── */ .screen-header { padding: 10px 14px 8px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid ${T.border}; } .screen-title { font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: ${T.textSec}; } .screen-body { flex: 1; overflow-y: auto; padding: 10px 12px; display: flex; flex-direction: column; gap: 10px; } /* ── Preset sidebar chip ── */ .preset-chip-s { width: 100%; padding: 7px 6px; border-radius: 5px; background: ${T.surface}; border: 1px solid ${T.border}; display: flex; flex-direction: column; align-items: center; cursor: pointer; transition: all .12s; } .preset-chip-s.active { border-color: ${T.amber}; background: rgba(232,160,48,.08); } .preset-chip-s:active { transform: scale(.96); } .preset-chip-s .pcs-num { font-family: 'JetBrains Mono',monospace; font-size: 14px; font-weight: 700; color: ${T.amber}; line-height: 1.1; } .preset-chip-s .pcs-name { font-size: 9px; color: ${T.textSec}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; text-align: center; line-height: 1.2; } .preset-chip-s .pcs-indicator { width: 4px; height: 4px; border-radius: 50%; margin-top: 3px; } /* ── Preset row (captures) ── */ .preset-row { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 6px; cursor: pointer; transition: background .12s; border: 1px solid transparent; } .preset-row:active, .preset-row.active { background: rgba(232,160,48,.08); border-color: rgba(232,160,48,.25); } .section-label { font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: ${T.textDim}; font-weight: 600; margin-bottom: 8px; } .card { background: ${T.panel}; border: 1px solid ${T.border}; border-radius: 8px; padding: 14px; } .card-sm { background: ${T.surface}; border: 1px solid ${T.border}; border-radius: 6px; padding: 10px; } `; // ── Knob ────────────────────────────────────────────────────── function Knob({ label, value = 50, onChange, size = 48, color = T.amber, min = 0, max = 100 }) { const startRef = useRef(null); const norm = (value - min) / (max - min); const angle = -140 + norm * 280; 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)); }; 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+12,cx=svgSize/2,cy=svgSize/2,r=size/2+3; const lx=cx+r*Math.cos((-140-90)*(Math.PI/180)),ly=cy+r*Math.sin((-140-90)*(Math.PI/180)); const ex=cx+r*Math.cos((angle-90)*(Math.PI/180)),ey=cy+r*Math.sin((angle-90)*(Math.PI/180)); const large=norm*280>180?1:0; return (
{norm>0&&}
{value}
{label}
); } // ── Param Slider ────────────────────────────────────────────── function ParamSlider({ label,value=50,onChange,onChangeEnd,min=0,max=100,color=T.amber }) { const trackRef=useRef(null);const dragRef=useRef(false);const lastValRef=useRef(value); const onChangeRef=useRef(onChange);const onChangeEndRef=useRef(onChangeEnd); const pct=((value-min)/(max-min))*100; useEffect(()=>{onChangeRef.current=onChange;},[onChange]); useEffect(()=>{onChangeEndRef.current=onChangeEnd;},[onChangeEnd]); 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();const v=valFromEvent(e);lastValRef.current=v;onChangeRef.current?.(v);}; const up=()=>{if(dragRef.current){onChangeEndRef.current?.(lastValRef.current);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]); const handleStart=(e)=>{e.preventDefault();const v=valFromEvent(e);lastValRef.current=v;dragRef.current=true;onChangeRef.current?.(v);}; const quick=(delta)=>(e)=>{e.preventDefault();e.stopPropagation();const next=Math.max(min,Math.min(max,value+delta));onChangeRef.current?.(next);onChangeEndRef.current?.(next);}; return (
{label}
{value}
); } // ── Select Param ──────────────────────────────────────────── function SelectParam({ def, value, color, onChange }) { return (
{def.label}
{(def.options || []).map(opt => ( ))}
); } // ── Parameter Panel ────────────────────────────────────────── function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,onToggleView,onFocus,onModelBrowser }) { const defs=getBlockParams(block.type);const color=getBlockColor(block.type); const visibleDefs = defs.filter(d => !d.conditional || d.conditional(params)); return (
onModelBrowser?.(block.id)}> {block.name||block.type||''} {onModelBrowser&&}
{onFocus&&}
{viewMode==='slider'?visibleDefs.map(p=>( p.type === 'select' ? ( {onParamChange?.(p.key,v);onParamChangeEnd?.(p.key,v);}}/> ) : ( onParamChange?.(p.key,v)} onChangeEnd={v=>onParamChangeEnd?.(p.key,v)}/> ) )):( visibleDefs.filter(p => p.type !== 'select').length > 0 && (
{visibleDefs.filter(p => p.type !== 'select').map(p=>( {onParamChange?.(p.key,v);onParamChangeEnd?.(p.key,v);}}/> ))}
) )}
); } // ── VU Meter ────────────────────────────────────────────────── function VUMeter({level=0,height=24,vertical=false}){const segs=12,active=Math.round((level/100)*segs); const c=i=>i>=10?T.red:i>=8?"#E8C030":T.green; if(vertical)return(
{Array.from({length:segs}).map((_,i)=>(
))}
); return(
{Array.from({length:segs}).map((_,i)=>(
))}
); } // ── Captures Screen ────────────────────────────────────────── function CapturesScreen({onClose}){ const[tab,setTab]=useState("models");const[query,setQuery]=useState(""); const[results,setResults]=useState(null);const[models,setModels]=useState([]);const[loading,setLoading]=useState(false); useEffect(()=>{API.listModels().then(d=>setModels(d.models||[])).catch(()=>{});},[]); const doSearch=async()=>{if(!query.trim())return;setLoading(true);try{const data=tab==="models"?await API.searchModels(query):await API.searchIrs(query);setResults(data.results||[]);}catch{setResults([]);}setLoading(false);}; const install=async(item)=>{try{if(tab==="models"){await API.installModel(item.download_url,item.name);}else{await API.installIr(item.download_url,item.name);}API.listModels().then(d=>setModels(d.models||[])).catch(()=>{});}catch(e){alert("Install failed: "+e.message);}}; return(
Downloads
🔍 setQuery(e.target.value)}onKeyDown={e=>e.key==="Enter"&&doSearch()}style={{flex:1,background:"none",border:"none",color:T.textPrimary,fontSize:12,outline:"none"}}/>
{results===null&&models.length>0&&(<>
Installed Models
{models.map((m,i)=>(
🎛
{m.name}
{m.architecture}·{m.size_mb}MB{m.loaded?"· CURRENT":""}
))})} {results!==null&&results.length===0&&
No results found
} {results!==null&&results.map((r,i)=>(
{r.name}
{r.author}·{r.size_display}
))}
); } // ── MOCK DATA (used when pedal not connected) ──────────────── const MOCK_BLOCKS=[ {id:"gate",type:"gate",name:"Noise Gate",bypassed:false,params:{threshold:40,release:25}}, {id:"comp",type:"compressor",name:"Compressor",bypassed:false,params:{threshold:50,ratio:4,gain:50}}, {id:"amp",type:"nam",name:"Twin Reverb",bypassed:false,params:{level:75}}, {id:"cab",type:"ir",name:"2x12 American",bypassed:false,params:{level:75}}, {id:"eq",type:"eq",name:"EQ",bypassed:false,params:{low:0,mid:0,high:0,level:70}}, {id:"reverb",type:"reverb",name:"Hall Reverb",bypassed:false,params:{subtype:"hall",decay:50,mix:30,tone:50}}, ]; const MOCK_PRESETS=[ {num:1,bank:1,name:"Plexi Crunch"},{num:2,bank:1,name:"Blues Rock Lead"}, {num:3,bank:1,name:"Deluxe Clean"},{num:4,bank:1,name:"British Drive"}, {num:5,bank:2,name:"Metal Djent"},{num:6,bank:2,name:"Funky Wah"}, {num:7,bank:2,name:"Ambient Pad"},{num:8,bank:2,name:"Shoegaze"}, ]; // ── App Shell — Landscape Helix Stadium ─────────────────────── export default function App(){ const[view,setView]=useState("main"); const[state,setState]=useState({connected:false,master_volume:0.72,routing_mode:"mono",cpu_percent:0}); const[blocks,setBlocks]=useState(MOCK_BLOCKS); const[selectedBlockId,setSelectedBlockId]=useState("gate"); const[blockParams,setBlockParams]=useState({}); const[paramView,setParamView]=useState("slider"); const[vuLevel,setVuLevel]=useState(0); const[currentPreset,setCurrentPreset]=useState(1); const[currentBank,setCurrentBank]=useState(1); const[presets,setPresets]=useState(MOCK_PRESETS); const[globalBypass,setGlobalBypass]=useState(false); const[loadingPreset,setLoadingPreset]=useState(false); const[focusBlockId,setFocusBlockId]=useState(null); const[modelBrowserBlockId,setModelBrowserBlockId]=useState(null); const[footswitchMode,setFootswitchMode]=useState("stomp"); // ── Poll pedal state from API ── const userChangedPreset = useRef(false); const userChangedBlock = useRef(false); const loadState=useCallback(async()=>{ try{ const s=await API.getState(); setState(s); if(s.input_level!=null)setVuLevel(s.input_level); if(s.current_preset?.name&&!userChangedPreset.current){ setCurrentPreset(s.current_preset.program!=null?s.current_preset.program+1:currentPreset); } // Only update blocks from API if user didn't just toggle/change them if(s.blocks&&s.blocks.length>0&&!userChangedBlock.current){ setBlocks(s.blocks); } }catch{} },[currentPreset]); useEffect(()=>{loadState();const id=setInterval(loadState,3000);return()=>clearInterval(id);},[loadState]); // VU animation fallback when no real data useEffect(()=>{const id=setInterval(()=>{setVuLevel(p=>Math.max(0,Math.min(100,p+(Math.random()-0.45)*8)));},150);return()=>clearInterval(id);},[]); // ── Load presets from API ── useEffect(()=>{ 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});});}); if(all.length>0)setPresets(all); } }).catch(()=>{}); },[]); // ── Change preset ── const handleLoadPreset=useCallback(async(bank,program)=>{ userChangedPreset.current = true; setLoadingPreset(true); try{ // Activate on the backend first await API.activatePreset(bank,program); // Short delay for backend to apply await new Promise(r => setTimeout(r, 300)); // Get updated state const s=await API.getState(); setState(s); if(s.current_preset?.name){ setCurrentPreset(s.current_preset.program!=null?s.current_preset.program+1:program+1); } // Get preset chain data and build blocks try{ const p=await API.getPreset(bank,program); if(p?.chain&&p.chain.length>0){ const newBlocks=p.chain.map((b,i)=>{ const raw = { type: b.fx_type || "fx", enabled: b.enabled !== false, params: b.params || {}, nam_model_path: b.nam_model_path, ir_file_path: b.ir_file_path, }; return { id: b.id || `block_${b.fx_type || 'fx'}_${i}`, type: raw.type, name: getBlockDisplayName(raw), bypassed: !b.enabled, enabled: raw.enabled, params: raw.params, nam_model_path: raw.nam_model_path, ir_file_path: raw.ir_file_path, }; }); setBlocks(newBlocks); userChangedBlock.current = true; } }catch(e){console.warn("Could not fetch chain:",e);} setSelectedBlockId(null); }catch(e){console.warn("Preset activate failed:",e);} setLoadingPreset(false); // Re-enable polling sync after 5s setTimeout(() => { userChangedPreset.current = false; }, 5000); setTimeout(() => { userChangedBlock.current = false; }, 5000); },[]); const handlePresetClick=useCallback(async(num)=>{ // Find the preset by number const p=presets.find(pr=>pr.num===num); if(!p)return; setCurrentPreset(num); await handleLoadPreset(p.bank||1,num-1); },[presets,handleLoadPreset]); // ── Blocks ── const selectedBlock=useMemo(()=>blocks.find(b=>b.id===selectedBlockId),[blocks,selectedBlockId]); useEffect(()=>{ if(selectedBlock&&blockParams[selectedBlockId]==null){ const defs=getBlockParams(selectedBlock.type);const init={}; defs.forEach(p=>{init[p.key]=(selectedBlock.params?.[p.key]!=null)?selectedBlock.params[p.key]:p.def;}); setBlockParams(prev=>({...prev,[selectedBlockId]:init})); } },[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 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]); // ── Bank navigation ── const currentBankPresets=useMemo(()=>presets.filter(p=>p.bank===currentBank),[presets,currentBank]); const bankLetter=String.fromCharCode(64+currentBank); const handleBankUp=useCallback(()=>{ const next=Math.min(currentBank+1,8); setCurrentBank(next); const bp=presets.filter(p=>p.bank===next); if(bp.length>0)handleLoadPreset(bp[0].bank,bp[0].num-1); },[currentBank,presets,handleLoadPreset]); const handleBankDown=useCallback(()=>{ const next=Math.max(currentBank-1,1); setCurrentBank(next); const bp=presets.filter(p=>p.bank===next); if(bp.length>0)handleLoadPreset(bp[0].bank,bp[0].num-1); },[currentBank,presets,handleLoadPreset]); const handleGlobalBypass=useCallback(()=>{ setGlobalBypass(p=>!p); API.bypassToggle().catch(()=>{}); },[]); // ── Tuner toggle ── const handleTunerToggle=useCallback(()=>{ const newState = !state.tuner_enabled; setState(prev=>({...prev, tuner_enabled: newState})); API.tunerToggle(newState).catch(()=>{}); if (newState) { setView("main"); // Return to main view when enabling tuner (or keep current) } },[state.tuner_enabled]); // ── Focus View handlers ── const handleFocusViewBypass=useCallback(()=>{ userChangedBlock.current=true; const id=focusBlockId; if(!id)return; setBlocks(prev=>prev.map(b=>b.id===id?{...b,bypassed:!b.bypassed}:b)); API.toggleBlock(id,false).catch(()=>{}); setTimeout(()=>{userChangedBlock.current=false;},3000); },[focusBlockId]); const handleOpenModelBrowser=useCallback((blockId)=>{ setModelBrowserBlockId(blockId); if(focusBlockId) setFocusBlockId(null); },[focusBlockId]); const handleSelectModel=useCallback(({type, name, modelPath, isBuiltin})=>{ const id=modelBrowserBlockId; if(!id)return; userChangedBlock.current=true; setBlocks(prev=>prev.map(b=>{ if(b.id!==id)return b; const defs=getBlockParams(type); const init={}; defs.forEach(p=>{init[p.key]=p.def;}); return { ...b, type, name: name || getBlockDisplayName({type}), params: init, nam_model_path: (!isBuiltin && modelPath && type==='nam') ? modelPath : undefined, ir_file_path: (!isBuiltin && modelPath && type==='ir') ? modelPath : undefined, }; })); setModelBrowserBlockId(null); setBlockParams(prev=>{ const next={...prev}; delete next[id]; return next; }); setTimeout(()=>{userChangedBlock.current=false;},3000); },[modelBrowserBlockId]); // ── Render ── return( <>
{view==="main"&&( <> {/* ══════ STATUS BAR ══════ */}
{state.connected?"CONNECTED":"OFFLINE"}
{state.current_preset?.name||(presets.find(p=>p.num===currentPreset)?.name||"—")} {loadingPreset&&}
CPU {state.cpu_percent!=null?`${state.cpu_percent}%`:"—"}
{["stomp","preset"].map(m=>())}
{/* ══════ MAIN AREA: Preset sidebar + Content ══════ */}
{/* ─── Preset Sidebar ─── */}
{/* Bank header */}
Bank {bankLetter}
Programs
{/* Preset list */}
{currentBankPresets.map(p=>(
handlePresetClick(p.num)}>
0{p.num}
{p.name}
))}
{/* ─── Main Content Area ─── */}
{/* Scribble info bar */}
{[ {label:"Preset",value:presets.find(p=>p.num===currentPreset)?.name||"—"}, {label:"Routing",value:(state.routing_mode||"MONO").toUpperCase()}, {label:"Volume",value:state.master_volume!=null?`${Math.round(state.master_volume*100)}%`:"—"}, {label:"Bank",value:bankLetter}, ].map(s=>(
{s.label}
{s.value}
))}
{/* Block Chain */}
{/* Parameter Panel or placeholder */} {selectedBlock&&blockParams[selectedBlockId]?( setParamView(p=>p==='slider'?'knob':'slider')} onParamChange={(key,value)=>handleParamChange(selectedBlockId,key,value)} onParamChangeEnd={(key,value)=>handleParamChangeEnd(selectedBlockId,key,value)} onFocus={()=>setFocusBlockId(selectedBlockId)} onModelBrowser={handleOpenModelBrowser} /> ):(
Tap a block to edit parameters
)}
{/* ══════ FOOTSWITCH BAR ══════ */} {const p=currentBankPresets[parseInt(idx)];if(p)handleLoadPreset(p.bank,p.num-1);}} /> )} {/* ── Focus View overlay ── */} {focusBlockId&&blocks.find(b=>b.id===focusBlockId)&&blockParams[focusBlockId]&&( b.id===focusBlockId)} params={blockParams[focusBlockId]||{}} viewMode={paramView} onClose={()=>setFocusBlockId(null)} onParamChange={(key,value)=>handleParamChange(focusBlockId,key,value)} onParamChangeEnd={(key,value)=>handleParamChangeEnd(focusBlockId,key,value)} onToggleView={()=>setParamView(p=>p==='slider'?'knob':'slider')} onToggleBypass={handleFocusViewBypass} onOpenModelBrowser={()=>handleOpenModelBrowser(focusBlockId)} /> )} {/* ── Overlays ── */} {view==="captures"&&setView("main")}/>} {view==="presets"&&(
Presets
{presets.map((p,i)=>(
{handlePresetClick(p.num);setView("main");}}>
{p.num}
{p.name}
Bank {p.bank||1}·Program {p.num-1}
{p.num===currentPreset&&}
))}
)} {/* ── Tuner overlay ── */} {state.tuner_enabled&&( { setState(prev=>({...prev, tuner_enabled: false})); API.tunerToggle(false).catch(()=>{}); }} /> )} {/* ── Model Browser ── */} {modelBrowserBlockId&&(()=>{ const block=blocks.find(b=>b.id===modelBrowserBlockId); if(!block)return null; return ( setModelBrowserBlockId(null)} /> ); })()}
); }