Skip to main content

tono_core/
program.rs

1//! program — the immutable result of compiling a [`Song`](crate::song::Song)
2//! (ADR 0003).
3//!
4//! A [`Program`] is the artifact applications render, ship, and (from
5//! 1.10.0-alpha.3) run: the resolved [`SoundDoc`], the musical metadata a
6//! transport needs, bounded resource estimates, streaming-coverage warnings,
7//! and a canonical content hash — all under three independently evolving
8//! version pins (`SCHEMA_VERSION`, `ENGINE_VERSION`, [`PROGRAM_VERSION`]).
9//! A Program is immutable by convention: it is *validated and resolved*, so
10//! mutating a public field invalidates [`Program::hash`] (a round-trip
11//! through [`Program::from_json`] re-verifies and catches it).
12//!
13//! This API is **stable** — frozen at 1.10.0-rc.1 (docs/api-tiers.md).
14
15use serde::{Deserialize, Serialize};
16
17use crate::diag::Diagnostic;
18use crate::dsl::{SeqWave, SoundDoc};
19use crate::ids::TrackId;
20use crate::render;
21use crate::streaming::StreamGraph;
22
23/// The current Program bundle format revision. A Program records the revision
24/// it was compiled with; a loader rejects a bundle newer than itself. Bumped
25/// when the serialized shape (or its semantics) changes — independently of
26/// the document schema and DSP engine revisions.
27pub const PROGRAM_VERSION: u32 = 2;
28
29/// A compiled song: validated, resolved, hashed. Built by
30/// [`Song::compile`](crate::song::Song::compile); reloaded by
31/// [`Program::from_json`].
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Program {
34    /// The bundle revision (see [`PROGRAM_VERSION`]).
35    pub program_version: u32,
36    /// The resolved document's effective schema version.
37    pub schema_version: u32,
38    /// The resolved document's effective engine revision.
39    pub engine_version: u32,
40    /// Canonical semantic hash. Version 1 covers the document; version 2
41    /// covers every serialized semantic field except this hash.
42    pub hash: u64,
43    /// The target this program was compiled for (offline or runtime).
44    #[serde(default)]
45    pub target: crate::song::CompileTarget,
46    /// The resolved document — renders through the exact same engine as
47    /// everything else; nothing new in the render path.
48    pub doc: SoundDoc,
49    /// The musical metadata a transport (or a host deciding how far ahead to
50    /// schedule) needs — preserved at compile time, not reconstructed.
51    pub meta: ProgramMeta,
52    /// Bounded resource estimates the runtime preallocates from (ADR 0005).
53    pub estimates: ResourceEstimates,
54    /// Offline compile warnings: streaming blockers re-derived from the
55    /// resolved document on load (a pure function of it), never stored.
56    /// Runtime-target compilation rejects these blockers instead.
57    #[serde(skip)]
58    pub warnings: Vec<Diagnostic>,
59}
60
61/// The musical facts of a compiled song, resolved once at compile time.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ProgramMeta {
64    /// The song's name.
65    pub name: String,
66    /// Tempo in beats per minute (clamped at compile, like the compiler's
67    /// duration math: degenerate tempos floor at 1).
68    pub tempo_bpm: f32,
69    /// Time-signature numerator (4 = 4/4) — the default meter before any
70    /// `meter_map` points.
71    pub beats_per_bar: u32,
72    /// Grid resolution (4 = sixteenth notes).
73    pub steps_per_beat: u32,
74    /// Tempo changes at exact beat positions (empty = constant `tempo_bpm`).
75    #[serde(default)]
76    pub tempo_map: Vec<crate::dsl::TempoPoint>,
77    /// Time-signature changes by bar (empty = `beats_per_bar`/4 throughout).
78    #[serde(default)]
79    pub meter_map: Vec<crate::song::MeterPoint>,
80    /// The pickup bar's length in beats, if any.
81    #[serde(default)]
82    pub pickup: Option<crate::units::Beat>,
83    /// Named bar ranges, sorted by bar — the runtime's transition targets.
84    #[serde(default)]
85    pub sections: Vec<crate::song::Section>,
86    /// Named beat points, sorted by position.
87    #[serde(default)]
88    pub markers: Vec<crate::song::Marker>,
89    /// The song's length in bars (end of its last placement or direct note).
90    pub length_bars: u32,
91    /// Total duration in seconds, including the release/reverb tail.
92    pub duration_secs: f32,
93    /// Total duration in frames (`duration_secs × sample_rate`, rounded).
94    pub duration_frames: u64,
95    /// The sample rate the program was compiled for.
96    pub sample_rate: u32,
97    /// One entry per track, in declaration order (so `id` is stable).
98    pub tracks: Vec<TrackMeta>,
99}
100
101/// One track's identity and role in a compiled program.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct TrackMeta {
104    /// The stable identifier: declaration order at compile time, so an
105    /// unchanged song recompiles to identical ids.
106    pub id: TrackId,
107    /// The track name (also the rendered layer id).
108    pub name: String,
109    /// The instrument voice.
110    pub wave: SeqWave,
111    /// How many notes the track plays (direct notes plus placements).
112    pub notes: u32,
113    /// Whether the track is muted in the mix.
114    pub mute: bool,
115    /// Whether the track is a solo track (every non-solo track is muted).
116    pub solo: bool,
117}
118
119/// Bounded estimates of what a Program costs to render or run. Upper bounds
120/// are stated where an exact figure isn't cheap; the runtime preallocates
121/// from these (ADR 0005).
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ResourceEstimates {
124    /// Total render length in frames (mono frame count).
125    pub frames: u64,
126    /// Total note events across all tracks.
127    pub events: u64,
128    /// An upper bound on simultaneously sounding notes across the mix: the
129    /// per-track maxima summed (tracks start together, so their peaks can
130    /// coincide). Voice pools sized to this never steal.
131    pub peak_voices: u32,
132    /// The dominant render allocation: the stereo f32 output buffers
133    /// (`frames × 2 channels × 4 bytes`). Everything else (per-track bounces)
134    /// is bounded by the same order.
135    pub memory_bytes: u64,
136}
137
138/// Why a serialized [`Program`] failed to load.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum ProgramError {
141    /// The JSON didn't parse or didn't match the bundle shape.
142    Json(String),
143    /// The bundle's `program_version` is newer than this binary supports.
144    TooNew {
145        /// The bundle's revision.
146        found: u32,
147        /// This binary's [`PROGRAM_VERSION`].
148        supported: u32,
149    },
150    /// The stored hash doesn't match the recomputed one — the bundle was
151    /// hand-edited or corrupted (T3002).
152    HashMismatch {
153        /// The hash stored in the bundle.
154        stored: u64,
155        /// The hash recomputed from the versioned bundle content.
156        computed: u64,
157    },
158}
159
160impl std::fmt::Display for ProgramError {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        match self {
163            ProgramError::Json(e) => write!(f, "program JSON: {e}"),
164            ProgramError::TooNew { found, supported } => write!(
165                f,
166                "T3001: program version {found} is newer than this binary supports ({supported})"
167            ),
168            ProgramError::HashMismatch { stored, computed } => write!(
169                f,
170                "T3002: program hash mismatch (stored {stored:#018x}, computed {computed:#018x}) — \
171                 the bundle was edited or corrupted; recompile the song"
172            ),
173        }
174    }
175}
176
177impl std::error::Error for ProgramError {}
178
179/// FNV-1a over bytes — the same primitive the golden corpus uses over sample
180/// bits, here over canonical JSON.
181fn fnv1a(bytes: &[u8]) -> u64 {
182    let mut h: u64 = 0xCBF2_9CE4_8422_2325;
183    for b in bytes {
184        h ^= *b as u64;
185        h = h.wrapping_mul(0x0000_0100_0000_01B3);
186    }
187    h
188}
189
190/// The canonical form of a resolved document (ADR 0003): UTF-8 JSON, object
191/// keys sorted (the serde_json default map), no insignificant whitespace,
192/// floats in shortest-round-trip form. Two equivalent songs — authored in
193/// Rust or Python — serialize to the same bytes.
194fn canonical_json<T: Serialize>(value: &T) -> Vec<u8> {
195    // serde_json's Map is a BTreeMap without the preserve_order feature, so
196    // to_value→to_string is already the canonicalization (sorted keys,
197    // compact separators, ryu float formatting).
198    let value = serde_json::to_value(value).expect("canonical content serializes");
199    serde_json::to_string(&value)
200        .expect("a resolved document serializes")
201        .into_bytes()
202}
203
204/// The canonical content hash of a resolved document: FNV-1a over its
205/// canonical JSON. Independent of the authoring structure and of serialization
206/// formatting — equivalent songs hash equal, from Rust or Python alike.
207pub fn content_hash(doc: &SoundDoc) -> u64 {
208    fnv1a(&canonical_json(doc))
209}
210
211impl Program {
212    /// Recompute this bundle's integrity hash. Version 1 covered only the
213    /// resolved document; version 2 covers every serialized semantic field
214    /// except `hash` itself. Keeping the v1 rule here preserves the shipped
215    /// compatibility fixture while new bundles protect their runtime metadata.
216    pub(crate) fn computed_hash(&self) -> u64 {
217        if self.program_version <= 1 {
218            return content_hash(&self.doc);
219        }
220        let mut value = serde_json::to_value(self).expect("a program serializes");
221        value
222            .as_object_mut()
223            .expect("a program serializes as an object")
224            .remove("hash");
225        fnv1a(&canonical_json(&value))
226    }
227
228    /// Render the full program to mono samples through the standard engine.
229    pub fn render_mono(&self) -> Vec<f32> {
230        render::render(&self.doc)
231    }
232
233    /// Render the full program to a stereo pair. A compiled song always has a
234    /// stereo mix; a defensively duplicated mono pair is returned if it
235    /// somehow doesn't.
236    pub fn render_stereo(&self) -> (Vec<f32>, Vec<f32>) {
237        let product = render::render_product(&self.doc);
238        product.stereo.unwrap_or_else(|| {
239            let m = product.mono;
240            (m.clone(), m)
241        })
242    }
243
244    /// Render a frame range `[start, end)` as a stereo pair — a slice of the
245    /// full render, so a note or tail crossing the range boundary sounds
246    /// exactly as it does in the full mix (this is why ranges render through,
247    /// not from, the boundary). Out-of-range requests clamp.
248    pub fn render_range_frames(&self, start: u64, end: u64) -> (Vec<f32>, Vec<f32>) {
249        let (l, r) = self.render_stereo();
250        let start = (start as usize).min(l.len());
251        let end = (end as usize).min(l.len()).max(start);
252        (l[start..end].to_vec(), r[start..end].to_vec())
253    }
254
255    /// Render a bar range `[start_bar, end_bar)` through the program's meter
256    /// map (see [`Self::render_range_frames`]).
257    pub fn render_range_bars(&self, start_bar: u32, end_bar: u32) -> (Vec<f32>, Vec<f32>) {
258        let transport = crate::runtime::Transport::for_program(&self.meta);
259        self.render_range_frames(
260            transport.frame_at_bar(start_bar),
261            transport.frame_at_bar(end_bar),
262        )
263    }
264
265    /// Render per-track and per-bus stereo stems (pre-master-chain — see
266    /// [`render::Stem`]): every track stem plus every bus stem, in
267    /// declaration order. Muted tracks are silent stems.
268    pub fn render_stems(&self) -> Vec<render::Stem> {
269        render::render_stems(&self.doc).unwrap_or_default()
270    }
271
272    /// Whether the resolved document streams natively (no warnings is the
273    /// same signal — blockers are the only warnings compilation produces).
274    pub fn is_streamable(&self) -> bool {
275        self.warnings.is_empty()
276    }
277
278    /// The machine-readable capability list: what this program can do on a
279    /// host — `"offline-render"` and `"stems"` always; `"streaming"` when the
280    /// resolved document streams natively. Derived from the warnings (a pure
281    /// function of the document), so it's identical from any loader.
282    pub fn capabilities(&self) -> Vec<&'static str> {
283        let mut caps = vec!["offline-render", "stems"];
284        if self.is_streamable() {
285            caps.push("streaming");
286        }
287        caps
288    }
289
290    /// Serialize the bundle (compact JSON; stable field order from the struct).
291    pub fn to_json(&self) -> String {
292        serde_json::to_string(self).expect("a program serializes")
293    }
294
295    /// Load a bundle: parse, reject a newer revision (T3001), re-verify the
296    /// semantic hash (T3002), and re-derive the warnings from the resolved
297    /// document. No musical recomputation — loading never recompiles.
298    pub fn from_json(json: &str) -> Result<Program, ProgramError> {
299        let mut program: Program =
300            serde_json::from_str(json).map_err(|e| ProgramError::Json(e.to_string()))?;
301        if program.program_version > PROGRAM_VERSION {
302            return Err(ProgramError::TooNew {
303                found: program.program_version,
304                supported: PROGRAM_VERSION,
305            });
306        }
307        let computed = program.computed_hash();
308        if computed != program.hash {
309            return Err(ProgramError::HashMismatch {
310                stored: program.hash,
311                computed,
312            });
313        }
314        program.warnings = blocker_warnings(&program.doc);
315        Ok(program)
316    }
317}
318
319/// The streaming blockers of a resolved document, as warnings with per-kind
320/// codes in the T15xx band.
321pub(crate) fn blocker_warnings(doc: &SoundDoc) -> Vec<Diagnostic> {
322    /// The per-kind code; a mixer part reports its cause's code (the message
323    /// already carries the track/bus context).
324    fn code(b: &crate::streaming::StreamBlocker) -> &'static str {
325        use crate::streaming::StreamBlocker as B;
326        match b {
327            B::Normalize => "T1501",
328            B::LoopPlayback => "T1502",
329            B::StereoTreatment => "T1503",
330            B::TracksRoot => "T1504",
331            B::LegacyRng { .. } => "T1505",
332            B::Sampler => "T1506",
333            B::ModulatedFilter => "T1507",
334            B::OfflineEffect { .. } => "T1508",
335            B::TracksPart { cause, .. } => code(cause),
336        }
337    }
338    StreamGraph::blockers(doc)
339        .into_iter()
340        .map(|b| {
341            Diagnostic::warning(code(&b), "doc", b.to_string()).with_remediation(
342                "the offline render is unaffected; live playback uses the buffer-backed Player",
343            )
344        })
345        .collect()
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::song::{CompileOptions, Song, note};
352
353    fn two_track_program() -> Program {
354        let mut song = Song::new("prog", 120.0);
355        song.add_track(
356            "bass",
357            crate::dsl::SeqWave::Bass,
358            crate::dsl::Adsr {
359                a: 0.005,
360                d: 0.1,
361                s: 0.8,
362                r: 0.2,
363                punch: 0.0,
364            },
365        );
366        song.add_pattern("riff", 1, vec![note(0, 4, "C2"), note(8, 4, "G2")]);
367        song.arrange("bass", "riff", 0);
368        song.compile(&CompileOptions::default()).expect("compiles")
369    }
370
371    #[test]
372    fn hash_is_canonical_regardless_of_field_order() {
373        let program = two_track_program();
374        let json = serde_json::to_string(&program.doc).unwrap();
375        // Shuffle the top-level key order: parse to a Value, re-emit in a
376        // different order, reparse — the hash must not move.
377        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
378        let obj = value.as_object_mut().unwrap();
379        let mut entries: Vec<_> = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
380        entries.reverse();
381        *obj = entries.into_iter().collect();
382        let reparsed: SoundDoc = serde_json::from_value(value).unwrap();
383        assert_eq!(content_hash(&reparsed), content_hash(&program.doc));
384        assert_eq!(program.computed_hash(), program.hash);
385    }
386
387    #[test]
388    fn program_round_trips_through_json() {
389        let program = two_track_program();
390        let loaded = Program::from_json(&program.to_json()).expect("loads");
391        assert_eq!(loaded.hash, program.hash);
392        assert_eq!(loaded.warnings.len(), program.warnings.len());
393        assert_eq!(loaded.target, program.target);
394        assert_eq!(loaded.render_mono(), program.render_mono());
395        // The capability list is machine-readable and derived on load.
396        assert!(loaded.capabilities().contains(&"streaming"));
397    }
398
399    #[test]
400    fn render_range_is_a_slice_of_the_full_render() {
401        let program = two_track_program();
402        let (l, r) = program.render_stereo();
403        let (sl, sr) = program.render_range_frames(1000, 5000);
404        assert_eq!(sl, l[1000..5000].to_vec());
405        assert_eq!(sr, r[1000..5000].to_vec());
406        // A bar range converts through the meter map.
407        let (bl, _) = program.render_range_bars(0, 1);
408        let transport = crate::runtime::Transport::for_program(&program.meta);
409        assert_eq!(bl.len(), transport.frame_at_bar(1) as usize);
410        // Out-of-range clamps instead of panicking.
411        let (cl, _) = program.render_range_frames(u64::MAX - 1, u64::MAX);
412        assert!(cl.is_empty());
413    }
414
415    #[test]
416    fn from_json_rejects_a_newer_revision() {
417        let program = two_track_program();
418        let mut value: serde_json::Value = serde_json::from_str(&program.to_json()).unwrap();
419        value["program_version"] = serde_json::json!(PROGRAM_VERSION + 1);
420        let err = Program::from_json(&serde_json::to_string(&value).unwrap()).unwrap_err();
421        assert_eq!(
422            err,
423            ProgramError::TooNew {
424                found: PROGRAM_VERSION + 1,
425                supported: PROGRAM_VERSION,
426            }
427        );
428    }
429
430    #[test]
431    fn from_json_catches_a_hand_edited_bundle() {
432        let program = two_track_program();
433        let mut value: serde_json::Value = serde_json::from_str(&program.to_json()).unwrap();
434        value["doc"]["duration"] = serde_json::json!(9.0);
435        let err = Program::from_json(&serde_json::to_string(&value).unwrap()).unwrap_err();
436        assert!(matches!(err, ProgramError::HashMismatch { .. }));
437
438        let mut value: serde_json::Value = serde_json::from_str(&program.to_json()).unwrap();
439        value["meta"]["sample_rate"] = serde_json::json!(48_000);
440        let err = Program::from_json(&serde_json::to_string(&value).unwrap()).unwrap_err();
441        assert!(
442            matches!(err, ProgramError::HashMismatch { .. }),
443            "runtime metadata is part of a v2 bundle's integrity boundary"
444        );
445    }
446
447    #[test]
448    fn estimates_bound_the_render() {
449        let program = two_track_program();
450        assert_eq!(program.estimates.events, 2);
451        assert_eq!(program.estimates.peak_voices, 1);
452        assert_eq!(
453            program.estimates.frames,
454            (program.doc.duration * program.doc.sample_rate as f32).round() as u64
455        );
456        assert_eq!(program.estimates.memory_bytes, program.estimates.frames * 8);
457    }
458}