diff --git a/Cargo.lock b/Cargo.lock index e6719efc..4f0c98ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3448,6 +3448,7 @@ dependencies = [ "proptest", "serde", "serde_json", + "soundcraft-au-host", "soundcraft-audio-io", "soundcraft-clap-host", "soundcraft-dsp", diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 18dacdc0..b698856c 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 diff --git a/crates/engine/src/cmd/clap.rs b/crates/engine/src/cmd/clap.rs index 7b39c1f4..15ddcd74 100644 --- a/crates/engine/src/cmd/clap.rs +++ b/crates/engine/src/cmd/clap.rs @@ -25,9 +25,13 @@ pub fn specs() -> Vec { )] } -/// A built-in plugin's description, else a hosted CLAP (`clap:`) or VST3 (`vst3:`) plugin's. +/// A built-in plugin's description, else a hosted CLAP (`clap:`), VST3 (`vst3:`) or +/// Audio Units (`au:::`) 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"); + } } diff --git a/crates/engine/src/cmd/instrument.rs b/crates/engine/src/cmd/instrument.rs new file mode 100644 index 00000000..6b34657a --- /dev/null +++ b/crates/engine/src/cmd/instrument.rs @@ -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 { + 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 { + 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 { + 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); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 665056ae..57ea4cfc 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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 }) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 6ba40c7c..fdfc55eb 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -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; diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index f7a9aace..ea0b05c0 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -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 { + 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, none: &str) -> Option { + 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:`), VST3 (`vst3:`) or diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 1bdb1847..37a90570 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -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); }