Skip to content

Commit 7a6080f

Browse files
authored
Merge pull request #12 from emuell/feature/standalone-app
Optional standalone format
2 parents f241100 + 2b92145 commit 7a6080f

17 files changed

Lines changed: 876 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
.DS_Store
22
.vscode
33
.zed
4+
justfile
45
Cargo.lock
56
target

examples/gain-plugin/Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,17 @@ name = "gain-plugin"
33
version = "0.1.0"
44
edition = "2024"
55

6+
[features]
7+
standalone = ["plinth-plugin/standalone"]
8+
69
[lib]
710
crate-type = ["cdylib", "lib", "staticlib"]
811

12+
[[bin]]
13+
name = "gain-standalone"
14+
path = "src/main.rs"
15+
required-features = ["standalone"]
16+
917
[dependencies]
1018
plinth-derive.workspace = true
1119
plinth-plugin.workspace = true

examples/gain-plugin/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Gain Plugin Example
2+
3+
A minimal example audio effect plugin demonstrating the `plugin-things` framework.
4+
5+
## Building
6+
7+
> Append `--release` to any command below for a release build.
8+
9+
### Plugin Bundles (CLAP & VST3)
10+
11+
```sh
12+
cargo xtask bundle gain-plugin
13+
```
14+
15+
### Standalone App
16+
17+
```sh
18+
cargo run -p gain-plugin --features standalone
19+
```
20+
21+
### Standalone App with Live Preview
22+
23+
Hot-reload the Slint UI when modifying .slint UI files without recompiling the app.
24+
25+
```sh
26+
SLINT_LIVE_PREVIEW=1 cargo run -p gain-plugin --features=standalone,slint/live-preview
27+
```

examples/gain-plugin/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ mod parameters;
33
mod plugin;
44
mod processor;
55
mod view;
6+
7+
pub use plugin::GainPlugin;

examples/gain-plugin/src/main.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
use plinth_plugin::standalone::run_standalone;
2+
use gain_plugin::GainPlugin;
3+
4+
fn main() {
5+
run_standalone::<GainPlugin>();
6+
}

examples/gain-plugin/src/plugin.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use crate::editor::{EditorSettings, GainPluginEditor};
1212
use crate::{parameters::GainParameters, processor::GainPluginProcessor};
1313

