diff --git a/package.json b/package.json index 575ec991..f31dfbe7 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,11 @@ "description": "", "type": "module", "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "dev": "bun --bun vite dev", + "build": "bun --bun vite build", + "preview": "bun --bun vite preview", + "check": "bun --bun svelte-kit sync && bun --bun svelte-check --tsconfig ./tsconfig.json", + "check:watch": "bun --bun svelte-kit sync && bun --bun svelte-check --tsconfig ./tsconfig.json --watch", "tauri": "tauri", "format": "prettier --write \"**/*.{ts,tsx,md,svelte,json}\" && cargo fmt --manifest-path src-tauri/Cargo.toml", "generate": "cd src-tauri && cargo test" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 297694e8..4150802b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -139,6 +139,7 @@ windows = { version = "0.61", features = [ "Win32_System_Variant", "Win32_System_Threading", "Win32_System_ProcessStatus", + "Win32_System_Memory", "Win32_Storage_FileSystem", "Win32_Devices_DeviceAndDriverInstallation", "Win32_Devices_FunctionDiscovery", diff --git a/src-tauri/src/audio/plugins/bridge/bridge_host.rs b/src-tauri/src/audio/plugins/bridge/bridge_host.rs new file mode 100644 index 00000000..11ff299a --- /dev/null +++ b/src-tauri/src/audio/plugins/bridge/bridge_host.rs @@ -0,0 +1,390 @@ +//! Host-side manager for Out-of-Process VST3 plugins on Windows. + +#![cfg(target_os = "windows")] + +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::Ordering; +use std::sync::mpsc::{channel, Receiver, Sender}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use tauri::Emitter; + +use crate::audio::effects::Effect; +use crate::audio::plugins::bridge::protocol::{HelperEvent, HostCommand}; +use crate::audio::plugins::bridge::shm_audio::ShmHost; +use crate::audio::plugins::host_api::{ + alive_flag, tag_state, untag_state, ActivateRequest, AliveFlag, EditorSize, Graveyard, + HostedNode, PluginHost, PluginParamInfo, PluginStatus, Unsupported, EDITOR_CLOSED_EVENT, +}; +use crate::audio::plugins::{ParamRing, PluginFormat}; + +/// RT audio node communicating with the helper process over Shared Memory. +pub struct BridgeNode { + shm: ShmHost, + channels: usize, + latency: usize, + alive: AliveFlag, + params: Arc, + param_cursor: usize, + cmd_tx: Sender, +} + +impl BridgeNode { + pub fn new( + shm: ShmHost, + channels: usize, + latency: usize, + alive: AliveFlag, + params: Arc, + cmd_tx: Sender, + ) -> Self { + let param_cursor = params.reader(); + Self { + shm, + channels, + latency, + alive, + params, + param_cursor, + cmd_tx, + } + } + + pub fn channels(&self) -> usize { + self.channels + } +} + +impl Effect for BridgeNode { + fn process(&mut self, samples: &mut [f32], frames: usize) { + // Drain parameter edits and forward to helper + while let Some((id, value)) = self.params.read(&mut self.param_cursor) { + let _ = self.cmd_tx.send(HostCommand::SetParam { id, value }); + } + + if let Ok(latency) = self.shm.process(samples, frames, self.channels) { + self.latency = latency; + } + } + + fn latency_frames(&self) -> usize { + self.latency + } +} + +impl Drop for BridgeNode { + fn drop(&mut self) { + self.alive.store(false, Ordering::Release); + self.shm.mark_dead(); + } +} + +pub struct BridgeSlot { + pub path: String, + pub plugin_id: String, + pub params: Vec, + pub has_editor: bool, + pub cmd_tx: Sender, + pub reply_rx: Arc>>, + pub alive: AliveFlag, + pub child: Arc>>, +} + +pub fn slots() -> &'static Mutex> { + static SLOTS: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + SLOTS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn graveyard() -> &'static Mutex, Arc>>)>> { + static GRAVEYARD: std::sync::OnceLock< + Mutex, Arc>>)>>, + > = std::sync::OnceLock::new(); + GRAVEYARD.get_or_init(|| Mutex::new(Graveyard::default())) +} + +pub fn with_slot(node_id: &str, f: impl FnOnce(&mut BridgeSlot) -> R) -> Option { + slots().lock().unwrap().get_mut(node_id).map(f) +} + +pub struct BridgeHost; + +impl PluginHost for BridgeHost { + fn activate(&self, req: ActivateRequest<'_>) -> Result { + let node_id = req.node_id.to_string(); + let path = req.path.to_string(); + let plugin_id = req.plugin_id.to_string(); + let session_id = format!("{}_{}", cuid2::create_id(), std::process::id()); + + // 1. Create Shared Memory audio channel + let shm = ShmHost::create(&session_id) + .map_err(|e| format!("bridge {node_id}: failed to create shm: {e}"))?; + + // 2. Spawn helper child process: splitwave.exe --plugin-bridge + let current_exe = std::env::current_exe() + .map_err(|e| format!("bridge {node_id}: failed to get current_exe: {e}"))?; + + let mut child = Command::new(current_exe) + .arg("--plugin-bridge") + .arg(&session_id) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .map_err(|e| format!("bridge {node_id}: failed to spawn helper: {e}"))?; + + let mut stdin = child + .stdin + .take() + .ok_or_else(|| format!("bridge {node_id}: failed to open helper stdin"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| format!("bridge {node_id}: failed to open helper stdout"))?; + + let (cmd_tx, cmd_rx) = channel::(); + let (reply_tx, reply_rx) = channel::(); + + // Background stdin writer + thread::spawn(move || { + while let Ok(cmd) = cmd_rx.recv() { + if let Ok(json) = serde_json::to_string(&cmd) { + if writeln!(stdin, "{json}").is_err() || stdin.flush().is_err() { + break; + } + } + if matches!(cmd, HostCommand::Shutdown) { + break; + } + } + }); + + // Background stdout reader + let event_node_id = node_id.clone(); + let event_param_ring = req.params.clone(); + let event_reply_tx = reply_tx.clone(); + thread::spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines() { + if let Ok(text) = line { + if let Ok(event) = serde_json::from_str::(&text) { + match &event { + HelperEvent::ParamEdited { id, value } => { + event_param_ring.push(*id, *value); + } + HelperEvent::EditorClosed => { + if let Some(app) = crate::app_handle() { + let _ = app.emit(EDITOR_CLOSED_EVENT, &event_node_id); + } + } + _ => { + let _ = event_reply_tx.send(event); + } + } + } + } else { + break; + } + } + }); + + // 3. Initialize plugin in helper + cmd_tx + .send(HostCommand::Init { + path: path.clone(), + plugin_id: plugin_id.clone(), + }) + .map_err(|e| format!("bridge {node_id}: failed to send Init: {e}"))?; + + let (params, has_editor) = match reply_rx.recv_timeout(Duration::from_secs(5)) { + Ok(HelperEvent::Loaded { params, has_editor }) => (params, has_editor), + Ok(HelperEvent::Error { message }) => { + return Err(format!("bridge {node_id} load error: {message}")); + } + other => { + return Err(format!( + "bridge {node_id}: unexpected response to Init: {other:?}" + )); + } + }; + + // 4. Activate plugin in helper + let state = req + .state + .and_then(|s| untag_state(&plugin_id, s)) + .map(str::to_string); + + cmd_tx + .send(HostCommand::Activate { + sample_rate: req.sample_rate, + max_frames: req.max_frames, + channels: req.channels, + state, + }) + .map_err(|e| format!("bridge {node_id}: failed to send Activate: {e}"))?; + + let (accepted_channels, latency_frames) = + match reply_rx.recv_timeout(Duration::from_secs(5)) { + Ok(HelperEvent::Activated { + accepted_channels, + latency_frames, + }) => (accepted_channels, latency_frames), + Ok(HelperEvent::Error { message }) => { + return Err(format!("bridge {node_id} activate error: {message}")); + } + other => { + return Err(format!( + "bridge {node_id}: unexpected response to Activate: {other:?}" + )); + } + }; + + let alive = alive_flag(); + let bridge_node = BridgeNode::new( + shm, + accepted_channels, + latency_frames, + alive.clone(), + req.params.clone(), + cmd_tx.clone(), + ); + + let child_arc = Arc::new(Mutex::new(Some(child))); + let reply_rx_arc = Arc::new(Mutex::new(reply_rx)); + + if req.primary { + let old = slots().lock().unwrap().insert( + node_id, + BridgeSlot { + path, + plugin_id, + params, + has_editor, + cmd_tx, + reply_rx: reply_rx_arc, + alive: alive.clone(), + child: child_arc.clone(), + }, + ); + if let Some(old) = old { + graveyard() + .lock() + .unwrap() + .bury((old.cmd_tx, old.child), old.alive); + } + } else { + graveyard().lock().unwrap().bury((cmd_tx, child_arc), alive); + } + + Ok(HostedNode::Bridge(bridge_node)) + } + + fn forget(&self, node_id: &str) { + let slot = slots().lock().unwrap().remove(node_id); + if let Some(slot) = slot { + graveyard() + .lock() + .unwrap() + .bury((slot.cmd_tx, slot.child), slot.alive); + } + } + + fn status(&self, node_id: &str) -> PluginStatus { + with_slot(node_id, |slot| PluginStatus { + path: Some(slot.path.clone()), + has_editor: slot.has_editor, + }) + .unwrap_or_default() + } + + fn params(&self, node_id: &str) -> Vec { + with_slot(node_id, |slot| slot.params.clone()).unwrap_or_default() + } + + fn save_state(&self, node_id: &str) -> Result, Unsupported> { + let (cmd_tx, reply_rx, plugin_id) = match with_slot(node_id, |slot| { + ( + slot.cmd_tx.clone(), + slot.reply_rx.clone(), + slot.plugin_id.clone(), + ) + }) { + Some(v) => v, + None => return Ok(None), + }; + + if cmd_tx.send(HostCommand::SaveState).is_err() { + return Ok(None); + } + + let rx = reply_rx.lock().unwrap(); + match rx.recv_timeout(Duration::from_millis(500)) { + Ok(HelperEvent::StateSaved { blob }) => Ok(blob.map(|b| tag_state(&plugin_id, &b))), + _ => Ok(None), + } + } + + fn notify_param_changed( + &self, + node_id: &str, + param_id: u32, + value: f64, + ) -> Result<(), Unsupported> { + with_slot(node_id, move |slot| { + let _ = slot.cmd_tx.send(HostCommand::SetParam { + id: param_id, + value, + }); + }); + Ok(()) + } + + fn embed_editor(&self, node_id: &str, window: &tauri::Window) -> Result { + let _ = window; // In bridge mode, the helper process manages its own pure Win32 top-level window + let (cmd_tx, reply_rx) = + with_slot(node_id, |slot| (slot.cmd_tx.clone(), slot.reply_rx.clone())) + .ok_or_else(|| format!("{node_id}: no plugin running"))?; + + cmd_tx + .send(HostCommand::OpenEditor { + title: "Plugin Editor".to_string(), + }) + .map_err(|e| format!("failed to send OpenEditor: {e}"))?; + + let rx = reply_rx.lock().unwrap(); + match rx.recv_timeout(Duration::from_millis(1500)) { + Ok(HelperEvent::EditorOpened { width, height }) => Ok((width, height)), + Ok(HelperEvent::Ok) => Ok((800, 600)), + Ok(HelperEvent::Error { message }) => Err(message), + other => Err(format!("unexpected response to OpenEditor: {other:?}")), + } + } + + fn destroy_editor(&self, node_id: &str) { + with_slot(node_id, |slot| { + let _ = slot.cmd_tx.send(HostCommand::CloseEditor); + }); + } + + fn tick_and_reclaim(&self) { + let mut freed = graveyard().lock().unwrap().reclaim(); + for (cmd_tx, child_mutex) in freed.drain(..) { + let _ = cmd_tx.send(HostCommand::Shutdown); + if let Ok(mut lock) = child_mutex.lock() { + if let Some(mut child) = lock.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } + } +} + +impl std::fmt::Debug for BridgeHost { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", PluginFormat::Vst3) + } +} diff --git a/src-tauri/src/audio/plugins/bridge/helper_main.rs b/src-tauri/src/audio/plugins/bridge/helper_main.rs new file mode 100644 index 00000000..b42b95de --- /dev/null +++ b/src-tauri/src/audio/plugins/bridge/helper_main.rs @@ -0,0 +1,579 @@ +//! Standalone helper process runner for Out-of-Process VST3 plugins on Windows. + +#![cfg(target_os = "windows")] + +use std::ffi::c_void; +use std::io::{BufRead, BufReader, Write}; +use std::sync::atomic::AtomicBool; +use std::sync::mpsc::{channel, Sender}; +use std::sync::Arc; +use std::thread; + +use windows::core::{w, HSTRING, PCWSTR}; +use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM}; +use windows::Win32::Graphics::Gdi::{BeginPaint, EndPaint, UpdateWindow, HBRUSH, PAINTSTRUCT}; +use windows::Win32::System::Com::{CoInitializeEx, COINIT_APARTMENTTHREADED}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::Input::KeyboardAndMouse::SetFocus; +use windows::Win32::UI::WindowsAndMessaging::{ + AdjustWindowRectEx, CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, + GetClassNameW, GetSystemMetrics, GetWindow, GetWindowLongPtrW, IsWindow, LoadCursorW, + MoveWindow, PeekMessageW, PostThreadMessageW, RegisterClassExW, SetForegroundWindow, + SetWindowLongPtrW, SetWindowPos, ShowWindow, TranslateMessage, CREATESTRUCTW, CS_DBLCLKS, + CS_HREDRAW, CS_OWNDC, CS_VREDRAW, GWLP_USERDATA, GW_CHILD, GW_HWNDNEXT, IDC_ARROW, PM_REMOVE, + SM_CXSCREEN, SM_CYSCREEN, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOZORDER, SW_HIDE, SW_SHOW, + WINDOW_EX_STYLE, WM_CLOSE, WM_CREATE, WM_ERASEBKGND, WM_NCCREATE, WM_NCDESTROY, WM_PAINT, + WM_SETFOCUS, WM_SIZE, WM_USER, WNDCLASSEXW, WS_CLIPCHILDREN, WS_CLIPSIBLINGS, + WS_OVERLAPPEDWINDOW, +}; + +use crate::audio::effects::Effect; +use crate::audio::plugins::bridge::protocol::{HelperEvent, HostCommand}; +use crate::audio::plugins::bridge::shm_audio::ShmHelper; +use crate::audio::plugins::vst3_backend::Vst3Module; +use crate::audio::plugins::vst3_com::EditListener; +use crate::audio::plugins::vst3_editor::{EditorView, PlugFrame}; +use crate::audio::plugins::vst3_host::Vst3Instance; +use crate::audio::plugins::vst3_node::Vst3Node; +use crate::audio::plugins::ParamRing; + +use vst3::ComWrapper; +use vst3::Steinberg::{ + kPlatformTypeHWND, kResultOk, kResultTrue, IPlugFrame, IPlugViewTrait, ViewRect, +}; + +const WM_HOST_COMMAND: u32 = WM_USER + 101; +const CLASS_NAME: PCWSTR = w!("SplitwaveBridgePluginWindow"); + +struct BridgeWindowState { + editor_view: Option, + event_tx: Sender, +} + +unsafe extern "system" fn bridge_wndproc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_NCCREATE => { + let cs = &*(lparam.0 as *const CREATESTRUCTW); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, cs.lpCreateParams as isize); + DefWindowProcW(hwnd, msg, wparam, lparam) + } + WM_CREATE => DefWindowProcW(hwnd, msg, wparam, lparam), + WM_ERASEBKGND => LRESULT(1), + WM_PAINT => { + let mut ps = PAINTSTRUCT::default(); + let _ = BeginPaint(hwnd, &mut ps); + let _ = EndPaint(hwnd, &ps); + LRESULT(0) + } + WM_SETFOCUS => { + let current = GetWindow(hwnd, GW_CHILD).ok(); + if let Some(child) = current { + let _ = SetFocus(Some(child)); + } + LRESULT(0) + } + WM_SIZE => { + let ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut BridgeWindowState; + if !ptr.is_null() { + let data = &mut *ptr; + let w = (lparam.0 & 0xffff) as i32; + let h = ((lparam.0 >> 16) & 0xffff) as i32; + + let mut current = GetWindow(hwnd, GW_CHILD).ok(); + while let Some(child) = current { + let _ = MoveWindow(child, 0, 0, w, h, true); + current = GetWindow(child, GW_HWNDNEXT).ok(); + } + + if let Some(ref mut editor) = data.editor_view { + let mut rect = ViewRect { + left: 0, + top: 0, + right: w.max(1), + bottom: h.max(1), + }; + let _ = editor.view().onSize(&mut rect); + } + } + LRESULT(0) + } + WM_CLOSE => { + let ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut BridgeWindowState; + if !ptr.is_null() { + let data = &*ptr; + let _ = data.event_tx.send(HelperEvent::EditorClosed); + } + let _ = ShowWindow(hwnd, SW_HIDE); + LRESULT(0) + } + WM_NCDESTROY => { + let ptr = SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0) as *mut BridgeWindowState; + if !ptr.is_null() { + let _ = Box::from_raw(ptr); + } + LRESULT(0) + } + _ => DefWindowProcW(hwnd, msg, wparam, lparam), + } +} + +fn register_window_class() -> Result<(), String> { + unsafe { + let h_instance = GetModuleHandleW(None).map_err(|e| format!("{e}"))?; + let cursor = LoadCursorW(None, IDC_ARROW).unwrap_or_default(); + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + style: CS_OWNDC | CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS, + lpfnWndProc: Some(bridge_wndproc), + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: h_instance.into(), + hIcon: Default::default(), + hCursor: cursor, + hbrBackground: HBRUSH(std::ptr::null_mut()), + lpszMenuName: PCWSTR::null(), + lpszClassName: CLASS_NAME, + hIconSm: Default::default(), + }; + let atom = RegisterClassExW(&wc); + if atom == 0 { + // Already registered or failed + } + Ok(()) + } +} + +struct BridgeListener { + tx: Sender, + param_ring: Arc, +} + +impl EditListener for BridgeListener { + fn param_edited(&self, id: u32, value: f64) { + self.param_ring.push(id, value); + let _ = self.tx.send(HelperEvent::ParamEdited { id, value }); + } + + fn restart(&self, _flags: i32) {} +} + +/// Entry point executed when `--plugin-bridge ` is passed. +pub fn run_helper(session_id: &str) -> i32 { + let _ = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; + let _ = register_window_class(); + + // Force creation of Win32 message queue on this thread before other threads post messages + unsafe { + let mut dummy = std::mem::zeroed(); + let _ = PeekMessageW(&mut dummy, None, 0, 0, PM_REMOVE); + } + + let main_thread_id = unsafe { windows::Win32::System::Threading::GetCurrentThreadId() }; + + let (event_tx, event_rx) = channel::(); + let (cmd_tx, cmd_rx) = channel::(); + + // Stdout event writer thread + thread::spawn(move || { + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + while let Ok(event) = event_rx.recv() { + if let Ok(json) = serde_json::to_string(&event) { + let _ = writeln!(handle, "{json}"); + let _ = handle.flush(); + } + } + }); + + // Stdin command reader thread + let cmd_tx_clone = cmd_tx.clone(); + thread::spawn(move || { + let stdin = std::io::stdin(); + let reader = BufReader::new(stdin); + for line in reader.lines() { + match line { + Ok(text) if !text.trim().is_empty() => { + if let Ok(cmd) = serde_json::from_str::(&text) { + let is_shutdown = matches!(cmd, HostCommand::Shutdown); + let _ = cmd_tx_clone.send(cmd); + unsafe { + let _ = PostThreadMessageW( + main_thread_id, + WM_HOST_COMMAND, + WPARAM(0), + LPARAM(0), + ); + } + if is_shutdown { + break; + } + } + } + _ => { + // Stdin EOF (host closed) + let _ = cmd_tx_clone.send(HostCommand::Shutdown); + unsafe { + let _ = PostThreadMessageW( + main_thread_id, + WM_HOST_COMMAND, + WPARAM(0), + LPARAM(0), + ); + } + break; + } + } + } + }); + + let mut instance: Option = None; + let param_ring = Arc::new(ParamRing::new()); + let mut window_hwnd: Option = None; + + // Start DSP audio thread + let dsp_session_id = session_id.to_string(); + let (dsp_node_tx, dsp_node_rx) = channel::(); + let node_sender = dsp_node_tx; + + thread::Builder::new() + .name("bridge:dsp".to_string()) + .spawn(move || { + let mut shm = match ShmHelper::open(&dsp_session_id) { + Ok(s) => s, + Err(e) => { + tracing::error!(session = %dsp_session_id, error = %e, "bridge: ShmHelper::open failed"); + return; + } + }; + + let mut node: Option = None; + let mut scratch = vec![0.0f32; 4096 * 8]; + + while shm.is_alive() { + // Check if a new node was provided + while let Ok(new_node) = dsp_node_rx.try_recv() { + node = Some(new_node); + } + + match shm.wait_for_input(20) { + Ok(Some((frames, channels))) => { + let total_samples = frames * channels; + if let Some(ref mut active_node) = node { + if scratch.len() < total_samples { + scratch.resize(total_samples, 0.0); + } + let input = shm.read_input(total_samples); + scratch[..total_samples].copy_from_slice(input); + active_node.process(&mut scratch[..total_samples], frames); + let latency = active_node.latency_frames(); + shm.write_output_and_signal(&scratch[..total_samples], frames, latency); + } else { + // No node active yet, echo zeros + scratch[..total_samples].fill(0.0); + shm.write_output_and_signal(&scratch[..total_samples], frames, 0); + } + } + Ok(None) => {} + Err(_) => break, + } + } + }) + .expect("failed to spawn bridge DSP thread"); + + // Main UI + Command event loop + unsafe { + let mut msg = std::mem::zeroed(); + loop { + let mut got_msg = false; + // Process pending Win32 messages + while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() { + got_msg = true; + if msg.message == WM_HOST_COMMAND { + // Handled in command drain below + } else { + let _ = TranslateMessage(&msg); + let _ = DispatchMessageW(&msg); + } + } + + let mut had_cmd = false; + + // Drain incoming host commands + while let Ok(cmd) = cmd_rx.try_recv() { + had_cmd = true; + match cmd { + HostCommand::Init { path, plugin_id } => { + match Vst3Module::open(std::path::Path::new(&path)) { + Ok(module) => match Vst3Instance::new(module, &plugin_id) { + Ok(mut inst) => { + inst.listen(Box::new(BridgeListener { + tx: event_tx.clone(), + param_ring: param_ring.clone(), + })); + let has_editor = inst.has_editor(); + let params = inst.params(); + instance = Some(inst); + let _ = + event_tx.send(HelperEvent::Loaded { params, has_editor }); + } + Err(e) => { + let _ = event_tx.send(HelperEvent::Error { message: e }); + } + }, + Err(e) => { + let _ = event_tx.send(HelperEvent::Error { message: e }); + } + } + } + HostCommand::Activate { + sample_rate, + max_frames, + channels, + state, + } => { + if let Some(ref mut inst) = instance { + if let Some(ref blob) = state { + let _ = inst.restore_state(blob); + } + let alive = Arc::new(AtomicBool::new(true)); + match inst.activate( + sample_rate, + max_frames, + channels, + param_ring.clone(), + alive, + ) { + Ok(node) => { + let accepted_channels = node.channels(); + let latency_frames = node.latency_frames(); + let _ = node_sender.send(node); + let _ = event_tx.send(HelperEvent::Activated { + accepted_channels, + latency_frames, + }); + } + Err(e) => { + let _ = event_tx.send(HelperEvent::Error { message: e }); + } + } + } else { + let _ = event_tx.send(HelperEvent::Error { + message: "instance not initialized".into(), + }); + } + } + HostCommand::OpenEditor { title } => { + if let Some(hwnd) = window_hwnd { + if IsWindow(Some(hwnd)).as_bool() { + let _ = ShowWindow(hwnd, SW_SHOW); + let _ = SetForegroundWindow(hwnd); + let _ = event_tx.send(HelperEvent::Ok); + continue; + } + } + + if let Some(ref mut inst) = instance { + let Some(view) = inst.take_view() else { + let _ = event_tx.send(HelperEvent::Error { + message: "plugin has no editor".into(), + }); + continue; + }; + + if view.isPlatformTypeSupported(kPlatformTypeHWND) != kResultTrue { + let _ = event_tx.send(HelperEvent::Error { + message: "plugin editor does not support HWND".into(), + }); + continue; + } + + let mut rect = ViewRect { + left: 0, + top: 0, + right: 0, + bottom: 0, + }; + if view.getSize(&mut rect) != kResultOk { + let _ = event_tx.send(HelperEvent::Error { + message: "plugin editor reported no size".into(), + }); + continue; + } + + let width = (rect.right - rect.left).max(200); + let height = (rect.bottom - rect.top).max(150); + + let style = WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN | WS_CLIPSIBLINGS; + let mut win_rect = RECT { + left: 0, + top: 0, + right: width, + bottom: height, + }; + let _ = AdjustWindowRectEx( + &mut win_rect, + style, + false, + WINDOW_EX_STYLE::default(), + ); + let win_w = win_rect.right - win_rect.left; + let win_h = win_rect.bottom - win_rect.top; + + let screen_w = GetSystemMetrics(SM_CXSCREEN); + let screen_h = GetSystemMetrics(SM_CYSCREEN); + let pos_x = ((screen_w - win_w) / 2).max(50); + let pos_y = ((screen_h - win_h) / 2).max(50); + + let user_data = Box::new(BridgeWindowState { + editor_view: None, + event_tx: event_tx.clone(), + }); + let user_data_ptr = Box::into_raw(user_data); + + let title_hstring = HSTRING::from(&title); + let h_instance = GetModuleHandleW(None).unwrap_or_default(); + + let hwnd_res = CreateWindowExW( + WINDOW_EX_STYLE::default(), + CLASS_NAME, + PCWSTR(title_hstring.as_ptr()), + style, + pos_x, + pos_y, + win_w, + win_h, + None, + None, + Some(h_instance.into()), + Some(user_data_ptr as *mut _), + ); + + let hwnd = match hwnd_res { + Ok(h) => h, + Err(e) => { + let _ = Box::from_raw(user_data_ptr); + let _ = event_tx.send(HelperEvent::Error { + message: format!("CreateWindowExW failed: {e}"), + }); + continue; + } + }; + + let resize_hwnd_val = hwnd.0 as isize; + let resize_cb = Box::new(move |new_w: u32, new_h: u32| { + let resize_hwnd = HWND(resize_hwnd_val as *mut c_void); + let mut r = RECT { + left: 0, + top: 0, + right: new_w as i32, + bottom: new_h as i32, + }; + let _ = AdjustWindowRectEx( + &mut r, + style, + false, + WINDOW_EX_STYLE::default(), + ); + let _ = SetWindowPos( + resize_hwnd, + None, + 0, + 0, + r.right - r.left, + r.bottom - r.top, + SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE, + ); + }); + + let frame = ComWrapper::new(PlugFrame::new(resize_cb)); + let frame_ptr = frame.as_com_ref::().map(|r| r.as_ptr()); + if let Some(fptr) = frame_ptr { + view.setFrame(fptr); + } + + if view.attached(hwnd.0 as *mut c_void, kPlatformTypeHWND) != kResultOk + { + let _ = DestroyWindow(hwnd); + let _ = event_tx.send(HelperEvent::Error { + message: "plugin view refused to attach".into(), + }); + continue; + } + + let _ = view.onSize(&mut rect); + + let mut current = GetWindow(hwnd, GW_CHILD).ok(); + while let Some(child) = current { + let mut class_buf = [0u16; 128]; + let len = GetClassNameW(child, &mut class_buf); + let _ = String::from_utf16_lossy(&class_buf[..len as usize]); + let _ = MoveWindow(child, 0, 0, width, height, true); + let _ = ShowWindow(child, SW_SHOW); + let _ = UpdateWindow(child); + current = GetWindow(child, GW_HWNDNEXT).ok(); + } + + (*user_data_ptr).editor_view = + Some(EditorView::from_raw_parts(view, frame)); + window_hwnd = Some(hwnd); + + let _ = ShowWindow(hwnd, SW_SHOW); + let _ = UpdateWindow(hwnd); + let _ = SetForegroundWindow(hwnd); + + let _ = event_tx.send(HelperEvent::EditorOpened { + width: width as u32, + height: height as u32, + }); + } + } + HostCommand::CloseEditor => { + if let Some(hwnd) = window_hwnd { + let _ = ShowWindow(hwnd, SW_HIDE); + } + let _ = event_tx.send(HelperEvent::Ok); + } + HostCommand::SetParam { id, value } => { + if let Some(ref inst) = instance { + inst.set_param(id, value); + param_ring.push(id, value); + } + } + HostCommand::GetParams => { + if let Some(ref inst) = instance { + let _ = event_tx.send(HelperEvent::ParamsList { + params: inst.params(), + }); + } + } + HostCommand::SaveState => { + let blob = instance.as_ref().and_then(|inst| inst.save_state()); + let _ = event_tx.send(HelperEvent::StateSaved { blob }); + } + HostCommand::RestoreState { blob } => { + if let Some(ref inst) = instance { + let _ = inst.restore_state(&blob); + } + let _ = event_tx.send(HelperEvent::Ok); + } + HostCommand::Shutdown => { + if let Some(hwnd) = window_hwnd { + let _ = DestroyWindow(hwnd); + } + drop(instance); + return 0; + } + } + } + + if !got_msg && !had_cmd { + windows::Win32::System::Threading::Sleep(5); + } + } + } +} diff --git a/src-tauri/src/audio/plugins/bridge/mod.rs b/src-tauri/src/audio/plugins/bridge/mod.rs new file mode 100644 index 00000000..71bc9b6e --- /dev/null +++ b/src-tauri/src/audio/plugins/bridge/mod.rs @@ -0,0 +1,10 @@ +//! Out-of-Process Plugin Bridge for Windows. + +#![cfg(target_os = "windows")] + +pub mod bridge_host; +pub mod helper_main; +pub mod protocol; +pub mod shm_audio; + +pub use bridge_host::{BridgeHost, BridgeNode}; diff --git a/src-tauri/src/audio/plugins/bridge/protocol.rs b/src-tauri/src/audio/plugins/bridge/protocol.rs new file mode 100644 index 00000000..a477bd31 --- /dev/null +++ b/src-tauri/src/audio/plugins/bridge/protocol.rs @@ -0,0 +1,63 @@ +//! IPC protocol for Out-of-Process Plugin Bridge on Windows. + +#![cfg(target_os = "windows")] + +use crate::audio::plugins::host_api::PluginParamInfo; +use serde::{Deserialize, Serialize}; + +/// Commands sent from Splitwave Host to the Plugin Bridge Helper process. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum HostCommand { + /// Initialize the plugin instance inside the helper process. + Init { path: String, plugin_id: String }, + /// Activate audio processing. + Activate { + sample_rate: u32, + max_frames: usize, + channels: usize, + state: Option, + }, + /// Open the native editor window. + OpenEditor { title: String }, + /// Close / hide the native editor window. + CloseEditor, + /// Set a parameter value from the host. + SetParam { id: u32, value: f64 }, + /// Request current parameter list. + GetParams, + /// Request saved state blob. + SaveState, + /// Restore plugin state from blob. + RestoreState { blob: String }, + /// Gracefully shutdown the helper process. + Shutdown, +} + +/// Events / Responses sent from the Helper process to Splitwave Host. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum HelperEvent { + /// Plugin loaded and initialized successfully. + Loaded { + params: Vec, + has_editor: bool, + }, + /// Plugin activated and ready for audio processing. + Activated { + accepted_channels: usize, + latency_frames: usize, + }, + /// Parameter value was edited inside the plugin's own window. + ParamEdited { id: u32, value: f64 }, + /// Editor window was opened with given dimensions. + EditorOpened { width: u32, height: u32 }, + /// Editor window was closed by the user (e.g. WM_CLOSE / X button). + EditorClosed, + /// Serialized state blob response. + StateSaved { blob: Option }, + /// Current parameters list response. + ParamsList { params: Vec }, + /// Operation succeeded. + Ok, + /// An error occurred in the helper process. + Error { message: String }, +} diff --git a/src-tauri/src/audio/plugins/bridge/shm_audio.rs b/src-tauri/src/audio/plugins/bridge/shm_audio.rs new file mode 100644 index 00000000..98a84e16 --- /dev/null +++ b/src-tauri/src/audio/plugins/bridge/shm_audio.rs @@ -0,0 +1,369 @@ +//! Shared memory and event synchronization for real-time audio transfer. + +#![cfg(target_os = "windows")] + +use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use windows::core::{HSTRING, PCWSTR}; +use windows::Win32::Foundation::{CloseHandle, HANDLE, WAIT_OBJECT_0, WAIT_TIMEOUT}; +use windows::Win32::System::Memory::{ + CreateFileMappingW, MapViewOfFile, OpenFileMappingW, UnmapViewOfFile, FILE_MAP_ALL_ACCESS, + MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE, +}; +use windows::Win32::System::Threading::{ + CreateEventW, OpenEventW, SetEvent, WaitForSingleObject, EVENT_ALL_ACCESS, +}; + +pub const MAX_SHM_FRAMES: usize = 4096; +pub const MAX_SHM_CHANNELS: usize = 8; +pub const MAX_SHM_SAMPLES: usize = MAX_SHM_FRAMES * MAX_SHM_CHANNELS; + +const SHM_MAGIC: u32 = 0x53504C57; // 'SPLW' + +#[repr(C)] +pub struct ShmHeader { + pub magic: AtomicU32, + pub sample_rate: AtomicU32, + pub channels: AtomicU32, + pub frames_in: AtomicU32, + pub frames_out: AtomicU32, + pub latency_frames: AtomicU32, + pub host_seq: AtomicU64, + pub helper_seq: AtomicU64, + pub alive: AtomicBool, +} + +#[repr(C)] +pub struct ShmBuffer { + pub header: ShmHeader, + pub input_samples: [f32; MAX_SHM_SAMPLES], + pub output_samples: [f32; MAX_SHM_SAMPLES], +} + +pub struct ShmHost { + mapping: HANDLE, + view: *mut ShmBuffer, + host_event: HANDLE, + helper_event: HANDLE, + seq: u64, +} + +unsafe impl Send for ShmHost {} +unsafe impl Sync for ShmHost {} + +impl ShmHost { + pub fn create(session_id: &str) -> Result { + unsafe { + let map_name = HSTRING::from(format!("Local\\Splitwave_SHM_{session_id}")); + let host_ev_name = HSTRING::from(format!("Local\\Splitwave_HostEv_{session_id}")); + let helper_ev_name = HSTRING::from(format!("Local\\Splitwave_HelperEv_{session_id}")); + + let size = std::mem::size_of::() as u32; + let mapping = CreateFileMappingW( + HANDLE::default(), + None, + PAGE_READWRITE, + 0, + size, + PCWSTR(map_name.as_ptr()), + ) + .map_err(|e| format!("CreateFileMappingW failed: {e}"))?; + + let view_ptr = MapViewOfFile(mapping, FILE_MAP_ALL_ACCESS, 0, 0, size as usize); + if view_ptr.Value.is_null() { + let _ = CloseHandle(mapping); + return Err("MapViewOfFile returned null".into()); + } + let view = view_ptr.Value as *mut ShmBuffer; + + // Initialize header + let header = &mut (*view).header; + header.magic.store(SHM_MAGIC, Ordering::Release); + header.channels.store(2, Ordering::Release); + header.frames_in.store(0, Ordering::Release); + header.frames_out.store(0, Ordering::Release); + header.latency_frames.store(0, Ordering::Release); + header.host_seq.store(0, Ordering::Release); + header.helper_seq.store(0, Ordering::Release); + header.alive.store(true, Ordering::Release); + + // Auto-reset events + let host_event = CreateEventW(None, false, false, PCWSTR(host_ev_name.as_ptr())) + .map_err(|e| format!("CreateEventW host_event failed: {e}"))?; + + let helper_event = CreateEventW(None, false, false, PCWSTR(helper_ev_name.as_ptr())) + .map_err(|e| { + let _ = CloseHandle(host_event); + format!("CreateEventW helper_event failed: {e}") + })?; + + Ok(Self { + mapping, + view, + host_event, + helper_event, + seq: 0, + }) + } + } + + /// Process a block of audio through the shared memory bridge. + /// `samples` must contain interleaved audio (frames * channels). + pub fn process( + &mut self, + samples: &mut [f32], + frames: usize, + channels: usize, + ) -> Result { + let total_samples = frames * channels; + if total_samples == 0 { + return Ok(0); + } + if total_samples > MAX_SHM_SAMPLES { + return Err("block size exceeds maximum shared memory buffer capacity".into()); + } + + unsafe { + let buf = &mut *self.view; + if !buf.header.alive.load(Ordering::Acquire) { + samples.fill(0.0); + return Ok(0); + } + + self.seq += 1; + buf.header + .channels + .store(channels as u32, Ordering::Relaxed); + buf.header.frames_in.store(frames as u32, Ordering::Relaxed); + buf.header.host_seq.store(self.seq, Ordering::Release); + + // Copy input samples + let input_slice = &mut buf.input_samples[..total_samples]; + input_slice.copy_from_slice(&samples[..total_samples]); + + // Signal helper that input is ready + let _ = SetEvent(self.host_event); + + // Wait for helper to finish processing (timeout 25ms to prevent hanging RT audio thread) + let wait_res = WaitForSingleObject(self.helper_event, 25); + if wait_res == WAIT_OBJECT_0 { + let out_slice = &buf.output_samples[..total_samples]; + samples[..total_samples].copy_from_slice(out_slice); + let latency = buf.header.latency_frames.load(Ordering::Acquire) as usize; + Ok(latency) + } else { + // Timeout or error: output silence + samples.fill(0.0); + if wait_res == WAIT_TIMEOUT { + tracing::warn!("plugin bridge helper process timed out on audio block"); + } + Ok(0) + } + } + } + + pub fn mark_dead(&self) { + unsafe { + if !self.view.is_null() { + (*self.view).header.alive.store(false, Ordering::Release); + } + let _ = SetEvent(self.host_event); + } + } +} + +impl Drop for ShmHost { + fn drop(&mut self) { + self.mark_dead(); + unsafe { + if !self.view.is_null() { + let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { + Value: self.view as *mut c_void, + }); + } + if !self.host_event.is_invalid() { + let _ = CloseHandle(self.host_event); + } + if !self.helper_event.is_invalid() { + let _ = CloseHandle(self.helper_event); + } + if !self.mapping.is_invalid() { + let _ = CloseHandle(self.mapping); + } + } + } +} + +pub struct ShmHelper { + mapping: HANDLE, + view: *mut ShmBuffer, + host_event: HANDLE, + helper_event: HANDLE, +} + +unsafe impl Send for ShmHelper {} +unsafe impl Sync for ShmHelper {} + +impl ShmHelper { + pub fn open(session_id: &str) -> Result { + unsafe { + let map_name = HSTRING::from(format!("Local\\Splitwave_SHM_{session_id}")); + let host_ev_name = HSTRING::from(format!("Local\\Splitwave_HostEv_{session_id}")); + let helper_ev_name = HSTRING::from(format!("Local\\Splitwave_HelperEv_{session_id}")); + + let mapping = OpenFileMappingW(FILE_MAP_ALL_ACCESS.0, false, PCWSTR(map_name.as_ptr())) + .map_err(|e| format!("OpenFileMappingW failed: {e}"))?; + + let size = std::mem::size_of::(); + let view_ptr = MapViewOfFile(mapping, FILE_MAP_ALL_ACCESS, 0, 0, size); + if view_ptr.Value.is_null() { + let _ = CloseHandle(mapping); + return Err("MapViewOfFile returned null in helper".into()); + } + let view = view_ptr.Value as *mut ShmBuffer; + + let host_event = OpenEventW(EVENT_ALL_ACCESS, false, PCWSTR(host_ev_name.as_ptr())) + .map_err(|e| format!("OpenEventW host_event failed: {e}"))?; + + let helper_event = OpenEventW(EVENT_ALL_ACCESS, false, PCWSTR(helper_ev_name.as_ptr())) + .map_err(|e| { + let _ = CloseHandle(host_event); + format!("OpenEventW helper_event failed: {e}") + })?; + + Ok(Self { + mapping, + view, + host_event, + helper_event, + }) + } + } + + /// Wait for input from the host. + /// Returns `Ok(Some((frames, channels)))` when input is available, `Ok(None)` on timeout. + pub fn wait_for_input(&self, timeout_ms: u32) -> Result, String> { + unsafe { + let wait_res = WaitForSingleObject(self.host_event, timeout_ms); + if wait_res == WAIT_OBJECT_0 { + let buf = &*self.view; + if !buf.header.alive.load(Ordering::Acquire) { + return Ok(None); + } + let frames = buf.header.frames_in.load(Ordering::Acquire) as usize; + let channels = buf.header.channels.load(Ordering::Acquire) as usize; + Ok(Some((frames, channels))) + } else if wait_res == WAIT_TIMEOUT { + let buf = &*self.view; + if !buf.header.alive.load(Ordering::Acquire) { + return Ok(None); + } + Ok(None) + } else { + Err("WaitForSingleObject failed on host_event".into()) + } + } + } + + /// Access input samples buffer. + pub fn read_input(&self, total_samples: usize) -> &[f32] { + unsafe { + let buf = &*self.view; + &buf.input_samples[..total_samples.min(MAX_SHM_SAMPLES)] + } + } + + /// Write output samples and notify host. + pub fn write_output_and_signal(&mut self, output: &[f32], frames: usize, latency: usize) { + unsafe { + let buf = &mut *self.view; + let len = output.len().min(MAX_SHM_SAMPLES); + buf.output_samples[..len].copy_from_slice(&output[..len]); + buf.header + .frames_out + .store(frames as u32, Ordering::Release); + buf.header + .latency_frames + .store(latency as u32, Ordering::Release); + let _ = SetEvent(self.helper_event); + } + } + + pub fn is_alive(&self) -> bool { + unsafe { + if self.view.is_null() { + false + } else { + (*self.view).header.alive.load(Ordering::Acquire) + } + } + } +} + +impl Drop for ShmHelper { + fn drop(&mut self) { + unsafe { + if !self.view.is_null() { + let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { + Value: self.view as *mut c_void, + }); + } + if !self.host_event.is_invalid() { + let _ = CloseHandle(self.host_event); + } + if !self.helper_event.is_invalid() { + let _ = CloseHandle(self.helper_event); + } + if !self.mapping.is_invalid() { + let _ = CloseHandle(self.mapping); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + + #[test] + fn test_shm_audio_roundtrip() { + let session_id = format!("test_shm_{}", cuid2::create_id()); + let mut host = ShmHost::create(&session_id).expect("create host shm"); + let helper_session_id = session_id.clone(); + + let helper_handle = thread::spawn(move || { + let mut helper = ShmHelper::open(&helper_session_id).expect("open helper shm"); + for _ in 0..4 { + if let Ok(Some((frames, channels))) = helper.wait_for_input(100) { + let total = frames * channels; + let input = helper.read_input(total); + let mut processed = input.to_vec(); + // Multiply gain by 2.0 + for s in &mut processed { + *s *= 2.0; + } + helper.write_output_and_signal(&processed, frames, 16); + } + } + }); + + const FRAMES: usize = 512; + const CHANNELS: usize = 2; + for i in 0..4 { + let mut buffer = vec![0.25f32; FRAMES * CHANNELS]; + let latency = host + .process(&mut buffer, FRAMES, CHANNELS) + .expect("host process"); + assert_eq!(latency, 16, "iteration {i}: latency matches"); + for (idx, &sample) in buffer.iter().enumerate() { + assert!( + (sample - 0.5).abs() < 1e-6, + "sample at {idx} is {sample}, expected 0.5" + ); + } + } + + helper_handle.join().expect("helper thread finished"); + } +} diff --git a/src-tauri/src/audio/plugins/editor.rs b/src-tauri/src/audio/plugins/editor.rs index 99cbec07..61096f6d 100644 --- a/src-tauri/src/audio/plugins/editor.rs +++ b/src-tauri/src/audio/plugins/editor.rs @@ -3,9 +3,12 @@ //! ours, the view inside it is the plugin's, and `PluginHost::embed_editor` is //! the only seam between them. +#[cfg(not(target_os = "windows"))] use std::collections::HashMap; +#[cfg(not(target_os = "windows"))] use std::sync::{Mutex, OnceLock}; +#[cfg(not(target_os = "windows"))] use tauri::Emitter; use super::host_api::EditorSize; @@ -23,6 +26,7 @@ const TITLEBAR_LOGICAL: f64 = 32.0; /// Native host windows that plugin editors are embedded into, keyed by node id. /// `tauri::Window` is `Send + Sync`, so this lives outside any main-thread state /// and can be created/closed from the command thread. +#[cfg(not(target_os = "windows"))] fn windows() -> &'static Mutex> { static WINDOWS: OnceLock>> = OnceLock::new(); WINDOWS.get_or_init(|| Mutex::new(HashMap::new())) @@ -30,17 +34,35 @@ fn windows() -> &'static Mutex> { /// The window hosting this node's editor, if one is open. pub fn window_for(node_id: &str) -> Option { - windows().lock().unwrap().get(node_id).cloned() + #[cfg(target_os = "windows")] + { + let _ = node_id; + None + } + #[cfg(not(target_os = "windows"))] + { + windows().lock().unwrap().get(node_id).cloned() + } } /// Closes a node's editor window if one is open. Shared with the format hosts, /// which have to take the window down alongside the instance it belongs to. pub fn close_window(node_id: &str) { - if let Some(w) = windows().lock().unwrap().remove(node_id) { - let _ = w.close(); + #[cfg(target_os = "windows")] + { + if let Some(host) = super::registry::for_node(node_id) { + host.destroy_editor(node_id); + } + } + #[cfg(not(target_os = "windows"))] + { + if let Some(w) = windows().lock().unwrap().remove(node_id) { + let _ = w.close(); + } } } + /// Rejects the degenerate sizes plugins report before their view exists (0x0) /// or absurd values, so the window is never opened invisibly small or huge. pub fn valid_gui_size(w: u32, h: u32) -> Option { @@ -69,18 +91,65 @@ pub fn decoration_overhead(window: &tauri::Window) -> (f64, f64) { (dw, dh) } -/// Sizes the window so its content area (below the title bar) is `w` x `h` -/// logical px. The plugin view fills the content area, so the title bar's -/// height is added -- otherwise the bar overlaps the top of the plugin and the -/// bottom gets clipped. +/// Sizes the window so its content area is `w` x `h` logical px. pub fn set_content_size(window: &tauri::Window, w: f64, h: f64) { - let (dw, dh) = decoration_overhead(window); - let _ = window.set_size(tauri::LogicalSize::new(w + dw, h + dh)); + #[cfg(target_os = "macos")] + { + let (dw, dh) = decoration_overhead(window); + let _ = window.set_size(tauri::LogicalSize::new(w + dw, h + dh)); + } + #[cfg(target_os = "windows")] + { + let _ = window.set_size(tauri::PhysicalSize::new(w as u32, h as u32)); + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let _ = window.set_size(tauri::LogicalSize::new(w, h)); + } } /// Opens the plugin editor embedded in a native host window. The tested plugins /// only support embedded GUIs, so the host must own the window and hand its /// native handle to the plugin. +#[cfg(target_os = "windows")] +pub fn open(node_id: &str, title: &str) -> Result<(), String> { + tracing::debug!(node_id, title, "opening plugin editor via bridge"); + let (cmd_tx, reply_rx) = super::bridge::bridge_host::with_slot(node_id, |slot| { + (slot.cmd_tx.clone(), slot.reply_rx.clone()) + }) + .ok_or_else(|| format!("{node_id}: no plugin is running on this node"))?; + + cmd_tx + .send(super::bridge::protocol::HostCommand::OpenEditor { + title: if title.is_empty() { + "Plugin Editor".to_string() + } else { + title.to_string() + }, + }) + .map_err(|e| format!("failed to send OpenEditor: {e}"))?; + + let rx = reply_rx.lock().unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(3000); + while std::time::Instant::now() < deadline { + match rx.recv_timeout(std::time::Duration::from_millis(500)) { + Ok(super::bridge::protocol::HelperEvent::EditorOpened { .. }) + | Ok(super::bridge::protocol::HelperEvent::Ok) => return Ok(()), + Ok(super::bridge::protocol::HelperEvent::Error { message }) => return Err(message), + Ok(_) => continue, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + return Err("helper process disconnected".to_string()); + } + } + } + Err("timed out waiting for plugin editor to open".to_string()) +} + +/// Opens the plugin editor embedded in a native host window. The tested plugins +/// only support embedded GUIs, so the host must own the window and hand its +/// native handle to the plugin. +#[cfg(not(target_os = "windows"))] pub fn open(node_id: &str, title: &str) -> Result<(), String> { tracing::debug!(node_id, title, "opening plugin editor"); let app = crate::app_handle().ok_or("app handle not ready")?; @@ -88,61 +157,87 @@ pub fn open(node_id: &str, title: &str) -> Result<(), String> { let _ = w.set_focus(); return Ok(()); } - let window = tauri::WindowBuilder::new(app, format!("plugin-editor-{node_id}")) - .title(if title.is_empty() { "Plugin" } else { title }) - .inner_size(FALLBACK_EDITOR_SIZE.0 as f64, FALLBACK_EDITOR_SIZE.1 as f64) - // Always resizable with a small floor: even when a plugin reports a bad - // size or does not reflow, the user can enlarge the window to reveal it. - .resizable(true) - .min_inner_size(200.0, 150.0) - .build() - .map_err(|e| format!("editor window for {node_id}: {e}"))?; let nid = node_id.to_string(); - window.on_window_event(move |ev| { - // The plugin's view is a child of this window: tear the GUI down before - // the window goes away, and tell the FE node its editor button is stale. - if matches!(ev, tauri::WindowEvent::CloseRequested { .. }) { - // Already the main thread, which is where `destroy_editor` belongs. - if let Some(host) = super::registry::for_node(&nid) { - host.destroy_editor(&nid); - } - windows().lock().unwrap().remove(&nid); - if let Some(app) = crate::app_handle() { - let _ = app.emit(super::host_api::EDITOR_CLOSED_EVENT, &nid); - } - } - }); + let title = if title.is_empty() { "Plugin" } else { title }.to_string(); + let app_handle = app.clone(); + + // The host window and its embedded plugin GUI must be created and parented on + // the UI thread so their event handling and teardown belong to the same thread. + let (window, size) = + super::main_thread::run(move || -> Result<(tauri::Window, EditorSize), String> { + let window = tauri::WindowBuilder::new(&app_handle, format!("plugin-editor-{nid}")) + .title(&title) + .inner_size(FALLBACK_EDITOR_SIZE.0 as f64, FALLBACK_EDITOR_SIZE.1 as f64) + // Always resizable with a small floor: even when a plugin reports a bad + // size or does not reflow, the user can enlarge the window to reveal it. + .resizable(true) + .min_inner_size(200.0, 150.0) + .build() + .map_err(|e| format!("editor window for {nid}: {e}"))?; + + let event_nid = nid.clone(); + window.on_window_event(move |ev| { + // The plugin's view is a child of this window: tear the GUI down before + // the window goes away, and tell the FE node its editor button is stale. + if let tauri::WindowEvent::CloseRequested { api, .. } = ev { + api.prevent_close(); + if let Some(host) = super::registry::for_node(&event_nid) { + host.destroy_editor(&event_nid); + } + if let Some(w) = windows().lock().unwrap().remove(&event_nid) { + let _ = w.destroy(); + } + if let Some(app) = crate::app_handle() { + let _ = app.emit(super::host_api::EDITOR_CLOSED_EVENT, &event_nid); + } + } + }); + + let embedded = match super::registry::for_node(&nid) { + Some(host) => host.embed_editor(&nid, &window), + None => Err(format!("{nid}: no plugin is running on this node")), + }; + + // The window exists before the plugin view does, so a failed embed would + // otherwise leave an empty one on screen and the caller none the wiser. + let size = match embedded { + Ok(size) => size, + Err(e) => { + tracing::error!(nid, error = %e, "plugin editor embed failed"); + let _ = window.close(); + return Err(e); + } + }; + + let (width, height) = valid_gui_size(size.0, size.1).unwrap_or(FALLBACK_EDITOR_SIZE); + set_content_size(&window, width as f64, height as f64); + + Ok((window, size)) + })??; + windows() .lock() .unwrap() .insert(node_id.to_string(), window.clone()); - let embedded = match super::registry::for_node(node_id) { - Some(host) => host.embed_editor(node_id, &window), - None => Err(format!("{node_id}: no plugin is running on this node")), - }; - - // The window exists before the plugin view does, so a failed embed would - // otherwise leave an empty one on screen and the caller none the wiser. - let size = match embedded { - Ok(size) => size, - Err(e) => { - tracing::error!(node_id, error = %e, "plugin editor embed failed"); - close_window(node_id); - return Err(e); - } - }; - // Sanitised once, here, rather than by each host: a size is a size whoever - // reported it. let (width, height) = valid_gui_size(size.0, size.1).unwrap_or(FALLBACK_EDITOR_SIZE); tracing::debug!(node_id, width, height, "plugin editor embedded"); - set_content_size(&window, width as f64, height as f64); Ok(()) } /// Tears down the plugin editor and closes its native window. +#[cfg(target_os = "windows")] +pub fn close(node_id: &str) -> Result<(), String> { + if let Some(host) = super::registry::for_node(node_id) { + host.destroy_editor(node_id); + } + Ok(()) +} + +/// Tears down the plugin editor and closes its native window. +#[cfg(not(target_os = "windows"))] pub fn close(node_id: &str) -> Result<(), String> { // The plugin's view is a child of this window, so it goes first -- and it // goes on the main thread, which is the one place AppKit and every format diff --git a/src-tauri/src/audio/plugins/host_api.rs b/src-tauri/src/audio/plugins/host_api.rs index 57904d23..0de66676 100644 --- a/src-tauri/src/audio/plugins/host_api.rs +++ b/src-tauri/src/audio/plugins/host_api.rs @@ -25,7 +25,7 @@ use crate::audio::pipeline::dag::DSP_BLOCK_FRAMES; pub const EDITOR_CLOSED_EVENT: &str = "plugin://editor-closed"; /// One automatable plugin parameter, sent to the frontend for the node UI. -#[derive(serde::Serialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginParamInfo { pub id: u32, @@ -86,6 +86,8 @@ pub enum HostedNode { #[cfg(target_os = "macos")] Au(super::AuNode), Vst3(super::vst3_node::Vst3Node), + #[cfg(target_os = "windows")] + Bridge(super::bridge::BridgeNode), } impl HostedNode { @@ -97,6 +99,8 @@ impl HostedNode { #[cfg(target_os = "macos")] HostedNode::Au(n) => n.channels(), HostedNode::Vst3(n) => n.channels(), + #[cfg(target_os = "windows")] + HostedNode::Bridge(n) => n.channels(), } } } @@ -109,6 +113,8 @@ impl Effect for HostedNode { #[cfg(target_os = "macos")] HostedNode::Au(n) => n.process(samples, frames), HostedNode::Vst3(n) => n.process(samples, frames), + #[cfg(target_os = "windows")] + HostedNode::Bridge(n) => n.process(samples, frames), } } @@ -119,6 +125,8 @@ impl Effect for HostedNode { #[cfg(target_os = "macos")] HostedNode::Au(n) => n.latency_frames(), HostedNode::Vst3(n) => n.latency_frames(), + #[cfg(target_os = "windows")] + HostedNode::Bridge(n) => n.latency_frames(), } } } diff --git a/src-tauri/src/audio/plugins/main_thread.rs b/src-tauri/src/audio/plugins/main_thread.rs index 09f98c4a..931dc146 100644 --- a/src-tauri/src/audio/plugins/main_thread.rs +++ b/src-tauri/src/audio/plugins/main_thread.rs @@ -5,17 +5,32 @@ //! on the mechanism, so it lives here rather than three times over. use std::sync::mpsc; -use std::sync::Once; +use std::sync::{Once, OnceLock}; +use std::thread::ThreadId; use std::time::Duration; +static MAIN_THREAD_ID: OnceLock = OnceLock::new(); + +/// Records the current thread as the main UI thread. +pub fn register_main_thread() { + let _ = MAIN_THREAD_ID.set(std::thread::current().id()); +} + +/// Returns true if the calling thread is the main UI thread. +pub fn is_main_thread() -> bool { + MAIN_THREAD_ID.get().copied() == Some(std::thread::current().id()) +} + /// How long a main-thread call may take before the caller gives up. A plugin /// that blocks the UI thread longer than this has already broken the app; the /// timeout keeps the calling thread from hanging with it. const MAIN_THREAD_TIMEOUT: Duration = Duration::from_secs(5); -/// Runs `f` on the Tauri main thread and blocks for its result. Callers must -/// not be the main thread themselves, or this deadlocks. +/// Runs `f` on the Tauri main thread and blocks for its result. pub fn run(f: impl FnOnce() -> R + Send + 'static) -> Result { + if is_main_thread() { + return Ok(f()); + } let app = crate::app_handle().ok_or_else(|| "app handle not ready".to_string())?; let (tx, rx) = mpsc::channel(); app.run_on_main_thread(move || { diff --git a/src-tauri/src/audio/plugins/mod.rs b/src-tauri/src/audio/plugins/mod.rs index 1003132f..74c87966 100644 --- a/src-tauri/src/audio/plugins/mod.rs +++ b/src-tauri/src/audio/plugins/mod.rs @@ -6,6 +6,8 @@ mod au_backend; #[cfg(target_os = "macos")] pub mod au_host; +#[cfg(target_os = "windows")] +pub mod bridge; mod clap_backend; pub mod clap_host; pub mod clap_registry; diff --git a/src-tauri/src/audio/plugins/registry.rs b/src-tauri/src/audio/plugins/registry.rs index c8c64aa4..ed624e73 100644 --- a/src-tauri/src/audio/plugins/registry.rs +++ b/src-tauri/src/audio/plugins/registry.rs @@ -15,9 +15,13 @@ use std::sync::{Mutex, OnceLock}; use super::au_host::AuHost; use super::clap_registry::ClapHost; use super::host_api::{ActivateRequest, HostedNode, PluginHost}; +#[cfg(not(target_os = "windows"))] use super::vst3_registry::Vst3Host; use super::PluginFormat; +#[cfg(target_os = "windows")] +static BRIDGE_HOST: super::bridge::BridgeHost = super::bridge::BridgeHost; + fn host_for(format: PluginFormat) -> &'static dyn PluginHost { match format { PluginFormat::Clap => &ClapHost, @@ -25,6 +29,9 @@ fn host_for(format: PluginFormat) -> &'static dyn PluginHost { PluginFormat::Au => &AuHost, #[cfg(not(target_os = "macos"))] PluginFormat::Au => panic!("Audio Unit plugins are only supported on macOS"), + #[cfg(target_os = "windows")] + PluginFormat::Vst3 => &BRIDGE_HOST, + #[cfg(not(target_os = "windows"))] PluginFormat::Vst3 => &Vst3Host, } } @@ -34,15 +41,16 @@ fn owners() -> &'static Mutex> { OWNERS.get_or_init(|| Mutex::new(HashMap::new())) } +#[cfg(target_os = "macos")] +static ALL_HOSTS: &[&'static dyn PluginHost] = &[&ClapHost, &AuHost, &Vst3Host]; +#[cfg(target_os = "windows")] +static ALL_HOSTS: &[&'static dyn PluginHost] = &[&ClapHost, &BRIDGE_HOST]; +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +static ALL_HOSTS: &[&'static dyn PluginHost] = &[&ClapHost, &Vst3Host]; + /// Every registered host, for work that is not tied to one node. pub fn hosts() -> impl Iterator { - [ - &ClapHost as &'static dyn PluginHost, - #[cfg(target_os = "macos")] - &AuHost, - &Vst3Host, - ] - .into_iter() + ALL_HOSTS.iter().copied() } /// The host currently running this node, or `None` if it runs no plugin. diff --git a/src-tauri/src/audio/plugins/vst3_editor.rs b/src-tauri/src/audio/plugins/vst3_editor.rs index f2b6614b..ae02f3b4 100644 --- a/src-tauri/src/audio/plugins/vst3_editor.rs +++ b/src-tauri/src/audio/plugins/vst3_editor.rs @@ -42,6 +42,12 @@ pub struct PlugFrame { resize: ResizeRequest, } +impl PlugFrame { + pub fn new(resize: ResizeRequest) -> Self { + Self { resize } + } +} + /// On X11 the plugin drives its editor from the host's event loop, so the frame /// it is given must also answer as `IRunLoop`. #[cfg(target_os = "linux")] @@ -115,6 +121,17 @@ pub struct EditorView { } impl EditorView { + pub fn from_raw_parts(view: ComPtr, frame: ComWrapper) -> Self { + Self { + view, + _frame: frame, + } + } + + pub fn view(&self) -> &ComPtr { + &self.view + } + /// Builds the plugin's view into `parent` -- an `NSView` on macOS, an `HWND` /// on Windows, an X11 window id on Linux -- and returns the size the plugin /// asked for. `None` when the plugin has no editor at all, which is not an @@ -143,15 +160,6 @@ impl EditorView { )); } - let frame = ComWrapper::new(PlugFrame { resize }); - let frame_ptr = frame - .as_com_ref::() - .map(|r| r.as_ptr()) - .ok_or("PlugFrame implements IPlugFrame")?; - // Before `attached`, so a plugin that resizes on open has somewhere - // to send the request. - view.setFrame(frame_ptr); - let mut rect = ViewRect { left: 0, top: 0, @@ -162,11 +170,22 @@ impl EditorView { return Err("editor reported no size".into()); } + let frame = ComWrapper::new(PlugFrame::new(resize)); + let frame_ptr = frame + .as_com_ref::() + .map(|r| r.as_ptr()) + .ok_or("PlugFrame implements IPlugFrame")?; + // Before `attached`, so a plugin that resizes on open has somewhere + // to send the request. + view.setFrame(frame_ptr); + if view.attached(parent, platform) != kResultOk { - view.setFrame(std::ptr::null_mut()); return Err("editor refused to attach to the window".into()); } + // Inform the view of its initial size so it initializes layout. + let _ = view.onSize(&mut rect); + #[cfg(target_os = "macos")] inset_below_titlebar(parent, titlebar); #[cfg(not(target_os = "macos"))] @@ -189,11 +208,9 @@ impl EditorView { impl Drop for EditorView { fn drop(&mut self) { - // Order matters: the plugin must let go of the parent view before the - // window closes, and of our frame before it is freed. unsafe { - self.view.removed(); - self.view.setFrame(std::ptr::null_mut()); + let _ = self.view.removed(); + let _ = self.view.setFrame(std::ptr::null_mut()); } } } diff --git a/src-tauri/src/audio/plugins/vst3_host.rs b/src-tauri/src/audio/plugins/vst3_host.rs index bc3f3a70..a0022867 100644 --- a/src-tauri/src/audio/plugins/vst3_host.rs +++ b/src-tauri/src/audio/plugins/vst3_host.rs @@ -29,6 +29,9 @@ pub struct Vst3Instance { separate: bool, /// Kept alive for the plugin, which holds only a borrowed reference to it. handler: Option>>>, + /// Cached editor view so has_editor doesn't create and immediately destroy it, + /// which would corrupt internal static state in plugins (such as JUCE LookAndFeel). + cached_view: Option>, /// The factory that made these lives in the module, so it outlives them. _module: Vst3Module, } @@ -118,6 +121,7 @@ impl Vst3Instance { controller, separate, handler: None, + cached_view: None, _module: module, }) } @@ -232,14 +236,32 @@ impl Vst3Instance { /// Whether the plugin has an editor at all. Asked before offering the /// button, so the node can say "no editor" instead of opening a blank - /// window. - pub fn has_editor(&self) -> bool { + /// window. Caches the view so it is not created and immediately destroyed. + pub fn has_editor(&mut self) -> bool { use vst3::Steinberg::Vst::ViewType::kEditor; - // SAFETY: the view is created only to be counted and immediately - // released; it is never attached. + if self.cached_view.is_some() { + return true; + } + unsafe { + if let Some(view) = + ComPtr::::from_raw(self.controller.createView(kEditor)) + { + self.cached_view = Some(view); + true + } else { + false + } + } + } + + /// Takes the cached editor view or creates a new one if not cached. + pub fn take_view(&mut self) -> Option> { + use vst3::Steinberg::Vst::ViewType::kEditor; + if let Some(view) = self.cached_view.take() { + return Some(view); + } unsafe { ComPtr::::from_raw(self.controller.createView(kEditor)) - .is_some() } } diff --git a/src-tauri/src/audio/plugins/vst3_registry.rs b/src-tauri/src/audio/plugins/vst3_registry.rs index 94bd4323..f43fbb9c 100644 --- a/src-tauri/src/audio/plugins/vst3_registry.rs +++ b/src-tauri/src/audio/plugins/vst3_registry.rs @@ -158,6 +158,7 @@ pub struct Vst3Host; impl PluginHost for Vst3Host { fn activate(&self, req: ActivateRequest<'_>) -> Result { + main_thread::ensure_ticker(); let (node_id, path, plugin_id) = ( req.node_id.to_string(), req.path.to_string(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c270e5d7..48d20a2f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -30,6 +30,18 @@ pub fn run_windows_vb_cable_helper() -> Option { audio::virtual_device::windows_cable::run_helper() } +#[cfg(target_os = "windows")] +pub fn run_windows_plugin_bridge_helper() -> Option { + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + if arg == "--plugin-bridge" { + let session_id = args.next().unwrap_or_default(); + return Some(audio::plugins::bridge::helper_main::run_helper(&session_id)); + } + } + None +} + pub fn app_handle() -> Option<&'static AppHandle> { APP_HANDLE.get() } @@ -182,6 +194,7 @@ pub fn run() { tauri::Builder::default() .setup(|app| { info!("app started"); + crate::audio::plugins::main_thread::register_main_thread(); let handle = app.handle().clone(); let _ = APP_HANDLE.set(handle.clone()); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a796d7d1..f61d356e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -12,5 +12,10 @@ fn main() { if let Some(exit_code) = splitwave_lib::run_windows_vb_cable_helper() { std::process::exit(exit_code); } + + #[cfg(target_os = "windows")] + if let Some(exit_code) = splitwave_lib::run_windows_plugin_bridge_helper() { + std::process::exit(exit_code); + } splitwave_lib::run() }