Skip to main content

truce_rack_standalone/
lib.rs

1//! cpal-driven standalone host for truce-rack.
2//!
3//! Loads a single plugin via the appropriate format wrapper,
4//! opens the default cpal output device, and renders the plugin
5//! into the device's audio stream. Optionally also opens a
6//! baseview window and embeds the plugin's editor — see the `gui`
7//! feature and the `windowed` module.
8//!
9//! # Layout
10//!
11//! - `run_clap` / `run_vst3` / `run_au` / `run_lv2` — one entry point
12//!   per format wrapper. Each is feature-gated.
13//! - [`run_with_plugin`] — common cpal plumbing every headless
14//!   entry point uses.
15//! - [`list_plugins`] — scan every enabled format and print one
16//!   line per discovered plugin (powers `--list`).
17
18use truce_rack_core::buffer::{AudioBuffer, BusRange};
19use truce_rack_core::bus::BusLayout;
20use truce_rack_core::error::{Error, Result};
21use truce_rack_core::events::EventList;
22use truce_rack_core::info::PluginInfo;
23use truce_rack_core::plugin::{Plugin, PluginCore, ProcessContext};
24
25pub mod cli;
26pub mod device;
27pub mod midi;
28pub mod midi_queue;
29pub mod transport;
30#[cfg(any(
31    feature = "clap",
32    feature = "vst3",
33    feature = "lv2",
34    all(feature = "au", target_vendor = "apple"),
35))]
36use truce_rack_core::scanner::PluginScanner;
37
38use cpal::Stream;
39use cpal::traits::{DeviceTrait, StreamTrait};
40
41#[cfg(feature = "gui")]
42pub mod keyboard;
43#[cfg(feature = "gui")]
44pub mod windowed;
45
46#[cfg(all(target_os = "macos", feature = "gui"))]
47pub mod menu_macos;
48
49#[cfg(target_os = "macos")]
50pub mod screenshot;
51
52/// Whether this build can open a plugin editor window — i.e. the
53/// `gui` feature is compiled in. `false` on Linux (baseview's Linux
54/// backend is gated off) and on any `--no-default-features` build.
55/// Callers use it to decide whether `--gui` can be the default.
56pub const GUI_AVAILABLE: bool = cfg!(feature = "gui");
57
58/// What the standalone runner wants to do once it has a stream
59/// open: stay alive for `seconds`, or block until a user sends
60/// SIGINT.
61#[derive(Debug, Clone, Copy)]
62pub enum RunMode {
63    /// Block this many seconds, then return.
64    Seconds(f32),
65    /// Block until SIGINT / `^C`.
66    UntilSignal,
67}
68
69/// Which format the CLI picked. Used by the dispatcher so a single
70/// `--name "Foo"` flag can resolve against the right scanner.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum Format {
73    /// CLAP.
74    Clap,
75    /// VST3.
76    Vst3,
77    /// Audio Unit v2.
78    Au,
79    /// LV2.
80    Lv2,
81}
82
83impl Format {
84    /// Parse `clap` / `vst3` / `au` / `lv2` (case-insensitive).
85    /// Returns `None` for anything else.
86    #[must_use]
87    pub fn parse(s: &str) -> Option<Self> {
88        match s.to_ascii_lowercase().as_str() {
89            "clap" => Some(Self::Clap),
90            "vst3" => Some(Self::Vst3),
91            "au" => Some(Self::Au),
92            "lv2" => Some(Self::Lv2),
93            _ => None,
94        }
95    }
96
97    /// Human-readable tag used in `--list` output and screenshot
98    /// filenames.
99    #[must_use]
100    pub fn tag(self) -> &'static str {
101        match self {
102            Self::Clap => "clap",
103            Self::Vst3 => "vst3",
104            Self::Au => "au",
105            Self::Lv2 => "lv2",
106        }
107    }
108}
109
110/// Open a CLAP plugin by id or name and run it.
111///
112/// `gui` opens a windowed runner that embeds the plugin's editor;
113/// without `gui` (or with `gui` disabled at compile time) the
114/// runner is headless and only drives the audio thread.
115///
116/// # Errors
117/// Propagates scanner / load / activate failures and cpal device
118/// errors.
119#[cfg(feature = "clap")]
120pub fn run_clap(selector: &PluginSelector, mode: RunMode, gui: bool) -> Result<()> {
121    let scanner = truce_rack_clap::ClapScanner::new();
122    let plugin = load_by_selector(&scanner, selector)?;
123    dispatch_run(plugin, mode, gui)
124}
125
126/// Open a VST3 plugin by id or name and run it.
127///
128/// # Errors
129/// Same shape as [`run_clap`].
130#[cfg(feature = "vst3")]
131pub fn run_vst3(selector: &PluginSelector, mode: RunMode, gui: bool) -> Result<()> {
132    let scanner = truce_rack_vst3::Vst3Scanner::new();
133    let plugin = load_by_selector(&scanner, selector)?;
134    dispatch_run(plugin, mode, gui)
135}
136
137/// Open an Audio Unit v2 plugin by id or name and run it.
138///
139/// # Errors
140/// Same shape as [`run_clap`].
141#[cfg(all(feature = "au", target_vendor = "apple"))]
142pub fn run_au(selector: &PluginSelector, mode: RunMode, gui: bool) -> Result<()> {
143    let scanner = truce_rack_au::AuScanner::new();
144    let plugin = load_by_selector(&scanner, selector)?;
145    dispatch_run(plugin, mode, gui)
146}
147
148/// Open an LV2 plugin by URI or name and run it.
149///
150/// # Errors
151/// Same shape as [`run_clap`].
152#[cfg(feature = "lv2")]
153pub fn run_lv2(selector: &PluginSelector, mode: RunMode, gui: bool) -> Result<()> {
154    let scanner = truce_rack_lv2::Lv2Scanner::new();
155    let plugin = load_by_selector(&scanner, selector)?;
156    dispatch_run(plugin, mode, gui)
157}
158
159/// How the CLI identified the plugin it wants to load. `Id` is an
160/// exact unique-id match (CLAP plugin id, VST3 CID hex, AU 4cc
161/// triplet). `Name` is a case-insensitive substring against the
162/// plugin's display name.
163#[derive(Debug, Clone)]
164pub enum PluginSelector {
165    /// Exact unique-id match.
166    Id(String),
167    /// Case-insensitive substring of the display name.
168    Name(String),
169}
170
171// Used only by the per-format `run_*` entry points, all of which
172// are feature-gated.
173#[cfg(any(
174    feature = "clap",
175    feature = "vst3",
176    feature = "lv2",
177    all(feature = "au", target_vendor = "apple"),
178))]
179fn load_by_selector<S>(scanner: &S, selector: &PluginSelector) -> Result<S::Plugin>
180where
181    S: PluginScanner,
182{
183    let entries = scanner.scan()?;
184    let info = match selector {
185        PluginSelector::Id(id) => entries
186            .into_iter()
187            .find(|p| p.unique_id == *id || p.name == *id),
188        PluginSelector::Name(name) => {
189            let needle = name.to_ascii_lowercase();
190            entries
191                .into_iter()
192                .find(|p| p.name.to_ascii_lowercase().contains(&needle))
193        }
194    };
195    let info = info.ok_or_else(|| {
196        let label = match selector {
197            PluginSelector::Id(s) | PluginSelector::Name(s) => s.clone(),
198        };
199        Error::PluginNotFound(label)
200    })?;
201    scanner.load(&info)
202}
203
204// Only referenced by the per-format `run_clap` / `run_vst3` /
205// `run_au` entry points, all of which are themselves feature-gated.
206#[cfg(any(
207    feature = "clap",
208    feature = "vst3",
209    feature = "lv2",
210    all(feature = "au", target_vendor = "apple"),
211))]
212#[cfg(feature = "gui")]
213fn dispatch_run<P>(plugin: P, mode: RunMode, gui: bool) -> Result<()>
214where
215    P: PluginCore + Plugin<f32> + Send + 'static,
216{
217    if gui {
218        windowed::run(plugin)
219    } else {
220        run_with_plugin(plugin, mode)
221    }
222}
223
224#[cfg(any(
225    feature = "clap",
226    feature = "vst3",
227    feature = "lv2",
228    all(feature = "au", target_vendor = "apple"),
229))]
230#[cfg(not(feature = "gui"))]
231fn dispatch_run<P>(plugin: P, mode: RunMode, gui: bool) -> Result<()>
232where
233    P: PluginCore + Plugin<f32> + Send + 'static,
234{
235    if gui {
236        return Err(Error::Other(
237            "--gui requires the `gui` feature to be enabled at build time".into(),
238        ));
239    }
240    run_with_plugin(plugin, mode)
241}
242
243/// Open the default cpal output device and pump `plugin` into
244/// it, running for `mode`.
245///
246/// # Errors
247/// Activation, cpal device opening, or stream-start failures.
248pub fn run_with_plugin<P>(plugin: P, mode: RunMode) -> Result<()>
249where
250    P: PluginCore + Plugin<f32> + Send + 'static,
251{
252    let (device, supported) = device::open_output_device()?;
253    let config = device::resolve_stream_config(&device, &supported);
254    let sample_rate = f64::from(config.sample_rate.0);
255    let channels = usize::from(config.channels.max(1));
256    let max_block = 1024usize;
257
258    let stream = build_audio_stream(plugin, &device, &config, sample_rate, channels, max_block)?;
259    stream
260        .play()
261        .map_err(|e| Error::Other(format!("stream.play: {e}")))?;
262
263    // Hardware MIDI in: held for the lifetime of the run so the
264    // headless mode is also playable from a connected controller.
265    let _midi_in = midi::MidiInputThread::start();
266
267    match mode {
268        RunMode::Seconds(secs) => {
269            std::thread::sleep(std::time::Duration::from_secs_f32(secs));
270        }
271        RunMode::UntilSignal => loop {
272            std::thread::sleep(std::time::Duration::from_secs(1));
273        },
274    }
275    drop(stream);
276    Ok(())
277}
278
279fn build_audio_stream<P>(
280    mut plugin: P,
281    device: &cpal::Device,
282    stream_config: &cpal::StreamConfig,
283    sample_rate: f64,
284    channels: usize,
285    max_block: usize,
286) -> Result<Stream>
287where
288    P: PluginCore + Plugin<f32> + Send + 'static,
289{
290    plugin.activate(BusLayout::stereo(), sample_rate, max_block)?;
291    let stream_config = stream_config.clone();
292
293    let mut input_buf = vec![vec![0.0f32; max_block]; channels];
294    let mut output_buf = vec![vec![0.0f32; max_block]; channels];
295    let bus_in = vec![BusRange::new(0, channels)];
296    let bus_out = vec![BusRange::new(0, channels)];
297    let mut clock = transport::TransportClock::new();
298
299    let stream = device
300        .build_output_stream(
301            &stream_config,
302            move |out: &mut [f32], _: &cpal::OutputCallbackInfo| {
303                let frames = out.len() / channels.max(1);
304
305                for ch in &mut input_buf {
306                    if ch.len() < frames {
307                        ch.resize(frames, 0.0);
308                    }
309                    for v in &mut ch[..frames] {
310                        *v = 0.0;
311                    }
312                }
313                for ch in &mut output_buf {
314                    if ch.len() < frames {
315                        ch.resize(frames, 0.0);
316                    }
317                    for v in &mut ch[..frames] {
318                        *v = 0.0;
319                    }
320                }
321
322                {
323                    let inputs: Vec<&[f32]> = input_buf.iter().map(|c| &c[..frames]).collect();
324                    let mut outputs: Vec<&mut [f32]> =
325                        output_buf.iter_mut().map(|c| &mut c[..frames]).collect();
326
327                    let mut buffer =
328                        AudioBuffer::new(&inputs, &mut outputs, frames, &bus_in, &bus_out);
329                    let mut events = EventList::default();
330                    midi_queue::drain_into(&mut events);
331                    let mut out_events = EventList::default();
332                    let mut ctx = ProcessContext {
333                        sample_rate,
334                        max_block_size: max_block,
335                        transport: clock.next_block(frames, sample_rate),
336                        output_events: &mut out_events,
337                    };
338                    let _ = plugin.process(&mut buffer, &events, &mut ctx);
339                }
340
341                device::live_route().write(out, &output_buf, channels, frames);
342            },
343            move |err| eprintln!("[truce-rack-standalone] stream error: {err}"),
344            None,
345        )
346        .map_err(|e| Error::Other(format!("build_output_stream: {e}")))?;
347    Ok(stream)
348}
349
350/// Scan every format compiled into this build and return a flat
351/// list of `(format, info)` pairs. Used by `--list` and by the
352/// screenshot bin's per-plugin walk.
353///
354/// CLAP / VST3 / AU `has_editor` is only known post-load — scanning
355/// alone reports `false`. The caller is responsible for loading
356/// each entry if they want truth on the editor field.
357#[must_use]
358pub fn list_plugins() -> Vec<(Format, PluginInfo)> {
359    // `mut` is conditional — only the cfg-on branches push into
360    // `out`. Suppress the warning for the all-features-off build.
361    #[allow(unused_mut)]
362    let mut out: Vec<(Format, PluginInfo)> = Vec::new();
363
364    #[cfg(feature = "clap")]
365    if let Ok(entries) = truce_rack_clap::ClapScanner::new().scan() {
366        for e in entries {
367            out.push((Format::Clap, e));
368        }
369    }
370
371    #[cfg(feature = "vst3")]
372    if let Ok(entries) = truce_rack_vst3::Vst3Scanner::new().scan() {
373        for e in entries {
374            out.push((Format::Vst3, e));
375        }
376    }
377
378    #[cfg(all(feature = "au", target_vendor = "apple"))]
379    if let Ok(entries) = truce_rack_au::AuScanner::new().scan() {
380        for e in entries {
381            out.push((Format::Au, e));
382        }
383    }
384
385    #[cfg(feature = "lv2")]
386    if let Ok(entries) = truce_rack_lv2::Lv2Scanner::new().scan() {
387        for e in entries {
388            out.push((Format::Lv2, e));
389        }
390    }
391
392    out
393}