Skip to main content

quiver/
port.rs

1//! Layer 2: Signal Conventions and Port System
2//!
3//! This module defines the signal types, port definitions, and type-erased interfaces
4//! that bridge the typed combinator layer with the graph-based patching system.
5
6use crate::StdMap;
7use alloc::string::String;
8#[cfg(feature = "wasm")]
9use alloc::string::ToString;
10use alloc::vec;
11use alloc::vec::Vec;
12use libm::Libm;
13use serde::{Deserialize, Serialize};
14
15/// Unique identifier for a port within a module
16pub type PortId = u32;
17
18/// Unique identifier for a parameter within a module
19pub type ParamId = u32;
20
21/// Semantic signal classification following hardware modular conventions
22///
23/// Serialized in `snake_case` (e.g. `"cv_bipolar"`, `"volt_per_octave"`) to match
24/// the JSON schema (`schemas/patch.schema.json`) and all TypeScript consumers.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
27#[serde(rename_all = "snake_case")]
28pub enum SignalKind {
29    /// Audio signal, AC-coupled, typically ±5V peak
30    Audio,
31
32    /// Bipolar control voltage, ±5V (LFO, pitch bend, modulation)
33    CvBipolar,
34
35    /// Unipolar control voltage, 0–10V (envelope, velocity, expression)
36    CvUnipolar,
37
38    /// Pitch CV following 1V/octave standard
39    /// Reference: 0V = C4 (middle C, 261.63 Hz)
40    VoltPerOctave,
41
42    /// Gate signal, binary state: 0V (low) or +5V (high)
43    /// Remains high while note/event is active
44    Gate,
45
46    /// Trigger signal, short pulse (~1–10ms) at +5V
47    /// Used for instantaneous events
48    Trigger,
49
50    /// Clock signal, regular trigger pulses at tempo
51    Clock,
52}
53
54impl SignalKind {
55    /// Returns the typical voltage range (min, max) for this signal type
56    pub fn voltage_range(&self) -> (f64, f64) {
57        match self {
58            SignalKind::Audio => (-5.0, 5.0),
59            SignalKind::CvBipolar => (-5.0, 5.0),
60            SignalKind::CvUnipolar => (0.0, 10.0),
61            SignalKind::VoltPerOctave => (-5.0, 5.0), // ~C-1 to C9
62            SignalKind::Gate => (0.0, 5.0),
63            SignalKind::Trigger => (0.0, 5.0),
64            SignalKind::Clock => (0.0, 5.0),
65        }
66    }
67
68    /// Whether multiple signals of this kind should be summed when connected
69    pub fn is_summable(&self) -> bool {
70        matches!(
71            self,
72            SignalKind::Audio
73                | SignalKind::CvBipolar
74                | SignalKind::CvUnipolar
75                | SignalKind::VoltPerOctave
76        )
77    }
78
79    /// Threshold voltage for high/low detection
80    pub fn gate_threshold(&self) -> Option<f64> {
81        match self {
82            SignalKind::Gate | SignalKind::Trigger | SignalKind::Clock => Some(2.5),
83            _ => None,
84        }
85    }
86}
87
88// =============================================================================
89// GUI Signal Semantics (Phase 2)
90// =============================================================================
91
92/// CSS hex color values for each signal type (for cable coloring in UI)
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
95#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
96pub struct SignalColors {
97    /// Audio signal color (default: red #e94560)
98    pub audio: String,
99    /// Bipolar CV color (default: dark blue #0f3460)
100    pub cv_bipolar: String,
101    /// Unipolar CV color (default: cyan #00b4d8)
102    pub cv_unipolar: String,
103    /// V/Oct pitch CV color (default: green #90be6d)
104    pub volt_per_octave: String,
105    /// Gate signal color (default: yellow #f9c74f)
106    pub gate: String,
107    /// Trigger signal color (default: orange #f8961e)
108    pub trigger: String,
109    /// Clock signal color (default: purple #9d4edd)
110    pub clock: String,
111}
112
113impl Default for SignalColors {
114    fn default() -> Self {
115        Self {
116            audio: "#e94560".into(),
117            cv_bipolar: "#0f3460".into(),
118            cv_unipolar: "#00b4d8".into(),
119            volt_per_octave: "#90be6d".into(),
120            gate: "#f9c74f".into(),
121            trigger: "#f8961e".into(),
122            clock: "#9d4edd".into(),
123        }
124    }
125}
126
127impl SignalColors {
128    /// Get the color for a specific signal kind
129    pub fn get(&self, kind: SignalKind) -> &str {
130        match kind {
131            SignalKind::Audio => &self.audio,
132            SignalKind::CvBipolar => &self.cv_bipolar,
133            SignalKind::CvUnipolar => &self.cv_unipolar,
134            SignalKind::VoltPerOctave => &self.volt_per_octave,
135            SignalKind::Gate => &self.gate,
136            SignalKind::Trigger => &self.trigger,
137            SignalKind::Clock => &self.clock,
138        }
139    }
140}
141
142/// Enhanced port information for GUI display
143#[derive(Debug, Clone, Serialize, Deserialize)]
144#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
145#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
146pub struct PortInfo {
147    /// Unique identifier within the module
148    pub id: u32,
149    /// Human-readable name
150    pub name: String,
151    /// Signal type
152    pub kind: SignalKind,
153    /// Port this is normalled to (by name, for UI display)
154    pub normalled_to: Option<String>,
155    /// Optional description for tooltips
156    pub description: Option<String>,
157}
158
159impl PortInfo {
160    /// Create a new PortInfo
161    pub fn new(id: u32, name: impl Into<String>, kind: SignalKind) -> Self {
162        Self {
163            id,
164            name: name.into(),
165            kind,
166            normalled_to: None,
167            description: None,
168        }
169    }
170
171    /// Set the normalled connection
172    pub fn with_normalled_to(mut self, port_name: impl Into<String>) -> Self {
173        self.normalled_to = Some(port_name.into());
174        self
175    }
176
177    /// Set the description
178    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
179        self.description = Some(desc.into());
180        self
181    }
182}
183
184impl From<&PortDef> for PortInfo {
185    fn from(def: &PortDef) -> Self {
186        Self {
187            id: def.id,
188            name: def.name.clone(),
189            kind: def.kind,
190            normalled_to: None, // PortDef uses PortId, PortInfo uses name string
191            description: None,
192        }
193    }
194}
195
196/// Compatibility status for port connections
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
198#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
199#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
200#[serde(rename_all = "snake_case", tag = "status")]
201pub enum Compatibility {
202    /// Exact signal type match
203    Exact,
204    /// Compatible connection (different but valid)
205    Allowed,
206    /// Connection works but may have issues
207    Warning { message: String },
208}
209
210/// Check if two signal kinds are compatible for connection
211///
212/// Returns the compatibility status indicating whether the connection is:
213/// - Exact: Same signal types
214/// - Allowed: Different but compatible types
215/// - Warning: Works but may cause issues (e.g., clicks, tuning problems)
216///
217/// # Single source of truth
218///
219/// This function is a thin adapter over the authoritative
220/// [`SignalKind::is_compatible_with`] implementation used by
221/// the patch graph's validation. Both APIs therefore always agree: a warning from one
222/// is a [`Compatibility::Warning`] from the other, and a clean verdict maps to
223/// [`Compatibility::Allowed`] (or [`Compatibility::Exact`] for identical kinds). Keep
224/// the compatibility rules in `is_compatible_with` only; do not fork them here.
225pub fn ports_compatible(from: SignalKind, to: SignalKind) -> Compatibility {
226    if from == to {
227        return Compatibility::Exact;
228    }
229
230    // Delegate to the authoritative compatibility check (defined in `graph`).
231    match from.is_compatible_with(&to).warning {
232        None => Compatibility::Allowed,
233        Some(message) => Compatibility::Warning { message },
234    }
235}
236
237/// Definition of a single port (input or output)
238#[derive(Debug, Clone, Serialize, Deserialize)]
239#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
240pub struct PortDef {
241    /// Unique identifier within the module
242    pub id: PortId,
243
244    /// Human-readable name (e.g., "cutoff", "voct", "out")
245    pub name: String,
246
247    /// Signal type for validation and UI hints
248    pub kind: SignalKind,
249
250    /// Default value when no cable connected
251    pub default: f64,
252
253    /// For inputs: internal source when unpatched (normalled connection)
254    pub normalled_to: Option<PortId>,
255
256    /// Whether this input has an associated attenuverter control
257    pub has_attenuverter: bool,
258}
259
260impl PortDef {
261    pub fn new(id: PortId, name: impl Into<String>, kind: SignalKind) -> Self {
262        Self {
263            id,
264            name: name.into(),
265            kind,
266            default: 0.0,
267            normalled_to: None,
268            has_attenuverter: false,
269        }
270    }
271
272    pub fn with_default(mut self, default: f64) -> Self {
273        self.default = default;
274        self
275    }
276
277    pub fn with_attenuverter(mut self) -> Self {
278        self.has_attenuverter = true;
279        self
280    }
281
282    pub fn normalled_to(mut self, port: PortId) -> Self {
283        self.normalled_to = Some(port);
284        self
285    }
286}
287
288/// Specification of all ports for a module
289#[derive(Debug, Clone, Default, Serialize, Deserialize)]
290#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
291#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
292pub struct PortSpec {
293    pub inputs: Vec<PortDef>,
294    pub outputs: Vec<PortDef>,
295}
296
297impl PortSpec {
298    pub fn new() -> Self {
299        Self::default()
300    }
301
302    pub fn input_by_name(&self, name: &str) -> Option<&PortDef> {
303        self.inputs.iter().find(|p| p.name == name)
304    }
305
306    pub fn output_by_name(&self, name: &str) -> Option<&PortDef> {
307        self.outputs.iter().find(|p| p.name == name)
308    }
309
310    pub fn input_by_id(&self, id: PortId) -> Option<&PortDef> {
311        self.inputs.iter().find(|p| p.id == id)
312    }
313
314    pub fn output_by_id(&self, id: PortId) -> Option<&PortDef> {
315        self.outputs.iter().find(|p| p.id == id)
316    }
317}
318
319/// Runtime port values container.
320///
321/// A small dense map from [`PortId`] to `f64`, laid out as two parallel vectors: `ids` in
322/// first-write order, and `values` where `None` means "not written since the last
323/// [`clear`](Self::clear)". Lookups are a linear scan of `ids`, which for the ≤ 8 ports a
324/// module declares is a handful of `u32` comparisons out of a single cache line — far
325/// cheaper than hashing a key, and with no per-key hashing on the audio path at all.
326///
327/// Two properties the graph engine relies on:
328///
329/// - **Slot order is stable.** [`clear`](Self::clear) blanks the values but *keeps* the id
330///   layout, so once a container has been warmed with a module's [`PortSpec`] order, slot
331///   `k` belongs to spec port `k` for the rest of its life. That is what lets
332///   `NodeExec::scatter` read outputs by index instead of by lookup.
333/// - **Unwritten is absent.** A port that a module never set reads back as `None` from
334///   [`get`](Self::get) and `false` from [`has`](Self::has), exactly as when this was a
335///   `HashMap` that simply had no such key.
336#[derive(Debug, Clone, Default)]
337pub struct PortValues {
338    /// Port ids in first-write order; slot `k` of `values` belongs to `ids[k]`.
339    ids: Vec<PortId>,
340    /// Value per slot, `None` when unwritten since the last [`clear`](PortValues::clear).
341    values: Vec<Option<f64>>,
342}
343
344impl PortValues {
345    pub fn new() -> Self {
346        Self::default()
347    }
348
349    /// Dense slot holding `id`, whether or not it currently holds a value.
350    #[inline]
351    fn slot_of(&self, id: PortId) -> Option<usize> {
352        self.ids.iter().position(|&candidate| candidate == id)
353    }
354
355    #[inline]
356    pub fn get(&self, id: PortId) -> Option<f64> {
357        self.slot_of(id).and_then(|k| self.values[k])
358    }
359
360    #[inline]
361    pub fn get_or(&self, id: PortId, default: f64) -> f64 {
362        self.get(id).unwrap_or(default)
363    }
364
365    #[inline]
366    pub fn set(&mut self, id: PortId, value: f64) {
367        match self.slot_of(id) {
368            Some(k) => self.values[k] = Some(value),
369            None => {
370                self.ids.push(id);
371                self.values.push(Some(value));
372            }
373        }
374    }
375
376    /// Accumulate (sum) a value into a port (for input mixing)
377    #[inline]
378    pub fn accumulate(&mut self, id: PortId, value: f64) {
379        // An absent port accumulates onto a fresh `0.0`, not onto `value` itself — which
380        // matters for signed zero: `0.0 + -0.0` is `+0.0`.
381        match self.slot_of(id) {
382            Some(k) => self.values[k] = Some(self.values[k].unwrap_or(0.0) + value),
383            None => {
384                self.ids.push(id);
385                self.values.push(Some(0.0 + value));
386            }
387        }
388    }
389
390    #[inline]
391    pub fn has(&self, id: PortId) -> bool {
392        self.get(id).is_some()
393    }
394
395    /// Forget every value, keeping the id layout (and its allocation) intact.
396    #[inline]
397    pub fn clear(&mut self) {
398        self.values.fill(None);
399    }
400
401    /// Value at dense slot `slot`, which is expected to hold `id`.
402    ///
403    /// The fast path for callers that know the layout — the graph's scatter, whose scratch
404    /// buffers were warmed in [`PortSpec`] output order at compile time — turning a lookup
405    /// into an indexed read. Falls back to [`get`](Self::get) whenever the slot does not
406    /// hold the expected id, so the result is always identical to `get(id)`.
407    #[inline]
408    pub(crate) fn get_at(&self, slot: usize, id: PortId) -> Option<f64> {
409        match self.ids.get(slot) {
410            Some(&found) if found == id => self.values[slot],
411            _ => self.get(id),
412        }
413    }
414
415    /// Iterate the ports that currently hold a value, in slot order.
416    ///
417    /// This is the public read path that replaces the `pub values` field removed in 0.2.0
418    /// (see the crate changelog). Unlike iterating that `HashMap` it is **deterministic**:
419    /// ports come back in first-write order — for a container warmed by a module's
420    /// [`PortSpec`], that is spec order — rather than in hash order.
421    ///
422    /// ```
423    /// use quiver::port::PortValues;
424    ///
425    /// let mut pv = PortValues::new();
426    /// pv.set(7, 1.5);
427    /// pv.set(3, -2.0);
428    /// assert_eq!(pv.iter().collect::<Vec<_>>(), vec![(7, 1.5), (3, -2.0)]);
429    /// ```
430    #[inline]
431    pub fn iter(&self) -> impl Iterator<Item = (PortId, f64)> + '_ {
432        self.ids
433            .iter()
434            .zip(self.values.iter())
435            .filter_map(|(&id, value)| value.map(|v| (id, v)))
436    }
437}
438
439/// Block-oriented port values for efficient processing
440pub struct BlockPortValues {
441    buffers: StdMap<PortId, Vec<f64>>,
442    block_size: usize,
443}
444
445impl BlockPortValues {
446    pub fn new(block_size: usize) -> Self {
447        Self {
448            buffers: StdMap::new(),
449            block_size,
450        }
451    }
452
453    pub fn block_size(&self) -> usize {
454        self.block_size
455    }
456
457    pub fn get_buffer(&self, port: PortId) -> Option<&[f64]> {
458        self.buffers.get(&port).map(|v| v.as_slice())
459    }
460
461    pub fn get_buffer_mut(&mut self, port: PortId) -> &mut Vec<f64> {
462        self.buffers
463            .entry(port)
464            .or_insert_with(|| vec![0.0; self.block_size])
465    }
466
467    pub fn frame(&self, index: usize) -> PortValues {
468        let mut values = PortValues::new();
469        self.frame_into(index, &mut values);
470        values
471    }
472
473    /// Read frame `index` into an existing [`PortValues`], reusing its allocation.
474    ///
475    /// Clears `dst` and refills it from each port buffer at `index`. Unlike [`Self::frame`], this
476    /// performs no allocation once `dst` has been warmed with the same key set, which lets
477    /// block loops (e.g. the default [`GraphModule::process_block`]) avoid a fresh
478    /// [`PortValues`] per frame.
479    pub fn frame_into(&self, index: usize, dst: &mut PortValues) {
480        dst.clear();
481        for (&port, buffer) in &self.buffers {
482            if index < buffer.len() {
483                dst.set(port, buffer[index]);
484            }
485        }
486    }
487
488    pub fn set_frame(&mut self, index: usize, values: PortValues) {
489        self.set_frame_ref(index, &values);
490    }
491
492    /// Write a borrowed [`PortValues`] into frame `index`, without taking ownership.
493    ///
494    /// The by-reference companion to [`Self::set_frame`], so a caller can reuse a single output
495    /// [`PortValues`] across every frame of a block instead of moving (and reallocating) one
496    /// per frame.
497    pub fn set_frame_ref(&mut self, index: usize, values: &PortValues) {
498        for (port, value) in values.iter() {
499            let buffer = self.get_buffer_mut(port);
500            if index < buffer.len() {
501                buffer[index] = value;
502            }
503        }
504    }
505
506    pub fn clear(&mut self) {
507        for buffer in self.buffers.values_mut() {
508            buffer.fill(0.0);
509        }
510    }
511}
512
513/// Parameter range mapping for modulated parameters
514#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
515pub enum ParamRange {
516    /// Linear mapping from normalized (0–1) to (min, max)
517    Linear { min: f64, max: f64 },
518
519    /// Exponential mapping, useful for frequency/time controls
520    Exponential { min: f64, max: f64 },
521
522    /// V/Oct: input is in volts, output is frequency multiplier
523    VoltPerOctave { base_freq: f64 },
524}
525
526impl ParamRange {
527    pub fn apply(&self, normalized: f64) -> f64 {
528        match self {
529            ParamRange::Linear { min, max } => min + normalized.clamp(0.0, 1.0) * (max - min),
530            ParamRange::Exponential { min, max } => {
531                let clamped = normalized.clamp(0.0, 1.0);
532                // Exponential interpolation `min * (max/min)^t` is only defined for a
533                // strictly positive domain (0 < min, 0 < max). If either bound is
534                // non-positive, `max/min` can be negative and `pow(neg, frac)` yields
535                // NaN, so fall back to a plain linear interpolation which is always
536                // finite. This guards callers that construct e.g. Exponential{min:20,
537                // max:-1} from silently poisoning frequency/time controls with NaN.
538                if *min > 0.0 && *max > 0.0 {
539                    min * Libm::<f64>::pow(max / min, clamped)
540                } else {
541                    min + clamped * (max - min)
542                }
543            }
544            ParamRange::VoltPerOctave { base_freq } => {
545                base_freq * Libm::<f64>::pow(2.0, normalized)
546            }
547        }
548    }
549}
550
551/// A parameter that combines a base value (knob) with CV modulation
552#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct ModulatedParam {
554    /// Base value from panel knob (typically 0.0–1.0 normalized)
555    pub base: f64,
556
557    /// Incoming CV **voltage** (set during tick).
558    ///
559    /// Interpreted on the bipolar ±5 V scale: [`value`](Self::value) normalizes it by
560    /// [`CV_FULL_SCALE_VOLTS`](Self::CV_FULL_SCALE_VOLTS) so that a full +5 V of CV
561    /// (with attenuverter at +1.0) contributes +1.0 to the normalized parameter.
562    pub cv: f64,
563
564    /// Attenuverter setting (-1.0 to 1.0)
565    /// Positive: CV adds to base
566    /// Negative: CV subtracts from base (inverted)
567    pub attenuverter: f64,
568
569    /// Output range mapping
570    pub range: ParamRange,
571}
572
573impl ModulatedParam {
574    /// Full-scale CV voltage used to normalize [`cv`](Self::cv) into the 0–1 base domain.
575    ///
576    /// Bipolar CV spans ±5 V, so dividing by 5 V maps a full-swing CV signal onto the
577    /// same normalized 0–1 range as `base` before the two are combined.
578    pub const CV_FULL_SCALE_VOLTS: f64 = 5.0;
579
580    pub fn new(range: ParamRange) -> Self {
581        Self {
582            base: 0.5,
583            cv: 0.0,
584            attenuverter: 1.0,
585            range,
586        }
587    }
588
589    pub fn with_base(mut self, base: f64) -> Self {
590        self.base = base;
591        self
592    }
593
594    /// Compute the effective parameter value.
595    ///
596    /// `base` is a normalized 0–1 knob position; `cv` is a voltage that is normalized by
597    /// [`CV_FULL_SCALE_VOLTS`](Self::CV_FULL_SCALE_VOLTS) before being scaled by the
598    /// attenuverter (±1.0) and summed with `base`. This keeps CV modulation proportional:
599    /// a full +5 V of CV shifts the normalized value by at most ±1.0 rather than slamming
600    /// the parameter to its rail. The combined value is then mapped through `range`.
601    pub fn value(&self) -> f64 {
602        let modulated = self.base + (self.cv / Self::CV_FULL_SCALE_VOLTS) * self.attenuverter;
603        self.range.apply(modulated)
604    }
605
606    /// Update CV from port value
607    pub fn set_cv(&mut self, cv: f64) {
608        self.cv = cv;
609    }
610}
611
612/// Parameter definition for UI binding
613#[derive(Debug, Clone, Serialize, Deserialize)]
614pub struct ParamDef {
615    pub id: ParamId,
616    pub name: String,
617    pub default: f64,
618    pub range: ParamRange,
619}
620
621/// Type-erased module interface for graph-based patching
622pub trait GraphModule: Send + Sync {
623    /// Returns the module's port specification
624    fn port_spec(&self) -> &PortSpec;
625
626    /// Process one sample given port values
627    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues);
628
629    /// Process one sample, told which outputs anyone will actually read.
630    ///
631    /// Bit `k` of `wanted` corresponds to output port `k` in [`port_spec`](Self::port_spec)
632    /// order; a clear bit means nothing in the patch consumes that port this compile, so the
633    /// module may skip producing it. Only [`Patch`](crate::graph::Patch) can know this —
634    /// it is the only thing that sees the cables — so it calls this instead of
635    /// [`tick`](Self::tick) once its routing plan is built. Anything with more than 32
636    /// outputs is handed an all-ones mask.
637    ///
638    /// The default implementation ignores the mask and delegates to
639    /// [`tick`](Self::tick), so implementing it is purely optional and adding it broke
640    /// nothing. A module that *does* implement it must honour two rules:
641    ///
642    /// 1. **The wanted ports are bit-identical to what `tick` would have written.** Masking
643    ///    is a permission to skip work, never a licence to compute it differently.
644    /// 2. **Retained state evolves identically regardless of the mask.** If producing an
645    ///    output has a side effect on state — advancing a phase accumulator, drawing from
646    ///    the global RNG, stepping a filter — that work must still happen; only the final
647    ///    write (and any pure arithmetic feeding just it) may be skipped. Otherwise the
648    ///    *wanted* outputs would drift as soon as an unwanted one is unpatched.
649    fn tick_masked(&mut self, inputs: &PortValues, outputs: &mut PortValues, wanted: u32) {
650        let _ = wanted;
651        self.tick(inputs, outputs);
652    }
653
654    /// Process a block of samples (optional optimization).
655    ///
656    /// The default drives [`tick`](Self::tick) frame-by-frame. It reuses a single input and
657    /// output [`PortValues`] across the whole block (via
658    /// [`BlockPortValues::frame_into`]/[`set_frame_ref`](BlockPortValues::set_frame_ref)), so
659    /// it does **not** allocate per frame — only once per call to warm the reused buffers.
660    ///
661    /// For the graph engine, prefer [`Patch::tick_block`](crate::graph::Patch::tick_block),
662    /// which is fully allocation-free after compile.
663    fn process_block(
664        &mut self,
665        inputs: &BlockPortValues,
666        outputs: &mut BlockPortValues,
667        frames: usize,
668    ) {
669        let mut in_frame = PortValues::new();
670        let mut out_frame = PortValues::new();
671        for i in 0..frames {
672            inputs.frame_into(i, &mut in_frame);
673            out_frame.clear();
674            self.tick(&in_frame, &mut out_frame);
675            outputs.set_frame_ref(i, &out_frame);
676        }
677    }
678
679    /// Reset internal state
680    fn reset(&mut self);
681
682    /// Set sample rate
683    fn set_sample_rate(&mut self, sample_rate: f64);
684
685    /// Whether this module breaks a feedback cycle in the patch graph.
686    ///
687    /// The graph normally rejects any cable cycle with [`PatchError::CycleDetected`].
688    /// A module that returns `true` (delay-style modules such as `UnitDelay` and
689    /// `DelayLine`) is treated as a one-sample delay boundary: [`Patch::compile`] excludes
690    /// the edges feeding *into* it from the topological sort, so a loop routed through it
691    /// compiles. At runtime such a module reads its inputs from the previous tick's output
692    /// buffers, giving the classic single-sample feedback delay. Cycles that contain no
693    /// cycle-breaker still fail to compile.
694    ///
695    /// [`PatchError::CycleDetected`]: crate::graph::PatchError::CycleDetected
696    /// [`Patch::compile`]: crate::graph::Patch::compile
697    fn breaks_feedback_cycle(&self) -> bool {
698        false
699    }
700
701    /// Get parameter definitions for UI binding.
702    ///
703    /// **Most built-in modules do not use this API.** Nearly all of them expose their
704    /// controllable quantities as **input ports** (see [`port_spec`](Self::port_spec)) —
705    /// e.g. a VCO's frequency, an SVF's cutoff, or an ADSR's stage times are all input
706    /// ports driven by cables or their `default` values — and leave this method at its
707    /// empty default. The authoritative way to discover and drive parameters for GUIs is
708    /// the `ModuleIntrospection` API (available with the `alloc` feature), not this
709    /// trait-default no-op. It remains here only for the handful of modules whose
710    /// parameters are genuinely not ports.
711    fn params(&self) -> &[ParamDef] {
712        &[]
713    }
714
715    /// Get a parameter value.
716    ///
717    /// Defaults to `None`. See [`params`](Self::params): most modules surface their state
718    /// through input ports and `ModuleIntrospection`, not through this method.
719    fn get_param(&self, _id: ParamId) -> Option<f64> {
720        None
721    }
722
723    /// Set a parameter value.
724    ///
725    /// Defaults to a no-op. See [`params`](Self::params): most modules surface their state
726    /// through input ports and `ModuleIntrospection`, not through this method.
727    fn set_param(&mut self, _id: ParamId, _value: f64) {}
728
729    /// Get module type identifier for serialization
730    fn type_id(&self) -> &'static str {
731        "unknown"
732    }
733
734    /// Serialize module state (alloc feature only)
735    #[cfg(feature = "alloc")]
736    fn serialize_state(&self) -> Option<serde_json::Value> {
737        None
738    }
739
740    /// Deserialize module state (alloc feature only)
741    #[cfg(feature = "alloc")]
742    fn deserialize_state(
743        &mut self,
744        _state: &serde_json::Value,
745    ) -> Result<(), alloc::string::String> {
746        Ok(())
747    }
748
749    /// Downcast this module to its [`ModuleIntrospection`](crate::introspection::ModuleIntrospection) view, if it exposes one.
750    ///
751    /// A `Box<dyn GraphModule>` (as stored inside a [`Patch`](crate::graph::Patch)) cannot
752    /// otherwise reach the module's `ModuleIntrospection` impl, so this hook bridges the two
753    /// trait objects. It returns `None` by default; modules with genuine internal (non-port)
754    /// parameters override it — typically via [`impl_introspect!`](crate::impl_introspect) —
755    /// to return `Some(self)`. Parameters that are input ports are discovered and driven
756    /// through the port system instead (see [`Patch::param_infos`](crate::graph::Patch::param_infos)),
757    /// so most modules leave this at the default.
758    ///
759    /// Gated on `alloc` because `ModuleIntrospection` (and its `Vec`/`String` payloads) live
760    /// in the alloc tier; pure `no_std` builds never see this method.
761    #[cfg(feature = "alloc")]
762    fn introspect(&self) -> Option<&dyn crate::introspection::ModuleIntrospection> {
763        None
764    }
765
766    /// Mutable companion to [`introspect`](Self::introspect), used to set internal parameters.
767    #[cfg(feature = "alloc")]
768    fn introspect_mut(&mut self) -> Option<&mut dyn crate::introspection::ModuleIntrospection> {
769        None
770    }
771}
772
773/// Wire a module's [`ModuleIntrospection`](crate::introspection::ModuleIntrospection) impl into the [`GraphModule`] trait object.
774///
775/// Invoke once inside a module's `impl GraphModule for T { .. }` block. It expands to the
776/// `introspect`/`introspect_mut` overrides (both `alloc`-gated) returning `Some(self)`, so a
777/// live [`Patch`](crate::graph::Patch) can reach the module's parameter metadata through its
778/// boxed trait object. Requires `T: ModuleIntrospection` (satisfied under `alloc`).
779#[macro_export]
780macro_rules! impl_introspect {
781    () => {
782        #[cfg(feature = "alloc")]
783        fn introspect(&self) -> Option<&dyn $crate::introspection::ModuleIntrospection> {
784            Some(self)
785        }
786        #[cfg(feature = "alloc")]
787        fn introspect_mut(
788            &mut self,
789        ) -> Option<&mut dyn $crate::introspection::ModuleIntrospection> {
790            Some(self)
791        }
792    };
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798
799    #[test]
800    fn test_signal_kind_ranges() {
801        assert_eq!(SignalKind::Audio.voltage_range(), (-5.0, 5.0));
802        assert_eq!(SignalKind::Gate.voltage_range(), (0.0, 5.0));
803        assert_eq!(SignalKind::CvUnipolar.voltage_range(), (0.0, 10.0));
804    }
805
806    #[test]
807    fn test_signal_kind_summable() {
808        assert!(SignalKind::Audio.is_summable());
809        assert!(SignalKind::CvBipolar.is_summable());
810        assert!(!SignalKind::Gate.is_summable());
811        assert!(!SignalKind::Trigger.is_summable());
812    }
813
814    #[test]
815    fn test_port_values() {
816        let mut pv = PortValues::new();
817        pv.set(0, 1.0);
818        pv.set(1, 2.0);
819        assert_eq!(pv.get(0), Some(1.0));
820        assert_eq!(pv.get(1), Some(2.0));
821        assert_eq!(pv.get(2), None);
822        assert_eq!(pv.get_or(2, 5.0), 5.0);
823
824        pv.accumulate(0, 0.5);
825        assert_eq!(pv.get(0), Some(1.5));
826    }
827
828    /// `clear` must forget values without forgetting the slot layout, and an unwritten port
829    /// must read back as absent — the two properties the graph engine's scratch buffers and
830    /// "unwritten output keeps its previous routing value" rule are built on.
831    #[test]
832    fn test_port_values_clear_keeps_layout_and_absence() {
833        let mut pv = PortValues::new();
834        pv.set(7, 1.0);
835        pv.set(3, 2.0);
836
837        pv.clear();
838        assert!(!pv.has(7));
839        assert!(!pv.has(3));
840        assert_eq!(pv.get(7), None);
841        assert_eq!(pv.get_or(3, -1.0), -1.0);
842        assert_eq!(pv.iter().count(), 0);
843
844        // Rewriting one port leaves the other absent, and slot order is unchanged.
845        pv.set(3, 4.0);
846        assert_eq!(pv.get_at(1, 3), Some(4.0));
847        assert_eq!(pv.get(7), None);
848        assert_eq!(pv.iter().collect::<Vec<_>>(), vec![(3, 4.0)]);
849    }
850
851    /// `get_at` is a hint, never a source of truth: a wrong slot still resolves through the
852    /// normal lookup, so it can never disagree with `get`.
853    #[test]
854    fn test_port_values_get_at_falls_back_to_lookup() {
855        let mut pv = PortValues::new();
856        pv.set(10, 1.0);
857        pv.set(11, 2.0);
858
859        assert_eq!(pv.get_at(0, 10), Some(1.0));
860        // Mismatched slot, out-of-range slot, and unknown id all agree with `get`.
861        assert_eq!(pv.get_at(1, 10), Some(1.0));
862        assert_eq!(pv.get_at(99, 11), Some(2.0));
863        assert_eq!(pv.get_at(0, 12), None);
864    }
865
866    /// Accumulating onto an absent port starts from `+0.0`, so a `-0.0` contribution does
867    /// not leave a negative zero behind (matching the previous entry-API implementation).
868    #[test]
869    fn test_port_values_accumulate_from_absent_normalizes_signed_zero() {
870        let mut pv = PortValues::new();
871        pv.accumulate(0, -0.0);
872        assert_eq!(pv.get(0).map(f64::to_bits), Some(0.0f64.to_bits()));
873    }
874
875    #[test]
876    fn test_param_range_linear() {
877        let range = ParamRange::Linear {
878            min: 0.0,
879            max: 100.0,
880        };
881        assert!((range.apply(0.0) - 0.0).abs() < 1e-10);
882        assert!((range.apply(0.5) - 50.0).abs() < 1e-10);
883        assert!((range.apply(1.0) - 100.0).abs() < 1e-10);
884    }
885
886    #[test]
887    fn test_param_range_exponential() {
888        let range = ParamRange::Exponential {
889            min: 20.0,
890            max: 20000.0,
891        };
892        assert!((range.apply(0.0) - 20.0).abs() < 1e-10);
893        assert!((range.apply(1.0) - 20000.0).abs() < 1e-10);
894    }
895
896    #[test]
897    fn test_param_range_voct() {
898        let range = ParamRange::VoltPerOctave { base_freq: 261.63 };
899        // 0V = C4 = 261.63 Hz
900        assert!((range.apply(0.0) - 261.63).abs() < 0.01);
901        // +1V = C5 = 523.26 Hz
902        assert!((range.apply(1.0) - 523.26).abs() < 0.01);
903    }
904
905    #[test]
906    fn test_modulated_param() {
907        let mut param = ModulatedParam::new(ParamRange::Linear {
908            min: 0.0,
909            max: 100.0,
910        })
911        .with_base(0.5);
912
913        // No CV: should return base * range
914        assert!((param.value() - 50.0).abs() < 1e-10);
915
916        // Realistic CV is a *voltage*, normalized by 5 V full scale.
917        // +1 V of CV shifts the normalized value by 1/5 = 0.2 -> 0.7 -> 70.
918        param.set_cv(1.0);
919        assert!((param.value() - 70.0).abs() < 1e-10);
920
921        // Invert attenuverter: +1 V CV now subtracts -> 0.3 -> 30.
922        param.attenuverter = -1.0;
923        assert!((param.value() - 30.0).abs() < 1e-10);
924    }
925
926    #[test]
927    fn test_modulated_param_full_scale_cv_is_proportional() {
928        // Q081 regression: a full ±5 V CV must map to a full ±1.0 normalized swing,
929        // not slam the parameter to a rail from any modest voltage.
930        let mut param = ModulatedParam::new(ParamRange::Linear {
931            min: 0.0,
932            max: 100.0,
933        })
934        .with_base(0.5);
935
936        // A modest +1 V of CV should move the param proportionally to
937        // 0.5 + (1/5)*1 = 0.7 -> 70, NOT slam to the maximum (the pre-fix behavior added
938        // the raw voltage: 0.5 + 1.0 = 1.5 -> clamped to 100).
939        param.set_cv(1.0);
940        assert!(
941            (param.value() - 70.0).abs() < 1e-10,
942            "1 V CV should be proportional, got {}",
943            param.value()
944        );
945
946        // Full +5 V reaches exactly the top of the range.
947        param.set_cv(5.0);
948        assert!((param.value() - 100.0).abs() < 1e-10);
949
950        // Full -5 V reaches the bottom.
951        param.set_cv(-5.0);
952        assert!((param.value() - 0.0).abs() < 1e-10);
953    }
954
955    #[test]
956    fn test_signal_kind_gate_threshold() {
957        assert!(SignalKind::Gate.gate_threshold().is_some());
958        assert!(SignalKind::Trigger.gate_threshold().is_some());
959        assert!(SignalKind::Audio.gate_threshold().is_none());
960    }
961
962    #[test]
963    fn test_port_def_with_default_and_attenuverter() {
964        let port = PortDef::new(0, "test", SignalKind::CvUnipolar)
965            .with_default(5.0)
966            .with_attenuverter();
967
968        assert!((port.default - 5.0).abs() < 0.001);
969        assert!(port.has_attenuverter);
970    }
971
972    #[test]
973    fn test_port_def_normalled_to() {
974        let port = PortDef::new(0, "test", SignalKind::CvUnipolar).normalled_to(1);
975        assert_eq!(port.normalled_to, Some(1));
976    }
977
978    #[test]
979    fn test_port_spec_lookup() {
980        let spec = PortSpec {
981            inputs: vec![
982                PortDef::new(0, "in1", SignalKind::Audio),
983                PortDef::new(1, "in2", SignalKind::CvBipolar),
984            ],
985            outputs: vec![
986                PortDef::new(10, "out1", SignalKind::Audio),
987                PortDef::new(11, "out2", SignalKind::Gate),
988            ],
989        };
990
991        assert!(spec.input_by_name("in1").is_some());
992        assert!(spec.input_by_name("nonexistent").is_none());
993        assert!(spec.output_by_name("out1").is_some());
994        assert!(spec.output_by_name("nonexistent").is_none());
995
996        assert!(spec.input_by_id(0).is_some());
997        assert!(spec.input_by_id(99).is_none());
998        assert!(spec.output_by_id(10).is_some());
999        assert!(spec.output_by_id(99).is_none());
1000    }
1001
1002    #[test]
1003    fn test_port_values_has() {
1004        let mut pv = PortValues::new();
1005        assert!(!pv.has(0));
1006        pv.set(0, 1.0);
1007        assert!(pv.has(0));
1008    }
1009
1010    #[test]
1011    fn test_port_values_clear() {
1012        let mut pv = PortValues::new();
1013        pv.set(0, 1.0);
1014        pv.set(1, 2.0);
1015        pv.clear();
1016        assert!(!pv.has(0));
1017        assert!(!pv.has(1));
1018    }
1019
1020    #[test]
1021    fn test_block_port_values() {
1022        let mut bpv = BlockPortValues::new(64);
1023        assert_eq!(bpv.block_size(), 64);
1024
1025        // Get mutable buffer (creates buffer for port 0)
1026        let buf_mut = bpv.get_buffer_mut(0);
1027        assert_eq!(buf_mut.len(), 64);
1028        buf_mut[0] = 1.0;
1029
1030        // Now we can read it
1031        assert_eq!(bpv.get_buffer(0).unwrap()[0], 1.0);
1032
1033        // Frame operations
1034        let mut frame_vals = PortValues::new();
1035        frame_vals.set(0, 99.0);
1036        bpv.set_frame(1, frame_vals);
1037
1038        // Clear
1039        bpv.clear();
1040    }
1041
1042    #[test]
1043    fn test_signal_kind_clock() {
1044        let range = SignalKind::Clock.voltage_range();
1045        assert_eq!(range, (0.0, 5.0));
1046        assert!(!SignalKind::Clock.is_summable());
1047    }
1048
1049    #[test]
1050    fn test_param_range_exponential_clamped() {
1051        let range = ParamRange::Exponential {
1052            min: 20.0,
1053            max: 20000.0,
1054        };
1055        // Test with values outside 0-1
1056        let below = range.apply(-0.5);
1057        assert!((below - 20.0).abs() < 1e-10);
1058
1059        let above = range.apply(1.5);
1060        assert!((above - 20000.0).abs() < 1e-10);
1061    }
1062
1063    #[test]
1064    fn test_param_range_exponential_invalid_domain_no_nan() {
1065        // Q082 regression: min>0 with max<=0 makes max/min negative, and
1066        // pow(negative, fractional) is NaN. The guard must fall back to linear.
1067        let range = ParamRange::Exponential {
1068            min: 20.0,
1069            max: -1.0,
1070        };
1071        for &t in &[0.0, 0.25, 0.5, 0.75, 1.0] {
1072            let v = range.apply(t);
1073            assert!(v.is_finite(), "apply({}) produced non-finite {}", t, v);
1074        }
1075        // Endpoints match the linear fallback.
1076        assert!((range.apply(0.0) - 20.0).abs() < 1e-10);
1077        assert!((range.apply(1.0) - (-1.0)).abs() < 1e-10);
1078
1079        // max == 0 (also invalid for exponential) must stay finite too.
1080        let zero_max = ParamRange::Exponential {
1081            min: 10.0,
1082            max: 0.0,
1083        };
1084        assert!(zero_max.apply(0.5).is_finite());
1085    }
1086
1087    // =============================================================================
1088    // Signal Semantics Tests (Phase 2)
1089    // =============================================================================
1090
1091    #[test]
1092    fn test_signal_colors_default() {
1093        let colors = SignalColors::default();
1094        assert_eq!(colors.audio, "#e94560");
1095        assert_eq!(colors.cv_bipolar, "#0f3460");
1096        assert_eq!(colors.cv_unipolar, "#00b4d8");
1097        assert_eq!(colors.volt_per_octave, "#90be6d");
1098        assert_eq!(colors.gate, "#f9c74f");
1099        assert_eq!(colors.trigger, "#f8961e");
1100        assert_eq!(colors.clock, "#9d4edd");
1101    }
1102
1103    #[test]
1104    fn test_signal_colors_get() {
1105        let colors = SignalColors::default();
1106        assert_eq!(colors.get(SignalKind::Audio), "#e94560");
1107        assert_eq!(colors.get(SignalKind::Gate), "#f9c74f");
1108        assert_eq!(colors.get(SignalKind::VoltPerOctave), "#90be6d");
1109    }
1110
1111    #[test]
1112    fn test_port_info_creation() {
1113        let info = PortInfo::new(0, "test", SignalKind::Audio)
1114            .with_description("A test port")
1115            .with_normalled_to("other");
1116
1117        assert_eq!(info.id, 0);
1118        assert_eq!(info.name, "test");
1119        assert_eq!(info.kind, SignalKind::Audio);
1120        assert_eq!(info.description, Some("A test port".to_string()));
1121        assert_eq!(info.normalled_to, Some("other".to_string()));
1122    }
1123
1124    #[test]
1125    fn test_port_info_from_port_def() {
1126        let def = PortDef::new(5, "cutoff", SignalKind::CvUnipolar);
1127        let info = PortInfo::from(&def);
1128
1129        assert_eq!(info.id, 5);
1130        assert_eq!(info.name, "cutoff");
1131        assert_eq!(info.kind, SignalKind::CvUnipolar);
1132        assert!(info.normalled_to.is_none());
1133        assert!(info.description.is_none());
1134    }
1135
1136    #[test]
1137    fn test_ports_compatible_exact() {
1138        assert_eq!(
1139            ports_compatible(SignalKind::Audio, SignalKind::Audio),
1140            Compatibility::Exact
1141        );
1142        assert_eq!(
1143            ports_compatible(SignalKind::Gate, SignalKind::Gate),
1144            Compatibility::Exact
1145        );
1146        assert_eq!(
1147            ports_compatible(SignalKind::VoltPerOctave, SignalKind::VoltPerOctave),
1148            Compatibility::Exact
1149        );
1150    }
1151
1152    #[test]
1153    fn test_ports_compatible_audio_to_anything() {
1154        // Unified with graph::SignalKind::is_compatible_with: Audio->CV / Audio->Gate are
1155        // permitted but flagged with a warning ("ensure this is intentional").
1156        assert!(matches!(
1157            ports_compatible(SignalKind::Audio, SignalKind::CvBipolar),
1158            Compatibility::Warning { .. }
1159        ));
1160        assert!(matches!(
1161            ports_compatible(SignalKind::Audio, SignalKind::Gate),
1162            Compatibility::Warning { .. }
1163        ));
1164    }
1165
1166    #[test]
1167    fn test_ports_compatible_cv_interop() {
1168        // Bipolar<->Unipolar CV crossings warn (possible clip/offset).
1169        assert!(matches!(
1170            ports_compatible(SignalKind::CvBipolar, SignalKind::CvUnipolar),
1171            Compatibility::Warning { .. }
1172        ));
1173        assert!(matches!(
1174            ports_compatible(SignalKind::CvUnipolar, SignalKind::CvBipolar),
1175            Compatibility::Warning { .. }
1176        ));
1177        // V/Oct -> bipolar CV is a clean, warning-free pitch extraction.
1178        assert_eq!(
1179            ports_compatible(SignalKind::VoltPerOctave, SignalKind::CvBipolar),
1180            Compatibility::Allowed
1181        );
1182    }
1183
1184    #[test]
1185    fn test_ports_compatible_gate_trigger_interop() {
1186        // Gate<->Trigger warn about timing differences.
1187        assert!(matches!(
1188            ports_compatible(SignalKind::Gate, SignalKind::Trigger),
1189            Compatibility::Warning { .. }
1190        ));
1191        assert!(matches!(
1192            ports_compatible(SignalKind::Trigger, SignalKind::Gate),
1193            Compatibility::Warning { .. }
1194        ));
1195        // Clock->Trigger is clean; Clock->Gate warns about duty cycle.
1196        assert_eq!(
1197            ports_compatible(SignalKind::Clock, SignalKind::Trigger),
1198            Compatibility::Allowed
1199        );
1200        assert!(matches!(
1201            ports_compatible(SignalKind::Clock, SignalKind::Gate),
1202            Compatibility::Warning { .. }
1203        ));
1204    }
1205
1206    #[test]
1207    fn test_ports_compatible_warnings() {
1208        // Gate to Audio: unusual connection -> warning.
1209        let compat = ports_compatible(SignalKind::Gate, SignalKind::Audio);
1210        assert!(matches!(compat, Compatibility::Warning { .. }));
1211
1212        // Bipolar CV -> V/Oct is treated as clean pitch modulation (no warning).
1213        assert_eq!(
1214            ports_compatible(SignalKind::CvBipolar, SignalKind::VoltPerOctave),
1215            Compatibility::Allowed
1216        );
1217    }
1218
1219    #[test]
1220    fn test_ports_compatible_agrees_with_is_compatible_with() {
1221        // Q124: the two public compatibility APIs must never disagree. Pin the
1222        // Audio -> CvBipolar case explicitly, then cross-check every ordered pair.
1223        // `is_compatible_with` (defined in `graph`) is the single source of truth.
1224        let audio_cv = SignalKind::Audio.is_compatible_with(&SignalKind::CvBipolar);
1225        assert!(
1226            audio_cv.warning.is_some(),
1227            "is_compatible_with should warn on Audio->CvBipolar"
1228        );
1229        assert!(
1230            matches!(
1231                ports_compatible(SignalKind::Audio, SignalKind::CvBipolar),
1232                Compatibility::Warning { .. }
1233            ),
1234            "ports_compatible should agree and warn on Audio->CvBipolar"
1235        );
1236
1237        let all = [
1238            SignalKind::Audio,
1239            SignalKind::CvBipolar,
1240            SignalKind::CvUnipolar,
1241            SignalKind::VoltPerOctave,
1242            SignalKind::Gate,
1243            SignalKind::Trigger,
1244            SignalKind::Clock,
1245        ];
1246        for &a in &all {
1247            for &b in &all {
1248                let low = ports_compatible(a, b);
1249                let high = a.is_compatible_with(&b);
1250                // Warning verdicts must match exactly between the two APIs.
1251                let low_warns = matches!(low, Compatibility::Warning { .. });
1252                assert_eq!(
1253                    low_warns,
1254                    high.warning.is_some(),
1255                    "compatibility APIs disagree for {:?} -> {:?}",
1256                    a,
1257                    b
1258                );
1259            }
1260        }
1261    }
1262
1263    #[test]
1264    fn test_signal_kind_serializes_snake_case() {
1265        // Q091: SignalKind must serialize snake_case to match the JSON schema and TS.
1266        assert_eq!(
1267            serde_json::to_string(&SignalKind::CvBipolar).unwrap(),
1268            "\"cv_bipolar\""
1269        );
1270        assert_eq!(
1271            serde_json::to_string(&SignalKind::VoltPerOctave).unwrap(),
1272            "\"volt_per_octave\""
1273        );
1274        assert_eq!(
1275            serde_json::to_string(&SignalKind::Audio).unwrap(),
1276            "\"audio\""
1277        );
1278        // Round-trips from snake_case.
1279        let k: SignalKind = serde_json::from_str("\"cv_unipolar\"").unwrap();
1280        assert_eq!(k, SignalKind::CvUnipolar);
1281    }
1282
1283    #[test]
1284    fn test_compatibility_serialization() {
1285        let exact = Compatibility::Exact;
1286        let json = serde_json::to_string(&exact).unwrap();
1287        assert!(json.contains("exact"));
1288
1289        let warning = Compatibility::Warning {
1290            message: "test".to_string(),
1291        };
1292        let json = serde_json::to_string(&warning).unwrap();
1293        assert!(json.contains("warning"));
1294        assert!(json.contains("test"));
1295    }
1296}