truce_params/info.rs
1use crate::range::ParamRange;
2
3/// Metadata for a single parameter, used by format wrappers.
4///
5/// `Copy` because every field is POD (`&'static str`, scalars,
6/// bitflags, the [`ParamRange`] / [`ParamUnit`] enums). Lets the
7/// audio path pass `param_infos[i]` by value without `clone()` noise.
8#[derive(Clone, Copy, Debug)]
9pub struct ParamInfo {
10 pub id: u32,
11 pub name: &'static str,
12 pub short_name: &'static str,
13 pub group: &'static str,
14 pub range: ParamRange,
15 pub default_plain: f64,
16 pub flags: ParamFlags,
17 pub unit: ParamUnit,
18 /// Which `*Param` type backs this entry. Drives display rounding
19 /// (`IntParam` skips fractional digits) and `value_text` parsing,
20 /// independently of [`ParamRange`] - a `FloatParam` declared with
21 /// `range = "discrete(...)"` should still format as a float, so
22 /// inferring kind from range alone is wrong.
23 pub kind: ParamValueKind,
24 /// Default host MIDI-learn binding: the MIDI message that should
25 /// drive this parameter. `None` (the common case) means the host
26 /// maps it itself with no plugin hint. Set by `#[param(midi_cc =
27 /// …)]` / `#[param(midi_source = …)]`; read by the VST3
28 /// `IMidiMapping`, AU parameter-MIDI-mapping, and LV2 `midi:binding`
29 /// paths. Ignored by CLAP / VST2 / AAX (the host owns the mapping).
30 pub midi_map: Option<MidiSource>,
31 /// Optional channel scope for [`Self::midi_map`], as the wire
32 /// channel `0..=15`. `None` matches any channel.
33 pub midi_channel: Option<u8>,
34}
35
36/// The MIDI message a parameter binds to for host MIDI-learn. CCs cover
37/// the common case; the non-CC per-channel messages each map onto a
38/// VST3 `ControllerNumbers` value, an AU status byte, and an LV2
39/// `midi:binding` class.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum MidiSource {
42 /// Control change, `0..=127`.
43 Cc(u8),
44 /// Pitch bend.
45 PitchBend,
46 /// Channel pressure (mono aftertouch).
47 ChannelPressure,
48 /// Program change.
49 ProgramChange,
50}
51
52/// Resolve which parameter a MIDI `source` on `channel` is bound to,
53/// from a param-info list. The first param whose binding matches the
54/// source and whose channel scope is unset (any) or equal wins;
55/// `#[derive(Params)]` rejects ambiguous overlaps at compile time, so
56/// at most one matches. Used by the format wrappers' mapping paths.
57#[must_use]
58pub fn map_source_to_param(infos: &[ParamInfo], channel: u8, source: MidiSource) -> Option<u32> {
59 infos
60 .iter()
61 .find(|p| p.midi_map == Some(source) && p.midi_channel.is_none_or(|ch| ch == channel))
62 .map(|p| p.id)
63}
64
65#[cfg(test)]
66mod tests {
67 use super::*;
68
69 fn info(id: u32, map: Option<MidiSource>, channel: Option<u8>) -> ParamInfo {
70 ParamInfo {
71 id,
72 name: "p",
73 short_name: "p",
74 group: "",
75 range: ParamRange::Linear { min: 0.0, max: 1.0 },
76 default_plain: 0.0,
77 flags: ParamFlags::AUTOMATABLE,
78 unit: ParamUnit::None,
79 kind: ParamValueKind::Float,
80 midi_map: map,
81 midi_channel: channel,
82 }
83 }
84
85 #[test]
86 fn resolves_cc_any_and_scoped_channel() {
87 let infos = [
88 info(1, Some(MidiSource::Cc(74)), None), // any channel
89 info(2, Some(MidiSource::Cc(71)), Some(0)), // channel 1 (wire 0)
90 info(3, Some(MidiSource::PitchBend), None),
91 ];
92 // Any-channel CC matches whatever channel.
93 assert_eq!(map_source_to_param(&infos, 5, MidiSource::Cc(74)), Some(1));
94 // Scoped CC matches only its channel.
95 assert_eq!(map_source_to_param(&infos, 0, MidiSource::Cc(71)), Some(2));
96 assert_eq!(map_source_to_param(&infos, 1, MidiSource::Cc(71)), None);
97 // Non-CC source resolves by kind.
98 assert_eq!(
99 map_source_to_param(&infos, 9, MidiSource::PitchBend),
100 Some(3)
101 );
102 // Unmapped source/CC returns None.
103 assert_eq!(map_source_to_param(&infos, 0, MidiSource::Cc(7)), None);
104 }
105}
106
107/// Which strongly-typed `*Param` constructor produced this
108/// [`ParamInfo`]. The `#[derive(Params)]` macro sets it from the
109/// field type so format-side code can branch on the original
110/// typing without re-deriving it from `range` / `unit`.
111#[derive(Clone, Copy, Debug, PartialEq, Eq)]
112pub enum ParamValueKind {
113 Float,
114 Int,
115 Bool,
116 Enum,
117}
118
119bitflags::bitflags! {
120 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
121 pub struct ParamFlags: u32 {
122 const AUTOMATABLE = 0b0_0001;
123 const HIDDEN = 0b0_0010;
124 const READONLY = 0b0_0100;
125 const IS_BYPASS = 0b0_1000;
126 /// This parameter participates in sample-accurate sub-block
127 /// chunking: a `ParamChange` event targeting it splits the
128 /// audio block at its `sample_offset`. Defaults on; cleared
129 /// by `#[param(chunk = false)]` on expensive-to-retarget
130 /// params (FFT sizes, lookahead, etc.) where the per-event
131 /// fixed cost of subdividing the block outweighs the
132 /// sample-accuracy win. Read by
133 /// `truce_core::chunked_process::is_split_event`.
134 const CHUNKED = 0b1_0000;
135 /// The host may modulate this parameter (CLAP
136 /// `CLAP_PARAM_IS_MODULATABLE`): it emits `CLAP_EVENT_PARAM_MOD`,
137 /// delivered to `process` as an `EventBody::ParamMod`. Opt-in - a
138 /// plugin sets it only for params it actually reads mod events
139 /// for; other formats ignore it (host-owned there).
140 const MODULATABLE = 0b10_0000;
141 /// Per-note-id (polyphonic) modulation: implies
142 /// [`Self::MODULATABLE`] and maps to
143 /// `CLAP_PARAM_IS_MODULATABLE_PER_NOTE_ID`. The `ParamMod`
144 /// event's `note_id` scopes the offset to one voice.
145 const MODULATABLE_PER_NOTE = 0b100_0000;
146 }
147}
148
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150pub enum ParamUnit {
151 None,
152 Db,
153 Hz,
154 Milliseconds,
155 Seconds,
156 Percent,
157 Semitones,
158 Pan,
159 Degrees,
160}
161
162impl ParamUnit {
163 /// Format-agnostic unit string for host display.
164 #[must_use]
165 pub fn as_str(&self) -> &'static str {
166 match self {
167 Self::Db => "dB",
168 Self::Hz => "Hz",
169 Self::Milliseconds => "ms",
170 Self::Seconds => "s",
171 Self::Percent => "%",
172 Self::Semitones => "st",
173 Self::Degrees => "°",
174 Self::Pan | Self::None => "",
175 }
176 }
177}