truce_params/lib.rs
1#![forbid(unsafe_code)]
2
3mod info;
4mod range;
5pub mod sample;
6mod smooth;
7mod types;
8
9pub use info::{ParamFlags, ParamInfo, ParamUnit, ParamValueKind};
10pub use range::ParamRange;
11pub use sample::{Float, Sample};
12pub use smooth::{Smoother, SmoothingStyle};
13pub use types::{
14 BoolParam, EnumParam, FloatParam, FloatParamReadF32, FloatParamReadF64, IntParam, MeterSlot,
15 ParamEnum,
16};
17
18/// Implementation detail - not part of the stable public API.
19/// Used by `truce-loader` to index into meter storage.
20#[doc(hidden)]
21pub const METER_ID_BASE: u32 = 1 << 24;
22
23/// Sealing module: external crates cannot implement [`Params`] or
24/// [`ParamEnum`] directly because they can't name `Sealed`. The
25/// `#[derive(Params)]` and `#[derive(ParamEnum)]` macros emit the
26/// `Sealed` impl alongside their trait impls, so derive users are
27/// unaffected.
28#[doc(hidden)]
29pub mod __private {
30 pub trait Sealed {}
31}
32
33/// Format a plain parameter value as a display string based on the parameter's unit.
34///
35/// Used by the `#[derive(Params)]` macro for default `format_value` implementations
36/// on `FloatParam` and `IntParam` fields. `IntParam` is identified by
37/// `ParamValueKind::Int`, set by the derive from the field type - its
38/// value is always integer-valued, so the fractional `{:.1}` / `{:.2}`
39/// formats float-typed params use would render "0.0 st" / "0.00"
40/// instead of "0 st" / "0".
41#[must_use]
42pub fn format_param_value(info: &ParamInfo, value: f64) -> String {
43 let is_int = info.kind == ParamValueKind::Int;
44 // Round to nearest integer before display so a smoothed IntParam
45 // that's mid-transition doesn't briefly render the rounded-down
46 // half-step (e.g. an `i32::from(value)` of -1 when value is -0.5
47 // mid-snap). `IntParam::value_i32` rounds the same way at the
48 // audio-thread read site.
49 #[allow(clippy::cast_possible_truncation)]
50 let int_value = value.round() as i64;
51 match info.unit {
52 ParamUnit::Db => {
53 if is_int {
54 format!("{int_value} dB")
55 } else {
56 format!("{value:.1} dB")
57 }
58 }
59 ParamUnit::Hz => {
60 if value >= 1000.0 {
61 format!("{:.1} kHz", value / 1000.0)
62 } else {
63 format!("{value:.0} Hz")
64 }
65 }
66 ParamUnit::Milliseconds => {
67 if is_int {
68 format!("{int_value} ms")
69 } else {
70 format!("{value:.1} ms")
71 }
72 }
73 ParamUnit::Seconds => {
74 if value >= 1.0 {
75 format!("{value:.2} s")
76 } else {
77 format!("{:.0} ms", value * 1000.0)
78 }
79 }
80 ParamUnit::Percent => format!("{:.0}%", value * 100.0),
81 ParamUnit::Semitones => {
82 if is_int {
83 format!("{int_value} st")
84 } else {
85 format!("{value:.1} st")
86 }
87 }
88 ParamUnit::Pan => {
89 // Convention: pan params are normalized to [-1.0, 1.0]. Round
90 // to nearest integer percent first so the dead-zone test and
91 // L/R label agree (e.g. -0.004 → 0% → "C", -0.006 → -1% → "1L").
92 // Result is bounded by `[-100, 100]` after clamp to `[-1, 1]`.
93 #[allow(clippy::cast_possible_truncation)]
94 let pct = (value * 100.0).round() as i32;
95 match pct.cmp(&0) {
96 std::cmp::Ordering::Equal => "C".to_string(),
97 std::cmp::Ordering::Less => format!("{}L", -pct),
98 std::cmp::Ordering::Greater => format!("{pct}R"),
99 }
100 }
101 ParamUnit::None => {
102 if is_int {
103 format!("{int_value}")
104 } else {
105 format!("{value:.2}")
106 }
107 }
108 }
109}
110
111/// Trait implemented by #[derive(Params)] on a struct.
112/// Format wrappers use this to enumerate, read, and write parameters.
113///
114/// Stays dyn-compatible (every method dispatches through `&self`) so
115/// editors can pass `Arc<dyn Params>` into the screenshot pipeline
116/// without naming the concrete type. Generic code that needs to
117/// *construct* a fresh `Params` value should add a `Default` bound
118/// rather than expecting one on the trait - `#[derive(Params)]` emits
119/// `impl Default` alongside the trait impl, so that bound is free for
120/// derive users.
121pub trait Params: __private::Sealed + Send + Sync + 'static {
122 /// All parameter infos, in declaration order.
123 fn param_infos(&self) -> Vec<ParamInfo>;
124
125 /// Append parameter infos onto an existing buffer. Default impl
126 /// delegates to [`Self::param_infos`] and `extend`s; the derive
127 /// macro overrides for nested structs so deep trees don't pay
128 /// O(depth) intermediate `Vec` allocations per outer call.
129 fn append_param_infos(&self, into: &mut Vec<ParamInfo>) {
130 into.extend(self.param_infos());
131 }
132
133 /// Static parameter metadata, available without an instance.
134 ///
135 /// Format wrappers' `register_*` paths call this to learn the
136 /// parameter set without constructing a full plugin. The
137 /// instance-based alternative would pay for any allocation the
138 /// constructor does (DSP buffers, FFT plans, image atlases, etc.)
139 /// at static-init time, which is fragile under AAX's `Describe`
140 /// running before main. The derive macro overrides this with a
141 /// `LazyLock`-cached `Vec<ParamInfo>` built from the same
142 /// compile-time metadata it uses for [`Self::param_infos`], so
143 /// registration becomes allocation-free after the first call.
144 ///
145 /// Default impl returns an empty vec - hand-written `Params` impls
146 /// that don't override fall through to the runtime path inside
147 /// `PluginExport::param_infos_static`. Gated by `Self: Sized` so
148 /// adding the method preserves dyn-compatibility for the existing
149 /// `&self`-method shape (`&dyn Params` skips this slot).
150 #[must_use]
151 fn param_infos_static() -> Vec<ParamInfo>
152 where
153 Self: Sized,
154 {
155 Vec::new()
156 }
157
158 /// Number of parameters.
159 fn count(&self) -> usize;
160
161 /// IDs of every `#[meter]` slot declared on the params struct
162 /// (including nested subtrees), in declaration order. Default impl
163 /// returns empty - only structs that declare meters need to
164 /// override. The derive macro implements it automatically.
165 ///
166 /// Format wrappers that expose DSP-side meters back to the UI
167 /// (LV2's output control ports, for instance) use this to know
168 /// which IDs to poll each `process()`.
169 fn meter_ids(&self) -> Vec<u32> {
170 Vec::new()
171 }
172
173 /// Get normalized value (0.0–1.0) by ID.
174 fn get_normalized(&self, id: u32) -> Option<f64>;
175
176 /// Set normalized value (0.0–1.0) by ID.
177 ///
178 /// Takes `&self`, not `&mut self` - the per-param storage in
179 /// `FloatParam` / `BoolParam` / `IntParam` / `EnumParam` is built
180 /// on `AtomicU32` / `AtomicU64`, so writes go through interior
181 /// mutability. Format wrappers, GUI editors, and the audio thread
182 /// all hold `&Params` (or `Arc<Params>`) concurrently and write
183 /// without coordination - every implementation must be sound under
184 /// concurrent `&self` writes from multiple threads.
185 fn set_normalized(&self, id: u32, value: f64);
186
187 /// Set normalized value and read back the resulting plain value in
188 /// one call. CLAP / AU forward the plain value to the host's
189 /// automation channel after a GUI write. The default impl is the
190 /// obvious `set_normalized` then `get_plain`; concrete `Params`
191 /// implementations that can compute both in one trait dispatch
192 /// (e.g. the `#[derive(Params)]` output) should override for a
193 /// single match-arm walk.
194 fn set_normalized_returning_plain(&self, id: u32, value: f64) -> f64 {
195 self.set_normalized(id, value);
196 self.get_plain(id).unwrap_or(0.0)
197 }
198
199 /// Set normalized value and read back the (post-clamp / post-step)
200 /// normalized value in one call. VST3 / VST2 / AAX forward
201 /// normalized values to the host's automation channel. Same
202 /// override-for-single-dispatch contract as
203 /// [`Self::set_normalized_returning_plain`].
204 fn set_normalized_returning_normalized(&self, id: u32, value: f64) -> f64 {
205 self.set_normalized(id, value);
206 self.get_normalized(id).unwrap_or(0.0)
207 }
208
209 /// Get plain value by ID.
210 fn get_plain(&self, id: u32) -> Option<f64>;
211
212 /// Set plain value by ID.
213 ///
214 /// Same `&self` interior-mutability contract as
215 /// [`Self::set_normalized`].
216 fn set_plain(&self, id: u32, value: f64);
217
218 /// Format a plain value to display string.
219 fn format_value(&self, id: u32, value: f64) -> Option<String>;
220
221 /// Parse a display string to plain value.
222 fn parse_value(&self, id: u32, text: &str) -> Option<f64>;
223
224 /// Reset all smoothers to current values.
225 fn snap_smoothers(&self);
226
227 /// Update smoother sample rates.
228 fn set_sample_rate(&self, sample_rate: f64);
229
230 /// Collect all parameter IDs and their current plain values.
231 fn collect_values(&self) -> (Vec<u32>, Vec<f64>);
232
233 /// Restore parameter values from a list of (id, value) pairs.
234 fn restore_values(&self, values: &[(u32, f64)]);
235
236 /// Walk every parameter and meter ID reachable from `self`
237 /// (including nested `#[nested]` substructs) and panic on the
238 /// first duplicate.
239 ///
240 /// Why this isn't just a compile-time check: the
241 /// `#[derive(Params)]` collision check at expansion time only
242 /// sees IDs declared in the *current* struct. A parent param
243 /// `id = 5` and a nested-substruct param `id = 5` both compile,
244 /// because the parent derive doesn't see into the nested type.
245 /// At runtime, the `set_plain` / `get_plain` dispatcher matches
246 /// at the outer level first and silently never reaches the
247 /// nested one - preset round-trips would corrupt the nested
248 /// value. This method makes that bug surface as a panic at
249 /// plugin construction instead of as quiet state loss.
250 ///
251 /// Called automatically by the derive-generated `Self::new()`.
252 /// Plugin code shouldn't need to invoke it directly.
253 fn assert_no_id_collisions(&self) {
254 let mut all = self.param_infos();
255 // Borrow the names from the existing infos so the panic
256 // message can identify *which* IDs collided.
257 let mut seen: Vec<(u32, &'static str)> = Vec::with_capacity(all.len());
258 for info in all.drain(..) {
259 for (prev_id, prev_name) in &seen {
260 assert!(
261 *prev_id != info.id,
262 "duplicate parameter ID {}: '{}' and '{}' (likely a \
263 parent / nested-struct collision; the per-struct \
264 compile-time check can't see across nested types)",
265 info.id,
266 prev_name,
267 info.name,
268 );
269 }
270 seen.push((info.id, info.name));
271 }
272 for meter_id in self.meter_ids() {
273 for (prev_id, prev_name) in &seen {
274 assert!(
275 *prev_id != meter_id,
276 "meter ID {meter_id} collides with parameter ID for '{prev_name}'",
277 );
278 }
279 }
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use crate::range::ParamRange;
287
288 fn pan_info() -> ParamInfo {
289 ParamInfo {
290 id: 0,
291 name: "Pan",
292 short_name: "Pan",
293 group: "",
294 range: ParamRange::Linear {
295 min: -1.0,
296 max: 1.0,
297 },
298 default_plain: 0.0,
299 flags: ParamFlags::empty(),
300 unit: ParamUnit::Pan,
301 kind: ParamValueKind::Float,
302 }
303 }
304
305 #[test]
306 fn pan_centre() {
307 let info = pan_info();
308 assert_eq!(format_param_value(&info, 0.0), "C");
309 assert_eq!(format_param_value(&info, 0.004), "C");
310 assert_eq!(format_param_value(&info, -0.004), "C");
311 }
312
313 #[test]
314 fn pan_left() {
315 let info = pan_info();
316 assert_eq!(format_param_value(&info, -0.5), "50L");
317 assert_eq!(format_param_value(&info, -1.0), "100L");
318 assert_eq!(format_param_value(&info, -0.006), "1L");
319 }
320
321 #[test]
322 fn pan_right() {
323 let info = pan_info();
324 assert_eq!(format_param_value(&info, 0.5), "50R");
325 assert_eq!(format_param_value(&info, 1.0), "100R");
326 assert_eq!(format_param_value(&info, 0.006), "1R");
327 }
328
329 fn int_info(unit: ParamUnit) -> ParamInfo {
330 ParamInfo {
331 id: 0,
332 name: "n",
333 short_name: "n",
334 group: "",
335 range: ParamRange::Discrete { min: -12, max: 12 },
336 default_plain: 0.0,
337 flags: ParamFlags::empty(),
338 unit,
339 kind: ParamValueKind::Int,
340 }
341 }
342
343 #[test]
344 fn int_param_no_fractional_zero() {
345 // IntParam values must render with no decimal places.
346 // A hard-coded `{:.1}` formatter (regardless of param kind)
347 // would render "0.0 st" / "-5.0 st" for semitone values.
348 assert_eq!(
349 format_param_value(&int_info(ParamUnit::Semitones), 0.0),
350 "0 st"
351 );
352 assert_eq!(
353 format_param_value(&int_info(ParamUnit::Semitones), -5.0),
354 "-5 st"
355 );
356 assert_eq!(format_param_value(&int_info(ParamUnit::None), 0.0), "0");
357 assert_eq!(format_param_value(&int_info(ParamUnit::Db), 6.0), "6 dB");
358 assert_eq!(
359 format_param_value(&int_info(ParamUnit::Milliseconds), 50.0),
360 "50 ms"
361 );
362 }
363}