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 an agent can act on it.
3
4use super::{
5    Adsr, AutoTarget, ENGINE_VERSION, Modulator, Node, Playback, SCHEMA_VERSION, SeqWave, SoundDoc,
6    Stereo, 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 an agent pattern-matches to self-correct — 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 { tracks, master } = &self.root {
146            if tracks.is_empty() {
147                return Err("tracks must be non-empty".into());
148            }
149            // A mixer document builds its stereo image from per-layer pan; a
150            // doc-level Haas/Wide treatment would be silently dropped by the
151            // renderer. v1 documents keep the historical silent-ignore so old
152            // libraries still load.
153            if self.effective_version() >= 2 && !matches!(self.stereo, Stereo::Mono) {
154                return Err(
155                    "a tracks document builds its stereo image from per-layer pan — remove the \
156                     doc-level stereo treatment (set stereo mode 'mono') and pan the layers \
157                     instead"
158                        .into(),
159                );
160            }
161            let mut seen_ids = std::collections::HashSet::new();
162            let mut seen_streams = std::collections::HashMap::new();
163            for (i, t) in tracks.iter().enumerate() {
164                // Errors name the layer by id when it has one — that is the
165                // address the agent used.
166                let who = match &t.id {
167                    Some(id) => format!("layer '{id}'"),
168                    None => format!("tracks[{i}]"),
169                };
170                if let Some(id) = &t.id {
171                    if id.is_empty()
172                        || !id
173                            .chars()
174                            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
175                    {
176                        return Err(format!(
177                            "{who}: layer ids are short slugs (a-z, 0-9, _), got '{id}'"
178                        ));
179                    }
180                    if id == "master" {
181                        return Err(
182                            "'master' is reserved for the master chain; pick another layer id"
183                                .into(),
184                        );
185                    }
186                    if !seen_ids.insert(id.clone()) {
187                        return Err(format!("duplicate layer id '{id}' — ids must be unique"));
188                    }
189                    // Stream keys must be collision-free or two layers would
190                    // silently share one noise stream (and u64::MAX is the
191                    // master bus's stream).
192                    let key = crate::dsp::layer_stream_key(id);
193                    if key == u64::MAX {
194                        return Err(format!(
195                            "{who}: this id collides with the master bus's RNG stream — rename \
196                             the layer"
197                        ));
198                    }
199                    if let Some(other) = seen_streams.insert(key, id.clone()) {
200                        return Err(format!(
201                            "layer ids '{other}' and '{id}' hash to the same RNG stream — \
202                             rename one of them"
203                        ));
204                    }
205                }
206                if !(-1.0..=1.0).contains(&t.pan) {
207                    return Err(format!("{who}: pan must be in [-1, 1], got {}", t.pan));
208                }
209                if !(0.0..=2.0).contains(&t.gain) {
210                    return Err(format!("{who}: gain must be in [0, 2], got {}", t.gain));
211                }
212                if !(0.0..self.duration).contains(&t.at) {
213                    return Err(format!(
214                        "{who}: at must be in [0, duration {}), got {} — the layer would be \
215                         entirely outside the render window",
216                        self.duration, t.at
217                    ));
218                }
219                let mut seen_lanes: Vec<AutoTarget> = Vec::new();
220                for lane in &t.automation {
221                    let (lname, lo, hi) = match lane.target {
222                        AutoTarget::Gain => ("gain", 0.0, 2.0),
223                        AutoTarget::Pan => ("pan", -1.0, 1.0),
224                    };
225                    // The renderer applies the first matching lane, so a
226                    // second lane for the same target is silently dead.
227                    if seen_lanes.contains(&lane.target) {
228                        return Err(format!(
229                            "{who}: duplicate automation lane for '{lname}' — only the first \
230                             applies, so this one would be dead"
231                        ));
232                    }
233                    seen_lanes.push(lane.target);
234                    for (pi, p) in lane.points.iter().enumerate() {
235                        if !p.t.is_finite() || p.t < 0.0 {
236                            return Err(format!(
237                                "{who}: automation[{lname}].points[{pi}].t must be >= 0 \
238                                 seconds, got {}",
239                                p.t
240                            ));
241                        }
242                        if !(lo..=hi).contains(&p.v) {
243                            return Err(format!(
244                                "{who}: automation[{lname}].points[{pi}].v must be in \
245                                 [{lo}, {hi}], got {}",
246                                p.v
247                            ));
248                        }
249                    }
250                }
251                if contains_tracks(&t.node) {
252                    return Err("tracks cannot nest inside a track".into());
253                }
254                validate_node(&t.node)?;
255            }
256            for (i, m) in master.iter().enumerate() {
257                if !m.is_processor() {
258                    return Err(format!(
259                        "master[{i}] must be a processor (filter/eq/dynamics/fx)"
260                    ));
261                }
262                validate_node(m)?;
263            }
264            return Ok(());
265        }
266        if contains_tracks(&self.root) {
267            return Err("tracks is the mixing console: it must be the document's root node".into());
268        }
269        // A bare processor as the root has no input and renders digital
270        // silence — say so instead of "succeeding".
271        if self.root.is_processor() {
272            return Err(
273                "the root node must be a source (osc/noise/seq/mix/…), not a bare processor \
274                 (filter/eq/dynamics/fx) — it would render silence"
275                    .into(),
276            );
277        }
278        validate_node(&self.root)
279    }
280}
281
282/// True if a `tracks` node appears anywhere in this subtree. Iterative (an
283/// explicit stack) so a pathologically deep programmatic document can't
284/// overflow the call stack before the depth cap in `validate_node_at` bites.
285fn contains_tracks(node: &Node) -> bool {
286    let mut stack = vec![node];
287    while let Some(n) = stack.pop() {
288        if matches!(n, Node::Tracks { .. }) {
289            return true;
290        }
291        stack.extend(n.children());
292    }
293    false
294}
295
296/// A finite value (rejects NaN/±inf — they would render NaN audio).
297fn finite(name: &str, v: f32) -> Result<(), String> {
298    if !v.is_finite() {
299        return Err(format!("{name} must be a finite number, got {v}"));
300    }
301    Ok(())
302}
303
304/// A finite, strictly positive value.
305fn positive(name: &str, v: f32) -> Result<(), String> {
306    finite(name, v)?;
307    if v <= 0.0 {
308        return Err(format!("{name} must be > 0, got {v}"));
309    }
310    Ok(())
311}
312
313/// A finite, non-negative value.
314fn non_negative(name: &str, v: f32) -> Result<(), String> {
315    finite(name, v)?;
316    if v < 0.0 {
317        return Err(format!("{name} must be >= 0, got {v}"));
318    }
319    Ok(())
320}
321
322impl crate::dsl::FmKnobs {
323    fn validate(&self) -> Result<(), String> {
324        positive("seq.fm_ratio", self.fm_ratio)?;
325        if !(0.0..=20.0).contains(&self.fm_index) {
326            return Err(format!(
327                "seq.fm_index must be in [0, 20], got {}",
328                self.fm_index
329            ));
330        }
331        positive("seq.fm_strike", self.fm_strike)
332    }
333}
334
335impl crate::dsl::PluckKnobs {
336    fn validate(&self) -> Result<(), String> {
337        if !(0.8..1.0).contains(&self.pluck_decay) {
338            return Err(format!(
339                "seq.pluck_decay must be in [0.8, 1), got {}",
340                self.pluck_decay
341            ));
342        }
343        in_unit("seq.pluck_body", self.pluck_body)?;
344        in_unit("seq.pluck_pick", self.pluck_pick)?;
345        if !(-1.0..=1.0).contains(&self.pluck_tone) {
346            return Err(format!(
347                "seq.pluck_tone must be in [-1, 1], got {}",
348                self.pluck_tone
349            ));
350        }
351        Ok(())
352    }
353}
354
355impl crate::dsl::PianoKnobs {
356    fn validate(&self) -> Result<(), String> {
357        positive("seq.piano_hammer", self.piano_hammer)?;
358        positive("seq.piano_strike", self.piano_strike)?;
359        positive("seq.piano_inharm", self.piano_inharm)?;
360        non_negative("seq.piano_detune", self.piano_detune)?;
361        positive("seq.piano_decay", self.piano_decay)
362    }
363}
364
365impl crate::dsl::BassKnobs {
366    fn validate(&self) -> Result<(), String> {
367        positive("seq.bass_cutoff", self.bass_cutoff)?;
368        non_negative("seq.bass_env", self.bass_env)?;
369        non_negative("seq.bass_env_vel", self.bass_env_vel)?;
370        positive("seq.bass_decay", self.bass_decay)?;
371        non_negative("seq.bass_click", self.bass_click)?;
372        non_negative("seq.bass_body", self.bass_body)?;
373        non_negative("seq.bass_sub", self.bass_sub)?;
374        positive("seq.bass_sub_ratio", self.bass_sub_ratio)?;
375        in_unit("seq.bass_drive", self.bass_drive)?;
376        positive("seq.bass_body_decay", self.bass_body_decay)
377    }
378}
379
380impl crate::dsl::Sf2Knobs {
381    /// Only meaningful when the seq's wave is `sampler` — the caller gates it.
382    fn validate(&self) -> Result<(), String> {
383        if self.sf2.is_empty() {
384            return Err(
385                "seq.sf2 must point at a SoundFont (.sf2) file when wave is 'sampler'".into(),
386            );
387        }
388        if self.sf2_preset > 127 {
389            return Err(format!(
390                "seq.sf2_preset must be in [0, 127], got {}",
391                self.sf2_preset
392            ));
393        }
394        Ok(())
395    }
396}
397
398fn validate_value(v: &Value, what: &str) -> Result<(), String> {
399    match v {
400        Value::Const(c) => finite(what, *c),
401        Value::Note(s) => note_to_hz(s).map(|_| ()).ok_or_else(|| {
402            format!("{what}: '{s}' is not a valid note (e.g. \"A4\", \"C#3\", \"midi:69\")")
403        }),
404        Value::Modulated(m) => match m {
405            Modulator::Slide { from, to, secs, .. } => {
406                finite(&format!("{what}: slide.from"), *from)?;
407                finite(&format!("{what}: slide.to"), *to)?;
408                positive(&format!("{what}: slide.secs"), *secs)
409            }
410            Modulator::Lfo {
411                rate,
412                depth,
413                center,
414                ..
415            } => {
416                positive(&format!("{what}: lfo.rate"), *rate)?;
417                finite(&format!("{what}: lfo.depth"), *depth)?;
418                finite(&format!("{what}: lfo.center"), *center)
419            }
420            Modulator::Arp { steps, rate } => {
421                if steps.is_empty() {
422                    return Err(format!("{what}: arp.steps must be non-empty"));
423                }
424                for (i, s) in steps.iter().enumerate() {
425                    finite(&format!("{what}: arp.steps[{i}]"), *s)?;
426                }
427                positive(&format!("{what}: arp.rate"), *rate)
428            }
429            Modulator::EnvMod { adsr, from, to } => {
430                finite(&format!("{what}: env.from"), *from)?;
431                finite(&format!("{what}: env.to"), *to)?;
432                adsr.validate(&format!("{what}: env"))?;
433                // The same flatten footgun as Node::Env: the ADSR fields are
434                // inlined on the modulator, so an "adsr" object is silently
435                // dropped and the parameter would pin at `from` forever.
436                if adsr.a == 0.0 && adsr.d == 0.0 && adsr.s == 0.0 && adsr.r == 0.0 {
437                    return Err(format!(
438                        "{what}: env is constant — a/d/s/r are all 0. The envelope fields \
439                         are inlined on the modulator (e.g. {{\"env\":{{\"a\":0.01,\"d\":0.1,\
440                         \"s\":0.7,\"r\":0.2,\"from\":..,\"to\":..}}}}); don't nest them \
441                         under \"adsr\""
442                    ));
443                }
444                Ok(())
445            }
446            Modulator::Rand { from, to, rate, .. } => {
447                finite(&format!("{what}: rand.from"), *from)?;
448                finite(&format!("{what}: rand.to"), *to)?;
449                positive(&format!("{what}: rand.rate"), *rate)?;
450                // Past ~10k targets/s the walk is indistinguishable from noise,
451                // and the renderer's per-sample catch-up loop becomes a denial
452                // of service (rate 1e12 ⇒ ~1e7 iterations per sample).
453                if *rate > 10_000.0 {
454                    return Err(format!(
455                        "{what}: rand.rate must be in (0, 10000], got {rate}"
456                    ));
457                }
458                Ok(())
459            }
460        },
461    }
462}
463
464/// Validate a `Value` that names a frequency: a constant must be finite and
465/// strictly positive (a modulated form is clamped per-sample at render time).
466fn validate_freq_value(v: &Value, what: &str) -> Result<(), String> {
467    match v {
468        Value::Const(c) => {
469            positive(what, *c)?;
470            // 100 kHz sits above every supported Nyquist (96 kHz at 192 kHz
471            // sr); past it a constant is an authoring error, and products
472            // like fm.freq × fm.ratio can reach f32 overflow and render NaN.
473            if *c > 100_000.0 {
474                return Err(format!("{what} must be <= 100000 Hz, got {c}"));
475            }
476        }
477        // note_to_hz already bounds a resolved note to <= 100 kHz.
478        Value::Note(_) => {}
479        Value::Modulated(m) => validate_freq_mod(m, what)?,
480    }
481    validate_value(v, what)
482}
483
484/// Bound a modulated frequency's endpoints far below the f32-overflow regime,
485/// so products like fm.freq × fm.ratio can never turn oscillator phases NaN.
486/// (center/depth are bounded independently, so an LFO's peak can reach 2× —
487/// loose, but still orders of magnitude from either danger zone.)
488fn validate_freq_mod(m: &Modulator, what: &str) -> Result<(), String> {
489    const MAX_HZ: f32 = 1e6;
490    let check = |name: &str, x: f32| {
491        if x.abs() > MAX_HZ {
492            Err(format!(
493                "{what}: {name} must be within ±{MAX_HZ} Hz, got {x}"
494            ))
495        } else {
496            Ok(())
497        }
498    };
499    match m {
500        Modulator::Slide { from, to, .. } => {
501            check("slide.from", *from)?;
502            check("slide.to", *to)
503        }
504        Modulator::Lfo { depth, center, .. } => {
505            check("lfo.center", *center)?;
506            check("lfo.depth", *depth)
507        }
508        Modulator::Arp { steps, .. } => {
509            for (i, s) in steps.iter().enumerate() {
510                check(&format!("arp.steps[{i}]"), *s)?;
511            }
512            Ok(())
513        }
514        Modulator::EnvMod { from, to, .. } | Modulator::Rand { from, to, .. } => {
515            check("from", *from)?;
516            check("to", *to)
517        }
518    }
519}
520
521fn in_unit(name: &str, v: f32) -> Result<(), String> {
522    if !(0.0..=1.0).contains(&v) {
523        return Err(format!("{name} must be in [0, 1], got {v}"));
524    }
525    Ok(())
526}
527
528/// EQ gain bound: ±24 dB covers any musical boost/cut; far beyond that the
529/// biquad coefficients overflow to inf/NaN and render silent garbage.
530fn validate_gain_db(name: &str, v: f32) -> Result<(), String> {
531    if !(-24.0..=24.0).contains(&v) {
532        return Err(format!("{name} must be in [-24, 24] dB, got {v}"));
533    }
534    Ok(())
535}
536
537/// Validate a `Value` whose constant form must lie in [0, 1] (modulated forms
538/// are clamped at render time).
539fn validate_unit_value(v: &Value, what: &str) -> Result<(), String> {
540    if let Value::Const(c) = v {
541        in_unit(what, *c)?;
542    }
543    validate_value(v, what)
544}
545
546/// Bounding the graph depth keeps validation (and the recursive renderer) off
547/// the stack for programmatically-built documents; JSON input is already capped
548/// well below this by serde's own recursion limit.
549const MAX_NODE_DEPTH: usize = 256;
550
551fn validate_node(node: &Node) -> Result<(), String> {
552    validate_node_at(node, 0)
553}
554
555fn validate_node_at(node: &Node, depth: usize) -> Result<(), String> {
556    if depth > MAX_NODE_DEPTH {
557        return Err(format!(
558            "the graph nests deeper than {MAX_NODE_DEPTH} levels — flatten it (e.g. into tracks)"
559        ));
560    }
561    match node {
562        Node::Square { freq, duty } => {
563            validate_freq_value(freq, "square.freq")?;
564            validate_unit_value(duty, "square.duty")
565        }
566        Node::Triangle { freq } => validate_freq_value(freq, "triangle.freq"),
567        Node::Sawtooth { freq } => validate_freq_value(freq, "sawtooth.freq"),
568        Node::Sine { freq } => validate_freq_value(freq, "sine.freq"),
569        Node::Noise { .. } => Ok(()),
570        Node::Impact { hardness, velocity } => {
571            in_unit("impact.hardness", *hardness)?;
572            in_unit("impact.velocity", *velocity)
573        }
574        Node::Dust { density, decay } => {
575            positive("dust.density", *density)?;
576            non_negative("dust.decay", *decay)
577        }
578        Node::Fm { freq, ratio, index } => {
579            validate_freq_value(freq, "fm.freq")?;
580            // Cap the ratio so freq × ratio can't reach f32 overflow — past
581            // it the modulator phase goes inf and renders NaN.
582            positive("fm.ratio", *ratio)?;
583            if *ratio > 4096.0 {
584                return Err(format!("fm.ratio must be <= 4096, got {ratio}"));
585            }
586            validate_value(index, "fm.index")
587        }
588        // Bound exhaustively (no `..`): the compiler then forces a validation
589        // decision for every knob this variant grows.
590        Node::Seq {
591            bpm,
592            steps_per_beat,
593            wave,
594            duty,
595            fm,
596            pluck,
597            piano,
598            kit: _,
599            bass,
600            sf2,
601            swing,
602            humanize,
603            env,
604            notes,
605        } => {
606            positive("seq.bpm", *bpm)?;
607            if *steps_per_beat < 1 {
608                return Err("seq.steps_per_beat must be >= 1".into());
609            }
610            if notes.is_empty() {
611                return Err("seq.notes must be non-empty".into());
612            }
613            validate_unit_value(duty, "seq.duty")?;
614            fm.validate()?;
615            pluck.validate()?;
616            piano.validate()?;
617            bass.validate()?;
618            in_unit("seq.swing", *swing)?;
619            in_unit("seq.humanize", *humanize)?;
620            if *wave == SeqWave::Sampler {
621                sf2.validate()?;
622            }
623            env.validate("seq.env")?;
624            for note in notes {
625                if note.len < 1 {
626                    return Err("seq note.len must be >= 1".into());
627                }
628                in_unit("seq note.gain", note.gain)?;
629                validate_freq_value(&note.pitch, "seq note.pitch")?;
630            }
631            Ok(())
632        }
633        Node::Env { adsr } => {
634            adsr.validate("env")?;
635            // An all-zero envelope is always silent — never intended. It's also
636            // the tell-tale of the flatten footgun: the env's a/d/s/r are inlined
637            // (`{"type":"env","a":..,"d":..}`), so wrapping them in an `"adsr"`
638            // object silently drops them all to 0. Reject it with that hint.
639            if adsr.a == 0.0 && adsr.d == 0.0 && adsr.s == 0.0 && adsr.r == 0.0 {
640                return Err("env is silent — a/d/s/r are all 0. The envelope fields \
641                    are inlined on the node (e.g. {\"type\":\"env\",\"a\":0.01,\"d\":0.1,\
642                    \"s\":0.7,\"r\":0.2}); don't nest them under \"adsr\""
643                    .into());
644            }
645            Ok(())
646        }
647        // Nested mixers are rejected earlier; this guards direct calls.
648        Node::Tracks { .. } => Err("tracks must be the document's root node".into()),
649        Node::Mix { inputs } | Node::Mul { inputs } => {
650            if inputs.is_empty() {
651                return Err("mix/mul requires at least one input".into());
652            }
653            inputs
654                .iter()
655                .try_for_each(|n| validate_node_at(n, depth + 1))
656        }
657        Node::Chain { stages } => {
658            if stages.is_empty() {
659                return Err("chain requires at least one stage".into());
660            }
661            // A leading processor has no input and renders digital silence —
662            // the worst outcome for a sound authored blind. Sources first.
663            if stages[0].is_processor() {
664                return Err(
665                    "chain's first stage must be a source (osc/noise/seq/mix/…), not a \
666                     processor (filter/eq/dynamics/fx) — it would render silence"
667                        .into(),
668                );
669            }
670            stages
671                .iter()
672                .try_for_each(|n| validate_node_at(n, depth + 1))
673        }
674        Node::Lowpass { cutoff, q }
675        | Node::Highpass { cutoff, q }
676        | Node::Bandpass { cutoff, q }
677        | Node::Notch { cutoff, q } => {
678            validate_freq_value(cutoff, "filter.cutoff")?;
679            positive("filter.q", *q)
680        }
681        Node::Peak { cutoff, q, gain_db } => {
682            validate_freq_value(cutoff, "peak.cutoff")?;
683            positive("peak.q", *q)?;
684            validate_gain_db("peak.gain_db", *gain_db)
685        }
686        Node::Lowshelf { cutoff, gain_db } | Node::Highshelf { cutoff, gain_db } => {
687            validate_freq_value(cutoff, "shelf.cutoff")?;
688            validate_gain_db("shelf.gain_db", *gain_db)
689        }
690        Node::Super {
691            freq,
692            voices,
693            detune_cents,
694            ..
695        } => {
696            validate_freq_value(freq, "super.freq")?;
697            if !(1..=16).contains(voices) {
698                return Err(format!("super.voices must be in [1, 16], got {voices}"));
699            }
700            // 10 octaves of unison spread; past it 2^(cents/1200) approaches
701            // f32 overflow and the voices render NaN.
702            if !(0.0..=12_000.0).contains(detune_cents) {
703                return Err(format!(
704                    "super.detune_cents must be in [0, 12000], got {detune_cents}"
705                ));
706            }
707            Ok(())
708        }
709        Node::Gain { amount } => validate_value(amount, "gain.amount"),
710        Node::Bitcrush { bits } => {
711            if !(1..=16).contains(bits) {
712                return Err(format!("bitcrush.bits must be in [1, 16], got {bits}"));
713            }
714            Ok(())
715        }
716        Node::Downsample { factor } => {
717            if *factor < 1 {
718                return Err("downsample.factor must be >= 1".into());
719            }
720            Ok(())
721        }
722        Node::Delay { secs, feedback } => {
723            // The upper bound caps the delay-line allocation: an unbounded
724            // `secs` would let a validated document request a buffer of
725            // arbitrary size and abort the process.
726            positive("delay.secs", *secs)?;
727            if *secs > 30.0 {
728                return Err(format!("delay.secs must be in (0, 30] seconds, got {secs}"));
729            }
730            in_unit("delay.feedback", *feedback)
731        }
732        Node::Reverb { room, mix } => {
733            in_unit("reverb.room", *room)?;
734            in_unit("reverb.mix", *mix)
735        }
736        Node::Modal { modes, mix } => {
737            if modes.is_empty() {
738                return Err("modal.modes must be non-empty".into());
739            }
740            if modes.len() > 64 {
741                return Err(format!(
742                    "modal.modes must have at most 64 modes, got {}",
743                    modes.len()
744                ));
745            }
746            for (i, m) in modes.iter().enumerate() {
747                positive(&format!("modal.modes[{i}].freq"), m.freq)?;
748                positive(&format!("modal.modes[{i}].decay"), m.decay)?;
749                in_unit(&format!("modal.modes[{i}].gain"), m.gain)?;
750            }
751            in_unit("modal.mix", *mix)
752        }
753        Node::Drive { amount, .. } => validate_value(amount, "drive.amount"),
754        Node::RingMod { freq } => validate_freq_value(freq, "ringmod.freq"),
755        Node::Chorus { rate, depth, mix } => {
756            positive("chorus.rate", *rate)?;
757            in_unit("chorus.depth", *depth)?;
758            in_unit("chorus.mix", *mix)
759        }
760        Node::Flanger {
761            rate,
762            depth,
763            feedback,
764            mix,
765        }
766        | Node::Phaser {
767            rate,
768            depth,
769            feedback,
770            mix,
771        } => {
772            positive("flanger/phaser.rate", *rate)?;
773            in_unit("flanger/phaser.depth", *depth)?;
774            in_unit("flanger/phaser.feedback", *feedback)?;
775            in_unit("flanger/phaser.mix", *mix)
776        }
777        Node::Duck {
778            trigger,
779            amount,
780            attack,
781            release,
782        } => {
783            in_unit("duck.amount", *amount)?;
784            non_negative("duck.attack", *attack)?;
785            non_negative("duck.release", *release)?;
786            validate_node_at(trigger, depth + 1)
787        }
788        Node::Compress {
789            threshold,
790            ratio,
791            attack,
792            release,
793            makeup,
794        } => {
795            finite("compress.threshold", *threshold)?;
796            // JSON 1e308 deserializes to f32 inf — finite first, then the bound.
797            finite("compress.ratio", *ratio)?;
798            if *ratio < 1.0 {
799                return Err(format!("compress.ratio must be >= 1, got {ratio}"));
800            }
801            non_negative("compress.attack", *attack)?;
802            non_negative("compress.release", *release)?;
803            finite("compress.makeup", *makeup)
804        }
805    }
806}