1use 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#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
41pub struct ParamSpec {
42 pub name: String,
44 pub paths: Vec<String>,
47 pub min: f32,
49 pub max: f32,
51 pub default: f32,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
58pub struct Patch {
59 pub doc: SoundDoc,
61 #[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 pub fn new(doc: SoundDoc) -> Self {
76 Patch {
77 doc,
78 params: Vec::new(),
79 }
80 }
81
82 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 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 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 pub fn render(&self, values: &BTreeMap<String, f32>) -> Result<Vec<f32>, EditError> {
114 Ok(render::render(&self.instantiate(values)?))
115 }
116
117 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 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 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 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 #[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}