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:
+256
-56
@@ -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")
|
||||
Reference in New Issue
Block a user