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 API = window.location.origin;
|
||||||
const WS_URL = API.replace(/^http/, 'ws') + '/ws';
|
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 ────────────────────────────────────────────────
|
// ── API helpers ────────────────────────────────────────────────
|
||||||
async function apiGet(path) {
|
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}`);
|
if (!r.ok) throw new Error(`${path} => ${r.status}`);
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiPost(path, data) {
|
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' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: data != null ? JSON.stringify(data) : undefined,
|
body: data != null ? JSON.stringify(data) : undefined,
|
||||||
});
|
});
|
||||||
@@ -37,7 +53,7 @@ async function apiPost(path, data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function apiPut(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' },
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
@@ -46,7 +62,7 @@ async function apiPut(path, data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function apiDelete(path) {
|
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}`);
|
if (!r.ok) throw new Error(`${path} => ${r.status}`);
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
@@ -54,7 +70,7 @@ async function apiDelete(path) {
|
|||||||
async function apiUpload(path, file) {
|
async function apiUpload(path, file) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
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}`);
|
if (!r.ok) throw new Error(`${path} => ${r.status}`);
|
||||||
return r.json();
|
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 === '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 === '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 === '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') {
|
if (msg.type === 'model_loaded' || msg.type === 'ir_loaded') {
|
||||||
apiGet('/api/state').then(s => setState(s));
|
apiGet('/api/state').then(s => setState(s));
|
||||||
}
|
}
|
||||||
@@ -1285,11 +1302,13 @@ function SettingsScreen({ state, connected, refresh }) {
|
|||||||
const [showConnect, setShowConnect] = useState(null);
|
const [showConnect, setShowConnect] = useState(null);
|
||||||
const [routingMode, setRoutingMode] = useState(state?.routing_mode || 'mono');
|
const [routingMode, setRoutingMode] = useState(state?.routing_mode || 'mono');
|
||||||
const [routingBp, setRoutingBp] = useState(state?.routing_breakpoint || 7);
|
const [routingBp, setRoutingBp] = useState(state?.routing_breakpoint || 7);
|
||||||
|
const [channelMode, setChannelMode] = useState(state?.channel_mode || 'dual-mono');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state?.routing_mode) setRoutingMode(state.routing_mode);
|
if (state?.routing_mode) setRoutingMode(state.routing_mode);
|
||||||
if (state?.routing_breakpoint != null) setRoutingBp(state.routing_breakpoint);
|
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 () => {
|
const scanWifi = async () => {
|
||||||
setWifiScanning(true);
|
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 */ }
|
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]);
|
useEffect(() => { if (connected) { scanWifi(); loadBtPaired(); } }, [connected]);
|
||||||
|
|
||||||
const wifiState = state?.wifi;
|
const wifiState = state?.wifi;
|
||||||
@@ -1521,6 +1547,7 @@ function SettingsScreen({ state, connected, refresh }) {
|
|||||||
|
|
||||||
{/* ── Routing Tab ───────────────────────────────────────────*/}
|
{/* ── Routing Tab ───────────────────────────────────────────*/}
|
||||||
{tab === 'routing' && (
|
{tab === 'routing' && (
|
||||||
|
<>
|
||||||
<div className="card-sm">
|
<div className="card-sm">
|
||||||
<div className="section-label">4CM Routing</div>
|
<div className="section-label">4CM Routing</div>
|
||||||
<div className="setting-row">
|
<div className="setting-row">
|
||||||
@@ -1546,6 +1573,27 @@ function SettingsScreen({ state, connected, refresh }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -1857,10 +1905,23 @@ const TABS = [
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const [tab, setTab] = useState("rig");
|
const [tab, setTab] = useState("rig");
|
||||||
const [showSnapshots, setShowSnapshots] = useState(false);
|
const [showSnapshots, setShowSnapshots] = useState(false);
|
||||||
|
const [channel, setChannelState] = useState(() => {
|
||||||
|
try { return localStorage.getItem('pedal_channel') || 'guitar'; } catch { return 'guitar'; }
|
||||||
|
});
|
||||||
const { state, connected, refresh } = usePedalState();
|
const { state, connected, refresh } = usePedalState();
|
||||||
|
|
||||||
useEffect(() => { document.title = "Pi Multi-FX Pedal"; }, []);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{css}</style>
|
<style>{css}</style>
|
||||||
@@ -1870,12 +1931,30 @@ export default function App() {
|
|||||||
|
|
||||||
{/* Status bar */}
|
{/* Status bar */}
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
|
<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={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||||
<div style={{ width: 6, height: 6, borderRadius: "50%",
|
<div style={{ width: 6, height: 6, borderRadius: "50%",
|
||||||
background: connected ? T.green : T.red,
|
background: connected ? T.green : T.red,
|
||||||
boxShadow: connected ? `0 0 6px ${T.green}` : "none" }} />
|
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>}
|
{state?.tuner_enabled && <span className="badge badge-amber" style={{ fontSize: 8, padding: "1px 5px" }}>TUNER</span>}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from .types import Bank, Channel, FXBlock, FXType, MIDIMapping, Preset, Snapshot
|
||||||
|
from .manager import PresetManager
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Bank", "Channel", "FXBlock", "FXType", "MIDIMapping",
|
||||||
|
"Preset", "PresetManager", "Snapshot",
|
||||||
|
]
|
||||||
|
|||||||
+256
-56
@@ -19,7 +19,7 @@ import threading
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .types import Bank, FXBlock, FXType, MIDIMapping, Preset, Snapshot
|
from .types import Bank, Channel, FXBlock, FXType, MIDIMapping, Preset, Snapshot
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ def _preset_to_dict(preset: Preset) -> dict:
|
|||||||
"""Serialize a Preset to a JSON-compatible dict."""
|
"""Serialize a Preset to a JSON-compatible dict."""
|
||||||
return {
|
return {
|
||||||
"name": preset.name,
|
"name": preset.name,
|
||||||
|
"channel": preset.channel.value,
|
||||||
"bank": preset.bank,
|
"bank": preset.bank,
|
||||||
"program": preset.program,
|
"program": preset.program,
|
||||||
"master_volume": preset.master_volume,
|
"master_volume": preset.master_volume,
|
||||||
@@ -137,6 +138,7 @@ def _preset_from_dict(data: dict) -> Preset:
|
|||||||
|
|
||||||
return Preset(
|
return Preset(
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
|
channel=Channel(data.get("channel", "guitar")),
|
||||||
bank=data.get("bank", 0),
|
bank=data.get("bank", 0),
|
||||||
program=data.get("program", 0),
|
program=data.get("program", 0),
|
||||||
chain=chain,
|
chain=chain,
|
||||||
@@ -171,6 +173,7 @@ class PresetManager:
|
|||||||
self._pipeline = audio_pipeline
|
self._pipeline = audio_pipeline
|
||||||
|
|
||||||
# Runtime state
|
# Runtime state
|
||||||
|
self._current_channel: Channel = Channel.GUITAR
|
||||||
self._current_bank: int = 0
|
self._current_bank: int = 0
|
||||||
self._current_program: int = 0
|
self._current_program: int = 0
|
||||||
self._dirty = False
|
self._dirty = False
|
||||||
@@ -185,10 +188,17 @@ class PresetManager:
|
|||||||
# Current snapshot slot (0 = none selected)
|
# Current snapshot slot (0 = none selected)
|
||||||
self._current_snapshot: int = 0
|
self._current_snapshot: int = 0
|
||||||
|
|
||||||
logger.info("PresetManager root: %s", self._dir)
|
# Migrate legacy flat presets → gtr channel on first boot
|
||||||
|
self._migrate_legacy_presets()
|
||||||
|
|
||||||
|
logger.info("PresetManager root: %s (channel=%s)", self._dir, self._current_channel.value)
|
||||||
|
|
||||||
# ── Public navigation API ───────────────────────────────────────────────
|
# ── Public navigation API ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_channel(self) -> Channel:
|
||||||
|
return self._current_channel
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_bank(self) -> int:
|
def current_bank(self) -> int:
|
||||||
return self._current_bank
|
return self._current_bank
|
||||||
@@ -197,10 +207,43 @@ class PresetManager:
|
|||||||
def current_program(self) -> int:
|
def current_program(self) -> int:
|
||||||
return self._current_program
|
return self._current_program
|
||||||
|
|
||||||
|
def set_channel(self, channel: Channel) -> Preset:
|
||||||
|
"""Switch the active channel and restore its last preset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: The channel to switch to.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The currently active Preset in the new channel.
|
||||||
|
"""
|
||||||
|
if channel == self._current_channel:
|
||||||
|
try:
|
||||||
|
return self.load(self._current_bank, self._current_program)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return self._select_and_activate(self._current_bank, self._current_program)
|
||||||
|
|
||||||
|
# Save current channel state first
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
|
self._current_channel = channel
|
||||||
|
|
||||||
|
# Try restoring channel-specific state
|
||||||
|
restored = self._restore_channel_state()
|
||||||
|
if restored:
|
||||||
|
return restored
|
||||||
|
|
||||||
|
# Default to bank 0, program 0 in the new channel
|
||||||
|
try:
|
||||||
|
return self._select_and_activate(0, 0)
|
||||||
|
except Exception:
|
||||||
|
# Channel is empty — create first blank preset
|
||||||
|
return self._select_and_activate(0, 0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_preset_path(self) -> Path:
|
def current_preset_path(self) -> Path:
|
||||||
"""Path to the slot for the current (bank, program)."""
|
"""Path to the slot for the current (channel, bank, program)."""
|
||||||
return self._preset_path(self._current_bank, self._current_program)
|
return self._preset_path(self._current_channel, self._current_bank, self._current_program)
|
||||||
|
|
||||||
def preset_up(self) -> Preset:
|
def preset_up(self) -> Preset:
|
||||||
"""Select next preset in current bank (wraps 3→0).
|
"""Select next preset in current bank (wraps 3→0).
|
||||||
@@ -240,17 +283,18 @@ class PresetManager:
|
|||||||
"""
|
"""
|
||||||
return self._switch_bank(-1)
|
return self._switch_bank(-1)
|
||||||
|
|
||||||
def select(self, bank: int, program: int) -> Preset:
|
def select(self, bank: int, program: int, channel: Channel | None = None) -> Preset:
|
||||||
"""Directly select a specific (bank, program) slot.
|
"""Directly select a specific (bank, program) slot.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
bank: Bank number.
|
bank: Bank number.
|
||||||
program: Preset index (0-3) within the bank.
|
program: Preset index (0-3) within the bank.
|
||||||
|
channel: Optional channel override (defaults to current).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The activated Preset.
|
The activated Preset.
|
||||||
"""
|
"""
|
||||||
return self._select_and_activate(bank, program)
|
return self._select_and_activate(bank, program, channel or self._current_channel)
|
||||||
|
|
||||||
def midi_pc(self, channel: int, program: int) -> Preset:
|
def midi_pc(self, channel: int, program: int) -> Preset:
|
||||||
"""Handle MIDI Program Change: bank=channel, program=program.
|
"""Handle MIDI Program Change: bank=channel, program=program.
|
||||||
@@ -267,15 +311,17 @@ class PresetManager:
|
|||||||
|
|
||||||
# ── CRUD ────────────────────────────────────────────────────────────────
|
# ── CRUD ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def save(self, preset: Preset, *, auto: bool = False) -> None:
|
def save(self, preset: Preset, *, auto: bool = False, channel: Channel | None = None) -> None:
|
||||||
"""Save a preset to its (bank, program) slot on disk.
|
"""Save a preset to its (bank, program) slot on disk.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
preset: The preset to persist.
|
preset: The preset to persist.
|
||||||
auto: If True, this was triggered by auto-save and the
|
auto: If True, this was triggered by auto-save and the
|
||||||
dirty/current-state write is implicitly handled.
|
dirty/current-state write is implicitly handled.
|
||||||
|
channel: Channel to save under (defaults to preset.channel).
|
||||||
"""
|
"""
|
||||||
path = self._preset_path(preset.bank, preset.program)
|
ch = channel or preset.channel
|
||||||
|
path = self._preset_path(ch, preset.bank, preset.program)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(
|
path.write_text(
|
||||||
json.dumps(_preset_to_dict(preset), indent=2, sort_keys=True),
|
json.dumps(_preset_to_dict(preset), indent=2, sort_keys=True),
|
||||||
@@ -287,16 +333,17 @@ class PresetManager:
|
|||||||
self._known_banks.add(preset.bank)
|
self._known_banks.add(preset.bank)
|
||||||
|
|
||||||
# Save/update bank metadata
|
# Save/update bank metadata
|
||||||
self._save_bank_meta(preset.bank)
|
self._save_bank_meta(ch, preset.bank)
|
||||||
|
|
||||||
logger.info("Saved preset '%s' → %s", preset.name, path)
|
logger.info("Saved preset '%s' → %s", preset.name, path)
|
||||||
|
|
||||||
def load(self, bank: int, program: int) -> Preset:
|
def load(self, bank: int, program: int, channel: Channel | None = None) -> Preset:
|
||||||
"""Load a preset from disk.
|
"""Load a preset from disk.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
bank: Bank number.
|
bank: Bank number.
|
||||||
program: Preset index (0-3).
|
program: Preset index (0-3).
|
||||||
|
channel: Channel to load from (defaults to current).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The deserialized Preset.
|
The deserialized Preset.
|
||||||
@@ -305,41 +352,52 @@ class PresetManager:
|
|||||||
FileNotFoundError: If the preset slot doesn't exist.
|
FileNotFoundError: If the preset slot doesn't exist.
|
||||||
json.JSONDecodeError: If the file is corrupt.
|
json.JSONDecodeError: If the file is corrupt.
|
||||||
"""
|
"""
|
||||||
path = self._preset_path(bank, program)
|
ch = channel or self._current_channel
|
||||||
|
path = self._preset_path(ch, bank, program)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
f"No preset at (bank={bank}, program={program}): {path}"
|
f"No preset at (channel={ch.value}, bank={bank}, program={program}): {path}"
|
||||||
)
|
)
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
preset = _preset_from_dict(data)
|
preset = _preset_from_dict(data)
|
||||||
# Ensure metadata is correct even if file was manually altered
|
# Ensure metadata is correct even if file was manually altered
|
||||||
|
preset.channel = ch
|
||||||
preset.bank = bank
|
preset.bank = bank
|
||||||
preset.program = program
|
preset.program = program
|
||||||
return preset
|
return preset
|
||||||
|
|
||||||
def delete(self, bank: int, program: int) -> None:
|
def delete(self, bank: int, program: int, channel: Channel | None = None) -> None:
|
||||||
"""Delete a preset slot from disk."""
|
"""Delete a preset slot from disk.
|
||||||
path = self._preset_path(bank, program)
|
|
||||||
|
Args:
|
||||||
|
bank: Bank number.
|
||||||
|
program: Preset index.
|
||||||
|
channel: Channel to delete from (defaults to current).
|
||||||
|
"""
|
||||||
|
ch = channel or self._current_channel
|
||||||
|
path = self._preset_path(ch, bank, program)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
path.unlink()
|
path.unlink()
|
||||||
logger.info("Deleted preset at (bank=%d, program=%d)", bank, program)
|
logger.info("Deleted preset at (channel=%s, bank=%d, program=%d)", ch.value, bank, program)
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
|
|
||||||
def rename(self, bank: int, program: int, new_name: str) -> Preset:
|
def rename(self, bank: int, program: int, new_name: str, channel: Channel | None = None) -> Preset:
|
||||||
"""Rename a preset and re-save it.
|
"""Rename a preset and re-save it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
bank: Bank number.
|
bank: Bank number.
|
||||||
program: Preset index.
|
program: Preset index.
|
||||||
new_name: Replacement name.
|
new_name: Replacement name.
|
||||||
|
channel: Channel (defaults to current).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The renamed Preset.
|
The renamed Preset.
|
||||||
"""
|
"""
|
||||||
preset = self.load(bank, program)
|
preset = self.load(bank, program, channel)
|
||||||
preset.name = new_name
|
preset.name = new_name
|
||||||
self.save(preset)
|
self.save(preset, channel=channel)
|
||||||
logger.info("Renamed preset at (%d,%d) → '%s'", bank, program, new_name)
|
logger.info("Renamed preset at (%s,%d,%d) → '%s'",
|
||||||
|
(channel or self._current_channel).value, bank, program, new_name)
|
||||||
return preset
|
return preset
|
||||||
|
|
||||||
def reorder(self, bank: int, program: int, new_program: int) -> None:
|
def reorder(self, bank: int, program: int, new_program: int) -> None:
|
||||||
@@ -415,10 +473,22 @@ class PresetManager:
|
|||||||
|
|
||||||
# ── Bank management ─────────────────────────────────────────────────────
|
# ── Bank management ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def list_banks(self) -> list[Bank]:
|
def list_banks(self, channel: Channel | None = None) -> list[Bank]:
|
||||||
"""Scan the preset directory and return known banks sorted by number."""
|
"""Scan the preset directory and return known banks sorted by number.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: Channel to list banks for (defaults to current).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Bank objects sorted by number.
|
||||||
|
"""
|
||||||
|
ch = channel or self._current_channel
|
||||||
|
ch_dir = self._dir / ch.value
|
||||||
|
if not ch_dir.is_dir():
|
||||||
|
return []
|
||||||
|
|
||||||
banks: dict[int, Bank] = {}
|
banks: dict[int, Bank] = {}
|
||||||
for meta_path in sorted(self._dir.glob("bank_*/bank.json")):
|
for meta_path in sorted(ch_dir.glob("bank_*/bank.json")):
|
||||||
try:
|
try:
|
||||||
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||||
num = data.get("number", 0)
|
num = data.get("number", 0)
|
||||||
@@ -434,7 +504,7 @@ class PresetManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Also discover banks by scanning dirs without bank.json
|
# Also discover banks by scanning dirs without bank.json
|
||||||
for p_dir in sorted(self._dir.glob("bank_*")):
|
for p_dir in sorted(ch_dir.glob("bank_*")):
|
||||||
try:
|
try:
|
||||||
num = int(p_dir.name.split("_")[1])
|
num = int(p_dir.name.split("_")[1])
|
||||||
if num not in banks:
|
if num not in banks:
|
||||||
@@ -460,20 +530,25 @@ class PresetManager:
|
|||||||
# ── Auto-restore ────────────────────────────────────────────────────────
|
# ── Auto-restore ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def save_state(self) -> None:
|
def save_state(self) -> None:
|
||||||
"""Persist the current (bank, program) for power-cycle restore.
|
"""Persist the current (channel, bank, program) for power-cycle restore.
|
||||||
|
|
||||||
Call this whenever the active preset changes to guarantee
|
Call this whenever the active preset or channel changes to guarantee
|
||||||
the pedal comes back to the same sound after a power cycle.
|
the pedal comes back to the same sound after a power cycle.
|
||||||
"""
|
"""
|
||||||
state = {"current_bank": self._current_bank, "current_program": self._current_program}
|
state = {
|
||||||
|
"current_channel": self._current_channel.value,
|
||||||
|
"current_bank": self._current_bank,
|
||||||
|
"current_program": self._current_program,
|
||||||
|
}
|
||||||
state_path = self._dir / "state.json"
|
state_path = self._dir / "state.json"
|
||||||
state_path.write_text(
|
state_path.write_text(
|
||||||
json.dumps(state, indent=2), encoding="utf-8"
|
json.dumps(state, indent=2), encoding="utf-8"
|
||||||
)
|
)
|
||||||
logger.debug("State saved: bank=%d program=%d", self._current_bank, self._current_program)
|
logger.debug("State saved: channel=%s bank=%d program=%d",
|
||||||
|
self._current_channel.value, self._current_bank, self._current_program)
|
||||||
|
|
||||||
def restore_state(self) -> Optional[Preset]:
|
def restore_state(self) -> Optional[Preset]:
|
||||||
"""Restore the last active preset from state.json.
|
"""Restore the last active (channel, bank, program) from state.json.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The restored Preset, or None if no state file exists or the
|
The restored Preset, or None if no state file exists or the
|
||||||
@@ -486,17 +561,20 @@ class PresetManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||||
|
ch = Channel(state.get("current_channel", "guitar"))
|
||||||
bank = state["current_bank"]
|
bank = state["current_bank"]
|
||||||
program = state["current_program"]
|
program = state["current_program"]
|
||||||
preset = self.load(bank, program)
|
preset = self.load(bank, program, ch)
|
||||||
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
except (FileNotFoundError, json.JSONDecodeError, KeyError, ValueError):
|
||||||
logger.warning("Could not restore state, falling back to first preset")
|
logger.warning("Could not restore state, falling back to first preset")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
self._current_channel = ch
|
||||||
self._current_bank = bank
|
self._current_bank = bank
|
||||||
self._current_program = program
|
self._current_program = program
|
||||||
self._dirty = False
|
self._dirty = False
|
||||||
logger.info("Restored state: bank=%d program=%d '%s'", bank, program, preset.name)
|
logger.info("Restored state: channel=%s bank=%d program=%d '%s'",
|
||||||
|
ch.value, bank, program, preset.name)
|
||||||
return preset
|
return preset
|
||||||
|
|
||||||
# ── Activation ──────────────────────────────────────────────────────────
|
# ── Activation ──────────────────────────────────────────────────────────
|
||||||
@@ -514,7 +592,8 @@ class PresetManager:
|
|||||||
|
|
||||||
# ── Factory presets ─────────────────────────────────────────────────────
|
# ── Factory presets ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def install_factory_presets(self, overwrite: bool = False) -> int:
|
def install_factory_presets(self, overwrite: bool = False,
|
||||||
|
channel: Channel = Channel.GUITAR) -> int:
|
||||||
"""Install bundled factory presets into the preset store.
|
"""Install bundled factory presets into the preset store.
|
||||||
|
|
||||||
Scans ``FACTORY_PRESET_DIR`` for ``bank_*/preset_*.json`` files
|
Scans ``FACTORY_PRESET_DIR`` for ``bank_*/preset_*.json`` files
|
||||||
@@ -523,6 +602,7 @@ class PresetManager:
|
|||||||
Args:
|
Args:
|
||||||
overwrite: If True, overwrite existing presets at the same
|
overwrite: If True, overwrite existing presets at the same
|
||||||
(bank, program) slots. If False, skip occupied slots.
|
(bank, program) slots. If False, skip occupied slots.
|
||||||
|
channel: Which channel to install factory presets into (default GUITAR).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of factory presets installed.
|
Number of factory presets installed.
|
||||||
@@ -542,14 +622,14 @@ class PresetManager:
|
|||||||
logger.warning("Skipping malformed factory preset path: %s", src)
|
logger.warning("Skipping malformed factory preset path: %s", src)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
dest = self._preset_path(bank_num, prog_num)
|
dest = self._preset_path(channel, bank_num, prog_num)
|
||||||
if dest.exists() and not overwrite:
|
if dest.exists() and not overwrite:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy2(src, dest)
|
shutil.copy2(src, dest)
|
||||||
self._known_banks.add(bank_num)
|
self._known_banks.add(bank_num)
|
||||||
self._save_bank_meta(bank_num)
|
self._save_bank_meta(channel, bank_num)
|
||||||
count += 1
|
count += 1
|
||||||
logger.debug("Installed factory preset: %s", dest)
|
logger.debug("Installed factory preset: %s", dest)
|
||||||
|
|
||||||
@@ -595,19 +675,31 @@ class PresetManager:
|
|||||||
|
|
||||||
# ── Internal helpers ────────────────────────────────────────────────────
|
# ── Internal helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _preset_path(self, bank: int, program: int) -> Path:
|
def _preset_path(self, channel: Channel, bank: int, program: int) -> Path:
|
||||||
"""Compute the on-disk path for a (bank, program) slot."""
|
"""Compute the on-disk path for a (channel, bank, program) slot."""
|
||||||
return self._dir / f"bank_{bank}" / f"preset_{program}.json"
|
return self._dir / channel.value / f"bank_{bank}" / f"preset_{program}.json"
|
||||||
|
|
||||||
def _bank_dir(self, bank: int) -> Path:
|
def _channel_dir(self, channel: Channel) -> Path:
|
||||||
return self._dir / f"bank_{bank}"
|
"""Return the root directory for a given channel."""
|
||||||
|
return self._dir / channel.value
|
||||||
|
|
||||||
def _bank_meta_path(self, bank: int) -> Path:
|
def _bank_dir(self, channel: Channel, bank: int) -> Path:
|
||||||
return self._bank_dir(bank) / "bank.json"
|
return self._channel_dir(channel) / f"bank_{bank}"
|
||||||
|
|
||||||
def _get_or_create_bank(self, number: int, name: str = "") -> Bank:
|
def _bank_meta_path(self, channel: Channel, bank: int) -> Path:
|
||||||
"""Return an existing bank or create a new one on disk."""
|
return self._bank_dir(channel, bank) / "bank.json"
|
||||||
meta_path = self._bank_meta_path(number)
|
|
||||||
|
def _get_or_create_bank(self, number: int, name: str = "",
|
||||||
|
channel: Channel | None = None) -> Bank:
|
||||||
|
"""Return an existing bank or create a new one on disk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
number: Bank number.
|
||||||
|
name: Optional display name.
|
||||||
|
channel: Channel (defaults to current).
|
||||||
|
"""
|
||||||
|
ch = channel or self._current_channel
|
||||||
|
meta_path = self._bank_meta_path(ch, number)
|
||||||
if meta_path.exists():
|
if meta_path.exists():
|
||||||
try:
|
try:
|
||||||
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||||
@@ -620,14 +712,15 @@ class PresetManager:
|
|||||||
|
|
||||||
# Create new bank
|
# Create new bank
|
||||||
bank = Bank(name=name or f"Bank {number}", number=number)
|
bank = Bank(name=name or f"Bank {number}", number=number)
|
||||||
self._save_bank_meta(number, bank.name)
|
self._save_bank_meta(ch, number, bank.name)
|
||||||
self._known_banks.add(number)
|
self._known_banks.add(number)
|
||||||
logger.info("Created bank %d: '%s'", number, bank.name)
|
logger.info("Created bank %d: '%s' (channel=%s)", number, bank.name, ch.value)
|
||||||
return bank
|
return bank
|
||||||
|
|
||||||
def _save_bank_meta(self, bank_num: int, name: str | None = None) -> None:
|
def _save_bank_meta(self, channel: Channel, bank_num: int,
|
||||||
|
name: str | None = None) -> None:
|
||||||
"""Write or update a bank's metadata file."""
|
"""Write or update a bank's metadata file."""
|
||||||
meta_path = self._bank_meta_path(bank_num)
|
meta_path = self._bank_meta_path(channel, bank_num)
|
||||||
existing = {}
|
existing = {}
|
||||||
if meta_path.exists():
|
if meta_path.exists():
|
||||||
try:
|
try:
|
||||||
@@ -673,6 +766,7 @@ class PresetManager:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
preset = Preset(
|
preset = Preset(
|
||||||
name=f"Empty {new_bank.name}",
|
name=f"Empty {new_bank.name}",
|
||||||
|
channel=self._current_channel,
|
||||||
bank=new_bank.number,
|
bank=new_bank.number,
|
||||||
program=self._current_program,
|
program=self._current_program,
|
||||||
)
|
)
|
||||||
@@ -680,11 +774,14 @@ class PresetManager:
|
|||||||
self._current_bank = new_bank.number
|
self._current_bank = new_bank.number
|
||||||
self._current_program = preset.program
|
self._current_program = preset.program
|
||||||
self.activate(preset)
|
self.activate(preset)
|
||||||
|
self._save_channel_state()
|
||||||
self.save_state()
|
self.save_state()
|
||||||
logger.info("Switched to bank %d, preset '%s'", new_bank.number, preset.name)
|
logger.info("Switched to bank %d, preset '%s' (channel=%s)",
|
||||||
|
new_bank.number, preset.name, self._current_channel.value)
|
||||||
return new_bank, preset
|
return new_bank, preset
|
||||||
|
|
||||||
def _select_and_activate(self, bank: int, program: int) -> Preset:
|
def _select_and_activate(self, bank: int, program: int,
|
||||||
|
channel: Channel | None = None) -> Preset:
|
||||||
"""Select a (bank, program) slot and activate it.
|
"""Select a (bank, program) slot and activate it.
|
||||||
|
|
||||||
If the slot is empty, creates a blank preset in that slot first.
|
If the slot is empty, creates a blank preset in that slot first.
|
||||||
@@ -692,22 +789,125 @@ class PresetManager:
|
|||||||
Args:
|
Args:
|
||||||
bank: Bank number.
|
bank: Bank number.
|
||||||
program: Preset index (0-3).
|
program: Preset index (0-3).
|
||||||
|
channel: Channel to operate on (defaults to current).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The activated Preset.
|
The activated Preset.
|
||||||
"""
|
"""
|
||||||
|
ch = channel or self._current_channel
|
||||||
program = max(0, min(PRESETS_PER_BANK - 1, program))
|
program = max(0, min(PRESETS_PER_BANK - 1, program))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
preset = self.load(bank, program)
|
preset = self.load(bank, program, ch)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# Create a blank placeholder preset
|
# Create a blank placeholder preset
|
||||||
bank_obj = self._get_or_create_bank(bank)
|
bank_obj = self._get_or_create_bank(bank, channel=ch)
|
||||||
preset = Preset(name=f"New {bank_obj.name} #{program}", bank=bank, program=program)
|
preset = Preset(
|
||||||
self.save(preset, auto=True)
|
name=f"New {bank_obj.name} #{program}",
|
||||||
|
channel=ch, bank=bank, program=program,
|
||||||
|
)
|
||||||
|
self.save(preset, auto=True, channel=ch)
|
||||||
|
|
||||||
|
self._current_channel = ch
|
||||||
self._current_bank = bank
|
self._current_bank = bank
|
||||||
self._current_program = program
|
self._current_program = program
|
||||||
self.activate(preset)
|
self.activate(preset)
|
||||||
|
self._save_channel_state(ch)
|
||||||
self.save_state()
|
self.save_state()
|
||||||
return preset
|
return preset
|
||||||
|
|
||||||
|
# ── Channel state helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def _channel_state_path(self, channel: Channel) -> Path:
|
||||||
|
"""Path to the per-channel state file."""
|
||||||
|
return self._channel_dir(channel) / "channel_state.json"
|
||||||
|
|
||||||
|
def _save_channel_state(self, channel: Channel | None = None) -> None:
|
||||||
|
"""Persist the channel-specific (bank, program) state."""
|
||||||
|
ch = channel or self._current_channel
|
||||||
|
state = {
|
||||||
|
"current_bank": self._current_bank,
|
||||||
|
"current_program": self._current_program,
|
||||||
|
}
|
||||||
|
path = self._channel_state_path(ch)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(state, indent=2), encoding="utf-8")
|
||||||
|
logger.debug("Channel state saved for %s: bank=%d program=%d",
|
||||||
|
ch.value, self._current_bank, self._current_program)
|
||||||
|
|
||||||
|
def _restore_channel_state(self, channel: Channel | None = None) -> Optional[Preset]:
|
||||||
|
"""Restore the per-channel (bank, program) state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: Channel to restore (defaults to current).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The restored Preset, or None if no saved state exists.
|
||||||
|
"""
|
||||||
|
ch = channel or self._current_channel
|
||||||
|
path = self._channel_state_path(ch)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
state = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
bank = state["current_bank"]
|
||||||
|
program = state["current_program"]
|
||||||
|
preset = self.load(bank, program, ch)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._current_bank = bank
|
||||||
|
self._current_program = program
|
||||||
|
logger.info("Restored channel state for %s: bank=%d program=%d '%s'",
|
||||||
|
ch.value, bank, program, preset.name)
|
||||||
|
return preset
|
||||||
|
|
||||||
|
# ── Legacy migration ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _migrate_legacy_presets(self) -> None:
|
||||||
|
"""Migrate flat bank_* directories under root into gtr/ subdirectory.
|
||||||
|
|
||||||
|
Before dual-channel support, presets were stored as:
|
||||||
|
{root}/bank_0/preset_0.json
|
||||||
|
|
||||||
|
Now they should live under:
|
||||||
|
{root}/gtr/bank_0/preset_0.json
|
||||||
|
|
||||||
|
This runs once on first boot with the new layout.
|
||||||
|
"""
|
||||||
|
ch_dir = self._dir / "guitar"
|
||||||
|
if ch_dir.exists():
|
||||||
|
# Already migrated or new install — nothing to do
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if there are legacy flat bank_* dirs
|
||||||
|
legacy_banks = sorted(self._dir.glob("bank_*"))
|
||||||
|
if not legacy_banks:
|
||||||
|
return # Fresh install, nothing to migrate
|
||||||
|
|
||||||
|
logger.info("Migrating %d legacy bank directories → gtr channel ...", len(legacy_banks))
|
||||||
|
ch_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for src in legacy_banks:
|
||||||
|
dest = ch_dir / src.name
|
||||||
|
try:
|
||||||
|
shutil.move(str(src), str(dest))
|
||||||
|
logger.debug(" migrated %s → %s", src.name, dest)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(" failed to migrate %s: %s", src.name, e)
|
||||||
|
|
||||||
|
# Also migrate state.json if it exists at root
|
||||||
|
legacy_state = self._dir / "state.json"
|
||||||
|
if legacy_state.exists():
|
||||||
|
try:
|
||||||
|
state = json.loads(legacy_state.read_text(encoding="utf-8"))
|
||||||
|
if "current_channel" not in state:
|
||||||
|
state["current_channel"] = "guitar"
|
||||||
|
legacy_state.write_text(
|
||||||
|
json.dumps(state, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info("Legacy preset migration complete")
|
||||||
@@ -7,6 +7,12 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Channel(enum.StrEnum):
|
||||||
|
"""Guitar or Bass signal channel — each has its own independent preset bank."""
|
||||||
|
GUITAR = "guitar"
|
||||||
|
BASS = "bass"
|
||||||
|
|
||||||
|
|
||||||
class FXType(enum.StrEnum):
|
class FXType(enum.StrEnum):
|
||||||
"""Types of effects in the pedal signal chain."""
|
"""Types of effects in the pedal signal chain."""
|
||||||
# Existing
|
# Existing
|
||||||
@@ -101,6 +107,7 @@ class Snapshot:
|
|||||||
class Preset:
|
class Preset:
|
||||||
"""A complete pedal preset — full signal chain state."""
|
"""A complete pedal preset — full signal chain state."""
|
||||||
name: str
|
name: str
|
||||||
|
channel: Channel = Channel.GUITAR
|
||||||
bank: int = 0
|
bank: int = 0
|
||||||
program: int = 0
|
program: int = 0
|
||||||
chain: list[FXBlock] = field(default_factory=list)
|
chain: list[FXBlock] = field(default_factory=list)
|
||||||
|
|||||||
+404
-99
@@ -32,7 +32,7 @@ from ..dsp.nam_host import NAMHost
|
|||||||
from ..dsp.ir_loader import IRLoader
|
from ..dsp.ir_loader import IRLoader
|
||||||
from ..dsp.pipeline import AudioPipeline, SAMPLE_RATE
|
from ..dsp.pipeline import AudioPipeline, SAMPLE_RATE
|
||||||
from ..presets.manager import PresetManager
|
from ..presets.manager import PresetManager
|
||||||
from ..presets.types import FXBlock, FXType, Preset
|
from ..presets.types import Channel, FXBlock, FXType, Preset
|
||||||
from ..system.tonedownload import (
|
from ..system.tonedownload import (
|
||||||
Tone3000Client,
|
Tone3000Client,
|
||||||
ModelResult,
|
ModelResult,
|
||||||
@@ -64,22 +64,156 @@ else:
|
|||||||
]
|
]
|
||||||
UI_DIST_DIR = next((d for d in _candidates if d.is_dir()), None)
|
UI_DIST_DIR = next((d for d in _candidates if d.is_dir()), None)
|
||||||
|
|
||||||
|
# ── Channel support ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
VALID_CHANNELS: frozenset = frozenset({"guitar", "bass"})
|
||||||
|
|
||||||
|
def _resolve_channel(channel: str = "guitar") -> str:
|
||||||
|
"""Normalize a channel string; fall back to 'guitar' for invalid values."""
|
||||||
|
if isinstance(channel, str) and channel.lower() in VALID_CHANNELS:
|
||||||
|
return channel.lower()
|
||||||
|
return "guitar"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Channel mode persistence ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CHANNEL_MODE_FILE = Path.home() / ".pedal" / "channel_mode.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_channel_mode() -> str:
|
||||||
|
"""Load channel mode from disk; default to 'dual-mono'."""
|
||||||
|
try:
|
||||||
|
if CHANNEL_MODE_FILE.exists():
|
||||||
|
data = json.loads(CHANNEL_MODE_FILE.read_text())
|
||||||
|
mode = data.get("mode", "dual-mono")
|
||||||
|
if mode in ("dual-mono", "stereo"):
|
||||||
|
return mode
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "dual-mono"
|
||||||
|
|
||||||
|
|
||||||
|
def _save_channel_mode(mode: str) -> None:
|
||||||
|
"""Persist channel mode to disk."""
|
||||||
|
CHANNEL_MODE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
CHANNEL_MODE_FILE.write_text(json.dumps({"mode": mode}))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChannelState:
|
||||||
|
"""Per-channel runtime state (volume, bypass, tuner)."""
|
||||||
|
volume: float = 0.8
|
||||||
|
bypass: bool = False
|
||||||
|
tuner_enabled: bool = False
|
||||||
|
current_snapshot: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelManager:
|
||||||
|
"""Manages dual-channel vs stereo routing mode and per-channel state.
|
||||||
|
|
||||||
|
In ``dual-mono`` mode each channel (guitar / bass) has independent
|
||||||
|
runtime state. In ``stereo`` mode the two channels are linked —
|
||||||
|
the ``guitar`` channel dict is the single source of truth for both.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.mode: str = _load_channel_mode()
|
||||||
|
self._channels: dict[str, ChannelState] = {
|
||||||
|
"guitar": ChannelState(),
|
||||||
|
"bass": ChannelState(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Mode management ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_mode(self, mode: str) -> None:
|
||||||
|
if mode not in ("dual-mono", "stereo"):
|
||||||
|
raise ValueError(f"Unknown channel mode: {mode!r}")
|
||||||
|
self.mode = mode
|
||||||
|
_save_channel_mode(mode)
|
||||||
|
|
||||||
|
# ── Per-channel helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def state_for(self, channel: str) -> ChannelState:
|
||||||
|
"""Return the ``ChannelState`` for a given channel.
|
||||||
|
|
||||||
|
In stereo mode both channels read from the ``guitar`` slot.
|
||||||
|
Falls back to ``guitar`` for unknown channel names.
|
||||||
|
"""
|
||||||
|
if self.mode == "stereo":
|
||||||
|
return self._channels["guitar"]
|
||||||
|
return self._channels.get(channel, self._channels["guitar"])
|
||||||
|
|
||||||
|
def sync_to_stereo(self) -> None:
|
||||||
|
"""When switching *to* stereo, copy bass -> guitar so guitar has
|
||||||
|
the most recently changed values from either channel."""
|
||||||
|
if self.mode == "stereo":
|
||||||
|
g = self._channels["guitar"]
|
||||||
|
b = self._channels["bass"]
|
||||||
|
# Pick the "latest" non-default values
|
||||||
|
for attr in ("volume", "bypass", "tuner_enabled", "current_snapshot"):
|
||||||
|
bv = getattr(b, attr)
|
||||||
|
if bv != getattr(ChannelState(), attr):
|
||||||
|
setattr(g, attr, bv)
|
||||||
|
|
||||||
|
def resolve_channel(self, param: str | None) -> str:
|
||||||
|
"""Return the active channel name.
|
||||||
|
|
||||||
|
In stereo mode always returns ``"guitar"`` (the canonical slot).
|
||||||
|
In dual-mono returns *param* if it's ``"guitar"`` or ``"bass"``,
|
||||||
|
otherwise falls back to ``"guitar"``.
|
||||||
|
"""
|
||||||
|
if self.mode == "stereo":
|
||||||
|
return "guitar"
|
||||||
|
if param in ("guitar", "bass"):
|
||||||
|
return param
|
||||||
|
return "guitar"
|
||||||
|
|
||||||
|
|
||||||
# ── Dependency container ─────────────────────────────────────────────────────
|
# ── Dependency container ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WebServerDeps:
|
class WebServerDeps:
|
||||||
"""Dependencies injected into the web server from the host pedal app."""
|
"""Dependencies injected into the web server from the host pedal app.
|
||||||
|
|
||||||
|
Supports dual-channel operation (guitar + bass). Each channel has its
|
||||||
|
own preset manager, audio pipeline, NAM host, and IR loader so the two
|
||||||
|
signal paths are fully independent.
|
||||||
|
|
||||||
|
The guitar fields are the defaults — callers that don't need dual-channel
|
||||||
|
can just set the guitar fields as before and everything works.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Guitar channel (default)
|
||||||
presets: Optional[PresetManager] = None
|
presets: Optional[PresetManager] = None
|
||||||
pipeline: Optional[AudioPipeline] = None
|
pipeline: Optional[AudioPipeline] = None
|
||||||
nam_host: Optional[NAMHost] = None
|
nam_host: Optional[NAMHost] = None
|
||||||
ir_loader: Optional[IRLoader] = None
|
ir_loader: Optional[IRLoader] = None
|
||||||
|
|
||||||
|
# Bass channel (separate from guitar)
|
||||||
|
bass_presets: Optional[PresetManager] = None
|
||||||
|
bass_pipeline: Optional[AudioPipeline] = None
|
||||||
|
bass_nam_host: Optional[NAMHost] = None
|
||||||
|
bass_ir_loader: Optional[IRLoader] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
"""True if we're wired to a live pedal instance."""
|
"""True if we're wired to a live pedal instance (any channel)."""
|
||||||
return self.presets is not None
|
return self.presets is not None or self.bass_presets is not None
|
||||||
|
|
||||||
|
def get_deps(self, channel: str) -> tuple:
|
||||||
|
"""Return (presets, pipeline, nam_host, ir_loader) for the given channel.
|
||||||
|
|
||||||
|
Falls back to guitar fields for unrecognised channels.
|
||||||
|
"""
|
||||||
|
if _resolve_channel(channel) == "bass":
|
||||||
|
return (
|
||||||
|
self.bass_presets,
|
||||||
|
self.bass_pipeline,
|
||||||
|
self.bass_nam_host,
|
||||||
|
self.bass_ir_loader,
|
||||||
|
)
|
||||||
|
return (self.presets, self.pipeline, self.nam_host, self.ir_loader)
|
||||||
|
|
||||||
|
|
||||||
# ── WebSocket connection manager ─────────────────────────────────────────────
|
# ── WebSocket connection manager ─────────────────────────────────────────────
|
||||||
@@ -139,11 +273,21 @@ class WebServer:
|
|||||||
self.port = port
|
self.port = port
|
||||||
|
|
||||||
self._manager = ConnectionManager()
|
self._manager = ConnectionManager()
|
||||||
|
self._channel_mgr = ChannelManager()
|
||||||
self._server: Optional[uvicorn.Server] = None
|
self._server: Optional[uvicorn.Server] = None
|
||||||
self._task: Optional[asyncio.Task] = None
|
self._task: Optional[asyncio.Task] = None
|
||||||
self._tonedownload: Optional[Tone3000Client] = None
|
self._tonedownload: Optional[Tone3000Client] = None
|
||||||
self._app = self._build_app()
|
self._app = self._build_app()
|
||||||
|
|
||||||
|
# ── Channel dep resolution ──────────────────────────────────────
|
||||||
|
|
||||||
|
def _channel_deps(self, channel: str = "guitar") -> tuple:
|
||||||
|
"""Shorthand for self.deps.get_deps(channel).
|
||||||
|
|
||||||
|
Returns (presets, pipeline, nam_host, ir_loader).
|
||||||
|
"""
|
||||||
|
return self.deps.get_deps(channel)
|
||||||
|
|
||||||
# ── App factory ─────────────────────────────────────────────────────
|
# ── App factory ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _build_app(self) -> FastAPI:
|
def _build_app(self) -> FastAPI:
|
||||||
@@ -177,8 +321,8 @@ class WebServer:
|
|||||||
if index_html.exists():
|
if index_html.exists():
|
||||||
content = index_html.read_text()
|
content = index_html.read_text()
|
||||||
return HTMLResponse(content)
|
return HTMLResponse(content)
|
||||||
# Fallback: legacy dashboard
|
# Fallback: legacy dashboard (guitar channel state)
|
||||||
state = self._gather_state()
|
state = self._gather_state(channel="guitar")
|
||||||
ctx = {"request": request}
|
ctx = {"request": request}
|
||||||
ctx.update(state)
|
ctx.update(state)
|
||||||
return templates.TemplateResponse(request, "dashboard.html", ctx)
|
return templates.TemplateResponse(request, "dashboard.html", ctx)
|
||||||
@@ -201,7 +345,7 @@ class WebServer:
|
|||||||
@app.get("/settings", response_class=HTMLResponse)
|
@app.get("/settings", response_class=HTMLResponse)
|
||||||
async def settings_page(request: Request):
|
async def settings_page(request: Request):
|
||||||
"""Settings page."""
|
"""Settings page."""
|
||||||
state = self._gather_state()
|
state = self._gather_state(channel="guitar")
|
||||||
ctx = {"request": request}
|
ctx = {"request": request}
|
||||||
ctx.update(state)
|
ctx.update(state)
|
||||||
return templates.TemplateResponse(request, "settings.html", ctx)
|
return templates.TemplateResponse(request, "settings.html", ctx)
|
||||||
@@ -209,17 +353,25 @@ class WebServer:
|
|||||||
# ── REST API ────────────────────────────────────────────────
|
# ── REST API ────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/state")
|
@app.get("/api/state")
|
||||||
async def get_state():
|
async def get_state(channel: Optional[str] = None, combined: bool = False):
|
||||||
"""Full current pedal state snapshot."""
|
"""Full pedal state snapshot.
|
||||||
state = self._gather_state()
|
|
||||||
|
Without parameters returns the active (guitar) channel's flat state.
|
||||||
|
``?channel=guitar|bass`` returns per-channel state.
|
||||||
|
``?combined=true`` returns both channel states in a combined view.
|
||||||
|
"""
|
||||||
|
if combined:
|
||||||
|
state = self._gather_state_combined()
|
||||||
|
else:
|
||||||
|
state = self._gather_state(channel=channel)
|
||||||
if not state:
|
if not state:
|
||||||
raise HTTPException(status_code=503, detail="Pedal not connected")
|
raise HTTPException(status_code=503, detail="Pedal not connected")
|
||||||
return state
|
return state
|
||||||
|
|
||||||
@app.get("/api/presets")
|
@app.get("/api/presets")
|
||||||
async def list_presets():
|
async def list_presets(channel: str = "guitar"):
|
||||||
"""List all banks and their presets."""
|
"""List all banks and their presets for the given channel."""
|
||||||
pm = self.deps.presets
|
pm, _pl, _nam, _ir = self._channel_deps(channel)
|
||||||
if not pm:
|
if not pm:
|
||||||
raise HTTPException(status_code=503, detail="Preset manager not available")
|
raise HTTPException(status_code=503, detail="Preset manager not available")
|
||||||
banks = pm.list_banks()
|
banks = pm.list_banks()
|
||||||
@@ -257,9 +409,9 @@ class WebServer:
|
|||||||
return {"banks": result}
|
return {"banks": result}
|
||||||
|
|
||||||
@app.get("/api/presets/{bank}/{program}")
|
@app.get("/api/presets/{bank}/{program}")
|
||||||
async def get_preset(bank: int, program: int):
|
async def get_preset(bank: int, program: int, channel: str = "guitar"):
|
||||||
"""Get a specific preset."""
|
"""Get a specific preset for the given channel."""
|
||||||
pm = self.deps.presets
|
pm, _pl, _nam, _ir = self._channel_deps(channel)
|
||||||
if not pm:
|
if not pm:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
try:
|
try:
|
||||||
@@ -286,17 +438,18 @@ class WebServer:
|
|||||||
raise HTTPException(status_code=404, detail="Preset not found")
|
raise HTTPException(status_code=404, detail="Preset not found")
|
||||||
|
|
||||||
@app.post("/api/presets/{bank}/{program}/activate")
|
@app.post("/api/presets/{bank}/{program}/activate")
|
||||||
async def activate_preset(bank: int, program: int):
|
async def activate_preset(bank: int, program: int, channel: str = "guitar"):
|
||||||
"""Activate a preset (load into pipeline)."""
|
"""Activate a preset (load into pipeline) for the given channel."""
|
||||||
pm = self.deps.presets
|
pm, _pl, _nam, _ir = self._channel_deps(channel)
|
||||||
if not pm:
|
if not pm:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
try:
|
try:
|
||||||
preset = pm.select(bank, program)
|
preset = pm.select(bank, program)
|
||||||
pm.save_state()
|
pm.save_state()
|
||||||
state = self._gather_state()
|
state = self._gather_state(channel)
|
||||||
await self._manager.broadcast({
|
await self._manager.broadcast({
|
||||||
"type": "preset_changed",
|
"type": "preset_changed",
|
||||||
|
"channel": channel,
|
||||||
"preset": {
|
"preset": {
|
||||||
"name": preset.name,
|
"name": preset.name,
|
||||||
"bank": preset.bank,
|
"bank": preset.bank,
|
||||||
@@ -309,9 +462,9 @@ class WebServer:
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@app.put("/api/presets/{bank}/{program}")
|
@app.put("/api/presets/{bank}/{program}")
|
||||||
async def save_preset(bank: int, program: int, data: dict):
|
async def save_preset(bank: int, program: int, data: dict, channel: str = "guitar"):
|
||||||
"""Save/update a preset."""
|
"""Save/update a preset for the given channel."""
|
||||||
pm = self.deps.presets
|
pm, _pl, _nam, _ir = self._channel_deps(channel)
|
||||||
if not pm:
|
if not pm:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
try:
|
try:
|
||||||
@@ -327,6 +480,7 @@ class WebServer:
|
|||||||
))
|
))
|
||||||
preset = Preset(
|
preset = Preset(
|
||||||
name=data.get("name", f"Preset {bank}-{program}"),
|
name=data.get("name", f"Preset {bank}-{program}"),
|
||||||
|
channel=Channel(channel),
|
||||||
bank=bank,
|
bank=bank,
|
||||||
program=program,
|
program=program,
|
||||||
chain=chain,
|
chain=chain,
|
||||||
@@ -339,9 +493,9 @@ class WebServer:
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@app.delete("/api/presets/{bank}/{program}")
|
@app.delete("/api/presets/{bank}/{program}")
|
||||||
async def delete_preset(bank: int, program: int):
|
async def delete_preset(bank: int, program: int, channel: str = "guitar"):
|
||||||
"""Delete a preset."""
|
"""Delete a preset for the given channel."""
|
||||||
pm = self.deps.presets
|
pm, _pl, _nam, _ir = self._channel_deps(channel)
|
||||||
if not pm:
|
if not pm:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
try:
|
try:
|
||||||
@@ -388,9 +542,9 @@ class WebServer:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/api/snapshots")
|
@app.get("/api/snapshots")
|
||||||
async def list_snapshots():
|
async def list_snapshots(channel: str = "guitar"):
|
||||||
"""List snapshot slots for the current preset."""
|
"""List snapshot slots for the current preset on the given channel."""
|
||||||
pm = self.deps.presets
|
pm, _pl, _nam, _ir = self._channel_deps(channel)
|
||||||
preset = _get_current_preset(pm)
|
preset = _get_current_preset(pm)
|
||||||
if not preset:
|
if not preset:
|
||||||
return {"snapshots": {}}
|
return {"snapshots": {}}
|
||||||
@@ -418,10 +572,9 @@ class WebServer:
|
|||||||
return {"snapshots": data, "current_snapshot": getattr(pm, '_current_snapshot', 0)}
|
return {"snapshots": data, "current_snapshot": getattr(pm, '_current_snapshot', 0)}
|
||||||
|
|
||||||
@app.post("/api/snapshots/{slot}/save")
|
@app.post("/api/snapshots/{slot}/save")
|
||||||
async def save_snapshot(slot: int, data: Optional[dict] = None):
|
async def save_snapshot(slot: int, data: Optional[dict] = None, channel: str = "guitar"):
|
||||||
"""Save current state to a snapshot slot (1-8)."""
|
"""Save current state to a snapshot slot (1-8) for the given channel."""
|
||||||
pm = self.deps.presets
|
pm, pl, _nam, _ir = self._channel_deps(channel)
|
||||||
pl = self.deps.pipeline
|
|
||||||
if not pm or not (1 <= slot <= 8):
|
if not pm or not (1 <= slot <= 8):
|
||||||
raise HTTPException(status_code=400, detail="Invalid slot (1-8)")
|
raise HTTPException(status_code=400, detail="Invalid slot (1-8)")
|
||||||
preset = _get_current_preset(pm)
|
preset = _get_current_preset(pm)
|
||||||
@@ -459,10 +612,9 @@ class WebServer:
|
|||||||
return {"ok": True, "slot": slot, "name": snap_data["name"]}
|
return {"ok": True, "slot": slot, "name": snap_data["name"]}
|
||||||
|
|
||||||
@app.post("/api/snapshots/{slot}/recall")
|
@app.post("/api/snapshots/{slot}/recall")
|
||||||
async def recall_snapshot(slot: int):
|
async def recall_snapshot(slot: int, channel: str = "guitar"):
|
||||||
"""Recall a snapshot: restore block states + params from slot."""
|
"""Recall a snapshot: restore block states + params from slot for the given channel."""
|
||||||
pm = self.deps.presets
|
pm, pl, _nam, _ir = self._channel_deps(channel)
|
||||||
pl = self.deps.pipeline
|
|
||||||
if not pm or not (1 <= slot <= 8):
|
if not pm or not (1 <= slot <= 8):
|
||||||
raise HTTPException(status_code=400, detail="Invalid slot (1-8)")
|
raise HTTPException(status_code=400, detail="Invalid slot (1-8)")
|
||||||
preset = _get_current_preset(pm)
|
preset = _get_current_preset(pm)
|
||||||
@@ -493,9 +645,10 @@ class WebServer:
|
|||||||
pm.save(preset)
|
pm.save(preset)
|
||||||
pm._current_snapshot = slot
|
pm._current_snapshot = slot
|
||||||
|
|
||||||
state = self._gather_state()
|
state = self._gather_state(channel)
|
||||||
await self._manager.broadcast({
|
await self._manager.broadcast({
|
||||||
"type": "snapshot_recalled",
|
"type": "snapshot_recalled",
|
||||||
|
"channel": channel,
|
||||||
"slot": slot,
|
"slot": slot,
|
||||||
"name": snap.name,
|
"name": snap.name,
|
||||||
"state": state,
|
"state": state,
|
||||||
@@ -503,9 +656,9 @@ class WebServer:
|
|||||||
return {"ok": True, "slot": slot, "name": snap.name}
|
return {"ok": True, "slot": slot, "name": snap.name}
|
||||||
|
|
||||||
@app.put("/api/snapshots/{slot}")
|
@app.put("/api/snapshots/{slot}")
|
||||||
async def update_snapshot(slot: int, data: dict):
|
async def update_snapshot(slot: int, data: dict, channel: str = "guitar"):
|
||||||
"""Rename or update a snapshot slot."""
|
"""Rename or update a snapshot slot for the given channel."""
|
||||||
pm = self.deps.presets
|
pm, _pl, _nam, _ir = self._channel_deps(channel)
|
||||||
if not pm or not (1 <= slot <= 8):
|
if not pm or not (1 <= slot <= 8):
|
||||||
raise HTTPException(status_code=400, detail="Invalid slot (1-8)")
|
raise HTTPException(status_code=400, detail="Invalid slot (1-8)")
|
||||||
preset = _get_current_preset(pm)
|
preset = _get_current_preset(pm)
|
||||||
@@ -519,15 +672,16 @@ class WebServer:
|
|||||||
pm.save(preset)
|
pm.save(preset)
|
||||||
await self._manager.broadcast({
|
await self._manager.broadcast({
|
||||||
"type": "snapshot_updated",
|
"type": "snapshot_updated",
|
||||||
|
"channel": channel,
|
||||||
"slot": slot,
|
"slot": slot,
|
||||||
"name": snap.name,
|
"name": snap.name,
|
||||||
})
|
})
|
||||||
return {"ok": True, "slot": slot, "name": snap.name}
|
return {"ok": True, "slot": slot, "name": snap.name}
|
||||||
|
|
||||||
@app.delete("/api/snapshots/{slot}")
|
@app.delete("/api/snapshots/{slot}")
|
||||||
async def delete_snapshot(slot: int):
|
async def delete_snapshot(slot: int, channel: str = "guitar"):
|
||||||
"""Delete a snapshot slot."""
|
"""Delete a snapshot slot for the given channel."""
|
||||||
pm = self.deps.presets
|
pm, _pl, _nam, _ir = self._channel_deps(channel)
|
||||||
if not pm or not (1 <= slot <= 8):
|
if not pm or not (1 <= slot <= 8):
|
||||||
raise HTTPException(status_code=400, detail="Invalid slot (1-8)")
|
raise HTTPException(status_code=400, detail="Invalid slot (1-8)")
|
||||||
preset = _get_current_preset(pm)
|
preset = _get_current_preset(pm)
|
||||||
@@ -539,14 +693,15 @@ class WebServer:
|
|||||||
pm.save(preset)
|
pm.save(preset)
|
||||||
await self._manager.broadcast({
|
await self._manager.broadcast({
|
||||||
"type": "snapshot_deleted",
|
"type": "snapshot_deleted",
|
||||||
|
"channel": channel,
|
||||||
"slot": slot,
|
"slot": slot,
|
||||||
})
|
})
|
||||||
return {"ok": True, "slot": slot}
|
return {"ok": True, "slot": slot}
|
||||||
|
|
||||||
@app.get("/api/snapshots/{slot}")
|
@app.get("/api/snapshots/{slot}")
|
||||||
async def get_snapshot(slot: int):
|
async def get_snapshot(slot: int, channel: str = "guitar"):
|
||||||
"""Get a single snapshot slot."""
|
"""Get a single snapshot slot for the given channel."""
|
||||||
pm = self.deps.presets
|
pm, _pl, _nam, _ir = self._channel_deps(channel)
|
||||||
preset = _get_current_preset(pm)
|
preset = _get_current_preset(pm)
|
||||||
if not preset:
|
if not preset:
|
||||||
raise HTTPException(status_code=404, detail="No active preset")
|
raise HTTPException(status_code=404, detail="No active preset")
|
||||||
@@ -556,6 +711,7 @@ class WebServer:
|
|||||||
return {
|
return {
|
||||||
"name": snap.name,
|
"name": snap.name,
|
||||||
"slot": slot,
|
"slot": slot,
|
||||||
|
"channel": channel,
|
||||||
"chain": [
|
"chain": [
|
||||||
{
|
{
|
||||||
"fx_type": b.fx_type.value,
|
"fx_type": b.fx_type.value,
|
||||||
@@ -569,9 +725,9 @@ class WebServer:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@app.post("/api/bypass")
|
@app.post("/api/bypass")
|
||||||
async def toggle_bypass(data: Optional[dict] = None):
|
async def toggle_bypass(data: Optional[dict] = None, channel: str = "guitar"):
|
||||||
"""Toggle or set bypass state."""
|
"""Toggle or set bypass state for the given channel."""
|
||||||
pl = self.deps.pipeline
|
_pm, pl, _nam, _ir = self._channel_deps(channel)
|
||||||
if not pl:
|
if not pl:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
if data and "bypass" in data:
|
if data and "bypass" in data:
|
||||||
@@ -580,14 +736,15 @@ class WebServer:
|
|||||||
pl._bypassed = not pl._bypassed
|
pl._bypassed = not pl._bypassed
|
||||||
await self._manager.broadcast({
|
await self._manager.broadcast({
|
||||||
"type": "bypass_changed",
|
"type": "bypass_changed",
|
||||||
|
"channel": channel,
|
||||||
"bypass": pl._bypassed,
|
"bypass": pl._bypassed,
|
||||||
})
|
})
|
||||||
return {"ok": True, "bypass": pl._bypassed}
|
return {"ok": True, "bypass": pl._bypassed}
|
||||||
|
|
||||||
@app.post("/api/tuner")
|
@app.post("/api/tuner")
|
||||||
async def toggle_tuner(data: Optional[dict] = None):
|
async def toggle_tuner(data: Optional[dict] = None, channel: str = "guitar"):
|
||||||
"""Toggle tuner mode."""
|
"""Toggle tuner mode for the given channel."""
|
||||||
pl = self.deps.pipeline
|
_pm, pl, _nam, _ir = self._channel_deps(channel)
|
||||||
if not pl:
|
if not pl:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
if data and "enabled" in data:
|
if data and "enabled" in data:
|
||||||
@@ -596,14 +753,15 @@ class WebServer:
|
|||||||
pl._tuner_enabled = not pl._tuner_enabled
|
pl._tuner_enabled = not pl._tuner_enabled
|
||||||
await self._manager.broadcast({
|
await self._manager.broadcast({
|
||||||
"type": "tuner_changed",
|
"type": "tuner_changed",
|
||||||
|
"channel": channel,
|
||||||
"tuner_enabled": pl._tuner_enabled,
|
"tuner_enabled": pl._tuner_enabled,
|
||||||
})
|
})
|
||||||
return {"ok": True, "tuner_enabled": pl._tuner_enabled}
|
return {"ok": True, "tuner_enabled": pl._tuner_enabled}
|
||||||
|
|
||||||
@app.get("/api/tuner/pitch")
|
@app.get("/api/tuner/pitch")
|
||||||
async def get_tuner_pitch():
|
async def get_tuner_pitch(channel: str = "guitar"):
|
||||||
"""Get current tuner pitch detection data (fast poll endpoint)."""
|
"""Get current tuner pitch detection data for the given channel (fast poll endpoint)."""
|
||||||
pl = self.deps.pipeline
|
_pm, pl, _nam, _ir = self._channel_deps(channel)
|
||||||
if not pl:
|
if not pl:
|
||||||
return {
|
return {
|
||||||
"frequency": 0.0, "note": "--", "cents": 0,
|
"frequency": 0.0, "note": "--", "cents": 0,
|
||||||
@@ -619,21 +777,21 @@ class WebServer:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@app.put("/api/volume")
|
@app.put("/api/volume")
|
||||||
async def set_volume(data: dict):
|
async def set_volume(data: dict, channel: str = "guitar"):
|
||||||
"""Set master volume (0.0-1.0)."""
|
"""Set master volume (0.0-1.0) for the given channel."""
|
||||||
pl = self.deps.pipeline
|
_pm, pl, _nam, _ir = self._channel_deps(channel)
|
||||||
if not pl:
|
if not pl:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
vol = max(0.0, min(1.0, float(data.get("volume", 0.8))))
|
vol = max(0.0, min(1.0, float(data.get("volume", 0.8))))
|
||||||
pl._master_volume = vol
|
pl._master_volume = vol
|
||||||
return {"ok": True, "volume": vol}
|
return {"ok": True, "volume": vol, "channel": channel}
|
||||||
|
|
||||||
# ── 4CM routing endpoints ────────────────────────────────
|
# ── 4CM routing endpoints ────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/routing")
|
@app.get("/api/routing")
|
||||||
async def get_routing():
|
async def get_routing(channel: str = "guitar"):
|
||||||
"""Get current routing mode and breakpoint."""
|
"""Get current routing mode and breakpoint for the given channel."""
|
||||||
pl = self.deps.pipeline
|
_pm, pl, _nam, _ir = self._channel_deps(channel)
|
||||||
if not pl:
|
if not pl:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
return {
|
return {
|
||||||
@@ -642,15 +800,15 @@ class WebServer:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@app.post("/api/routing")
|
@app.post("/api/routing")
|
||||||
async def set_routing(data: dict):
|
async def set_routing(data: dict, channel: str = "guitar"):
|
||||||
"""Set routing mode and/or breakpoint.
|
"""Set routing mode and/or breakpoint for the given channel.
|
||||||
|
|
||||||
Body:
|
Body:
|
||||||
routing_mode (optional): ``\"mono\"`` or ``\"4cm\"``.
|
routing_mode (optional): ``\"mono\"`` or ``\"4cm\"``.
|
||||||
routing_breakpoint (optional): chain index for split.
|
routing_breakpoint (optional): chain index for split.
|
||||||
"""
|
"""
|
||||||
pl = self.deps.pipeline
|
_pm, pl, _nam, _ir = self._channel_deps(channel)
|
||||||
pm = self.deps.presets
|
pm, _pl2, _nam2, _ir2 = self._channel_deps(channel)
|
||||||
if not pl:
|
if not pl:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
mode = data.get("routing_mode", pl.routing_mode)
|
mode = data.get("routing_mode", pl.routing_mode)
|
||||||
@@ -669,16 +827,47 @@ class WebServer:
|
|||||||
|
|
||||||
await self._manager.broadcast({
|
await self._manager.broadcast({
|
||||||
"type": "routing_changed",
|
"type": "routing_changed",
|
||||||
|
"channel": channel,
|
||||||
"routing_mode": pl.routing_mode,
|
"routing_mode": pl.routing_mode,
|
||||||
"routing_breakpoint": pl.routing_breakpoint,
|
"routing_breakpoint": pl.routing_breakpoint,
|
||||||
})
|
})
|
||||||
return {"ok": True, "routing_mode": pl.routing_mode,
|
return {"ok": True, "routing_mode": pl.routing_mode,
|
||||||
"routing_breakpoint": pl.routing_breakpoint}
|
"routing_breakpoint": pl.routing_breakpoint}
|
||||||
|
|
||||||
|
# ── Channel mode endpoints ───────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/api/channel-mode")
|
||||||
|
async def get_channel_mode():
|
||||||
|
"""Get the current channel routing mode."""
|
||||||
|
return {"channel_mode": self._channel_mgr.mode}
|
||||||
|
|
||||||
|
@app.post("/api/channel-mode")
|
||||||
|
async def set_channel_mode(data: dict):
|
||||||
|
"""Set channel routing mode.
|
||||||
|
|
||||||
|
Body: { "channel_mode": "dual-mono" | "stereo" }
|
||||||
|
"""
|
||||||
|
mode = data.get("channel_mode", "")
|
||||||
|
if mode not in ("dual-mono", "stereo"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid channel_mode: {mode!r}. Must be 'dual-mono' or 'stereo'.",
|
||||||
|
)
|
||||||
|
was = self._channel_mgr.mode
|
||||||
|
self._channel_mgr.set_mode(mode)
|
||||||
|
if mode == "stereo" and was == "dual-mono":
|
||||||
|
self._channel_mgr.sync_to_stereo()
|
||||||
|
logger.info("Channel mode switched: %s → %s", was, mode)
|
||||||
|
await self._manager.broadcast({
|
||||||
|
"type": "channel_mode_changed",
|
||||||
|
"channel_mode": mode,
|
||||||
|
})
|
||||||
|
return {"ok": True, "channel_mode": mode}
|
||||||
|
|
||||||
@app.get("/api/models")
|
@app.get("/api/models")
|
||||||
async def list_models():
|
async def list_models(channel: str = "guitar"):
|
||||||
"""List available NAM models."""
|
"""List available NAM models for the given channel."""
|
||||||
nam = self.deps.nam_host
|
_pm, _pl, nam, _ir = self._channel_deps(channel)
|
||||||
if not nam:
|
if not nam:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
models = nam.list_available_models()
|
models = nam.list_available_models()
|
||||||
@@ -697,9 +886,9 @@ class WebServer:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@app.post("/api/models/load")
|
@app.post("/api/models/load")
|
||||||
async def load_model(data: dict):
|
async def load_model(data: dict, channel: str = "guitar"):
|
||||||
"""Load a NAM model by path."""
|
"""Load a NAM model by path for the given channel."""
|
||||||
nam = self.deps.nam_host
|
_pm, _pl, nam, _ir = self._channel_deps(channel)
|
||||||
if not nam:
|
if not nam:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
path = data.get("path", "")
|
path = data.get("path", "")
|
||||||
@@ -715,18 +904,18 @@ class WebServer:
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@app.post("/api/models/unload")
|
@app.post("/api/models/unload")
|
||||||
async def unload_model():
|
async def unload_model(channel: str = "guitar"):
|
||||||
"""Unload the current NAM model."""
|
"""Unload the current NAM model for the given channel."""
|
||||||
nam = self.deps.nam_host
|
_pm, _pl, nam, _ir = self._channel_deps(channel)
|
||||||
if not nam:
|
if not nam:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
nam.unload()
|
nam.unload()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@app.get("/api/irs")
|
@app.get("/api/irs")
|
||||||
async def list_irs():
|
async def list_irs(channel: str = "guitar"):
|
||||||
"""List available IR files."""
|
"""List available IR files for the given channel."""
|
||||||
ir = self.deps.ir_loader
|
_pm, _pl, _nam, ir = self._channel_deps(channel)
|
||||||
if not ir:
|
if not ir:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
irs = ir.get_irs()
|
irs = ir.get_irs()
|
||||||
@@ -746,9 +935,9 @@ class WebServer:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@app.post("/api/irs/load")
|
@app.post("/api/irs/load")
|
||||||
async def load_ir(data: dict):
|
async def load_ir(data: dict, channel: str = "guitar"):
|
||||||
"""Load an IR file by path."""
|
"""Load an IR file by path for the given channel."""
|
||||||
ir = self.deps.ir_loader
|
_pm, _pl, _nam, ir = self._channel_deps(channel)
|
||||||
if not ir:
|
if not ir:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
path = data.get("path", "")
|
path = data.get("path", "")
|
||||||
@@ -764,9 +953,9 @@ class WebServer:
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@app.post("/api/irs/unload")
|
@app.post("/api/irs/unload")
|
||||||
async def unload_ir():
|
async def unload_ir(channel: str = "guitar"):
|
||||||
"""Unload the current IR."""
|
"""Unload the current IR for the given channel."""
|
||||||
ir = self.deps.ir_loader
|
_pm, _pl, _nam, ir = self._channel_deps(channel)
|
||||||
if not ir:
|
if not ir:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
ir.unload()
|
ir.unload()
|
||||||
@@ -999,10 +1188,16 @@ class WebServer:
|
|||||||
async def websocket_endpoint(ws: WebSocket):
|
async def websocket_endpoint(ws: WebSocket):
|
||||||
await self._manager.connect(ws)
|
await self._manager.connect(ws)
|
||||||
logger.info("WebSocket client connected (%d active)", self._manager.active_count)
|
logger.info("WebSocket client connected (%d active)", self._manager.active_count)
|
||||||
|
# Track per-connection channel preference
|
||||||
|
_ws_channel: str = "guitar"
|
||||||
try:
|
try:
|
||||||
# Send initial state snapshot on connect
|
# Send initial state snapshot on connect (combined view)
|
||||||
state = self._gather_state()
|
state = self._gather_state_combined()
|
||||||
await ws.send_text(json.dumps({"type": "connected", "state": state}))
|
await ws.send_text(json.dumps({
|
||||||
|
"type": "connected",
|
||||||
|
"state": state,
|
||||||
|
"channels": list(VALID_CHANNELS),
|
||||||
|
}))
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
raw = await ws.receive_text()
|
raw = await ws.receive_text()
|
||||||
@@ -1012,8 +1207,20 @@ class WebServer:
|
|||||||
if msg_type == "ping":
|
if msg_type == "ping":
|
||||||
await ws.send_text(json.dumps({"type": "pong"}))
|
await ws.send_text(json.dumps({"type": "pong"}))
|
||||||
elif msg_type == "get_state":
|
elif msg_type == "get_state":
|
||||||
state = self._gather_state()
|
ch = msg.get("channel", _ws_channel)
|
||||||
await ws.send_text(json.dumps({"type": "state", "state": state}))
|
_ws_channel = _resolve_channel(ch)
|
||||||
|
state = self._gather_state(channel=_ws_channel)
|
||||||
|
await ws.send_text(json.dumps({
|
||||||
|
"type": "state",
|
||||||
|
"channel": _ws_channel,
|
||||||
|
"state": state,
|
||||||
|
}))
|
||||||
|
elif msg_type == "set_channel":
|
||||||
|
_ws_channel = _resolve_channel(msg.get("channel", "guitar"))
|
||||||
|
await ws.send_text(json.dumps({
|
||||||
|
"type": "channel_set",
|
||||||
|
"channel": _ws_channel,
|
||||||
|
}))
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1264,6 +1471,42 @@ class WebServer:
|
|||||||
ir_loader.get_irs()
|
ir_loader.get_irs()
|
||||||
return {"ok": True, "path": str(path), "filename": path.name}
|
return {"ok": True, "path": str(path), "filename": path.name}
|
||||||
|
|
||||||
|
# ── Channel-prefixed route aliases ──────────────────────
|
||||||
|
# Every state/control endpoint that accepts ?channel= is also
|
||||||
|
# available at /api/channel/{channel}/... for explicit routing.
|
||||||
|
|
||||||
|
_ch_base = "/api/channel/{channel}"
|
||||||
|
|
||||||
|
# State
|
||||||
|
app.add_api_route(f"{_ch_base}/state", get_state, methods=["GET"])
|
||||||
|
# Presets
|
||||||
|
app.add_api_route(f"{_ch_base}/presets", list_presets, methods=["GET"])
|
||||||
|
app.add_api_route(f"{_ch_base}/presets/{{bank}}/{{program}}", get_preset, methods=["GET"]) # noqa: F821
|
||||||
|
app.add_api_route(f"{_ch_base}/presets/{{bank}}/{{program}}/activate", activate_preset, methods=["POST"]) # noqa: F821
|
||||||
|
app.add_api_route(f"{_ch_base}/presets/{{bank}}/{{program}}", save_preset, methods=["PUT"]) # noqa: F821
|
||||||
|
app.add_api_route(f"{_ch_base}/presets/{{bank}}/{{program}}", delete_preset, methods=["DELETE"]) # noqa: F821
|
||||||
|
# Snapshots
|
||||||
|
app.add_api_route(f"{_ch_base}/snapshots", list_snapshots, methods=["GET"])
|
||||||
|
app.add_api_route(f"{_ch_base}/snapshots/{{slot}}", get_snapshot, methods=["GET"])
|
||||||
|
app.add_api_route(f"{_ch_base}/snapshots/{{slot}}/save", save_snapshot, methods=["POST"])
|
||||||
|
app.add_api_route(f"{_ch_base}/snapshots/{{slot}}/recall", recall_snapshot, methods=["POST"])
|
||||||
|
app.add_api_route(f"{_ch_base}/snapshots/{{slot}}", update_snapshot, methods=["PUT"])
|
||||||
|
app.add_api_route(f"{_ch_base}/snapshots/{{slot}}", delete_snapshot, methods=["DELETE"])
|
||||||
|
# Controller
|
||||||
|
app.add_api_route(f"{_ch_base}/bypass", toggle_bypass, methods=["POST"])
|
||||||
|
app.add_api_route(f"{_ch_base}/tuner", toggle_tuner, methods=["POST"])
|
||||||
|
app.add_api_route(f"{_ch_base}/tuner/pitch", get_tuner_pitch, methods=["GET"])
|
||||||
|
app.add_api_route(f"{_ch_base}/volume", set_volume, methods=["PUT"])
|
||||||
|
app.add_api_route(f"{_ch_base}/routing", get_routing, methods=["GET"])
|
||||||
|
app.add_api_route(f"{_ch_base}/routing", set_routing, methods=["POST"])
|
||||||
|
# Models & IRs
|
||||||
|
app.add_api_route(f"{_ch_base}/models", list_models, methods=["GET"])
|
||||||
|
app.add_api_route(f"{_ch_base}/models/load", load_model, methods=["POST"])
|
||||||
|
app.add_api_route(f"{_ch_base}/models/unload", unload_model, methods=["POST"])
|
||||||
|
app.add_api_route(f"{_ch_base}/irs", list_irs, methods=["GET"])
|
||||||
|
app.add_api_route(f"{_ch_base}/irs/load", load_ir, methods=["POST"])
|
||||||
|
app.add_api_route(f"{_ch_base}/irs/unload", unload_ir, methods=["POST"])
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
# ── Network/BT state gathering ──────────────────────────────────
|
# ── Network/BT state gathering ──────────────────────────────────
|
||||||
@@ -1322,12 +1565,14 @@ class WebServer:
|
|||||||
|
|
||||||
# ── State gathering ──────────────────────────────────────────────
|
# ── State gathering ──────────────────────────────────────────────
|
||||||
|
|
||||||
def _gather_state(self) -> dict[str, Any]:
|
def _gather_channel_state(self, channel: str) -> dict[str, Any]:
|
||||||
"""Collect a snapshot of the current pedal state."""
|
"""Collect a snapshot for a single channel.
|
||||||
pm = self.deps.presets
|
|
||||||
pl = self.deps.pipeline
|
Returns the full state dict for the requested channel. When the
|
||||||
nam = self.deps.nam_host
|
channel's preset manager is not connected the dict has a flat
|
||||||
ir = self.deps.ir_loader
|
``connected: False`` marker.
|
||||||
|
"""
|
||||||
|
pm, pl, nam, ir = self._channel_deps(channel)
|
||||||
|
|
||||||
if not pm:
|
if not pm:
|
||||||
return {
|
return {
|
||||||
@@ -1378,6 +1623,7 @@ class WebServer:
|
|||||||
current_ir_name = _safe_str(current_ir_obj.name if current_ir_obj else None)
|
current_ir_name = _safe_str(current_ir_obj.name if current_ir_obj else None)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"channel": channel,
|
||||||
"connected": True,
|
"connected": True,
|
||||||
"current_preset": {
|
"current_preset": {
|
||||||
"name": preset.name if preset else "—",
|
"name": preset.name if preset else "—",
|
||||||
@@ -1398,15 +1644,74 @@ class WebServer:
|
|||||||
"nam_model": current_model_name,
|
"nam_model": current_model_name,
|
||||||
"ir_loaded": bool(ir and ir.is_loaded) if ir else False,
|
"ir_loaded": bool(ir and ir.is_loaded) if ir else False,
|
||||||
"ir_name": current_ir_name,
|
"ir_name": current_ir_name,
|
||||||
|
"channel_mode": self._channel_mgr.mode,
|
||||||
# Audio levels from pipeline (RMS float32 normalized 0.0-1.0, scaled to 0-100)
|
# Audio levels from pipeline (RMS float32 normalized 0.0-1.0, scaled to 0-100)
|
||||||
"input_level": min(100, round((pl._input_level or 0.0) * 150)) if pl else 0,
|
"input_level": (
|
||||||
"output_level": min(100, round((pl._output_level or 0.0) * 150)) if pl else 0,
|
min(100, round((getattr(pl, '_input_level', 0.0) or 0.0) * 150))
|
||||||
|
if pl and isinstance(getattr(pl, '_input_level', None), (int, float))
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
"output_level": (
|
||||||
|
min(100, round((getattr(pl, '_output_level', 0.0) or 0.0) * 150))
|
||||||
|
if pl and isinstance(getattr(pl, '_output_level', None), (int, float))
|
||||||
|
else 0
|
||||||
|
),
|
||||||
# System stats
|
# System stats
|
||||||
"cpu_percent": self._gather_system_stats().get("cpu_percent", 0),
|
"cpu_percent": self._gather_system_stats().get("cpu_percent", 0),
|
||||||
"sample_rate": SAMPLE_RATE,
|
"sample_rate": SAMPLE_RATE,
|
||||||
"wifi": self._gather_wifi_state(),
|
"wifi": self._gather_wifi_state(),
|
||||||
"bluetooth": self._gather_bt_state(),
|
"bluetooth": self._gather_bt_state(),
|
||||||
"current_snapshot": getattr(self.deps.presets, '_current_snapshot', 0) if self.deps.presets else 0,
|
"current_snapshot": getattr(pm, '_current_snapshot', 0) if pm else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _gather_state(self, channel: Optional[str] = None) -> dict[str, Any]:
|
||||||
|
"""Collect pedal state snapshot.
|
||||||
|
|
||||||
|
By default (``channel=None``) returns the guitar channel's flat
|
||||||
|
state — this preserves backward compatibility for existing clients
|
||||||
|
that expect ``connected``, ``current_preset``, etc. at the top
|
||||||
|
level.
|
||||||
|
|
||||||
|
Pass ``channel=\"guitar\"`` or ``channel=\"bass\"`` for explicit
|
||||||
|
per-channel state.
|
||||||
|
"""
|
||||||
|
ch = channel if channel is not None else "guitar"
|
||||||
|
return self._gather_channel_state(ch)
|
||||||
|
|
||||||
|
def _gather_state_combined(self) -> dict[str, Any]:
|
||||||
|
"""Return a combined view with both channel states.
|
||||||
|
|
||||||
|
Shape::
|
||||||
|
|
||||||
|
{
|
||||||
|
\"channels\": {
|
||||||
|
\"guitar\": { … },
|
||||||
|
\"bass\": { … },
|
||||||
|
},
|
||||||
|
\"active_channel\": \"guitar\",
|
||||||
|
\"channel_mode\": \"dual-mono\",
|
||||||
|
\"wifi\": { … },
|
||||||
|
\"bluetooth\": { … },
|
||||||
|
\"cpu_percent\": …,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
guitar = self._gather_channel_state("guitar")
|
||||||
|
bass = self._gather_channel_state("bass")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"channels": {
|
||||||
|
"guitar": guitar,
|
||||||
|
"bass": bass,
|
||||||
|
},
|
||||||
|
"active_channel": "guitar",
|
||||||
|
"channel_mode": self._channel_mgr.mode,
|
||||||
|
"wifi": guitar.get("wifi", {}),
|
||||||
|
"bluetooth": guitar.get("bluetooth", {}),
|
||||||
|
"cpu_percent": max(
|
||||||
|
guitar.get("cpu_percent", 0),
|
||||||
|
bass.get("cpu_percent", 0),
|
||||||
|
),
|
||||||
|
"sample_rate": SAMPLE_RATE,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Tone3000 client lazy init ────────────────────────────────
|
# ── Tone3000 client lazy init ────────────────────────────────
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/ui/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/ui/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Pi Multi-FX Pedal</title>
|
<title>Pi Multi-FX Pedal</title>
|
||||||
<script type="module" crossorigin src="/ui/assets/index-DKZHEo9m.js"></script>
|
<script type="module" crossorigin src="/ui/assets/index-BiNA-JeT.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/ui/assets/index-CuJgR-s6.css">
|
<link rel="stylesheet" crossorigin href="/ui/assets/index-CuJgR-s6.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from src.presets.manager import (
|
|||||||
_preset_from_dict,
|
_preset_from_dict,
|
||||||
_preset_to_dict,
|
_preset_to_dict,
|
||||||
)
|
)
|
||||||
from src.presets.types import FXBlock, FXType, MIDIMapping, Preset
|
from src.presets.types import Channel, FXBlock, FXType, MIDIMapping, Preset
|
||||||
|
|
||||||
|
|
||||||
# ── Fixtures ────────────────────────────────────────────────────────────────
|
# ── Fixtures ────────────────────────────────────────────────────────────────
|
||||||
@@ -152,7 +152,7 @@ class TestSaveLoad:
|
|||||||
|
|
||||||
def test_save_creates_json_file(self, manager: PresetManager, sample_preset: Preset, preset_dir: Path):
|
def test_save_creates_json_file(self, manager: PresetManager, sample_preset: Preset, preset_dir: Path):
|
||||||
manager.save(sample_preset)
|
manager.save(sample_preset)
|
||||||
path = preset_dir / "bank_0" / "preset_0.json"
|
path = preset_dir / "guitar" / "bank_0" / "preset_0.json"
|
||||||
assert path.exists()
|
assert path.exists()
|
||||||
content = path.read_text(encoding="utf-8")
|
content = path.read_text(encoding="utf-8")
|
||||||
assert "Crunch Rhythm" in content
|
assert "Crunch Rhythm" in content
|
||||||
@@ -513,8 +513,152 @@ class TestEdgeCases:
|
|||||||
assert manager.current_program == 1
|
assert manager.current_program == 1
|
||||||
|
|
||||||
def test_corrupted_json_raises(self, manager: PresetManager, preset_dir: Path):
|
def test_corrupted_json_raises(self, manager: PresetManager, preset_dir: Path):
|
||||||
path = preset_dir / "bank_0" / "preset_0.json"
|
path = preset_dir / "guitar" / "bank_0" / "preset_0.json"
|
||||||
path.parent.mkdir(parents=True)
|
path.parent.mkdir(parents=True)
|
||||||
path.write_text("not valid json", encoding="utf-8")
|
path.write_text("not valid json", encoding="utf-8")
|
||||||
with pytest.raises(json.JSONDecodeError):
|
with pytest.raises(json.JSONDecodeError):
|
||||||
manager.load(0, 0)
|
manager.load(0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Channel independence ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannelIndependence:
|
||||||
|
"""Guitar and bass channels each have their own preset bank namespace."""
|
||||||
|
|
||||||
|
def test_channel_serialization_round_trip(self):
|
||||||
|
"""Channel field survives to_dict / from_dict."""
|
||||||
|
p = Preset(name="GTR Lead", channel=Channel.GUITAR, bank=0, program=0)
|
||||||
|
d = _preset_to_dict(p)
|
||||||
|
assert d["channel"] == "guitar"
|
||||||
|
restored = _preset_from_dict(d)
|
||||||
|
assert restored.channel == Channel.GUITAR
|
||||||
|
|
||||||
|
p2 = Preset(name="Bass Slap", channel=Channel.BASS, bank=0, program=0)
|
||||||
|
d2 = _preset_to_dict(p2)
|
||||||
|
assert d2["channel"] == "bass"
|
||||||
|
restored2 = _preset_from_dict(d2)
|
||||||
|
assert restored2.channel == Channel.BASS
|
||||||
|
|
||||||
|
def test_channel_defaults_to_guitar(self):
|
||||||
|
"""Presets without explicit channel default to guitar."""
|
||||||
|
p = Preset(name="Default", bank=0, program=0)
|
||||||
|
assert p.channel == Channel.GUITAR
|
||||||
|
|
||||||
|
def test_guitar_and_bass_independent_banks(self, manager: PresetManager):
|
||||||
|
"""Guitar and bass presets in the same (bank, program) don't collide."""
|
||||||
|
gtr = Preset(name="GTR Crunch", channel=Channel.GUITAR, bank=0, program=0)
|
||||||
|
bass = Preset(name="Bass Slap", channel=Channel.BASS, bank=0, program=0)
|
||||||
|
|
||||||
|
# Save guitar via current channel (defaults to guitar)
|
||||||
|
manager.save(gtr)
|
||||||
|
# Save bass explicitly
|
||||||
|
manager.save(bass, channel=Channel.BASS)
|
||||||
|
|
||||||
|
# Load guitar
|
||||||
|
loaded_gtr = manager.load(0, 0, Channel.GUITAR)
|
||||||
|
assert loaded_gtr.name == "GTR Crunch"
|
||||||
|
assert loaded_gtr.channel == Channel.GUITAR
|
||||||
|
|
||||||
|
# Load bass
|
||||||
|
loaded_bass = manager.load(0, 0, Channel.BASS)
|
||||||
|
assert loaded_bass.name == "Bass Slap"
|
||||||
|
assert loaded_bass.channel == Channel.BASS
|
||||||
|
|
||||||
|
def test_channel_switch_restores_preset(self, manager: PresetManager):
|
||||||
|
"""Switching channels restores the last active state for that channel."""
|
||||||
|
manager.save(Preset(name="GTR Lead", channel=Channel.GUITAR, bank=0, program=0))
|
||||||
|
manager.save(Preset(name="GTR Clean", channel=Channel.GUITAR, bank=0, program=1))
|
||||||
|
manager.save(Preset(name="Bass Slap", channel=Channel.BASS, bank=1, program=2))
|
||||||
|
|
||||||
|
# Select guitar preset (bank=0, program=1)
|
||||||
|
manager.select(0, 1, Channel.GUITAR)
|
||||||
|
|
||||||
|
# Select bass preset so its channel state is saved
|
||||||
|
manager.select(1, 2, Channel.BASS)
|
||||||
|
|
||||||
|
# Switch to guitar — should go to (0, 1)
|
||||||
|
manager.set_channel(Channel.GUITAR)
|
||||||
|
assert manager.current_channel == Channel.GUITAR
|
||||||
|
assert manager.current_bank == 0
|
||||||
|
assert manager.current_program == 1
|
||||||
|
|
||||||
|
# Switch back to bass — should restore (1, 2)
|
||||||
|
manager.set_channel(Channel.BASS)
|
||||||
|
assert manager.current_channel == Channel.BASS
|
||||||
|
assert manager.current_bank == 1
|
||||||
|
assert manager.current_program == 2
|
||||||
|
|
||||||
|
def test_set_same_channel_noop(self, manager: PresetManager):
|
||||||
|
"""set_channel to the same channel preserves current position."""
|
||||||
|
manager.save(Preset(name="Only", channel=Channel.GUITAR, bank=0, program=2))
|
||||||
|
manager.select(0, 2, Channel.GUITAR)
|
||||||
|
result = manager.set_channel(Channel.GUITAR)
|
||||||
|
assert result.name == "Only"
|
||||||
|
assert manager.current_bank == 0
|
||||||
|
assert manager.current_program == 2
|
||||||
|
|
||||||
|
def test_current_channel_property(self, manager: PresetManager):
|
||||||
|
"""current_channel returns the active channel."""
|
||||||
|
assert manager.current_channel == Channel.GUITAR
|
||||||
|
manager.set_channel(Channel.BASS)
|
||||||
|
assert manager.current_channel == Channel.BASS
|
||||||
|
manager.set_channel(Channel.GUITAR)
|
||||||
|
assert manager.current_channel == Channel.GUITAR
|
||||||
|
|
||||||
|
def test_list_banks_channel_scoped(self, manager: PresetManager):
|
||||||
|
"""list_banks only returns banks for the specified channel."""
|
||||||
|
manager.save(Preset(name="GTR_A", channel=Channel.GUITAR, bank=0, program=0))
|
||||||
|
manager.save(Preset(name="BASS_A", channel=Channel.BASS, bank=5, program=0))
|
||||||
|
|
||||||
|
gtr_banks = manager.list_banks(Channel.GUITAR)
|
||||||
|
assert len(gtr_banks) == 1
|
||||||
|
assert gtr_banks[0].number == 0
|
||||||
|
|
||||||
|
bass_banks = manager.list_banks(Channel.BASS)
|
||||||
|
assert len(bass_banks) == 1
|
||||||
|
assert bass_banks[0].number == 5
|
||||||
|
|
||||||
|
def test_migrate_legacy_presets(self, tmp_path: Path):
|
||||||
|
"""Legacy flat bank_* dirs are migrated to guitar/ on first boot."""
|
||||||
|
# Create legacy layout: bank_0/preset_0.json at root
|
||||||
|
legacy_bank = tmp_path / "bank_0"
|
||||||
|
legacy_bank.mkdir()
|
||||||
|
(legacy_bank / "preset_0.json").write_text(
|
||||||
|
json.dumps({"name": "Legacy Tone", "bank": 0, "program": 0}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create PresetManager — should migrate
|
||||||
|
pm = PresetManager(preset_dir=str(tmp_path), audio_pipeline=None)
|
||||||
|
|
||||||
|
# Old location should be gone
|
||||||
|
assert not (tmp_path / "bank_0").exists()
|
||||||
|
|
||||||
|
# New location should exist
|
||||||
|
guitar_path = tmp_path / "guitar" / "bank_0" / "preset_0.json"
|
||||||
|
assert guitar_path.exists()
|
||||||
|
|
||||||
|
# Preset should be loadable from guitar channel
|
||||||
|
preset = pm.load(0, 0, Channel.GUITAR)
|
||||||
|
assert preset.name == "Legacy Tone"
|
||||||
|
|
||||||
|
def test_migration_skips_when_already_migrated(self, tmp_path: Path):
|
||||||
|
"""Migration doesn't run if guitar/ dir already exists."""
|
||||||
|
guitar_dir = tmp_path / "guitar"
|
||||||
|
guitar_dir.mkdir(parents=True)
|
||||||
|
(guitar_dir / "bank_0").mkdir()
|
||||||
|
(guitar_dir / "bank_0" / "preset_0.json").write_text(
|
||||||
|
json.dumps({"name": "Already Migrated", "channel": "guitar", "bank": 0, "program": 0}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
pm = PresetManager(preset_dir=str(tmp_path), audio_pipeline=None)
|
||||||
|
preset = pm.load(0, 0, Channel.GUITAR)
|
||||||
|
assert preset.name == "Already Migrated"
|
||||||
|
|
||||||
|
def test_fresh_install_no_migration(self, tmp_path: Path):
|
||||||
|
"""Fresh install with no files doesn't raise or create guitar dir."""
|
||||||
|
pm = PresetManager(preset_dir=str(tmp_path), audio_pipeline=None)
|
||||||
|
assert not (tmp_path / "guitar").exists()
|
||||||
|
assert pm.list_banks() == []
|
||||||
@@ -32,6 +32,7 @@ def mock_presets():
|
|||||||
pm = MagicMock()
|
pm = MagicMock()
|
||||||
pm.current_bank = 0
|
pm.current_bank = 0
|
||||||
pm.current_program = 0
|
pm.current_program = 0
|
||||||
|
pm._current_snapshot = 0
|
||||||
pm.load.return_value = Preset(name="Default", bank=0, program=0)
|
pm.load.return_value = Preset(name="Default", bank=0, program=0)
|
||||||
return pm
|
return pm
|
||||||
|
|
||||||
@@ -41,6 +42,11 @@ def mock_pipeline():
|
|||||||
pl = MagicMock()
|
pl = MagicMock()
|
||||||
pl._bypassed = False
|
pl._bypassed = False
|
||||||
pl._tuner_enabled = False
|
pl._tuner_enabled = False
|
||||||
|
pl._tuner_frequency = 0.0
|
||||||
|
pl._tuner_note = '--'
|
||||||
|
pl._tuner_cents = 0
|
||||||
|
pl._tuner_string = -1
|
||||||
|
pl._tuner_confidence = 0.0
|
||||||
pl._master_volume = 0.8
|
pl._master_volume = 0.8
|
||||||
pl.routing_mode = "mono"
|
pl.routing_mode = "mono"
|
||||||
pl.routing_breakpoint = 7
|
pl.routing_breakpoint = 7
|
||||||
|
|||||||
Reference in New Issue
Block a user