1use 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 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 {
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 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 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 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 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 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 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 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
423fn 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
437fn 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
445fn 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
454fn 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 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 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 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
605fn validate_freq_value(v: &Value, what: &str) -> Result<(), String> {
608 match v {
609 Value::Const(c) => {
610 positive(what, *c)?;
611 if *c > 100_000.0 {
615 return Err(format!("{what} must be <= 100000 Hz, got {c}"));
616 }
617 }
618 Value::Note(_) => {}
620 Value::Modulated(m) => validate_freq_mod(m, what)?,
621 }
622 validate_value(v, what)
623}
624
625fn 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
669fn 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
678fn 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
712fn 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
721const 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 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 validate_unit_value(position, "wavetable.position")
768 }
769 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(¬e.pitch, "seq note.pitch")?;
813 }
814 Ok(())
815 }
816 Node::Env { adsr } => {
817 adsr.validate("env")?;
818 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 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 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 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 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 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 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
1013fn validate_convolve(
1017 decay: f32,
1018 size: f32,
1019 predelay: f32,
1020 damp: f32,
1021 mix: f32,
1022) -> Result<(), String> {
1023 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 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
1049fn 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}