Initial scaffold: Pi Multi-FX Pedal with NAM A2, IR cab, multi-FX, MIDI, stomp UI
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user