1use crate::dsl::*;
7use crate::oversampling::OversamplingFactor;
8use crate::tolerance::ToleranceEngine;
9
10use super::compiled::*;
11
12#[derive(Clone)]
17pub struct CompileOptions {
18 pub oversampling: OversamplingFactor,
19 pub tolerance: ToleranceEngine,
20 pub thermal: bool,
21 pub collapse_nl: bool,
26 pub skip_k_tables: bool,
31 pub skip_blockwise: bool,
35 pub force_serial_blockwise: bool,
40 pub force_serial_blockwise_feedback_gain: f64,
44 pub disable_iir: bool,
48 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 pub fn force_monolithic(self) -> Self {
85 Self {
86 skip_blockwise: true,
87 ..self
88 }
89 }
90
91 pub fn release() -> Self {
93 Self::default()
94 }
95
96 pub fn debug() -> Self {
99 Self {
100 skip_k_tables: true,
101 ..Self::default()
102 }
103 }
104
105 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
120pub fn compile_pedal(pedal: &PedalDef, sample_rate: f64) -> Result<CompiledPedal, String> {
126 compile_pedal_with_options(pedal, sample_rate, CompileOptions::default())
127}
128
129pub fn compile_pedal_with_options(
134 pedal: &PedalDef,
135 sample_rate: f64,
136 options: CompileOptions,
137) -> Result<CompiledPedal, String> {
138 if !pedal.subcircuits.is_empty() {
140 return compile_subcircuit_equipment(pedal, sample_rate, &options);
141 }
142
143 super::spqr_build::compile_via_spqr_with_options(pedal, sample_rate, options)
145}
146
147pub 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#[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 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 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 let _ = std::fs::write(&blob_path, &blob);
247 let _ = std::fs::write(&hash_path, &cache_key);
248
249 Ok(blob)
250}
251
252fn 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 let resolved = subcircuit::resolve_subcircuits(pedal)
274 .map_err(|e| format!("subcircuit resolution: {e}"))?;
275
276 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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; 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 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 use pedalkernel_rt::dyn_node::DynNode;
681 use pedalkernel_rt::wdf_leaf::*;
682
683 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 let found = tree.set_voltage_by_port("cv_test", 3.0);
708 assert!(found, "should find VS by port name");
709
710 tree.set_voltage(1.0);
712
713 let b_with_cv = tree.reflected();
716
717 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 #[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 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 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 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}