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 (
);
}
// ── 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 */}
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 ? (
) : (
Not loaded
)}
{/* Quick links */}
{
const tabbar = document.querySelector('.tabbar');
const tabs = tabbar?.children;
if (tabs) tabs[3]?.click(); // IRs tab index
}}>Browse IRs
{
const tabbar = document.querySelector('.tabbar');
const tabs = tabbar?.children;
if (tabs) tabs[2]?.click(); // Models tab index
}}>Browse Models
);
}
// 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})` : ''}
setShowAdd(true)}>+ Add Block
{/* 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.
setShowAdd(true)}>+ Add your first block
) : (
fxList.map(f => (
toggle(f.id)} />
))
)}
{/* Add Block slide panel */}
{showAdd && (
setShowAdd(false)}>
{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 (
{showSearch && (
🔍
setSearchQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} />
{searching ? "..." : "Go"}
{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}
Get
))}
)}
)}
{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
) : (
handleLoad(m.path)}>Load
)}
))
)}
);
}
// 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 (
{showSearch && (
🔍
setSearchQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} />
{searching ? "..." : "Go"}
{searchResults.length > 0 && (
{searchResults.map(r => (
handleInstall(r)} style={{ borderBottom: `1px solid ${T.border}`, borderRadius: 0 }}>
{r.name}
{r.author} · {r.size_display}
Get
))}
)}
)}
{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
) : (
handleLoad(ir.path)}>Load
)}
))
)}
);
}
// 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
setShowSave(true)}>Save
+ New
{/* Bank selector */}
{banks.length > 1 && (
{banks.map((b, i) => (
setSelectedBank(i)}>{b.name}
))}
)}
{/* 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 ? (
{ e.stopPropagation(); handleDelete(p.bank, p.program); }}>Confirm
{ e.stopPropagation(); setDeleting(null); }}>Cancel
) : (
{ e.stopPropagation(); setDeleting(`${p.bank}-${p.program}`); }}>Delete
)}
);
})
)}
{/* Save dialog */}
{showSave && (
setShowSave(false)}>
)}
);
}
// 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 tabs */}
{[{ id: 'wifi', label: 'WiFi' }, { id: 'bt', label: 'Bluetooth' }, { id: 'routing', label: 'Routing' }].map(t => (
setTab(t.id)}>{t.label}
))}
{/* ── 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
{wifiScanning ? "..." : "Scan"}
{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 && (
)}
>
)}
{/* ── 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})
{btScanning ? "..." : "Scan"}
{btPaired.length === 0 ? (
No paired devices.
) : (
btPaired.map(d => (
{d.name || d.address}
{d.address} · {d.connected ? 'Connected' : 'Paired'}
{!d.connected && (
{ try { await apiPost('/api/bluetooth/connect', { address: d.address }); } catch(e) { alert('Failed'); } }}>Connect
)}
))
)}
)}
{btScanning && btDevices.length > 0 && (
Discovered
{btDevices.map(d => (
{d.name || d.address}
{d.address}
pairBt(d.address)}>Pair
))}
)}
>
)}
{/* ── Routing Tab ───────────────────────────────────────────*/}
{tab === 'routing' && (
<>
4CM Routing
Mode
Mono (normal) or 4CM (4-cable method)
{ setRoutingMode(e.target.value); handleRouting(e.target.value, routingBp); }}
style={{ background: T.surface, border: `1px solid ${T.border}`, borderRadius: 6, padding: "6px 10px", color: T.textPrimary, fontSize: 13, outline: "none" }}>
Mono
4CM
{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
handleChannelMode(e.target.value)}
style={{ background: T.surface, border: `1px solid ${T.border}`, borderRadius: 6, padding: "6px 10px", color: T.textPrimary, fontSize: 13, outline: "none" }}>
Dual-Mono
Stereo
{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 ? (
⏹ Stop
) : (
● Record
)}
{/* 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
setDiscardEdits(d => !d)}>
Discard Edits
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)] && (
handleDelete(currentSlot)}>
Delete Snapshot {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
) : (
handleChannel('guitar')}
style={{ padding: '2px 8px', fontSize: 10, fontWeight: 700, letterSpacing: '.1em' }}>
GUITAR
handleChannel('bass')}
style={{ padding: '2px 8px', fontSize: 10, fontWeight: 700, letterSpacing: '.1em' }}>
BASS
)}
{state?.tuner_enabled &&
TUNER }
{/* Snapshot camera icon */}
setShowSnapshots(true)}
title="Snapshots">
📷
{state?.current_snapshot > 0 && (
{state.current_snapshot}
)}
{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} />
)}
>
);
}