Files
pi-multifx-pedal/frontend-react/src/App.jsx
T
shawn 5c51b3db28 feat(nam): download 10 new A2 models for bass, orange, and other missing categories
Bank 7 — Bass: Ampeg PF20T, Aguilar Tone Hammer 500, GK 800RB (Gallien-Krueger), Aguilar DB751
Bank 7 — Orange: Orange Rocker 30 Dirty + Natural
Bank 7 — Others: Fender Bassman dimed, Hiwatt DR103 Bright + Cornish, Supro Black Magick M3

All SlimmableContainer v0.7.0, ~287-295 KB, verified JSON.
Also fixed Bank 2 Tone3000 storage keys to include .nam extension (Tone3000 changed URL format).
Inventory updated: 54 → 64 total models.
2026-06-13 02:09:56 -04:00

2118 lines
96 KiB
React

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 (
<div className="knob-wrap" style={{ width: size + 12 }}>
<div className="knob" style={{ width: size, height: size }} onMouseDown={handleStart} onTouchStart={handleStart} ref={ref}>
<svg style={{ position: "absolute", inset: -6, width: svgSize, height: svgSize, overflow: "visible" }}>
<circle cx={cx} cy={cy} r={r} fill="none" stroke={T.border} strokeWidth="2.5" strokeLinecap="round" opacity=".5" />
{norm > 0 && <path d={`M ${cx} ${cy - r} A ${r} ${r} 0 ${norm > 0.5 ? 1 : 0} 1 ${ex} ${ey}`}
fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round"
style={{ filter: `drop-shadow(0 0 3px ${color})` }} />}
</svg>
<div className="knob-inner" style={{ "--amber": color }}>
<div className="knob-dot" style={{ height: size * 0.38, bottom: size / 2 - 2,
transform: `translateX(-50%) rotate(${angle}deg)`, transformOrigin: `50% ${size * 0.38}px` }} />
</div>
</div>
<div className="knob-value mono" style={{ color }}>{value}</div>
<div className="knob-label">{label}</div>
</div>
);
}
// ── 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 (
<div style={{ display: "flex", flexDirection: "column-reverse", gap: 2, height, alignItems: "center" }}>
{Array.from({ length: segs }).map((_, i) => (
<div key={i} style={{ width: 6, height: (height - segs * 2) / segs,
borderRadius: 1, background: i < active ? segColor(i) : T.border,
boxShadow: i < active ? `0 0 4px ${segColor(i)}` : "none",
transition: "background .05s, box-shadow .05s" }} />
))}
</div>
);
return (
<div style={{ display: "flex", gap: 2, height: 6, alignItems: "center", width: height }}>
{Array.from({ length: segs }).map((_, i) => (
<div key={i} style={{ flex: 1, height: "100%", borderRadius: 1, background: i < active ? segColor(i) : T.border }} />
))}
</div>
);
}
// ── 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 (
<div className="waveform-wrap" style={{ width, height }}>
<canvas ref={canvasRef} width={width} height={height} className="waveform-canvas" />
<div className="playhead" style={{ left: `${playPos}%` }} />
</div>
);
}
// ── 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 (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
<div style={{ position: "relative", height, display: "flex", justifyContent: "center" }}>
<div className="fader-track" style={{ height }} ref={trackRef} onMouseDown={handleStart} onTouchStart={handleStart}>
<div className="fader-fill" style={{ height: `${value}%`, background: color, opacity: 0.4 }} />
<div className="fader-thumb" style={{ bottom: thumbBottom }} onMouseDown={handleStart} onTouchStart={handleStart} />
</div>
</div>
<div className="knob-value mono" style={{ color }}>{value}</div>
<div className="knob-label">{label}</div>
</div>
);
}
// ── 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 (
<div className={`fx-block ${active ? "active" : ""} ${bypassed ? "bypassed" : ""}`}>
<div className="fx-header" onClick={() => setOpen(o => !o)}>
<div className={`bypass-led ${!bypassed ? "led-on" : "led-off"}`}
onClick={(e) => { e.stopPropagation(); onToggle?.(); }} />
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>{title}</span>
<span className="badge badge-blue" style={{ fontSize: 10 }}>{type}</span>
<span style={{ color: T.textDim, fontSize: 18, marginLeft: 6 }}>{open ? "▾" : "▸"}</span>
</div>
{open && (
<div className="fx-body">
{params.map((p, i) => (
<Knob key={p.key || p.name} label={p.name} value={vals[i]} size={44}
onChange={v => setVals(prev => prev.map((x, j) => j === i ? v : x))}
min={p.min ?? 0} max={p.max ?? 100} />
))}
</div>
)}
</div>
);
}
// ── Signal Arrow Connector ──────────────────────────────────
function SignalArrow({ active = true, variant = 'right', animated = true }) {
const color = active ? T.green : T.textDim;
if (variant === 'right') {
return (
<svg width="22" height="14" viewBox="0 0 22 14" style={{ flexShrink: 0, display: 'block' }}>
<line x1="2" y1="7" x2="14" y2="7" stroke={color} strokeWidth="2" strokeLinecap="round" />
<polygon points="12,3 20,7 12,11" fill={color} />
{active && animated && (
<circle r="1.5" fill={color} opacity="0.6">
<animate attributeName="cx" values="4;18" dur="1.2s" repeatCount="indefinite" />
</circle>
)}
</svg>
);
}
if (variant === 'split') {
return (
<svg width="26" height="24" viewBox="0 0 26 24" style={{ flexShrink: 0, display: 'block' }}>
<line x1="2" y1="10" x2="14" y2="10" stroke={color} strokeWidth="2" strokeLinecap="round" />
<line x1="8" y1="10" x2="18" y2="18" stroke={color} strokeWidth="2" strokeLinecap="round" />
<polygon points="12,6 18,10 12,14" fill={color} />
<polygon points="16,14 21,20 19,13" fill={color} />
</svg>
);
}
if (variant === 'merge') {
return (
<svg width="26" height="24" viewBox="0 0 26 24" style={{ flexShrink: 0, display: 'block' }}>
<line x1="2" y1="4" x2="12" y2="12" stroke={color} strokeWidth="2" strokeLinecap="round" />
<line x1="2" y1="20" x2="12" y2="12" stroke={color} strokeWidth="2" strokeLinecap="round" />
<line x1="12" y1="12" x2="14" y2="12" stroke={color} strokeWidth="2" strokeLinecap="round" />
<polygon points="12,8 18,12 12,16" fill={color} />
</svg>
);
}
return null;
}
// ── Slide Panel ──────────────────────────────────────────────
function SlidePanel({ children, onClose }) {
return (
<div className="slide-panel" onClick={onClose}>
<div className="slide-panel-inner" onClick={e => e.stopPropagation()}>
<div className="slide-panel-handle" />
{children}
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════════
// 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 (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className="screen-header">
<div>
<div className="screen-title">Active Rig</div>
<div style={{ display: "flex", gap: 6, marginTop: 4, flexWrap: "wrap", alignItems: "center" }}>
{state?.current_preset ? (
<span className="nam-chip" style={{ color: T.amber, borderColor: `${T.amber}55`, background: `${T.amber}15` }}>
{state.current_preset.name}
</span>
) : (
<span style={{ fontSize: 11, color: T.textDim }}>No preset loaded</span>
)}
{connected ? (
<span className="badge badge-green">Connected</span>
) : (
<span className="badge badge-red">Offline</span>
)}
</div>
</div>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<VUMeter level={inputLevel} height={44} />
<VUMeter level={outputLevel} height={44} />
</div>
</div>
<div className="screen-body">
{/* Status row */}
<div className="card-sm" style={{ display: "flex", gap: 10, justifyContent: "space-around" }}>
<div style={{ textAlign: "center" }}>
<div className="section-label" style={{ marginBottom: 4 }}>Bypass</div>
<div className={`switch ${bypass ? 'on' : ''}`} onClick={handleBypass} style={{ margin: "0 auto" }} />
</div>
<div style={{ textAlign: "center" }}>
<div className="section-label" style={{ marginBottom: 4 }}>Tuner</div>
<div className={`switch ${tuner ? 'on' : ''}`} onClick={handleTuner} style={{ margin: "0 auto" }} />
</div>
<div style={{ textAlign: "center" }}>
<div className="section-label" style={{ marginBottom: 4 }}>Routing</div>
<span className="mono" style={{ fontSize: 12, color: T.amber }}>{state?.routing_mode || "mono"}</span>
</div>
</div>
{/* Tuner active indicator */}
{tuner && (
<div className="card-sm" style={{ background: `${T.amber}15`, borderColor: T.amber, textAlign: "center", padding: "20px 16px" }}>
<div style={{ fontSize: 32, marginBottom: 8 }}>🎵</div>
<div className="section-label" style={{ color: T.amber, marginBottom: 4, fontSize: 14 }}>TUNER ACTIVE</div>
<div className="mono" style={{ fontSize: 24, color: T.amber, letterSpacing: 4 }}></div>
<div style={{ fontSize: 11, color: T.textDim, marginTop: 8 }}>Tap switch again to exit tuner mode</div>
</div>
)}
{/* Volume */}
<div className="card" style={{ display: "flex", gap: 20, alignItems: "center" }}>
<div style={{ flex: 1 }}>
<div className="section-label">Master Volume</div>
<input type="range" min={0} max={100} value={volume}
onChange={e => handleVolume(+e.target.value)}
style={{ width: "100%", accentColor: T.amber }} />
<div className="mono" style={{ fontSize: 11, color: T.amber, marginTop: 4 }}>
{volume}%
</div>
</div>
<div className="mono" style={{ fontSize: 11, color: T.textSec, textAlign: "right" }}>
{state?.nam_loaded ? "NAM: Active" : "NAM: —"}
<br />
{state?.ir_loaded ? "IR: Active" : "IR: —"}
</div>
</div>
{/* Signal chain */}
<div className="card-sm">
<div className="section-label">Signal Chain</div>
<div style={{ display: "flex", alignItems: "center", gap: 4, overflowX: "auto", padding: "4px 0" }}>
{(() => {
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) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
{is4CM && s === "AMP" && (
<SignalArrow variant="split" active={active} />
)}
{is4CM && s === "CAB" && (
<SignalArrow variant="merge" active={active} />
)}
<div style={{ padding: "3px 8px", borderRadius: 4, fontSize: 10, fontWeight: 700,
background: i === 0 || i === arr.length - 1 ? T.blueDim : (is4CM && s === "AMP" ? "rgba(58,184,122,.15)" : T.amberDim),
color: i === 0 || i === arr.length - 1 ? T.blue : (is4CM && s === "AMP" ? T.green : T.amber),
border: `1px solid ${(i === 0 || i === arr.length - 1 ? T.blue : (is4CM && s === "AMP" ? T.green : T.amber))}60` }}>
{s}
{is4CM && s === "AMP" && <span style={{ fontSize: 8, marginLeft: 4, opacity: 0.7 }}>4CM</span>}
{is4CM && s === "BYP" && <span style={{ fontSize: 8, marginLeft: 4, opacity: 0.7 }}>4CM</span>}
</div>
{is4CM && s === "AMP" && (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", flexShrink: 0, gap: 1 }}>
<SignalArrow variant="split" active={active} />
<span style={{ fontSize: 7, color: T.textDim, letterSpacing: '.05em', lineHeight: '1' }}>LOOP</span>
<SignalArrow variant="merge" active={active} />
</div>
)}
{i < arr.length - 1 && !(is4CM && s === "AMP") && <SignalArrow active={active} />}
</div>
));
})()}
</div>
</div>
{/* NAM + IR info */}
<div className="card-sm" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<div>
<div className="section-label" style={{ marginBottom: 6 }}>NAM Model</div>
{state?.nam_model ? (
<div className="nam-chip">{state.nam_model}</div>
) : (
<div style={{ fontSize: 11, color: T.textDim }}>Not loaded</div>
)}
</div>
<div>
<div className="section-label" style={{ marginBottom: 6 }}>IR</div>
{state?.ir_name ? (
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div className="nam-chip" style={{ color: T.green, borderColor: `${T.green}55`, background: `${T.green}15`, fontSize: 10 }}>{state.ir_name}</div>
</div>
) : (
<div style={{ fontSize: 11, color: T.textDim }}>Not loaded</div>
)}
</div>
</div>
{/* Quick links */}
<div className="card-sm" style={{ display: "flex", justifyContent: "space-around", gap: 6 }}>
<button className="btn btn-ghost btn-sm" style={{ flex: 1, fontSize: 10 }} onClick={() => {
const tabbar = document.querySelector('.tabbar');
const tabs = tabbar?.children;
if (tabs) tabs[3]?.click(); // IRs tab index
}}>Browse IRs</button>
<button className="btn btn-ghost btn-sm" style={{ flex: 1, fontSize: 10 }} onClick={() => {
const tabbar = document.querySelector('.tabbar');
const tabs = tabbar?.children;
if (tabs) tabs[2]?.click(); // Models tab index
}}>Browse Models</button>
</div>
</div>
</div>
);
}
// 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 <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}><div className="loader" /></div>;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className="screen-header">
<div className="screen-title">FX Chain {(fxList?.length || 0) > 0 ? `(${fxList.length})` : ''}</div>
<button className="btn btn-ghost btn-sm" onClick={() => setShowAdd(true)}>+ Add Block</button>
</div>
<div className="screen-body">
{/* Signal path */}
{fxList && fxList.length > 0 && (
<div style={{ display: "flex", alignItems: "center", gap: 4, padding: "4px 2px", overflowX: "auto" }}>
{(() => {
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 (
<div key={`bp-${i}`} style={{ display: "flex", flexDirection: "column", alignItems: "center", flexShrink: 0, gap: 1 }}>
<SignalArrow variant="split" active={true} />
<span style={{ fontSize: 7, color: T.textDim, letterSpacing: '.05em', lineHeight: '1' }}>4CM</span>
<SignalArrow variant="merge" active={true} />
</div>
);
}
if (item.type === 'merge-bp') {
// Already rendered as part of the split-bp composite — skip
return null;
}
return (
<div key={`${item.type}-${i}`} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
{item.type === 'io' && (
<div style={{ padding: "3px 8px", borderRadius: 4, fontSize: 10, fontWeight: 700,
background: T.blueDim, color: T.blue, border: `1px solid ${T.blue}60` }}>{item.label}</div>
)}
{item.type === 'fx' && (
<div style={{ padding: "3px 8px", borderRadius: 4, fontSize: 10, fontWeight: 700,
background: T.amberDim, color: item.bypassed ? T.textDim : T.amber,
border: `1px solid ${(item.bypassed ? T.textDim : T.amber)}60`, opacity: item.bypassed ? 0.5 : 1 }}>{item.label}</div>
)}
{/* Connector after this block */}
{i < items.length - 1 && items[i+1]?.type !== 'split-bp' && items[i]?.type !== 'merge-bp' && (
<SignalArrow active={item.type === 'io' || !item.bypassed} />
)}
</div>
);
});
})()}
</div>
)}
{(!fxList || fxList.length === 0) ? (
<div style={{ textAlign: "center", padding: 30, color: T.textDim, fontSize: 14 }}>
No FX blocks in the chain.<br />
<button className="btn btn-ghost btn-sm" style={{ marginTop: 10 }} onClick={() => setShowAdd(true)}>+ Add your first block</button>
</div>
) : (
fxList.map(f => (
<FXBlock key={f.id} {...f} active={!f.bypassed} onToggle={() => toggle(f.id)} />
))
)}
</div>
{/* Add Block slide panel */}
{showAdd && (
<SlidePanel onClose={() => setShowAdd(false)}>
<div className="screen-header">
<div className="screen-title">Add FX Block</div>
</div>
<div className="screen-body" style={{ gap: 4 }}>
{Object.entries(FX_TYPE_LABELS).map(([key, label]) => (
<div key={key} className="preset-row" onClick={() => addBlock(key)} style={{ borderRadius: 6 }}>
<span className="badge badge-blue" style={{ width: 60, flexShrink: 0 }}>{key.split('_')[0]}</span>
<span style={{ flex: 1, fontSize: 13 }}>{label}</span>
<span style={{ color: T.textDim }}>+</span>
</div>
))}
</div>
</SlidePanel>
)}
</div>
);
}
// 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 <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}><div className="loader" /></div>;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className="screen-header">
<div className="screen-title">NAM Models ({models.length})</div>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-ghost btn-sm" onClick={() => setShowSearch(!showSearch)}>
{showSearch ? "Local" : "Search"}
</button>
<button className="btn btn-primary btn-sm" onClick={() => fileRef.current?.click()}>
{uploading ? "..." : "+ Upload"}
</button>
<input ref={fileRef} type="file" accept=".nam" style={{ display: "none" }} onChange={handleUpload} />
</div>
</div>
{showSearch && (
<div style={{ padding: "8px 12px", borderBottom: `1px solid ${T.border}` }}>
<div className="search-bar">
<span style={{ color: T.textDim }}>🔍</span>
<input type="text" placeholder="Search Tone3000…" value={searchQ}
onChange={e => setSearchQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} />
<button className="btn btn-primary btn-sm" onClick={handleSearch} disabled={searching}>
{searching ? "..." : "Go"}
</button>
</div>
{searchResults.length > 0 && (
<div style={{ marginTop: 8, maxHeight: 240, overflow: "auto" }}>
{searchResults.map(r => (
<div key={r.id} className="preset-row" onClick={() => handleInstall(r)} style={{ borderBottom: `1px solid ${T.border}`, borderRadius: 0 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13 }}>{r.name}</div>
<div style={{ fontSize: 10, color: T.textDim }}>{r.author} · {r.size_display} · {r.architecture}</div>
</div>
<button className="btn btn-primary btn-sm">Get</button>
</div>
))}
</div>
)}
</div>
)}
<div className="screen-body">
{current && (
<div className="card-sm">
<div className="section-label" style={{ marginBottom: 6 }}>Loaded Model</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div className="nam-chip">{current}</div>
<button className="btn btn-danger btn-sm" onClick={handleUnload}>Unload</button>
</div>
</div>
)}
{models.length === 0 ? (
<div style={{ textAlign: "center", padding: 30, color: T.textDim, fontSize: 14 }}>
No NAM models found.<br />Upload a .nam file or search Tone3000.
</div>
) : (
models.map(m => (
<div key={m.name} className={`preset-row ${m.loaded ? "active" : ""}`}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{m.name}</div>
<div style={{ fontSize: 10, color: T.textDim }}>{m.architecture} · {m.size_mb} MB</div>
</div>
{m.loaded ? (
<span className="badge badge-green">Loaded</span>
) : (
<button className="btn btn-ghost btn-sm" onClick={() => handleLoad(m.path)}>Load</button>
)}
</div>
))
)}
</div>
</div>
);
}
// 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 <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}><div className="loader" /></div>;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className="screen-header">
<div className="screen-title">IR Files ({irs.length})</div>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-ghost btn-sm" onClick={() => setShowSearch(!showSearch)}>
{showSearch ? "Local" : "Search"}
</button>
<button className="btn btn-primary btn-sm" onClick={() => fileRef.current?.click()}>
{uploading ? "..." : "+ Upload"}
</button>
<input ref={fileRef} type="file" accept=".wav" style={{ display: "none" }} onChange={handleUpload} />
</div>
</div>
{showSearch && (
<div style={{ padding: "8px 12px", borderBottom: `1px solid ${T.border}` }}>
<div className="search-bar">
<span style={{ color: T.textDim }}>🔍</span>
<input type="text" placeholder="Search Tone3000 IRs…" value={searchQ}
onChange={e => setSearchQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} />
<button className="btn btn-primary btn-sm" onClick={handleSearch} disabled={searching}>
{searching ? "..." : "Go"}
</button>
</div>
{searchResults.length > 0 && (
<div style={{ marginTop: 8, maxHeight: 240, overflow: "auto" }}>
{searchResults.map(r => (
<div key={r.id} className="preset-row" onClick={() => handleInstall(r)} style={{ borderBottom: `1px solid ${T.border}`, borderRadius: 0 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13 }}>{r.name}</div>
<div style={{ fontSize: 10, color: T.textDim }}>{r.author} · {r.size_display}</div>
</div>
<button className="btn btn-primary btn-sm">Get</button>
</div>
))}
</div>
)}
</div>
)}
<div className="screen-body">
{current && (
<div className="card-sm">
<div className="section-label" style={{ marginBottom: 6 }}>Loaded IR</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div className="nam-chip" style={{ color: T.green, borderColor: `${T.green}55`, background: `${T.green}15` }}>{current}</div>
<button className="btn btn-danger btn-sm" onClick={handleUnload}>Unload</button>
</div>
</div>
)}
{irs.length === 0 ? (
<div style={{ textAlign: "center", padding: 30, color: T.textDim, fontSize: 14 }}>
No IR files found.<br />Upload a .wav file or search Tone3000.
</div>
) : (
irs.map(ir => (
<div key={ir.name} className={`preset-row ${ir.loaded ? "active" : ""}`}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{ir.name}</div>
<div style={{ fontSize: 10, color: T.textDim }}>{ir.sample_rate} Hz · {ir.length_ms} ms · {ir.num_taps} taps</div>
</div>
{ir.loaded ? (
<span className="badge badge-green">Loaded</span>
) : (
<button className="btn btn-ghost btn-sm" onClick={() => handleLoad(ir.path)}>Load</button>
)}
</div>
))
)}
</div>
</div>
);
}
// 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 <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}><div className="loader" /></div>;
const currentPreset = state?.current_preset;
const bankData = banks[selectedBank];
const presets = bankData?.presets || [];
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className="screen-header">
<div className="screen-title">Presets</div>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-ghost btn-sm" onClick={() => setShowSave(true)}>Save</button>
<button className="btn btn-primary btn-sm" onClick={handleNewPreset}>+ New</button>
</div>
</div>
<div className="screen-body">
{/* Bank selector */}
{banks.length > 1 && (
<div className="toggle">
{banks.map((b, i) => (
<button key={b.number} className={`toggle-btn ${selectedBank === i ? "active" : ""}`}
onClick={() => setSelectedBank(i)}>{b.name}</button>
))}
</div>
)}
{/* Current preset indicator */}
{currentPreset && (
<div className="card-sm" style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 11, color: T.textDim }}>Current</div>
<div style={{ fontSize: 14, fontWeight: 600, color: T.amber }}>
{currentPreset.name}
<span className="mono" style={{ fontSize: 10, color: T.textDim, marginLeft: 8 }}>
Bank {currentPreset.bank} · Prog {currentPreset.program}
</span>
</div>
</div>
</div>
)}
{/* Preset list */}
{presets.length === 0 ? (
<div style={{ textAlign: "center", padding: 30, color: T.textDim, fontSize: 14 }}>No presets in this bank.</div>
) : (
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 (
<div key={i} className={`preset-row ${isActive ? "active" : ""}`}
onClick={() => !isDeleting && handleActivate(p.bank, p.program)}>
<div className="mono" style={{ width: 28, height: 28, borderRadius: 6, display: "flex",
alignItems: "center", justifyContent: "center", fontSize: 12, fontWeight: 700,
background: isActive ? `${T.amber}22` : T.surface,
color: isActive ? T.amber : T.textDim,
border: `1px solid ${isActive ? T.amber+"60" : T.border}` }}>{p.program + 1}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{p.name}</div>
<div style={{ fontSize: 10, color: T.textDim }}>Bank {p.bank} · Program {p.program}</div>
</div>
{isActive && <span style={{ color: T.amber, fontSize: 16 }}></span>}
{isDeleting ? (
<div style={{ display: "flex", gap: 4 }}>
<button className="btn btn-danger btn-sm" onClick={e => { e.stopPropagation(); handleDelete(p.bank, p.program); }}>Confirm</button>
<button className="btn btn-ghost btn-sm" onClick={e => { e.stopPropagation(); setDeleting(null); }}>Cancel</button>
</div>
) : (
<button className="btn btn-ghost btn-sm" style={{ color: T.red }}
onClick={e => { e.stopPropagation(); setDeleting(`${p.bank}-${p.program}`); }}>Delete</button>
)}
</div>
);
})
)}
</div>
{/* Save dialog */}
{showSave && (
<SlidePanel onClose={() => setShowSave(false)}>
<div className="screen-header">
<div className="screen-title">Save Preset</div>
</div>
<div className="screen-body">
<div className="search-bar">
<input type="text" placeholder="Preset name…" value={saveName}
onChange={e => setSaveName(e.target.value)} autoFocus
onKeyDown={e => e.key === 'Enter' && handleSave()} />
</div>
<div style={{ display: "flex", gap: 8 }}>
<button className="btn btn-primary" style={{ flex: 1 }} onClick={handleSave}>Save</button>
<button className="btn btn-ghost" onClick={() => setShowSave(false)}>Cancel</button>
</div>
</div>
</SlidePanel>
)}
</div>
);
}
// 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 (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className="screen-header">
<div className="screen-title">Settings</div>
</div>
{/* Settings tabs */}
<div className="toggle" style={{ margin: "6px 10px" }}>
{[{ id: 'wifi', label: 'WiFi' }, { id: 'bt', label: 'Bluetooth' }, { id: 'routing', label: 'Routing' }].map(t => (
<button key={t.id} className={`toggle-btn ${tab === t.id ? "active" : ""}`} onClick={() => setTab(t.id)}>{t.label}</button>
))}
</div>
<div className="screen-body" style={{ gap: 4 }}>
{/* ── WiFi Tab ─────────────────────────────────────────────*/}
{tab === 'wifi' && (
<>
{/* WiFi Status */}
<div className="card-sm">
<div className="setting-row" style={{ border: "none", paddingTop: 0 }}>
<div>
<div className="setting-label">WiFi</div>
<div className="setting-desc">{wifiState?.connected ? `Connected to ${wifiState.ssid}` : 'Disconnected'}</div>
</div>
<div className={`switch ${wifiState?.connected ? 'on' : ''}`} onClick={disconnectWifi} />
</div>
{wifiState?.connected && (
<div style={{ fontSize: 11, color: T.textDim }}>
IP: {wifiState.ip} · Signal: {wifiState.signal}%
</div>
)}
{/* Hotspot */}
<div className="setting-row">
<div>
<div className="setting-label">Hotspot</div>
<div className="setting-desc">{wifiState?.hotspot_active ? `Active: ${wifiState.hotspot_ssid}` : 'Off'}</div>
</div>
<div className={`switch ${wifiState?.hotspot_active ? 'on' : ''}`} onClick={toggleHotspot} />
</div>
</div>
{/* Scan networks */}
<div className="card-sm">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
<div className="section-label" style={{ marginBottom: 0 }}>Networks</div>
<button className="btn btn-ghost btn-sm" onClick={scanWifi} disabled={wifiScanning}>
{wifiScanning ? "..." : "Scan"}
</button>
</div>
{wifiNetworks.length === 0 ? (
<div style={{ fontSize: 12, color: T.textDim, padding: "8px 0" }}>Scan to see available networks.</div>
) : (
wifiNetworks.map(n => (
<div key={n.ssid} className="preset-row" style={{ padding: "8px 0", borderRadius: 0, borderBottom: `1px solid ${T.border}` }}
onClick={() => wpaState?.ssid !== n.ssid && setShowConnect(n.ssid)}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13 }}>{n.ssid}</div>
<div className="mono" style={{ fontSize: 10, color: T.textDim }}>Signal: {n.signal}% · {n.encryption || 'Open'}</div>
</div>
{wifiState?.ssid === n.ssid && <span className="badge badge-green">Connected</span>}
</div>
))
)}
</div>
{/* Connect dialog */}
{showConnect && (
<div className="card-sm">
<div className="section-label">Connect to {showConnect}</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<input type="password" placeholder="Password (leave blank for open network)"
value={connectPw} onChange={e => 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)} />
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-primary btn-sm" style={{ flex: 1 }}
onClick={() => connectWifi(showConnect, connectPw)}>Connect</button>
<button className="btn btn-ghost btn-sm" onClick={() => setShowConnect(null)}>Cancel</button>
</div>
</div>
</div>
)}
</>
)}
{/* ── Bluetooth Tab ─────────────────────────────────────────*/}
{tab === 'bt' && (
<>
<div className="card-sm">
<div className="setting-row" style={{ border: "none", paddingTop: 0 }}>
<div>
<div className="setting-label">Bluetooth</div>
<div className="setting-desc">{btState?.powered ? 'Powered on' : 'Off'}</div>
</div>
<div className={`switch ${btState?.powered ? 'on' : ''}`}
onClick={() => handleBtPower(!btState?.powered)} />
</div>
{btState?.powered && (
<>
<div className="setting-row">
<div>
<div className="setting-label">Discoverable</div>
<div className="setting-desc">{btState?.name} ({btState?.address})</div>
</div>
<div className={`switch ${btState?.discoverable ? 'on' : ''}`}
onClick={async () => { try { await apiPost('/api/bluetooth/discoverable', { on: !btState?.discoverable }); setTimeout(refresh, 500); } catch(e) {} }} />
</div>
<div className="setting-row">
<div>
<div className="setting-label">Bluetooth MIDI</div>
<div className="setting-desc">{btState?.midi_enabled ? 'Enabled' : 'Disabled'}</div>
</div>
<div className={`switch ${btState?.midi_enabled ? 'on' : ''}`}
onClick={async () => { try { await (btState?.midi_enabled ? apiPost('/api/bluetooth/midi-disable') : apiPost('/api/bluetooth/midi-enable')); setTimeout(refresh, 500); } catch(e) {} }} />
</div>
</>
)}
</div>
{btState?.powered && (
<div className="card-sm">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
<div className="section-label" style={{ marginBottom: 0 }}>Paired Devices ({btPaired.length})</div>
<button className="btn btn-ghost btn-sm" onClick={scanBt} disabled={btScanning}>
{btScanning ? "..." : "Scan"}
</button>
</div>
{btPaired.length === 0 ? (
<div style={{ fontSize: 12, color: T.textDim, padding: "8px 0" }}>No paired devices.</div>
) : (
btPaired.map(d => (
<div key={d.address} className="preset-row" style={{ padding: "8px 0", borderRadius: 0, borderBottom: `1px solid ${T.border}` }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13 }}>{d.name || d.address}</div>
<div className="mono" style={{ fontSize: 10, color: T.textDim }}>{d.address} · {d.connected ? 'Connected' : 'Paired'}</div>
</div>
{!d.connected && (
<button className="btn btn-ghost btn-sm" onClick={async () => { try { await apiPost('/api/bluetooth/connect', { address: d.address }); } catch(e) { alert('Failed'); } }}>Connect</button>
)}
</div>
))
)}
</div>
)}
{btScanning && btDevices.length > 0 && (
<div className="card-sm">
<div className="section-label" style={{ marginBottom: 6 }}>Discovered</div>
{btDevices.map(d => (
<div key={d.address} className="preset-row" style={{ padding: "8px 0", borderRadius: 0, borderBottom: `1px solid ${T.border}` }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13 }}>{d.name || d.address}</div>
<div className="mono" style={{ fontSize: 10, color: T.textDim }}>{d.address}</div>
</div>
<button className="btn btn-ghost btn-sm" onClick={() => pairBt(d.address)}>Pair</button>
</div>
))}
</div>
)}
</>
)}
{/* ── Routing Tab ───────────────────────────────────────────*/}
{tab === 'routing' && (
<>
<div className="card-sm">
<div className="section-label">4CM Routing</div>
<div className="setting-row">
<div>
<div className="setting-label">Mode</div>
<div className="setting-desc">Mono (normal) or 4CM (4-cable method)</div>
</div>
<select value={routingMode} onChange={e => { 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" }}>
<option value="mono">Mono</option>
<option value="4cm">4CM</option>
</select>
</div>
{routingMode === '4cm' && (
<div className="setting-row">
<div>
<div className="setting-label">Breakpoint</div>
<div className="setting-desc">Chain position for amp send/return</div>
</div>
<input type="number" min={1} max={15} value={routingBp}
onChange={e => { 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" }} />
</div>
)}
</div>
{/* Channel Mode */}
<div className="card-sm">
<div className="section-label">Channel Mode</div>
<div className="setting-row">
<div>
<div className="setting-label">Routing</div>
<div className="setting-desc">Dual-mono: two independent channels · Stereo: linked as L/R pair</div>
</div>
<select value={channelMode} onChange={e => 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" }}>
<option value="dual-mono">Dual-Mono</option>
<option value="stereo">Stereo</option>
</select>
</div>
<div style={{ fontSize: 10, color: T.textDim, marginTop: 4, padding: '4px 0' }}>
{channelMode === 'dual-mono'
? 'Each channel controlled independently from its own phone.'
: 'Channels A (Left) and B (Right) are controlled together.'}
</div>
</div>
</>
)}
</div>
</div>
);
}
// 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 (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className="screen-header">
<div>
<div className="screen-title">Looper / Recorder</div>
<div className="mono" style={{ fontSize: 10, color: T.textDim }}>
{state?.sample_rate ? `${state.sample_rate/1000}kHz` : '—'}
{recording && <span style={{ color: T.red, marginLeft: 8 }}> RECORDING</span>}
</div>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{recording ? (
<button className="btn btn-danger btn-sm" onClick={handleStop} style={{ padding: "8px 16px" }}>
Stop
</button>
) : (
<button className="btn btn-primary btn-sm" onClick={handleRecord} style={{ padding: "8px 16px", background: T.red, color: '#fff' }}>
Record
</button>
)}
</div>
</div>
<div className="screen-body">
{/* Live levels during recording */}
<div className="card" style={{ padding: "16px", display: "flex", gap: 16, alignItems: "center" }}>
<div style={{ flex: 1 }}>
<div className="section-label">Input Level</div>
<div style={{ display: "flex", gap: 2, height: 20, marginTop: 4 }}>
{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 <div key={i} style={{ flex: 1, borderRadius: 2, background: active ? c : T.border,
transition: 'all .05s' }} />;
})}
</div>
</div>
<div style={{ flex: 1 }}>
<div className="section-label">Output Level</div>
<div style={{ display: "flex", gap: 2, height: 20, marginTop: 4 }}>
{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 <div key={i} style={{ flex: 1, borderRadius: 2, background: active ? c : T.border,
transition: 'all .05s' }} />;
})}
</div>
</div>
</div>
{/* Recording status */}
{recording && (
<div className="card-sm" style={{ background: `${T.red}15`, borderColor: T.red, textAlign: 'center', padding: 20 }}>
<div style={{ fontSize: 40, marginBottom: 8, animation: 'pulse 1s ease-in-out infinite' }}>🔴</div>
<div style={{ fontSize: 14, fontWeight: 600, color: T.red }}>Recording...</div>
<div className="mono" style={{ fontSize: 11, color: T.textDim, marginTop: 4 }}>
Saving to captures folder tap Stop to finish
</div>
</div>
)}
{/* Captured files */}
{!recording && (
<>
<div className="card-sm">
<div className="section-label" style={{ marginBottom: 6 }}>Recordings ({captures.length})</div>
{loading ? (
<div className="loader" style={{ margin: '10px auto' }} />
) : captures.length === 0 ? (
<div style={{ fontSize: 12, color: T.textDim, padding: '10px 0' }}>
No recordings yet. Tap Record to capture audio.
</div>
) : (
captures.map(c => (
<div key={c.name} className="preset-row" style={{ padding: '8px 0', borderRadius: 0, borderBottom: `1px solid ${T.border}` }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13 }}>{c.name}</div>
<div className="mono" style={{ fontSize: 10, color: T.textDim }}>{c.size_display}</div>
</div>
<a href={`/api/captures/${c.name}`} target="_blank" rel="noopener"
className="btn btn-ghost btn-sm" style={{ textDecoration: 'none' }}> Play</a>
</div>
))
)}
</div>
</>
)}
</div>
<style>{`@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }`}</style>
</div>
);
}
// ══════════════════════════════════════════════════════════════
// 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 (
<SlidePanel onClose={onClose}>
<div className="screen-header" style={{ justifyContent: "space-between" }}>
<div className="screen-title">Snapshots</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<label style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 10, color: T.textDim, cursor: "pointer" }}>
<div className={`switch ${discardEdits ? 'on' : ''}`} style={{ width: 28, height: 16 }}
onClick={() => setDiscardEdits(d => !d)}>
<div style={{ position: "absolute", top: 1, left: discardEdits ? 11 : 1, width: 13, height: 13,
borderRadius: "50%", background: "#fff", transition: "left .15s" }} />
</div>
Discard Edits
</label>
</div>
</div>
<div className="screen-body" style={{ gap: 8 }}>
<div style={{ fontSize: 11, color: T.textDim, marginBottom: 4 }}>
Tap to recall &middot; Hold to save
{discardEdits && <span style={{ color: T.amber, marginLeft: 8 }}>Edits will be discarded on recall</span>}
</div>
<div className="snap-grid">
{[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 (
<div key={slot} className={`snap-slot ${isFilled ? "filled" : ""} ${isActive ? "active" : ""}`}
onMouseDown={() => handlePointerDown(slot)}
onTouchStart={() => handlePointerDown(slot)}
onMouseUp={() => handlePointerUp(slot)}
onTouchEnd={() => handlePointerUp(slot)}
onMouseLeave={handlePointerLeave}
onTouchCancel={handlePointerLeave}
style={isFlashing ? { background: 'rgba(58,184,122,.15)' } : {}}>
<div className="snap-num">{slot}</div>
{isEditing ? (
<input className="snap-name-input" value={editName}
onChange={e => 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 ? (
<div className="snap-name"
onClick={e => {
e.stopPropagation();
setEditingSlot(slot);
setEditName(snap.name);
}}>{snap.name}</div>
) : (
<div className="snap-empty">Empty</div>
)}
{isFlashing && <div className="snap-save-indicator" />}
{isActive && <div style={{ position: "absolute", bottom: -2, left: "50%", transform: "translateX(-50%)",
width: 20, height: 2, borderRadius: 1, background: T.amber }} />}
</div>
);
})}
</div>
{/* Delete button for active snapshot */}
{currentSlot > 0 && snapshots[String(currentSlot)] && (
<div style={{ marginTop: 8, display: "flex", gap: 8 }}>
<button className="btn btn-danger btn-sm" style={{ flex: 1 }}
onClick={() => handleDelete(currentSlot)}>
Delete Snapshot {currentSlot}
</button>
</div>
)}
</div>
</SlidePanel>
);
}
// ══════════════════════════════════════════════════════════════
// 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 (
<>
<style>{css}</style>
<div style={{ width: "100%", maxWidth: 440, margin: "0 auto", height: "100dvh",
display: "flex", flexDirection: "column", background: T.bg, overflow: "hidden",
fontFamily: "'Inter', sans-serif" }}>
{/* Status bar */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "4px 10px", background: T.panel, borderBottom: `1px solid ${T.border}`, flexShrink: 0 }}>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<div style={{ width: 6, height: 6, borderRadius: "50%",
background: connected ? T.green : T.red,
boxShadow: connected ? `0 0 6px ${T.green}` : "none" }} />
{/* Channel toggle — hidden in stereo mode */}
{state?.channel_mode === 'stereo' ? (
<span className="badge badge-amber" style={{ fontSize: 9, padding: "2px 7px", letterSpacing: ".08em" }}>
STEREO
</span>
) : (
<div className="toggle" style={{ margin: 0, height: 22 }}>
<button className={`toggle-btn ${channel === 'guitar' ? 'active' : ''}`}
onClick={() => handleChannel('guitar')}
style={{ padding: '2px 8px', fontSize: 10, fontWeight: 700, letterSpacing: '.1em' }}>
GUITAR
</button>
<button className={`toggle-btn ${channel === 'bass' ? 'active' : ''}`}
onClick={() => handleChannel('bass')}
style={{ padding: '2px 8px', fontSize: 10, fontWeight: 700, letterSpacing: '.1em' }}>
BASS
</button>
</div>
)}
{state?.tuner_enabled && <span className="badge badge-amber" style={{ fontSize: 8, padding: "1px 5px" }}>TUNER</span>}
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{/* Snapshot camera icon */}
<button className={`snap-header-btn ${showSnapshots ? "active" : ""}`}
onClick={() => setShowSnapshots(true)}
title="Snapshots">
📷
{state?.current_snapshot > 0 && (
<span className="snap-header-num">{state.current_snapshot}</span>
)}
</button>
{state?.cpu_percent != null && (
<span className="mono" style={{ fontSize: 9, color: T.textDim }}>CPU {state.cpu_percent}%</span>
)}
{state?.sample_rate && (
<span className="mono" style={{ fontSize: 9, color: T.textDim }}>{state.sample_rate/1000}kHz</span>
)}
{state?.bypass && <span className="badge badge-red" style={{ fontSize: 8, padding: "1px 5px" }}>BYP</span>}
{state?.nam_loaded && <span className="badge badge-blue" style={{ fontSize: 8, padding: "1px 5px" }}>NAM</span>}
{state?.ir_loaded && <span className="badge badge-green" style={{ fontSize: 8, padding: "1px 5px" }}>IR</span>}
</div>
</div>
{/* Screen */}
<div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column" }}>
{tab === "rig" && <RigScreen state={state} connected={connected} refresh={refresh} />}
{tab === "fx" && <FXScreen state={state} connected={connected} />}
{tab === "models" && <ModelsScreen state={state} connected={connected} refresh={refresh} />}
{tab === "irs" && <IRsScreen state={state} connected={connected} refresh={refresh} />}
{tab === "presets" && <PresetsScreen state={state} connected={connected} refresh={refresh} />}
{tab === "settings" && <SettingsScreen state={state} connected={connected} refresh={refresh} />}
{tab === "record" && <RecordScreen state={state} />}
</div>
{/* Tab bar */}
<div className="tabbar">
{TABS.map(t => (
<div key={t.id} className={`tab ${tab === t.id ? "active" : ""}`} onClick={() => setTab(t.id)}>
<span style={{ fontSize: 14, display: "block" }}>{t.icon}</span>
{t.label}
</div>
))}
</div>
{/* Snapshot panel overlay */}
{showSnapshots && (
<SnapshotPanel onClose={() => setShowSnapshots(false)} state={state} connected={connected} />
)}
</div>
</>
);
}