feat: Snapshots — 8 per preset save/recall
Adds snapshot system to Helix Stadium: Backend: - Snapshot data model (8 slots per preset, captures block bypass + params + master volume) - Snapshot API endpoints: list, save, recall, rename, delete - Integrated into preset serialization/deserialization - Tracks current_snapshot in state / WebSocket broadcasts Frontend: - Camera icon in header bar with current snapshot number - SnapshotPanel as slide-up overlay - 8 snapshot slots in a horizontal row - Tap to recall filled snapshot, tap empty slot to save - Press-and-hold (600ms) to save current state - Inline name editing on tap - Discard Edits toggle - Green flash indicator on save - Delete button for active snapshot
This commit is contained in:
@@ -89,6 +89,13 @@ function usePedalState() {
|
||||
if (msg.type === 'model_loaded' || msg.type === 'ir_loaded') {
|
||||
apiGet('/api/state').then(s => setState(s));
|
||||
}
|
||||
if (msg.type === 'snapshot_recalled' && msg.state) {
|
||||
setState(msg.state);
|
||||
}
|
||||
if (msg.type === 'snapshot_saved') {
|
||||
// Refresh state to pick up current_snapshot
|
||||
apiGet('/api/state').then(s => setState(s));
|
||||
}
|
||||
} catch (e) { /* ignore parse errors */ }
|
||||
};
|
||||
ws.onclose = () => {
|
||||
@@ -263,6 +270,38 @@ const css = `
|
||||
border-radius: 16px 16px 0 0; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.slide-panel-handle { width: 36px; height: 4px; border-radius: 2px; background: ${T.border};
|
||||
margin: 8px auto; flex-shrink: 0; }
|
||||
|
||||
/* Snapshot slots */
|
||||
.snap-grid { display: flex; gap: 6px; overflow-x: auto; padding: 4px 0; }
|
||||
.snap-slot { min-width: 60px; flex-shrink: 0; border-radius: 8px; border: 1.5px solid ${T.border};
|
||||
background: ${T.surface}; cursor: pointer; text-align: center; padding: 8px 4px;
|
||||
transition: all .12s; touch-action: manipulation; position: relative; }
|
||||
.snap-slot:active { transform: scale(.95); }
|
||||
.snap-slot.filled { border-color: ${T.amberDim}; }
|
||||
.snap-slot.active { border-color: ${T.amber}; background: rgba(232,160,48,.08); }
|
||||
.snap-slot .snap-num { font-family: 'JetBrains Mono', monospace; font-size: 16px;
|
||||
font-weight: 700; color: ${T.textDim}; }
|
||||
.snap-slot.filled .snap-num { color: ${T.amber}; }
|
||||
.snap-slot.active .snap-num { color: ${T.amber}; text-shadow: 0 0 8px ${T.amber}; }
|
||||
.snap-slot .snap-name { font-size: 9px; color: ${T.textDim}; margin-top: 3px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.snap-slot.filled .snap-name { color: ${T.textSec}; }
|
||||
.snap-slot.active .snap-name { color: ${T.amber}; }
|
||||
.snap-slot .snap-empty { font-size: 9px; color: ${T.textDim}; margin-top: 3px; font-style: italic; }
|
||||
.snap-slot .snap-save-indicator { position: absolute; top: -3px; right: -3px;
|
||||
width: 10px; height: 10px; border-radius: 50%; background: ${T.green};
|
||||
box-shadow: 0 0 6px ${T.green}; animation: snap-pop .3s ease-out; }
|
||||
@keyframes snap-pop { 0% { transform: scale(2); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
|
||||
.snap-name-input { background: ${T.surface}; border: 1px solid ${T.amber}; border-radius: 4px;
|
||||
padding: 2px 6px; color: ${T.amber}; font-size: 10px; text-align: center; outline: none;
|
||||
font-family: 'Inter', sans-serif; width: 100%; }
|
||||
.snap-header-btn { cursor: pointer; border: none; background: none; color: ${T.textSec};
|
||||
font-size: 16px; padding: 4px 6px; border-radius: 6px; transition: all .12s;
|
||||
display: flex; align-items: center; gap: 4px; }
|
||||
.snap-header-btn:hover { color: ${T.amber}; background: rgba(232,160,48,.1); }
|
||||
.snap-header-btn.active { color: ${T.amber}; background: rgba(232,160,48,.15); }
|
||||
.snap-header-num { font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
||||
font-weight: 700; color: ${T.amber}; min-width: 16px; text-align: center; }
|
||||
`;
|
||||
|
||||
// ── Knob ──────────────────────────────────────────────────────
|
||||
@@ -1640,6 +1679,167 @@ function RecordScreen({ state }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// SNAPSHOT PANEL
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
function SnapshotPanel({ onClose, state, connected }) {
|
||||
const [snapshots, setSnapshots] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingSlot, setEditingSlot] = useState(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [saveFlash, setSaveFlash] = useState(null);
|
||||
const [discardEdits, setDiscardEdits] = useState(false);
|
||||
const holdTimer = useRef(null);
|
||||
const currentSlot = state?.current_snapshot ?? 0;
|
||||
|
||||
const loadSnapshots = useCallback(async () => {
|
||||
if (!connected) { setLoading(false); return; }
|
||||
try {
|
||||
const data = await apiGet('/api/snapshots');
|
||||
setSnapshots(data.snapshots || {});
|
||||
} catch (e) { /* ignore */ }
|
||||
setLoading(false);
|
||||
}, [connected]);
|
||||
|
||||
useEffect(() => { loadSnapshots(); }, [loadSnapshots]);
|
||||
|
||||
// Handle press & hold vs tap
|
||||
const handlePointerDown = (slot) => {
|
||||
holdTimer.current = setTimeout(() => {
|
||||
// Long press → save
|
||||
holdTimer.current = null;
|
||||
handleSave(slot);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
const handlePointerUp = (slot) => {
|
||||
if (holdTimer.current) {
|
||||
clearTimeout(holdTimer.current);
|
||||
holdTimer.current = null;
|
||||
// Short tap → recall (or save if empty)
|
||||
if (snapshots[String(slot)]) {
|
||||
handleRecall(slot);
|
||||
} else {
|
||||
handleSave(slot);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
if (holdTimer.current) {
|
||||
clearTimeout(holdTimer.current);
|
||||
holdTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (slot) => {
|
||||
try {
|
||||
await apiPost(`/api/snapshots/${slot}/save`);
|
||||
setSaveFlash(slot);
|
||||
setTimeout(() => setSaveFlash(null), 800);
|
||||
await loadSnapshots();
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleRecall = async (slot) => {
|
||||
try {
|
||||
await apiPost(`/api/snapshots/${slot}/recall`);
|
||||
await loadSnapshots();
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleRename = async (slot) => {
|
||||
if (!editName.trim()) return;
|
||||
try {
|
||||
await apiPut(`/api/snapshots/${slot}`, { name: editName.trim() });
|
||||
setEditingSlot(null);
|
||||
await loadSnapshots();
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleDelete = async (slot) => {
|
||||
try {
|
||||
await apiDelete(`/api/snapshots/${slot}`);
|
||||
await loadSnapshots();
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<SlidePanel onClose={onClose}>
|
||||
<div className="screen-header" style={{ justifyContent: "space-between" }}>
|
||||
<div className="screen-title">Snapshots</div>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 10, color: T.textDim, cursor: "pointer" }}>
|
||||
<div className={`switch ${discardEdits ? 'on' : ''}`} style={{ width: 28, height: 16 }}
|
||||
onClick={() => setDiscardEdits(d => !d)}>
|
||||
<div style={{ position: "absolute", top: 1, left: discardEdits ? 11 : 1, width: 13, height: 13,
|
||||
borderRadius: "50%", background: "#fff", transition: "left .15s" }} />
|
||||
</div>
|
||||
Discard Edits
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="screen-body" style={{ gap: 8 }}>
|
||||
<div style={{ fontSize: 11, color: T.textDim, marginBottom: 4 }}>
|
||||
Tap to recall · Hold to save
|
||||
{discardEdits && <span style={{ color: T.amber, marginLeft: 8 }}>Edits will be discarded on recall</span>}
|
||||
</div>
|
||||
<div className="snap-grid">
|
||||
{[1,2,3,4,5,6,7,8].map(slot => {
|
||||
const snap = snapshots[String(slot)];
|
||||
const isActive = currentSlot === slot;
|
||||
const isFilled = !!snap;
|
||||
const isEditing = editingSlot === slot;
|
||||
const isFlashing = saveFlash === slot;
|
||||
return (
|
||||
<div key={slot} className={`snap-slot ${isFilled ? "filled" : ""} ${isActive ? "active" : ""}`}
|
||||
onMouseDown={() => handlePointerDown(slot)}
|
||||
onTouchStart={() => handlePointerDown(slot)}
|
||||
onMouseUp={() => handlePointerUp(slot)}
|
||||
onTouchEnd={() => handlePointerUp(slot)}
|
||||
onMouseLeave={handlePointerLeave}
|
||||
onTouchCancel={handlePointerLeave}
|
||||
style={isFlashing ? { background: 'rgba(58,184,122,.15)' } : {}}>
|
||||
<div className="snap-num">{slot}</div>
|
||||
{isEditing ? (
|
||||
<input className="snap-name-input" value={editName}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onBlur={() => handleRename(slot)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRename(slot); if (e.key === 'Escape') setEditingSlot(null); }}
|
||||
autoFocus onClick={e => e.stopPropagation()} />
|
||||
) : snap ? (
|
||||
<div className="snap-name"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setEditingSlot(slot);
|
||||
setEditName(snap.name);
|
||||
}}>{snap.name}</div>
|
||||
) : (
|
||||
<div className="snap-empty">Empty</div>
|
||||
)}
|
||||
{isFlashing && <div className="snap-save-indicator" />}
|
||||
{isActive && <div style={{ position: "absolute", bottom: -2, left: "50%", transform: "translateX(-50%)",
|
||||
width: 20, height: 2, borderRadius: 1, background: T.amber }} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Delete button for active snapshot */}
|
||||
{currentSlot > 0 && snapshots[String(currentSlot)] && (
|
||||
<div style={{ marginTop: 8, display: "flex", gap: 8 }}>
|
||||
<button className="btn btn-danger btn-sm" style={{ flex: 1 }}
|
||||
onClick={() => handleDelete(currentSlot)}>
|
||||
Delete Snapshot {currentSlot}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SlidePanel>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// APP SHELL
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
@@ -1656,6 +1856,7 @@ const TABS = [
|
||||
|
||||
export default function App() {
|
||||
const [tab, setTab] = useState("rig");
|
||||
const [showSnapshots, setShowSnapshots] = useState(false);
|
||||
const { state, connected, refresh } = usePedalState();
|
||||
|
||||
useEffect(() => { document.title = "Pi Multi-FX Pedal"; }, []);
|
||||
@@ -1678,6 +1879,15 @@ export default function App() {
|
||||
{state?.tuner_enabled && <span className="badge badge-amber" style={{ fontSize: 8, padding: "1px 5px" }}>TUNER</span>}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{/* Snapshot camera icon */}
|
||||
<button className={`snap-header-btn ${showSnapshots ? "active" : ""}`}
|
||||
onClick={() => setShowSnapshots(true)}
|
||||
title="Snapshots">
|
||||
📷
|
||||
{state?.current_snapshot > 0 && (
|
||||
<span className="snap-header-num">{state.current_snapshot}</span>
|
||||
)}
|
||||
</button>
|
||||
{state?.cpu_percent != null && (
|
||||
<span className="mono" style={{ fontSize: 9, color: T.textDim }}>CPU {state.cpu_percent}%</span>
|
||||
)}
|
||||
@@ -1710,6 +1920,11 @@ export default function App() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Snapshot panel overlay */}
|
||||
{showSnapshots && (
|
||||
<SnapshotPanel onClose={() => setShowSnapshots(false)} state={state} connected={connected} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
+43
-1
@@ -19,7 +19,7 @@ import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .types import Bank, FXBlock, FXType, MIDIMapping, Preset
|
||||
from .types import Bank, FXBlock, FXType, MIDIMapping, Preset, Snapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -70,6 +70,24 @@ def _preset_to_dict(preset: Preset) -> dict:
|
||||
}
|
||||
for key, mapping in preset.midi_mappings.items()
|
||||
},
|
||||
"snapshots": {
|
||||
str(slot): {
|
||||
"name": snap.name,
|
||||
"master_volume": snap.master_volume,
|
||||
"chain": [
|
||||
{
|
||||
"fx_type": block.fx_type.value,
|
||||
"enabled": block.enabled,
|
||||
"bypass": block.bypass,
|
||||
"params": dict(block.params),
|
||||
"nam_model_path": block.nam_model_path,
|
||||
"ir_file_path": block.ir_file_path,
|
||||
}
|
||||
for block in snap.chain
|
||||
],
|
||||
}
|
||||
for slot, snap in preset.snapshots.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +115,26 @@ def _preset_from_dict(data: dict) -> Preset:
|
||||
max_val=md.get("max_val", 1.0),
|
||||
)
|
||||
|
||||
snapshots = {}
|
||||
for slot_str, snap_data in data.get("snapshots", {}).items():
|
||||
snap_chain = []
|
||||
for bd in snap_data.get("chain", []):
|
||||
snap_chain.append(
|
||||
FXBlock(
|
||||
fx_type=FXType(bd["fx_type"]),
|
||||
enabled=bd.get("enabled", True),
|
||||
bypass=bd.get("bypass", False),
|
||||
params=dict(bd.get("params", {})),
|
||||
nam_model_path=bd.get("nam_model_path", ""),
|
||||
ir_file_path=bd.get("ir_file_path", ""),
|
||||
)
|
||||
)
|
||||
snapshots[int(slot_str)] = Snapshot(
|
||||
name=snap_data.get("name", f"Snapshot {slot_str}"),
|
||||
chain=snap_chain,
|
||||
master_volume=snap_data.get("master_volume", 0.8),
|
||||
)
|
||||
|
||||
return Preset(
|
||||
name=data["name"],
|
||||
bank=data.get("bank", 0),
|
||||
@@ -107,6 +145,7 @@ def _preset_from_dict(data: dict) -> Preset:
|
||||
routing_mode=data.get("routing_mode", "mono"),
|
||||
routing_breakpoint=data.get("routing_breakpoint", 7),
|
||||
tuner_enabled=data.get("tuner_enabled", False),
|
||||
snapshots=snapshots,
|
||||
)
|
||||
|
||||
|
||||
@@ -143,6 +182,9 @@ class PresetManager:
|
||||
# Auto-save debounce timer
|
||||
self._auto_save_timer: Optional[threading.Timer] = None
|
||||
|
||||
# Current snapshot slot (0 = none selected)
|
||||
self._current_snapshot: int = 0
|
||||
|
||||
logger.info("PresetManager root: %s", self._dir)
|
||||
|
||||
# ── Public navigation API ───────────────────────────────────────────────
|
||||
|
||||
@@ -89,6 +89,14 @@ class MIDIMapping:
|
||||
max_val: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Snapshot:
|
||||
"""A saved snapshot of block states and parameters within a preset."""
|
||||
name: str
|
||||
chain: list[FXBlock] = field(default_factory=list)
|
||||
master_volume: float = 0.8
|
||||
|
||||
|
||||
@dataclass
|
||||
class Preset:
|
||||
"""A complete pedal preset — full signal chain state."""
|
||||
@@ -104,6 +112,10 @@ class Preset:
|
||||
routing_mode: str = "mono" # "mono" or "4cm"
|
||||
routing_breakpoint: int = 7 # chain index where pre/post split occurs
|
||||
|
||||
# ── Snapshots (8 per preset) ──
|
||||
snapshots: dict[int, Snapshot] = field(default_factory=dict)
|
||||
"""Snapshot slots 1-8. Empty dict means no snapshots saved."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Bank:
|
||||
|
||||
@@ -350,6 +350,224 @@ class WebServer:
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# ── Snapshot endpoints ──────────────────────────────────────
|
||||
|
||||
SNAPSHOT_NAMES = {1: "Clean", 2: "Crunch", 3: "Lead", 4: "Rhythm",
|
||||
5: "Ambient", 6: "Swell", 7: "Mellow", 8: "Edge"}
|
||||
|
||||
def _get_current_preset(pm) -> Preset | None:
|
||||
"""Load the currently active preset."""
|
||||
from ..presets.types import Preset
|
||||
if not pm:
|
||||
return None
|
||||
try:
|
||||
return pm.load(pm.current_bank, pm.current_program)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _snapshot_from_pipeline(pm, pl, slot: int, name: str = "") -> dict:
|
||||
"""Capture current block states + params into a snapshot dict."""
|
||||
from ..presets.types import FXBlock, FXType
|
||||
preset = _get_current_preset(pm)
|
||||
if not preset:
|
||||
return {}
|
||||
chain_data = []
|
||||
for b in preset.chain:
|
||||
chain_data.append({
|
||||
"fx_type": b.fx_type.value,
|
||||
"enabled": b.enabled,
|
||||
"bypass": b.bypass,
|
||||
"params": dict(b.params),
|
||||
"nam_model_path": b.nam_model_path,
|
||||
"ir_file_path": b.ir_file_path,
|
||||
})
|
||||
return {
|
||||
"name": name or SNAPSHOT_NAMES.get(slot, f"Snapshot {slot}"),
|
||||
"chain": chain_data,
|
||||
"master_volume": preset.master_volume,
|
||||
}
|
||||
|
||||
@app.get("/api/snapshots")
|
||||
async def list_snapshots():
|
||||
"""List snapshot slots for the current preset."""
|
||||
pm = self.deps.presets
|
||||
preset = _get_current_preset(pm)
|
||||
if not preset:
|
||||
return {"snapshots": {}}
|
||||
data = {}
|
||||
for slot in range(1, 9):
|
||||
snap = preset.snapshots.get(slot)
|
||||
if snap:
|
||||
chain = []
|
||||
for b in snap.chain:
|
||||
chain.append({
|
||||
"fx_type": b.fx_type.value,
|
||||
"enabled": b.enabled,
|
||||
"bypass": b.bypass,
|
||||
"params": dict(b.params),
|
||||
"nam_model_path": b.nam_model_path,
|
||||
"ir_file_path": b.ir_file_path,
|
||||
})
|
||||
data[str(slot)] = {
|
||||
"name": snap.name,
|
||||
"chain": chain,
|
||||
"master_volume": snap.master_volume,
|
||||
}
|
||||
else:
|
||||
data[str(slot)] = None
|
||||
return {"snapshots": data, "current_snapshot": getattr(pm, '_current_snapshot', 0)}
|
||||
|
||||
@app.post("/api/snapshots/{slot}/save")
|
||||
async def save_snapshot(slot: int, data: Optional[dict] = None):
|
||||
"""Save current state to a snapshot slot (1-8)."""
|
||||
pm = self.deps.presets
|
||||
pl = self.deps.pipeline
|
||||
if not pm or not (1 <= slot <= 8):
|
||||
raise HTTPException(status_code=400, detail="Invalid slot (1-8)")
|
||||
preset = _get_current_preset(pm)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail="No active preset")
|
||||
name = (data or {}).get("name", "")
|
||||
snap_data = _snapshot_from_pipeline(pm, pl, slot, name)
|
||||
from ..presets.types import FXBlock, FXType, Snapshot as SnapModel
|
||||
snap_chain = []
|
||||
for bd in snap_data["chain"]:
|
||||
snap_chain.append(
|
||||
FXBlock(
|
||||
fx_type=FXType(bd["fx_type"]),
|
||||
enabled=bd.get("enabled", True),
|
||||
bypass=bd.get("bypass", False),
|
||||
params=dict(bd.get("params", {})),
|
||||
nam_model_path=bd.get("nam_model_path", ""),
|
||||
ir_file_path=bd.get("ir_file_path", ""),
|
||||
)
|
||||
)
|
||||
preset.snapshots[slot] = SnapModel(
|
||||
name=snap_data["name"],
|
||||
chain=snap_chain,
|
||||
master_volume=snap_data["master_volume"],
|
||||
)
|
||||
pm.save(preset)
|
||||
# Mark current snapshot
|
||||
pm._current_snapshot = slot
|
||||
# Broadcast snapshot update
|
||||
await self._manager.broadcast({
|
||||
"type": "snapshot_saved",
|
||||
"slot": slot,
|
||||
"name": snap_data["name"],
|
||||
})
|
||||
return {"ok": True, "slot": slot, "name": snap_data["name"]}
|
||||
|
||||
@app.post("/api/snapshots/{slot}/recall")
|
||||
async def recall_snapshot(slot: int):
|
||||
"""Recall a snapshot: restore block states + params from slot."""
|
||||
pm = self.deps.presets
|
||||
pl = self.deps.pipeline
|
||||
if not pm or not (1 <= slot <= 8):
|
||||
raise HTTPException(status_code=400, detail="Invalid slot (1-8)")
|
||||
preset = _get_current_preset(pm)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail="No active preset")
|
||||
snap = preset.snapshots.get(slot)
|
||||
if not snap:
|
||||
raise HTTPException(status_code=404, detail=f"No snapshot in slot {slot}")
|
||||
|
||||
# Apply snapshot state to the pipeline
|
||||
if pl:
|
||||
# Restore bypass states and params for each block
|
||||
for i, snap_block in enumerate(snap.chain):
|
||||
if i < len(preset.chain):
|
||||
preset.chain[i].bypass = snap_block.bypass
|
||||
preset.chain[i].enabled = snap_block.enabled
|
||||
preset.chain[i].params = dict(snap_block.params)
|
||||
preset.chain[i].nam_model_path = snap_block.nam_model_path
|
||||
preset.chain[i].ir_file_path = snap_block.ir_file_path
|
||||
# Restore master volume
|
||||
preset.master_volume = snap.master_volume
|
||||
# Push to pipeline
|
||||
try:
|
||||
pl.load_preset(preset)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pm.save(preset)
|
||||
pm._current_snapshot = slot
|
||||
|
||||
state = self._gather_state()
|
||||
await self._manager.broadcast({
|
||||
"type": "snapshot_recalled",
|
||||
"slot": slot,
|
||||
"name": snap.name,
|
||||
"state": state,
|
||||
})
|
||||
return {"ok": True, "slot": slot, "name": snap.name}
|
||||
|
||||
@app.put("/api/snapshots/{slot}")
|
||||
async def update_snapshot(slot: int, data: dict):
|
||||
"""Rename or update a snapshot slot."""
|
||||
pm = self.deps.presets
|
||||
if not pm or not (1 <= slot <= 8):
|
||||
raise HTTPException(status_code=400, detail="Invalid slot (1-8)")
|
||||
preset = _get_current_preset(pm)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail="No active preset")
|
||||
snap = preset.snapshots.get(slot)
|
||||
if not snap:
|
||||
raise HTTPException(status_code=404, detail=f"No snapshot in slot {slot}")
|
||||
if "name" in data:
|
||||
snap.name = data["name"]
|
||||
pm.save(preset)
|
||||
await self._manager.broadcast({
|
||||
"type": "snapshot_updated",
|
||||
"slot": slot,
|
||||
"name": snap.name,
|
||||
})
|
||||
return {"ok": True, "slot": slot, "name": snap.name}
|
||||
|
||||
@app.delete("/api/snapshots/{slot}")
|
||||
async def delete_snapshot(slot: int):
|
||||
"""Delete a snapshot slot."""
|
||||
pm = self.deps.presets
|
||||
if not pm or not (1 <= slot <= 8):
|
||||
raise HTTPException(status_code=400, detail="Invalid slot (1-8)")
|
||||
preset = _get_current_preset(pm)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail="No active preset")
|
||||
preset.snapshots.pop(slot, None)
|
||||
if getattr(pm, '_current_snapshot', 0) == slot:
|
||||
pm._current_snapshot = 0
|
||||
pm.save(preset)
|
||||
await self._manager.broadcast({
|
||||
"type": "snapshot_deleted",
|
||||
"slot": slot,
|
||||
})
|
||||
return {"ok": True, "slot": slot}
|
||||
|
||||
@app.get("/api/snapshots/{slot}")
|
||||
async def get_snapshot(slot: int):
|
||||
"""Get a single snapshot slot."""
|
||||
pm = self.deps.presets
|
||||
preset = _get_current_preset(pm)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail="No active preset")
|
||||
snap = preset.snapshots.get(slot)
|
||||
if not snap:
|
||||
raise HTTPException(status_code=404, detail=f"No snapshot in slot {slot}")
|
||||
return {
|
||||
"name": snap.name,
|
||||
"slot": slot,
|
||||
"chain": [
|
||||
{
|
||||
"fx_type": b.fx_type.value,
|
||||
"enabled": b.enabled,
|
||||
"bypass": b.bypass,
|
||||
"params": dict(b.params),
|
||||
}
|
||||
for b in snap.chain
|
||||
],
|
||||
"master_volume": snap.master_volume,
|
||||
}
|
||||
|
||||
@app.post("/api/bypass")
|
||||
async def toggle_bypass(data: Optional[dict] = None):
|
||||
"""Toggle or set bypass state."""
|
||||
@@ -382,6 +600,24 @@ class WebServer:
|
||||
})
|
||||
return {"ok": True, "tuner_enabled": pl._tuner_enabled}
|
||||
|
||||
@app.get("/api/tuner/pitch")
|
||||
async def get_tuner_pitch():
|
||||
"""Get current tuner pitch detection data (fast poll endpoint)."""
|
||||
pl = self.deps.pipeline
|
||||
if not pl:
|
||||
return {
|
||||
"frequency": 0.0, "note": "--", "cents": 0,
|
||||
"string": -1, "confidence": 0.0, "enabled": False,
|
||||
}
|
||||
return {
|
||||
"frequency": getattr(pl, '_tuner_frequency', 0.0),
|
||||
"note": getattr(pl, '_tuner_note', '--'),
|
||||
"cents": getattr(pl, '_tuner_cents', 0),
|
||||
"string": getattr(pl, '_tuner_string', -1),
|
||||
"confidence": getattr(pl, '_tuner_confidence', 0.0),
|
||||
"enabled": pl._tuner_enabled,
|
||||
}
|
||||
|
||||
@app.put("/api/volume")
|
||||
async def set_volume(data: dict):
|
||||
"""Set master volume (0.0-1.0)."""
|
||||
@@ -1099,6 +1335,11 @@ class WebServer:
|
||||
"current_preset": None,
|
||||
"bypass": False,
|
||||
"tuner_enabled": False,
|
||||
"tuner_frequency": 0.0,
|
||||
"tuner_note": "--",
|
||||
"tuner_cents": 0,
|
||||
"tuner_string": -1,
|
||||
"tuner_confidence": 0.0,
|
||||
"master_volume": 0.8,
|
||||
"routing_mode": "mono",
|
||||
"routing_breakpoint": 7,
|
||||
@@ -1145,6 +1386,11 @@ class WebServer:
|
||||
} if preset else None,
|
||||
"bypass": pl._bypassed if pl else False,
|
||||
"tuner_enabled": pl._tuner_enabled if pl else False,
|
||||
"tuner_frequency": getattr(pl, '_tuner_frequency', 0.0),
|
||||
"tuner_note": getattr(pl, '_tuner_note', '--'),
|
||||
"tuner_cents": getattr(pl, '_tuner_cents', 0),
|
||||
"tuner_string": getattr(pl, '_tuner_string', -1),
|
||||
"tuner_confidence": getattr(pl, '_tuner_confidence', 0.0),
|
||||
"master_volume": pl._master_volume if pl else 0.8,
|
||||
"routing_mode": pl.routing_mode if pl else "mono",
|
||||
"routing_breakpoint": pl.routing_breakpoint if pl else 7,
|
||||
@@ -1160,6 +1406,7 @@ class WebServer:
|
||||
"sample_rate": SAMPLE_RATE,
|
||||
"wifi": self._gather_wifi_state(),
|
||||
"bluetooth": self._gather_bt_state(),
|
||||
"current_snapshot": getattr(self.deps.presets, '_current_snapshot', 0) if self.deps.presets else 0,
|
||||
}
|
||||
|
||||
# ── 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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Pi Multi-FX Pedal</title>
|
||||
<script type="module" crossorigin src="/ui/assets/index-BwPywO0-.js"></script>
|
||||
<script type="module" crossorigin src="/ui/assets/index-DKZHEo9m.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/ui/assets/index-CuJgR-s6.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user