Skip to main content

tono_core/song/
mod.rs

1//! song — compose a full piece by adding instruments and arranging parts.
2//!
3//! A [`Song`] is the ergonomic layer above the raw graph: you add instrument
4//! **tracks**, define reusable **patterns** (phrases on a bar grid), and
5//! **arrange** them on a timeline. [`Song::to_doc`] compiles the whole thing
6//! down to an ordinary [`SoundDoc`](crate::dsl::SoundDoc) (a `tracks` root of `seq` tracks), so it
7//! renders, mixes, exports, and **replays byte-identically** through the exact
8//! same engine as everything else — nothing new in the render path.
9//!
10//! ```
11//! use tono_core::song::{Song, note};
12//! use tono_core::dsl::{Adsr, SeqWave};
13//!
14//! let amp = Adsr { a: 0.005, d: 0.1, s: 0.8, r: 0.2, punch: 0.0 };
15//! let mut song = Song::new("groove", 120.0);
16//! song.add_track("bass", SeqWave::Bass, amp);
17//! song.add_pattern("riff", 1, vec![note(0, 4, "C2"), note(8, 4, "G2")]);
18//! song.arrange("bass", "riff", 0);
19//! song.arrange("bass", "riff", 1); // same phrase, next bar
20//! let doc = song.to_doc().unwrap(); // a normal, deterministic SoundDoc
21//! ```
22
23mod compile;
24mod diagnostics;
25mod estimate;
26mod pattern;
27mod phrase;
28
29pub use compile::{CompileOptions, CompileTarget};
30pub use pattern::{
31    PatternError, concat, euclidean, gate, humanize, layer, probability, quantize, repeat, reverse,
32    rotate, slice, stretch, transpose, tuplet, vel,
33};
34pub use phrase::Phrase;
35
36use serde::{Deserialize, Serialize};
37
38use crate::catalog::{Voice, VoiceParams};
39use crate::dsl::{Adsr, ENGINE_VERSION, Node, SeqNote, SeqWave, Value};
40
41/// One instrument track: an instrument voice plus its mixer settings. Notes come
42/// from the patterns arranged onto it.
43#[non_exhaustive]
44#[derive(Clone, Debug, Serialize, Deserialize)]
45pub struct SongTrack {
46    /// Stable track name — patterns are arranged onto it and it becomes the
47    /// rendered layer id.
48    pub name: String,
49    /// The instrument (a synth wave, a built-in instrument like `piano`/`bass`/
50    /// `kit`, or `sampler` with a SoundFont).
51    pub wave: SeqWave,
52    /// The per-note amplitude envelope.
53    pub env: Adsr,
54    /// Channel fader, 0..2 (1 = unity).
55    #[serde(default = "unit_gain")]
56    pub gain: f32,
57    /// Stereo position, −1 (hard left) .. 1 (hard right).
58    #[serde(default)]
59    pub pan: f32,
60    /// SoundFont path when `wave` is `sampler` (else ignored).
61    #[serde(default)]
62    pub sf2: String,
63    /// General MIDI program when `wave` is `sampler`.
64    #[serde(default)]
65    pub sf2_preset: u32,
66    /// SoundFont bank when `wave` is `sampler` (128 = the GM drum map).
67    #[serde(default)]
68    pub sf2_bank: u32,
69    /// Notes written directly onto this track (via [`Song::add`]), in addition
70    /// to any arranged from patterns. `step` is absolute from the song start.
71    #[serde(default)]
72    pub notes: Vec<SeqNote>,
73    /// Voice-specific synthesis parameters (from the catalog instrument).
74    #[serde(default)]
75    pub voice: VoiceParams,
76    /// Reverb send, 0..1 — wraps the track's seq in a reverb (0 = dry).
77    #[serde(default)]
78    pub reverb: f32,
79    /// Per-track swing override (0..1); `None` uses the song's swing.
80    #[serde(default)]
81    pub swing: Option<f32>,
82    /// Per-track humanize override (0..1); `None` uses the song's humanize.
83    #[serde(default)]
84    pub humanize: Option<f32>,
85    /// Muted tracks compile to a muted mixer layer (present but silent).
86    #[serde(default)]
87    pub mute: bool,
88    /// When ANY track is solo, every non-solo track is muted — the console
89    /// behavior, deterministic regardless of declaration order. A track that
90    /// is both muted and solo stays muted.
91    #[serde(default)]
92    pub solo: bool,
93    /// Automation on this track's gain/pan, addressed in BEATS on the song
94    /// grid and compiled to seconds through the tempo map (or the constant
95    /// bpm). Empty = the static `gain`/`pan` apply — byte-identical.
96    #[serde(default, skip_serializing_if = "Vec::is_empty")]
97    pub automation: Vec<SongLane>,
98    /// The mix bus this track routes to (a name from the song's `buses`).
99    /// None = the master bus.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub bus: Option<String>,
102    /// Post-fader sends into the song's mix buses.
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub sends: Vec<crate::dsl::Send>,
105}
106
107/// One automation lane on a song track: `target` driven by beat-addressed
108/// breakpoints (see the document's `AutoLane` for the compiled form).
109#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
110pub struct SongLane {
111    /// What this lane controls (gain or pan).
112    pub target: crate::dsl::AutoTarget,
113    /// The interpolation between breakpoints (default linear).
114    #[serde(default)]
115    pub curve: crate::dsl::AutoCurve,
116    /// Breakpoints on the beat grid.
117    pub points: Vec<SongPoint>,
118}
119
120/// One beat-addressed automation breakpoint.
121#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
122pub struct SongPoint {
123    /// The beat position (fractional beats are fine — the lane is a
124    /// continuous curve, not grid events).
125    pub at: f32,
126    /// The target value at this beat.
127    pub v: f32,
128}
129
130/// A reusable phrase: notes on the bar grid, `bars` long. Note `step`s are
131/// relative to the pattern's start, so the same pattern drops in at any bar.
132#[derive(Clone, Debug, Serialize, Deserialize)]
133pub struct Pattern {
134    /// Pattern name — placements reference it.
135    pub name: String,
136    /// Length in bars (how far the next pattern on the same track is pushed).
137    pub bars: u32,
138    /// The notes, with `step` relative to the pattern start.
139    pub notes: Vec<SeqNote>,
140}
141
142/// Place `pattern` on `track` starting at bar `bar` (0-based).
143#[derive(Clone, Debug, Serialize, Deserialize)]
144pub struct Placement {
145    /// The track the pattern plays on.
146    pub track: String,
147    /// The pattern to play.
148    pub pattern: String,
149    /// The bar it starts at (0-based).
150    pub bar: u32,
151}
152
153pub use crate::units::MeterPoint;
154
155/// A named range of bars — a verse, a chorus, a build. Sections are musical
156/// metadata: they render nothing themselves, but they are compiled into the
157/// Program so the runtime can quantize transitions to them (ADR 0003/0005).
158#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
159pub struct Section {
160    /// The section name (`"verse"`, `"chorus"`).
161    pub name: String,
162    /// The bar it starts at (0-based).
163    pub bar: u32,
164    /// Its length in bars.
165    pub bars: u32,
166}
167
168/// A named point on the musical timeline — a hit, a cue, a drop. Like
169/// sections, markers are metadata compiled into the Program.
170#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
171pub struct Marker {
172    /// The marker name.
173    pub name: String,
174    /// Its exact beat position.
175    pub at: crate::units::Beat,
176}
177
178/// A full song: tracks (instruments), patterns (phrases), and an arrangement
179/// (where each pattern plays). Serializable, so a song is a saveable project.
180#[non_exhaustive]
181#[derive(Clone, Debug, Serialize, Deserialize)]
182pub struct Song {
183    /// Project name.
184    pub name: String,
185    /// Tempo in beats per minute.
186    pub bpm: f32,
187    /// Grid resolution: steps per beat (4 = sixteenth notes).
188    #[serde(default = "default_steps_per_beat")]
189    pub steps_per_beat: u32,
190    /// Beats per bar (time-signature numerator; 4 = 4/4).
191    #[serde(default = "default_beats_per_bar")]
192    pub beats_per_bar: u32,
193    /// Swing, 0..1, applied to every track.
194    #[serde(default)]
195    pub swing: f32,
196    /// Humanize, 0..1 (deterministic timing/velocity jitter), applied to every track.
197    #[serde(default)]
198    pub humanize: f32,
199    /// Tempo changes at exact beat positions (ADR 0002). Empty = the constant
200    /// `bpm` — the only behavior pre-existing songs had, so they compile
201    /// byte-identically. The first point must sit at beat 0.
202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
203    pub tempo_map: Vec<crate::dsl::TempoPoint>,
204    /// Time-signature changes by bar. Empty = `beats_per_bar`/4 throughout.
205    /// The first point must be bar 0 when present.
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub meter_map: Vec<MeterPoint>,
208    /// Pickup (anacrusis): bar 0's length in beats when it isn't a full bar.
209    /// None = bar 0 is full length.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub pickup: Option<crate::units::Beat>,
212    /// Named ranges of bars (verse/chorus/…) — metadata compiled into the
213    /// Program for the runtime's quantized transitions.
214    #[serde(default, skip_serializing_if = "Vec::is_empty")]
215    pub sections: Vec<Section>,
216    /// Named points on the timeline — metadata compiled into the Program.
217    #[serde(default, skip_serializing_if = "Vec::is_empty")]
218    pub markers: Vec<Marker>,
219    /// Mix buses: named submixes with insert chains (e.g. a shared reverb).
220    /// Tracks route to them with their `bus` field and feed them with `sends`.
221    #[serde(default, skip_serializing_if = "Vec::is_empty")]
222    pub buses: Vec<crate::dsl::Bus>,
223    /// The instrument tracks.
224    pub tracks: Vec<SongTrack>,
225    /// The reusable phrases.
226    pub patterns: Vec<Pattern>,
227    /// Where each pattern plays.
228    pub arrangement: Vec<Placement>,
229    /// A master effect chain over the whole mix.
230    #[serde(default)]
231    pub master: Vec<Node>,
232    /// Song-level deterministic seed, stamped onto the compiled document's
233    /// `seed` (the RNG stream everything stochastic draws from). `None` keeps
234    /// the document default (0). Same song + same seed ⇒ same program hash.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub seed: Option<u64>,
237    /// DSP-kernel revision this song is pinned to (see [`ENGINE_VERSION`]),
238    /// stamped at creation. A song saved as JSON therefore reopens and renders
239    /// byte-identically on newer tonos, exactly like a `SoundDoc` — kernel
240    /// upgrades never silently change a saved project's audio. Omitted (saves
241    /// from before this field) ⇒ compiled with the current engine.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub engine: Option<u32>,
244    /// Document schema version for the compiled doc (see
245    /// [`crate::dsl::SCHEMA_VERSION`]), stamped at creation. Omitted (older
246    /// saves) ⇒ the compiled doc keeps its historical v1 semantics.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub version: Option<u32>,
249}
250
251/// A note for a pattern at grid `step`, `len` steps long, pitched by name
252/// (`"C4"`, `"F#3"`, `"midi:36"`) or Hz — velocity 1.0.
253pub fn note(step: u32, len: u32, pitch: &str) -> SeqNote {
254    note_vel(step, len, pitch, 1.0)
255}
256
257/// [`note`] with an explicit velocity (0..1).
258pub fn note_vel(step: u32, len: u32, pitch: &str, gain: f32) -> SeqNote {
259    SeqNote {
260        step,
261        len,
262        pitch: Value::Note(pitch.to_string()),
263        gain,
264    }
265}
266
267/// Why a song failed to compile to a [`SoundDoc`](crate::dsl::SoundDoc).
268#[derive(Debug, Clone, PartialEq, Eq)]
269pub enum SongError {
270    /// The song has no tracks.
271    Empty,
272    /// The arrangement places a pattern on a track that doesn't exist.
273    UnknownTrack(String),
274    /// The arrangement references a pattern that doesn't exist.
275    UnknownPattern(String),
276    /// The compiled document failed to build or validate.
277    Compile(String),
278}
279
280impl std::fmt::Display for SongError {
281    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282        match self {
283            SongError::Empty => f.write_str("song has no tracks"),
284            SongError::UnknownTrack(t) => {
285                write!(f, "arrangement references unknown track '{t}'")
286            }
287            SongError::UnknownPattern(p) => {
288                write!(f, "arrangement references unknown pattern '{p}'")
289            }
290            SongError::Compile(e) => f.write_str(e),
291        }
292    }
293}
294
295impl std::error::Error for SongError {}
296
297impl From<SongError> for String {
298    fn from(e: SongError) -> String {
299        e.to_string()
300    }
301}
302
303impl Song {
304    /// An empty song at `bpm`, 4/4, sixteenth-note grid.
305    pub fn new(name: impl Into<String>, bpm: f32) -> Self {
306        Song {
307            name: name.into(),
308            bpm,
309            steps_per_beat: default_steps_per_beat(),
310            beats_per_bar: default_beats_per_bar(),
311            swing: 0.0,
312            humanize: 0.0,
313            tempo_map: Vec::new(),
314            meter_map: Vec::new(),
315            pickup: None,
316            sections: Vec::new(),
317            markers: Vec::new(),
318            buses: Vec::new(),
319            tracks: Vec::new(),
320            patterns: Vec::new(),
321            arrangement: Vec::new(),
322            master: Vec::new(),
323            seed: None,
324            engine: Some(ENGINE_VERSION),
325            version: Some(crate::dsl::SCHEMA_VERSION),
326        }
327    }
328
329    /// Set the song-level deterministic seed (builder style) — see
330    /// [`Song::seed`].
331    pub fn with_seed(mut self, seed: u64) -> Self {
332        self.seed = Some(seed);
333        self
334    }
335
336    /// Add an instrument track. The name becomes the rendered layer id, so it
337    /// is slugified and deduplicated exactly like the fluent [`add`](Self::add)
338    /// path — a duplicate name would land a placement on BOTH tracks, and a
339    /// non-slug name would fail validation downstream at render.
340    pub fn add_track(&mut self, name: impl Into<String>, wave: SeqWave, env: Adsr) -> &mut Self {
341        let name = self.unique_name(&slugify(&name.into()));
342        self.tracks.push(SongTrack {
343            name,
344            wave,
345            env,
346            gain: 1.0,
347            pan: 0.0,
348            sf2: String::new(),
349            sf2_preset: 0,
350            sf2_bank: 0,
351            notes: Vec::new(),
352            voice: VoiceParams::default(),
353            reverb: 0.0,
354            swing: None,
355            humanize: None,
356            mute: false,
357            solo: false,
358            automation: Vec::new(),
359            bus: None,
360            sends: Vec::new(),
361        });
362        self
363    }
364
365    /// Add a catalog [`Voice`] and write its notes on the shared beat
366    /// timeline, in one fluent call — the ergonomic way to build a song.
367    ///
368    /// The closure gets a [`Phrase`]: place notes with `.at(beat).note(pitch,
369    /// beats)`, step a melody with `.play(..)` / `.rest(..)`, stack a `.chord(..)`,
370    /// or hit drums with `.kick()` / `.snare()` / `.hat()`. Consumes and returns
371    /// the song so calls chain: `Song::new(..).add(..).add(..).to_doc()`.
372    ///
373    /// Beats map to the grid at the song's `steps_per_beat`; a duplicate
374    /// instrument name is disambiguated automatically.
375    pub fn add(mut self, instrument: Voice, write: impl FnOnce(&mut Phrase)) -> Self {
376        let mut phrase = Phrase::new(self.steps_per_beat);
377        write(&mut phrase);
378        // The track name becomes the rendered layer id, which must be a slug
379        // (a-z, 0-9, _) — so slugify the instrument's display name.
380        let name = self.unique_name(&slugify(&instrument.name));
381        self.tracks.push(SongTrack {
382            name,
383            wave: instrument.wave,
384            env: instrument.env,
385            gain: instrument.gain,
386            pan: instrument.pan,
387            sf2: String::new(),
388            sf2_preset: 0,
389            sf2_bank: 0,
390            notes: phrase.notes,
391            voice: instrument.voice,
392            reverb: instrument.reverb,
393            swing: instrument.swing,
394            humanize: instrument.humanize,
395            mute: false,
396            solo: false,
397            automation: Vec::new(),
398            bus: None,
399            sends: Vec::new(),
400        });
401        self
402    }
403
404    /// Add a catalog [`Voice`] as a track with an explicit name, writing no
405    /// notes — the constructor the Python typed API uses: naming the track
406    /// explicitly keeps layer ids stable across faces (the fluent
407    /// [`add`](Self::add) path names the track after the instrument's display
408    /// name instead). The name is slugified and deduplicated exactly like
409    /// [`add_track`](Self::add_track), so patterns arrange onto it and it
410    /// becomes the rendered layer id. Notes come from the patterns arranged
411    /// onto the track.
412    ///
413    /// This API is **stable** — frozen at 1.10.0-rc.1
414    /// (docs/api-tiers.md).
415    pub fn add_voice(&mut self, name: impl Into<String>, voice: &Voice) -> &mut Self {
416        let name = self.unique_name(&slugify(&name.into()));
417        self.tracks.push(SongTrack {
418            name,
419            wave: voice.wave,
420            env: voice.env,
421            gain: voice.gain,
422            pan: voice.pan,
423            sf2: String::new(),
424            sf2_preset: 0,
425            sf2_bank: 0,
426            notes: Vec::new(),
427            voice: voice.voice,
428            reverb: voice.reverb,
429            swing: voice.swing,
430            humanize: voice.humanize,
431            mute: false,
432            solo: false,
433            automation: Vec::new(),
434            bus: None,
435            sends: Vec::new(),
436        });
437        self
438    }
439
440    /// A track name not already taken — appends `_2`, `_3`, … on collision
441    /// (keeping it a valid layer-id slug).
442    fn unique_name(&self, base: &str) -> String {
443        if !self.tracks.iter().any(|t| t.name == base) {
444            return base.to_string();
445        }
446        (2..)
447            .map(|i| format!("{base}_{i}"))
448            .find(|n| !self.tracks.iter().any(|t| &t.name == n))
449            .expect("an unused suffix always exists")
450    }
451
452    /// Define a reusable pattern.
453    pub fn add_pattern(
454        &mut self,
455        name: impl Into<String>,
456        bars: u32,
457        notes: Vec<SeqNote>,
458    ) -> &mut Self {
459        self.patterns.push(Pattern {
460            name: name.into(),
461            bars: bars.max(1),
462            notes,
463        });
464        self
465    }
466
467    /// Place a pattern on a track at `bar`.
468    pub fn arrange(&mut self, track: impl Into<String>, pattern: impl Into<String>, bar: u32) {
469        self.arrangement.push(Placement {
470            track: track.into(),
471            pattern: pattern.into(),
472            bar,
473        });
474    }
475
476    /// Place a pattern `times` times back-to-back on a track from `start_bar`
477    /// (a repeated section). The pattern's `bars` sets the stride.
478    pub fn arrange_repeat(&mut self, track: &str, pattern: &str, start_bar: u32, times: u32) {
479        let stride = self
480            .patterns
481            .iter()
482            .find(|p| p.name == pattern)
483            .map(|p| p.bars)
484            .unwrap_or(1);
485        for i in 0..times {
486            self.arrange(track, pattern, start_bar + i * stride);
487        }
488    }
489
490    /// Set the master effect chain (builder style).
491    pub fn with_master(mut self, master: Vec<Node>) -> Self {
492        self.master = master;
493        self
494    }
495}
496
497/// Turn a display name into a layer-id slug: lowercase, runs of non-`[a-z0-9]`
498/// collapsed to a single `_`, no leading/trailing `_`. `"Mellow Piano"` →
499/// `"mellow_piano"`, `"808 drums"` → `"808_drums"`.
500fn slugify(name: &str) -> String {
501    let mut s = String::with_capacity(name.len());
502    let mut pending_us = false;
503    for c in name.chars() {
504        if c.is_ascii_alphanumeric() {
505            if pending_us && !s.is_empty() {
506                s.push('_');
507            }
508            s.push(c.to_ascii_lowercase());
509            pending_us = false;
510        } else {
511            pending_us = true;
512        }
513    }
514    if s.is_empty() {
515        s.push_str("track");
516    }
517    s
518}
519
520fn unit_gain() -> f32 {
521    1.0
522}
523fn default_steps_per_beat() -> u32 {
524    4
525}
526fn default_beats_per_bar() -> u32 {
527    4
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use crate::render;
534
535    fn amp() -> Adsr {
536        Adsr {
537            a: 0.005,
538            d: 0.1,
539            s: 0.8,
540            r: 0.2,
541            punch: 0.0,
542        }
543    }
544    fn peak(s: &[f32]) -> f32 {
545        s.iter().fold(0.0f32, |m, &x| m.max(x.abs()))
546    }
547
548    #[test]
549    fn compiles_and_renders_a_two_track_song() {
550        let mut song = Song::new("demo", 120.0);
551        song.add_track("bass", SeqWave::Bass, amp());
552        song.add_track("drums", SeqWave::Kit, amp());
553        song.add_pattern("bassline", 1, vec![note(0, 4, "C2"), note(8, 4, "G2")]);
554        song.add_pattern(
555            "beat",
556            1,
557            vec![note(0, 2, "midi:36"), note(8, 2, "midi:38")],
558        );
559        song.arrange_repeat("bass", "bassline", 0, 2);
560        song.arrange_repeat("drums", "beat", 0, 2);
561        assert_eq!(song.length_bars(), 2);
562
563        let doc = song.to_doc().unwrap();
564        assert!(matches!(&doc.root, Node::Tracks { tracks, .. } if tracks.len() == 2));
565        let out = render::render(&doc);
566        assert!(peak(&out) > 0.0, "the song makes sound");
567        // Deterministic: recompiling and re-rendering yields the same samples.
568        assert_eq!(render::render(&song.to_doc().unwrap()), out);
569    }
570
571    #[test]
572    fn pattern_places_at_the_right_bar() {
573        // 4/4 at 4 steps/beat ⇒ 16 steps per bar.
574        let mut song = Song::new("s", 120.0);
575        song.add_track("lead", SeqWave::Square, amp());
576        song.add_pattern("p", 1, vec![note(0, 1, "C4")]);
577        song.arrange("lead", "p", 0);
578        song.arrange("lead", "p", 2); // bar 2 ⇒ step 32
579        let doc = song.to_doc().unwrap();
580        let Node::Tracks { tracks, .. } = &doc.root else {
581            panic!("tracks root");
582        };
583        let Node::Seq { notes, .. } = &tracks[0].node else {
584            panic!("seq track");
585        };
586        assert_eq!(
587            notes.iter().map(|n| n.step).collect::<Vec<_>>(),
588            vec![0, 32]
589        );
590    }
591
592    #[test]
593    fn rejects_unknown_references() {
594        let mut a = Song::new("s", 120.0);
595        a.add_track("t", SeqWave::Sine, amp());
596        a.add_pattern("p", 1, vec![note(0, 1, "C4")]);
597        a.arrange("nope", "p", 0);
598        assert_eq!(
599            a.to_doc().unwrap_err(),
600            SongError::UnknownTrack("nope".into())
601        );
602
603        let mut b = Song::new("s", 120.0);
604        b.add_track("t", SeqWave::Sine, amp());
605        b.arrange("t", "ghost", 0);
606        assert_eq!(
607            b.to_doc().unwrap_err(),
608            SongError::UnknownPattern("ghost".into())
609        );
610    }
611
612    #[test]
613    fn round_trips_through_serde() {
614        let mut song = Song::new("s", 128.0);
615        song.add_track("bass", SeqWave::Bass, amp());
616        song.add_pattern("r", 1, vec![note(0, 4, "C2")]);
617        song.arrange("bass", "r", 0);
618        let json = serde_json::to_string(&song).unwrap();
619        let back: Song = serde_json::from_str(&json).unwrap();
620        assert!(back.to_doc().is_ok(), "a saved song reloads and compiles");
621    }
622
623    #[test]
624    fn fluent_add_places_notes_on_the_beat_grid() {
625        use crate::catalog::{Drums, GrandPiano};
626        // 4 steps/beat: beat 0 → step 0, beat 1 → step 4, beat 0.5 → step 2.
627        let song = Song::new("demo", 120.0)
628            .add(GrandPiano::grand(), |t| {
629                t.at(0.0).note("C4", 1.0).at(1.0).note("E4", 1.0);
630            })
631            .add(Drums::acoustic(), |t| {
632                t.at(0.0).kick().at(0.5).hat();
633            });
634        let doc = song.to_doc().unwrap();
635        let Node::Tracks { tracks, .. } = &doc.root else {
636            panic!("tracks root");
637        };
638        assert_eq!(tracks.len(), 2);
639        let Node::Seq { notes, .. } = &tracks[0].node else {
640            panic!("seq");
641        };
642        assert_eq!(notes.iter().map(|n| n.step).collect::<Vec<_>>(), vec![0, 4]);
643        let Node::Seq { notes: drums, .. } = &tracks[1].node else {
644            panic!("seq");
645        };
646        assert_eq!(drums.iter().map(|n| n.step).collect::<Vec<_>>(), vec![0, 2]);
647    }
648
649    #[test]
650    fn fluent_song_renders_deterministically() {
651        use crate::catalog::{Bass, GrandPiano};
652        let build = || {
653            Song::new("tune", 100.0)
654                .add(GrandPiano::grand(), |t| {
655                    t.play("C4", 1.0).play("E4", 1.0).play("G4", 1.0);
656                })
657                .add(Bass::finger(), |t| {
658                    t.at(0.0).note("C2", 3.0);
659                })
660                .to_doc()
661                .unwrap()
662        };
663        let a = render::render(&build());
664        assert!(peak(&a) > 0.0, "the fluent song makes sound");
665        assert_eq!(render::render(&build()), a, "byte-identical every render");
666    }
667
668    #[test]
669    fn guitar_voice_param_reaches_the_seq() {
670        use crate::catalog::Guitar;
671        let doc = Song::new("g", 120.0)
672            .add(Guitar::steel(), |t| {
673                t.at(0.0).note("E3", 2.0);
674            })
675            .to_doc()
676            .unwrap();
677        let Node::Tracks { tracks, .. } = &doc.root else {
678            panic!("tracks");
679        };
680        let Node::Seq { pluck, .. } = &tracks[0].node else {
681            panic!("seq");
682        };
683        assert!(
684            (pluck.pluck_decay - 0.965).abs() < 1e-6,
685            "steel pluck_decay set"
686        );
687    }
688
689    #[test]
690    fn duplicate_instrument_names_are_disambiguated() {
691        use crate::catalog::GrandPiano;
692        let song = Song::new("two pianos", 120.0)
693            .add(GrandPiano::grand(), |t| {
694                t.at(0.0).note("C4", 1.0);
695            })
696            .add(GrandPiano::grand(), |t| {
697                t.at(0.0).note("E4", 1.0);
698            });
699        assert_eq!(song.tracks[0].name, "grand_piano");
700        assert_eq!(song.tracks[1].name, "grand_piano_2");
701    }
702
703    #[test]
704    fn per_track_reverb_wraps_and_is_dry_by_default() {
705        use crate::catalog::GrandPiano;
706        // Dry (default): the track node is a bare seq — byte-identical to before.
707        let dry = Song::new("s", 100.0)
708            .add(GrandPiano::grand(), |t| {
709                t.at(0.0).note("C4", 1.0);
710            })
711            .to_doc()
712            .unwrap();
713        let Node::Tracks { tracks, .. } = &dry.root else {
714            panic!("tracks")
715        };
716        assert!(
717            matches!(&tracks[0].node, Node::Seq { .. }),
718            "dry = bare seq"
719        );
720        // Wet: the seq is wrapped in a chain [seq, reverb].
721        let wet = Song::new("s", 100.0)
722            .add(GrandPiano::grand().reverb(0.5), |t| {
723                t.at(0.0).note("C4", 1.0);
724            })
725            .to_doc()
726            .unwrap();
727        let Node::Tracks { tracks, .. } = &wet.root else {
728            panic!("tracks")
729        };
730        let Node::Chain { stages } = &tracks[0].node else {
731            panic!("reverb wraps the seq in a chain")
732        };
733        assert!(matches!(stages[0], Node::Seq { .. }));
734        assert!(matches!(stages[1], Node::Reverb { .. }));
735        assert!(
736            render::render(&wet).iter().any(|&x| x != 0.0),
737            "wet song sounds"
738        );
739    }
740
741    #[test]
742    fn per_track_swing_overrides_the_song_swing() {
743        use crate::catalog::Bass;
744        let doc = Song::new("s", 120.0) // song swing defaults to 0
745            .add(Bass::finger().swing(0.6), |t| {
746                t.at(0.0).note("C2", 1.0).at(1.0).note("G1", 1.0);
747            })
748            .to_doc()
749            .unwrap();
750        let Node::Tracks { tracks, .. } = &doc.root else {
751            panic!("tracks")
752        };
753        let Node::Seq { swing, .. } = &tracks[0].node else {
754            panic!("seq")
755        };
756        assert!(
757            (*swing - 0.6).abs() < 1e-6,
758            "track swing overrides the song's"
759        );
760    }
761
762    #[test]
763    fn catalog_names_become_valid_layer_id_slugs() {
764        use crate::catalog::{Drums, Guitar, Strings};
765        // Instruments with spaces / digits in their display names must yield
766        // slug layer ids so the doc passes validation (the CLI enforces it).
767        let doc = Song::new("s", 100.0)
768            .add(Strings::warm(), |t| {
769                t.at(0.0).chord(&["C4", "E4"], 4.0);
770            })
771            .add(Guitar::steel(), |t| {
772                t.at(0.0).note("E3", 4.0);
773            })
774            .add(Drums::tr808(), |t| {
775                t.at(0.0).kick();
776            })
777            .to_doc()
778            .unwrap();
779        assert!(
780            doc.validate().is_ok(),
781            "catalog song validates: {:?}",
782            doc.validate()
783        );
784        let Node::Tracks { tracks, .. } = &doc.root else {
785            panic!("tracks");
786        };
787        for t in tracks {
788            let id = t.id.as_deref().unwrap();
789            assert!(
790                id.chars()
791                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
792                "layer id '{id}' is a slug"
793            );
794        }
795    }
796
797    #[test]
798    fn song_pins_engine_and_version_at_creation() {
799        let mut song = Song::new("pinned", 120.0);
800        song.add_track("bass", SeqWave::Bass, amp());
801        song.tracks[0].notes.push(note(0, 4, "C2"));
802        assert_eq!(song.engine, Some(ENGINE_VERSION));
803        let doc = song.to_doc().unwrap();
804        assert_eq!(doc.engine, Some(ENGINE_VERSION));
805        assert_eq!(doc.version, Some(crate::dsl::SCHEMA_VERSION));
806
807        // A save pinned to an older engine keeps it across upgrades — the
808        // audio of a saved project never silently changes.
809        song.engine = Some(3);
810        assert_eq!(song.to_doc().unwrap().engine, Some(3));
811
812        // Legacy saves (no pins) keep their historical behavior: current
813        // engine, v1 doc semantics.
814        let mut v = serde_json::to_value(&song).unwrap();
815        v.as_object_mut().unwrap().remove("engine");
816        v.as_object_mut().unwrap().remove("version");
817        let legacy: Song = serde_json::from_value(v).unwrap();
818        let doc = legacy.to_doc().unwrap();
819        assert_eq!(doc.engine, Some(ENGINE_VERSION));
820        assert_eq!(doc.version, None);
821    }
822
823    #[test]
824    fn length_bars_counts_direct_track_notes() {
825        // Notes written via the fluent path live on the track, not in a
826        // pattern placement — they must still count toward the song length.
827        let mut song = Song::new("fluent", 120.0); // 16 steps per bar
828        song.add_track("bass", SeqWave::Bass, amp());
829        assert_eq!(song.length_bars(), 0);
830        song.tracks[0].notes.push(note(17, 4, "C2")); // ends at step 21 → bar 2
831        assert_eq!(song.length_bars(), 2);
832    }
833
834    #[test]
835    fn slugifies_names() {
836        assert_eq!(slugify("Mellow Piano"), "mellow_piano");
837        assert_eq!(slugify("808 drums"), "808_drums");
838        assert_eq!(slugify("steel guitar"), "steel_guitar");
839        assert_eq!(slugify("  !!  "), "track");
840    }
841
842    #[test]
843    fn pathological_step_values_saturate_instead_of_wrapping() {
844        // u32 arithmetic on raw input used to panic in debug and wrap in
845        // release; a saturated tail end is harmless (the seq renderer caps
846        // notes at the render window anyway).
847        let mut song = Song::new("s", 120.0);
848        song.add_track("t", SeqWave::Square, amp());
849        song.tracks[0].notes.push(note(u32::MAX, 4, "C4"));
850        let doc = song.to_doc().unwrap(); // must not panic
851        assert!(doc.duration.is_finite());
852        // A huge placement bar takes the same path.
853        let mut song = Song::new("s", 120.0);
854        song.add_track("t", SeqWave::Square, amp());
855        song.add_pattern("p", 1, vec![note(0, 1, "C4")]);
856        song.arrange("t", "p", u32::MAX);
857        let doc = song.to_doc().unwrap();
858        assert!(doc.duration.is_finite());
859    }
860
861    #[test]
862    fn add_track_slugifies_and_dedups_names() {
863        // Duplicate names used to land a placement on BOTH tracks; non-slug
864        // names compiled to a doc that fails validation downstream.
865        let mut song = Song::new("s", 120.0);
866        song.add_track("My Bass", SeqWave::Bass, amp());
867        song.add_track("My Bass", SeqWave::Bass, amp());
868        assert_eq!(song.tracks[0].name, "my_bass");
869        assert_eq!(song.tracks[1].name, "my_bass_2");
870    }
871
872    #[test]
873    fn add_voice_slugifies_dedups_and_carries_the_voice_fields() {
874        use crate::catalog::{Bass, Drums};
875        let voice = Bass::pick()
876            .gain(0.8)
877            .pan(-0.25)
878            .reverb(0.4)
879            .swing(0.5)
880            .humanize(0.1);
881        let mut song = Song::new("s", 120.0);
882        song.add_voice("My Bass", &voice);
883        song.add_voice("My Bass", &Bass::finger());
884        song.add_voice("drums", &Drums::tr808());
885        assert_eq!(song.tracks[0].name, "my_bass");
886        assert_eq!(song.tracks[1].name, "my_bass_2");
887        assert_eq!(song.tracks[2].name, "drums");
888
889        let t = &song.tracks[0];
890        assert_eq!(t.wave, voice.wave);
891        assert_eq!(t.env, voice.env);
892        assert_eq!(t.gain, 0.8);
893        assert_eq!(t.pan, -0.25);
894        assert_eq!(t.reverb, 0.4);
895        assert_eq!(t.swing, Some(0.5));
896        assert_eq!(t.humanize, Some(0.1));
897        assert_eq!(t.voice, voice.voice, "the pick's bass_* params ride along");
898        assert!(t.notes.is_empty(), "add_voice writes no notes");
899        assert!(t.sf2.is_empty() && t.sf2_preset == 0 && t.sf2_bank == 0);
900        assert!(!t.mute && !t.solo);
901
902        // The explicitly-named track arranges and compiles like any other.
903        song.add_pattern("p", 1, vec![note(0, 2, "C2")]);
904        song.arrange("my_bass", "p", 0);
905        assert!(song.to_doc().is_ok());
906    }
907
908    #[test]
909    fn degenerate_bpm_keeps_duration_and_placement_consistent() {
910        // bpm < 1 used to size the duration for bpm=1 while the seq played at
911        // the real bpm — notes past bar 0 silently dropped. Both use the
912        // clamped value now.
913        let mut song = Song::new("s", 0.5);
914        song.add_track("t", SeqWave::Sine, amp());
915        song.tracks[0].notes.push(note(0, 2, "C4"));
916        song.tracks[0].notes.push(note(16, 2, "C4")); // ends at step 18
917        let doc = song.to_doc().unwrap();
918        doc.validate().unwrap();
919        let Node::Tracks { tracks, .. } = &doc.root else {
920            panic!("tracks root");
921        };
922        let Node::Seq { bpm, .. } = &tracks[0].node else {
923            panic!("a reverb-less track compiles to a bare seq");
924        };
925        assert_eq!(*bpm, 1.0, "the seq plays at the clamped bpm");
926        let expected = 18.0 * (60.0 / 4.0) + 2.0; // 15 s per step at bpm 1
927        assert!(
928            (doc.duration - expected).abs() < 1e-3,
929            "duration matches the clamped bpm: {}",
930            doc.duration
931        );
932    }
933}