Skip to main content

pedalkernel/compiler/
compile.rs

1//! Main compiler entry point: PedalDef -> CompiledPedal.
2//!
3//! Delegates to the SPQR pipeline (`compile_via_spqr`) for all circuit
4//! compilation. The legacy 6-pass pipeline has been removed.
5
6use crate::dsl::*;
7use crate::oversampling::OversamplingFactor;
8use crate::tolerance::ToleranceEngine;
9
10use super::compiled::*;
11
12// ═════��═════════════════════════════════════════════════════════════════════
13// Compile options
14// ═��═══════════════════════════════════════════════════════��═════════════════
15
16#[derive(Clone)]
17pub struct CompileOptions {
18    pub oversampling: OversamplingFactor,
19    pub tolerance: ToleranceEngine,
20    pub thermal: bool,
21    /// When true, collapse ALL nonlinear elements into a single MultiNlStage
22    /// instead of planning them as individual stages. Used for sidechain
23    /// sub-circuits where the entire NL network should be solved as one
24    /// multi-junction system (shared MNA + scattering matrix).
25    pub collapse_nl: bool,
26    /// When true, skip K-method lookup table generation. NL roots fall back
27    /// to Newton-Raphson iteration at runtime. Use for fast debug builds —
28    /// K-table generation is the main compile-time bottleneck (65536-entry
29    /// 2D sweeps per NL root).
30    pub skip_k_tables: bool,
31    /// When true, skip blockwise decomposition of multi-NL groups.
32    /// Falls back to monolithic R-type adaptor. Use when blockwise
33    /// produces incorrect pot bindings or signal routing.
34    pub skip_blockwise: bool,
35    /// Diagnostic mode: build blockwise-decomposed nonlinear ladders as
36    /// serial WDF/K-method rung stages instead of packaging them into one
37    /// delay-free Blockwise coupling stage. This intentionally breaks
38    /// delay-free feedback, so it is only useful for isolating rung behavior.
39    pub force_serial_blockwise: bool,
40    /// Diagnostic mode: when `force_serial_blockwise` is enabled, wrap the
41    /// serial rung stages in a one-sample output feedback loop with this gain.
42    /// `0.0` preserves the plain forced-serial behavior.
43    pub force_serial_blockwise_feedback_gain: f64,
44    /// Diagnostic mode: do not synthesize IIR stages from rigid subgraphs.
45    /// Rigid passive networks fall through to StateSpace where possible, and
46    /// active feedback networks fall through to the existing WDF adaptors.
47    pub disable_iir: bool,
48    /// Solve coupled blockwise K-method stages with the full delay-free
49    /// Newton/Jacobian iteration. This is useful for compiler validation and
50    /// offline reference renders, but too expensive for some realtime targets.
51    /// When false, coupled blockwise stages use table-driven fixed-point
52    /// iteration without building/solving the full Jacobian.
53    pub coupled_blockwise_newton: bool,
54}
55
56impl Default for CompileOptions {
57    fn default() -> Self {
58        Self {
59            oversampling: OversamplingFactor::X4,
60            tolerance: ToleranceEngine::ideal(),
61            thermal: false,
62            collapse_nl: false,
63            skip_k_tables: false,
64            skip_blockwise: false,
65            force_serial_blockwise: false,
66            force_serial_blockwise_feedback_gain: 0.0,
67            disable_iir: false,
68            coupled_blockwise_newton: true,
69        }
70    }
71}
72
73impl CompileOptions {
74    /// Return a copy of these options with blockwise decomposition disabled,
75    /// forcing all nonlinear groups through the monolithic/rigid MNA path.
76    ///
77    /// Use this for differential testing: compile the same circuit twice
78    /// (`default()` and `force_monolithic()`) and compare outputs sample-wise.
79    /// Any deviation indicates that blockwise is behaving as a dialect change
80    /// rather than a pure optimization — which is a bug.
81    ///
82    /// Follows the same opt-in pattern as [`CompileOptions::thermal`]:
83    /// default `false` (blockwise enabled), set `true` to override.
84    pub fn force_monolithic(self) -> Self {
85        Self {
86            skip_blockwise: true,
87            ..self
88        }
89    }
90
91    /// Default options for release builds: full optimization including K-tables.
92    pub fn release() -> Self {
93        Self::default()
94    }
95
96    /// Fast options for debug builds: skip K-table generation.
97    /// NL elements use Newton-Raphson iteration instead (correct but slower at runtime).
98    pub fn debug() -> Self {
99        Self {
100            skip_k_tables: true,
101            ..Self::default()
102        }
103    }
104
105    /// Auto-detect from the `PROFILE` env var set by Cargo during build.rs.
106    /// Returns `release()` for "release" profile, `debug()` for everything else.
107    ///
108    /// Use in build.rs:
109    /// ```no_run
110    /// let options = pedalkernel::compiler::CompileOptions::from_cargo_profile();
111    /// ```
112    pub fn from_cargo_profile() -> Self {
113        match std::env::var("PROFILE").as_deref() {
114            Ok("release") => Self::release(),
115            _ => Self::debug(),
116        }
117    }
118}
119
120// ══════════════════════════════��═══════════════════════════════════��════════
121// Public entry points
122// ═══════���═════════════════════���═════════════════════════════════════════════
123
124/// Compile a `.pedal` circuit into a real-time audio processor with default options.
125pub fn compile_pedal(pedal: &PedalDef, sample_rate: f64) -> Result<CompiledPedal, String> {
126    compile_pedal_with_options(pedal, sample_rate, CompileOptions::default())
127}
128
129/// Compile a `.pedal` circuit into a real-time audio processor with custom options.
130///
131/// Delegates to the SPQR pipeline for standard circuits and to the subcircuit
132/// equipment compiler for multi-subcircuit equipment definitions.
133pub fn compile_pedal_with_options(
134    pedal: &PedalDef,
135    sample_rate: f64,
136    options: CompileOptions,
137) -> Result<CompiledPedal, String> {
138    // Subcircuit equipment uses a separate compilation path.
139    if !pedal.subcircuits.is_empty() {
140        return compile_subcircuit_equipment(pedal, sample_rate, &options);
141    }
142
143    // Delegate to SPQR pipeline.
144    super::spqr_build::compile_via_spqr_with_options(pedal, sample_rate, options)
145}
146
147// ──────────────────────────────────────────────────────────────────────────
148// Cached compile for build.rs
149// ──────────────────────────────────────────────────────────────────────────
150
151/// Compute a cache key for a `.pedal` source + compile options.
152///
153/// Returns a hex string hash that changes when the source, sample rate,
154/// or relevant options change.
155pub fn compile_cache_key(source: &str, sample_rate: f64, options: &CompileOptions) -> String {
156    use std::collections::hash_map::DefaultHasher;
157    use std::hash::{Hash, Hasher};
158
159    let mut hasher = DefaultHasher::new();
160    source.hash(&mut hasher);
161    sample_rate.to_bits().hash(&mut hasher);
162    options.skip_k_tables.hash(&mut hasher);
163    options.collapse_nl.hash(&mut hasher);
164    options.skip_blockwise.hash(&mut hasher);
165    options.force_serial_blockwise.hash(&mut hasher);
166    options
167        .force_serial_blockwise_feedback_gain
168        .to_bits()
169        .hash(&mut hasher);
170    options.disable_iir.hash(&mut hasher);
171    options.coupled_blockwise_newton.hash(&mut hasher);
172    (options.oversampling as u8).hash(&mut hasher);
173    format!("{:016x}", hasher.finish())
174}
175
176/// Compile a `.pedal` source to a postcard blob with caching.
177///
178/// Single-call utility for build.rs. Handles parse → compile → serialize →
179/// cache write. Returns the postcard bytes (write them to OUT_DIR).
180///
181/// Skips recompilation when the cached blob exists and source hash matches.
182/// Uses `CompileOptions::from_cargo_profile()` internally if you want
183/// automatic debug/release detection, or pass your own options.
184///
185/// ```no_run
186/// // In build.rs:
187/// let options = pedalkernel::compiler::CompileOptions::from_cargo_profile();
188/// let blob = pedalkernel::compiler::compile_pedal_cached(
189///     &source, "TB303 Filter", "tb303_filter", 48_000.0, &options, out_path,
190/// ).expect("compile failed");
191/// // blob is already written to out_path/tb303_filter.postcard
192/// ```
193#[cfg(feature = "build-cache")]
194pub fn compile_pedal_cached(
195    source: &str,
196    name: &str,
197    file_stem: &str,
198    sample_rate: f64,
199    options: &CompileOptions,
200    out_dir: &std::path::Path,
201) -> Result<Vec<u8>, String> {
202    let blob_path = out_dir.join(format!("{file_stem}.postcard"));
203    let hash_path = out_dir.join(format!("{file_stem}.hash"));
204    let cache_key = compile_cache_key(source, sample_rate, options);
205
206    // Check cache
207    if blob_path.exists() && hash_path.exists() {
208        if let Ok(cached) = std::fs::read_to_string(&hash_path) {
209            if cached.trim() == cache_key {
210                if let Ok(blob) = std::fs::read(&blob_path) {
211                    eprintln!("  {name}: cached ({} bytes)", blob.len());
212                    return Ok(blob);
213                }
214            }
215        }
216    }
217
218    // Cache miss — compile
219    eprintln!("  {name}: parsing...");
220    let pedal_def =
221        crate::dsl::parse_pedal_file(source).map_err(|e| format!("{name}: parse failed: {e}"))?;
222    eprintln!(
223        "  {name}: compiling (skip_k_tables={}, oversampling={:?})...",
224        options.skip_k_tables, options.oversampling
225    );
226    let t0 = std::time::Instant::now();
227    let compiled = compile_pedal_with_options(&pedal_def, sample_rate, options.clone())
228        .map_err(|e| format!("{name}: compile failed: {e}"))?;
229    eprintln!(
230        "  {name}: compile done in {:.1}s, {} stages, {} ports",
231        t0.elapsed().as_secs_f64(),
232        compiled.stages.len(),
233        compiled.ports.len()
234    );
235    eprintln!("  {name}: serializing...");
236    let blob =
237        postcard::to_allocvec(&compiled).map_err(|e| format!("{name}: serialize failed: {e}"))?;
238
239    eprintln!(
240        "  {name}: compiled ({} bytes, k_tables={})",
241        blob.len(),
242        !options.skip_k_tables
243    );
244
245    // Write cache
246    let _ = std::fs::write(&blob_path, &blob);
247    let _ = std::fs::write(&hash_path, &cache_key);
248
249    Ok(blob)
250}
251
252// ──────────────────────────────────────────────────────────────────────────
253// Subcircuit equipment compiler
254// ──────────────────────────────────────────────────────────────────────────
255
256/// Compile an equipment definition that uses subcircuit blocks.
257///
258/// Each subcircuit is compiled independently as a sub-PedalDef (potentially at a
259/// different sample rate) and wired together via the routing
260/// graph produced by `subcircuit::build_routing()`.
261///
262/// When all components live inside subcircuits (`pedal.components.is_empty()`),
263/// the caller's normal pipeline is entirely replaced by this function.
264fn compile_subcircuit_equipment(
265    pedal: &PedalDef,
266    sample_rate: f64,
267    _options: &CompileOptions,
268) -> Result<CompiledPedal, String> {
269    use super::stage::SubcircuitProcessor;
270    use super::subcircuit;
271
272    // 1. Resolve subcircuits to compilable sub-PedalDefs
273    let resolved = subcircuit::resolve_subcircuits(pedal)
274        .map_err(|e| format!("subcircuit resolution: {e}"))?;
275
276    // 2. Compile each subcircuit
277    let mut processors: Vec<SubcircuitProcessor> = Vec::with_capacity(resolved.len());
278    for r in &resolved {
279        let sc_rate = sample_rate / r.rate_divisor as f64;
280        // Subcircuits use no oversampling by default to avoid N × M sample rate blowup.
281        let sc_options = CompileOptions {
282            collapse_nl: false,
283            oversampling: crate::oversampling::OversamplingFactor::X1,
284            ..CompileOptions::default()
285        };
286        let compiled = compile_pedal_with_options(&r.pedal_def, sc_rate, sc_options)
287            .map_err(|e| format!("subcircuit '{}': {e}", r.name))?;
288        processors.push(SubcircuitProcessor {
289            circuit: compiled,
290            name: r.name.clone(),
291            rate_divisor: r.rate_divisor,
292            rate_counter: r.rate_divisor.max(1),
293            held_output: 0.0,
294            prev_output: 0.0,
295        });
296    }
297
298    // 3. Build routing graph
299    let (routing, output_idx) = subcircuit::build_routing(pedal, &resolved)
300        .map_err(|e| format!("subcircuit routing: {e}"))?;
301
302    let n = processors.len();
303
304    // 4. Assemble routing-only CompiledPedal shell
305    Ok(CompiledPedal {
306        stages: Vec::new(),
307        stage_route_plan: pedalkernel_rt::processor::StageRoutePlan::default(),
308        push_pull_stages: Vec::new(),
309        pre_gain: 1.0,
310        output_gain: 1.0,
311        rail_saturation: super::compiled::RailSaturation::None,
312        rail_sat_oversampler: crate::oversampling::Oversampler::new(
313            crate::oversampling::OversamplingFactor::X1,
314        ),
315        sample_rate: sample_rate as crate::Wave,
316        controls: Vec::new(),
317        gain_range: (1.0, 1.0),
318        supply_voltage: pedal
319            .supplies
320            .first()
321            .map_or(9.0, |s| s.config.voltage as crate::Wave),
322        lfos: Vec::new(),
323        envelopes: Vec::new(),
324        bbds: Vec::new(),
325        delay_lines: Vec::new(),
326        springs: Vec::new(),
327        vcos: Vec::new(),
328        vcas: Vec::new(),
329        thermal: None,
330        tolerance_seed: 0,
331        oversampling: crate::oversampling::OversamplingFactor::X1,
332        opamp_stages: Vec::new(),
333        power_supply: None,
334        metrics_accumulator: None,
335        metrics_buffer: None,
336        #[cfg(feature = "diag")]
337        diag_ring: None,
338        input_loading: None,
339        output_loading: None,
340        output_dc_block: None,
341        sidechains: Vec::new(),
342        subcircuit_processors: processors,
343        subcircuit_routing: routing,
344        subcircuit_output_idx: Some(output_idx),
345        subcircuit_outputs: vec![0.0; n],
346        pot_smoothers: Vec::new(),
347        wiper_dividers: Vec::new(),
348        pot_mirrors: hashbrown::HashMap::new(),
349        base_grid_bias: 0.0,
350        multi_nl_recompute_counter: 0,
351        node_signals: Vec::new(),
352        bbd_wet_mix: 0.5,
353        bbd_mix_pot_id: None,
354        triggers: Vec::new(),
355        original_passive_values: hashbrown::HashMap::new(),
356        ports: Vec::new(),
357        port_values: Vec::new(),
358        internal_ports: Vec::new(),
359        boundary_loads: Vec::new(),
360        detector_led_coupling: None,
361        initialized: false,
362    })
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::dsl::parse_pedal_file;
369
370    #[test]
371    fn calibrate_output_gain_reasonable() {
372        // A simple resistor divider: output should be ~half input.
373        // calibrate should produce a gain ~2.0 to compensate.
374        let src = r#"
375            pedal "Divider" {
376                components {
377                    R1: resistor(10k)
378                    R2: resistor(10k)
379                }
380                nets {
381                    in -> R1.a
382                    R1.b -> R2.a
383                    R2.b -> gnd
384                    R1.b -> out
385                }
386                calibrate
387            }
388        "#;
389        let pedal = parse_pedal_file(src).unwrap();
390        assert!(pedal.calibrate);
391        let compiled = compile_pedal(&pedal, 48000.0).unwrap();
392        // Output gain should compensate for the ~6dB loss
393        assert!(
394            compiled.output_gain > 1.0,
395            "expected output_gain > 1.0, got {}",
396            compiled.output_gain
397        );
398        assert!(
399            compiled.output_gain < 10.0,
400            "expected output_gain < 10.0, got {}",
401            compiled.output_gain
402        );
403    }
404
405    #[test]
406    fn no_calibrate_gives_unity_gain() {
407        let src = r#"
408            pedal "Pass" {
409                components {
410                    R1: resistor(10k)
411                }
412                nets {
413                    in -> R1.a
414                    R1.b -> out
415                }
416            }
417        "#;
418        let pedal = parse_pedal_file(src).unwrap();
419        assert!(!pedal.calibrate);
420        let compiled = compile_pedal(&pedal, 48000.0).unwrap();
421        assert!(
422            (compiled.output_gain - 1.0).abs() < 1e-10,
423            "expected output_gain = 1.0, got {}",
424            compiled.output_gain
425        );
426    }
427
428    // ── Port binding tests ──────────────────────────────────────────
429
430    #[test]
431    fn compile_with_ports_produces_port_bindings() {
432        let src = r#"pedal "PortTest" {
433    supply 9V
434    ports {
435        audio_in: input
436        cv_cutoff: input
437        audio_out: output
438    }
439    components { R1: resistor(10k) R_cv: resistor(100k) }
440    nets {
441        audio_in -> R1.a
442        cv_cutoff -> R_cv.a
443        R_cv.b -> R1.a
444        R1.b -> audio_out
445    }
446    controls {}
447}"#;
448        let pedal = parse_pedal_file(src).unwrap();
449        assert_eq!(pedal.ports.len(), 3);
450
451        let compiled = compile_pedal(&pedal, 48000.0).unwrap();
452        eprintln!(
453            "  ports: {:?}",
454            compiled
455                .ports
456                .iter()
457                .map(|p| (&p.name, p.direction, p.index, p.node_id))
458                .collect::<Vec<_>>()
459        );
460
461        assert_eq!(compiled.ports.len(), 3, "should have 3 port bindings");
462        assert_eq!(compiled.port_values.len(), 3, "port_values should match");
463
464        // Check names and directions
465        let audio_in = compiled.ports.iter().find(|p| p.name == "audio_in");
466        assert!(audio_in.is_some(), "should have audio_in port");
467        assert_eq!(
468            audio_in.unwrap().direction,
469            pedalkernel_rt::PortDirection::Input
470        );
471
472        let cv_cutoff = compiled.ports.iter().find(|p| p.name == "cv_cutoff");
473        assert!(cv_cutoff.is_some(), "should have cv_cutoff port");
474        assert_eq!(
475            cv_cutoff.unwrap().direction,
476            pedalkernel_rt::PortDirection::Input
477        );
478
479        let audio_out = compiled.ports.iter().find(|p| p.name == "audio_out");
480        assert!(audio_out.is_some(), "should have audio_out port");
481        assert_eq!(
482            audio_out.unwrap().direction,
483            pedalkernel_rt::PortDirection::Output
484        );
485
486        // Node IDs should be valid (not MAX)
487        for port in &compiled.ports {
488            assert_ne!(
489                port.node_id,
490                usize::MAX,
491                "port {} should have valid node_id",
492                port.name
493            );
494        }
495    }
496
497    #[test]
498    fn set_control_falls_back_to_matching_cv_input_port() {
499        use crate::PedalProcessor;
500
501        let src = r#"pedal "ControlToCvPort" {
502    supply 9V
503    ports {
504        audio_in: input
505        cv_cutoff: input
506        audio_out: output
507    }
508    components { R1: resistor(10k) R_cv: resistor(100k) }
509    nets {
510        audio_in -> R1.a
511        cv_cutoff -> R_cv.a
512        R_cv.b -> R1.a
513        R1.b -> audio_out
514    }
515    controls {}
516}"#;
517        let pedal = parse_pedal_file(src).unwrap();
518        let mut compiled = compile_pedal(&pedal, 48000.0).unwrap();
519        let cv_idx = compiled.resolve_port("cv_cutoff").unwrap();
520
521        compiled.set_control("Cutoff", 0.75);
522
523        assert_eq!(
524            compiled.port_values[cv_idx], 0.75,
525            "unbound Cutoff control should drive matching cv_cutoff input port"
526        );
527    }
528
529    #[test]
530    fn compile_without_ports_has_empty_ports() {
531        let src = r#"pedal "NoPort" {
532    supply 9V
533    components { R1: resistor(10k) }
534    nets { in -> R1.a  R1.b -> out }
535    controls {}
536}"#;
537        let pedal = parse_pedal_file(src).unwrap();
538        let compiled = compile_pedal(&pedal, 48000.0).unwrap();
539        // No ports section → empty (or implicit in/out — depends on design)
540        // For now, empty is fine. Backward compat.
541        eprintln!("  ports: {}", compiled.ports.len());
542    }
543
544    #[test]
545    fn process_ports_passes_signal() {
546        use crate::PedalProcessor;
547
548        let src = r#"pedal "PortIO" {
549    supply 9V
550    ports {
551        audio_in: input
552        audio_out: output
553    }
554    components { R1: resistor(10k) }
555    nets { audio_in -> R1.a  R1.b -> audio_out }
556    controls {}
557}"#;
558        let pedal = parse_pedal_file(src).unwrap();
559        let mut compiled = compile_pedal(&pedal, 48000.0).unwrap();
560
561        let in_idx = compiled.resolve_port("audio_in").unwrap();
562        let out_idx = compiled.resolve_port("audio_out").unwrap();
563        assert_eq!(compiled.port_count(), 2);
564
565        // Process several samples with a sine input
566        let mut ports = vec![0.0f64; compiled.port_count()];
567        let mut peak = 0.0f64;
568        for i in 0..4800 {
569            let input = (2.0 * std::f64::consts::PI * 440.0 * i as f64 / 48000.0).sin();
570            ports[in_idx] = input;
571            compiled.process_ports(&mut ports);
572            peak = peak.max(ports[out_idx].abs());
573        }
574
575        eprintln!("  process_ports: peak output = {peak:.6}");
576        assert!(
577            peak > 0.01,
578            "signal should flow through ports: peak={peak:.6}. \
579             If 0, audio_in port isn't reaching the WDF stages."
580        );
581    }
582
583    #[test]
584    fn process_ports_backward_compat_with_old_process() {
585        use crate::PedalProcessor;
586
587        // Circuit using old-style in/out (no ports section)
588        let src = r#"pedal "OldStyle" {
589    supply 9V
590    components { R1: resistor(10k) }
591    nets { in -> R1.a  R1.b -> out }
592    controls {}
593}"#;
594        let pedal = parse_pedal_file(src).unwrap();
595        let mut compiled = compile_pedal(&pedal, 48000.0).unwrap();
596
597        // Old API should still work
598        let mut peak = 0.0f64;
599        for i in 0..4800 {
600            let input = (2.0 * std::f64::consts::PI * 440.0 * i as f64 / 48000.0).sin();
601            let out = compiled.process(input);
602            peak = peak.max(out.abs());
603        }
604        eprintln!("  old process(): peak = {peak:.6}");
605        assert!(
606            peak > 0.01,
607            "old process(f64) should still work: peak={peak:.6}"
608        );
609    }
610
611    #[test]
612    fn cv_port_modulates_signal() {
613        use crate::PedalProcessor;
614
615        // Two input ports: audio_in and cv_mod. cv_mod connects through
616        // a resistor to the same node as audio_in, so it adds to the signal.
617        let src = r#"pedal "CVTest" {
618    supply 9V
619    ports {
620        audio_in: input
621        cv_mod: input
622        audio_out: output
623    }
624    components {
625        R1: resistor(10k)
626        R_cv: resistor(100k)
627    }
628    nets {
629        audio_in -> R1.a
630        cv_mod -> R_cv.a
631        R_cv.b -> R1.a
632        R1.b -> audio_out
633    }
634    controls {}
635}"#;
636        let pedal = parse_pedal_file(src).unwrap();
637        let mut compiled = compile_pedal(&pedal, 48000.0).unwrap();
638
639        let in_idx = compiled.resolve_port("audio_in").unwrap();
640        let cv_idx = compiled.resolve_port("cv_mod").unwrap();
641        let out_idx = compiled.resolve_port("audio_out").unwrap();
642
643        // Process with CV = 0 (no modulation)
644        let mut ports = vec![0.0f64; compiled.port_count()];
645        let mut peak_no_cv = 0.0f64;
646        for i in 0..4800 {
647            let input = (2.0 * std::f64::consts::PI * 440.0 * i as f64 / 48000.0).sin();
648            ports[in_idx] = input;
649            ports[cv_idx] = 0.0;
650            compiled.process_ports(&mut ports);
651            if i >= 2400 {
652                peak_no_cv = peak_no_cv.max(ports[out_idx].abs());
653            }
654        }
655
656        // Process with CV = 0.5 (adds DC offset — should change output)
657        compiled.reset();
658        let mut peak_with_cv = 0.0f64;
659        for i in 0..4800 {
660            let input = (2.0 * std::f64::consts::PI * 440.0 * i as f64 / 48000.0).sin();
661            ports[in_idx] = input;
662            ports[cv_idx] = 5.0; // large DC offset via CV
663            compiled.process_ports(&mut ports);
664            if i >= 2400 {
665                peak_with_cv = peak_with_cv.max(ports[out_idx].abs());
666            }
667        }
668
669        eprintln!("  CV test: no_cv={peak_no_cv:.4}, with_cv={peak_with_cv:.4}");
670        // CV should change the output (either level or DC offset)
671        assert!(
672            (peak_with_cv - peak_no_cv).abs() > 0.001 || peak_with_cv > 0.01,
673            "CV port should affect output: no_cv={peak_no_cv:.4}, with_cv={peak_with_cv:.4}"
674        );
675    }
676
677    #[test]
678    fn port_vs_leaf_is_addressable() {
679        // Verify set_voltage_by_port finds, sets, and isn't overwritten
680        use pedalkernel_rt::dyn_node::DynNode;
681        use pedalkernel_rt::wdf_leaf::*;
682
683        // Build: Series(VS_main, Series(VS_port, Resistor))
684        // VS_main is the audio input, VS_port is the CV port.
685        let vs_main = DynNode::Leaf(LeafKind::VoltageSource(WdfVoltageSource {
686            voltage: 0.0,
687            rp: 1.0,
688            is_cathode_bias: false,
689            port_name: None,
690        }));
691        let vs_port = DynNode::Leaf(LeafKind::VoltageSource(WdfVoltageSource {
692            voltage: 0.0,
693            rp: 1.0,
694            is_cathode_bias: false,
695            port_name: Some("cv_test".into()),
696        }));
697        let r = DynNode::Leaf(LeafKind::Resistor(WdfResistor {
698            comp_id: Some("R1".into()),
699            rp: 10000.0,
700            last_a: 0.0,
701        }));
702        let cv_branch = DynNode::Series(Box::new(vs_port), Box::new(r));
703        let mut tree = DynNode::Series(Box::new(vs_main), Box::new(cv_branch));
704        tree.recompute();
705
706        // Set port VS to 3.0
707        let found = tree.set_voltage_by_port("cv_test", 3.0);
708        assert!(found, "should find VS by port name");
709
710        // Global set_voltage(1.0) should set VS_main but NOT VS_port
711        tree.set_voltage(1.0);
712
713        // Scatter and check: with VS_main=1.0 and VS_port=3.0, the
714        // reflected wave should differ from VS_main=1.0, VS_port=0.0
715        let b_with_cv = tree.reflected();
716
717        // Reset port to 0 and scatter again
718        tree.set_voltage_by_port("cv_test", 0.0);
719        tree.set_voltage(1.0);
720        let b_without_cv = tree.reflected();
721
722        eprintln!("  reflected: with_cv={b_with_cv:.6}, without_cv={b_without_cv:.6}");
723        assert!(
724            (b_with_cv - b_without_cv).abs() > 0.01,
725            "port VS should affect scattering: with={b_with_cv:.4}, without={b_without_cv:.4}"
726        );
727    }
728
729    /// Verify that a second declared input port whose node sits inside a
730    /// PassiveRType (all-passive) stage actually drives the circuit.
731    ///
732    /// Circuit: audio_in → R_main(10k) → node_m → C_noise(100n) → noise_in
733    ///                                              node_m → D1 (silicon) → gnd
734    ///                                              node_m → audio_out
735    ///
736    /// The cap C_noise forces `node_m` to be compiled as a reactive WDF leaf
737    /// rather than stamped purely in the G-matrix, so the passive group becomes
738    /// a PassiveRType stage with children.  `noise_in` connects to `node_m` via
739    /// C_noise; injecting 5 V there must visibly change the output.
740    ///
741    /// Two assertions:
742    /// 1. `max_diff > 1e-6` — noise_in=5 V shifts the output.
743    /// 2. backward-compat diff < 1e-12 — noise_in=0 is byte-identical to
744    ///    single-port `process()`.
745    #[test]
746    fn second_input_port_drives_circuit_via_process_ports() {
747        use crate::PedalProcessor;
748
749        let src = r#"pedal "TwoPortNoise" {
750    ports {
751        audio_in:  input
752        noise_in:  input
753        audio_out: output
754    }
755    components {
756        R_main:  resistor(10k)
757        C_noise: cap(100n)
758        D1:      diode_pair(silicon)
759    }
760    nets {
761        audio_in  -> R_main.a
762        R_main.b  -> C_noise.a
763        C_noise.b -> noise_in
764        R_main.b  -> D1.a
765        D1.b      -> gnd
766        R_main.b  -> audio_out
767    }
768    controls {}
769}"#;
770        let pedal = parse_pedal_file(src).unwrap();
771        let mut compiled = compile_pedal(&pedal, 48000.0).unwrap();
772
773        let in_idx = compiled.resolve_port("audio_in").unwrap();
774        let noise_idx = compiled.resolve_port("noise_in").unwrap();
775        let out_idx = compiled.resolve_port("audio_out").unwrap();
776
777        // --- run 1: noise_in = 5 V DC + 440 Hz audio ---
778        let n_samples = 2048usize;
779        let mut ports = vec![0.0f64; compiled.port_count()];
780        let mut out_with_noise = vec![0.0f64; n_samples];
781        for i in 0..n_samples {
782            let sig = (2.0 * std::f64::consts::PI * 440.0 * i as f64 / 48000.0).sin();
783            ports[in_idx] = sig;
784            ports[noise_idx] = 5.0;
785            compiled.process_ports(&mut ports);
786            out_with_noise[i] = ports[out_idx];
787        }
788
789        // --- run 2: noise_in = 0 (multi-port call) ---
790        compiled.reset();
791        let mut out_no_noise = vec![0.0f64; n_samples];
792        for i in 0..n_samples {
793            let sig = (2.0 * std::f64::consts::PI * 440.0 * i as f64 / 48000.0).sin();
794            ports[in_idx] = sig;
795            ports[noise_idx] = 0.0;
796            compiled.process_ports(&mut ports);
797            out_no_noise[i] = ports[out_idx];
798        }
799
800        // --- run 3: single-port process() for backward-compat check ---
801        compiled.reset();
802        let mut out_single = vec![0.0f64; n_samples];
803        for i in 0..n_samples {
804            let sig = (2.0 * std::f64::consts::PI * 440.0 * i as f64 / 48000.0).sin();
805            out_single[i] = compiled.process(sig);
806        }
807
808        let max_diff: f64 = out_with_noise
809            .iter()
810            .zip(&out_no_noise)
811            .map(|(a, b)| (a - b).abs())
812            .fold(0.0f64, f64::max);
813
814        let compat_diff: f64 = out_no_noise
815            .iter()
816            .zip(&out_single)
817            .map(|(a, b)| (a - b).abs())
818            .fold(0.0f64, f64::max);
819
820        eprintln!(
821            "  second_port: max_diff={max_diff:e} (want >1e-6), compat_diff={compat_diff:e} (want <1e-12)"
822        );
823
824        assert!(
825            max_diff > 1e-6,
826            "noise_in=5V must visibly change output vs noise_in=0: max_diff={max_diff:e}"
827        );
828        assert!(
829            compat_diff < 1e-12,
830            "process_ports(noise=0) must be byte-identical to process(): compat_diff={compat_diff:e}"
831        );
832    }
833}