Skip to main content

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}