From b2394958e74e8e86ea95211bb84149c5c68a22e4 Mon Sep 17 00:00:00 2001 From: Heewon Oh Date: Fri, 25 Sep 2026 17:16:14 +0900 Subject: [PATCH 1/4] feat(context): expose the host's mid-session render mode (AUD-1692) Add ProcessContext::host_render_mode(), defaulting to None. The CLAP wrapper returns the mode last set through clap_plugin_render::set(). nih-plug already stores that value, but only hands it to the plugin via BufferConfig on the next initialize(). CLAP hosts - and clap-wrapper's AAX wrapper when Pro Tools enters an offline bounce - may switch the render mode without re-activating, so a plugin whose real-time path polls a worker thread without blocking had no way to know it should block for the length of a faster-than-real-time bounce. The default implementation keeps every other wrapper and every existing ProcessContext implementor source-compatible. --- src/context/process.rs | 18 +++++++++++++++++- src/wrapper/clap/context.rs | 6 +++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/context/process.rs b/src/context/process.rs index 1922486ea..ca22dd775 100644 --- a/src/context/process.rs +++ b/src/context/process.rs @@ -1,7 +1,7 @@ //! A context passed during the process function. use super::PluginApi; -use crate::prelude::{Plugin, PluginNoteEvent}; +use crate::prelude::{Plugin, PluginNoteEvent, ProcessMode}; /// Contains both context data and callbacks the plugin can use during processing. Most notably this /// is how a plugin sends and receives note events, gets transport information, and accesses @@ -92,6 +92,22 @@ pub trait ProcessContext { /// monophonic modulation when dropping the capacity down to 1. fn set_current_voice_capacity(&self, capacity: u32); + /// The render mode the host has requested **after** [`Plugin::initialize()`], for plugin APIs + /// that let a host switch it mid-session. `None` means the API has no such mechanism, in which + /// case [`BufferConfig::process_mode`][crate::prelude::BufferConfig::process_mode] from the + /// last `initialize()` call is still authoritative. + /// + /// CLAP hosts may call `clap_plugin_render::set()` at any time on the main thread, for + /// instance to render an offline bounce faster than real time without re-activating the + /// plugin. That change only reaches `BufferConfig` on the next `initialize()`, so a plugin + /// whose real-time path cannot keep up with faster-than-real-time processing (e.g. one that + /// hands audio to a worker thread and polls for results without blocking) can read this to + /// switch to a blocking strategy for the duration of the bounce. The value is a lock-free + /// atomic load and is safe to call from the audio thread. + fn host_render_mode(&self) -> Option { + None + } + // TODO: Add this, this works similar to [GuiContext::set_parameter] but it adds the parameter // change to a queue (or directly to the VST3 plugin's parameter output queues) instead of // using main thread host automation (and all the locks involved there). diff --git a/src/wrapper/clap/context.rs b/src/wrapper/clap/context.rs index cf3668f2a..933511c1f 100644 --- a/src/wrapper/clap/context.rs +++ b/src/wrapper/clap/context.rs @@ -12,7 +12,7 @@ use super::wrapper::{OutputParamEvent, Task, Wrapper}; use crate::event_loop::EventLoop; use crate::prelude::{ ClapPlugin, GuiContext, InitContext, ParamPtr, PluginApi, PluginNoteEvent, ProcessContext, - RemoteControlsContext, RemoteControlsPage, RemoteControlsSection, Transport, + ProcessMode, RemoteControlsContext, RemoteControlsPage, RemoteControlsSection, Transport, }; use crate::wrapper::util::strlcpy; @@ -127,6 +127,10 @@ impl ProcessContext

for WrapperProcessContext<'_, P> { fn set_current_voice_capacity(&self, capacity: u32) { self.wrapper.set_current_voice_capacity(capacity) } + + fn host_render_mode(&self) -> Option { + Some(self.wrapper.current_process_mode.load()) + } } impl GuiContext for WrapperGuiContext

