This commit is contained in:
Brandon Thomas committed 2026-10-13 06:08:18 -04:00
commit 5323c8ef25
11 files changed
+511 -22

No files matched your search

Generated
+2
View File
@@ -3482,6 +3482,7 @@ dependencies = [
"serde",
"soundcraft-dsp",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
[[package]]
@@ -3640,6 +3641,7 @@ dependencies = [
"soundcraft-dsp",
"thiserror 2.0.21",
"vst3",
"windows-sys 0.61.2",
]
[[package]]
+1
View File
@@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track
## Progress log
- **2026-10-12 (plugin editors on Windows):** VST3 editors and embedded CLAP editors open in a SoundCraft host window on Windows (owned by the app window, resized on request, closed from its title bar); verified with a JUCE VST3 compressor and a CLAP effect. No readiness percentage change.
- **2026-10-12:** `soundcraft-cli convert` rejects an unparsable or zero `--rate` and an unknown `--bit-depth` (exit 1, nothing written) instead of converting with a default (#256)
- **2026-10-12:** Timecode labels with huge fields no longer overflow: frame counts are computed in 128 bits and clamp, so `setup.current_timecode` and `session.format_time` saturate instead of failing (#248)
- **2026-10-12:** `session.format_time` with the smallest bar number no longer fails with an internal error: the bar offset saturates (#249)
+4
View File
@@ -13,6 +13,10 @@ serde.workspace = true
thiserror.workspace = true
log.workspace = true
# The Windows host window for embedded editors (a top-level HWND the plugin parents its GUI in).
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] }
[[test]]
name = "editor_main_thread"
harness = false
+308 -8
View File
@@ -20,7 +20,7 @@ use clap_sys::events::{
clap_event_transport, clap_input_events, clap_output_events,
};
use clap_sys::ext::audio_ports::{CLAP_AUDIO_PORT_IS_MAIN, CLAP_EXT_AUDIO_PORTS, clap_audio_port_info, clap_plugin_audio_ports};
use clap_sys::ext::gui::{CLAP_EXT_GUI, clap_host_gui, clap_plugin_gui};
use clap_sys::ext::gui::{CLAP_EXT_GUI, clap_host_gui, clap_plugin_gui, clap_window, clap_window_handle};
use clap_sys::ext::latency::{CLAP_EXT_LATENCY, clap_host_latency, clap_plugin_latency};
use clap_sys::ext::log::{CLAP_EXT_LOG, CLAP_LOG_ERROR, CLAP_LOG_FATAL, CLAP_LOG_WARNING, clap_host_log, clap_log_severity};
use clap_sys::ext::note_ports::{CLAP_EXT_NOTE_PORTS, clap_note_port_info, clap_plugin_note_ports};
@@ -40,7 +40,7 @@ use soundcraft_dsp::Transport;
use std::collections::HashMap;
use std::ffi::{CStr, CString, c_char, c_void};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
/// Most plugins one factory may report (hostile-input cap).
@@ -279,7 +279,7 @@ impl Bundle {
inst.state_ext = inst.extension::<clap_plugin_state>(CLAP_EXT_STATE);
let gui = inst.extension::<clap_plugin_gui>(CLAP_EXT_GUI);
inst.gui = Arc::new(GuiLink {
inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false }),
inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false, window: None }),
host: Arc::clone(&inst.state),
});
Ok(inst)
@@ -314,6 +314,8 @@ pub(crate) struct HostState {
pub gui_destroyed: AtomicBool,
pub gui_show: AtomicBool,
pub gui_hide: AtomicBool,
/// A size an embedded editor asked for (`width << 32 | height`; 0 = none pending).
pub gui_resize: AtomicU64,
/// Parameter changes the plugin reported (output events: typically edits in its GUI),
/// waiting for the UI. Bounded by `EDIT_CAP`; the audio thread only `try_lock`s it.
pub edits: Mutex<Vec<(u32, f64)>>,
@@ -332,6 +334,7 @@ impl Default for HostState {
gui_destroyed: AtomicBool::new(false),
gui_show: AtomicBool::new(false),
gui_hide: AtomicBool::new(false),
gui_resize: AtomicU64::new(0),
edits: Mutex::new(Vec::with_capacity(EDIT_CAP)),
}
}
@@ -412,8 +415,16 @@ unsafe extern "C" fn host_state_mark_dirty(host: *const clap_host) {
unsafe extern "C" fn host_gui_resize_hints_changed(_host: *const clap_host) {}
unsafe extern "C" fn host_gui_request_resize(_host: *const clap_host, _width: u32, _height: u32) -> bool {
// Floating windows belong to the plugin, which resizes them itself.
unsafe extern "C" fn host_gui_request_resize(host: *const clap_host, width: u32, height: u32) -> bool {
// Floating windows belong to the plugin, which resizes them itself; an embedded editor's host
// window is resized by the editor's `idle` on the main thread.
// SAFETY: the plugin passes back the host pointer we gave it.
if let Some(s) = unsafe { state(host) }
&& width > 0
&& height > 0
{
s.gui_resize.store((u64::from(width) << 32) | u64::from(height), Ordering::Relaxed);
}
true
}
@@ -739,6 +750,8 @@ struct GuiInner {
alive: bool,
created: bool,
visible: bool,
/// The host window of an embedded editor (`None` for floating editors).
window: Option<hostwin::HostWindow>,
}
/// The link between an instance and its editor and state handles. The instance may process on
@@ -762,6 +775,7 @@ impl Default for GuiLink {
alive: false,
created: false,
visible: false,
window: None,
}),
host: Arc::new(HostState::default()),
}
@@ -803,6 +817,9 @@ impl GuiLink {
// SAFETY: live plugin with a created GUI; main thread (checked).
unsafe { show(g.plugin) };
}
if let Some(w) = &g.window {
w.raise();
}
g.visible = true;
return Ok(());
}
@@ -810,7 +827,11 @@ impl GuiLink {
let (Some(supported), Some(create)) = (vt.is_api_supported, vt.create) else { return Err("incomplete clap.gui extension".into()) };
// SAFETY: live plugin; `api` is a static NUL-terminated string; main thread.
if !unsafe { supported(g.plugin, api.as_ptr(), true) } {
return Err("this plugin offers only an embedded editor; SoundCraft hosts floating CLAP editors".into());
// SAFETY: as above.
if hostwin::SUPPORTED && unsafe { supported(g.plugin, api.as_ptr(), false) } {
return self.open_embedded(&mut g, vt, api, title);
}
return Err("this plugin offers only an embedded editor, which SoundCraft hosts on Windows only".into());
}
self.host.gui_closed.store(false, Ordering::Relaxed);
self.host.gui_destroyed.store(false, Ordering::Relaxed);
@@ -842,6 +863,61 @@ impl GuiLink {
Ok(())
}
/// Embeds the editor in a host window of ours (plugins that offer no floating editor).
fn open_embedded(&self, g: &mut GuiInner, vt: clap_plugin_gui, api: &CStr, title: &str) -> Result<(), String> {
let (Some(create), Some(set_parent)) = (vt.create, vt.set_parent) else { return Err("incomplete clap.gui extension".into()) };
self.host.gui_closed.store(false, Ordering::Relaxed);
self.host.gui_destroyed.store(false, Ordering::Relaxed);
self.host.gui_resize.store(0, Ordering::Relaxed);
// SAFETY: live plugin; `api` is a static NUL-terminated string; main thread; no GUI yet.
if !unsafe { create(g.plugin, api.as_ptr(), false) } {
return Err("the plugin could not create its editor".into());
}
g.created = true;
if let Some(f) = vt.set_scale {
// SAFETY: live plugin with a created GUI.
unsafe { f(g.plugin, 1.0) };
}
let (mut w, mut h) = (640u32, 480u32);
if let Some(f) = vt.get_size {
let (mut a, mut b) = (0u32, 0u32);
// SAFETY: live plugin with a created GUI; both out-pointers are valid locals.
if unsafe { f(g.plugin, &mut a, &mut b) } && a > 0 && b > 0 {
(w, h) = (a.min(16_384), b.min(16_384));
}
}
let win = match hostwin::HostWindow::new(title, f64::from(w), f64::from(h)) {
Ok(win) => win,
Err(e) => {
GuiLink::destroy(g);
return Err(e);
}
};
let parent = clap_window { api: api.as_ptr(), specific: clap_window_handle { win32: win.parent() } };
// SAFETY: live plugin with a created embedded GUI; `parent` names our live window and
// outlives the call.
if !unsafe { set_parent(g.plugin, &parent) } {
win.close();
GuiLink::destroy(g);
return Err("the editor could not attach to its window".into());
}
g.window = Some(win);
let shown = match vt.show {
// SAFETY: live plugin with a created, parented GUI.
Some(f) => unsafe { f(g.plugin) },
None => true,
};
if !shown {
GuiLink::destroy(g);
return Err("the plugin could not show its editor".into());
}
if let Some(win) = &g.window {
win.raise();
}
g.visible = true;
Ok(())
}
fn destroy(g: &mut GuiInner) {
if g.created
&& let Some(f) = GuiLink::vt(g).and_then(|v| v.destroy)
@@ -851,6 +927,10 @@ impl GuiLink {
}
g.created = false;
g.visible = false;
// The plugin's child window is gone; now its host window can go too.
if let Some(w) = g.window.take() {
w.close();
}
}
/// `clap_plugin_state.save` into memory (`None` without the extension, on failure, or once the
@@ -879,7 +959,7 @@ impl GuiLink {
pub fn is_open(&self) -> bool {
let g = self.lock();
g.alive && g.created && g.visible
g.alive && g.created && g.visible && g.window.as_ref().is_none_or(hostwin::HostWindow::is_visible)
}
/// Main-thread housekeeping: runs a requested `on_main_thread`, follows the plugin's
@@ -900,7 +980,21 @@ impl GuiLink {
}
}
if g.created {
if self.host.gui_closed.swap(false, Ordering::Relaxed) {
let resize = self.host.gui_resize.swap(0, Ordering::Relaxed);
if resize != 0
&& let Some(win) = &g.window
{
let (w, h) = ((resize >> 32) as u32, resize as u32);
hostwin::resize(win.raw(), f64::from(w), f64::from(h));
if let Some(f) = GuiLink::vt(&g).and_then(|v| v.set_size) {
// SAFETY: live plugin with a created embedded GUI, main thread.
unsafe { f(g.plugin, w, h) };
}
}
if g.window.as_ref().is_some_and(|w| !w.is_visible()) {
// The user closed the embedded editor's host window: free the editor.
GuiLink::destroy(&mut g);
} else if self.host.gui_closed.swap(false, Ordering::Relaxed) {
// The user closed the floating window (or the plugin lost its GUI): free it.
self.host.gui_destroyed.store(false, Ordering::Relaxed);
GuiLink::destroy(&mut g);
@@ -936,6 +1030,8 @@ impl GuiLink {
} else {
log::warn!("clap: editor still open while its plugin is destroyed off the main thread");
g.created = false;
// Window calls are main-thread only: leave the host window rather than crash.
g.window = None;
}
}
g.alive = false;
@@ -1326,6 +1422,210 @@ impl Drop for Instance {
}
}
// ---- host window for embedded editors ------------------------------------------------------
/// The Windows host window for embedded editors: a top-level window owned by the app's active
/// window (no taskbar button) whose client area is the plugin's parent `HWND`. Closing it only
/// hides it; the editor link notices in `idle`, destroys the plugin's GUI, then the window.
/// Messages are dispatched by the app's own event loop on the main thread.
#[cfg(windows)]
mod hostwin {
use std::ffi::c_void;
use std::sync::OnceLock;
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM};
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetActiveWindow;
use windows_sys::Win32::UI::WindowsAndMessaging::{
AdjustWindowRectEx, CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DestroyWindow, GWL_EXSTYLE, GWL_STYLE,
GetWindowLongPtrW, IDC_ARROW, IsWindow, IsWindowVisible, LoadCursorW, RegisterClassExW, SW_HIDE, SW_SHOW, SWP_NOACTIVATE, SWP_NOMOVE,
SWP_NOZORDER, SetForegroundWindow, SetWindowPos, ShowWindow, WM_CLOSE, WNDCLASSEXW, WS_CAPTION, WS_CLIPCHILDREN, WS_OVERLAPPED, WS_SYSMENU,
};
pub const SUPPORTED: bool = true;
/// Rust names the process's first thread "main"; the app runs its UI (and message loop) there.
fn on_main_thread() -> bool {
std::thread::current().name() == Some("main")
}
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().filter(|&c| c != 0).chain(Some(0)).collect()
}
const STYLE: u32 = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN;
unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
if msg == WM_CLOSE {
// SAFETY: `hwnd` is the window this procedure was called for.
unsafe { ShowWindow(hwnd, SW_HIDE) };
return 0;
}
// SAFETY: default handling of a message for the window it was sent to.
unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }
}
/// Registers the window class once; the name lives for the whole process.
fn class_name() -> Option<&'static [u16]> {
static CLASS: OnceLock<Option<Vec<u16>>> = OnceLock::new();
CLASS
.get_or_init(|| {
let name = wide("SoundCraftClapEditor");
// SAFETY: null asks for this process's module handle.
let instance = unsafe { GetModuleHandleW(std::ptr::null()) };
let wc = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
style: CS_HREDRAW | CS_VREDRAW,
lpfnWndProc: Some(wndproc),
cbClsExtra: 0,
cbWndExtra: 0,
hInstance: instance,
hIcon: std::ptr::null_mut(),
// SAFETY: a system cursor (null instance + predefined id).
hCursor: unsafe { LoadCursorW(std::ptr::null_mut(), IDC_ARROW) },
hbrBackground: std::ptr::null_mut(),
lpszMenuName: std::ptr::null(),
lpszClassName: name.as_ptr(),
hIconSm: std::ptr::null_mut(),
};
// SAFETY: a fully initialised class description whose strings outlive the call
// (the name is kept for the process lifetime in this `OnceLock`).
(unsafe { RegisterClassExW(&wc) } != 0).then_some(name)
})
.as_deref()
}
/// Outer window size for a client area of `w`×`h`.
fn outer_size(style: u32, ex_style: u32, w: f64, h: f64) -> (i32, i32) {
let mut r = RECT { left: 0, top: 0, right: w as i32, bottom: h as i32 };
// SAFETY: `r` is a valid, writable rect.
unsafe { AdjustWindowRectEx(&mut r, style, 0, ex_style) };
(r.right.saturating_sub(r.left), r.bottom.saturating_sub(r.top))
}
/// Owns the window handle; destroyed in `close` (main thread only).
pub struct HostWindow {
hwnd: HWND,
}
// SAFETY: the handle is only used on the main thread (every method checks); the struct
// merely carries it between threads' locks.
unsafe impl Send for HostWindow {}
impl HostWindow {
fn live(&self) -> Option<HWND> {
// SAFETY: `IsWindow` accepts any value and only reports whether it names a window.
(on_main_thread() && unsafe { IsWindow(self.hwnd) } != 0).then_some(self.hwnd)
}
pub fn new(title: &str, w: f64, h: f64) -> Result<HostWindow, String> {
if !on_main_thread() {
return Err("plugin editors must be opened on the main thread".into());
}
let class = class_name().ok_or_else(|| "cannot register the editor window class".to_string())?;
let title = wide(title);
let (ow, oh) = outer_size(STYLE, 0, w, h);
// SAFETY: main thread; the active window (or null) of this thread's queue.
let owner = unsafe { GetActiveWindow() };
// SAFETY: registered class and NUL-terminated strings that outlive the call; the
// owner is null or a window of this thread.
let hwnd = unsafe {
CreateWindowExW(
0,
class.as_ptr(),
title.as_ptr(),
STYLE,
CW_USEDEFAULT,
CW_USEDEFAULT,
ow,
oh,
owner,
std::ptr::null_mut(),
GetModuleHandleW(std::ptr::null()),
std::ptr::null(),
)
};
if hwnd.is_null() {
return Err("cannot create the editor window".into());
}
Ok(HostWindow { hwnd })
}
/// The window handle (for resizes).
pub fn raw(&self) -> *mut c_void {
self.hwnd
}
/// The `HWND` the plugin creates its child window in.
pub fn parent(&self) -> *mut c_void {
self.live().unwrap_or(std::ptr::null_mut())
}
pub fn raise(&self) {
if let Some(h) = self.live() {
// SAFETY: a live window of this thread.
unsafe {
ShowWindow(h, SW_SHOW);
SetForegroundWindow(h);
}
}
}
pub fn is_visible(&self) -> bool {
// SAFETY: a live window of this thread.
self.live().is_some_and(|h| unsafe { IsWindowVisible(h) } != 0)
}
pub fn close(self) {
if let Some(h) = self.live() {
// SAFETY: our own live window; the plugin's GUI was destroyed first.
unsafe { DestroyWindow(h) };
}
}
}
/// Resizes a host window's client area (`window` from [`HostWindow::raw`]); main thread only.
pub fn resize(window: *mut c_void, w: f64, h: f64) {
// SAFETY: `IsWindow` accepts any value.
if !on_main_thread() || window.is_null() || unsafe { IsWindow(window) } == 0 {
return;
}
// SAFETY: a live window of this thread; style values are read, not changed.
let (style, ex) = unsafe { (GetWindowLongPtrW(window, GWL_STYLE) as u32, GetWindowLongPtrW(window, GWL_EXSTYLE) as u32) };
let (ow, oh) = outer_size(style, ex, w, h);
// SAFETY: as above; only the size changes.
unsafe { SetWindowPos(window, std::ptr::null_mut(), 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE) };
}
}
/// Other platforms: embedded editors are not hosted (floating ones still are).
#[cfg(not(windows))]
mod hostwin {
use std::ffi::c_void;
pub const SUPPORTED: bool = false;
pub struct HostWindow;
impl HostWindow {
pub fn new(_title: &str, _w: f64, _h: f64) -> Result<HostWindow, String> {
Err("embedded plugin editors are not hosted on this platform".into())
}
pub fn raw(&self) -> *mut c_void {
std::ptr::null_mut()
}
pub fn parent(&self) -> *mut c_void {
std::ptr::null_mut()
}
pub fn raise(&self) {}
pub fn is_visible(&self) -> bool {
false
}
pub fn close(self) {}
}
pub fn resize(_window: *mut c_void, _w: f64, _h: f64) {}
}
#[cfg(test)]
mod tests {
use super::*;
+4 -2
View File
@@ -20,8 +20,10 @@
//! Plugin state (`clap.state`) round-trips through [`Plugin::save_state`] /
//! [`Plugin::load_state`] as an opaque blob. Plugin editors (`clap.gui`) open as the plugin's own
//! floating window ([`Plugin::open_editor`], or [`Plugin::editor`] for a handle usable on the main
//! thread while the instance processes on the audio thread); plugins that offer only embedded
//! editors are not hosted yet, and SoundCraft's generic parameter editor still works for them.
//! thread while the instance processes on the audio thread). Plugins that offer only an embedded
//! editor get a host window of SoundCraft's on Windows (resized on `request_resize`, closed by
//! its title bar); elsewhere they are not hosted yet, and SoundCraft's generic parameter editor
//! still works for them.
//! Parameter changes made in the plugin's GUI arrive as output events while the plugin processes
//! and are reported by the editor's `idle`.
+4
View File
@@ -23,6 +23,10 @@ objc2 = "0.6"
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry", "NSString"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSView", "NSWindow", "NSGraphics"] }
# The Windows editor host window (a top-level HWND the plugin view's child window lives in).
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] }
[[test]]
name = "editor_main_thread"
harness = false
+174 -1
View File
@@ -1928,8 +1928,181 @@ mod window {
}
}
/// The Windows host window: a top-level window owned by the app's active window (so it stays
/// above it and has no taskbar button) whose client area is the plugin view's `HWND` parent.
/// Closing it only hides it; the editor link notices in `idle`, detaches the view, then destroys
/// the window. Messages are dispatched by the app's own event loop on the main thread.
#[cfg(windows)]
mod window {
use std::ffi::{c_char, c_void};
use std::sync::OnceLock;
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM};
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetActiveWindow;
use windows_sys::Win32::UI::WindowsAndMessaging::{
AdjustWindowRectEx, CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DestroyWindow, GWL_EXSTYLE, GWL_STYLE,
GetWindowLongPtrW, IDC_ARROW, IsWindow, IsWindowVisible, LoadCursorW, RegisterClassExW, SW_HIDE, SW_SHOW, SWP_NOACTIVATE, SWP_NOMOVE,
SWP_NOZORDER, SetForegroundWindow, SetWindowPos, ShowWindow, WM_CLOSE, WNDCLASSEXW, WS_CAPTION, WS_CLIPCHILDREN, WS_OVERLAPPED, WS_SYSMENU,
};
pub const PLATFORM_TYPE: Option<*const c_char> = Some(vst3::Steinberg::kPlatformTypeHWND);
/// Rust names the process's first thread "main"; the app runs its UI (and message loop) there.
pub fn on_main_thread() -> bool {
std::thread::current().name() == Some("main")
}
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().filter(|&c| c != 0).chain(Some(0)).collect()
}
const STYLE: u32 = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN;
unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
if msg == WM_CLOSE {
// SAFETY: `hwnd` is the window this procedure was called for.
unsafe { ShowWindow(hwnd, SW_HIDE) };
return 0;
}
// SAFETY: default handling of a message for the window it was sent to.
unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }
}
/// Registers the window class once; the name lives for the whole process.
fn class_name() -> Option<&'static [u16]> {
static CLASS: OnceLock<Option<Vec<u16>>> = OnceLock::new();
CLASS
.get_or_init(|| {
let name = wide("SoundCraftVst3Editor");
// SAFETY: null asks for this process's module handle.
let instance = unsafe { GetModuleHandleW(std::ptr::null()) };
let wc = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
style: CS_HREDRAW | CS_VREDRAW,
lpfnWndProc: Some(wndproc),
cbClsExtra: 0,
cbWndExtra: 0,
hInstance: instance,
hIcon: std::ptr::null_mut(),
// SAFETY: a system cursor (null instance + predefined id).
hCursor: unsafe { LoadCursorW(std::ptr::null_mut(), IDC_ARROW) },
hbrBackground: std::ptr::null_mut(),
lpszMenuName: std::ptr::null(),
lpszClassName: name.as_ptr(),
hIconSm: std::ptr::null_mut(),
};
// SAFETY: a fully initialised class description whose strings outlive the call
// (the name is kept for the process lifetime in this `OnceLock`).
(unsafe { RegisterClassExW(&wc) } != 0).then_some(name)
})
.as_deref()
}
/// Outer window size for a client area of `w`×`h`.
fn outer_size(style: u32, ex_style: u32, w: f64, h: f64) -> (i32, i32) {
let mut r = RECT { left: 0, top: 0, right: w as i32, bottom: h as i32 };
// SAFETY: `r` is a valid, writable rect.
unsafe { AdjustWindowRectEx(&mut r, style, 0, ex_style) };
(r.right.saturating_sub(r.left), r.bottom.saturating_sub(r.top))
}
/// Owns the window handle; destroyed in `close` (main thread only).
pub struct HostWindow {
hwnd: HWND,
}
// SAFETY: the handle is only used on the main thread (every method checks); the struct
// merely carries it between threads' locks.
unsafe impl Send for HostWindow {}
impl HostWindow {
fn live(&self) -> Option<HWND> {
// SAFETY: `IsWindow` accepts any value and only reports whether it names a window.
(on_main_thread() && unsafe { IsWindow(self.hwnd) } != 0).then_some(self.hwnd)
}
pub fn new(title: &str, w: f64, h: f64) -> Result<HostWindow, String> {
if !on_main_thread() {
return Err("not on the main thread".into());
}
let class = class_name().ok_or_else(|| "cannot register the editor window class".to_string())?;
let title = wide(title);
let (ow, oh) = outer_size(STYLE, 0, w, h);
// SAFETY: main thread; the active window (or null) of this thread's queue.
let owner = unsafe { GetActiveWindow() };
// SAFETY: registered class and NUL-terminated strings that outlive the call; the
// owner is null or a window of this thread.
let hwnd = unsafe {
CreateWindowExW(
0,
class.as_ptr(),
title.as_ptr(),
STYLE,
CW_USEDEFAULT,
CW_USEDEFAULT,
ow,
oh,
owner,
std::ptr::null_mut(),
GetModuleHandleW(std::ptr::null()),
std::ptr::null(),
)
};
if hwnd.is_null() {
return Err("cannot create the editor window".into());
}
Ok(HostWindow { hwnd })
}
/// The window handle (for the plug frame's resizes).
pub fn raw(&self) -> *mut c_void {
self.hwnd
}
/// The `HWND` the plugin view creates its child window in.
pub fn parent(&self) -> *mut c_void {
self.live().unwrap_or(std::ptr::null_mut())
}
pub fn raise(&self) {
if let Some(h) = self.live() {
// SAFETY: a live window of this thread.
unsafe {
ShowWindow(h, SW_SHOW);
SetForegroundWindow(h);
}
}
}
pub fn is_visible(&self) -> bool {
// SAFETY: a live window of this thread.
self.live().is_some_and(|h| unsafe { IsWindowVisible(h) } != 0)
}
pub fn close(self) {
if let Some(h) = self.live() {
// SAFETY: our own live window; the view was detached before (see `teardown`).
unsafe { DestroyWindow(h) };
}
}
}
/// Resizes a host window's client area (`window` from [`HostWindow::raw`]); main thread only.
pub fn resize(window: *mut c_void, w: f64, h: f64) {
// SAFETY: `IsWindow` accepts any value.
if !on_main_thread() || window.is_null() || unsafe { IsWindow(window) } == 0 {
return;
}
// SAFETY: a live window of this thread; style values are read, not changed.
let (style, ex) = unsafe { (GetWindowLongPtrW(window, GWL_STYLE) as u32, GetWindowLongPtrW(window, GWL_EXSTYLE) as u32) };
let (ow, oh) = outer_size(style, ex, w, h);
// SAFETY: as above; only the size changes.
unsafe { SetWindowPos(window, std::ptr::null_mut(), 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE) };
}
}
/// Other platforms: no editor windows yet (opening reports "unsupported").
#[cfg(not(target_os = "macos"))]
#[cfg(not(any(target_os = "macos", windows)))]
mod window {
use std::ffi::{c_char, c_void};
+5 -3
View File
@@ -25,13 +25,15 @@
//! component state is accepted too) and re-reads the parameter values.
//!
//! Plugin editors (`IPlugView`): on macOS the view is attached to the content `NSView` of a host
//! `NSWindow` (titled and closable, floating above the app, resized on `IPlugFrame::resizeView`).
//! `NSWindow` (titled and closable, floating above the app, resized on `IPlugFrame::resizeView`);
//! on Windows it is attached to a host `HWND` owned by the app's window (no taskbar button,
//! closing it hides it until the link tears it down).
//! [`Plugin::open_editor`] opens it directly; [`Plugin::editor`] returns a handle usable on the
//! main thread while the instance processes on the audio thread (VST3's own threading model:
//! the controller and its views live on the UI thread). Parameter edits made in the editor
//! (`IComponentHandler::performEdit`) are reported by the handle's `idle` so the host can write
//! them into the session. Windows (HWND) and Linux (X11) editor windows are not hosted yet:
//! opening reports "unsupported".
//! them into the session. Linux (X11) editor windows are not hosted yet: opening reports
//! "unsupported".
//!
//! Not hosted yet: `IMessage`/`IAttributeList` creation through the host (plugins that need it
//! for processor ↔ controller messaging may lose those messages), sidechain (aux buses are kept
+2 -2
View File
@@ -9,8 +9,8 @@ fn main() {
let dir = common::fixture_dir();
let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
let mut p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate");
// Editor windows are hosted on macOS only; elsewhere the probe reports "unsupported".
if cfg!(target_os = "macos") {
// Editor windows are hosted on macOS and Windows; elsewhere the probe reports "unsupported".
if cfg!(any(target_os = "macos", windows)) {
assert_eq!(p.editor_size(), Ok((320, 240)));
} else {
assert!(p.editor_size().is_err());
+3 -3
View File
@@ -271,8 +271,8 @@ fn host_set_controller_param_survives_state_round_trip() {
fn editor_view_is_probed_and_edits_come_back_as_plain_values() {
let id = id_of("Fixture Gain");
let mut p = soundcraft_vst3_host::instantiate_plugin(&id).unwrap();
// Editor windows are hosted on macOS only; elsewhere the probe reports "unsupported".
if cfg!(target_os = "macos") {
// Editor windows are hosted on macOS and Windows; elsewhere the probe reports "unsupported".
if cfg!(any(target_os = "macos", windows)) {
assert_eq!(p.editor_size(), Ok((320, 240)));
} else {
assert!(p.editor_size().is_err());
@@ -281,7 +281,7 @@ fn editor_view_is_probed_and_edits_come_back_as_plain_values() {
// The fixture "edits" Gain to 0.9 (normalized) when it gets the component handler.
assert_eq!(ed.idle(), vec![("7".to_string(), 1.8)]);
assert!(ed.idle().is_empty());
if cfg!(target_os = "macos") {
if cfg!(any(target_os = "macos", windows)) {
assert!(ed.open().unwrap_err().contains("main thread"), "test threads are not the main thread");
} else {
assert!(ed.open().is_err(), "unsupported elsewhere");
+4 -3
View File
@@ -1,6 +1,6 @@
# Plugin parity: built-in processors and plugin hosting
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first plugin checklist against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1
Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins
cover the rest. This doc tracks both. Our plugins have our own names and are written from
@@ -44,8 +44,8 @@ User report #152: a saved instrument level does not reach the bounce.
| Notes / MIDI to instruments | ✅ | ✅ | But no live MIDI input (G-02) |
| State saved in sessions, presets | ✅ | ✅ | |
| Create/destroy off the audio thread | ✅ | ✅ | |
| Editors: CLAP | — | 🟡 floating windows; no embedded GUI | |
| Editors: VST3 | — | 🟡 macOS only (verified with a commercial pitch editor) | Windows/Linux views missing |
| Editors: CLAP | — | 🟡 floating windows; embedded editors on Windows | Embedded on macOS/Linux missing |
| Editors: VST3 | — | 🟡 macOS (verified with a commercial pitch editor) and Windows (verified with a free JUCE compressor) | Linux views missing |
| Editors: AU | — | ❌ | Cocoa view hosting missing |
| Multiple editor pages | ✅ | 🟡 | #6 |
| macOS hardened-runtime entitlement for third-party code | ✅ | ❌ | #153, #149 — signed builds refuse third-party VST3 |
@@ -58,4 +58,5 @@ User report #152: a saved instrument level does not reach the bounce.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Editors: VST3 and embedded CLAP editors on Windows |
| 2026-10-10 | major | Created: built-in processor inventory by category and third-party hosting checklist |