Skip to main content

pedalkernel/
pedalboard.rs

1//! Pedalboard processor — chains multiple compiled pedals in series.
2//!
3//! Implements [`PedalProcessor`] so it can be used directly with
4//! [`AudioEngine::start()`] — zero changes to the JACK engine.
5//!
6//! Control routing uses a `"idx:label"` prefix scheme:
7//! - `"0:Drive"` → pedal index 0, knob "Drive"
8//! - `"2:__bypass__"` → toggle bypass on pedal index 2
9
10use crate::board::{find_global_board, BoardDef, BoardEntry};
11use crate::compiler::{compile_pedal, compile_split_pedal, CompiledPedal, SplitCompiledPedal};
12use crate::dsl::parse_pedal_file;
13use crate::loading::InterstageLoading;
14use crate::PedalProcessor;
15use std::path::Path;
16
17/// A single slot in the pedalboard chain.
18///
19/// When `processor` is `None`, the slot acts as dry passthrough — this
20/// happens when a pedal fails to load or compile. A warning is emitted
21/// at build time and the slot is marked as failed.
22struct PedalSlot {
23    name: String,
24    processor: Option<CompiledPedal>,
25    bypassed: bool,
26    failed: bool,
27}
28
29/// An FX send/return loop backed by a split amp pedal.
30///
31/// The amp's pre-section runs first, then the FX chain, then the post-section.
32struct FxLoopSlot {
33    /// The split amp pedal (pre → FX chain → post).
34    amp: SplitCompiledPedal,
35    /// Effects inside the FX loop.
36    effects: Vec<PedalSlot>,
37    /// Wet/dry mix for the FX chain (0.0 = bypass loop, 1.0 = full wet).
38    mix: f64,
39}
40
41/// A slot in the signal chain — either a single pedal or an FX loop group.
42enum ChainSlot {
43    Pedal(PedalSlot),
44    FxLoop(FxLoopSlot),
45}
46
47/// Chains N `CompiledPedal` instances in series, with optional FX loop groups.
48///
49/// When impedance loading is enabled, interstage loading models are inserted
50/// between adjacent pedals to simulate the electrical interaction between
51/// source and load impedances.
52pub struct PedalboardProcessor {
53    chain: Vec<ChainSlot>,
54    /// Interstage loading between pedals. `loading[i]` sits between
55    /// `chain[i]` and `chain[i+1]`. Length = max(0, chain.len() - 1).
56    loading: Vec<InterstageLoading>,
57}
58
59impl PedalboardProcessor {
60    /// Build a pedalboard from a parsed `.board` definition.
61    ///
62    /// Each `.pedal` file is read relative to `board_dir`, compiled at the
63    /// given sample rate, and any knob overrides from the board file are applied.
64    ///
65    /// If a pedal fails to load or compile, it is replaced with a dry
66    /// passthrough slot and a warning is emitted to stderr. The board
67    /// still loads — only the broken pedal is silent.
68    pub fn from_board(
69        board: &BoardDef,
70        board_dir: &Path,
71        sample_rate: f64,
72    ) -> Result<Self, String> {
73        let (pb, warnings) = Self::from_board_with_warnings(board, board_dir, sample_rate);
74        for w in &warnings {
75            eprintln!("WARNING: {w}");
76        }
77        Ok(pb)
78    }
79
80    /// Build a pedalboard, returning both the processor and any warnings.
81    ///
82    /// Pedals that fail to read, parse, or compile are replaced with dry
83    /// passthrough slots. The returned warnings describe each failure.
84    ///
85    /// If a `.global.board` file is found (by walking up from `board_dir`),
86    /// its `start` pedals are prepended and `end` pedals are appended to
87    /// the signal chain.
88    pub fn from_board_with_warnings(
89        board: &BoardDef,
90        board_dir: &Path,
91        sample_rate: f64,
92    ) -> (Self, Vec<String>) {
93        let mut chain = Vec::new();
94        let mut warnings = Vec::new();
95
96        // Detect global board
97        let global = find_global_board(board_dir);
98        if let Some((ref def, ref dir)) = global {
99            let n_start = def.start.len();
100            let n_end = def.end.len();
101            eprintln!(
102                "Global: {} ({} start, {} end)",
103                dir.join(".global.board").display(),
104                n_start,
105                n_end,
106            );
107        }
108
109        // Prepend global start pedals
110        if let Some((ref def, ref global_dir)) = global {
111            for entry in &def.start {
112                let slot = Self::compile_pedal_entry(entry, global_dir, sample_rate, &mut warnings);
113                chain.push(ChainSlot::Pedal(slot));
114            }
115        }
116
117        // Build main board entries
118        for entry in &board.entries {
119            match entry {
120                BoardEntry::Pedal(pedal_entry) => {
121                    let slot = Self::compile_pedal_entry(
122                        pedal_entry,
123                        board_dir,
124                        sample_rate,
125                        &mut warnings,
126                    );
127                    chain.push(ChainSlot::Pedal(slot));
128                }
129                BoardEntry::FxLoop(group) => {
130                    // Find the referenced amp pedal — it must have been declared
131                    // earlier in the board as a regular Pedal entry.
132                    // We need to load, parse, and compile it as a split pedal.
133                    let amp_entry = board.entries.iter().find_map(|e| {
134                        if let BoardEntry::Pedal(p) = e {
135                            if p.id == group.target_id {
136                                return Some(p);
137                            }
138                        }
139                        None
140                    });
141
142                    let amp_result: Result<(String, SplitCompiledPedal), String> = match amp_entry {
143                        None => Err(format!(
144                            "fx_loop references '{}' but no pedal with that ID exists",
145                            group.target_id
146                        )),
147                        Some(entry) => {
148                            let pedal_path = {
149                                let direct = board_dir.join(&entry.path);
150                                if direct.exists() {
151                                    direct
152                                } else if let Some(found) =
153                                    Self::find_in_subdirs(board_dir, &entry.path)
154                                {
155                                    found
156                                } else {
157                                    direct
158                                }
159                            };
160                            (|| {
161                                let source = std::fs::read_to_string(&pedal_path).map_err(|e| {
162                                    format!(
163                                        "Error reading amp '{}' at {}: {e}",
164                                        entry.id,
165                                        pedal_path.display()
166                                    )
167                                })?;
168                                let pedal_def = parse_pedal_file(&source)
169                                    .map_err(|e| format!("Parse error in '{}': {e}", entry.id))?;
170
171                                if !pedal_def.has_fx_loop() {
172                                    return Err(format!(
173                                        "Pedal '{}' has no fx_send/fx_return nodes",
174                                        entry.id
175                                    ));
176                                }
177
178                                let mut split = compile_split_pedal(&pedal_def, sample_rate)
179                                    .map_err(|e| {
180                                        format!("Failed to split-compile '{}': {e}", entry.id)
181                                    })?;
182
183                                // Apply overrides from the board
184                                for ctrl in &pedal_def.controls {
185                                    split.set_control(&ctrl.label, ctrl.default);
186                                }
187                                for (label, val) in &entry.overrides {
188                                    split.set_control(label, *val);
189                                }
190
191                                Ok((pedal_def.name.clone(), split))
192                            })()
193                        }
194                    };
195
196                    match amp_result {
197                        Ok((name, split)) => {
198                            // Remove the regular pedal entry for this amp from the chain
199                            // (it's now handled by the FX loop)
200                            chain.retain(|slot| {
201                                if let ChainSlot::Pedal(p) = slot {
202                                    p.name != name || p.failed
203                                } else {
204                                    true
205                                }
206                            });
207
208                            let mut effects = Vec::with_capacity(group.pedals.len());
209                            for pedal_entry in &group.pedals {
210                                let slot = Self::compile_pedal_entry(
211                                    pedal_entry,
212                                    board_dir,
213                                    sample_rate,
214                                    &mut warnings,
215                                );
216                                effects.push(slot);
217                            }
218                            chain.push(ChainSlot::FxLoop(FxLoopSlot {
219                                amp: split,
220                                effects,
221                                mix: group.mix,
222                            }));
223                        }
224                        Err(msg) => {
225                            warnings
226                                .push(format!("FX loop for '{}' failed: {msg}", group.target_id));
227                            // Fall back: just add the FX loop effects as regular pedals
228                            for pedal_entry in &group.pedals {
229                                let slot = Self::compile_pedal_entry(
230                                    pedal_entry,
231                                    board_dir,
232                                    sample_rate,
233                                    &mut warnings,
234                                );
235                                chain.push(ChainSlot::Pedal(slot));
236                            }
237                        }
238                    }
239                }
240            }
241        }
242
243        // Append global end pedals
244        if let Some((ref def, ref global_dir)) = global {
245            for entry in &def.end {
246                let slot = Self::compile_pedal_entry(entry, global_dir, sample_rate, &mut warnings);
247                chain.push(ChainSlot::Pedal(slot));
248            }
249        }
250
251        // Create interstage loading between adjacent pedals.
252        // Default: transparent (buffered output → high-Z input), which is the
253        // most common modern pedal configuration and has negligible effect.
254        let n = chain.len();
255        let loading = if n > 1 {
256            (0..n - 1)
257                .map(|_| InterstageLoading::transparent(sample_rate as crate::Wave))
258                .collect()
259        } else {
260            Vec::new()
261        };
262
263        (Self { chain, loading }, warnings)
264    }
265
266    /// Build a pedalboard from a parsed `.board` definition, using a resolver
267    /// function instead of filesystem reads.
268    ///
269    /// The `resolver` takes a pedal path string (as written in the `.board` file)
270    /// and returns the `.pedal` source text.  This allows building boards from
271    /// `include_str!`'d sources without any filesystem access — useful for
272    /// embedded/factory boards in the VST plugin.
273    ///
274    /// Global boards and FX loops are not supported in resolver mode (boards
275    /// referencing FX loops should use [`from_board_with_warnings`] instead).
276    pub fn from_board_with_resolver(
277        board: &BoardDef,
278        sample_rate: f64,
279        resolver: impl Fn(&str) -> Option<String>,
280    ) -> (Self, Vec<String>) {
281        let mut chain = Vec::new();
282        let mut warnings = Vec::new();
283
284        for entry in &board.entries {
285            match entry {
286                BoardEntry::Pedal(pedal_entry) => {
287                    let slot = Self::compile_pedal_entry_from_source(
288                        pedal_entry,
289                        &resolver,
290                        sample_rate,
291                        &mut warnings,
292                    );
293                    chain.push(ChainSlot::Pedal(slot));
294                }
295                BoardEntry::FxLoop(group) => {
296                    warnings.push(format!(
297                        "FX loop '{}' skipped — not supported in resolver mode",
298                        group.target_id,
299                    ));
300                    // Add the FX loop effects as regular pedals (best effort).
301                    for pedal_entry in &group.pedals {
302                        let slot = Self::compile_pedal_entry_from_source(
303                            pedal_entry,
304                            &resolver,
305                            sample_rate,
306                            &mut warnings,
307                        );
308                        chain.push(ChainSlot::Pedal(slot));
309                    }
310                }
311            }
312        }
313
314        let n = chain.len();
315        let loading = if n > 1 {
316            (0..n - 1)
317                .map(|_| InterstageLoading::transparent(sample_rate as crate::Wave))
318                .collect()
319        } else {
320            Vec::new()
321        };
322
323        (Self { chain, loading }, warnings)
324    }
325
326    /// Compile a pedal entry using a resolver instead of filesystem reads.
327    fn compile_pedal_entry_from_source(
328        entry: &crate::board::BoardPedalEntry,
329        resolver: &impl Fn(&str) -> Option<String>,
330        sample_rate: f64,
331        warnings: &mut Vec<String>,
332    ) -> PedalSlot {
333        let result: Result<(String, CompiledPedal), String> = (|| {
334            let source = resolver(&entry.path)
335                .ok_or_else(|| format!("Pedal '{}' not found: {}", entry.id, entry.path))?;
336            let pedal_def = parse_pedal_file(&source)
337                .map_err(|e| format!("Parse error in '{}' ({}): {e}", entry.id, entry.path))?;
338            let mut proc = compile_pedal(&pedal_def, sample_rate).map_err(|e| {
339                format!("Compilation error in '{}' ({}): {e}", entry.id, entry.path)
340            })?;
341
342            for ctrl in &pedal_def.controls {
343                proc.set_control(&ctrl.label, ctrl.default as crate::Wave);
344            }
345            for (label, val) in &entry.overrides {
346                proc.set_control(label, *val as crate::Wave);
347            }
348
349            Ok((pedal_def.name.clone(), proc))
350        })();
351
352        match result {
353            Ok((name, proc)) => PedalSlot {
354                name,
355                processor: Some(proc),
356                bypassed: false,
357                failed: false,
358            },
359            Err(msg) => {
360                warnings.push(format!(
361                    "Pedal '{}' failed — using dry passthrough: {msg}",
362                    entry.id
363                ));
364                PedalSlot {
365                    name: format!("{} [FAILED]", entry.id),
366                    processor: None,
367                    bypassed: false,
368                    failed: true,
369                }
370            }
371        }
372    }
373
374    /// Compile a single pedal entry into a PedalSlot.
375    fn compile_pedal_entry(
376        entry: &crate::board::BoardPedalEntry,
377        board_dir: &Path,
378        sample_rate: f64,
379        warnings: &mut Vec<String>,
380    ) -> PedalSlot {
381        // Try direct path first, then search subdirectories by filename.
382        let pedal_path = {
383            let direct = board_dir.join(&entry.path);
384            if direct.exists() {
385                direct
386            } else if let Some(found) = Self::find_in_subdirs(board_dir, &entry.path) {
387                found
388            } else {
389                direct // fall through to produce the original error message
390            }
391        };
392
393        let result: Result<(String, CompiledPedal), String> = (|| {
394            let source = std::fs::read_to_string(&pedal_path).map_err(|e| {
395                format!(
396                    "Error reading pedal '{}' at {}: {e}",
397                    entry.id,
398                    pedal_path.display()
399                )
400            })?;
401            let pedal_def = parse_pedal_file(&source)
402                .map_err(|e| format!("Parse error in '{}' ({}): {e}", entry.id, entry.path))?;
403            let mut proc = compile_pedal(&pedal_def, sample_rate).map_err(|e| {
404                format!("Compilation error in '{}' ({}): {e}", entry.id, entry.path)
405            })?;
406
407            // Apply default control values from the .pedal file
408            for ctrl in &pedal_def.controls {
409                proc.set_control(&ctrl.label, ctrl.default as crate::Wave);
410            }
411
412            // Apply overrides from the .board file
413            for (label, val) in &entry.overrides {
414                proc.set_control(label, *val as crate::Wave);
415            }
416
417            Ok((pedal_def.name.clone(), proc))
418        })();
419
420        match result {
421            Ok((name, proc)) => PedalSlot {
422                name,
423                processor: Some(proc),
424                bypassed: false,
425                failed: false,
426            },
427            Err(msg) => {
428                warnings.push(format!(
429                    "Pedal '{}' failed — using dry passthrough: {msg}",
430                    entry.id
431                ));
432                PedalSlot {
433                    name: format!("{} [FAILED]", entry.id),
434                    processor: None,
435                    bypassed: false,
436                    failed: true,
437                }
438            }
439        }
440    }
441
442    /// Search subdirectories of `base` for a file matching `filename`.
443    fn find_in_subdirs(base: &Path, filename: &str) -> Option<std::path::PathBuf> {
444        let target = Path::new(filename).file_name()?.to_str()?;
445        Self::walk_for_file(base, target)
446    }
447
448    fn walk_for_file(dir: &Path, target: &str) -> Option<std::path::PathBuf> {
449        for entry in std::fs::read_dir(dir).ok()?.flatten() {
450            let path = entry.path();
451            if path.is_dir() {
452                if let Some(found) = Self::walk_for_file(&path, target) {
453                    return Some(found);
454                }
455            } else if path.file_name().and_then(|n| n.to_str()) == Some(target) {
456                return Some(path);
457            }
458        }
459        None
460    }
461
462    /// Flat list of all pedal slots (for indexed access).
463    /// FX loop effect slots are flattened — indices are stable across the board.
464    fn flat_pedals(&self) -> Vec<&PedalSlot> {
465        let mut out = Vec::new();
466        for slot in &self.chain {
467            match slot {
468                ChainSlot::Pedal(p) => out.push(p),
469                ChainSlot::FxLoop(lp) => {
470                    for p in &lp.effects {
471                        out.push(p);
472                    }
473                }
474            }
475        }
476        out
477    }
478
479    /// Mutable flat list of all pedal slots.
480    fn flat_pedals_mut(&mut self) -> Vec<&mut PedalSlot> {
481        let mut out = Vec::new();
482        for slot in &mut self.chain {
483            match slot {
484                ChainSlot::Pedal(p) => out.push(p),
485                ChainSlot::FxLoop(lp) => {
486                    for p in &mut lp.effects {
487                        out.push(p);
488                    }
489                }
490            }
491        }
492        out
493    }
494
495    /// Number of pedals in the chain (flattened, including FX loop contents).
496    pub fn len(&self) -> usize {
497        self.flat_pedals().len()
498    }
499
500    /// Whether the pedalboard is empty.
501    pub fn is_empty(&self) -> bool {
502        self.chain.is_empty()
503    }
504
505    /// Get the name of pedal at the given flat index.
506    pub fn pedal_name(&self, index: usize) -> Option<&str> {
507        self.flat_pedals().get(index).map(|s| s.name.as_str())
508    }
509
510    /// Check if a pedal is bypassed.
511    pub fn is_pedal_bypassed(&self, index: usize) -> bool {
512        self.flat_pedals().get(index).is_some_and(|s| s.bypassed)
513    }
514
515    /// Check if a pedal slot failed to compile (dry passthrough).
516    pub fn is_pedal_failed(&self, index: usize) -> bool {
517        self.flat_pedals().get(index).is_some_and(|s| s.failed)
518    }
519
520    /// Configure the impedance loading between two adjacent pedals.
521    ///
522    /// `junction_index` is the junction between `chain[junction_index]` and
523    /// `chain[junction_index + 1]`. The `source` describes the output impedance
524    /// of the preceding pedal, and `load` describes the input impedance of
525    /// the following pedal.
526    ///
527    /// This models the electrical interaction that causes real pedals to sound
528    /// different depending on what's connected before/after them. For example,
529    /// a Fuzz Face after a buffered pedal sounds different than directly after
530    /// a guitar, because its low input impedance loads the source differently.
531    pub fn set_interstage_loading(
532        &mut self,
533        junction_index: usize,
534        source: crate::loading::ImpedanceModel,
535        load: crate::loading::ImpedanceModel,
536        sample_rate: f64,
537    ) {
538        if junction_index < self.loading.len() {
539            self.loading[junction_index] =
540                InterstageLoading::new(source, load, sample_rate as crate::Wave);
541        }
542    }
543
544    /// Get the input impedance of the pedalboard (Ω).
545    ///
546    /// Returns the input impedance of the first pedal in the chain.
547    /// If the chain is empty or the first pedal has no processor, returns high-Z (1MΩ).
548    pub fn input_impedance(&self) -> f64 {
549        for slot in &self.chain {
550            match slot {
551                ChainSlot::Pedal(p) => {
552                    if !p.bypassed {
553                        if let Some(ref proc) = p.processor {
554                            return proc.input_impedance() as f64;
555                        }
556                    }
557                }
558                ChainSlot::FxLoop(fx) => {
559                    // FX loop's input impedance is the amp's preamp input
560                    return fx.amp.input_impedance();
561                }
562            }
563        }
564        1_000_000.0 // High-Z default
565    }
566
567    /// Get the output impedance of the pedalboard (Ω).
568    ///
569    /// Returns the output impedance of the last pedal in the chain.
570    /// If the chain is empty or the last pedal has no processor, returns low-Z (1kΩ).
571    pub fn output_impedance(&self) -> f64 {
572        for slot in self.chain.iter().rev() {
573            match slot {
574                ChainSlot::Pedal(p) => {
575                    if !p.bypassed {
576                        if let Some(ref proc) = p.processor {
577                            return proc.output_impedance() as f64;
578                        }
579                    }
580                }
581                ChainSlot::FxLoop(fx) => {
582                    // FX loop's output impedance is the amp's poweramp output
583                    return fx.amp.output_impedance();
584                }
585            }
586        }
587        1_000.0 // Low-Z default
588    }
589}
590
591/// Process a single pedal slot, respecting bypass.
592fn process_slot(slot: &mut PedalSlot, signal: f64) -> f64 {
593    if slot.bypassed {
594        signal
595    } else if let Some(ref mut proc) = slot.processor {
596        proc.process(signal as crate::Wave) as f64
597    } else {
598        signal // dry passthrough (failed pedal)
599    }
600}
601
602impl PedalProcessor for PedalboardProcessor {
603    fn process(&mut self, input: crate::Wave) -> crate::Wave {
604        let mut signal = input as f64;
605        let n = self.chain.len();
606        for i in 0..n {
607            match &mut self.chain[i] {
608                ChainSlot::Pedal(slot) => {
609                    signal = process_slot(slot, signal);
610                }
611                ChainSlot::FxLoop(fx) => {
612                    // Pre-amp section → FX send
613                    let send = fx.amp.process_pre(signal);
614                    // FX chain with wet/dry mix
615                    let dry = send;
616                    let mut wet = send;
617                    for slot in &mut fx.effects {
618                        wet = process_slot(slot, wet);
619                    }
620                    let fx_return = dry * (1.0 - fx.mix) + wet * fx.mix;
621                    // Post-amp section (power amp) ← FX return
622                    signal = fx.amp.process_post(fx_return);
623                }
624            }
625            // Apply interstage loading between this slot and the next.
626            if i < self.loading.len() {
627                signal = self.loading[i].process(signal as crate::Wave) as f64;
628            }
629        }
630        signal as crate::Wave
631    }
632
633    fn set_sample_rate(&mut self, rate: crate::Wave) {
634        for chain_slot in &mut self.chain {
635            match chain_slot {
636                ChainSlot::Pedal(slot) => {
637                    if let Some(ref mut proc) = slot.processor {
638                        proc.set_sample_rate(rate);
639                    }
640                }
641                ChainSlot::FxLoop(fx) => {
642                    fx.amp.set_sample_rate(rate as f64);
643                    for slot in &mut fx.effects {
644                        if let Some(ref mut proc) = slot.processor {
645                            proc.set_sample_rate(rate);
646                        }
647                    }
648                }
649            }
650        }
651        for loading in &mut self.loading {
652            loading.set_sample_rate(rate);
653        }
654    }
655
656    fn reset(&mut self) {
657        for chain_slot in &mut self.chain {
658            match chain_slot {
659                ChainSlot::Pedal(slot) => {
660                    if let Some(ref mut proc) = slot.processor {
661                        proc.reset();
662                    }
663                }
664                ChainSlot::FxLoop(fx) => {
665                    fx.amp.reset();
666                    for slot in &mut fx.effects {
667                        if let Some(ref mut proc) = slot.processor {
668                            proc.reset();
669                        }
670                    }
671                }
672            }
673        }
674        for loading in &mut self.loading {
675            loading.reset();
676        }
677    }
678
679    /// Control routing: `"idx:label"` dispatches to the appropriate pedal.
680    ///
681    /// Indices are flat across the board — FX loop pedals are numbered
682    /// sequentially after pedals before the loop. Special controls:
683    /// - `"idx:__bypass__"` toggles bypass (value > 0.5 = bypassed)
684    /// - `"fx_loop:mix"` controls the FX loop wet/dry blend (first loop)
685    /// - `"fx_loop_N:mix"` controls the Nth FX loop's mix (0-indexed)
686    fn set_control(&mut self, label: &str, value: crate::Wave) {
687        let value = value as f64;
688        // FX loop controls: "fx_loop:mix", "fx_loop_N:mix", "fx_loop:Knob", "fx_loop_N:Knob"
689        if let Some(rest) = label.strip_prefix("fx_loop") {
690            // Determine which FX loop index and which knob label
691            let (loop_idx, knob) = if let Some(knob_label) = rest.strip_prefix(':') {
692                (0usize, knob_label)
693            } else if let Some(idx_and_label) = rest.strip_prefix('_') {
694                if let Some((idx_str, knob_label)) = idx_and_label.split_once(':') {
695                    if let Ok(idx) = idx_str.parse::<usize>() {
696                        (idx, knob_label)
697                    } else {
698                        return;
699                    }
700                } else {
701                    return;
702                }
703            } else {
704                return;
705            };
706
707            let mut cur_loop = 0;
708            for slot in &mut self.chain {
709                if let ChainSlot::FxLoop(fx) = slot {
710                    if cur_loop == loop_idx {
711                        if knob == "mix" {
712                            fx.mix = value.clamp(0.0, 1.0);
713                        } else {
714                            // Route to the split amp's controls
715                            fx.amp.set_control(knob, value);
716                        }
717                        return;
718                    }
719                    cur_loop += 1;
720                }
721            }
722            return;
723        }
724
725        // Standard pedal control routing
726        if let Some((prefix, remainder)) = label.split_once(':') {
727            if let Ok(idx) = prefix.parse::<usize>() {
728                let mut flat = self.flat_pedals_mut();
729                if let Some(slot) = flat.get_mut(idx) {
730                    if remainder == "__bypass__" {
731                        slot.bypassed = value > 0.5;
732                    } else if let Some(ref mut proc) = slot.processor {
733                        proc.set_control(remainder, value as crate::Wave);
734                    }
735                }
736            }
737        }
738    }
739}
740
741// ---------------------------------------------------------------------------
742// Board Switcher — meta-processor for switching between pre-compiled boards
743// ---------------------------------------------------------------------------
744
745/// Meta-processor wrapping multiple pre-compiled pedalboards.
746///
747/// Delegates `process()` to the active board. Switching is instant —
748/// just an index change, no allocation, no audio gap.
749pub struct BoardSwitcherProcessor {
750    boards: Vec<PedalboardProcessor>,
751    active: usize,
752}
753
754impl BoardSwitcherProcessor {
755    /// Create a new board switcher from a vec of pre-compiled pedalboards.
756    pub fn new(boards: Vec<PedalboardProcessor>) -> Self {
757        Self { boards, active: 0 }
758    }
759
760    /// Number of boards.
761    pub fn len(&self) -> usize {
762        self.boards.len()
763    }
764
765    /// Whether there are no boards.
766    pub fn is_empty(&self) -> bool {
767        self.boards.is_empty()
768    }
769
770    /// Current active board index.
771    pub fn active(&self) -> usize {
772        self.active
773    }
774}
775
776impl PedalProcessor for BoardSwitcherProcessor {
777    fn process(&mut self, input: crate::Wave) -> crate::Wave {
778        if let Some(board) = self.boards.get_mut(self.active) {
779            board.process(input)
780        } else {
781            input
782        }
783    }
784
785    fn set_sample_rate(&mut self, rate: crate::Wave) {
786        for board in &mut self.boards {
787            board.set_sample_rate(rate);
788        }
789    }
790
791    fn reset(&mut self) {
792        for board in &mut self.boards {
793            board.reset();
794        }
795    }
796
797    fn set_control(&mut self, label: &str, value: crate::Wave) {
798        if label == "__switch_board__" {
799            let idx = value as usize;
800            if idx < self.boards.len() {
801                self.active = idx;
802            }
803        } else if let Some(board) = self.boards.get_mut(self.active) {
804            board.set_control(label, value);
805        }
806    }
807}
808
809// ---------------------------------------------------------------------------
810// PedalSwitcherProcessor — switches between individual compiled pedals
811// ---------------------------------------------------------------------------
812
813/// Meta-processor wrapping multiple pre-compiled individual pedals.
814///
815/// Delegates `process()` to the active pedal. Switching is instant —
816/// just an index change, no allocation, no audio gap.
817/// Unlike `BoardSwitcherProcessor`, forwards bare control labels (no prefix).
818pub struct PedalSwitcherProcessor {
819    pedals: Vec<CompiledPedal>,
820    active: usize,
821}
822
823impl PedalSwitcherProcessor {
824    /// Create a new pedal switcher from a vec of pre-compiled pedals.
825    pub fn new(pedals: Vec<CompiledPedal>) -> Self {
826        Self { pedals, active: 0 }
827    }
828
829    /// Number of pedals.
830    pub fn len(&self) -> usize {
831        self.pedals.len()
832    }
833
834    /// Whether there are no pedals.
835    pub fn is_empty(&self) -> bool {
836        self.pedals.is_empty()
837    }
838
839    /// Current active pedal index.
840    pub fn active(&self) -> usize {
841        self.active
842    }
843}
844
845impl PedalProcessor for PedalSwitcherProcessor {
846    fn process(&mut self, input: crate::Wave) -> crate::Wave {
847        if let Some(pedal) = self.pedals.get_mut(self.active) {
848            pedal.process(input)
849        } else {
850            input
851        }
852    }
853
854    fn set_sample_rate(&mut self, rate: crate::Wave) {
855        for pedal in &mut self.pedals {
856            pedal.set_sample_rate(rate);
857        }
858    }
859
860    fn reset(&mut self) {
861        for pedal in &mut self.pedals {
862            pedal.reset();
863        }
864    }
865
866    fn set_control(&mut self, label: &str, value: crate::Wave) {
867        if label == "__switch_pedal__" {
868            let idx = value as usize;
869            if idx < self.pedals.len() {
870                self.active = idx;
871            }
872        } else if let Some(pedal) = self.pedals.get_mut(self.active) {
873            pedal.set_control(label, value);
874        }
875    }
876}
877
878// ---------------------------------------------------------------------------
879// Tests
880// ---------------------------------------------------------------------------
881
882#[cfg(test)]
883mod tests {
884    use super::*;
885    use crate::board::parse_board_file;
886
887    #[test]
888    fn parse_and_build_pedalboard() {
889        let src = r#"
890board "Test Board" {
891  ts: "tube_screamer.pedal" { Drive = 0.6, Level = 0.7 }
892}
893"#;
894        let board = parse_board_file(src).unwrap();
895        let board_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples");
896        let pb = PedalboardProcessor::from_board(&board, &board_dir, 48000.0).unwrap();
897        assert_eq!(pb.len(), 1);
898        assert_eq!(pb.pedal_name(0), Some("Tube Screamer"));
899        assert!(!pb.is_pedal_bypassed(0));
900    }
901
902    #[test]
903    fn pedalboard_processes_audio() {
904        let src = r#"
905board "Chain" {
906  ts: "tube_screamer.pedal"
907  bd: "blues_driver.pedal"
908}
909"#;
910        let board = parse_board_file(src).unwrap();
911        let board_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples");
912        let mut pb = PedalboardProcessor::from_board(&board, &board_dir, 48000.0).unwrap();
913
914        // Process some samples — should produce non-zero output
915        let mut max_out = 0.0_f64;
916        for i in 0..1000 {
917            let input = (i as f64 * 440.0 * 2.0 * std::f64::consts::PI / 48000.0).sin() * 0.1;
918            let output = pb.process(input);
919            max_out = max_out.max(output.abs());
920        }
921        assert!(max_out > 0.0001, "pedalboard should produce output");
922    }
923
924    #[test]
925    fn pedalboard_bypass_control() {
926        let src = r#"
927board "Bypass Test" {
928  ts: "tube_screamer.pedal"
929}
930"#;
931        let board = parse_board_file(src).unwrap();
932        let board_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples");
933        let mut pb = PedalboardProcessor::from_board(&board, &board_dir, 48000.0).unwrap();
934
935        // Bypass pedal 0
936        pb.set_control("0:__bypass__", 1.0);
937        assert!(pb.is_pedal_bypassed(0));
938
939        // Bypassed pedal should pass through
940        let input = 0.42;
941        let output = pb.process(input);
942        assert!((output - input).abs() < f64::EPSILON);
943
944        // Un-bypass
945        pb.set_control("0:__bypass__", 0.0);
946        assert!(!pb.is_pedal_bypassed(0));
947    }
948
949    #[test]
950    fn pedalboard_knob_control_routing() {
951        let src = r#"
952board "Knob Test" {
953  ts: "tube_screamer.pedal"
954}
955"#;
956        let board = parse_board_file(src).unwrap();
957        let board_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples");
958        let mut pb = PedalboardProcessor::from_board(&board, &board_dir, 48000.0).unwrap();
959
960        // Setting a control via the routing scheme should not panic
961        pb.set_control("0:Drive", 0.9);
962        pb.set_control("0:Level", 0.3);
963
964        // Invalid index should be a no-op
965        pb.set_control("99:Drive", 0.5);
966    }
967
968    #[test]
969    fn pedalboard_missing_pedal_file_uses_dry_passthrough() {
970        let src = r#"
971board "Bad" {
972  x: "nonexistent.pedal"
973}
974"#;
975        let board = parse_board_file(src).unwrap();
976        let board_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples");
977        let (pb, warnings) =
978            PedalboardProcessor::from_board_with_warnings(&board, &board_dir, 48000.0);
979
980        // Board still loads — the failed pedal becomes dry passthrough
981        assert_eq!(pb.len(), 1);
982        assert!(pb.is_pedal_failed(0));
983        assert!(!warnings.is_empty());
984
985        // Dry passthrough: input == output
986        let mut pb = pb;
987        let output = pb.process(0.42);
988        assert!((output - 0.42).abs() < f64::EPSILON);
989    }
990
991    // ── BoardSwitcherProcessor tests ─────────────────────────────────────
992
993    fn make_test_board(pedal_file: &str) -> PedalboardProcessor {
994        let src = format!(
995            r#"board "Test" {{
996  p: "{pedal_file}"
997}}"#
998        );
999        let board = parse_board_file(&src).unwrap();
1000        let board_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples");
1001        PedalboardProcessor::from_board(&board, &board_dir, 48000.0).unwrap()
1002    }
1003
1004    #[test]
1005    fn switcher_delegates_to_active_board() {
1006        let board0 = make_test_board("tube_screamer.pedal");
1007        let board1 = make_test_board("blues_driver.pedal");
1008        let mut switcher = BoardSwitcherProcessor::new(vec![board0, board1]);
1009
1010        assert_eq!(switcher.len(), 2);
1011        assert_eq!(switcher.active(), 0);
1012
1013        // Process through board 0
1014        let out0 = switcher.process(0.1);
1015
1016        // Switch to board 1
1017        switcher.set_control("__switch_board__", 1.0);
1018        assert_eq!(switcher.active(), 1);
1019
1020        // Process through board 1 — output should differ from board 0
1021        let out1 = switcher.process(0.1);
1022        // Both should produce output (not necessarily different on first sample,
1023        // but the switcher should not crash)
1024        assert!(out0.is_finite());
1025        assert!(out1.is_finite());
1026    }
1027
1028    #[test]
1029    fn switcher_bounds_check() {
1030        let board0 = make_test_board("tube_screamer.pedal");
1031        let mut switcher = BoardSwitcherProcessor::new(vec![board0]);
1032
1033        // Out-of-bounds switch should be ignored
1034        switcher.set_control("__switch_board__", 99.0);
1035        assert_eq!(switcher.active(), 0);
1036    }
1037
1038    #[test]
1039    fn switcher_forwards_controls_to_active() {
1040        let board0 = make_test_board("tube_screamer.pedal");
1041        let board1 = make_test_board("blues_driver.pedal");
1042        let mut switcher = BoardSwitcherProcessor::new(vec![board0, board1]);
1043
1044        // Forward a control to board 0 (active) — should not panic
1045        switcher.set_control("0:Drive", 0.9);
1046
1047        // Switch and forward to board 1
1048        switcher.set_control("__switch_board__", 1.0);
1049        switcher.set_control("0:Gain", 0.8);
1050    }
1051
1052    #[test]
1053    fn switcher_set_sample_rate_all_boards() {
1054        let board0 = make_test_board("tube_screamer.pedal");
1055        let board1 = make_test_board("blues_driver.pedal");
1056        let mut switcher = BoardSwitcherProcessor::new(vec![board0, board1]);
1057
1058        // Should not panic — sets rate on all boards
1059        switcher.set_sample_rate(44100.0);
1060        switcher.reset();
1061    }
1062
1063    // ── Global board tests ──────────────────────────────────────────────
1064
1065    #[test]
1066    fn global_board_prepends_and_appends() {
1067        let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples");
1068        let ts_path = examples
1069            .join("pedals/overdrive/tube_screamer.pedal")
1070            .canonicalize()
1071            .unwrap();
1072        let comp_path = examples
1073            .join("pedals/compressor/dyna_comp.pedal")
1074            .canonicalize()
1075            .unwrap();
1076        let bd_path = examples
1077            .join("pedals/overdrive/blues_driver.pedal")
1078            .canonicalize()
1079            .unwrap();
1080
1081        let tmp = tempfile::tempdir().unwrap();
1082
1083        let global_content = format!(
1084            "global {{\n  start {{\n    comp: \"{}\" {{ Sensitivity = 0.3 }}\n  }}\n  end {{\n    bd: \"{}\"\n  }}\n}}",
1085            comp_path.display(),
1086            bd_path.display(),
1087        );
1088        std::fs::write(tmp.path().join(".global.board"), &global_content).unwrap();
1089
1090        let sub = tmp.path().join("boards");
1091        std::fs::create_dir(&sub).unwrap();
1092        let board_content = format!(
1093            "board \"Test\" {{\n  ts: \"{}\" {{ Drive = 0.6 }}\n}}",
1094            ts_path.display(),
1095        );
1096        std::fs::write(sub.join("test.board"), &board_content).unwrap();
1097
1098        let board = parse_board_file(&board_content).unwrap();
1099        let (pb, warnings) = PedalboardProcessor::from_board_with_warnings(&board, &sub, 48000.0);
1100
1101        assert_eq!(pb.len(), 3, "warnings: {warnings:?}");
1102        assert_eq!(pb.pedal_name(0), Some("MXR Dyna Comp"));
1103        assert_eq!(pb.pedal_name(1), Some("Tube Screamer"));
1104        assert_eq!(pb.pedal_name(2), Some("Boss Blues Driver"));
1105    }
1106
1107    #[test]
1108    fn global_board_start_only() {
1109        let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples");
1110        let ts_path = examples
1111            .join("pedals/overdrive/tube_screamer.pedal")
1112            .canonicalize()
1113            .unwrap();
1114        let comp_path = examples
1115            .join("pedals/compressor/dyna_comp.pedal")
1116            .canonicalize()
1117            .unwrap();
1118
1119        let tmp = tempfile::tempdir().unwrap();
1120
1121        let global_content = format!(
1122            "global {{\n  start {{\n    comp: \"{}\"\n  }}\n}}",
1123            comp_path.display(),
1124        );
1125        std::fs::write(tmp.path().join(".global.board"), &global_content).unwrap();
1126
1127        let board_content = format!("board \"Test\" {{\n  ts: \"{}\"\n}}", ts_path.display(),);
1128
1129        let board = parse_board_file(&board_content).unwrap();
1130        let (pb, _) = PedalboardProcessor::from_board_with_warnings(&board, tmp.path(), 48000.0);
1131
1132        assert_eq!(pb.len(), 2);
1133        assert_eq!(pb.pedal_name(0), Some("MXR Dyna Comp"));
1134        assert_eq!(pb.pedal_name(1), Some("Tube Screamer"));
1135    }
1136
1137    #[test]
1138    fn global_board_signal_chain_order() {
1139        let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples");
1140        let ts_path = examples
1141            .join("pedals/overdrive/tube_screamer.pedal")
1142            .canonicalize()
1143            .unwrap();
1144        let comp_path = examples
1145            .join("pedals/compressor/dyna_comp.pedal")
1146            .canonicalize()
1147            .unwrap();
1148        let bd_path = examples
1149            .join("pedals/overdrive/blues_driver.pedal")
1150            .canonicalize()
1151            .unwrap();
1152
1153        let tmp = tempfile::tempdir().unwrap();
1154
1155        let global_content = format!(
1156            "global {{\n  start {{\n    comp: \"{}\"\n  }}\n  end {{\n    bd: \"{}\"\n  }}\n}}",
1157            comp_path.display(),
1158            bd_path.display(),
1159        );
1160        std::fs::write(tmp.path().join(".global.board"), &global_content).unwrap();
1161
1162        let board_content = format!(
1163            "board \"Chain\" {{\n  ts: \"{}\" {{ Drive = 0.8 }}\n}}",
1164            ts_path.display(),
1165        );
1166
1167        let board = parse_board_file(&board_content).unwrap();
1168        let (pb, _) = PedalboardProcessor::from_board_with_warnings(&board, tmp.path(), 48000.0);
1169
1170        assert_eq!(pb.len(), 3);
1171
1172        let mut pb = pb;
1173        let mut max_out = 0.0_f64;
1174        for i in 0..1000 {
1175            let input = (i as f64 * 440.0 * 2.0 * std::f64::consts::PI / 48000.0).sin() * 0.1;
1176            let output = pb.process(input);
1177            assert!(output.is_finite());
1178            max_out = max_out.max(output.abs());
1179        }
1180        assert!(
1181            max_out > 0.0001,
1182            "pedalboard with global should produce output"
1183        );
1184    }
1185
1186    #[test]
1187    fn no_global_board_unchanged() {
1188        let tmp = tempfile::tempdir().unwrap();
1189        let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples");
1190        let ts_path = examples
1191            .join("pedals/overdrive/tube_screamer.pedal")
1192            .canonicalize()
1193            .unwrap();
1194
1195        let board_content = format!("board \"Solo\" {{\n  ts: \"{}\"\n}}", ts_path.display(),);
1196
1197        let board = parse_board_file(&board_content).unwrap();
1198        let (pb, warnings) =
1199            PedalboardProcessor::from_board_with_warnings(&board, tmp.path(), 48000.0);
1200
1201        assert_eq!(pb.len(), 1);
1202        assert!(warnings.is_empty());
1203        assert_eq!(pb.pedal_name(0), Some("Tube Screamer"));
1204    }
1205
1206    #[test]
1207    fn global_board_control_routing_flat_index() {
1208        let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples");
1209        let ts_path = examples
1210            .join("pedals/overdrive/tube_screamer.pedal")
1211            .canonicalize()
1212            .unwrap();
1213        let comp_path = examples
1214            .join("pedals/compressor/dyna_comp.pedal")
1215            .canonicalize()
1216            .unwrap();
1217
1218        let tmp = tempfile::tempdir().unwrap();
1219
1220        let global_content = format!(
1221            "global {{\n  start {{\n    comp: \"{}\"\n  }}\n}}",
1222            comp_path.display(),
1223        );
1224        std::fs::write(tmp.path().join(".global.board"), &global_content).unwrap();
1225
1226        let board_content = format!("board \"Test\" {{\n  ts: \"{}\"\n}}", ts_path.display(),);
1227
1228        let board = parse_board_file(&board_content).unwrap();
1229        let (mut pb, _) =
1230            PedalboardProcessor::from_board_with_warnings(&board, tmp.path(), 48000.0);
1231
1232        pb.set_control("0:__bypass__", 1.0);
1233        assert!(pb.is_pedal_bypassed(0));
1234        assert!(!pb.is_pedal_bypassed(1));
1235
1236        pb.set_control("1:Drive", 0.9);
1237    }
1238
1239    // ── FX loop tests ────────────────────────────────────────────────────
1240
1241    #[test]
1242    fn pedalboard_fx_loop_processes_audio() {
1243        let src = r#"
1244board "FX Loop" {
1245  amp: "tweed_deluxe_5e3.pedal" { Volume = 0.7, Tone = 0.5 }
1246  fx_loop(amp) {
1247    ts: "tube_screamer.pedal" { Drive = 0.3, Level = 0.5 }
1248  }
1249}
1250"#;
1251        let board = parse_board_file(src).unwrap();
1252        let board_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples");
1253        let (mut pb, warnings) =
1254            PedalboardProcessor::from_board_with_warnings(&board, &board_dir, 48000.0);
1255        assert!(
1256            warnings.is_empty(),
1257            "FX loop board should load without warnings: {warnings:?}"
1258        );
1259
1260        // Process some samples — should produce non-zero output
1261        let mut max_out = 0.0_f64;
1262        for i in 0..1000 {
1263            let input = (i as f64 * 440.0 * 2.0 * std::f64::consts::PI / 48000.0).sin() * 0.1;
1264            let output = pb.process(input);
1265            assert!(output.is_finite(), "FX loop output should be finite");
1266            max_out = max_out.max(output.abs());
1267        }
1268        assert!(max_out > 0.0001, "FX loop board should produce output");
1269    }
1270
1271    #[test]
1272    fn pedalboard_fx_loop_mix_control() {
1273        let src = r#"
1274board "Mix" {
1275  amp: "tweed_deluxe_5e3.pedal" { Volume = 0.5, Tone = 0.5 }
1276  fx_loop(amp) {
1277    ts: "tube_screamer.pedal" { Drive = 0.5 }
1278  }
1279}
1280"#;
1281        let board = parse_board_file(src).unwrap();
1282        let board_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples");
1283        let mut pb = PedalboardProcessor::from_board(&board, &board_dir, 48000.0).unwrap();
1284
1285        // Set FX loop mix — should not panic
1286        pb.set_control("fx_loop:mix", 0.5);
1287
1288        // Route a control to the amp inside the FX loop
1289        pb.set_control("fx_loop:Volume", 0.8);
1290
1291        // Process should still work
1292        let output = pb.process(0.1);
1293        assert!(output.is_finite());
1294    }
1295
1296    #[test]
1297    fn pedalboard_fx_loop_missing_amp_falls_back() {
1298        let src = r#"
1299board "Bad FX Loop" {
1300  ts: "tube_screamer.pedal"
1301  fx_loop(nonexistent) {
1302    bd: "blues_driver.pedal"
1303  }
1304}
1305"#;
1306        let board = parse_board_file(src).unwrap();
1307        let board_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples");
1308        let (pb, warnings) =
1309            PedalboardProcessor::from_board_with_warnings(&board, &board_dir, 48000.0);
1310
1311        // Should have a warning about the missing amp
1312        assert!(
1313            warnings.iter().any(|w| w.contains("nonexistent")),
1314            "Should warn about missing amp: {warnings:?}"
1315        );
1316        // The blues_driver should still be in the chain as a fallback pedal
1317        assert!(pb.len() >= 2, "Fallback should still have pedals");
1318    }
1319
1320    #[test]
1321    fn from_board_with_resolver_builds_chain() {
1322        let board_src = r#"
1323board "Resolver Test" {
1324  ts: "tube_screamer.pedal" { Drive = 0.7 }
1325  bd: "blues_driver.pedal"
1326}
1327"#;
1328        let ts_src = std::fs::read_to_string(
1329            Path::new(env!("CARGO_MANIFEST_DIR"))
1330                .join("examples/pedals/overdrive/tube_screamer.pedal"),
1331        )
1332        .unwrap();
1333        let bd_src = std::fs::read_to_string(
1334            Path::new(env!("CARGO_MANIFEST_DIR"))
1335                .join("examples/pedals/overdrive/blues_driver.pedal"),
1336        )
1337        .unwrap();
1338
1339        let sources: Vec<(&str, String)> = vec![
1340            ("tube_screamer.pedal", ts_src),
1341            ("blues_driver.pedal", bd_src),
1342        ];
1343
1344        let board = parse_board_file(board_src).unwrap();
1345        let (pb, warnings) =
1346            PedalboardProcessor::from_board_with_resolver(&board, 48000.0, |path| {
1347                sources
1348                    .iter()
1349                    .find(|(p, _)| *p == path)
1350                    .map(|(_, s)| s.clone())
1351            });
1352
1353        assert!(warnings.is_empty(), "Unexpected warnings: {warnings:?}");
1354        assert_eq!(pb.len(), 2);
1355        assert_eq!(pb.pedal_name(0), Some("Tube Screamer"));
1356        assert_eq!(pb.pedal_name(1), Some("Boss Blues Driver"));
1357
1358        // Verify it processes audio
1359        let mut pb = pb;
1360        let input = crate::wav::sine_wave(330.0, 0.02, 48000);
1361        let output: Vec<f64> = input.iter().map(|&s| pb.process(s)).collect();
1362        let max_out = output.iter().copied().fold(0.0_f64, |a, b| a.max(b.abs()));
1363        assert!(max_out > 1e-6, "Board should produce non-silent output");
1364    }
1365
1366    #[test]
1367    fn from_board_with_resolver_missing_pedal_warns() {
1368        let board_src = r#"
1369board "Missing Test" {
1370  ts: "nonexistent.pedal"
1371}
1372"#;
1373        let board = parse_board_file(board_src).unwrap();
1374        let (pb, warnings) =
1375            PedalboardProcessor::from_board_with_resolver(&board, 48000.0, |_| None);
1376
1377        assert_eq!(pb.len(), 1);
1378        assert!(pb.is_pedal_failed(0));
1379        assert!(
1380            warnings.iter().any(|w| w.contains("not found")),
1381            "Should warn about missing pedal: {warnings:?}"
1382        );
1383    }
1384}