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/examples/gain-plugin/Cargo.toml b/examples/gain-plugin/Cargo.toml index 45a299a..c65b3f4 100644 --- a/examples/gain-plugin/Cargo.toml +++ b/examples/gain-plugin/Cargo.toml @@ -3,9 +3,17 @@ name = "gain-plugin" version = "0.1.0" edition = "2024" +[features] +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/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 +``` 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/examples/gain-plugin/src/main.rs b/examples/gain-plugin/src/main.rs new file mode 100644 index 0000000..09b081c --- /dev/null +++ b/examples/gain-plugin/src/main.rs @@ -0,0 +1,6 @@ +use plinth_plugin::standalone::run_standalone; +use gain_plugin::GainPlugin; + +fn main() { + run_standalone::(); +} 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 {} diff --git a/plinth-plugin/Cargo.toml b/plinth-plugin/Cargo.toml index dc2ff13..063a9bc 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, features = ["asio", "jack"] } +midir = { version = "0.11", 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..d598fe2 --- /dev/null +++ b/plinth-plugin/src/formats/standalone.rs @@ -0,0 +1,11 @@ +mod audio; +mod config; +mod host; +mod midi; +mod parameters; +mod plugin; +mod runner; + +pub use config::{AudioDeviceDriver, AudioOutputConfig, MidiInputConfig}; +pub use plugin::StandalonePlugin; +pub use runner::{run_standalone, run_standalone_with_config}; diff --git a/plinth-plugin/src/formats/standalone/audio.rs b/plinth-plugin/src/formats/standalone/audio.rs new file mode 100644 index 0000000..74f67c1 --- /dev/null +++ b/plinth-plugin/src/formats/standalone/audio.rs @@ -0,0 +1,115 @@ +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}; + +/// 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); +} + +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); + } +} + +/// Runs a plinth processor on a CPAL audio stream +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(event); + } + + // Collect pending parameter change events + for event in self.parameter_event_map.iter_events() { + 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 audio 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); + } + + // 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/config.rs b/plinth-plugin/src/formats/standalone/config.rs new file mode 100644 index 0000000..a703ff7 --- /dev/null +++ b/plinth-plugin/src/formats/standalone/config.rs @@ -0,0 +1,177 @@ +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. + 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/host.rs b/plinth-plugin/src/formats/standalone/host.rs new file mode 100644 index 0000000..c36d8cd --- /dev/null +++ b/plinth-plugin/src/formats/standalone/host.rs @@ -0,0 +1,50 @@ +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 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 start_parameter_change(&self, _id: ParameterId) {} + fn end_parameter_change(&self, _id: ParameterId) {} + + fn reload_parameters(&self) {} + + fn mark_state_dirty(&self) {} +} diff --git a/plinth-plugin/src/formats/standalone/midi.rs b/plinth-plugin/src/formats/standalone/midi.rs new file mode 100644 index 0000000..3ea6dd5 --- /dev/null +++ b/plinth-plugin/src/formats/standalone/midi.rs @@ -0,0 +1,109 @@ +use std::sync::mpsc::Sender; + +use midir::{MidiInput, MidiInputConnection}; + +use super::config::MidiInputConfig; +use crate::Event; + +pub fn connect_inputs( + config: &MidiInputConfig, + sender: Sender, +) -> Vec> { + let midi_in = match MidiInput::new("plinth-standalone") { + Ok(m) => m, + Err(err) => { + log::warn!("Failed to create MIDI input: {err}"); + return vec![]; + } + }; + + let ports = midi_in.ports(); + + if ports.is_empty() { + log::info!("No MIDI input ports available"); + } + + 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) => { + 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(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}"), + } + } + + 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..6221f64 --- /dev/null +++ b/plinth-plugin/src/formats/standalone/parameters.rs @@ -0,0 +1,66 @@ +use std::collections::{BTreeMap, btree_map}; +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 { + // 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()?; + + 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..4b62434 --- /dev/null +++ b/plinth-plugin/src/formats/standalone/runner.rs @@ -0,0 +1,279 @@ +use std::{rc::Rc, sync::{Arc, mpsc}, time::{Duration, Instant}}; + +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::{parameters::StandaloneParameterEventMap, audio::AudioState, config::{AudioOutputConfig, MidiInputConfig}, host::StandaloneHost, midi, plugin::StandalonePlugin}; + +use crate::{Editor, Event, Host, HostInfo, ProcessMode, Processor, ProcessorConfig, formats::PluginFormat}; + +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 + { + #[allow(clippy::collapsible_if)] + 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)); + } +} + +/// 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, +) { + let host_info = HostInfo { + name: Some("Standalone".to_string()), + format: PluginFormat::Standalone, + }; + + let mut plugin = P::new(host_info); + + // Parameter event map (shared between host and audio thread) + let parameter_event_map = + plugin.with_parameters(|params| Arc::new(StandaloneParameterEventMap::new(params))); + + // Channels + let (midi_sender, midi_receiver) = mpsc::channel::(); + let (to_plugin_sender, to_plugin_receiver) = mpsc::channel::(); + + // 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: audio_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(); + + // Create audio state + let audio_state = AudioState::

::new( + processor, + audio_stream_config.channels() as usize, + midi_receiver, + parameter_event_map.clone(), + ); + + // Create and start the 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 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"); + + // 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};