"""Load/save the soundboard configuration as JSON. Pure/headless: no audio or GUI imports, so it is fully unit-testable. Reads are defensive — a missing or corrupt file yields a valid default config rather than crashing. """ from __future__ import annotations import json import os from typing import Any DEFAULT_CONFIG: dict[str, Any] = { "output_device": None, # substring of the device name, e.g. "CABLE Input" "volume": 80, # 0-100 (UI scale) "buttons": [], # list of {"id", "label", "file_path"} } def default_config() -> dict[str, Any]: """A fresh deep copy of the default config.""" return json.loads(json.dumps(DEFAULT_CONFIG)) def _coerce(raw: Any) -> dict[str, Any]: """Merge a parsed object onto the defaults, dropping anything malformed.""" cfg = default_config() if not isinstance(raw, dict): return cfg if isinstance(raw.get("output_device"), str): cfg["output_device"] = raw["output_device"] vol = raw.get("volume") if isinstance(vol, (int, float)): cfg["volume"] = int(max(0, min(100, vol))) buttons = [] if isinstance(raw.get("buttons"), list): for b in raw["buttons"]: if not isinstance(b, dict): continue bid = b.get("id") label = b.get("label") path = b.get("file_path") if isinstance(bid, str) and isinstance(label, str) and isinstance(path, str): buttons.append({"id": bid, "label": label, "file_path": path}) cfg["buttons"] = buttons return cfg def load_config(path: str) -> dict[str, Any]: """Load config from ``path``; return a valid default if missing or corrupt.""" if not os.path.exists(path): return default_config() try: with open(path, "r", encoding="utf-8") as fh: raw = json.load(fh) except (OSError, ValueError): return default_config() return _coerce(raw) def save_config(path: str, config: dict[str, Any]) -> None: """Atomically write config to ``path`` (write temp + replace).""" config = _coerce(config) directory = os.path.dirname(os.path.abspath(path)) os.makedirs(directory, exist_ok=True) tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as fh: json.dump(config, fh, indent=2) os.replace(tmp, path)