Skip to main content

tono_core/song/
compile.rs

1//! Compiling a [`Song`](super::Song) to a deterministic [`SoundDoc`] — the
2//! `tracks` root of `seq` tracks. Length/duration math lives here too, and
3//! [`Song::compile`] — the full validation + lowering entry point that
4//! returns an immutable [`Program`] (ADR 0003).
5
6use super::{Song, SongError, SongTrack};
7use crate::diag::{CompileError, Diagnostic};
8use crate::dsl::{ENGINE_VERSION, Node, SeqNote, SoundDoc, Track};
9use crate::ids::TrackId;
10use crate::program::{PROGRAM_VERSION, Program, ProgramMeta, TrackMeta, blocker_warnings};
11use crate::units::Beat;
12
13/// What a compiled [`Program`] will be used for. Offline compilation preserves
14/// streaming blockers as warnings; runtime compilation promotes them to errors
15/// so a target that requires native streaming cannot silently fall back to a
16/// full pre-rendered buffer.
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum CompileTarget {
20    /// Offline rendering (mix, ranges, stems).
21    #[default]
22    Offline,
23    /// Real-time playback through the runtime engine.
24    Runtime,
25}
26
27/// Knobs for [`Song::compile`]. `Default`: the document's own sample rate
28/// (44 100 Hz), offline target.
29#[derive(Debug, Clone, Default)]
30pub struct CompileOptions {
31    /// Stamp the resolved document with this sample rate (None keeps the
32    /// document default, 44 100 Hz).
33    pub sample_rate: Option<u32>,
34    /// What the program will be used for.
35    pub target: CompileTarget,
36}
37
38/// The reverb room size of a track's send (one shared, musical room).
39const SEND_ROOM: f32 = 0.6;
40/// Mix at full send — half wet keeps the dry signal audible under it.
41const SEND_MIX_MAX: f32 = 0.5;
42
43/// Where a note stops sounding, in steps. Zero-length notes still occupy one
44/// step (the same floor the seq renderer applies). Saturating: a pathological
45/// step/len wraps in release (and panics in debug) for no benefit — the seq
46/// renderer already caps notes at the render window.
47pub(crate) fn note_end(n: &SeqNote) -> u32 {
48    n.step.saturating_add(n.len.max(1))
49}
50
51impl Song {
52    /// The song's length in bars: the end of its last-ending pattern or of the
53    /// last note written directly onto a track (the fluent [`Song::add`] path).
54    pub fn length_bars(&self) -> u32 {
55        let steps_per_bar = self.steps_per_bar();
56        let from_patterns = self
57            .arrangement
58            .iter()
59            .map(|pl| {
60                let bars = self
61                    .patterns
62                    .iter()
63                    .find(|p| p.name == pl.pattern)
64                    .map(|p| p.bars)
65                    .unwrap_or(0);
66                pl.bar.saturating_add(bars)
67            })
68            .max()
69            .unwrap_or(0);
70        let from_notes = if self.plain_meter() {
71            self.tracks
72                .iter()
73                .flat_map(|t| t.notes.iter())
74                .map(|n| note_end(n).div_ceil(steps_per_bar))
75                .max()
76                .unwrap_or(0)
77        } else {
78            self.tracks
79                .iter()
80                .flat_map(|t| t.notes.iter())
81                .map(|n| {
82                    let beat = Beat::new(note_end(n) as i64, self.steps_per_beat.max(1));
83                    self.bar_count_at_beat(beat)
84                })
85                .max()
86                .unwrap_or(0)
87        };
88        from_patterns.max(from_notes)
89    }
90
91    /// Bars elapsed at `beat` under the meter map — the shared walk
92    /// ([`crate::units::bar_count_at_beat`]).
93    fn bar_count_at_beat(&self, beat: Beat) -> u32 {
94        crate::units::bar_count_at_beat(&self.meter_map, self.beats_per_bar, self.pickup, beat)
95    }
96
97    /// Steps per bar, degenerate (zero) fields floored to 1 — the one formula
98    /// [`length_bars`](Self::length_bars) and [`to_doc`](Self::to_doc) share,
99    /// so a deserialized song can't report a length its compile disagrees with.
100    fn steps_per_bar(&self) -> u32 {
101        self.beats_per_bar
102            .max(1)
103            .saturating_mul(self.steps_per_beat.max(1))
104    }
105
106    fn checked_steps_per_bar(&self) -> Result<u32, SongError> {
107        self.beats_per_bar
108            .max(1)
109            .checked_mul(self.steps_per_beat.max(1))
110            .ok_or_else(|| {
111                SongError::Compile(format!(
112                    "beats_per_bar ({}) × steps_per_beat ({}) exceeds the u32 song grid",
113                    self.beats_per_bar, self.steps_per_beat
114                ))
115            })
116    }
117
118    /// Whether the meter is plain (`beats_per_bar`/4 throughout, no pickup) —
119    /// the legacy placement path, byte-identical to before the maps existed.
120    pub(crate) fn plain_meter(&self) -> bool {
121        self.meter_map.is_empty() && self.pickup.is_none()
122    }
123
124    /// The exact beat `bar` starts at — the pickup plus the meter walk,
125    /// segment-wise. The shared walk ([`crate::units::beat_at_bar`]) every
126    /// tempo-aware path (compiler, transport) uses, so they never disagree.
127    pub fn beat_at_bar(&self, bar: u32) -> Beat {
128        crate::units::beat_at_bar(&self.meter_map, self.beats_per_bar, self.pickup, bar)
129    }
130
131    /// A beat position to a grid step, erroring when it lands between steps
132    /// (the grid is the seq's; placements must sit on it).
133    pub(crate) fn beat_to_step(&self, beat: Beat) -> Result<u32, SongError> {
134        let spb = self.steps_per_beat.max(1) as i128;
135        let num = beat.num as i128 * spb;
136        if num % beat.den as i128 != 0 {
137            return Err(SongError::Compile(format!(
138                "a placement at beat {beat} doesn't land on the {}-steps-per-beat \
139                 grid — change steps_per_beat or the meter map/pickup",
140                self.steps_per_beat.max(1)
141            )));
142        }
143        Ok(u32::try_from((num / beat.den as i128).max(0)).unwrap_or(u32::MAX))
144    }
145
146    /// Compile to a deterministic [`SoundDoc`] — a
147    /// `tracks` root of `seq` tracks. Errors if the song is empty or an
148    /// arrangement references a missing track or pattern.
149    pub fn to_doc(&self) -> Result<crate::dsl::SoundDoc, SongError> {
150        if self.tracks.is_empty() {
151            return Err(SongError::Empty);
152        }
153        for pl in &self.arrangement {
154            if !self.tracks.iter().any(|t| t.name == pl.track) {
155                return Err(SongError::UnknownTrack(pl.track.clone()));
156            }
157            if !self.patterns.iter().any(|p| p.name == pl.pattern) {
158                return Err(SongError::UnknownPattern(pl.pattern.clone()));
159            }
160        }
161        self.checked_steps_per_bar()?;
162
163        let sec_per_step = 60.0 / (self.bpm.max(1.0) * self.steps_per_beat.max(1) as f32);
164        let any_solo = self.tracks.iter().any(|t| t.solo);
165        let mut end_step = 0u32;
166        let mut doc_tracks = Vec::with_capacity(self.tracks.len());
167        for t in &self.tracks {
168            doc_tracks.push(self.compile_track(t, &mut end_step, any_solo)?);
169        }
170
171        // With a tempo map, seconds come from the segment walk; without one,
172        // the legacy constant-tempo formula (byte-identical history).
173        let duration = if self.tempo_map.is_empty() {
174            end_step as f32 * sec_per_step + 2.0 // tail for release/reverb
175        } else {
176            let end_beat = end_step as f64 / self.steps_per_beat.max(1) as f64;
177            crate::dsl::tempo_map_seconds_at(&self.tempo_map, end_beat) as f32 + 2.0
178        };
179        let root = Node::Tracks {
180            tracks: doc_tracks,
181            master: self.master.clone(),
182            buses: self.buses.clone(),
183        };
184        // The song's pinned engine/version win over the current ones, so a
185        // saved project replays byte-identically across kernel upgrades.
186        // Older saves without the pins keep their historical behavior: the
187        // current engine, v1 schema semantics.
188        let mut json = serde_json::json!({
189            "name": self.name,
190            "duration": duration,
191            "engine": self.engine.unwrap_or(ENGINE_VERSION),
192            "root": serde_json::to_value(&root).map_err(|e| SongError::Compile(e.to_string()))?,
193        });
194        if let Some(v) = self.version {
195            json["version"] = serde_json::json!(v);
196        }
197        let doc: crate::dsl::SoundDoc = serde_json::from_value(json)
198            .map_err(|e| SongError::Compile(format!("song doc build: {e}")))?;
199        Ok(doc)
200    }
201
202    /// Compile one song track to a mixer [`Track`]: merge its direct notes with
203    /// its pattern placements, build the seq node, and wrap the reverb send.
204    /// Extends `end_step` to the track's last note end. `any_solo` carries the
205    /// song-level solo state: when any track is solo, every non-solo track is
206    /// muted (a muted solo track stays muted).
207    fn compile_track(
208        &self,
209        t: &SongTrack,
210        end_step: &mut u32,
211        any_solo: bool,
212    ) -> Result<Track, SongError> {
213        let steps_per_bar = self.steps_per_bar();
214        let mut notes: Vec<SeqNote> = t.notes.clone();
215        for n in &notes {
216            *end_step = (*end_step).max(note_end(n));
217        }
218        for pl in self.arrangement.iter().filter(|p| p.track == t.name) {
219            let pat = self
220                .patterns
221                .iter()
222                .find(|p| p.name == pl.pattern)
223                .expect("pattern existence checked above");
224            // Plain meter keeps the legacy integer stride (byte-identical);
225            // maps place bars through the exact beat walk.
226            let offset = if self.plain_meter() {
227                pl.bar.saturating_mul(steps_per_bar)
228            } else {
229                self.beat_to_step(self.beat_at_bar(pl.bar))?
230            };
231            for n in &pat.notes {
232                let placed = SeqNote {
233                    step: n.step.saturating_add(offset),
234                    len: n.len,
235                    pitch: n.pitch.clone(),
236                    gain: n.gain,
237                };
238                *end_step = (*end_step).max(note_end(&placed));
239                notes.push(placed);
240            }
241        }
242        notes.sort_by_key(|n| n.step);
243
244        // Build the seq node via serde so the seq-only fields (duty, fm_*,
245        // pluck_decay) take the engine's own defaults — then merge the whole
246        // VoiceParams struct over it. Field names match the seq node's keys
247        // one-for-one, so every set knob flows through and a newly added voice
248        // param can never be silently dropped here.
249        let mut seq_json = serde_json::json!({
250            "type": "seq",
251            // bpm/steps_per_beat are clamped exactly like to_doc's duration
252            // math — degenerate values would otherwise place notes beyond the
253            // computed duration (silently dropping them) or build an invalid seq.
254            "bpm": self.bpm.max(1.0),
255            "steps_per_beat": self.steps_per_beat.max(1),
256            "wave": serde_json::to_value(t.wave).map_err(|e| SongError::Compile(e.to_string()))?,
257            "env": serde_json::to_value(t.env).map_err(|e| SongError::Compile(e.to_string()))?,
258            "swing": t.swing.unwrap_or(self.swing),
259            "humanize": t.humanize.unwrap_or(self.humanize),
260            "sf2": t.sf2,
261            "sf2_preset": t.sf2_preset,
262            "sf2_bank": t.sf2_bank,
263            "notes": serde_json::to_value(&notes).map_err(|e| SongError::Compile(e.to_string()))?,
264        });
265        if let serde_json::Value::Object(voice) =
266            serde_json::to_value(t.voice).map_err(|e| SongError::Compile(e.to_string()))?
267        {
268            for (key, val) in voice {
269                if !val.is_null() {
270                    seq_json[key] = val;
271                }
272            }
273        }
274        // The song's tempo map applies to every track's seq (the grid is
275        // shared); empty maps omit the field, so plain songs are unchanged.
276        if !self.tempo_map.is_empty() {
277            seq_json["tempo_map"] = serde_json::to_value(&self.tempo_map)
278                .map_err(|e| SongError::Compile(e.to_string()))?;
279        }
280        let seq: Node = serde_json::from_value(seq_json)
281            .map_err(|e| SongError::Compile(format!("track '{}' seq build: {e}", t.name)))?;
282
283        // A reverb send wraps the seq in a chain (dry when reverb == 0, so
284        // the track is byte-identical without it).
285        let node = if t.reverb > 0.0 {
286            let rv = t.reverb.clamp(0.0, 1.0);
287            Node::Chain {
288                stages: vec![
289                    seq,
290                    Node::Reverb {
291                        room: SEND_ROOM,
292                        mix: SEND_MIX_MAX * rv,
293                    },
294                ],
295            }
296        } else {
297            seq
298        };
299        Ok(Track {
300            id: Some(t.name.clone()),
301            node,
302            pan: t.pan,
303            gain: t.gain,
304            at: 0.0,
305            mute: t.mute || (any_solo && !t.solo),
306            automation: t
307                .automation
308                .iter()
309                .map(|lane| self.compile_lane(lane))
310                .collect(),
311            sidechain: None,
312            bus: t.bus.clone(),
313            sends: t.sends.clone(),
314        })
315    }
316
317    /// A song lane (beats) to a document lane (seconds): through the tempo
318    /// map when the song has one, else the constant bpm.
319    fn compile_lane(&self, lane: &super::SongLane) -> crate::dsl::AutoLane {
320        crate::dsl::AutoLane {
321            target: lane.target,
322            curve: lane.curve,
323            points: lane
324                .points
325                .iter()
326                .map(|p| crate::dsl::AutoPoint {
327                    t: self.seconds_at_beat(p.at),
328                    v: p.v,
329                })
330                .collect(),
331        }
332    }
333
334    /// Seconds at a beat position on the song grid (the lane conversion).
335    fn seconds_at_beat(&self, beat: f32) -> f32 {
336        if self.tempo_map.is_empty() {
337            beat * 60.0 / self.bpm.max(1.0)
338        } else {
339            crate::dsl::tempo_map_seconds_at(&self.tempo_map, beat as f64) as f32
340        }
341    }
342}
343
344impl Song {
345    /// Compile the song into an immutable, hashed [`Program`] — the central
346    /// validation + lowering entry point (ADR 0003). Validation collects
347    /// every problem in one pass (unknown references, a document that fails
348    /// validation); the returned artifact carries the resolved document,
349    /// musical metadata, bounded resource estimates, streaming-coverage
350    /// warnings, and a canonical semantic hash that a Python-authored
351    /// equivalent song reproduces exactly.
352    ///
353    /// This API is **stable** — frozen at 1.10.0-rc.1
354    /// (docs/api-tiers.md).
355    ///
356    /// ```
357    /// use tono_core::song::{CompileOptions, Song, note};
358    /// use tono_core::dsl::{Adsr, SeqWave};
359    ///
360    /// let amp = Adsr { a: 0.005, d: 0.1, s: 0.8, r: 0.2, punch: 0.0 };
361    /// let mut song = Song::new("demo", 120.0);
362    /// song.add_track("bass", SeqWave::Bass, amp);
363    /// song.add_pattern("riff", 1, vec![note(0, 4, "C2")]);
364    /// song.arrange("bass", "riff", 0);
365    /// let program = song.compile(&CompileOptions::default()).unwrap();
366    /// assert!(!program.render_mono().is_empty());
367    /// ```
368    pub fn compile(&self, opts: &CompileOptions) -> Result<Program, CompileError> {
369        // One pass, every problem collected — the author fixes one compile,
370        // not a drip-feed of first errors.
371        let mut diags = CompileError::default();
372        if self.tracks.is_empty() {
373            diags.push(Diagnostic::from(&SongError::Empty));
374        }
375        for (i, pl) in self.arrangement.iter().enumerate() {
376            if !self.tracks.iter().any(|t| t.name == pl.track) {
377                let mut d = Diagnostic::from(&SongError::UnknownTrack(pl.track.clone()));
378                d.path = format!("arrangement[{i}].track");
379                diags.push(d);
380            }
381            if !self.patterns.iter().any(|p| p.name == pl.pattern) {
382                let mut d = Diagnostic::from(&SongError::UnknownPattern(pl.pattern.clone()));
383                d.path = format!("arrangement[{i}].pattern");
384                diags.push(d);
385            }
386        }
387        if diags.has_errors() {
388            return Err(diags);
389        }
390
391        self.validate_maps(&mut diags);
392        if diags.has_errors() {
393            return Err(diags);
394        }
395
396        let mut doc = match self.to_doc() {
397            Ok(doc) => doc,
398            Err(e) => {
399                diags.push(Diagnostic::from(&e));
400                return Err(diags);
401            }
402        };
403        if let Some(rate) = opts.sample_rate {
404            doc.sample_rate = rate;
405        }
406        if let Some(seed) = self.seed {
407            doc.seed = seed;
408        }
409        if let Err(e) = doc.validate() {
410            diags.push(
411                Diagnostic::error("T2000", "doc", e.to_string())
412                    .with_remediation("fix the flagged document field and recompile"),
413            );
414            return Err(diags);
415        }
416
417        let warnings = blocker_warnings(&doc);
418        if opts.target == CompileTarget::Runtime && !warnings.is_empty() {
419            for mut blocker in warnings {
420                blocker.severity = crate::diag::Severity::Error;
421                diags.push(blocker);
422            }
423            return Err(diags);
424        }
425        let meta = self.program_meta(&doc);
426        let estimates = super::estimate::program_estimates(&doc);
427        let mut program = Program {
428            program_version: PROGRAM_VERSION,
429            schema_version: doc.effective_version(),
430            engine_version: doc.effective_engine(),
431            hash: 0,
432            target: opts.target,
433            doc,
434            meta,
435            estimates,
436            warnings,
437        };
438        program.hash = program.computed_hash();
439        Ok(program)
440    }
441
442    /// The [`ProgramMeta`] of the resolved document: the musical facts a
443    /// transport needs, captured at compile time.
444    fn program_meta(&self, doc: &SoundDoc) -> ProgramMeta {
445        let mut sections = self.sections.clone();
446        sections.sort_by_key(|s| s.bar);
447        let mut markers = self.markers.clone();
448        markers.sort_by_key(|m| m.at);
449        ProgramMeta {
450            name: doc.name.clone(),
451            tempo_bpm: self.bpm.max(1.0),
452            beats_per_bar: self.beats_per_bar.max(1),
453            steps_per_beat: self.steps_per_beat.max(1),
454            tempo_map: self.tempo_map.clone(),
455            meter_map: self.meter_map.clone(),
456            pickup: self.pickup,
457            sections,
458            markers,
459            length_bars: self.length_bars(),
460            duration_secs: doc.duration,
461            duration_frames: super::estimate::duration_frames(doc),
462            sample_rate: doc.sample_rate,
463            tracks: self
464                .tracks
465                .iter()
466                .enumerate()
467                .map(|(i, t)| TrackMeta {
468                    id: TrackId::from(i as u64 + 1),
469                    name: t.name.clone(),
470                    wave: t.wave,
471                    notes: super::estimate::track_note_count(doc, i),
472                    mute: t.mute,
473                    solo: t.solo,
474                })
475                .collect(),
476        }
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use crate::dsl::{Adsr, SeqWave};
484    use crate::song::note;
485    use crate::units::MeterPoint;
486
487    fn amp() -> Adsr {
488        Adsr {
489            a: 0.005,
490            d: 0.1,
491            s: 0.8,
492            r: 0.2,
493            punch: 0.0,
494        }
495    }
496
497    fn demo_song() -> Song {
498        let mut song = Song::new("demo", 120.0);
499        song.add_track("bass", SeqWave::Bass, amp());
500        song.add_track("keys", SeqWave::Epiano, amp());
501        song.add_pattern("riff", 1, vec![note(0, 4, "C2"), note(8, 4, "G2")]);
502        song.add_pattern("stab", 1, vec![note(4, 2, "C4")]);
503        song.arrange("bass", "riff", 0);
504        song.arrange("keys", "stab", 0);
505        song
506    }
507
508    #[test]
509    fn compile_collects_every_unknown_reference_in_one_pass() {
510        let mut song = Song::new("s", 120.0);
511        song.add_track("t", SeqWave::Sine, amp());
512        song.arrange("nope", "ghost", 0);
513        song.arrange("alsono", "ghost2", 1);
514        let err = song.compile(&CompileOptions::default()).unwrap_err();
515        let codes: Vec<_> = err.0.iter().map(|d| (d.code, d.path.as_str())).collect();
516        assert_eq!(
517            codes,
518            vec![
519                ("T1001", "arrangement[0].track"),
520                ("T1002", "arrangement[0].pattern"),
521                ("T1001", "arrangement[1].track"),
522                ("T1002", "arrangement[1].pattern"),
523            ],
524            "every bad reference reported, with its exact path"
525        );
526    }
527
528    #[test]
529    fn compile_an_empty_song_is_t1000() {
530        let song = Song::new("s", 120.0);
531        let err = song.compile(&CompileOptions::default()).unwrap_err();
532        assert_eq!(err.0.len(), 1);
533        assert_eq!(err.0[0].code, "T1000");
534    }
535
536    #[test]
537    fn an_overflowing_deserialized_grid_is_a_compile_error() {
538        let mut song = demo_song();
539        song.beats_per_bar = u32::MAX;
540        song.steps_per_beat = u32::MAX;
541        let err = song.compile(&CompileOptions::default()).unwrap_err();
542        assert!(err.to_string().contains("exceeds the u32 song grid"));
543    }
544
545    #[test]
546    fn compile_is_deterministic() {
547        let a = demo_song().compile(&CompileOptions::default()).unwrap();
548        let b = demo_song().compile(&CompileOptions::default()).unwrap();
549        assert_eq!(a.hash, b.hash);
550        assert_eq!(a.render_mono(), b.render_mono());
551    }
552
553    #[test]
554    fn the_seed_stamps_the_doc_and_moves_the_hash() {
555        let plain = demo_song().compile(&CompileOptions::default()).unwrap();
556        assert_eq!(plain.doc.seed, 0);
557        let seeded = demo_song()
558            .with_seed(7)
559            .compile(&CompileOptions::default())
560            .unwrap();
561        assert_eq!(seeded.doc.seed, 7);
562        assert_ne!(plain.hash, seeded.hash, "the seed is part of the artifact");
563        // And stays deterministic per seed.
564        let again = demo_song()
565            .with_seed(7)
566            .compile(&CompileOptions::default())
567            .unwrap();
568        assert_eq!(seeded.hash, again.hash);
569    }
570
571    #[test]
572    fn mute_and_solo_reach_the_mixer() {
573        let mut song = demo_song();
574        song.tracks[1].solo = true;
575        let program = song.compile(&CompileOptions::default()).unwrap();
576        let Node::Tracks { tracks, .. } = &program.doc.root else {
577            panic!("tracks root");
578        };
579        assert!(tracks[0].mute, "the non-solo track is muted");
580        assert!(!tracks[1].mute, "the solo track sounds");
581        // A muted solo track stays muted.
582        let mut song = demo_song();
583        song.tracks[1].solo = true;
584        song.tracks[1].mute = true;
585        let program = song.compile(&CompileOptions::default()).unwrap();
586        let Node::Tracks { tracks, .. } = &program.doc.root else {
587            panic!("tracks root");
588        };
589        assert!(tracks[1].mute);
590    }
591
592    #[test]
593    fn compile_option_sample_rate_stamps_the_program() {
594        let program = demo_song()
595            .compile(&CompileOptions {
596                sample_rate: Some(48_000),
597                ..CompileOptions::default()
598            })
599            .unwrap();
600        assert_eq!(program.doc.sample_rate, 48_000);
601        assert_eq!(program.meta.sample_rate, 48_000);
602    }
603
604    #[test]
605    fn a_streamable_tracks_root_compiles_without_warnings() {
606        // A plain compiled song is a schema-v2 mixer whose parts all stream
607        // (built-in seq waves, no master chain): no TracksRoot warning, and
608        // is_streamable follows.
609        let program = demo_song().compile(&CompileOptions::default()).unwrap();
610        assert!(
611            program.is_streamable(),
612            "a plain compiled song streams natively now: {:?}",
613            program.warnings
614        );
615        assert!(program.warnings.is_empty());
616    }
617
618    #[test]
619    fn a_tracks_root_with_an_unstreamable_part_still_warns() {
620        // A master-chain convolve can't stream — the program keeps the
621        // warning (and the Player fallback), now naming the failing part.
622        let mut song = demo_song();
623        song.master.push(
624            serde_json::from_value(
625                serde_json::json!({ "type": "convolve", "decay": 0.6, "mix": 0.4 }),
626            )
627            .unwrap(),
628        );
629        let program = song.compile(&CompileOptions::default()).unwrap();
630        assert!(!program.is_streamable());
631        assert!(
632            program
633                .warnings
634                .iter()
635                .any(|d| d.code == "T1508" && d.message.contains("the master chain")),
636            "the master-chain blocker is a warning with its context: {:?}",
637            program.warnings
638        );
639        assert!(
640            program
641                .warnings
642                .iter()
643                .all(|d| d.severity == crate::diag::Severity::Warning),
644            "warnings never fail a compile"
645        );
646    }
647
648    #[test]
649    fn runtime_target_rejects_streaming_blockers() {
650        let mut song = demo_song();
651        song.master.push(
652            serde_json::from_value(
653                serde_json::json!({ "type": "convolve", "decay": 0.6, "mix": 0.4 }),
654            )
655            .unwrap(),
656        );
657        let err = song
658            .compile(&CompileOptions {
659                target: CompileTarget::Runtime,
660                ..CompileOptions::default()
661            })
662            .unwrap_err();
663        assert!(err.has_errors());
664        assert!(
665            err.0
666                .iter()
667                .any(|d| d.code == "T1508" && d.severity == crate::diag::Severity::Error)
668        );
669    }
670
671    #[test]
672    fn an_invalid_resolved_doc_is_t2000() {
673        // A sampler track without a SoundFont path resolves but fails
674        // document validation at compile.
675        let mut song = Song::new("s", 120.0);
676        song.add_track("keys", SeqWave::Sampler, amp());
677        song.tracks[0].notes.push(note(0, 4, "C4"));
678        let err = song.compile(&CompileOptions::default()).unwrap_err();
679        assert_eq!(err.0.len(), 1);
680        assert_eq!(err.0[0].code, "T2000");
681        assert_eq!(err.0[0].path, "doc");
682    }
683
684    #[test]
685    fn meta_preserves_the_musical_facts() {
686        let program = demo_song().compile(&CompileOptions::default()).unwrap();
687        assert_eq!(program.meta.name, "demo");
688        assert_eq!(program.meta.tempo_bpm, 120.0);
689        assert_eq!(program.meta.length_bars, 1);
690        assert_eq!(program.meta.tracks.len(), 2);
691        assert_eq!(program.meta.tracks[0].id.get(), 1);
692        assert_eq!(program.meta.tracks[1].id.get(), 2);
693        assert_eq!(program.meta.tracks[0].name, "bass");
694        assert_eq!(program.meta.tracks[0].notes, 2);
695        assert_eq!(
696            program.meta.duration_frames,
697            (program.doc.duration * program.doc.sample_rate as f32).round() as u64
698        );
699    }
700
701    #[test]
702    fn estimates_count_events_and_peak_voices() {
703        let mut song = Song::new("s", 120.0);
704        song.add_track("chords", SeqWave::Organ, amp());
705        // Three overlapping notes (a chord) plus a later single.
706        song.tracks[0].notes.push(note(0, 8, "C4"));
707        song.tracks[0].notes.push(note(0, 8, "E4"));
708        song.tracks[0].notes.push(note(0, 8, "G4"));
709        song.tracks[0].notes.push(note(8, 4, "A4"));
710        let program = song.compile(&CompileOptions::default()).unwrap();
711        assert_eq!(program.estimates.events, 4);
712        assert_eq!(program.estimates.peak_voices, 3, "the chord is 3 voices");
713    }
714
715    #[test]
716    fn program_renders_stereo_matching_the_doc() {
717        let program = demo_song().compile(&CompileOptions::default()).unwrap();
718        let (l, r) = program.render_stereo();
719        let product = crate::render::render_product(&program.doc);
720        let (el, er) = product.stereo.unwrap();
721        assert_eq!(l, el);
722        assert_eq!(r, er);
723    }
724
725    #[test]
726    fn tempo_map_walk_is_segment_exact() {
727        // 120 BPM for 4 beats, then 240: beat 8 lands at 2.0 + 1.0 = 3.0 s.
728        let map = vec![
729            crate::dsl::TempoPoint {
730                at: Beat::zero(),
731                bpm: 120.0,
732            },
733            crate::dsl::TempoPoint {
734                at: Beat::from_int(4),
735                bpm: 240.0,
736            },
737        ];
738        assert_eq!(crate::dsl::tempo_map_seconds_at(&map, 0.0), 0.0);
739        assert_eq!(crate::dsl::tempo_map_seconds_at(&map, 4.0), 2.0);
740        assert_eq!(crate::dsl::tempo_map_seconds_at(&map, 8.0), 3.0);
741        assert_eq!(crate::dsl::tempo_map_bpm_at(&map, 3.999), 120.0);
742        assert_eq!(crate::dsl::tempo_map_bpm_at(&map, 4.0), 240.0);
743    }
744
745    fn tempo_mapped_song() -> Song {
746        let mut song = demo_song();
747        song.tempo_map = vec![
748            crate::dsl::TempoPoint {
749                at: Beat::zero(),
750                bpm: 120.0,
751            },
752            crate::dsl::TempoPoint {
753                at: Beat::from_int(4),
754                bpm: 240.0,
755            },
756        ];
757        song
758    }
759
760    #[test]
761    fn tempo_map_reaches_the_seq_and_the_meta() {
762        let program = tempo_mapped_song()
763            .compile(&CompileOptions {
764                sample_rate: Some(48_000),
765                ..CompileOptions::default()
766            })
767            .unwrap();
768        let Node::Tracks { tracks, .. } = &program.doc.root else {
769            panic!("tracks root");
770        };
771        let Node::Seq { tempo_map, .. } = &tracks[0].node else {
772            panic!("seq track");
773        };
774        assert_eq!(tempo_map.len(), 2, "the seq carries the map");
775        assert_eq!(program.meta.tempo_map.len(), 2, "the meta preserves it");
776        // The last note ends at beat 3 (step 12 + len 4 at 4 spb): 1.5 s at
777        // 120 BPM, + 2 s tail.
778        assert!(
779            (program.doc.duration - 3.5).abs() < 1e-4,
780            "{}",
781            program.doc.duration
782        );
783    }
784
785    #[test]
786    fn tempo_map_places_notes_on_exact_frames() {
787        // One note at beat 0 and one at beat 8 (step 32): with 120 → 240 at
788        // beat 4, the second starts at exactly 3.0 s = frame 144 000 at 48 kHz.
789        let json = r#"{ "name": "mapped", "duration": 4.0, "version": 2, "engine": 4,
790            "sample_rate": 48000,
791            "root": { "type": "seq", "bpm": 120, "wave": "sawtooth",
792                "tempo_map": [ { "at": { "num": 0, "den": 1 }, "bpm": 120 },
793                               { "at": { "num": 4, "den": 1 }, "bpm": 240 } ],
794                "env": { "a": 0.0, "d": 0.0, "s": 1.0, "r": 0.01 },
795                "notes": [ { "step": 0, "len": 4, "pitch": "A4" },
796                           { "step": 32, "len": 4, "pitch": "A4" } ] } }"#;
797        let doc: SoundDoc = serde_json::from_str(json).unwrap();
798        doc.validate().unwrap();
799        let out = crate::render::render(&doc);
800        // The saw starts at −1, so the onset frame is the first nonzero sample.
801        let onsets: Vec<usize> = {
802            let mut marks = Vec::new();
803            let mut silent = true;
804            for (i, &s) in out.iter().enumerate() {
805                if silent && s != 0.0 {
806                    marks.push(i);
807                    silent = false;
808                } else if !silent && s == 0.0 {
809                    silent = true;
810                }
811            }
812            marks
813        };
814        assert_eq!(onsets.len(), 2, "two notes: {onsets:?}");
815        assert_eq!(
816            onsets[1] - onsets[0],
817            144_000,
818            "the note past the tempo change lands exactly 3.0 s later: {onsets:?}"
819        );
820    }
821
822    #[test]
823    fn a_tempo_mapped_seq_streams_byte_identically() {
824        let json = r#"{ "name": "mapped", "duration": 1.0, "version": 2, "engine": 4,
825            "root": { "type": "seq", "bpm": 120, "wave": "square",
826                "tempo_map": [ { "at": { "num": 0, "den": 1 }, "bpm": 120 },
827                               { "at": { "num": 2, "den": 1 }, "bpm": 90 } ],
828                "env": { "a": 0.005, "d": 0.05, "s": 0.6, "r": 0.05 },
829                "notes": [ { "step": 0, "len": 2, "pitch": "C4" },
830                           { "step": 6, "len": 2, "pitch": "E4" },
831                           { "step": 12, "len": 2, "pitch": "G4" } ] } }"#;
832        let doc: SoundDoc = serde_json::from_str(json).unwrap();
833        doc.validate().unwrap();
834        crate::streaming::tests::assert_byte_identical(&doc);
835    }
836
837    #[test]
838    fn meter_map_and_pickup_place_bars_exactly() {
839        // 6/8: bar 1 starts at beat 3 (step 12 at 4 spb).
840        let mut song = Song::new("waltzish", 120.0);
841        song.meter_map = vec![MeterPoint {
842            bar: 0,
843            numerator: 6,
844            denominator: 8,
845        }];
846        song.add_track("t", SeqWave::Sine, amp());
847        song.add_pattern("p", 1, vec![note(0, 1, "C4")]);
848        song.arrange("t", "p", 1);
849        assert_eq!(song.beat_at_bar(1), Beat::from_int(3));
850        assert_eq!(song.beat_at_bar(2), Beat::from_int(6));
851        let program = song.compile(&CompileOptions::default()).unwrap();
852        let Node::Tracks { tracks, .. } = &program.doc.root else {
853            panic!("tracks");
854        };
855        let Node::Seq { notes, .. } = &tracks[0].node else {
856            panic!("seq");
857        };
858        assert_eq!(notes[0].step, 12, "bar 1 of 6/8 is step 12");
859        // A one-beat pickup shifts bar 1 to step 4.
860        song.pickup = Some(Beat::from_int(1));
861        let program = song.compile(&CompileOptions::default()).unwrap();
862        let Node::Tracks { tracks, .. } = &program.doc.root else {
863            panic!("tracks");
864        };
865        let Node::Seq { notes, .. } = &tracks[0].node else {
866            panic!("seq");
867        };
868        assert_eq!(notes[0].step, 4, "bar 1 follows the one-beat pickup");
869    }
870
871    #[test]
872    fn off_grid_placements_are_t1005() {
873        let mut song = Song::new("s", 120.0);
874        song.pickup = Some(Beat::new(1, 3)); // a third of a beat at 4 spb
875        song.add_track("t", SeqWave::Sine, amp());
876        song.add_pattern("p", 1, vec![note(0, 1, "C4")]);
877        song.arrange("t", "p", 1);
878        let err = song.compile(&CompileOptions::default()).unwrap_err();
879        assert!(
880            err.0
881                .iter()
882                .any(|d| d.code == "T1005" && d.path == "arrangement[0].bar"),
883            "{:?}",
884            err.0
885        );
886    }
887
888    #[test]
889    fn map_and_section_violations_have_their_codes() {
890        let mut song = demo_song();
891        song.tempo_map = vec![crate::dsl::TempoPoint {
892            at: Beat::from_int(2),
893            bpm: 140.0,
894        }];
895        let err = song.compile(&CompileOptions::default()).unwrap_err();
896        assert!(err.0.iter().any(|d| d.code == "T1003"), "{:?}", err.0);
897
898        let mut song = demo_song();
899        song.meter_map = vec![MeterPoint {
900            bar: 0,
901            numerator: 3,
902            denominator: 5,
903        }];
904        let err = song.compile(&CompileOptions::default()).unwrap_err();
905        assert!(err.0.iter().any(|d| d.code == "T1004"), "{:?}", err.0);
906
907        let mut song = demo_song();
908        song.sections.push(crate::song::Section {
909            name: String::new(),
910            bar: 0,
911            bars: 4,
912        });
913        let err = song.compile(&CompileOptions::default()).unwrap_err();
914        assert!(err.0.iter().any(|d| d.code == "T1006"), "{:?}", err.0);
915    }
916
917    #[test]
918    fn sections_and_markers_reach_the_meta_sorted() {
919        let mut song = demo_song();
920        song.sections.push(crate::song::Section {
921            name: "chorus".into(),
922            bar: 4,
923            bars: 4,
924        });
925        song.sections.push(crate::song::Section {
926            name: "verse".into(),
927            bar: 0,
928            bars: 4,
929        });
930        song.markers.push(crate::song::Marker {
931            name: "drop".into(),
932            at: Beat::from_int(16),
933        });
934        let program = song.compile(&CompileOptions::default()).unwrap();
935        assert_eq!(program.meta.sections[0].name, "verse");
936        assert_eq!(program.meta.sections[1].name, "chorus");
937        assert_eq!(program.meta.markers[0].name, "drop");
938        // And they survive the bundle round-trip.
939        let loaded = crate::program::Program::from_json(&program.to_json()).unwrap();
940        assert_eq!(loaded.meta.sections.len(), 2);
941    }
942
943    #[test]
944    fn length_bars_respects_the_meter_map() {
945        // 6/8 (3 beats a bar): a note ending at beat 6 closes bar 2.
946        let mut song = Song::new("s", 120.0);
947        song.meter_map = vec![MeterPoint {
948            bar: 0,
949            numerator: 6,
950            denominator: 8,
951        }];
952        song.add_track("t", SeqWave::Sine, amp());
953        song.tracks[0].notes.push(note(0, 24, "C4")); // 24 steps = 6 beats
954        assert_eq!(song.length_bars(), 2);
955        // In 4/4 the same note reaches only bar 2's start too (6 beats = 1.5 bars → 2).
956        song.meter_map.clear();
957        assert_eq!(song.length_bars(), 2);
958    }
959
960    #[test]
961    fn automation_compiles_beats_to_seconds() {
962        let mut song = Song::new("s", 120.0);
963        song.add_track("t", SeqWave::Sine, amp());
964        song.tracks[0].notes.push(note(0, 4, "C4"));
965        song.tracks[0].automation.push(crate::song::SongLane {
966            target: crate::dsl::AutoTarget::Gain,
967            curve: crate::dsl::AutoCurve::Step,
968            points: vec![
969                crate::song::SongPoint { at: 0.0, v: 0.2 },
970                crate::song::SongPoint { at: 2.0, v: 0.8 },
971            ],
972        });
973        let program = song.compile(&CompileOptions::default()).unwrap();
974        let Node::Tracks { tracks, .. } = &program.doc.root else {
975            panic!("tracks root");
976        };
977        assert_eq!(tracks[0].automation.len(), 1);
978        assert_eq!(tracks[0].automation[0].curve, crate::dsl::AutoCurve::Step);
979        assert_eq!(
980            tracks[0].automation[0].points[1].t, 1.0,
981            "beat 2 at 120 BPM is 1.0 s"
982        );
983
984        // Through a tempo map: 120 → 60 at beat 2, so beat 4 is 1 + 2 = 3 s.
985        song.tempo_map = vec![
986            crate::dsl::TempoPoint {
987                at: Beat::zero(),
988                bpm: 120.0,
989            },
990            crate::dsl::TempoPoint {
991                at: Beat::from_int(2),
992                bpm: 60.0,
993            },
994        ];
995        song.tracks[0].automation[0].points[1].at = 4.0;
996        let program = song.compile(&CompileOptions::default()).unwrap();
997        let Node::Tracks { tracks, .. } = &program.doc.root else {
998            panic!("tracks root");
999        };
1000        assert_eq!(
1001            tracks[0].automation[0].points[1].t, 3.0,
1002            "the lane crosses the tempo map segment-wise"
1003        );
1004    }
1005
1006    #[test]
1007    fn buses_and_sends_pass_through_to_the_document() {
1008        let mut song = demo_song();
1009        song.buses.push(crate::dsl::Bus {
1010            id: "verb".into(),
1011            gain: 0.8,
1012            effects: vec![Node::Reverb {
1013                room: 0.6,
1014                mix: 0.4,
1015            }],
1016        });
1017        song.tracks[1].bus = Some("verb".into());
1018        song.tracks[0].sends.push(crate::dsl::Send {
1019            bus: "verb".into(),
1020            amount: 0.3,
1021        });
1022        let program = song.compile(&CompileOptions::default()).unwrap();
1023        let Node::Tracks { tracks, buses, .. } = &program.doc.root else {
1024            panic!("tracks root");
1025        };
1026        assert_eq!(buses.len(), 1);
1027        assert_eq!(buses[0].id, "verb");
1028        assert_eq!(tracks[1].bus.as_deref(), Some("verb"));
1029        assert_eq!(tracks[0].sends.len(), 1);
1030        assert_eq!(tracks[0].sends[0].bus, "verb");
1031        assert!(program.doc.validate().is_ok(), "the wired mix validates");
1032        // And the mix renders (the send leaves a reverb tail).
1033        assert!(!program.render_mono().is_empty());
1034    }
1035}