Skip to main content

truce_standalone/
lib.rs

1//! Standalone host for truce plugins.
2//!
3//! Runs a plugin cdylib with direct cpal audio I/O and an optional
4//! GUI window (via baseview + the plugin's own `Editor`). Zero
5//! plugin-library code is required - the runner obtains the editor
6//! via `PluginExport::editor()`, the same API every format wrapper
7//! uses.
8//!
9//! # Entry point
10//!
11//! Plugins supply a `[[bin]] <suffix>-standalone` target with a
12//! `src/main.rs` that calls:
13//!
14//! ```ignore
15//! fn main() {
16//!     truce_standalone::run::<my_plugin::Plugin>();
17//! }
18//! ```
19//!
20//! # Modes
21//!
22//! - **Windowed** (default, requires `gui` feature): opens a
23//!   baseview window hosting the plugin's editor, drives a cpal
24//!   stream on the audio thread.
25//! - **Headless** (`--headless` flag or the `gui` feature disabled):
26//!   audio only. For effects this means audio passes through; for
27//!   instruments the plugin emits silence unless a MIDI device is
28//!   connected (`--midi-input`; see [`midi`]).
29//!
30//! See [`cli`] for the full flag surface.
31
32pub mod audio;
33pub mod cli;
34pub mod keyboard;
35pub mod midi;
36#[cfg(feature = "playback")]
37pub mod offline;
38#[cfg(feature = "playback")]
39pub mod playback;
40pub mod presets;
41pub mod state;
42pub mod transport;
43
44#[cfg(feature = "gui")]
45pub mod windowed;
46
47#[cfg(all(target_os = "macos", feature = "gui"))]
48pub mod menu_macos;
49
50#[cfg(all(target_os = "macos", feature = "gui"))]
51pub mod windowed_macos;
52
53#[cfg(all(target_os = "windows", feature = "gui"))]
54pub mod menu_windows;
55
56#[cfg(all(target_os = "windows", feature = "gui"))]
57pub mod windowed_windows;
58
59#[cfg(all(target_os = "linux", feature = "gui"))]
60pub mod windowed_x11;
61
62pub mod headless;
63
64pub use truce_core::export::PluginExport;
65
66// ---------------------------------------------------------------------------
67// Verbose state - set once from CLI / env, read everywhere via `vlog!`.
68// ---------------------------------------------------------------------------
69
70use std::sync::atomic::{AtomicBool, Ordering};
71
72static VERBOSE: AtomicBool = AtomicBool::new(false);
73
74/// True when `--verbose` / `-v` (or `TRUCE_STANDALONE_VERBOSE=1`) was
75/// passed at launch. Errors and `--list-*` output ignore this flag.
76pub fn is_verbose() -> bool {
77    VERBOSE.load(Ordering::Relaxed)
78}
79
80pub(crate) fn set_verbose(on: bool) {
81    VERBOSE.store(on, Ordering::Relaxed);
82}
83
84/// `eprintln!`, but only fires when [`is_verbose`] is true. Used for
85/// status chatter (device picks, toggles, transport state, save /
86/// load notices) - anything the user might want a trace of but that
87/// shouldn't clutter the default output.
88macro_rules! vlog {
89    ($($arg:tt)*) => {
90        if $crate::is_verbose() {
91            eprintln!($($arg)*);
92        }
93    };
94}
95pub(crate) use vlog;
96
97/// Plugin-author launch defaults - used as the lowest tier of the
98/// CLI parser, beneath argv and `TRUCE_STANDALONE_*` env vars.
99/// Empty `Defaults::default()` lets every value fall through to the
100/// compiled runtime default (input off, output on, cpal-picked
101/// devices). Pass to [`run_with`] when you want to override.
102#[derive(Default, Clone, Copy, Debug)]
103pub struct Defaults {
104    /// Whether the mic is enabled at launch. `None` = use the
105    /// privacy default (off).
106    pub input_enabled: Option<bool>,
107    /// Whether the speakers are enabled at launch. `None` = use the
108    /// runtime default (on).
109    pub output_enabled: Option<bool>,
110}
111
112impl Defaults {
113    /// Layer these author-supplied defaults beneath whatever argv /
114    /// env already resolved. Only fields where `opts` is `None` adopt
115    /// the default; CLI / env take precedence.
116    ///
117    /// Adding a new field to [`Defaults`] **must also add a line
118    /// here** - keeping the apply logic next to the struct is the
119    /// only thing stopping a new field from silently never being
120    /// applied. The `match` below is exhaustive over [`Defaults`]'s
121    /// fields by destructuring; adding a field there forces a
122    /// compile error here if this method isn't updated.
123    fn apply(self, opts: &mut cli::Options) {
124        let Defaults {
125            input_enabled,
126            output_enabled,
127        } = self;
128        opts.input_enabled = opts.input_enabled.or(input_enabled);
129        opts.output_enabled = opts.output_enabled.or(output_enabled);
130    }
131}
132
133/// Run the plugin standalone with no plugin-author defaults. Argv,
134/// env, and the compiled runtime defaults are the only inputs.
135/// Dispatches to the windowed or headless runner; returns when the
136/// user closes the window or sends SIGINT.
137pub fn run<P: PluginExport>()
138where
139    P::Params: 'static,
140{
141    run_with::<P>(Defaults::default());
142}
143
144/// `cargo truce package` on Windows links the standalone `.exe` with
145/// `/SUBSYSTEM:WINDOWS` so the packaged installer doesn't pop a stray
146/// console next to the plugin window when the user launches from the
147/// Start Menu / Explorer. The downside is that the same `.exe`
148/// invoked from `cmd.exe` / PowerShell starts with no console, so
149/// `eprintln!` lands on a null handle. `AttachConsole(ATTACH_PARENT_PROCESS)`
150/// rebinds the standard handles to whatever terminal launched us, so
151/// `--help`, `--list-devices`, and error diagnostics print where the
152/// user expects. Failure means there was no parent console (Start
153/// Menu / Explorer launch) - silently move on; that's the case the
154/// subsystem flag exists to handle. No-op on non-Windows or in
155/// console-subsystem builds (`AttachConsole` returns failure when a
156/// console is already attached, which we ignore).
157#[cfg(target_os = "windows")]
158fn attach_parent_console() {
159    use windows_sys::Win32::System::Console::{ATTACH_PARENT_PROCESS, AttachConsole};
160    // SAFETY: trivial FFI - no aliasing or lifetime concerns.
161    unsafe {
162        let _ = AttachConsole(ATTACH_PARENT_PROCESS);
163    }
164}
165
166#[cfg(not(target_os = "windows"))]
167fn attach_parent_console() {}
168
169/// Run the plugin standalone with the supplied launch defaults.
170/// Argv and env still take precedence - `defaults` only fills in
171/// values neither layer set. Same dispatch as [`run`].
172pub fn run_with<P: PluginExport>(defaults: Defaults)
173where
174    P::Params: 'static,
175{
176    // Must run before any stdout/stderr output: Rust caches the
177    // standard handles on first use, so attaching after the first
178    // `eprintln!` would leave the cached null handles in place and
179    // any later prints would still vanish.
180    attach_parent_console();
181
182    let mut opts = match cli::parse() {
183        Ok(o) => o,
184        Err(e) => {
185            eprintln!("Error: {e}");
186            eprintln!("Run with --help for usage.");
187            std::process::exit(2);
188        }
189    };
190
191    // `--help` was a process-wide `exit(0)` inside `cli::parse`. Now
192    // it returns `Options { help: true, .. }` so libraries calling
193    // into `parse` can be tested without short-circuiting the
194    // process; we honor it here at the binary boundary.
195    if opts.help {
196        return;
197    }
198
199    // Latch the verbose flag before anything else logs - every
200    // `vlog!` checks this static.
201    set_verbose(opts.verbose);
202
203    // Layer the plugin-author defaults beneath whatever argv / env
204    // already resolved. Other Options fields stay CLI/env-only -
205    // device, sample rate, buffer, MIDI, BPM, state are per-machine
206    // concerns the developer shouldn't pin in code.
207    defaults.apply(&mut opts);
208
209    // Lowest tier above the runtime default: the TOML-baked
210    // `mute_preview_output`. Lets analyzer-style plug-ins ship a
211    // standalone that drives `process()` from mic input without
212    // closing a feedback loop to the speakers. CLI / env / `Defaults`
213    // already set `output_enabled` to something explicit if any of
214    // those tiers cared, so this `or` only fires when nothing above
215    // it spoke.
216    if P::info().mute_preview_output {
217        opts.output_enabled = opts.output_enabled.or(Some(false));
218    }
219
220    if opts.list_devices {
221        audio::list_devices();
222        return;
223    }
224    if opts.list_midi {
225        midi::list_midi();
226        return;
227    }
228    if opts.list_presets {
229        presets::print_list::<P>(opts.presets_dir.as_deref());
230        return;
231    }
232
233    // `--output-file` always forces headless: opening a window
234    // during a render burns GPU/CPU on a UI nobody is watching,
235    // and offline mode doesn't drive an event loop at all. Notice
236    // only fires when the user didn't explicitly ask for headless,
237    // so we don't double-message the deliberate case.
238    #[cfg(feature = "playback")]
239    if opts.output_file.is_some() && !opts.headless {
240        eprintln!(
241            "--output-file implies --headless; \
242             running without a window."
243        );
244        opts.headless = true;
245    }
246
247    // `--no-playback` only applies in the canonical CI render
248    // shape (--input-file + --output-file). In any other combo
249    // there's either no driver or no destination - soft-warn and
250    // fall through to real-time so the runner stays useful.
251    #[cfg(feature = "playback")]
252    if opts.no_playback && !(opts.input_file.is_some() && opts.output_file.is_some()) {
253        eprintln!(
254            "--no-playback ignored: \
255             requires both --input-file and --output-file"
256        );
257        opts.no_playback = false;
258    }
259
260    #[cfg(feature = "playback")]
261    if opts.no_playback {
262        match offline::render::<P>(&opts) {
263            Ok(()) => return,
264            Err(e) => {
265                eprintln!("Error: {e}");
266                std::process::exit(1);
267            }
268        }
269    }
270
271    #[cfg(feature = "gui")]
272    if opts.headless {
273        headless::run::<P>(&opts);
274    } else {
275        windowed::run::<P>(&opts);
276    }
277    #[cfg(not(feature = "gui"))]
278    headless::run::<P>(&opts);
279}