diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs index d31ddac2..afa17dad 100644 --- a/crates/dsp/src/lib.rs +++ b/crates/dsp/src/lib.rs @@ -275,6 +275,12 @@ pub trait PluginEditor: Send { /// Tells the editor that a parameter changed elsewhere (generic editor, automation), so the /// GUI shows it. Values are in SoundCraft units. fn set_param(&mut self, _id: &str, _value: f32) {} + /// The plugin's own text for a parameter value (SoundCraft units) when SoundCraft's + /// [`ParamInfo::format`] would be wrong for it. Main thread only; `None` means "format it + /// yourself". + fn value_text(&mut self, _id: &str, _value: f32) -> Option { + None + } } /// Reads a plugin's state on the main (UI) thread while the instance processes on the audio diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index b945d4b6..e290727d 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -726,6 +726,12 @@ impl Player { } } + /// The plugin's own text for a parameter value when SoundCraft's format would be wrong for it + /// (see `PluginEditor::value_text`). Main thread only. + pub fn editor_value_text(&self, track: TrackId, slot: usize, id: &str, value: f32) -> Option { + self.side().editors.get_mut(&(track, slot))?.value_text(id, value) + } + /// Start playback at `from`, stopping at `end`, or looping `looped`. pub fn play(&self, from: Samples, end: Option, looped: Option) { self.shared.position.store(from, Ordering::Relaxed); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index dda31ce0..57eda5b7 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -544,7 +544,11 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { } } let _ = resp; - let shown = if instrument { crate::sampler_window::format_param(&ins.plugin, p, x) } else { p.format(x) }; + let shown = if instrument { + crate::sampler_window::format_param(&ins.plugin, p, x) + } else { + app.player.as_ref().and_then(|pl| pl.editor_value_text(tid, slot, p.id, x)).unwrap_or_else(|| p.format(x)) + }; ui.label(egui::RichText::new(shown).font(mono(11.0))); ui.end_row(); } diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index d1ddea23..bec9f167 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -1788,6 +1788,20 @@ impl EditorLink { v.is_finite().then(|| v.clamp(0.0, 1.0)) } + /// The plugin's own text for a normalized value (`getParamStringByValue`). Main thread only + /// (`None` elsewhere, and once the instance is gone). + pub fn value_text(&self, id: u32, n: f64) -> Option { + if !window::on_main_thread() || !n.is_finite() { + return None; + } + let g = self.lock(); + let c = g.controller.as_ref()?; + let mut s: sv::String128 = [0; 128]; + // SAFETY: valid controller (the instance clears it under this lock before it terminates), + // main thread; `s` is a writable String128. + ok(unsafe { c.getParamStringByValue(id, n.clamp(0.0, 1.0), &mut s) }).then(|| read_c16(&s)).filter(|t| !t.is_empty()) + } + /// Shows a value change in the editor (`setParamNormalized`, main thread). pub fn set_normalized(&self, id: u32, n: f64) { if !window::on_main_thread() || !n.is_finite() { diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs index 2975465f..ed59fa8e 100644 --- a/crates/vst3-host/src/plugin.rs +++ b/crates/vst3-host/src/plugin.rs @@ -2,6 +2,7 @@ use crate::ffi::{AFTERTOUCH, BusBuffers, EVENT_CAP, EditorLink, HostContext, Instance, NoteEvent, PARAM_QUEUE_CAP, PITCH_BEND}; use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport, Unit}; +use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use vst3::ComWrapper; @@ -399,7 +400,16 @@ impl Plugin for Vst3Plugin { fn editor(&mut self) -> Option> { let link = self.inst.editor()?; - Some(Box::new(Vst3Editor { link, host: self.inst.host_ref(), info: self.info, ids: self.ids.clone(), maps: self.maps.clone() })) + let mut units: HashMap = self.inst.params().into_iter().map(|p| (p.id, p.units)).collect(); + Some(Box::new(Vst3Editor { + link, + host: self.inst.host_ref(), + info: self.info, + index: self.info.params.iter().enumerate().map(|(i, p)| (p.id, i)).collect(), + units: self.ids.iter().map(|id| units.remove(id).unwrap_or_default()).collect(), + ids: self.ids.clone(), + maps: self.maps.clone(), + })) } fn state_reader(&mut self) -> Option> { @@ -423,6 +433,10 @@ struct Vst3Editor { link: Arc, host: ComWrapper, info: &'static PluginInfo, + /// Position of each SoundCraft param id in `info.params` (looked up once per row and frame). + index: HashMap<&'static str, usize>, + /// The plugin's own unit label of each param (same order as `ids`). + units: Vec, ids: Vec, maps: Vec, } @@ -487,4 +501,19 @@ impl PluginEditor for Vst3Editor { self.link.set_normalized(vid, n); } } + + fn value_text(&mut self, id: &str, value: f32) -> Option { + let &i = self.index.get(id)?; + let pi = self.info.params.get(i)?; + // A plugin whose plain mapping is the identity (JUCE, the SDK's default `Parameter`) leaves + // a 0..1 range with the units of its real one: only its own text says what the value is. + if !matches!(self.maps.get(i)?, Mapping::Plain) || pi.min != 0.0 || pi.max != 1.0 { + return None; + } + let text = self.link.value_text(*self.ids.get(i)?, self.normalized(i, value)?)?; + // VST3 keeps the units apart from the text (JUCE's label, the SDK's `RangeParameter`), + // but some plugins put them in both. + let units = self.units.get(i).map_or("", |u| u.trim()); + if units.is_empty() || text.trim_end().to_lowercase().ends_with(&units.to_lowercase()) { Some(text) } else { Some(format!("{text} {units}")) } + } } diff --git a/crates/vst3-host/tests/editor_main_thread.rs b/crates/vst3-host/tests/editor_main_thread.rs index 6bc732c7..c2399c81 100644 --- a/crates/vst3-host/tests/editor_main_thread.rs +++ b/crates/vst3-host/tests/editor_main_thread.rs @@ -23,7 +23,16 @@ fn main() { ed.set_param("7", 1.5); ed.set_param("nope", 1.0); assert!(ed.idle().is_empty(), "set_param is not reported back as an edit"); + // #599: an identity-mapped (JUCE-style) parameter shows the plugin's own text, verbatim, with + // its units appended once; correctly mapped parameters and choices keep SoundCraft's format. + assert_eq!(ed.value_text("13", 0.375).as_deref(), Some("\u{2212}6.0 dB")); + assert_eq!(ed.value_text("13", 1.0).as_deref(), Some("24.0 dB")); + assert_eq!(ed.value_text("14", 0.25).as_deref(), Some("3.0 dB"), "units already in the text"); + assert_eq!(ed.value_text("7", 1.5), None); + assert_eq!(ed.value_text("3", 1.0), None); + assert_eq!(ed.value_text("nope", 0.5), None); drop(p); + assert_eq!(ed.value_text("13", 0.375), None, "the plugin is gone"); assert!(!ed.is_open()); assert!(ed.open().is_err(), "the plugin is gone"); println!("editor_main_thread: ok"); diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs index 935d4c66..2b7f73d9 100644 --- a/crates/vst3-host/tests/fixture/src/lib.rs +++ b/crates/vst3-host/tests/fixture/src/lib.rs @@ -298,12 +298,17 @@ impl IConnectionPointTrait for GainController { } } -const GAIN_PARAMS: [(u32, &str, &str, i32, f64, i32); 5] = [ +const GAIN_PARAMS: [(u32, &str, &str, i32, f64, i32); 7] = [ (7, "Gain", "dB", 0, 0.5, ParameterInfo_::ParameterFlags_::kCanAutomate), (9, "Secret", "", 0, 0.0, ParameterInfo_::ParameterFlags_::kIsHidden), (3, "Mode", "", 2, 0.0, ParameterInfo_::ParameterFlags_::kIsList), (11, "Bypass", "", 1, 0.0, ParameterInfo_::ParameterFlags_::kIsBypass), (12, "Freq", "Hz", 0, 0.5, 0), + // Like JUCE and the SDK's default `Parameter`: no plain mapping (identity), so the real range + // is only in the plugin's own text (-24..+24, the default 0.375 is -6), without the units. + (13, "Level", "dB", 0, 0.375, ParameterInfo_::ParameterFlags_::kCanAutomate), + // Identity too, but its text already carries the units (0..12 dB). + (14, "Drive", "dB", 0, 0.0, ParameterInfo_::ParameterFlags_::kCanAutomate), ]; impl IEditControllerTrait for GainController { @@ -351,6 +356,8 @@ impl IEditControllerTrait for GainController { let s = unsafe { &mut *string }; let text = match id { 3 => ["A", "B", "C"][((n * 2.0).round() as usize).min(2)].to_string(), + 13 => format!("{:.1}", 48.0 * n - 24.0).replace('-', "\u{2212}"), + 14 => format!("{:.1} dB", 12.0 * n), _ => format!("{:.2}", unsafe { self.normalizedParamToPlain(id, n) }), }; copy_c16(&text, s); diff --git a/crates/vst3-host/tests/fixture_e2e.rs b/crates/vst3-host/tests/fixture_e2e.rs index 5efd33cc..eb9037f5 100644 --- a/crates/vst3-host/tests/fixture_e2e.rs +++ b/crates/vst3-host/tests/fixture_e2e.rs @@ -42,7 +42,7 @@ fn info_exposes_visible_params_with_plain_ranges() { assert_eq!(info.id, gain); assert_eq!(info.short_name, "Fixture "); let ids: Vec<&str> = info.params.iter().map(|p| p.id).collect(); - assert_eq!(ids, vec!["7", "3", "12"], "hidden 9 and bypass 11 are skipped"); + assert_eq!(ids, vec!["7", "3", "12", "13", "14"], "hidden 9 and bypass 11 are skipped"); let g = info.param("7").unwrap(); assert_eq!((g.name, g.min, g.max, g.default, g.unit, g.taper), ("Gain", 0.0, 2.0, 1.0, Unit::Db, Taper::Linear)); let mode = info.param("3").unwrap(); @@ -55,6 +55,23 @@ fn info_exposes_visible_params_with_plain_ranges() { assert!(std::ptr::eq(info, soundcraft_vst3_host::plugin_info(&gain).unwrap())); } +/// #599: a plugin whose `normalizedParamToPlain` is the identity (JUCE, the SDK's default +/// `Parameter`) gives SoundCraft a 0..1 range; its real values are only in its own text, which the +/// editor handle reads from the controller on the main thread only (see `editor_main_thread`). +#[test] +fn identity_mapped_param_text_is_read_on_the_main_thread_only() { + let id = id_of("Fixture Gain"); + let level = soundcraft_vst3_host::plugin_info(&id).unwrap().param("13").unwrap(); + assert_eq!((level.min, level.max, level.unit), (0.0, 1.0, Unit::Db)); + let mut p = soundcraft_vst3_host::instantiate_plugin(&id).unwrap(); + let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle"); + if cfg!(any(target_os = "macos", windows)) { + assert_eq!(ed.value_text("13", 0.375), None, "test threads are not the main thread"); + } else { + assert_eq!(ed.value_text("13", 0.375).as_deref(), Some("\u{2212}6.0 dB")); + } +} + #[test] fn gain_processes_audio_and_follows_params() { let mut p = soundcraft_vst3_host::create(&id_of("Fixture Gain")).unwrap();