1use super::{
5 Adsr, AutoTarget, ENGINE_VERSION, Modulator, Node, Playback, SCHEMA_VERSION, SeqWave, SoundDoc,
6 Stereo, Value, note_to_hz,
7};
8
9impl Adsr {
10 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#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ValidateError(String);
26
27impl ValidateError {
28 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 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 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 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 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 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 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 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 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
282fn 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
296fn 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
304fn 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
313fn 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 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 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 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
464fn validate_freq_value(v: &Value, what: &str) -> Result<(), String> {
467 match v {
468 Value::Const(c) => {
469 positive(what, *c)?;
470 if *c > 100_000.0 {
474 return Err(format!("{what} must be <= 100000 Hz, got {c}"));
475 }
476 }
477 Value::Note(_) => {}
479 Value::Modulated(m) => validate_freq_mod(m, what)?,
480 }
481 validate_value(v, what)
482}
483
484fn 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
528fn 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
537fn 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
546const 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 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 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(¬e.pitch, "seq note.pitch")?;
630 }
631 Ok(())
632 }
633 Node::Env { adsr } => {
634 adsr.validate("env")?;
635 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 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 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 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 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 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}