Skip to main content

tono_core/dsl/
validate.rs

1//! Range and structure validation for a [`SoundDoc`] beyond what serde
2//! enforces. Every message is human-readable so a caller can act on it.
3
4use super::{
5    Adsr, AutoTarget, ENGINE_VERSION, Modulator, Node, Playback, SCHEMA_VERSION, SeqWave, SoundDoc,
6    Stereo, TempoPoint, Value, note_to_hz,
7};
8
9impl Adsr {
10    /// Range-check the envelope shape. `what` prefixes error messages
11    /// (e.g. `"env"` ⇒ `"env.a must be >= 0"`).
12    fn validate(&self, what: &str) -> Result<(), String> {
13        for (n, v) in [("a", self.a), ("d", self.d), ("r", self.r)] {
14            non_negative(&format!("{what}.{n}"), v)?;
15        }
16        in_unit(&format!("{what}.s"), self.s)?;
17        in_unit(&format!("{what}.punch"), self.punch)
18    }
19}
20
21/// Why a document failed validation. Wraps the human-readable reason — the
22/// same message a caller can use to correct the document — behind a real
23/// error type (`Display` + `std::error::Error`).
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ValidateError(String);
26
27impl ValidateError {
28    /// The human-readable reason.
29    pub fn message(&self) -> &str {
30        &self.0
31    }
32}
33
34impl std::fmt::Display for ValidateError {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.write_str(&self.0)
37    }
38}
39
40impl std::error::Error for ValidateError {}
41
42impl std::ops::Deref for ValidateError {
43    type Target = str;
44    /// Deref to the message so callers (and a decade of tests) can treat the
45    /// error as the string it carries: `err.contains("freq")`.
46    fn deref(&self) -> &str {
47        &self.0
48    }
49}
50
51impl From<ValidateError> for String {
52    fn from(e: ValidateError) -> String {
53        e.0
54    }
55}
56
57impl SoundDoc {
58    /// Validate ranges and structure beyond what serde already enforces.
59    /// The error's message is human-readable and names the offending field.
60    pub fn validate(&self) -> Result<(), ValidateError> {
61        self.validate_inner().map_err(ValidateError)
62    }
63
64    fn validate_inner(&self) -> Result<(), String> {
65        let v = self.effective_version();
66        if v == 0 || v > SCHEMA_VERSION {
67            return Err(format!(
68                "version must be in [1, {SCHEMA_VERSION}], got {v} — a document from a newer \
69                 tono cannot render correctly here; upgrade tono"
70            ));
71        }
72        let e = self.effective_engine();
73        if e > ENGINE_VERSION {
74            return Err(format!(
75                "engine must be in [0, {ENGINE_VERSION}], got {e} — a document authored against \
76                 a newer DSP kernel cannot render correctly here; upgrade tono"
77            ));
78        }
79        // 600 s covers full songs; the cap exists only to bound render memory.
80        if !(self.duration > 0.0 && self.duration <= 600.0) {
81            return Err(format!(
82                "duration must be in (0, 600] seconds, got {}",
83                self.duration
84            ));
85        }
86        if !(8_000..=192_000).contains(&self.sample_rate) {
87            return Err(format!(
88                "sample_rate must be in [8000, 192000] Hz, got {}",
89                self.sample_rate
90            ));
91        }
92        match self.stereo {
93            Stereo::Mono => {}
94            Stereo::Haas { ms, pan } => {
95                if !(0.5..=40.0).contains(&ms) {
96                    return Err(format!("stereo.haas.ms must be in [0.5, 40], got {ms}"));
97                }
98                if !(-1.0..=1.0).contains(&pan) {
99                    return Err(format!("stereo.haas.pan must be in [-1, 1], got {pan}"));
100                }
101            }
102            Stereo::Wide { amount } => in_unit("stereo.wide.amount", amount)?,
103        }
104        if let Some(nz) = &self.normalize {
105            if let Some(t) = nz.target_lufs
106                && !(-60.0..=0.0).contains(&t)
107            {
108                return Err(format!(
109                    "normalize.target_lufs must be in [-60, 0] LUFS, got {t}"
110                ));
111            }
112            if !(-12.0..=0.0).contains(&nz.ceiling_dbtp) {
113                return Err(format!(
114                    "normalize.ceiling_dbtp must be in [-12, 0] dBTP, got {}",
115                    nz.ceiling_dbtp
116                ));
117            }
118        }
119        if let Playback::Loop {
120            start_secs,
121            end_secs,
122            crossfade_secs,
123        } = self.playback
124        {
125            if start_secs < 0.0 || start_secs >= self.duration {
126                return Err(format!(
127                    "playback.loop.start_secs must be in [0, duration), got {start_secs}"
128                ));
129            }
130            if let Some(end) = end_secs {
131                if end <= start_secs {
132                    return Err(format!(
133                        "playback.loop.end_secs ({end}) must be > start_secs ({start_secs})"
134                    ));
135                }
136                if end > self.duration {
137                    return Err(format!(
138                        "playback.loop.end_secs ({end}) must be <= duration ({})",
139                        self.duration
140                    ));
141                }
142            }
143            non_negative("playback.loop.crossfade_secs", crossfade_secs)?;
144        }
145        if let Node::Tracks {
146            tracks,
147            master,
148            buses,
149        } = &self.root
150        {
151            if tracks.is_empty() {
152                return Err("tracks must be non-empty".into());
153            }
154            // A mixer document builds its stereo image from per-layer pan; a
155            // doc-level Haas/Wide treatment would be silently dropped by the
156            // renderer. v1 documents keep the historical silent-ignore so old
157            // libraries still load.
158            if self.effective_version() >= 2 && !matches!(self.stereo, Stereo::Mono) {
159                return Err(
160                    "a tracks document builds its stereo image from per-layer pan — remove the \
161                     doc-level stereo treatment (set stereo mode 'mono') and pan the layers \
162                     instead"
163                        .into(),
164                );
165            }
166            let mut seen_ids = std::collections::HashSet::new();
167            let mut seen_streams = std::collections::HashMap::new();
168            for (i, t) in tracks.iter().enumerate() {
169                // Errors name the layer by id when it has one — that is the
170                // address the caller used.
171                let who = match &t.id {
172                    Some(id) => format!("layer '{id}'"),
173                    None => format!("tracks[{i}]"),
174                };
175                if let Some(id) = &t.id {
176                    if id.is_empty()
177                        || !id
178                            .chars()
179                            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
180                    {
181                        return Err(format!(
182                            "{who}: layer ids are short slugs (a-z, 0-9, _), got '{id}'"
183                        ));
184                    }
185                    if id == "master" {
186                        return Err(
187                            "'master' is reserved for the master chain; pick another layer id"
188                                .into(),
189                        );
190                    }
191                    if !seen_ids.insert(id.clone()) {
192                        return Err(format!("duplicate layer id '{id}' — ids must be unique"));
193                    }
194                    // Stream keys must be collision-free or two layers would
195                    // silently share one noise stream (and u64::MAX is the
196                    // master bus's stream).
197                    let key = crate::dsp::layer_stream_key(id);
198                    if key == u64::MAX {
199                        return Err(format!(
200                            "{who}: this id collides with the master bus's RNG stream — rename \
201                             the layer"
202                        ));
203                    }
204                    if let Some(other) = seen_streams.insert(key, id.clone()) {
205                        return Err(format!(
206                            "layer ids '{other}' and '{id}' hash to the same RNG stream — \
207                             rename one of them"
208                        ));
209                    }
210                }
211                if !(-1.0..=1.0).contains(&t.pan) {
212                    return Err(format!("{who}: pan must be in [-1, 1], got {}", t.pan));
213                }
214                if !(0.0..=2.0).contains(&t.gain) {
215                    return Err(format!("{who}: gain must be in [0, 2], got {}", t.gain));
216                }
217                if !(0.0..self.duration).contains(&t.at) {
218                    return Err(format!(
219                        "{who}: at must be in [0, duration {}), got {} — the layer would be \
220                         entirely outside the render window",
221                        self.duration, t.at
222                    ));
223                }
224                let mut seen_lanes: Vec<AutoTarget> = Vec::new();
225                for lane in &t.automation {
226                    let (lname, lo, hi) = match lane.target {
227                        AutoTarget::Gain => ("gain", 0.0, 2.0),
228                        AutoTarget::Pan => ("pan", -1.0, 1.0),
229                    };
230                    // The renderer applies the first matching lane, so a
231                    // second lane for the same target is silently dead.
232                    if seen_lanes.contains(&lane.target) {
233                        return Err(format!(
234                            "{who}: duplicate automation lane for '{lname}' — only the first \
235                             applies, so this one would be dead"
236                        ));
237                    }
238                    seen_lanes.push(lane.target);
239                    for (pi, p) in lane.points.iter().enumerate() {
240                        if !p.t.is_finite() || p.t < 0.0 {
241                            return Err(format!(
242                                "{who}: automation[{lname}].points[{pi}].t must be >= 0 \
243                                 seconds, got {}",
244                                p.t
245                            ));
246                        }
247                        if !(lo..=hi).contains(&p.v) {
248                            return Err(format!(
249                                "{who}: automation[{lname}].points[{pi}].v must be in \
250                                 [{lo}, {hi}], got {}",
251                                p.v
252                            ));
253                        }
254                    }
255                }
256                if contains_tracks(&t.node) {
257                    return Err("tracks cannot nest inside a track".into());
258                }
259                validate_node(&t.node)?;
260            }
261            // Sidechain wiring is a cross-track check, so it runs once every
262            // id is known (a source may be declared after its follower).
263            for (i, t) in tracks.iter().enumerate() {
264                let Some(sc) = &t.sidechain else { continue };
265                let who = match &t.id {
266                    Some(id) => format!("layer '{id}'"),
267                    None => format!("tracks[{i}]"),
268                };
269                if !(0.0..=1.0).contains(&sc.amount) {
270                    return Err(format!(
271                        "{who}: sidechain.amount must be in [0, 1], got {}",
272                        sc.amount
273                    ));
274                }
275                if !sc.attack.is_finite() || sc.attack < 0.0 {
276                    return Err(format!(
277                        "{who}: sidechain.attack must be >= 0 seconds, got {}",
278                        sc.attack
279                    ));
280                }
281                if !sc.release.is_finite() || sc.release < 0.0 {
282                    return Err(format!(
283                        "{who}: sidechain.release must be >= 0 seconds, got {}",
284                        sc.release
285                    ));
286                }
287                if t.id.as_deref() == Some(sc.source.as_str()) {
288                    return Err(format!(
289                        "{who}: sidechain source '{}' is the track itself — a track cannot \
290                         duck to its own signal",
291                        sc.source
292                    ));
293                }
294                let Some(source) = tracks
295                    .iter()
296                    .find(|s| s.id.as_deref() == Some(sc.source.as_str()))
297                else {
298                    return Err(format!(
299                        "{who}: sidechain source '{}' is not a layer id in this document — \
300                         point it at the track whose signal should drive the duck",
301                        sc.source
302                    ));
303                };
304                if source.sidechain.is_some() {
305                    return Err(format!(
306                        "{who}: sidechain source '{}' is itself a follower — \
307                         follower-of-follower chains are not supported; duck directly to \
308                         the source's source",
309                        sc.source
310                    ));
311                }
312            }
313            for (i, m) in master.iter().enumerate() {
314                if !m.is_processor() {
315                    return Err(format!(
316                        "master[{i}] must be a processor (filter/eq/dynamics/fx)"
317                    ));
318                }
319                validate_node(m)?;
320            }
321            // Bus wiring: ids are unique slugs that can't collide with a
322            // layer id or the master stream; effects are processors like the
323            // master chain; routing references must name real buses.
324            let mut seen_bus_ids = std::collections::HashSet::new();
325            for b in buses {
326                if b.id.is_empty()
327                    || !b
328                        .id
329                        .chars()
330                        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
331                {
332                    return Err(format!(
333                        "bus ids are short slugs (a-z, 0-9, _), got '{}'",
334                        b.id
335                    ));
336                }
337                if b.id == "master" {
338                    return Err(
339                        "'master' is reserved for the master chain; pick another bus id".into(),
340                    );
341                }
342                if !seen_bus_ids.insert(b.id.clone()) {
343                    return Err(format!("duplicate bus id '{}' — ids must be unique", b.id));
344                }
345                if seen_ids.contains(&b.id) {
346                    return Err(format!(
347                        "bus id '{}' is also a layer id — a track and its bus must be named apart",
348                        b.id
349                    ));
350                }
351                if !(0.0..=2.0).contains(&b.gain) {
352                    return Err(format!(
353                        "bus '{}': gain must be in [0, 2], got {}",
354                        b.id, b.gain
355                    ));
356                }
357                for (i, fx) in b.effects.iter().enumerate() {
358                    if !fx.is_processor() {
359                        return Err(format!(
360                            "bus '{}' effects[{i}] must be a processor (filter/eq/dynamics/fx)",
361                            b.id
362                        ));
363                    }
364                    if contains_tracks(fx) {
365                        return Err(format!("bus '{}' effects cannot nest tracks", b.id));
366                    }
367                    validate_node(fx)?;
368                }
369            }
370            for (i, t) in tracks.iter().enumerate() {
371                let who = match &t.id {
372                    Some(id) => format!("layer '{id}'"),
373                    None => format!("tracks[{i}]"),
374                };
375                if let Some(bus) = &t.bus
376                    && !buses.iter().any(|b| &b.id == bus)
377                {
378                    return Err(format!(
379                        "{who}: routed to bus '{bus}', which is not a bus in this document"
380                    ));
381                }
382                let mut seen_sends: Vec<&str> = Vec::new();
383                for s in &t.sends {
384                    if !(0.0..=1.0).contains(&s.amount) {
385                        return Err(format!(
386                            "{who}: send to '{}' must have amount in [0, 1], got {}",
387                            s.bus, s.amount
388                        ));
389                    }
390                    if !buses.iter().any(|b| b.id == s.bus) {
391                        return Err(format!(
392                            "{who}: sends to bus '{}', which is not a bus in this document",
393                            s.bus
394                        ));
395                    }
396                    if seen_sends.contains(&s.bus.as_str()) {
397                        return Err(format!(
398                            "{who}: duplicate send to bus '{}' — raise one send's amount instead",
399                            s.bus
400                        ));
401                    }
402                    seen_sends.push(&s.bus);
403                }
404            }
405            return Ok(());
406        }
407        if contains_tracks(&self.root) {
408            return Err("tracks is the mixing console: it must be the document's root node".into());
409        }
410        // A bare processor as the root has no input and renders digital
411        // silence — say so instead of "succeeding".
412        if self.root.is_processor() {
413            return Err(
414                "the root node must be a source (osc/noise/seq/mix/…), not a bare processor \
415                 (filter/eq/dynamics/fx) — it would render silence"
416                    .into(),
417            );
418        }
419        validate_node(&self.root)
420    }
421}
422
423/// True if a `tracks` node appears anywhere in this subtree. Iterative (an
424/// explicit stack) so a pathologically deep programmatic document can't
425/// overflow the call stack before the depth cap in `validate_node_at` bites.
426fn contains_tracks(node: &Node) -> bool {
427    let mut stack = vec![node];
428    while let Some(n) = stack.pop() {
429        if matches!(n, Node::Tracks { .. }) {
430            return true;
431        }
432        stack.extend(n.children());
433    }
434    false
435}
436
437/// A finite value (rejects NaN/±inf — they would render NaN audio).
438fn finite(name: &str, v: f32) -> Result<(), String> {
439    if !v.is_finite() {
440        return Err(format!("{name} must be a finite number, got {v}"));
441    }
442    Ok(())
443}
444
445/// A finite, strictly positive value.
446fn positive(name: &str, v: f32) -> Result<(), String> {
447    finite(name, v)?;
448    if v <= 0.0 {
449        return Err(format!("{name} must be > 0, got {v}"));
450    }
451    Ok(())
452}
453
454/// A finite, non-negative value.
455fn non_negative(name: &str, v: f32) -> Result<(), String> {
456    finite(name, v)?;
457    if v < 0.0 {
458        return Err(format!("{name} must be >= 0, got {v}"));
459    }
460    Ok(())
461}
462
463impl crate::dsl::FmKnobs {
464    fn validate(&self) -> Result<(), String> {
465        positive("seq.fm_ratio", self.fm_ratio)?;
466        if !(0.0..=20.0).contains(&self.fm_index) {
467            return Err(format!(
468                "seq.fm_index must be in [0, 20], got {}",
469                self.fm_index
470            ));
471        }
472        positive("seq.fm_strike", self.fm_strike)
473    }
474}
475
476impl crate::dsl::PluckKnobs {
477    fn validate(&self) -> Result<(), String> {
478        if !(0.8..1.0).contains(&self.pluck_decay) {
479            return Err(format!(
480                "seq.pluck_decay must be in [0.8, 1), got {}",
481                self.pluck_decay
482            ));
483        }
484        in_unit("seq.pluck_body", self.pluck_body)?;
485        in_unit("seq.pluck_pick", self.pluck_pick)?;
486        if !(-1.0..=1.0).contains(&self.pluck_tone) {
487            return Err(format!(
488                "seq.pluck_tone must be in [-1, 1], got {}",
489                self.pluck_tone
490            ));
491        }
492        Ok(())
493    }
494}
495
496impl crate::dsl::PianoKnobs {
497    fn validate(&self) -> Result<(), String> {
498        positive("seq.piano_hammer", self.piano_hammer)?;
499        positive("seq.piano_strike", self.piano_strike)?;
500        positive("seq.piano_inharm", self.piano_inharm)?;
501        non_negative("seq.piano_detune", self.piano_detune)?;
502        positive("seq.piano_decay", self.piano_decay)
503    }
504}
505
506impl crate::dsl::BassKnobs {
507    fn validate(&self) -> Result<(), String> {
508        positive("seq.bass_cutoff", self.bass_cutoff)?;
509        non_negative("seq.bass_env", self.bass_env)?;
510        non_negative("seq.bass_env_vel", self.bass_env_vel)?;
511        positive("seq.bass_decay", self.bass_decay)?;
512        non_negative("seq.bass_click", self.bass_click)?;
513        non_negative("seq.bass_body", self.bass_body)?;
514        non_negative("seq.bass_sub", self.bass_sub)?;
515        positive("seq.bass_sub_ratio", self.bass_sub_ratio)?;
516        in_unit("seq.bass_drive", self.bass_drive)?;
517        positive("seq.bass_body_decay", self.bass_body_decay)
518    }
519}
520
521impl crate::dsl::Sf2Knobs {
522    /// Only meaningful when the seq's wave is `sampler` — the caller gates it.
523    fn validate(&self) -> Result<(), String> {
524        if self.sf2.is_empty() {
525            return Err(
526                "seq.sf2 must point at a SoundFont (.sf2) file when wave is 'sampler'".into(),
527            );
528        }
529        if self.sf2_preset > 127 {
530            return Err(format!(
531                "seq.sf2_preset must be in [0, 127], got {}",
532                self.sf2_preset
533            ));
534        }
535        Ok(())
536    }
537}
538
539fn validate_value(v: &Value, what: &str) -> Result<(), String> {
540    match v {
541        Value::Const(c) => finite(what, *c),
542        Value::Note(s) => note_to_hz(s).map(|_| ()).ok_or_else(|| {
543            format!("{what}: '{s}' is not a valid note (e.g. \"A4\", \"C#3\", \"midi:69\")")
544        }),
545        Value::Modulated(m) => match m {
546            Modulator::Slide { from, to, secs, .. } => {
547                finite(&format!("{what}: slide.from"), *from)?;
548                finite(&format!("{what}: slide.to"), *to)?;
549                positive(&format!("{what}: slide.secs"), *secs)
550            }
551            Modulator::Lfo {
552                rate,
553                depth,
554                center,
555                ..
556            } => {
557                positive(&format!("{what}: lfo.rate"), *rate)?;
558                finite(&format!("{what}: lfo.depth"), *depth)?;
559                finite(&format!("{what}: lfo.center"), *center)
560            }
561            Modulator::Arp { steps, rate } => {
562                if steps.is_empty() {
563                    return Err(format!("{what}: arp.steps must be non-empty"));
564                }
565                for (i, s) in steps.iter().enumerate() {
566                    finite(&format!("{what}: arp.steps[{i}]"), *s)?;
567                }
568                positive(&format!("{what}: arp.rate"), *rate)
569            }
570            Modulator::EnvMod { adsr, from, to } => {
571                finite(&format!("{what}: env.from"), *from)?;
572                finite(&format!("{what}: env.to"), *to)?;
573                adsr.validate(&format!("{what}: env"))?;
574                // The same flatten footgun as Node::Env: the ADSR fields are
575                // inlined on the modulator, so an "adsr" object is silently
576                // dropped and the parameter would pin at `from` forever.
577                if adsr.a == 0.0 && adsr.d == 0.0 && adsr.s == 0.0 && adsr.r == 0.0 {
578                    return Err(format!(
579                        "{what}: env is constant — a/d/s/r are all 0. The envelope fields \
580                         are inlined on the modulator (e.g. {{\"env\":{{\"a\":0.01,\"d\":0.1,\
581                         \"s\":0.7,\"r\":0.2,\"from\":..,\"to\":..}}}}); don't nest them \
582                         under \"adsr\""
583                    ));
584                }
585                Ok(())
586            }
587            Modulator::Rand { from, to, rate, .. } => {
588                finite(&format!("{what}: rand.from"), *from)?;
589                finite(&format!("{what}: rand.to"), *to)?;
590                positive(&format!("{what}: rand.rate"), *rate)?;
591                // Past ~10k targets/s the walk is indistinguishable from noise,
592                // and the renderer's per-sample catch-up loop becomes a denial
593                // of service (rate 1e12 ⇒ ~1e7 iterations per sample).
594                if *rate > 10_000.0 {
595                    return Err(format!(
596                        "{what}: rand.rate must be in (0, 10000], got {rate}"
597                    ));
598                }
599                Ok(())
600            }
601        },
602    }
603}
604
605/// Validate a `Value` that names a frequency: a constant must be finite and
606/// strictly positive (a modulated form is clamped per-sample at render time).
607fn validate_freq_value(v: &Value, what: &str) -> Result<(), String> {
608    match v {
609        Value::Const(c) => {
610            positive(what, *c)?;
611            // 100 kHz sits above every supported Nyquist (96 kHz at 192 kHz
612            // sr); past it a constant is an authoring error, and products
613            // like fm.freq × fm.ratio can reach f32 overflow and render NaN.
614            if *c > 100_000.0 {
615                return Err(format!("{what} must be <= 100000 Hz, got {c}"));
616            }
617        }
618        // note_to_hz already bounds a resolved note to <= 100 kHz.
619        Value::Note(_) => {}
620        Value::Modulated(m) => validate_freq_mod(m, what)?,
621    }
622    validate_value(v, what)
623}
624
625/// Bound a modulated frequency's endpoints far below the f32-overflow regime,
626/// so products like fm.freq × fm.ratio can never turn oscillator phases NaN.
627/// (center/depth are bounded independently, so an LFO's peak can reach 2× —
628/// loose, but still orders of magnitude from either danger zone.)
629fn validate_freq_mod(m: &Modulator, what: &str) -> Result<(), String> {
630    const MAX_HZ: f32 = 1e6;
631    let check = |name: &str, x: f32| {
632        if x.abs() > MAX_HZ {
633            Err(format!(
634                "{what}: {name} must be within ±{MAX_HZ} Hz, got {x}"
635            ))
636        } else {
637            Ok(())
638        }
639    };
640    match m {
641        Modulator::Slide { from, to, .. } => {
642            check("slide.from", *from)?;
643            check("slide.to", *to)
644        }
645        Modulator::Lfo { depth, center, .. } => {
646            check("lfo.center", *center)?;
647            check("lfo.depth", *depth)
648        }
649        Modulator::Arp { steps, .. } => {
650            for (i, s) in steps.iter().enumerate() {
651                check(&format!("arp.steps[{i}]"), *s)?;
652            }
653            Ok(())
654        }
655        Modulator::EnvMod { from, to, .. } | Modulator::Rand { from, to, .. } => {
656            check("from", *from)?;
657            check("to", *to)
658        }
659    }
660}
661
662fn in_unit(name: &str, v: f32) -> Result<(), String> {
663    if !(0.0..=1.0).contains(&v) {
664        return Err(format!("{name} must be in [0, 1], got {v}"));
665    }
666    Ok(())
667}
668
669/// EQ gain bound: ±24 dB covers any musical boost/cut; far beyond that the
670/// biquad coefficients overflow to inf/NaN and render silent garbage.
671fn validate_gain_db(name: &str, v: f32) -> Result<(), String> {
672    if !(-24.0..=24.0).contains(&v) {
673        return Err(format!("{name} must be in [-24, 24] dB, got {v}"));
674    }
675    Ok(())
676}
677
678/// A seq tempo map is a sorted list of tempo changes starting at beat 0,
679/// with sane tempos and a bounded length. The sampler plays through one
680/// shared synthesizer whose note offs are scheduled in absolute frames, so
681/// it keeps its constant-tempo path — a map there is an authoring error.
682fn validate_tempo_map(map: &[TempoPoint], wave: SeqWave) -> Result<(), String> {
683    if map.is_empty() {
684        return Ok(());
685    }
686    if wave == SeqWave::Sampler {
687        return Err("seq.tempo_map is not supported on the sampler wave".into());
688    }
689    if map.len() > 1024 {
690        return Err(format!(
691            "seq.tempo_map is capped at 1024 points, got {}",
692            map.len()
693        ));
694    }
695    if map[0].at != crate::units::Beat::zero() {
696        return Err("seq.tempo_map's first point must be at beat 0".into());
697    }
698    let mut prev = crate::units::Beat::zero();
699    for (i, p) in map.iter().enumerate() {
700        positive(&format!("seq.tempo_map[{i}].bpm"), p.bpm)?;
701        if i > 0 && p.at <= prev {
702            return Err(format!(
703                "seq.tempo_map must be strictly ascending by beat (point {i} is not after point {})",
704                i - 1
705            ));
706        }
707        prev = p.at;
708    }
709    Ok(())
710}
711
712/// Validate a `Value` whose constant form must lie in [0, 1] (modulated forms
713/// are clamped at render time).
714fn validate_unit_value(v: &Value, what: &str) -> Result<(), String> {
715    if let Value::Const(c) = v {
716        in_unit(what, *c)?;
717    }
718    validate_value(v, what)
719}
720
721/// Bounding the graph depth keeps validation (and the recursive renderer) off
722/// the stack for programmatically-built documents; JSON input is already capped
723/// well below this by serde's own recursion limit.
724const MAX_NODE_DEPTH: usize = 256;
725
726fn validate_node(node: &Node) -> Result<(), String> {
727    validate_node_at(node, 0)
728}
729
730fn validate_node_at(node: &Node, depth: usize) -> Result<(), String> {
731    if depth > MAX_NODE_DEPTH {
732        return Err(format!(
733            "the graph nests deeper than {MAX_NODE_DEPTH} levels — flatten it (e.g. into tracks)"
734        ));
735    }
736    match node {
737        Node::Square { freq, duty } => {
738            validate_freq_value(freq, "square.freq")?;
739            validate_unit_value(duty, "square.duty")
740        }
741        Node::Triangle { freq } => validate_freq_value(freq, "triangle.freq"),
742        Node::Sawtooth { freq } => validate_freq_value(freq, "sawtooth.freq"),
743        Node::Sine { freq } => validate_freq_value(freq, "sine.freq"),
744        Node::Noise { .. } => Ok(()),
745        Node::Impact { hardness, velocity } => {
746            in_unit("impact.hardness", *hardness)?;
747            in_unit("impact.velocity", *velocity)
748        }
749        Node::Dust { density, decay } => {
750            positive("dust.density", *density)?;
751            non_negative("dust.decay", *decay)
752        }
753        Node::Fm { freq, ratio, index } => {
754            validate_freq_value(freq, "fm.freq")?;
755            // Cap the ratio so freq × ratio can't reach f32 overflow — past
756            // it the modulator phase goes inf and renders NaN.
757            positive("fm.ratio", *ratio)?;
758            if *ratio > 4096.0 {
759                return Err(format!("fm.ratio must be <= 4096, got {ratio}"));
760            }
761            validate_value(index, "fm.index")
762        }
763        Node::Wavetable { freq, position, .. } => {
764            validate_freq_value(freq, "wavetable.freq")?;
765            // Const positions are bounded here; modulated ones clamp at render
766            // time (same policy as square.duty).
767            validate_unit_value(position, "wavetable.position")
768        }
769        // Bound exhaustively (no `..`): the compiler then forces a validation
770        // decision for every knob this variant grows.
771        Node::Seq {
772            bpm,
773            tempo_map,
774            steps_per_beat,
775            wave,
776            duty,
777            fm,
778            pluck,
779            piano,
780            kit: _,
781            bass,
782            sf2,
783            swing,
784            humanize,
785            env,
786            notes,
787        } => {
788            positive("seq.bpm", *bpm)?;
789            if *steps_per_beat < 1 {
790                return Err("seq.steps_per_beat must be >= 1".into());
791            }
792            validate_tempo_map(tempo_map, *wave)?;
793            if notes.is_empty() {
794                return Err("seq.notes must be non-empty".into());
795            }
796            validate_unit_value(duty, "seq.duty")?;
797            fm.validate()?;
798            pluck.validate()?;
799            piano.validate()?;
800            bass.validate()?;
801            in_unit("seq.swing", *swing)?;
802            in_unit("seq.humanize", *humanize)?;
803            if *wave == SeqWave::Sampler {
804                sf2.validate()?;
805            }
806            env.validate("seq.env")?;
807            for note in notes {
808                if note.len < 1 {
809                    return Err("seq note.len must be >= 1".into());
810                }
811                in_unit("seq note.gain", note.gain)?;
812                validate_freq_value(&note.pitch, "seq note.pitch")?;
813            }
814            Ok(())
815        }
816        Node::Env { adsr } => {
817            adsr.validate("env")?;
818            // An all-zero envelope is always silent — never intended. It's also
819            // the tell-tale of the flatten footgun: the env's a/d/s/r are inlined
820            // (`{"type":"env","a":..,"d":..}`), so wrapping them in an `"adsr"`
821            // object silently drops them all to 0. Reject it with that hint.
822            if adsr.a == 0.0 && adsr.d == 0.0 && adsr.s == 0.0 && adsr.r == 0.0 {
823                return Err("env is silent — a/d/s/r are all 0. The envelope fields \
824                    are inlined on the node (e.g. {\"type\":\"env\",\"a\":0.01,\"d\":0.1,\
825                    \"s\":0.7,\"r\":0.2}); don't nest them under \"adsr\""
826                    .into());
827            }
828            Ok(())
829        }
830        // Nested mixers are rejected earlier; this guards direct calls.
831        Node::Tracks { .. } => Err("tracks must be the document's root node".into()),
832        Node::Mix { inputs } | Node::Mul { inputs } => {
833            if inputs.is_empty() {
834                return Err("mix/mul requires at least one input".into());
835            }
836            inputs
837                .iter()
838                .try_for_each(|n| validate_node_at(n, depth + 1))
839        }
840        Node::Chain { stages } => {
841            if stages.is_empty() {
842                return Err("chain requires at least one stage".into());
843            }
844            // A leading processor has no input and renders digital silence —
845            // the worst outcome for a sound authored blind. Sources first.
846            if stages[0].is_processor() {
847                return Err(
848                    "chain's first stage must be a source (osc/noise/seq/mix/…), not a \
849                     processor (filter/eq/dynamics/fx) — it would render silence"
850                        .into(),
851                );
852            }
853            stages
854                .iter()
855                .try_for_each(|n| validate_node_at(n, depth + 1))
856        }
857        Node::Lowpass { cutoff, q }
858        | Node::Highpass { cutoff, q }
859        | Node::Bandpass { cutoff, q }
860        | Node::Notch { cutoff, q } => {
861            validate_freq_value(cutoff, "filter.cutoff")?;
862            positive("filter.q", *q)
863        }
864        Node::Peak { cutoff, q, gain_db } => {
865            validate_freq_value(cutoff, "peak.cutoff")?;
866            positive("peak.q", *q)?;
867            validate_gain_db("peak.gain_db", *gain_db)
868        }
869        Node::Lowshelf { cutoff, gain_db } | Node::Highshelf { cutoff, gain_db } => {
870            validate_freq_value(cutoff, "shelf.cutoff")?;
871            validate_gain_db("shelf.gain_db", *gain_db)
872        }
873        Node::Super {
874            freq,
875            voices,
876            detune_cents,
877            ..
878        } => {
879            validate_freq_value(freq, "super.freq")?;
880            if !(1..=16).contains(voices) {
881                return Err(format!("super.voices must be in [1, 16], got {voices}"));
882            }
883            // 10 octaves of unison spread; past it 2^(cents/1200) approaches
884            // f32 overflow and the voices render NaN.
885            if !(0.0..=12_000.0).contains(detune_cents) {
886                return Err(format!(
887                    "super.detune_cents must be in [0, 12000], got {detune_cents}"
888                ));
889            }
890            Ok(())
891        }
892        Node::Gain { amount } => validate_value(amount, "gain.amount"),
893        Node::Bitcrush { bits } => {
894            if !(1..=16).contains(bits) {
895                return Err(format!("bitcrush.bits must be in [1, 16], got {bits}"));
896            }
897            Ok(())
898        }
899        Node::Downsample { factor } => {
900            if *factor < 1 {
901                return Err("downsample.factor must be >= 1".into());
902            }
903            Ok(())
904        }
905        Node::Delay { secs, feedback } => {
906            // The upper bound caps the delay-line allocation: an unbounded
907            // `secs` would let a validated document request a buffer of
908            // arbitrary size and abort the process.
909            positive("delay.secs", *secs)?;
910            if *secs > 30.0 {
911                return Err(format!("delay.secs must be in (0, 30] seconds, got {secs}"));
912            }
913            in_unit("delay.feedback", *feedback)
914        }
915        Node::Reverb { room, mix } => {
916            in_unit("reverb.room", *room)?;
917            in_unit("reverb.mix", *mix)
918        }
919        Node::Modal { modes, mix } => {
920            if modes.is_empty() {
921                return Err("modal.modes must be non-empty".into());
922            }
923            if modes.len() > 64 {
924                return Err(format!(
925                    "modal.modes must have at most 64 modes, got {}",
926                    modes.len()
927                ));
928            }
929            for (i, m) in modes.iter().enumerate() {
930                positive(&format!("modal.modes[{i}].freq"), m.freq)?;
931                positive(&format!("modal.modes[{i}].decay"), m.decay)?;
932                in_unit(&format!("modal.modes[{i}].gain"), m.gain)?;
933            }
934            in_unit("modal.mix", *mix)
935        }
936        Node::Drive { amount, .. } => validate_value(amount, "drive.amount"),
937        Node::RingMod { freq } => validate_freq_value(freq, "ringmod.freq"),
938        Node::Tremolo { rate, depth } => {
939            finite("tremolo.rate", *rate)?;
940            // 40 Hz is already fast enough to read as a tone, not a tremolo.
941            if !(0.0..=40.0).contains(rate) {
942                return Err(format!("tremolo.rate must be in [0, 40] Hz, got {rate}"));
943            }
944            in_unit("tremolo.depth", *depth)
945        }
946        Node::Chorus { rate, depth, mix } => {
947            positive("chorus.rate", *rate)?;
948            in_unit("chorus.depth", *depth)?;
949            in_unit("chorus.mix", *mix)
950        }
951        Node::Flanger {
952            rate,
953            depth,
954            feedback,
955            mix,
956        }
957        | Node::Phaser {
958            rate,
959            depth,
960            feedback,
961            mix,
962        } => {
963            positive("flanger/phaser.rate", *rate)?;
964            in_unit("flanger/phaser.depth", *depth)?;
965            in_unit("flanger/phaser.feedback", *feedback)?;
966            in_unit("flanger/phaser.mix", *mix)
967        }
968        Node::Duck {
969            trigger,
970            amount,
971            attack,
972            release,
973        } => {
974            in_unit("duck.amount", *amount)?;
975            non_negative("duck.attack", *attack)?;
976            non_negative("duck.release", *release)?;
977            validate_node_at(trigger, depth + 1)
978        }
979        Node::Compress {
980            threshold,
981            ratio,
982            attack,
983            release,
984            makeup,
985        } => {
986            finite("compress.threshold", *threshold)?;
987            // JSON 1e308 deserializes to f32 inf — finite first, then the bound.
988            finite("compress.ratio", *ratio)?;
989            if *ratio < 1.0 {
990                return Err(format!("compress.ratio must be >= 1, got {ratio}"));
991            }
992            non_negative("compress.attack", *attack)?;
993            non_negative("compress.release", *release)?;
994            finite("compress.makeup", *makeup)
995        }
996        Node::Convolve {
997            decay,
998            size,
999            predelay,
1000            damp,
1001            mix,
1002        } => validate_convolve(*decay, *size, *predelay, *damp, *mix),
1003        Node::Granular {
1004            grain_ms,
1005            density,
1006            pitch,
1007            spread,
1008            mix,
1009        } => validate_granular(*grain_ms, *density, *pitch, *spread, *mix),
1010    }
1011}
1012
1013/// `convolve` bounds. Kept out of `validate_node_at`'s body (like the seq
1014/// knob impls) so the recursion's stack frame stays lean — the depth-cap test
1015/// recurses 256 levels on a 2 MiB test-thread stack.
1016fn validate_convolve(
1017    decay: f32,
1018    size: f32,
1019    predelay: f32,
1020    damp: f32,
1021    mix: f32,
1022) -> Result<(), String> {
1023    // The 30 s caps mirror delay.secs: they bound the IR allocation so
1024    // a validated document can't request a buffer of arbitrary size.
1025    positive("convolve.decay", decay)?;
1026    if decay > 30.0 {
1027        return Err(format!(
1028            "convolve.decay must be in (0, 30] seconds, got {decay}"
1029        ));
1030    }
1031    finite("convolve.size", size)?;
1032    // 0 = follow `decay` (the serde default); a real cap must be positive
1033    // and bounded like `decay`.
1034    if !(0.0..=30.0).contains(&size) {
1035        return Err(format!(
1036            "convolve.size must be 0 (= decay) or in (0, 30] seconds, got {size}"
1037        ));
1038    }
1039    non_negative("convolve.predelay", predelay)?;
1040    if predelay > 30.0 {
1041        return Err(format!(
1042            "convolve.predelay must be in [0, 30] seconds, got {predelay}"
1043        ));
1044    }
1045    in_unit("convolve.damp", damp)?;
1046    in_unit("convolve.mix", mix)
1047}
1048
1049/// `granular` bounds (out of `validate_node_at` for the same frame-size
1050/// reason as [`validate_convolve`]).
1051fn validate_granular(
1052    grain_ms: f32,
1053    density: f32,
1054    pitch: f32,
1055    spread: f32,
1056    mix: f32,
1057) -> Result<(), String> {
1058    finite("granular.grain_ms", grain_ms)?;
1059    if !(5.0..=500.0).contains(&grain_ms) {
1060        return Err(format!(
1061            "granular.grain_ms must be in [5, 500] ms, got {grain_ms}"
1062        ));
1063    }
1064    finite("granular.density", density)?;
1065    if !(0.1..=200.0).contains(&density) {
1066        return Err(format!(
1067            "granular.density must be in [0.1, 200] grains/sec, got {density}"
1068        ));
1069    }
1070    finite("granular.pitch", pitch)?;
1071    if !(0.25..=4.0).contains(&pitch) {
1072        return Err(format!("granular.pitch must be in [0.25, 4], got {pitch}"));
1073    }
1074    in_unit("granular.spread", spread)?;
1075    in_unit("granular.mix", mix)
1076}