Files
pi-multifx-pedal/src/presets/manager.py
T
shawn 659e53766a fix(bass-presets): move factory presets to channel subdirs + per-channel install
Root cause: factory presets for bass (banks 17-20) sat in the root
factory dir alongside guitar presets, and install_factory_presets()
used rglob() which picked up ALL presets into the GUITAR channel.

Changes:
- Move bank_17-20 from presets/factory/ → presets/factory/bass/
  (matching the existing keys/, vocals/, backing_tracks/ layout)
- Fix install_factory_presets() to scan channel-specific subdirectory:
  GUITAR scans root bank_* dirs, all others scan <channel>/bank_*
- Fix main.py to iterate over all 5 channels when installing factory
  presets (was only installing into GUITAR)
- Import Channel enum in main.py

Tests: 56/56 preset manager tests pass. Integration verified:
- GUITAR: 68 presets (banks 0-16)
- BASS: 16 presets (banks 17-20, SansAmp/Darkglass/Ampeg SVT)
- KEYS: 12 presets (banks 21-23)
- VOCALS: 12 presets (banks 24-26)
- BACKING_TRACKS: 12 presets (banks 27-29)
2026-06-13 10:23:57 -04:00

925 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, Channel, FXBlock, FXType, MIDIMapping, Preset, Snapshot
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."""
FACTORY_IR_DIR = FACTORY_PRESET_DIR / "irs"
"""Location of bundled factory cabinet IR .wav files."""
# ── Serialisation helpers ───────────────────────────────────────────────────
def _preset_to_dict(preset: Preset) -> dict:
"""Serialize a Preset to a JSON-compatible dict."""
return {
"name": preset.name,
"channel": preset.channel.value,
"bank": preset.bank,
"program": preset.program,
"master_volume": preset.master_volume,
"routing_mode": preset.routing_mode,
"routing_breakpoint": preset.routing_breakpoint,
"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()
},
"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()
},
}
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),
)
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"],
channel=Channel(data.get("channel", "guitar")),
bank=data.get("bank", 0),
program=data.get("program", 0),
chain=chain,
midi_mappings=midi_mappings,
master_volume=data.get("master_volume", 0.8),
routing_mode=data.get("routing_mode", "mono"),
routing_breakpoint=data.get("routing_breakpoint", 7),
tuner_enabled=data.get("tuner_enabled", False),
snapshots=snapshots,
)
# ── 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_channel: Channel = Channel.GUITAR
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
# Current snapshot slot (0 = none selected)
self._current_snapshot: int = 0
# Migrate legacy flat presets → gtr channel on first boot
self._migrate_legacy_presets()
logger.info("PresetManager root: %s (channel=%s)", self._dir, self._current_channel.value)
# ── Public navigation API ───────────────────────────────────────────────
@property
def current_channel(self) -> Channel:
return self._current_channel
@property
def current_bank(self) -> int:
return self._current_bank
@property
def current_program(self) -> int:
return self._current_program
def set_channel(self, channel: Channel) -> Preset:
"""Switch the active channel and restore its last preset.
Args:
channel: The channel to switch to.
Returns:
The currently active Preset in the new channel.
"""
if channel == self._current_channel:
try:
return self.load(self._current_bank, self._current_program)
except FileNotFoundError:
pass
return self._select_and_activate(self._current_bank, self._current_program)
# Save current channel state first
self.save_state()
self._current_channel = channel
# Try restoring channel-specific state
restored = self._restore_channel_state()
if restored:
return restored
# Default to bank 0, program 0 in the new channel
try:
return self._select_and_activate(0, 0)
except Exception:
# Channel is empty — create first blank preset
return self._select_and_activate(0, 0)
@property
def current_preset_path(self) -> Path:
"""Path to the slot for the current (channel, bank, program)."""
return self._preset_path(self._current_channel, 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, channel: Channel | None = None) -> Preset:
"""Directly select a specific (bank, program) slot.
Args:
bank: Bank number.
program: Preset index (0-3) within the bank.
channel: Optional channel override (defaults to current).
Returns:
The activated Preset.
"""
return self._select_and_activate(bank, program, channel or self._current_channel)
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, channel: Channel | None = None) -> 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.
channel: Channel to save under (defaults to preset.channel).
"""
ch = channel or preset.channel
path = self._preset_path(ch, 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(ch, preset.bank)
logger.info("Saved preset '%s'%s", preset.name, path)
def load(self, bank: int, program: int, channel: Channel | None = None) -> Preset:
"""Load a preset from disk.
Args:
bank: Bank number.
program: Preset index (0-3).
channel: Channel to load from (defaults to current).
Returns:
The deserialized Preset.
Raises:
FileNotFoundError: If the preset slot doesn't exist.
json.JSONDecodeError: If the file is corrupt.
"""
ch = channel or self._current_channel
path = self._preset_path(ch, bank, program)
if not path.exists():
raise FileNotFoundError(
f"No preset at (channel={ch.value}, 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.channel = ch
preset.bank = bank
preset.program = program
return preset
def delete(self, bank: int, program: int, channel: Channel | None = None) -> None:
"""Delete a preset slot from disk.
Args:
bank: Bank number.
program: Preset index.
channel: Channel to delete from (defaults to current).
"""
ch = channel or self._current_channel
path = self._preset_path(ch, bank, program)
if path.exists():
path.unlink()
logger.info("Deleted preset at (channel=%s, bank=%d, program=%d)", ch.value, bank, program)
self._dirty = True
def rename(self, bank: int, program: int, new_name: str, channel: Channel | None = None) -> Preset:
"""Rename a preset and re-save it.
Args:
bank: Bank number.
program: Preset index.
new_name: Replacement name.
channel: Channel (defaults to current).
Returns:
The renamed Preset.
"""
preset = self.load(bank, program, channel)
preset.name = new_name
self.save(preset, channel=channel)
logger.info("Renamed preset at (%s,%d,%d) → '%s'",
(channel or self._current_channel).value, 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)
# ── 4CM routing config ──────────────────────────────────────────────
def get_4cm_config(self, preset: Preset) -> tuple[str, int]:
"""Return (routing_mode, routing_breakpoint) from a preset.
Returns:
Tuple of ``(routing_mode, routing_breakpoint)``.
"""
return (preset.routing_mode, preset.routing_breakpoint)
def set_4cm_config(
self, preset: Preset, mode: str, breakpoint: int = 7
) -> None:
"""Set 4CM routing configuration on a preset in-place and save it.
Args:
preset: The preset to modify.
mode: ``\"mono\"`` or ``\"4cm\"``.
breakpoint: Chain index where the pre/post split occurs
(default 7 = after IR_CAB, before mod FX).
Raises:
ValueError: If ``mode`` is not ``\"mono\"`` or ``\"4cm\"``.
"""
if mode not in ("mono", "4cm"):
raise ValueError(f"routing_mode must be 'mono' or '4cm', got {mode!r}")
preset.routing_mode = mode
preset.routing_breakpoint = breakpoint
self.save(preset)
logger.info("Set 4CM config on '%s': mode=%s breakpoint=%d",
preset.name, mode, breakpoint)
# ── Bank management ─────────────────────────────────────────────────────
def list_banks(self, channel: Channel | None = None) -> list[Bank]:
"""Scan the preset directory and return known banks sorted by number.
Args:
channel: Channel to list banks for (defaults to current).
Returns:
List of Bank objects sorted by number.
"""
ch = channel or self._current_channel
ch_dir = self._dir / ch.value
if not ch_dir.is_dir():
return []
banks: dict[int, Bank] = {}
for meta_path in sorted(ch_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(ch_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 (channel, bank, program) for power-cycle restore.
Call this whenever the active preset or channel changes to guarantee
the pedal comes back to the same sound after a power cycle.
"""
state = {
"current_channel": self._current_channel.value,
"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: channel=%s bank=%d program=%d",
self._current_channel.value, self._current_bank, self._current_program)
def restore_state(self) -> Optional[Preset]:
"""Restore the last active (channel, bank, program) 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"))
ch = Channel(state.get("current_channel", "guitar"))
bank = state["current_bank"]
program = state["current_program"]
preset = self.load(bank, program, ch)
except (FileNotFoundError, json.JSONDecodeError, KeyError, ValueError):
logger.warning("Could not restore state, falling back to first preset")
return None
self._current_channel = ch
self._current_bank = bank
self._current_program = program
self._dirty = False
logger.info("Restored state: channel=%s bank=%d program=%d '%s'",
ch.value, 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,
channel: Channel = Channel.GUITAR) -> int:
"""Install bundled factory presets into the preset store.
Scans the channel-specific subdirectory of ``FACTORY_PRESET_DIR``
for ``bank_*/preset_*.json`` files and copies them into the user
preset store. For GUITAR the root of ``FACTORY_PRESET_DIR`` is
scanned (backward-compatible with legacy ``bank_0````bank_16``).
All other channels use ``FACTORY_PRESET_DIR / channel.value``.
Args:
overwrite: If True, overwrite existing presets at the same
(bank, program) slots. If False, skip occupied slots.
channel: Which channel to install factory presets into (default GUITAR).
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
# Determine source directory — root for GUITAR, subdir for all others
source_dir = FACTORY_PRESET_DIR if channel == Channel.GUITAR \
else FACTORY_PRESET_DIR / channel.value
if not source_dir.is_dir():
logger.warning("No factory presets for channel %s: %s",
channel.value, source_dir)
return 0
count = 0
for src in sorted(source_dir.glob("bank_*/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(channel, 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(channel, bank_num)
count += 1
logger.debug("Installed factory preset: %s", dest)
if count:
self._dirty = True
logger.info("Installed %d factory presets for %s", count, channel.value)
return count
def install_factory_irs(self, overwrite: bool = False) -> int:
"""Install bundled factory cabinet IR .wav files into the IR directory.
Copies IR files from ``FACTORY_IR_DIR`` into the IR loader's default
directory (``~/.pedal/irs/``). Existing files are skipped unless
``overwrite=True``.
Args:
overwrite: If True, overwrite existing IR files with the same name.
Returns:
Number of factory IR files installed.
"""
if not FACTORY_IR_DIR.is_dir():
logger.warning("Factory IR directory not found: %s", FACTORY_IR_DIR)
return 0
from ..dsp.ir_loader import DEFAULT_IR_DIR
dest = DEFAULT_IR_DIR
dest.mkdir(parents=True, exist_ok=True)
count = 0
for src in sorted(FACTORY_IR_DIR.glob("*.wav")):
dst = dest / src.name
if dst.exists() and not overwrite:
logger.debug("Skipping existing IR: %s", dst.name)
continue
shutil.copy2(src, dst)
count += 1
logger.debug("Installed factory IR: %s", dst.name)
if count:
logger.info("Installed %d factory cab IRs into %s", count, dest)
return count
# ── Internal helpers ────────────────────────────────────────────────────
def _preset_path(self, channel: Channel, bank: int, program: int) -> Path:
"""Compute the on-disk path for a (channel, bank, program) slot."""
return self._dir / channel.value / f"bank_{bank}" / f"preset_{program}.json"
def _channel_dir(self, channel: Channel) -> Path:
"""Return the root directory for a given channel."""
return self._dir / channel.value
def _bank_dir(self, channel: Channel, bank: int) -> Path:
return self._channel_dir(channel) / f"bank_{bank}"
def _bank_meta_path(self, channel: Channel, bank: int) -> Path:
return self._bank_dir(channel, bank) / "bank.json"
def _get_or_create_bank(self, number: int, name: str = "",
channel: Channel | None = None) -> Bank:
"""Return an existing bank or create a new one on disk.
Args:
number: Bank number.
name: Optional display name.
channel: Channel (defaults to current).
"""
ch = channel or self._current_channel
meta_path = self._bank_meta_path(ch, 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(ch, number, bank.name)
self._known_banks.add(number)
logger.info("Created bank %d: '%s' (channel=%s)", number, bank.name, ch.value)
return bank
def _save_bank_meta(self, channel: Channel, bank_num: int,
name: str | None = None) -> None:
"""Write or update a bank's metadata file."""
meta_path = self._bank_meta_path(channel, 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}",
channel=self._current_channel,
bank=new_bank.number,
program=self._current_program,
)
self._current_bank = new_bank.number
self._current_program = preset.program
self.activate(preset)
self._save_channel_state()
self.save_state()
logger.info("Switched to bank %d, preset '%s' (channel=%s)",
new_bank.number, preset.name, self._current_channel.value)
return new_bank, preset
def _select_and_activate(self, bank: int, program: int,
channel: Channel | None = None) -> 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).
channel: Channel to operate on (defaults to current).
Returns:
The activated Preset.
"""
ch = channel or self._current_channel
program = max(0, min(PRESETS_PER_BANK - 1, program))
try:
preset = self.load(bank, program, ch)
except FileNotFoundError:
# Create a blank placeholder preset
bank_obj = self._get_or_create_bank(bank, channel=ch)
preset = Preset(
name=f"New {bank_obj.name} #{program}",
channel=ch, bank=bank, program=program,
)
self.save(preset, auto=True, channel=ch)
self._current_channel = ch
self._current_bank = bank
self._current_program = program
self.activate(preset)
self._save_channel_state(ch)
self.save_state()
return preset
# ── Channel state helpers ───────────────────────────────────────────
def _channel_state_path(self, channel: Channel) -> Path:
"""Path to the per-channel state file."""
return self._channel_dir(channel) / "channel_state.json"
def _save_channel_state(self, channel: Channel | None = None) -> None:
"""Persist the channel-specific (bank, program) state."""
ch = channel or self._current_channel
state = {
"current_bank": self._current_bank,
"current_program": self._current_program,
}
path = self._channel_state_path(ch)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(state, indent=2), encoding="utf-8")
logger.debug("Channel state saved for %s: bank=%d program=%d",
ch.value, self._current_bank, self._current_program)
def _restore_channel_state(self, channel: Channel | None = None) -> Optional[Preset]:
"""Restore the per-channel (bank, program) state.
Args:
channel: Channel to restore (defaults to current).
Returns:
The restored Preset, or None if no saved state exists.
"""
ch = channel or self._current_channel
path = self._channel_state_path(ch)
if not path.exists():
return None
try:
state = json.loads(path.read_text(encoding="utf-8"))
bank = state["current_bank"]
program = state["current_program"]
preset = self.load(bank, program, ch)
except (FileNotFoundError, json.JSONDecodeError, KeyError):
return None
self._current_bank = bank
self._current_program = program
logger.info("Restored channel state for %s: bank=%d program=%d '%s'",
ch.value, bank, program, preset.name)
return preset
# ── Legacy migration ────────────────────────────────────────────────
def _migrate_legacy_presets(self) -> None:
"""Migrate flat bank_* directories under root into gtr/ subdirectory.
Before dual-channel support, presets were stored as:
{root}/bank_0/preset_0.json
Now they should live under:
{root}/gtr/bank_0/preset_0.json
This runs once on first boot with the new layout.
"""
ch_dir = self._dir / "guitar"
if ch_dir.exists():
# Already migrated or new install — nothing to do
return
# Check if there are legacy flat bank_* dirs
legacy_banks = sorted(self._dir.glob("bank_*"))
if not legacy_banks:
return # Fresh install, nothing to migrate
logger.info("Migrating %d legacy bank directories → gtr channel ...", len(legacy_banks))
ch_dir.mkdir(parents=True, exist_ok=True)
for src in legacy_banks:
dest = ch_dir / src.name
try:
shutil.move(str(src), str(dest))
logger.debug(" migrated %s%s", src.name, dest)
except OSError as e:
logger.warning(" failed to migrate %s: %s", src.name, e)
# Also migrate state.json if it exists at root
legacy_state = self._dir / "state.json"
if legacy_state.exists():
try:
state = json.loads(legacy_state.read_text(encoding="utf-8"))
if "current_channel" not in state:
state["current_channel"] = "guitar"
legacy_state.write_text(
json.dumps(state, indent=2), encoding="utf-8"
)
except (json.JSONDecodeError, OSError):
pass
logger.info("Legacy preset migration complete")