Files
AbletonMCP_AI/AbletonMCP_AI/mcp_server/engines/vst_manager.py
2026-04-12 22:14:35 -03:00

615 lines
23 KiB
Python

"""
VST/AU Plugin Manager for AbletonMCP_AI
Manages VST and AU plugin detection, loading, and parameter configuration.
Supports popular plugins like Serum, Massive, Sylenth1, FabFilter, and ValhallaDSP.
"""
from __future__ import annotations
import os
import json
import logging
from typing import Dict, List, Optional, Any, Tuple
from dataclasses import dataclass, asdict
from enum import Enum
logger = logging.getLogger(__name__)
class PluginType(Enum):
"""Types of plugins supported."""
VST2 = "VST2"
VST3 = "VST3"
AU = "AU" # Audio Units (macOS)
class PluginCategory(Enum):
"""Categories of plugins."""
SYNTH = "synth"
EFFECT = "effect"
EQ = "eq"
COMPRESSOR = "compressor"
REVERB = "reverb"
DELAY = "delay"
UTILITY = "utility"
@dataclass
class PluginInfo:
"""Information about a detected plugin."""
name: str
display_name: str
plugin_type: PluginType
category: PluginCategory
manufacturer: str
path: Optional[str] = None
is_installed: bool = False
version: str = ""
presets: List[str] = None
def __post_init__(self):
if self.presets is None:
self.presets = []
@dataclass
class ParameterInfo:
"""Information about a plugin parameter."""
name: str
display_name: str
min_value: float
max_value: float
default_value: float
value_type: str = "float" # float, int, bool
# Popular plugin database with known parameters
POPULAR_PLUGINS = {
# Synths
"serum": PluginInfo(
name="Serum",
display_name="Xfer Serum",
plugin_type=PluginType.VST2,
category=PluginCategory.SYNTH,
manufacturer="Xfer Records",
is_installed=False,
presets=["Init", "Bass - Basic", "Lead - Saw", "Pad - Warm"]
),
"massive": PluginInfo(
name="Massive",
display_name="Native Instruments Massive",
plugin_type=PluginType.VST2,
category=PluginCategory.SYNTH,
manufacturer="Native Instruments",
is_installed=False,
presets=["Init", "Bass - Deep", "Lead - Scream", "Pad - Atmosphere"]
),
"sylenth1": PluginInfo(
name="Sylenth1",
display_name="LennarDigital Sylenth1",
plugin_type=PluginType.VST2,
category=PluginCategory.SYNTH,
manufacturer="LennarDigital",
is_installed=False,
presets=["Init", "Bass - Sub", "Lead - SuperSaw", "Pad - Cloud"]
),
# FabFilter Effects
"pro-q": PluginInfo(
name="Pro-Q",
display_name="FabFilter Pro-Q 3",
plugin_type=PluginType.VST3,
category=PluginCategory.EQ,
manufacturer="FabFilter",
is_installed=False,
presets=["Default", "Low Cut", "High Cut", "Vocal", "Drums", "Mastering"]
),
"pro-c": PluginInfo(
name="Pro-C",
display_name="FabFilter Pro-C 2",
plugin_type=PluginType.VST3,
category=PluginCategory.COMPRESSOR,
manufacturer="FabFilter",
is_installed=False,
presets=["Default", "Vocal", "Drums", "Bus", "Mastering"]
),
"pro-r": PluginInfo(
name="Pro-R",
display_name="FabFilter Pro-R",
plugin_type=PluginType.VST3,
category=PluginCategory.REVERB,
manufacturer="FabFilter",
is_installed=False,
presets=["Default", "Hall", "Room", "Plate", "Vocal"]
),
# ValhallaDSP Effects
"valhalla_room": PluginInfo(
name="ValhallaRoom",
display_name="ValhallaRoom",
plugin_type=PluginType.VST2,
category=PluginCategory.REVERB,
manufacturer="ValhallaDSP",
is_installed=False,
presets=["Default", "Small Room", "Medium Room", "Large Hall", "Cathedral"]
),
"valhalla_vintage_verb": PluginInfo(
name="ValhallaVintageVerb",
display_name="ValhallaVintageVerb",
plugin_type=PluginType.VST2,
category=PluginCategory.REVERB,
manufacturer="ValhallaDSP",
is_installed=False,
presets=["Default", "1970s", "1980s", "1990s", "Modern"]
),
"valhalla_delay": PluginInfo(
name="ValhallaDelay",
display_name="ValhallaDelay",
plugin_type=PluginType.VST2,
category=PluginCategory.DELAY,
manufacturer="ValhallaDSP",
is_installed=False,
presets=["Default", "Tape", "Ping Pong", "Reverse"]
),
"valhalla_supermassive": PluginInfo(
name="ValhallaSupermassive",
display_name="ValhallaSupermassive",
plugin_type=PluginType.VST2,
category=PluginCategory.DELAY,
manufacturer="ValhallaDSP",
is_installed=False,
presets=["Default", "Sagittarius", "Great Wall", "Circinus"]
),
}
# Known parameter mappings for popular plugins
PLUGIN_PARAMETERS = {
"serum": {
"osc_a_wave": ParameterInfo("osc_a_wave", "Osc A Waveform", 0, 100, 0),
"osc_a_level": ParameterInfo("osc_a_level", "Osc A Level", 0, 1, 0.8),
"osc_b_wave": ParameterInfo("osc_b_wave", "Osc B Waveform", 0, 100, 0),
"osc_b_level": ParameterInfo("osc_b_level", "Osc B Level", 0, 1, 0),
"filter_cutoff": ParameterInfo("filter_cutoff", "Filter Cutoff", 0, 22000, 22000),
"filter_resonance": ParameterInfo("filter_resonance", "Filter Resonance", 0, 1, 0),
"attack": ParameterInfo("attack", "Amp Attack", 0, 10, 0.01),
"decay": ParameterInfo("decay", "Amp Decay", 0, 10, 0),
"sustain": ParameterInfo("sustain", "Amp Sustain", 0, 1, 1),
"release": ParameterInfo("release", "Amp Release", 0, 10, 0.5),
},
"massive": {
"osc1_pitch": ParameterInfo("osc1_pitch", "Osc 1 Pitch", -24, 24, 0),
"osc1_wtpos": ParameterInfo("osc1_wtpos", "Osc 1 Wavetable Pos", 0, 100, 0),
"osc2_pitch": ParameterInfo("osc2_pitch", "Osc 2 Pitch", -24, 24, 0),
"filter_cutoff": ParameterInfo("filter_cutoff", "Filter Cutoff", 0, 127, 127),
"filter_resonance": ParameterInfo("filter_resonance", "Filter Resonance", 0, 127, 0),
"attack": ParameterInfo("attack", "Amp Attack", 0, 127, 0),
"decay": ParameterInfo("decay", "Amp Decay", 0, 127, 0),
"sustain": ParameterInfo("sustain", "Amp Sustain", 0, 127, 127),
"release": ParameterInfo("release", "Amp Release", 0, 127, 20),
},
"sylenth1": {
"osc_a1_wave": ParameterInfo("osc_a1_wave", "Osc A1 Wave", 0, 4, 0),
"osc_a1_pitch": ParameterInfo("osc_a1_pitch", "Osc A1 Pitch", -10, 10, 0),
"osc_a2_wave": ParameterInfo("osc_a2_wave", "Osc A2 Wave", 0, 4, 0),
"cutoff_a": ParameterInfo("cutoff_a", "Filter A Cutoff", 0, 10, 10),
"resonance_a": ParameterInfo("resonance_a", "Filter A Resonance", 0, 10, 0),
"attack": ParameterInfo("attack", "Amp Attack", 0, 10, 0),
"decay": ParameterInfo("decay", "Amp Decay", 0, 10, 0),
"sustain": ParameterInfo("sustain", "Amp Sustain", 0, 10, 10),
"release": ParameterInfo("release", "Amp Release", 0, 10, 0),
},
"pro-q": {
"gain": ParameterInfo("gain", "Output Gain", -36, 36, 0),
"mix": ParameterInfo("mix", "Mix", 0, 100, 100),
"band1_gain": ParameterInfo("band1_gain", "Band 1 Gain", -30, 30, 0),
"band1_freq": ParameterInfo("band1_freq", "Band 1 Freq", 10, 30000, 200),
"band1_q": ParameterInfo("band1_q", "Band 1 Q", 0.025, 40, 1),
"band2_gain": ParameterInfo("band2_gain", "Band 2 Gain", -30, 30, 0),
"band2_freq": ParameterInfo("band2_freq", "Band 2 Freq", 10, 30000, 1000),
},
"pro-c": {
"threshold": ParameterInfo("threshold", "Threshold", -60, 0, 0),
"ratio": ParameterInfo("ratio", "Ratio", 1, 20, 2),
"attack": ParameterInfo("attack", "Attack", 0.005, 250, 10),
"release": ParameterInfo("release", "Release", 1, 2500, 100),
"makeup": ParameterInfo("makeup", "Makeup Gain", -36, 36, 0),
},
"valhalla_room": {
"mix": ParameterInfo("mix", "Mix", 0, 100, 50),
"decay": ParameterInfo("decay", "Decay", 0.1, 10, 2),
"size": ParameterInfo("size", "Size", 0, 1, 0.5),
"predelay": ParameterInfo("predelay", "PreDelay", 0, 200, 20),
},
"valhalla_vintage_verb": {
"mix": ParameterInfo("mix", "Mix", 0, 100, 50),
"decay": ParameterInfo("decay", "Decay", 0.1, 10, 2),
"damping": ParameterInfo("damping", "Damping", 0, 100, 50),
},
}
class VSTManager:
"""Manager for VST/AU plugins in Ableton Live."""
def __init__(self, song=None, connection=None):
"""
Initialize VST Manager.
Args:
song: Ableton Live song object (optional)
connection: TCP connection to Ableton (optional)
"""
self.song = song
self.connection = connection
self._scanned_plugins: Dict[str, PluginInfo] = {}
self._plugin_cache_file = self._get_cache_path()
# Initialize with known plugins
self._initialize_plugin_database()
# Try to load cached scan results
self._load_cached_plugins()
def _get_cache_path(self) -> str:
"""Get path for plugin cache file."""
script_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.join(script_dir, "..", "..", "vst_plugin_cache.json")
def _initialize_plugin_database(self):
"""Initialize the plugin database with known popular plugins."""
for key, info in POPULAR_PLUGINS.items():
self._scanned_plugins[key] = PluginInfo(
name=info.name,
display_name=info.display_name,
plugin_type=info.plugin_type,
category=info.category,
manufacturer=info.manufacturer,
is_installed=False,
version=info.version,
presets=list(info.presets) if info.presets else []
)
def _load_cached_plugins(self):
"""Load cached plugin scan results."""
try:
if os.path.exists(self._plugin_cache_file):
with open(self._plugin_cache_file, 'r') as f:
cached = json.load(f)
for key, data in cached.items():
if key in self._scanned_plugins:
self._scanned_plugins[key].is_installed = data.get('is_installed', False)
self._scanned_plugins[key].path = data.get('path')
if 'presets' in data and data['presets']:
self._scanned_plugins[key].presets = data['presets']
logger.info(f"Loaded {len(cached)} plugins from cache")
except Exception as e:
logger.warning(f"Could not load plugin cache: {e}")
def _save_cached_plugins(self):
"""Save plugin scan results to cache."""
try:
cache_data = {}
for key, info in self._scanned_plugins.items():
cache_data[key] = {
'name': info.name,
'is_installed': info.is_installed,
'path': info.path,
'presets': info.presets
}
with open(self._plugin_cache_file, 'w') as f:
json.dump(cache_data, f, indent=2)
except Exception as e:
logger.warning(f"Could not save plugin cache: {e}")
def scan_vst_plugins(self, force_rescan: bool = False) -> Dict[str, Any]:
"""
Scan for installed VST/AU plugins.
This attempts to detect which popular plugins are installed
in the system. In a real implementation, this would query
the operating system's plugin registry or Ableton's plugin list.
Args:
force_rescan: Force a fresh scan even if cache exists
Returns:
Dictionary with scan results and installed plugin list
"""
installed_count = 0
if force_rescan or not any(p.is_installed for p in self._scanned_plugins.values()):
# In a real implementation, this would:
# 1. Query Windows Registry for VST2 paths
# 2. Scan VST3 standard paths
# 3. Query macOS AudioUnit registry
# 4. Or query Ableton Live's plugin database
# For now, we simulate detection based on common installation paths
# and let the user confirm installation when loading
common_paths = self._get_common_plugin_paths()
for key, info in self._scanned_plugins.items():
# Check if plugin might be installed
# This is a heuristic - actual detection requires OS-specific calls
found_path = self._find_plugin_file(info.name, common_paths, info.plugin_type)
if found_path:
info.is_installed = True
info.path = found_path
installed_count += 1
logger.info(f"Detected plugin: {info.name} at {found_path}")
self._save_cached_plugins()
# Build results
installed = [p.name for p in self._scanned_plugins.values() if p.is_installed]
not_installed = [p.name for p in self._scanned_plugins.values() if not p.is_installed]
return {
"total_known": len(self._scanned_plugins),
"installed_count": len(installed),
"installed_plugins": installed,
"not_installed": not_installed,
"categories": self._get_plugins_by_category(),
"scan_paths_checked": self._get_common_plugin_paths() if force_rescan else [],
"cache_file": self._plugin_cache_file,
"note": "Plugin detection is heuristic. Actual availability verified when loading."
}
def _get_common_plugin_paths(self) -> List[str]:
"""Get common plugin installation paths."""
paths = []
# Windows VST2 paths
if os.name == 'nt':
paths.extend([
os.path.expandvars(r"%PROGRAMFILES%\VstPlugins"),
os.path.expandvars(r"%PROGRAMFILES(x86)%\VstPlugins"),
os.path.expandvars(r"%COMMONPROGRAMFILES%\VST2"),
os.path.expandvars(r"%COMMONPROGRAMFILES(x86)%\VST2"),
os.path.expandvars(r"%PROGRAMFILES%\Common Files\VST2"),
os.path.expandvars(r"%PROGRAMFILES%\Common Files\VST3"),
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Common\VST3"),
])
# macOS paths (for completeness)
else:
paths.extend([
"/Library/Audio/Plug-Ins/VST",
"/Library/Audio/Plug-Ins/VST3",
"/Library/Audio/Plug-Ins/Components",
os.path.expanduser("~/Library/Audio/Plug-Ins/VST"),
os.path.expanduser("~/Library/Audio/Plug-Ins/VST3"),
os.path.expanduser("~/Library/Audio/Plug-Ins/Components"),
])
return paths
def _find_plugin_file(self, plugin_name: str, paths: List[str], plugin_type: PluginType) -> Optional[str]:
"""Search for plugin file in given paths."""
# Normalize plugin name for file search
name_variants = [
plugin_name,
plugin_name.replace(" ", ""),
plugin_name.replace(" ", "-"),
]
# Extension based on plugin type
extensions = []
if plugin_type == PluginType.VST2:
extensions = ['.dll'] if os.name == 'nt' else ['.vst', '.so']
elif plugin_type == PluginType.VST3:
extensions = ['.vst3']
elif plugin_type == PluginType.AU:
extensions = ['.component']
for path in paths:
if not os.path.exists(path):
continue
try:
for root, dirs, files in os.walk(path):
for ext in extensions:
for name_variant in name_variants:
filename = name_variant + ext
if filename.lower() in [f.lower() for f in files]:
return os.path.join(root, filename)
# Check directories (for bundles)
if ext == '.component' or ext == '.vst3':
bundle_name = name_variant + ext
if bundle_name.lower() in [d.lower() for d in dirs]:
return os.path.join(root, bundle_name)
except Exception as e:
logger.debug(f"Error scanning {path}: {e}")
return None
def get_vst_presets(self, plugin_name: str) -> Dict[str, Any]:
"""
Get available presets for a plugin.
Args:
plugin_name: Name of the plugin
Returns:
Dictionary with preset list and plugin info
"""
key = plugin_name.lower().replace(" ", "_")
# Handle aliases
aliases = {
"serum": "serum",
"xfer_serum": "serum",
"massive": "massive",
"ni_massive": "massive",
"sylenth1": "sylenth1",
"sylenth": "sylenth1",
"pro-q": "pro-q",
"pro_q": "pro-q",
"fabfilter_pro_q": "pro-q",
"pro-c": "pro-c",
"pro_c": "pro-c",
"fabfilter_pro_c": "pro-c",
"valhallaroom": "valhalla_room",
"valhalla_room": "valhalla_room",
"valhallavintageverb": "valhalla_vintage_verb",
"valhalla_vintage_verb": "valhalla_vintage_verb",
}
key = aliases.get(key, key)
if key not in self._scanned_plugins:
return {
"status": "error",
"message": f"Unknown plugin: {plugin_name}",
"available_plugins": list(self._scanned_plugins.keys())
}
info = self._scanned_plugins[key]
# Get parameters for this plugin
params = PLUGIN_PARAMETERS.get(key, {})
param_list = [asdict(p) for p in params.values()]
return {
"status": "success",
"plugin_name": info.name,
"display_name": info.display_name,
"manufacturer": info.manufacturer,
"category": info.category.value,
"is_installed": info.is_installed,
"presets": info.presets,
"parameters": param_list,
"plugin_type": info.plugin_type.value,
}
def get_all_plugins(self) -> Dict[str, Any]:
"""Get list of all known plugins with their status."""
plugins = []
for key, info in self._scanned_plugins.items():
plugins.append({
"key": key,
"name": info.name,
"display_name": info.display_name,
"manufacturer": info.manufacturer,
"category": info.category.value,
"is_installed": info.is_installed,
"plugin_type": info.plugin_type.value,
})
return {
"total": len(plugins),
"installed": sum(1 for p in plugins if p["is_installed"]),
"plugins": plugins
}
def _get_plugins_by_category(self) -> Dict[str, List[str]]:
"""Group plugins by category."""
by_category = {}
for info in self._scanned_plugins.values():
cat = info.category.value
if cat not in by_category:
by_category[cat] = []
by_category[cat].append(info.name)
return by_category
def validate_plugin_installation(self, plugin_name: str) -> Tuple[bool, str]:
"""
Validate if a plugin is actually installed and usable.
Args:
plugin_name: Name of the plugin to validate
Returns:
Tuple of (is_installed, message)
"""
key = plugin_name.lower().replace(" ", "_")
# Handle common aliases
aliases = {
"serum": "serum",
"xfer_serum": "serum",
"massive": "massive",
"ni_massive": "massive",
"sylenth1": "sylenth1",
"sylenth": "sylenth1",
"pro-q": "pro-q",
"pro-q_3": "pro-q",
"pro-c": "pro-c",
"pro-c_2": "pro-c",
"valhallaroom": "valhalla_room",
"valhalla_vintage_verb": "valhalla_vintage_verb",
}
key = aliases.get(key, key)
if key not in self._scanned_plugins:
return False, f"Plugin '{plugin_name}' not in database"
info = self._scanned_plugins[key]
if info.is_installed and info.path:
# Verify file still exists
if os.path.exists(info.path):
return True, f"Plugin found at {info.path}"
else:
# File moved or deleted, update status
info.is_installed = False
info.path = None
self._save_cached_plugins()
return False, "Plugin was moved or deleted"
# Not marked as installed, try to find it
paths = self._get_common_plugin_paths()
found = self._find_plugin_file(info.name, paths, info.plugin_type)
if found:
info.is_installed = True
info.path = found
self._save_cached_plugins()
return True, f"Plugin found at {found}"
return False, f"Plugin '{plugin_name}' not found in standard plugin directories"
# Global instance
_vst_manager: Optional[VSTManager] = None
def get_vst_manager(song=None, connection=None) -> VSTManager:
"""Get or create global VST manager instance."""
global _vst_manager
if _vst_manager is None:
_vst_manager = VSTManager(song=song, connection=connection)
return _vst_manager
def scan_vst_plugins(force_rescan: bool = False) -> Dict[str, Any]:
"""Scan for installed VST/AU plugins."""
manager = get_vst_manager()
return manager.scan_vst_plugins(force_rescan=force_rescan)
def get_vst_presets(plugin_name: str) -> Dict[str, Any]:
"""Get presets for a plugin."""
manager = get_vst_manager()
return manager.get_vst_presets(plugin_name)
def get_all_plugins() -> Dict[str, Any]:
"""Get all known plugins."""
manager = get_vst_manager()
return manager.get_all_plugins()
def validate_plugin(plugin_name: str) -> Tuple[bool, str]:
"""Validate plugin installation."""
manager = get_vst_manager()
return manager.validate_plugin_installation(plugin_name)
def get_plugin_parameters(plugin_name: str) -> Dict[str, ParameterInfo]:
"""Get parameters for a plugin."""
key = plugin_name.lower().replace(" ", "_")
return PLUGIN_PARAMETERS.get(key, {})