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}