From 3ce0ec12a8baddfa37739445e9fae359d15e07e6 Mon Sep 17 00:00:00 2001 From: Heewon Oh Date: Wed, 28 Oct 2026 23:03:58 +0900 Subject: [PATCH] fix(vst3): hold editor edits made while processing While the host is processing audio, an edit made in the plugin's own editor was not applied to the parameter: the wrapper only called performEdit() and relied on the host to echo the value back through the next process call. Until then getParamNormalized() reported the old value. Cubase 15 and Nuendo read the controller value back during endEdit() and send what they read to the processor after the edit, so the stale value undid it: buttons did not toggle, editor preset recalls set nothing, and knobs snapped back on release. The edit is now held in a preallocated per-parameter slot (an AtomicBool and the value as AtomicU32 bits, plus one any-pending flag), stored before performEdit(). getParamNormalized() reports a held value, process() applies held edits right before draining the host's parameter changes so host automation in the same call still wins, and setProcessing(false) applies them while holding the processing gate. A direct edit made while not processing, a state restore, and a host setParamNormalized() with a different value all discard the held edit so it cannot later override a newer value. No allocation or locking on the audio thread. --- src/wrapper/vst3/context.rs | 22 +- src/wrapper/vst3/held_edit_tests.rs | 375 ++++++++++++++++++++++++++++ src/wrapper/vst3/inner.rs | 117 ++++++++- src/wrapper/vst3/wrapper.rs | 37 +++ 4 files changed, 542 insertions(+), 9 deletions(-) create mode 100644 src/wrapper/vst3/held_edit_tests.rs diff --git a/src/wrapper/vst3/context.rs b/src/wrapper/vst3/context.rs index 88e9d356..ceeb923d 100644 --- a/src/wrapper/vst3/context.rs +++ b/src/wrapper/vst3/context.rs @@ -166,14 +166,20 @@ impl GuiContext for WrapperGuiContext

