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:
2026-06-12 19:20:56 -04:00
parent a1f35a56e3
commit b6dff3c0f3
7 changed files with 702 additions and 154 deletions
+215
View File
@@ -89,6 +89,13 @@ function usePedalState() {
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));
} }
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 */ } } catch (e) { /* ignore parse errors */ }
}; };
ws.onclose = () => { ws.onclose = () => {
@@ -263,6 +270,38 @@ const css = `
border-radius: 16px 16px 0 0; overflow: hidden; display: flex; flex-direction: column; } 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}; .slide-panel-handle { width: 36px; height: 4px; border-radius: 2px; background: ${T.border};
margin: 8px auto; flex-shrink: 0; } 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 ────────────────────────────────────────────────────── // ── 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 &middot; Hold to save
{discardEdits && <span style={{ color: T.amber, marginLeft: 8 }}>Edits will be discarded on recall</span>}
</div>
<div className="snap-grid">
{[1,2,3,4,5,6,7,8].map(slot => {
const snap = snapshots[String(slot)];
const isActive = currentSlot === slot;
const isFilled = !!snap;
const isEditing = editingSlot === slot;
const isFlashing = saveFlash === slot;
return (
<div key={slot} className={`snap-slot ${isFilled ? "filled" : ""} ${isActive ? "active" : ""}`}
onMouseDown={() => handlePointerDown(slot)}
onTouchStart={() => handlePointerDown(slot)}
onMouseUp={() => handlePointerUp(slot)}
onTouchEnd={() => handlePointerUp(slot)}
onMouseLeave={handlePointerLeave}
onTouchCancel={handlePointerLeave}
style={isFlashing ? { background: 'rgba(58,184,122,.15)' } : {}}>
<div className="snap-num">{slot}</div>
{isEditing ? (
<input className="snap-name-input" value={editName}
onChange={e => setEditName(e.target.value)}
onBlur={() => handleRename(slot)}
onKeyDown={e => { if (e.key === 'Enter') handleRename(slot); if (e.key === 'Escape') setEditingSlot(null); }}
autoFocus onClick={e => e.stopPropagation()} />
) : snap ? (
<div className="snap-name"
onClick={e => {
e.stopPropagation();
setEditingSlot(slot);
setEditName(snap.name);
}}>{snap.name}</div>
) : (
<div className="snap-empty">Empty</div>
)}
{isFlashing && <div className="snap-save-indicator" />}
{isActive && <div style={{ position: "absolute", bottom: -2, left: "50%", transform: "translateX(-50%)",
width: 20, height: 2, borderRadius: 1, background: T.amber }} />}
</div>
);
})}
</div>
{/* Delete button for active snapshot */}
{currentSlot > 0 && snapshots[String(currentSlot)] && (
<div style={{ marginTop: 8, display: "flex", gap: 8 }}>
<button className="btn btn-danger btn-sm" style={{ flex: 1 }}
onClick={() => handleDelete(currentSlot)}>
Delete Snapshot {currentSlot}
</button>
</div>
)}
</div>
</SlidePanel>
);
}
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
// APP SHELL // APP SHELL
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
@@ -1656,6 +1856,7 @@ 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 { state, connected, refresh } = usePedalState(); const { state, connected, refresh } = usePedalState();
useEffect(() => { document.title = "Pi Multi-FX Pedal"; }, []); 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>} {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" }}>
{/* 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 && ( {state?.cpu_percent != null && (
<span className="mono" style={{ fontSize: 9, color: T.textDim }}>CPU {state.cpu_percent}%</span> <span className="mono" style={{ fontSize: 9, color: T.textDim }}>CPU {state.cpu_percent}%</span>
)} )}
@@ -1710,6 +1920,11 @@ export default function App() {
</div> </div>
))} ))}
</div> </div>
{/* Snapshot panel overlay */}
{showSnapshots && (
<SnapshotPanel onClose={() => setShowSnapshots(false)} state={state} connected={connected} />
)}
</div> </div>
</> </>
); );
+43 -1
View File
@@ -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 from .types import Bank, FXBlock, FXType, MIDIMapping, Preset, Snapshot
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -70,6 +70,24 @@ def _preset_to_dict(preset: Preset) -> dict:
} }
for key, mapping in preset.midi_mappings.items() 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), 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( return Preset(
name=data["name"], name=data["name"],
bank=data.get("bank", 0), bank=data.get("bank", 0),
@@ -107,6 +145,7 @@ def _preset_from_dict(data: dict) -> Preset:
routing_mode=data.get("routing_mode", "mono"), routing_mode=data.get("routing_mode", "mono"),
routing_breakpoint=data.get("routing_breakpoint", 7), routing_breakpoint=data.get("routing_breakpoint", 7),
tuner_enabled=data.get("tuner_enabled", False), tuner_enabled=data.get("tuner_enabled", False),
snapshots=snapshots,
) )
@@ -143,6 +182,9 @@ class PresetManager:
# Auto-save debounce timer # Auto-save debounce timer
self._auto_save_timer: Optional[threading.Timer] = None 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) logger.info("PresetManager root: %s", self._dir)
# ── Public navigation API ─────────────────────────────────────────────── # ── Public navigation API ───────────────────────────────────────────────
+12
View File
@@ -89,6 +89,14 @@ class MIDIMapping:
max_val: float = 1.0 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 @dataclass
class Preset: class Preset:
"""A complete pedal preset — full signal chain state.""" """A complete pedal preset — full signal chain state."""
@@ -104,6 +112,10 @@ class Preset:
routing_mode: str = "mono" # "mono" or "4cm" routing_mode: str = "mono" # "mono" or "4cm"
routing_breakpoint: int = 7 # chain index where pre/post split occurs 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 @dataclass
class Bank: class Bank:
+247
View File
@@ -350,6 +350,224 @@ class WebServer:
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(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") @app.post("/api/bypass")
async def toggle_bypass(data: Optional[dict] = None): async def toggle_bypass(data: Optional[dict] = None):
"""Toggle or set bypass state.""" """Toggle or set bypass state."""
@@ -382,6 +600,24 @@ class WebServer:
}) })
return {"ok": True, "tuner_enabled": pl._tuner_enabled} 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") @app.put("/api/volume")
async def set_volume(data: dict): async def set_volume(data: dict):
"""Set master volume (0.0-1.0).""" """Set master volume (0.0-1.0)."""
@@ -1099,6 +1335,11 @@ class WebServer:
"current_preset": None, "current_preset": None,
"bypass": False, "bypass": False,
"tuner_enabled": False, "tuner_enabled": False,
"tuner_frequency": 0.0,
"tuner_note": "--",
"tuner_cents": 0,
"tuner_string": -1,
"tuner_confidence": 0.0,
"master_volume": 0.8, "master_volume": 0.8,
"routing_mode": "mono", "routing_mode": "mono",
"routing_breakpoint": 7, "routing_breakpoint": 7,
@@ -1145,6 +1386,11 @@ class WebServer:
} if preset else None, } if preset else None,
"bypass": pl._bypassed if pl else False, "bypass": pl._bypassed if pl else False,
"tuner_enabled": pl._tuner_enabled 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, "master_volume": pl._master_volume if pl else 0.8,
"routing_mode": pl.routing_mode if pl else "mono", "routing_mode": pl.routing_mode if pl else "mono",
"routing_breakpoint": pl.routing_breakpoint if pl else 7, "routing_breakpoint": pl.routing_breakpoint if pl else 7,
@@ -1160,6 +1406,7 @@ class WebServer:
"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,
} }
# ── 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
+1 -1
View File
@@ -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-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"> <link rel="stylesheet" crossorigin href="/ui/assets/index-CuJgR-s6.css">
</head> </head>
<body> <body>