Skip to main content

quiver/
introspection.rs

1//! GUI Introspection API (Phase 1)
2//!
3//! This module provides types and traits for exposing module metadata to UIs,
4//! enabling automatic generation of appropriate controls (knobs, sliders, etc.).
5
6use alloc::format;
7use alloc::string::String;
8#[cfg(feature = "wasm")]
9use alloc::string::ToString;
10use alloc::vec::Vec;
11use serde::{Deserialize, Serialize};
12
13use crate::port::GraphModule;
14
15// =============================================================================
16// Parameter Value Formatting
17// =============================================================================
18
19/// How to format parameter values for display
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
22#[serde(rename_all = "snake_case", tag = "type")]
23pub enum ValueFormat {
24    /// Decimal number with specified precision
25    Decimal { places: u8 },
26    /// Frequency in Hz/kHz
27    Frequency,
28    /// Time in ms/s
29    Time,
30    /// Level in decibels
31    Decibels,
32    /// Percentage (0-100%)
33    Percent,
34    /// Musical note name (C4, D#5, etc.)
35    NoteName,
36    /// Ratio (1:2, 3:1, etc.)
37    Ratio,
38}
39
40impl Default for ValueFormat {
41    fn default() -> Self {
42        ValueFormat::Decimal { places: 2 }
43    }
44}
45
46impl ValueFormat {
47    /// Format a value according to this format specification
48    pub fn format(&self, value: f64) -> String {
49        match self {
50            ValueFormat::Decimal { places } => {
51                format!("{:.prec$}", value, prec = *places as usize)
52            }
53            ValueFormat::Frequency => {
54                if value >= 1000.0 {
55                    format!("{:.2} kHz", value / 1000.0)
56                } else {
57                    format!("{:.1} Hz", value)
58                }
59            }
60            ValueFormat::Time => {
61                if value >= 1.0 {
62                    format!("{:.2} s", value)
63                } else {
64                    format!("{:.1} ms", value * 1000.0)
65                }
66            }
67            ValueFormat::Decibels => {
68                format!("{:.1} dB", value)
69            }
70            ValueFormat::Percent => {
71                format!("{:.0}%", value * 100.0)
72            }
73            ValueFormat::NoteName => {
74                // Convert voltage to MIDI note number (0V = C4 = 60)
75                let midi_note = libm::Libm::<f64>::round((value * 12.0) + 60.0) as i32;
76                let note_names = [
77                    "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
78                ];
79                let note = note_names[(midi_note.rem_euclid(12)) as usize];
80                let octave = (midi_note / 12) - 1;
81                format!("{}{}", note, octave)
82            }
83            ValueFormat::Ratio => {
84                if value >= 1.0 {
85                    format!("{:.1}:1", value)
86                } else if value > 0.0 {
87                    format!("1:{:.1}", 1.0 / value)
88                } else {
89                    "0:1".into()
90                }
91            }
92        }
93    }
94}
95
96// =============================================================================
97// Parameter Curve (Value Scaling)
98// =============================================================================
99
100/// How parameter values are scaled between min and max
101#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
102#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
103#[serde(rename_all = "snake_case", tag = "type")]
104pub enum ParamCurve {
105    /// Linear interpolation between min and max
106    #[default]
107    Linear,
108    /// Exponential scaling (good for frequency, time)
109    Exponential,
110    /// Logarithmic scaling (good for dB, perception)
111    Logarithmic,
112    /// Discrete steps
113    Stepped { steps: u32 },
114}
115
116impl ParamCurve {
117    /// Apply the curve to a normalized (0-1) value, returning the actual value
118    pub fn apply(&self, normalized: f64, min: f64, max: f64) -> f64 {
119        let n = normalized.clamp(0.0, 1.0);
120        match self {
121            ParamCurve::Linear => min + n * (max - min),
122            ParamCurve::Exponential => {
123                if min <= 0.0 {
124                    n * max
125                } else {
126                    min * libm::Libm::<f64>::pow(max / min, n)
127                }
128            }
129            ParamCurve::Logarithmic => {
130                // Inverse of exponential
131                let log_min = if min > 0.0 {
132                    libm::Libm::<f64>::log10(min)
133                } else {
134                    0.0
135                };
136                let log_max = libm::Libm::<f64>::log10(max.max(0.001));
137                libm::Libm::<f64>::pow(10.0, log_min + n * (log_max - log_min))
138            }
139            ParamCurve::Stepped { steps } => {
140                let step_size = (max - min) / (*steps as f64);
141                let step_index = libm::Libm::<f64>::floor(n * (*steps as f64)) as u32;
142                min + (step_index.min(*steps - 1) as f64) * step_size
143            }
144        }
145    }
146
147    /// Convert an actual value to normalized (0-1) based on this curve
148    pub fn normalize(&self, value: f64, min: f64, max: f64) -> f64 {
149        if (max - min).abs() < 1e-10 {
150            return 0.0;
151        }
152
153        match self {
154            ParamCurve::Linear => ((value - min) / (max - min)).clamp(0.0, 1.0),
155            ParamCurve::Exponential => {
156                if min <= 0.0 || value <= 0.0 {
157                    ((value - min) / (max - min)).clamp(0.0, 1.0)
158                } else {
159                    let log_ratio =
160                        libm::Libm::<f64>::log(value / min) / libm::Libm::<f64>::log(max / min);
161                    log_ratio.clamp(0.0, 1.0)
162                }
163            }
164            ParamCurve::Logarithmic => {
165                let log_min = if min > 0.0 {
166                    libm::Libm::<f64>::log10(min)
167                } else {
168                    0.0
169                };
170                let log_max = libm::Libm::<f64>::log10(max.max(0.001));
171                let log_val = libm::Libm::<f64>::log10(value.max(0.001));
172                ((log_val - log_min) / (log_max - log_min)).clamp(0.0, 1.0)
173            }
174            ParamCurve::Stepped { steps } => {
175                let step_size = (max - min) / (*steps as f64);
176                let step_index = libm::Libm::<f64>::round((value - min) / step_size) as u32;
177                (step_index as f64 / *steps as f64).clamp(0.0, 1.0)
178            }
179        }
180    }
181}
182
183// =============================================================================
184// Control Type
185// =============================================================================
186
187/// Suggested UI control type for a parameter
188#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
189#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
190#[serde(rename_all = "snake_case")]
191pub enum ControlType {
192    /// Rotary knob (most common for synth parameters)
193    #[default]
194    Knob,
195    /// Linear slider (vertical or horizontal)
196    Slider,
197    /// On/off toggle switch
198    Toggle,
199    /// Dropdown or segmented selector for discrete options
200    Select,
201}
202
203// =============================================================================
204// Parameter Information
205// =============================================================================
206
207/// Complete parameter descriptor for UI generation
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
210#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
211pub struct ParamInfo {
212    /// Unique identifier within module (e.g., "frequency", "resonance")
213    pub id: String,
214    /// Display name (e.g., "Frequency", "Resonance")
215    pub name: String,
216    /// Current value
217    pub value: f64,
218    /// Minimum value
219    pub min: f64,
220    /// Maximum value
221    pub max: f64,
222    /// Default value
223    pub default: f64,
224    /// Value scaling curve
225    pub curve: ParamCurve,
226    /// Suggested control type
227    pub control: ControlType,
228    /// Unit for display (Hz, ms, dB, %, etc.)
229    pub unit: Option<String>,
230    /// Value formatting hint
231    pub format: ValueFormat,
232}
233
234impl ParamInfo {
235    /// Create a new parameter info with sensible defaults
236    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
237        Self {
238            id: id.into(),
239            name: name.into(),
240            value: 0.5,
241            min: 0.0,
242            max: 1.0,
243            default: 0.5,
244            curve: ParamCurve::Linear,
245            control: ControlType::Knob,
246            unit: None,
247            format: ValueFormat::default(),
248        }
249    }
250
251    /// Set the value range
252    pub fn with_range(mut self, min: f64, max: f64) -> Self {
253        self.min = min;
254        self.max = max;
255        self
256    }
257
258    /// Set the default value
259    pub fn with_default(mut self, default: f64) -> Self {
260        self.default = default;
261        self.value = default;
262        self
263    }
264
265    /// Set the current value
266    pub fn with_value(mut self, value: f64) -> Self {
267        self.value = value;
268        self
269    }
270
271    /// Set the curve type
272    pub fn with_curve(mut self, curve: ParamCurve) -> Self {
273        self.curve = curve;
274        self
275    }
276
277    /// Set the control type
278    pub fn with_control(mut self, control: ControlType) -> Self {
279        self.control = control;
280        self
281    }
282
283    /// Set the unit string
284    pub fn with_unit(mut self, unit: impl Into<String>) -> Self {
285        self.unit = Some(unit.into());
286        self
287    }
288
289    /// Set the value format
290    pub fn with_format(mut self, format: ValueFormat) -> Self {
291        self.format = format;
292        self
293    }
294
295    /// Create a frequency parameter (20Hz - 20kHz, exponential)
296    pub fn frequency(id: impl Into<String>, name: impl Into<String>) -> Self {
297        Self::new(id, name)
298            .with_range(20.0, 20000.0)
299            .with_default(1000.0)
300            .with_curve(ParamCurve::Exponential)
301            .with_unit("Hz")
302            .with_format(ValueFormat::Frequency)
303    }
304
305    /// Create a time parameter (1ms - 10s, exponential)
306    pub fn time(id: impl Into<String>, name: impl Into<String>) -> Self {
307        Self::new(id, name)
308            .with_range(0.001, 10.0)
309            .with_default(0.1)
310            .with_curve(ParamCurve::Exponential)
311            .with_unit("s")
312            .with_format(ValueFormat::Time)
313    }
314
315    /// Create a level/gain parameter (-60dB to +12dB)
316    pub fn decibels(id: impl Into<String>, name: impl Into<String>) -> Self {
317        Self::new(id, name)
318            .with_range(-60.0, 12.0)
319            .with_default(0.0)
320            .with_curve(ParamCurve::Linear)
321            .with_unit("dB")
322            .with_format(ValueFormat::Decibels)
323    }
324
325    /// Create a percentage parameter (0-100%)
326    pub fn percent(id: impl Into<String>, name: impl Into<String>) -> Self {
327        Self::new(id, name)
328            .with_range(0.0, 1.0)
329            .with_default(0.5)
330            .with_curve(ParamCurve::Linear)
331            .with_format(ValueFormat::Percent)
332    }
333
334    /// Create a toggle parameter (0 or 1)
335    pub fn toggle(id: impl Into<String>, name: impl Into<String>) -> Self {
336        Self::new(id, name)
337            .with_range(0.0, 1.0)
338            .with_default(0.0)
339            .with_curve(ParamCurve::Stepped { steps: 2 })
340            .with_control(ControlType::Toggle)
341    }
342
343    /// Create a selector parameter with N options
344    pub fn select(id: impl Into<String>, name: impl Into<String>, options: u32) -> Self {
345        Self::new(id, name)
346            .with_range(0.0, (options - 1) as f64)
347            .with_default(0.0)
348            .with_curve(ParamCurve::Stepped { steps: options })
349            .with_control(ControlType::Select)
350            .with_format(ValueFormat::Decimal { places: 0 })
351    }
352
353    /// Get the normalized (0-1) value
354    pub fn normalized(&self) -> f64 {
355        self.curve.normalize(self.value, self.min, self.max)
356    }
357
358    /// Set value from normalized (0-1) input
359    pub fn set_normalized(&mut self, normalized: f64) {
360        self.value = self.curve.apply(normalized, self.min, self.max);
361    }
362
363    /// Format the current value for display
364    pub fn format_value(&self) -> String {
365        self.format.format(self.value)
366    }
367}
368
369// =============================================================================
370// Module Introspection Trait
371// =============================================================================
372
373/// Trait for modules to expose their parameters to UIs
374///
375/// This trait extends `GraphModule` to provide parameter metadata
376/// that UIs can use to automatically generate appropriate controls.
377pub trait ModuleIntrospection: GraphModule {
378    /// Get all parameter descriptors for this module
379    ///
380    /// Returns a list of `ParamInfo` describing each controllable parameter.
381    /// The order should be consistent and reflect a logical grouping.
382    fn param_infos(&self) -> Vec<ParamInfo> {
383        Vec::new()
384    }
385
386    /// Get a specific parameter by its ID
387    fn get_param_info(&self, id: &str) -> Option<ParamInfo> {
388        self.param_infos().into_iter().find(|p| p.id == id)
389    }
390
391    /// Set a parameter value by its ID
392    ///
393    /// Returns true if the parameter was found and set, false otherwise.
394    fn set_param_by_id(&mut self, _id: &str, _value: f64) -> bool {
395        false
396    }
397}
398
399// =============================================================================
400// Tests
401// =============================================================================
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn test_value_format_decimal() {
409        let fmt = ValueFormat::Decimal { places: 2 };
410        assert_eq!(fmt.format(1.23456), "1.23");
411    }
412
413    #[test]
414    fn test_value_format_frequency() {
415        let fmt = ValueFormat::Frequency;
416        assert_eq!(fmt.format(440.0), "440.0 Hz");
417        assert_eq!(fmt.format(2500.0), "2.50 kHz");
418    }
419
420    #[test]
421    fn test_value_format_time() {
422        let fmt = ValueFormat::Time;
423        assert_eq!(fmt.format(0.1), "100.0 ms");
424        assert_eq!(fmt.format(2.5), "2.50 s");
425    }
426
427    #[test]
428    fn test_value_format_decibels() {
429        let fmt = ValueFormat::Decibels;
430        assert_eq!(fmt.format(-12.0), "-12.0 dB");
431    }
432
433    #[test]
434    fn test_value_format_percent() {
435        let fmt = ValueFormat::Percent;
436        assert_eq!(fmt.format(0.5), "50%");
437        assert_eq!(fmt.format(1.0), "100%");
438    }
439
440    #[test]
441    fn test_value_format_note_name() {
442        let fmt = ValueFormat::NoteName;
443        assert_eq!(fmt.format(0.0), "C4"); // 0V = C4
444        assert_eq!(fmt.format(1.0), "C5"); // 1V = C5
445    }
446
447    #[test]
448    fn test_value_format_ratio() {
449        let fmt = ValueFormat::Ratio;
450        assert_eq!(fmt.format(2.0), "2.0:1");
451        assert_eq!(fmt.format(0.5), "1:2.0");
452    }
453
454    #[test]
455    fn test_param_curve_linear() {
456        let curve = ParamCurve::Linear;
457        assert!((curve.apply(0.0, 0.0, 100.0) - 0.0).abs() < 0.01);
458        assert!((curve.apply(0.5, 0.0, 100.0) - 50.0).abs() < 0.01);
459        assert!((curve.apply(1.0, 0.0, 100.0) - 100.0).abs() < 0.01);
460    }
461
462    #[test]
463    fn test_param_curve_exponential() {
464        let curve = ParamCurve::Exponential;
465        let val = curve.apply(0.5, 20.0, 20000.0);
466        // At 50%, exponential should give geometric mean
467        let expected = (20.0_f64 * 20000.0).sqrt();
468        assert!((val - expected).abs() < 1.0);
469    }
470
471    #[test]
472    fn test_param_curve_stepped() {
473        let curve = ParamCurve::Stepped { steps: 4 };
474        // With 4 steps over range 0-3, step_size = 0.75
475        // step_index = floor(normalized * steps)
476        assert!((curve.apply(0.0, 0.0, 3.0) - 0.0).abs() < 0.01); // step 0
477        assert!((curve.apply(0.25, 0.0, 3.0) - 0.75).abs() < 0.01); // step 1
478        assert!((curve.apply(0.5, 0.0, 3.0) - 1.5).abs() < 0.01); // step 2
479        assert!((curve.apply(0.75, 0.0, 3.0) - 2.25).abs() < 0.01); // step 3
480    }
481
482    #[test]
483    fn test_param_curve_normalize_linear() {
484        let curve = ParamCurve::Linear;
485        assert!((curve.normalize(50.0, 0.0, 100.0) - 0.5).abs() < 0.01);
486    }
487
488    #[test]
489    fn test_param_info_creation() {
490        let param = ParamInfo::new("freq", "Frequency")
491            .with_range(20.0, 20000.0)
492            .with_default(440.0)
493            .with_curve(ParamCurve::Exponential)
494            .with_unit("Hz")
495            .with_format(ValueFormat::Frequency);
496
497        assert_eq!(param.id, "freq");
498        assert_eq!(param.name, "Frequency");
499        assert_eq!(param.min, 20.0);
500        assert_eq!(param.max, 20000.0);
501        assert_eq!(param.default, 440.0);
502        assert_eq!(param.value, 440.0);
503        assert_eq!(param.unit, Some("Hz".to_string()));
504    }
505
506    #[test]
507    fn test_param_info_frequency_preset() {
508        let param = ParamInfo::frequency("cutoff", "Cutoff");
509        assert_eq!(param.min, 20.0);
510        assert_eq!(param.max, 20000.0);
511        assert_eq!(param.curve, ParamCurve::Exponential);
512    }
513
514    #[test]
515    fn test_param_info_toggle_preset() {
516        let param = ParamInfo::toggle("sync", "Hard Sync");
517        assert_eq!(param.control, ControlType::Toggle);
518        assert!(matches!(param.curve, ParamCurve::Stepped { steps: 2 }));
519    }
520
521    #[test]
522    fn test_param_info_select_preset() {
523        let param = ParamInfo::select("waveform", "Waveform", 4);
524        assert_eq!(param.control, ControlType::Select);
525        assert!(matches!(param.curve, ParamCurve::Stepped { steps: 4 }));
526    }
527
528    #[test]
529    fn test_param_info_normalized() {
530        let mut param = ParamInfo::new("test", "Test")
531            .with_range(0.0, 100.0)
532            .with_value(50.0);
533
534        assert!((param.normalized() - 0.5).abs() < 0.01);
535
536        param.set_normalized(0.25);
537        assert!((param.value - 25.0).abs() < 0.01);
538    }
539
540    #[test]
541    fn test_param_info_format_value() {
542        let param = ParamInfo::frequency("freq", "Frequency").with_value(1000.0);
543        assert_eq!(param.format_value(), "1.00 kHz");
544    }
545
546    #[test]
547    fn test_param_info_serialization() {
548        let param = ParamInfo::frequency("cutoff", "Cutoff");
549        let json = serde_json::to_string(&param).unwrap();
550        assert!(json.contains("cutoff"));
551        assert!(json.contains("exponential"));
552
553        let parsed: ParamInfo = serde_json::from_str(&json).unwrap();
554        assert_eq!(parsed.id, "cutoff");
555    }
556}