{ match &*self.inner.component_handler.borrow() { Some(handler) => match self.inner.param_ptr_to_hash.get(¶m) { Some(hash) => { - // Only update the parameters manually if the host is not processing audio. If - // the plugin is currently processing audio, the host will pass this change back - // to the plugin in the audio callback. This also prevents the values from - // changing in the middle of the process callback, which would be unsound. - // FIXME: So this doesn't work for REAPER, because they just silently stop - // processing audio when you bypass the plugin. Great. We can add a time - // based heuristic to work around this in the meantime. - if !self.inner.is_processing.load(Ordering::SeqCst) { + // Only update the parameters directly if the host is not processing audio. + // Otherwise the value must not change in the middle of a process call, so the + // edit is held and applied at the start of the next process call (or when + // processing stops). The held value is also what `getParamNormalized()` + // reports until then: Cubase 15 and Nuendo read the controller value back + // during `endEdit()` and send it to the processor after the edit, so reporting + // the old value would undo the edit. It is held before `performEdit()` because + // a host may read the value back from within that call. This also covers hosts + // that never echo the edit, like REAPER when it bypasses the plugin. + if self.inner.is_processing.load(Ordering::SeqCst) { + self.inner.hold_edit(*hash, normalized); + } else { + // An older held value must not be applied over this edit later + self.inner.clear_held_edit(*hash); self.inner.set_normalized_value_by_hash( *hash, normalized, diff --git a/src/wrapper/vst3/held_edit_tests.rs b/src/wrapper/vst3/held_edit_tests.rs new file mode 100644 index 00000000..c0bb3f88 --- /dev/null +++ b/src/wrapper/vst3/held_edit_tests.rs @@ -0,0 +1,375 @@ +//! Editor edits made while the host is processing audio, driven through the wrapper's VST3 +//! interfaces with a mock component handler and mock parameter change queues. + +use super::*; +use crate::prelude::*; +use std::sync::Mutex; +use vst3_sys::utils::StaticVstPtr; +use vst3_sys::vst::{IComponentHandler, ProcessSetup, SymbolicSampleSizes}; + +const GAIN_ID: &str = "gain"; +const ENABLED_ID: &str = "enabled"; + +struct TestParams { + gain: FloatParam, + enabled: BoolParam, +} + +impl Default for TestParams { + fn default() -> Self { + Self { + gain: FloatParam::new("Gain", 0.25, FloatRange::Linear { min: 0.0, max: 1.0 }), + enabled: BoolParam::new("Enabled", false), + } + } +} + +// The pointers point to fields of this object, which the wrapper keeps alive. +unsafe impl Params for TestParams { + fn param_map(&self) -> Vec<(String, ParamPtr, String)> { + vec![ + (GAIN_ID.to_owned(), self.gain.as_ptr(), String::new()), + (ENABLED_ID.to_owned(), self.enabled.as_ptr(), String::new()), + ] + } +} + +#[derive(Default)] +struct TestPlugin { + params: Arc, +} + +impl Plugin for TestPlugin { + const NAME: &'static str = "Held edit test"; + const VENDOR: &'static str = "Intrect QA"; + const URL: &'static str = ""; + const EMAIL: &'static str = ""; + const VERSION: &'static str = "0.0.0"; + const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[AudioIOLayout { + main_input_channels: NonZeroU32::new(2), + main_output_channels: NonZeroU32::new(2), + aux_input_ports: &[], + aux_output_ports: &[], + names: PortNames::const_default(), + }]; + type SysExMessage = (); + type BackgroundTask = (); + + fn params(&self) -> Arc { + self.params.clone() + } + + fn process( + &mut self, + _: &mut Buffer, + _: &mut AuxiliaryBuffers, + _: &mut impl ProcessContext, + ) -> ProcessStatus { + ProcessStatus::Normal + } +} + +impl Vst3Plugin for TestPlugin { + const VST3_CLASS_ID: [u8; 16] = *b"HeldEditTest0001"; + const VST3_SUBCATEGORIES: &'static [Vst3SubCategory] = &[Vst3SubCategory::Fx]; +} + +/// Records the edits the plugin sends to the host. +#[VST3(implements(IComponentHandler))] +struct MockComponentHandler { + performed: Mutex>, + ended: Mutex>, +} + +impl IComponentHandler for MockComponentHandler { + unsafe fn begin_edit(&self, _id: u32) -> tresult { + kResultOk + } + + unsafe fn perform_edit(&self, id: u32, value_normalized: f64) -> tresult { + self.performed.lock().unwrap().push((id, value_normalized)); + kResultOk + } + + unsafe fn end_edit(&self, id: u32) -> tresult { + self.ended.lock().unwrap().push(id); + kResultOk + } + + unsafe fn restart_component(&self, _flags: i32) -> tresult { + kResultOk + } +} + +/// One parameter's points, all at sample offset 0, in the order the host sends them. +#[VST3(implements(IParamValueQueue))] +struct MockParamValueQueue { + id: u32, + values: Vec, +} + +impl IParamValueQueue for MockParamValueQueue { + unsafe fn get_parameter_id(&self) -> u32 { + self.id + } + + unsafe fn get_point_count(&self) -> i32 { + self.values.len() as i32 + } + + unsafe fn get_point(&self, index: i32, sample_offset: *mut i32, value: *mut f64) -> tresult { + match self.values.get(index as usize) { + Some(point) => { + *sample_offset = 0; + *value = *point; + kResultOk + } + None => kInvalidArgument, + } + } + + unsafe fn add_point(&self, _sample_offset: i32, _value: f64, _index: *mut i32) -> tresult { + kResultFalse + } +} + +#[VST3(implements(IParameterChanges))] +struct MockParameterChanges { + queues: Vec<&'static MockParamValueQueue>, +} + +impl IParameterChanges for MockParameterChanges { + unsafe fn get_parameter_count(&self) -> i32 { + self.queues.len() as i32 + } + + unsafe fn get_parameter_data(&self, index: i32) -> StaticVstPtr { + match self.queues.get(index as usize) { + // `StaticVstPtr` is a transparent pointer to the object's vtable pointer, see view.rs + Some(queue) => mem::transmute(&queue.__iparamvaluequeuevptr as *const *const _), + None => mem::zeroed(), + } + } + + unsafe fn add_parameter_data( + &self, + _id: *const u32, + _index: *mut i32, + ) -> StaticVstPtr { + mem::zeroed() + } +} + +/// A wrapper that is active and processing, with a component handler set, and the handler. +/// +/// The mock COM objects are leaked so they outlive any reference the wrapper holds to them. +fn processing_wrapper() -> (Box>, &'static MockComponentHandler) { + let wrapper = Wrapper::::new(); + let handler: &'static MockComponentHandler = Box::leak(MockComponentHandler::allocate( + Mutex::new(Vec::new()), + Mutex::new(Vec::new()), + )); + let setup = ProcessSetup { + process_mode: ProcessModes::kRealtime as i32, + symbolic_sample_size: SymbolicSampleSizes::kSample32 as i32, + max_samples_per_block: 64, + sample_rate: 48000.0, + }; + unsafe { + let handler_ptr: SharedVstPtr = + mem::transmute(&handler.__icomponenthandlervptr as *const *const _); + assert_eq!(wrapper.set_component_handler(handler_ptr), kResultOk); + assert_eq!(wrapper.setup_processing(&setup), kResultOk); + assert_eq!(wrapper.set_active(1), kResultOk); + assert_eq!(wrapper.set_processing(1), kResultOk); + } + + (wrapper, handler) +} + +fn hash(wrapper: &Wrapper, id: &str) -> u32 { + wrapper.inner.param_id_to_hash[id] +} + +/// The parameter's actual value, as the plugin sees it. +fn value(wrapper: &Wrapper, id: &str) -> f32 { + unsafe { wrapper.inner.param_by_hash[&hash(wrapper, id)].unmodulated_normalized_value() } +} + +/// A begin/set/end gesture made in the plugin's editor. +fn editor_edit(wrapper: &Wrapper, id: &str, normalized: f32) { + let gui_context = wrapper.inner.clone().make_gui_context(); + let param = wrapper.inner.param_by_hash[&hash(wrapper, id)]; + unsafe { + gui_context.raw_begin_set_parameter(param); + gui_context.raw_set_parameter_normalized(param, normalized); + gui_context.raw_end_set_parameter(param); + } +} + +/// A process call that only carries parameter changes (a VST3 parameter flush), with each +/// `(hash, values)` pair delivered as one parameter's queue. +fn process(wrapper: &Wrapper, changes: Vec<(u32, Vec)>) { + let queues = changes + .into_iter() + .map(|(id, values)| &*Box::leak(MockParamValueQueue::allocate(id, values))) + .collect(); + let param_changes: &'static MockParameterChanges = + Box::leak(MockParameterChanges::allocate(queues)); + + let mut data: vst3_sys::vst::ProcessData = unsafe { mem::zeroed() }; + data.process_mode = ProcessModes::kRealtime as i32; + data.symbolic_sample_size = SymbolicSampleSizes::kSample32 as i32; + data.num_samples = 0; + data.input_param_changes = + unsafe { mem::transmute(¶m_changes.__iparameterchangesvptr as *const *const _) }; + assert_eq!(unsafe { wrapper.process(&mut data) }, kResultOk); +} + +fn stop(wrapper: &Wrapper) { + unsafe { + assert_eq!(wrapper.set_processing(0), kResultOk); + assert_eq!(wrapper.set_active(0), kResultOk); + } +} + +/// Cubase 15 and Nuendo read the controller value back during `endEdit()` and send what they read +/// to the processor after the `performEdit()` value. Reading it right after the gesture is the same +/// as reading it from within the mock's `end_edit()`, since no process call happens in between. +#[test] +fn cubase_endedit_readback_keeps_the_edit() { + let (wrapper, handler) = processing_wrapper(); + let gain = hash(&wrapper, GAIN_ID); + let enabled = hash(&wrapper, ENABLED_ID); + assert_eq!(value(&wrapper, GAIN_ID), 0.25); + assert_eq!(value(&wrapper, ENABLED_ID), 0.0); + + let mut changes = Vec::new(); + for (id, hash, normalized) in [(ENABLED_ID, enabled, 1.0), (GAIN_ID, gain, 0.75)] { + editor_edit(&wrapper, id, normalized); + let read_back = unsafe { wrapper.get_param_normalized(hash) }; + println!("{id}: performEdit={normalized} getParamNormalized at endEdit={read_back}"); + changes.push((hash, vec![normalized as f64, read_back])); + } + assert_eq!( + *handler.performed.lock().unwrap(), + vec![(enabled, 1.0), (gain, 0.75)] + ); + assert_eq!(*handler.ended.lock().unwrap(), vec![enabled, gain]); + + process(&wrapper, changes); + let (enabled_value, gain_value) = (value(&wrapper, ENABLED_ID), value(&wrapper, GAIN_ID)); + println!("after process: enabled={enabled_value} gain={gain_value}"); + assert_eq!(enabled_value, 1.0, "the BoolParam edit was undone"); + assert_eq!(gain_value, 0.75, "the FloatParam edit was undone"); + stop(&wrapper); +} + +#[test] +fn held_edit_applies_without_host_echo() { + let (wrapper, _handler) = processing_wrapper(); + editor_edit(&wrapper, GAIN_ID, 0.75); + editor_edit(&wrapper, ENABLED_ID, 1.0); + // Not applied in the middle of processing + assert_eq!(value(&wrapper, GAIN_ID), 0.25); + assert_eq!(value(&wrapper, ENABLED_ID), 0.0); + + process(&wrapper, Vec::new()); + assert_eq!(value(&wrapper, GAIN_ID), 0.75); + assert_eq!(value(&wrapper, ENABLED_ID), 1.0); + stop(&wrapper); +} + +#[test] +fn host_changes_in_the_same_call_win_over_held_edits() { + let (wrapper, _handler) = processing_wrapper(); + let gain = hash(&wrapper, GAIN_ID); + editor_edit(&wrapper, GAIN_ID, 0.75); + + process(&wrapper, vec![(gain, vec![0.5])]); + assert_eq!(value(&wrapper, GAIN_ID), 0.5); + assert_eq!(unsafe { wrapper.get_param_normalized(gain) }, 0.5); + stop(&wrapper); +} + +#[test] +fn set_processing_false_flushes_held_edits() { + let (wrapper, _handler) = processing_wrapper(); + editor_edit(&wrapper, GAIN_ID, 0.75); + editor_edit(&wrapper, ENABLED_ID, 1.0); + + unsafe { assert_eq!(wrapper.set_processing(0), kResultOk) }; + assert_eq!(value(&wrapper, GAIN_ID), 0.75); + assert_eq!(value(&wrapper, ENABLED_ID), 1.0); + unsafe { assert_eq!(wrapper.set_active(0), kResultOk) }; +} + +#[test] +fn state_restore_discards_held_edits() { + let (wrapper, _handler) = processing_wrapper(); + let gain = hash(&wrapper, GAIN_ID); + // A state with gain at 0.5 + process(&wrapper, vec![(gain, vec![0.5])]); + let mut state = wrapper.inner.get_state_object(); + process(&wrapper, vec![(gain, vec![0.25])]); + assert_eq!(value(&wrapper, GAIN_ID), 0.25); + + editor_edit(&wrapper, GAIN_ID, 0.75); + // The same `set_state_inner_exclusive()` that `IComponent::setState()` and the editor's + // preset restore use + assert!(wrapper.inner.set_state_inner(&mut state)); + assert_eq!(unsafe { wrapper.get_param_normalized(gain) }, 0.5); + + process(&wrapper, Vec::new()); + assert_eq!(value(&wrapper, GAIN_ID), 0.5); + stop(&wrapper); +} + +#[test] +fn edit_while_not_processing_clears_stale_hold() { + let (wrapper, _handler) = processing_wrapper(); + editor_edit(&wrapper, GAIN_ID, 0.75); + + // `set_processing(false)` would flush the held edit, so the window in which a host has + // stopped processing without saying so (REAPER bypassing the plugin) is created directly + wrapper.inner.is_processing.store(false, Ordering::SeqCst); + editor_edit(&wrapper, GAIN_ID, 0.5); + assert_eq!(value(&wrapper, GAIN_ID), 0.5); + wrapper.inner.is_processing.store(true, Ordering::SeqCst); + + process(&wrapper, Vec::new()); + assert_eq!( + value(&wrapper, GAIN_ID), + 0.5, + "the older held edit was applied" + ); + stop(&wrapper); +} + +#[test] +fn host_set_param_normalized_keeps_an_echo_and_clears_a_different_value() { + let (wrapper, _handler) = processing_wrapper(); + let gain = hash(&wrapper, GAIN_ID); + + // The host echoing the editor's own edit to the controller keeps it held + editor_edit(&wrapper, GAIN_ID, 0.75); + unsafe { assert_eq!(wrapper.set_param_normalized(gain, 0.75), kResultOk) }; + assert_eq!(unsafe { wrapper.get_param_normalized(gain) }, 0.75); + process(&wrapper, Vec::new()); + assert_eq!(value(&wrapper, GAIN_ID), 0.75); + + // A different value from the host discards the held edit, so the edit cannot later be + // applied over the value the host sends to the processor. The host would send 0.5 with the + // next process call. It is left out here so that only the discarded edit could change the + // value. + editor_edit(&wrapper, GAIN_ID, 0.1); + unsafe { assert_eq!(wrapper.set_param_normalized(gain, 0.5), kResultOk) }; + assert_eq!(unsafe { wrapper.get_param_normalized(gain) }, 0.75); + process(&wrapper, Vec::new()); + assert_eq!( + value(&wrapper, GAIN_ID), + 0.75, + "the discarded edit was applied" + ); + stop(&wrapper); +} diff --git a/src/wrapper/vst3/inner.rs b/src/wrapper/vst3/inner.rs index 3fd08138..f33c2ebc 100644 --- a/src/wrapper/vst3/inner.rs +++ b/src/wrapper/vst3/inner.rs @@ -138,6 +138,51 @@ pub(crate) struct WrapperInner { /// having to add a setter function to the parameter (or even worse, have it be completely /// untyped). pub param_ptr_to_hash: HashMap, + + /// Editor edits made while the host is processing audio, one slot per parameter in + /// [`param_hashes`][Self::param_hashes] order. See [`HeldEdit`]. + pub held_edits: Vec, + /// Maps a parameter hash to its index in [`held_edits`][Self::held_edits]. + pub held_edit_idx_by_hash: HashMap, + /// Set after any slot in [`held_edits`][Self::held_edits] has been marked pending, so the audio + /// thread can skip scanning the slots when nothing has been edited. + pub any_held_edit: AtomicBool, +} + +/// An edit made in the plugin's editor while the host is processing audio. +/// +/// Such an edit is not applied to the parameter right away, since that could change a value in the +/// middle of a process call. Previously the wrapper relied entirely on the host to echo the +/// `performEdit()` value back through the process call's parameter changes. Until that happened +/// `IEditController::getParamNormalized()` reported the old value, and Cubase 15 (and Nuendo) read +/// the controller value back during `endEdit()` and send what they read to the processor after the +/// edit, undoing it. The edit is now held here instead: the controller reports it, and it is applied +/// at the start of the next process call (before the host's own parameter changes) or when +/// processing stops. +pub struct HeldEdit { + /// Whether `value` holds an edit that has not yet been applied. Stored after `value` with + /// release semantics. + pub pending: AtomicBool, + /// The held normalized value, as `f32` bits. + pub value: AtomicU32, +} + +impl HeldEdit { + fn new() -> Self { + Self { + pending: AtomicBool::new(false), + value: AtomicU32::new(0), + } + } + + /// The held value, if there is one. + pub fn get(&self) -> Option { + if self.pending.load(Ordering::Acquire) { + Some(f32::from_bits(self.value.load(Ordering::Acquire))) + } else { + None + } + } } /// Tasks that can be sent from the plugin to be executed on the main thread in a non-blocking @@ -248,10 +293,16 @@ impl WrapperInner

{ } } - let param_hashes = param_id_hashes_ptrs_groups + let param_hashes: Vec = param_id_hashes_ptrs_groups .iter() .map(|(_, hash, _, _)| *hash) .collect(); + let held_edits = param_hashes.iter().map(|_| HeldEdit::new()).collect(); + let held_edit_idx_by_hash = param_hashes + .iter() + .enumerate() + .map(|(idx, hash)| (*hash, idx)) + .collect(); let param_by_hash = param_id_hashes_ptrs_groups .iter() .map(|(_, hash, ptr, _)| (*hash, *ptr)) @@ -320,6 +371,10 @@ impl WrapperInner

{ param_units, param_id_to_hash, param_ptr_to_hash, + + held_edits, + held_edit_idx_by_hash, + any_held_edit: AtomicBool::new(false), }); // FIXME: Right now this is safe, but if we are going to have a singleton main thread queue @@ -437,6 +492,62 @@ impl WrapperInner

{ .map(|s| s.as_str()) } + /// The [`HeldEdit`] slot for a parameter hash, if the hash belongs to one of the plugin's + /// parameters. + pub fn held_edit(&self, hash: u32) -> Option<&HeldEdit> { + self.held_edit_idx_by_hash + .get(&hash) + .map(|idx| &self.held_edits[*idx]) + } + + /// Hold an editor edit made while the host is processing audio until the next process call or + /// until processing stops. A later edit of the same parameter replaces it. + pub fn hold_edit(&self, hash: u32, normalized_value: f32) { + if let Some(held) = self.held_edit(hash) { + // The value is published before the flags so a reader that sees the flags also sees + // this value + held.value + .store(normalized_value.to_bits(), Ordering::Release); + held.pending.store(true, Ordering::Release); + self.any_held_edit.store(true, Ordering::Release); + } + } + + /// Discard a parameter's held edit, if any, so it can no longer override a value that was set + /// after it. + pub fn clear_held_edit(&self, hash: u32) { + if let Some(held) = self.held_edit(hash) { + held.pending.store(false, Ordering::Release); + } + } + + /// Discard every held edit. Used when restoring state. + pub fn clear_held_edits(&self) { + for held in &self.held_edits { + held.pending.store(false, Ordering::Release); + } + } + + /// Apply every held edit. Called at the start of a process call before the host's parameter + /// changes are read, so the host's changes in the same call still take precedence, and when + /// processing stops. The caller must own `processing_gate`. Does not allocate or lock. + /// + /// Between clearing a slot's flag and applying its value `getParamNormalized()` briefly + /// reports the old value. An edit made during that window sets the flags again and is applied + /// on the next call, so no edit is ever lost. + pub fn apply_held_edits(&self, sample_rate: Option) { + if !self.any_held_edit.swap(false, Ordering::AcqRel) { + return; + } + + for (held, hash) in self.held_edits.iter().zip(&self.param_hashes) { + if held.pending.swap(false, Ordering::AcqRel) { + let value = f32::from_bits(held.value.load(Ordering::Acquire)); + self.set_normalized_value_by_hash(*hash, value, sample_rate); + } + } + } + /// Convenience function for setting a value for a parameter as triggered by a VST3 parameter /// update. The same rate is for updating parameter smoothing. /// @@ -604,6 +715,10 @@ impl WrapperInner

{ "Plugin returned false when reinitializing after loading state" ); + // An editor edit held from before the restore must not override the restored values on the + // next process call + self.clear_held_edits(); + // Reinitialize the plugin after loading state so it can respond to the new parameter values let task_posted = self.schedule_gui(Task::ParameterValuesChanged); nih_debug_assert!(task_posted, "The task queue is full, dropping task..."); diff --git a/src/wrapper/vst3/wrapper.rs b/src/wrapper/vst3/wrapper.rs index a50a29d2..d70d2857 100644 --- a/src/wrapper/vst3/wrapper.rs +++ b/src/wrapper/vst3/wrapper.rs @@ -709,6 +709,11 @@ impl IEditController for Wrapper

{ } unsafe fn get_param_normalized(&self, id: u32) -> f64 { + // An editor edit that has not been applied yet is the controller's current value + if let Some(value) = self.inner.held_edit(id).and_then(|held| held.get()) { + return value as f64; + } + match self.inner.param_by_hash.get(&id) { Some(param_ptr) => param_ptr.modulated_normalized_value() as f64, _ => 0.5, @@ -719,6 +724,17 @@ impl IEditController for Wrapper

{ // If the plugin is currently processing audio, then this parameter change will also be sent // to the process function if self.inner.is_processing.load(Ordering::SeqCst) { + // A host may echo the editor's own edit here, possibly from within `performEdit()`. + // That must keep the edit held, or `getParamNormalized()` would report the old value + // again when the host reads it back during `endEdit()`. Any other value is the host + // telling us about a newer value, so the held edit is discarded to make sure it cannot + // be applied over the value the host sends to the processor. + if let Some(held) = self.inner.held_edit(id) { + if held.get() != Some(value as f32) { + held.pending.store(false, Ordering::Release); + } + } + return kResultOk; } @@ -943,6 +959,19 @@ impl IAudioProcessor for Wrapper

{ self.inner.last_process_status.store(ProcessStatus::Normal); self.inner.is_processing.store(state, Ordering::SeqCst); + // Editor edits held while processing would otherwise only be applied by the next process + // call. This waits for an in-flight process call, like `set_active()` does. The gate is + // only unavailable on synchronous reentry, in which case no process call can be running. + if !state { + let _processing_guard = self.inner.processing_gate.lock(); + self.inner.apply_held_edits( + self.inner + .current_buffer_config + .load() + .map(|c| c.sample_rate), + ); + } + // This function is also used to reset buffers on the plugin, so we should do the same // thing. We don't call `reset()` in `setup_processing()` for that same reason. if state { @@ -1033,6 +1062,10 @@ impl IAudioProcessor for Wrapper

{ // and now. We'll also need to store the note events in the same vector because MIDI CC // messages are sent through parameter changes. This vector gets sorted at the end so we // can treat it as a sort of queue. + // Editor edits held since the last call are applied first, so the host's own + // parameter changes in this call (automation, or the edit sent back) come after them + self.inner.apply_held_edits(Some(sample_rate)); + let mut process_events = self.inner.process_events.borrow_mut(); process_events.clear(); @@ -2059,6 +2092,10 @@ mod aux_bus_tests { } } +#[cfg(test)] +#[path = "held_edit_tests.rs"] +mod held_edit_tests; + #[cfg(test)] mod lifecycle_tests { use super::*;