From fbfa75c6fe5fc8fbd9a9c2966e001a074af97a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduard=20M=C3=BCller?= Date: Tue, 24 Mar 2026 15:05:05 +0100 Subject: [PATCH 01/11] Add new standalone plugin format - optional feature, disabled by default - CPAL for audio IO, opening the default device with default settings - midir for note input, opening the first available MIDI input device - winit to create a slint host window --- examples/gain-plugin/Cargo.toml | 3 + examples/gain-plugin/src/lib.rs | 2 + plinth-plugin/Cargo.toml | 7 + plinth-plugin/src/formats.rs | 6 + plinth-plugin/src/formats/standalone.rs | 10 + plinth-plugin/src/formats/standalone/audio.rs | 85 +++++++ plinth-plugin/src/formats/standalone/host.rs | 54 +++++ .../src/formats/standalone/macros.rs | 8 + plinth-plugin/src/formats/standalone/midi.rs | 92 +++++++ .../src/formats/standalone/parameters.rs | 62 +++++ .../src/formats/standalone/plugin.rs | 6 + .../src/formats/standalone/runner.rs | 227 ++++++++++++++++++ plinth-plugin/src/lib.rs | 2 + 13 files changed, 564 insertions(+) create mode 100644 plinth-plugin/src/formats/standalone.rs create mode 100644 plinth-plugin/src/formats/standalone/audio.rs create mode 100644 plinth-plugin/src/formats/standalone/host.rs create mode 100644 plinth-plugin/src/formats/standalone/macros.rs create mode 100644 plinth-plugin/src/formats/standalone/midi.rs create mode 100644 plinth-plugin/src/formats/standalone/parameters.rs create mode 100644 plinth-plugin/src/formats/standalone/plugin.rs create mode 100644 plinth-plugin/src/formats/standalone/runner.rs diff --git a/examples/gain-plugin/Cargo.toml b/examples/gain-plugin/Cargo.toml index 45a299a..15d5a7f 100644 --- a/examples/gain-plugin/Cargo.toml +++ b/examples/gain-plugin/Cargo.toml @@ -3,6 +3,9 @@ name = "gain-plugin" version = "0.1.0" edition = "2024" +[features] +standalone = ["plinth-plugin/standalone"] + [lib] crate-type = ["cdylib", "lib", "staticlib"] diff --git a/examples/gain-plugin/src/lib.rs b/examples/gain-plugin/src/lib.rs index f70cbba..84361fa 100644 --- a/examples/gain-plugin/src/lib.rs +++ b/examples/gain-plugin/src/lib.rs @@ -3,3 +3,5 @@ mod parameters; mod plugin; mod processor; mod view; + +pub use plugin::GainPlugin; diff --git a/plinth-plugin/Cargo.toml b/plinth-plugin/Cargo.toml index dc2ff13..2766e78 100644 --- a/plinth-plugin/Cargo.toml +++ b/plinth-plugin/Cargo.toml @@ -8,6 +8,9 @@ readme = "README.md" repository = "https://github.com/ilmai/plugin-things" license = "MIT" +[features] +standalone = ["dep:cpal", "dep:midir", "dep:winit"] + [dependencies] atomic_refcell = "0.1" clap-sys = "0.5" @@ -22,6 +25,10 @@ thiserror = "2" vst3 = "0.3" widestring = "1" xxhash-rust = { version = "0.8", features = ["xxh32"] } +# standalone features +winit = { version = "0.30", optional = true } +cpal = { version = "0.17", optional = true } +midir = { version = "0.10", optional = true } [build-dependencies] bindgen = "0.72" diff --git a/plinth-plugin/src/formats.rs b/plinth-plugin/src/formats.rs index eb8cc33..6a88a86 100644 --- a/plinth-plugin/src/formats.rs +++ b/plinth-plugin/src/formats.rs @@ -3,12 +3,16 @@ use std::fmt::Display; #[cfg(target_os="macos")] pub mod auv3; pub mod clap; +#[cfg(feature = "standalone")] +pub mod standalone; pub mod vst3; #[derive(Clone, Copy, Debug)] pub enum PluginFormat { Auv3, Clap, + #[cfg(feature = "standalone")] + Standalone, Vst3, } @@ -17,6 +21,8 @@ impl Display for PluginFormat { match self { PluginFormat::Auv3 => f.write_str("AUv3"), PluginFormat::Clap => f.write_str("CLAP"), + #[cfg(feature = "standalone")] + PluginFormat::Standalone => f.write_str("Standalone"), PluginFormat::Vst3 => f.write_str("VST3"), } } diff --git a/plinth-plugin/src/formats/standalone.rs b/plinth-plugin/src/formats/standalone.rs new file mode 100644 index 0000000..3a1c65e --- /dev/null +++ b/plinth-plugin/src/formats/standalone.rs @@ -0,0 +1,10 @@ +mod audio; +mod host; +mod macros; +mod midi; +mod parameters; +mod plugin; +mod runner; + +pub use plugin::StandalonePlugin; +pub use runner::run_standalone; diff --git a/plinth-plugin/src/formats/standalone/audio.rs b/plinth-plugin/src/formats/standalone/audio.rs new file mode 100644 index 0000000..0599081 --- /dev/null +++ b/plinth-plugin/src/formats/standalone/audio.rs @@ -0,0 +1,85 @@ +use std::sync::{Arc, mpsc::Receiver}; + +use cpal::{FromSample, Sample}; +use plinth_core::{buffers::buffer::Buffer, signals::{signal::{Signal, SignalMut}, signal_base::SignalBase}}; + +use super::parameters::StandaloneParameterEventMap; +use super::plugin::StandalonePlugin; +use crate::{Event, Processor}; + +pub struct AudioState { + pub processor: P::Processor, + pub buffer: Buffer, + pub channels: usize, + pub midi_receiver: Receiver, + pub parameter_event_map: Arc, + pending_events: Vec, +} + +impl AudioState

