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}