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></>
);
}
+601 -275
View File
File diff suppressed because it is too large Load Diff
+524
View File
@@ -0,0 +1,524 @@
import { useState, useRef, useCallback, useMemo } from "react";
// ── 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 param definitions (mirrored from App.jsx) ──
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 }],
};
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',
};
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",
};
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 (block.nam_model_path) {
const parts = block.nam_model_path.split('/').pop()?.replace(/\.(nam|wav|aiff?)$/i, '') || '';
return parts || 'NAM Model';
}
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);
}
// ── Large touch knob (bigger version for Focus View) ──
function FocusKnob({ label, value = 50, onChange, size = 72, color = T.amber, min = 0, max = 100, compact = false }) {
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 + 16, cx = svgSize / 2, cy = svgSize / 2, r = size / 2 + 4;
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 (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: compact ? 2 : 6,
cursor: 'pointer',
}}>
<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>
<div style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: compact ? 11 : 14,
fontWeight: 700, color,
}}>{value}</div>
<div style={{
fontSize: compact ? 7 : 9, color: T.textSec,
letterSpacing: '.06em', textTransform: 'uppercase', textAlign: 'center',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: size + 16,
}}>{label}</div>
</div>
);
}
// ── Large Param Slider (bigger for Focus View) ──
function FocusSlider({ 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;
const [touchVal, setTouchVal] = useState(null);
useMemo(() => { onChangeRef.current = onChange; }, [onChange]);
useMemo(() => { 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]);
useMemo(() => {
const move = (e) => { if (!dragRef.current) return; e.preventDefault(); const v = valFromEvent(e); lastValRef.current = v; setTouchVal(v); onChangeRef.current?.(v); };
const up = () => { if (dragRef.current) { onChangeEndRef.current?.(lastValRef.current); dragRef.current = false; setTouchVal(null); } };
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; setTouchVal(v); 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 (
<div style={{ marginBottom: 4 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
<span style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '.06em', color }}>{label}</span>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button style={{
width: 34, height: 34, borderRadius: '50%', border: `1px solid ${T.border}`,
background: T.panel, color: T.textPrimary, fontSize: 16, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
lineHeight: 1,
}} onMouseDown={quick(-5)} onTouchStart={quick(-5)}></button>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: 16, fontWeight: 700, color,
minWidth: 40, textAlign: 'center',
}}>{touchVal ?? value}</span>
<button style={{
width: 34, height: 34, borderRadius: '50%', border: `1px solid ${T.border}`,
background: T.panel, color: T.textPrimary, fontSize: 16, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
lineHeight: 1,
}} onMouseDown={quick(5)} onTouchStart={quick(5)}>+</button>
</div>
</div>
<div ref={trackRef}
onMouseDown={handleStart} onTouchStart={handleStart}
style={{
position: 'relative', height: 44, display: 'flex', alignItems: 'center',
cursor: 'pointer', touchAction: 'none',
}}>
<div style={{
width: '100%', height: 10, background: T.border, borderRadius: 5,
position: 'relative', overflow: 'hidden',
}}>
<div style={{
height: '100%', borderRadius: 5, position: 'absolute', top: 0, left: 0,
width: `${pct}%`, background: color,
boxShadow: `0 0 8px ${color}44`,
transition: 'width .03s',
}} />
</div>
<div style={{
width: 30, height: 30, borderRadius: '50%', position: 'absolute',
top: '50%', left: `${pct}%`,
transform: 'translate(-50%,-50%)',
background: color,
boxShadow: `0 2px 10px rgba(0,0,0,.6), 0 0 0 3px ${T.bg}`,
pointerEvents: 'none',
transition: 'left .03s',
}} />
</div>
</div>
);
}
// ── Parameter page (single page of up to 8 params) ──
function ParamPage({ defs, params, viewMode, color, onParamChange, onParamChangeEnd, compact }) {
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: viewMode === 'knob' ? 8 : 0,
width: '100%', minWidth: 0, flexShrink: 0,
}}>
{viewMode === 'slider' ? (
defs.map(p => (
<FocusSlider 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 style={{
display: 'grid',
gridTemplateColumns: `repeat(${compact ? 4 : Math.min(4, defs.length)}, 1fr)`,
gap: 12,
padding: '8px 0',
justifyItems: 'center',
width: '100%',
maxWidth: 480,
margin: '0 auto',
}}>
{defs.map(p => (
<FocusKnob key={p.key} label={p.label} size={compact ? 60 : 72}
value={params[p.key] != null ? params[p.key] : p.def}
min={p.min} max={p.max} color={color}
onChange={v => { onParamChange(p.key, v); onParamChangeEnd(p.key, v); }}
compact={compact} />
))}
</div>
)}
</div>
);
}
// ── Focus View (full-screen overlay) ──
export default function FocusView({
block,
params,
viewMode,
onClose,
onParamChange,
onParamChangeEnd,
onToggleView,
onToggleBypass,
onOpenModelBrowser,
}) {
const defs = getBlockParams(block.type);
const color = getBlockColor(block.type);
const displayName = getBlockDisplayName(block);
// Pagination — max 8 params per page
const PER_PAGE = 8;
const pages = useMemo(() => {
const p = [];
for (let i = 0; i < defs.length; i += PER_PAGE) {
p.push(defs.slice(i, i + PER_PAGE));
}
return p;
}, [defs]);
const [currentPage, setCurrentPage] = useState(0);
const scrollRef = useRef(null);
const touchStartX = useRef(null);
const [isCompact, setIsCompact] = useState(window.innerWidth < 500);
// Track resize for compact layout
useMemo(() => {
const onResize = () => setIsCompact(window.innerWidth < 500);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
// ── Swipe handling ──
const handleTouchStart = useCallback((e) => {
touchStartX.current = e.touches[0].clientX;
}, []);
const handleTouchEnd = useCallback((e) => {
if (touchStartX.current == null) return;
const dx = e.changedTouches[0].clientX - touchStartX.current;
touchStartX.current = null;
if (Math.abs(dx) < 50) return; // threshold
if (dx < 0 && currentPage < pages.length - 1) {
setCurrentPage(p => Math.min(p + 1, pages.length - 1));
} else if (dx > 0 && currentPage > 0) {
setCurrentPage(p => Math.max(p - 1, 0));
}
}, [currentPage, pages.length]);
// ── Keyboard nav ──
useMemo(() => {
const handler = (e) => {
if (e.key === 'ArrowLeft' && currentPage > 0) setCurrentPage(p => p - 1);
if (e.key === 'ArrowRight' && currentPage < pages.length - 1) setCurrentPage(p => p + 1);
if (e.key === 'Escape') onClose?.();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [currentPage, pages.length, onClose]);
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', flexDirection: 'column',
background: T.bg,
fontFamily: "'Inter', sans-serif",
color: T.textPrimary,
}}>
{/* ── 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, minWidth: 0 }}>
<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',
flexShrink: 0,
}}>
</button>
<div style={{ minWidth: 0 }}>
<div style={{
fontSize: 15, fontWeight: 600, whiteSpace: 'nowrap',
overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{displayName}
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 6, marginTop: 2,
}}>
<span style={{
display: 'inline-flex', padding: '1px 6px', borderRadius: 3,
fontSize: 9, fontWeight: 700, letterSpacing: '.06em',
textTransform: 'uppercase',
background: `${color}22`, color,
}}>
{(block.type || 'fx').replace(/_/g, ' ').toUpperCase()}
</span>
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
{/* Slider/Knob toggle */}
<button onClick={onToggleView}
style={{
padding: '6px 10px', borderRadius: 6, fontSize: 10, fontWeight: 600,
background: T.surface, border: `1px solid ${T.border}`,
color: T.textPrimary, cursor: 'pointer', letterSpacing: '.04em',
}}>
{viewMode === 'slider' ? '◉ Knobs' : '▦ Sliders'}
</button>
</div>
</div>
{/* ── Bypass toggle (prominent) ── */}
<div style={{
padding: '6px 14px',
borderBottom: `1px solid ${T.border}`,
background: T.surface,
flexShrink: 0,
}}>
<button onClick={onToggleBypass}
style={{
width: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '10px 14px',
borderRadius: 8,
border: `2px solid ${block.bypassed ? T.red + '66' : T.green + '66'}`,
background: block.bypassed
? `linear-gradient(180deg, ${T.red + '18'}, ${T.panel})`
: `linear-gradient(180deg, ${T.green + '18'}, ${T.panel})`,
cursor: 'pointer',
transition: 'all .12s',
}}>
<div style={{
width: 12, height: 12, borderRadius: '50%',
background: block.bypassed ? T.textDim : T.green,
boxShadow: block.bypassed ? 'none' : `0 0 8px ${T.green}`,
}} />
<span style={{
fontSize: 12, fontWeight: 700, letterSpacing: '.08em', textTransform: 'uppercase',
color: block.bypassed ? T.red : T.green,
}}>
{block.bypassed ? 'Bypassed — Tap to Enable' : 'Active — Tap to Bypass'}
</span>
</button>
</div>
{/* ── Parameter area (scrollable with pages) ── */}
<div
ref={scrollRef}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
style={{
flex: 1, overflowY: 'auto', overflowX: 'hidden',
padding: '12px 16px',
display: 'flex', flexDirection: 'column',
gap: 6,
}}>
{pages.length === 0 ? (
<div style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: T.textDim, fontSize: 12,
}}>
No parameters for this block type
</div>
) : (
<>
{/* Page content */}
<ParamPage
defs={pages[currentPage]}
params={params}
viewMode={viewMode}
color={color}
onParamChange={onParamChange}
onParamChangeEnd={onParamChangeEnd}
compact={isCompact}
/>
{/* Spacer so page dots don't crowd content */}
<div style={{ flex: 1, minHeight: 8 }} />
</>
)}
</div>
{/* ── Bottom bar: Model browser + page indicators ── */}
<div style={{
padding: '8px 14px 12px',
borderTop: `1px solid ${T.border}`,
background: T.panel,
flexShrink: 0,
display: 'flex', flexDirection: 'column', gap: 6,
}}>
{/* Page dots */}
{pages.length > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 5 }}>
{pages.map((_, i) => (
<button key={i} onClick={() => setCurrentPage(i)}
style={{
width: 8, height: 8, borderRadius: '50%', border: 'none',
padding: 0, cursor: 'pointer',
background: i === currentPage ? color : T.border,
opacity: i === currentPage ? 1 : 0.4,
transition: 'all .12s',
}} />
))}
</div>
)}
{/* Page indicator text */}
{pages.length > 1 && (
<div style={{
textAlign: 'center', fontSize: 9, color: T.textDim,
fontFamily: "'JetBrains Mono', monospace", letterSpacing: '.04em',
}}>
Page {currentPage + 1} / {pages.length}
<span style={{ marginLeft: 8, opacity: 0.6 }}>
swipe to navigate
</span>
</div>
)}
{/* Model browser button / swap block */}
{onOpenModelBrowser && (
<button onClick={onOpenModelBrowser}
style={{
width: '100%',
padding: '8px 14px', borderRadius: 7,
background: T.surface, border: `1px solid ${T.border}`,
color: T.textPrimary, fontSize: 11, fontWeight: 600,
cursor: 'pointer', letterSpacing: '.04em',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}>
<span>🎛</span> Swap Model / Block Type
</button>
)}
</div>
</div>
);
}
+104 -146
View File
@@ -1,6 +1,5 @@
import { useRef, useEffect } from "react";
// ── Design tokens ──
const T = {
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060",
@@ -19,9 +18,17 @@ function getBlockColor(type) {
return TYPE_COLORS[(type || "").toLowerCase()] || T.amber;
}
function getShortLabel(type) {
return {
gate: "GATE", overdrive: "OD", distortion: "DST", fuzz: "FUZZ",
chorus: "CHO", flanger: "FLN", phaser: "PHA", tremolo: "TRE",
delay: "DLY", echo: "ECH", reverb: "REV",
compressor: "CMP", eq: "EQ", cabinet: "CAB", ir: "IR", nam: "NAM",
}[(type || "").toLowerCase()] || (type || "FX").toUpperCase().slice(0, 4);
}
// ── Single Footswitch with Scribble Strip ──
function Footswitch({ id, label, mainLabel, type, sub, active, enabled, onClick, onDoubleClick, isBank, bankDir }) {
const color = getBlockColor(type);
const tapRef = useRef(null);
useEffect(() => {
@@ -30,12 +37,8 @@ function Footswitch({ id, label, mainLabel, type, sub, active, enabled, onClick,
let lastTap = 0;
const handler = () => {
const now = Date.now();
if (now - lastTap < 300) {
onDoubleClick(id);
lastTap = 0;
} else {
lastTap = now;
}
if (now - lastTap < 300) { onDoubleClick(id); lastTap = 0; }
else { lastTap = now; }
};
el.addEventListener("click", handler);
return () => el.removeEventListener("click", handler);
@@ -44,107 +47,67 @@ function Footswitch({ id, label, mainLabel, type, sub, active, enabled, onClick,
const isBypassSwitch = type === "bypass";
const isBankSwitch = type === "bank";
const isActive = active || (isBypassSwitch && !enabled);
const borderClr = isActive ? T.amber : isBankSwitch ? T.blueDim : T.border;
return (
<div
ref={tapRef}
onClick={() => onClick?.(id)}
style={{
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2,
padding: "6px 3px 8px",
borderRadius: 8,
border: `1px solid ${isActive ? T.amber : isBankSwitch ? T.blueDim : T.border}`,
flex: 1, minWidth: 0, display: "flex", flexDirection: "column",
alignItems: "center", gap: 2, padding: "6px 3px 8px",
borderRadius: 8, border: `1px solid ${borderClr}`,
background: isActive && !isBypassSwitch
? `linear-gradient(180deg, ${T.amber + "15"} 0%, ${T.panel} 100%)`
? `linear-gradient(180deg, ${T.amber}15 0%, ${T.panel} 100%)`
: isBypassSwitch && !enabled
? `linear-gradient(180deg, ${T.red + "15"} 0%, ${T.panel} 100%)`
? `linear-gradient(180deg, ${T.red}15 0%, ${T.panel} 100%)`
: T.surface,
cursor: "pointer",
minHeight: 58,
position: "relative",
transition: "all .1s",
WebkitTapHighlightColor: "transparent",
cursor: "pointer", minHeight: 58, position: "relative",
transition: "all .1s", WebkitTapHighlightColor: "transparent",
boxShadow: isActive && !isBypassSwitch
? `0 0 8px ${T.amber}33, inset 0 0 0 1px ${T.amber}22`
: isBypassSwitch && !enabled
? `0 0 8px ${T.red}33`
: "none",
: isBypassSwitch && !enabled ? `0 0 8px ${T.red}33` : "none",
}}
>
{/* ── Scribble strip — LCD-like display ── */}
{/* Scribble strip */}
<div style={{
width: "100%",
background: "#0A0A10",
width: "100%", background: "#0A0A10",
border: `1px solid ${isActive ? T.amber + "66" : T.border}`,
borderRadius: 3,
padding: "2px 2px",
textAlign: "center",
minHeight: 18,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 3, padding: "2px 2px", textAlign: "center",
minHeight: 18, display: "flex", alignItems: "center", justifyContent: "center",
boxShadow: isActive ? `inset 0 0 4px ${T.amber}22` : "none",
}}>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 7,
fontWeight: 700,
fontFamily: "'JetBrains Mono', monospace", fontSize: 7, fontWeight: 700,
letterSpacing: ".02em",
color: isActive ? T.amber : (isBypassSwitch && !enabled ? T.red : T.textPrimary),
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "100%",
lineHeight: 1.1,
}}>
{label}
</span>
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
maxWidth: "100%", lineHeight: 1.1,
}}>{label}</span>
</div>
{/* ── Main label ── */}
{/* Main label */}
<div style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: isBankSwitch ? 10 : 9,
fontFamily: "'JetBrains Mono', monospace", fontSize: isBankSwitch ? 10 : 9,
fontWeight: 700,
color: enabled !== false ? T.textPrimary : T.textDim,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "100%",
lineHeight: 1.2,
marginTop: 1,
}}>
{mainLabel || label || type || id || "FX"}
</div>
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
maxWidth: "100%", lineHeight: 1.2, marginTop: 1,
}}>{mainLabel || label || type || id || "FX"}</div>
{/* ── Sub label (bypassed state / param value) ── */}
{/* Sub label */}
{sub && (
<div style={{
fontSize: 7,
color: T.textDim,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "100%",
lineHeight: 1,
}}>
{sub}
</div>
fontSize: 7, color: T.textDim,
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
maxWidth: "100%", lineHeight: 1,
}}>{sub}</div>
)}
{/* ── Status LED ── */}
{/* LED */}
{!isBankSwitch && (
<div style={{
position: "absolute",
top: 3,
right: 3,
width: 4,
height: 4,
position: "absolute", top: 3, right: 3, width: 4, height: 4,
borderRadius: "50%",
background: enabled !== false ? T.green : T.textDim,
boxShadow: enabled !== false ? `0 0 5px ${T.green}` : "none",
@@ -152,22 +115,13 @@ function Footswitch({ id, label, mainLabel, type, sub, active, enabled, onClick,
}} />
)}
{/* ── Bank direction indicator ── */}
{/* Bank indicator */}
{isBankSwitch && (
<div style={{
position: "absolute",
top: 3,
right: 3,
fontSize: 6,
padding: "1px 3px",
borderRadius: 2,
background: `${T.blueDim}88`,
color: T.blue,
fontWeight: 700,
lineHeight: 1,
}}>
{bankDir === "up" ? "▲" : "▼"}
</div>
position: "absolute", top: 3, right: 3, fontSize: 6,
padding: "1px 3px", borderRadius: 2,
background: `${T.blueDim}88`, color: T.blue, fontWeight: 700, lineHeight: 1,
}}>{bankDir === "up" ? "▲" : "▼"}</div>
)}
</div>
);
@@ -175,74 +129,76 @@ function Footswitch({ id, label, mainLabel, type, sub, active, enabled, onClick,
// ── Footswitch Bar ──
export default function FootswitchBar({
blocks,
mode = "stomp",
blocks = [],
selectedBlockId,
currentBank = "A",
presets = [],
currentPreset,
snapshots = [],
currentSnapshot,
onSelectBlock,
onToggleBlock,
onBankUp,
onBankDown,
onGlobalBypass,
globalBypass,
onPresetClick,
onSnapshotClick,
}) {
// Build items: [bank_up, up_to_4_blocks, BYPASS, bank_down]
// Always reserve slots for BYPASS and bank controls by capping block count
const MAX_BLOCK_SWITCHES = 4;
let items = [];
const blockSwitches = (blocks || []).slice(0, MAX_BLOCK_SWITCHES).map(b => {
// Short scribble strip label — abbreviated type like real pedals
const shortLabel = {
gate: "GATE", overdrive: "OD", distortion: "DST", fuzz: "FUZZ",
chorus: "CHO", flanger: "FLN", phaser: "PHA", tremolo: "TRE",
delay: "DLY", echo: "ECH", reverb: "REV",
compressor: "CMP", eq: "EQ", cabinet: "CAB", ir: "IR", nam: "NAM",
}[(b.type || "").toLowerCase()] || (b.type || "FX").toUpperCase();
return {
id: b.id,
label: shortLabel,
if (mode === "stomp" || mode === "stomp-b") {
// Stomp mode: up to 6 block footswitches + global bypass
const maxBlocks = blocks.length > 5 ? 6 : Math.max(4, blocks.length);
const blockSwitches = blocks.slice(0, maxBlocks).map(b => ({
id: b.id, type: "block",
label: getShortLabel(b.type),
mainLabel: b.name || b.type || "FX",
type: b.type,
sub: b.bypassed ? "BYPASSED" : "",
enabled: !b.bypassed,
};
});
const items = [
{
id: "bank_up",
type: "bank",
label: "BANK ▲",
mainLabel: `${currentBank} · 01`,
bankDir: "up",
enabled: true,
},
...blockSwitches,
{
id: "bypass",
type: "bypass",
label: globalBypass ? "MUTED" : "BYPASS",
mainLabel: "GLOBAL",
sub: globalBypass ? "" : "tap to mute",
enabled: !globalBypass,
},
{
id: "bank_down",
type: "bank",
label: "BANK ▼",
mainLabel: `${currentBank} · 01`,
bankDir: "down",
enabled: true,
},
];
}));
// Fill remaining with empty slots
while (blockSwitches.length < 5) {
blockSwitches.push({ id: "empty_" + blockSwitches.length, type: "empty", label: "—", mainLabel: "Empty", enabled: false });
}
items = [
...blockSwitches.slice(0, 5),
{ id: "bypass", type: "bypass", label: globalBypass ? "MUTED" : "BYPASS", mainLabel: "GLOBAL", sub: globalBypass ? "" : "tap to mute", enabled: !globalBypass },
];
} else if (mode === "preset") {
// Preset mode: bank up/down + 4 presets
items = [
{ id: "bank_up", type: "bank", label: "BANK ▲", mainLabel: `${currentBank} · ${currentPreset}`, bankDir: "up", enabled: true },
...presets.slice(0, 4).map((p, i) => ({
id: "preset_" + i, type: "preset",
label: `0${p.num || (i+1)}`,
mainLabel: p.name || `P${i+1}`,
sub: p.num === currentPreset ? "ACTIVE" : "",
enabled: true,
})),
{ id: "bank_down", type: "bank", label: "BANK ▼", mainLabel: `${currentBank} · ${currentPreset}`, bankDir: "down", enabled: true },
];
} else if (mode === "snapshot") {
// Snapshot mode (placeholder for future snapshot feature)
items = [
...snapshots.slice(0, 6).map((s, i) => ({
id: "snap_" + (s.num || i), type: "snapshot",
label: `SNAP ${s.num || (i+1)}`,
mainLabel: s.name || `Snapshot ${s.num || (i+1)}`,
sub: (s.num || i+1) === currentSnapshot ? "ACTIVE" : "",
enabled: true,
})),
];
while (items.length < 6) {
items.push({ id: "snap_empty_" + items.length, type: "empty", label: "—", mainLabel: "Empty", enabled: false });
}
}
return (
<div style={{
display: "flex",
gap: 4,
padding: "6px 8px 10px",
background: T.bg,
borderTop: `1px solid ${T.border}`,
flexShrink: 0,
display: "flex", gap: 4, padding: "6px 8px 10px",
background: T.bg, borderTop: `1px solid ${T.border}`, flexShrink: 0,
}}>
{items.map(item => (
<Footswitch
@@ -252,7 +208,7 @@ export default function FootswitchBar({
mainLabel={item.mainLabel}
type={item.type}
sub={item.sub}
active={item.id === selectedBlockId || (item.type === "bypass" && globalBypass)}
active={item.id === selectedBlockId || (item.type === "preset" && item.sub === "ACTIVE") || (item.type === "snapshot" && item.sub === "ACTIVE")}
enabled={item.enabled}
isBank={item.type === "bank"}
bankDir={item.bankDir}
@@ -260,10 +216,12 @@ export default function FootswitchBar({
if (id === "bank_up") onBankUp?.();
else if (id === "bank_down") onBankDown?.();
else if (id === "bypass") onGlobalBypass?.();
else onSelectBlock?.(id);
else if (id?.startsWith("preset_")) onPresetClick?.(id.replace("preset_", ""));
else if (id?.startsWith("snap_")) onSnapshotClick?.(id.replace("snap_", ""));
else if (mode === "stomp" || mode === "stomp-b") onSelectBlock?.(id);
}}
onDoubleClick={(id) => {
if (id !== "bank_up" && id !== "bank_down" && id !== "bypass") {
if (id !== "bank_up" && id !== "bank_down" && id !== "bypass" && (mode === "stomp" || mode === "stomp-b")) {
onToggleBlock?.(id);
}
}}
+407
View File
@@ -0,0 +1,407 @@
import { useState, useEffect, useRef, useCallback } from "react";
// ── Design tokens (mirrored) ──────────────────────────────────
const T = {
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8",
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6",
textSec: "#8888A0", textDim: "#444458",
};
// ── String labels matching _STRING_NAMES ──────────────────────
const STRING_LABELS = [
{ num: 6, name: "E", freq: 82.41, color: "#C84040" }, // Low E
{ num: 5, name: "A", freq: 110.0, color: "#D07030" }, // A
{ num: 4, name: "D", freq: 146.83, color: "#E8A030" }, // D
{ num: 3, name: "G", freq: 196.0, color: "#60A0E0" }, // G
{ num: 2, name: "B", freq: 246.94, color: "#3A7BA8" }, // B
{ num: 1, name: "e", freq: 329.63, color: "#50C080" }, // High e
];
// ── Note names for reference ─────────────────────────────────
const NOTE_FLAT = ["C","Db","D","Eb","E","F","Gb","G","Ab","A","Bb","B"];
// ── Mock tuner data generator (when pedal offline) ───────────
const MOCK_NOTES = [
{ note: "E2", freq: 82.41 }, { note: "F2", freq: 87.31 },
{ note: "G2", freq: 98.0 }, { note: "A2", freq: 110.0 },
{ note: "B2", freq: 123.47 }, { note: "C3", freq: 130.81 },
{ note: "D3", freq: 146.83 }, { note: "E3", freq: 164.81 },
{ note: "F3", freq: 174.61 }, { note: "G3", freq: 196.0 },
{ note: "A3", freq: 220.0 }, { note: "B3", freq: 246.94 },
{ note: "C4", freq: 261.63 }, { note: "D4", freq: 293.66 },
{ note: "E4", freq: 329.63 },
];
function getMockTunerData() {
// Simulate a slowly wandering note
const t = Date.now() / 1000;
const idx = Math.floor((t * 0.3) % MOCK_NOTES.length);
const n = MOCK_NOTES[idx];
const wobble = Math.sin(t * 2.5) * 15; // cent wobble
const confidence = 0.6 + Math.sin(t * 0.7) * 0.25;
// Guess string
let string = -1;
for (let si = 0; si < STRING_LABELS.length; si++) {
if (Math.abs(n.freq - STRING_LABELS[si].freq) / STRING_LABELS[si].freq < 0.2) {
string = STRING_LABELS[si].num;
break;
}
}
return {
note: n.note,
cents: Math.round(wobble),
string,
confidence: Math.max(0, Math.min(1, confidence)),
frequency: n.freq + wobble / 10,
};
}
// ── Tuner Screen ──────────────────────────────────────────────
export default function TunerScreen({ tunerData, connected, onExit }) {
const [data, setData] = useState(() => ({
note: "--",
cents: 0,
string: -1,
confidence: 0,
frequency: 0,
}));
// Fast update from external data or mock
useEffect(() => {
if (connected && tunerData) {
setData({
note: tunerData.tuner_note || "--",
cents: tunerData.tuner_cents ?? 0,
string: tunerData.tuner_string ?? -1,
confidence: tunerData.tuner_confidence ?? 0,
frequency: tunerData.tuner_frequency ?? 0,
});
}
}, [connected, tunerData]);
// Mock animation when offline or no data
useEffect(() => {
if (connected && tunerData?.tuner_enabled) return;
const id = setInterval(() => {
setData(getMockTunerData());
}, 200);
return () => clearInterval(id);
}, [connected, tunerData]);
// Fast polling for tuner pitch data when enabled
useEffect(() => {
if (!connected) return;
if (!tunerData?.tuner_enabled) return;
let cancelled = false;
const poll = async () => {
try {
const res = await fetch("/api/tuner/pitch");
if (!cancelled && res.ok) {
const d = await res.json();
setData({
note: d.note || "--",
cents: d.cents ?? 0,
string: d.string ?? -1,
confidence: d.confidence ?? 0,
frequency: d.frequency ?? 0,
});
}
} catch {}
};
poll(); // Initial fetch
const id = setInterval(poll, 80); // ~12.5 Hz for smooth needle
return () => { cancelled = true; clearInterval(id); };
}, [connected, tunerData?.tuner_enabled]);
// ── Calculate needle angle from cents ──
const cents = data.cents;
const confidence = data.confidence;
// Needle angle: -50 to +50 degrees for -50 to +50 cents
const needleAngle = Math.max(-50, Math.min(50, cents));
const needlePct = (needleAngle + 50) / 100; // 0.0 to 1.0
const isInTune = Math.abs(cents) <= 3;
const isClose = Math.abs(cents) <= 10;
// ── String indicators ──
const stringMatch = data.string;
// ── Cent display segments ──
const centSegments = [];
for (let i = -50; i <= 50; i += 5) {
centSegments.push(i);
}
// Determine note color (green = in tune, amber = close, red = far)
const noteColor = isInTune
? T.green
: isClose
? T.amber
: T.textPrimary;
return (
<div style={{
display: "flex",
flexDirection: "column",
height: "100%",
background: T.bg,
overflow: "hidden",
}}>
{/* ── Header ── */}
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "6px 12px",
background: T.panel,
borderBottom: `1px solid ${T.border}`,
flexShrink: 0,
}}>
<div style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: ".12em",
textTransform: "uppercase",
color: isInTune ? T.green : T.amber,
}}>
{isInTune ? "● In Tune" : "◌ Tuner"}
</div>
<button
onClick={onExit}
style={{
padding: "6px 14px",
borderRadius: 6,
background: T.surface,
border: `1px solid ${T.border}`,
color: T.textPrimary,
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
}}
>
Exit Tuner
</button>
</div>
{/* ── Main tuner area ── */}
<div style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 6,
padding: "8px 16px",
}}>
{/* Note name — very large */}
<div style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 72,
fontWeight: 700,
color: noteColor,
textShadow: isInTune
? `0 0 30px ${T.green}44`
: isClose
? `0 0 20px ${T.amber}33`
: "none",
lineHeight: 1,
letterSpacing: ".02em",
transition: "color .08s, text-shadow .08s",
minHeight: 76,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
{data.note}
</div>
{/* Cent deviation — needle display */}
<div style={{
width: "100%",
maxWidth: 320,
height: 60,
position: "relative",
marginTop: 4,
}}>
{/* Cent tick marks */}
<div style={{
position: "absolute",
bottom: 16,
left: 0,
right: 0,
height: 16,
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
padding: "0 10px",
}}>
{centSegments.map((c, i) => {
const isMid = c === 0;
const isMajor = c % 10 === 0;
return (
<div key={i} style={{
width: isMid ? 3 : (isMajor ? 2 : 1),
height: isMid ? 16 : (isMajor ? 10 : 5),
background: isMid ? T.amber : T.border,
borderRadius: 1,
flexShrink: 0,
}} />
);
})}
</div>
{/* Cent labels */}
<div style={{
position: "absolute",
bottom: 2,
left: 0,
right: 0,
display: "flex",
justifyContent: "space-between",
padding: "0 6px",
fontSize: 7,
color: T.textDim,
fontFamily: "'JetBrains Mono', monospace",
}}>
<span>-50¢</span>
<span>-25¢</span>
<span>0¢</span>
<span>+25¢</span>
<span>+50¢</span>
</div>
{/* Needle */}
<div style={{
position: "absolute",
bottom: 16,
left: `calc(10px + ${needlePct} * (100% - 20px))`,
transform: "translateX(-50%)",
transition: "left .04s ease-out",
}}>
{/* Needle line */}
<div style={{
width: 2,
height: 44,
background: isInTune ? T.green : (isClose ? T.amber : T.textPrimary),
borderRadius: 1,
margin: "0 auto",
boxShadow: isInTune
? `0 0 8px ${T.green}`
: `0 0 4px ${isClose ? T.amber : "transparent"}`,
}} />
{/* Needle dot */}
<div style={{
width: 8,
height: 8,
borderRadius: "50%",
background: isInTune ? T.green : (isClose ? T.amber : T.textPrimary),
margin: "-1px auto 0",
boxShadow: isInTune
? `0 0 12px ${T.green}`
: `0 0 6px ${isClose ? T.amber : "transparent"}`,
}} />
</div>
</div>
{/* ── Cent readout ── */}
<div style={{
fontSize: 13,
fontFamily: "'JetBrains Mono', monospace",
color: noteColor,
letterSpacing: ".04em",
minHeight: 18,
}}>
{cents > 0 ? `+${cents}¢` : `${cents}¢`}
{isInTune && <span style={{color: T.green}}> </span>}
</div>
</div>
{/* ── String indicator bar ── */}
<div style={{
display: "flex",
gap: 6,
padding: "8px 16px 12px",
justifyContent: "center",
borderTop: `1px solid ${T.border}`,
flexShrink: 0,
}}>
{STRING_LABELS.map(s => {
const isActive = s.num === stringMatch;
return (
<div
key={s.num}
style={{
width: 40,
height: 40,
borderRadius: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: isActive ? `${s.color}22` : T.surface,
border: `2px solid ${isActive ? s.color : T.border}`,
transition: "all .1s",
boxShadow: isActive ? `0 0 12px ${s.color}44` : "none",
}}
>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 16,
fontWeight: 700,
color: isActive ? s.color : T.textDim,
lineHeight: 1,
}}>
{s.name}
</span>
<span style={{
fontSize: 8,
color: isActive ? s.color : T.textDim,
opacity: 0.7,
lineHeight: 1,
}}>
{s.num}
</span>
</div>
);
})}
</div>
{/* ── Confidence bar ── */}
<div style={{
padding: "0 16px 8px",
display: "flex",
alignItems: "center",
gap: 8,
flexShrink: 0,
}}>
<span style={{ fontSize: 8, color: T.textDim, fontFamily: "'JetBrains Mono', monospace" }}>
SIG
</span>
<div style={{
flex: 1,
height: 3,
borderRadius: 2,
background: T.border,
overflow: "hidden",
}}>
<div style={{
height: "100%",
width: `${Math.max(0, Math.min(100, confidence * 100))}%`,
background: confidence > 0.7 ? T.green : (confidence > 0.3 ? T.amber : T.red),
borderRadius: 2,
transition: "width .08s",
}} />
</div>
<span style={{
fontSize: 8,
color: T.textDim,
fontFamily: "'JetBrains Mono', monospace",
minWidth: 20,
textAlign: "right",
}}>
{Math.round(confidence * 100)}%
</span>
</div>
</div>
);
}