Skip to main content

tono_core/
patch.rs

1//! Parametric patches — the in-engine runtime.
2//!
3//! A [`Patch`] is a [`SoundDoc`] template plus named parameters, each bound to
4//! one or more JSON paths in the graph. Instantiating it with runtime values
5//! produces a concrete document the deterministic renderer turns into audio — so
6//! a game ships ONE patch and renders endless per-instance variations (an impact
7//! that scales with force, a footstep that varies by surface) with **zero baked
8//! files**. Pure and deterministic like the rest of the core: the same patch and
9//! the same values always render byte-identically, and it compiles native, to
10//! WASM, and into a game engine.
11
12//!
13//! ```
14//! use std::collections::BTreeMap;
15//! use tono_core::patch::Patch;
16//!
17//! let patch: Patch = serde_json::from_str(r#"{
18//!     "doc": { "name": "zap", "duration": 0.2, "engine": 4,
19//!              "root": { "type": "sine", "freq": 880 } },
20//!     "params": [ { "name": "pitch", "paths": ["root.freq"],
21//!                   "min": 100.0, "max": 2000.0, "default": 880.0 } ]
22//! }"#).unwrap();
23//!
24//! // One patch, endless per-instance variations — deterministic each time.
25//! let low = patch.render(&BTreeMap::from([("pitch".into(), 220.0)])).unwrap();
26//! let high = patch.render(&BTreeMap::from([("pitch".into(), 1760.0)])).unwrap();
27//! assert_ne!(low, high);
28//! assert_eq!(low, patch.render(&BTreeMap::from([("pitch".into(), 220.0)])).unwrap());
29//! ```
30
31use std::collections::BTreeMap;
32
33use serde::{Deserialize, Serialize};
34
35use crate::dsl::SoundDoc;
36use crate::edit::{EditError, EditOp, apply_ops};
37use crate::render;
38
39/// A named, range-bounded parameter that drives one or more graph paths.
40#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
41pub struct ParamSpec {
42    /// Semantic name the runtime sets (`"size"`, `"hardness"`, `"surface"`).
43    pub name: String,
44    /// JSON paths this parameter writes (e.g. `root.modes[0].decay`). One value
45    /// can drive several paths at once.
46    pub paths: Vec<String>,
47    /// Lower bound (values are clamped into `[min, max]`).
48    pub min: f32,
49    /// Upper bound.
50    pub max: f32,
51    /// Value used when the runtime doesn't provide one.
52    pub default: f32,
53}
54
55/// A `SoundDoc` template plus its parameters. Ships as JSON; loaded and rendered
56/// at runtime with per-instance values.
57#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
58pub struct Patch {
59    /// The template document.
60    pub doc: SoundDoc,
61    /// The parameters that vary it.
62    #[serde(default)]
63    pub params: Vec<ParamSpec>,
64}
65
66impl From<SoundDoc> for Patch {
67    fn from(doc: SoundDoc) -> Self {
68        Patch::new(doc)
69    }
70}
71
72impl Patch {
73    /// A patch around `doc` with no parameters yet — add [`ParamSpec`]s to
74    /// expose knobs.
75    pub fn new(doc: SoundDoc) -> Self {
76        Patch {
77            doc,
78            params: Vec::new(),
79        }
80    }
81
82    /// Bake the patch into a concrete document with the given parameter values
83    /// (missing → default, out-of-range → clamped). Validated like any edit, so
84    /// a bad path or value is a clear error, never a corrupt graph.
85    pub fn instantiate(&self, values: &BTreeMap<String, f32>) -> Result<SoundDoc, EditError> {
86        let mut ops = Vec::new();
87        for spec in &self.params {
88            let (lo, hi) = (spec.min.min(spec.max), spec.min.max(spec.max));
89            // NaN bounds (programmatic only — JSON can't carry NaN) would
90            // panic f32::clamp; treat them as unbounded on that side.
91            let lo = if lo.is_nan() { f32::NEG_INFINITY } else { lo };
92            let hi = if hi.is_nan() { f32::INFINITY } else { hi };
93            let raw = values.get(&spec.name).copied().unwrap_or(spec.default);
94            // A NaN (runtime value or default — programmatic only) has no
95            // in-domain reading: skip the write and leave the template's own
96            // value, rather than panic f32::clamp or bake NaN into the graph.
97            if raw.is_nan() {
98                continue;
99            }
100            let v = raw.clamp(lo, hi);
101            for path in &spec.paths {
102                ops.push(EditOp::Set {
103                    path: path.clone(),
104                    value: serde_json::json!(v),
105                });
106            }
107        }
108        apply_ops(&self.doc, &ops)
109    }
110
111    /// Instantiate and render to mono samples — the one call a game makes per
112    /// SFX instance.
113    pub fn render(&self, values: &BTreeMap<String, f32>) -> Result<Vec<f32>, EditError> {
114        Ok(render::render(&self.instantiate(values)?))
115    }
116
117    /// The parameter defaults as a value map — a starting point to tweak.
118    pub fn defaults(&self) -> BTreeMap<String, f32> {
119        self.params
120            .iter()
121            .map(|p| (p.name.clone(), p.default))
122            .collect()
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    fn sine_patch() -> Patch {
131        let doc: SoundDoc = serde_json::from_str(
132            r#"{ "name":"tone", "duration":0.2, "root":{"type":"sine","freq":440} }"#,
133        )
134        .unwrap();
135        Patch {
136            doc,
137            params: vec![ParamSpec {
138                name: "pitch".into(),
139                paths: vec!["root.freq".into()],
140                min: 100.0,
141                max: 2000.0,
142                default: 440.0,
143            }],
144        }
145    }
146
147    fn freq_of(doc: &SoundDoc) -> serde_json::Value {
148        serde_json::to_value(doc).unwrap()["root"]["freq"].clone()
149    }
150
151    #[test]
152    fn instantiate_writes_the_param_into_the_graph() {
153        let p = sine_patch();
154        let mut v = BTreeMap::new();
155        v.insert("pitch".to_string(), 880.0);
156        assert_eq!(
157            freq_of(&p.instantiate(&v).unwrap()),
158            serde_json::json!(880.0)
159        );
160    }
161
162    #[test]
163    fn missing_uses_default_and_out_of_range_clamps() {
164        let p = sine_patch();
165        assert_eq!(
166            freq_of(&p.instantiate(&BTreeMap::new()).unwrap()),
167            serde_json::json!(440.0)
168        );
169        let mut v = BTreeMap::new();
170        v.insert("pitch".to_string(), 99_999.0);
171        assert_eq!(
172            freq_of(&p.instantiate(&v).unwrap()),
173            serde_json::json!(2000.0)
174        );
175    }
176
177    #[test]
178    fn renders_vary_by_value_and_stay_deterministic() {
179        let p = sine_patch();
180        let val = |hz: f32| BTreeMap::from([("pitch".to_string(), hz)]);
181        let bits = |s: &[f32]| s.iter().map(|x| x.to_bits()).collect::<Vec<_>>();
182        let lo = p.render(&val(220.0)).unwrap();
183        let hi = p.render(&val(880.0)).unwrap();
184        assert!(!lo.is_empty() && lo.len() == hi.len());
185        assert_ne!(bits(&lo), bits(&hi), "different value → different audio");
186        // Same value twice → byte-identical (the runtime determinism guarantee).
187        assert_eq!(bits(&lo), bits(&p.render(&val(220.0)).unwrap()));
188    }
189
190    #[test]
191    fn out_of_range_default_is_clamped_and_nan_never_panics() {
192        let doc: SoundDoc = serde_json::from_str(
193            r#"{ "name":"tone", "duration":0.2, "root":{"type":"sine","freq":440} }"#,
194        )
195        .unwrap();
196        // An author default outside [min, max] must not be baked verbatim.
197        let p = Patch {
198            doc: doc.clone(),
199            params: vec![ParamSpec {
200                name: "pitch".into(),
201                paths: vec!["root.freq".into()],
202                min: 100.0,
203                max: 2000.0,
204                default: 99_999.0,
205            }],
206        };
207        assert_eq!(
208            freq_of(&p.instantiate(&BTreeMap::new()).unwrap()),
209            serde_json::json!(2000.0)
210        );
211        // NaN bounds / values (programmatic only) must not panic f32::clamp —
212        // a NaN value skips the write and leaves the template's own value.
213        let p = Patch {
214            doc,
215            params: vec![ParamSpec {
216                name: "pitch".into(),
217                paths: vec!["root.freq".into()],
218                min: f32::NAN,
219                max: f32::NAN,
220                default: 440.0,
221            }],
222        };
223        let mut v = BTreeMap::new();
224        v.insert("pitch".to_string(), f32::NAN);
225        let d = p.instantiate(&v).unwrap();
226        assert_eq!(freq_of(&d), serde_json::json!(440.0));
227    }
228
229    /// The shipped example patch parses, its paths are valid, and its parameters
230    /// audibly change the sound — so the runtime guide's example actually works.
231    #[test]
232    fn shipped_impact_patch_renders_across_its_range() {
233        let patch: Patch = serde_json::from_str(include_str!(
234            "../../../docs/examples/parametric-impact.patch.json"
235        ))
236        .expect("valid patch");
237        let d = patch.render(&patch.defaults()).unwrap();
238        assert!(
239            !d.is_empty() && d.iter().any(|x| *x != 0.0),
240            "defaults make sound"
241        );
242
243        let bits = |s: &[f32]| s.iter().map(|x| x.to_bits()).collect::<Vec<_>>();
244        let small = patch
245            .render(&BTreeMap::from([
246                ("size".to_string(), 0.15),
247                ("hardness".to_string(), 0.8),
248            ]))
249            .unwrap();
250        let large = patch
251            .render(&BTreeMap::from([
252                ("size".to_string(), 1.4),
253                ("hardness".to_string(), 0.8),
254            ]))
255            .unwrap();
256        assert_ne!(bits(&small), bits(&large), "size changes the ring");
257    }
258}