Initial scaffold: Pi Multi-FX Pedal with NAM A2, IR cab, multi-FX, MIDI, stomp UI

This commit is contained in:
2026-06-07 23:22:43 -04:00
commit ed29748a62
24 changed files with 6215 additions and 0 deletions
View File
+597
View File
@@ -0,0 +1,597 @@
"""Preset and bank manager — save, load, navigate, and auto-restore presets.
The PresetManager owns the on-disk preset store and provides:
- Human-readable JSON preset storage
- Bank structure with 4 presets per bank
- Footswitch navigation (preset-up/down wraps within bank, bank-up/down switches banks)
- MIDI Program Change routing (bank=channel, program=preset)
- Auto-restore of last active preset on power cycle
- Factory preset installation
- Preset rename and reorder
"""
from __future__ import annotations
import json
import logging
import shutil
import threading
from pathlib import Path
from typing import Optional
from .types import Bank, FXBlock, FXType, MIDIMapping, Preset
logger = logging.getLogger(__name__)
# ── Constants ───────────────────────────────────────────────────────────────
PRESETS_PER_BANK = 4
"""Number of presets per bank (convention: footswitch 1-4 within a bank)."""
AUTO_SAVE_DELAY_S = 1.0
"""Debounce delay in seconds before writing auto-save state to disk."""
FACTORY_PRESET_DIR = Path(__file__).resolve().parent.parent.parent / "presets" / "factory"
"""Location of bundled factory preset JSON files."""
# ── Serialisation helpers ───────────────────────────────────────────────────
def _preset_to_dict(preset: Preset) -> dict:
"""Serialize a Preset to a JSON-compatible dict."""
return {
"name": preset.name,
"bank": preset.bank,
"program": preset.program,
"master_volume": preset.master_volume,
"tuner_enabled": preset.tuner_enabled,
"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 preset.chain
],
"midi_mappings": {
key: {
"cc_number": mapping.cc_number,
"channel": mapping.channel,
"min_val": mapping.min_val,
"max_val": mapping.max_val,
}
for key, mapping in preset.midi_mappings.items()
},
}
def _preset_from_dict(data: dict) -> Preset:
"""Deserialize a Preset from a JSON-compatible dict."""
chain = []
for block_data in data.get("chain", []):
chain.append(
FXBlock(
fx_type=FXType(block_data["fx_type"]),
enabled=block_data.get("enabled", True),
bypass=block_data.get("bypass", False),
params=dict(block_data.get("params", {})),
nam_model_path=block_data.get("nam_model_path", ""),
ir_file_path=block_data.get("ir_file_path", ""),
)
)
midi_mappings = {}
for key, md in data.get("midi_mappings", {}).items():
midi_mappings[key] = MIDIMapping(
cc_number=md.get("cc_number", 0),
channel=md.get("channel", 0),
min_val=md.get("min_val", 0.0),
max_val=md.get("max_val", 1.0),
)
return Preset(
name=data["name"],
bank=data.get("bank", 0),
program=data.get("program", 0),
chain=chain,
midi_mappings=midi_mappings,
master_volume=data.get("master_volume", 0.8),
tuner_enabled=data.get("tuner_enabled", False),
)
# ── The PresetManager ───────────────────────────────────────────────────────
class PresetManager:
"""Manages preset/bank storage, navigation, MIDI binding, and auto-save.
Args:
preset_dir: Directory for the preset store (created if missing).
audio_pipeline: Optional AudioPipeline to activate presets on.
When set, ``activate()`` calls ``pipeline.load_preset()``.
"""
def __init__(
self,
preset_dir: str | Path = "~/.pedal/presets",
audio_pipeline: object = None,
) -> None:
self._dir = Path(preset_dir).expanduser().resolve()
self._dir.mkdir(parents=True, exist_ok=True)
self._pipeline = audio_pipeline
# Runtime state
self._current_bank: int = 0
self._current_program: int = 0
self._dirty = False
self._lock = threading.Lock()
# Register of known bank numbers (populated lazily)
self._known_banks: set[int] = set()
# Auto-save debounce timer
self._auto_save_timer: Optional[threading.Timer] = None
logger.info("PresetManager root: %s", self._dir)
# ── Public navigation API ───────────────────────────────────────────────
@property
def current_bank(self) -> int:
return self._current_bank
@property
def current_program(self) -> int:
return self._current_program
@property
def current_preset_path(self) -> Path:
"""Path to the slot for the current (bank, program)."""
return self._preset_path(self._current_bank, self._current_program)
def preset_up(self) -> Preset:
"""Select next preset in current bank (wraps 3→0).
Returns:
The activated Preset.
"""
bank = self._get_or_create_bank(self._current_bank)
prog = (self._current_program + 1) % PRESETS_PER_BANK
return self._select_and_activate(self._current_bank, prog)
def preset_down(self) -> Preset:
"""Select previous preset in current bank (wraps 0→3).
Returns:
The activated Preset.
"""
prog = (self._current_program - 1) % PRESETS_PER_BANK
return self._select_and_activate(self._current_bank, prog)
def bank_up(self) -> tuple[Bank, Preset]:
"""Move to the next higher-numbered bank.
If there are no banks above the current one, wraps to the
lowest known bank number.
Returns:
(Bank, Preset) — the new bank and its currently active preset.
"""
return self._switch_bank(+1)
def bank_down(self) -> tuple[Bank, Preset]:
"""Move to the next lower-numbered bank (wraps around).
Returns:
(Bank, Preset) — the new bank and its currently active preset.
"""
return self._switch_bank(-1)
def select(self, bank: int, program: int) -> Preset:
"""Directly select a specific (bank, program) slot.
Args:
bank: Bank number.
program: Preset index (0-3) within the bank.
Returns:
The activated Preset.
"""
return self._select_and_activate(bank, program)
def midi_pc(self, channel: int, program: int) -> Preset:
"""Handle MIDI Program Change: bank=channel, program=program.
Args:
channel: MIDI channel (used as bank number).
program: MIDI program number (used as preset index).
Returns:
The activated Preset.
"""
logger.info("MIDI PC: bank=%d, program=%d", channel, program)
return self._select_and_activate(channel, program)
# ── CRUD ────────────────────────────────────────────────────────────────
def save(self, preset: Preset, *, auto: bool = False) -> None:
"""Save a preset to its (bank, program) slot on disk.
Args:
preset: The preset to persist.
auto: If True, this was triggered by auto-save and the
dirty/current-state write is implicitly handled.
"""
path = self._preset_path(preset.bank, preset.program)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(_preset_to_dict(preset), indent=2, sort_keys=True),
encoding="utf-8",
)
if not auto:
self._dirty = True
self._known_banks.add(preset.bank)
# Save/update bank metadata
self._save_bank_meta(preset.bank)
logger.info("Saved preset '%s'%s", preset.name, path)
def load(self, bank: int, program: int) -> Preset:
"""Load a preset from disk.
Args:
bank: Bank number.
program: Preset index (0-3).
Returns:
The deserialized Preset.
Raises:
FileNotFoundError: If the preset slot doesn't exist.
json.JSONDecodeError: If the file is corrupt.
"""
path = self._preset_path(bank, program)
if not path.exists():
raise FileNotFoundError(
f"No preset at (bank={bank}, program={program}): {path}"
)
data = json.loads(path.read_text(encoding="utf-8"))
preset = _preset_from_dict(data)
# Ensure metadata is correct even if file was manually altered
preset.bank = bank
preset.program = program
return preset
def delete(self, bank: int, program: int) -> None:
"""Delete a preset slot from disk."""
path = self._preset_path(bank, program)
if path.exists():
path.unlink()
logger.info("Deleted preset at (bank=%d, program=%d)", bank, program)
self._dirty = True
def rename(self, bank: int, program: int, new_name: str) -> Preset:
"""Rename a preset and re-save it.
Args:
bank: Bank number.
program: Preset index.
new_name: Replacement name.
Returns:
The renamed Preset.
"""
preset = self.load(bank, program)
preset.name = new_name
self.save(preset)
logger.info("Renamed preset at (%d,%d) → '%s'", bank, program, new_name)
return preset
def reorder(self, bank: int, program: int, new_program: int) -> None:
"""Move a preset to a different slot within the same bank.
If the target slot is occupied the two presets are swapped.
If the current active preset is being moved, tracking is updated.
Args:
bank: Bank number.
program: Current program index.
new_program: Target program index (0-3).
"""
if program == new_program:
return
if not (0 <= new_program < PRESETS_PER_BANK):
raise ValueError(f"Program index {new_program} out of range [0, {PRESETS_PER_BANK})")
# Load both existing presets (or create empty placeholder for empty slot)
try:
src = self.load(bank, program)
except FileNotFoundError:
raise FileNotFoundError(f"Cannot reorder: no preset at (bank={bank}, program={program})")
try:
dst = self.load(bank, new_program)
src.program = new_program
dst.program = program
self.save(dst)
except FileNotFoundError:
src.program = new_program
self.save(src)
# Update current tracking if we moved the active preset
with self._lock:
if self._current_bank == bank and self._current_program == program:
self._current_program = new_program
logger.info("Reordered preset at (%d,%d) → (%d,%d)", bank, program, bank, new_program)
# ── Bank management ─────────────────────────────────────────────────────
def list_banks(self) -> list[Bank]:
"""Scan the preset directory and return known banks sorted by number."""
banks: dict[int, Bank] = {}
for meta_path in sorted(self._dir.glob("bank_*/bank.json")):
try:
data = json.loads(meta_path.read_text(encoding="utf-8"))
num = data.get("number", 0)
preset_count = sum(
1 for _ in meta_path.parent.glob("preset_*.json")
)
banks[num] = Bank(
name=data.get("name", f"Bank {num}"),
number=num,
presets=[None] * preset_count, # Placeholder
)
except (json.JSONDecodeError, KeyError):
continue
# Also discover banks by scanning dirs without bank.json
for p_dir in sorted(self._dir.glob("bank_*")):
try:
num = int(p_dir.name.split("_")[1])
if num not in banks:
banks[num] = Bank(name=f"Bank {num}", number=num)
except (ValueError, IndexError):
continue
self._known_banks = set(banks.keys())
return [banks[k] for k in sorted(banks)]
def get_or_create_bank(self, number: int, name: str = "") -> Bank:
"""Return an existing bank or create a new one.
Args:
number: Bank number.
name: Optional display name (defaults to f"Bank {number}").
Returns:
The Bank object.
"""
return self._get_or_create_bank(number, name)
# ── Auto-restore ────────────────────────────────────────────────────────
def save_state(self) -> None:
"""Persist the current (bank, program) for power-cycle restore.
Call this whenever the active preset changes to guarantee
the pedal comes back to the same sound after a power cycle.
"""
state = {"current_bank": self._current_bank, "current_program": self._current_program}
state_path = self._dir / "state.json"
state_path.write_text(
json.dumps(state, indent=2), encoding="utf-8"
)
logger.debug("State saved: bank=%d program=%d", self._current_bank, self._current_program)
def restore_state(self) -> Optional[Preset]:
"""Restore the last active preset from state.json.
Returns:
The restored Preset, or None if no state file exists or the
saved slot is empty.
"""
state_path = self._dir / "state.json"
if not state_path.exists():
logger.info("No saved state to restore")
return None
try:
state = json.loads(state_path.read_text(encoding="utf-8"))
bank = state["current_bank"]
program = state["current_program"]
preset = self.load(bank, program)
except (FileNotFoundError, json.JSONDecodeError, KeyError):
logger.warning("Could not restore state, falling back to first preset")
return None
self._current_bank = bank
self._current_program = program
self._dirty = False
logger.info("Restored state: bank=%d program=%d '%s'", bank, program, preset.name)
return preset
# ── Activation ──────────────────────────────────────────────────────────
def activate(self, preset: Preset) -> None:
"""Apply a preset to the audio pipeline (if one is connected).
Args:
preset: The preset to activate.
"""
if self._pipeline is not None:
self._pipeline.load_preset(preset)
logger.info("Activated preset '%s' (bank=%d, program=%d)",
preset.name, preset.bank, preset.program)
# ── Factory presets ─────────────────────────────────────────────────────
def install_factory_presets(self, overwrite: bool = False) -> int:
"""Install bundled factory presets into the preset store.
Scans ``FACTORY_PRESET_DIR`` for ``bank_*/preset_*.json`` files
and copies them into the user preset store.
Args:
overwrite: If True, overwrite existing presets at the same
(bank, program) slots. If False, skip occupied slots.
Returns:
Number of factory presets installed.
"""
if not FACTORY_PRESET_DIR.is_dir():
logger.warning("Factory preset directory not found: %s", FACTORY_PRESET_DIR)
return 0
count = 0
for src in sorted(FACTORY_PRESET_DIR.rglob("preset_*.json")):
# Derive (bank, program) from file path
bank_dir = src.parent
try:
bank_num = int(bank_dir.name.split("_")[1])
prog_num = int(src.stem.split("_")[1])
except (ValueError, IndexError):
logger.warning("Skipping malformed factory preset path: %s", src)
continue
dest = self._preset_path(bank_num, prog_num)
if dest.exists() and not overwrite:
continue
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
self._known_banks.add(bank_num)
self._save_bank_meta(bank_num)
count += 1
logger.debug("Installed factory preset: %s", dest)
if count:
self._dirty = True
logger.info("Installed %d factory presets", count)
return count
# ── Internal helpers ────────────────────────────────────────────────────
def _preset_path(self, bank: int, program: int) -> Path:
"""Compute the on-disk path for a (bank, program) slot."""
return self._dir / f"bank_{bank}" / f"preset_{program}.json"
def _bank_dir(self, bank: int) -> Path:
return self._dir / f"bank_{bank}"
def _bank_meta_path(self, bank: int) -> Path:
return self._bank_dir(bank) / "bank.json"
def _get_or_create_bank(self, number: int, name: str = "") -> Bank:
"""Return an existing bank or create a new one on disk."""
meta_path = self._bank_meta_path(number)
if meta_path.exists():
try:
data = json.loads(meta_path.read_text(encoding="utf-8"))
return Bank(
name=data.get("name", name or f"Bank {number}"),
number=data.get("number", number),
)
except (json.JSONDecodeError, KeyError):
pass
# Create new bank
bank = Bank(name=name or f"Bank {number}", number=number)
self._save_bank_meta(number, bank.name)
self._known_banks.add(number)
logger.info("Created bank %d: '%s'", number, bank.name)
return bank
def _save_bank_meta(self, bank_num: int, name: str | None = None) -> None:
"""Write or update a bank's metadata file."""
meta_path = self._bank_meta_path(bank_num)
existing = {}
if meta_path.exists():
try:
existing = json.loads(meta_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
pass
meta = {
"number": bank_num,
"name": name or existing.get("name", f"Bank {bank_num}"),
"preset_count": PRESETS_PER_BANK,
}
meta_path.parent.mkdir(parents=True, exist_ok=True)
meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
def _switch_bank(self, direction: int) -> tuple[Bank, Preset]:
"""Move +1 or -1 through known banks, wrapping at edges.
Args:
direction: +1 for bank_up, -1 for bank_down.
Returns:
(Bank, Preset) of the new active slot.
"""
banks = self.list_banks()
if not banks:
raise RuntimeError("No banks available")
current_idx = next(
(i for i, b in enumerate(banks) if b.number == self._current_bank),
None,
)
if current_idx is None:
current_idx = 0
new_idx = (current_idx + direction) % len(banks)
new_bank = banks[new_idx]
# Activate preset at same program index within the new bank,
# defaulting to a blank preset if the slot is empty
try:
preset = self.load(new_bank.number, self._current_program)
except FileNotFoundError:
preset = Preset(
name=f"Empty {new_bank.name}",
bank=new_bank.number,
program=self._current_program,
)
self._current_bank = new_bank.number
self._current_program = preset.program
self.activate(preset)
self.save_state()
logger.info("Switched to bank %d, preset '%s'", new_bank.number, preset.name)
return new_bank, preset
def _select_and_activate(self, bank: int, program: int) -> Preset:
"""Select a (bank, program) slot and activate it.
If the slot is empty, creates a blank preset in that slot first.
Args:
bank: Bank number.
program: Preset index (0-3).
Returns:
The activated Preset.
"""
program = max(0, min(PRESETS_PER_BANK - 1, program))
try:
preset = self.load(bank, program)
except FileNotFoundError:
# Create a blank placeholder preset
bank_obj = self._get_or_create_bank(bank)
preset = Preset(name=f"New {bank_obj.name} #{program}", bank=bank, program=program)
self.save(preset, auto=True)
self._current_bank = bank
self._current_program = program
self.activate(preset)
self.save_state()
return preset
+69
View File
@@ -0,0 +1,69 @@
"""Preset and signal chain data model for the Pi Multi-FX Pedal."""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from typing import Optional
class FXType(enum.StrEnum):
"""Types of effects in the pedal signal chain."""
NOISE_GATE = "noise_gate"
COMPRESSOR = "compressor"
BOOST = "boost"
OVERDRIVE = "overdrive"
DISTORTION = "distortion"
FUZZ = "fuzz"
NAM_AMP = "nam_amp"
IR_CAB = "ir_cab"
EQ = "eq"
CHORUS = "chorus"
FLANGER = "flanger"
PHASER = "phaser"
TREMOLO = "tremolo"
VIBRATO = "vibrato"
DELAY = "delay"
REVERB = "reverb"
VOLUME = "volume"
TUNER = "tuner"
@dataclass
class FXBlock:
"""A single block in the signal chain."""
fx_type: FXType
enabled: bool = True
bypass: bool = False
params: dict[str, float] = field(default_factory=dict)
nam_model_path: str = "" # Path to .nam file (for NAM_AMP type)
ir_file_path: str = "" # Path to .wav IR file (for IR_CAB type)
@dataclass
class MIDIMapping:
"""MIDI control mapping for a parameter."""
cc_number: int = 0
channel: int = 0
min_val: float = 0.0
max_val: float = 1.0
@dataclass
class Preset:
"""A complete pedal preset — full signal chain state."""
name: str
bank: int = 0
program: int = 0
chain: list[FXBlock] = field(default_factory=list)
midi_mappings: dict[str, MIDIMapping] = field(default_factory=dict)
master_volume: float = 0.8
tuner_enabled: bool = False
@dataclass
class Bank:
"""A bank of presets (typically 4 per bank)."""
name: str
number: int
presets: list[Preset] = field(default_factory=list)