# SonicForge Studio VST / VSTi Engine Service import os import numpy as np import functools from ctypes import c_int, c_char_p, c_void_p def midi_note_to_freq(note_number: int) -> float: return 440.0 * (2.0 ** ((note_number - 69) / 12.0)) def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float = 120.0, instrument: str = 'synth') -> np.ndarray: beat_duration_sec = 60.0 / max(30.0, bpm) max_duration_sec = 2.0 for event in midi_events: start_beat = event.get('start_beat', 0.0) dur_beats = event.get('duration_beats', 1.0) end_sec = (start_beat + dur_beats) * beat_duration_sec if end_sec > max_duration_sec: max_duration_sec = end_sec total_samples = int((max_duration_sec + 0.5) * sr) out_l = np.zeros(total_samples, dtype=np.float32) out_r = np.zeros(total_samples, dtype=np.float32) for event in midi_events: note = event.get('note', 60) velocity = event.get('velocity', 100) / 127.0 start_beat = event.get('start_beat', 0.0) dur_beats = event.get('duration_beats', 1.0) start_sample = int(start_beat * beat_duration_sec * sr) dur_samples = int(dur_beats * beat_duration_sec * sr) end_sample = min(total_samples, start_sample + dur_samples) actual_len = end_sample - start_sample if actual_len <= 0 or start_sample >= total_samples: continue freq = midi_note_to_freq(note) t = np.arange(actual_len) / float(sr) tone = 0.6 * np.sin(2 * np.pi * freq * t) + 0.3 * np.sin(2 * np.pi * freq * 2 * t) + 0.1 * np.sin(2 * np.pi * freq * 3 * t) attack = min(int(0.01 * sr), actual_len // 4) release = min(int(0.05 * sr), actual_len // 4) env = np.ones(actual_len, dtype=np.float32) if attack > 0: env[:attack] = np.linspace(0.0, 1.0, attack) if release > 0: env[-release:] = np.linspace(1.0, 0.0, release) signal = tone * env * velocity out_l[start_sample:end_sample] += signal out_r[start_sample:end_sample] += signal max_peak = max(np.max(np.abs(out_l)), np.max(np.abs(out_r))) if max_peak > 1.0: out_l /= max_peak out_r /= max_peak return np.vstack([out_l, out_r]) def check_pedalboard_safe(): import subprocess, sys try: res = subprocess.run( [sys.executable, "-c", "import pedalboard"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0 ) return res.returncode == 0 except Exception: return False def check_pyfluidsynth_safe(): import subprocess, sys try: res = subprocess.run( [sys.executable, "-c", "import fluidsynth"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0 ) return res.returncode == 0 except Exception: return False HAS_PEDALBOARD = check_pedalboard_safe() HAS_PYFLUIDSYNTH = check_pyfluidsynth_safe() def ensure_pyfluidsynth(): global HAS_PYFLUIDSYNTH if not HAS_PYFLUIDSYNTH: HAS_PYFLUIDSYNTH = check_pyfluidsynth_safe() return HAS_PYFLUIDSYNTH if HAS_PEDALBOARD: try: from pedalboard import VST3Plugin, Pedalboard, Gain, MidiMessage except Exception: HAS_PEDALBOARD = False if HAS_PYFLUIDSYNTH: try: import fluidsynth except Exception: HAS_PYFLUIDSYNTH = False # ── Module-level caches ── _FLUID_CACHE = {} # path → (fluidsynth.FluidSynth, refcount) _PLUGIN_MANAGER_INSTANCE = None _PLUGIN_MANAGER_ARGS = None _SF_INSTRUMENTS_CACHE = {} # sf_id → list[presets] def get_plugin_manager(vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None) -> "PluginManager": """Singleton: reuse PluginManager when args match, else create new.""" global _PLUGIN_MANAGER_INSTANCE, _PLUGIN_MANAGER_ARGS args = (vst_dir, sf_dir, upload_sf_dir) if _PLUGIN_MANAGER_INSTANCE is not None and _PLUGIN_MANAGER_ARGS == args: return _PLUGIN_MANAGER_INSTANCE _PLUGIN_MANAGER_ARGS = args _PLUGIN_MANAGER_INSTANCE = PluginManager(vst_dir, sf_dir, upload_sf_dir) return _PLUGIN_MANAGER_INSTANCE def load_soundfont_cached(path: str): """Return a cached FluidSynth instance for path, incrementing refcount.""" global _FLUID_CACHE if not HAS_PYFLUIDSYNTH: return None if path in _FLUID_CACHE: fl, ref = _FLUID_CACHE[path] _FLUID_CACHE[path] = (fl, ref + 1) return fl try: import fluidsynth fl = fluidsynth.FluidSynth(sample_rate=44100, gain=0.5) font_id = fl.sfload(path) fl.program_select(0, font_id, 0, 0) _FLUID_CACHE[path] = (fl, 1) return fl except Exception: return None def release_soundfont(path: str): """Decrement refcount; delete FluidSynth when count reaches 0.""" global _FLUID_CACHE if path not in _FLUID_CACHE: return fl, ref = _FLUID_CACHE[path] if ref <= 1: try: fl.delete() except Exception: pass del _FLUID_CACHE[path] else: _FLUID_CACHE[path] = (fl, ref - 1) class PluginManager: def __init__(self, vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None): self.vst_dir = vst_dir self.sf_dir = sf_dir self.upload_sf_dir = upload_sf_dir self._sf_scan_cache = None # cache for _scan_soundfonts() def _scan_plugins(self) -> dict: plugins = {} if not os.path.isdir(self.vst_dir): return plugins for root, dirs, files in os.walk(self.vst_dir): for file in files: if file.endswith(".vst3") or file.endswith(".so"): plugin_path = os.path.join(root, file) plugin_name = os.path.splitext(file)[0] plugins[plugin_name] = plugin_path return plugins def _scan_soundfonts(self) -> list: sfonts = [] dirs = [self.sf_dir] if self.upload_sf_dir and self.upload_sf_dir != self.sf_dir: dirs.append(self.upload_sf_dir) # Load metadata cache for upload soundfonts meta_cache = {} if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir): for f in os.listdir(self.upload_sf_dir): if f.endswith(".meta"): try: import json with open(os.path.join(self.upload_sf_dir, f), "r") as mf: meta_cache[os.path.splitext(f)[0]] = json.load(mf) except Exception: pass for d in dirs: if not os.path.isdir(d): continue for f in os.listdir(d): if f.endswith(".sf2") or f.endswith(".sf3"): base_id = os.path.splitext(f)[0] meta = meta_cache.get(base_id, None) if meta: display_name = meta.get("original_name", f) else: # Generate a friendly name from UUID: truncate to first 8 chars short_id = base_id[:8] if len(base_id) > 8 else base_id display_name = f"SoundFont_{short_id}" sfonts.append({"id": base_id, "name": display_name, "file": f, "display": os.path.splitext(display_name)[0][:40]}) return sfonts def load_vst(self, plugin_name: str, preset_data: dict = None): if not HAS_PEDALBOARD: return None plugins = self._scan_plugins() if plugin_name not in plugins: return None path = plugins[plugin_name] vst = VST3Plugin(path) if preset_data: for k, v in preset_data.items(): try: setattr(vst, k, v) except Exception: pass return vst def _scan_soundfonts_cached(self): if self._sf_scan_cache is not None: return self._sf_scan_cache self._sf_scan_cache = self._scan_soundfonts() return self._sf_scan_cache def load_soundfont(self, path: str): return load_soundfont_cached(path) def list_soundfont_instruments(self, sf_id: str): if not ensure_pyfluidsynth(): return [] if sf_id in _SF_INSTRUMENTS_CACHE: return _SF_INSTRUMENTS_CACHE[sf_id] search_dirs = [] if os.path.isdir(self.sf_dir): search_dirs.append(self.sf_dir) if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir) and self.upload_sf_dir != self.sf_dir: search_dirs.append(self.upload_sf_dir) for d in search_dirs: for f in os.listdir(d): if not (f.endswith(".sf2") or f.endswith(".sf3")): continue base = os.path.splitext(f)[0] if base == sf_id or base == sf_id.replace("sf_", ""): path = os.path.join(d, f) try: import fluidsynth fl = fluidsynth.Synth() fid = fl.sfload(path) if fid < 0: fl.delete() continue presets = [] _fl = fluidsynth._fl _fl.fluid_synth_get_sfont_by_id.restype = c_void_p _fl.fluid_preset_get_name.restype = c_char_p _fl.fluid_sfont_get_preset.restype = c_void_p sfont_ptr = _fl.fluid_synth_get_sfont_by_id(c_void_p(fl.synth), c_int(fid)) if sfont_ptr: for bank in range(0, 2): for prog_num in range(0, 128): try: preset = fluidsynth.fluid_sfont_get_preset(sfont_ptr, c_int(bank), c_int(prog_num)) except Exception: break if preset: name_ptr = fluidsynth.fluid_preset_get_name(preset) if name_ptr: name_val = c_char_p(name_ptr).value if name_val: presets.append({ "bank": bank, "program": prog_num, "name": name_val.decode("utf-8", errors="replace") }) fl.delete() _SF_INSTRUMENTS_CACHE[sf_id] = presets[:256] return presets[:256] except Exception: import traceback; traceback.print_exc() _SF_INSTRUMENTS_CACHE[sf_id] = [] return [] def list_available(self) -> dict: return { "vst_instruments": [ {"id": k, "name": k, "type": "VST3", "has_native_support": HAS_PEDALBOARD} for k in self._scan_plugins().keys() ], "soundfonts": self._scan_soundfonts() } @staticmethod def midi_events_to_messages(midi_events: list, bpm: float, sr: int) -> list: if not HAS_PEDALBOARD: return [] beat_duration_sec = 60.0 / max(30.0, bpm) messages = [] for ev in midi_events: note = ev.get("note", 60) velocity = ev.get("velocity", 100) start_beat = ev.get("start_beat", 0.0) dur_beats = ev.get("duration_beats", 1.0) start_sec = start_beat * beat_duration_sec dur_sec = dur_beats * beat_duration_sec sample_offset = int(start_sec * sr) end_sample_offset = int((start_sec + dur_sec) * sr) messages.append(MidiMessage(note_on=note, velocity=velocity, sample_offset=sample_offset)) messages.append(MidiMessage(note_off=note, velocity=0, sample_offset=end_sample_offset)) return messages @staticmethod def validate_sf2_header(data: bytes) -> bool: if len(data) < 12: return False if data[0:4] != b'RIFF': return False if data[8:12] != b'sfbk': return False return True