Skip to main content

tono_core/
edit.rs

1//! Surgical, path-addressed edits to a sound graph.
2//!
3//! Instead of re-submitting the whole [`SoundDoc`] to change one number
4//! (`refine_sound`), the agent addresses a node or parameter by a JSON path —
5//! `root.inputs[0].freq`, `root.stages[1].cutoff` — and applies small ops:
6//! many ordered edits in one render. Edits are applied on the `serde_json`
7//! representation, then parsed back to a `SoundDoc` and validated, so an
8//! illegal edit is rejected with a readable error rather than producing a
9//! broken graph.
10
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use serde_json::Value as Json;
14
15use crate::dsl::{SoundDoc, ValidateError};
16
17/// One element of a path: an object key or an array index.
18enum Seg {
19    Key(String),
20    Index(usize),
21}
22
23/// Parse a path like `root.inputs[0].freq`, `root.stages[1].cutoff`, or
24/// `root.notes[0].pitch`. Both `name[0]` and `name.0` index forms are accepted.
25fn parse_path(path: &str) -> Result<Vec<Seg>, String> {
26    let mut segs = Vec::new();
27    for raw in path.split('.') {
28        if raw.is_empty() {
29            continue;
30        }
31        if let Some(br) = raw.find('[') {
32            let key = &raw[..br];
33            if !key.is_empty() {
34                segs.push(Seg::Key(key.to_string()));
35            }
36            let mut s = &raw[br..];
37            while let Some(rest) = s.strip_prefix('[') {
38                let end = rest.find(']').ok_or("unclosed '[' in path")?;
39                let num = &rest[..end];
40                let idx: usize = num
41                    .parse()
42                    .map_err(|_| format!("bad array index '{num}' in path"))?;
43                segs.push(Seg::Index(idx));
44                s = &rest[end + 1..];
45            }
46            if !s.is_empty() {
47                return Err(format!("trailing '{s}' after index in path"));
48            }
49        } else if let Ok(idx) = raw.parse::<usize>() {
50            segs.push(Seg::Index(idx));
51        } else {
52            segs.push(Seg::Key(raw.to_string()));
53        }
54    }
55    Ok(segs)
56}
57
58/// Navigate to a mutable reference at `segs`.
59fn nav_mut<'a>(root: &'a mut Json, segs: &[Seg]) -> Result<&'a mut Json, String> {
60    let mut cur = root;
61    for seg in segs {
62        cur = match seg {
63            Seg::Key(k) => cur
64                .get_mut(k)
65                .ok_or_else(|| format!("no field '{k}' at this path"))?,
66            Seg::Index(i) => cur
67                .get_mut(*i)
68                .ok_or_else(|| format!("no array index {i} at this path"))?,
69        };
70    }
71    Ok(cur)
72}
73
74/// A single edit operation, externally tagged by `op`. (`Serialize` so the
75/// session journal can record edit calls verbatim.)
76#[non_exhaustive]
77#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
78#[serde(tag = "op", rename_all = "snake_case")]
79pub enum EditOp {
80    /// Set the JSON value at `path`. Works for a numeric parameter
81    /// (`root.inputs[0].freq` → `180` or a modulator object), a scalar field
82    /// (`duration`, `seed`, `root.stages[0].q`), or an entire node
83    /// (`root.inputs[1]` → `{ "type": "sine", "freq": 220 }`).
84    Set {
85        /// Path to the target value.
86        path: String,
87        /// The new JSON value (number, modulator object, or whole node).
88        value: Json,
89    },
90    /// Insert a node into the array at `path` (a `chain`'s `stages` or a
91    /// `mix`/`mul`'s `inputs`) at `index` (appended if omitted).
92    Insert {
93        /// Path to the array (e.g. `root.inputs` or `root.stages`).
94        path: String,
95        /// Insertion index; appends when omitted or past the end.
96        #[serde(default)]
97        index: Option<usize>,
98        /// The node to insert.
99        node: Json,
100    },
101    /// Remove an array element: either the element at `index` within the array
102    /// at `path`, or the element a path ending in `[n]` points to.
103    Remove {
104        /// Path to an array, or to an array element ending in `[n]`.
105        path: String,
106        /// Index to remove within the array at `path`.
107        #[serde(default)]
108        index: Option<usize>,
109    },
110}
111
112fn apply_one(json: &mut Json, op: &EditOp) -> Result<(), String> {
113    match op {
114        EditOp::Set { path, value } => {
115            let segs = parse_path(path)?;
116            if segs.is_empty() {
117                return Err("set: path must not be empty".into());
118            }
119            let target = nav_mut(json, &segs)?;
120            *target = value.clone();
121            Ok(())
122        }
123        EditOp::Insert { path, index, node } => {
124            let segs = parse_path(path)?;
125            let target = nav_mut(json, &segs)?;
126            let arr = target
127                .as_array_mut()
128                .ok_or("insert: path does not point to an array (use a chain's `stages` or a mix/mul `inputs`)")?;
129            let i = index.unwrap_or(arr.len()).min(arr.len());
130            arr.insert(i, node.clone());
131            Ok(())
132        }
133        EditOp::Remove { path, index } => {
134            let segs = parse_path(path)?;
135            match index {
136                Some(i) => {
137                    let arr = nav_mut(json, &segs)?
138                        .as_array_mut()
139                        .ok_or("remove: path does not point to an array")?;
140                    if *i >= arr.len() {
141                        return Err(format!(
142                            "remove: index {i} out of range (len {})",
143                            arr.len()
144                        ));
145                    }
146                    arr.remove(*i);
147                    Ok(())
148                }
149                None => match segs.last() {
150                    Some(Seg::Index(i)) => {
151                        let i = *i;
152                        let parent = nav_mut(json, &segs[..segs.len() - 1])?
153                            .as_array_mut()
154                            .ok_or("remove: parent of the indexed element is not an array")?;
155                        if i >= parent.len() {
156                            return Err(format!(
157                                "remove: index {i} out of range (len {})",
158                                parent.len()
159                            ));
160                        }
161                        parent.remove(i);
162                        Ok(())
163                    }
164                    _ => Err("remove: provide `index`, or a path ending in `[n]`".into()),
165                },
166            }
167        }
168    }
169}
170
171/// Why an edit failed.
172#[derive(Debug, Clone, PartialEq)]
173pub enum EditError {
174    /// Op `index` failed — a missing path, a bad array index, or a value the
175    /// node can't take. The reason names the offending path.
176    Op {
177        /// Index of the failing op in the batch.
178        index: usize,
179        /// What went wrong, naming the path.
180        reason: String,
181    },
182    /// The edited JSON no longer deserializes as a `SoundDoc`.
183    InvalidGraph(String),
184    /// The edited document fails validation.
185    Invalid(ValidateError),
186    /// `morph`: the two graphs are not the same shape (or hold values that
187    /// cannot be interpolated) at the named path.
188    Morph(String),
189}
190
191impl std::fmt::Display for EditError {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        match self {
194            EditError::Op { index, reason } => write!(f, "op[{index}]: {reason}"),
195            EditError::InvalidGraph(e) => write!(f, "edited graph is invalid: {e}"),
196            EditError::Invalid(e) => write!(f, "edited document fails validation: {e}"),
197            EditError::Morph(e) => f.write_str(e),
198        }
199    }
200}
201
202impl std::error::Error for EditError {}
203
204impl From<ValidateError> for EditError {
205    fn from(e: ValidateError) -> Self {
206        EditError::Invalid(e)
207    }
208}
209
210/// Apply `ops` to `doc` in order, returning the edited, re-validated graph.
211/// An op referencing a missing path, or producing an invalid graph, fails
212/// with an [`EditError`] naming the offending op index.
213pub fn apply_ops(doc: &SoundDoc, ops: &[EditOp]) -> Result<SoundDoc, EditError> {
214    let mut json = serde_json::to_value(doc).map_err(|e| EditError::InvalidGraph(e.to_string()))?;
215    for (index, op) in ops.iter().enumerate() {
216        apply_one(&mut json, op).map_err(|reason| EditError::Op { index, reason })?;
217    }
218    let edited: SoundDoc =
219        serde_json::from_value(json).map_err(|e| EditError::InvalidGraph(e.to_string()))?;
220    edited.validate()?;
221    Ok(edited)
222}
223
224/// A flattened description of one node in the graph: its path, type, and the
225/// immediate (non-child) parameters addressable by path.
226#[derive(Debug, Clone, Serialize, JsonSchema)]
227pub struct NodeInfo {
228    /// Path to this node (e.g. `root`, `root.inputs[0]`, `root.stages[1]`).
229    pub path: String,
230    /// Node type (`square`, `lowpass`, `mix`, ...).
231    #[serde(rename = "type")]
232    pub node_type: String,
233    /// Immediate scalar / modulator parameters, keyed by name. Child node arrays
234    /// (`inputs` / `stages` / `notes`) are listed as separate `NodeInfo` rows.
235    pub params: Json,
236}
237
238/// Recognised child-array field names (arrays of nodes / notes / modal modes).
239fn is_child_array(key: &str) -> bool {
240    matches!(key, "inputs" | "stages" | "notes" | "modes")
241}
242
243/// Join a path prefix and a segment (layer-relative paths start empty: the
244/// layer's own node is path `""`, its children `inputs[0]`, `env`, ...).
245fn join(prefix: &str, seg: &str) -> String {
246    if prefix.is_empty() {
247        seg.to_string()
248    } else {
249        format!("{prefix}.{seg}")
250    }
251}
252
253fn walk(json: &Json, path: &str, out: &mut Vec<NodeInfo>) {
254    let Some(obj) = json.as_object() else { return };
255    if let Some(t) = obj.get("type").and_then(|v| v.as_str()) {
256        let mut params = serde_json::Map::new();
257        for (k, v) in obj {
258            // Child structures get their own rows — duplicating the whole
259            // tracks/master subtree into a params blob helps nobody.
260            if k == "type" || is_child_array(k) || k == "tracks" || k == "master" {
261                continue;
262            }
263            params.insert(k.clone(), v.clone());
264        }
265        out.push(NodeInfo {
266            path: path.to_string(),
267            node_type: t.to_string(),
268            params: Json::Object(params),
269        });
270    }
271    // Recurse into child node arrays regardless (covers nested mix/chain).
272    for (k, v) in obj {
273        if k == "tracks" {
274            // Mixer channels: each element wraps its graph in `node`.
275            if let Some(arr) = v.as_array() {
276                for (i, ch) in arr.iter().enumerate() {
277                    if let Some(node) = ch.get("node") {
278                        walk(node, &join(path, &format!("tracks[{i}].node")), out);
279                    }
280                }
281            }
282            continue;
283        }
284        if k == "trigger" || k == "node" {
285            walk(v, &join(path, k), out);
286            continue;
287        }
288        if k == "modes" {
289            // Modal partials have no `type` tag (like seq notes); give each its
290            // own addressable row so `modes[i].freq` is discoverable.
291            if let Some(arr) = v.as_array() {
292                for (i, mode) in arr.iter().enumerate() {
293                    out.push(NodeInfo {
294                        path: join(path, &format!("modes[{i}]")),
295                        node_type: "mode".to_string(),
296                        params: mode.clone(),
297                    });
298                }
299            }
300            continue;
301        }
302        if k == "notes" {
303            // Seq notes have no `type` tag but ARE the most-edited leaves in
304            // the music workflow — every note gets an addressable row.
305            if let Some(arr) = v.as_array() {
306                for (i, note) in arr.iter().enumerate() {
307                    out.push(NodeInfo {
308                        path: join(path, &format!("notes[{i}]")),
309                        node_type: "note".to_string(),
310                        params: note.clone(),
311                    });
312                }
313            }
314            continue;
315        }
316        if !is_child_array(k) {
317            continue;
318        }
319        if let Some(arr) = v.as_array() {
320            for (i, child) in arr.iter().enumerate() {
321                walk(child, &join(path, &format!("{k}[{i}]")), out);
322            }
323        }
324    }
325}
326
327/// One mixer layer's addressing map: its mixer fields plus the node rows of its
328/// graph. Node paths are LAYER-RELATIVE — combined with the layer id when
329/// editing; the layer's own node is path `""`.
330#[derive(Debug, Clone, Serialize, JsonSchema)]
331pub struct LayerInfo {
332    /// The stable layer id (edits address a layer by this id plus a path).
333    pub id: String,
334    /// Stereo position −1..1.
335    pub pan: f32,
336    /// Fader 0..2.
337    pub gain: f32,
338    /// Start offset in seconds.
339    pub at: f32,
340    /// Muted layers are off the bus (and off every export).
341    pub mute: bool,
342    /// The layer's nodes, with layer-relative paths.
343    pub nodes: Vec<NodeInfo>,
344}
345
346/// The full addressing map of a document.
347#[derive(Debug, Clone, Serialize, JsonSchema)]
348pub struct DescribeMap {
349    /// Node rows of a plain (non-mixer) document, absolute paths from `root`.
350    pub nodes: Vec<NodeInfo>,
351    /// Mixer layers, keyed by stable id (empty for plain documents).
352    pub layers: Vec<LayerInfo>,
353    /// Master-chain processors, addressed absolutely (`root.master[0]`, no
354    /// `layer` arg).
355    pub master: Vec<NodeInfo>,
356}
357
358/// Produce the addressing map for a graph — exactly what can be edited, and the
359/// path (and layer id) to reach each field.
360pub fn describe(doc: &SoundDoc) -> DescribeMap {
361    let mut map = DescribeMap {
362        nodes: Vec::new(),
363        layers: Vec::new(),
364        master: Vec::new(),
365    };
366    // Serializing a derived-Serialize SoundDoc is infallible in practice (the
367    // presets::patch precedent); failing loud beats returning an empty map an
368    // agent would read as "nothing editable".
369    let json = serde_json::to_value(doc).expect("SoundDoc serializes");
370    if let crate::dsl::Node::Tracks { tracks, master } = &doc.root {
371        for (i, t) in tracks.iter().enumerate() {
372            let mut nodes = Vec::new();
373            if let Some(node_json) = json["root"]["tracks"][i].get("node") {
374                walk(node_json, "", &mut nodes);
375            }
376            map.layers.push(LayerInfo {
377                id: t.id.clone().unwrap_or_else(|| format!("layer_{i}")),
378                pan: t.pan,
379                gain: t.gain,
380                at: t.at,
381                mute: t.mute,
382                nodes,
383            });
384        }
385        for (i, _) in master.iter().enumerate() {
386            if let Some(m) = json["root"]["master"].get(i) {
387                walk(m, &format!("root.master[{i}]"), &mut map.master);
388            }
389        }
390        return map;
391    }
392    if let Some(root) = json.get("root") {
393        walk(root, "root", &mut map.nodes);
394    }
395    map
396}
397
398/// Linearly interpolate two same-shaped graphs at `t ∈ [0, 1]`: every numeric
399/// parameter is lerped (integers re-rounded), note-name strings are lerped in
400/// Hz, and any structural difference is a clear error. `t = 0` ⇒ `a`,
401/// `t = 1` ⇒ `b`.
402pub fn morph(a: &SoundDoc, b: &SoundDoc, t: f32) -> Result<SoundDoc, EditError> {
403    let mut ja = serde_json::to_value(a).map_err(|e| EditError::InvalidGraph(e.to_string()))?;
404    let mut jb = serde_json::to_value(b).map_err(|e| EditError::InvalidGraph(e.to_string()))?;
405    // Name/version/engine/seed/sample_rate are identity and provenance, not
406    // parameters — unify them before the walk. Interpolating `engine` would
407    // sweep a morph through different DSP kernels mid-t (audible timbre
408    // jumps), and an optional-field presence mismatch would reject two
409    // structurally identical sounds.
410    jb["name"] = ja["name"].clone();
411    jb["seed"] = ja["seed"].clone();
412    jb["sample_rate"] = ja["sample_rate"].clone();
413    // `version` is optional on the wire: mirror a's presence/absence exactly
414    // so the key sets always match.
415    if let Some(v) = ja.get("version") {
416        jb["version"] = v.clone();
417    } else if let Some(o) = jb.as_object_mut() {
418        o.remove("version");
419    }
420    // The newer kernel of the two wins, so a morph never downgrades either
421    // sound's DSP (engine 0 = the legacy omitted field).
422    let engine = a.effective_engine().max(b.effective_engine());
423    for j in [&mut ja, &mut jb] {
424        if let Some(o) = j.as_object_mut() {
425            if engine > 0 {
426                o.insert("engine".into(), serde_json::json!(engine));
427            } else {
428                o.remove("engine");
429            }
430        }
431    }
432    // Layer identity is positional in a morph: two same-shaped mixer docs
433    // almost always carry different layer ids (they're unique per doc), and
434    // ids/mute are not interpolatable — copy a's onto b instead of erroring.
435    if let Some(ta) = ja["root"].get("tracks").and_then(|v| v.as_array()).cloned()
436        && let Some(tb) = jb["root"].get_mut("tracks").and_then(|v| v.as_array_mut())
437    {
438        for (i, track_b) in tb.iter_mut().enumerate() {
439            let Some(track_a) = ta.get(i) else { break };
440            for key in ["id", "mute"] {
441                match track_a.get(key) {
442                    Some(v) => track_b[key] = v.clone(),
443                    None => {
444                        if let Some(o) = track_b.as_object_mut() {
445                            o.remove(key);
446                        }
447                    }
448                }
449            }
450        }
451    }
452    let merged = lerp_json(&ja, &jb, t, "$").map_err(EditError::Morph)?;
453    let doc: SoundDoc = serde_json::from_value(merged)
454        .map_err(|e| EditError::InvalidGraph(format!("morphed graph invalid: {e}")))?;
455    doc.validate()?;
456    Ok(doc)
457}
458
459fn lerp_json(a: &Json, b: &Json, t: f32, path: &str) -> Result<Json, String> {
460    match (a, b) {
461        (Json::Number(x), Json::Number(y)) => {
462            let (fx, fy) = (x.as_f64().unwrap_or(0.0), y.as_f64().unwrap_or(0.0));
463            let v = fx + (fy - fx) * t as f64;
464            if (x.is_i64() || x.is_u64()) && (y.is_i64() || y.is_u64()) {
465                Ok(Json::from(v.round() as i64))
466            } else {
467                Ok(serde_json::Number::from_f64(v)
468                    .map(Json::Number)
469                    .unwrap_or_else(|| Json::from(0)))
470            }
471        }
472        (Json::String(x), Json::String(y)) => {
473            if x == y {
474                Ok(a.clone())
475            } else if let (Some(fa), Some(fb)) =
476                (crate::dsl::note_to_hz(x), crate::dsl::note_to_hz(y))
477            {
478                // Two different note names: morph the pitch in Hz.
479                let hz = fa + (fb - fa) * t;
480                Ok(serde_json::Number::from_f64(hz as f64)
481                    .map(Json::Number)
482                    .unwrap_or_else(|| Json::from(0)))
483            } else {
484                Err(format!(
485                    "{path}: cannot morph between '{x}' and '{y}' — node types / enum choices must match"
486                ))
487            }
488        }
489        (Json::Array(x), Json::Array(y)) => {
490            if x.len() != y.len() {
491                return Err(format!(
492                    "{path}: array lengths differ ({} vs {}) — morph needs identical structure",
493                    x.len(),
494                    y.len()
495                ));
496            }
497            x.iter()
498                .zip(y)
499                .enumerate()
500                .map(|(i, (xa, xb))| lerp_json(xa, xb, t, &format!("{path}[{i}]")))
501                .collect::<Result<Vec<_>, _>>()
502                .map(Json::Array)
503        }
504        (Json::Object(x), Json::Object(y)) => {
505            if x.len() != y.len() || x.keys().any(|k| !y.contains_key(k)) {
506                return Err(format!(
507                    "{path}: object fields differ — morph needs graphs with identical shape"
508                ));
509            }
510            let mut out = serde_json::Map::new();
511            for (k, va) in x {
512                out.insert(k.clone(), lerp_json(va, &y[k], t, &format!("{path}.{k}"))?);
513            }
514            Ok(Json::Object(out))
515        }
516        (Json::Bool(x), Json::Bool(y)) if x == y => Ok(a.clone()),
517        (Json::Null, Json::Null) => Ok(Json::Null),
518        _ => Err(format!(
519            "{path}: structure mismatch — morph interpolates parameters of two same-shaped graphs"
520        )),
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    fn laser() -> SoundDoc {
529        serde_json::from_str(
530            r#"{ "name": "laser", "duration": 0.2, "root": { "type": "mix", "inputs": [
531                { "type": "mul", "inputs": [
532                    { "type": "square", "freq": 880, "duty": 0.25 },
533                    { "type": "env", "d": 0.18 } ] },
534                { "type": "noise" }
535            ] } }"#,
536        )
537        .unwrap()
538    }
539
540    fn op(json: &str) -> EditOp {
541        serde_json::from_str(json).unwrap()
542    }
543
544    #[test]
545    fn set_changes_a_nested_param() {
546        let edited = apply_ops(
547            &laser(),
548            &[op(
549                r#"{ "op": "set", "path": "root.inputs[0].inputs[0].freq", "value": 440 }"#,
550            )],
551        )
552        .unwrap();
553        let v = serde_json::to_value(&edited).unwrap();
554        assert_eq!(v["root"]["inputs"][0]["inputs"][0]["freq"], 440.0);
555    }
556
557    #[test]
558    fn insert_and_remove_reshape_arrays() {
559        let edited = apply_ops(
560            &laser(),
561            &[
562                op(r#"{ "op": "insert", "path": "root.inputs",
563                         "node": { "type": "sine", "freq": 220 } }"#),
564                op(r#"{ "op": "remove", "path": "root.inputs[1]" }"#),
565            ],
566        )
567        .unwrap();
568        let v = serde_json::to_value(&edited).unwrap();
569        let inputs = v["root"]["inputs"].as_array().unwrap();
570        assert_eq!(inputs.len(), 2); // noise removed, sine appended
571        assert_eq!(inputs[1]["type"], "sine");
572    }
573
574    #[test]
575    fn bad_edits_fail_with_op_index_and_reason() {
576        let err = apply_ops(
577            &laser(),
578            &[op(
579                r#"{ "op": "set", "path": "root.nope.freq", "value": 1 }"#,
580            )],
581        )
582        .unwrap_err();
583        assert!(err.to_string().contains("op[0]"), "{err}");
584        assert!(err.to_string().contains("no field 'nope'"), "{err}");
585        // An edit that parses but breaks validation is also rejected.
586        let err = apply_ops(
587            &laser(),
588            &[op(r#"{ "op": "set", "path": "duration", "value": -1 }"#)],
589        )
590        .unwrap_err();
591        assert!(err.to_string().contains("duration"), "{err}");
592    }
593
594    #[test]
595    fn describe_maps_layers_notes_and_master() {
596        let doc: SoundDoc = serde_json::from_str(
597            r#"{ "name": "song", "duration": 1.0, "root": { "type": "tracks",
598                "tracks": [
599                  { "id": "lead", "node": { "type": "seq", "bpm": 120, "wave": "sine",
600                      "env": { "d": 0.1 },
601                      "notes": [ { "step": 0, "len": 2, "pitch": "C4" } ] } },
602                  { "id": "pad", "node": { "type": "chain", "stages": [
603                      { "type": "sine", "freq": 220 },
604                      { "type": "lowpass", "cutoff": 900 } ] }, "pan": -0.3 }
605                ],
606                "master": [ { "type": "compress", "threshold": -12, "ratio": 4 } ] } }"#,
607        )
608        .unwrap();
609        let map = describe(&doc);
610        assert!(map.nodes.is_empty()); // mixer docs describe per layer
611        assert_eq!(map.layers.len(), 2);
612        let lead = &map.layers[0];
613        assert_eq!(lead.id, "lead");
614        // The seq itself (layer-relative path "") and its note both get rows.
615        assert_eq!(lead.nodes[0].path, "");
616        assert_eq!(lead.nodes[0].node_type, "seq");
617        assert!(
618            !lead.nodes[0]
619                .params
620                .as_object()
621                .unwrap()
622                .contains_key("notes")
623        );
624        assert_eq!(lead.nodes[1].path, "notes[0]");
625        assert_eq!(lead.nodes[1].node_type, "note");
626        assert_eq!(lead.nodes[1].params["pitch"], "C4");
627        let pad = &map.layers[1];
628        assert_eq!(pad.nodes[1].path, "stages[0]");
629        assert_eq!(pad.pan, -0.3);
630        // Master processors are addressable without a layer arg.
631        assert_eq!(map.master[0].path, "root.master[0]");
632        assert_eq!(map.master[0].node_type, "compress");
633    }
634
635    #[test]
636    fn describe_lists_every_node_with_paths() {
637        let infos = describe(&laser()).nodes;
638        let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
639        assert_eq!(
640            paths,
641            vec![
642                "root",
643                "root.inputs[0]",
644                "root.inputs[0].inputs[0]",
645                "root.inputs[0].inputs[1]",
646                "root.inputs[1]",
647            ]
648        );
649        assert_eq!(infos[2].node_type, "square");
650        assert_eq!(infos[2].params["freq"], 880.0);
651    }
652
653    #[test]
654    fn morph_unifies_layer_identity() {
655        let mk = |id1: &str, id2: &str, f: f32| -> SoundDoc {
656            serde_json::from_str(&format!(
657                r#"{{ "name": "m", "duration": 0.2, "version": 2,
658                     "root": {{ "type": "tracks", "tracks": [
659                        {{ "id": "{id1}", "node": {{ "type": "sine", "freq": {f} }} }},
660                        {{ "id": "{id2}", "node": {{ "type": "noise" }}, "mute": true }}
661                     ] }} }}"#
662            ))
663            .unwrap()
664        };
665        // Independently-minted layer ids must never block a morph.
666        let a = mk("crack", "tail_a", 200.0);
667        let b = mk("snap", "tail_b", 400.0);
668        let mid = morph(&a, &b, 0.5).unwrap();
669        let v = serde_json::to_value(&mid).unwrap();
670        assert_eq!(v["root"]["tracks"][0]["id"], "crack"); // a's identity wins
671        assert_eq!(v["root"]["tracks"][0]["node"]["freq"], 300.0);
672        assert_eq!(v["root"]["tracks"][1]["mute"], true);
673    }
674
675    #[test]
676    fn morph_midpoint_lerps_numbers_and_notes() {
677        let a: SoundDoc = serde_json::from_str(
678            r#"{ "name": "a", "duration": 0.2, "root": { "type": "sine", "freq": 200 } }"#,
679        )
680        .unwrap();
681        let b: SoundDoc = serde_json::from_str(
682            r#"{ "name": "b", "duration": 0.4, "root": { "type": "sine", "freq": 400 } }"#,
683        )
684        .unwrap();
685        let mid = morph(&a, &b, 0.5).unwrap();
686        let v = serde_json::to_value(&mid).unwrap();
687        assert_eq!(v["root"]["freq"], 300.0);
688        assert!((mid.duration - 0.3).abs() < 1e-6);
689        // Structural mismatch is a clear error.
690        let c: SoundDoc =
691            serde_json::from_str(r#"{ "name": "c", "root": { "type": "noise" } }"#).unwrap();
692        assert!(morph(&a, &c, 0.5).is_err());
693    }
694
695    #[test]
696    fn morph_unifies_engine_seed_and_rate_instead_of_lerping() {
697        // Presence mismatch (engine on one side only) must not reject the
698        // morph, and the value must never interpolate through intermediate
699        // kernels — the newer of the two wins.
700        let a: SoundDoc = serde_json::from_str(
701            r#"{ "name": "a", "duration": 0.2, "seed": 7,
702                 "root": { "type": "sine", "freq": 200 } }"#,
703        )
704        .unwrap();
705        let b: SoundDoc = serde_json::from_str(
706            r#"{ "name": "b", "duration": 0.2, "seed": 99, "engine": 3,
707                 "root": { "type": "sine", "freq": 400 } }"#,
708        )
709        .unwrap();
710        let mid = morph(&a, &b, 0.5).unwrap();
711        assert_eq!(mid.engine, Some(3), "max engine wins, never a lerp");
712        assert_eq!(mid.seed, 7, "a's seed is identity, not a parameter");
713        // Both legacy (no engine) stays legacy.
714        let c: SoundDoc = serde_json::from_str(
715            r#"{ "name": "c", "duration": 0.2, "root": { "type": "sine", "freq": 300 } }"#,
716        )
717        .unwrap();
718        assert_eq!(morph(&a, &c, 0.5).unwrap().engine, None);
719    }
720}