Files
soundcraft/crates/vst3-host/src/plugin.rs
T
JonRC 576ff36f61 Tell hosted plugins when they render offline
Bounces ran VST3 plugins with processMode kRealtime and never set
clap.render, so plugins that work differently offline never could.
Offline renders now create their plugins with Plugin::set_offline(true):
VST3 plugins get kOffline in setupProcessing and in every process call,
and CLAP plugins with clap.render are set to CLAP_RENDER_OFFLINE.
Playback stays realtime.

Fixes #1289.
2026-11-02 12:34:32 -03:00

578 lines
22 KiB
Rust

//! `Vst3Plugin`: a loaded VST3 instance behind the safe `soundcraft_dsp::Plugin` trait.
use crate::ffi::{AFTERTOUCH, BusBuffers, EVENT_CAP, EditorLink, HostContext, Instance, NoteEvent, PARAM_QUEUE_CAP, PITCH_BEND, ProcessInput};
use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport, Unit};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use vst3::ComWrapper;
/// Largest block we set a plugin up for.
const MAX_BLOCK: usize = 1 << 16;
/// Most channels a host strip may ask for.
const MAX_CHANNELS: usize = 64;
/// An `unsynced` slot with no value waiting (a NaN pattern, which `set_param` never stores).
const SYNCED: u64 = u64::MAX;
/// How a SoundCraft parameter value maps to the VST3 normalized value.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Mapping {
/// Toggle/choice index `0..=steps` ↔ `index / steps`.
Stepped(f64),
/// Plain value through the controller's `plainParamToNormalized`.
Plain,
/// Linear over the info range (the controller gave no usable plain range).
Linear,
}
/// A hosted VST3 plugin. Parameter ids are VST3 `ParamID`s as decimal strings ("0", "17", …);
/// values are plain (display) values with their names and ranges in `info().params`.
pub struct Vst3Plugin {
inst: Instance,
info: &'static PluginInfo,
/// VST3 id of each `info.params` entry (same order).
ids: Vec<u32>,
maps: Vec<Mapping>,
values: Vec<f32>,
/// Normalized values waiting for the next block (coalesced per id; bounded).
pending: Vec<(u32, f64)>,
/// Host-set normalized values the controller has not seen yet (same order as `ids`; the bits
/// of an `f64`, or [`SYNCED`]). `set_param` may run on the audio thread, so it never calls the
/// controller; the state reader, which already does, hands them over first.
unsynced: Arc<[AtomicU64]>,
/// Pending notes, kept sorted by time. Never grows past `EVENT_CAP`.
notes: Vec<NoteEvent>,
/// The parameter each MIDI controller drives (see `Instance::midi_mapping`).
midi_map: Vec<Option<u32>>,
/// Controller points for the next block: (time, param id, normalized value). Bounded.
controls: Vec<(u32, u32, f64)>,
inputs: BusBuffers,
outputs: BusBuffers,
has_input: bool,
has_output: bool,
event_input: bool,
held: [bool; 128],
channels: usize,
max_block: usize,
latency: usize,
tail: usize,
position: i64,
transport: Option<Transport>,
}
impl Vst3Plugin {
pub(crate) fn new(inst: Instance, info: &'static PluginInfo) -> Vst3Plugin {
let ids: Vec<u32> = info.params.iter().map(|p| p.id.parse::<u32>().unwrap_or(u32::MAX)).collect();
let maps: Vec<Mapping> = info
.params
.iter()
.zip(&ids)
.map(|(p, &id)| match p.unit {
Unit::Toggle | Unit::Choice => Mapping::Stepped(f64::from(p.max.max(1.0))),
_ => {
let lo = inst.to_plain(id, 0.0);
let hi = inst.to_plain(id, 1.0);
match (lo, hi) {
(Some(lo), Some(hi))
if (lo as f32 - p.min).abs() <= 1e-6 * p.min.abs().max(1.0)
&& (hi as f32 - p.max).abs() <= 1e-6 * p.max.abs().max(1.0) =>
{
Mapping::Plain
}
_ => Mapping::Linear,
}
}
})
.collect();
let mut p = Vst3Plugin {
info,
values: Vec::with_capacity(ids.len()),
pending: Vec::with_capacity(ids.len().min(PARAM_QUEUE_CAP)),
unsynced: ids.iter().map(|_| AtomicU64::new(SYNCED)).collect(),
notes: Vec::with_capacity(EVENT_CAP),
midi_map: Vec::new(),
controls: Vec::with_capacity(EVENT_CAP),
inputs: BusBuffers::new(&[], 0),
outputs: BusBuffers::new(&[], 0),
has_input: false,
has_output: false,
event_input: false,
held: [false; 128],
channels: 2,
max_block: 0,
latency: 0,
tail: 0,
position: 0,
transport: None,
ids,
maps,
inst,
};
let values: Vec<f32> = (0..p.ids.len())
.map(|i| {
let (Some(pi), Some(&id)) = (p.info.params.get(i), p.ids.get(i)) else { return 0.0 };
p.inst.normalized_value(id).and_then(|n| p.plain_from_normalized(i, n)).map_or(pi.default, |v| pi.clamp(v))
})
.collect();
p.values = values;
p
}
fn plain_from_normalized(&self, i: usize, n: f64) -> Option<f32> {
let pi = self.info.params.get(i)?;
let id = *self.ids.get(i)?;
let v = match self.maps.get(i)? {
Mapping::Stepped(steps) => (n * steps).round(),
Mapping::Plain => self.inst.to_plain(id, n)?,
Mapping::Linear => f64::from(pi.min) + n * f64::from(pi.max - pi.min),
};
v.is_finite().then_some(v as f32)
}
fn normalized_from_plain(&self, i: usize, v: f32) -> Option<f64> {
let pi = self.info.params.get(i)?;
let id = *self.ids.get(i)?;
let span = f64::from(pi.max - pi.min);
let linear = if span > 0.0 { (f64::from(v) - f64::from(pi.min)) / span } else { 0.0 };
let n = match self.maps.get(i)? {
Mapping::Stepped(steps) if *steps > 0.0 => f64::from(v) / steps,
Mapping::Stepped(_) => 0.0,
Mapping::Plain => self.inst.to_normalized(id, f64::from(v)).unwrap_or(linear),
Mapping::Linear => linear,
};
n.is_finite().then(|| n.clamp(0.0, 1.0))
}
/// The editor's size in pixels without opening it (creates and releases a view). For
/// diagnostics and tests; real plugins expect this on the main thread.
pub fn editor_size(&self) -> Result<(u32, u32), String> {
self.inst.editor().ok_or_else(|| "this plugin has no edit controller".to_string())?.probe()
}
/// Re-reads every parameter value from the controller (after a state load) and drops
/// changes still queued from before it.
fn refresh_values(&mut self) {
for i in 0..self.ids.len() {
let (Some(pi), Some(&id)) = (self.info.params.get(i), self.ids.get(i)) else { continue };
if let Some(v) = self.inst.normalized_value(id).and_then(|n| self.plain_from_normalized(i, n))
&& let Some(slot) = self.values.get_mut(i)
{
*slot = pi.clamp(v);
}
}
self.pending.clear();
for u in self.unsynced.iter() {
u.store(SYNCED, Ordering::Relaxed);
}
}
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
/// allocates on the audio thread).
fn push_note(&mut self, e: NoteEvent) {
push_sorted(&mut self.notes, e);
}
}
fn push_sorted(notes: &mut Vec<NoteEvent>, e: NoteEvent) {
if notes.len() >= notes.capacity() {
return;
}
let pos = notes.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
notes.insert(pos, e);
}
/// Keeps only the pending releases, one per key and moved to the start of the next block, so at
/// most 128 remain and `all_notes_off` always has room in the queue.
fn keep_releases(notes: &mut Vec<NoteEvent>) {
let mut seen = [false; 128];
notes.retain(|e| match *e {
NoteEvent::Off { key, .. } => seen.get_mut(usize::from(key)).is_some_and(|s| !std::mem::replace(s, true)),
_ => false,
});
for e in notes.iter_mut() {
e.set_time(0);
}
}
/// Copy `n` frames from `start` of the host buffers into the plugin's input `port` (mono sources
/// fill every port channel).
fn feed_input(inputs: &mut BusBuffers, port: usize, io: &[Vec<f32>], ch: usize, (start, n): (usize, usize)) {
for c in 0..inputs.channels(port) {
let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n));
if let (Some(src), Some(dst)) = (src, inputs.channel_mut(port, c).and_then(|d| d.get_mut(..n))) {
dst.copy_from_slice(src);
}
}
}
/// Copy `n` frames of the plugin's output `port` into the host buffers from `start`; a mono host
/// buffer takes the average of a stereo port.
fn read_output(outputs: &BusBuffers, port: usize, io: &mut [Vec<f32>], ch: usize, (start, n): (usize, usize)) {
let oc = outputs.channels(port);
for (c, dst) in io.iter_mut().take(ch).enumerate() {
let Some(dst) = dst.get_mut(start..start + n) else { continue };
if ch == 1 && oc >= 2 {
if let (Some(l), Some(r)) = (outputs.channel(port, 0), outputs.channel(port, 1)) {
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
*d = 0.5 * (a + b);
}
}
} else if let Some(src) = outputs.channel(port, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) {
dst.copy_from_slice(src);
}
}
}
impl Plugin for Vst3Plugin {
fn info(&self) -> &'static PluginInfo {
self.info
}
fn prepare(&mut self, sample_rate: f32, max_block: usize, channels: usize) {
let sr = if sample_rate.is_finite() && sample_rate > 0.0 { f64::from(sample_rate) } else { 48_000.0 };
let mb = max_block.clamp(1, MAX_BLOCK);
self.channels = channels.clamp(1, MAX_CHANNELS);
self.max_block = 0;
match self.inst.configure(sr, mb, self.channels == 1) {
Ok(layout) => {
self.inputs = BusBuffers::new(&layout.inputs, mb);
self.outputs = BusBuffers::new(&layout.outputs, mb);
self.has_input = layout.inputs.first().is_some_and(|&c| c > 0);
self.has_output = layout.outputs.first().is_some_and(|&c| c > 0);
self.event_input = layout.event_input;
self.max_block = mb;
self.latency = self.inst.latency() as usize;
self.tail = self.inst.tail() as usize;
self.midi_map = self.inst.midi_mapping();
}
Err(e) => {
log::warn!("{}: {e}", self.info.id);
self.latency = 0;
}
}
// Re-send every value so a re-activated plugin matches the session.
self.pending.clear();
for i in 0..self.ids.len().min(PARAM_QUEUE_CAP) {
if let (Some(&id), Some(n)) = (self.ids.get(i), self.values.get(i).and_then(|&v| self.normalized_from_plain(i, v))) {
self.pending.push((id, n));
}
}
}
fn set_offline(&mut self, offline: bool) {
// `processMode`, set up by the next `prepare`.
self.inst.set_offline(offline);
}
fn reset(&mut self) {
// Keep pending releases and release whatever is still held, so the processor's voices stop
// on the next block instead of sounding on while the host forgets them.
keep_releases(&mut self.notes);
self.controls.clear();
self.all_notes_off();
self.held = [false; 128];
}
fn set_param(&mut self, id: &str, value: f32) -> bool {
let Some(i) = self.info.params.iter().position(|p| p.id == id) else { return false };
let (Some(pi), Some(&vid)) = (self.info.params.get(i), self.ids.get(i)) else { return false };
let v = pi.clamp(value);
if let Some(slot) = self.values.get_mut(i) {
*slot = v;
}
let Some(n) = self.normalized_from_plain(i, v) else { return true };
if let Some(u) = self.unsynced.get(i) {
u.store(n.to_bits(), Ordering::Relaxed);
}
if let Some(e) = self.pending.iter_mut().find(|(pid, _)| *pid == vid) {
e.1 = n;
} else if self.pending.len() < self.pending.capacity() {
self.pending.push((vid, n));
}
true
}
fn param(&self, id: &str) -> Option<f32> {
let i = self.info.params.iter().position(|p| p.id == id)?;
self.values.get(i).copied()
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
if !self.inst.is_active() || self.max_block == 0 {
return;
}
let ch = self.channels.min(io.len());
let frames = io.iter().take(ch).map(Vec::len).fold(frames, usize::min);
if frames == 0 || ch == 0 {
return;
}
if self.inst.host().latency_changed.swap(false, Ordering::Relaxed) {
self.latency = self.inst.latency() as usize;
}
let last = u32::try_from(frames - 1).unwrap_or(u32::MAX);
for e in &mut self.notes {
if e.time() > last {
e.set_time(last);
}
}
for c in &mut self.controls {
c.0 = c.0.min(last);
}
let mut start = 0usize;
while start < frames {
let n = (frames - start).min(self.max_block);
let end = u32::try_from(start + n).unwrap_or(u32::MAX);
// Feed the main input bus (mono sources fill every bus channel).
if self.has_input {
feed_input(&mut self.inputs, 0, io, ch, (start, n));
}
let k = self.notes.iter().position(|e| e.time() >= end).unwrap_or(self.notes.len());
let offset = u32::try_from(start).unwrap_or(u32::MAX);
let notes = self.notes.get(..k).unwrap_or(&[]);
let r = self.inst.process(ProcessInput {
frames: n,
inputs: &mut self.inputs,
outputs: &mut self.outputs,
params: &self.pending,
controls: &self.controls,
notes,
offset,
project_time: self.position,
transport: self.transport.as_ref(),
});
self.pending.clear();
self.notes.drain(..k);
self.position = self.position.saturating_add(i64::try_from(n).unwrap_or(0));
match r {
Ok(()) if self.has_output => {
read_output(&self.outputs, 0, io, ch, (start, n));
}
Err(e) => log::debug!("{}: {e}", self.info.id),
_ => {}
}
start += n;
}
self.controls.clear();
}
fn latency(&self) -> usize {
self.latency
}
fn set_transport(&mut self, transport: &Transport) {
self.transport = Some(*transport);
}
fn tail_samples(&self) -> usize {
self.tail
}
fn note_on(&mut self, offset: usize, note: u8, velocity: u8) {
if note > 127 || !self.event_input {
return;
}
let time = u32::try_from(offset).unwrap_or(u32::MAX);
self.push_note(NoteEvent::On { time, key: note, velocity: f32::from(velocity.min(127)) / 127.0 });
if let Some(h) = self.held.get_mut(note as usize) {
*h = true;
}
}
fn note_off(&mut self, offset: usize, note: u8) {
if note > 127 || !self.event_input {
return;
}
let time = u32::try_from(offset).unwrap_or(u32::MAX);
self.push_note(NoteEvent::Off { time, key: note });
if let Some(h) = self.held.get_mut(note as usize) {
*h = false;
}
}
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
let time = u32::try_from(offset).unwrap_or(u32::MAX);
let [_, d1, d2] = msg.map(|b| b & 0x7F);
let (ctrl, value) = match msg[0] & 0xF0 {
0xB0 => (usize::from(d1), f64::from(d2) / 127.0),
0xD0 => (AFTERTOUCH, f64::from(d1) / 127.0),
0xE0 => (PITCH_BEND, f64::from(u16::from(d2) << 7 | u16::from(d1)) / 16383.0),
0xA0 if self.event_input => {
self.push_note(NoteEvent::Pressure { time, key: d1, pressure: f32::from(d2) / 127.0 });
return;
}
_ => return,
};
if let Some(&Some(id)) = self.midi_map.get(ctrl)
&& self.controls.len() < self.controls.capacity()
{
self.controls.push((time, id, value));
}
}
fn all_notes_off(&mut self) {
for k in 0..128u8 {
if self.held.get(k as usize).copied().unwrap_or(false) {
self.note_off(0, k);
}
}
}
fn load_state(&mut self, data: &[u8]) -> bool {
let ok = self.inst.load_state(data);
if ok {
self.refresh_values();
}
ok
}
fn open_editor(&mut self) -> Result<(), String> {
self.inst.editor().ok_or_else(|| "this plugin has no editor".to_string())?.open(self.info.name)
}
fn close_editor(&mut self) {
if let Some(e) = self.inst.editor() {
e.close();
}
}
fn editor(&mut self) -> Option<Box<dyn PluginEditor>> {
let link = self.inst.editor()?;
let mut units: HashMap<u32, String> = self.inst.params().into_iter().map(|p| (p.id, p.units)).collect();
Some(Box::new(Vst3Editor {
link,
host: self.inst.host_ref(),
info: self.info,
index: self.info.params.iter().enumerate().map(|(i, p)| (p.id, i)).collect(),
units: self.ids.iter().map(|id| units.remove(id).unwrap_or_default()).collect(),
ids: self.ids.clone(),
maps: self.maps.clone(),
}))
}
fn state_reader(&mut self) -> Option<Box<dyn soundcraft_dsp::PluginStateReader>> {
let (link, ids, unsynced) = (self.inst.state_link(), self.ids.clone(), Arc::clone(&self.unsynced));
Some(Box::new(move || {
// A parameter only the controller stores (no processor state) would otherwise be
// saved with the controller's stale value.
for (&id, u) in ids.iter().zip(unsynced.iter()) {
let bits = u.swap(SYNCED, Ordering::Relaxed);
if bits != SYNCED {
link.sync_param(id, f64::from_bits(bits));
}
}
link.save_state()
}))
}
}
/// A VST3 plugin's editor (`IPlugView` in a host window), driven from the main thread.
struct Vst3Editor {
link: Arc<EditorLink>,
host: ComWrapper<HostContext>,
info: &'static PluginInfo,
/// Position of each SoundCraft param id in `info.params` (looked up once per row and frame).
index: HashMap<&'static str, usize>,
/// The plugin's own unit label of each param (same order as `ids`).
units: Vec<String>,
ids: Vec<u32>,
maps: Vec<Mapping>,
}
impl Vst3Editor {
fn plain(&self, i: usize, n: f64) -> Option<f32> {
let pi = self.info.params.get(i)?;
let id = *self.ids.get(i)?;
let v = match self.maps.get(i)? {
Mapping::Stepped(steps) => (n * steps).round(),
Mapping::Plain => self.link.to_plain(id, n)?,
Mapping::Linear => f64::from(pi.min) + n * f64::from(pi.max - pi.min),
};
v.is_finite().then(|| pi.clamp(v as f32))
}
fn normalized(&self, i: usize, v: f32) -> Option<f64> {
let pi = self.info.params.get(i)?;
let id = *self.ids.get(i)?;
let span = f64::from(pi.max - pi.min);
let linear = if span > 0.0 { (f64::from(v) - f64::from(pi.min)) / span } else { 0.0 };
let n = match self.maps.get(i)? {
Mapping::Stepped(steps) if *steps > 0.0 => f64::from(v) / steps,
Mapping::Stepped(_) => 0.0,
Mapping::Plain => self.link.to_normalized(id, f64::from(v)).unwrap_or(linear),
Mapping::Linear => linear,
};
n.is_finite().then(|| n.clamp(0.0, 1.0))
}
}
impl PluginEditor for Vst3Editor {
fn open(&mut self) -> Result<(), String> {
// Edits reported before the editor opened (e.g. during setup) are stale.
let _ = self.host.take_edits();
self.link.open(self.info.name)
}
fn close(&mut self) {
self.link.close();
}
fn is_open(&self) -> bool {
self.link.is_open()
}
fn idle(&mut self) -> Vec<(String, f32)> {
self.link.idle();
let mut out = Vec::new();
for (vid, n) in self.host.take_edits() {
let Some(i) = self.ids.iter().position(|&x| x == vid) else { continue };
if let (Some(p), Some(v)) = (self.info.params.get(i), self.plain(i, n)) {
out.push((p.id.to_string(), v));
}
}
out
}
fn set_param(&mut self, id: &str, value: f32) {
let Some(i) = self.info.params.iter().position(|p| p.id == id) else { return };
if let (Some(&vid), Some(n)) = (self.ids.get(i), self.normalized(i, value)) {
self.link.set_normalized(vid, n);
}
}
fn value_text(&mut self, id: &str, value: f32) -> Option<String> {
let &i = self.index.get(id)?;
let pi = self.info.params.get(i)?;
// A plugin whose plain mapping is the identity (JUCE, the SDK's default `Parameter`) leaves
// a 0..1 range with the units of its real one: only its own text says what the value is.
if !matches!(self.maps.get(i)?, Mapping::Plain) || pi.min != 0.0 || pi.max != 1.0 {
return None;
}
let text = self.link.value_text(*self.ids.get(i)?, self.normalized(i, value)?)?;
// VST3 keeps the units apart from the text (JUCE's label, the SDK's `RangeParameter`),
// but some plugins put them in both.
let units = self.units.get(i).map_or("", |u| u.trim());
if units.is_empty() || text.trim_end().to_lowercase().ends_with(&units.to_lowercase()) { Some(text) } else { Some(format!("{text} {units}")) }
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A queue full of releases still has room to release every held key after a reset.
#[test]
fn reset_keeps_room_to_release_every_held_key() {
let mut notes = Vec::with_capacity(EVENT_CAP);
for i in 0..EVENT_CAP {
push_sorted(&mut notes, NoteEvent::Off { time: i as u32, key: (i % 4) as u8 });
}
keep_releases(&mut notes);
for key in 0..128u8 {
push_sorted(&mut notes, NoteEvent::Off { time: 0, key });
}
for key in 0..128u8 {
assert!(notes.iter().any(|e| matches!(*e, NoteEvent::Off { key: k, .. } if k == key)), "key {key} was never released");
}
}
}