Skip to main content

ferraus_plugins/
api.rs

1//! Public entry points for running plugins from other crates (`ferraus-synth` renders
2//! `InstrumentSource::Plugin` through [`render_instrument`]; effect chains use
3//! [`process_effect`]). Every call starts its own host process, so a misbehaving plugin
4//! surfaces as an [`ErrorKind::Plugin`] error and never takes the caller down.
5
6pub use crate::client::PluginFormat;
7use crate::client::{default_watchdog, PluginHost};
8use crate::protocol::{Event, ParamValue, PluginInfo};
9use base64::Engine;
10use ferraus_core::{AudioBuffer, Error, ErrorKind, OpCtx, Result};
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Value};
13use std::path::{Path, PathBuf};
14use std::time::Duration;
15
16/// A specific plugin: bundle path plus the plugin id inside it.
17#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
18pub struct PluginRef {
19    pub format: PluginFormat,
20    /// The `.clap` / `.vst3` bundle (file or directory).
21    pub path: PathBuf,
22    /// Plugin id inside the bundle (CLAP id or VST3 class id). `None` = the only one.
23    pub plugin_id: Option<String>,
24    pub name: String,
25    #[serde(default)]
26    pub vendor: String,
27    #[serde(default)]
28    pub version: String,
29}
30
31impl PluginRef {
32    /// A plugin given directly by path (format from the extension, CLAP by default).
33    pub fn from_path(path: impl Into<PathBuf>, plugin_id: Option<&str>) -> PluginRef {
34        let path = path.into();
35        let name = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "plugin".into());
36        PluginRef {
37            format: PluginFormat::from_path(&path).unwrap_or(PluginFormat::Clap),
38            path,
39            plugin_id: plugin_id.map(|s| s.to_string()),
40            name,
41            vendor: String::new(),
42            version: String::new(),
43        }
44    }
45
46    /// Resolve a name, id, or path (`"Surge XT"`, `org.surge-synth-team.surge-xt`,
47    /// `plugin:"Surge XT"`, `/path/Foo.clap`, `/path/Foo.clap#plugin.id`) against the scan
48    /// cache, the standard plugin folders, and `sound_paths`.
49    pub fn resolve(query: &str, sound_paths: &[PathBuf]) -> Result<PluginRef> {
50        crate::scan::resolve_plugin(query, sound_paths, default_watchdog())
51    }
52
53    fn label(&self) -> &str {
54        &self.name
55    }
56}
57
58/// One MIDI event at an absolute sample frame of the render.
59#[derive(Clone, Copy, Debug, PartialEq)]
60pub struct MidiEvent {
61    pub frame: u64,
62    pub kind: MidiEventKind,
63}
64
65#[derive(Clone, Copy, Debug, PartialEq)]
66pub enum MidiEventKind {
67    /// `velocity` in 0..=1.
68    NoteOn {
69        channel: u8,
70        key: u8,
71        velocity: f32,
72    },
73    NoteOff {
74        channel: u8,
75        key: u8,
76        velocity: f32,
77    },
78    /// Any other 3-byte MIDI message (CC, pitch bend, program change...).
79    Raw([u8; 3]),
80}
81
82impl MidiEvent {
83    pub fn note_on(frame: u64, channel: u8, key: u8, velocity: f32) -> MidiEvent {
84        MidiEvent { frame, kind: MidiEventKind::NoteOn { channel, key, velocity } }
85    }
86
87    pub fn note_off(frame: u64, channel: u8, key: u8) -> MidiEvent {
88        MidiEvent { frame, kind: MidiEventKind::NoteOff { channel, key, velocity: 0.0 } }
89    }
90
91    fn to_wire(self) -> (u64, Event) {
92        let e = match self.kind {
93            MidiEventKind::NoteOn { channel, key, velocity } => Event::NoteOn { time: 0, channel, key, velocity },
94            MidiEventKind::NoteOff { channel, key, velocity } => Event::NoteOff { time: 0, channel, key, velocity },
95            MidiEventKind::Raw(data) => Event::Midi { time: 0, data },
96        };
97        (self.frame, e)
98    }
99}
100
101/// How long to keep processing after the input (or the render duration) ends.
102#[derive(Clone, Copy, Debug, PartialEq)]
103pub enum Tail {
104    /// The plugin's reported tail (capped at 30 s). Effects that report nothing or an
105    /// infinite tail get 2 s; instruments that report nothing get none.
106    Auto,
107    Seconds(f64),
108}
109
110/// Everything that configures one plugin run.
111#[derive(Clone, Debug)]
112pub struct PluginOptions {
113    /// `(name or id, value)`: values in display units (`2.5kHz`, `-6dB`, `30%`), plain
114    /// numbers, or `norm:0.3`.
115    pub params: Vec<(String, String)>,
116    /// Preset name or load key (applied after `state`, before `params`).
117    pub preset: Option<String>,
118    /// Native state blob (applied first).
119    pub state: Option<Vec<u8>>,
120    pub block_size: u32,
121    pub tail: Tail,
122    pub watchdog: Duration,
123    /// Blocks in flight in the shared-memory ring.
124    pub slots: u32,
125    /// Called with the fraction done after each block; returning an error aborts.
126    pub progress: Option<fn(f64)>,
127}
128
129impl Default for PluginOptions {
130    fn default() -> Self {
131        PluginOptions {
132            params: Vec::new(),
133            preset: None,
134            state: None,
135            block_size: 512,
136            tail: Tail::Auto,
137            watchdog: default_watchdog(),
138            slots: 4,
139            progress: None,
140        }
141    }
142}
143
144/// The result of a run: audio plus everything needed to record and reproduce it.
145#[derive(Clone, Debug)]
146pub struct PluginRun {
147    pub audio: AudioBuffer,
148    pub info: PluginInfo,
149    /// Values of the parameters that were set explicitly (after conversion).
150    pub params: Vec<ParamValue>,
151    /// Native state captured after state/preset/params were applied, before processing.
152    pub state: Option<Vec<u8>>,
153    pub latency: u32,
154    pub tail_frames: usize,
155    pub preset: Option<String>,
156}
157
158impl PluginRun {
159    /// The sidecar record for this plugin stage.
160    pub fn record(&self, plugin: &PluginRef) -> Value {
161        let params: serde_json::Map<String, Value> = self
162            .params
163            .iter()
164            .map(|p| (p.name.clone(), json!({"id": p.id, "value": p.value, "text": p.text})))
165            .collect();
166        json!({
167            "name": if self.info.descriptor.name.is_empty() { plugin.name.clone() } else { self.info.descriptor.name.clone() },
168            "id": self.info.descriptor.id,
169            "path": plugin.path.display().to_string(),
170            "format": plugin.format.as_str(),
171            "version": self.info.descriptor.version,
172            "vendor": self.info.descriptor.vendor,
173            "params": params,
174            "preset": self.preset,
175            "state_b64": self.state.as_ref().map(|s| base64::engine::general_purpose::STANDARD.encode(s)),
176            "latency_samples": self.latency,
177            "tail_samples": self.tail_frames,
178        })
179    }
180
181    /// Record this stage in an op context: adds the sidecar entry and marks the output's
182    /// determinism as unknown.
183    pub fn record_in(&self, plugin: &PluginRef, ctx: &mut OpCtx) {
184        ctx.plugins.push(self.record(plugin));
185        ctx.deterministic = Some(false);
186    }
187}
188
189/// A loaded plugin in its own host process, for inspection (`plugin info`, presets,
190/// state) or for driving runs by hand.
191pub struct PluginSession {
192    pub host: PluginHost,
193    pub info: PluginInfo,
194    pub plugin: PluginRef,
195}
196
197impl PluginSession {
198    pub fn open(plugin: &PluginRef, watchdog: Duration) -> Result<PluginSession> {
199        let mut host = PluginHost::spawn(plugin.format, plugin.label(), &plugin.path, watchdog)?;
200        let info = host.load(plugin.plugin_id.as_deref())?;
201        if !info.descriptor.name.is_empty() {
202            host.set_label(&info.descriptor.name);
203        }
204        Ok(PluginSession { host, info, plugin: plugin.clone() })
205    }
206
207    /// Apply state, then preset, then params; returns the explicit params' values.
208    pub fn configure(&mut self, opts: &PluginOptions) -> Result<Vec<ParamValue>> {
209        if let Some(s) = &opts.state {
210            self.host.set_state(s)?;
211        }
212        if let Some(p) = &opts.preset {
213            self.host.load_preset(p)?;
214        }
215        self.host.set_params(&opts.params)
216    }
217
218    fn capture_state(&mut self) -> Result<Option<Vec<u8>>> {
219        if self.info.has_state {
220            Ok(Some(self.host.get_state()?))
221        } else {
222            Ok(None)
223        }
224    }
225}
226
227fn tail_frames(tail: Tail, reported: Option<u32>, sr: u32, instrument: bool) -> usize {
228    let secs = match tail {
229        Tail::Seconds(s) => s.max(0.0),
230        Tail::Auto => match reported {
231            Some(t) if t != u32::MAX => (t as f64 / sr as f64).min(30.0),
232            Some(_) => 2.0,
233            None if instrument => 0.0,
234            None => 2.0,
235        },
236    };
237    (secs * sr as f64).round() as usize
238}
239
240fn check_sr(sr: u32) -> Result<()> {
241    if !(8000..=768_000).contains(&sr) {
242        return Err(Error::usage(format!("sample rate {sr} Hz is out of range")));
243    }
244    Ok(())
245}
246
247fn validate_block(opts: &PluginOptions) -> Result<()> {
248    if !(16..=16384).contains(&opts.block_size) {
249        return Err(Error::usage(format!("block size {} is out of range (16..16384)", opts.block_size)));
250    }
251    Ok(())
252}
253
254/// Drive an instrument plugin with MIDI and return `duration` seconds of audio (plus the
255/// tail, see [`Tail`]), latency-compensated so events sound at their frame.
256pub fn render_instrument_with(
257    plugin: &PluginRef,
258    midi_events: &[MidiEvent],
259    sample_rate: u32,
260    duration: f64,
261    opts: &PluginOptions,
262) -> Result<PluginRun> {
263    check_sr(sample_rate)?;
264    validate_block(opts)?;
265    if !(duration.is_finite() && duration >= 0.0) {
266        return Err(Error::usage("render duration must be a positive number of seconds"));
267    }
268    let mut s = PluginSession::open(plugin, opts.watchdog)?;
269    let params = s.configure(opts)?;
270    let state = s.capture_state()?;
271    let (ci, co) = (s.info.main_in_channels, s.info.main_out_channels);
272    if co == 0 {
273        return Err(Error::new(
274            ErrorKind::Plugin,
275            "plugin_no_output",
276            format!("plugin '{}' has no audio output", s.plugin.name),
277        ));
278    }
279    let act = s.host.activate(sample_rate, opts.block_size, ci, co, opts.slots)?;
280    let latency = act.latency as usize;
281    let dur = (duration * sample_rate as f64).round() as usize;
282    let tail = tail_frames(opts.tail, act.tail, sample_rate, true);
283    let total = dur + tail + latency;
284    let events: Vec<(u64, Event)> = midi_events.iter().map(|e| e.to_wire()).collect();
285    let progress = opts.progress;
286    let out = s.host.run(&[], total, &events, &mut |f| {
287        if let Some(p) = progress {
288            p(f)
289        }
290        Ok(())
291    })?;
292    let data: Vec<Vec<f32>> = out.into_iter().map(|c| c[latency..].to_vec()).collect();
293    let info = s.info.clone();
294    s.host.shutdown();
295    Ok(PluginRun {
296        audio: AudioBuffer::from_planar(sample_rate, data),
297        info,
298        params,
299        state,
300        latency: act.latency,
301        tail_frames: tail,
302        preset: opts.preset.clone(),
303    })
304}
305
306/// Render an instrument plugin. `params` are `(name or id, value)` pairs; `state` is a
307/// native state blob (e.g. from `plugin state save`). See [`render_instrument_with`].
308pub fn render_instrument(
309    plugin: &PluginRef,
310    midi_events: &[MidiEvent],
311    sample_rate: u32,
312    duration: f64,
313    params: &[(String, String)],
314    state: Option<&[u8]>,
315) -> Result<AudioBuffer> {
316    let opts = PluginOptions { params: params.to_vec(), state: state.map(|s| s.to_vec()), ..Default::default() };
317    Ok(render_instrument_with(plugin, midi_events, sample_rate, duration, &opts)?.audio)
318}
319
320/// Run audio through an effect plugin. The output is `input` length plus the tail, with
321/// the plugin's latency removed so every sample stays at its original position.
322pub fn process_effect_with(plugin: &PluginRef, input: &AudioBuffer, opts: &PluginOptions) -> Result<PluginRun> {
323    process_effect_inner(plugin, input, opts, &mut |_| Ok(()))
324}
325
326pub(crate) fn process_effect_inner(
327    plugin: &PluginRef,
328    input: &AudioBuffer,
329    opts: &PluginOptions,
330    progress: &mut dyn FnMut(f64) -> Result<()>,
331) -> Result<PluginRun> {
332    check_sr(input.sample_rate)?;
333    validate_block(opts)?;
334    let mut s = PluginSession::open(plugin, opts.watchdog)?;
335    let params = s.configure(opts)?;
336    let state = s.capture_state()?;
337    let (ci, co) = (s.info.main_in_channels, s.info.main_out_channels);
338    if ci == 0 || co == 0 {
339        return Err(Error::new(
340            ErrorKind::Usage,
341            "plugin_not_effect",
342            format!(
343                "plugin '{}' has no audio {} (is it an instrument?)",
344                s.plugin.name,
345                if ci == 0 { "input" } else { "output" }
346            ),
347        )
348        .with_hint("render instruments with: ferraus render <file.mid> --instrument plugin:<name>"));
349    }
350    let act = s.host.activate(input.sample_rate, opts.block_size, ci, co, opts.slots)?;
351    let latency = act.latency as usize;
352    let tail = tail_frames(opts.tail, act.tail, input.sample_rate, false);
353    let adapted = input.with_channels(ci as usize);
354    let total = input.frames() + tail + latency;
355    let user_progress = opts.progress;
356    let out = s.host.run(&adapted.data, total, &[], &mut |f| {
357        if let Some(p) = user_progress {
358            p(f)
359        }
360        progress(f)
361    })?;
362    let data: Vec<Vec<f32>> = out.into_iter().map(|c| c[latency..].to_vec()).collect();
363    let info = s.info.clone();
364    s.host.shutdown();
365    Ok(PluginRun {
366        audio: AudioBuffer::from_planar(input.sample_rate, data),
367        info,
368        params,
369        state,
370        latency: act.latency,
371        tail_frames: tail,
372        preset: opts.preset.clone(),
373    })
374}
375
376/// Run audio through an effect plugin with `tail_s` seconds of tail. See
377/// [`process_effect_with`].
378pub fn process_effect(
379    plugin: &PluginRef,
380    input: &AudioBuffer,
381    params: &[(String, String)],
382    state: Option<&[u8]>,
383    tail_s: f64,
384) -> Result<AudioBuffer> {
385    let opts = PluginOptions {
386        params: params.to_vec(),
387        state: state.map(|s| s.to_vec()),
388        tail: Tail::Seconds(tail_s),
389        ..Default::default()
390    };
391    Ok(process_effect_with(plugin, input, &opts)?.audio)
392}
393
394/// Read a state file written by `plugin state save` (JSON with `state_b64`), or a raw
395/// native blob.
396pub fn read_state_file(path: &Path) -> Result<Vec<u8>> {
397    let bytes = std::fs::read(path).map_err(|e| {
398        Error::new(
399            ErrorKind::InputMissing,
400            "input_not_found",
401            format!("cannot read state file {}: {e}", path.display()),
402        )
403    })?;
404    if let Ok(v) = serde_json::from_slice::<Value>(&bytes) {
405        let b64 = v
406            .get("state_b64")
407            .or_else(|| v.pointer("/plugins/0/state_b64"))
408            .and_then(|s| s.as_str())
409            .ok_or_else(|| Error::usage(format!("{} is JSON but has no state_b64 field", path.display())))?;
410        return base64::engine::general_purpose::STANDARD
411            .decode(b64.trim())
412            .map_err(|e| Error::usage(format!("{}: state_b64 is not base64: {e}", path.display())));
413    }
414    Ok(bytes)
415}