Skip to main content

rill_graph/
graph.rs

1use rill_core::math::Transcendental;
2use rill_core::queues::CommandEnum;
3use rill_core::traits::Params;
4use rill_core_actor::ActorRef;
5
6use indexmap::IndexMap;
7use rill_lang::builtin::SignatureSource;
8use rill_lang::graph_ir::{EdgeKind, GraphEdge, GraphIr, GraphNode};
9use std::collections::HashMap;
10
11// ============================================================================
12// Build Errors
13// ============================================================================
14
15/// Errors that can occur during graph construction.
16#[derive(Debug, Clone)]
17pub enum BuildError {
18    /// A cycle was detected in the signal edge graph.
19    CycleDetected,
20    /// Backend creation failed.
21    Backend(String),
22    /// A node type is not registered in the built-in registry.
23    UnknownNodeType(String),
24    /// The graph topology is not supported for conversion to a flat chain.
25    UnsupportedTopology(String),
26    /// AST compilation failed.
27    CompilationFailed(String),
28}
29
30impl std::fmt::Display for BuildError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            Self::CycleDetected => write!(f, "graph cycle detected"),
34            Self::Backend(msg) => write!(f, "backend error: {msg}"),
35            Self::UnknownNodeType(msg) => write!(f, "unknown node type: {msg}"),
36            Self::UnsupportedTopology(msg) => write!(f, "unsupported topology: {msg}"),
37            Self::CompilationFailed(msg) => write!(f, "compilation failed: {msg}"),
38        }
39    }
40}
41
42// ============================================================================
43// Node Storage
44// ============================================================================
45
46/// A deferred node recipe — constructed at build_ir time.
47struct NodeRecipe<T: Transcendental, const BUF_SIZE: usize> {
48    type_name: String,
49    id: u32,
50    name: String,
51    params: Params,
52    routing_entries: Vec<(usize, usize, f32)>,
53    _phantom: std::marker::PhantomData<(T, [(); BUF_SIZE])>,
54}
55
56// ============================================================================
57// GraphBuilder (Mutable Construction)
58// ============================================================================
59
60/// A named resource (tape loop) shared between nodes in the graph.
61#[derive(Clone)]
62pub struct GraphResource {
63    /// Unique name referenced by node parameters.
64    pub name: String,
65    /// Resource kind string (`"tape"`).
66    pub kind: String,
67    /// Capacity in samples (for `"tape"` kind).
68    pub capacity: usize,
69}
70
71/// Mutable builder for an immutable signal graph.
72pub struct GraphBuilder<T: Transcendental, const BUF_SIZE: usize> {
73    recipes: Vec<NodeRecipe<T, BUF_SIZE>>,
74    signal_edges: Vec<(usize, usize, usize, usize)>,
75    control_edges: Vec<(usize, usize, usize, usize)>,
76    clock_edges: Vec<(usize, usize, usize, usize)>,
77    feedback_edges: Vec<(usize, usize, usize, usize)>,
78    resources: Vec<GraphResource>,
79    sample_rate: Option<f32>,
80    parent_ref: Option<ActorRef<CommandEnum>>,
81}
82
83impl<T: Transcendental, const BUF_SIZE: usize> Default for GraphBuilder<T, BUF_SIZE> {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89impl<T: Transcendental, const BUF_SIZE: usize> GraphBuilder<T, BUF_SIZE> {
90    /// Create a new empty graph builder.
91    pub fn new() -> Self {
92        Self {
93            recipes: Vec::new(),
94            signal_edges: Vec::new(),
95            control_edges: Vec::new(),
96            clock_edges: Vec::new(),
97            feedback_edges: Vec::new(),
98            resources: Vec::new(),
99            sample_rate: None,
100            parent_ref: None,
101        }
102    }
103
104    /// Add a node by type name.
105    ///
106    /// Returns the index of the newly added node.
107    pub fn add_node(&mut self, type_name: &str, params: &Params) -> usize {
108        let id = self.recipes.len() as u32;
109        self.add_node_with_id(type_name, params, id)
110    }
111
112    /// Add a node with an explicit `NodeId`.
113    pub fn add_node_with_id(&mut self, type_name: &str, params: &Params, id: u32) -> usize {
114        self.add_node_with_name(type_name, params, id, String::new())
115    }
116
117    /// Add a node with an explicit `NodeId` and a human-readable name
118    /// (typically sourced from the JSON `name` field). The name becomes the
119    /// program/anchor name in the compiled graph, used by `SetParameter` routing.
120    pub fn add_node_with_name(
121        &mut self,
122        type_name: &str,
123        params: &Params,
124        id: u32,
125        name: String,
126    ) -> usize {
127        let idx = self.recipes.len();
128        self.recipes.push(NodeRecipe {
129            type_name: type_name.to_string(),
130            id,
131            name,
132            params: params.clone(),
133            routing_entries: Vec::new(),
134            _phantom: std::marker::PhantomData,
135        });
136        idx
137    }
138
139    /// Store a routing matrix entry to be applied at build time.
140    pub fn add_routing_entry(&mut self, idx: usize, from: usize, to: usize, gain: f32) {
141        if let Some(recipe) = self.recipes.get_mut(idx) {
142            recipe.routing_entries.push((from, to, gain));
143        }
144    }
145
146    /// Register a named resource (tape loop, buffer, etc.).
147    pub fn add_resource(&mut self, resource: GraphResource) {
148        self.resources.push(resource);
149    }
150
151    /// Number of nodes added to the builder so far.
152    pub fn node_count(&self) -> usize {
153        self.recipes.len()
154    }
155
156    /// Set the sample rate for this builder.
157    pub fn set_sample_rate(&mut self, sr: f32) {
158        self.sample_rate = Some(sr);
159    }
160
161    /// Set the parent RackCase actor reference (Graph → parent ClockTick).
162    pub fn set_parent_ref(&mut self, parent: ActorRef<CommandEnum>) {
163        self.parent_ref = Some(parent);
164    }
165
166    /// Connect signal ports.
167    pub fn connect_signal(
168        &mut self,
169        from_node: usize,
170        from_port: usize,
171        to_node: usize,
172        to_port: usize,
173    ) {
174        self.signal_edges
175            .push((from_node, from_port, to_node, to_port));
176    }
177
178    /// Connect control ports (modulation values).
179    pub fn connect_control(
180        &mut self,
181        from_node: usize,
182        from_port: usize,
183        to_node: usize,
184        to_port: usize,
185    ) {
186        self.control_edges
187            .push((from_node, from_port, to_node, to_port));
188    }
189
190    /// Connect clock ports (timing events).
191    pub fn connect_clock(
192        &mut self,
193        from_node: usize,
194        from_port: usize,
195        to_node: usize,
196        to_port: usize,
197    ) {
198        self.clock_edges
199            .push((from_node, from_port, to_node, to_port));
200    }
201
202    /// Connect feedback ports (delay lines, state carryover).
203    pub fn connect_feedback(
204        &mut self,
205        from_node: usize,
206        from_port: usize,
207        to_node: usize,
208        to_port: usize,
209    ) {
210        self.feedback_edges
211            .push((from_node, from_port, to_node, to_port));
212    }
213
214    /// Build a [`rill_lang::graph_ir::GraphIr`] using the built-in `Registry`.
215    ///
216    /// This is the new execution path. It looks up each node type in the registry,
217    /// constructs placeholder IRs, and performs topological sort. Actual compilation
218    /// to executable programs happens in a future phase.
219    pub fn build_ir(
220        self,
221        registry: &rill_lang::builtin::Registry<T>,
222    ) -> Result<GraphIr, BuildError> {
223        // 1. Build index → name mapping
224        let idx_to_name: HashMap<usize, String> = self
225            .recipes
226            .iter()
227            .enumerate()
228            .map(|(idx, recipe)| {
229                let name = if recipe.name.is_empty() {
230                    format!("node_{}", recipe.id)
231                } else {
232                    recipe.name.clone()
233                };
234                (idx, name)
235            })
236            .collect();
237
238        // 2. Create GraphNodes from recipes
239        let mut nodes: IndexMap<String, GraphNode> = IndexMap::new();
240        let mut node_list: Vec<String> = Vec::new();
241
242        for (idx, recipe) in self.recipes.iter().enumerate() {
243            let name = idx_to_name[&idx].clone();
244            node_list.push(name.clone());
245
246            let sig = registry
247                .builtin_sig(&recipe.type_name)
248                .or_else(|| {
249                    // Strip "rill/" prefix for graph node → lang builtin mapping
250                    recipe
251                        .type_name
252                        .strip_prefix("rill/")
253                        .and_then(|n| registry.builtin_sig(n))
254                })
255                .or_else(|| {
256                    // Common suffix mappings
257                    let mapped = match recipe.type_name.as_str() {
258                        "rill/dry_wet_mix" => "dry_wet",
259                        "rill/parametric_eq" => "eq_parametric",
260                        "rill/graphic_eq" => "graphic_eq",
261                        "rill/mono_to_stereo" => "mono_to_stereo",
262                        "rill/moog_ladder" => "moog",
263                        "rill/write_head" => "write_head",
264                        "rill/read_head" => "read_head",
265                        "rill/lofi_chip" => "ay38910",
266                        _ => "",
267                    };
268                    if mapped.is_empty() {
269                        None
270                    } else {
271                        registry.builtin_sig(mapped)
272                    }
273                })
274                .ok_or_else(|| BuildError::UnknownNodeType(recipe.type_name.clone()))?;
275
276            let arity = (sig.signal_ins(), sig.signal_outs);
277
278            // Convert all recipe parameters to ParamDef.
279            // Include non-f32 values (SignalSlab placeholders) so that
280            // SetParameter can target them by name via param_maps.
281            let param_defs: Vec<rill_lang::ir::ParamDef> = recipe
282                .params
283                .parameters
284                .iter()
285                .map(|(k, v)| {
286                    let default = v.as_f32().unwrap_or(0.0) as f64;
287                    rill_lang::ir::ParamDef {
288                        name: k.clone(),
289                        default,
290                        min: f64::NEG_INFINITY,
291                        max: f64::INFINITY,
292                    }
293                })
294                .collect();
295
296            // Name → recipe-index lookup for building param_bindings.
297            let name_to_recipe_idx: HashMap<String, usize> = param_defs
298                .iter()
299                .enumerate()
300                .map(|(i, pd)| (pd.name.clone(), i))
301                .collect();
302
303            let param_values: Vec<f64>;
304            let param_bindings: Vec<(usize, usize)>;
305
306            if sig.param_names.is_empty() {
307                // Backward compat: no names → positional identity (HashMap order).
308                param_values = recipe
309                    .params
310                    .parameters
311                    .values()
312                    .filter_map(|v| v.as_f32().map(|f| f as f64))
313                    .collect();
314                param_bindings = (0..param_defs.len()).map(|i| (i, i)).collect();
315            } else {
316                // Named params: match recipe param names to builtin arg positions.
317                // param_values[i] = value for builtin arg i, in correct positional order.
318                // param_bindings[(arg_pos, recipe_param_idx)] — used by push_builtin_params
319                // to route SetParameter changes to the right builtin set_param(arg_pos, _) call.
320                let num_args = sig.param_names.len();
321                let mut values = vec![0.0; num_args];
322                let mut bindings = Vec::with_capacity(num_args);
323                for (arg_pos, builtin_name) in sig.param_names.iter().enumerate() {
324                    if let Some(&recipe_idx) = name_to_recipe_idx.get(*builtin_name) {
325                        values[arg_pos] = param_defs[recipe_idx].default;
326                        bindings.push((arg_pos, recipe_idx));
327                    }
328                }
329                param_values = values;
330                param_bindings = bindings;
331            }
332
333            // Build BuiltinInstance: one builtin wrapping the recipe's type
334            let builtin_name = sig.name.to_string();
335            let builtin_instance = rill_lang::ir::BuiltinInstance {
336                name: builtin_name,
337                params: param_values,
338                kind: sig.kind,
339                signal_ins: arity.0,
340                signal_outs: arity.1,
341                param_bindings,
342            };
343
344            // Build instructions: one LoadInput (if the builtin has signal inputs)
345            // followed by CallBlock. Use separate registers for input/output when
346            // both exist — avoids register aliasing in exec_foreign_block where
347            // taking the output register would clobber the input.
348            let mut instrs = Vec::new();
349            let mut output_reg = 0usize;
350            let mut num_regs = 1usize;
351            if arity.1 > 0 {
352                if arity.0 > 0 {
353                    instrs.push(rill_lang::ir::Instr::LoadInput { dst: 0, index: 0 });
354                    num_regs = 2;
355                    output_reg = 1;
356                }
357                #[cfg(feature = "debug")]
358                {
359                    // ProbePoint needs an extra register: output_reg + 1
360                    num_regs += 1;
361                }
362                let srcs = if arity.0 > 0 { vec![0] } else { vec![] };
363                instrs.push(rill_lang::ir::Instr::CallBlock {
364                    dst: output_reg,
365                    srcs,
366                    instance: 0,
367                });
368                #[cfg(feature = "debug")]
369                instrs.push(rill_lang::ir::Instr::ProbePoint {
370                    id: idx as u32,
371                    src: output_reg,
372                    dst: output_reg.wrapping_add(1),
373                });
374            }
375
376            let ir = rill_lang::ir::Ir {
377                instrs,
378                num_regs,
379                output_reg,
380                num_inputs: arity.0,
381                num_outputs: arity.1,
382                state: rill_lang::ir::StateLayout {
383                    state_slots: 0,
384                    delay_lens: vec![],
385                    num_outputs: arity.1,
386                },
387                builtins: vec![builtin_instance],
388                params: param_defs.clone(),
389            };
390
391            nodes.insert(
392                name.clone(),
393                GraphNode {
394                    arity,
395                    ir,
396                    params: param_defs,
397                    keep: false,
398                    inline: false,
399                    is_bridge: false,
400                    feedback_read: vec![],
401                    feedback_write: vec![],
402                },
403            );
404        }
405
406        // 3. Convert edges
407        let mut edges = Vec::new();
408        for (from_idx, from_port, to_idx, to_port) in &self.signal_edges {
409            edges.push(GraphEdge {
410                from_node: idx_to_name[from_idx].clone(),
411                from_port: *from_port,
412                to_node: idx_to_name[to_idx].clone(),
413                to_port: *to_port,
414                kind: EdgeKind::Signal,
415            });
416        }
417        for (from_idx, from_port, to_idx, to_port) in &self.feedback_edges {
418            edges.push(GraphEdge {
419                from_node: idx_to_name[from_idx].clone(),
420                from_port: *from_port,
421                to_node: idx_to_name[to_idx].clone(),
422                to_port: *to_port,
423                kind: EdgeKind::Feedback,
424            });
425        }
426
427        // 4. Compute topological order (Kahn's algorithm on signal edges only)
428        let mut in_degree: HashMap<String, usize> = HashMap::new();
429        for name in &node_list {
430            in_degree.insert(name.clone(), 0);
431        }
432        for edge in &edges {
433            if edge.kind == EdgeKind::Signal {
434                *in_degree.get_mut(&edge.to_node).unwrap() += 1;
435            }
436        }
437
438        let mut adj: HashMap<String, Vec<String>> = HashMap::new();
439        for name in &node_list {
440            adj.insert(name.clone(), vec![]);
441        }
442        for edge in &edges {
443            if edge.kind == EdgeKind::Signal {
444                adj.get_mut(&edge.from_node)
445                    .unwrap()
446                    .push(edge.to_node.clone());
447            }
448        }
449
450        let mut queue: Vec<String> = in_degree
451            .iter()
452            .filter(|(_, &d)| d == 0)
453            .map(|(n, _)| n.clone())
454            .collect();
455        let mut topo_order = Vec::new();
456
457        while let Some(node) = queue.pop() {
458            topo_order.push(node.clone());
459            if let Some(neighbors) = adj.get(&node) {
460                for neighbor in neighbors {
461                    let deg = in_degree.get_mut(neighbor).unwrap();
462                    *deg -= 1;
463                    if *deg == 0 {
464                        queue.push(neighbor.clone());
465                    }
466                }
467            }
468        }
469
470        if topo_order.len() != node_list.len() {
471            return Err(BuildError::CycleDetected);
472        }
473
474        // 5. Compute graph-level inputs/outputs from root/leaf nodes
475        let inputs = topo_order
476            .iter()
477            .filter(|n| in_degree.get(*n) == Some(&0))
478            .map(|n| nodes[n].arity.0)
479            .sum();
480        let outputs = topo_order
481            .iter()
482            .filter(|n| adj.get(*n).is_none_or(|v| v.is_empty()))
483            .map(|n| nodes[n].arity.1)
484            .sum();
485
486        Ok(GraphIr {
487            inputs,
488            outputs,
489            nodes,
490            edges,
491            topo_order,
492        })
493    }
494
495    /// Convert the graph to an rill-lang AST `Program`.
496    ///
497    /// Each graph node becomes an [`Expr::Apply`](rill_lang::ast::Expr::Apply) with parameters ordered
498    /// according to the builtin's `BuiltinSig::param_names`. Nodes are
499    /// chained via [`BinOp::Seq`](rill_lang::ast::BinOp::Seq) according to their signal connections.
500    ///
501    /// Only simple chain topologies are supported (fan-out/fan-in will
502    /// return [`BuildError::UnsupportedTopology`]).
503    pub fn ast_from_def(
504        &self,
505        registry: &rill_lang::builtin::Registry<T>,
506    ) -> Result<rill_lang::ast::Program, BuildError> {
507        use rill_lang::ast::{BinOp, Def, Expr, Param, Program};
508        use rill_lang::error::Span;
509
510        let dummy = Span::new(0, 0);
511
512        // Build id-to-index mapping
513        let mut id_to_idx: HashMap<u32, usize> = HashMap::new();
514        for (i, r) in self.recipes.iter().enumerate() {
515            id_to_idx.insert(r.id, i);
516        }
517
518        // Resolve builtin names and parameter order for each recipe
519        struct NodeMeta {
520            builtin_name: String,
521            param_values: Vec<f64>,
522            param_names: Vec<String>,
523        }
524
525        let mut node_metas: Vec<NodeMeta> = Vec::with_capacity(self.recipes.len());
526
527        for recipe in &self.recipes {
528            let builtin_name = Self::resolve_builtin_name(&recipe.type_name, registry)
529                .ok_or_else(|| BuildError::UnknownNodeType(recipe.type_name.clone()))?;
530
531            let sig = registry.builtin_sig(&builtin_name).unwrap();
532
533            // Build parameter values in builtin param_names order
534            let param_names: Vec<String> = sig.param_names.iter().map(|n| n.to_string()).collect();
535            let mut param_values = Vec::with_capacity(param_names.len());
536
537            // Build a lookup from recipe param name to f64 value
538            let recipe_defaults: HashMap<&str, f64> = recipe
539                .params
540                .parameters
541                .iter()
542                .filter_map(|(k, v)| v.as_f32().map(|f| (k.as_str(), f as f64)))
543                .collect();
544
545            for name in &param_names {
546                let val = recipe_defaults.get(name.as_str()).copied().unwrap_or(0.0);
547                param_values.push(val);
548            }
549
550            node_metas.push(NodeMeta {
551                builtin_name,
552                param_values,
553                param_names,
554            });
555        }
556
557        // Topological sort
558        let mut in_degree: Vec<usize> = vec![0; self.recipes.len()];
559        let mut adj: Vec<Vec<usize>> = vec![vec![]; self.recipes.len()];
560
561        for (from_idx, _from_port, to_idx, _to_port) in &self.signal_edges {
562            if *from_idx < self.recipes.len() && *to_idx < self.recipes.len() {
563                adj[*from_idx].push(*to_idx);
564                in_degree[*to_idx] += 1;
565            }
566        }
567
568        let mut queue: Vec<usize> = (0..self.recipes.len())
569            .filter(|i| in_degree[*i] == 0)
570            .collect();
571        let mut order: Vec<usize> = Vec::new();
572
573        while let Some(u) = queue.pop() {
574            order.push(u);
575            for &v in &adj[u] {
576                in_degree[v] -= 1;
577                if in_degree[v] == 0 {
578                    queue.push(v);
579                }
580            }
581        }
582
583        if order.len() != self.recipes.len() {
584            return Err(BuildError::CycleDetected);
585        }
586
587        // Check for unsupported topologies
588        for (i, targets) in adj.iter().enumerate() {
589            if targets.len() > 1 {
590                return Err(BuildError::UnsupportedTopology(format!(
591                    "node {} fans out to {} destinations (split not yet supported)",
592                    i,
593                    targets.len()
594                )));
595            }
596            let in_count = self
597                .signal_edges
598                .iter()
599                .filter(|(_, _, to, _)| *to == i)
600                .count();
601            if in_count > 1 {
602                return Err(BuildError::UnsupportedTopology(format!(
603                    "node {} receives {} signal inputs (merge not yet supported)",
604                    i, in_count
605                )));
606            }
607        }
608
609        // Build AST expressions for each node in topo order
610        // Map recipe index → AST expression
611        let mut node_exprs: Vec<Option<Expr>> = vec![None; self.recipes.len()];
612
613        // Collect all parameter names for the main definition
614        let mut all_param_names: Vec<String> = Vec::new();
615
616        for &idx in &order {
617            let meta = &node_metas[idx];
618
619            // Find upstream signal connection
620            let upstream_expr: Option<Expr> = self
621                .signal_edges
622                .iter()
623                .find(|(_, _, to, _)| *to == idx)
624                .and_then(|(from, _from_port, _to, _to_port)| node_exprs[*from].clone());
625
626            // Build args: Float for static (first) params, Ref for dynamic (last) param.
627            // Only expose the last (dynamic) param as a main definition parameter.
628            //
629            // Convention: the last param in builtin param_names is the SetParameter target.
630            let mut args: Vec<Expr> = Vec::new();
631            let n = meta.param_names.len();
632            for (i, (&val, name)) in meta
633                .param_values
634                .iter()
635                .zip(meta.param_names.iter())
636                .enumerate()
637            {
638                if i < n - 1 {
639                    // Static param: put Float constant, no main parameter
640                    args.push(Expr::Float(val, dummy));
641                } else {
642                    // Dynamic param: use Ref + register on main definition
643                    all_param_names.push(name.clone());
644                    args.push(Expr::Ref(name.clone(), dummy));
645                }
646            }
647
648            let apply = Expr::Apply {
649                name: meta.builtin_name.clone(),
650                args,
651                span: dummy,
652            };
653
654            let expr = match upstream_expr {
655                Some(up) => Expr::Bin {
656                    op: BinOp::Seq,
657                    lhs: Box::new(up),
658                    rhs: Box::new(apply),
659                    span: dummy,
660                },
661                None => apply,
662            };
663
664            node_exprs[idx] = Some(expr);
665        }
666
667        // Find the last node (sink/leaf) — the one with no downstream edges
668        let leaf: usize = order
669            .iter()
670            .rfind(|&&i| adj[i].is_empty())
671            .copied()
672            .unwrap_or(0);
673
674        let body = node_exprs[leaf].clone().unwrap_or(Expr::Wire(dummy));
675
676        let params: Vec<Param> = all_param_names
677            .into_iter()
678            .map(|name| Param { name, span: dummy })
679            .collect();
680
681        Ok(Program {
682            defs: vec![Def::Anchor {
683                name: "main".to_string(),
684                params,
685                body,
686                span: dummy,
687                where_defs: vec![],
688            }],
689        })
690    }
691
692    /// Compile directly from the graph definition to a `CompiledGraphEngine`.
693    ///
694    /// Calls [`ast_from_def`](Self::ast_from_def) followed by rill-lang compilation.
695    pub fn compile_def<const BUF: usize>(
696        &self,
697        registry: &rill_lang::builtin::Registry<T>,
698        sample_rate: f32,
699    ) -> Result<rill_lang::graph_engine::CompiledGraphEngine<T, BUF>, BuildError> {
700        let program = self.ast_from_def(registry)?;
701        rill_lang::compile_program::<T, BUF>(&program, registry, sample_rate)
702            .map_err(|e| BuildError::CompilationFailed(format!("{e}")))
703    }
704
705    fn resolve_builtin_name(
706        type_name: &str,
707        registry: &rill_lang::builtin::Registry<T>,
708    ) -> Option<String> {
709        if registry.builtin_sig(type_name).is_some() {
710            return Some(type_name.to_string());
711        }
712        if let Some(rest) = type_name.strip_prefix("rill/") {
713            if registry.builtin_sig(rest).is_some() {
714                return Some(rest.to_string());
715            }
716        }
717        let mapped = match type_name {
718            "rill/dry_wet_mix" => "dry_wet",
719            "rill/parametric_eq" => "eq_parametric",
720            "rill/graphic_eq" => "graphic_eq",
721            "rill/mono_to_stereo" => "mono_to_stereo",
722            "rill/moog_ladder" => "moog",
723            "rill/write_head" => "write_head",
724            "rill/read_head" => "read_head",
725            "rill/lofi_chip" => "ay38910",
726            _ => "",
727        };
728        if !mapped.is_empty() && registry.builtin_sig(mapped).is_some() {
729            return Some(mapped.to_string());
730        }
731        None
732    }
733}