feat: separate preset banks per channel (guitar/bass)

- Channel enum (GUITAR, BASS) with channel field on Preset dataclass
- Channel-prefixed storage: {root}/{channel}/bank_{bank}/preset_{program}.json
- PresetManager channel awareness: set_channel(), current_channel property
- Per-channel state persistence (channel_state.json per channel dir)
- Legacy migration: flat bank_* dirs auto-migrate to guitar/ on first boot
- All CRUD methods accept optional channel parameter
- API endpoints accept channel param on GET/PUT/DELETE /api/presets
- /api/channel GET/POST for channel switching
- 10 new channel tests (independence, switching, migration, scoping)
- Factory presets install to specified channel
This commit is contained in:
2026-06-12 23:22:47 -04:00
parent 0a21297f77
commit bfe1434f75
10 changed files with 1100 additions and 352 deletions
+87 -8
View File
@@ -20,15 +20,31 @@ const T = {
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}${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}${path}`, {
const r = await fetch(`${API}${_ch(path)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: data != null ? JSON.stringify(data) : undefined,
});
@@ -37,7 +53,7 @@ async function apiPost(path, data) {
}
async function apiPut(path, data) {
const r = await fetch(`${API}${path}`, {
const r = await fetch(`${API}${_ch(path)}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
@@ -46,7 +62,7 @@ async function apiPut(path, data) {
}
async function apiDelete(path) {
const r = await fetch(`${API}${path}`, { method: 'DELETE' });
const r = await fetch(`${API}${_ch(path)}`, { method: 'DELETE' });
if (!r.ok) throw new Error(`${path} => ${r.status}`);
return r.json();
}
@@ -54,7 +70,7 @@ async function apiDelete(path) {
async function apiUpload(path, file) {
const fd = new FormData();
fd.append('file', file);
const r = await fetch(`${API}${path}`, { method: 'POST', body: fd });
const r = await fetch(`${API}${_ch(path)}`, { method: 'POST', body: fd });
if (!r.ok) throw new Error(`${path} => ${r.status}`);
return r.json();
}
@@ -86,6 +102,7 @@ function usePedalState() {
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));
}
@@ -1285,11 +1302,13 @@ function SettingsScreen({ state, connected, refresh }) {
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);
}, [state?.routing_mode, 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);
@@ -1351,6 +1370,13 @@ function SettingsScreen({ state, connected, refresh }) {
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;
@@ -1521,6 +1547,7 @@ function SettingsScreen({ state, connected, refresh }) {
{/* ── Routing Tab ───────────────────────────────────────────*/}
{tab === 'routing' && (
<>
<div className="card-sm">
<div className="section-label">4CM Routing</div>
<div className="setting-row">
@@ -1546,6 +1573,27 @@ function SettingsScreen({ state, connected, refresh }) {
</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>
@@ -1857,10 +1905,23 @@ const TABS = [
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>
@@ -1870,12 +1931,30 @@ export default function App() {
{/* Status bar */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "6px 12px 4px", background: T.panel, borderBottom: `1px solid ${T.border}`, flexShrink: 0 }}>
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" }} />
<span className="mono" style={{ fontSize: 9, color: T.textSec }}>PI MULTI-FX</span>
{/* 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" }}>