1414
#[derive(Default)]
15-
struct GainPlugin {
15+
pub struct GainPlugin {
1616
parameters: Rc<GainParameters>,
1717
editor_settings: Rc<RefCell<EditorSettings>>,
1818
}
@@ -88,3 +88,6 @@ impl Vst3Plugin for GainPlugin {
8888

8989
export_clap!(GainPlugin);
9090
export_vst3!(GainPlugin);
91+
92+
#[cfg(feature = "standalone")]
93+
impl plinth_plugin::standalone::StandalonePlugin for GainPlugin {}

plinth-plugin/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ readme = "README.md"
88
repository = "https://github.com/ilmai/plugin-things"
99
license = "MIT"
1010

11+
[features]
12+
standalone = ["dep:cpal", "dep:midir", "dep:winit"]
13+
1114
[dependencies]
1215
atomic_refcell = "0.1"
1316
clap-sys = "0.5"
@@ -22,6 +25,10 @@ thiserror = "2"
2225
vst3 = "0.3"
2326
widestring = "1"
2427
xxhash-rust = { version = "0.8", features = ["xxh32"] }
28+
# standalone features
29+
winit = { version = "0.30", optional = true }
30+
cpal = { version = "0.17", optional = true, features = ["asio", "jack"] }
31+
midir = { version = "0.11", optional = true }
2532

2633
[build-dependencies]
2734
bindgen = "0.72"

plinth-plugin/src/formats.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@ use std::fmt::Display;
33
#[cfg(target_os="macos")]
44
pub mod auv3;
55
pub mod clap;
6+
#[cfg(feature = "standalone")]
7+
pub mod standalone;
68
pub mod vst3;
79

810
#[derive(Clone, Copy, Debug)]
911
pub enum PluginFormat {
1012
Auv3,
1113
Clap,
14+
#[cfg(feature = "standalone")]
15+
Standalone,
1216
Vst3,
1317
}
1418

@@ -17,6 +21,8 @@ impl Display for PluginFormat {
1721
match self {
1822
PluginFormat::Auv3 => f.write_str("AUv3"),
1923
PluginFormat::Clap => f.write_str("CLAP"),
24+
#[cfg(feature = "standalone")]
25+
PluginFormat::Standalone => f.write_str("Standalone"),
2026
PluginFormat::Vst3 => f.write_str("VST3"),
2127
}
2228
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
mod audio;
2+
mod config;
3+
mod host;
4+
mod midi;
5+
mod parameters;
6+
mod plugin;
7+
mod runner;
8+
9+
pub use config::{AudioDeviceDriver, AudioOutputConfig, MidiInputConfig};
10+
pub use plugin::StandalonePlugin;
11+
pub use runner::{run_standalone, run_standalone_with_config};
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
use std::sync::{Arc, mpsc::Receiver};
2+
3+
use cpal::{FromSample, Sample};
4+
use plinth_core::{ buffers::buffer::Buffer, signals::{ signal::{Signal, SignalMut}, signal_base::SignalBase } };
5+
6+
use super::parameters::StandaloneParameterEventMap;
7+
use super::plugin::StandalonePlugin;
8+
use crate::{Event, Processor};
9+
10+
/// Push events to a event list vec, printing a warning when preallocated memory exceeded.
11+
trait EventListPush {
12+
type EventType;
13+
fn push_event(&mut self, event: Self::EventType);
14+
}
15+
16+
impl EventListPush for Vec<Event> {
17+
type EventType = Event;
18+
fn push_event(&mut self, event: Event) {
19+
if self.len() == self.capacity() {
20+
log::warn!(
21+
"Event queue exceeded preallocated capacity of {} - allocating more. \
22+
Increase EVENT_QUEUE_LEN to avoid allocation on the audio thread.",
23+
self.capacity()
24+
);
25+
self.reserve(128);
26+
}
27+
self.push(event);
28+
}
29+
}
30+
31+
/// Runs a plinth processor on a CPAL audio stream
32+
pub struct AudioState<P: StandalonePlugin> {
33+
pub processor: P::Processor,
34+
pub buffer: Buffer,
35+
pub channels: usize,
36+
pub midi_receiver: Receiver<Event>,
37+
pub parameter_event_map: Arc<StandaloneParameterEventMap>,
38+
pending_events: Vec<Event>,
39+
}
40+
41+
impl<P: StandalonePlugin> AudioState<P> {
42+
pub fn new(
43+
processor: P::Processor,
44+
channels: usize,
45+
midi_receiver: Receiver<Event>,
46+
parameter_event_map: Arc<StandaloneParameterEventMap>,
47+
) -> Self {
48+
Self {
49+
processor,
50+
buffer: Buffer::new(channels, P::MAX_BLOCK_SIZE),
51+
channels,
52+
midi_receiver,
53+
parameter_event_map,
54+
pending_events: Vec::with_capacity(P::EVENT_QUEUE_LEN),
55+
}
56+
}
57+
58+
pub fn process<T>(&mut self, data: &mut [T], channels: usize)
59+
where
60+
T: Sample + FromSample<f32>,
61+
f32: FromSample<T>,
62+
{
63+
let frame_count = data.len() / channels;
64+
65+
// Drain MIDI events
66+
self.pending_events.clear();
67+
while let Ok(event) = self.midi_receiver.try_recv() {
68+
self.pending_events.push_event(event);
69+
}
70+
71+
// Collect pending parameter change events
72+
for event in self.parameter_event_map.iter_events() {
73+
self.pending_events.push_event(event);
74+
}
75+
76+
// Process audio, ensuring we don't call process with more than P::MAX_BLOCK_SIZE frames
77+
debug_assert!(
78+
self.buffer.capacity() == P::MAX_BLOCK_SIZE,
79+
"Buffer must be preallocated to avoid allocation on the audio thread"
80+
);
81+
82+
let mut frame_offset = 0;
83+
while frame_offset < frame_count {
84+
let chunk_size = (frame_count - frame_offset).min(P::MAX_BLOCK_SIZE);
85+
86+
// Truncate or extend buffer to fit the chunk
87+
if self.buffer.len() != chunk_size {
88+
self.buffer.resize(chunk_size);
89+
}
90+
91+
// Deinterleave chunk from CPAL buffer
92+
for frame in 0..chunk_size {
93+
for ch in 0..self.channels {
94+
self.buffer.channel_mut(ch)[frame] =
95+
f32::from_sample(data[(frame_offset + frame) * self.channels + ch]);
96+
}
97+
}
98+
99+
// Process and drain all events on first run, assuming they have no time tags
100+
let aux: Option<&Buffer> = None;
101+
self.processor
102+
.process(&mut self.buffer, aux, None, self.pending_events.drain(..));
103+
104+
// Reinterleave chunk back into CPAL buffer
105+
for frame in 0..chunk_size {
106+
for ch in 0..self.channels {
107+
data[(frame_offset + frame) * self.channels + ch] =
108+
T::from_sample(self.buffer.channel(ch)[frame]);
109+
}
110+
}
111+
112+
frame_offset += chunk_size;
113+
}
114+
}
115+
}

0 commit comments

Comments
 (0)