PEDAL-UI: Footswitch modes (stomp/preset) + mode selector in status bar + SVG fx icons from pipedal project
This commit is contained in:
+239
-35
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user