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
+247
View File
@@ -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 ────────────────────────────────