Skip to main content

ferraus_plugins/
protocol.rs

1//! The wire protocol between ferraus and a plugin host process (`ferraus-plughost` for CLAP,
2//! `ferraus-plughost-vst3` for VST3). See `PROTOCOL.md` in this crate for the full spec.
3//!
4//! * Control: length-prefixed JSON frames (`u32` little-endian byte length, then UTF-8 JSON)
5//!   on the host's stdin (requests) and stdout (replies). Exactly one reply per request, in
6//!   order. The host's own stdout is redirected to stderr before any plugin code runs, so a
7//!   chatty plugin can never corrupt the stream.
8//! * Audio: a memory-mapped temp file created by the client, holding a 64-byte header and a
9//!   ring of `slots` blocks; each slot has planar `f32` input channels followed by planar
10//!   output channels of `block_size` frames.
11//!
12//! This module has no dependency on `ferraus-core`, so the host binaries link only this.
13
14use serde::{Deserialize, Serialize};
15use std::io::{self, Read, Write};
16
17/// Bumped on any incompatible change to the messages or the shared-memory layout.
18pub const PROTOCOL_VERSION: u32 = 1;
19/// Largest accepted frame (64 MiB): states and preset lists are the only big payloads.
20pub const MAX_FRAME: usize = 64 << 20;
21/// First 8 bytes of the shared-memory file.
22pub const SHM_MAGIC: &[u8; 8] = b"FRAUSHM1";
23/// Size of the shared-memory header in bytes.
24pub const SHM_HEADER: usize = 64;
25
26/// Write one frame: `u32` LE length followed by the JSON bytes.
27pub fn write_frame<W: Write + ?Sized, T: Serialize>(w: &mut W, value: &T) -> io::Result<()> {
28    let bytes = serde_json::to_vec(value).map_err(io::Error::other)?;
29    if bytes.len() > MAX_FRAME {
30        return Err(io::Error::other("frame too large"));
31    }
32    let mut buf = Vec::with_capacity(4 + bytes.len());
33    buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
34    buf.extend_from_slice(&bytes);
35    w.write_all(&buf)?;
36    w.flush()
37}
38
39/// Read one frame. `Ok(None)` on a clean end of stream before a frame starts.
40pub fn read_frame<R: Read + ?Sized>(r: &mut R) -> io::Result<Option<Vec<u8>>> {
41    let mut len = [0u8; 4];
42    let mut got = 0;
43    while got < 4 {
44        let n = r.read(&mut len[got..])?;
45        if n == 0 {
46            if got == 0 {
47                return Ok(None);
48            }
49            return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "truncated frame header"));
50        }
51        got += n;
52    }
53    let n = u32::from_le_bytes(len) as usize;
54    if n > MAX_FRAME {
55        return Err(io::Error::other(format!("frame of {n} bytes exceeds the limit")));
56    }
57    let mut buf = vec![0u8; n];
58    r.read_exact(&mut buf)?;
59    Ok(Some(buf))
60}
61
62/// Client → host.
63#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
64#[serde(tag = "cmd", rename_all = "snake_case")]
65pub enum Request {
66    /// Handshake; the reply names the host, its version and protocol version.
67    Hello,
68    /// List the plugins inside a bundle without instantiating any (used by `plugin scan`).
69    Probe { path: String },
70    /// Load the bundle and instantiate one plugin (`plugin_id` may be omitted when the
71    /// bundle holds exactly one). Replies with [`PluginInfo`].
72    Load {
73        path: String,
74        #[serde(default, skip_serializing_if = "Option::is_none")]
75        plugin_id: Option<String>,
76    },
77    /// Set parameters by id or display name; values in display text (`2.5kHz`, `-6dB`,
78    /// `30%`), plain numbers, or `norm:0.3`. Replies with the resulting values.
79    SetParams { params: Vec<ParamAssign> },
80    /// Current parameter values.
81    GetParams,
82    /// Discover presets (CLAP preset-discovery factory / VST3 program lists).
83    ListPresets,
84    /// Load a preset by name or load key. Replies with the resulting parameter values.
85    LoadPreset { preset: String },
86    /// Save the plugin's native state. Replies with base64 bytes.
87    GetState,
88    /// Restore native state saved earlier.
89    SetState { state_b64: String },
90    /// Activate at a sample rate and maximum block size and map the shared-memory file.
91    Activate { sample_rate: f64, block_size: u32, shm_path: String },
92    /// Process `frames` (≤ block size) from slot `slot`; events carry sample offsets
93    /// relative to the block start, sorted by time.
94    Process {
95        slot: u32,
96        frames: u32,
97        #[serde(default, skip_serializing_if = "Vec::is_empty")]
98        events: Vec<Event>,
99    },
100    /// Stop processing and deactivate (the plugin stays loaded).
101    Deactivate,
102    /// Tear down and exit.
103    Quit,
104}
105
106/// Host → client.
107#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
108#[serde(tag = "reply", rename_all = "snake_case")]
109#[allow(clippy::large_enum_variant)] // one reply per request; boxing buys nothing here
110pub enum Reply {
111    Hello {
112        host: String,
113        version: String,
114        protocol: u32,
115        formats: Vec<String>,
116    },
117    Probe {
118        plugins: Vec<Descriptor>,
119    },
120    Loaded {
121        info: PluginInfo,
122    },
123    Params {
124        params: Vec<ParamValue>,
125    },
126    Presets {
127        presets: Vec<PresetInfo>,
128    },
129    State {
130        state_b64: String,
131    },
132    Activated {
133        latency: u32,
134        /// Tail in samples as the plugin reports it; `None` when it does not say,
135        /// `Some(u32::MAX)` for infinite.
136        #[serde(default)]
137        tail: Option<u32>,
138        channels_in: u32,
139        channels_out: u32,
140    },
141    Processed {
142        slot: u32,
143    },
144    Ok,
145    Error {
146        code: String,
147        message: String,
148    },
149}
150
151/// A sample-accurate event inside one block.
152#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)]
153#[serde(tag = "type", rename_all = "snake_case")]
154pub enum Event {
155    NoteOn {
156        time: u32,
157        channel: u8,
158        key: u8,
159        velocity: f32,
160    },
161    NoteOff {
162        time: u32,
163        channel: u8,
164        key: u8,
165        velocity: f32,
166    },
167    /// Any 3-byte MIDI message (CC, pitch bend, aftertouch, program change...).
168    Midi {
169        time: u32,
170        data: [u8; 3],
171    },
172}
173
174impl Event {
175    pub fn time(&self) -> u32 {
176        match self {
177            Event::NoteOn { time, .. } | Event::NoteOff { time, .. } | Event::Midi { time, .. } => *time,
178        }
179    }
180}
181
182/// `name=value` as given on the command line.
183#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
184pub struct ParamAssign {
185    /// Stable parameter id (`12`, `id:12`) or display name (`cutoff`, `Filter/Cutoff`).
186    pub name: String,
187    pub value: String,
188}
189
190/// What a plugin declares about itself (no instantiation needed).
191#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
192pub struct Descriptor {
193    pub id: String,
194    pub name: String,
195    #[serde(default)]
196    pub vendor: String,
197    #[serde(default)]
198    pub version: String,
199    #[serde(default)]
200    pub description: String,
201    #[serde(default)]
202    pub url: String,
203    /// CLAP feature strings or VST3 sub-categories, lower-cased.
204    #[serde(default)]
205    pub features: Vec<String>,
206    /// `instrument`, `audio-effect`, `note-effect`, `analyzer`, or `other`.
207    #[serde(default)]
208    pub kind: String,
209}
210
211impl Descriptor {
212    /// Derive [`Descriptor::kind`] from feature strings.
213    pub fn classify(features: &[String]) -> String {
214        let has = |f: &str| features.iter().any(|x| x.eq_ignore_ascii_case(f));
215        if has("instrument") || has("synth") || has("synthesizer") || has("sampler") {
216            "instrument"
217        } else if has("note-effect") {
218            "note-effect"
219        } else if has("audio-effect") || has("fx") {
220            "audio-effect"
221        } else if has("analyzer") {
222            "analyzer"
223        } else {
224            "other"
225        }
226        .to_string()
227    }
228}
229
230/// One parameter, with its range in plain (plugin) units.
231#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
232pub struct ParamInfo {
233    pub id: u32,
234    pub name: String,
235    #[serde(default, skip_serializing_if = "String::is_empty")]
236    pub module: String,
237    pub min: f64,
238    pub max: f64,
239    pub default: f64,
240    pub value: f64,
241    /// Display text of the default and current values, from the plugin.
242    #[serde(default)]
243    pub default_text: String,
244    #[serde(default)]
245    pub value_text: String,
246    /// Unit inferred from the display text (`dB`, `Hz`, `%`, `ms`...), when any.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub unit: Option<String>,
249    #[serde(default)]
250    pub stepped: bool,
251    #[serde(default)]
252    pub hidden: bool,
253    #[serde(default)]
254    pub readonly: bool,
255    #[serde(default)]
256    pub bypass: bool,
257    #[serde(default)]
258    pub automatable: bool,
259}
260
261/// A parameter's value after a set/get.
262#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
263pub struct ParamValue {
264    pub id: u32,
265    pub name: String,
266    pub value: f64,
267    pub text: String,
268}
269
270#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
271pub struct PortInfo {
272    pub name: String,
273    pub channels: u32,
274    pub main: bool,
275}
276
277#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
278pub struct NotePortInfo {
279    pub name: String,
280    /// `clap`, `midi`, `midi-mpe`, `midi2`.
281    pub dialects: Vec<String>,
282}
283
284#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
285pub struct PresetInfo {
286    pub name: String,
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub load_key: Option<String>,
289    /// `plugin` (built into the plugin), `file`, or `program` (VST3 program list).
290    pub location_kind: String,
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub location: Option<String>,
293    #[serde(default, skip_serializing_if = "Vec::is_empty")]
294    pub creators: Vec<String>,
295    #[serde(default, skip_serializing_if = "String::is_empty")]
296    pub description: String,
297    #[serde(default, skip_serializing_if = "String::is_empty")]
298    pub provider: String,
299}
300
301/// Everything ferraus needs after instantiation.
302#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
303pub struct PluginInfo {
304    pub descriptor: Descriptor,
305    /// `clap` or `vst3`.
306    pub format: String,
307    pub params: Vec<ParamInfo>,
308    pub audio_inputs: Vec<PortInfo>,
309    pub audio_outputs: Vec<PortInfo>,
310    pub note_inputs: Vec<NotePortInfo>,
311    pub note_outputs: Vec<NotePortInfo>,
312    /// Channels of the main input/output port: the ones carried in shared memory.
313    pub main_in_channels: u32,
314    pub main_out_channels: u32,
315    pub has_state: bool,
316    pub has_presets: bool,
317}
318
319impl PluginInfo {
320    pub fn accepts_notes(&self) -> bool {
321        !self.note_inputs.is_empty()
322    }
323}
324
325// ---------------------------------------------------------------------------------------
326// shared memory
327
328/// Geometry of the shared-memory audio file.
329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub struct ShmLayout {
331    pub channels_in: u32,
332    pub channels_out: u32,
333    pub block_size: u32,
334    pub slots: u32,
335}
336
337impl ShmLayout {
338    pub fn slot_bytes(&self) -> usize {
339        (self.channels_in + self.channels_out) as usize * self.block_size as usize * 4
340    }
341
342    pub fn total_bytes(&self) -> usize {
343        SHM_HEADER + self.slot_bytes() * self.slots as usize
344    }
345
346    /// Byte offset of input channel `ch` in `slot`.
347    pub fn input_offset(&self, slot: u32, ch: u32) -> usize {
348        SHM_HEADER + slot as usize * self.slot_bytes() + (ch * self.block_size) as usize * 4
349    }
350
351    /// Byte offset of output channel `ch` in `slot`.
352    pub fn output_offset(&self, slot: u32, ch: u32) -> usize {
353        SHM_HEADER + slot as usize * self.slot_bytes() + ((self.channels_in + ch) * self.block_size) as usize * 4
354    }
355
356    pub fn write_header(&self, buf: &mut [u8]) {
357        buf[..8].copy_from_slice(SHM_MAGIC);
358        let words = [PROTOCOL_VERSION, self.channels_in, self.channels_out, self.block_size, self.slots];
359        for (i, w) in words.iter().enumerate() {
360            buf[8 + 4 * i..12 + 4 * i].copy_from_slice(&w.to_le_bytes());
361        }
362    }
363
364    pub fn read_header(buf: &[u8]) -> Result<ShmLayout, String> {
365        if buf.len() < SHM_HEADER || &buf[..8] != SHM_MAGIC {
366            return Err("shared memory file has no ferraus header".into());
367        }
368        let w = |i: usize| u32::from_le_bytes([buf[8 + 4 * i], buf[9 + 4 * i], buf[10 + 4 * i], buf[11 + 4 * i]]);
369        if w(0) != PROTOCOL_VERSION {
370            return Err(format!("shared memory protocol {} does not match host protocol {PROTOCOL_VERSION}", w(0)));
371        }
372        let l = ShmLayout { channels_in: w(1), channels_out: w(2), block_size: w(3), slots: w(4) };
373        if l.block_size == 0 || l.slots == 0 || l.total_bytes() > buf.len() {
374            return Err("shared memory header is inconsistent with the file size".into());
375        }
376        Ok(l)
377    }
378}
379
380/// View `n` f32 values at byte offset `off` of a mapped region.
381///
382/// # Safety
383/// `base` must point to a live mapping at least `off + 4n` bytes long, `off` must be a
384/// multiple of 4 (the mapping itself is page-aligned), and no other live reference may
385/// alias the same range.
386pub unsafe fn f32_slice_mut<'a>(base: *mut u8, off: usize, n: usize) -> &'a mut [f32] {
387    std::slice::from_raw_parts_mut(base.add(off) as *mut f32, n)
388}
389
390// ---------------------------------------------------------------------------------------
391// parameter text conversion (shared by every backend)
392
393/// Hooks into a plugin's own text conversion.
394pub trait ParamText {
395    fn text_to_value(&mut self, id: u32, text: &str) -> Option<f64>;
396    fn value_to_text(&mut self, id: u32, value: f64) -> Option<String>;
397}
398
399fn fmt_num(v: f64) -> String {
400    let s = format!("{v:.6}");
401    let s = s.trim_end_matches('0').trim_end_matches('.');
402    if s == "-0" {
403        "0".into()
404    } else {
405        s.to_string()
406    }
407}
408
409/// Split `"2.5kHz"` into `(2.5, "khz")`.
410fn split_number(text: &str) -> Option<(f64, String)> {
411    let t = text.trim();
412    if !t.starts_with(|c: char| c.is_ascii_digit() || matches!(c, '.' | '-' | '+')) {
413        return None;
414    }
415    // the longest numeric prefix (handles `1e3`, `-6`, `2.5`)
416    let end = (1..=t.len()).rev().filter(|&i| t.is_char_boundary(i)).find(|&i| {
417        let s = &t[..i];
418        s.chars().all(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')) && s.parse::<f64>().is_ok()
419    })?;
420    let n: f64 = t[..end].parse().ok()?;
421    Some((n, t[end..].trim().to_ascii_lowercase()))
422}
423
424/// Text variants to try against a plugin's `text_to_value` for a ferraus-style value.
425/// `"2.5kHz"` yields `["2.5kHz", "2500 Hz", "2500Hz", "2500", "2.5 kHz", "2.5"]`.
426pub fn text_candidates(text: &str) -> Vec<String> {
427    let mut out = vec![text.trim().to_string()];
428    if let Some((n, unit)) = split_number(text) {
429        let mut push = |s: String| {
430            if !out.contains(&s) {
431                out.push(s)
432            }
433        };
434        match unit.as_str() {
435            "khz" | "k" => {
436                let hz = n * 1000.0;
437                push(format!("{} Hz", fmt_num(hz)));
438                push(format!("{}Hz", fmt_num(hz)));
439                push(fmt_num(hz));
440                push(format!("{} kHz", fmt_num(n)));
441                push(fmt_num(n));
442            }
443            "hz" => {
444                push(format!("{} Hz", fmt_num(n)));
445                push(format!("{}Hz", fmt_num(n)));
446                push(fmt_num(n));
447                push(format!("{} kHz", fmt_num(n / 1000.0)));
448            }
449            "db" => {
450                push(format!("{} dB", fmt_num(n)));
451                push(format!("{}dB", fmt_num(n)));
452                push(fmt_num(n));
453            }
454            "%" => {
455                push(format!("{} %", fmt_num(n)));
456                push(format!("{}%", fmt_num(n)));
457                push(fmt_num(n));
458                push(fmt_num(n / 100.0));
459            }
460            "ms" => {
461                push(format!("{} ms", fmt_num(n)));
462                push(format!("{}ms", fmt_num(n)));
463                push(fmt_num(n));
464                push(format!("{} s", fmt_num(n / 1000.0)));
465            }
466            "s" | "sec" => {
467                push(format!("{} s", fmt_num(n)));
468                push(format!("{}s", fmt_num(n)));
469                push(format!("{} ms", fmt_num(n * 1000.0)));
470                push(fmt_num(n));
471            }
472            "st" | "semitones" | "semi" => {
473                push(format!("{} st", fmt_num(n)));
474                push(format!("{} semitones", fmt_num(n)));
475                push(fmt_num(n));
476            }
477            "ct" | "cents" | "cent" => {
478                push(format!("{} ct", fmt_num(n)));
479                push(format!("{} cents", fmt_num(n)));
480                push(fmt_num(n));
481            }
482            "" => push(fmt_num(n)),
483            other => {
484                push(format!("{} {other}", fmt_num(n)));
485                push(fmt_num(n));
486            }
487        }
488    }
489    out
490}
491
492/// The unit suffix of a display string: `"-6.00 dB"` → `dB`.
493pub fn infer_unit(display: &str) -> Option<String> {
494    let (_, unit) = split_number(display)?;
495    if unit.is_empty() {
496        return None;
497    }
498    let orig = display.trim();
499    let u = orig[orig.len() - unit.len()..].trim().to_string();
500    if u.chars().any(|c| c.is_ascii_digit()) || u.len() > 12 {
501        None
502    } else {
503        Some(u)
504    }
505}
506
507/// Find a parameter by stable id (`12`, `id:12`) or display name (exact, case-insensitive,
508/// punctuation-insensitive, or `module/name`).
509pub fn find_param<'a>(params: &'a [ParamInfo], key: &str) -> Result<&'a ParamInfo, String> {
510    let key = key.trim();
511    let idkey = key.strip_prefix("id:").unwrap_or(key);
512    if let Ok(id) = idkey.parse::<u32>() {
513        if let Some(p) = params.iter().find(|p| p.id == id) {
514            if key.starts_with("id:") || !params.iter().any(|p| p.name == key) {
515                return Ok(p);
516            }
517        }
518    }
519    let norm = |s: &str| s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect::<String>();
520    let full = |p: &ParamInfo| {
521        if p.module.is_empty() {
522            p.name.clone()
523        } else {
524            format!("{}/{}", p.module.trim_end_matches('/'), p.name)
525        }
526    };
527    let stages: [&dyn Fn(&ParamInfo) -> bool; 4] = [
528        &|p| p.name == key || full(p) == key,
529        &|p| p.name.eq_ignore_ascii_case(key) || full(p).eq_ignore_ascii_case(key),
530        &|p| norm(&p.name) == norm(key) || norm(&full(p)) == norm(key),
531        &|p| norm(&p.name).starts_with(&norm(key)) && !norm(key).is_empty(),
532    ];
533    for test in stages {
534        let hits: Vec<&ParamInfo> = params.iter().filter(|p| test(p)).collect();
535        match hits.len() {
536            0 => continue,
537            1 => return Ok(hits[0]),
538            _ => {
539                let names: Vec<String> = hits.iter().take(8).map(|p| format!("{} (id {})", full(p), p.id)).collect();
540                return Err(format!(
541                    "parameter '{key}' is ambiguous: {}; use the id (e.g. id:{})",
542                    names.join(", "),
543                    hits[0].id
544                ));
545            }
546        }
547    }
548    let mut known: Vec<String> = params.iter().filter(|p| !p.hidden).take(12).map(|p| p.name.clone()).collect();
549    if params.len() > 12 {
550        known.push("...".into());
551    }
552    Err(format!("unknown parameter '{key}'; parameters include: {}", known.join(", ")))
553}
554
555/// Convert a ferraus value string to the plugin's plain value: `norm:x`, the plugin's own
556/// text parser (with unit-spelling variants), a plain in-range number, or a stepped label.
557pub fn resolve_value(p: &ParamInfo, text: &str, conv: &mut dyn ParamText) -> Result<f64, String> {
558    let t = text.trim();
559    let (lo, hi) = (p.min.min(p.max), p.min.max(p.max));
560    let in_range = |v: f64| v >= lo - 1e-9 * (1.0 + lo.abs()) && v <= hi + 1e-9 * (1.0 + hi.abs());
561    if let Some(n) = t.strip_prefix("norm:") {
562        let x: f64 = n.trim().parse().map_err(|_| format!("'{t}' is not norm:<0..1>"))?;
563        if !(0.0..=1.0).contains(&x) {
564            return Err(format!("{t} is outside norm:0..1"));
565        }
566        let mut v = p.min + x * (p.max - p.min);
567        if p.stepped {
568            v = v.round();
569        }
570        return Ok(v);
571    }
572    for c in text_candidates(t) {
573        if let Some(v) = conv.text_to_value(p.id, &c) {
574            if v.is_finite() {
575                return Ok(v.clamp(lo, hi));
576            }
577        }
578    }
579    if let Ok(v) = t.parse::<f64>() {
580        if in_range(v) {
581            return Ok(v.clamp(lo, hi));
582        }
583    }
584    if p.stepped && hi - lo <= 512.0 {
585        let mut v = lo.ceil();
586        while v <= hi {
587            if let Some(label) = conv.value_to_text(p.id, v) {
588                if label.trim().eq_ignore_ascii_case(t) {
589                    return Ok(v);
590                }
591            }
592            v += 1.0;
593        }
594    }
595    let example = if p.default_text.is_empty() { fmt_num(p.default) } else { p.default_text.clone() };
596    Err(format!(
597        "cannot convert '{t}' for parameter '{}' (range {}..{}, e.g. '{example}' or norm:0.5)",
598        p.name,
599        fmt_num(p.min),
600        fmt_num(p.max)
601    ))
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    #[test]
609    fn frames_roundtrip() {
610        let mut buf = Vec::new();
611        write_frame(&mut buf, &Request::Hello).unwrap();
612        write_frame(
613            &mut buf,
614            &Request::Process {
615                slot: 2,
616                frames: 64,
617                events: vec![Event::NoteOn { time: 3, channel: 0, key: 60, velocity: 0.8 }],
618            },
619        )
620        .unwrap();
621        let mut r = &buf[..];
622        let a: Request = serde_json::from_slice(&read_frame(&mut r).unwrap().unwrap()).unwrap();
623        let b: Request = serde_json::from_slice(&read_frame(&mut r).unwrap().unwrap()).unwrap();
624        assert_eq!(a, Request::Hello);
625        assert!(matches!(b, Request::Process { slot: 2, frames: 64, .. }));
626        assert!(read_frame(&mut r).unwrap().is_none());
627    }
628
629    #[test]
630    fn candidates_cover_units() {
631        let c = text_candidates("2.5kHz");
632        assert!(c.contains(&"2500 Hz".to_string()) && c.contains(&"2500".to_string()));
633        assert!(text_candidates("30%").contains(&"0.3".to_string()));
634        assert!(text_candidates("-6dB").contains(&"-6".to_string()));
635        assert_eq!(infer_unit("-6.00 dB").as_deref(), Some("dB"));
636        assert_eq!(infer_unit("440 Hz").as_deref(), Some("Hz"));
637        assert_eq!(infer_unit("0.500"), None);
638    }
639
640    #[test]
641    fn shm_layout_offsets() {
642        let l = ShmLayout { channels_in: 2, channels_out: 2, block_size: 512, slots: 4 };
643        let mut b = vec![0u8; l.total_bytes()];
644        l.write_header(&mut b);
645        assert_eq!(ShmLayout::read_header(&b).unwrap(), l);
646        assert_eq!(l.input_offset(0, 0), 64);
647        assert_eq!(l.output_offset(0, 0), 64 + 2 * 512 * 4);
648        assert_eq!(l.input_offset(1, 0), 64 + 4 * 512 * 4);
649    }
650
651    struct Db;
652    impl ParamText for Db {
653        fn text_to_value(&mut self, _id: u32, t: &str) -> Option<f64> {
654            t.trim().parse().ok()
655        }
656        fn value_to_text(&mut self, _id: u32, v: f64) -> Option<String> {
657            Some(format!("{v:.2} dB"))
658        }
659    }
660
661    #[test]
662    fn resolves_values() {
663        let p = ParamInfo { id: 3, name: "gain".into(), min: -60.0, max: 24.0, ..Default::default() };
664        assert_eq!(resolve_value(&p, "-6dB", &mut Db).unwrap(), -6.0);
665        assert_eq!(resolve_value(&p, "norm:0.5", &mut Db).unwrap(), -18.0);
666        assert!(resolve_value(&p, "loud", &mut Db).is_err());
667        let ps = vec![p.clone()];
668        assert_eq!(find_param(&ps, "Gain").unwrap().id, 3);
669        assert_eq!(find_param(&ps, "id:3").unwrap().id, 3);
670        assert!(find_param(&ps, "cutoff").is_err());
671    }
672}