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. An authoring
4//! tool creates one; 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::{AutoCurve, AutoLane, AutoPoint, AutoTarget, Bus, Send, Sidechain, 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.
49/// Revision 5 makes the render byte-identical ACROSS PLATFORMS (ADR 0001):
50/// every transcendental in the byte-pinned render path — oscillators,
51/// envelopes, filters, dynamics, pitch conversion, the loudness/normalize
52/// measurement — evaluates through the deterministic `crate::det` kernels
53/// instead of platform libm (whose last bits differ between macOS-arm64 and
54/// linux-x86_64), and `convolve` runs a fixed-order radix-2 FFT (twiddles
55/// from `det::sin`/`det::cos`, f64 throughout, both signals zero-padded to
56/// the next power of two ≥ input + IR − 1) instead of rustfft. Engine ≤ 4
57/// documents keep their historical per-platform renders bit-for-bit.
58pub const ENGINE_VERSION: u32 = 5;
59
60// Serde `default = "..."` requires free functions. Values with non-obvious
61// origins: haas 12 ms sits in the precedence-effect sweet spot, ceiling
62// −1 dBTP is the common streaming-safe true-peak ceiling.
63fn default_sample_rate() -> u32 {
64    44_100
65}
66fn default_duration() -> f32 {
67    0.3
68}
69fn default_gain() -> f32 {
70    1.0
71}
72fn default_haas_ms() -> f32 {
73    12.0
74}
75fn default_wide_amount() -> f32 {
76    0.6
77}
78fn default_ceiling_dbtp() -> f32 {
79    -1.0
80}
81fn default_crossfade() -> f32 {
82    0.1
83}
84fn default_mode_decay() -> f32 {
85    0.4
86}
87
88/// A complete sound: metadata plus a single root node.
89#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
90pub struct SoundDoc {
91    /// Human-readable label for the sound (e.g. `"laser_zap"`).
92    pub name: String,
93    /// Length of the rendered sound in seconds.
94    #[serde(default = "default_duration")]
95    pub duration: f32,
96    /// Output sample rate in Hz.
97    #[serde(default = "default_sample_rate")]
98    pub sample_rate: u32,
99    /// Seed for any stochastic node (noise). Same seed ⇒ identical audio.
100    #[serde(default)]
101    pub seed: u64,
102    /// DSL schema version. Omitted ⇒ 1, the semantics documents were authored
103    /// under before versioning mattered; the authoring tools stamp new
104    /// documents with the current [`SCHEMA_VERSION`]. Documents from a newer
105    /// tono are rejected by `validate` instead of silently misrendered.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub version: Option<u32>,
108    /// DSP-kernel revision (see [`ENGINE_VERSION`]). Omitted ⇒ 0, the original
109    /// kernels — so every existing document renders byte-for-byte as before.
110    /// The authoring tools stamp new documents with the current
111    /// [`ENGINE_VERSION`]; raising a document's `engine` opts it into newer,
112    /// higher-quality kernels (anti-aliased `drive`, …) and DOES change its
113    /// output. A document from a newer tono (engine > `ENGINE_VERSION`) is
114    /// rejected by `validate` rather than silently misrendered.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub engine: Option<u32>,
117    /// Optional stereo treatment applied to the final mono render. Defaults to
118    /// mono (game SFX are usually authored mono and spatialised by the engine;
119    /// use stereo for BGM, ambience, and UI stingers).
120    #[serde(default)]
121    pub stereo: Stereo,
122    /// Optional output-stage loudness normalization + true-peak limiting. When
123    /// set with `target_lufs`, the final render is gain-matched to that
124    /// integrated loudness, then brick-wall limited so the inter-sample (true)
125    /// peak never exceeds `ceiling_dbtp`. Leave unset for the default behaviour
126    /// (a transparent −0.1 dBFS sample-peak safety limit only). Use it to ship a
127    /// level-matched set: pick one target (e.g. −16 LUFS for SFX) for the pack.
128    #[serde(default)]
129    pub normalize: Option<Normalize>,
130    /// Playback intent. `oneshot` (default) renders the sound as-is. `loop`
131    /// extracts the loop region and equal-power crossfades its tail into its
132    /// head so the rendered file repeats seamlessly — the right mode for
133    /// ambience beds, engine drones, and BGM. The exported WAV carries a `smpl`
134    /// loop chunk so engines (Godot / Unity / FMOD) loop at the sample-accurate
135    /// points without manual setup.
136    #[serde(default)]
137    pub playback: Playback,
138    /// The signal graph. Usually a `mix`, `mul`, or `chain`.
139    pub root: Node,
140}
141
142impl SoundDoc {
143    /// A new document around `root`, stamped with the current
144    /// [`SCHEMA_VERSION`] and [`ENGINE_VERSION`] (this is the authoring
145    /// constructor — new sounds get the current kernels) and every other
146    /// field at its serde default: 0.3 s, 44 100 Hz, seed 0, mono, one-shot.
147    pub fn new(name: impl Into<String>, root: Node) -> Self {
148        SoundDoc {
149            name: name.into(),
150            duration: default_duration(),
151            sample_rate: default_sample_rate(),
152            seed: 0,
153            version: Some(SCHEMA_VERSION),
154            engine: Some(ENGINE_VERSION),
155            stereo: Stereo::default(),
156            normalize: None,
157            playback: Playback::default(),
158            root,
159        }
160    }
161}
162
163/// How the rendered sound is meant to be played back.
164#[non_exhaustive]
165#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
166#[serde(tag = "mode", rename_all = "lowercase")]
167pub enum Playback {
168    /// Play once (default).
169    #[default]
170    OneShot,
171    /// Seamless loop. The renderer extracts the region `[start_secs, end_secs)`
172    /// and crossfades its last `crossfade_secs` (equal-power) onto its head, so
173    /// the rendered buffer repeats with no click. The output is the loop body
174    /// (shorter than the source by the crossfade), and the WAV gets a `smpl`
175    /// loop spanning the whole file.
176    #[serde(rename = "loop")]
177    Loop {
178        /// Loop start in seconds (default 0).
179        #[serde(default)]
180        start_secs: f32,
181        /// Loop end in seconds (default: end of the rendered buffer).
182        #[serde(default)]
183        end_secs: Option<f32>,
184        /// Equal-power crossfade length in seconds (default 0.1). Longer hides
185        /// bigger discontinuities but shortens the loop more.
186        #[serde(default = "default_crossfade")]
187        crossfade_secs: f32,
188    },
189}
190
191/// Output-stage loudness normalization + true-peak limiting.
192#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
193pub struct Normalize {
194    /// Target integrated loudness in LUFS (e.g. −16 for SFX, −14 for music).
195    /// The render is gain-matched to hit this before limiting. Omit to skip
196    /// loudness matching and only apply the true-peak ceiling.
197    #[serde(default)]
198    pub target_lufs: Option<f32>,
199    /// True-peak ceiling in dBTP. The output is limited so its inter-sample peak
200    /// stays at or below this. Defaults to −1.0.
201    #[serde(default = "default_ceiling_dbtp")]
202    pub ceiling_dbtp: f32,
203}
204
205/// Stereo treatment for the final render.
206#[non_exhaustive]
207#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
208#[serde(tag = "mode", rename_all = "lowercase")]
209pub enum Stereo {
210    /// Mono — both channels identical (default).
211    #[default]
212    Mono,
213    /// Haas precedence widening: one channel delayed by `ms` ([0.5, 40], validated), shifting
214    /// the apparent position and adding width. `pan` (-1 left .. 1 right) sets
215    /// which side leads.
216    Haas {
217        /// Inter-channel delay in milliseconds.
218        #[serde(default = "default_haas_ms")]
219        ms: f32,
220        /// Lead side, −1 (left) .. 1 (right).
221        #[serde(default)]
222        pan: f32,
223    },
224    /// Pseudo-stereo: decorrelate the channels for width on pads / BGM.
225    Wide {
226        /// Width amount, 0 (mono) .. 1 (fully decorrelated).
227        #[serde(default = "default_wide_amount")]
228        amount: f32,
229    },
230}
231
232/// A numeric parameter that is either a constant or a time-varying modulator.
233#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
234#[serde(untagged)]
235pub enum Value {
236    /// A constant value (e.g. a fixed frequency in Hz).
237    Const(f32),
238    /// A musical pitch as a string: a note name like `"A4"`, `"C#3"`, `"Gb5"`,
239    /// `"F#-1"`, or a MIDI number like `"midi:69"` / `"m69"`. Resolves to Hz
240    /// (A4 = 440, 12-TET) — so melodies read musically instead of as raw Hz.
241    Note(String),
242    /// A modulator that produces a value per sample.
243    Modulated(Modulator),
244}
245
246impl From<f32> for Value {
247    /// A constant — `"freq": 440.0.into()`.
248    fn from(v: f32) -> Self {
249        Value::Const(v)
250    }
251}
252
253impl From<&str> for Value {
254    /// A note name (`"C4"`, `"F#3"`, `"midi:69"`) — resolved to Hz at render.
255    fn from(name: &str) -> Self {
256        Value::Note(name.to_string())
257    }
258}
259
260impl From<String> for Value {
261    /// A note name (see [`note_to_hz`]).
262    fn from(name: String) -> Self {
263        Value::Note(name)
264    }
265}
266
267impl From<Modulator> for Value {
268    fn from(m: Modulator) -> Self {
269        Value::Modulated(m)
270    }
271}
272
273/// Parse a musical pitch into Hz: a note name (`"A4"`, `"C#3"`, `"Gb5"`,
274/// `"F#-1"`; octave defaults to 4) or a MIDI number (`"midi:69"` / `"m69"`).
275/// A4 = 440 Hz, 12-tone equal temperament. Returns `None` if unparseable.
276///
277/// This is the engine-0 (platform-libm) conversion — the historical public
278/// behavior, kept for API compatibility. The render paths call the
279/// engine-aware variant with the document's engine so engine ≥ 5 documents
280/// convert through the deterministic kernels (ADR 0001).
281pub fn note_to_hz(s: &str) -> Option<f32> {
282    note_to_hz_e(s, 0)
283}
284
285/// [`note_to_hz`] at a given engine revision: engine ≥ 5 evaluates the final
286/// `2^((m−69)/12)` through `crate::det::powff` (cross-platform identical),
287/// below that through platform libm (bit-exact with every historical render).
288pub(crate) fn note_to_hz_e(s: &str, engine: u32) -> Option<f32> {
289    let s = s.trim();
290    if s.is_empty() {
291        return None;
292    }
293    // MIDI forms: "midi:69" or "m69".
294    if let Some(num) = s
295        .strip_prefix("midi:")
296        .or_else(|| s.strip_prefix(['m', 'M']))
297        && let Ok(n) = num.trim().parse::<f32>()
298    {
299        return midi_to_hz_e(n, engine);
300    }
301    // Note name: letter, optional #/b accidentals, optional octave (default 4).
302    let mut chars = s.chars().peekable();
303    let mut semis: i32 = match chars.next()?.to_ascii_uppercase() {
304        'C' => 0,
305        'D' => 2,
306        'E' => 4,
307        'F' => 5,
308        'G' => 7,
309        'A' => 9,
310        'B' => 11,
311        _ => return None,
312    };
313    loop {
314        match chars.peek() {
315            Some('#') => semis += 1,
316            Some('b') => semis -= 1,
317            _ => break,
318        }
319        chars.next();
320    }
321    let rest: String = chars.collect();
322    let octave: i32 = if rest.is_empty() {
323        4
324    } else {
325        rest.parse().ok()?
326    };
327    // i64 headroom: huge octaves ("A200000000") would overflow i32 arithmetic.
328    midi_to_hz_e(((octave as i64 + 1) * 12 + semis as i64) as f32, engine)
329}
330
331fn midi_to_hz_e(m: f32, engine: u32) -> Option<f32> {
332    let hz = 440.0 * crate::dsp::powf(2.0, (m - 69.0) / 12.0, engine);
333    // Reject pitches that would poison the render: non-finite or non-positive
334    // Hz turns oscillator phase accumulators to NaN, and anything far above
335    // the highest supported Nyquist (96 kHz at 192 kHz sr) is an authoring
336    // error, not a sound.
337    (hz.is_finite() && hz > 0.0 && hz <= 100_000.0).then_some(hz)
338}
339
340/// Interpolation curve for a [`Modulator::Slide`].
341#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
342#[serde(rename_all = "lowercase")]
343pub enum Curve {
344    /// Linear interpolation.
345    #[default]
346    Lin,
347    /// Exponential interpolation (perceptually natural for pitch/cutoff sweeps).
348    Exp,
349}
350
351/// Oscillator shape for an LFO modulator.
352#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
353#[serde(rename_all = "lowercase")]
354pub enum Shape {
355    /// Sine wave.
356    #[default]
357    Sine,
358    /// Square wave.
359    Square,
360    /// Triangle wave.
361    Triangle,
362    /// Sawtooth wave.
363    Saw,
364}
365
366/// A time-varying parameter value. Externally tagged: `{ "slide": {...} }`.
367#[non_exhaustive]
368#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
369pub enum Modulator {
370    /// Glide from `from` to `to` over `secs`, then hold at `to`.
371    #[serde(rename = "slide")]
372    Slide {
373        /// Start value.
374        from: f32,
375        /// End value.
376        to: f32,
377        /// Glide time in seconds.
378        secs: f32,
379        /// Interpolation curve.
380        #[serde(default)]
381        curve: Curve,
382    },
383    /// Low-frequency oscillation around `center` (vibrato / tremolo).
384    #[serde(rename = "lfo")]
385    Lfo {
386        /// Oscillator shape.
387        #[serde(default)]
388        shape: Shape,
389        /// Oscillation rate in Hz.
390        rate: f32,
391        /// Peak deviation from `center`.
392        depth: f32,
393        /// Mean value the LFO oscillates around.
394        center: f32,
395    },
396    /// Step through `steps` at `rate` steps/sec, looping (arpeggio / blip table).
397    #[serde(rename = "arp")]
398    Arp {
399        /// Sequence of values to cycle through.
400        steps: Vec<f32>,
401        /// Steps per second.
402        rate: f32,
403    },
404    /// An ADSR envelope mapped onto a parameter range: the value rides from
405    /// `from` (envelope = 0) to `to` (envelope = 1). This is the modulation
406    /// behind filter envelopes (cutoff `from` high `to` low), pitch envelopes,
407    /// and amplitude shaping of any param. The shape is time-based, not slide.
408    #[serde(rename = "env")]
409    EnvMod {
410        /// Envelope shape.
411        #[serde(flatten)]
412        adsr: Adsr,
413        /// Parameter value when the envelope is at 0.
414        from: f32,
415        /// Parameter value when the envelope is at 1.
416        to: f32,
417    },
418    /// Smooth random walk between `from` and `to`, drifting at `rate` new
419    /// targets per second (smoothstep-interpolated). The organic, NON-periodic
420    /// motion the other modulators lack — wind gusting on a filter cutoff,
421    /// fire flicker on a gain, drifting detune. Deterministic and edit-stable:
422    /// the walk is seeded only from this modulator's own fields, so it never
423    /// shifts when sibling nodes change. Give two `rand`s different `seed`s (or
424    /// rates) to decorrelate them.
425    #[serde(rename = "rand")]
426    Rand {
427        /// Lower bound of the walk.
428        from: f32,
429        /// Upper bound of the walk.
430        to: f32,
431        /// New random targets per second (low = slow drift, high = jittery).
432        rate: f32,
433        /// Decorrelation seed; defaults to 0. Distinct values give independent
434        /// walks for the same `from`/`to`/`rate`.
435        #[serde(default)]
436        seed: u64,
437    },
438}
439
440/// Waveshaper curve for [`Node::Drive`].
441#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
442#[serde(rename_all = "lowercase")]
443pub enum DriveShape {
444    /// Smooth `tanh` saturation (warm).
445    #[default]
446    Tanh,
447    /// Hard clipping (aggressive, square-ish).
448    Hard,
449    /// Wavefolding (bright, metallic harmonics).
450    Fold,
451}
452
453/// Spectral colour of a [`Node::Noise`] source.
454#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
455#[serde(rename_all = "lowercase")]
456pub enum NoiseColor {
457    /// Flat spectrum (bright, hissy).
458    #[default]
459    White,
460    /// −3 dB/octave (warm; wind, rumble, surf).
461    Pink,
462    /// −6 dB/octave (dark; distant booms, low rumble).
463    Brown,
464}
465
466/// Oscillator shape for a [`Node::Super`] unison oscillator.
467#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
468#[serde(rename_all = "lowercase")]
469pub enum SuperWave {
470    /// Sawtooth (the classic supersaw).
471    #[default]
472    Sawtooth,
473    /// Square / pulse.
474    Square,
475}
476
477/// Table set of a [`Node::Wavetable`] morphing oscillator: an ordered set of
478/// single-cycle waves that `position` (0..1) crossfades across. Each sub-wave
479/// is generated at node build time by additive synthesis, band-limited to 32
480/// partials (darker sub-waves use fewer), sampled into a 2048-sample table —
481/// zero assets, fully deterministic.
482#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
483#[serde(rename_all = "lowercase")]
484pub enum WavetableKind {
485    /// sine → triangle → square → saw: the classic dark-to-bright morph.
486    #[default]
487    Basic,
488    /// A saw that grows its harmonic count (1 → 2 → 4 → 8 → 16 → 32 partials):
489    /// a pure brightness ramp.
490    Harmonics,
491    /// Vowel-ish fixed formant stacks a → e → i → o → u (fundamental plus two
492    /// partials tuned to formant centres, voiced against a ~110 Hz reference).
493    /// Sweep `position` slowly for vocal morphs.
494    Formant,
495    /// Sparse, cluster-like partial stacks (missing fundamentals, wide gaps)
496    /// that read metallic / clangorous while staying perfectly periodic.
497    Metallic,
498}
499
500/// Oscillator choice for a [`Node::Seq`] note.
501#[non_exhaustive]
502#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
503#[serde(rename_all = "lowercase")]
504pub enum SeqWave {
505    /// Square / pulse (uses the seq's `duty`).
506    #[default]
507    Square,
508    /// Triangle wave.
509    Triangle,
510    /// Sawtooth wave.
511    Sawtooth,
512    /// Sine wave.
513    Sine,
514    /// White noise (for drums / percussion).
515    Noise,
516    /// Two-operator FM struck per note (uses the seq's `fm_ratio` /
517    /// `fm_index` / `fm_strike`): the modulation index starts bright at the
518    /// attack and decays, like a hammer strike — e-piano, piano, bells,
519    /// mallets. Louder notes (higher `gain`) ring brighter.
520    Fm,
521    /// Karplus-Strong plucked string (uses the seq's `pluck_decay`): a noise
522    /// burst rings through a tuned feedback loop — guitar, harp, koto. Pitch
523    /// is fixed per note (slides are ignored).
524    Pluck,
525    /// Acoustic piano model: two detuned FM strings per note with a hammer
526    /// thump, velocity-sensitive brightness, and a natural pitch-dependent
527    /// decay (bass strings ring for seconds, treble dies fast) — no
528    /// parameters to set, play it like a piano. Set the seq env to
529    /// `{a:0.002, s:1, r:0.2}` and let the instrument shape each note;
530    /// `len` works like holding the key (with the pedal, longer).
531    Piano,
532    /// Electric piano (Rhodes-style): a soft FM body plus a bright metal
533    /// tine that pings on the attack and fades fast. Velocity opens the
534    /// tine — dig in for bark, play soft for bell-like warmth.
535    Epiano,
536    /// Tonewheel organ: drawbar harmonics (16′ 8′ 4′ 2⅔′ 2′) with a touch of
537    /// percussion on the attack. Sustains at full level while the key is
538    /// held — pair with env `{s:1}` and let `len` do the phrasing.
539    Organ,
540    /// String ensemble: three detuned band-limited saws per note with a slow
541    /// bow swell and a mellowing lowpass — pads, sustained chords, swells.
542    /// Notes bloom ~150 ms after the attack; write them slightly early.
543    Strings,
544    /// Brass section: two detuned band-limited saws through a lowpass whose
545    /// cutoff swells open over the first ~70 ms — the "blat" of a horn
546    /// attack. Velocity (`gain`) opens the filter further: dig in for a
547    /// bright stab, play soft for a mellow swell. Sustains while held —
548    /// pair with env `{s:1}` and let `len` phrase.
549    Brass,
550    /// Concert flute: a sine with a vibrato (~5.5 Hz) that fades in over
551    /// the first ~150 ms, over a breath of lowpassed air noise. Velocity
552    /// (`gain`) adds breath and edge. Sustains while held — write long
553    /// notes and let `len` shape the phrase.
554    Flute,
555    /// Marimba-like mallet: a warm sine fundamental with two wooden strike
556    /// partials that die in tens of milliseconds — the "thok" of a mallet
557    /// hit. Velocity (`gain`) brightens the strike. Woodier and
558    /// shorter-lived than `epiano`; use a short env and space the notes.
559    Mallet,
560    /// Struck bell: inharmonic partials (1, 2.02, 2.74, 4.07, 5.43) with
561    /// per-partial decays — the highs die first, the hum rings on — plus a
562    /// slightly detuned twin of the fundamental whose slow beating is the
563    /// shimmer. Velocity (`gain`) scales the hit. Long natural ring: give
564    /// the notes room.
565    Bell,
566    /// Fingered bass: a filtered saw whose cutoff snaps open with velocity
567    /// and settles, over a solid sine sub. Punchy, dark, sits under a mix.
568    Bass,
569    /// Drum kit on the General MIDI map — the note's pitch picks the drum,
570    /// not a frequency: `"midi:36"` kick, `38` snare, `42` closed hat,
571    /// `46` open hat, `41..50` toms, `49` crash, `51` ride, `39` clap,
572    /// `56` cowbell. Velocity (`gain`) sets the hit level.
573    Kit,
574    /// Pitched cowbell: two clashing saturated partials with a fast knock
575    /// decay — played melodically it is THE phonk / Memphis lead. More
576    /// cowbell.
577    Cowbell,
578    /// SoundFont sampler: plays the notes through real recorded instruments
579    /// from an `.sf2` file (set the seq's `sf2` path and `sf2_preset` — the
580    /// General MIDI program number, e.g. 0 grand piano, 32 acoustic bass,
581    /// 48 strings; `sf2_bank: 128` selects the percussion bank, where notes
582    /// follow the GM drum map). The biggest realism jump available: this is
583    /// how DAWs sound real.
584    Sampler,
585}
586
587/// Which drum-kit voicing the `kit` seq wave synthesizes. Every style follows
588/// the same General MIDI note map; they differ only in how each drum is
589/// synthesized. `Classic` is the original kit — omitting `kit` (or setting it to
590/// `classic`) renders byte-identically to before this field existed.
591#[non_exhaustive]
592#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
593#[serde(rename_all = "lowercase")]
594pub enum KitStyle {
595    /// The original synthesized GM kit.
596    #[default]
597    Classic,
598    /// A deeper, more realistic acoustic kit — punchier kick, tuned snare body,
599    /// ringier toms, shimmery cymbals.
600    Acoustic,
601    /// Clean synthesized electronic drums — tight, punchy, crisp.
602    Electronic,
603    /// Roland TR-808 style — a long booming sub kick, ringy cowbell, snappy
604    /// snare, tick-y percussion.
605    #[serde(rename = "808")]
606    Eight08,
607}
608
609/// An ADSR amplitude envelope. One shape, used in three places: the [`Node::Env`]
610/// amplitude envelope, the per-note envelope of a [`Node::Seq`], and (with a
611/// `from`/`to` range) the [`Modulator::EnvMod`] parameter envelope.
612#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
613pub struct Adsr {
614    /// Attack time in seconds.
615    #[serde(default)]
616    pub a: f32,
617    /// Decay time in seconds.
618    #[serde(default)]
619    pub d: f32,
620    /// Sustain level, 0..1.
621    #[serde(default)]
622    pub s: f32,
623    /// Release time in seconds.
624    #[serde(default)]
625    pub r: f32,
626    /// Initial transient boost, 0..1.
627    #[serde(default)]
628    pub punch: f32,
629}
630
631impl Adsr {
632    /// An envelope with the four classic stages (`punch` 0). Attack/decay/
633    /// release in seconds, sustain 0..1.
634    pub fn new(a: f32, d: f32, s: f32, r: f32) -> Self {
635        Adsr {
636            a,
637            d,
638            s,
639            r,
640            punch: 0.0,
641        }
642    }
643}
644
645/// One resonant mode of a [`Node::Modal`] bank: a single damped sinusoidal
646/// partial. A struck object's timbre is the set of these — their frequency
647/// ratios say "metal" vs "wood" vs "glass", their decays say how it rings.
648#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
649pub struct Mode {
650    /// Modal frequency in Hz.
651    pub freq: f32,
652    /// −60 dB ring time in seconds: how long this partial sustains after the
653    /// strike. Higher modes usually decay faster than the fundamental.
654    #[serde(default = "default_mode_decay")]
655    pub decay: f32,
656    /// Relative amplitude of this partial, 0..1.
657    #[serde(default = "default_gain")]
658    pub gain: f32,
659}
660
661/// One note in a [`Node::Seq`].
662#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
663pub struct SeqNote {
664    /// Grid step at which the note starts (0-based).
665    pub step: u32,
666    /// Note length in grid steps.
667    pub len: u32,
668    /// Pitch in Hz (a constant, or a modulator such as a `slide` for a glide /
669    /// pitched-drum thump). Ignored when the seq wave is `noise`.
670    pub pitch: Value,
671    /// Note velocity / level, 0..1.
672    #[serde(default = "default_gain")]
673    pub gain: f32,
674}
675
676/// A tempo change at an exact beat position (ADR 0002): from `at` until the
677/// next change, the tempo is `bpm`. In a [`Node::Seq`]'s `tempo_map` the
678/// first point must sit at beat 0 — an empty map is the constant-tempo
679/// `bpm` behavior, byte-identical to before the map existed.
680#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq)]
681pub struct TempoPoint {
682    /// The exact beat the change takes effect at (a normalized rational —
683    /// tuplets and fractional bar lines stay exact).
684    pub at: crate::units::Beat,
685    /// The new tempo in beats per minute.
686    pub bpm: f32,
687}
688
689/// Seconds elapsed at `beat` under a tempo map — the segment walk in f64,
690/// the one conversion every tempo-aware path (renderer, compiler) shares
691/// (ADR 0002). Degenerate tempos floor at 1 BPM, like the seq's own clamp.
692/// The map must be non-empty and start at beat 0 (validation enforces both).
693pub fn tempo_map_seconds_at(map: &[TempoPoint], beat: f64) -> f64 {
694    let mut secs = 0.0;
695    let mut prev_beat = 0.0f64;
696    let mut bpm = (map[0].bpm as f64).max(1.0);
697    for p in &map[1..] {
698        let at = p.at.to_f64();
699        if beat <= at {
700            break;
701        }
702        secs += (at - prev_beat) * 60.0 / bpm;
703        prev_beat = at;
704        bpm = (p.bpm as f64).max(1.0);
705    }
706    secs + (beat - prev_beat) * 60.0 / bpm
707}
708
709/// The tempo in effect at `beat` under a tempo map (≥ 1 BPM, floored).
710pub fn tempo_map_bpm_at(map: &[TempoPoint], beat: f64) -> f64 {
711    let mut bpm = (map[0].bpm as f64).max(1.0);
712    for p in &map[1..] {
713        if beat < p.at.to_f64() {
714            break;
715        }
716        bpm = (p.bpm as f64).max(1.0);
717    }
718    bpm
719}
720
721/// The inverse of [`tempo_map_seconds_at`]: the beat position at `seconds`
722/// under the map — the segment walk in f64, exact per ADR 0002. The map must
723/// be non-empty and start at beat 0.
724pub fn tempo_map_beat_at_seconds(map: &[TempoPoint], seconds: f64) -> f64 {
725    let mut secs = 0.0;
726    let mut prev_beat = 0.0f64;
727    let mut bpm = (map[0].bpm as f64).max(1.0);
728    for p in &map[1..] {
729        let at = p.at.to_f64();
730        let span_secs = (at - prev_beat) * 60.0 / bpm;
731        if seconds < secs + span_secs {
732            return prev_beat + (seconds - secs) * bpm / 60.0;
733        }
734        secs += span_secs;
735        prev_beat = at;
736        bpm = (p.bpm as f64).max(1.0);
737    }
738    prev_beat + (seconds - secs) * bpm / 60.0
739}
740
741impl SoundDoc {
742    /// The schema version this document's render semantics follow (omitted ⇒ 1).
743    pub fn effective_version(&self) -> u32 {
744        self.version.unwrap_or(1)
745    }
746
747    /// The DSP-kernel revision this document renders under (omitted ⇒ 0, the
748    /// original kernels). Gates byte-changing kernel upgrades so old documents
749    /// stay bit-exact; see [`ENGINE_VERSION`].
750    pub fn effective_engine(&self) -> u32 {
751        self.engine.unwrap_or(0)
752    }
753
754    /// Every SoundFont path the document references (each `seq` with
755    /// `wave: "sampler"` and a non-empty `sf2`). [`validate`](Self::validate)
756    /// is filesystem-free — the core is pure compute — so a *loader* (the CLI,
757    /// the Python bindings, a game's asset pipeline) calls this after
758    /// validation to check the files exist and fail loud at load time.
759    pub fn sf2_paths(&self) -> Vec<&str> {
760        fn walk<'doc>(node: &'doc Node, out: &mut Vec<&'doc str>) {
761            if let Node::Seq { wave, sf2, .. } = node
762                && *wave == SeqWave::Sampler
763                && !sf2.sf2.is_empty()
764            {
765                out.push(sf2.sf2.as_str());
766            }
767            node.children().for_each(|c| walk(c, out));
768        }
769        let mut out = Vec::new();
770        walk(&self.root, &mut out);
771        out
772    }
773
774    /// Backfill missing track ids deterministically (`layer_<position>`,
775    /// suffixed on collision with explicit ids). Runs at the build chokepoint
776    /// so every persisted mixer document carries addressable layers; the rule
777    /// is positional, so replaying a journal mints identical ids. Returns true
778    /// if anything changed.
779    pub fn ensure_track_ids(&mut self) -> bool {
780        let Node::Tracks { tracks, .. } = &mut self.root else {
781            return false;
782        };
783        let used: std::collections::HashSet<String> =
784            tracks.iter().filter_map(|t| t.id.clone()).collect();
785        let mut changed = false;
786        for (i, t) in tracks.iter_mut().enumerate() {
787            if t.id.is_none() {
788                let mut id = format!("layer_{i}");
789                let mut n = 2;
790                while used.contains(&id) {
791                    id = format!("layer_{i}_{n}");
792                    n += 1;
793                }
794                t.id = Some(id);
795                changed = true;
796            }
797        }
798        changed
799    }
800}