tono_core/dsl/mod.rs
1//! The Tono synthesis-graph DSL.
2//!
3//! A [`SoundDoc`] is the canonical, declarative source of a sound. The AI agent
4//! authors one of these; the renderer turns it into samples. Everything here is
5//! `serde`-deserializable (the on-disk / wire format is JSON) and `JsonSchema`-
6//! describable so a tool can self-correct against the schema.
7
8mod node;
9#[cfg(test)]
10mod tests;
11mod tracks;
12mod validate;
13
14pub use node::{BassKnobs, Children, ChildrenMut, FmKnobs, Node, PianoKnobs, PluckKnobs, Sf2Knobs};
15pub use tracks::{AutoLane, AutoPoint, AutoTarget, Track};
16pub use validate::ValidateError;
17
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21/// Current DSL schema version. Stored on every doc so old graphs stay loadable
22/// as the vocabulary evolves. Version 2 gives every mixer track its own
23/// deterministic RNG stream (v1 threads one stream through the track list in
24/// order, so editing one track shifts the noise content of its siblings).
25pub const SCHEMA_VERSION: u32 = 2;
26
27/// Current DSP-kernel (engine) revision. Distinct from [`SCHEMA_VERSION`]:
28/// that versions the *document schema* (what fields exist and how the graph is
29/// structured); this versions the *audio kernels* (how a node turns into
30/// samples). Splitting them lets a quality-improving kernel change ship
31/// WITHOUT altering the bytes of any document authored before it. A document's
32/// `engine` is `0` when omitted — the original kernels every shipped sound was
33/// rendered under, byte-identical forever. New documents are stamped with this
34/// value, opting them into the current kernels (e.g. anti-aliased `drive`).
35/// Revision 1 adds antiderivative anti-aliasing to [`Node::Drive`]. Revision 2
36/// gives each `noise`/`dust` node its own structurally-seeded RNG (derived from
37/// its position in the graph) instead of drawing from one shared, traversal-order
38/// stream — decorrelating sibling noise and, crucially, letting the real-time
39/// streaming renderer produce byte-identical randomness block-by-block.
40/// Revision 3 upgrades the `piano` seq voice to an inharmonic additive model
41/// (stretched partials, per-partial decay, a hammer-strike spectrum, and a
42/// detuned unison pair) — a far richer grand than the two-operator FM of
43/// engine ≤ 2, which stays bit-exact for older documents.
44/// Revision 4 corrects the mixer output stage — loudness normalization
45/// measures the stereo program jointly (one shared gain, preserving the
46/// authored balance), uses sample-rate-correct gated BS.1770 loudness, and
47/// limits against a real oversampled true-peak estimate — and seeds humanize
48/// jitter per note, so chords stop sharing one timing/velocity offset.
49pub const ENGINE_VERSION: u32 = 4;
50
51// Serde `default = "..."` requires free functions. Values with non-obvious
52// origins: haas 12 ms sits in the precedence-effect sweet spot, ceiling
53// −1 dBTP is the common streaming-safe true-peak ceiling.
54fn default_sample_rate() -> u32 {
55 44_100
56}
57fn default_duration() -> f32 {
58 0.3
59}
60fn default_gain() -> f32 {
61 1.0
62}
63fn default_haas_ms() -> f32 {
64 12.0
65}
66fn default_wide_amount() -> f32 {
67 0.6
68}
69fn default_ceiling_dbtp() -> f32 {
70 -1.0
71}
72fn default_crossfade() -> f32 {
73 0.1
74}
75fn default_mode_decay() -> f32 {
76 0.4
77}
78
79/// A complete sound: metadata plus a single root node.
80#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
81pub struct SoundDoc {
82 /// Human-readable label for the sound (e.g. `"laser_zap"`).
83 pub name: String,
84 /// Length of the rendered sound in seconds.
85 #[serde(default = "default_duration")]
86 pub duration: f32,
87 /// Output sample rate in Hz.
88 #[serde(default = "default_sample_rate")]
89 pub sample_rate: u32,
90 /// Seed for any stochastic node (noise). Same seed ⇒ identical audio.
91 #[serde(default)]
92 pub seed: u64,
93 /// DSL schema version. Omitted ⇒ 1, the semantics documents were authored
94 /// under before versioning mattered; the authoring tools stamp new
95 /// documents with the current [`SCHEMA_VERSION`]. Documents from a newer
96 /// tono are rejected by `validate` instead of silently misrendered.
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub version: Option<u32>,
99 /// DSP-kernel revision (see [`ENGINE_VERSION`]). Omitted ⇒ 0, the original
100 /// kernels — so every existing document renders byte-for-byte as before.
101 /// The authoring tools stamp new documents with the current
102 /// [`ENGINE_VERSION`]; raising a document's `engine` opts it into newer,
103 /// higher-quality kernels (anti-aliased `drive`, …) and DOES change its
104 /// output. A document from a newer tono (engine > `ENGINE_VERSION`) is
105 /// rejected by `validate` rather than silently misrendered.
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub engine: Option<u32>,
108 /// Optional stereo treatment applied to the final mono render. Defaults to
109 /// mono (game SFX are usually authored mono and spatialised by the engine;
110 /// use stereo for BGM, ambience, and UI stingers).
111 #[serde(default)]
112 pub stereo: Stereo,
113 /// Optional output-stage loudness normalization + true-peak limiting. When
114 /// set with `target_lufs`, the final render is gain-matched to that
115 /// integrated loudness, then brick-wall limited so the inter-sample (true)
116 /// peak never exceeds `ceiling_dbtp`. Leave unset for the default behaviour
117 /// (a transparent −0.1 dBFS sample-peak safety limit only). Use it to ship a
118 /// level-matched set: pick one target (e.g. −16 LUFS for SFX) for the pack.
119 #[serde(default)]
120 pub normalize: Option<Normalize>,
121 /// Playback intent. `oneshot` (default) renders the sound as-is. `loop`
122 /// extracts the loop region and equal-power crossfades its tail into its
123 /// head so the rendered file repeats seamlessly — the right mode for
124 /// ambience beds, engine drones, and BGM. The exported WAV carries a `smpl`
125 /// loop chunk so engines (Godot / Unity / FMOD) loop at the sample-accurate
126 /// points without manual setup.
127 #[serde(default)]
128 pub playback: Playback,
129 /// The signal graph. Usually a `mix`, `mul`, or `chain`.
130 pub root: Node,
131}
132
133impl SoundDoc {
134 /// A new document around `root`, stamped with the current
135 /// [`SCHEMA_VERSION`] and [`ENGINE_VERSION`] (this is the authoring
136 /// constructor — new sounds get the current kernels) and every other
137 /// field at its serde default: 0.3 s, 44 100 Hz, seed 0, mono, one-shot.
138 pub fn new(name: impl Into<String>, root: Node) -> Self {
139 SoundDoc {
140 name: name.into(),
141 duration: default_duration(),
142 sample_rate: default_sample_rate(),
143 seed: 0,
144 version: Some(SCHEMA_VERSION),
145 engine: Some(ENGINE_VERSION),
146 stereo: Stereo::default(),
147 normalize: None,
148 playback: Playback::default(),
149 root,
150 }
151 }
152}
153
154/// How the rendered sound is meant to be played back.
155#[non_exhaustive]
156#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
157#[serde(tag = "mode", rename_all = "lowercase")]
158pub enum Playback {
159 /// Play once (default).
160 #[default]
161 OneShot,
162 /// Seamless loop. The renderer extracts the region `[start_secs, end_secs)`
163 /// and crossfades its last `crossfade_secs` (equal-power) onto its head, so
164 /// the rendered buffer repeats with no click. The output is the loop body
165 /// (shorter than the source by the crossfade), and the WAV gets a `smpl`
166 /// loop spanning the whole file.
167 #[serde(rename = "loop")]
168 Loop {
169 /// Loop start in seconds (default 0).
170 #[serde(default)]
171 start_secs: f32,
172 /// Loop end in seconds (default: end of the rendered buffer).
173 #[serde(default)]
174 end_secs: Option<f32>,
175 /// Equal-power crossfade length in seconds (default 0.1). Longer hides
176 /// bigger discontinuities but shortens the loop more.
177 #[serde(default = "default_crossfade")]
178 crossfade_secs: f32,
179 },
180}
181
182/// Output-stage loudness normalization + true-peak limiting.
183#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
184pub struct Normalize {
185 /// Target integrated loudness in LUFS (e.g. −16 for SFX, −14 for music).
186 /// The render is gain-matched to hit this before limiting. Omit to skip
187 /// loudness matching and only apply the true-peak ceiling.
188 #[serde(default)]
189 pub target_lufs: Option<f32>,
190 /// True-peak ceiling in dBTP. The output is limited so its inter-sample peak
191 /// stays at or below this. Defaults to −1.0.
192 #[serde(default = "default_ceiling_dbtp")]
193 pub ceiling_dbtp: f32,
194}
195
196/// Stereo treatment for the final render.
197#[non_exhaustive]
198#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
199#[serde(tag = "mode", rename_all = "lowercase")]
200pub enum Stereo {
201 /// Mono — both channels identical (default).
202 #[default]
203 Mono,
204 /// Haas precedence widening: one channel delayed by `ms` ([0.5, 40], validated), shifting
205 /// the apparent position and adding width. `pan` (-1 left .. 1 right) sets
206 /// which side leads.
207 Haas {
208 /// Inter-channel delay in milliseconds.
209 #[serde(default = "default_haas_ms")]
210 ms: f32,
211 /// Lead side, −1 (left) .. 1 (right).
212 #[serde(default)]
213 pan: f32,
214 },
215 /// Pseudo-stereo: decorrelate the channels for width on pads / BGM.
216 Wide {
217 /// Width amount, 0 (mono) .. 1 (fully decorrelated).
218 #[serde(default = "default_wide_amount")]
219 amount: f32,
220 },
221}
222
223/// A numeric parameter that is either a constant or a time-varying modulator.
224#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
225#[serde(untagged)]
226pub enum Value {
227 /// A constant value (e.g. a fixed frequency in Hz).
228 Const(f32),
229 /// A musical pitch as a string: a note name like `"A4"`, `"C#3"`, `"Gb5"`,
230 /// `"F#-1"`, or a MIDI number like `"midi:69"` / `"m69"`. Resolves to Hz
231 /// (A4 = 440, 12-TET) — so melodies read musically instead of as raw Hz.
232 Note(String),
233 /// A modulator that produces a value per sample.
234 Modulated(Modulator),
235}
236
237impl From<f32> for Value {
238 /// A constant — `"freq": 440.0.into()`.
239 fn from(v: f32) -> Self {
240 Value::Const(v)
241 }
242}
243
244impl From<&str> for Value {
245 /// A note name (`"C4"`, `"F#3"`, `"midi:69"`) — resolved to Hz at render.
246 fn from(name: &str) -> Self {
247 Value::Note(name.to_string())
248 }
249}
250
251impl From<String> for Value {
252 /// A note name (see [`note_to_hz`]).
253 fn from(name: String) -> Self {
254 Value::Note(name)
255 }
256}
257
258impl From<Modulator> for Value {
259 fn from(m: Modulator) -> Self {
260 Value::Modulated(m)
261 }
262}
263
264/// Parse a musical pitch into Hz: a note name (`"A4"`, `"C#3"`, `"Gb5"`,
265/// `"F#-1"`; octave defaults to 4) or a MIDI number (`"midi:69"` / `"m69"`).
266/// A4 = 440 Hz, 12-tone equal temperament. Returns `None` if unparseable.
267pub fn note_to_hz(s: &str) -> Option<f32> {
268 let s = s.trim();
269 if s.is_empty() {
270 return None;
271 }
272 // MIDI forms: "midi:69" or "m69".
273 if let Some(num) = s
274 .strip_prefix("midi:")
275 .or_else(|| s.strip_prefix(['m', 'M']))
276 && let Ok(n) = num.trim().parse::<f32>()
277 {
278 return midi_to_hz(n);
279 }
280 // Note name: letter, optional #/b accidentals, optional octave (default 4).
281 let mut chars = s.chars().peekable();
282 let mut semis: i32 = match chars.next()?.to_ascii_uppercase() {
283 'C' => 0,
284 'D' => 2,
285 'E' => 4,
286 'F' => 5,
287 'G' => 7,
288 'A' => 9,
289 'B' => 11,
290 _ => return None,
291 };
292 loop {
293 match chars.peek() {
294 Some('#') => semis += 1,
295 Some('b') => semis -= 1,
296 _ => break,
297 }
298 chars.next();
299 }
300 let rest: String = chars.collect();
301 let octave: i32 = if rest.is_empty() {
302 4
303 } else {
304 rest.parse().ok()?
305 };
306 // i64 headroom: huge octaves ("A200000000") would overflow i32 arithmetic.
307 midi_to_hz(((octave as i64 + 1) * 12 + semis as i64) as f32)
308}
309
310fn midi_to_hz(m: f32) -> Option<f32> {
311 let hz = 440.0 * 2f32.powf((m - 69.0) / 12.0);
312 // Reject pitches that would poison the render: non-finite or non-positive
313 // Hz turns oscillator phase accumulators to NaN, and anything far above
314 // the highest supported Nyquist (96 kHz at 192 kHz sr) is an authoring
315 // error, not a sound.
316 (hz.is_finite() && hz > 0.0 && hz <= 100_000.0).then_some(hz)
317}
318
319/// Interpolation curve for a [`Modulator::Slide`].
320#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
321#[serde(rename_all = "lowercase")]
322pub enum Curve {
323 /// Linear interpolation.
324 #[default]
325 Lin,
326 /// Exponential interpolation (perceptually natural for pitch/cutoff sweeps).
327 Exp,
328}
329
330/// Oscillator shape for an LFO modulator.
331#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
332#[serde(rename_all = "lowercase")]
333pub enum Shape {
334 /// Sine wave.
335 #[default]
336 Sine,
337 /// Square wave.
338 Square,
339 /// Triangle wave.
340 Triangle,
341 /// Sawtooth wave.
342 Saw,
343}
344
345/// A time-varying parameter value. Externally tagged: `{ "slide": {...} }`.
346#[non_exhaustive]
347#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
348pub enum Modulator {
349 /// Glide from `from` to `to` over `secs`, then hold at `to`.
350 #[serde(rename = "slide")]
351 Slide {
352 /// Start value.
353 from: f32,
354 /// End value.
355 to: f32,
356 /// Glide time in seconds.
357 secs: f32,
358 /// Interpolation curve.
359 #[serde(default)]
360 curve: Curve,
361 },
362 /// Low-frequency oscillation around `center` (vibrato / tremolo).
363 #[serde(rename = "lfo")]
364 Lfo {
365 /// Oscillator shape.
366 #[serde(default)]
367 shape: Shape,
368 /// Oscillation rate in Hz.
369 rate: f32,
370 /// Peak deviation from `center`.
371 depth: f32,
372 /// Mean value the LFO oscillates around.
373 center: f32,
374 },
375 /// Step through `steps` at `rate` steps/sec, looping (arpeggio / blip table).
376 #[serde(rename = "arp")]
377 Arp {
378 /// Sequence of values to cycle through.
379 steps: Vec<f32>,
380 /// Steps per second.
381 rate: f32,
382 },
383 /// An ADSR envelope mapped onto a parameter range: the value rides from
384 /// `from` (envelope = 0) to `to` (envelope = 1). This is the modulation
385 /// behind filter envelopes (cutoff `from` high `to` low), pitch envelopes,
386 /// and amplitude shaping of any param. The shape is time-based, not slide.
387 #[serde(rename = "env")]
388 EnvMod {
389 /// Envelope shape.
390 #[serde(flatten)]
391 adsr: Adsr,
392 /// Parameter value when the envelope is at 0.
393 from: f32,
394 /// Parameter value when the envelope is at 1.
395 to: f32,
396 },
397 /// Smooth random walk between `from` and `to`, drifting at `rate` new
398 /// targets per second (smoothstep-interpolated). The organic, NON-periodic
399 /// motion the other modulators lack — wind gusting on a filter cutoff,
400 /// fire flicker on a gain, drifting detune. Deterministic and edit-stable:
401 /// the walk is seeded only from this modulator's own fields, so it never
402 /// shifts when sibling nodes change. Give two `rand`s different `seed`s (or
403 /// rates) to decorrelate them.
404 #[serde(rename = "rand")]
405 Rand {
406 /// Lower bound of the walk.
407 from: f32,
408 /// Upper bound of the walk.
409 to: f32,
410 /// New random targets per second (low = slow drift, high = jittery).
411 rate: f32,
412 /// Decorrelation seed; defaults to 0. Distinct values give independent
413 /// walks for the same `from`/`to`/`rate`.
414 #[serde(default)]
415 seed: u64,
416 },
417}
418
419/// Waveshaper curve for [`Node::Drive`].
420#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
421#[serde(rename_all = "lowercase")]
422pub enum DriveShape {
423 /// Smooth `tanh` saturation (warm).
424 #[default]
425 Tanh,
426 /// Hard clipping (aggressive, square-ish).
427 Hard,
428 /// Wavefolding (bright, metallic harmonics).
429 Fold,
430}
431
432/// Spectral colour of a [`Node::Noise`] source.
433#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
434#[serde(rename_all = "lowercase")]
435pub enum NoiseColor {
436 /// Flat spectrum (bright, hissy).
437 #[default]
438 White,
439 /// −3 dB/octave (warm; wind, rumble, surf).
440 Pink,
441 /// −6 dB/octave (dark; distant booms, low rumble).
442 Brown,
443}
444
445/// Oscillator shape for a [`Node::Super`] unison oscillator.
446#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
447#[serde(rename_all = "lowercase")]
448pub enum SuperWave {
449 /// Sawtooth (the classic supersaw).
450 #[default]
451 Sawtooth,
452 /// Square / pulse.
453 Square,
454}
455
456/// Oscillator choice for a [`Node::Seq`] note.
457#[non_exhaustive]
458#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
459#[serde(rename_all = "lowercase")]
460pub enum SeqWave {
461 /// Square / pulse (uses the seq's `duty`).
462 #[default]
463 Square,
464 /// Triangle wave.
465 Triangle,
466 /// Sawtooth wave.
467 Sawtooth,
468 /// Sine wave.
469 Sine,
470 /// White noise (for drums / percussion).
471 Noise,
472 /// Two-operator FM struck per note (uses the seq's `fm_ratio` /
473 /// `fm_index` / `fm_strike`): the modulation index starts bright at the
474 /// attack and decays, like a hammer strike — e-piano, piano, bells,
475 /// mallets. Louder notes (higher `gain`) ring brighter.
476 Fm,
477 /// Karplus-Strong plucked string (uses the seq's `pluck_decay`): a noise
478 /// burst rings through a tuned feedback loop — guitar, harp, koto. Pitch
479 /// is fixed per note (slides are ignored).
480 Pluck,
481 /// Acoustic piano model: two detuned FM strings per note with a hammer
482 /// thump, velocity-sensitive brightness, and a natural pitch-dependent
483 /// decay (bass strings ring for seconds, treble dies fast) — no
484 /// parameters to set, play it like a piano. Set the seq env to
485 /// `{a:0.002, s:1, r:0.2}` and let the instrument shape each note;
486 /// `len` works like holding the key (with the pedal, longer).
487 Piano,
488 /// Electric piano (Rhodes-style): a soft FM body plus a bright metal
489 /// tine that pings on the attack and fades fast. Velocity opens the
490 /// tine — dig in for bark, play soft for bell-like warmth.
491 Epiano,
492 /// Tonewheel organ: drawbar harmonics (16′ 8′ 4′ 2⅔′ 2′) with a touch of
493 /// percussion on the attack. Sustains at full level while the key is
494 /// held — pair with env `{s:1}` and let `len` do the phrasing.
495 Organ,
496 /// String ensemble: three detuned band-limited saws per note with a slow
497 /// bow swell and a mellowing lowpass — pads, sustained chords, swells.
498 /// Notes bloom ~150 ms after the attack; write them slightly early.
499 Strings,
500 /// Fingered bass: a filtered saw whose cutoff snaps open with velocity
501 /// and settles, over a solid sine sub. Punchy, dark, sits under a mix.
502 Bass,
503 /// Drum kit on the General MIDI map — the note's pitch picks the drum,
504 /// not a frequency: `"midi:36"` kick, `38` snare, `42` closed hat,
505 /// `46` open hat, `41..50` toms, `49` crash, `51` ride, `39` clap,
506 /// `56` cowbell. Velocity (`gain`) sets the hit level.
507 Kit,
508 /// Pitched cowbell: two clashing saturated partials with a fast knock
509 /// decay — played melodically it is THE phonk / Memphis lead. More
510 /// cowbell.
511 Cowbell,
512 /// SoundFont sampler: plays the notes through real recorded instruments
513 /// from an `.sf2` file (set the seq's `sf2` path and `sf2_preset` — the
514 /// General MIDI program number, e.g. 0 grand piano, 32 acoustic bass,
515 /// 48 strings; `sf2_bank: 128` selects the percussion bank, where notes
516 /// follow the GM drum map). The biggest realism jump available: this is
517 /// how DAWs sound real.
518 Sampler,
519}
520
521/// Which drum-kit voicing the `kit` seq wave synthesizes. Every style follows
522/// the same General MIDI note map; they differ only in how each drum is
523/// synthesized. `Classic` is the original kit — omitting `kit` (or setting it to
524/// `classic`) renders byte-identically to before this field existed.
525#[non_exhaustive]
526#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
527#[serde(rename_all = "lowercase")]
528pub enum KitStyle {
529 /// The original synthesized GM kit.
530 #[default]
531 Classic,
532 /// A deeper, more realistic acoustic kit — punchier kick, tuned snare body,
533 /// ringier toms, shimmery cymbals.
534 Acoustic,
535 /// Clean synthesized electronic drums — tight, punchy, crisp.
536 Electronic,
537 /// Roland TR-808 style — a long booming sub kick, ringy cowbell, snappy
538 /// snare, tick-y percussion.
539 #[serde(rename = "808")]
540 Eight08,
541}
542
543/// An ADSR amplitude envelope. One shape, used in three places: the [`Node::Env`]
544/// amplitude envelope, the per-note envelope of a [`Node::Seq`], and (with a
545/// `from`/`to` range) the [`Modulator::EnvMod`] parameter envelope.
546#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
547pub struct Adsr {
548 /// Attack time in seconds.
549 #[serde(default)]
550 pub a: f32,
551 /// Decay time in seconds.
552 #[serde(default)]
553 pub d: f32,
554 /// Sustain level, 0..1.
555 #[serde(default)]
556 pub s: f32,
557 /// Release time in seconds.
558 #[serde(default)]
559 pub r: f32,
560 /// Initial transient boost, 0..1.
561 #[serde(default)]
562 pub punch: f32,
563}
564
565impl Adsr {
566 /// An envelope with the four classic stages (`punch` 0). Attack/decay/
567 /// release in seconds, sustain 0..1.
568 pub fn new(a: f32, d: f32, s: f32, r: f32) -> Self {
569 Adsr {
570 a,
571 d,
572 s,
573 r,
574 punch: 0.0,
575 }
576 }
577}
578
579/// One resonant mode of a [`Node::Modal`] bank: a single damped sinusoidal
580/// partial. A struck object's timbre is the set of these — their frequency
581/// ratios say "metal" vs "wood" vs "glass", their decays say how it rings.
582#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
583pub struct Mode {
584 /// Modal frequency in Hz.
585 pub freq: f32,
586 /// −60 dB ring time in seconds: how long this partial sustains after the
587 /// strike. Higher modes usually decay faster than the fundamental.
588 #[serde(default = "default_mode_decay")]
589 pub decay: f32,
590 /// Relative amplitude of this partial, 0..1.
591 #[serde(default = "default_gain")]
592 pub gain: f32,
593}
594
595/// One note in a [`Node::Seq`].
596#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
597pub struct SeqNote {
598 /// Grid step at which the note starts (0-based).
599 pub step: u32,
600 /// Note length in grid steps.
601 pub len: u32,
602 /// Pitch in Hz (a constant, or a modulator such as a `slide` for a glide /
603 /// pitched-drum thump). Ignored when the seq wave is `noise`.
604 pub pitch: Value,
605 /// Note velocity / level, 0..1.
606 #[serde(default = "default_gain")]
607 pub gain: f32,
608}
609
610impl SoundDoc {
611 /// The schema version this document's render semantics follow (omitted ⇒ 1).
612 pub fn effective_version(&self) -> u32 {
613 self.version.unwrap_or(1)
614 }
615
616 /// The DSP-kernel revision this document renders under (omitted ⇒ 0, the
617 /// original kernels). Gates byte-changing kernel upgrades so old documents
618 /// stay bit-exact; see [`ENGINE_VERSION`].
619 pub fn effective_engine(&self) -> u32 {
620 self.engine.unwrap_or(0)
621 }
622
623 /// Every SoundFont path the document references (each `seq` with
624 /// `wave: "sampler"` and a non-empty `sf2`). [`validate`](Self::validate)
625 /// is filesystem-free — the core is pure compute — so a *loader* (the CLI,
626 /// the Python bindings, a game's asset pipeline) calls this after
627 /// validation to check the files exist and fail loud at load time.
628 pub fn sf2_paths(&self) -> Vec<&str> {
629 fn walk<'doc>(node: &'doc Node, out: &mut Vec<&'doc str>) {
630 if let Node::Seq { wave, sf2, .. } = node
631 && *wave == SeqWave::Sampler
632 && !sf2.sf2.is_empty()
633 {
634 out.push(sf2.sf2.as_str());
635 }
636 node.children().for_each(|c| walk(c, out));
637 }
638 let mut out = Vec::new();
639 walk(&self.root, &mut out);
640 out
641 }
642
643 /// Backfill missing track ids deterministically (`layer_<position>`,
644 /// suffixed on collision with explicit ids). Runs at the build chokepoint
645 /// so every persisted mixer document carries addressable layers; the rule
646 /// is positional, so replaying a journal mints identical ids. Returns true
647 /// if anything changed.
648 pub fn ensure_track_ids(&mut self) -> bool {
649 let Node::Tracks { tracks, .. } = &mut self.root else {
650 return false;
651 };
652 let used: std::collections::HashSet<String> =
653 tracks.iter().filter_map(|t| t.id.clone()).collect();
654 let mut changed = false;
655 for (i, t) in tracks.iter_mut().enumerate() {
656 if t.id.is_none() {
657 let mut id = format!("layer_{i}");
658 let mut n = 2;
659 while used.contains(&id) {
660 id = format!("layer_{i}_{n}");
661 n += 1;
662 }
663 t.id = Some(id);
664 changed = true;
665 }
666 }
667 changed
668 }
669}