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)
485 lines
20 KiB
Python
485 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""Plugin Manager CLI — scan, list, install, remove, and manage audio plugins.
|
|
|
|
Usage:
|
|
plugin-mgr scan Full scan and registry sync
|
|
plugin-mgr list List all registered plugins
|
|
plugin-mgr list --format=lv2 List by format
|
|
plugin-mgr list --category=reverb List by category
|
|
plugin-mgr search <query> Search plugins
|
|
plugin-mgr info <uri|name> Show plugin details
|
|
plugin-mgr install-local <path> Install from local path
|
|
plugin-mgr install-nam <path> Install local .nam model
|
|
plugin-mgr download-nam <url> Download .nam model
|
|
plugin-mgr remove <uri|name> Remove plugin (registry only)
|
|
plugin-mgr remove --files <uri> Remove plugin + delete files
|
|
plugin-mgr enable <uri> Enable a disabled plugin
|
|
plugin-mgr disable <uri> Disable a plugin
|
|
plugin-mgr blacklist <uri> Blacklist a plugin
|
|
plugin-mgr unblacklist <uri> Remove from blacklist
|
|
plugin-mgr blacklist-list Show all blacklist rules
|
|
plugin-mgr blacklist-add <pat> Add a user blacklist rule
|
|
plugin-mgr nam-list List installed NAM models
|
|
plugin-mgr stats Show registry statistics
|
|
plugin-mgr vacuum Compact/optimize the database
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
# Ensure the src directory is on the path
|
|
_project_root = Path(__file__).resolve().parent.parent
|
|
_src = _project_root / "src"
|
|
if str(_src) not in sys.path:
|
|
sys.path.insert(0, str(_src))
|
|
|
|
from plugin import (
|
|
PluginManager,
|
|
PluginCategory,
|
|
PluginFormat,
|
|
PluginStatus,
|
|
PluginBlacklist,
|
|
BlacklistEntry,
|
|
CATEGORY_LABELS,
|
|
BUILTIN_BLACKLIST,
|
|
)
|
|
|
|
|
|
# ── CLI argument definitions ─────────────────────────────────────────────────
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
prog="plugin-mgr",
|
|
description="RPi Mixer Plugin Manager — scan, register, and manage audio plugins",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=textwrap.dedent("""\
|
|
Examples:
|
|
plugin-mgr scan
|
|
plugin-mgr list --format=lv2
|
|
plugin-mgr list --category=reverb
|
|
plugin-mgr search "tube amp"
|
|
plugin-mgr install-nam ~/downloads/my-tone.nam
|
|
plugin-mgr stats
|
|
"""),
|
|
)
|
|
sp = parser.add_subparsers(dest="command", help="Command to execute")
|
|
|
|
# scan
|
|
sp.add_parser("scan", help="Scan filesystem and sync registry")
|
|
|
|
# list
|
|
list_p = sp.add_parser("list", help="List registered plugins")
|
|
list_p.add_argument("--format", "-f", help="Filter by format (lv2, vst3, ladspa, nam)")
|
|
list_p.add_argument("--category", "-c", help="Filter by category (reverb, delay, etc.)")
|
|
list_p.add_argument("--loadable", "-l", action="store_true", help="Only show loadable plugins")
|
|
list_p.add_argument("--verbose", "-v", action="store_true", help="Show ports and metadata")
|
|
|
|
# search
|
|
search_p = sp.add_parser("search", help="Search plugins by name/description")
|
|
search_p.add_argument("query", help="Search query")
|
|
search_p.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
|
|
|
|
# info
|
|
info_p = sp.add_parser("info", help="Show detailed plugin info")
|
|
info_p.add_argument("plugin", help="Plugin URI or name")
|
|
|
|
# install-local
|
|
install_p = sp.add_parser("install-local", help="Install plugin from local path")
|
|
install_p.add_argument("path", help="Path to plugin bundle directory or file")
|
|
install_p.add_argument("--format", "-f", default="lv2", help="Plugin format (default: lv2)")
|
|
|
|
# install-nam
|
|
nam_inst_p = sp.add_parser("install-nam", help="Install local .nam model")
|
|
nam_inst_p.add_argument("path", help="Path to .nam file")
|
|
nam_inst_p.add_argument("--name", "-n", help="Name for the model")
|
|
|
|
# download-nam
|
|
nam_dl_p = sp.add_parser("download-nam", help="Download .nam model from URL")
|
|
nam_dl_p.add_argument("url", help="Download URL")
|
|
nam_dl_p.add_argument("--name", "-n", help="Name for the model")
|
|
|
|
# nam-list
|
|
sp.add_parser("nam-list", help="List installed NAM models")
|
|
|
|
# remove
|
|
remove_p = sp.add_parser("remove", help="Remove plugin from registry")
|
|
remove_p.add_argument("plugin", help="Plugin URI or name")
|
|
remove_p.add_argument("--files", action="store_true", help="Also delete plugin files from disk")
|
|
|
|
# enable / disable
|
|
enable_p = sp.add_parser("enable", help="Enable a disabled plugin")
|
|
enable_p.add_argument("plugin", help="Plugin URI or name")
|
|
disable_p = sp.add_parser("disable", help="Disable a plugin")
|
|
disable_p.add_argument("plugin", help="Plugin URI or name")
|
|
|
|
# blacklist
|
|
bl_p = sp.add_parser("blacklist", help="Blacklist a plugin")
|
|
bl_p.add_argument("plugin", help="Plugin URI or name")
|
|
unbl_p = sp.add_parser("unblacklist", help="Remove plugin from blacklist")
|
|
unbl_p.add_argument("plugin", help="Plugin URI or name")
|
|
|
|
# blacklist-list
|
|
bl_list = sp.add_parser("blacklist-list", help="Show all blacklist rules")
|
|
bl_list.add_argument("--builtin", action="store_true", help="Only show built-in rules")
|
|
bl_list.add_argument("--user", action="store_true", help="Only show user-defined rules")
|
|
|
|
# blacklist-add
|
|
bl_add = sp.add_parser("blacklist-add", help="Add a user blacklist rule")
|
|
bl_add.add_argument("pattern", help="Glob pattern to match (e.g., '*BrokenPlugin*')")
|
|
bl_add.add_argument("--field", default="name", help="Field to match: uri, name, path (default: name)")
|
|
bl_add.add_argument("--reason", "-r", default="User-defined blacklist", help="Reason for blacklisting")
|
|
bl_add.add_argument("--severity", default="block", choices=["block", "warn"], help="Severity (default: block)")
|
|
|
|
# stats
|
|
sp.add_parser("stats", help="Show registry statistics")
|
|
|
|
# vacuum
|
|
sp.add_parser("vacuum", help="Compact and optimize the database")
|
|
|
|
return parser
|
|
|
|
|
|
# ── Output helpers ───────────────────────────────────────────────────────────
|
|
|
|
def _fmt_plugin_short(p) -> str:
|
|
"""One-line plugin summary."""
|
|
status_icon = {
|
|
PluginStatus.ACTIVE: "[+]",
|
|
PluginStatus.DISABLED: "[-]",
|
|
PluginStatus.BLACKLISTED: "[!]",
|
|
PluginStatus.STALE: "[?]",
|
|
PluginStatus.ERROR: "[E]",
|
|
PluginStatus.REMOVED: " ",
|
|
}.get(p.status, "[ ]")
|
|
|
|
cats = ", ".join(c.value for c in p.categories[:3])
|
|
if len(p.categories) > 3:
|
|
cats += ", …"
|
|
|
|
io_str = f"{p.audio_inputs}i/{p.audio_outputs}o"
|
|
if p.midi_inputs or p.midi_outputs:
|
|
io_str += f" MIDI:{p.midi_inputs}i/{p.midi_outputs}o"
|
|
|
|
nam_tag = ""
|
|
if p.nam_model_size:
|
|
compat = "✓" if p.rpi4b_known_good else "⚠" if p.rpi4b_known_broken else "?"
|
|
nam_tag = f" [NAM/{p.nam_model_size} {compat}]"
|
|
|
|
cpu_str = f" CPU~{p.estimated_cpu_pct:.0f}%" if p.estimated_cpu_pct else ""
|
|
rpi_tag = " [RPi4B✓]" if p.rpi4b_known_good else " [RPi4B⚠]" if p.rpi4b_known_broken else ""
|
|
|
|
return (
|
|
f"{status_icon} {p.meta.name:<30} "
|
|
f"{p.meta.format.value:<6} {io_str:<12} "
|
|
f"[{cats}]{nam_tag}{rpi_tag}{cpu_str}"
|
|
)
|
|
|
|
|
|
def _fmt_plugin_verbose(p) -> str:
|
|
"""Detailed plugin info block."""
|
|
lines = [
|
|
f"Name: {p.meta.name}",
|
|
f"URI: {p.meta.uri}",
|
|
f"Format: {p.meta.format.value}",
|
|
f"Version: {p.meta.version or '—'}",
|
|
f"Author: {p.meta.author or '—'}",
|
|
f"License: {p.meta.license or '—'}",
|
|
f"Description: {p.meta.description or '—'}",
|
|
f"Homepage: {p.meta.homepage or '—'}",
|
|
f"Project: {p.meta.project or '—'}",
|
|
f"Categories: {', '.join(CATEGORY_LABELS.get(c, c.value) for c in p.categories)}",
|
|
f"Status: {p.status.value}",
|
|
"",
|
|
f"Audio I/O: {p.audio_inputs} in, {p.audio_outputs} out",
|
|
f"MIDI I/O: {p.midi_inputs} in, {p.midi_outputs} out",
|
|
f"Bundle: {p.bundle_path or '—'}",
|
|
f"Library: {p.library_path or '—'}",
|
|
f"Arch: {p.meta.arch or '—'} (ARM optimized: {'yes' if p.meta.arm_optimized else 'no'})",
|
|
"",
|
|
]
|
|
if p.rpi4b_known_good:
|
|
lines.append("RPi4B: ✓ Verified working")
|
|
elif p.rpi4b_known_broken:
|
|
lines.append("RPi4B: ⚠ Known broken")
|
|
if p.estimated_cpu_pct:
|
|
lines.append(f"Est. CPU: ~{p.estimated_cpu_pct:.1f}%")
|
|
if p.estimated_ram_mb:
|
|
lines.append(f"Est. RAM: ~{p.estimated_ram_mb:.0f} MB")
|
|
|
|
if p.nam_model_path:
|
|
lines.append(f"")
|
|
lines.append(f"NAM Model: {p.nam_model_path}")
|
|
lines.append(f"Model Size: {p.nam_model_size}")
|
|
|
|
if p.control_ports:
|
|
lines.append(f"")
|
|
lines.append(f"Control Ports ({len(p.control_ports)}):")
|
|
for port in p.control_ports[:20]:
|
|
lines.append(f" [{port.index:3d}] {port.name:<25} {port.direction:6} "
|
|
f"{port.minimum:7.2f} .. {port.maximum:7.2f} (def: {port.default:.2f})")
|
|
if len(p.control_ports) > 20:
|
|
lines.append(f" … and {len(p.control_ports) - 20} more")
|
|
|
|
if p.error_message:
|
|
lines.append(f"")
|
|
lines.append(f"Error: {p.error_message}")
|
|
|
|
if p.scanned_at:
|
|
from datetime import datetime
|
|
lines.append(f"")
|
|
lines.append(f"Scanned: {datetime.fromtimestamp(p.scanned_at).strftime('%Y-%m-%d %H:%M:%S')}")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ── Resolve plugin by URI or name ────────────────────────────────────────────
|
|
|
|
def _resolve(mgr: PluginManager, identifier: str):
|
|
"""Resolve a plugin by URI or name (case-insensitive)."""
|
|
plugin = mgr.registry.get(identifier)
|
|
if plugin:
|
|
return plugin
|
|
plugin = mgr.registry.get_by_name(identifier)
|
|
if plugin:
|
|
return plugin
|
|
# Try partial name match
|
|
for p in mgr.registry.list_all():
|
|
if identifier.lower() in p.meta.name.lower():
|
|
return p
|
|
return None
|
|
|
|
|
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
parser = build_parser()
|
|
args = parser.parse_args()
|
|
|
|
if args.command is None:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
mgr = PluginManager()
|
|
|
|
# ── scan ─────────────────────────────────────────────────────────────
|
|
if args.command == "scan":
|
|
print("Scanning filesystem for audio plugins…")
|
|
result = mgr.sync()
|
|
print(f" New: {result['inserted']}")
|
|
print(f" Updated: {result['updated']}")
|
|
print(f" Stale: {result['stale']}")
|
|
print(f"Done.")
|
|
|
|
# ── list ─────────────────────────────────────────────────────────────
|
|
elif args.command == "list":
|
|
fmt = PluginFormat(args.format) if args.format else None
|
|
cat = PluginCategory(args.category) if args.category else None
|
|
|
|
if fmt:
|
|
plugins = mgr.registry.list_by_format(fmt)
|
|
elif cat:
|
|
plugins = mgr.registry.list_by_category(cat)
|
|
else:
|
|
plugins = mgr.registry.list_all()
|
|
|
|
if args.loadable:
|
|
plugins = [p for p in plugins if p.is_loadable]
|
|
|
|
if not plugins:
|
|
print("No plugins found.")
|
|
return
|
|
|
|
for p in plugins:
|
|
if args.verbose:
|
|
print(_fmt_plugin_verbose(p))
|
|
print("-" * 72)
|
|
else:
|
|
print(_fmt_plugin_short(p))
|
|
print(f"\n{len(plugins)} plugin(s)")
|
|
|
|
# ── search ───────────────────────────────────────────────────────────
|
|
elif args.command == "search":
|
|
plugins = mgr.registry.search(args.query)
|
|
if not plugins:
|
|
print(f"No plugins matching '{args.query}'.")
|
|
return
|
|
for p in plugins:
|
|
if args.verbose:
|
|
print(_fmt_plugin_verbose(p))
|
|
print("-" * 72)
|
|
else:
|
|
print(_fmt_plugin_short(p))
|
|
print(f"\n{len(plugins)} match(es)")
|
|
|
|
# ── info ─────────────────────────────────────────────────────────────
|
|
elif args.command == "info":
|
|
plugin = _resolve(mgr, args.plugin)
|
|
if plugin is None:
|
|
print(f"Plugin not found: {args.plugin}")
|
|
sys.exit(1)
|
|
print(_fmt_plugin_verbose(plugin))
|
|
|
|
# ── install-local ────────────────────────────────────────────────────
|
|
elif args.command == "install-local":
|
|
fmt = PluginFormat(args.format)
|
|
result = mgr.install_local(args.path, fmt)
|
|
if result:
|
|
print(f"Installed: {result.meta.name} ({result.meta.uri})")
|
|
else:
|
|
print("Installation failed.")
|
|
sys.exit(1)
|
|
|
|
# ── install-nam ──────────────────────────────────────────────────────
|
|
elif args.command == "install-nam":
|
|
result = mgr.nam_install_local(args.path, args.name)
|
|
if result:
|
|
print(f"Installed NAM model: {result.meta.name} ({result.nam_model_size})")
|
|
if result.rpi4b_known_broken:
|
|
print("⚠ Warning: This model may cause xruns on RPi4B (standard size).")
|
|
else:
|
|
print("Installation failed.")
|
|
sys.exit(1)
|
|
|
|
# ── download-nam ─────────────────────────────────────────────────────
|
|
elif args.command == "download-nam":
|
|
print(f"Downloading NAM model from {args.url}…")
|
|
result = mgr.nam_download(args.url, args.name)
|
|
if result:
|
|
print(f"Downloaded: {result.meta.name} ({result.nam_model_size})")
|
|
if result.rpi4b_known_broken:
|
|
print("⚠ Warning: This model may cause xruns on RPi4B (standard size).")
|
|
else:
|
|
print("Download failed.")
|
|
sys.exit(1)
|
|
|
|
# ── nam-list ─────────────────────────────────────────────────────────
|
|
elif args.command == "nam-list":
|
|
models = mgr.nam.list_models()
|
|
if not models:
|
|
print("No NAM models installed.")
|
|
return
|
|
for m in models:
|
|
compat = "✓ RPi4B" if m.rpi4b_compatible else "⚠ xruns likely"
|
|
print(f" [{m.size_category:8}] {m.name:<30} {m.file_size_mb:6.1f} MB {compat}")
|
|
counts = mgr.nam.count_models()
|
|
print(f"\n nano:{counts['nano']} feather:{counts['feather']} "
|
|
f"standard:{counts['standard']} custom:{counts['custom']}")
|
|
|
|
# ── remove ───────────────────────────────────────────────────────────
|
|
elif args.command == "remove":
|
|
plugin = _resolve(mgr, args.plugin)
|
|
if plugin is None:
|
|
print(f"Plugin not found: {args.plugin}")
|
|
sys.exit(1)
|
|
if mgr.remove(plugin.meta.uri, delete_files=args.files):
|
|
action = "Removed (files deleted)" if args.files else "Removed (registry only)"
|
|
print(f"{action}: {plugin.meta.name}")
|
|
else:
|
|
print("Remove failed.")
|
|
sys.exit(1)
|
|
|
|
# ── enable / disable ─────────────────────────────────────────────────
|
|
elif args.command == "enable":
|
|
plugin = _resolve(mgr, args.plugin)
|
|
if plugin is None:
|
|
print(f"Plugin not found: {args.plugin}")
|
|
sys.exit(1)
|
|
if mgr.enable_plugin(plugin.meta.uri):
|
|
print(f"Enabled: {plugin.meta.name}")
|
|
else:
|
|
print("Failed.")
|
|
sys.exit(1)
|
|
|
|
elif args.command == "disable":
|
|
plugin = _resolve(mgr, args.plugin)
|
|
if plugin is None:
|
|
print(f"Plugin not found: {args.plugin}")
|
|
sys.exit(1)
|
|
if mgr.disable_plugin(plugin.meta.uri):
|
|
print(f"Disabled: {plugin.meta.name}")
|
|
else:
|
|
print("Failed.")
|
|
sys.exit(1)
|
|
|
|
# ── blacklist / unblacklist ──────────────────────────────────────────
|
|
elif args.command == "blacklist":
|
|
plugin = _resolve(mgr, args.plugin)
|
|
if plugin is None:
|
|
print(f"Plugin not found: {args.plugin}")
|
|
sys.exit(1)
|
|
if mgr.blacklist_plugin(plugin.meta.uri):
|
|
print(f"Blacklisted: {plugin.meta.name}")
|
|
else:
|
|
print("Failed.")
|
|
sys.exit(1)
|
|
|
|
elif args.command == "unblacklist":
|
|
plugin = _resolve(mgr, args.plugin)
|
|
if plugin is None:
|
|
print(f"Plugin not found: {args.plugin}")
|
|
sys.exit(1)
|
|
if mgr.unblacklist_plugin(plugin.meta.uri):
|
|
print(f"Removed from blacklist: {plugin.meta.name}")
|
|
else:
|
|
print("Failed.")
|
|
sys.exit(1)
|
|
|
|
# ── blacklist-list ───────────────────────────────────────────────────
|
|
elif args.command == "blacklist-list":
|
|
if args.builtin:
|
|
entries = mgr.blacklist.list_builtin()
|
|
elif args.user:
|
|
entries = mgr.blacklist.list_user()
|
|
else:
|
|
entries = mgr.blacklist.list_all()
|
|
|
|
for e in entries:
|
|
tag = "blk" if e.severity == "block" else "warn"
|
|
src = "builtin" if e.added_by == "builtin" else "user"
|
|
print(f" [{tag}] [{src}] {e.field}:{e.pattern}")
|
|
if e.reason:
|
|
print(f" {e.reason}")
|
|
print(f"\n{len(entries)} rule(s)")
|
|
|
|
# ── blacklist-add ────────────────────────────────────────────────────
|
|
elif args.command == "blacklist-add":
|
|
import time
|
|
entry = BlacklistEntry(
|
|
pattern=args.pattern,
|
|
field=args.field,
|
|
reason=args.reason,
|
|
severity=args.severity,
|
|
added_by="user",
|
|
added_at=time.time(),
|
|
)
|
|
mgr.blacklist.add_user_entry(entry)
|
|
print(f"Added blacklist rule: {args.field}:{args.pattern} [{args.severity}]")
|
|
|
|
# ── stats ────────────────────────────────────────────────────────────
|
|
elif args.command == "stats":
|
|
stats = mgr.registry.stats()
|
|
print(f"Plugin Registry Statistics")
|
|
print(f" Total plugins: {stats['total']}")
|
|
print(f" By format:")
|
|
for fmt, count in stats["by_format"].items():
|
|
print(f" {fmt:<8} {count}")
|
|
print(f" Blacklisted: {stats['blacklisted']}")
|
|
print(f" Stale: {stats['stale']}")
|
|
print(f" RPi4B verified: {stats['rpi4b_verified']}")
|
|
print(f" NAM models: {stats['nam_models']}")
|
|
|
|
# ── vacuum ───────────────────────────────────────────────────────────
|
|
elif args.command == "vacuum":
|
|
print("Compacting database…")
|
|
mgr.registry.vacuum()
|
|
print("Done.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|