"""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")