Merge pull request #680 from marcoarnulfo/fix/vst3-param-units

VST3: show the plugin's own text for identity-mapped parameters
This commit is contained in:
Brandon Thomas
2026-10-19 02:31:29 -04:00
committed by GitHub
8 changed files with 96 additions and 4 deletions
+6
View File
@@ -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<String> {
None
}
}
/// Reads a plugin's state on the main (UI) thread while the instance processes on the audio
+6
View File
@@ -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<String> {
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<Samples>, looped: Option<Range>) {
self.shared.position.store(from, Ordering::Relaxed);
+5 -1
View File
@@ -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();
}
+14
View File
@@ -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<String> {
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() {
+30 -1
View File
@@ -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<Box<dyn PluginEditor>> {
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<u32, String> = 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<Box<dyn soundcraft_dsp::PluginStateReader>> {
@@ -423,6 +433,10 @@ struct Vst3Editor {
link: Arc<EditorLink>,
host: ComWrapper<HostContext>,
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<String>,
ids: Vec<u32>,
maps: Vec<Mapping>,
}
@@ -487,4 +501,19 @@ impl PluginEditor for Vst3Editor {
self.link.set_normalized(vid, n);
}
}
fn value_text(&mut self, id: &str, value: f32) -> Option<String> {
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}")) }
}
}
@@ -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");
+8 -1
View File
@@ -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);
+18 -1
View File
@@ -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();