diff --git a/ROADMAP.md b/ROADMAP.md index 37e60541..201ca74f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -125,10 +125,11 @@ mutated and truncated movies. Field reports from the first public week are track - **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change. - **2026-10-16:** Master fader volume automation uses each render block's actual last sample, matching track faders. Breakpoints in the next block no longer pull the preceding block's level toward them, and ramps reach their last-sample value. Regression tests cover nonzero starts, one-sample callbacks and several block sizes. No readiness percentage change. - **2026-10-16:** Aux, folder and master latency calculations exclude inserts already taken out for producing NaN or infinity, matching audio tracks. Subsequent blocks no longer compensate for an insert that is bypassed after failure. Regression tests check impulse timing, healthy insert latency and reset/retry at 64- and 512-sample blocks. No readiness percentage change. +- **2026-10-16:** Split into Mono extracts the correct source channel for each mono track instead of playing channel zero on every track. Clip offsets, gain, fades and stretch, source timestamps, Undo/Redo and saved audio are preserved; repeated clips share each extracted mono source. Unavailable media returns an error without partial edits. No readiness percentage change. - **2026-10-14:** MCP `batch` rejects more than 1000 calls before executing any command, instead of silently running only the first 1000 (#460). No readiness percentage change. - **2026-10-13:** WAV: an empty data chunk followed by metadata (LIST, bext) reads as no audio; a recording that was never finalised (sizes left at 0) still gives what was written (#466) - **2026-10-13:** Sampler: a ping-pong loop keeps its phase and direction when a step is wider than the loop (a narrow loop with high transposition), instead of reflecting once and dropping the rest of the travel (#463) -- **2026-10-16:** Split into Mono extracts the correct source channel for each mono track instead of playing channel zero on every track. Clip offsets, gain, fades and stretch, source timestamps, Undo/Redo and saved audio are preserved; repeated clips share each extracted mono source. Unavailable media returns an error without partial edits. No readiness percentage change. +- **2026-10-13 (VST3 host messages):** The VST3 host creates `IMessage` and `IAttributeList` objects for plugins (`IHostApplication::createInstance`), so plugins whose controller and processor talk through `IConnectionPoint::notify` work: Line 6 Helix Stadium Native now loads its content database (it reported "Failed to get content database recovery info (Out of memory condition)") and exposes its parameters. No readiness percentage change. - **2026-10-13:** `mix.instrument` takes the built-in instrument's parameters in `params` (validated, clamped) and refuses any other field, so a synth setting written beside `plugin` is an error instead of being accepted and ignored (#265) - **2026-10-13:** A bounce of a bus that an aux returns onto lines up with the timeline: the aux insert's lookahead is compensated like the main mix's (it left the bus late and cut the end off), and tracks routed to that bus wait for it too (#246) - **2026-10-13:** Delay compensation covers the inserts of the master faders: a bounce of the main mix with a Maximizer on the master lines up with the same mix without it (it came out 71 samples late) (#351) @@ -165,10 +166,10 @@ mutated and truncated movies. Field reports from the first public week are track | 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged | | 2026-10-16 | trivial | Master volume automation block boundaries corrected; readiness estimates unchanged | | 2026-10-16 | trivial | Failed inserts no longer contribute aux, folder or master latency after containment; readiness estimates unchanged | +| 2026-10-16 | trivial | Split into Mono preserves each source channel, clip edits and saved audio; readiness estimates unchanged | | 2026-10-14 | trivial | Progress log: oversized MCP batches rejected before execution (#460) | | 2026-10-13 | trivial | Progress log: empty WAV data chunk followed by metadata (#466) | | 2026-10-13 | trivial | Progress log: sampler ping-pong phase for wide steps (#463) | -| 2026-10-16 | trivial | Split into Mono preserves each source channel, clip edits and saved audio; readiness estimates unchanged | | 2026-10-13 | trivial | Progress log: mix.instrument params, unknown fields refused (#265) | | 2026-10-13 | trivial | Progress log: bus bounce compensates aux returns (#246) | | 2026-10-13 | trivial | Progress log: master insert latency compensated (#351) | diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index bec9f167..8b20e4ef 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -646,13 +646,32 @@ impl IHostApplicationTrait for HostContext { } } - unsafe fn createInstance(&self, _cid: *mut sb::TUID, _iid: *mut sb::TUID, obj: *mut *mut c_void) -> sb::tresult { - // IMessage / IAttributeList are not provided yet; plugins must cope with that. + /// Creates the objects plugins may ask the host for: `IMessage` (what a controller and its + /// processor send each other through `IConnectionPoint::notify`) and `IAttributeList`. + /// Plugins that talk between their halves this way fail without them (Line 6 Helix + /// reported "Failed to get content database recovery info (Out of memory condition)"). + unsafe fn createInstance(&self, cid: *mut sb::TUID, iid: *mut sb::TUID, obj: *mut *mut c_void) -> sb::tresult { // SAFETY: `obj` is the plugin's out-pointer (or null, handled). - if let Some(o) = unsafe { obj.as_mut() } { - *o = std::ptr::null_mut(); + let Some(out) = (unsafe { obj.as_mut() }) else { return sb::kInvalidArgument }; + *out = std::ptr::null_mut(); + // SAFETY: `cid` and `iid` point to 16-byte TUIDs (or are null, handled). + let (Some(cid), Some(iid)) = (unsafe { cid.as_ref() }, unsafe { iid.as_ref() }) else { return sb::kInvalidArgument }; + let (cid, iid) = (tuid_bytes(cid), tuid_bytes(iid)); + let wants = |class: [u8; 16]| cid == class && (iid == class || iid == FUnknown::IID); + let made = if wants(sv::IMessage::IID) { + ComWrapper::new(HostMessage::default()).to_com_ptr::().map(|p| p.into_raw().cast::()) + } else if wants(sv::IAttributeList::IID) { + ComWrapper::new(HostAttributes::default()).to_com_ptr::().map(|p| p.into_raw().cast::()) + } else { + None + }; + match made { + Some(p) => { + *out = p; + sb::kResultOk + } + None => sb::kResultFalse, } - sb::kResultFalse } } @@ -684,6 +703,191 @@ impl IComponentHandlerTrait for HostContext { } } +/// Longest string attribute kept, in UTF-16 units (longer ones are cut). +const ATTR_STRING_MAX: usize = 1 << 20; +/// Largest binary attribute kept. +const ATTR_BINARY_MAX: usize = 256 << 20; + +/// One `IAttributeList` value. Strings and blobs live in their own allocation, so a pointer +/// handed out by `getBinary` stays valid while the list holds the value. +enum Attr { + Int(i64), + Float(f64), + Str(Box<[u16]>), + Bin(Box<[u8]>), +} + +/// The host's `IAttributeList`: what a plugin puts in an `IMessage`. +#[derive(Default)] +pub(crate) struct HostAttributes { + values: Mutex, Attr>>, +} + +impl Class for HostAttributes { + type Interfaces = (sv::IAttributeList,); +} + +/// An attribute id's bytes (`None` for a null id). +fn attr_key(id: sv::IAttributeList_::AttrID) -> Option> { + // SAFETY: a non-null AttrID is a NUL-terminated C string owned by the caller. + (!id.is_null()).then(|| unsafe { std::ffi::CStr::from_ptr(id) }.to_bytes().to_vec()) +} + +impl HostAttributes { + fn values(&self) -> std::sync::MutexGuard<'_, std::collections::HashMap, Attr>> { + self.values.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn set(&self, id: sv::IAttributeList_::AttrID, v: Attr) -> sb::tresult { + let Some(k) = attr_key(id) else { return sb::kInvalidArgument }; + self.values().insert(k, v); + sb::kResultOk + } +} + +impl sv::IAttributeListTrait for HostAttributes { + unsafe fn setInt(&self, id: sv::IAttributeList_::AttrID, value: sb::int64) -> sb::tresult { + self.set(id, Attr::Int(value)) + } + + unsafe fn getInt(&self, id: sv::IAttributeList_::AttrID, value: *mut sb::int64) -> sb::tresult { + // SAFETY: `value` is the caller's out-pointer (or null, handled). + match ( + attr_key(id).and_then(|k| match self.values().get(&k) { + Some(Attr::Int(v)) => Some(*v), + _ => None, + }), + unsafe { value.as_mut() }, + ) { + (Some(v), Some(out)) => { + *out = v; + sb::kResultOk + } + _ => sb::kResultFalse, + } + } + + unsafe fn setFloat(&self, id: sv::IAttributeList_::AttrID, value: f64) -> sb::tresult { + self.set(id, Attr::Float(value)) + } + + unsafe fn getFloat(&self, id: sv::IAttributeList_::AttrID, value: *mut f64) -> sb::tresult { + // SAFETY: `value` is the caller's out-pointer (or null, handled). + match ( + attr_key(id).and_then(|k| match self.values().get(&k) { + Some(Attr::Float(v)) => Some(*v), + _ => None, + }), + unsafe { value.as_mut() }, + ) { + (Some(v), Some(out)) => { + *out = v; + sb::kResultOk + } + _ => sb::kResultFalse, + } + } + + unsafe fn setString(&self, id: sv::IAttributeList_::AttrID, string: *const sv::TChar) -> sb::tresult { + if string.is_null() { + return sb::kInvalidArgument; + } + let mut s = Vec::new(); + // SAFETY: a non-null string is NUL-terminated UTF-16 owned by the caller; reading stops at + // the NUL or at ATTR_STRING_MAX units. + while s.len() < ATTR_STRING_MAX { + let c = unsafe { *string.add(s.len()) }; + if c == 0 { + break; + } + s.push(c); + } + self.set(id, Attr::Str(s.into_boxed_slice())) + } + + unsafe fn getString(&self, id: sv::IAttributeList_::AttrID, string: *mut sv::TChar, size_in_bytes: sb::uint32) -> sb::tresult { + let units = size_in_bytes as usize / 2; + if string.is_null() || units == 0 { + return sb::kInvalidArgument; + } + let Some(k) = attr_key(id) else { return sb::kInvalidArgument }; + let values = self.values(); + let Some(Attr::Str(s)) = values.get(&k) else { return sb::kResultFalse }; + let n = s.len().min(units - 1); + // SAFETY: the caller's buffer holds `units` UTF-16 units; `n + 1 <= units` are written. + unsafe { + std::ptr::copy_nonoverlapping(s.as_ptr(), string, n); + *string.add(n) = 0; + } + sb::kResultOk + } + + unsafe fn setBinary(&self, id: sv::IAttributeList_::AttrID, data: *const c_void, size_in_bytes: sb::uint32) -> sb::tresult { + let n = size_in_bytes as usize; + if n > ATTR_BINARY_MAX { + return sb::kOutOfMemory; + } + let bytes: Box<[u8]> = if n == 0 || data.is_null() { + Box::new([]) + } else { + // SAFETY: the caller's `data` holds `n` readable bytes. + unsafe { std::slice::from_raw_parts(data.cast::(), n) }.into() + }; + self.set(id, Attr::Bin(bytes)) + } + + unsafe fn getBinary(&self, id: sv::IAttributeList_::AttrID, data: *mut *const c_void, size_in_bytes: *mut sb::uint32) -> sb::tresult { + let Some(k) = attr_key(id) else { return sb::kInvalidArgument }; + let values = self.values(); + let Some(Attr::Bin(b)) = values.get(&k) else { return sb::kResultFalse }; + // SAFETY: both are the caller's out-pointers (or null, handled). The pointer handed out + // stays valid until this attribute is replaced or the list is released (VST3 contract). + match (unsafe { data.as_mut() }, unsafe { size_in_bytes.as_mut() }) { + (Some(d), Some(sz)) => { + *d = b.as_ptr().cast::(); + *sz = u32::try_from(b.len()).unwrap_or(u32::MAX); + sb::kResultOk + } + _ => sb::kInvalidArgument, + } + } +} + +/// The host's `IMessage`: an id and an attribute list, created for a plugin through +/// `IHostApplication::createInstance`. +pub(crate) struct HostMessage { + id: Mutex, + attributes: ComWrapper, +} + +impl Default for HostMessage { + fn default() -> Self { + HostMessage { id: Mutex::new(std::ffi::CString::default()), attributes: ComWrapper::new(HostAttributes::default()) } + } +} + +impl Class for HostMessage { + type Interfaces = (sv::IMessage,); +} + +impl sv::IMessageTrait for HostMessage { + unsafe fn getMessageID(&self) -> sb::FIDString { + // The string's buffer lives until the next `setMessageID`, as the interface expects. + self.id.lock().unwrap_or_else(PoisonError::into_inner).as_ptr() + } + + unsafe fn setMessageID(&self, id: sb::FIDString) { + // SAFETY: a non-null id is a NUL-terminated C string owned by the caller. + let s = if id.is_null() { std::ffi::CString::default() } else { unsafe { std::ffi::CStr::from_ptr(id) }.to_owned() }; + *self.id.lock().unwrap_or_else(PoisonError::into_inner) = s; + } + + unsafe fn getAttributes(&self) -> *mut sv::IAttributeList { + // Not add-ref'ed: the list belongs to the message (VST3 contract). + self.attributes.as_com_ref::().map_or(std::ptr::null_mut(), |r| r.as_ptr()) + } +} + /// One parameter's points for one block. SoundCraft sends at most one point per block; a /// plugin writing output changes may add more, of which only the last is kept. #[derive(Default)] @@ -2151,6 +2355,57 @@ mod window { #[cfg(test)] mod tests { use super::*; + use sv::{IAttributeListTrait as _, IMessageTrait as _}; + + fn tuid(iid: [u8; 16]) -> sb::TUID { + iid.map(|b| b as c_char) + } + + /// Asks the host for an object the way a plugin does. + fn create(host: &ComWrapper, cid: [u8; 16]) -> Option> { + let app = host.to_com_ptr::()?; + let (mut c, mut i) = (tuid(cid), tuid(I::IID)); + let mut obj: *mut c_void = std::ptr::null_mut(); + // SAFETY: valid TUIDs and out-pointer, as a plugin passes them. + let r = unsafe { app.createInstance(&mut c, &mut i, &mut obj) }; + // SAFETY: on success `obj` is an owned (add-ref'ed) pointer to `I`. + (r == sb::kResultOk).then(|| unsafe { ComPtr::from_raw(obj.cast::()) }).flatten() + } + + #[test] + fn plugins_get_messages_and_attribute_lists_from_the_host() { + let host = ComWrapper::new(HostContext::default()); + let msg = create::(&host, sv::IMessage::IID).expect("IMessage"); + // SAFETY: calls through the interface with valid C strings and buffers. + unsafe { + msg.setMessageID(c"DatabaseInfo".as_ptr()); + assert_eq!(std::ffi::CStr::from_ptr(msg.getMessageID()).to_str(), Ok("DatabaseInfo")); + let attrs = vst3::ComRef::from_raw(msg.getAttributes()).expect("attributes"); + assert_eq!(attrs.setInt(c"count".as_ptr(), 42), sb::kResultOk); + assert_eq!(attrs.setFloat(c"gain".as_ptr(), 0.5), sb::kResultOk); + let text: Vec = "Helix".encode_utf16().chain([0]).collect(); + assert_eq!(attrs.setString(c"name".as_ptr(), text.as_ptr()), sb::kResultOk); + assert_eq!(attrs.setBinary(c"blob".as_ptr(), [1u8, 2, 3].as_ptr().cast(), 3), sb::kResultOk); + + let (mut i, mut f) = (0i64, 0f64); + assert_eq!(attrs.getInt(c"count".as_ptr(), &mut i), sb::kResultOk); + assert_eq!(attrs.getFloat(c"gain".as_ptr(), &mut f), sb::kResultOk); + assert_eq!((i, f), (42, 0.5)); + assert_eq!(attrs.getInt(c"gain".as_ptr(), &mut i), sb::kResultFalse, "a float is not an int"); + assert_eq!(attrs.getInt(c"missing".as_ptr(), &mut i), sb::kResultFalse); + // A buffer of 4 units takes 3 characters and the NUL. + let mut buf = [0xFFFFu16; 4]; + assert_eq!(attrs.getString(c"name".as_ptr(), buf.as_mut_ptr(), 8), sb::kResultOk); + assert_eq!(buf, ['H' as u16, 'e' as u16, 'l' as u16, 0]); + let (mut p, mut n): (*const c_void, u32) = (std::ptr::null(), 0); + assert_eq!(attrs.getBinary(c"blob".as_ptr(), &mut p, &mut n), sb::kResultOk); + assert_eq!(std::slice::from_raw_parts(p.cast::(), n as usize), [1, 2, 3]); + assert_eq!(attrs.setInt(std::ptr::null(), 1), sb::kInvalidArgument, "a null id is refused"); + } + assert!(create::(&host, sv::IAttributeList::IID).is_some()); + // Anything else is still not provided. + assert!(create::(&host, sv::IComponentHandler::IID).is_none()); + } #[test] fn the_transport_fills_the_process_context() { diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md index 6aa796b5..20821573 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-13 · **Change:** trivial (Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously 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 @@ -58,6 +58,7 @@ User report #152: a saved instrument level does not reach the bounce. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | VST3 host: IMessage and IAttributeList for plugins whose halves talk through messages (Line 6 Helix Stadium Native verified) | | 2026-10-13 | trivial | Sampler ping-pong loop phase (#463) | | 2026-10-13 | trivial | mix.instrument sets built-in instrument parameters in params (#265) | | 2026-10-12 | minor | Editors: VST3 and embedded CLAP editors on Windows |