{ From 807c048891ab90d080f7ac613a79a71ea141f419 Mon Sep 17 00:00:00 2001 From: unohee Date: Sun, 4 Oct 2026 05:29:29 +0900 Subject: [PATCH 2/4] Add opt-in concurrent AAX parameter delivery bridge --- AAX_PARAMETER_DELIVERY.md | 37 ++ src/plugin/clap.rs | 9 + src/wrapper/clap.rs | 1 + src/wrapper/clap/aax_parameter_tests.rs | 488 ++++++++++++++++++++++++ src/wrapper/clap/aax_params.rs | 53 +++ src/wrapper/clap/wrapper.rs | 310 +++++++++++---- 6 files changed, 830 insertions(+), 68 deletions(-) create mode 100644 AAX_PARAMETER_DELIVERY.md create mode 100644 src/wrapper/clap/aax_parameter_tests.rs create mode 100644 src/wrapper/clap/aax_params.rs diff --git a/AAX_PARAMETER_DELIVERY.md b/AAX_PARAMETER_DELIVERY.md new file mode 100644 index 000000000..0ea6af9b0 --- /dev/null +++ b/AAX_PARAMETER_DELIVERY.md @@ -0,0 +1,37 @@ +# Private AAX parameter-only delivery + +`io.intrect.aax-parameter-delivery/1` is separate from standard CLAP +`params.flush`. It requires an explicit +`ClapPlugin::CLAP_AAX_CONCURRENT_PARAMETER_DELIVERY` opt-in, no MIDI input/output, +no polyphonic modulation, and disabled sample-accurate automation. + +The plugin must tolerate atomic parameter values changing during DSP and use +thread-safe parameter callbacks. The extension never borrows the mutable plugin +instance or its audio/note event buffers. + +The host serializes incoming parameter batches with a nonblocking gate. Audio +tries once: when background owns the gate, audio/transport processing continues +without incoming parameter/modulation events. A later owning callback consumes +the queued batch. Lifecycle and state callbacks must not overlap delivery. + +Background calls `try_begin` before consuming any host input. Zero means busy; +a nonzero token identifies that acquisition. The same thread supplies that token +to `flush` and calls `end` exactly once, including on failure. Failed acquisition, +stale tokens and wrong-thread calls cannot release another owner's gate. +Exhausted token generations refuse acquisition instead of reusing a token. + +`flush` validates the entire stable input batch before applying any value. It +accepts only finite monophonic values in the advertised CLAP range for known parameter IDs, and emits only GUI +parameter values/gestures. A rejected input batch must be retained in order by the host. Rejected output is +retained ahead of later gestures/values and acknowledged events are not replayed. +Audio and background parameter-output drains share an atomic gate, preserving +gesture/value order. Audio defers this drain when busy and continues DSP. + +The ABI is `repr(C)` with three CLAP C function pointers: `try_begin(plugin) -> +u64`, `flush(plugin, token, input, output) -> u32`, and `end(plugin, token) -> +bool`. Standard CLAP flush and lifecycle contracts remain unchanged. This is a +private Intrect host/plugin agreement, not permission to invoke standard flush +concurrently with processing. + +Flush results are 0 (invalid/unowned input, retain batch), 1 (input consumed, output +still pending, discard batch and retry output), or 2 (complete delivery). diff --git a/src/plugin/clap.rs b/src/plugin/clap.rs index 79d588e03..1436ddd5f 100644 --- a/src/plugin/clap.rs +++ b/src/plugin/clap.rs @@ -17,6 +17,15 @@ pub trait ClapPlugin: Plugin { /// browser. const CLAP_FEATURES: &'static [ClapFeature]; + /// Opt into Intrect's private AAX parameter-only delivery extension. + /// + /// This permits atomic parameter values and GUI parameter gestures to be updated while + /// `Plugin::process()` runs. The plugin must read parameters through their atomic getters, + /// have thread-safe parameter callbacks, and tolerate a value changing within a block. + /// Sample-accurate automation, MIDI and polyphonic modulation are not supported by this + /// extension. Standard CLAP `params.flush` retains its normal thread/exclusion contract. + const CLAP_AAX_CONCURRENT_PARAMETER_DELIVERY: bool = false; + /// If set, this informs the host about the plugin's capabilities for polyphonic modulation. const CLAP_POLY_MODULATION_CONFIG: Option = None; diff --git a/src/wrapper/clap.rs b/src/wrapper/clap.rs index 8ef4b2a6f..62b796c3c 100644 --- a/src/wrapper/clap.rs +++ b/src/wrapper/clap.rs @@ -1,6 +1,7 @@ #[macro_use] mod util; +pub mod aax_params; mod context; mod descriptor; pub mod features; diff --git a/src/wrapper/clap/aax_parameter_tests.rs b/src/wrapper/clap/aax_parameter_tests.rs new file mode 100644 index 000000000..0e04e5c30 --- /dev/null +++ b/src/wrapper/clap/aax_parameter_tests.rs @@ -0,0 +1,488 @@ +use super::*; +use crate::prelude::{ClapFeature, FloatParam, FloatRange, Param, ProcessContext}; +use std::ptr; + +struct TestParams { + value: FloatParam, +} +unsafe impl Params for TestParams { + fn param_map(&self) -> Vec<(String, ParamPtr, String)> { + vec![("value".into(), self.value.as_ptr(), String::new())] + } +} +struct TestPlugin { + params: Arc, +} +impl Default for TestPlugin { + fn default() -> Self { + Self { + params: Arc::new(TestParams { + value: FloatParam::new("Value", 0.25, FloatRange::Linear { min: 0.0, max: 1.0 }), + }), + } + } +} +impl Plugin for TestPlugin { + const NAME: &'static str = "AAX delivery fixture"; + const VENDOR: &'static str = "Intrect"; + const URL: &'static str = "https://intrect.io"; + const EMAIL: &'static str = "test@intrect.io"; + const VERSION: &'static str = "0.0.0"; + const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[AudioIOLayout { + main_input_channels: NonZeroU32::new(1), + main_output_channels: NonZeroU32::new(1), + ..AudioIOLayout::const_default() + }]; + type SysExMessage = (); + type BackgroundTask = (); + fn params(&self) -> Arc { + self.params.clone() + } + fn process( + &mut self, + buffer: &mut crate::buffer::Buffer, + _: &mut AuxiliaryBuffers, + _: &mut impl ProcessContext, + ) -> ProcessStatus { + for channel in buffer.as_slice() { + for sample in channel.iter_mut() { + *sample *= self.params.value.value(); + } + } + ProcessStatus::Normal + } +} +impl ClapPlugin for TestPlugin { + const CLAP_ID: &'static str = "io.intrect.aax-delivery-test"; + const CLAP_DESCRIPTION: Option<&'static str> = None; + const CLAP_MANUAL_URL: Option<&'static str> = None; + const CLAP_SUPPORT_URL: Option<&'static str> = None; + const CLAP_FEATURES: &'static [ClapFeature] = &[ClapFeature::AudioEffect]; + const CLAP_AAX_CONCURRENT_PARAMETER_DELIVERY: bool = ENABLED; +} +unsafe extern "C" fn no_extension(_: *const clap_host, _: *const c_char) -> *const c_void { + ptr::null() +} +unsafe extern "C" fn no_request(_: *const clap_host) {} +fn host() -> clap_host { + clap_host { + clap_version: clap_sys::version::CLAP_VERSION, + host_data: ptr::null_mut(), + name: c"AAX fixture".as_ptr(), + vendor: c"Intrect".as_ptr(), + url: c"https://intrect.io".as_ptr(), + version: c"0".as_ptr(), + get_extension: Some(no_extension), + request_restart: Some(no_request), + request_process: Some(no_request), + request_callback: Some(no_request), + } +} +unsafe extern "C" fn input_size(input: *const clap_input_events) -> u32 { + (&*((*input).ctx as *const Vec)).len() as u32 +} +unsafe extern "C" fn input_get( + input: *const clap_input_events, + index: u32, +) -> *const clap_event_header { + let events = &*((*input).ctx as *const Vec); + events + .get(index as usize) + .map_or(ptr::null(), |event| &event.header) +} +unsafe extern "C" fn output_push( + output: *const clap_output_events, + event: *const clap_event_header, +) -> bool { + let result = &mut *((*output).ctx as *mut Vec); + result.push((*event).type_); + true +} +fn value(id: u32, plain: f64) -> clap_event_param_value { + clap_event_param_value { + header: clap_event_header { + size: mem::size_of::() as u32, + time: 0, + space_id: CLAP_CORE_EVENT_SPACE_ID, + type_: CLAP_EVENT_PARAM_VALUE, + flags: 0, + }, + param_id: id, + cookie: ptr::null_mut(), + note_id: -1, + port_index: -1, + channel: -1, + key: -1, + value: plain, + } +} +#[test] +fn extension_requires_explicit_opt_in() { + let host = host(); + let disabled = unsafe { Wrapper::>::new(&host) }; + let enabled = unsafe { Wrapper::>::new(&host) }; + for (expected, plugin) in [ + (false, disabled.clap_plugin.borrow()), + (true, enabled.clap_plugin.borrow()), + ] { + let extension = unsafe { + (plugin.get_extension.unwrap())(&*plugin, AAX_PARAMETER_DELIVERY_ID.as_ptr()) + }; + assert_eq!(!extension.is_null(), expected); + } +} +#[test] +fn bridge_avoids_note_buffers_and_preserves_parameter_gestures() { + let host = host(); + let wrapper = unsafe { Wrapper::>::new(&host) }; + let plugin = wrapper.clap_plugin.borrow(); + let id = wrapper.param_id_to_hash["value"]; + let events = vec![value(id, 0.75)]; + let input = clap_input_events { + ctx: &events as *const _ as *mut _, + size: Some(input_size), + get: Some(input_get), + }; + let mut received = Vec::new(); + let output = clap_output_events { + ctx: &mut received as *mut Vec as *mut _, + try_push: Some(output_push), + }; + wrapper + .output_parameter_events + .push(OutputParamEvent::BeginGesture { param_hash: id }) + .unwrap(); + wrapper + .output_parameter_events + .push(OutputParamEvent::SetValue { + param_hash: id, + clap_plain_value: 0.5, + }) + .unwrap(); + wrapper + .output_parameter_events + .push(OutputParamEvent::EndGesture { param_hash: id }) + .unwrap(); + let _held_input = wrapper.input_events.borrow_mut(); + let _held_output = wrapper.output_events.borrow_mut(); + let ext = &Wrapper::>::AAX_PARAMETER_DELIVERY; + unsafe { + let token = (ext.try_begin)(&*plugin); + assert_ne!(token, 0); + assert_eq!((ext.try_begin)(&*plugin), 0); + assert_eq!((ext.flush)(&*plugin, token, &input, &output), 2); + assert!((ext.end)(&*plugin, token)); + } + assert_eq!(wrapper.plugin.lock().params.value.value(), 0.5); + assert_eq!( + received, + [ + CLAP_EVENT_PARAM_GESTURE_BEGIN, + CLAP_EVENT_PARAM_VALUE, + CLAP_EVENT_PARAM_GESTURE_END + ] + ); + assert!(wrapper.output_parameter_events.is_empty()); +} +#[test] +fn rejected_batch_applies_nothing_and_audio_output_defers_without_consuming() { + let host = host(); + let wrapper = unsafe { Wrapper::>::new(&host) }; + let plugin = wrapper.clap_plugin.borrow(); + let id = wrapper.param_id_to_hash["value"]; + let mut events = vec![value(id, 0.8), value(id, 0.9)]; + events[1].header.size = mem::size_of::() as u32; + let input = clap_input_events { + ctx: &events as *const _ as *mut _, + size: Some(input_size), + get: Some(input_get), + }; + let mut received = Vec::new(); + let output = clap_output_events { + ctx: &mut received as *mut Vec as *mut _, + try_push: Some(output_push), + }; + wrapper + .output_parameter_events + .push(OutputParamEvent::SetValue { + param_hash: id, + clap_plain_value: 0.6, + }) + .unwrap(); + let ext = &Wrapper::>::AAX_PARAMETER_DELIVERY; + let token = unsafe { (ext.try_begin)(&*plugin) }; + assert_ne!(token, 0); + unsafe { + assert_eq!((ext.flush)(&*plugin, token, &input, &output), 0); + wrapper.handle_out_events(&output, 0, 64); + } + assert_eq!(wrapper.plugin.lock().params.value.value(), 0.25); + assert!(received.is_empty()); + assert_eq!(wrapper.output_parameter_events.len(), 1); + unsafe { + assert!((ext.end)(&*plugin, token)); + wrapper.handle_out_events(&output, 0, 64); + } + assert_eq!(wrapper.plugin.lock().params.value.value(), 0.6); + assert_eq!(received, [CLAP_EVENT_PARAM_VALUE]); +} + +#[test] +fn failed_stale_and_cross_thread_tokens_cannot_release_another_owner() { + let host = host(); + let wrapper = unsafe { Wrapper::>::new(&host) }; + let ext = &Wrapper::>::AAX_PARAMETER_DELIVERY; + let plugin = wrapper.clap_plugin.borrow(); + let first = unsafe { (ext.try_begin)(&*plugin) }; + assert_ne!(first, 0); + assert_eq!(unsafe { (ext.try_begin)(&*plugin) }, 0); + assert!(!unsafe { (ext.end)(&*plugin, 0) }); + thread::scope(|scope| { + scope + .spawn(|| { + let plugin = wrapper.clap_plugin.borrow(); + assert!(!unsafe { (ext.end)(&*plugin, first) }); + assert_eq!(unsafe { (ext.try_begin)(&*plugin) }, 0); + }) + .join() + .unwrap(); + }); + assert!(unsafe { (ext.end)(&*plugin, first) }); + let second = unsafe { (ext.try_begin)(&*plugin) }; + assert!(second > first); + assert!(!unsafe { (ext.end)(&*plugin, first) }); + assert_eq!(unsafe { (ext.try_begin)(&*plugin) }, 0); + assert!(unsafe { (ext.end)(&*plugin, second) }); +} + +#[test] +fn rejected_output_is_retained_in_order_without_replaying_accepted_gestures() { + struct Sink { + accepted: Vec, + reject_value_once: bool, + } + unsafe extern "C" fn push( + out: *const clap_output_events, + event: *const clap_event_header, + ) -> bool { + let sink = &mut *((*out).ctx as *mut Sink); + if (*event).type_ == CLAP_EVENT_PARAM_VALUE && sink.reject_value_once { + sink.reject_value_once = false; + return false; + } + sink.accepted.push((*event).type_); + true + } + let host = host(); + let wrapper = unsafe { Wrapper::>::new(&host) }; + let plugin = wrapper.clap_plugin.borrow(); + let id = wrapper.param_id_to_hash["value"]; + let events = Vec::::new(); + let input = clap_input_events { + ctx: &events as *const _ as *mut _, + size: Some(input_size), + get: Some(input_get), + }; + let mut sink = Sink { + accepted: Vec::new(), + reject_value_once: true, + }; + let output = clap_output_events { + ctx: &mut sink as *mut _ as *mut _, + try_push: Some(push), + }; + for event in [ + OutputParamEvent::BeginGesture { param_hash: id }, + OutputParamEvent::SetValue { + param_hash: id, + clap_plain_value: 0.6, + }, + OutputParamEvent::EndGesture { param_hash: id }, + ] { + wrapper.output_parameter_events.push(event).unwrap(); + } + let ext = &Wrapper::>::AAX_PARAMETER_DELIVERY; + unsafe { + let token = (ext.try_begin)(&*plugin); + assert_ne!(token, 0); + assert_eq!((ext.flush)(&*plugin, token, &input, &output), 1); + assert_eq!(sink.accepted, [CLAP_EVENT_PARAM_GESTURE_BEGIN]); + assert!(wrapper.pending_parameter_output.borrow().is_some()); + assert_eq!(wrapper.output_parameter_events.len(), 1); + assert_eq!((ext.flush)(&*plugin, token, &input, &output), 2); + assert!((ext.end)(&*plugin, token)); + } + assert_eq!( + sink.accepted, + [ + CLAP_EVENT_PARAM_GESTURE_BEGIN, + CLAP_EVENT_PARAM_VALUE, + CLAP_EVENT_PARAM_GESTURE_END + ] + ); + assert!(wrapper.pending_parameter_output.borrow().is_none()); + assert!(wrapper.output_parameter_events.is_empty()); +} + +#[test] +fn actual_process_completes_while_background_parameter_output_is_blocked() { + use clap_sys::audio_buffer::clap_audio_buffer; + use std::sync::mpsc; + + struct BlockingSink { + entered: mpsc::Sender<()>, + release: mpsc::Receiver<()>, + } + unsafe extern "C" fn hold_output( + output: *const clap_output_events, + _: *const clap_event_header, + ) -> bool { + let sink = &*((*output).ctx as *const BlockingSink); + sink.entered.send(()).unwrap(); + sink.release.recv().unwrap(); + true + } + let host = host(); + let wrapper = unsafe { Wrapper::>::new(&host) }; + { + let plugin = wrapper.clap_plugin.borrow(); + unsafe { + assert!((plugin.init.unwrap())(&*plugin)); + assert!((plugin.activate.unwrap())(&*plugin, 48000.0, 64, 64)); + assert!((plugin.start_processing.unwrap())(&*plugin)); + } + } + let id = wrapper.param_id_to_hash["value"]; + wrapper + .output_parameter_events + .push(OutputParamEvent::SetValue { + param_hash: id, + clap_plain_value: 0.6, + }) + .unwrap(); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (audio_tx, audio_rx) = mpsc::channel(); + let completed_while_held = thread::scope(|scope| { + let background = scope.spawn(|| { + let plugin = wrapper.clap_plugin.borrow(); + let events = Vec::::new(); + let input = clap_input_events { + ctx: &events as *const _ as *mut _, + size: Some(input_size), + get: Some(input_get), + }; + let sink = BlockingSink { + entered: entered_tx, + release: release_rx, + }; + let output = clap_output_events { + ctx: &sink as *const _ as *mut _, + try_push: Some(hold_output), + }; + let ext = &Wrapper::>::AAX_PARAMETER_DELIVERY; + unsafe { + let token = (ext.try_begin)(&*plugin); + assert_ne!(token, 0); + let result = (ext.flush)(&*plugin, token, &input, &output); + assert!((ext.end)(&*plugin, token)); + result + } + }); + // If setup fails, release and join before asserting so a failed test cannot hang. + let entered = entered_rx.recv_timeout(Duration::from_secs(2)).is_ok(); + let audio = scope.spawn(|| { + let plugin = wrapper.clap_plugin.borrow(); + let mut input = [0.5f32; 64]; + let mut output = [-99.0f32; 64]; + let mut ip = input.as_mut_ptr(); + let mut op = output.as_mut_ptr(); + let in_buffer = clap_audio_buffer { + data32: &mut ip, + data64: ptr::null_mut(), + channel_count: 1, + latency: 0, + constant_mask: 0, + }; + let mut out_buffer = clap_audio_buffer { + data32: &mut op, + data64: ptr::null_mut(), + channel_count: 1, + latency: 0, + constant_mask: 0, + }; + let mut received = Vec::::new(); + let events = clap_output_events { + ctx: &mut received as *mut _ as *mut _, + try_push: Some(output_push), + }; + let process = clap_process { + steady_time: 0, + frames_count: 64, + transport: ptr::null(), + audio_inputs: &in_buffer, + audio_outputs: &mut out_buffer, + audio_inputs_count: 1, + audio_outputs_count: 1, + in_events: ptr::null(), + out_events: &events, + }; + let result = unsafe { (plugin.process.unwrap())(&*plugin, &process) }; + audio_tx.send((result, output)).unwrap(); + }); + let completed = audio_rx.recv_timeout(Duration::from_secs(2)).ok(); + release_tx.send(()).unwrap(); + let flush_result = background.join().unwrap(); + audio.join().unwrap(); + assert!(entered, "background output callback did not enter"); + assert_eq!(flush_result, 2); + completed + }); + let (status, output) = + completed_while_held.expect("DSP waited for background parameter output"); + assert_ne!(status, CLAP_PROCESS_ERROR); + assert!(output.iter().all(|sample| (*sample - 0.3).abs() < 1e-6)); + let plugin = wrapper.clap_plugin.borrow(); + unsafe { + (plugin.stop_processing.unwrap())(&*plugin); + (plugin.deactivate.unwrap())(&*plugin); + } +} + +#[test] +fn out_of_range_and_nonfinite_values_reject_the_entire_input_batch() { + let host = host(); + let wrapper = unsafe { Wrapper::>::new(&host) }; + let plugin = wrapper.clap_plugin.borrow(); + let id = wrapper.param_id_to_hash["value"]; + let ext = &Wrapper::>::AAX_PARAMETER_DELIVERY; + let mut received = Vec::::new(); + let output = clap_output_events { + ctx: &mut received as *mut _ as *mut _, + try_push: Some(output_push), + }; + for invalid in [ + f64::MAX, + -f64::MAX, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NAN, + -0.1, + 1.0 + f64::EPSILON, + ] { + let events = vec![value(id, 0.8), value(id, invalid)]; + let input = clap_input_events { + ctx: &events as *const _ as *mut _, + size: Some(input_size), + get: Some(input_get), + }; + unsafe { + let token = (ext.try_begin)(&*plugin); + assert_ne!(token, 0); + assert_eq!((ext.flush)(&*plugin, token, &input, &output), 0); + assert!((ext.end)(&*plugin, token)); + } + assert_eq!(wrapper.plugin.lock().params.value.value(), 0.25); + assert!(received.is_empty()); + } +} diff --git a/src/wrapper/clap/aax_params.rs b/src/wrapper/clap/aax_params.rs new file mode 100644 index 000000000..206d363d0 --- /dev/null +++ b/src/wrapper/clap/aax_params.rs @@ -0,0 +1,53 @@ +//! Intrect AAX-only parameter delivery, separate from standard CLAP `params.flush`. +//! +//! A host must serialize all incoming parameter batches against this extension. While it owns +//! this gate, concurrent `process` calls may contain audio/transport but no parameter/modulation +//! input events. Lifecycle/state callbacks must not overlap this delivery. Output parameter +//! values/gestures use the same gate in `process`, which skips the drain instead of waiting. +//! No audio or MIDI event buffers are accessed by the extension. + +use clap_sys::events::{clap_input_events, clap_output_events}; +use clap_sys::plugin::clap_plugin; +use std::ffi::CStr; +use std::sync::atomic::{AtomicBool, Ordering}; + +pub const AAX_PARAMETER_DELIVERY_ID: &CStr = + unsafe { CStr::from_bytes_with_nul_unchecked(b"io.intrect.aax-parameter-delivery/1\0") }; + +/// Private extension ABI. All callbacks use the CLAP C calling convention. +/// +/// # Safety +/// `try_begin` is called only on the host's serialized background parameter service. If it +/// returns a nonzero token, that caller must call `end` exactly once with that token on the same thread, including on a +/// failed `flush`. A zero token indicates failed acquisition. Stale tokens and calls from another thread are rejected. Input events must remain +/// stable throughout `flush`, and output callbacks must accept only parameter values/gestures. +/// `flush` returns 0 for invalid/unowned input (retain the input), 1 for consumed input with +/// parameter output still pending (discard the input and retry output), or 2 for complete delivery. +/// Rejected output stays queued ahead of later GUI events; accepted events are not replayed. +#[repr(C)] +pub struct AaxParameterDelivery { + pub try_begin: unsafe extern "C" fn(*const clap_plugin) -> u64, + pub flush: unsafe extern "C" fn( + *const clap_plugin, + u64, + *const clap_input_events, + *const clap_output_events, + ) -> u32, + pub end: unsafe extern "C" fn(*const clap_plugin, u64) -> bool, +} + +pub(super) struct ParameterDeliveryGuard<'a>(&'a AtomicBool); + +impl<'a> ParameterDeliveryGuard<'a> { + pub(super) fn try_acquire(busy: &'a AtomicBool) -> Option { + busy.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .ok() + .map(|_| Self(busy)) + } +} + +impl Drop for ParameterDeliveryGuard<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} diff --git a/src/wrapper/clap/wrapper.rs b/src/wrapper/clap/wrapper.rs index fbe452aa4..21fca9d8f 100644 --- a/src/wrapper/clap/wrapper.rs +++ b/src/wrapper/clap/wrapper.rs @@ -22,9 +22,6 @@ use clap_sys::ext::audio_ports::{ use clap_sys::ext::audio_ports_config::{ clap_audio_ports_config, clap_plugin_audio_ports_config, CLAP_EXT_AUDIO_PORTS_CONFIG, }; -use clap_sys::ext::remote_controls::{ - clap_plugin_remote_controls, clap_remote_controls_page, CLAP_EXT_REMOTE_CONTROLS, -}; use clap_sys::ext::gui::{ clap_gui_resize_hints, clap_host_gui, clap_plugin_gui, clap_window, CLAP_EXT_GUI, CLAP_WINDOW_API_COCOA, CLAP_WINDOW_API_WIN32, CLAP_WINDOW_API_X11, @@ -40,6 +37,9 @@ use clap_sys::ext::params::{ CLAP_PARAM_IS_MODULATABLE, CLAP_PARAM_IS_MODULATABLE_PER_NOTE_ID, CLAP_PARAM_IS_READONLY, CLAP_PARAM_IS_STEPPED, CLAP_PARAM_RESCAN_VALUES, }; +use clap_sys::ext::remote_controls::{ + clap_plugin_remote_controls, clap_remote_controls_page, CLAP_EXT_REMOTE_CONTROLS, +}; use clap_sys::ext::render::{ clap_plugin_render, clap_plugin_render_mode, CLAP_EXT_RENDER, CLAP_RENDER_OFFLINE, CLAP_RENDER_REALTIME, @@ -72,11 +72,12 @@ use std::mem; use std::num::NonZeroU32; use std::os::raw::c_char; use std::ptr::NonNull; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Weak}; use std::thread::{self, ThreadId}; use std::time::Duration; +use super::aax_params::{AaxParameterDelivery, ParameterDeliveryGuard, AAX_PARAMETER_DELIVERY_ID}; use super::context::{WrapperGuiContext, WrapperInitContext, WrapperProcessContext}; use super::descriptor::PluginDescriptor; use super::util::ClapPtr; @@ -222,6 +223,11 @@ pub struct Wrapper { /// even if it does then that should still not be a problem because the host also reads it /// in the same order, right? output_parameter_events: ArrayQueue, + pending_parameter_output: AtomicRefCell>, + parameter_delivery_busy: AtomicBool, + parameter_delivery_nonce: AtomicU64, + parameter_delivery_token: AtomicU64, + parameter_delivery_owner: AtomicCell>, host_thread_check: AtomicRefCell>>, @@ -642,6 +648,11 @@ impl Wrapper

{ param_ptr_to_hash, poly_mod_ids_by_hash, output_parameter_events: ArrayQueue::new(OUTPUT_EVENT_QUEUE_CAPACITY), + pending_parameter_output: AtomicRefCell::new(None), + parameter_delivery_busy: AtomicBool::new(false), + parameter_delivery_nonce: AtomicU64::new(0), + parameter_delivery_token: AtomicU64::new(0), + parameter_delivery_owner: AtomicCell::new(None), host_thread_check: AtomicRefCell::new(None), @@ -982,72 +993,21 @@ impl Wrapper

{ current_sample_idx: usize, total_buffer_len: usize, ) { - // We'll always write these events to the first sample, so even when we add note output we - // shouldn't have to think about interleaving events here - let sample_rate = self.current_buffer_config.load().map(|c| c.sample_rate); - while let Some(change) = self.output_parameter_events.pop() { - let push_successful = match change { - OutputParamEvent::BeginGesture { param_hash } => { - let event = clap_event_param_gesture { - header: clap_event_header { - size: mem::size_of::() as u32, - time: current_sample_idx as u32, - space_id: CLAP_CORE_EVENT_SPACE_ID, - type_: CLAP_EVENT_PARAM_GESTURE_BEGIN, - flags: CLAP_EVENT_IS_LIVE, - }, - param_id: param_hash, - }; - - clap_call! { out=>try_push(out, &event.header) } - } - OutputParamEvent::SetValue { - param_hash, - clap_plain_value, - } => { - self.update_plain_value_by_hash( - param_hash, - ClapParamUpdate::PlainValueSet(clap_plain_value), - sample_rate, - ); - - let event = clap_event_param_value { - header: clap_event_header { - size: mem::size_of::() as u32, - time: current_sample_idx as u32, - space_id: CLAP_CORE_EVENT_SPACE_ID, - type_: CLAP_EVENT_PARAM_VALUE, - flags: CLAP_EVENT_IS_LIVE, - }, - param_id: param_hash, - cookie: std::ptr::null_mut(), - port_index: -1, - note_id: -1, - channel: -1, - key: -1, - value: clap_plain_value, - }; - - clap_call! { out=>try_push(out, &event.header) } - } - OutputParamEvent::EndGesture { param_hash } => { - let event = clap_event_param_gesture { - header: clap_event_header { - size: mem::size_of::() as u32, - time: current_sample_idx as u32, - space_id: CLAP_CORE_EVENT_SPACE_ID, - type_: CLAP_EVENT_PARAM_GESTURE_END, - flags: CLAP_EVENT_IS_LIVE, - }, - param_id: param_hash, - }; - - clap_call! { out=>try_push(out, &event.header) } + // An opted-in AAX host may drain only parameter output on a background thread. + // The audio path tries once and keeps processing notes/audio if that drain owns the gate. + let guard = if P::CLAP_AAX_CONCURRENT_PARAMETER_DELIVERY { + ParameterDeliveryGuard::try_acquire(&self.parameter_delivery_busy) + } else { + None + }; + if !P::CLAP_AAX_CONCURRENT_PARAMETER_DELIVERY || guard.is_some() { + if !self.handle_out_parameter_events(out, current_sample_idx) { + if let Some(host_params) = &*self.host_params.borrow() { + clap_call! { host_params=>request_flush(&*self.host_callback) }; } - }; - - nih_debug_assert!(push_successful); + } } + drop(guard); // Also send all note events generated by the plugin let mut output_events = self.output_events.borrow_mut(); @@ -2354,6 +2314,14 @@ impl Wrapper

{ &wrapper.clap_plugin_note_ports as *const _ as *const c_void } else if id == CLAP_EXT_PARAMS { &wrapper.clap_plugin_params as *const _ as *const c_void + } else if id == AAX_PARAMETER_DELIVERY_ID + && P::CLAP_AAX_CONCURRENT_PARAMETER_DELIVERY + && !P::SAMPLE_ACCURATE_AUTOMATION + && P::MIDI_INPUT == MidiConfig::None + && P::MIDI_OUTPUT == MidiConfig::None + && wrapper.poly_mod_ids_by_hash.is_empty() + { + &Self::AAX_PARAMETER_DELIVERY as *const _ as *const c_void } else if id == CLAP_EXT_REMOTE_CONTROLS { &wrapper.clap_plugin_remote_controls as *const _ as *const c_void } else if id == CLAP_EXT_RENDER { @@ -3053,6 +3021,208 @@ impl Wrapper

{ } } + /// Drain only parameter gestures and values; the caller owns the parameter delivery gate. + /// Never touches process input/output note buffers or the mutable plugin instance. + unsafe fn handle_out_parameter_events( + &self, + out: &clap_output_events, + current_sample_idx: usize, + ) -> bool { + // We'll always write these events to the first sample, so even when we add note output we + // shouldn't have to think about interleaving events here + let sample_rate = self.current_buffer_config.load().map(|c| c.sample_rate); + let mut pending = self.pending_parameter_output.borrow_mut(); + while let Some(change) = pending + .take() + .or_else(|| self.output_parameter_events.pop()) + { + let push_successful = match change.clone() { + OutputParamEvent::BeginGesture { param_hash } => { + let event = clap_event_param_gesture { + header: clap_event_header { + size: mem::size_of::() as u32, + time: current_sample_idx as u32, + space_id: CLAP_CORE_EVENT_SPACE_ID, + type_: CLAP_EVENT_PARAM_GESTURE_BEGIN, + flags: CLAP_EVENT_IS_LIVE, + }, + param_id: param_hash, + }; + + clap_call! { out=>try_push(out, &event.header) } + } + OutputParamEvent::SetValue { + param_hash, + clap_plain_value, + } => { + self.update_plain_value_by_hash( + param_hash, + ClapParamUpdate::PlainValueSet(clap_plain_value), + sample_rate, + ); + + let event = clap_event_param_value { + header: clap_event_header { + size: mem::size_of::() as u32, + time: current_sample_idx as u32, + space_id: CLAP_CORE_EVENT_SPACE_ID, + type_: CLAP_EVENT_PARAM_VALUE, + flags: CLAP_EVENT_IS_LIVE, + }, + param_id: param_hash, + cookie: std::ptr::null_mut(), + port_index: -1, + note_id: -1, + channel: -1, + key: -1, + value: clap_plain_value, + }; + + clap_call! { out=>try_push(out, &event.header) } + } + OutputParamEvent::EndGesture { param_hash } => { + let event = clap_event_param_gesture { + header: clap_event_header { + size: mem::size_of::() as u32, + time: current_sample_idx as u32, + space_id: CLAP_CORE_EVENT_SPACE_ID, + type_: CLAP_EVENT_PARAM_GESTURE_END, + flags: CLAP_EVENT_IS_LIVE, + }, + param_id: param_hash, + }; + + clap_call! { out=>try_push(out, &event.header) } + } + }; + + if !push_successful { + // Keep the rejected event ahead of later gestures/values on either drain thread. + *pending = Some(change); + return false; + } + } + true + } + + const AAX_PARAMETER_DELIVERY: AaxParameterDelivery = AaxParameterDelivery { + try_begin: Self::aax_parameter_delivery_try_begin, + flush: Self::aax_parameter_delivery_flush, + end: Self::aax_parameter_delivery_end, + }; + + unsafe extern "C" fn aax_parameter_delivery_try_begin(plugin: *const clap_plugin) -> u64 { + check_null_ptr!(0, plugin, (*plugin).plugin_data); + let wrapper = &*((*plugin).plugin_data as *const Self); + if wrapper + .parameter_delivery_busy + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + return 0; + } + let Ok(previous) = wrapper.parameter_delivery_nonce.fetch_update( + Ordering::Relaxed, + Ordering::Relaxed, + |value| value.checked_add(1), + ) else { + wrapper + .parameter_delivery_busy + .store(false, Ordering::Release); + return 0; + }; + let token = previous + 1; + wrapper + .parameter_delivery_owner + .store(Some(thread::current().id())); + wrapper + .parameter_delivery_token + .store(token, Ordering::Release); + token + } + + fn owns_aax_parameter_delivery(&self, token: u64) -> bool { + token != 0 + && self.parameter_delivery_busy.load(Ordering::Acquire) + && self.parameter_delivery_token.load(Ordering::Acquire) == token + && self.parameter_delivery_owner.load() == Some(thread::current().id()) + } + + unsafe extern "C" fn aax_parameter_delivery_flush( + plugin: *const clap_plugin, + token: u64, + in_: *const clap_input_events, + out: *const clap_output_events, + ) -> u32 { + check_null_ptr!(0, plugin, (*plugin).plugin_data, in_, out); + let wrapper = &*((*plugin).plugin_data as *const Self); + if !wrapper.owns_aax_parameter_delivery(token) { + return 0; + } + // Validate the whole batch before applying it. This extension is not a general flush. + let count = clap_call! { in_=>size(in_) }; + for index in 0..count { + let event = clap_call! { in_=>get(in_, index) }; + if event.is_null() + || (*event).space_id != CLAP_CORE_EVENT_SPACE_ID + || (*event).type_ != CLAP_EVENT_PARAM_VALUE + || (*event).size < mem::size_of::() as u32 + { + return 0; + } + let value = &*(event as *const clap_event_param_value); + if value.note_id != -1 + || value.port_index != -1 + || value.channel != -1 + || value.key != -1 + || !value.value.is_finite() + { + return 0; + } + let Some(parameter) = wrapper.param_by_hash.get(&value.param_id) else { + return 0; + }; + // Match the advertised CLAP range, before narrowing to f32. This also + // excludes finite f64 values that would overflow the atomic parameter. + let maximum = parameter.step_count().unwrap_or(1) as f64; + if maximum == 0.0 || value.value < 0.0 || value.value > maximum { + return 0; + } + } + let sample_rate = wrapper.current_buffer_config.load().map(|c| c.sample_rate); + for index in 0..count { + let event = clap_call! { in_=>get(in_, index) }; + let value = &*(event as *const clap_event_param_value); + wrapper.update_plain_value_by_hash( + value.param_id, + ClapParamUpdate::PlainValueSet(value.value), + sample_rate, + ); + } + if wrapper.handle_out_parameter_events(&*out, 0) { + 2 + } else { + 1 + } + } + + unsafe extern "C" fn aax_parameter_delivery_end( + plugin: *const clap_plugin, + token: u64, + ) -> bool { + check_null_ptr!(false, plugin, (*plugin).plugin_data); + let wrapper = &*((*plugin).plugin_data as *const Self); + if !wrapper.owns_aax_parameter_delivery(token) { + return false; + } + wrapper.parameter_delivery_owner.store(None); + wrapper.parameter_delivery_token.store(0, Ordering::Release); + wrapper + .parameter_delivery_busy + .store(false, Ordering::Release); + true + } + unsafe extern "C" fn ext_params_flush( plugin: *const clap_plugin, in_: *const clap_input_events, @@ -3270,3 +3440,7 @@ unsafe fn query_host_extension( None } } + +#[cfg(test)] +#[path = "aax_parameter_tests.rs"] +mod aax_parameter_tests; From d727d4e2bb7aeb3ca16abaa10dada462ccdaf37a Mon Sep 17 00:00:00 2001 From: unohee Date: Sun, 4 Oct 2026 12:58:10 +0900 Subject: [PATCH 3/4] Run public repository CodeQL on GitHub-hosted Ubuntu --- .github/workflows/codeql.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7469d12ef..7b04c4d78 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -19,12 +19,11 @@ permissions: jobs: analyze: name: Analyze (${{ matrix.language }}) - runs-on: [self-hosted, Linux, X64, codeql-mini] + runs-on: ubuntu-latest timeout-minutes: 30 strategy: fail-fast: false - # One CodeQL language at a time: this host has 4 cores and a single - # codeql-mini runner, so a parallel matrix starves ordinary CI jobs. + # Analyze one language at a time to limit concurrent analysis jobs. max-parallel: 1 matrix: language: From df6852c76564ba164aa9dfe802b7d090d61cd8db Mon Sep 17 00:00:00 2001 From: unohee Date: Sun, 4 Oct 2026 13:17:30 +0900 Subject: [PATCH 4/4] Accept zero-width CLAP parameter ranges in AAX delivery --- src/wrapper/clap/aax_parameter_tests.rs | 101 +++++++++++++++++++++++- src/wrapper/clap/wrapper.rs | 16 ++-- 2 files changed, 108 insertions(+), 9 deletions(-) diff --git a/src/wrapper/clap/aax_parameter_tests.rs b/src/wrapper/clap/aax_parameter_tests.rs index 0e04e5c30..3aca4fbc9 100644 --- a/src/wrapper/clap/aax_parameter_tests.rs +++ b/src/wrapper/clap/aax_parameter_tests.rs @@ -1,13 +1,23 @@ use super::*; -use crate::prelude::{ClapFeature, FloatParam, FloatRange, Param, ProcessContext}; +use crate::prelude::{ClapFeature, Enum, EnumParam, FloatParam, FloatRange, Param, ProcessContext}; use std::ptr; +#[derive(Enum, Debug, PartialEq, Clone, Copy)] +enum SingleVariant { + Only, +} + struct TestParams { value: FloatParam, + single: Option>, } unsafe impl Params for TestParams { fn param_map(&self) -> Vec<(String, ParamPtr, String)> { - vec![("value".into(), self.value.as_ptr(), String::new())] + let mut params = vec![("value".into(), self.value.as_ptr(), String::new())]; + if let Some(single) = &self.single { + params.push(("single".into(), single.as_ptr(), String::new())); + } + params } } struct TestPlugin { @@ -18,10 +28,97 @@ impl Default for TestPlugin { Self { params: Arc::new(TestParams { value: FloatParam::new("Value", 0.25, FloatRange::Linear { min: 0.0, max: 1.0 }), + // IntRange rejects zero-width ranges in debug tests, but release + // EnumParam still advertises its single variant as CLAP [0, 0]. + single: if cfg!(debug_assertions) { + None + } else { + Some(EnumParam::new("Single", SingleVariant::Only)) + }, }), } } } + +#[cfg(not(debug_assertions))] +#[test] +fn zero_width_advertised_enum_accepts_zero_and_rejects_invalid_batches() { + let host = host(); + let wrapper = unsafe { Wrapper::>::new(&host) }; + let plugin = wrapper.clap_plugin.borrow(); + let value_id = wrapper.param_id_to_hash["value"]; + let single_id = wrapper.param_id_to_hash["single"]; + let ext = &Wrapper::>::AAX_PARAMETER_DELIVERY; + let mut info = unsafe { mem::zeroed::() }; + assert!(unsafe { Wrapper::>::ext_params_get_info(&*plugin, 1, &mut info) }); + assert_eq!(info.id, single_id); + assert_eq!( + (info.min_value, info.max_value, info.default_value), + (0.0, 0.0, 0.0) + ); + assert_eq!( + wrapper + .plugin + .lock() + .params + .single + .as_ref() + .unwrap() + .step_count(), + Some(0) + ); + let mut received = Vec::::new(); + let output = clap_output_events { + ctx: &mut received as *mut _ as *mut _, + try_push: Some(output_push), + }; + for zero in [0.0, -0.0] { + let events = vec![value(value_id, 0.8), value(single_id, zero)]; + let input = clap_input_events { + ctx: &events as *const _ as *mut _, + size: Some(input_size), + get: Some(input_get), + }; + unsafe { + let token = (ext.try_begin)(&*plugin); + assert_ne!(token, 0); + assert_eq!((ext.flush)(&*plugin, token, &input, &output), 2); + assert!((ext.end)(&*plugin, token)); + } + let params = wrapper.plugin.lock().params.clone(); + assert_eq!(params.value.value(), 0.8); + let single = params.single.as_ref().unwrap(); + assert_eq!(single.value(), SingleVariant::Only); + assert_eq!(single.unmodulated_normalized_value(), 0.0); + let mut plain = f64::NAN; + assert!(unsafe { + Wrapper::>::ext_params_get_value(&*plugin, single_id, &mut plain) + }); + assert_eq!(plain, 0.0); + } + for invalid in [ + f64::EPSILON, + -f64::EPSILON, + f64::NAN, + f64::INFINITY, + f64::NEG_INFINITY, + ] { + let events = vec![value(value_id, 0.2), value(single_id, invalid)]; + let input = clap_input_events { + ctx: &events as *const _ as *mut _, + size: Some(input_size), + get: Some(input_get), + }; + unsafe { + let token = (ext.try_begin)(&*plugin); + assert_ne!(token, 0); + assert_eq!((ext.flush)(&*plugin, token, &input, &output), 0); + assert!((ext.end)(&*plugin, token)); + } + assert_eq!(wrapper.plugin.lock().params.value.value(), 0.8); + } + assert!(received.is_empty()); +} impl Plugin for TestPlugin { const NAME: &'static str = "AAX delivery fixture"; const VENDOR: &'static str = "Intrect"; diff --git a/src/wrapper/clap/wrapper.rs b/src/wrapper/clap/wrapper.rs index 21fca9d8f..404298dc8 100644 --- a/src/wrapper/clap/wrapper.rs +++ b/src/wrapper/clap/wrapper.rs @@ -837,8 +837,10 @@ impl Wrapper

{ Some(param_ptr) => { match update_type { ClapParamUpdate::PlainValueSet(clap_plain_value) => { + // Single-variant enums advertise [0, 0]. A unit divisor + // keeps their sole plain value normalized without 0/0. let normalized_value = clap_plain_value as f32 - / unsafe { param_ptr.step_count() }.unwrap_or(1) as f32; + / unsafe { param_ptr.step_count() }.unwrap_or(1).max(1) as f32; if unsafe { param_ptr.set_normalized_value(normalized_value) } { if let Some(sample_rate) = sample_rate { @@ -859,7 +861,7 @@ impl Wrapper

{ } ClapParamUpdate::PlainValueMod(clap_plain_delta) => { let normalized_delta = clap_plain_delta as f32 - / unsafe { param_ptr.step_count() }.unwrap_or(1) as f32; + / unsafe { param_ptr.step_count() }.unwrap_or(1).max(1) as f32; if unsafe { param_ptr.modulate_value(normalized_delta) } { if let Some(sample_rate) = sample_rate { @@ -1401,7 +1403,7 @@ impl Wrapper

{ // integer or enum parameters let param_ptr = self.param_by_hash[&event.param_id]; let normalized_value = - event.value as f32 / param_ptr.step_count().unwrap_or(1) as f32; + event.value as f32 / param_ptr.step_count().unwrap_or(1).max(1) as f32; input_events.push_back(NoteEvent::MonoAutomation { timing, @@ -1419,8 +1421,8 @@ impl Wrapper

{ // The modulation offset needs to be normalized to account for modulated // integer or enum parameters let param_ptr = self.param_by_hash[&event.param_id]; - let normalized_offset = - event.amount as f32 / param_ptr.step_count().unwrap_or(1) as f32; + let normalized_offset = event.amount as f32 + / param_ptr.step_count().unwrap_or(1).max(1) as f32; // The host may also add key and channel information here, but it may // also pass -1. So not having that information here at all seems like @@ -2982,7 +2984,7 @@ impl Wrapper

{ dest, // CLAP does not have a separate unit, so we'll include the unit here ¶m_ptr.normalized_value_to_string( - value as f32 / param_ptr.step_count().unwrap_or(1) as f32, + value as f32 / param_ptr.step_count().unwrap_or(1).max(1) as f32, true, ), ); @@ -3185,7 +3187,7 @@ impl Wrapper

{ // Match the advertised CLAP range, before narrowing to f32. This also // excludes finite f64 values that would overflow the atomic parameter. let maximum = parameter.step_count().unwrap_or(1) as f64; - if maximum == 0.0 || value.value < 0.0 || value.value > maximum { + if value.value < 0.0 || value.value > maximum { return 0; } }