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#[derive(Debug, Clone, Default)]
321pub struct PortValues {
322    pub values: StdMap<PortId, f64>,
323}
324
325impl PortValues {
326    pub fn new() -> Self {
327        Self::default()
328    }
329
330    pub fn get(&self, id: PortId) -> Option<f64> {
331        self.values.get(&id).copied()
332    }
333
334    pub fn get_or(&self, id: PortId, default: f64) -> f64 {
335        self.values.get(&id).copied().unwrap_or(default)
336    }
337
338    pub fn set(&mut self, id: PortId, value: f64) {
339        self.values.insert(id, value);
340    }
341
342    /// Accumulate (sum) a value into a port (for input mixing)
343    pub fn accumulate(&mut self, id: PortId, value: f64) {
344        *self.values.entry(id).or_insert(0.0) += value;
345    }
346
347    pub fn has(&self, id: PortId) -> bool {
348        self.values.contains_key(&id)
349    }
350
351    pub fn clear(&mut self) {
352        self.values.clear();
353    }
354}
355
356/// Block-oriented port values for efficient processing
357pub struct BlockPortValues {
358    buffers: StdMap<PortId, Vec<f64>>,
359    block_size: usize,
360}
361
362impl BlockPortValues {
363    pub fn new(block_size: usize) -> Self {
364        Self {
365            buffers: StdMap::new(),
366            block_size,
367        }
368    }
369
370    pub fn block_size(&self) -> usize {
371        self.block_size
372    }
373
374    pub fn get_buffer(&self, port: PortId) -> Option<&[f64]> {
375        self.buffers.get(&port).map(|v| v.as_slice())
376    }
377
378    pub fn get_buffer_mut(&mut self, port: PortId) -> &mut Vec<f64> {
379        self.buffers
380            .entry(port)
381            .or_insert_with(|| vec![0.0; self.block_size])
382    }
383
384    pub fn frame(&self, index: usize) -> PortValues {
385        let mut values = PortValues::new();
386        self.frame_into(index, &mut values);
387        values
388    }
389
390    /// Read frame `index` into an existing [`PortValues`], reusing its allocation.
391    ///
392    /// Clears `dst` and refills it from each port buffer at `index`. Unlike [`Self::frame`], this
393    /// performs no allocation once `dst` has been warmed with the same key set, which lets
394    /// block loops (e.g. the default [`GraphModule::process_block`]) avoid a fresh
395    /// [`PortValues`] per frame.
396    pub fn frame_into(&self, index: usize, dst: &mut PortValues) {
397        dst.clear();
398        for (&port, buffer) in &self.buffers {
399            if index < buffer.len() {
400                dst.set(port, buffer[index]);
401            }
402        }
403    }
404
405    pub fn set_frame(&mut self, index: usize, values: PortValues) {
406        self.set_frame_ref(index, &values);
407    }
408
409    /// Write a borrowed [`PortValues`] into frame `index`, without taking ownership.
410    ///
411    /// The by-reference companion to [`Self::set_frame`], so a caller can reuse a single output
412    /// [`PortValues`] across every frame of a block instead of moving (and reallocating) one
413    /// per frame.
414    pub fn set_frame_ref(&mut self, index: usize, values: &PortValues) {
415        for (&port, &value) in &values.values {
416            let buffer = self.get_buffer_mut(port);
417            if index < buffer.len() {
418                buffer[index] = value;
419            }
420        }
421    }
422
423    pub fn clear(&mut self) {
424        for buffer in self.buffers.values_mut() {
425            buffer.fill(0.0);
426        }
427    }
428}
429
430/// Parameter range mapping for modulated parameters
431#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
432pub enum ParamRange {
433    /// Linear mapping from normalized (0–1) to (min, max)
434    Linear { min: f64, max: f64 },
435
436    /// Exponential mapping, useful for frequency/time controls
437    Exponential { min: f64, max: f64 },
438
439    /// V/Oct: input is in volts, output is frequency multiplier
440    VoltPerOctave { base_freq: f64 },
441}
442
443impl ParamRange {
444    pub fn apply(&self, normalized: f64) -> f64 {
445        match self {
446            ParamRange::Linear { min, max } => min + normalized.clamp(0.0, 1.0) * (max - min),
447            ParamRange::Exponential { min, max } => {
448                let clamped = normalized.clamp(0.0, 1.0);
449                // Exponential interpolation `min * (max/min)^t` is only defined for a
450                // strictly positive domain (0 < min, 0 < max). If either bound is
451                // non-positive, `max/min` can be negative and `pow(neg, frac)` yields
452                // NaN, so fall back to a plain linear interpolation which is always
453                // finite. This guards callers that construct e.g. Exponential{min:20,
454                // max:-1} from silently poisoning frequency/time controls with NaN.
455                if *min > 0.0 && *max > 0.0 {
456                    min * Libm::<f64>::pow(max / min, clamped)
457                } else {
458                    min + clamped * (max - min)
459                }
460            }
461            ParamRange::VoltPerOctave { base_freq } => {
462                base_freq * Libm::<f64>::pow(2.0, normalized)
463            }
464        }
465    }
466}
467
468/// A parameter that combines a base value (knob) with CV modulation
469#[derive(Debug, Clone, Serialize, Deserialize)]
470pub struct ModulatedParam {
471    /// Base value from panel knob (typically 0.0–1.0 normalized)
472    pub base: f64,
473
474    /// Incoming CV **voltage** (set during tick).
475    ///
476    /// Interpreted on the bipolar ±5 V scale: [`value`](Self::value) normalizes it by
477    /// [`CV_FULL_SCALE_VOLTS`](Self::CV_FULL_SCALE_VOLTS) so that a full +5 V of CV
478    /// (with attenuverter at +1.0) contributes +1.0 to the normalized parameter.
479    pub cv: f64,
480
481    /// Attenuverter setting (-1.0 to 1.0)
482    /// Positive: CV adds to base
483    /// Negative: CV subtracts from base (inverted)
484    pub attenuverter: f64,
485
486    /// Output range mapping
487    pub range: ParamRange,
488}
489
490impl ModulatedParam {
491    /// Full-scale CV voltage used to normalize [`cv`](Self::cv) into the 0–1 base domain.
492    ///
493    /// Bipolar CV spans ±5 V, so dividing by 5 V maps a full-swing CV signal onto the
494    /// same normalized 0–1 range as `base` before the two are combined.
495    pub const CV_FULL_SCALE_VOLTS: f64 = 5.0;
496
497    pub fn new(range: ParamRange) -> Self {
498        Self {
499            base: 0.5,
500            cv: 0.0,
501            attenuverter: 1.0,
502            range,
503        }
504    }
505
506    pub fn with_base(mut self, base: f64) -> Self {
507        self.base = base;
508        self
509    }
510
511    /// Compute the effective parameter value.
512    ///
513    /// `base` is a normalized 0–1 knob position; `cv` is a voltage that is normalized by
514    /// [`CV_FULL_SCALE_VOLTS`](Self::CV_FULL_SCALE_VOLTS) before being scaled by the
515    /// attenuverter (±1.0) and summed with `base`. This keeps CV modulation proportional:
516    /// a full +5 V of CV shifts the normalized value by at most ±1.0 rather than slamming
517    /// the parameter to its rail. The combined value is then mapped through `range`.
518    pub fn value(&self) -> f64 {
519        let modulated = self.base + (self.cv / Self::CV_FULL_SCALE_VOLTS) * self.attenuverter;
520        self.range.apply(modulated)
521    }
522
523    /// Update CV from port value
524    pub fn set_cv(&mut self, cv: f64) {
525        self.cv = cv;
526    }
527}
528
529/// Parameter definition for UI binding
530#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct ParamDef {
532    pub id: ParamId,
533    pub name: String,
534    pub default: f64,
535    pub range: ParamRange,
536}
537
538/// Type-erased module interface for graph-based patching
539pub trait GraphModule: Send + Sync {
540    /// Returns the module's port specification
541    fn port_spec(&self) -> &PortSpec;
542
543    /// Process one sample given port values
544    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues);
545
546    /// Process a block of samples (optional optimization).
547    ///
548    /// The default drives [`tick`](Self::tick) frame-by-frame. It reuses a single input and
549    /// output [`PortValues`] across the whole block (via
550    /// [`BlockPortValues::frame_into`]/[`set_frame_ref`](BlockPortValues::set_frame_ref)), so
551    /// it does **not** allocate per frame — only once per call to warm the reused buffers.
552    ///
553    /// For the graph engine, prefer [`Patch::tick_block`](crate::graph::Patch::tick_block),
554    /// which is fully allocation-free after compile.
555    fn process_block(
556        &mut self,
557        inputs: &BlockPortValues,
558        outputs: &mut BlockPortValues,
559        frames: usize,
560    ) {
561        let mut in_frame = PortValues::new();
562        let mut out_frame = PortValues::new();
563        for i in 0..frames {
564            inputs.frame_into(i, &mut in_frame);
565            out_frame.clear();
566            self.tick(&in_frame, &mut out_frame);
567            outputs.set_frame_ref(i, &out_frame);
568        }
569    }
570
571    /// Reset internal state
572    fn reset(&mut self);
573
574    /// Set sample rate
575    fn set_sample_rate(&mut self, sample_rate: f64);
576
577    /// Whether this module breaks a feedback cycle in the patch graph.
578    ///
579    /// The graph normally rejects any cable cycle with [`PatchError::CycleDetected`].
580    /// A module that returns `true` (delay-style modules such as `UnitDelay` and
581    /// `DelayLine`) is treated as a one-sample delay boundary: [`Patch::compile`] excludes
582    /// the edges feeding *into* it from the topological sort, so a loop routed through it
583    /// compiles. At runtime such a module reads its inputs from the previous tick's output
584    /// buffers, giving the classic single-sample feedback delay. Cycles that contain no
585    /// cycle-breaker still fail to compile.
586    ///
587    /// [`PatchError::CycleDetected`]: crate::graph::PatchError::CycleDetected
588    /// [`Patch::compile`]: crate::graph::Patch::compile
589    fn breaks_feedback_cycle(&self) -> bool {
590        false
591    }
592
593    /// Get parameter definitions for UI binding.
594    ///
595    /// **Most built-in modules do not use this API.** Nearly all of them expose their
596    /// controllable quantities as **input ports** (see [`port_spec`](Self::port_spec)) —
597    /// e.g. a VCO's frequency, an SVF's cutoff, or an ADSR's stage times are all input
598    /// ports driven by cables or their `default` values — and leave this method at its
599    /// empty default. The authoritative way to discover and drive parameters for GUIs is
600    /// the `ModuleIntrospection` API (available with the `alloc` feature), not this
601    /// trait-default no-op. It remains here only for the handful of modules whose
602    /// parameters are genuinely not ports.
603    fn params(&self) -> &[ParamDef] {
604        &[]
605    }
606
607    /// Get a parameter value.
608    ///
609    /// Defaults to `None`. See [`params`](Self::params): most modules surface their state
610    /// through input ports and `ModuleIntrospection`, not through this method.
611    fn get_param(&self, _id: ParamId) -> Option<f64> {
612        None
613    }
614
615    /// Set a parameter value.
616    ///
617    /// Defaults to a no-op. See [`params`](Self::params): most modules surface their state
618    /// through input ports and `ModuleIntrospection`, not through this method.
619    fn set_param(&mut self, _id: ParamId, _value: f64) {}
620
621    /// Get module type identifier for serialization
622    fn type_id(&self) -> &'static str {
623        "unknown"
624    }
625
626    /// Serialize module state (alloc feature only)
627    #[cfg(feature = "alloc")]
628    fn serialize_state(&self) -> Option<serde_json::Value> {
629        None
630    }
631
632    /// Deserialize module state (alloc feature only)
633    #[cfg(feature = "alloc")]
634    fn deserialize_state(
635        &mut self,
636        _state: &serde_json::Value,
637    ) -> Result<(), alloc::string::String> {
638        Ok(())
639    }
640
641    /// Downcast this module to its [`ModuleIntrospection`](crate::introspection::ModuleIntrospection) view, if it exposes one.
642    ///
643    /// A `Box<dyn GraphModule>` (as stored inside a [`Patch`](crate::graph::Patch)) cannot
644    /// otherwise reach the module's `ModuleIntrospection` impl, so this hook bridges the two
645    /// trait objects. It returns `None` by default; modules with genuine internal (non-port)
646    /// parameters override it — typically via [`impl_introspect!`](crate::impl_introspect) —
647    /// to return `Some(self)`. Parameters that are input ports are discovered and driven
648    /// through the port system instead (see [`Patch::param_infos`](crate::graph::Patch::param_infos)),
649    /// so most modules leave this at the default.
650    ///
651    /// Gated on `alloc` because `ModuleIntrospection` (and its `Vec`/`String` payloads) live
652    /// in the alloc tier; pure `no_std` builds never see this method.
653    #[cfg(feature = "alloc")]
654    fn introspect(&self) -> Option<&dyn crate::introspection::ModuleIntrospection> {
655        None
656    }
657
658    /// Mutable companion to [`introspect`](Self::introspect), used to set internal parameters.
659    #[cfg(feature = "alloc")]
660    fn introspect_mut(&mut self) -> Option<&mut dyn crate::introspection::ModuleIntrospection> {
661        None
662    }
663}
664
665/// Wire a module's [`ModuleIntrospection`](crate::introspection::ModuleIntrospection) impl into the [`GraphModule`] trait object.
666///
667/// Invoke once inside a module's `impl GraphModule for T { .. }` block. It expands to the
668/// `introspect`/`introspect_mut` overrides (both `alloc`-gated) returning `Some(self)`, so a
669/// live [`Patch`](crate::graph::Patch) can reach the module's parameter metadata through its
670/// boxed trait object. Requires `T: ModuleIntrospection` (satisfied under `alloc`).
671#[macro_export]
672macro_rules! impl_introspect {
673    () => {
674        #[cfg(feature = "alloc")]
675        fn introspect(&self) -> Option<&dyn $crate::introspection::ModuleIntrospection> {
676            Some(self)
677        }
678        #[cfg(feature = "alloc")]
679        fn introspect_mut(
680            &mut self,
681        ) -> Option<&mut dyn $crate::introspection::ModuleIntrospection> {
682            Some(self)
683        }
684    };
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    #[test]
692    fn test_signal_kind_ranges() {
693        assert_eq!(SignalKind::Audio.voltage_range(), (-5.0, 5.0));
694        assert_eq!(SignalKind::Gate.voltage_range(), (0.0, 5.0));
695        assert_eq!(SignalKind::CvUnipolar.voltage_range(), (0.0, 10.0));
696    }
697
698    #[test]
699    fn test_signal_kind_summable() {
700        assert!(SignalKind::Audio.is_summable());
701        assert!(SignalKind::CvBipolar.is_summable());
702        assert!(!SignalKind::Gate.is_summable());
703        assert!(!SignalKind::Trigger.is_summable());
704    }
705
706    #[test]
707    fn test_port_values() {
708        let mut pv = PortValues::new();
709        pv.set(0, 1.0);
710        pv.set(1, 2.0);
711        assert_eq!(pv.get(0), Some(1.0));
712        assert_eq!(pv.get(1), Some(2.0));
713        assert_eq!(pv.get(2), None);
714        assert_eq!(pv.get_or(2, 5.0), 5.0);
715
716        pv.accumulate(0, 0.5);
717        assert_eq!(pv.get(0), Some(1.5));
718    }
719
720    #[test]
721    fn test_param_range_linear() {
722        let range = ParamRange::Linear {
723            min: 0.0,
724            max: 100.0,
725        };
726        assert!((range.apply(0.0) - 0.0).abs() < 1e-10);
727        assert!((range.apply(0.5) - 50.0).abs() < 1e-10);
728        assert!((range.apply(1.0) - 100.0).abs() < 1e-10);
729    }
730
731    #[test]
732    fn test_param_range_exponential() {
733        let range = ParamRange::Exponential {
734            min: 20.0,
735            max: 20000.0,
736        };
737        assert!((range.apply(0.0) - 20.0).abs() < 1e-10);
738        assert!((range.apply(1.0) - 20000.0).abs() < 1e-10);
739    }
740
741    #[test]
742    fn test_param_range_voct() {
743        let range = ParamRange::VoltPerOctave { base_freq: 261.63 };
744        // 0V = C4 = 261.63 Hz
745        assert!((range.apply(0.0) - 261.63).abs() < 0.01);
746        // +1V = C5 = 523.26 Hz
747        assert!((range.apply(1.0) - 523.26).abs() < 0.01);
748    }
749
750    #[test]
751    fn test_modulated_param() {
752        let mut param = ModulatedParam::new(ParamRange::Linear {
753            min: 0.0,
754            max: 100.0,
755        })
756        .with_base(0.5);
757
758        // No CV: should return base * range
759        assert!((param.value() - 50.0).abs() < 1e-10);
760
761        // Realistic CV is a *voltage*, normalized by 5 V full scale.
762        // +1 V of CV shifts the normalized value by 1/5 = 0.2 -> 0.7 -> 70.
763        param.set_cv(1.0);
764        assert!((param.value() - 70.0).abs() < 1e-10);
765
766        // Invert attenuverter: +1 V CV now subtracts -> 0.3 -> 30.
767        param.attenuverter = -1.0;
768        assert!((param.value() - 30.0).abs() < 1e-10);
769    }
770
771    #[test]
772    fn test_modulated_param_full_scale_cv_is_proportional() {
773        // Q081 regression: a full ±5 V CV must map to a full ±1.0 normalized swing,
774        // not slam the parameter to a rail from any modest voltage.
775        let mut param = ModulatedParam::new(ParamRange::Linear {
776            min: 0.0,
777            max: 100.0,
778        })
779        .with_base(0.5);
780
781        // A modest +1 V of CV should move the param proportionally to
782        // 0.5 + (1/5)*1 = 0.7 -> 70, NOT slam to the maximum (the pre-fix behavior added
783        // the raw voltage: 0.5 + 1.0 = 1.5 -> clamped to 100).
784        param.set_cv(1.0);
785        assert!(
786            (param.value() - 70.0).abs() < 1e-10,
787            "1 V CV should be proportional, got {}",
788            param.value()
789        );
790
791        // Full +5 V reaches exactly the top of the range.
792        param.set_cv(5.0);
793        assert!((param.value() - 100.0).abs() < 1e-10);
794
795        // Full -5 V reaches the bottom.
796        param.set_cv(-5.0);
797        assert!((param.value() - 0.0).abs() < 1e-10);
798    }
799
800    #[test]
801    fn test_signal_kind_gate_threshold() {
802        assert!(SignalKind::Gate.gate_threshold().is_some());
803        assert!(SignalKind::Trigger.gate_threshold().is_some());
804        assert!(SignalKind::Audio.gate_threshold().is_none());
805    }
806
807    #[test]
808    fn test_port_def_with_default_and_attenuverter() {
809        let port = PortDef::new(0, "test", SignalKind::CvUnipolar)
810            .with_default(5.0)
811            .with_attenuverter();
812
813        assert!((port.default - 5.0).abs() < 0.001);
814        assert!(port.has_attenuverter);
815    }
816
817    #[test]
818    fn test_port_def_normalled_to() {
819        let port = PortDef::new(0, "test", SignalKind::CvUnipolar).normalled_to(1);
820        assert_eq!(port.normalled_to, Some(1));
821    }
822
823    #[test]
824    fn test_port_spec_lookup() {
825        let spec = PortSpec {
826            inputs: vec![
827                PortDef::new(0, "in1", SignalKind::Audio),
828                PortDef::new(1, "in2", SignalKind::CvBipolar),
829            ],
830            outputs: vec![
831                PortDef::new(10, "out1", SignalKind::Audio),
832                PortDef::new(11, "out2", SignalKind::Gate),
833            ],
834        };
835
836        assert!(spec.input_by_name("in1").is_some());
837        assert!(spec.input_by_name("nonexistent").is_none());
838        assert!(spec.output_by_name("out1").is_some());
839        assert!(spec.output_by_name("nonexistent").is_none());
840
841        assert!(spec.input_by_id(0).is_some());
842        assert!(spec.input_by_id(99).is_none());
843        assert!(spec.output_by_id(10).is_some());
844        assert!(spec.output_by_id(99).is_none());
845    }
846
847    #[test]
848    fn test_port_values_has() {
849        let mut pv = PortValues::new();
850        assert!(!pv.has(0));
851        pv.set(0, 1.0);
852        assert!(pv.has(0));
853    }
854
855    #[test]
856    fn test_port_values_clear() {
857        let mut pv = PortValues::new();
858        pv.set(0, 1.0);
859        pv.set(1, 2.0);
860        pv.clear();
861        assert!(!pv.has(0));
862        assert!(!pv.has(1));
863    }
864
865    #[test]
866    fn test_block_port_values() {
867        let mut bpv = BlockPortValues::new(64);
868        assert_eq!(bpv.block_size(), 64);
869
870        // Get mutable buffer (creates buffer for port 0)
871        let buf_mut = bpv.get_buffer_mut(0);
872        assert_eq!(buf_mut.len(), 64);
873        buf_mut[0] = 1.0;
874
875        // Now we can read it
876        assert_eq!(bpv.get_buffer(0).unwrap()[0], 1.0);
877
878        // Frame operations
879        let mut frame_vals = PortValues::new();
880        frame_vals.set(0, 99.0);
881        bpv.set_frame(1, frame_vals);
882
883        // Clear
884        bpv.clear();
885    }
886
887    #[test]
888    fn test_signal_kind_clock() {
889        let range = SignalKind::Clock.voltage_range();
890        assert_eq!(range, (0.0, 5.0));
891        assert!(!SignalKind::Clock.is_summable());
892    }
893
894    #[test]
895    fn test_param_range_exponential_clamped() {
896        let range = ParamRange::Exponential {
897            min: 20.0,
898            max: 20000.0,
899        };
900        // Test with values outside 0-1
901        let below = range.apply(-0.5);
902        assert!((below - 20.0).abs() < 1e-10);
903
904        let above = range.apply(1.5);
905        assert!((above - 20000.0).abs() < 1e-10);
906    }
907
908    #[test]
909    fn test_param_range_exponential_invalid_domain_no_nan() {
910        // Q082 regression: min>0 with max<=0 makes max/min negative, and
911        // pow(negative, fractional) is NaN. The guard must fall back to linear.
912        let range = ParamRange::Exponential {
913            min: 20.0,
914            max: -1.0,
915        };
916        for &t in &[0.0, 0.25, 0.5, 0.75, 1.0] {
917            let v = range.apply(t);
918            assert!(v.is_finite(), "apply({}) produced non-finite {}", t, v);
919        }
920        // Endpoints match the linear fallback.
921        assert!((range.apply(0.0) - 20.0).abs() < 1e-10);
922        assert!((range.apply(1.0) - (-1.0)).abs() < 1e-10);
923
924        // max == 0 (also invalid for exponential) must stay finite too.
925        let zero_max = ParamRange::Exponential {
926            min: 10.0,
927            max: 0.0,
928        };
929        assert!(zero_max.apply(0.5).is_finite());
930    }
931
932    // =============================================================================
933    // Signal Semantics Tests (Phase 2)
934    // =============================================================================
935
936    #[test]
937    fn test_signal_colors_default() {
938        let colors = SignalColors::default();
939        assert_eq!(colors.audio, "#e94560");
940        assert_eq!(colors.cv_bipolar, "#0f3460");
941        assert_eq!(colors.cv_unipolar, "#00b4d8");
942        assert_eq!(colors.volt_per_octave, "#90be6d");
943        assert_eq!(colors.gate, "#f9c74f");
944        assert_eq!(colors.trigger, "#f8961e");
945        assert_eq!(colors.clock, "#9d4edd");
946    }
947
948    #[test]
949    fn test_signal_colors_get() {
950        let colors = SignalColors::default();
951        assert_eq!(colors.get(SignalKind::Audio), "#e94560");
952        assert_eq!(colors.get(SignalKind::Gate), "#f9c74f");
953        assert_eq!(colors.get(SignalKind::VoltPerOctave), "#90be6d");
954    }
955
956    #[test]
957    fn test_port_info_creation() {
958        let info = PortInfo::new(0, "test", SignalKind::Audio)
959            .with_description("A test port")
960            .with_normalled_to("other");
961
962        assert_eq!(info.id, 0);
963        assert_eq!(info.name, "test");
964        assert_eq!(info.kind, SignalKind::Audio);
965        assert_eq!(info.description, Some("A test port".to_string()));
966        assert_eq!(info.normalled_to, Some("other".to_string()));
967    }
968
969    #[test]
970    fn test_port_info_from_port_def() {
971        let def = PortDef::new(5, "cutoff", SignalKind::CvUnipolar);
972        let info = PortInfo::from(&def);
973
974        assert_eq!(info.id, 5);
975        assert_eq!(info.name, "cutoff");
976        assert_eq!(info.kind, SignalKind::CvUnipolar);
977        assert!(info.normalled_to.is_none());
978        assert!(info.description.is_none());
979    }
980
981    #[test]
982    fn test_ports_compatible_exact() {
983        assert_eq!(
984            ports_compatible(SignalKind::Audio, SignalKind::Audio),
985            Compatibility::Exact
986        );
987        assert_eq!(
988            ports_compatible(SignalKind::Gate, SignalKind::Gate),
989            Compatibility::Exact
990        );
991        assert_eq!(
992            ports_compatible(SignalKind::VoltPerOctave, SignalKind::VoltPerOctave),
993            Compatibility::Exact
994        );
995    }
996
997    #[test]
998    fn test_ports_compatible_audio_to_anything() {
999        // Unified with graph::SignalKind::is_compatible_with: Audio->CV / Audio->Gate are
1000        // permitted but flagged with a warning ("ensure this is intentional").
1001        assert!(matches!(
1002            ports_compatible(SignalKind::Audio, SignalKind::CvBipolar),
1003            Compatibility::Warning { .. }
1004        ));
1005        assert!(matches!(
1006            ports_compatible(SignalKind::Audio, SignalKind::Gate),
1007            Compatibility::Warning { .. }
1008        ));
1009    }
1010
1011    #[test]
1012    fn test_ports_compatible_cv_interop() {
1013        // Bipolar<->Unipolar CV crossings warn (possible clip/offset).
1014        assert!(matches!(
1015            ports_compatible(SignalKind::CvBipolar, SignalKind::CvUnipolar),
1016            Compatibility::Warning { .. }
1017        ));
1018        assert!(matches!(
1019            ports_compatible(SignalKind::CvUnipolar, SignalKind::CvBipolar),
1020            Compatibility::Warning { .. }
1021        ));
1022        // V/Oct -> bipolar CV is a clean, warning-free pitch extraction.
1023        assert_eq!(
1024            ports_compatible(SignalKind::VoltPerOctave, SignalKind::CvBipolar),
1025            Compatibility::Allowed
1026        );
1027    }
1028
1029    #[test]
1030    fn test_ports_compatible_gate_trigger_interop() {
1031        // Gate<->Trigger warn about timing differences.
1032        assert!(matches!(
1033            ports_compatible(SignalKind::Gate, SignalKind::Trigger),
1034            Compatibility::Warning { .. }
1035        ));
1036        assert!(matches!(
1037            ports_compatible(SignalKind::Trigger, SignalKind::Gate),
1038            Compatibility::Warning { .. }
1039        ));
1040        // Clock->Trigger is clean; Clock->Gate warns about duty cycle.
1041        assert_eq!(
1042            ports_compatible(SignalKind::Clock, SignalKind::Trigger),
1043            Compatibility::Allowed
1044        );
1045        assert!(matches!(
1046            ports_compatible(SignalKind::Clock, SignalKind::Gate),
1047            Compatibility::Warning { .. }
1048        ));
1049    }
1050
1051    #[test]
1052    fn test_ports_compatible_warnings() {
1053        // Gate to Audio: unusual connection -> warning.
1054        let compat = ports_compatible(SignalKind::Gate, SignalKind::Audio);
1055        assert!(matches!(compat, Compatibility::Warning { .. }));
1056
1057        // Bipolar CV -> V/Oct is treated as clean pitch modulation (no warning).
1058        assert_eq!(
1059            ports_compatible(SignalKind::CvBipolar, SignalKind::VoltPerOctave),
1060            Compatibility::Allowed
1061        );
1062    }
1063
1064    #[test]
1065    fn test_ports_compatible_agrees_with_is_compatible_with() {
1066        // Q124: the two public compatibility APIs must never disagree. Pin the
1067        // Audio -> CvBipolar case explicitly, then cross-check every ordered pair.
1068        // `is_compatible_with` (defined in `graph`) is the single source of truth.
1069        let audio_cv = SignalKind::Audio.is_compatible_with(&SignalKind::CvBipolar);
1070        assert!(
1071            audio_cv.warning.is_some(),
1072            "is_compatible_with should warn on Audio->CvBipolar"
1073        );
1074        assert!(
1075            matches!(
1076                ports_compatible(SignalKind::Audio, SignalKind::CvBipolar),
1077                Compatibility::Warning { .. }
1078            ),
1079            "ports_compatible should agree and warn on Audio->CvBipolar"
1080        );
1081
1082        let all = [
1083            SignalKind::Audio,
1084            SignalKind::CvBipolar,
1085            SignalKind::CvUnipolar,
1086            SignalKind::VoltPerOctave,
1087            SignalKind::Gate,
1088            SignalKind::Trigger,
1089            SignalKind::Clock,
1090        ];
1091        for &a in &all {
1092            for &b in &all {
1093                let low = ports_compatible(a, b);
1094                let high = a.is_compatible_with(&b);
1095                // Warning verdicts must match exactly between the two APIs.
1096                let low_warns = matches!(low, Compatibility::Warning { .. });
1097                assert_eq!(
1098                    low_warns,
1099                    high.warning.is_some(),
1100                    "compatibility APIs disagree for {:?} -> {:?}",
1101                    a,
1102                    b
1103                );
1104            }
1105        }
1106    }
1107
1108    #[test]
1109    fn test_signal_kind_serializes_snake_case() {
1110        // Q091: SignalKind must serialize snake_case to match the JSON schema and TS.
1111        assert_eq!(
1112            serde_json::to_string(&SignalKind::CvBipolar).unwrap(),
1113            "\"cv_bipolar\""
1114        );
1115        assert_eq!(
1116            serde_json::to_string(&SignalKind::VoltPerOctave).unwrap(),
1117            "\"volt_per_octave\""
1118        );
1119        assert_eq!(
1120            serde_json::to_string(&SignalKind::Audio).unwrap(),
1121            "\"audio\""
1122        );
1123        // Round-trips from snake_case.
1124        let k: SignalKind = serde_json::from_str("\"cv_unipolar\"").unwrap();
1125        assert_eq!(k, SignalKind::CvUnipolar);
1126    }
1127
1128    #[test]
1129    fn test_compatibility_serialization() {
1130        let exact = Compatibility::Exact;
1131        let json = serde_json::to_string(&exact).unwrap();
1132        assert!(json.contains("exact"));
1133
1134        let warning = Compatibility::Warning {
1135            message: "test".to_string(),
1136        };
1137        let json = serde_json::to_string(&warning).unwrap();
1138        assert!(json.contains("warning"));
1139        assert!(json.contains("test"));
1140    }
1141}