Files
shawn 9cd8292acc
Lint & Validate / lint (push) Has been cancelled
Phase 1-4: Audio stack, mixer engine, MIDI, and network API
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)
2026-05-19 20:39:17 -04:00

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