import { useState, useRef, useEffect, useCallback } 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", }; const API = window.location.origin; const WS_URL = API.replace(/^http/, 'ws') + '/ws'; // ── Channel state (module-level, synced from localStorage) ───── let _channel = 'guitar'; try { const saved = localStorage.getItem('pedal_channel'); if (saved === 'guitar' || saved === 'bass') _channel = saved; } catch {} export function getChannel() { return _channel; } export function setChannel(ch) { _channel = ch; } function _ch(url) { const c = _channel; if (!c) return url; return url + (url.includes('?') ? '&' : '?') + 'channel=' + encodeURIComponent(c); } // ── API helpers ──────────────────────────────────────────────── async function apiGet(path) { const r = await fetch(`${API}${_ch(path)}`); if (!r.ok) throw new Error(`${path} => ${r.status}`); return r.json(); } async function apiPost(path, data) { const r = await fetch(`${API}${_ch(path)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: data != null ? JSON.stringify(data) : undefined, }); if (!r.ok) throw new Error(`${path} => ${r.status}`); return r.json(); } async function apiPut(path, data) { const r = await fetch(`${API}${_ch(path)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (!r.ok) throw new Error(`${path} => ${r.status}`); return r.json(); } async function apiDelete(path) { const r = await fetch(`${API}${_ch(path)}`, { method: 'DELETE' }); if (!r.ok) throw new Error(`${path} => ${r.status}`); return r.json(); } async function apiUpload(path, file) { const fd = new FormData(); fd.append('file', file); const r = await fetch(`${API}${_ch(path)}`, { method: 'POST', body: fd }); if (!r.ok) throw new Error(`${path} => ${r.status}`); return r.json(); } // ── usePedalState hook ───────────────────────────────────────── function usePedalState() { const [state, setState] = useState(null); const [connected, setConnected] = useState(false); const wsRef = useRef(null); const pollRef = useRef(null); useEffect(() => { // Fetch initial state apiGet('/api/state').then(s => { setState(s); setConnected(true); }).catch(() => { setConnected(false); }); // WebSocket for real-time updates let reconnectTimer; function connect() { try { const ws = new WebSocket(WS_URL); ws.onmessage = (e) => { try { const msg = JSON.parse(e.data); if (msg.type === 'connected' || msg.type === 'state') { setState(msg.state); setConnected(true); } if (msg.type === 'preset_changed' && msg.state) setState(msg.state); if (msg.type === 'bypass_changed') setState(prev => prev ? { ...prev, bypass: msg.bypass } : prev); if (msg.type === 'tuner_changed') setState(prev => prev ? { ...prev, tuner_enabled: msg.tuner_enabled } : prev); if (msg.type === 'routing_changed') setState(prev => prev ? { ...prev, routing_mode: msg.routing_mode, routing_breakpoint: msg.routing_breakpoint } : prev); if (msg.type === 'channel_mode_changed') setState(prev => prev ? { ...prev, channel_mode: msg.channel_mode } : prev); if (msg.type === 'model_loaded' || msg.type === 'ir_loaded') { apiGet('/api/state').then(s => setState(s)); } if (msg.type === 'snapshot_recalled' && msg.state) { setState(msg.state); } if (msg.type === 'snapshot_saved') { // Refresh state to pick up current_snapshot apiGet('/api/state').then(s => setState(s)); } } catch (e) { /* ignore parse errors */ } }; ws.onclose = () => { setConnected(false); reconnectTimer = setTimeout(connect, 3000); }; ws.onerror = () => ws.close(); wsRef.current = ws; } catch (e) { reconnectTimer = setTimeout(connect, 3000); } } connect(); // Polling fallback every 5s pollRef.current = setInterval(() => { apiGet('/api/state').then(s => { setState(s); setConnected(true); }).catch(() => {}); }, 5000); return () => { clearInterval(pollRef.current); clearTimeout(reconnectTimer); if (wsRef.current) wsRef.current.close(); }; }, []); const refresh = useCallback(() => { apiGet('/api/state').then(s => { setState(s); setConnected(true); }).catch(() => {}); }, []); return { state, connected, refresh }; } const css = ` @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@300;400;500;600&display=swap'); * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; } body { background: ${T.bg}; color: ${T.textPrimary}; font-family: 'Inter', sans-serif; user-select: none; } :root { --amber: ${T.amber}; --blue: ${T.blue}; --green: ${T.green}; } .mono { font-family: 'JetBrains Mono', monospace; } ::-webkit-scrollbar { width: 4px; height: 4px; } ::-webkit-scrollbar-track { background: ${T.panel}; } ::-webkit-scrollbar-thumb { background: ${T.border}; border-radius: 2px; } input, textarea, select { font-family: inherit; } .knob-wrap { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; } .knob { border-radius: 50%; position: relative; touch-action: none; } .knob-inner { border-radius: 50%; width: 100%; height: 100%; position: relative; background: radial-gradient(circle at 35% 30%, #3A3A48, #1A1A22); box-shadow: 0 2px 8px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.07); } .knob-dot { position: absolute; width: 3px; border-radius: 2px; background: var(--amber); left: 50%; transform-origin: bottom center; box-shadow: 0 0 6px var(--amber); } .knob-ring { position: absolute; inset: -5px; border-radius: 50%; } .knob-label { font-size: 10px; color: ${T.textSec}; letter-spacing: .06em; text-transform: uppercase; text-align: center; } .knob-value { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: ${T.amber}; } .toggle { display: flex; background: ${T.surface}; border-radius: 6px; border: 1px solid ${T.border}; overflow: hidden; } .toggle-btn { flex: 1; padding: 7px 12px; font-size: 12px; font-weight: 500; letter-spacing: .04em; background: none; border: none; color: ${T.textSec}; cursor: pointer; transition: all .15s; } .toggle-btn.active { background: ${T.amber}; color: #000; } .vu-bar { display: flex; gap: 2px; align-items: flex-end; height: 100%; } .vu-seg { width: 4px; border-radius: 1px; transition: opacity .05s; } .fader-track { width: 4px; border-radius: 2px; background: ${T.border}; position: relative; cursor: pointer; touch-action: none; } .fader-fill { width: 100%; border-radius: 2px; position: absolute; bottom: 0; transition: height .05s; } .fader-thumb { width: 28px; height: 10px; border-radius: 3px; position: absolute; left: 50%; transform: translateX(-50%); background: linear-gradient(180deg, #3A3A4A, #252530); border: 1px solid #4A4A5A; box-shadow: 0 2px 4px rgba(0,0,0,.5); cursor: grab; touch-action: none; } .fader-thumb::after { content: ''; position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); width: 16px; height: 2px; border-radius: 1px; background: rgba(255,255,255,.15); box-shadow: 0 3px 0 rgba(255,255,255,.15), 0 -3px 0 rgba(255,255,255,.15); } .tabbar { display: flex; background: ${T.panel}; border-bottom: 1px solid ${T.border}; flex-shrink: 0; } .tab { flex: 1; padding: 10px 4px; font-size: 10px; font-weight: 500; letter-spacing: .04em; text-align: center; text-transform: uppercase; color: ${T.textDim}; cursor: pointer; border-bottom: 2px solid transparent; transition: all .15s; } .tab.active { color: ${T.amber}; border-bottom-color: ${T.amber}; } .card { background: ${T.panel}; border: 1px solid ${T.border}; border-radius: 10px; padding: 16px; } .card-sm { background: ${T.surface}; border: 1px solid ${T.border}; border-radius: 8px; padding: 12px; } .btn { padding: 10px 18px; border-radius: 7px; font-size: 13px; font-weight: 600; letter-spacing: .04em; border: none; cursor: pointer; transition: all .12s; } .btn-primary { background: ${T.amber}; color: #000; } .btn-primary:active { filter: brightness(.85); transform: scale(.97); } .btn-ghost { background: ${T.surface}; color: ${T.textPrimary}; border: 1px solid ${T.border}; } .btn-ghost:active { background: ${T.border}; } .btn-danger { background: rgba(200,64,64,.2); color: ${T.red}; border: 1px solid rgba(200,64,64,.3); } .btn-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; background: ${T.surface}; border: 1px solid ${T.border}; cursor: pointer; font-size: 16px; transition: all .12s; } .btn-icon:active { transform: scale(.93); } .btn-icon.active { background: rgba(232,160,48,.15); border-color: ${T.amber}; color: ${T.amber}; } .btn-sm { padding: 6px 12px; font-size: 12px; } .fx-block { background: ${T.panel}; border: 1px solid ${T.border}; border-radius: 10px; overflow: hidden; transition: border-color .15s; } .fx-block.active { border-color: ${T.amber}; } .fx-block.bypassed { opacity: .45; } .fx-header { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-bottom: 1px solid ${T.border}; background: ${T.surface}; cursor: pointer; } .fx-body { padding: 12px; display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; } .bypass-led { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .led-on { background: ${T.green}; box-shadow: 0 0 8px ${T.green}; } .led-off { background: ${T.textDim}; } .waveform-wrap { position: relative; overflow: hidden; border-radius: 6px; background: #0F0F14; } .waveform-canvas { display: block; } .playhead { position: absolute; top: 0; bottom: 0; width: 1.5px; background: ${T.amber}; box-shadow: 0 0 8px ${T.amber}; pointer-events: none; transition: left .08s linear; } .nam-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; background: rgba(58,123,168,.15); border: 1px solid rgba(58,123,168,.35); border-radius: 5px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: ${T.blue}; } .section-label { font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: ${T.textDim}; font-weight: 600; margin-bottom: 10px; } .preset-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 8px; cursor: pointer; transition: background .12s; border: 1px solid transparent; } .preset-row:active, .preset-row.active { background: rgba(232,160,48,.08); border-color: rgba(232,160,48,.25); } .transport-btn { width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 16px; cursor: pointer; border: 2px solid ${T.border}; background: ${T.surface}; transition: all .12s; } .transport-btn:active { transform: scale(.92); } .transport-btn.rec { border-color: ${T.red}; background: rgba(200,64,64,.15); } .transport-btn.play { border-color: ${T.green}; background: rgba(58,184,122,.15); } .badge { display: inline-flex; align-items: center; justify-content: center; padding: 2px 7px; border-radius: 4px; font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } .badge-amber { background: rgba(232,160,48,.2); color: ${T.amber}; } .badge-green { background: rgba(58,184,122,.2); color: ${T.green}; } .badge-blue { background: rgba(58,123,168,.2); color: ${T.blue}; } .badge-red { background: rgba(200,64,64,.2); color: ${T.red}; } .screen-header { padding: 12px 14px 8px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid ${T.border}; flex-shrink: 0; } .screen-title { font-size: 13px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: ${T.textSec}; } .screen-body { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 12px; } /* Settings lists */ .setting-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid ${T.border}; } .setting-label { font-size: 13px; color: ${T.textPrimary}; } .setting-desc { font-size: 11px; color: ${T.textDim}; margin-top: 2px; } .switch { width: 40px; height: 22px; border-radius: 11px; background: ${T.border}; cursor: pointer; position: relative; transition: background .2s; flex-shrink: 0; } .switch.on { background: ${T.green}; } .switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: white; transition: transform .15s; } .switch.on::after { transform: translateX(18px); } .search-bar { display: flex; background: ${T.surface}; border-radius: 7px; border: 1px solid ${T.border}; padding: 8px 12px; gap: 8px; align-items: center; } .search-bar input { background: none; border: none; outline: none; color: ${T.textPrimary}; font-size: 13px; flex: 1; } .search-bar input::placeholder { color: ${T.textDim}; } .file-drop { border: 2px dashed ${T.border}; border-radius: 8px; padding: 20px; text-align: center; cursor: pointer; transition: all .15s; font-size: 13px; color: ${T.textDim}; } .file-drop:hover, .file-drop.dragover { border-color: ${T.amber}; color: ${T.amber}; } .loader { width: 20px; height: 20px; border: 2px solid ${T.border}; border-top-color: ${T.amber}; border-radius: 50%; animation: spin .6s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .slide-panel { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 100; display: flex; align-items: flex-end; justify-content: center; } .slide-panel-inner { background: ${T.bg}; max-width: 440px; width: 100%; max-height: 85vh; border-radius: 16px 16px 0 0; overflow: hidden; display: flex; flex-direction: column; } .slide-panel-handle { width: 36px; height: 4px; border-radius: 2px; background: ${T.border}; margin: 8px auto; flex-shrink: 0; } /* Snapshot slots */ .snap-grid { display: flex; gap: 6px; overflow-x: auto; padding: 4px 0; } .snap-slot { min-width: 60px; flex-shrink: 0; border-radius: 8px; border: 1.5px solid ${T.border}; background: ${T.surface}; cursor: pointer; text-align: center; padding: 8px 4px; transition: all .12s; touch-action: manipulation; position: relative; } .snap-slot:active { transform: scale(.95); } .snap-slot.filled { border-color: ${T.amberDim}; } .snap-slot.active { border-color: ${T.amber}; background: rgba(232,160,48,.08); } .snap-slot .snap-num { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 700; color: ${T.textDim}; } .snap-slot.filled .snap-num { color: ${T.amber}; } .snap-slot.active .snap-num { color: ${T.amber}; text-shadow: 0 0 8px ${T.amber}; } .snap-slot .snap-name { font-size: 9px; color: ${T.textDim}; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .snap-slot.filled .snap-name { color: ${T.textSec}; } .snap-slot.active .snap-name { color: ${T.amber}; } .snap-slot .snap-empty { font-size: 9px; color: ${T.textDim}; margin-top: 3px; font-style: italic; } .snap-slot .snap-save-indicator { position: absolute; top: -3px; right: -3px; width: 10px; height: 10px; border-radius: 50%; background: ${T.green}; box-shadow: 0 0 6px ${T.green}; animation: snap-pop .3s ease-out; } @keyframes snap-pop { 0% { transform: scale(2); opacity: 0; } 100% { transform: scale(1); opacity: 1; } } .snap-name-input { background: ${T.surface}; border: 1px solid ${T.amber}; border-radius: 4px; padding: 2px 6px; color: ${T.amber}; font-size: 10px; text-align: center; outline: none; font-family: 'Inter', sans-serif; width: 100%; } .snap-header-btn { cursor: pointer; border: none; background: none; color: ${T.textSec}; font-size: 16px; padding: 4px 6px; border-radius: 6px; transition: all .12s; display: flex; align-items: center; gap: 4px; } .snap-header-btn:hover { color: ${T.amber}; background: rgba(232,160,48,.1); } .snap-header-btn.active { color: ${T.amber}; background: rgba(232,160,48,.15); } .snap-header-num { font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700; color: ${T.amber}; min-width: 16px; text-align: center; } `; // ── Knob ────────────────────────────────────────────────────── function Knob({ label, value = 50, onChange, size = 52, color = T.amber, min = 0, max = 100 }) { const ref = useRef(null); const startRef = useRef(null); const norm = (value - min) / (max - min); const angle = -140 + norm * 280; const handleStart = (e) => { e.preventDefault(); const clientY = e.touches ? e.touches[0].clientY : e.clientY; startRef.current = { y: clientY, val: value }; const move = (ev) => { const cy = ev.touches ? ev.touches[0].clientY : ev.clientY; const delta = (startRef.current.y - cy) / 120; const next = Math.max(min, Math.min(max, startRef.current.val + delta * (max - min))); onChange?.(Math.round(next)); }; const up = () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); window.removeEventListener("touchmove", move); window.removeEventListener("touchend", up); }; window.addEventListener("mousemove", move); window.addEventListener("mouseup", up); window.addEventListener("touchmove", move, { passive: false }); window.addEventListener("touchend", up); }; const svgSize = size + 12; const cx = svgSize / 2, cy = svgSize / 2, r = size / 2 + 3; const endA = (angle - 90) * (Math.PI / 180); const ex = cx + r * Math.cos(endA), ey = cy + r * Math.sin(endA); return (
{norm > 0 && 0.5 ? 1 : 0} 1 ${ex} ${ey}`} fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" style={{ filter: `drop-shadow(0 0 3px ${color})` }} />}
{value}
{label}
); } // ── VU Meter ────────────────────────────────────────────────── function VUMeter({ level = 0, height = 60, vertical = true }) { const segs = 12; const active = Math.round((level / 100) * segs); const segColor = (i) => { if (i >= 10) return T.red; if (i >= 8) return "#E8C030"; return T.green; }; if (vertical) return (
{Array.from({ length: segs }).map((_, i) => (
))}
); return (
{Array.from({ length: segs }).map((_, i) => (
))}
); } // ── Waveform ────────────────────────────────────────────────── function Waveform({ width, height, color = T.amber, playPos = 0, seed = 1 }) { const canvasRef = useRef(null); useEffect(() => { const c = canvasRef.current; if (!c) return; const ctx = c.getContext("2d"); ctx.clearRect(0, 0, width, height); const mid = height / 2; ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.shadowColor = color; ctx.shadowBlur = 2; ctx.beginPath(); for (let x = 0; x < width; x++) { const t = x / width; const amp = (Math.sin(t * 31 * seed + seed) * 0.4 + Math.sin(t * 97 * seed) * 0.3 + Math.sin(t * 17 * seed + 2) * 0.2 + Math.random() * 0.1); const h = Math.abs(amp) * (mid * 0.85); ctx.moveTo(x, mid - h); ctx.lineTo(x, mid + h); } ctx.stroke(); ctx.globalAlpha = 0.12; ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(0, mid); for (let x = 0; x < width; x++) { const t = x / width; const amp = (Math.sin(t * 31 * seed + seed) * 0.4 + Math.sin(t * 97 * seed) * 0.3 + Math.sin(t * 17 * seed + 2) * 0.2); ctx.lineTo(x, mid - Math.abs(amp) * mid * 0.85); } ctx.lineTo(width, mid); ctx.closePath(); ctx.fill(); ctx.globalAlpha = 1; }, [width, height, color, seed]); return (
); } // ── Fader ────────────────────────────────────────────────────── function Fader({ value = 75, onChange, height = 120, label, color = T.amber }) { const trackRef = useRef(null); const handleStart = (e) => { e.preventDefault(); const move = (ev) => { const track = trackRef.current.getBoundingClientRect(); const cy = ev.touches ? ev.touches[0].clientY : ev.clientY; const pct = 1 - Math.max(0, Math.min(1, (cy - track.top) / track.height)); onChange?.(Math.round(pct * 100)); }; 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 thumbBottom = (value / 100) * (height - 10); return (
{value}
{label}
); } // ── FX Block ────────────────────────────────────────────────── function FXBlock({ title, type, active = true, bypassed = false, params = [], onToggle }) { const [open, setOpen] = useState(true); const [vals, setVals] = useState(params.map(p => p.default ?? p.default ?? 50)); return (
setOpen(o => !o)}>
{ e.stopPropagation(); onToggle?.(); }} /> {title} {type} {open ? "▾" : "▸"}
{open && (
{params.map((p, i) => ( setVals(prev => prev.map((x, j) => j === i ? v : x))} min={p.min ?? 0} max={p.max ?? 100} /> ))}
)}
); } // ── Signal Arrow Connector ────────────────────────────────── function SignalArrow({ active = true, variant = 'right', animated = true }) { const color = active ? T.green : T.textDim; if (variant === 'right') { return ( {active && animated && ( )} ); } if (variant === 'split') { return ( ); } if (variant === 'merge') { return ( ); } return null; } // ── Slide Panel ────────────────────────────────────────────── function SlidePanel({ children, onClose }) { return (
e.stopPropagation()}>
{children}
); } // ══════════════════════════════════════════════════════════════ // SCREENS // ══════════════════════════════════════════════════════════════ // 1. RIG SCREEN ──────────────────────────────────────────────── function RigScreen({ state, connected, refresh }) { const [volume, setVolume] = useState(state?.master_volume != null ? Math.round(state.master_volume * 100) : 80); // Optimistic local states so toggles respond immediately const [localBypass, setLocalBypass] = useState(null); const [localTuner, setLocalTuner] = useState(null); const bypass = localBypass ?? state?.bypass ?? false; const tuner = localTuner ?? state?.tuner_enabled ?? false; useEffect(() => { if (state?.master_volume != null) setVolume(Math.round(state.master_volume * 100)); }, [state?.master_volume]); // Sync optimistic states from server when they arrive useEffect(() => { if (state?.bypass !== undefined) setLocalBypass(null); if (state?.tuner_enabled !== undefined) setLocalTuner(null); }, [state?.bypass, state?.tuner_enabled]); const handleVolume = async (val) => { setVolume(val); try { await apiPut('/api/volume', { volume: val / 100 }); } catch (e) { /* ignore */ } }; const handleBypass = async () => { const next = !bypass; setLocalBypass(next); try { await apiPost('/api/bypass', { bypass: next }); } catch (e) { setLocalBypass(null); } }; const handleTuner = async () => { const next = !tuner; setLocalTuner(next); try { await apiPost('/api/tuner', { enabled: next }); } catch (e) { setLocalTuner(null); } }; // Real audio levels from the pipeline (0-100 scale, updated every poll cycle) const inputLevel = state?.input_level ?? 0; const outputLevel = state?.output_level ?? 0; return (
Active Rig
{state?.current_preset ? ( {state.current_preset.name} ) : ( No preset loaded )} {connected ? ( Connected ) : ( Offline )}
{/* Status row */}
Bypass
Tuner
Routing
{state?.routing_mode || "mono"}
{/* Tuner active indicator */} {tuner && (
🎵
TUNER ACTIVE
—————●—————
Tap switch again to exit tuner mode
)} {/* Volume */}
Master Volume
handleVolume(+e.target.value)} style={{ width: "100%", accentColor: T.amber }} />
{volume}%
{state?.nam_loaded ? "NAM: Active" : "NAM: —"}
{state?.ir_loaded ? "IR: Active" : "IR: —"}
{/* Signal chain */}
Signal Chain
{(() => { const chain = ["IN", state?.bypass ? "BYP" : (state?.nam_loaded ? "NAM" : "AMP"), state?.ir_loaded ? "IR" : "CAB", "OUT"]; const is4CM = state?.routing_mode === '4cm'; const bp = state?.routing_breakpoint || 7; const active = !state?.bypass; return chain.map((s, i, arr) => (
{is4CM && s === "AMP" && ( )} {is4CM && s === "CAB" && ( )}
{s} {is4CM && s === "AMP" && 4CM} {is4CM && s === "BYP" && 4CM}
{is4CM && s === "AMP" && (
LOOP
)} {i < arr.length - 1 && !(is4CM && s === "AMP") && }
)); })()}
{/* NAM + IR info */}
NAM Model
{state?.nam_model ? (
{state.nam_model}
) : (
Not loaded
)}
IR
{state?.ir_name ? (
{state.ir_name}
) : (
Not loaded
)}
{/* Quick links */}
); } // 2. FX CHAIN SCREEN ─────────────────────────────────────────── const FX_TYPE_LABELS = { noise_gate: "Noise Gate", compressor: "Compressor", boost: "Boost", overdrive: "Overdrive", distortion: "Distortion", fuzz: "Fuzz", eq: "EQ", chorus: "Chorus", flanger: "Flanger", phaser: "Phaser", tremolo: "Tremolo", vibrato: "Vibrato", delay: "Delay", reverb: "Reverb", volume: "Volume", nam_amp: "NAM Amp", ir_cab: "IR Cab", octaver: "Octaver", pitch_shifter: "Pitch Shifter", harmonizer: "Harmonizer", whammy: "Whammy", detune: "Detune", ring_modulator: "Ring Mod", auto_wah: "Auto Wah", envelope_filter: "Env Filter", rotary_speaker: "Rotary", uni_vibe: "Uni-Vibe", auto_pan: "Auto Pan", stereo_widener: "Stereo Widener", bitcrusher: "Bitcrusher", wavefolder: "Wavefolder", rectifier: "Rectifier", expander: "Expander", de_esser: "De-esser", transient_shaper: "Transient Shaper", parametric_eq: "Parametric EQ", high_pass_filter: "High Pass", low_pass_filter: "Low Pass", band_pass_filter: "Band Pass", notch_filter: "Notch Filter", formant_filter: "Formant Filter", ping_pong_delay: "Ping Pong Delay", multi_tap_delay: "Multi Tap", reverse_delay: "Reverse Delay", tape_echo: "Tape Echo", shimmer_reverb: "Shimmer Reverb", looper: "Looper", early_reflections: "Early Reflections", sidechain_compressor: "Sidechain Comp", }; function FXScreen({ state, connected }) { const [fxList, setFxList] = useState(null); const [loading, setLoading] = useState(true); const [showAdd, setShowAdd] = useState(false); const [paramSchemas, setParamSchemas] = useState({}); // Load block param schemas const loadParams = useCallback(async (fxType) => { if (paramSchemas[fxType]) return paramSchemas[fxType]; try { const data = await apiGet(`/api/block-params/${fxType}`); setParamSchemas(prev => ({ ...prev, [fxType]: data.params || [] })); return data.params || []; } catch (e) { return []; } }, [paramSchemas]); useEffect(() => { if (!connected || !state?.current_preset) { // Offline: show a simple empty state setFxList([]); setLoading(false); return; } // Load active preset's chain from presets API apiGet('/api/presets').then(async data => { const banks = data.banks || []; const cp = state.current_preset; const preset = banks[cp.bank]?.presets?.[cp.program]; if (preset?.chain) { const blocks = await Promise.all(preset.chain.map(async (b, i) => { const params = await loadParams(b.fx_type); return { id: i + 1, title: FX_TYPE_LABELS[b.fx_type] || b.fx_type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), type: b.fx_type, bypassed: b.bypass || !b.enabled, params: params, }; })); setFxList(blocks); } setLoading(false); }).catch(() => { setFxList([]); setLoading(false); }); }, [connected, state, loadParams]); const toggle = (id) => setFxList(prev => prev ? prev.map(f => f.id === id ? { ...f, bypassed: !f.bypassed } : f) : prev); const addBlock = async (fxType) => { setShowAdd(false); const params = await loadParams(fxType); setFxList(prev => [...(prev || []), { id: Date.now(), title: FX_TYPE_LABELS[fxType] || fxType, type: fxType, bypassed: false, params: params, }]); }; if (loading) return
; return (
FX Chain {(fxList?.length || 0) > 0 ? `(${fxList.length})` : ''}
{/* Signal path */} {fxList && fxList.length > 0 && (
{(() => { const is4CM = state?.routing_mode === '4cm'; const bp = is4CM ? (state?.routing_breakpoint || 7) : -1; const items = []; // Build chain items array with connector metadata items.push({ label: "IN", type: "io", idx: -1 }); fxList.forEach((f, idx) => { if (is4CM && idx === bp) { items.push({ label: null, type: "merge-bp", idx }); } items.push({ label: f.type.toUpperCase(), type: "fx", idx, bypassed: f.bypassed }); if (is4CM && idx === bp - 1) { items.push({ label: null, type: "split-bp", idx }); } }); items.push({ label: "OUT", type: "io", idx: -1 }); return items.map((item, i) => { if (item.type === 'split-bp') { return (
4CM
); } if (item.type === 'merge-bp') { // Already rendered as part of the split-bp composite — skip return null; } return (
{item.type === 'io' && (
{item.label}
)} {item.type === 'fx' && (
{item.label}
)} {/* Connector after this block */} {i < items.length - 1 && items[i+1]?.type !== 'split-bp' && items[i]?.type !== 'merge-bp' && ( )}
); }); })()}
)} {(!fxList || fxList.length === 0) ? (
No FX blocks in the chain.
) : ( fxList.map(f => ( toggle(f.id)} /> )) )}
{/* Add Block slide panel */} {showAdd && ( setShowAdd(false)}>
Add FX Block
{Object.entries(FX_TYPE_LABELS).map(([key, label]) => (
addBlock(key)} style={{ borderRadius: 6 }}> {key.split('_')[0]} {label} +
))}
)}
); } // 3. MODELS SCREEN ───────────────────────────────────────────── function ModelsScreen({ state, connected, refresh }) { const [models, setModels] = useState([]); const [current, setCurrent] = useState(null); const [loading, setLoading] = useState(true); const [searching, setSearching] = useState(false); const [searchQ, setSearchQ] = useState(''); const [searchResults, setSearchResults] = useState([]); const [showSearch, setShowSearch] = useState(false); const [uploading, setUploading] = useState(false); const fileRef = useRef(null); const loadModels = useCallback(async () => { if (!connected) { setLoading(false); return; } try { const data = await apiGet('/api/models'); setModels(data.models || []); setCurrent(data.current); } catch (e) { /* ignore */ } setLoading(false); }, [connected]); useEffect(() => { loadModels(); }, [loadModels]); const handleLoad = async (path) => { try { await apiPost('/api/models/load', { path }); await loadModels(); refresh(); } catch (e) { alert('Failed to load model'); } }; const handleUnload = async () => { try { await apiPost('/api/models/unload'); setCurrent(null); await loadModels(); refresh(); } catch (e) { alert('Failed to unload'); } }; const handleUpload = async (e) => { const file = e.target.files?.[0]; if (!file) return; setUploading(true); try { await apiUpload('/api/models/upload', file); await loadModels(); } catch (err) { alert('Upload failed'); } setUploading(false); }; const handleSearch = useCallback(async () => { if (!searchQ.trim()) return; setSearching(true); try { const data = await apiGet(`/api/models/tonedownload/search?q=${encodeURIComponent(searchQ)}`); setSearchResults(data.results || []); } catch (e) { alert('Search failed'); } setSearching(false); }, [searchQ]); const handleInstall = async (result) => { try { await apiPost('/api/models/tonedownload/install', { download_url: result.download_url, name: result.name || result.filename, id: result.id, size: result.size, pack_name: result.pack_name, display_name: result.name, }); await loadModels(); refresh(); } catch (e) { alert('Install failed'); } }; if (loading) return
; return (
NAM Models ({models.length})
{showSearch && (
🔍 setSearchQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} />
{searchResults.length > 0 && (
{searchResults.map(r => (
handleInstall(r)} style={{ borderBottom: `1px solid ${T.border}`, borderRadius: 0 }}>
{r.name}
{r.author} · {r.size_display} · {r.architecture}
))}
)}
)}
{current && (
Loaded Model
{current}
)} {models.length === 0 ? (
No NAM models found.
Upload a .nam file or search Tone3000.
) : ( models.map(m => (
{m.name}
{m.architecture} · {m.size_mb} MB
{m.loaded ? ( Loaded ) : ( )}
)) )}
); } // 4. IRS SCREEN ──────────────────────────────────────────────── function IRsScreen({ state, connected, refresh }) { const [irs, setIrs] = useState([]); const [current, setCurrent] = useState(null); const [loading, setLoading] = useState(true); const [searching, setSearching] = useState(false); const [searchQ, setSearchQ] = useState(''); const [searchResults, setSearchResults] = useState([]); const [showSearch, setShowSearch] = useState(false); const [uploading, setUploading] = useState(false); const fileRef = useRef(null); const loadIrs = useCallback(async () => { if (!connected) { setLoading(false); return; } try { const data = await apiGet('/api/irs'); setIrs(data.irs || []); setCurrent(data.current); } catch (e) { /* ignore */ } setLoading(false); }, [connected]); useEffect(() => { loadIrs(); }, [loadIrs]); const handleLoad = async (path) => { try { await apiPost('/api/irs/load', { path }); await loadIrs(); refresh(); } catch (e) { alert('Failed to load IR'); } }; const handleUnload = async () => { try { await apiPost('/api/irs/unload'); setCurrent(null); await loadIrs(); refresh(); } catch (e) { alert('Failed to unload'); } }; const handleUpload = async (e) => { const file = e.target.files?.[0]; if (!file) return; setUploading(true); try { await apiUpload('/api/irs/upload', file); await loadIrs(); } catch (err) { alert('Upload failed'); } setUploading(false); }; const handleSearch = useCallback(async () => { if (!searchQ.trim()) return; setSearching(true); try { const data = await apiGet(`/api/irs/tonedownload/search?q=${encodeURIComponent(searchQ)}`); setSearchResults(data.results || []); } catch (e) { alert('Search failed'); } setSearching(false); }, [searchQ]); const handleInstall = async (result) => { try { await apiPost('/api/irs/tonedownload/install', { download_url: result.download_url, name: result.name || result.filename, id: result.id, size: result.size, display_name: result.name, }); await loadIrs(); refresh(); } catch (e) { alert('Install failed'); } }; if (loading) return
; return (
IR Files ({irs.length})
{showSearch && (
🔍 setSearchQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} />
{searchResults.length > 0 && (
{searchResults.map(r => (
handleInstall(r)} style={{ borderBottom: `1px solid ${T.border}`, borderRadius: 0 }}>
{r.name}
{r.author} · {r.size_display}
))}
)}
)}
{current && (
Loaded IR
{current}
)} {irs.length === 0 ? (
No IR files found.
Upload a .wav file or search Tone3000.
) : ( irs.map(ir => (
{ir.name}
{ir.sample_rate} Hz · {ir.length_ms} ms · {ir.num_taps} taps
{ir.loaded ? ( Loaded ) : ( )}
)) )}
); } // 5. PRESETS SCREEN ──────────────────────────────────────────── function PresetsScreen({ state, connected, refresh }) { const [banks, setBanks] = useState([]); const [selectedBank, setSelectedBank] = useState(0); const [loading, setLoading] = useState(true); const [saveName, setSaveName] = useState(''); const [showSave, setShowSave] = useState(false); const [deleting, setDeleting] = useState(null); const loadPresets = useCallback(async () => { if (!connected) { setBanks([{ name: "Default", number: 0, presets: [ { name: "Edge of Breakup", bank: 0, program: 0 }, { name: "Modern High Gain", bank: 0, program: 1 }, null, null, ]}]); setLoading(false); return; } try { const data = await apiGet('/api/presets'); setBanks(data.banks || []); } catch (e) { /* ignore */ } setLoading(false); }, [connected]); useEffect(() => { loadPresets(); }, [loadPresets]); const handleActivate = async (bank, program) => { try { await apiPost(`/api/presets/${bank}/${program}/activate`); await loadPresets(); refresh(); } catch (e) { alert('Failed to load preset'); } }; const handleSave = async () => { if (!saveName.trim()) return; const currentBank = state?.current_preset?.bank ?? 0; const currentProgram = state?.current_preset?.program ?? 0; try { await apiPut(`/api/presets/${currentBank}/${currentProgram}`, { name: saveName }); setShowSave(false); await loadPresets(); } catch (e) { alert('Failed to save'); } }; const handleNewPreset = async () => { // Find the first empty slot, or create at next program number const bank = selectedBank; const bankData = banks[bank]; const presets = bankData?.presets || []; let nextProg = presets.findIndex(p => p == null); if (nextProg === -1) nextProg = presets.length; const name = `New Preset ${nextProg + 1}`; try { await apiPut(`/api/presets/${bank}/${nextProg}`, { name }); await loadPresets(); await apiPost(`/api/presets/${bank}/${nextProg}/activate`); refresh(); } catch (e) { alert('Failed to create preset'); } }; const handleDelete = async (bank, program) => { try { await apiDelete(`/api/presets/${bank}/${program}`); setDeleting(null); await loadPresets(); } catch (e) { alert('Failed to delete'); } }; if (loading) return
; const currentPreset = state?.current_preset; const bankData = banks[selectedBank]; const presets = bankData?.presets || []; return (
Presets
{/* Bank selector */} {banks.length > 1 && (
{banks.map((b, i) => ( ))}
)} {/* Current preset indicator */} {currentPreset && (
Current
{currentPreset.name} Bank {currentPreset.bank} · Prog {currentPreset.program}
)} {/* Preset list */} {presets.length === 0 ? (
No presets in this bank.
) : ( presets.map((p, i) => { if (!p) return null; const isActive = currentPreset?.bank === p.bank && currentPreset?.program === p.program; const isDeleting = deleting === `${p.bank}-${p.program}`; return (
!isDeleting && handleActivate(p.bank, p.program)}>
{p.program + 1}
{p.name}
Bank {p.bank} · Program {p.program}
{isActive && } {isDeleting ? (
) : ( )}
); }) )}
{/* Save dialog */} {showSave && ( setShowSave(false)}>
Save Preset
setSaveName(e.target.value)} autoFocus onKeyDown={e => e.key === 'Enter' && handleSave()} />
)}
); } // 6. SETTINGS SCREEN ─────────────────────────────────────────── function SettingsScreen({ state, connected, refresh }) { const [tab, setTab] = useState('wifi'); const [wifiNetworks, setWifiNetworks] = useState([]); const [btDevices, setBtDevices] = useState([]); const [btPaired, setBtPaired] = useState([]); const [wifiScanning, setWifiScanning] = useState(false); const [btScanning, setBtScanning] = useState(false); const [connectSsid, setConnectSsid] = useState(''); const [connectPw, setConnectPw] = useState(''); const [showConnect, setShowConnect] = useState(null); const [routingMode, setRoutingMode] = useState(state?.routing_mode || 'mono'); const [routingBp, setRoutingBp] = useState(state?.routing_breakpoint || 7); const [channelMode, setChannelMode] = useState(state?.channel_mode || 'dual-mono'); useEffect(() => { if (state?.routing_mode) setRoutingMode(state.routing_mode); if (state?.routing_breakpoint != null) setRoutingBp(state.routing_breakpoint); if (state?.channel_mode) setChannelMode(state.channel_mode); }, [state?.routing_mode, state?.routing_breakpoint, state?.channel_mode]); const scanWifi = async () => { setWifiScanning(true); try { const data = await apiGet('/api/wifi/scan'); setWifiNetworks(data.networks || []); } catch (e) { /* ignore */ } setWifiScanning(false); }; const connectWifi = async (ssid, password) => { try { await apiPost('/api/wifi/connect', { ssid, password }); setShowConnect(null); setConnectSsid(''); setConnectPw(''); setTimeout(refresh, 2000); } catch (e) { alert('Failed to connect'); } }; const disconnectWifi = async () => { try { await apiPost('/api/wifi/disconnect'); setTimeout(refresh, 2000); } catch (e) { /* ignore */ } }; const toggleHotspot = async () => { const active = state?.wifi?.hotspot_active; try { if (active) await apiPost('/api/wifi/hotspot/disable'); else await apiPost('/api/wifi/hotspot/enable', { ssid: 'Pi-Pedal', password: 'pedal1234' }); setTimeout(refresh, 2000); } catch (e) { /* ignore */ } }; const handleBtPower = async (on) => { try { await apiPost('/api/bluetooth/power', { on }); setTimeout(refresh, 1000); } catch (e) { /* ignore */ } }; const scanBt = async () => { setBtScanning(true); try { const data = await apiPost('/api/bluetooth/scan'); setBtDevices(data.devices || []); } catch (e) { /* ignore */ } setBtScanning(false); }; const loadBtPaired = async () => { try { const data = await apiGet('/api/bluetooth/paired'); setBtPaired(data.devices || []); } catch (e) { /* ignore */ } }; const pairBt = async (addr) => { try { await apiPost('/api/bluetooth/pair', { address: addr }); await loadBtPaired(); } catch (e) { alert('Pairing failed'); } }; const handleRouting = async (mode, bp) => { try { await apiPost('/api/routing', { routing_mode: mode, routing_breakpoint: bp }); } catch (e) { /* ignore */ } }; const handleChannelMode = async (mode) => { try { await apiPost('/api/channel-mode', { channel_mode: mode }); setChannelMode(mode); } catch (e) { /* ignore */ } }; useEffect(() => { if (connected) { scanWifi(); loadBtPaired(); } }, [connected]); const wifiState = state?.wifi; const btState = state?.bluetooth; return (
Settings
{/* Settings tabs */}
{[{ id: 'wifi', label: 'WiFi' }, { id: 'bt', label: 'Bluetooth' }, { id: 'routing', label: 'Routing' }].map(t => ( ))}
{/* ── WiFi Tab ─────────────────────────────────────────────*/} {tab === 'wifi' && ( <> {/* WiFi Status */}
WiFi
{wifiState?.connected ? `Connected to ${wifiState.ssid}` : 'Disconnected'}
{wifiState?.connected && (
IP: {wifiState.ip} · Signal: {wifiState.signal}%
)} {/* Hotspot */}
Hotspot
{wifiState?.hotspot_active ? `Active: ${wifiState.hotspot_ssid}` : 'Off'}
{/* Scan networks */}
Networks
{wifiNetworks.length === 0 ? (
Scan to see available networks.
) : ( wifiNetworks.map(n => (
wpaState?.ssid !== n.ssid && setShowConnect(n.ssid)}>
{n.ssid}
Signal: {n.signal}% · {n.encryption || 'Open'}
{wifiState?.ssid === n.ssid && Connected}
)) )}
{/* Connect dialog */} {showConnect && (
Connect to {showConnect}
setConnectPw(e.target.value)} style={{ background: T.surface, border: `1px solid ${T.border}`, borderRadius: 6, padding: "8px 12px", color: T.textPrimary, fontSize: 13, outline: "none" }} onKeyDown={e => e.key === 'Enter' && connectWifi(showConnect, connectPw)} />
)} )} {/* ── Bluetooth Tab ─────────────────────────────────────────*/} {tab === 'bt' && ( <>
Bluetooth
{btState?.powered ? 'Powered on' : 'Off'}
handleBtPower(!btState?.powered)} />
{btState?.powered && ( <>
Discoverable
{btState?.name} ({btState?.address})
{ try { await apiPost('/api/bluetooth/discoverable', { on: !btState?.discoverable }); setTimeout(refresh, 500); } catch(e) {} }} />
Bluetooth MIDI
{btState?.midi_enabled ? 'Enabled' : 'Disabled'}
{ try { await (btState?.midi_enabled ? apiPost('/api/bluetooth/midi-disable') : apiPost('/api/bluetooth/midi-enable')); setTimeout(refresh, 500); } catch(e) {} }} />
)}
{btState?.powered && (
Paired Devices ({btPaired.length})
{btPaired.length === 0 ? (
No paired devices.
) : ( btPaired.map(d => (
{d.name || d.address}
{d.address} · {d.connected ? 'Connected' : 'Paired'}
{!d.connected && ( )}
)) )}
)} {btScanning && btDevices.length > 0 && (
Discovered
{btDevices.map(d => (
{d.name || d.address}
{d.address}
))}
)} )} {/* ── Routing Tab ───────────────────────────────────────────*/} {tab === 'routing' && ( <>
4CM Routing
Mode
Mono (normal) or 4CM (4-cable method)
{routingMode === '4cm' && (
Breakpoint
Chain position for amp send/return
{ const v = +e.target.value; setRoutingBp(v); handleRouting(routingMode, v); }} style={{ background: T.surface, border: `1px solid ${T.border}`, borderRadius: 6, padding: "6px 10px", color: T.textPrimary, fontSize: 13, width: 60, outline: "none", textAlign: "center" }} />
)}
{/* Channel Mode */}
Channel Mode
Routing
Dual-mono: two independent channels · Stereo: linked as L/R pair
{channelMode === 'dual-mono' ? 'Each channel controlled independently from its own phone.' : 'Channels A (Left) and B (Right) are controlled together.'}
)}
); } // 7. LOOPER / RECORDER SCREEN ────────────────────────────────── function RecordScreen({ state }) { const [recording, setRecording] = useState(false); const [captures, setCaptures] = useState([]); const [loading, setLoading] = useState(true); const inputLevel = state?.input_level ?? 0; const outputLevel = state?.output_level ?? 0; const loadCaptures = useCallback(async () => { try { const data = await apiGet('/api/captures'); setCaptures(data.captures || []); } catch (e) { /* ignore */ } setLoading(false); }, []); useEffect(() => { loadCaptures(); }, [loadCaptures]); const handleRecord = async () => { try { await apiPost('/api/capture/start'); setRecording(true); } catch (e) { alert('Failed to start recording'); } }; const handleStop = async () => { try { await apiPost('/api/capture/stop'); setRecording(false); loadCaptures(); } catch (e) { alert('Failed to stop recording'); } }; return (
Looper / Recorder
{state?.sample_rate ? `${state.sample_rate/1000}kHz` : '—'} {recording && ● RECORDING}
{recording ? ( ) : ( )}
{/* Live levels during recording */}
Input Level
{Array.from({ length: 12 }).map((_, i) => { const active = Math.round((inputLevel / 100) * 12) > i; const c = i >= 10 ? T.red : i >= 8 ? '#E8C030' : T.green; return
; })}
Output Level
{Array.from({ length: 12 }).map((_, i) => { const active = Math.round((outputLevel / 100) * 12) > i; const c = i >= 10 ? T.red : i >= 8 ? '#E8C030' : T.green; return
; })}
{/* Recording status */} {recording && (
🔴
Recording...
Saving to captures folder — tap Stop to finish
)} {/* Captured files */} {!recording && ( <>
Recordings ({captures.length})
{loading ? (
) : captures.length === 0 ? (
No recordings yet. Tap ● Record to capture audio.
) : ( captures.map(c => (
{c.name}
{c.size_display}
▶ Play
)) )}
)}
); } // ══════════════════════════════════════════════════════════════ // SNAPSHOT PANEL // ══════════════════════════════════════════════════════════════ function SnapshotPanel({ onClose, state, connected }) { const [snapshots, setSnapshots] = useState({}); const [loading, setLoading] = useState(true); const [editingSlot, setEditingSlot] = useState(null); const [editName, setEditName] = useState(''); const [saveFlash, setSaveFlash] = useState(null); const [discardEdits, setDiscardEdits] = useState(false); const holdTimer = useRef(null); const currentSlot = state?.current_snapshot ?? 0; const loadSnapshots = useCallback(async () => { if (!connected) { setLoading(false); return; } try { const data = await apiGet('/api/snapshots'); setSnapshots(data.snapshots || {}); } catch (e) { /* ignore */ } setLoading(false); }, [connected]); useEffect(() => { loadSnapshots(); }, [loadSnapshots]); // Handle press & hold vs tap const handlePointerDown = (slot) => { holdTimer.current = setTimeout(() => { // Long press → save holdTimer.current = null; handleSave(slot); }, 600); }; const handlePointerUp = (slot) => { if (holdTimer.current) { clearTimeout(holdTimer.current); holdTimer.current = null; // Short tap → recall (or save if empty) if (snapshots[String(slot)]) { handleRecall(slot); } else { handleSave(slot); } } }; const handlePointerLeave = () => { if (holdTimer.current) { clearTimeout(holdTimer.current); holdTimer.current = null; } }; const handleSave = async (slot) => { try { await apiPost(`/api/snapshots/${slot}/save`); setSaveFlash(slot); setTimeout(() => setSaveFlash(null), 800); await loadSnapshots(); } catch (e) { /* ignore */ } }; const handleRecall = async (slot) => { try { await apiPost(`/api/snapshots/${slot}/recall`); await loadSnapshots(); } catch (e) { /* ignore */ } }; const handleRename = async (slot) => { if (!editName.trim()) return; try { await apiPut(`/api/snapshots/${slot}`, { name: editName.trim() }); setEditingSlot(null); await loadSnapshots(); } catch (e) { /* ignore */ } }; const handleDelete = async (slot) => { try { await apiDelete(`/api/snapshots/${slot}`); await loadSnapshots(); } catch (e) { /* ignore */ } }; return (
Snapshots
Tap to recall · Hold to save {discardEdits && Edits will be discarded on recall}
{[1,2,3,4,5,6,7,8].map(slot => { const snap = snapshots[String(slot)]; const isActive = currentSlot === slot; const isFilled = !!snap; const isEditing = editingSlot === slot; const isFlashing = saveFlash === slot; return (
handlePointerDown(slot)} onTouchStart={() => handlePointerDown(slot)} onMouseUp={() => handlePointerUp(slot)} onTouchEnd={() => handlePointerUp(slot)} onMouseLeave={handlePointerLeave} onTouchCancel={handlePointerLeave} style={isFlashing ? { background: 'rgba(58,184,122,.15)' } : {}}>
{slot}
{isEditing ? ( setEditName(e.target.value)} onBlur={() => handleRename(slot)} onKeyDown={e => { if (e.key === 'Enter') handleRename(slot); if (e.key === 'Escape') setEditingSlot(null); }} autoFocus onClick={e => e.stopPropagation()} /> ) : snap ? (
{ e.stopPropagation(); setEditingSlot(slot); setEditName(snap.name); }}>{snap.name}
) : (
Empty
)} {isFlashing &&
} {isActive &&
}
); })}
{/* Delete button for active snapshot */} {currentSlot > 0 && snapshots[String(currentSlot)] && (
)}
); } // ══════════════════════════════════════════════════════════════ // APP SHELL // ══════════════════════════════════════════════════════════════ const TABS = [ { id: "rig", label: "Rig", icon: "🎛" }, { id: "fx", label: "FX Chain", icon: "⛓" }, { id: "models", label: "Models", icon: "📁" }, { id: "irs", label: "IRs", icon: "🎚" }, { id: "presets", label: "Presets", icon: "⭐" }, { id: "settings", label: "Settings", icon: "⚙" }, { id: "record", label: "Looper", icon: "🎙" }, ]; export default function App() { const [tab, setTab] = useState("rig"); const [showSnapshots, setShowSnapshots] = useState(false); const [channel, setChannelState] = useState(() => { try { return localStorage.getItem('pedal_channel') || 'guitar'; } catch { return 'guitar'; } }); const { state, connected, refresh } = usePedalState(); useEffect(() => { document.title = "Pi Multi-FX Pedal"; }, []); // Sync channel to localStorage + module-level API var useEffect(() => { try { localStorage.setItem('pedal_channel', channel); } catch (e) {} setChannel(channel); }, [channel]); const handleChannel = (ch) => { setChannelState(ch); }; return ( <>
{/* Status bar */}
{/* Channel toggle — hidden in stereo mode */} {state?.channel_mode === 'stereo' ? ( STEREO ) : (
)} {state?.tuner_enabled && TUNER}
{/* Snapshot camera icon */} {state?.cpu_percent != null && ( CPU {state.cpu_percent}% )} {state?.sample_rate && ( {state.sample_rate/1000}kHz )} {state?.bypass && BYP} {state?.nam_loaded && NAM} {state?.ir_loaded && IR}
{/* Screen */}
{tab === "rig" && } {tab === "fx" && } {tab === "models" && } {tab === "irs" && } {tab === "presets" && } {tab === "settings" && } {tab === "record" && }
{/* Tab bar */}
{TABS.map(t => (
setTab(t.id)}> {t.icon} {t.label}
))}
{/* Snapshot panel overlay */} {showSnapshots && ( setShowSnapshots(false)} state={state} connected={connected} /> )}
); }