Merge pull request #23 from ebbicott/feature/plugin-menus-instruments

Plugin menus by vendor (scrollable), Audio Units inserts, and an instrument picker
This commit is contained in:
Brandon Thomas
2026-10-09 18:25:06 -04:00
committed by GitHub
8 changed files with 260 additions and 40 deletions
Generated
+1
View File
@@ -3448,6 +3448,7 @@ dependencies = [
"proptest",
"serde",
"serde_json",
"soundcraft-au-host",
"soundcraft-audio-io",
"soundcraft-clap-host",
"soundcraft-dsp",
+1
View File
@@ -14,6 +14,7 @@ soundcraft-midi.workspace = true
soundcraft-dsp.workspace = true
soundcraft-clap-host.workspace = true
soundcraft-vst3-host.workspace = true
soundcraft-au-host.workspace = true
soundcraft-model.workspace = true
soundcraft-mix.workspace = true
soundcraft-video.workspace = true
+17 -2
View File
@@ -25,9 +25,13 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// A built-in plugin's description, else a hosted CLAP (`clap:<id>`) or VST3 (`vst3:<class id>`) plugin's.
/// A built-in plugin's description, else a hosted CLAP (`clap:<id>`), VST3 (`vst3:<class id>`) or
/// Audio Units (`au:<type>:<subtype>:<manufacturer>`) plugin's.
pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> {
soundcraft_dsp::plugin_info(id).or_else(|| soundcraft_clap_host::plugin_info(id)).or_else(|| soundcraft_vst3_host::plugin_info(id))
soundcraft_dsp::plugin_info(id)
.or_else(|| soundcraft_clap_host::plugin_info(id))
.or_else(|| soundcraft_vst3_host::plugin_info(id))
.or_else(|| soundcraft_au_host::plugin_info(id))
}
#[cfg(test)]
@@ -52,4 +56,15 @@ mod tests {
assert!(super::plugin_info("eq_7band").is_some());
assert!(super::plugin_info("clap:no.such.plugin").is_none());
}
/// Apple's own Audio Units ship with every Mac, so `mix.insert` can be checked against one.
#[cfg(target_os = "macos")]
#[test]
fn audio_units_insert_by_id() {
let mut e = Engine::default();
e.execute("track.new", &json!({})).unwrap();
let t = e.session().tracks[0].id.0;
let r = e.execute("mix.insert", &json!({"track": t, "plugin": "au:aufx:bpas:appl"}));
assert_eq!(r.map(|v| v["plugin"].clone()), Ok(json!("au:aufx:bpas:appl")), "Apple AUBandpass");
}
}
+83
View File
@@ -0,0 +1,83 @@
//! An instrument track's instrument (`mix.instrument`): the plugin that turns the track's MIDI
//! into audio, built in or hosted (CLAP, VST3, Audio Units). The mixer plays `Track::instrument`;
//! the instrument is otherwise only set when the track is created (`track.new`).
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::{Insert, TrackKind};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"mix.instrument",
"Set Instrument",
[],
None,
"{track?, plugin: id} — an instrument track's instrument: built-in, `clap:`, `vst3:` or `au:` id",
has_selection,
set_instrument
)]
}
fn set_instrument(e: &mut Engine, p: &Value) -> Result<Value> {
let plugin = str_param(p, "plugin").ok_or_else(|| bad("mix.instrument", "`plugin` required"))?;
let info = super::clap::plugin_info(plugin).ok_or_else(|| bad("mix.instrument", format!("unknown plugin `{plugin}`")))?;
if !info.is_instrument {
return Err(bad("mix.instrument", format!("`{plugin}` is not an instrument")));
}
let t = tracks_required(e, "mix.instrument", p)?.first().copied().ok_or_else(|| bad("mix.instrument", "no track"))?;
if e.session().track(t).map(|tr| tr.kind) != Some(TrackKind::Instrument) {
return Err(bad("mix.instrument", "only instrument tracks have an instrument"));
}
let mut ins = Insert::new(info.id);
for pi in info.params {
ins.params.insert(pi.id.to_string(), pi.default);
}
if let Some(tr) = e.session_mut().track_mut(t) {
tr.instrument = Some(ins);
}
Ok(json!({"track": t, "plugin": info.id}))
}
#[cfg(test)]
mod tests {
use crate::Engine;
use serde_json::json;
use soundcraft_model::TrackId;
fn track(e: &mut Engine, kind: &str) -> TrackId {
e.execute("track.new", &json!({"kind": kind})).unwrap();
e.session().tracks.last().unwrap().id
}
fn instrument_of(e: &Engine, t: TrackId) -> Option<String> {
e.session().track(t).and_then(|tr| tr.instrument.as_ref()).map(|i| i.plugin.clone())
}
#[test]
fn sets_an_instrument_tracks_instrument_with_defaults_and_undoes() {
let mut e = Engine::default();
let t = track(&mut e, "instrument");
assert_eq!(instrument_of(&e, t).as_deref(), Some("subtractive_synth"));
let r = e.execute("mix.instrument", &json!({"track": t.0, "plugin": "drum_synth"})).unwrap();
assert_eq!(r["plugin"], "drum_synth");
assert_eq!(instrument_of(&e, t).as_deref(), Some("drum_synth"));
let params = e.session().track(t).and_then(|tr| tr.instrument.as_ref()).map(|i| i.params.len());
assert_eq!(params, soundcraft_dsp::plugin_info("drum_synth").map(|p| p.params.len()), "every parameter at its default");
e.execute("edit.undo", &json!({})).unwrap();
assert_eq!(instrument_of(&e, t).as_deref(), Some("subtractive_synth"));
}
#[test]
fn refuses_effects_unknown_plugins_and_other_track_kinds() {
let mut e = Engine::default();
let inst = track(&mut e, "instrument");
let audio = track(&mut e, "audio");
assert!(e.execute("mix.instrument", &json!({"track": inst.0, "plugin": "eq_7band"})).is_err(), "an effect");
assert!(e.execute("mix.instrument", &json!({"track": inst.0, "plugin": "vst3:00000000000000000000000000000000"})).is_err());
assert!(e.execute("mix.instrument", &json!({"track": inst.0})).is_err(), "no plugin");
assert!(e.execute("mix.instrument", &json!({"track": audio.0, "plugin": "drum_synth"})).is_err(), "an audio track");
assert_eq!(instrument_of(&e, inst).as_deref(), Some("subtractive_synth"));
assert_eq!(instrument_of(&e, audio), None);
}
}
+2
View File
@@ -16,6 +16,7 @@ mod edit_more;
mod event;
mod event_more;
mod file;
mod instrument;
mod midi;
mod mix;
mod more_util;
@@ -119,6 +120,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(vst3::specs());
v.extend(surround::specs());
v.extend(plugin_state::specs());
v.extend(instrument::specs());
v.extend(video::specs());
v
})
+9 -1
View File
@@ -839,7 +839,15 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
TrackKind::Video => "VIDEO",
};
if !kind_label.is_empty() {
ui.painter().text(pos2(pl_r.max.x + 4.0, name_r.center().y), Align2::LEFT_CENTER, kind_label, bold(9.0), t.text_dim);
let kr = ui.painter().text(pos2(pl_r.max.x + 4.0, name_r.center().y), Align2::LEFT_CENTER, kind_label, bold(9.0), t.text_dim);
if track.kind == TrackKind::Instrument {
// The instrument picker (and the instrument's editor) behind the INST label.
let name = track.instrument.as_ref().and_then(|i| crate::mix_window::plugin_info(&i.plugin)).map_or("none", |p| p.name);
let resp = ui
.interact(kr.expand(3.0), ui.id().with(("instrument", id.0)), Sense::click())
.on_hover_text(format!("Instrument: {name} (click to change or open its editor)"));
egui::Popup::menu(&resp).show(|ui| crate::mix_window::instrument_menu(app, ui, id));
}
}
if h < 30.0 {
return;
+125 -28
View File
@@ -140,8 +140,9 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let sr = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 16.0), vec2(inner_w - 4.0, 15.0));
let slot = track.mixer.inserts.iter().position(|i| i.as_ref().is_some_and(|i| plugin_info(&i.plugin).is_some_and(|p| p.is_instrument)));
match slot {
_ if track.kind == TrackKind::Instrument => instrument_slot(app, ui, &track, sr),
Some(k) => insert_slot(app, ui, &track, k, sr),
None if matches!(track.kind, TrackKind::Instrument | TrackKind::Midi) => {
None if track.kind == TrackKind::Midi => {
ui.painter().text(sr.center(), Align2::CENTER_CENTER, "MIDI in: all", regular(9.0), t.text_dim);
}
None => {}
@@ -401,6 +402,32 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
ui.painter().line_segment([pos2(r.max.x, r.min.y), pos2(r.max.x, r.max.y)], Stroke::new(1.0, t.border));
}
/// An instrument track's INSTRUMENT row: click opens a hosted instrument's plugin window,
/// right-click (or click, when none is set) picks another instrument.
fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {
let t = Tokens::DARK;
let (id, slot) = (track.id, soundcraft_mix::INSTRUMENT_SLOT);
let ins = track.instrument.as_ref();
let info = ins.and_then(|i| plugin_info(&i.plugin));
let resp = ui.interact(r, ui.id().with(("instrument", id.0)), Sense::click());
let fill = if info.is_some() { Color32::from_rgb(52, 62, 80) } else { t.slot_bg };
ui.painter().rect(
r,
CornerRadius::same(2),
if resp.hovered() { fill.gamma_multiply(1.3) } else { fill },
Stroke::new(1.0, Color32::from_rgb(16, 16, 16)),
StrokeKind::Inside,
);
ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, info.map_or("no instrument", |p| p.short_name), regular(10.5), t.text);
let hosted = ins.is_some_and(|i| soundcraft_mix::is_third_party(&i.plugin));
if resp.clicked() && hosted && !app.ui.plugin_windows.contains(&(id, slot)) {
app.ui.plugin_windows.push((id, slot));
}
let menu_resp = if hosted { resp.clone().on_hover_text("Click: open instrument · right-click: change") } else { resp.clone() };
let popup = if hosted { egui::Popup::context_menu(&menu_resp) } else { egui::Popup::menu(&menu_resp) };
popup.show(|ui| instrument_menu(app, ui, id));
}
fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) {
let t = Tokens::DARK;
let id = track.id;
@@ -457,44 +484,114 @@ pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, oc
}
});
}
// Third-party CLAP plugins (scanned once, cached by soundcraft-clap-host).
if let Some(plugin) = hosted_menus(ui, false)
&& let Err(e) = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin}))
{
app.ui.status = e;
}
}
/// The instrument picker of an instrument track: its instrument's editor, then the built-in,
/// CLAP, VST3 and Audio Units instruments.
pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
let slot = soundcraft_mix::INSTRUMENT_SLOT;
let current = app.engine.session().track(id).and_then(|t| t.instrument.clone());
if let Some(info) = current.as_ref().and_then(|i| plugin_info(&i.plugin)) {
ui.label(egui::RichText::new(info.name).strong());
if let Some(p) = &app.player
&& p.has_editor(id, slot)
{
if p.editor_open(id, slot) {
if ui.button("Close Plugin Editor").clicked() {
p.close_editor(id, slot);
}
} else if ui.button("Open Plugin Editor").clicked()
&& let Err(e) = p.open_editor(id, slot)
{
app.ui.status = format!("{}: {e}", info.name);
}
}
ui.separator();
}
let mut picked = None;
ui.menu_button("Built-in", |ui| {
for p in soundcraft_dsp::plugins().iter().filter(|p| p.is_instrument) {
if ui.button(p.name).clicked() {
picked = Some(p.id.to_string());
}
}
});
if let Some(p) = hosted_menus(ui, true) {
picked = Some(p);
}
if let Some(plugin) = picked
&& let Err(e) = app.run("mix.instrument", json!({"track": id.0, "plugin": plugin}))
{
app.ui.status = e;
}
}
/// A hosted plugin as the plugin menus list it.
struct MenuPlugin {
id: String,
name: String,
vendor: String,
}
/// The CLAP, VST3 and (on macOS) Audio Units submenus, of effects or of instruments; returns the
/// id picked. Each format is scanned the first time its submenu opens, then cached by its host.
fn hosted_menus(ui: &mut Ui, instruments: bool) -> Option<String> {
let mut picked = None;
ui.menu_button("CLAP", |ui| {
let found: Vec<_> = soundcraft_clap_host::scan().into_iter().filter(|d| !d.is_instrument).collect();
if found.is_empty() {
ui.label("No CLAP plugins found");
}
for d in found {
if ui.button(format!("{} ({})", d.name, d.vendor)).clicked() {
let _ = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": d.id}));
}
let list = soundcraft_clap_host::scan().into_iter().filter(|d| d.is_instrument == instruments);
if let Some(p) = vendor_menus(ui, list.map(|d| MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }).collect(), "No CLAP plugins found") {
picked = Some(p);
}
});
// Third-party VST3 plugins (scanned once, cached by soundcraft-vst3-host).
ui.menu_button("VST3", |ui| {
let found: Vec<_> = soundcraft_vst3_host::scan().into_iter().filter(|d| !d.is_instrument).collect();
if found.is_empty() {
ui.label("No VST3 plugins found");
}
for d in found {
if ui.button(format!("{} ({})", d.name, d.vendor)).clicked() {
let _ = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": d.id}));
}
let list = soundcraft_vst3_host::scan().into_iter().filter(|d| d.is_instrument == instruments);
if let Some(p) = vendor_menus(ui, list.map(|d| MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }).collect(), "No VST3 plugins found") {
picked = Some(p);
}
});
// Audio Units (macOS; scanned once, cached by soundcraft-au-host).
if cfg!(target_os = "macos") {
ui.menu_button("Audio Units", |ui| {
let found: Vec<_> = soundcraft_au_host::scan().into_iter().filter(|d| !d.is_instrument).collect();
if found.is_empty() {
ui.label("No Audio Units found");
}
for d in found {
if ui.button(format!("{} ({})", d.name, d.vendor)).clicked() {
let _ = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": d.id}));
}
let list = soundcraft_au_host::scan().into_iter().filter(|d| d.is_instrument == instruments);
if let Some(p) = vendor_menus(ui, list.map(|d| MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }).collect(), "No Audio Units found")
{
picked = Some(p);
}
});
}
picked
}
/// One submenu per vendor (as Pro Tools lists plug-ins by manufacturer), each list scrolling
/// within the screen so that every plugin stays reachable however many are installed.
fn vendor_menus(ui: &mut Ui, mut list: Vec<MenuPlugin>, none: &str) -> Option<String> {
if list.is_empty() {
ui.label(none);
return None;
}
list.sort_by_cached_key(|p| (p.vendor.to_lowercase(), p.name.to_lowercase()));
let mut vendors: Vec<&str> = list.iter().map(|p| p.vendor.as_str()).collect();
vendors.dedup();
let max_h = (ui.ctx().content_rect().height() - 80.0).max(160.0);
let mut picked = None;
egui::ScrollArea::vertical().id_salt("vendors").max_height(max_h).show(ui, |ui| {
for v in vendors {
ui.menu_button(if v.is_empty() { "Unknown vendor" } else { v }, |ui| {
egui::ScrollArea::vertical().id_salt("plugins").max_height(max_h).show(ui, |ui| {
for p in list.iter().filter(|p| p.vendor == v) {
if ui.button(&p.name).clicked() {
picked = Some(p.id.clone());
}
}
});
});
}
});
picked
}
/// A built-in plugin's description, else a hosted CLAP (`clap:<id>`), VST3 (`vst3:<class id>`) or
+22 -9
View File
@@ -373,26 +373,33 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
let wins = app.ui.plugin_windows.clone();
let mut keep = Vec::new();
for (tid, slot) in wins {
let Some((tname, ins)) =
app.engine.session().track(tid).and_then(|t| t.mixer.inserts.get(slot).cloned().flatten().map(|i| (t.name.clone(), i)))
else {
// The instrument slot (an instrument track's instrument) or an insert slot.
let instrument = slot == soundcraft_mix::INSTRUMENT_SLOT;
let Some((tname, ins)) = app.engine.session().track(tid).and_then(|t| {
let ins = if instrument { t.instrument.clone() } else { t.mixer.inserts.get(slot).cloned().flatten() };
ins.map(|i| (t.name.clone(), i))
}) else {
continue;
};
let Some(info) = crate::mix_window::plugin_info(&ins.plugin) else { continue };
let place = if instrument { "instrument".to_string() } else { char::from(b'a'.saturating_add(slot as u8)).to_string() };
let mut open = true;
egui::Window::new(format!("{tname} · {} · {}", (b'a' + slot as u8) as char, info.name))
egui::Window::new(format!("{tname} · {place} · {}", info.name))
.id(egui::Id::new(("plugin", tid.0, slot)))
.open(&mut open)
.default_width(340.0)
.default_pos(egui::pos2(ctx.content_rect().width() - 380.0, 120.0))
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(&ins.preset).color(Tokens::DARK.text_dim));
let label = if ins.bypass { "BYPASSED" } else { "Bypass" };
if ui.button(label).clicked() {
let _ = app.run("mix.insert_bypass", json!({"track": tid.0, "slot": slot}));
// Bypass and presets act on insert slots.
if !instrument {
ui.label(egui::RichText::new(&ins.preset).color(Tokens::DARK.text_dim));
let label = if ins.bypass { "BYPASSED" } else { "Bypass" };
if ui.button(label).clicked() {
let _ = app.run("mix.insert_bypass", json!({"track": tid.0, "slot": slot}));
}
ui.menu_button("Presets ▾", |ui| preset_menu(app, ui, tid, slot, info, &ins));
}
ui.menu_button("Presets ▾", |ui| preset_menu(app, ui, tid, slot, info, &ins));
if soundcraft_mix::is_third_party(&ins.plugin) {
let open = app.player.as_ref().is_some_and(|p| p.editor_open(tid, slot));
let available = app.player.as_ref().is_some_and(|p| p.has_editor(tid, slot));
@@ -411,6 +418,12 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
}
}
});
if instrument {
// The parameter commands address insert slots: an instrument is played and
// edited in its own editor.
ui.label(egui::RichText::new("Load and edit sounds in the plugin's own editor.").color(Tokens::DARK.text_dim));
return;
}
if info.id == "eq_7band" || info.id == "eq_1band" {
eq_curve(ui, info.id, &ins);
}