use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value as Json;
use crate::dsl::{SoundDoc, ValidateError};
enum Seg {
Key(String),
Index(usize),
}
fn parse_path(path: &str) -> Result<Vec<Seg>, String> {
let mut segs = Vec::new();
for raw in path.split('.') {
if raw.is_empty() {
continue;
}
if let Some(br) = raw.find('[') {
let key = &raw[..br];
if !key.is_empty() {
segs.push(Seg::Key(key.to_string()));
}
let mut s = &raw[br..];
while let Some(rest) = s.strip_prefix('[') {
let end = rest.find(']').ok_or("unclosed '[' in path")?;
let num = &rest[..end];
let idx: usize = num
.parse()
.map_err(|_| format!("bad array index '{num}' in path"))?;
segs.push(Seg::Index(idx));
s = &rest[end + 1..];
}
if !s.is_empty() {
return Err(format!("trailing '{s}' after index in path"));
}
} else if let Ok(idx) = raw.parse::<usize>() {
segs.push(Seg::Index(idx));
} else {
segs.push(Seg::Key(raw.to_string()));
}
}
Ok(segs)
}
fn nav_mut<'a>(root: &'a mut Json, segs: &[Seg]) -> Result<&'a mut Json, String> {
let mut cur = root;
for seg in segs {
cur = match seg {
Seg::Key(k) => cur
.get_mut(k)
.ok_or_else(|| format!("no field '{k}' at this path"))?,
Seg::Index(i) => cur
.get_mut(*i)
.ok_or_else(|| format!("no array index {i} at this path"))?,
};
}
Ok(cur)
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum EditOp {
Set {
path: String,
value: Json,
},
Insert {
path: String,
#[serde(default)]
index: Option<usize>,
node: Json,
},
Remove {
path: String,
#[serde(default)]
index: Option<usize>,
},
}
fn apply_one(json: &mut Json, op: &EditOp) -> Result<(), String> {
match op {
EditOp::Set { path, value } => {
let segs = parse_path(path)?;
if segs.is_empty() {
return Err("set: path must not be empty".into());
}
let target = nav_mut(json, &segs)?;
*target = value.clone();
Ok(())
}
EditOp::Insert { path, index, node } => {
let segs = parse_path(path)?;
let target = nav_mut(json, &segs)?;
let arr = target
.as_array_mut()
.ok_or("insert: path does not point to an array (use a chain's `stages` or a mix/mul `inputs`)")?;
let i = index.unwrap_or(arr.len()).min(arr.len());
arr.insert(i, node.clone());
Ok(())
}
EditOp::Remove { path, index } => {
let segs = parse_path(path)?;
match index {
Some(i) => {
let arr = nav_mut(json, &segs)?
.as_array_mut()
.ok_or("remove: path does not point to an array")?;
if *i >= arr.len() {
return Err(format!(
"remove: index {i} out of range (len {})",
arr.len()
));
}
arr.remove(*i);
Ok(())
}
None => match segs.last() {
Some(Seg::Index(i)) => {
let i = *i;
let parent = nav_mut(json, &segs[..segs.len() - 1])?
.as_array_mut()
.ok_or("remove: parent of the indexed element is not an array")?;
if i >= parent.len() {
return Err(format!(
"remove: index {i} out of range (len {})",
parent.len()
));
}
parent.remove(i);
Ok(())
}
_ => Err("remove: provide `index`, or a path ending in `[n]`".into()),
},
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum EditError {
Op {
index: usize,
reason: String,
},
InvalidGraph(String),
Invalid(ValidateError),
Morph(String),
}
impl std::fmt::Display for EditError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EditError::Op { index, reason } => write!(f, "op[{index}]: {reason}"),
EditError::InvalidGraph(e) => write!(f, "edited graph is invalid: {e}"),
EditError::Invalid(e) => write!(f, "edited document fails validation: {e}"),
EditError::Morph(e) => f.write_str(e),
}
}
}
impl std::error::Error for EditError {}
impl From<ValidateError> for EditError {
fn from(e: ValidateError) -> Self {
EditError::Invalid(e)
}
}
pub fn apply_ops(doc: &SoundDoc, ops: &[EditOp]) -> Result<SoundDoc, EditError> {
let mut json = serde_json::to_value(doc).map_err(|e| EditError::InvalidGraph(e.to_string()))?;
for (index, op) in ops.iter().enumerate() {
apply_one(&mut json, op).map_err(|reason| EditError::Op { index, reason })?;
}
let edited: SoundDoc =
serde_json::from_value(json).map_err(|e| EditError::InvalidGraph(e.to_string()))?;
edited.validate()?;
Ok(edited)
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct NodeInfo {
pub path: String,
#[serde(rename = "type")]
pub node_type: String,
pub params: Json,
}
fn is_child_array(key: &str) -> bool {
matches!(key, "inputs" | "stages" | "notes" | "modes")
}
fn join(prefix: &str, seg: &str) -> String {
if prefix.is_empty() {
seg.to_string()
} else {
format!("{prefix}.{seg}")
}
}
fn walk(json: &Json, path: &str, out: &mut Vec<NodeInfo>) {
let Some(obj) = json.as_object() else { return };
if let Some(t) = obj.get("type").and_then(|v| v.as_str()) {
let mut params = serde_json::Map::new();
for (k, v) in obj {
if k == "type" || is_child_array(k) || k == "tracks" || k == "master" {
continue;
}
params.insert(k.clone(), v.clone());
}
out.push(NodeInfo {
path: path.to_string(),
node_type: t.to_string(),
params: Json::Object(params),
});
}
for (k, v) in obj {
if k == "tracks" {
if let Some(arr) = v.as_array() {
for (i, ch) in arr.iter().enumerate() {
if let Some(node) = ch.get("node") {
walk(node, &join(path, &format!("tracks[{i}].node")), out);
}
}
}
continue;
}
if k == "trigger" || k == "node" {
walk(v, &join(path, k), out);
continue;
}
if k == "modes" {
if let Some(arr) = v.as_array() {
for (i, mode) in arr.iter().enumerate() {
out.push(NodeInfo {
path: join(path, &format!("modes[{i}]")),
node_type: "mode".to_string(),
params: mode.clone(),
});
}
}
continue;
}
if k == "notes" {
if let Some(arr) = v.as_array() {
for (i, note) in arr.iter().enumerate() {
out.push(NodeInfo {
path: join(path, &format!("notes[{i}]")),
node_type: "note".to_string(),
params: note.clone(),
});
}
}
continue;
}
if !is_child_array(k) {
continue;
}
if let Some(arr) = v.as_array() {
for (i, child) in arr.iter().enumerate() {
walk(child, &join(path, &format!("{k}[{i}]")), out);
}
}
}
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct LayerInfo {
pub id: String,
pub pan: f32,
pub gain: f32,
pub at: f32,
pub mute: bool,
pub nodes: Vec<NodeInfo>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct DescribeMap {
pub nodes: Vec<NodeInfo>,
pub layers: Vec<LayerInfo>,
pub master: Vec<NodeInfo>,
}
pub fn describe(doc: &SoundDoc) -> DescribeMap {
let mut map = DescribeMap {
nodes: Vec::new(),
layers: Vec::new(),
master: Vec::new(),
};
let json = serde_json::to_value(doc).expect("SoundDoc serializes");
if let crate::dsl::Node::Tracks { tracks, master } = &doc.root {
for (i, t) in tracks.iter().enumerate() {
let mut nodes = Vec::new();
if let Some(node_json) = json["root"]["tracks"][i].get("node") {
walk(node_json, "", &mut nodes);
}
map.layers.push(LayerInfo {
id: t.id.clone().unwrap_or_else(|| format!("layer_{i}")),
pan: t.pan,
gain: t.gain,
at: t.at,
mute: t.mute,
nodes,
});
}
for (i, _) in master.iter().enumerate() {
if let Some(m) = json["root"]["master"].get(i) {
walk(m, &format!("root.master[{i}]"), &mut map.master);
}
}
return map;
}
if let Some(root) = json.get("root") {
walk(root, "root", &mut map.nodes);
}
map
}
pub fn morph(a: &SoundDoc, b: &SoundDoc, t: f32) -> Result<SoundDoc, EditError> {
let mut ja = serde_json::to_value(a).map_err(|e| EditError::InvalidGraph(e.to_string()))?;
let mut jb = serde_json::to_value(b).map_err(|e| EditError::InvalidGraph(e.to_string()))?;
jb["name"] = ja["name"].clone();
jb["seed"] = ja["seed"].clone();
jb["sample_rate"] = ja["sample_rate"].clone();
if let Some(v) = ja.get("version") {
jb["version"] = v.clone();
} else if let Some(o) = jb.as_object_mut() {
o.remove("version");
}
let engine = a.effective_engine().max(b.effective_engine());
for j in [&mut ja, &mut jb] {
if let Some(o) = j.as_object_mut() {
if engine > 0 {
o.insert("engine".into(), serde_json::json!(engine));
} else {
o.remove("engine");
}
}
}
if let Some(ta) = ja["root"].get("tracks").and_then(|v| v.as_array()).cloned()
&& let Some(tb) = jb["root"].get_mut("tracks").and_then(|v| v.as_array_mut())
{
for (i, track_b) in tb.iter_mut().enumerate() {
let Some(track_a) = ta.get(i) else { break };
for key in ["id", "mute"] {
match track_a.get(key) {
Some(v) => track_b[key] = v.clone(),
None => {
if let Some(o) = track_b.as_object_mut() {
o.remove(key);
}
}
}
}
}
}
let merged = lerp_json(&ja, &jb, t, "$").map_err(EditError::Morph)?;
let doc: SoundDoc = serde_json::from_value(merged)
.map_err(|e| EditError::InvalidGraph(format!("morphed graph invalid: {e}")))?;
doc.validate()?;
Ok(doc)
}
fn lerp_json(a: &Json, b: &Json, t: f32, path: &str) -> Result<Json, String> {
match (a, b) {
(Json::Number(x), Json::Number(y)) => {
let (fx, fy) = (x.as_f64().unwrap_or(0.0), y.as_f64().unwrap_or(0.0));
let v = fx + (fy - fx) * t as f64;
if (x.is_i64() || x.is_u64()) && (y.is_i64() || y.is_u64()) {
Ok(Json::from(v.round() as i64))
} else {
Ok(serde_json::Number::from_f64(v)
.map(Json::Number)
.unwrap_or_else(|| Json::from(0)))
}
}
(Json::String(x), Json::String(y)) => {
if x == y {
Ok(a.clone())
} else if let (Some(fa), Some(fb)) =
(crate::dsl::note_to_hz(x), crate::dsl::note_to_hz(y))
{
let hz = fa + (fb - fa) * t;
Ok(serde_json::Number::from_f64(hz as f64)
.map(Json::Number)
.unwrap_or_else(|| Json::from(0)))
} else {
Err(format!(
"{path}: cannot morph between '{x}' and '{y}' — node types / enum choices must match"
))
}
}
(Json::Array(x), Json::Array(y)) => {
if x.len() != y.len() {
return Err(format!(
"{path}: array lengths differ ({} vs {}) — morph needs identical structure",
x.len(),
y.len()
));
}
x.iter()
.zip(y)
.enumerate()
.map(|(i, (xa, xb))| lerp_json(xa, xb, t, &format!("{path}[{i}]")))
.collect::<Result<Vec<_>, _>>()
.map(Json::Array)
}
(Json::Object(x), Json::Object(y)) => {
if x.len() != y.len() || x.keys().any(|k| !y.contains_key(k)) {
return Err(format!(
"{path}: object fields differ — morph needs graphs with identical shape"
));
}
let mut out = serde_json::Map::new();
for (k, va) in x {
out.insert(k.clone(), lerp_json(va, &y[k], t, &format!("{path}.{k}"))?);
}
Ok(Json::Object(out))
}
(Json::Bool(x), Json::Bool(y)) if x == y => Ok(a.clone()),
(Json::Null, Json::Null) => Ok(Json::Null),
_ => Err(format!(
"{path}: structure mismatch — morph interpolates parameters of two same-shaped graphs"
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn laser() -> SoundDoc {
serde_json::from_str(
r#"{ "name": "laser", "duration": 0.2, "root": { "type": "mix", "inputs": [
{ "type": "mul", "inputs": [
{ "type": "square", "freq": 880, "duty": 0.25 },
{ "type": "env", "d": 0.18 } ] },
{ "type": "noise" }
] } }"#,
)
.unwrap()
}
fn op(json: &str) -> EditOp {
serde_json::from_str(json).unwrap()
}
#[test]
fn set_changes_a_nested_param() {
let edited = apply_ops(
&laser(),
&[op(
r#"{ "op": "set", "path": "root.inputs[0].inputs[0].freq", "value": 440 }"#,
)],
)
.unwrap();
let v = serde_json::to_value(&edited).unwrap();
assert_eq!(v["root"]["inputs"][0]["inputs"][0]["freq"], 440.0);
}
#[test]
fn insert_and_remove_reshape_arrays() {
let edited = apply_ops(
&laser(),
&[
op(r#"{ "op": "insert", "path": "root.inputs",
"node": { "type": "sine", "freq": 220 } }"#),
op(r#"{ "op": "remove", "path": "root.inputs[1]" }"#),
],
)
.unwrap();
let v = serde_json::to_value(&edited).unwrap();
let inputs = v["root"]["inputs"].as_array().unwrap();
assert_eq!(inputs.len(), 2); assert_eq!(inputs[1]["type"], "sine");
}
#[test]
fn bad_edits_fail_with_op_index_and_reason() {
let err = apply_ops(
&laser(),
&[op(
r#"{ "op": "set", "path": "root.nope.freq", "value": 1 }"#,
)],
)
.unwrap_err();
assert!(err.to_string().contains("op[0]"), "{err}");
assert!(err.to_string().contains("no field 'nope'"), "{err}");
let err = apply_ops(
&laser(),
&[op(r#"{ "op": "set", "path": "duration", "value": -1 }"#)],
)
.unwrap_err();
assert!(err.to_string().contains("duration"), "{err}");
}
#[test]
fn describe_maps_layers_notes_and_master() {
let doc: SoundDoc = serde_json::from_str(
r#"{ "name": "song", "duration": 1.0, "root": { "type": "tracks",
"tracks": [
{ "id": "lead", "node": { "type": "seq", "bpm": 120, "wave": "sine",
"env": { "d": 0.1 },
"notes": [ { "step": 0, "len": 2, "pitch": "C4" } ] } },
{ "id": "pad", "node": { "type": "chain", "stages": [
{ "type": "sine", "freq": 220 },
{ "type": "lowpass", "cutoff": 900 } ] }, "pan": -0.3 }
],
"master": [ { "type": "compress", "threshold": -12, "ratio": 4 } ] } }"#,
)
.unwrap();
let map = describe(&doc);
assert!(map.nodes.is_empty()); assert_eq!(map.layers.len(), 2);
let lead = &map.layers[0];
assert_eq!(lead.id, "lead");
assert_eq!(lead.nodes[0].path, "");
assert_eq!(lead.nodes[0].node_type, "seq");
assert!(
!lead.nodes[0]
.params
.as_object()
.unwrap()
.contains_key("notes")
);
assert_eq!(lead.nodes[1].path, "notes[0]");
assert_eq!(lead.nodes[1].node_type, "note");
assert_eq!(lead.nodes[1].params["pitch"], "C4");
let pad = &map.layers[1];
assert_eq!(pad.nodes[1].path, "stages[0]");
assert_eq!(pad.pan, -0.3);
assert_eq!(map.master[0].path, "root.master[0]");
assert_eq!(map.master[0].node_type, "compress");
}
#[test]
fn describe_lists_every_node_with_paths() {
let infos = describe(&laser()).nodes;
let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
assert_eq!(
paths,
vec![
"root",
"root.inputs[0]",
"root.inputs[0].inputs[0]",
"root.inputs[0].inputs[1]",
"root.inputs[1]",
]
);
assert_eq!(infos[2].node_type, "square");
assert_eq!(infos[2].params["freq"], 880.0);
}
#[test]
fn morph_unifies_layer_identity() {
let mk = |id1: &str, id2: &str, f: f32| -> SoundDoc {
serde_json::from_str(&format!(
r#"{{ "name": "m", "duration": 0.2, "version": 2,
"root": {{ "type": "tracks", "tracks": [
{{ "id": "{id1}", "node": {{ "type": "sine", "freq": {f} }} }},
{{ "id": "{id2}", "node": {{ "type": "noise" }}, "mute": true }}
] }} }}"#
))
.unwrap()
};
let a = mk("crack", "tail_a", 200.0);
let b = mk("snap", "tail_b", 400.0);
let mid = morph(&a, &b, 0.5).unwrap();
let v = serde_json::to_value(&mid).unwrap();
assert_eq!(v["root"]["tracks"][0]["id"], "crack"); assert_eq!(v["root"]["tracks"][0]["node"]["freq"], 300.0);
assert_eq!(v["root"]["tracks"][1]["mute"], true);
}
#[test]
fn morph_midpoint_lerps_numbers_and_notes() {
let a: SoundDoc = serde_json::from_str(
r#"{ "name": "a", "duration": 0.2, "root": { "type": "sine", "freq": 200 } }"#,
)
.unwrap();
let b: SoundDoc = serde_json::from_str(
r#"{ "name": "b", "duration": 0.4, "root": { "type": "sine", "freq": 400 } }"#,
)
.unwrap();
let mid = morph(&a, &b, 0.5).unwrap();
let v = serde_json::to_value(&mid).unwrap();
assert_eq!(v["root"]["freq"], 300.0);
assert!((mid.duration - 0.3).abs() < 1e-6);
let c: SoundDoc =
serde_json::from_str(r#"{ "name": "c", "root": { "type": "noise" } }"#).unwrap();
assert!(morph(&a, &c, 0.5).is_err());
}
#[test]
fn morph_unifies_engine_seed_and_rate_instead_of_lerping() {
let a: SoundDoc = serde_json::from_str(
r#"{ "name": "a", "duration": 0.2, "seed": 7,
"root": { "type": "sine", "freq": 200 } }"#,
)
.unwrap();
let b: SoundDoc = serde_json::from_str(
r#"{ "name": "b", "duration": 0.2, "seed": 99, "engine": 3,
"root": { "type": "sine", "freq": 400 } }"#,
)
.unwrap();
let mid = morph(&a, &b, 0.5).unwrap();
assert_eq!(mid.engine, Some(3), "max engine wins, never a lerp");
assert_eq!(mid.seed, 7, "a's seed is identity, not a parameter");
let c: SoundDoc = serde_json::from_str(
r#"{ "name": "c", "duration": 0.2, "root": { "type": "sine", "freq": 300 } }"#,
)
.unwrap();
assert_eq!(morph(&a, &c, 0.5).unwrap().engine, None);
}
}