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:
@@ -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" }}>
|
||||
|
||||
Reference in New Issue
Block a user