{ + pub fn new( + processor: P::Processor, + channels: usize, + midi_receiver: Receiver, + parameter_event_map: Arc, + ) -> Self { + Self { + processor, + buffer: Buffer::new(channels, P::MAX_BLOCK_SIZE), + channels, + midi_receiver, + parameter_event_map, + pending_events: Vec::with_capacity(P::EVENT_QUEUE_LEN), + } + } + + pub fn process(&mut self, data: &mut [T], channels: usize) + where + T: Sample + FromSample, + f32: FromSample, + { + let frame_count = data.len() / channels; + + // Drain MIDI events + self.pending_events.clear(); + while let Ok(event) = self.midi_receiver.try_recv() { + self.pending_events.push(event); + } + for event in self.parameter_event_map.iter_events() { + self.pending_events.push(event); + } + + // Process audio, ensuring we don't call process with more than P::MAX_BLOCK_SIZE frames + let mut frame_offset = 0; + while frame_offset < frame_count { + let chunk_size = (frame_count - frame_offset).min(P::MAX_BLOCK_SIZE); + + if self.buffer.len() != chunk_size { + self.buffer.resize(chunk_size); + } + + // Deinterleave chunk from CPAL buffer + for frame in 0..chunk_size { + for ch in 0..self.channels { + self.buffer.channel_mut(ch)[frame] = + f32::from_sample(data[(frame_offset + frame) * self.channels + ch]); + } + } + + // Process and drain all events on first run, assuming they have no time tags + let aux: Option<&Buffer> = None; + self.processor + .process(&mut self.buffer, aux, None, self.pending_events.drain(..)); + + // Reinterleave chunk back into CPAL buffer + for frame in 0..chunk_size { + for ch in 0..self.channels { + data[(frame_offset + frame) * self.channels + ch] = + T::from_sample(self.buffer.channel(ch)[frame]); + } + } + + frame_offset += chunk_size; + } + } +} diff --git a/plinth-plugin/src/formats/standalone/host.rs b/plinth-plugin/src/formats/standalone/host.rs new file mode 100644 index 0000000..4bc09ff --- /dev/null +++ b/plinth-plugin/src/formats/standalone/host.rs @@ -0,0 +1,54 @@ +use std::sync::{Arc, mpsc::Sender}; + +use crate::{Event, Host, ParameterId, ParameterValue}; + +use super::parameters::StandaloneParameterEventMap; + +pub struct StandaloneHost { + parameter_event_map: Arc, + to_plugin_sender: Sender, +} + +impl StandaloneHost { + pub fn new( + parameter_event_map: Arc, + to_plugin_sender: Sender, + ) -> Self { + Self { + parameter_event_map, + to_plugin_sender, + } + } +} + +impl Host for StandaloneHost { + fn can_resize(&self) -> bool { + false + } + + fn resize_view(&self, _width: f64, _height: f64) -> bool { + false + } + + fn start_parameter_change(&self, id: ParameterId) { + let _ = self.to_plugin_sender.send(Event::StartParameterChange { id }); + } + + fn change_parameter_value(&self, id: ParameterId, normalized: ParameterValue) { + self.parameter_event_map.change_parameter_value(id, normalized); + + let _ = self.to_plugin_sender.send(Event::ParameterValue { + sample_offset: 0, + id, + value: normalized, + }); + } + + fn end_parameter_change(&self, id: ParameterId) { + let _ = self.to_plugin_sender.send(Event::EndParameterChange { id }); + } + + fn reload_parameters(&self) {} + + fn mark_state_dirty(&self) {} +} diff --git a/plinth-plugin/src/formats/standalone/macros.rs b/plinth-plugin/src/formats/standalone/macros.rs new file mode 100644 index 0000000..61030fe --- /dev/null +++ b/plinth-plugin/src/formats/standalone/macros.rs @@ -0,0 +1,8 @@ +#[macro_export] +macro_rules! export_standalone { + ($plugin:ty) => { + fn main() { + ::plinth_plugin::standalone::run_standalone::<$plugin>(); + } + }; +} diff --git a/plinth-plugin/src/formats/standalone/midi.rs b/plinth-plugin/src/formats/standalone/midi.rs new file mode 100644 index 0000000..8ceff5e --- /dev/null +++ b/plinth-plugin/src/formats/standalone/midi.rs @@ -0,0 +1,92 @@ +use std::sync::mpsc::Sender; + +use midir::{MidiInput, MidiInputConnection}; + +use crate::Event; + +pub fn connect_midi_inputs(sender: Sender) -> Vec> { + let midi_in = match MidiInput::new("plinth-standalone") { + Ok(midi_in) => midi_in, + Err(err) => { + log::warn!("Failed to create MIDI input: {err}"); + return vec![]; + } + }; + + let ports = midi_in.ports(); + let mut connections = Vec::with_capacity(ports.len()); + + for port in &ports { + let port_name = midi_in.port_name(port).unwrap_or_else(|_| port.id()); + let midi_in = match MidiInput::new("plinth-standalone") { + Ok(m) => m, + Err(e) => { + log::warn!("Failed to create MIDI input for port '{port_name}': {e}"); + continue; + } + }; + + let sender = sender.clone(); + match midi_in.connect( + port, + "plinth-midi-input", + move |_timestamp, data, _| { + if let Some(event) = parse_midi(data) { + let _ = sender.send(event); + } + }, + (), + ) { + Ok(conn) => connections.push(conn), + Err(e) => log::warn!("Failed to connect to MIDI input port '{port_name}': {e}"), + } + } + + connections +} + +fn parse_midi(data: &[u8]) -> Option { + if data.len() < 2 { + return None; + } + + let status = data[0] & 0xF0; + let channel = (data[0] & 0x0F) as i16; + let key = data[1] as i16; + let velocity = if data.len() >= 3 { + data[2] as f64 / 127.0 + } else { + 0.0 + }; + + match status { + 0x90 if data.len() >= 3 && data[2] > 0 => Some(Event::NoteOn { + sample_offset: 0, + channel, + key, + note: -1, + velocity, + }), + 0x80 | 0x90 => Some(Event::NoteOff { + sample_offset: 0, + channel, + key, + note: -1, + velocity, + }), + 0xE0 if data.len() >= 3 => { + let lsb = data[1] as i16; + let msb = data[2] as i16; + let bend = (msb << 7 | lsb) - 8192; + let semitones = bend as f64 / 8192.0 * 2.0; + Some(Event::PitchBend { + sample_offset: 0, + channel, + key: -1, + note: -1, + semitones, + }) + } + _ => None, + } +} diff --git a/plinth-plugin/src/formats/standalone/parameters.rs b/plinth-plugin/src/formats/standalone/parameters.rs new file mode 100644 index 0000000..536c93e --- /dev/null +++ b/plinth-plugin/src/formats/standalone/parameters.rs @@ -0,0 +1,62 @@ +use std::collections::{btree_map, BTreeMap}; +use std::sync::atomic::{AtomicBool, Ordering}; + +use portable_atomic::AtomicF64; + +use crate::{Event, ParameterId, ParameterValue, Parameters}; + +#[derive(Default)] +struct ParameterEventInfo { + value: AtomicF64, + changed: AtomicBool, +} + +pub(crate) struct StandaloneParameterEventMap { + parameter_event_info: BTreeMap, +} + +impl StandaloneParameterEventMap { + pub(crate) fn new(parameters: &impl Parameters) -> Self { + let mut parameter_event_info = BTreeMap::new(); + + for &id in parameters.ids() { + parameter_event_info.insert(id, Default::default()); + } + + Self { parameter_event_info } + } + + pub(crate) fn change_parameter_value(&self, id: ParameterId, value: ParameterValue) { + let info = self.parameter_event_info.get(&id).unwrap(); + info.value.store(value, Ordering::Release); + info.changed.store(true, Ordering::Release); + } + + pub(crate) fn iter_events(&self) -> StandaloneParameterEventIterator<'_> { + StandaloneParameterEventIterator { + event_info_iterator: self.parameter_event_info.iter(), + } + } +} + +pub(crate) struct StandaloneParameterEventIterator<'a> { + event_info_iterator: btree_map::Iter<'a, ParameterId, ParameterEventInfo>, +} + +impl Iterator for StandaloneParameterEventIterator<'_> { + type Item = Event; + + fn next(&mut self) -> Option { + loop { + let (&id, info) = self.event_info_iterator.next()?; + + if info.changed.swap(false, Ordering::AcqRel) { + return Some(Event::ParameterValue { + sample_offset: 0, + id, + value: info.value.load(Ordering::Acquire), + }); + } + } + } +} diff --git a/plinth-plugin/src/formats/standalone/plugin.rs b/plinth-plugin/src/formats/standalone/plugin.rs new file mode 100644 index 0000000..ab4570b --- /dev/null +++ b/plinth-plugin/src/formats/standalone/plugin.rs @@ -0,0 +1,6 @@ +use crate::Plugin; + +pub trait StandalonePlugin: Plugin { + const EVENT_QUEUE_LEN: usize = 1024; + const MAX_BLOCK_SIZE: usize = 4096; +} diff --git a/plinth-plugin/src/formats/standalone/runner.rs b/plinth-plugin/src/formats/standalone/runner.rs new file mode 100644 index 0000000..3f401d6 --- /dev/null +++ b/plinth-plugin/src/formats/standalone/runner.rs @@ -0,0 +1,227 @@ +use std::{rc::Rc, sync::{Arc, mpsc}, time::{Duration, Instant}}; + +use cpal::{BufferSize, FromSample, I24, SizedSample, Stream, StreamConfig, traits::{DeviceTrait, HostTrait, StreamTrait}}; +use midir::MidiInputConnection; +use raw_window_handle::HasWindowHandle; +use winit::{application::ApplicationHandler, event::WindowEvent, event_loop::{ActiveEventLoop, ControlFlow, EventLoop}, window::{Window, WindowAttributes, WindowId}}; + +use super::{audio::AudioState, host::StandaloneHost, midi, plugin::StandalonePlugin}; +use crate::{Editor, Event, Host, HostInfo, ProcessMode, Processor, ProcessorConfig, formats::PluginFormat}; +use super::parameters::StandaloneParameterEventMap; + +struct StandaloneRunner { + plugin: P, + editor: P::Editor, + to_plugin_receiver: mpsc::Receiver, + title: &'static str, + size: (f64, f64), + window: Option, + last_frame: Instant, + audio_stream: Stream, + midi_connections: Vec>, +} + +impl Drop for StandaloneRunner

{ + fn drop(&mut self) { + let _ = self.audio_stream.pause(); + self.midi_connections.clear(); + } +} + +impl ApplicationHandler for StandaloneRunner

{ + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + // Create new window + let attrs = WindowAttributes::default() + .with_title(self.title) + .with_inner_size(winit::dpi::LogicalSize::new(self.size.0, self.size.1)) + .with_resizable(self.editor.can_resize()); + + let window = match event_loop.create_window(attrs) { + Ok(w) => w, + Err(e) => { + log::error!("failed to create window: {e}"); + event_loop.exit(); + return; + } + }; + + // Set initial scale and get initial size + if !cfg!(target_os = "macos") { + // On macOS the system's DPI scale already is applied in the plugin view + self.editor.set_scale(window.scale_factor()); + } + self.size = self.editor.window_size(); + + // Attach editor to the window + let handle = window + .window_handle() + .expect("Failed to get window's platform handle") + .as_raw(); + self.editor.open(handle); + self.window = Some(window); + } + + fn suspended(&mut self, _event_loop: &ActiveEventLoop) { + self.editor.close(); + self.window = None; + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { + if let WindowEvent::CloseRequested = event { + self.editor.close(); + event_loop.exit(); + } else if let WindowEvent::ScaleFactorChanged { + scale_factor, + inner_size_writer: _, + } = event + { + if !cfg!(target_os = "macos") { + // see `resumed`impl + self.editor.set_scale(scale_factor); + } + } + } + + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_none() { + return; + } + + let now = Instant::now(); + let frame_interval = Duration::from_millis(16); + + if now >= self.last_frame + frame_interval { + while let Ok(event) = self.to_plugin_receiver.try_recv() { + self.plugin.process_event(&event); + } + self.editor.on_frame(); + self.last_frame = now; + } + + event_loop.set_control_flow(ControlFlow::WaitUntil(self.last_frame + frame_interval)); + } +} + +pub fn run_standalone() { + let host_info = HostInfo { + name: Some("Standalone".to_string()), + format: PluginFormat::Standalone, + }; + + let mut plugin = P::new(host_info); + + // Build parameter event map (shared between host and audio thread) + let parameter_event_map = + plugin.with_parameters(|params| Arc::new(StandaloneParameterEventMap::new(params))); + + // cpal setup + let cpal_host = cpal::default_host(); + let device = cpal_host + .default_output_device() + .expect("No audio output device available"); + let supported_config = device + .default_output_config() + .expect("No audio output config available"); + + let stream_config = StreamConfig { + channels: supported_config.channels(), + sample_rate: supported_config.sample_rate(), + buffer_size: BufferSize::Default, + }; + + // Create processor + // NB: CPAL unfortunately has no getter for the real applied block size, so we need to ensure that the processor never gets called with more frames + let processor_config = ProcessorConfig { + sample_rate: stream_config.sample_rate as f64, + min_block_size: 1, + max_block_size: P::MAX_BLOCK_SIZE, + process_mode: ProcessMode::Realtime, + }; + let mut processor = plugin.create_processor(processor_config); + processor.reset(); + + // Channels + let (midi_sender, midi_receiver) = mpsc::channel::(); + let (to_plugin_sender, to_plugin_receiver) = mpsc::channel::(); + + // MIDI connections (only if plugin accepts note input) + let midi_connections = if P::HAS_NOTE_INPUT { + midi::connect_midi_inputs(midi_sender) + } else { + vec![] + }; + + // Build audio state + let audio_state = AudioState::

::new( + processor, + stream_config.channels as usize, + midi_receiver, + parameter_event_map.clone(), + ); + + // Create and start cpal stream + fn run_audio_stream( + device: &cpal::Device, + config: cpal::StreamConfig, + mut audio_state: AudioState

, + ) -> Result> + where + P: StandalonePlugin + 'static, + T: SizedSample + FromSample, + f32: FromSample, + { + let channels = config.channels as usize; + + let stream = device.build_output_stream( + &config, + move |data: &mut [T], _: &cpal::OutputCallbackInfo| { + audio_state.process(data, channels); + }, + |err| { + log::error!("An audio stream error occurred: {err}"); + }, + None, + )?; + stream.play()?; + + Ok(stream) + } + + let audio_stream = match supported_config.sample_format() { + cpal::SampleFormat::I8 => run_audio_stream::(&device, stream_config, audio_state), + cpal::SampleFormat::I16 => run_audio_stream::(&device, stream_config, audio_state), + cpal::SampleFormat::I24 => run_audio_stream::(&device, stream_config, audio_state), + cpal::SampleFormat::I32 => run_audio_stream::(&device, stream_config, audio_state), + cpal::SampleFormat::I64 => run_audio_stream::(&device, stream_config, audio_state), + cpal::SampleFormat::U8 => run_audio_stream::(&device, stream_config, audio_state), + cpal::SampleFormat::U16 => run_audio_stream::(&device, stream_config, audio_state), + cpal::SampleFormat::U32 => run_audio_stream::(&device, stream_config, audio_state), + cpal::SampleFormat::U64 => run_audio_stream::(&device, stream_config, audio_state), + cpal::SampleFormat::F32 => run_audio_stream::(&device, stream_config, audio_state), + cpal::SampleFormat::F64 => run_audio_stream::(&device, stream_config, audio_state), + sample_format => panic!("Unsupported sample format '{sample_format}'"), + } + .expect("Failed to build audio output stream"); + + // Create host and editor + let host = Rc::new(StandaloneHost::new(parameter_event_map, to_plugin_sender)); + let editor = plugin.create_editor(host as Rc); + + // Create winit event loop + let event_loop = EventLoop::new().expect("Failed to create event loop"); + + // Run winit event loop (blocks until window is closed) + let mut runner = StandaloneRunner { + plugin, + editor, + to_plugin_receiver, + title: P::NAME, + size: P::Editor::DEFAULT_SIZE, + window: None, + last_frame: Instant::now(), + audio_stream, + midi_connections, + }; + + event_loop.run_app(&mut runner).expect("Event loop error"); +} diff --git a/plinth-plugin/src/lib.rs b/plinth-plugin/src/lib.rs index 5525111..ce4d10b 100644 --- a/plinth-plugin/src/lib.rs +++ b/plinth-plugin/src/lib.rs @@ -3,6 +3,8 @@ pub use error::Error; pub use event::Event; pub use host::{Host, HostInfo}; pub use formats::{clap, vst3}; +#[cfg(feature = "standalone")] +pub use formats::standalone; pub use parameters::{Parameters, ParameterId, ParameterValue}; pub use parameters::bool::{BoolParameter, BoolFormatter}; pub use parameters::enums::{Enum, EnumParameter}; From a0e463e662fdc09935b708e60db41adfb6647c2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduard=20M=C3=BCller?= Date: Tue, 24 Mar 2026 15:14:31 +0100 Subject: [PATCH 02/11] Add standalone target for the gain example --- examples/gain-plugin/Cargo.toml | 5 +++++ examples/gain-plugin/src/main.rs | 3 +++ examples/gain-plugin/src/plugin.rs | 5 ++++- 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 examples/gain-plugin/src/main.rs diff --git a/examples/gain-plugin/Cargo.toml b/examples/gain-plugin/Cargo.toml index 15d5a7f..c65b3f4 100644 --- a/examples/gain-plugin/Cargo.toml +++ b/examples/gain-plugin/Cargo.toml @@ -9,6 +9,11 @@ standalone = ["plinth-plugin/standalone"] [lib] crate-type = ["cdylib", "lib", "staticlib"] +[[bin]] +name = "gain-standalone" +path = "src/main.rs" +required-features = ["standalone"] + [dependencies] plinth-derive.workspace = true plinth-plugin.workspace = true diff --git a/examples/gain-plugin/src/main.rs b/examples/gain-plugin/src/main.rs new file mode 100644 index 0000000..d3c9df6 --- /dev/null +++ b/examples/gain-plugin/src/main.rs @@ -0,0 +1,3 @@ +use gain_plugin::GainPlugin; + +plinth_plugin::export_standalone!(GainPlugin); diff --git a/examples/gain-plugin/src/plugin.rs b/examples/gain-plugin/src/plugin.rs index b65e4f1..7c30c57 100644 --- a/examples/gain-plugin/src/plugin.rs +++ b/examples/gain-plugin/src/plugin.rs @@ -12,7 +12,7 @@ use crate::editor::{EditorSettings, GainPluginEditor}; use crate::{parameters::GainParameters, processor::GainPluginProcessor}; #[derive(Default)] -struct GainPlugin { +pub struct GainPlugin { parameters: Rc, editor_settings: Rc>, } @@ -88,3 +88,6 @@ impl Vst3Plugin for GainPlugin { export_clap!(GainPlugin); export_vst3!(GainPlugin); + +#[cfg(feature = "standalone")] +impl plinth_plugin::standalone::StandalonePlugin for GainPlugin {} From 60fe046219ebda5c7909325f40247dde022cfaf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduard=20M=C3=BCller?= Date: Wed, 25 Mar 2026 20:17:57 +0100 Subject: [PATCH 03/11] Add justfile to ease building and testing the gain-plugin example --- .cargo/config.toml | 3 ++- justfile | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 justfile diff --git a/.cargo/config.toml b/.cargo/config.toml index 4e547e5..da727a5 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,3 @@ [alias] -xtask = "run --package xtask --release --" +xtask-release = "run --package xtask --release --" +xtask-debug = "run --package xtask --" diff --git a/justfile b/justfile new file mode 100644 index 0000000..0a65719 --- /dev/null +++ b/justfile @@ -0,0 +1,23 @@ +vst3_path := if os() == "windows" { "C:/Program\\ Files/Common\\ Files/VST3/Dev/" } else if os() == "linux" { "~/.vst3/" } else if os() == "macos" { "~/Library/Audio/Plug-Ins/VST3/" } else { error("Unexpected OS") } +clap_path := if os() == "windows" { "C:/Program\\ Files/Common\\ Files/CLAP/Dev/" } else if os() == "linux" { "~/.clap/" } else if os() == "macos" { "~/Library/Audio/Plug-Ins/CLAP/" } else { error("Unexpected OS") } + +default: + @just --choose + +[arg("target", pattern="debug|release")] +bundle-gain-plugin target="debug": + cargo xtask-{{ target }} bundle gain-plugin {{ if target == "debug" { "" } else { "--release" } }} + +[arg("target", pattern="debug|release")] +install-gain-plugin target="debug": bundle-gain-plugin + cargo xtask-{{ target }} bundle gain-plugin {{ if target == "debug" { "" } else { "--release" } }} + cp -rf ./target/bundled/gain-plugin.vst3 {{ vst3_path }} + cp -rf ./target/bundled/gain-plugin.clap {{ clap_path }} + +[arg("target", pattern="debug|release")] +run-gain-standalone target="debug": + cargo run {{ if target == "release" { "--release" } else { "" } }} -p gain-plugin --features standalone + +[arg("target", pattern="debug|release")] +run-gain-standalone-live target="debug" $SLINT_LIVE_PREVIEW="1": + cargo run {{ if target == "release" { "--release" } else { "" } }} -p gain-plugin --features=standalone,slint/live-preview From ecb61fcd1e6d3a3911c857ea8cb20ffd48e9dd5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduard=20M=C3=BCller?= Date: Wed, 22 Apr 2026 14:12:47 +0200 Subject: [PATCH 04/11] Don't send Start/EndParameterChange events to plugins --- plinth-plugin/src/formats/standalone/host.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/plinth-plugin/src/formats/standalone/host.rs b/plinth-plugin/src/formats/standalone/host.rs index 4bc09ff..c36d8cd 100644 --- a/plinth-plugin/src/formats/standalone/host.rs +++ b/plinth-plugin/src/formats/standalone/host.rs @@ -30,12 +30,9 @@ impl Host for StandaloneHost { false } - fn start_parameter_change(&self, id: ParameterId) { - let _ = self.to_plugin_sender.send(Event::StartParameterChange { id }); - } - fn change_parameter_value(&self, id: ParameterId, normalized: ParameterValue) { - self.parameter_event_map.change_parameter_value(id, normalized); + self.parameter_event_map + .change_parameter_value(id, normalized); let _ = self.to_plugin_sender.send(Event::ParameterValue { sample_offset: 0, @@ -44,9 +41,8 @@ impl Host for StandaloneHost { }); } - fn end_parameter_change(&self, id: ParameterId) { - let _ = self.to_plugin_sender.send(Event::EndParameterChange { id }); - } + fn start_parameter_change(&self, _id: ParameterId) {} + fn end_parameter_change(&self, _id: ParameterId) {} fn reload_parameters(&self) {} From eef5489a26ecee4b58d365eb891452f47514e865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduard=20M=C3=BCller?= Date: Wed, 22 Apr 2026 14:16:41 +0200 Subject: [PATCH 05/11] Revert xtask changes and remove/ignore justfile --- .cargo/config.toml | 3 +-- .gitignore | 1 + justfile | 23 ----------------------- 3 files changed, 2 insertions(+), 25 deletions(-) delete mode 100644 justfile diff --git a/.cargo/config.toml b/.cargo/config.toml index da727a5..4e547e5 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,3 +1,2 @@ [alias] -xtask-release = "run --package xtask --release --" -xtask-debug = "run --package xtask --" +xtask = "run --package xtask --release --" diff --git a/.gitignore b/.gitignore index 626ce15..863fde9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .vscode .zed +justfile Cargo.lock target diff --git a/justfile b/justfile deleted file mode 100644 index 0a65719..0000000 --- a/justfile +++ /dev/null @@ -1,23 +0,0 @@ -vst3_path := if os() == "windows" { "C:/Program\\ Files/Common\\ Files/VST3/Dev/" } else if os() == "linux" { "~/.vst3/" } else if os() == "macos" { "~/Library/Audio/Plug-Ins/VST3/" } else { error("Unexpected OS") } -clap_path := if os() == "windows" { "C:/Program\\ Files/Common\\ Files/CLAP/Dev/" } else if os() == "linux" { "~/.clap/" } else if os() == "macos" { "~/Library/Audio/Plug-Ins/CLAP/" } else { error("Unexpected OS") } - -default: - @just --choose - -[arg("target", pattern="debug|release")] -bundle-gain-plugin target="debug": - cargo xtask-{{ target }} bundle gain-plugin {{ if target == "debug" { "" } else { "--release" } }} - -[arg("target", pattern="debug|release")] -install-gain-plugin target="debug": bundle-gain-plugin - cargo xtask-{{ target }} bundle gain-plugin {{ if target == "debug" { "" } else { "--release" } }} - cp -rf ./target/bundled/gain-plugin.vst3 {{ vst3_path }} - cp -rf ./target/bundled/gain-plugin.clap {{ clap_path }} - -[arg("target", pattern="debug|release")] -run-gain-standalone target="debug": - cargo run {{ if target == "release" { "--release" } else { "" } }} -p gain-plugin --features standalone - -[arg("target", pattern="debug|release")] -run-gain-standalone-live target="debug" $SLINT_LIVE_PREVIEW="1": - cargo run {{ if target == "release" { "--release" } else { "" } }} -p gain-plugin --features=standalone,slint/live-preview From fb8f4e35dec1f38a6a134c0b3624431df7f9625a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduard=20M=C3=BCller?= Date: Wed, 22 Apr 2026 22:20:25 +0200 Subject: [PATCH 06/11] Log (for events) or assert (for audio buffers) allocations on the audio thread --- plinth-plugin/src/formats/standalone/audio.rs | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/plinth-plugin/src/formats/standalone/audio.rs b/plinth-plugin/src/formats/standalone/audio.rs index 0599081..632a9fd 100644 --- a/plinth-plugin/src/formats/standalone/audio.rs +++ b/plinth-plugin/src/formats/standalone/audio.rs @@ -7,6 +7,26 @@ use super::parameters::StandaloneParameterEventMap; use super::plugin::StandalonePlugin; use crate::{Event, Processor}; +trait EventListPush { + type EventType; + fn push_event(&mut self, event: Self::EventType); +} + +impl EventListPush for Vec { + type EventType = Event; + fn push_event(&mut self, event: Event) { + if self.len() == self.capacity() { + log::warn!( + "Event queue exceeded preallocated capacity of {} - allocating more. \ + Increase EVENT_QUEUE_LEN to avoid allocation on the audio thread.", + self.capacity() + ); + self.reserve(128); + } + self.push(event); + } +} + pub struct AudioState { pub processor: P::Processor, pub buffer: Buffer, @@ -43,17 +63,25 @@ impl AudioState

{ // Drain MIDI events self.pending_events.clear(); while let Ok(event) = self.midi_receiver.try_recv() { - self.pending_events.push(event); + self.pending_events.push_event(event); } + + // Collect pending parameter change events for event in self.parameter_event_map.iter_events() { - self.pending_events.push(event); + self.pending_events.push_event(event); } // Process audio, ensuring we don't call process with more than P::MAX_BLOCK_SIZE frames + debug_assert!( + self.buffer.capacity() == P::MAX_BLOCK_SIZE, + "Buffer must be preallocated to avoid allocation on the saudio thread" + ); + let mut frame_offset = 0; while frame_offset < frame_count { let chunk_size = (frame_count - frame_offset).min(P::MAX_BLOCK_SIZE); + // Truncate or extend buffer to fit the chunk if self.buffer.len() != chunk_size { self.buffer.resize(chunk_size); } From 7bc59ae334bc21fbc2b0c953aea85f86556d9ed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduard=20M=C3=BCller?= Date: Wed, 22 Apr 2026 22:35:44 +0200 Subject: [PATCH 07/11] Add README for the gain example to make clear how to build it --- examples/gain-plugin/README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 examples/gain-plugin/README.md diff --git a/examples/gain-plugin/README.md b/examples/gain-plugin/README.md new file mode 100644 index 0000000..a736a07 --- /dev/null +++ b/examples/gain-plugin/README.md @@ -0,0 +1,27 @@ +# Gain Plugin Example + +A minimal example audio effect plugin demonstrating the `plugin-things` framework. + +## Building + +> Append `--release` to any command below for a release build. + +### Plugin Bundles (CLAP & VST3) + +```sh +cargo xtask bundle gain-plugin +``` + +### Standalone App + +```sh +cargo run -p gain-plugin --features standalone +``` + +### Standalone App with Live Preview + +Hot-reload the Slint UI when modifying .slint UI files without recompiling the app. + +```sh +SLINT_LIVE_PREVIEW=1 cargo run -p gain-plugin --features=standalone,slint/live-preview +``` From d2738bafd38bac49a473414c02298cc39c4623f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduard=20M=C3=BCller?= Date: Wed, 22 Apr 2026 22:47:37 +0200 Subject: [PATCH 08/11] Add TODO for StandaloneParameterEventIterator's next impl --- plinth-plugin/src/formats/standalone/parameters.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plinth-plugin/src/formats/standalone/parameters.rs b/plinth-plugin/src/formats/standalone/parameters.rs index 536c93e..6221f64 100644 --- a/plinth-plugin/src/formats/standalone/parameters.rs +++ b/plinth-plugin/src/formats/standalone/parameters.rs @@ -1,4 +1,4 @@ -use std::collections::{btree_map, BTreeMap}; +use std::collections::{BTreeMap, btree_map}; use std::sync::atomic::{AtomicBool, Ordering}; use portable_atomic::AtomicF64; @@ -23,7 +23,9 @@ impl StandaloneParameterEventMap { parameter_event_info.insert(id, Default::default()); } - Self { parameter_event_info } + Self { + parameter_event_info, + } } pub(crate) fn change_parameter_value(&self, id: ParameterId, value: ParameterValue) { @@ -47,6 +49,8 @@ impl Iterator for StandaloneParameterEventIterator<'_> { type Item = Event; fn next(&mut self) -> Option { + // TODO: this iterates the entire parameter list on every audio callback regardless of how many + // parameters actually changed. Should fix this before shipping standalone apps to end users. loop { let (&id, info) = self.event_info_iterator.next()?; From 6625f0fc1e8e985db820f3645487736ceaa5a38c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduard=20M=C3=BCller?= Date: Fri, 24 Apr 2026 12:20:06 +0200 Subject: [PATCH 09/11] Add audio and MIDI configs and enumerate functions to allow selecting devices programatically --- plinth-plugin/Cargo.toml | 4 +- plinth-plugin/src/formats/standalone.rs | 2 + plinth-plugin/src/formats/standalone/audio.rs | 4 +- .../src/formats/standalone/config.rs | 180 ++++++++++++++++++ .../src/formats/standalone/macros.rs | 31 ++- plinth-plugin/src/formats/standalone/midi.rs | 30 ++- .../src/formats/standalone/runner.rs | 100 +++++----- 7 files changed, 298 insertions(+), 53 deletions(-) create mode 100644 plinth-plugin/src/formats/standalone/config.rs diff --git a/plinth-plugin/Cargo.toml b/plinth-plugin/Cargo.toml index 2766e78..063a9bc 100644 --- a/plinth-plugin/Cargo.toml +++ b/plinth-plugin/Cargo.toml @@ -27,8 +27,8 @@ widestring = "1" xxhash-rust = { version = "0.8", features = ["xxh32"] } # standalone features winit = { version = "0.30", optional = true } -cpal = { version = "0.17", optional = true } -midir = { version = "0.10", optional = true } +cpal = { version = "0.17", optional = true, features = ["asio", "jack"] } +midir = { version = "0.11", optional = true } [build-dependencies] bindgen = "0.72" diff --git a/plinth-plugin/src/formats/standalone.rs b/plinth-plugin/src/formats/standalone.rs index 3a1c65e..d661d54 100644 --- a/plinth-plugin/src/formats/standalone.rs +++ b/plinth-plugin/src/formats/standalone.rs @@ -1,4 +1,5 @@ mod audio; +mod config; mod host; mod macros; mod midi; @@ -6,5 +7,6 @@ mod parameters; mod plugin; mod runner; +pub use config::{AudioDeviceDriver, AudioOutputConfig, MidiInputConfig}; pub use plugin::StandalonePlugin; pub use runner::run_standalone; diff --git a/plinth-plugin/src/formats/standalone/audio.rs b/plinth-plugin/src/formats/standalone/audio.rs index 632a9fd..140deb4 100644 --- a/plinth-plugin/src/formats/standalone/audio.rs +++ b/plinth-plugin/src/formats/standalone/audio.rs @@ -1,12 +1,13 @@ use std::sync::{Arc, mpsc::Receiver}; use cpal::{FromSample, Sample}; -use plinth_core::{buffers::buffer::Buffer, signals::{signal::{Signal, SignalMut}, signal_base::SignalBase}}; +use plinth_core::{ buffers::buffer::Buffer, signals::{ signal::{Signal, SignalMut}, signal_base::SignalBase } }; use super::parameters::StandaloneParameterEventMap; use super::plugin::StandalonePlugin; use crate::{Event, Processor}; +/// Push events to a event list vec, printing a warning when preallocated memory exceeded. trait EventListPush { type EventType; fn push_event(&mut self, event: Self::EventType); @@ -27,6 +28,7 @@ impl EventListPush for Vec { } } +/// Runs a plinth processor on a CPAL audio stream pub struct AudioState { pub processor: P::Processor, pub buffer: Buffer, diff --git a/plinth-plugin/src/formats/standalone/config.rs b/plinth-plugin/src/formats/standalone/config.rs new file mode 100644 index 0000000..1ae6190 --- /dev/null +++ b/plinth-plugin/src/formats/standalone/config.rs @@ -0,0 +1,180 @@ +use cpal::traits::{DeviceTrait, HostTrait}; +use midir::MidiInput; + +/// Available audio backends for [`StandalonePlugin`]. +#[derive(Debug, Default, Clone, Copy)] +pub enum AudioDeviceDriver { + #[default] + Default, + #[cfg(target_os = "windows")] + Asio, + #[cfg(target_os = "windows")] + Wasapi, + #[cfg(target_os = "linux")] + Alsa, + #[cfg(target_os = "macos")] + CoreAudio, + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + Jack, +} + +impl AudioDeviceDriver { + fn open(self) -> Result> { + match self { + AudioDeviceDriver::Default => Ok(cpal::default_host()), + #[cfg(target_os = "windows")] + AudioDeviceDriver::Asio => Ok(cpal::host_from_id(cpal::HostId::Asio)?), + #[cfg(target_os = "windows")] + AudioDeviceDriver::Wasapi => Ok(cpal::host_from_id(cpal::HostId::Wasapi)?), + #[cfg(target_os = "linux")] + AudioDeviceDriver::Alsa => Ok(cpal::host_from_id(cpal::HostId::Alsa)?), + #[cfg(target_os = "macos")] + AudioDeviceDriver::CoreAudio => Ok(cpal::host_from_id(cpal::HostId::CoreAudio)?), + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + AudioDeviceDriver::Jack => Ok(cpal::host_from_id(cpal::HostId::Jack)?), + } + } +} + +/// Audio output configuration for [`StandalonePlugin`]. +#[derive(Debug, Default)] +pub struct AudioOutputConfig { + /// Audio host/driver to use. Defaults to `cpal::default_host`. + pub driver: AudioDeviceDriver, + /// Id of the output device to open. `None` selects the driver's default device. + pub device_id: Option, + /// Desired sample rate in Hz. `None` uses the device's default rate. + pub sample_rate: Option, + /// Audio buffer size in frames. `None` uses the device's default buffer size. + pub buffer_size: Option, +} + +impl AudioOutputConfig { + const PREFERRED_SAMPLE_RATE: cpal::SampleRate = 44100; + const PREFERRED_CHANNELS: cpal::ChannelCount = 2; + const PREFERRED_SAMPLE_FORMAT: cpal::SampleFormat = cpal::SampleFormat::F32; + + /// Returns all audio drivers available on this platform. + /// + /// Always includes [`AudioDeviceDriver::Default`], followed by any named drivers that are + /// currently available (e.g. ASIO, WASAPI on Windows; ALSA, JACK on Linux). + pub fn available_drivers() -> Vec { + let hosts = cpal::available_hosts(); + let mut drivers = vec![AudioDeviceDriver::Default]; + #[cfg(target_os = "windows")] + if hosts.contains(&cpal::HostId::Asio) { + drivers.push(AudioDeviceDriver::Asio); + } + #[cfg(target_os = "windows")] + if hosts.contains(&cpal::HostId::Wasapi) { + drivers.push(AudioDeviceDriver::Wasapi); + } + #[cfg(target_os = "linux")] + if hosts.contains(&cpal::HostId::Alsa) { + drivers.push(AudioDeviceDriver::Alsa); + } + #[cfg(target_os = "macos")] + if hosts.contains(&cpal::HostId::CoreAudio) { + drivers.push(AudioDeviceDriver::CoreAudio); + } + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + if hosts.contains(&cpal::HostId::Jack) { + drivers.push(AudioDeviceDriver::Jack); + } + drivers + } + + /// Returns `(id, name)`s of all output devices available for the given driver. + pub fn available_devices( + driver: AudioDeviceDriver, + ) -> Result, Box> { + let host = driver.open()?; + let mut devices = Vec::new(); + for device in host.output_devices()? { + match (device.id(), device.description()) { + (Ok(id), Ok(description)) => { + devices.push((id, description.to_string())); + } + (Ok(id), Err(_)) => { + devices.push((id.clone(), id.to_string())); + } + (Err(err), _) => { + log::warn!("Failed to query audio device id {err}") + } + } + } + Ok(devices) + } + + pub fn open_host(&self) -> Result> { + self.driver.open() + } + + pub fn open_device( + &self, + host: &mut cpal::Host, + ) -> Result> { + if let Some(device_id) = &self.device_id { + log::info!("Opening CPAL output device '{}'...", device_id); + host.output_devices()? + .find(|d| d.id().ok().as_ref() == Some(device_id)) + .ok_or_else(|| "Specified audio device not found".into()) + } else { + log::info!("Opening CPAL default output device..."); + host.default_output_device() + .ok_or_else(|| "No audio output device available".into()) + } + } + + pub fn select_stream_config( + &self, + device: &cpal::Device, + ) -> Result> { + let target_rate = self.sample_rate.unwrap_or(Self::PREFERRED_SAMPLE_RATE); + let mut configs = device.supported_output_configs()?.collect::>(); + configs.sort_by(|a, b| b.cmp_default_heuristics(a)); + let supports_rate = |s: &cpal::SupportedStreamConfigRange| { + (s.min_sample_rate()..=s.max_sample_rate()).contains(&target_rate) + }; + let best_match = configs + .iter() + .find(|s| { + supports_rate(s) + && s.channels() == Self::PREFERRED_CHANNELS + && s.sample_format() == Self::PREFERRED_SAMPLE_FORMAT + }) + .or_else(|| { + configs + .iter() + .find(|s| supports_rate(s) && s.channels() == Self::PREFERRED_CHANNELS) + }) + .or_else(|| configs.iter().find(|s| supports_rate(s))); + match best_match { + Some(s) => Ok(s.with_sample_rate(target_rate)), + None => { + log::warn!("No matching audio device config found, using device default"); + Ok(device.default_output_config()?) + } + } + } +} + +/// MIDI input configuration for [`StandalonePlugin`]. +#[derive(Debug, Default)] +pub struct MidiInputConfig { + /// Names of MIDI input ports to connect to. `None` connects to all available ports. + pub port_names: Option>, +} + +impl MidiInputConfig { + /// Returns the names of all currently available MIDI input ports. + pub fn available_ports() -> Result, Box> { + let midi_in = MidiInput::new("plinth-standalone")?; + let ports = midi_in.ports(); + let names = ports + .iter() + .filter_map(|p| midi_in.port_name(p).ok()) + .collect(); + Ok(names) + } +} diff --git a/plinth-plugin/src/formats/standalone/macros.rs b/plinth-plugin/src/formats/standalone/macros.rs index 61030fe..352f0c6 100644 --- a/plinth-plugin/src/formats/standalone/macros.rs +++ b/plinth-plugin/src/formats/standalone/macros.rs @@ -1,8 +1,37 @@ +/// Generates a `main` entry point that runs the given plugin as a standalone application. +/// +/// By default audio and MIDI use default configurations. To customise them call [`run_standalone`] directly: +/// +/// ```rust,ignore +/// fn main() { +/// // Enumerate available audio devices for the default driver +/// let audio_devices = AudioOutputConfig::available_devices(AudioDeviceDriver::Default) +/// .expect("Failed to enumerate audio devices"); +/// // Enumerate available MIDI input ports +/// let midi_ports = MidiInputConfig::available_ports() +/// .expect("Failed to enumerate MIDI ports"); +/// +/// let audio_config = AudioOutputConfig { +/// driver: AudioDeviceDriver::Default, +/// device_id: audio_devices.first().map(|(id, _)| id.clone()), +/// sample_rate: Some(48000), +/// buffer_size: Some(512), +/// }; +/// let midi_config = MidiInputConfig { +/// port_names: Some(vec!["My MIDI Keyboard".to_string()]), +/// }; +/// +/// run_standalone::(audio_config, midi_config); +/// } +/// ``` #[macro_export] macro_rules! export_standalone { ($plugin:ty) => { fn main() { - ::plinth_plugin::standalone::run_standalone::<$plugin>(); + ::plinth_plugin::standalone::run_standalone::<$plugin>( + Default::default(), + Default::default(), + ); } }; } diff --git a/plinth-plugin/src/formats/standalone/midi.rs b/plinth-plugin/src/formats/standalone/midi.rs index 8ceff5e..0d221b8 100644 --- a/plinth-plugin/src/formats/standalone/midi.rs +++ b/plinth-plugin/src/formats/standalone/midi.rs @@ -2,11 +2,15 @@ use std::sync::mpsc::Sender; use midir::{MidiInput, MidiInputConnection}; +use super::config::MidiInputConfig; use crate::Event; -pub fn connect_midi_inputs(sender: Sender) -> Vec> { +pub fn connect_inputs( + config: &MidiInputConfig, + sender: Sender, +) -> Vec> { let midi_in = match MidiInput::new("plinth-standalone") { - Ok(midi_in) => midi_in, + Ok(m) => m, Err(err) => { log::warn!("Failed to create MIDI input: {err}"); return vec![]; @@ -14,10 +18,25 @@ pub fn connect_midi_inputs(sender: Sender) -> Vec }; let ports = midi_in.ports(); + + if ports.is_empty() { + log::info!("No MIDI input ports available"); + } else { + for port in &ports { + let name = midi_in.port_name(port).unwrap_or_else(|_| port.id()); + log::info!("Available MIDI input port: '{name}'"); + } + } + let mut connections = Vec::with_capacity(ports.len()); for port in &ports { let port_name = midi_in.port_name(port).unwrap_or_else(|_| port.id()); + + if config.port_names.as_ref().is_some_and(|names| !names.iter().any(|n| n == &port_name)) { + continue; + } + let midi_in = match MidiInput::new("plinth-standalone") { Ok(m) => m, Err(e) => { @@ -37,8 +56,11 @@ pub fn connect_midi_inputs(sender: Sender) -> Vec }, (), ) { - Ok(conn) => connections.push(conn), - Err(e) => log::warn!("Failed to connect to MIDI input port '{port_name}': {e}"), + Ok(connection) => { + log::info!("Connected MIDI input port '{port_name}'"); + connections.push(connection); + } + Err(err) => log::warn!("Failed to connect MIDI input port '{port_name}': {err}"), } } diff --git a/plinth-plugin/src/formats/standalone/runner.rs b/plinth-plugin/src/formats/standalone/runner.rs index 3f401d6..7c26289 100644 --- a/plinth-plugin/src/formats/standalone/runner.rs +++ b/plinth-plugin/src/formats/standalone/runner.rs @@ -1,13 +1,13 @@ use std::{rc::Rc, sync::{Arc, mpsc}, time::{Duration, Instant}}; -use cpal::{BufferSize, FromSample, I24, SizedSample, Stream, StreamConfig, traits::{DeviceTrait, HostTrait, StreamTrait}}; +use cpal::{BufferSize, FromSample, I24, SizedSample, Stream, StreamConfig, traits::{DeviceTrait, StreamTrait}}; use midir::MidiInputConnection; use raw_window_handle::HasWindowHandle; use winit::{application::ApplicationHandler, event::WindowEvent, event_loop::{ActiveEventLoop, ControlFlow, EventLoop}, window::{Window, WindowAttributes, WindowId}}; -use super::{audio::AudioState, host::StandaloneHost, midi, plugin::StandalonePlugin}; +use super::{parameters::StandaloneParameterEventMap, audio::AudioState, config::{AudioOutputConfig, MidiInputConfig}, host::StandaloneHost, midi, plugin::StandalonePlugin}; + use crate::{Editor, Event, Host, HostInfo, ProcessMode, Processor, ProcessorConfig, formats::PluginFormat}; -use super::parameters::StandaloneParameterEventMap; struct StandaloneRunner { plugin: P, @@ -75,6 +75,7 @@ impl ApplicationHandler for StandaloneRunner

{ inner_size_writer: _, } = event { + #[allow(clippy::collapsible_if)] if !cfg!(target_os = "macos") { // see `resumed`impl self.editor.set_scale(scale_factor); @@ -102,7 +103,10 @@ impl ApplicationHandler for StandaloneRunner

{ } } -pub fn run_standalone() { +pub fn run_standalone( + audio_config: AudioOutputConfig, + midi_config: MidiInputConfig, +) { let host_info = HostInfo { name: Some("Standalone".to_string()), format: PluginFormat::Standalone, @@ -110,29 +114,36 @@ pub fn run_standalone() { let mut plugin = P::new(host_info); - // Build parameter event map (shared between host and audio thread) + // Parameter event map (shared between host and audio thread) let parameter_event_map = plugin.with_parameters(|params| Arc::new(StandaloneParameterEventMap::new(params))); - // cpal setup - let cpal_host = cpal::default_host(); - let device = cpal_host - .default_output_device() - .expect("No audio output device available"); - let supported_config = device - .default_output_config() - .expect("No audio output config available"); + // Channels + let (midi_sender, midi_receiver) = mpsc::channel::(); + let (to_plugin_sender, to_plugin_receiver) = mpsc::channel::(); - let stream_config = StreamConfig { - channels: supported_config.channels(), - sample_rate: supported_config.sample_rate(), - buffer_size: BufferSize::Default, + // Open MIDI connections if plugin accepts note inputs + let midi_connections = if P::HAS_NOTE_INPUT { + midi::connect_inputs(&midi_config, midi_sender) + } else { + vec![] }; + // Open audio device + let mut audio_host = audio_config + .open_host() + .expect("Failed to open audio driver"); + let audio_device = audio_config + .open_device(&mut audio_host) + .expect("Failed to open audio device"); + let audio_stream_config = audio_config + .select_stream_config(&audio_device) + .expect("Failed to select audio stream config"); + // Create processor // NB: CPAL unfortunately has no getter for the real applied block size, so we need to ensure that the processor never gets called with more frames let processor_config = ProcessorConfig { - sample_rate: stream_config.sample_rate as f64, + sample_rate: audio_stream_config.sample_rate() as f64, min_block_size: 1, max_block_size: P::MAX_BLOCK_SIZE, process_mode: ProcessMode::Realtime, @@ -140,26 +151,15 @@ pub fn run_standalone() { let mut processor = plugin.create_processor(processor_config); processor.reset(); - // Channels - let (midi_sender, midi_receiver) = mpsc::channel::(); - let (to_plugin_sender, to_plugin_receiver) = mpsc::channel::(); - - // MIDI connections (only if plugin accepts note input) - let midi_connections = if P::HAS_NOTE_INPUT { - midi::connect_midi_inputs(midi_sender) - } else { - vec![] - }; - - // Build audio state + // Create audio state let audio_state = AudioState::

::new( processor, - stream_config.channels as usize, + audio_stream_config.channels() as usize, midi_receiver, parameter_event_map.clone(), ); - // Create and start cpal stream + // Create and start the CPAL stream fn run_audio_stream( device: &cpal::Device, config: cpal::StreamConfig, @@ -187,18 +187,28 @@ pub fn run_standalone() { Ok(stream) } - let audio_stream = match supported_config.sample_format() { - cpal::SampleFormat::I8 => run_audio_stream::(&device, stream_config, audio_state), - cpal::SampleFormat::I16 => run_audio_stream::(&device, stream_config, audio_state), - cpal::SampleFormat::I24 => run_audio_stream::(&device, stream_config, audio_state), - cpal::SampleFormat::I32 => run_audio_stream::(&device, stream_config, audio_state), - cpal::SampleFormat::I64 => run_audio_stream::(&device, stream_config, audio_state), - cpal::SampleFormat::U8 => run_audio_stream::(&device, stream_config, audio_state), - cpal::SampleFormat::U16 => run_audio_stream::(&device, stream_config, audio_state), - cpal::SampleFormat::U32 => run_audio_stream::(&device, stream_config, audio_state), - cpal::SampleFormat::U64 => run_audio_stream::(&device, stream_config, audio_state), - cpal::SampleFormat::F32 => run_audio_stream::(&device, stream_config, audio_state), - cpal::SampleFormat::F64 => run_audio_stream::(&device, stream_config, audio_state), + let stream_format = audio_stream_config.sample_format(); + let stream_config = StreamConfig { + channels: audio_stream_config.channels(), + sample_rate: audio_stream_config.sample_rate(), + buffer_size: audio_config + .buffer_size + .map(BufferSize::Fixed) + .unwrap_or(BufferSize::Default), + }; + + let audio_stream = match stream_format { + cpal::SampleFormat::I8 => run_audio_stream::(&audio_device, stream_config, audio_state), + cpal::SampleFormat::I16 => run_audio_stream::(&audio_device, stream_config, audio_state), + cpal::SampleFormat::I24 => run_audio_stream::(&audio_device, stream_config, audio_state), + cpal::SampleFormat::I32 => run_audio_stream::(&audio_device, stream_config, audio_state), + cpal::SampleFormat::I64 => run_audio_stream::(&audio_device, stream_config, audio_state), + cpal::SampleFormat::U8 => run_audio_stream::(&audio_device, stream_config, audio_state), + cpal::SampleFormat::U16 => run_audio_stream::(&audio_device, stream_config, audio_state), + cpal::SampleFormat::U32 => run_audio_stream::(&audio_device, stream_config, audio_state), + cpal::SampleFormat::U64 => run_audio_stream::(&audio_device, stream_config, audio_state), + cpal::SampleFormat::F32 => run_audio_stream::(&audio_device, stream_config, audio_state), + cpal::SampleFormat::F64 => run_audio_stream::(&audio_device, stream_config, audio_state), sample_format => panic!("Unsupported sample format '{sample_format}'"), } .expect("Failed to build audio output stream"); @@ -222,6 +232,6 @@ pub fn run_standalone() { audio_stream, midi_connections, }; - + event_loop.run_app(&mut runner).expect("Event loop error"); } From 0408af62912241c7c4f92b122ec17ab52e35de00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduard=20M=C3=BCller?= Date: Fri, 24 Apr 2026 15:14:55 +0200 Subject: [PATCH 10/11] Removed some logging & fixed some comments --- plinth-plugin/src/formats/standalone/audio.rs | 2 +- plinth-plugin/src/formats/standalone/config.rs | 3 --- plinth-plugin/src/formats/standalone/midi.rs | 5 ----- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/plinth-plugin/src/formats/standalone/audio.rs b/plinth-plugin/src/formats/standalone/audio.rs index 140deb4..74f67c1 100644 --- a/plinth-plugin/src/formats/standalone/audio.rs +++ b/plinth-plugin/src/formats/standalone/audio.rs @@ -76,7 +76,7 @@ impl AudioState

{ // Process audio, ensuring we don't call process with more than P::MAX_BLOCK_SIZE frames debug_assert!( self.buffer.capacity() == P::MAX_BLOCK_SIZE, - "Buffer must be preallocated to avoid allocation on the saudio thread" + "Buffer must be preallocated to avoid allocation on the audio thread" ); let mut frame_offset = 0; diff --git a/plinth-plugin/src/formats/standalone/config.rs b/plinth-plugin/src/formats/standalone/config.rs index 1ae6190..a703ff7 100644 --- a/plinth-plugin/src/formats/standalone/config.rs +++ b/plinth-plugin/src/formats/standalone/config.rs @@ -55,9 +55,6 @@ impl AudioOutputConfig { const PREFERRED_SAMPLE_FORMAT: cpal::SampleFormat = cpal::SampleFormat::F32; /// Returns all audio drivers available on this platform. - /// - /// Always includes [`AudioDeviceDriver::Default`], followed by any named drivers that are - /// currently available (e.g. ASIO, WASAPI on Windows; ALSA, JACK on Linux). pub fn available_drivers() -> Vec { let hosts = cpal::available_hosts(); let mut drivers = vec![AudioDeviceDriver::Default]; diff --git a/plinth-plugin/src/formats/standalone/midi.rs b/plinth-plugin/src/formats/standalone/midi.rs index 0d221b8..3ea6dd5 100644 --- a/plinth-plugin/src/formats/standalone/midi.rs +++ b/plinth-plugin/src/formats/standalone/midi.rs @@ -21,11 +21,6 @@ pub fn connect_inputs( if ports.is_empty() { log::info!("No MIDI input ports available"); - } else { - for port in &ports { - let name = midi_in.port_name(port).unwrap_or_else(|_| port.id()); - log::info!("Available MIDI input port: '{name}'"); - } } let mut connections = Vec::with_capacity(ports.len()); From 2b92145d150ac85ca95105ab49b8da72b191ac55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduard=20M=C3=BCller?= Date: Fri, 24 Apr 2026 15:22:16 +0200 Subject: [PATCH 11/11] Remove `export_standalone` macro & add new `run_standalone` and `run_standalone_with_config` with examples in comments --- examples/gain-plugin/src/main.rs | 5 ++- plinth-plugin/src/formats/standalone.rs | 3 +- .../src/formats/standalone/macros.rs | 37 ---------------- .../src/formats/standalone/runner.rs | 44 ++++++++++++++++++- 4 files changed, 48 insertions(+), 41 deletions(-) delete mode 100644 plinth-plugin/src/formats/standalone/macros.rs diff --git a/examples/gain-plugin/src/main.rs b/examples/gain-plugin/src/main.rs index d3c9df6..09b081c 100644 --- a/examples/gain-plugin/src/main.rs +++ b/examples/gain-plugin/src/main.rs @@ -1,3 +1,6 @@ +use plinth_plugin::standalone::run_standalone; use gain_plugin::GainPlugin; -plinth_plugin::export_standalone!(GainPlugin); +fn main() { + run_standalone::(); +} diff --git a/plinth-plugin/src/formats/standalone.rs b/plinth-plugin/src/formats/standalone.rs index d661d54..d598fe2 100644 --- a/plinth-plugin/src/formats/standalone.rs +++ b/plinth-plugin/src/formats/standalone.rs @@ -1,7 +1,6 @@ mod audio; mod config; mod host; -mod macros; mod midi; mod parameters; mod plugin; @@ -9,4 +8,4 @@ mod runner; pub use config::{AudioDeviceDriver, AudioOutputConfig, MidiInputConfig}; pub use plugin::StandalonePlugin; -pub use runner::run_standalone; +pub use runner::{run_standalone, run_standalone_with_config}; diff --git a/plinth-plugin/src/formats/standalone/macros.rs b/plinth-plugin/src/formats/standalone/macros.rs deleted file mode 100644 index 352f0c6..0000000 --- a/plinth-plugin/src/formats/standalone/macros.rs +++ /dev/null @@ -1,37 +0,0 @@ -/// Generates a `main` entry point that runs the given plugin as a standalone application. -/// -/// By default audio and MIDI use default configurations. To customise them call [`run_standalone`] directly: -/// -/// ```rust,ignore -/// fn main() { -/// // Enumerate available audio devices for the default driver -/// let audio_devices = AudioOutputConfig::available_devices(AudioDeviceDriver::Default) -/// .expect("Failed to enumerate audio devices"); -/// // Enumerate available MIDI input ports -/// let midi_ports = MidiInputConfig::available_ports() -/// .expect("Failed to enumerate MIDI ports"); -/// -/// let audio_config = AudioOutputConfig { -/// driver: AudioDeviceDriver::Default, -/// device_id: audio_devices.first().map(|(id, _)| id.clone()), -/// sample_rate: Some(48000), -/// buffer_size: Some(512), -/// }; -/// let midi_config = MidiInputConfig { -/// port_names: Some(vec!["My MIDI Keyboard".to_string()]), -/// }; -/// -/// run_standalone::(audio_config, midi_config); -/// } -/// ``` -#[macro_export] -macro_rules! export_standalone { - ($plugin:ty) => { - fn main() { - ::plinth_plugin::standalone::run_standalone::<$plugin>( - Default::default(), - Default::default(), - ); - } - }; -} diff --git a/plinth-plugin/src/formats/standalone/runner.rs b/plinth-plugin/src/formats/standalone/runner.rs index 7c26289..4b62434 100644 --- a/plinth-plugin/src/formats/standalone/runner.rs +++ b/plinth-plugin/src/formats/standalone/runner.rs @@ -103,7 +103,49 @@ impl ApplicationHandler for StandaloneRunner

{ } } -pub fn run_standalone( +/// Runs the given plugin as a standalone application using the default audio output device and all available +/// MIDI input ports (if the plugin has `HAS_NOTE_INPUT` set). +/// +/// # Example +/// +/// ```rust,ignore +/// use plinth_plugin::standalone::run_standalone; +/// +/// fn main() { +/// run_standalone::(); +/// } +/// ``` +pub fn run_standalone() { + run_standalone_with_config::

(AudioOutputConfig::default(), MidiInputConfig::default()); +} + +/// Runs the given plugin as a standalone application with explicit audio and MIDI configuration. +/// +/// # Example +/// +/// ```rust,ignore +/// fn main() { +/// // Enumerate available audio devices for the default driver +/// let audio_devices = AudioOutputConfig::available_devices(AudioDeviceDriver::Default) +/// .expect("Failed to enumerate audio devices"); +/// // Enumerate available MIDI input ports +/// let midi_ports = MidiInputConfig::available_ports() +/// .expect("Failed to enumerate MIDI ports"); +/// +/// let audio_config = AudioOutputConfig { +/// driver: AudioDeviceDriver::Default, +/// device_id: audio_devices.first().map(|(id, _)| id.clone()), +/// sample_rate: Some(48000), +/// buffer_size: Some(512), +/// }; +/// let midi_config = MidiInputConfig { +/// port_names: Some(vec!["My MIDI Keyboard".to_string()]), +/// }; +/// +/// run_standalone_with_config::(audio_config, midi_config); +/// } +/// ``` +pub fn run_standalone_with_config( audio_config: AudioOutputConfig, midi_config: MidiInputConfig, ) {