From 7eb643fc5eb11a3bced8bc90f12a6713999fceb4 Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Mon, 12 Oct 2026 17:44:36 +0200 Subject: [PATCH] clap-host: host embedded plugin editors on Windows Plugins that offer no floating editor (most CLAP plugins on Windows) now get a host window of SoundCraft's: the GUI is created embedded, parented to a top-level HWND owned by the app window, sized from get_size, resized on request_resize (applied by the editor's idle on the main thread, then set_size), and torn down when the user closes the window. Floating editors work as before; other platforms still report embedded editors as unsupported. Docs: plugin-parity editor rows and the roadmap log. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + ROADMAP.md | 2 + crates/clap-host/Cargo.toml | 4 + crates/clap-host/src/ffi.rs | 324 +++++++++++++++++++++++++++++++++++- crates/clap-host/src/lib.rs | 6 +- docs/plugin-parity.md | 7 +- 6 files changed, 330 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4b20fab..c943818 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3482,6 +3482,7 @@ dependencies = [ "serde", "soundcraft-dsp", "thiserror 2.0.21", + "windows-sys 0.61.2", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index bab0d92..2069627 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -125,6 +125,8 @@ mutated and truncated movies. Field reports from the first public week are track - **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) +- **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-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change. - **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/ diff --git a/crates/clap-host/Cargo.toml b/crates/clap-host/Cargo.toml index e0ff742..e08bbdf 100644 --- a/crates/clap-host/Cargo.toml +++ b/crates/clap-host/Cargo.toml @@ -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 diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs index 16aaf33..c83046e 100644 --- a/crates/clap-host/src/ffi.rs +++ b/crates/clap-host/src/ffi.rs @@ -18,7 +18,7 @@ use clap_sys::events::{ clap_event_note, clap_event_param_value, 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}; @@ -36,7 +36,7 @@ use clap_sys::version::{CLAP_VERSION, clap_version_is_compatible}; 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). @@ -275,7 +275,7 @@ impl Bundle { inst.state_ext = inst.extension::(CLAP_EXT_STATE); let gui = inst.extension::(CLAP_EXT_GUI); inst.gui = Arc::new(GuiLink { - inner: Mutex::new(GuiInner { plugin, gui, alive: !gui.is_null(), created: false, visible: false }), + inner: Mutex::new(GuiInner { plugin, gui, alive: !gui.is_null(), created: false, visible: false, window: None }), host: Arc::clone(&inst.state), }); Ok(inst) @@ -310,6 +310,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>, @@ -328,6 +330,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)), } } @@ -408,8 +411,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 } @@ -700,6 +711,8 @@ struct GuiInner { alive: bool, created: bool, visible: bool, + /// The host window of an embedded editor (`None` for floating editors). + window: Option, } /// The link between an instance and its editor handles. The instance may process on the audio @@ -715,7 +728,14 @@ pub(crate) struct GuiLink { impl Default for GuiLink { fn default() -> Self { GuiLink { - inner: Mutex::new(GuiInner { plugin: std::ptr::null(), gui: std::ptr::null(), alive: false, created: false, visible: false }), + inner: Mutex::new(GuiInner { + plugin: std::ptr::null(), + gui: std::ptr::null(), + alive: false, + created: false, + visible: false, + window: None, + }), host: Arc::new(HostState::default()), } } @@ -755,6 +775,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(()); } @@ -762,7 +785,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); @@ -794,6 +821,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) @@ -803,6 +885,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(); + } } /// Closes (destroys) the editor. @@ -815,7 +901,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 @@ -836,7 +922,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); @@ -872,6 +972,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; @@ -1264,3 +1366,207 @@ impl Drop for Instance { self.created = false; } } + +// ---- 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 { + 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>> = 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::() 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 { + // 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 { + 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 { + 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) {} +} diff --git a/crates/clap-host/src/lib.rs b/crates/clap-host/src/lib.rs index 2740f49..de64d72 100644 --- a/crates/clap-host/src/lib.rs +++ b/crates/clap-host/src/lib.rs @@ -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`. diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md index f59b598..b8d800a 100644 --- a/docs/plugin-parity.md +++ b/docs/plugin-parity.md @@ -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 |