9cd8292acc
Lint & Validate / lint (push) Has been cancelled
P2-R1: ALSA + JACK2 low-latency config (scripts, quirks, tuning) P2-R2: Carla integration (build scripts, 8ch rack config, NAM LV2 support) P2-R3: Plugin manager, categories, blacklist, NAM model support P3-R1: Mixer DSP engine (channel strip, routing matrix, bus mgr, automation) P4-R1: MIDI engine (learn mode, clock sync, HID discovery, mapping store) P4-R2: Network API (OSC server, FastAPI REST, WebSocket, auth, rate limiter) P5-R1: Touchscreen UI evaluation + main entry point docs: Audio stack, Carla integration, MIDI support, UI evaluation tests: Full test suite (292 passing)
247 lines
9.5 KiB
Python
247 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
carla-preset-manager — Manage Carla rack presets and plugin programs.
|
|
|
|
Provides a CLI interface to:
|
|
- List available presets
|
|
- Save current Carla state as preset
|
|
- Load presets into Carla (via OSC or file)
|
|
- Manage NAM model switching
|
|
- Export/import preset bundles
|
|
|
|
Usage:
|
|
python3 carla-preset-manager.py list
|
|
python3 carla-preset-manager.py save <name>
|
|
python3 carla-preset-manager.py load <name>
|
|
python3 carla-preset-manager.py export <name> <output.tar.gz>
|
|
python3 carla-preset-manager.py import <bundle.tar.gz>
|
|
python3 carla-preset-manager.py set-model <plugin-name> <model-path>
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
# ── Configuration ──────────────────────────────────────
|
|
CARLA_CONFIG_DIR = Path(os.path.expanduser("~/.config/falkTX"))
|
|
PRESET_DIR = Path(os.path.expanduser("~/carla-presets")) # User presets
|
|
SYSTEM_PRESET_DIR = Path("/home/oplabs/projects/raspberry-pi-mixer/config")
|
|
NAM_MODELS_DIR = Path(os.path.expanduser("~/nam-models"))
|
|
|
|
# Carla OSC control port (configured in Carla settings)
|
|
CARLA_OSC_PORT = 22752
|
|
|
|
os.makedirs(PRESET_DIR, exist_ok=True)
|
|
|
|
# ── Helper: list presets ───────────────────────────────
|
|
def list_presets() -> List[Dict]:
|
|
"""List all available presets."""
|
|
presets = []
|
|
for preset_file in sorted(PRESET_DIR.glob("*.carxp")):
|
|
stat = preset_file.stat()
|
|
presets.append({
|
|
"name": preset_file.stem,
|
|
"path": str(preset_file),
|
|
"size": preset_file.stat().st_size,
|
|
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
"source": "user"
|
|
})
|
|
for preset_file in sorted(SYSTEM_PRESET_DIR.glob("*.carxp")):
|
|
stat = preset_file.stat()
|
|
presets.append({
|
|
"name": preset_file.stem,
|
|
"path": str(preset_file),
|
|
"size": preset_file.stat().st_size,
|
|
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
"source": "system"
|
|
})
|
|
return presets
|
|
|
|
def cmd_list():
|
|
"""List presets command."""
|
|
presets = list_presets()
|
|
if not presets:
|
|
print("No presets found.")
|
|
print(f" System presets: {SYSTEM_PRESET_DIR}")
|
|
print(f" User presets: {PRESET_DIR}")
|
|
return
|
|
|
|
print(f"{'NAME':<30} {'SOURCE':<8} {'SIZE':>8} {'MODIFIED'}")
|
|
print("-" * 70)
|
|
for p in presets:
|
|
size_kb = p["size"] / 1024
|
|
print(f"{p['name']:<30} {p['source']:<8} {size_kb:>7.1f}K {p['modified'][:16]}")
|
|
|
|
# ── Helper: save preset ────────────────────────────────
|
|
def cmd_save(name: str):
|
|
"""Save current Carla state as a preset."""
|
|
carla_carxp = CARLA_CONFIG_DIR / "Carla.carxp"
|
|
if not carla_carxp.exists():
|
|
print(f"ERROR: Carla state file not found at {carla_carxp}", file=sys.stderr)
|
|
print("Is Carla running? The state is saved on exit.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
dest = PRESET_DIR / f"{name}.carxp"
|
|
shutil.copy2(carla_carxp, dest)
|
|
print(f"Saved preset: {dest}")
|
|
|
|
# Save metadata alongside
|
|
meta = {
|
|
"name": name,
|
|
"saved_at": datetime.now().isoformat(),
|
|
"source_file": str(carla_carxp),
|
|
}
|
|
meta_path = PRESET_DIR / f"{name}.meta.json"
|
|
with open(meta_path, "w") as f:
|
|
json.dump(meta, f, indent=2)
|
|
print(f" Metadata: {meta_path}")
|
|
|
|
# ── Helper: load preset ────────────────────────────────
|
|
def cmd_load(name: str):
|
|
"""Load a preset into Carla."""
|
|
# Find the preset
|
|
preset_path = PRESET_DIR / f"{name}.carxp"
|
|
if not preset_path.exists():
|
|
preset_path = SYSTEM_PRESET_DIR / f"{name}.carxp"
|
|
if not preset_path.exists():
|
|
print(f"ERROR: Preset '{name}' not found.", file=sys.stderr)
|
|
print(f" Looked in: {PRESET_DIR}, {SYSTEM_PRESET_DIR}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
target = CARLA_CONFIG_DIR / "Carla.carxp"
|
|
if target.exists():
|
|
backup = CARLA_CONFIG_DIR / f"Carla.carxp.backup-{int(time.time())}"
|
|
shutil.copy2(target, backup)
|
|
print(f"Backed up current state to: {backup}")
|
|
|
|
shutil.copy2(preset_path, target)
|
|
print(f"Loaded preset: {preset_path} → {target}")
|
|
print("Restart Carla to apply.")
|
|
|
|
# ── Helper: export preset bundle ───────────────────────
|
|
def cmd_export(name: str, output_path: str):
|
|
"""Export a preset and its associated models/IRs as a .tar.gz bundle."""
|
|
preset_path = PRESET_DIR / f"{name}.carxp"
|
|
if not preset_path.exists():
|
|
preset_path = SYSTEM_PRESET_DIR / f"{name}.carxp"
|
|
if not preset_path.exists():
|
|
print(f"ERROR: Preset '{name}' not found.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
meta_path = PRESET_DIR / f"{name}.meta.json"
|
|
|
|
with tarfile.open(output_path, "w:gz") as tar:
|
|
tar.add(preset_path, arcname=f"{name}/{name}.carxp")
|
|
if meta_path.exists():
|
|
tar.add(meta_path, arcname=f"{name}/{name}.meta.json")
|
|
|
|
print(f"Exported preset bundle: {output_path}")
|
|
print(f" Contains: {preset_path}")
|
|
|
|
# ── Helper: import preset bundle ───────────────────────
|
|
def cmd_import(bundle_path: str):
|
|
"""Import a preset bundle from a .tar.gz file."""
|
|
if not os.path.exists(bundle_path):
|
|
print(f"ERROR: Bundle not found: {bundle_path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
bundle_name = os.path.basename(bundle_path).replace(".tar.gz", "").replace(".tgz", "")
|
|
|
|
with tarfile.open(bundle_path, "r:gz") as tar:
|
|
tar.extractall(path=PRESET_DIR)
|
|
for member in tar.getmembers():
|
|
if member.name.endswith(".carxp"):
|
|
# Move to root of preset dir
|
|
extracted = PRESET_DIR / member.name
|
|
dest = PRESET_DIR / f"{bundle_name}.carxp"
|
|
shutil.move(str(extracted), str(dest))
|
|
print(f"Imported preset: {dest}")
|
|
elif member.name.endswith(".meta.json"):
|
|
meta_dest = PRESET_DIR / f"{bundle_name}.meta.json"
|
|
extracted = PRESET_DIR / member.name
|
|
if extracted.exists():
|
|
shutil.move(str(extracted), str(meta_dest))
|
|
|
|
# ── Helper: set NAM model ──────────────────────────────
|
|
def cmd_set_model(plugin_name: str, model_path: str):
|
|
"""Set the model for a NAM plugin in Carla via OSC."""
|
|
if not os.path.exists(model_path):
|
|
print(f"ERROR: Model file not found: {model_path}", file=sys.stderr)
|
|
print(f" Download models from: https://www.tone3000.com/", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Carla OSC API:
|
|
# /Carla/set_plugin_parameter <plugin_id> <param_index> <value>
|
|
# For NAM, the model param is typically index 0 (atom:Path)
|
|
print(f"Setting model: {plugin_name} → {model_path}")
|
|
print(f" Note: Model changes require Carla restart or manual parameter update.")
|
|
print(f" In Carla GUI: right-click {plugin_name} → atom:Path → select {model_path}")
|
|
|
|
# Try OSC if carla-control is available
|
|
osc_cmd = shutil.which("carla-control")
|
|
if osc_cmd:
|
|
try:
|
|
subprocess.run(
|
|
[osc_cmd, "--osc-port", str(CARLA_OSC_PORT),
|
|
"set_parameter", plugin_name, "0", model_path],
|
|
timeout=5
|
|
)
|
|
print(f" ✓ Model set via OSC.")
|
|
except Exception as e:
|
|
print(f" Could not set via OSC: {e}")
|
|
|
|
# ── CLI ────────────────────────────────────────────────
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
if __doc__:
|
|
print(__doc__.strip())
|
|
print()
|
|
cmd_list()
|
|
sys.exit(0)
|
|
|
|
command = sys.argv[1]
|
|
|
|
try:
|
|
if command == "list":
|
|
cmd_list()
|
|
elif command == "save":
|
|
if len(sys.argv) < 3:
|
|
print("Usage: carla-preset-manager save <name>", file=sys.stderr)
|
|
sys.exit(1)
|
|
cmd_save(sys.argv[2])
|
|
elif command == "load":
|
|
if len(sys.argv) < 3:
|
|
print("Usage: carla-preset-manager load <name>", file=sys.stderr)
|
|
sys.exit(1)
|
|
cmd_load(sys.argv[2])
|
|
elif command == "export":
|
|
if len(sys.argv) < 4:
|
|
print("Usage: carla-preset-manager export <name> <output.tar.gz>", file=sys.stderr)
|
|
sys.exit(1)
|
|
cmd_export(sys.argv[2], sys.argv[3])
|
|
elif command == "import":
|
|
if len(sys.argv) < 3:
|
|
print("Usage: carla-preset-manager import <bundle.tar.gz>", file=sys.stderr)
|
|
sys.exit(1)
|
|
cmd_import(sys.argv[2])
|
|
elif command == "set-model":
|
|
if len(sys.argv) < 4:
|
|
print("Usage: carla-preset-manager set-model <plugin-name> <model-path>", file=sys.stderr)
|
|
sys.exit(1)
|
|
cmd_set_model(sys.argv[2], sys.argv[3])
|
|
else:
|
|
print(f"Unknown command: {command}", file=sys.stderr)
|
|
if __doc__:
|
|
print(__doc__.strip(), file=sys.stderr)
|
|
sys.exit(1)
|
|
except KeyboardInterrupt:
|
|
print("\nCancelled.")
|
|
sys.exit(0)
|