use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: u32 = 2;
pub const ENGINE_VERSION: u32 = 3;
fn default_sample_rate() -> u32 {
44_100
}
fn default_duration() -> f32 {
0.3
}
fn default_duty() -> Value {
Value::Const(0.5)
}
fn default_q() -> f32 {
0.707
}
fn default_gain() -> f32 {
1.0
}
fn default_steps_per_beat() -> u32 {
4
}
fn default_mod_depth() -> f32 {
0.5
}
fn default_mod_mix() -> f32 {
0.5
}
fn default_chorus_rate() -> f32 {
1.5
}
fn default_flanger_rate() -> f32 {
0.25
}
fn default_flanger_feedback() -> f32 {
0.5
}
fn default_phaser_rate() -> f32 {
0.4
}
fn default_phaser_feedback() -> f32 {
0.3
}
fn default_comp_attack() -> f32 {
0.005
}
fn default_comp_release() -> f32 {
0.08
}
fn default_haas_ms() -> f32 {
12.0
}
fn default_wide_amount() -> f32 {
0.6
}
fn default_ceiling_dbtp() -> f32 {
-1.0
}
fn default_crossfade() -> f32 {
0.1
}
fn default_voices() -> u32 {
7
}
fn default_detune() -> f32 {
15.0
}
fn default_seq_fm_ratio() -> f32 {
1.0
}
fn default_seq_fm_index() -> f32 {
5.0
}
fn default_seq_fm_strike() -> f32 {
0.2
}
fn default_pluck_decay() -> f32 {
0.996
}
fn default_pluck_body() -> f32 {
0.0
}
fn default_pluck_pick() -> f32 {
0.0
}
fn default_pluck_tone() -> f32 {
0.0
}
fn default_piano_hammer() -> f32 {
1.0
}
fn default_piano_strike() -> f32 {
0.125
}
fn default_piano_inharm() -> f32 {
1.0
}
fn default_piano_detune() -> f32 {
1.0
}
fn default_piano_decay() -> f32 {
1.0
}
fn default_bass_cutoff() -> f32 {
250.0
}
fn default_bass_env() -> f32 {
700.0
}
fn default_bass_env_vel() -> f32 {
1100.0
}
fn default_bass_decay() -> f32 {
0.15
}
fn default_bass_click() -> f32 {
0.0
}
fn default_bass_body() -> f32 {
0.7
}
fn default_bass_sub() -> f32 {
0.45
}
fn default_bass_sub_ratio() -> f32 {
1.0
}
fn default_bass_drive() -> f32 {
0.0
}
fn default_bass_body_decay() -> f32 {
2.0
}
fn default_duck_amount() -> f32 {
0.8
}
fn default_duck_attack() -> f32 {
0.005
}
fn default_duck_release() -> f32 {
0.25
}
fn default_mode_decay() -> f32 {
0.4
}
fn default_modal_mix() -> f32 {
1.0
}
fn default_impact_hardness() -> f32 {
0.5
}
fn default_dust_decay() -> f32 {
0.02
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SoundDoc {
pub name: String,
#[serde(default = "default_duration")]
pub duration: f32,
#[serde(default = "default_sample_rate")]
pub sample_rate: u32,
#[serde(default)]
pub seed: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engine: Option<u32>,
#[serde(default)]
pub stereo: Stereo,
#[serde(default)]
pub normalize: Option<Normalize>,
#[serde(default)]
pub playback: Playback,
pub root: Node,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "mode", rename_all = "lowercase")]
pub enum Playback {
#[default]
OneShot,
#[serde(rename = "loop")]
Loop {
#[serde(default)]
start_secs: f32,
#[serde(default)]
end_secs: Option<f32>,
#[serde(default = "default_crossfade")]
crossfade_secs: f32,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
pub struct Normalize {
#[serde(default)]
pub target_lufs: Option<f32>,
#[serde(default = "default_ceiling_dbtp")]
pub ceiling_dbtp: f32,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "mode", rename_all = "lowercase")]
pub enum Stereo {
#[default]
Mono,
Haas {
#[serde(default = "default_haas_ms")]
ms: f32,
#[serde(default)]
pan: f32,
},
Wide {
#[serde(default = "default_wide_amount")]
amount: f32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum Value {
Const(f32),
Note(String),
Modulated(Modulator),
}
pub fn note_to_hz(s: &str) -> Option<f32> {
let s = s.trim();
if s.is_empty() {
return None;
}
if let Some(num) = s
.strip_prefix("midi:")
.or_else(|| s.strip_prefix(['m', 'M']))
&& let Ok(n) = num.trim().parse::<f32>()
{
return Some(midi_to_hz(n));
}
let mut chars = s.chars().peekable();
let mut semis: i32 = match chars.next()?.to_ascii_uppercase() {
'C' => 0,
'D' => 2,
'E' => 4,
'F' => 5,
'G' => 7,
'A' => 9,
'B' => 11,
_ => return None,
};
loop {
match chars.peek() {
Some('#') => semis += 1,
Some('b') => semis -= 1,
_ => break,
}
chars.next();
}
let rest: String = chars.collect();
let octave: i32 = if rest.is_empty() {
4
} else {
rest.parse().ok()?
};
Some(midi_to_hz(((octave + 1) * 12 + semis) as f32))
}
fn midi_to_hz(m: f32) -> f32 {
440.0 * 2f32.powf((m - 69.0) / 12.0)
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Curve {
#[default]
Lin,
Exp,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Shape {
#[default]
Sine,
Square,
Triangle,
Saw,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub enum Modulator {
#[serde(rename = "slide")]
Slide {
from: f32,
to: f32,
secs: f32,
#[serde(default)]
curve: Curve,
},
#[serde(rename = "lfo")]
Lfo {
#[serde(default)]
shape: Shape,
rate: f32,
depth: f32,
center: f32,
},
#[serde(rename = "arp")]
Arp {
steps: Vec<f32>,
rate: f32,
},
#[serde(rename = "env")]
EnvMod {
#[serde(flatten)]
adsr: Adsr,
from: f32,
to: f32,
},
#[serde(rename = "rand")]
Rand {
from: f32,
to: f32,
rate: f32,
#[serde(default)]
seed: u64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Node {
Square {
freq: Value,
#[serde(default = "default_duty")]
duty: Value,
},
Triangle {
freq: Value,
},
Sawtooth {
freq: Value,
},
Sine {
freq: Value,
},
Noise {
#[serde(default)]
color: NoiseColor,
},
Fm {
freq: Value,
ratio: f32,
index: Value,
},
Super {
#[serde(default)]
wave: SuperWave,
freq: Value,
#[serde(default = "default_voices")]
voices: u32,
#[serde(default = "default_detune")]
detune_cents: f32,
},
Seq {
bpm: f32,
#[serde(default = "default_steps_per_beat")]
steps_per_beat: u32,
wave: SeqWave,
#[serde(default = "default_duty")]
duty: Value,
#[serde(default = "default_seq_fm_ratio")]
fm_ratio: f32,
#[serde(default = "default_seq_fm_index")]
fm_index: f32,
#[serde(default = "default_seq_fm_strike")]
fm_strike: f32,
#[serde(default = "default_pluck_decay")]
pluck_decay: f32,
#[serde(default = "default_pluck_body")]
pluck_body: f32,
#[serde(default = "default_pluck_pick")]
pluck_pick: f32,
#[serde(default = "default_pluck_tone")]
pluck_tone: f32,
#[serde(default = "default_piano_hammer")]
piano_hammer: f32,
#[serde(default = "default_piano_strike")]
piano_strike: f32,
#[serde(default = "default_piano_inharm")]
piano_inharm: f32,
#[serde(default = "default_piano_detune")]
piano_detune: f32,
#[serde(default = "default_piano_decay")]
piano_decay: f32,
#[serde(default)]
kit: KitStyle,
#[serde(default = "default_bass_cutoff")]
bass_cutoff: f32,
#[serde(default = "default_bass_env")]
bass_env: f32,
#[serde(default = "default_bass_env_vel")]
bass_env_vel: f32,
#[serde(default = "default_bass_decay")]
bass_decay: f32,
#[serde(default = "default_bass_click")]
bass_click: f32,
#[serde(default = "default_bass_body")]
bass_body: f32,
#[serde(default = "default_bass_sub")]
bass_sub: f32,
#[serde(default = "default_bass_sub_ratio")]
bass_sub_ratio: f32,
#[serde(default = "default_bass_drive")]
bass_drive: f32,
#[serde(default = "default_bass_body_decay")]
bass_body_decay: f32,
#[serde(default)]
sf2: String,
#[serde(default)]
sf2_preset: u32,
#[serde(default)]
sf2_bank: u32,
#[serde(default)]
swing: f32,
#[serde(default)]
humanize: f32,
env: Adsr,
notes: Vec<SeqNote>,
},
Impact {
#[serde(default = "default_impact_hardness")]
hardness: f32,
#[serde(default = "default_gain")]
velocity: f32,
},
Dust {
density: f32,
#[serde(default = "default_dust_decay")]
decay: f32,
},
Env {
#[serde(flatten)]
adsr: Adsr,
},
Tracks {
tracks: Vec<Track>,
#[serde(default)]
master: Vec<Node>,
},
Mix {
inputs: Vec<Node>,
},
Mul {
inputs: Vec<Node>,
},
Chain {
stages: Vec<Node>,
},
Lowpass {
cutoff: Value,
#[serde(default = "default_q")]
q: f32,
},
Highpass {
cutoff: Value,
#[serde(default = "default_q")]
q: f32,
},
Bandpass {
cutoff: Value,
#[serde(default = "default_q")]
q: f32,
},
Notch {
cutoff: Value,
#[serde(default = "default_q")]
q: f32,
},
Peak {
cutoff: Value,
#[serde(default = "default_q")]
q: f32,
#[serde(default)]
gain_db: f32,
},
Lowshelf {
cutoff: Value,
#[serde(default)]
gain_db: f32,
},
Highshelf {
cutoff: Value,
#[serde(default)]
gain_db: f32,
},
Gain {
amount: Value,
},
Bitcrush {
bits: u8,
},
Downsample {
factor: u32,
},
Delay {
secs: f32,
#[serde(default)]
feedback: f32,
},
Reverb {
#[serde(default)]
room: f32,
#[serde(default)]
mix: f32,
},
Modal {
modes: Vec<Mode>,
#[serde(default = "default_modal_mix")]
mix: f32,
},
Drive {
amount: Value,
#[serde(default)]
shape: DriveShape,
#[serde(default, skip_serializing_if = "Option::is_none")]
aa: Option<bool>,
},
RingMod {
freq: Value,
},
Flanger {
#[serde(default = "default_flanger_rate")]
rate: f32,
#[serde(default = "default_mod_depth")]
depth: f32,
#[serde(default = "default_flanger_feedback")]
feedback: f32,
#[serde(default = "default_mod_mix")]
mix: f32,
},
Phaser {
#[serde(default = "default_phaser_rate")]
rate: f32,
#[serde(default = "default_mod_depth")]
depth: f32,
#[serde(default = "default_phaser_feedback")]
feedback: f32,
#[serde(default = "default_mod_mix")]
mix: f32,
},
Chorus {
#[serde(default = "default_chorus_rate")]
rate: f32,
#[serde(default = "default_mod_depth")]
depth: f32,
#[serde(default = "default_mod_mix")]
mix: f32,
},
Duck {
trigger: Box<Node>,
#[serde(default = "default_duck_amount")]
amount: f32,
#[serde(default = "default_duck_attack")]
attack: f32,
#[serde(default = "default_duck_release")]
release: f32,
},
Compress {
threshold: f32,
ratio: f32,
#[serde(default = "default_comp_attack")]
attack: f32,
#[serde(default = "default_comp_release")]
release: f32,
#[serde(default)]
makeup: f32,
},
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum DriveShape {
#[default]
Tanh,
Hard,
Fold,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum NoiseColor {
#[default]
White,
Pink,
Brown,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum SuperWave {
#[default]
Sawtooth,
Square,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum SeqWave {
#[default]
Square,
Triangle,
Sawtooth,
Sine,
Noise,
Fm,
Pluck,
Piano,
Epiano,
Organ,
Strings,
Bass,
Kit,
Cowbell,
Sampler,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum KitStyle {
#[default]
Classic,
Acoustic,
Electronic,
#[serde(rename = "808")]
Eight08,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Adsr {
#[serde(default)]
pub a: f32,
#[serde(default)]
pub d: f32,
#[serde(default)]
pub s: f32,
#[serde(default)]
pub r: f32,
#[serde(default)]
pub punch: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Track {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub node: Node,
#[serde(default)]
pub pan: f32,
#[serde(default = "default_gain")]
pub gain: f32,
#[serde(default)]
pub at: f32,
#[serde(default)]
pub mute: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub automation: Vec<AutoLane>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum AutoTarget {
Gain,
Pan,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AutoPoint {
pub t: f32,
pub v: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AutoLane {
pub target: AutoTarget,
pub points: Vec<AutoPoint>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Mode {
pub freq: f32,
#[serde(default = "default_mode_decay")]
pub decay: f32,
#[serde(default = "default_gain")]
pub gain: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SeqNote {
pub step: u32,
pub len: u32,
pub pitch: Value,
#[serde(default = "default_gain")]
pub gain: f32,
}
impl Node {
pub fn is_processor(&self) -> bool {
matches!(
self,
Node::Lowpass { .. }
| Node::Highpass { .. }
| Node::Bandpass { .. }
| Node::Notch { .. }
| Node::Peak { .. }
| Node::Lowshelf { .. }
| Node::Highshelf { .. }
| Node::Gain { .. }
| Node::Bitcrush { .. }
| Node::Downsample { .. }
| Node::Delay { .. }
| Node::Reverb { .. }
| Node::Modal { .. }
| Node::Drive { .. }
| Node::RingMod { .. }
| Node::Chorus { .. }
| Node::Flanger { .. }
| Node::Phaser { .. }
| Node::Compress { .. }
| Node::Duck { .. }
)
}
}
impl Adsr {
fn validate(&self, what: &str) -> Result<(), String> {
for (n, v) in [("a", self.a), ("d", self.d), ("r", self.r)] {
if v < 0.0 {
return Err(format!("{what}.{n} must be >= 0, got {v}"));
}
}
in_unit(&format!("{what}.s"), self.s)?;
in_unit(&format!("{what}.punch"), self.punch)
}
}
impl SoundDoc {
pub fn effective_version(&self) -> u32 {
self.version.unwrap_or(1)
}
pub fn effective_engine(&self) -> u32 {
self.engine.unwrap_or(0)
}
pub fn ensure_track_ids(&mut self) -> bool {
let Node::Tracks { tracks, .. } = &mut self.root else {
return false;
};
let used: std::collections::HashSet<String> =
tracks.iter().filter_map(|t| t.id.clone()).collect();
let mut changed = false;
for (i, t) in tracks.iter_mut().enumerate() {
if t.id.is_none() {
let mut id = format!("layer_{i}");
let mut n = 2;
while used.contains(&id) {
id = format!("layer_{i}_{n}");
n += 1;
}
t.id = Some(id);
changed = true;
}
}
changed
}
pub fn validate(&self) -> Result<(), String> {
let v = self.effective_version();
if v == 0 || v > SCHEMA_VERSION {
return Err(format!(
"version must be in [1, {SCHEMA_VERSION}], got {v} — a document from a newer \
tono cannot render correctly here; upgrade tono"
));
}
let e = self.effective_engine();
if e > ENGINE_VERSION {
return Err(format!(
"engine must be in [0, {ENGINE_VERSION}], got {e} — a document authored against \
a newer DSP kernel cannot render correctly here; upgrade tono"
));
}
if !(self.duration > 0.0 && self.duration <= 600.0) {
return Err(format!(
"duration must be in (0, 600] seconds, got {}",
self.duration
));
}
if !(8_000..=192_000).contains(&self.sample_rate) {
return Err(format!(
"sample_rate must be in [8000, 192000] Hz, got {}",
self.sample_rate
));
}
match self.stereo {
Stereo::Mono => {}
Stereo::Haas { ms, pan } => {
if !(0.5..=40.0).contains(&ms) {
return Err(format!("stereo.haas.ms must be in [0.5, 40], got {ms}"));
}
if !(-1.0..=1.0).contains(&pan) {
return Err(format!("stereo.haas.pan must be in [-1, 1], got {pan}"));
}
}
Stereo::Wide { amount } => in_unit("stereo.wide.amount", amount)?,
}
if let Some(nz) = &self.normalize {
if let Some(t) = nz.target_lufs
&& !(-60.0..=0.0).contains(&t)
{
return Err(format!(
"normalize.target_lufs must be in [-60, 0] LUFS, got {t}"
));
}
if !(-12.0..=0.0).contains(&nz.ceiling_dbtp) {
return Err(format!(
"normalize.ceiling_dbtp must be in [-12, 0] dBTP, got {}",
nz.ceiling_dbtp
));
}
}
if let Playback::Loop {
start_secs,
end_secs,
crossfade_secs,
} = self.playback
{
if start_secs < 0.0 || start_secs >= self.duration {
return Err(format!(
"playback.loop.start_secs must be in [0, duration), got {start_secs}"
));
}
if let Some(end) = end_secs {
if end <= start_secs {
return Err(format!(
"playback.loop.end_secs ({end}) must be > start_secs ({start_secs})"
));
}
if end > self.duration {
return Err(format!(
"playback.loop.end_secs ({end}) must be <= duration ({})",
self.duration
));
}
}
if crossfade_secs < 0.0 {
return Err(format!(
"playback.loop.crossfade_secs must be >= 0, got {crossfade_secs}"
));
}
}
if let Node::Tracks { tracks, master } = &self.root {
if tracks.is_empty() {
return Err("tracks must be non-empty".into());
}
if self.effective_version() >= 2 && !matches!(self.stereo, Stereo::Mono) {
return Err(
"a tracks document builds its stereo image from per-layer pan — remove the \
doc-level stereo treatment (set stereo mode 'mono') and pan the layers \
instead"
.into(),
);
}
let mut seen_ids = std::collections::HashSet::new();
let mut seen_streams = std::collections::HashMap::new();
for (i, t) in tracks.iter().enumerate() {
let who = match &t.id {
Some(id) => format!("layer '{id}'"),
None => format!("tracks[{i}]"),
};
if let Some(id) = &t.id {
if id.is_empty()
|| !id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
return Err(format!(
"{who}: layer ids are short slugs (a-z, 0-9, _), got '{id}'"
));
}
if id == "master" {
return Err(
"'master' is reserved for the master chain; pick another layer id"
.into(),
);
}
if !seen_ids.insert(id.clone()) {
return Err(format!("duplicate layer id '{id}' — ids must be unique"));
}
let key = crate::dsp::layer_stream_key(id);
if key == u64::MAX {
return Err(format!(
"{who}: this id collides with the master bus's RNG stream — rename \
the layer"
));
}
if let Some(other) = seen_streams.insert(key, id.clone()) {
return Err(format!(
"layer ids '{other}' and '{id}' hash to the same RNG stream — \
rename one of them"
));
}
}
if !(-1.0..=1.0).contains(&t.pan) {
return Err(format!("{who}: pan must be in [-1, 1], got {}", t.pan));
}
if !(0.0..=2.0).contains(&t.gain) {
return Err(format!("{who}: gain must be in [0, 2], got {}", t.gain));
}
if !(0.0..self.duration).contains(&t.at) {
return Err(format!(
"{who}: at must be in [0, duration {}), got {} — the layer would be \
entirely outside the render window",
self.duration, t.at
));
}
if contains_tracks(&t.node) {
return Err("tracks cannot nest inside a track".into());
}
validate_node(&t.node)?;
}
for (i, m) in master.iter().enumerate() {
if !m.is_processor() {
return Err(format!(
"master[{i}] must be a processor (filter/eq/dynamics/fx)"
));
}
validate_node(m)?;
}
return Ok(());
}
if contains_tracks(&self.root) {
return Err("tracks is the mixing console: it must be the document's root node".into());
}
validate_node(&self.root)
}
}
fn contains_tracks(node: &Node) -> bool {
match node {
Node::Tracks { .. } => true,
Node::Mix { inputs } | Node::Mul { inputs } => inputs.iter().any(contains_tracks),
Node::Chain { stages } => stages.iter().any(contains_tracks),
Node::Duck { trigger, .. } => contains_tracks(trigger),
_ => false,
}
}
fn validate_value(v: &Value, what: &str) -> Result<(), String> {
match v {
Value::Const(_) => Ok(()),
Value::Note(s) => note_to_hz(s).map(|_| ()).ok_or_else(|| {
format!("{what}: '{s}' is not a valid note (e.g. \"A4\", \"C#3\", \"midi:69\")")
}),
Value::Modulated(m) => match m {
Modulator::Slide { secs, .. } => {
if *secs <= 0.0 {
return Err(format!("{what}: slide.secs must be > 0, got {secs}"));
}
Ok(())
}
Modulator::Lfo { rate, .. } => {
if *rate <= 0.0 {
return Err(format!("{what}: lfo.rate must be > 0, got {rate}"));
}
Ok(())
}
Modulator::Arp { steps, rate } => {
if steps.is_empty() {
return Err(format!("{what}: arp.steps must be non-empty"));
}
if *rate <= 0.0 {
return Err(format!("{what}: arp.rate must be > 0, got {rate}"));
}
Ok(())
}
Modulator::EnvMod { adsr, .. } => adsr.validate(&format!("{what}: env")),
Modulator::Rand { rate, .. } => {
if *rate <= 0.0 {
return Err(format!("{what}: rand.rate must be > 0, got {rate}"));
}
Ok(())
}
},
}
}
fn in_unit(name: &str, v: f32) -> Result<(), String> {
if !(0.0..=1.0).contains(&v) {
return Err(format!("{name} must be in [0, 1], got {v}"));
}
Ok(())
}
fn validate_gain_db(name: &str, v: f32) -> Result<(), String> {
if !(-24.0..=24.0).contains(&v) {
return Err(format!("{name} must be in [-24, 24] dB, got {v}"));
}
Ok(())
}
fn validate_unit_value(v: &Value, what: &str) -> Result<(), String> {
if let Value::Const(c) = v {
in_unit(what, *c)?;
}
validate_value(v, what)
}
fn validate_node(node: &Node) -> Result<(), String> {
match node {
Node::Square { freq, duty } => {
validate_value(freq, "square.freq")?;
validate_unit_value(duty, "square.duty")
}
Node::Triangle { freq } => validate_value(freq, "triangle.freq"),
Node::Sawtooth { freq } => validate_value(freq, "sawtooth.freq"),
Node::Sine { freq } => validate_value(freq, "sine.freq"),
Node::Noise { .. } => Ok(()),
Node::Impact { hardness, velocity } => {
in_unit("impact.hardness", *hardness)?;
in_unit("impact.velocity", *velocity)
}
Node::Dust { density, decay } => {
if *density <= 0.0 {
return Err(format!("dust.density must be > 0, got {density}"));
}
if *decay < 0.0 {
return Err(format!("dust.decay must be >= 0, got {decay}"));
}
Ok(())
}
Node::Fm { freq, ratio, index } => {
validate_value(freq, "fm.freq")?;
if *ratio <= 0.0 {
return Err(format!("fm.ratio must be > 0, got {ratio}"));
}
validate_value(index, "fm.index")
}
Node::Seq {
bpm,
steps_per_beat,
duty,
fm_ratio,
fm_index,
fm_strike,
pluck_decay,
sf2,
sf2_preset,
wave,
swing,
humanize,
env,
notes,
..
} => {
if *bpm <= 0.0 {
return Err(format!("seq.bpm must be > 0, got {bpm}"));
}
if *steps_per_beat < 1 {
return Err("seq.steps_per_beat must be >= 1".into());
}
if notes.is_empty() {
return Err("seq.notes must be non-empty".into());
}
validate_unit_value(duty, "seq.duty")?;
if *fm_ratio <= 0.0 {
return Err(format!("seq.fm_ratio must be > 0, got {fm_ratio}"));
}
if !(0.0..=20.0).contains(fm_index) {
return Err(format!("seq.fm_index must be in [0, 20], got {fm_index}"));
}
if *fm_strike <= 0.0 {
return Err(format!("seq.fm_strike must be > 0, got {fm_strike}"));
}
if !(0.8..1.0).contains(pluck_decay) {
return Err(format!(
"seq.pluck_decay must be in [0.8, 1), got {pluck_decay}"
));
}
in_unit("seq.swing", *swing)?;
in_unit("seq.humanize", *humanize)?;
if *wave == SeqWave::Sampler {
if sf2.is_empty() {
return Err(
"seq.sf2 must point at a SoundFont (.sf2) file when wave is 'sampler'"
.into(),
);
}
if !std::path::Path::new(sf2).exists() {
return Err(format!("seq.sf2: no such file '{sf2}'"));
}
if *sf2_preset > 127 {
return Err(format!(
"seq.sf2_preset must be in 0..=127, got {sf2_preset}"
));
}
}
env.validate("seq.env")?;
for note in notes {
if note.len < 1 {
return Err("seq note.len must be >= 1".into());
}
in_unit("seq note.gain", note.gain)?;
validate_value(¬e.pitch, "seq note.pitch")?;
}
Ok(())
}
Node::Env { adsr } => {
adsr.validate("env")?;
if adsr.a == 0.0 && adsr.d == 0.0 && adsr.s == 0.0 && adsr.r == 0.0 {
return Err("env is silent — a/d/s/r are all 0. The envelope fields \
are inlined on the node (e.g. {\"type\":\"env\",\"a\":0.01,\"d\":0.1,\
\"s\":0.7,\"r\":0.2}); don't nest them under \"adsr\""
.into());
}
Ok(())
}
Node::Tracks { .. } => Err("tracks must be the document's root node".into()),
Node::Mix { inputs } | Node::Mul { inputs } => {
if inputs.is_empty() {
return Err("mix/mul requires at least one input".into());
}
inputs.iter().try_for_each(validate_node)
}
Node::Chain { stages } => {
if stages.is_empty() {
return Err("chain requires at least one stage".into());
}
stages.iter().try_for_each(validate_node)
}
Node::Lowpass { cutoff, q }
| Node::Highpass { cutoff, q }
| Node::Bandpass { cutoff, q }
| Node::Notch { cutoff, q } => {
validate_value(cutoff, "filter.cutoff")?;
if *q <= 0.0 {
return Err(format!("filter.q must be > 0, got {q}"));
}
Ok(())
}
Node::Peak { cutoff, q, gain_db } => {
validate_value(cutoff, "peak.cutoff")?;
if *q <= 0.0 {
return Err(format!("peak.q must be > 0, got {q}"));
}
validate_gain_db("peak.gain_db", *gain_db)
}
Node::Lowshelf { cutoff, gain_db } | Node::Highshelf { cutoff, gain_db } => {
validate_value(cutoff, "shelf.cutoff")?;
validate_gain_db("shelf.gain_db", *gain_db)
}
Node::Super {
freq,
voices,
detune_cents,
..
} => {
validate_value(freq, "super.freq")?;
if !(1..=16).contains(voices) {
return Err(format!("super.voices must be in [1, 16], got {voices}"));
}
if *detune_cents < 0.0 {
return Err(format!(
"super.detune_cents must be >= 0, got {detune_cents}"
));
}
Ok(())
}
Node::Gain { amount } => validate_value(amount, "gain.amount"),
Node::Bitcrush { bits } => {
if !(1..=16).contains(bits) {
return Err(format!("bitcrush.bits must be in [1, 16], got {bits}"));
}
Ok(())
}
Node::Downsample { factor } => {
if *factor < 1 {
return Err("downsample.factor must be >= 1".into());
}
Ok(())
}
Node::Delay { secs, feedback } => {
if *secs <= 0.0 {
return Err(format!("delay.secs must be > 0, got {secs}"));
}
in_unit("delay.feedback", *feedback)
}
Node::Reverb { room, mix } => {
in_unit("reverb.room", *room)?;
in_unit("reverb.mix", *mix)
}
Node::Modal { modes, mix } => {
if modes.is_empty() {
return Err("modal.modes must be non-empty".into());
}
if modes.len() > 64 {
return Err(format!(
"modal.modes must have at most 64 modes, got {}",
modes.len()
));
}
for (i, m) in modes.iter().enumerate() {
if m.freq <= 0.0 {
return Err(format!("modal.modes[{i}].freq must be > 0, got {}", m.freq));
}
if m.decay <= 0.0 {
return Err(format!(
"modal.modes[{i}].decay must be > 0, got {}",
m.decay
));
}
in_unit(&format!("modal.modes[{i}].gain"), m.gain)?;
}
in_unit("modal.mix", *mix)
}
Node::Drive { amount, .. } => validate_value(amount, "drive.amount"),
Node::RingMod { freq } => validate_value(freq, "ringmod.freq"),
Node::Chorus { rate, depth, mix } => {
if *rate <= 0.0 {
return Err(format!("chorus.rate must be > 0, got {rate}"));
}
in_unit("chorus.depth", *depth)?;
in_unit("chorus.mix", *mix)
}
Node::Flanger {
rate,
depth,
feedback,
mix,
}
| Node::Phaser {
rate,
depth,
feedback,
mix,
} => {
if *rate <= 0.0 {
return Err(format!("flanger/phaser.rate must be > 0, got {rate}"));
}
in_unit("flanger/phaser.depth", *depth)?;
in_unit("flanger/phaser.feedback", *feedback)?;
in_unit("flanger/phaser.mix", *mix)
}
Node::Duck {
trigger,
amount,
attack,
release,
} => {
in_unit("duck.amount", *amount)?;
if *attack < 0.0 || *release < 0.0 {
return Err("duck.attack/release must be >= 0".into());
}
validate_node(trigger)
}
Node::Compress {
ratio,
attack,
release,
..
} => {
if *ratio < 1.0 {
return Err(format!("compress.ratio must be >= 1, got {ratio}"));
}
if *attack < 0.0 || *release < 0.0 {
return Err("compress.attack/release must be >= 0".into());
}
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn roundtrip(json: &str) -> serde_json::Value {
let doc: SoundDoc = serde_json::from_str(json).expect("deserialize");
serde_json::to_value(&doc).expect("serialize")
}
fn doc_with_root(root: &str) -> SoundDoc {
serde_json::from_str(&format!(
r#"{{ "name": "t", "duration": 0.2, "engine": 1, "root": {root} }}"#
))
.expect("deserialize")
}
#[test]
fn doc_defaults_fill_in() {
let doc: SoundDoc =
serde_json::from_str(r#"{ "name": "beep", "root": { "type": "sine", "freq": 440 } }"#)
.unwrap();
assert_eq!(doc.duration, 0.3);
assert_eq!(doc.sample_rate, 44_100);
assert_eq!(doc.version, None);
assert_eq!(doc.effective_version(), 1);
assert!(matches!(doc.stereo, Stereo::Mono));
assert!(matches!(doc.playback, Playback::OneShot));
}
#[test]
fn v2_tracks_reject_doc_level_stereo() {
let mut doc: SoundDoc = serde_json::from_str(
r#"{ "name": "band", "duration": 0.2, "version": 2,
"stereo": { "mode": "wide" },
"root": { "type": "tracks",
"tracks": [ { "id": "a", "node": { "type": "sine", "freq": 220 } } ] } }"#,
)
.unwrap();
let err = doc.validate().unwrap_err();
assert!(err.contains("per-layer pan"), "{err}");
doc.version = None;
assert_eq!(doc.validate(), Ok(()));
}
#[test]
fn future_schema_versions_are_rejected() {
let mut doc: SoundDoc =
serde_json::from_str(r#"{ "name": "beep", "root": { "type": "sine", "freq": 440 } }"#)
.unwrap();
assert_eq!(doc.validate(), Ok(()));
doc.version = Some(SCHEMA_VERSION);
assert_eq!(doc.validate(), Ok(()));
doc.version = Some(SCHEMA_VERSION + 1);
let err = doc.validate().unwrap_err();
assert!(err.contains("upgrade tono"), "unhelpful error: {err}");
doc.version = Some(0);
assert!(doc.validate().is_err());
}
#[test]
fn engine_defaults_to_zero_and_bounds_at_current_revision() {
let mut doc: SoundDoc =
serde_json::from_str(r#"{ "name": "beep", "root": { "type": "sine", "freq": 440 } }"#)
.unwrap();
assert_eq!(doc.engine, None);
assert_eq!(doc.effective_engine(), 0);
assert_eq!(doc.validate(), Ok(()));
doc.engine = Some(ENGINE_VERSION);
assert_eq!(doc.validate(), Ok(()));
doc.engine = Some(ENGINE_VERSION + 1);
let err = doc.validate().unwrap_err();
assert!(err.contains("engine must be in"), "unhelpful error: {err}");
}
#[test]
fn modal_and_impact_validate_their_ranges() {
let modal = |modes: &str| -> Result<(), String> {
doc_with_root(&format!(
r#"{{ "type": "chain", "stages": [
{{ "type": "impact" }},
{{ "type": "modal", "modes": {modes} }} ] }}"#
))
.validate()
};
assert!(modal(r#"[ { "freq": 440, "decay": 0.5, "gain": 1.0 } ]"#).is_ok());
assert!(modal("[]").unwrap_err().contains("non-empty"));
assert!(modal(r#"[ { "freq": -1 } ]"#).unwrap_err().contains("freq"));
assert!(
modal(r#"[ { "freq": 440, "decay": 0 } ]"#)
.unwrap_err()
.contains("decay")
);
assert!(
modal(r#"[ { "freq": 440, "gain": 2 } ]"#)
.unwrap_err()
.contains("gain")
);
assert!(
doc_with_root(r#"{ "type": "impact", "hardness": 1.5 }"#)
.validate()
.unwrap_err()
.contains("hardness")
);
}
#[test]
fn dust_and_rand_validate_their_ranges() {
assert!(
doc_with_root(r#"{ "type": "dust", "density": 50 }"#)
.validate()
.is_ok()
);
assert!(
doc_with_root(r#"{ "type": "dust", "density": 0 }"#)
.validate()
.unwrap_err()
.contains("density")
);
let with_cutoff = |m: &str| {
doc_with_root(&format!(
r#"{{ "type": "chain", "stages": [
{{ "type": "noise" }},
{{ "type": "lowpass", "cutoff": {m} }} ] }}"#
))
.validate()
};
assert!(with_cutoff(r#"{ "rand": { "from": 200, "to": 1200, "rate": 0.8 } }"#).is_ok());
assert!(
with_cutoff(r#"{ "rand": { "from": 200, "to": 1200, "rate": 0 } }"#)
.unwrap_err()
.contains("rand.rate")
);
}
#[test]
fn node_tag_is_type_lowercase() {
let v = roundtrip(r#"{ "name": "n", "root": { "type": "ringmod", "freq": 100 } }"#);
assert_eq!(v["root"]["type"], "ringmod");
}
#[test]
fn env_flattens_adsr_fields_inline() {
let v = roundtrip(
r#"{ "name": "n", "root": { "type": "env", "a": 0.01, "d": 0.2, "punch": 0.3 } }"#,
);
assert_eq!(v["root"]["a"], 0.01f32 as f64);
assert_eq!(v["root"]["punch"], 0.3f32 as f64);
assert!(v["root"].get("adsr").is_none());
}
#[test]
fn value_untagged_forms() {
let doc: SoundDoc = serde_json::from_str(
r#"{ "name": "n", "root": { "type": "mix", "inputs": [
{ "type": "sine", "freq": 440 },
{ "type": "sine", "freq": "A4" },
{ "type": "sine", "freq": { "slide": { "from": 880, "to": 180, "secs": 0.2 } } },
{ "type": "sine", "freq": { "lfo": { "rate": 5, "depth": 10, "center": 440 } } },
{ "type": "sine", "freq": { "arp": { "steps": [523, 659], "rate": 12 } } },
{ "type": "sine", "freq": { "env": { "a": 0.1, "from": 100, "to": 800 } } }
] } }"#,
)
.unwrap();
let Node::Mix { inputs } = &doc.root else {
panic!("expected mix");
};
assert!(matches!(
&inputs[0],
Node::Sine {
freq: Value::Const(f)
} if *f == 440.0
));
assert!(matches!(&inputs[1], Node::Sine { freq: Value::Note(s) } if s == "A4"));
assert!(matches!(
&inputs[2],
Node::Sine {
freq: Value::Modulated(Modulator::Slide {
curve: Curve::Lin,
..
})
}
));
assert!(matches!(
&inputs[5],
Node::Sine {
freq: Value::Modulated(Modulator::EnvMod { adsr, .. })
} if adsr.a == 0.1
));
}
#[test]
fn playback_loop_tag() {
let v = roundtrip(
r#"{ "name": "n", "playback": { "mode": "loop", "crossfade_secs": 0.25 },
"root": { "type": "noise" } }"#,
);
assert_eq!(v["playback"]["mode"], "loop");
assert_eq!(v["playback"]["crossfade_secs"], 0.25f32 as f64);
}
#[test]
fn stereo_modes() {
let v = roundtrip(
r#"{ "name": "n", "stereo": { "mode": "haas", "pan": -1 },
"root": { "type": "noise" } }"#,
);
assert_eq!(v["stereo"]["mode"], "haas");
assert_eq!(v["stereo"]["ms"], 12.0); }
#[test]
fn note_names_resolve_to_hz() {
assert_eq!(note_to_hz("A4"), Some(440.0));
assert_eq!(note_to_hz("midi:69"), Some(440.0));
assert_eq!(note_to_hz("m69"), Some(440.0));
assert!((note_to_hz("C#3").unwrap() - 138.591).abs() < 0.01);
assert!((note_to_hz("Gb5").unwrap() - 739.989).abs() < 0.01);
assert_eq!(note_to_hz("A"), Some(440.0));
assert_eq!(note_to_hz("a4"), Some(440.0));
assert!((note_to_hz("F#-1").unwrap() - 11.562).abs() < 0.01);
assert_eq!(note_to_hz(""), None);
assert_eq!(note_to_hz("H4"), None);
assert_eq!(note_to_hz("A4x"), None);
}
#[test]
fn processors_are_processors_sources_are_not() {
let p: Node = serde_json::from_str(r#"{ "type": "reverb", "room": 0.5 }"#).unwrap();
assert!(p.is_processor());
let s: Node = serde_json::from_str(r#"{ "type": "sine", "freq": 440 }"#).unwrap();
assert!(!s.is_processor());
}
fn doc(json: &str) -> SoundDoc {
serde_json::from_str(json).expect("deserialize")
}
#[test]
fn validate_accepts_a_sane_doc() {
let d = doc(
r#"{ "name": "zap", "duration": 0.2, "root": { "type": "mul", "inputs": [
{ "type": "square", "freq": { "slide": { "from": 880, "to": 180, "secs": 0.18 } } },
{ "type": "env", "d": 0.18, "punch": 0.3 }
] } }"#,
);
assert_eq!(d.validate(), Ok(()));
}
#[test]
fn validate_rejects_out_of_range_metadata() {
let d = doc(r#"{ "name": "n", "duration": 0, "root": { "type": "noise" } }"#);
assert!(d.validate().unwrap_err().contains("duration"));
let d = doc(r#"{ "name": "n", "sample_rate": 1000, "root": { "type": "noise" } }"#);
assert!(d.validate().unwrap_err().contains("sample_rate"));
let d = doc(
r#"{ "name": "n", "normalize": { "target_lufs": 5 }, "root": { "type": "noise" } }"#,
);
assert!(d.validate().unwrap_err().contains("target_lufs"));
}
#[test]
fn validate_rejects_bad_loop_region() {
let d = doc(r#"{ "name": "n", "duration": 1,
"playback": { "mode": "loop", "start_secs": 0.8, "end_secs": 0.5 },
"root": { "type": "noise" } }"#);
assert!(d.validate().unwrap_err().contains("end_secs"));
}
#[test]
fn validate_rejects_unit_range_violations() {
let d = doc(r#"{ "name": "n", "root": { "type": "chain", "stages": [
{ "type": "noise" }, { "type": "reverb", "mix": 1.5 }
] } }"#);
assert!(d.validate().unwrap_err().contains("reverb.mix"));
let d = doc(r#"{ "name": "n", "root": { "type": "env", "s": 2 } }"#);
assert!(d.validate().unwrap_err().contains("env.s"));
}
#[test]
fn validate_rejects_silent_all_zero_env() {
let d = doc(r#"{ "name": "n", "root": { "type": "env",
"adsr": { "a": 0.01, "d": 0.1, "s": 0.7, "r": 0.2 } } }"#);
assert!(d.validate().unwrap_err().contains("env is silent"));
let ok = doc(
r#"{ "name": "n", "root": { "type": "env", "a": 0.01, "d": 0.1, "s": 0.7, "r": 0.2 } }"#,
);
assert!(ok.validate().is_ok());
}
#[test]
fn validate_rejects_extreme_eq_gain() {
let d = doc(r#"{ "name": "n", "root": { "type": "chain", "stages": [
{ "type": "noise" }, { "type": "peak", "cutoff": 1000, "gain_db": 2000 }
] } }"#);
assert!(d.validate().unwrap_err().contains("peak.gain_db"));
let d = doc(r#"{ "name": "n", "root": { "type": "chain", "stages": [
{ "type": "noise" }, { "type": "lowshelf", "cutoff": 200, "gain_db": -100 }
] } }"#);
assert!(d.validate().unwrap_err().contains("shelf.gain_db"));
}
#[test]
fn validate_rejects_empty_combinators_and_bad_notes() {
let d = doc(r#"{ "name": "n", "root": { "type": "mix", "inputs": [] } }"#);
assert!(d.validate().unwrap_err().contains("mix/mul"));
let d = doc(r#"{ "name": "n", "root": { "type": "sine", "freq": "H9" } }"#);
assert!(d.validate().unwrap_err().contains("not a valid note"));
}
}