PEDAL-UI: Footswitch modes (stomp/preset) + mode selector in status bar + SVG fx icons from pipedal project

This commit is contained in:
2026-06-12 19:20:39 -04:00
parent 64f1086343
commit 91228cb284
84 changed files with 19852 additions and 783 deletions
+239 -35
View File
@@ -1,6 +1,8 @@
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";
// ── API Layer ──────────────────────────────────────────────────────
const API_BASE = "";
@@ -23,12 +25,14 @@ const API = {
listModels: () => api("GET", "/api/models"),
loadModel: (path) => api("POST", "/api/models/load", { path }),
listPresets: () => api("GET", "/api/presets"),
loadPreset: (bank, program) => api("GET", "/api/presets/{bank}/{program}".replace("{bank}",bank).replace("{program}",program)),
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 ──────────────────────────────────────────────
@@ -49,6 +53,13 @@ const T = {
};
// ── 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 }],
@@ -64,14 +75,36 @@ const BLOCK_PARAMS = {
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',
compressor: '#D09040', gate: T.textSec, eq: '#7080D0',
cabinet: '#D07090', ir: '#D07090',
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) {
@@ -80,6 +113,20 @@ function getBlockParams(type) {
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 = `
@@ -264,26 +311,70 @@ function ParamSlider({ label,value=50,onChange,onChangeEnd,min=0,max=100,color=T
);
}
// ── Select Param ────────────────────────────────────────────
function SelectParam({ def, value, color, onChange }) {
return (
<div className="param-row">
<div className="param-header">
<span className="param-label" style={{color}}>{def.label}</span>
</div>
<div style={{
display: "flex", gap: 4, flexWrap: "wrap",
background: "#0A0A10", borderRadius: 6, padding: 4,
}}>
{(def.options || []).map(opt => (
<button key={opt.value}
onClick={() => onChange?.(opt.value)}
style={{
flex: 1, padding: "5px 8px", borderRadius: 4, border: "none",
fontSize: 10, fontWeight: 600, cursor: "pointer",
background: value === opt.value ? color : "transparent",
color: value === opt.value ? "#000" : "#8888A0",
transition: "all .1s", letterSpacing: ".03em",
whiteSpace: "nowrap",
}}
>
{opt.label}
</button>
))}
</div>
</div>
);
}
// ── Parameter Panel ──────────────────────────────────────────
function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,onToggleView }) {
function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,onToggleView,onFocus }) {
const defs=getBlockParams(block.type);const color=getBlockColor(block.type);
const visibleDefs = defs.filter(d => !d.conditional || d.conditional(params));
return (
<div className="param-panel">
<div className="param-panel-header">
<span className="param-panel-title"><span style={{color}}></span> {block.name||block.type||''}</span>
<button className="btn-icon" style={{width:26,height:26,fontSize:11,flexShrink:0}}
onClick={onToggleView}>{viewMode==='slider'?'◉':'▦'}</button>
<div style={{display:'flex',gap:4,alignItems:'center'}}>
{onFocus&&<button className="btn" style={{padding:'4px 8px',fontSize:9,fontWeight:600,background:`${T.amber}22`,border:`1px solid ${T.amber}44`,color:T.amber,borderRadius:5,letterSpacing:'.04em',cursor:'pointer'}}
onClick={onFocus}>🔍 Focus</button>}
<button className="btn-icon" style={{width:26,height:26,fontSize:11,flexShrink:0}}
onClick={onToggleView}>{viewMode==='slider'?'◉':'▦'}</button>
</div>
</div>
{viewMode==='slider'?defs.map(p=>(
<ParamSlider key={p.key} label={p.label}
value={params[p.key]!=null?params[p.key]:p.def} min={p.min} max={p.max} color={color}
onChange={v=>onParamChange?.(p.key,v)} onChangeEnd={v=>onParamChangeEnd?.(p.key,v)}/>
)):(
<div className="param-knobs">{defs.map(p=>(
<Knob key={p.key} label={p.label}
value={params[p.key]!=null?params[p.key]:p.def} min={p.min} max={p.max} size={48} color={color}
{viewMode==='slider'?visibleDefs.map(p=>(
p.type === 'select' ? (
<SelectParam key={p.key} def={p} value={params[p.key]!=null?params[p.key]:p.def}
color={color}
onChange={v=>{onParamChange?.(p.key,v);onParamChangeEnd?.(p.key,v);}}/>
))}</div>
) : (
<ParamSlider key={p.key} label={p.label}
value={params[p.key]!=null?params[p.key]:p.def} min={p.min} max={p.max} color={color}
onChange={v=>onParamChange?.(p.key,v)} onChangeEnd={v=>onParamChangeEnd?.(p.key,v)}/>
)
)):(
visibleDefs.filter(p => p.type !== 'select').length > 0 && (
<div className="param-knobs">{visibleDefs.filter(p => p.type !== 'select').map(p=>(
<Knob key={p.key} label={p.label}
value={params[p.key]!=null?params[p.key]:p.def} min={p.min} max={p.max} size={48} color={color}
onChange={v=>{onParamChange?.(p.key,v);onParamChangeEnd?.(p.key,v);}}/>
))}</div>
)
)}
</div>
);
@@ -329,10 +420,11 @@ function CapturesScreen({onClose}){
// ── MOCK DATA (used when pedal not connected) ────────────────
const MOCK_BLOCKS=[
{id:"gate",type:"gate",name:"Noise Gate",bypassed:false,params:{threshold:40,release:25,depth:70,mix:100}},
{id:"drive",type:"overdrive",name:"OD-808",bypassed:false,params:{drive:65,tone:55,level:70}},
{id:"dist",type:"distortion",name:"RAT",bypassed:true,params:{drive:75,filter:50,level:65}},
{id:"mod",type:"phaser",name:"Phase 90",bypassed:false,params:{rate:35,depth:60,feedback:40,mix:50}},
{id:"delay",type:"delay",name:"Digital Delay",bypassed:false,params:{time:45,feedback:25,mix:40}},
{id:"split",type:"split",name:"Split",bypassed:false,params:{splitType:"y",panA:50,panB:50,activePath:"A"}},
{id:"drive_a",type:"overdrive",name:"OD-808",path:"A",bypassed:false,params:{drive:65,tone:55,level:70}},
{id:"delay_a",type:"delay",name:"Digital Delay",path:"A",bypassed:false,params:{time:45,feedback:25,mix:40}},
{id:"fuzz_b",type:"fuzz",name:"Big Muff",path:"B",bypassed:false,params:{fuzz:75,tone:40,level:65}},
{id:"merge",type:"merge",name:"Merge",bypassed:false,params:{blend:100,levelA:100,panA:0,levelB:100,panB:0}},
{id:"reverb",type:"reverb",name:"Hall Reverb",bypassed:false,params:{decay:60,mix:35,tone:50}},
];
@@ -357,19 +449,25 @@ export default function App(){
const[presets,setPresets]=useState(MOCK_PRESETS);
const[globalBypass,setGlobalBypass]=useState(false);
const[loadingPreset,setLoadingPreset]=useState(false);
const[focusBlockId,setFocusBlockId]=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 API has preset info, use it
if(s.current_preset?.name){
if(s.current_preset?.name&&!userChangedPreset.current){
setCurrentPreset(s.current_preset.program!=null?s.current_preset.program+1:currentPreset);
}
// If API has blocks, use them
if(s.blocks&&s.blocks.length>0){setBlocks(s.blocks);}
// 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]);
@@ -391,14 +489,49 @@ export default function App(){
// ── Change preset ──
const handleLoadPreset=useCallback(async(bank,program)=>{
userChangedPreset.current = true;
setLoadingPreset(true);
try{
const p=await API.loadPreset(bank,program);
if(p?.chain)setBlocks(p.chain.map((b,i)=>({...b,id:b.id||(`block_${i}`),bypassed:!b.enabled,params:b.params||{}})));
setCurrentPreset(program);
// 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 load failed:",e);}
}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)=>{
@@ -421,12 +554,12 @@ export default function App(){
},[selectedBlockId,selectedBlock,blockParams]);
const handleSelectBlock=useCallback((id)=>{setSelectedBlockId(prev=>prev===id?null:id);},[]);
const handleToggleBlock=useCallback((id)=>{setBlocks(prev=>prev.map(b=>b.id===id?{...b,bypassed:!b.bypassed}:b));API.toggleBlock(id,false).catch(()=>{});},[]);
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(()=>{const id="block_"+Date.now();setBlocks(prev=>[...prev,{id,type:"overdrive",name:"New Drive",bypassed:false,params:{drive:30,tone:50,level:70}}]);},[]);
const handleRemoveBlock=useCallback((id)=>{setBlocks(prev=>prev.filter(b=>b.id!==id));if(selectedBlockId===id)setSelectedBlockId(null);},[selectedBlockId]);
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]);
@@ -435,23 +568,47 @@ export default function App(){
const handleBankUp=useCallback(()=>{
const next=Math.min(currentBank+1,8);
setCurrentBank(next);
// Load first preset of new bank
const bp=presets.filter(p=>p.bank===next);
if(bp.length>0)handlePresetClick(bp[0].num);
},[currentBank,presets,handlePresetClick]);
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)handlePresetClick(bp[0].num);
},[currentBank,presets,handlePresetClick]);
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 handleOpenFocusModelBrowser=useCallback(()=>{
setView("captures");
setFocusBlockId(null);
},[]);
// ── Render ──
return(
<><style>{css}</style>
@@ -483,6 +640,21 @@ export default function App(){
<div style={{width:1,height:12,background:T.border}}/>
<button className="btn-icon" style={{width:24,height:24,fontSize:10}}onClick={()=>setView("captures")}title="Downloads">📁</button>
<button className="btn-icon" style={{width:24,height:24,fontSize:10}}onClick={()=>setView("presets")}title="Presets"></button>
<div style={{display:"flex",gap:2,alignItems:"center",background:T.surface,borderRadius:4,border:`1px solid ${T.border}`,padding:2,height:24}}>
{["stomp","preset"].map(m=>(<button key={m} onClick={()=>setFootswitchMode(m)} style={{
padding:"1px 5px",borderRadius:3,border:"none",height:20,
fontSize:8,fontWeight:700,letterSpacing:".04em",textTransform:"uppercase",
background:footswitchMode===m?T.amber:"transparent",
color:footswitchMode===m?"#000":T.textDim,
cursor:"pointer",lineHeight:1.5,minWidth:34,display:"flex",alignItems:"center",justifyContent:"center",
}}>{m==="stomp"?"SM":"PR"}</button>))}
</div>
<button className="btn-icon" style={{width:24,height:24,fontSize:10,
background:state.tuner_enabled?'rgba(128,208,160,.15)':T.surface,
borderColor:state.tuner_enabled?T.green:T.border,
color:state.tuner_enabled?T.green:T.textPrimary}}
onClick={handleTunerToggle} title={state.tuner_enabled?"Exit Tuner":"Tuner"}></button>
<button className="btn-icon" style={{width:24,height:24,fontSize:10,background:'rgba(232,160,48,.12)',borderColor:T.amber,color:T.amber}}onClick={()=>{if(!document.fullscreenElement){document.documentElement.requestFullscreen()}else{document.exitFullscreen()}}}title="Fullscreen"></button>
</div>
</div>
@@ -551,6 +723,7 @@ export default function App(){
onToggleView={()=>setParamView(p=>p==='slider'?'knob':'slider')}
onParamChange={(key,value)=>handleParamChange(selectedBlockId,key,value)}
onParamChangeEnd={(key,value)=>handleParamChangeEnd(selectedBlockId,key,value)}
onFocus={()=>setFocusBlockId(selectedBlockId)}
/>
):(
<div style={{flex:1,display:"flex",alignItems:"center",justifyContent:"center",background:T.bg,color:T.textDim,fontSize:11}}>
@@ -562,19 +735,37 @@ export default function App(){
{/* ══════ FOOTSWITCH BAR ══════ */}
<FootswitchBar
mode={footswitchMode}
blocks={blocks}
selectedBlockId={selectedBlockId}
currentBank={bankLetter}
currentPreset={currentPreset}
presets={currentBankPresets}
onSelectBlock={handleSelectBlock}
onToggleBlock={handleToggleBlock}
onBankUp={handleBankUp}
onBankDown={handleBankDown}
onGlobalBypass={handleGlobalBypass}
globalBypass={globalBypass}
onPresetClick={(idx)=>{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]&&(
<FocusView
block={blocks.find(b=>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={handleOpenFocusModelBrowser}
/>
)}
{/* ── Overlays ── */}
{view==="captures"&&<CapturesScreen onClose={()=>setView("main")}/>}
{view==="presets"&&(
@@ -594,6 +785,19 @@ export default function App(){
</div>
</div>
)}
{/* ── Tuner overlay ── */}
{state.tuner_enabled&&(
<TunerScreen
tunerData={state}
connected={state.connected}
onExit={()=>{
setState(prev=>({...prev, tuner_enabled: false}));
API.tunerToggle(false).catch(()=>{});
}}
/>
)}
</div></>
);
}