feat: separate preset banks per channel (guitar/bass)

- Channel enum (GUITAR, BASS) with channel field on Preset dataclass
- Channel-prefixed storage: {root}/{channel}/bank_{bank}/preset_{program}.json
- PresetManager channel awareness: set_channel(), current_channel property
- Per-channel state persistence (channel_state.json per channel dir)
- Legacy migration: flat bank_* dirs auto-migrate to guitar/ on first boot
- All CRUD methods accept optional channel parameter
- API endpoints accept channel param on GET/PUT/DELETE /api/presets
- /api/channel GET/POST for channel switching
- 10 new channel tests (independence, switching, migration, scoping)
- Factory presets install to specified channel
This commit is contained in:
2026-06-12 23:22:47 -04:00
parent 0a21297f77
commit bfe1434f75
10 changed files with 1100 additions and 352 deletions
+256 -56
View File
@@ -19,7 +19,7 @@ import threading
from pathlib import Path
from typing import Optional
from .types import Bank, FXBlock, FXType, MIDIMapping, Preset, Snapshot
from .types import Bank, Channel, FXBlock, FXType, MIDIMapping, Preset, Snapshot
logger = logging.getLogger(__name__)
@@ -44,6 +44,7 @@ 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,
@@ -137,6 +138,7 @@ def _preset_from_dict(data: dict) -> Preset:
return Preset(
name=data["name"],
channel=Channel(data.get("channel", "guitar")),
bank=data.get("bank", 0),
program=data.get("program", 0),
chain=chain,
@@ -171,6 +173,7 @@ class PresetManager:
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
@@ -185,10 +188,17 @@ class PresetManager:
# Current snapshot slot (0 = none selected)
self._current_snapshot: int = 0
logger.info("PresetManager root: %s", self._dir)
# 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
@@ -197,10 +207,43 @@ class PresetManager:
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 (bank, program)."""
return self._preset_path(self._current_bank, self._current_program)
"""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).
@@ -240,17 +283,18 @@ class PresetManager:
"""
return self._switch_bank(-1)
def select(self, bank: int, program: int) -> Preset:
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)
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.
@@ -267,15 +311,17 @@ class PresetManager:
# ── CRUD ────────────────────────────────────────────────────────────────
def save(self, preset: Preset, *, auto: bool = False) -> None:
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).
"""
path = self._preset_path(preset.bank, preset.program)
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),
@@ -287,16 +333,17 @@ class PresetManager:
self._known_banks.add(preset.bank)
# Save/update bank metadata
self._save_bank_meta(preset.bank)
self._save_bank_meta(ch, preset.bank)
logger.info("Saved preset '%s'%s", preset.name, path)
def load(self, bank: int, program: int) -> Preset:
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.
@@ -305,41 +352,52 @@ class PresetManager:
FileNotFoundError: If the preset slot doesn't exist.
json.JSONDecodeError: If the file is corrupt.
"""
path = self._preset_path(bank, program)
ch = channel or self._current_channel
path = self._preset_path(ch, bank, program)
if not path.exists():
raise FileNotFoundError(
f"No preset at (bank={bank}, program={program}): {path}"
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) -> None:
"""Delete a preset slot from disk."""
path = self._preset_path(bank, program)
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 (bank=%d, program=%d)", bank, program)
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) -> Preset:
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)
preset = self.load(bank, program, channel)
preset.name = new_name
self.save(preset)
logger.info("Renamed preset at (%d,%d) → '%s'", bank, program, 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:
@@ -415,10 +473,22 @@ class PresetManager:
# ── Bank management ─────────────────────────────────────────────────────
def list_banks(self) -> list[Bank]:
"""Scan the preset directory and return known banks sorted by number."""
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(self._dir.glob("bank_*/bank.json")):
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)
@@ -434,7 +504,7 @@ class PresetManager:
continue
# Also discover banks by scanning dirs without bank.json
for p_dir in sorted(self._dir.glob("bank_*")):
for p_dir in sorted(ch_dir.glob("bank_*")):
try:
num = int(p_dir.name.split("_")[1])
if num not in banks:
@@ -460,20 +530,25 @@ class PresetManager:
# ── Auto-restore ────────────────────────────────────────────────────────
def save_state(self) -> None:
"""Persist the current (bank, program) for power-cycle restore.
"""Persist the current (channel, bank, program) for power-cycle restore.
Call this whenever the active preset changes to guarantee
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_bank": self._current_bank, "current_program": self._current_program}
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: bank=%d program=%d", self._current_bank, self._current_program)
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 preset from state.json.
"""Restore the last active (channel, bank, program) from state.json.
Returns:
The restored Preset, or None if no state file exists or the
@@ -486,17 +561,20 @@ class PresetManager:
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)
except (FileNotFoundError, json.JSONDecodeError, KeyError):
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: bank=%d program=%d '%s'", bank, program, preset.name)
logger.info("Restored state: channel=%s bank=%d program=%d '%s'",
ch.value, bank, program, preset.name)
return preset
# ── Activation ──────────────────────────────────────────────────────────
@@ -514,7 +592,8 @@ class PresetManager:
# ── Factory presets ─────────────────────────────────────────────────────
def install_factory_presets(self, overwrite: bool = False) -> int:
def install_factory_presets(self, overwrite: bool = False,
channel: Channel = Channel.GUITAR) -> int:
"""Install bundled factory presets into the preset store.
Scans ``FACTORY_PRESET_DIR`` for ``bank_*/preset_*.json`` files
@@ -523,6 +602,7 @@ class PresetManager:
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.
@@ -542,14 +622,14 @@ class PresetManager:
logger.warning("Skipping malformed factory preset path: %s", src)
continue
dest = self._preset_path(bank_num, prog_num)
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(bank_num)
self._save_bank_meta(channel, bank_num)
count += 1
logger.debug("Installed factory preset: %s", dest)
@@ -595,19 +675,31 @@ class PresetManager:
# ── 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 _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 _bank_dir(self, bank: int) -> Path:
return self._dir / f"bank_{bank}"
def _channel_dir(self, channel: Channel) -> Path:
"""Return the root directory for a given channel."""
return self._dir / channel.value
def _bank_meta_path(self, bank: int) -> Path:
return self._bank_dir(bank) / "bank.json"
def _bank_dir(self, channel: Channel, bank: int) -> Path:
return self._channel_dir(channel) / f"bank_{bank}"
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)
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"))
@@ -620,14 +712,15 @@ class PresetManager:
# Create new bank
bank = Bank(name=name or f"Bank {number}", number=number)
self._save_bank_meta(number, bank.name)
self._save_bank_meta(ch, number, bank.name)
self._known_banks.add(number)
logger.info("Created bank %d: '%s'", number, bank.name)
logger.info("Created bank %d: '%s' (channel=%s)", number, bank.name, ch.value)
return bank
def _save_bank_meta(self, bank_num: int, name: str | None = None) -> None:
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(bank_num)
meta_path = self._bank_meta_path(channel, bank_num)
existing = {}
if meta_path.exists():
try:
@@ -673,6 +766,7 @@ class PresetManager:
except FileNotFoundError:
preset = Preset(
name=f"Empty {new_bank.name}",
channel=self._current_channel,
bank=new_bank.number,
program=self._current_program,
)
@@ -680,11 +774,14 @@ class PresetManager:
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'", new_bank.number, preset.name)
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) -> 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.
@@ -692,22 +789,125 @@ class PresetManager:
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)
preset = self.load(bank, program, ch)
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)
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
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")