use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::dsl::SoundDoc;
use crate::edit::{EditOp, apply_ops};
use crate::render;
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ParamSpec {
pub name: String,
pub paths: Vec<String>,
pub min: f32,
pub max: f32,
pub default: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Patch {
pub doc: SoundDoc,
#[serde(default)]
pub params: Vec<ParamSpec>,
}
impl Patch {
pub fn instantiate(&self, values: &BTreeMap<String, f32>) -> Result<SoundDoc, String> {
let mut ops = Vec::new();
for spec in &self.params {
let (lo, hi) = (spec.min.min(spec.max), spec.min.max(spec.max));
let v = values
.get(&spec.name)
.copied()
.unwrap_or(spec.default)
.clamp(lo, hi);
for path in &spec.paths {
ops.push(EditOp::Set {
path: path.clone(),
value: serde_json::json!(v),
});
}
}
apply_ops(&self.doc, &ops)
}
pub fn render(&self, values: &BTreeMap<String, f32>) -> Result<Vec<f32>, String> {
Ok(render::render(&self.instantiate(values)?))
}
pub fn defaults(&self) -> BTreeMap<String, f32> {
self.params
.iter()
.map(|p| (p.name.clone(), p.default))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sine_patch() -> Patch {
let doc: SoundDoc = serde_json::from_str(
r#"{ "name":"tone", "duration":0.2, "root":{"type":"sine","freq":440} }"#,
)
.unwrap();
Patch {
doc,
params: vec![ParamSpec {
name: "pitch".into(),
paths: vec!["root.freq".into()],
min: 100.0,
max: 2000.0,
default: 440.0,
}],
}
}
fn freq_of(doc: &SoundDoc) -> serde_json::Value {
serde_json::to_value(doc).unwrap()["root"]["freq"].clone()
}
#[test]
fn instantiate_writes_the_param_into_the_graph() {
let p = sine_patch();
let mut v = BTreeMap::new();
v.insert("pitch".to_string(), 880.0);
assert_eq!(
freq_of(&p.instantiate(&v).unwrap()),
serde_json::json!(880.0)
);
}
#[test]
fn missing_uses_default_and_out_of_range_clamps() {
let p = sine_patch();
assert_eq!(
freq_of(&p.instantiate(&BTreeMap::new()).unwrap()),
serde_json::json!(440.0)
);
let mut v = BTreeMap::new();
v.insert("pitch".to_string(), 99_999.0);
assert_eq!(
freq_of(&p.instantiate(&v).unwrap()),
serde_json::json!(2000.0)
);
}
#[test]
fn renders_vary_by_value_and_stay_deterministic() {
let p = sine_patch();
let val = |hz: f32| BTreeMap::from([("pitch".to_string(), hz)]);
let bits = |s: &[f32]| s.iter().map(|x| x.to_bits()).collect::<Vec<_>>();
let lo = p.render(&val(220.0)).unwrap();
let hi = p.render(&val(880.0)).unwrap();
assert!(!lo.is_empty() && lo.len() == hi.len());
assert_ne!(bits(&lo), bits(&hi), "different value → different audio");
assert_eq!(bits(&lo), bits(&p.render(&val(220.0)).unwrap()));
}
#[test]
fn shipped_impact_patch_renders_across_its_range() {
let patch: Patch = serde_json::from_str(include_str!(
"../../../docs/examples/parametric-impact.patch.json"
))
.expect("valid patch");
let d = patch.render(&patch.defaults()).unwrap();
assert!(
!d.is_empty() && d.iter().any(|x| *x != 0.0),
"defaults make sound"
);
let bits = |s: &[f32]| s.iter().map(|x| x.to_bits()).collect::<Vec<_>>();
let small = patch
.render(&BTreeMap::from([
("size".to_string(), 0.15),
("hardness".to_string(), 0.8),
]))
.unwrap();
let large = patch
.render(&BTreeMap::from([
("size".to_string(), 1.4),
("hardness".to_string(), 0.8),
]))
.unwrap();
assert_ne!(bits(&small), bits(&large), "size changes the ring");
}
}