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
+43 -1
View File
@@ -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 ───────────────────────────────────────────────
+12
View File
@@ -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: